Skip to content

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

Open
Prefix wants to merge 4 commits into
kagent-dev:mainfrom
Prefix:feat/a2a-conversation-lineage-headers
Open

feat(adk): propagate parent + root context_id headers on outbound A2A calls#1927
Prefix wants to merge 4 commits 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 #1926.

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 <1775218+Prefix@users.noreply.github.com>
@github-actions github-actions Bot added the enhancement New feature or request label May 26, 2026
@Prefix Prefix marked this pull request as ready for review May 26, 2026 16:15
Copilot AI review requested due to automatic review settings May 26, 2026 16:15
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR adds propagation of conversation lineage headers to outbound A2A calls so downstream agents can correlate requests across multi-hop subagent chains.

Changes:

  • Introduced x-kagent-parent-context-id and x-kagent-root-context-id header derivation and propagation in KAgentRemoteA2ATool.
  • Updated call-context header merging logic to allow header_provider overrides while preserving lineage by default.
  • Added unit tests covering lineage semantics and verifying headers reach outbound HTTP via _SubagentInterceptor.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
python/packages/kagent-adk/src/kagent/adk/_remote_a2a_tool.py Adds lineage header constants and logic to compute/merge outbound propagation headers.
python/packages/kagent-adk/tests/unittests/test_remote_a2a_tool.py Extends mocks to support session id/state and adds focused tests for lineage propagation & override behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread python/packages/kagent-adk/src/kagent/adk/_remote_a2a_tool.py
Comment thread python/packages/kagent-adk/src/kagent/adk/_remote_a2a_tool.py Outdated
Comment thread python/packages/kagent-adk/tests/unittests/test_remote_a2a_tool.py
Prefix and others added 2 commits May 26, 2026 19:19
…g + annotate test helper

Addresses Copilot review feedback on kagent-dev#1927.

- _build_lineage_headers now hard-returns {} when the local session id is
  unresolvable. Without this guard the function would emit a root-only
  header forwarded from inbound state, contradicting the docstring
  contract "no session id → no lineage headers" and matching the
  existing unit-test assertion.

- _make_tool's header_provider parameter gains Callable type annotation
  matching the production signature.

Signed-off-by: Lukas <1775218+Prefix@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@EItanya EItanya left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These changes make sense to me overall. @supreme-gg-gg is the expert on this flow so I'd like his take first. The one thing I'd like to say is that we should add this to the go runtime as well.

Mirrors the Python ADK change from this PR over into the Go adk
KAgentRemoteA2ATool path so multi-hop A2A fleets get the same
cross-hop conversation lineage in both runtimes.

Single file, no executor change needed: the Go interceptor still has
access to the inbound a2asrv.CallContext on ctx (already used by the
authzForwardingInterceptor), so the root header can be forwarded
unchanged from inbound RequestMeta without mirroring Python's
session.state["headers"] copy in _agent_executor.py.

Wiring follows the existing kagent precedent in remote_a2a_tool.go:
- parent_context_id comes from ctx.SessionID() stashed via a context
  value at the same call sites that already stash userIDContextKey,
  matching userIDForwardingInterceptor's pattern;
- root_context_id is read from the inbound a2asrv.CallContext via
  CallContextFrom, matching authzForwardingInterceptor's pattern;
- pre-existing headers on req.Meta win, which gives callers using
  extraHeaders the same override knob Python exposes via
  header_provider.

Test coverage mirrors the five derivation cases in the Python
TestLineageHeaderPropagation suite: chain root, mid-chain forward,
legacy-parent promotion, no-session-id no-op, caller override wins.

Signed-off-by: Lukas Urbonas <lukas.urbonas@surfsharkteam.com>
@Prefix Prefix force-pushed the feat/a2a-conversation-lineage-headers branch from 0034e12 to ee2f61b Compare May 27, 2026 13:23
@Prefix
Copy link
Copy Markdown
Author

Prefix commented May 27, 2026

added go lang

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

3 participants