Repository navigation
Design: IRunningAgentChatFactory in Llm.Core; wrap in Phantom.Workspaces for UI tracking #659
Description
Tracking / design issue — decomposed into independently implementable sub-issues below.
Decomposed into
| # | Title | Prerequisites |
|---|---|---|
| #667 | Add AgentSessionId typed wrapper to Llm.Interfaces |
— |
| #668 | Move RunningAgentChatLease to Llm.Core with callback-based disposal |
#667 |
| #669 | Add RunningAgentChat entry type to Llm.Core |
#667, #668, #670 |
| #670 | Add IRunningAgentChatFactory interface and AgentChatFactory implementation to Llm.Core |
#667, #668, #669 |
| #671 | Make AgentChat and AgentServices implement IServiceProvider |
#670 |
| #672 | Add ISubAgentTable interface and implement it on AgentChat |
#670, #671 |
| #673 | Add ICopilotSubAgentReceiver and github-copilot-subagent provider to Llm.Core |
#671 |
| #674 | Wire CopilotSdkChatClient to handle SubAgentStarted using IRunningAgentChatFactory and ISubAgentTable |
#670, #671, #672, #673 |
| #675 | Update Phantom.Workspaces.Services to wrap IRunningAgentChatFactory with entity display info |
#670 |
Summary
Introduce IRunningAgentChatFactory in Phantom.Workspaces.Llm.Core so the SubAgent class (see #658) and other LLM-layer code can acquire a leased reference to an AgentChat by session ID without depending on Phantom.Workspaces. Add ISubAgentTable and ICopilotSubAgentReceiver to support the github-copilot-subagent provider. A wrapper in Phantom.Workspaces.Services attaches entity display information on top.
Design
1. AgentSessionId typed wrapper (new, Phantom.Workspaces.Llm.Interfaces)
// Phantom.Workspaces.Llm.Interfaces/AgentSessionId.cs
public readonly record struct AgentSessionId(string Value)
{
public override string ToString() => Value;
}Replaces bare string session ID uses throughout the factory interface, lease type, and IAgentPersistenceStore.
2. RunningAgentChatLease (move to Phantom.Workspaces.Llm.Core)
Move from Phantom.Workspaces.Services. Dispose callback replaces the back-reference to RunningAgentChatTable.
// Phantom.Workspaces.Llm.Core/RunningAgentChatLease.cs
public sealed class RunningAgentChatLease : IAsyncDisposable
{
private readonly Func<ValueTask> _onDispose;
private int _disposed;
public AgentSessionId SessionId { get; }
public AgentChat AgentChat { get; }
internal RunningAgentChatLease(AgentSessionId sessionId, AgentChat agentChat, Func<ValueTask> onDispose) { … }
public ValueTask DisposeAsync()
{
if (Interlocked.CompareExchange(ref _disposed, 1, 0) != 0)
return ValueTask.CompletedTask;
return _onDispose();
}
}3. RunningAgentChat (new, Phantom.Workspaces.Llm.Core)
Each entry in the observable collection. Observers call AcquireLeaseAsync() to obtain their own ref-counted lease.
// Phantom.Workspaces.Llm.Core/RunningAgentChat.cs
public sealed class RunningAgentChat
{
private readonly IRunningAgentChatFactory _factory;
public AgentSessionId SessionId { get; }
internal RunningAgentChat(AgentSessionId sessionId, IRunningAgentChatFactory factory) { … }
/// <summary>
/// Acquires a new ref-counted lease on this session's AgentChat.
/// Dispose the lease when done.
/// </summary>
public Task<RunningAgentChatLease> AcquireLeaseAsync(CancellationToken ct = default)
=> _factory.GetAsync(SessionId, ct);
}4. IRunningAgentChatFactory (new, Phantom.Workspaces.Llm.Core)
// Phantom.Workspaces.Llm.Core/IRunningAgentChatFactory.cs
public interface IRunningAgentChatFactory
{
/// <summary>
/// The live set of sessions currently held by at least one lease.
/// Mutations are dispatched on the foreground scheduler; UI subscribers need not marshal.
/// </summary>
ObservableCollection<RunningAgentChat> RunningSessions { get; }
/// <summary>
/// Acquires a ref-counted lease on the AgentChat for <paramref name="sessionId"/>,
/// loading it from persistence if not already running.
/// </summary>
Task<RunningAgentChatLease> GetAsync(AgentSessionId sessionId, CancellationToken ct = default);
/// <summary>
/// Creates a new AgentChat from <paramref name="definition"/> + <paramref name="sessionId"/>,
/// persists it, adds to RunningSessions, and returns a lease.
/// </summary>
Task<RunningAgentChatLease> CreateAsync(
AgentDefinition definition,
AgentSessionId sessionId,
AgentServices? services = null,
CancellationToken ct = default);
}5. AgentChatFactory (implementation, Phantom.Workspaces.Llm.Core)
Reference-counted table implementing IRunningAgentChatFactory. On first GetAsync for a session ID: loads AgentChat from IAgentPersistenceStore, acquires an internal anchor lease, adds a RunningAgentChat entry to RunningSessions. On last lease released: removes the entry, calls AgentChat.DisposeAsync(). CreateAsync persists the new session before adding it to the table.
// Phantom.Workspaces.Llm.Core/AgentChatFactory.cs
internal sealed class AgentChatFactory : IRunningAgentChatFactory, IAsyncDisposable
{
private readonly IAgentPersistenceStore _store;
private readonly AgentServices _services;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly Dictionary<AgentSessionId, Entry> _entries = new();
private readonly ObservableCollection<RunningAgentChat> _runningSessions = new();
public ObservableCollection<RunningAgentChat> RunningSessions => _runningSessions;
public Task<RunningAgentChatLease> GetAsync(AgentSessionId sessionId, CancellationToken ct = default) { … }
public Task<RunningAgentChatLease> CreateAsync(AgentDefinition definition, AgentSessionId sessionId, AgentServices? services = null, CancellationToken ct = default) { … }
}AgentServices gains a property to carry the factory instance:
public IRunningAgentChatFactory? RunningAgentChatFactory { get; init; }6. AgentChat implements IServiceProvider
// AgentChat : IServiceProvider
public object? GetService(Type serviceType)
{
return this.chatClientAgent?.GetService(serviceType)
?? this.request.AgentServices?.GetService(serviceType);
}AgentServices gains an explicit IServiceProvider implementation (typed switch, no reflection):
// AgentServices : IServiceProvider
public object? GetService(Type serviceType)
{
if (serviceType == typeof(ILoggerFactory)) return LoggerFactory;
if (serviceType == typeof(IAgentPersistenceStore)) return AgentPersistenceStoreOverride;
if (serviceType == typeof(IRunningAgentChatFactory)) return RunningAgentChatFactory;
if (serviceType == typeof(IToolsetFactory)) return ToolsetFactory;
if (serviceType == typeof(IToolResourceFactory)) return ToolResourceFactory;
return null;
}7. ISubAgentTable (new, Phantom.Workspaces.Llm.Core)
public interface ISubAgentTable
{
/// <summary>
/// Registers a newly created sub-agent with this parent.
/// Adds it to the parent's SubAgents observable collection,
/// persists the parent→child link, and returns the SubAgent wrapper.
/// </summary>
SubAgent Add(AgentChat agentChat);
}8. ICopilotSubAgentReceiver (new, Phantom.Workspaces.Llm.Core)
public interface ICopilotSubAgentReceiver
{
void Push(ChatResponseUpdate update);
void Complete();
void Fail(Exception exception);
}Implemented by CopilotSubAgentChatClient — the IChatClient created by AgentFactory for the github-copilot-subagent provider.
9. github-copilot-subagent provider (new, AgentFactory.cs)
{ "kind": "prompt", "model": { "provider": "github-copilot-subagent" } }No tools, no model options, no trust profile. AgentFactory creates a CopilotSubAgentChatClient for this provider. This definition is constructed internally by CopilotSdkChatClient — not exposed to users.
10. CopilotSdkChatClient construction flow on SubAgentStarted
When the Copilot SDK fires SubAgentStarted(copilotSubAgentId):
- Constructs the sub-agent
AgentDefinitioninternally - Generates a new
AgentSessionId - Calls
factory.CreateAsync(definition, sessionId)→RunningAgentChatLease→AgentChat - Queries the
AgentChatforICopilotSubAgentReceiver:var receiver = agentChat.GetService<ICopilotSubAgentReceiver>() ?? throw new InvalidOperationException("Sub-agent AgentChat does not expose ICopilotSubAgentReceiver");
- Calls
subAgentTable.Add(lease.AgentChat)→SubAgent - Forwards subsequent events via
receiver.Push(...),receiver.Complete(),receiver.Fail(...)
11. Wrapper in Phantom.Workspaces.Services
// Phantom.Workspaces.Services/RunningAgentChatWithEntityInfo.cs
public sealed class RunningAgentChatWithEntityInfo
{
public AgentSessionId SessionId { get; }
public string EntityName { get; }
public string? EntityId { get; }
public Task<RunningAgentChatLease> AcquireLeaseAsync(CancellationToken ct = default) { … }
}// Phantom.Workspaces.Services/IRunningAgentChatTable.cs
public interface IRunningAgentChatTable
{
ObservableCollection<RunningAgentChatWithEntityInfo> RunningSessions { get; }
Task<RunningAgentChatLease> AcquireAsync(
AgentSessionId sessionId,
string entityName = "",
string? entityId = null,
CancellationToken ct = default);
}Files
| File | Change |
|---|---|
New: Phantom.Workspaces.Llm.Interfaces/AgentSessionId.cs |
Typed session-ID wrapper |
New: Phantom.Workspaces.Llm.Core/IRunningAgentChatFactory.cs |
New interface with GetAsync + CreateAsync |
New: Phantom.Workspaces.Llm.Core/AgentChatFactory.cs |
Reference-counted implementation |
New: Phantom.Workspaces.Llm.Core/RunningAgentChat.cs |
Observable collection entry type |
Move+edit: Phantom.Workspaces.Llm.Core/RunningAgentChatLease.cs |
Moved from Phantom.Workspaces.Services; callback-based disposal |
New: Phantom.Workspaces.Llm.Core/ISubAgentTable.cs |
New interface |
New: Phantom.Workspaces.Llm.Core/ICopilotSubAgentReceiver.cs |
New interface |
New: Phantom.Workspaces.Llm.Core/CopilotSubAgentChatClient.cs |
Implements IChatClient + ICopilotSubAgentReceiver |
Phantom.Workspaces.Llm.Interfaces/AgentServices.cs |
Add IRunningAgentChatFactory? RunningAgentChatFactory; implement IServiceProvider |
Phantom.Workspaces.Llm.Core/AgentChat.cs |
Accept IRunningAgentChatFactory via AgentServices; implement ISubAgentTable; implement IServiceProvider; inject into CopilotSdkChatClient |
Phantom.Workspaces.Llm.Core/AgentFactory.cs |
Add github-copilot-subagent provider case |
Phantom.Workspaces.Llm.Core/CopilotSdkChatClient.cs |
Accept IRunningAgentChatFactory + ISubAgentTable; handle SubAgentStarted |
Phantom.Workspaces.Services/RunningAgentChatTable.cs |
Becomes wrapper around IRunningAgentChatFactory; adds entity info |
Phantom.Workspaces.Services/IRunningAgentChatTable.cs |
Updated AcquireAsync signature; RunningSessions typed to RunningAgentChatWithEntityInfo |
New: Phantom.Workspaces.Services/RunningAgentChatWithEntityInfo.cs |
Replaces old RunningAgentChat in Services |
Delete: Phantom.Workspaces.Services/RunningAgentChat.cs |
Superseded by Phantom.Workspaces.Llm.Core/RunningAgentChat.cs |
Delete: Phantom.Workspaces.Services/RunningAgentChatLease.cs |
Moved to Phantom.Workspaces.Llm.Core |
Expected tests
AgentChatFactory
GetAsync_NewSession_LoadsFromPersistenceAndReturnsLeaseGetAsync_NewSession_AppearsInRunningSessionsGetAsync_SessionAlreadyRunning_ReturnsSameAgentChatNewLeaseGetAsync_AfterLastLeaseDisposed_RemovesFromRunningSessionsGetAsync_AfterLastLeaseDisposed_DisposesAgentChatGetAsync_ConcurrentFirstAcquire_OnlyCreatesOneAgentChatCreateAsync_PersistsSessionBeforeReturningCreateAsync_NewSession_AppearsInRunningSessionsDisposeAsync_ReleasesAllSessions
RunningAgentChatLease
DisposeAsync_CallsOnDisposeCallbackDisposeAsync_CalledTwice_OnlyCallsCallbackOnce
AgentSessionId
AgentSessionId_Equality_SameValue_EqualAgentSessionId_ToString_ReturnsValue
ICopilotSubAgentReceiver / CopilotSubAgentChatClient
Push_ForwardsUpdateToSubscriberComplete_SignalsCompletionFail_PropagatesExceptionGetService_ReturnsICopilotSubAgentReceiver
CopilotSdkChatClient — SubAgentStarted flow
SubAgentStarted_CreatesSubAgentChatViaFactorySubAgentStarted_RegistersSubAgentWithTableSubAgentStarted_ReceiverAcquiredFromSubAgentChatSubAgentStarted_SubsequentEventsForwardedToReceiver
RunningAgentChatTable (wrapper)
AcquireAsync_AddsEntityInfoToRunningSessionsAcquireAsync_LastLeaseDisposed_RemovesFromRunningSessionsAcquireAsync_TwoLeasesForSameSession_RemovedOnlyOnLastDisposeAcquireAsync_EntityInfoPreservedForDurationOfSession
AgentChat IServiceProvider
AgentChat_GetService_ICopilotSubAgentReceiver_ReturnsCopilotSubAgentChatClientAgentChat_GetService_IRunningAgentChatFactory_ReturnsFromAgentServicesAgentChat_GetService_ILoggerFactory_ReturnsFromAgentServicesAgentChat_GetService_UnknownType_ReturnsNullAgentServices_GetService_ReturnsNullForUnknownTypeAgentServices_GetService_ReturnsRegisteredService_ForEachKnownType
Activity
JoshuaRowePhantom commentedon Jul 6, 2026
All sub-issues #667–#675 are implemented and merged to features. Closing.
JoshuaRowePhantom commentedon Jul 6, 2026
Verification passed
Checked: All 9 sub-issues (#667–#675) committed on the \eatures\ branch. Inspected the following files and test suites:
Implementation files:
eadonly record struct AgentSessionId(string Value)\ present ✅
Tests verified:
Result: Implementation found, all verification criteria satisfied. No disabled tests, no unresolved TODOs, no timing-dependent tests.