Skip to content

feat(adk): propagate parent + root context_id headers on outbound A2A calls#1925

Closed
Prefix wants to merge 1 commit into
kagent-dev:mainfrom
Prefix:feat/a2a-conversation-lineage-headers
Closed

feat(adk): propagate parent + root context_id headers on outbound A2A calls#1925
Prefix wants to merge 1 commit into
kagent-dev:mainfrom
Prefix:feat/a2a-conversation-lineage-headers

Conversation

@Prefix
Copy link
Copy Markdown

@Prefix Prefix commented May 26, 2026

Summary

Stamps two conversation-lineage HTTP headers on every outbound KAgentRemoteA2ATool call so a remote peer can correlate the turn with the originating chat conversation across a chain of A2A hops.

  • x-kagent-parent-context-id — immediate caller's session id (changes per hop)
  • x-kagent-root-context-id — top-of-chain context_id (stable across hops + across turns of the same conversation)

Closes #1924.

Why

KAgentRemoteA2ATool.run_async ships the outbound A2A message with context_id = self._last_context_id, a uuid4() minted once per tool instance. The receiving peer has no way to correlate the turn with the chat that started it. Anyone building multi-hop kagent fleets that key per-conversation state (sessions, worker pods, idempotency tokens, cache entries) on a stable identifier today either hand-rolls a header_provider on every tool or accepts losing continuity at every hop.

Motivating concrete failure mode: a chat-tier Declarative agent delegates to a router that spawns a per-conversation worker pod (e.g. a kubernetes-sigs/agent-sandbox SandboxClaim running claude --print); the router has no stable identifier to key the claim on, so a fresh pod spawns every turn and the user-visible symptom is "the agent loses memory between turns" even though the kagent UI thread is the same.

The receive side already copies all inbound HTTP headers into session.state["headers"] (set by A2aAgentExecutor.execute, lines 541-545), so no server-side plumbing change is needed — a peer that wants the root context_id just reads it from session state.

What changed

python/packages/kagent-adk/src/kagent/adk/_remote_a2a_tool.py

  • Two new module-level header constants: PARENT_CONTEXT_ID_HEADER and ROOT_CONTEXT_ID_HEADER.
  • _build_call_context now also derives a lineage-headers dict and merges it into the outbound ClientCallContext state. The existing header_provider callback still wins on overlap, so callers can override lineage when they need to.
  • Helper _build_lineage_headers derives parent_context_id from tool_context.session.id and root_context_id from the inbound state["headers"] (forwarded unchanged when present, falls back to legacy parent header for older callers, falls back to caller's own session id when this agent is the chain root). Returns {} when no session id is resolvable so the outbound request matches pre-feature behavior on stub tool contexts.

python/packages/kagent-adk/tests/unittests/test_remote_a2a_tool.py

  • Extends _MockSession/MockToolContext to expose id and state so lineage logic is testable.
  • Extends _make_tool helper to forward an optional header_provider.
  • New TestLineageHeaderPropagation class covers six cases:
    • Root agent stamps own id as parent + root.
    • Mid-chain agent forwards root unchanged + overrides parent with own id.
    • Legacy inbound with only parent header promotes it to root.
    • No session id → no lineage headers (non-breaking).
    • header_provider overrides lineage when callers need to.
    • End-to-end through _SubagentInterceptor onto outbound http_kwargs.headers.

Test plan

  • uv run ruff format --diff → clean
  • uv run ruff check → "All checks passed!"
  • uv run pytest packages/kagent-adk/tests/unittests/test_remote_a2a_tool.py -q28 passed
  • E2E coverage — happy to add if a maintainer points at the right place; the change is non-breaking by construction (additive headers + no-op when no session id), so I scoped this PR to unit coverage initially.

Notes for reviewers

  • Marked draft to gather design feedback first per CONTRIBUTING.md guidance. Happy to iterate on header naming, derivation rules, or scope.
  • Backward-compatible: callers that ignore the new headers see no behavior change; callers that opt in just read session.state["headers"][ROOT_CONTEXT_ID_HEADER].
  • The export of header-name constants is intentional so BYO consumers (from kagent.adk._remote_a2a_tool import ROOT_CONTEXT_ID_HEADER) avoid string-literal drift.

… calls

When an agent calls a peer via KAgentRemoteA2ATool, the outbound A2A message
today carries only the tool's own pre-generated context_id (a uuid4() minted
at tool construction time). The receiving agent has no way to correlate
that turn back to the originating chat conversation, which makes it
impossible to key per-conversation state (sessions, sandbox pods, cache
entries, idempotency tokens) on a stable identifier across a chain of A2A
hops.

This change has KAgentRemoteA2ATool stamp two HTTP headers on every
outbound call:

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

  x-kagent-root-context-id    — the top-of-chain context_id, forwarded
                                unchanged through every hop. Stays stable
                                across hops and across turns of the same
                                conversation. Set to the caller's own
                                session id when this agent is the root.

Receiving agents already see all inbound HTTP headers in
session.state['headers'] (set by A2aAgentExecutor.execute), so no
server-side plumbing change is needed; a peer that wants to use the root
context_id just reads it from session state.

Includes new unit-test coverage for the root, mid-chain, legacy-inbound,
empty-session-id, and provider-override paths plus an end-to-end check
that the headers make it through _SubagentInterceptor onto the outbound
http_kwargs.

Header constants are exported from the module so downstream BYO agents
can consume them by reference rather than string-literal.

The existing x-user-id propagation and any caller-supplied header_provider
output are preserved; lineage headers are layered underneath the provider
so a custom provider can still override them when needed.

Why this matters in practice — example use case that motivated the
change: a chat agent delegates to a router agent via A2A; the router
spawns a per-conversation sandbox pod and wants the same pod to serve
every subsequent turn of the same chat. Without a stable identifier the
router has to re-create the sandbox on every turn (because the tool's
self-generated context_id is opaque to the chat agent). With the root
header, the router keys the sandbox claim on x-kagent-root-context-id
and reuses the same pod across turns.

Signed-off-by: Lukas Urbonas <lukas.urbonas@surfsharkteam.com>
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels May 26, 2026
@Prefix
Copy link
Copy Markdown
Author

Prefix commented May 26, 2026

Closing — will reopen clean under a fresh fork. Sorry for the churn.

@Prefix Prefix closed this May 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant