Skip to content

Route sub-agent events in CopilotSdkChatClient and buffer tool-call prompts #622

Closed

Description

@JoshuaRowePhantom

Parent: Part of #34

Scope

Make CopilotSdkChatClient implement ISubAgentChat for the root stream and wire an ISubAgentChatRegistry dependency. Add a GetTarget(agentId?) helper that dispatches delta/tool events to the correct sink (root or child). Handle SubagentStartedEvent, SubagentCompletedEvent, and SubagentFailedEvent via session.On(...). Buffer arriving ToolExecutionStartEvent items keyed by tool-call ID so that when SubagentStartedEvent fires, the tool-call arguments can be injected as the sub-agent's first history message (handling the race where either event may arrive first).

Relevant design extract (from #34)

SDK event surface

The Copilot SDK exposes five first-class sub-agent events via session.On(...):

Event Meaning
SubagentStartedEvent A sub-agent was launched; carries AgentId, ParentToolCallId, AgentName, AgentDisplayName, AgentDescription
SubagentCompletedEvent Sub-agent finished; carries DurationMs, TotalTokens, TotalToolCalls
SubagentFailedEvent Sub-agent failed; carries Error, DurationMs
SubagentSelectedEvent Session-level selection signal (no AgentId)
SubagentDeselectedEvent Session-level deselection signal (no AgentId)

Every streaming event also carries two routing signals:

Signal Meaning
SessionEvent.AgentId Non-null for any event originating inside a sub-agent. Stable identity for its lifetime.
*.Data.ParentToolCallId ID of the tool call in the parent session that spawned this sub-agent. Present on AssistantMessageDeltaEvent, AssistantMessageEvent, AssistantUsageEvent, ToolExecutionStartEvent, ToolExecutionCompleteEvent.

§3 — CopilotSdkChatClient changes

CopilotSdkChatClient implements ISubAgentChat for the root stream. A GetTarget helper dispatches events:

private ISubAgentChat GetTarget(string? agentId) =>
    agentId == null ? this : this.subAgentRegistry?.TryGet(agentId) ?? this;

The session.On(...) handler builds an AgentDefinition from the SDK event metadata and calls GetOrCreateAsync:

case SubagentStartedEvent started:
    var subDef = AgentDefinition.ForHostedSubAgent(
        started.Data.AgentDisplayName,
        started.Data.AgentDescription);
    await (this.subAgentRegistry?.GetOrCreateAsync(
        started.AgentId ?? started.Data.ToolCallId,
        subDef,
        started.Data.ToolCallId) ?? Task.CompletedTask);
    break;

case SubagentCompletedEvent completed:
    this.subAgentRegistry?.TryGet(completed.AgentId ?? completed.Data.ToolCallId)?.Complete();
    break;

case SubagentFailedEvent failed:
    this.subAgentRegistry?.TryGet(failed.AgentId ?? failed.Data.ToolCallId)
        ?.Fail(new AgentSubagentFailedException(failed.Error));
    break;

case AssistantMessageDeltaEvent delta when !string.IsNullOrEmpty(delta.Data.DeltaContent):
    GetTarget(delta.AgentId).Push(new ChatResponseUpdate(ChatRole.Assistant, delta.Data.DeltaContent));
    break;

case ToolExecutionStartEvent toolStart:
    GetTarget(toolStart.AgentId).Push(MapToolStart(toolStart));
    break;

case ToolExecutionCompleteEvent toolComplete:
    GetTarget(toolComplete.AgentId).Push(MapToolComplete(toolComplete));
    break;

SubagentSelectedEvent and SubagentDeselectedEvent are recorded as diagnostic entries on the root history only.

§12 — Tool call prompt as first history message

When a sub-agent is spawned, the parent's ToolExecutionStartEvent for the matching ParentToolCallId contains the tool call arguments — this is effectively the "prompt" given to the sub-agent. It must be recorded as the first message in the sub-agent's chat history.

Design:

  • CopilotSdkChatClient buffers ToolExecutionStartEvent entries (keyed by tool call ID) as they arrive on the root stream.
  • When SubagentStartedEvent fires, look up the buffered ToolExecutionStartEvent for started.Data.ParentToolCallId.
  • If found, push the tool call arguments as the first message to the child ISubAgentChat before any other content is pushed.
  • If the ToolExecutionStartEvent has not yet arrived when SubagentStartedEvent fires (ordering edge case), buffer the sub-agent start and flush when the tool start event arrives.

§8 — Setting ParentToolCallId on history items

When ToolExecutionCompleteEvent arrives for a tool call ID that was recorded as spawning a sub-agent (i.e., it matches a SubagentStartedEvent.Data.ParentToolCallId), the resulting AgentChatHistoryItem must be constructed with ParentToolCallId set. This enables the jump link to the sub-agent's view (rendered in #629).

Artifacts

File Change
Phantom.Workspaces.Llm.Core/CopilotSdkChatClient.cs Implement ISubAgentChat; inject ISubAgentChatRegistry?; add GetTarget; wire SDK lifecycle events; buffer ToolExecutionStartEvent for prompt injection; set ParentToolCallId on tool-complete history items
Phantom.Workspaces.Llm.Core.Tests/CopilotSdkChatClientSubAgentRoutingTests.cs New — unit tests using a fake ISubAgentChatRegistry (see below)

Expected tests

  • AssistantMessageDeltaEvent_NullAgentId_WrittenToRootStream — delta with AgentId == null goes to root
  • AssistantMessageDeltaEvent_WithAgentId_WrittenToChildStream — delta with AgentId goes to child sink
  • SubagentStartedEvent_CallsGetOrCreateAsync_WithAgentDefinition — registry GetOrCreateAsync invoked with correct metadata
  • SubagentCompletedEvent_CallsComplete_OnChildSink — child sink Complete() called
  • SubagentFailedEvent_CallsFail_OnChildSink — child sink Fail(AgentSubagentFailedException) called
  • ToolStart_ArrivesBeforeSubagentStarted_PromptInjectedAsFirstMessage — tool-start event buffered; injected when sub-agent starts
  • SubagentStarted_ArrivesBeforeToolStart_PromptInjectedWhenToolStartArrives — prompt injection still works when ordering is reversed
  • ToolExecutionStartEvent_WithAgentId_RoutedToChildSink — tool-start on sub-agent routed correctly
  • ToolExecutionCompleteEvent_WithAgentId_RoutedToChildSink — tool-complete on sub-agent routed correctly
  • SubAgent_ToolCallPrompt_IsFirstHistoryMessage — sub-agent history first message content matches tool call arguments from ToolExecutionStartEvent
  • SubAgent_ToolCallPrompt_SubagentStartedBeforeToolStart_StillRecorded — prompt still recorded as first message when SubagentStartedEvent arrives before ToolExecutionStartEvent

Verification steps

  1. .\scripts\run-tests.ps1 -TestNames CopilotSdkChatClientSubAgentRoutingTests — all tests green.
  2. dotnet build Phantom.Workspaces.Llm.Core — zero errors.

Dependencies

Requires #619 (core interfaces), #621 (AgentChatHistoryItem.ParentToolCallId).

Activity

  1. JoshuaRowePhantom commented on Jul 2, 2026

    @JoshuaRowePhantom
    OwnerAuthor

    Depends on: #619, #620

  2. JoshuaRowePhantom commented on Jul 2, 2026

    @JoshuaRowePhantom
    OwnerAuthor

    Design

    Root cause / approach: CopilotSdkChatClient currently writes all streaming events directly to a per-turn channel, ignoring the AgentId routing signal. Sub-agent events are not handled at all.

    Design decisions:

    1. CopilotSdkChatClient implements ISubAgentChat — Push writes to the active turn channel writer stored in a volatile field. Complete/Fail are no-ops on root.
    2. ISubAgentChatRegistry injected as optional constructor parameter.
    3. GetTarget(string? agentId) returns this when agentId is null, otherwise looks up child via registry.
    4. Per-turn buffers (local to BeginTurnAsync): bufferedToolStarts and pendingSubAgentStarts handle the race.
    5. Prompt injection pushes tool call arguments as first ChatResponseUpdate to child sink.
    6. AgentDefinition created via AgentDefinition.FromJson minimal JSON payload.
    7. AgentSubagentFailedException: new exception wrapping SubagentFailedEvent.Data.Error.

    Files changing:

    • Phantom.Workspaces.Llm.Core/CopilotSdkChatClient.cs
    • Phantom.Workspaces.Llm.Core.Tests/CopilotSdkChatClientSubAgentRoutingTests.cs (new)
  3. JoshuaRowePhantom commented on Jul 2, 2026

    @JoshuaRowePhantom
    OwnerAuthor

    Tests

    All in Phantom.Workspaces.Llm.Core.Tests/CopilotSdkChatClientSubAgentRoutingTests.cs:

    1. AssistantMessageDeltaEvent_NullAgentId_WrittenToRootStream
    2. AssistantMessageDeltaEvent_WithAgentId_WrittenToChildStream
    3. SubagentStartedEvent_CallsGetOrCreateAsync_WithAgentDefinition
    4. SubagentCompletedEvent_CallsComplete_OnChildSink
    5. SubagentFailedEvent_CallsFail_OnChildSink
    6. ToolStart_ArrivesBeforeSubagentStarted_PromptInjectedAsFirstMessage
    7. SubagentStarted_ArrivesBeforeToolStart_PromptInjectedWhenToolStartArrives
    8. ToolExecutionStartEvent_WithAgentId_RoutedToChildSink
    9. ToolExecutionCompleteEvent_WithAgentId_RoutedToChildSink
    10. SubAgent_ToolCallPrompt_IsFirstHistoryMessage
    11. SubAgent_ToolCallPrompt_SubagentStartedBeforeToolStart_StillRecorded
  4. JoshuaRowePhantom commented on Jul 2, 2026

    @JoshuaRowePhantom
    OwnerAuthor

    Implementation complete

    Implemented in commit \

  5. added 2 commits that reference this issue on Jul 2, 2026
  6. JoshuaRowePhantom commented on Jul 2, 2026

    @JoshuaRowePhantom
    OwnerAuthor

    Verification failed

    Checked: Commit

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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions