Skip to content

[FEATURE] Propagate parent + root context_id headers on outbound A2A calls #1926

@Prefix

Description

@Prefix

📋 Prerequisites

  • I have searched the existing issues to avoid creating a duplicate
  • By submitting this issue, you agree to follow our Code of Conduct

📝 Feature Summary

Propagate two conversation-lineage HTTP headers (x-kagent-parent-context-id + x-kagent-root-context-id) on outbound KAgentRemoteA2ATool calls so a peer agent can correlate a turn with the originating chat conversation across a chain of A2A hops.

❓ Problem Statement / Motivation

Today KAgentRemoteA2ATool.run_async ships the outbound A2A message with context_id = self._last_context_id, which is a uuid4() minted once per tool instance at construction (_remote_a2a_tool.py line 159). The receiving peer sees that opaque uuid as its own A2A context_id and has no way to correlate the turn back to the originating chat conversation.

_SubagentInterceptor only forwards two headers today: x-user-id and x-kagent-source: agent (_remote_a2a_tool.py lines 73-84). The receive side already copies all inbound headers into session.state["headers"] in _agent_executor.py lines 541-545 — so the plumbing to consume new headers exists; it is only the outbound-stamping side that lacks lineage data.

Concrete failure mode that motivated this issue: a chat-tier Declarative kagent Agent delegates to a router agent over A2A; the router spawns a per-conversation worker pod (e.g. a kubernetes-sigs/agent-sandbox SandboxClaim running claude --print) and wants the same pod to serve every subsequent turn of the same chat thread. Without a stable cross-hop identifier the router has to re-create the worker on every turn — because the tool's self-generated context_id is opaque to the chat-tier and changes the moment the chat-tier's tool list is rebuilt. The user-visible symptom is "my coding agent loses memory between turns" even though the kagent UI thread is the same.

Anyone building multi-hop kagent fleets that key state on a conversation id today either has to hand-roll a header_provider callback on every KAgentRemoteA2ATool instance or accept losing per-conversation continuity at every A2A hop. Both options are friction that should not exist for the obvious correlation case.

💡 Proposed Solution

Add two outbound HTTP headers, set automatically inside KAgentRemoteA2ATool._build_call_context:

x-kagent-parent-context-id  — immediate caller's session id (= the agent
                              that just ran this tool). Changes with every
                              hop.

x-kagent-root-context-id    — top-of-chain context_id. Forwarded unchanged
                              when an inbound `x-kagent-root-context-id`
                              header is present in this agent's session
                              state; otherwise set to the caller's own
                              session id (this agent is the chain root).
                              Stays stable across hops and across turns of
                              the same conversation.

Derivation logic (self-healing, no per-agent configuration needed):

parent_context_id = tool_context.session.id
inbound_headers   = tool_context.session.state.get("headers", {}) or {}
root_context_id   = (
    inbound_headers.get("x-kagent-root-context-id")     # forwarded
    or inbound_headers.get("x-kagent-parent-context-id") # legacy fallback
    or parent_context_id                                  # we are root
)

Receiving agents already see every inbound HTTP header in session.state["headers"] (set by A2aAgentExecutor.execute), so no server-side plumbing change is required — a peer that wants to use the root context_id just reads state["headers"]["x-kagent-root-context-id"].

Header names are exported as module-level constants (PARENT_CONTEXT_ID_HEADER, ROOT_CONTEXT_ID_HEADER) so downstream BYO agents can consume them by reference rather than by string literal.

The patch preserves backward compatibility:

  • Existing x-user-id and x-kagent-source propagation is unchanged.
  • Constructor-supplied header_provider callbacks still win — lineage headers are layered underneath the provider so a custom provider can override them when it really needs to.
  • When the caller cannot resolve a session id (older tool_context shapes used in tests / unusual integration paths), no lineage headers are emitted — outbound request matches pre-feature behavior.

A draft PR with the implementation, unit-test coverage (root, mid-chain, legacy-inbound, missing session id, provider-override, end-to-end through _SubagentInterceptor) and a DCO sign-off is attached to this issue.

🔄 Alternatives Considered

  1. Per-tool header_provider callback — already exists; works but every consumer has to write the same lineage-extraction code, and most consumers don't know they should. Friction tax on the common case.

  2. Reuse context_id field on A2AMessage itself to carry root identity — would mis-use a field the a2a-sdk uses for per-call session correlation, breaks the abstraction.

  3. Add the data to tool_context.session.state (a session-state key) — would only round-trip if the receiving agent runs in the same kagent process, which is not the case for cross-pod A2A calls.

  4. OTEL trace context propagation — already there (gen_ai.conversation.id span attribute set in _agent_executor.py) but trace IDs are not exposed to agent-level code and are not a stable per-conversation identifier across UI turns.

The explicit-header approach is the smallest viable change that solves the correlation problem at the right layer, mirrors how x-user-id is already propagated, and stays inside the HTTP plane where downstream code already reads it.

🎯 Affected Service(s)

App Service

📚 Additional Context

  • Receiving-side helper code already copies all inbound HTTP headers into session.state["headers"], so this PR only changes the outbound stamping logic plus tests.
  • Draft PR with full diff + unit tests: linked above.

🙋 Are you willing to contribute?

  • I am willing to submit a PR for this feature

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status

    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions