feat(adk): propagate parent + root context_id headers on outbound A2A calls#1927
Open
Prefix wants to merge 4 commits into
Open
feat(adk): propagate parent + root context_id headers on outbound A2A calls#1927Prefix wants to merge 4 commits into
Prefix wants to merge 4 commits into
Conversation
… 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>
Contributor
There was a problem hiding this comment.
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-idandx-kagent-root-context-idheader derivation and propagation inKAgentRemoteA2ATool. - Updated call-context header merging logic to allow
header_provideroverrides 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.
…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>
EItanya
reviewed
May 26, 2026
Contributor
EItanya
left a comment
There was a problem hiding this comment.
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>
0034e12 to
ee2f61b
Compare
Author
|
added go lang |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Stamps two conversation-lineage HTTP headers on every outbound
KAgentRemoteA2AToolcall 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_asyncships the outbound A2A message withcontext_id = self._last_context_id, auuid4()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 aheader_provideron 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-sandboxSandboxClaimrunningclaude --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 byA2aAgentExecutor.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.pyPARENT_CONTEXT_ID_HEADERandROOT_CONTEXT_ID_HEADER._build_call_contextnow also derives a lineage-headers dict and merges it into the outboundClientCallContextstate. The existingheader_providercallback still wins on overlap, so callers can override lineage when they need to._build_lineage_headersderivesparent_context_idfromtool_context.session.idandroot_context_idfrom the inboundstate["headers"](forwarded unchanged when present, falls back to legacyparentheader 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_MockSession/MockToolContextto exposeidandstateso lineage logic is testable._make_toolhelper to forward an optionalheader_provider.TestLineageHeaderPropagationclass covers six cases:parentheader promotes it to root.header_provideroverrides lineage when callers need to._SubagentInterceptoronto outboundhttp_kwargs.headers.Test plan
uv run ruff format --diff→ cleanuv run ruff check→ "All checks passed!"uv run pytest packages/kagent-adk/tests/unittests/test_remote_a2a_tool.py -q→ 28 passedNotes for reviewers
session.state["headers"][ROOT_CONTEXT_ID_HEADER].from kagent.adk._remote_a2a_tool import ROOT_CONTEXT_ID_HEADER) avoid string-literal drift.