Skip to content

agentId missing from SessionEvent in .NET, Go, and Python SDKs #1110

Description

@willvelida

Summary

The agentId field is present in the JSON wire format (session-events.schema.json) and exposed on every event variant in the Node.js SDK, but it is not generated in the .NET, Go, or Python SDKs. This makes sub-agent correlation significantly harder in those languages.

Current Behavior

  • Node.js: Every event variant includes agentId?: string — works correctly
  • .NET: SessionEvent base class has Id, Timestamp, ParentId, Ephemeral, Type — no AgentId
  • Go: SessionEvent struct has ID, Timestamp, ParentID, Ephemeral, Type, Data — no AgentID
  • Python: SessionEvent dataclass has id, timestamp, parent_id, ephemeral, type — no agent_id

Additionally, the .NET SessionEvent has no [JsonExtensionData] attribute, so agentId from the wire format is silently dropped during deserialization with no way to access it.

Root Cause

The code generators for .NET (scripts/codegen/csharp.ts), Go (scripts/codegen/go.ts), and Python (scripts/codegen/python.ts) extract a shared base type for the event envelope but omit agentId from it. The Node.js generator uses a flat union type that repeats all envelope fields per variant, which naturally includes agentId.

Impact

Without agentId, .NET/Go/Python consumers must rely on:

  1. SubagentStartedEvent.ToolCallIdSubagentCompletedEvent.ToolCallId lifecycle correlation
  2. Deprecated ParentToolCallId on AssistantMessageDeltaData, ToolExecutionStartData, etc.

The ParentToolCallId field is marked [Obsolete] — when it's removed, there will be no way to identify which sub-agent emitted a streaming delta or tool execution event in .NET/Go/Python.

Code Samples

Node.js — Works Today

In Node.js, agentId is available on every event, making sub-agent attribution straightforward:

import { CopilotClient } from "@anthropic-ai/copilot-sdk";

const client = new CopilotClient();
await client.start();

const session = await client.createSession({
  model: "claude-opus-4.6",
  customAgents: [
    { name: "researcher", displayName: "Research Agent", instructions: "..." },
    { name: "writer", displayName: "Writer Agent", instructions: "..." },
  ],
});

session.on((event) => {
  // agentId is directly available on the event envelope
  if (event.agentId) {
    console.log(`[${event.agentId}] ${event.type}`);
  }

  switch (event.type) {
    case "assistant.message_delta":
      // Attribute streaming content to the correct sub-agent
      console.log(`Agent "${event.agentId}" says: ${event.data.deltaContent}`);
      break;

    case "tool.execution_start":
      // Know which sub-agent initiated the tool call
      console.log(`Agent "${event.agentId}" calling tool: ${event.data.toolName}`);
      break;
  }
});

await session.sendAndWait({ prompt: "Research and write a summary of topic X" });

.NET — Current Workaround (Lifecycle Correlation)

Without agentId, .NET consumers must track sub-agent boundaries manually:

var client = new CopilotClient(new CopilotClientOptions { /* ... */ });
await client.StartAsync();

var session = await client.CreateSessionAsync(new SessionConfig
{
    Model = "claude-opus-4.6",
    CustomAgents =
    [
        new CustomAgentConfig { Name = "researcher", DisplayName = "Research Agent", Instructions = "..." },
        new CustomAgentConfig { Name = "writer", DisplayName = "Writer Agent", Instructions = "..." },
    ],
});

// Manual tracking required because agentId is not on SessionEvent
var activeAgents = new Dictionary<string, string>(); // toolCallId → agentName

session.On(evt =>
{
    switch (evt)
    {
        case SubagentStartedEvent started:
            // Must manually track which sub-agent is active
            activeAgents[started.Data.ToolCallId] = started.Data.AgentDisplayName;
            Console.WriteLine($"Sub-agent '{started.Data.AgentDisplayName}' started");
            break;

        case SubagentCompletedEvent completed:
            activeAgents.Remove(completed.Data.ToolCallId);
            Console.WriteLine($"Sub-agent '{completed.Data.AgentDisplayName}' completed in {completed.Data.DurationMs}ms");
            break;

        case ToolExecutionStartEvent toolStart:
            // No agentId on the event — cannot directly attribute this tool call
            // Must use deprecated ParentToolCallId or infer from ordering
            #pragma warning disable CS0618
            var agent = toolStart.Data.ParentToolCallId is { } parentId
                && activeAgents.TryGetValue(parentId, out var name) ? name : "unknown";
            #pragma warning restore CS0618
            Console.WriteLine($"Tool '{toolStart.Data.ToolName}' called by agent '{agent}'");
            break;

        case AssistantMessageDeltaEvent delta:
            // Same problem — no agentId, must use deprecated field
            #pragma warning disable CS0618
            var source = delta.Data.ParentToolCallId is { } pid
                && activeAgents.TryGetValue(pid, out var agentName) ? agentName : "root";
            #pragma warning restore CS0618
            Console.WriteLine($"[{source}] {delta.Data.DeltaContent}");
            break;
    }
});

await session.SendAndWaitAsync(new MessageOptions { Prompt = "Research and write a summary of topic X" });

.NET — Desired Behavior (With agentId)

If AgentId were available on SessionEvent, the code becomes much simpler:

session.On(evt =>
{
    // Direct sub-agent attribution — no manual tracking needed
    var source = evt.AgentId ?? "root";

    switch (evt)
    {
        case ToolExecutionStartEvent toolStart:
            Console.WriteLine($"Agent '{source}' calling tool: {toolStart.Data.ToolName}");
            break;

        case AssistantMessageDeltaEvent delta:
            Console.WriteLine($"[{source}] {delta.Data.DeltaContent}");
            break;

        case SubagentCompletedEvent completed:
            Console.WriteLine($"Agent '{source}' completed in {completed.Data.DurationMs}ms");
            break;
    }
});

Go — Desired Behavior (With AgentID)

session.On(func(evt copilot.SessionEvent) {
    source := "root"
    if evt.AgentID != nil {
        source = *evt.AgentID
    }

    switch data := evt.Data.(type) {
    case *copilot.ToolExecutionStartData:
        log.Printf("Agent %q calling tool: %s", source, data.ToolName)
    case *copilot.AssistantMessageDeltaData:
        log.Printf("[%s] %s", source, data.DeltaContent)
    case *copilot.SubagentCompletedData:
        log.Printf("Agent %q completed in %.0fms", source, *data.DurationMs)
    }
})

Python — Desired Behavior (With agent_id)

@session.on
def handle_event(event):
    source = event.agent_id or "root"

    if isinstance(event.data, ToolExecutionStartData):
        print(f"Agent '{source}' calling tool: {event.data.tool_name}")
    elif isinstance(event.data, AssistantMessageDeltaData):
        print(f"[{source}] {event.data.delta_content}")
    elif isinstance(event.data, SubagentCompletedData):
        print(f"Agent '{source}' completed in {event.data.duration_ms}ms")

Expected Behavior

Add agentId (or equivalent) to the base event type in all SDKs:

  • .NET: public string? AgentId { get; set; } on SessionEvent
  • Go: AgentID *string on SessionEvent
  • Python: agent_id: str | None = None on SessionEvent

Environment

  • .NET SDK: GitHub.Copilot.SDK v0.2.2

Activity

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions