Skip to content

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

@willvelida

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions