Skip to content

Design: IRunningAgentChatFactory in Llm.Core; wrap in Phantom.Workspaces for UI tracking #659

Closed

Description

@JoshuaRowePhantom

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):

  1. Constructs the sub-agent AgentDefinition internally
  2. Generates a new AgentSessionId
  3. Calls factory.CreateAsync(definition, sessionId) → RunningAgentChatLease → AgentChat
  4. Queries the AgentChat for ICopilotSubAgentReceiver:
    var receiver = agentChat.GetService<ICopilotSubAgentReceiver>()
        ?? throw new InvalidOperationException("Sub-agent AgentChat does not expose ICopilotSubAgentReceiver");
  5. Calls subAgentTable.Add(lease.AgentChat) → SubAgent
  6. 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_LoadsFromPersistenceAndReturnsLease
  • GetAsync_NewSession_AppearsInRunningSessions
  • GetAsync_SessionAlreadyRunning_ReturnsSameAgentChatNewLease
  • GetAsync_AfterLastLeaseDisposed_RemovesFromRunningSessions
  • GetAsync_AfterLastLeaseDisposed_DisposesAgentChat
  • GetAsync_ConcurrentFirstAcquire_OnlyCreatesOneAgentChat
  • CreateAsync_PersistsSessionBeforeReturning
  • CreateAsync_NewSession_AppearsInRunningSessions
  • DisposeAsync_ReleasesAllSessions

RunningAgentChatLease

  • DisposeAsync_CallsOnDisposeCallback
  • DisposeAsync_CalledTwice_OnlyCallsCallbackOnce

AgentSessionId

  • AgentSessionId_Equality_SameValue_Equal
  • AgentSessionId_ToString_ReturnsValue

ICopilotSubAgentReceiver / CopilotSubAgentChatClient

  • Push_ForwardsUpdateToSubscriber
  • Complete_SignalsCompletion
  • Fail_PropagatesException
  • GetService_ReturnsICopilotSubAgentReceiver

CopilotSdkChatClient — SubAgentStarted flow

  • SubAgentStarted_CreatesSubAgentChatViaFactory
  • SubAgentStarted_RegistersSubAgentWithTable
  • SubAgentStarted_ReceiverAcquiredFromSubAgentChat
  • SubAgentStarted_SubsequentEventsForwardedToReceiver

RunningAgentChatTable (wrapper)

  • AcquireAsync_AddsEntityInfoToRunningSessions
  • AcquireAsync_LastLeaseDisposed_RemovesFromRunningSessions
  • AcquireAsync_TwoLeasesForSameSession_RemovedOnlyOnLastDispose
  • AcquireAsync_EntityInfoPreservedForDurationOfSession

AgentChat IServiceProvider

  • AgentChat_GetService_ICopilotSubAgentReceiver_ReturnsCopilotSubAgentChatClient
  • AgentChat_GetService_IRunningAgentChatFactory_ReturnsFromAgentServices
  • AgentChat_GetService_ILoggerFactory_ReturnsFromAgentServices
  • AgentChat_GetService_UnknownType_ReturnsNull
  • AgentServices_GetService_ReturnsNullForUnknownType
  • AgentServices_GetService_ReturnsRegisteredService_ForEachKnownType

Activity

  1. JoshuaRowePhantom commented on Jul 6, 2026

    @JoshuaRowePhantom
    OwnerAuthor

    All sub-issues #667–#675 are implemented and merged to features. Closing.

  2. JoshuaRowePhantom commented on Jul 6, 2026

    @JoshuaRowePhantom
    OwnerAuthor

    Verification passed

    Checked: All 9 sub-issues (#667–#675) committed on the \ eatures\ branch. Inspected the following files and test suites:

    Implementation files:

    • \Phantom.Workspaces.Llm.Interfaces/AgentSessionId.cs\ —
      eadonly record struct AgentSessionId(string Value)\ present ✅
    • \Phantom.Workspaces.Llm.Interfaces/IRunningAgentChatFactory.cs\ — marker interface for DI ✅
    • \Phantom.Workspaces.Llm.Interfaces/AgentServices.cs\ — implements \IServiceProvider, \RunningAgentChatFactory\ property ✅
    • \Phantom.Workspaces.Llm.Core/RunningAgentChatLease.cs\ — moved from Services; callback-based disposal; uses \AgentSessionId\ ✅
    • \Phantom.Workspaces.Llm.Core/RunningAgentChat.cs\ — observable collection entry type ✅
    • \Phantom.Workspaces.Llm.Core/IRunningAgentChatFactory.cs\ — full interface with \GetAsync, \CreateAsync, \GetOrCreateAsync, \RunningSessions\ ✅
    • \Phantom.Workspaces.Llm.Core/AgentChatFactory.cs\ — ref-counted implementation, foreground-scheduled \RunningSessions\ mutations ✅
    • \Phantom.Workspaces.Llm.Core/ISubAgentTable.cs\ — \SubAgent Add(AgentChat)\ ✅
    • \Phantom.Workspaces.Llm.Core/ICopilotSubAgentReceiver.cs\ — \Push, \Complete, \Fail\ ✅
    • \Phantom.Workspaces.Llm.Core/CopilotSubAgentChatClient.cs\ — implements \IChatClient\ + \ICopilotSubAgentReceiver\ ✅
    • \Phantom.Workspaces.Llm.Core/AgentFactory.cs\ — \github-copilot-subagent\ provider case ✅
    • \Phantom.Workspaces.Llm.Core/CopilotSdkChatClient.cs\ — \SubAgentStarted\ handled via \IRunningAgentChatFactory\ + \ISubAgentTable\ ✅
    • \Phantom.Workspaces/Services/RunningAgentChatWithEntityInfo.cs\ — new wrapper type ✅
    • \Phantom.Workspaces/Services/IRunningAgentChatTable.cs\ — updated signature; \ObservableCollection\ ✅
    • \Phantom.Workspaces/Services/RunningAgentChatTable.cs\ — wraps \IRunningAgentChatFactory, enriches with entity info ✅
    • \Phantom.Workspaces/Services/RunningAgentChat.cs\ — deleted (moved to Llm.Core) ✅
    • \Phantom.Workspaces/Services/RunningAgentChatLease.cs\ — deleted (moved to Llm.Core) ✅

    Tests verified:

    • \AgentChatFactoryTests\ — all 9+ cases including concurrent acquisition, scheduler dispatch, dispose ✅
    • \AgentSessionIdTests\ — equality, ToString, dictionary key usage ✅
    • \RunningAgentChatLeaseTests\ — callback invoked once, thread safety ✅
    • \CopilotSubAgentChatClientTests\ — Push/Complete/Fail, GetService, AgentFactory provider case ✅
    • \CopilotSdkChatClientSubAgentFactoryTests\ — SubAgentStarted creates via factory, registers with table, acquires receiver, forwards events ✅
    • \RunningAgentChatTableTests\ — entity info enrichment, lease lifecycle, scheduler dispatch ✅
    • \ISubAgentTableTests\ — Add returns SubAgent, persists link, appears in SubAgents collection ✅
    • \AgentChatServiceProviderTests\ — GetService for IRunningAgentChatFactory, ILoggerFactory, unknown type ✅
    • \AgentServicesTests\ — GetService for all known types, null for unknown ✅

    Result: Implementation found, all verification criteria satisfied. No disabled tests, no unresolved TODOs, no timing-dependent tests.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestverifiedIssue has been verified

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions