Repository navigation
feat(a2a): report task token usage through a versioned A2A extension - #2461
Conversation
90d57f0 to
c8981c7
Compare
c8981c7 to
a8d0c31
Compare
|
Rebased onto The three remaining red checks are not from this branch, they reproduce identically on
|
…date Both runtimes accumulate non-partial per-call token usage across an execution and stamp it on the terminal status update (completed, input-required, failed) under kagent_usage_total. Resumed tasks seed the accumulator from the task metadata, so the total covers the whole task rather than the last segment. The value uses the same genai UsageMetadata serialization as the per-event adk_usage_metadata / kagent_usage_metadata entries, plus modelVersion. Signed-off-by: QuentinBisson <quentin@giantswarm.io>
The Python accumulator ran from the A2A after-event interceptor, which only fires for ADK events the converter turns into at least one A2A event. The event carrying the usage of the call that pauses a task for input has its long-running function call stripped before conversion and produces no A2A event, so its tokens were never counted. Accumulate from an ADK runner plugin instead, matching the Go vantage point. Failures raised outside the upstream executor publish their own terminal event, which now carries the total like the other terminal states. Derive totalTokenCount when no provider reported one, so Anthropic agents stop reporting a zero total next to non-zero counts. Signed-off-by: QuentinBisson <quentin@giantswarm.io>
…e reflect use Signed-off-by: QuentinBisson <quentin@giantswarm.io>
cbe1b02 to
013356f
Compare
|
@supreme-gg-gg this is clean against main and green. Could you take a look when you have a moment? |
EItanya
left a comment
There was a problem hiding this comment.
🤖 AI-generated review.
Reviewed 013356fc. I found one correctness issue and a simpler way to integrate this with ADK:
-
Derive missing totals per call, before accumulation. In both Go and Python, the fallback only runs when the accumulated total is zero. An Anthropic-style execution derives and persists 120 tokens; after resuming and spending another 230 without a reported total, prompt/output counts reach 350 but
totalTokenCountstays at 120. Mixing calls with and without reported totals also undercounts. Reproduced both cases in both languages. -
Use Go's existing
plugin.Config.OnEventCallback. It observes ADK events before A2A conversion, including the paused tool-call event. Registering it throughRunnerConfig.PluginConfiglets us retain the default provider and remove the custom runner/provider and reflection test. I tested that replacement: the Go A2A package passes, including the HITL pause/resume test. The Python implementation already uses this approach. -
Upstream direction: both ADKs expose per-call usage/hooks; current Python main also has private telemetry aggregation, though it is absent from this PR's locked version and isn't a public A2A task-total API. A public invocation-usage summary consumed by ADK's A2A adapters would be a useful upstream contribution, leaving a thin kagent metadata mapping. No need to wait for that to use the existing plugin hooks here.
Reproduction sources, exact plugin experiment, commands, results and coverage limits. Baseline Go package tests and 17 focused Python tests passed; four added regression probes fail as described. No live-model or database persistence tests were run.
A call that reports no total contributed nothing to totalTokenCount, and the derived fallback only applied when the accumulated total was zero. A task mixing calls with and without a reported total undercounted, and a resumed task whose persisted total was derived kept the stored value while its prompt and candidate counts grew. Aggregation now runs from plugin.Config.OnEventCallback, which sees every ADK event before A2A conversion, including the paused tool-call event. The custom RunnerProvider and its mirror of the ADK default provider are gone. Signed-off-by: QuentinBisson <quentin@giantswarm.io>
…l-v2 Signed-off-by: QuentinBisson <quentin@giantswarm.io> # Conflicts: # go/adk/pkg/a2a/executor.go
|
Could we expose this through an optional, versioned A2A usage extension? A2A doesn't currently define a built-in token-usage schema. Upstream discussed this exact question in a2aproject/A2A#1155 (comment) and recommended an opt-in extension. There's also a newer cost/budget proposal (a2aproject/A2A#2121), but it remains a proposal rather than an adopted standard. The existing
The aggregation work here would still be useful; this would formalize its public output. A2A extension guidance: https://a2a-protocol.org/latest/topics/extensions/ |
|
Agreed, let's make it an extension, following the same pattern as the HITL one: a versioned There are two ways to do it, and I'd like to confirm which you want. The first is a data-only extension. The schema becomes discoverable and versioned, the value keeps being emitted as it is today, and no existing consumer breaks. This covers what you asked for. The second also gates emission on client activation. That is what the upstream thread had in mind (a2aproject/A2A#1155 (comment)): an agent that does not want to share its token counts can decline. The catch is that it only means something if the per-event I'd go with the first for now. Nobody has asked to withhold token counts, and if that need appears, or if an upstream usage extension gets adopted, gating can come as a v2 of the URI. To keep that door open cheaply I'd have our own clients send the activation header from the start, so a later gate is a no-op inside kagent and only affects third parties. The v1 spec would state plainly that the extension is data-only and always emitted. One detail either way: the aggregate has to keep being computed regardless of activation, because a resumed task reads the running total back from the stored task. Happy to do the second instead if you'd rather take the break now while the API version is already breaking. |
# Conflicts: # go/adk/pkg/a2a/executor.go # python/packages/kagent-adk/src/kagent/adk/_agent_executor.py # python/packages/kagent-adk/tests/unittests/test_agent_executor.py
…l-v2 # Conflicts: # go/adk/pkg/a2a/executor.go # python/packages/kagent-adk/src/kagent/adk/_agent_executor.py
| | `kagent.dev/a2a/task-created-at` | task | Durable task creation time used by task projections | | ||
| | `kagent.dev/a2a/part-type` | data part | Semantic data kind, including function calls and function results | | ||
| | `kagent.dev/a2a/usage` | task event | Model usage reported by runtimes that support it | | ||
| | `kagent.dev/a2a/usage-total` | task status update | Task-lifetime usage aggregate; each terminal status update carries the latest total | |
There was a problem hiding this comment.
I think the earlier comments said we want this as an extension instead of just metadata? Otherwise this is looking good
There was a problem hiding this comment.
Yes you're right and this makes total sense :)
I did not do the change yet as I was waiting for feedback on whether you prefer a data extension Always on or it if needs to be gated which imply the existing metadata like kagent_usage_metadata move to the extension or are fully removed
There was a problem hiding this comment.
Going with the always-on version for v1 unless you object: a versioned kagent.dev profile extension, advertised with required: false. The agent echoes A2A-Extensions when a client activates it, and our own clients send the header, but emission isn't gated, so current readers keep working. Gating, and moving kagent_usage_metadata into the extension, can come as v2 of the URI.
There was a problem hiding this comment.
Small correction: only the Go runtime echoes A2A-Extensions. The Python A2A SDK has no server-side activation API, which is the same gap HITL has today. Emission isn't gated, so it doesn't change behavior. I can send the Python echo for both extensions as a follow-up.
e088188 to
f625572
Compare
|
@EItanya I fixed go-lint and I'm fixing the DCO |
Signed-off-by: QuentinBisson <quentin@giantswarm.io>
003b4d3 to
d437a13
Compare
|
Thanks for checking this PR :D |
|
I'm trying haha, such bad luck! |
| * The usage extension rides on the same header. | ||
| */ | ||
| [HITL_EXTENSION_HEADER]: HITL_EXTENSION_URI, | ||
| [HITL_EXTENSION_HEADER]: `${HITL_EXTENSION_URI},${USAGE_EXTENSION_URI}`, |
There was a problem hiding this comment.
what reads this payload, ui only sends the header
There was a problem hiding this comment.
Fair point, nothing on the client side reads it. I removed the activation from the UI, and also from the MCP invoke tool and the Go and Python remote tools, which had the same issue. Emission doesn't depend on activation in v1, and a gated version would get a new URI, so sending it now gains nothing. Fixed in 4100b9c.
…l-v2 # Conflicts: # go/core/internal/translator/kagent/agentcard_test.go
…t read it Signed-off-by: QuentinBisson <quentin@giantswarm.io>
4100b9c to
0c4b2ee
Compare
…l-v2 # Conflicts: # go/core/internal/translator/kagent/compiler.go
Fixes #3061
Supersedes #2332, which the stale bot closed and GitHub refuses to reopen. This is a reimplementation, not a rebase: both executors were rewritten to delegate to the ADK library's own A2A executor, so the manual event loop the original PR hooked into no longer exists.
What this adds
A versioned profile extension,
https://kagent.dev/extensions/usage/v1, specified indocs/architecture/usage-extension.md. The status update that ends an execution (completed,input-required,failed,canceled) carries the task's token usage in its metadata under the extension URI:{ "inputTokens": 300, "outputTokens": 50, "reasoningTokens": 0, "cachedInputTokens": 0, "totalTokens": 350, "models": [{"model": "gpt-4o-2024-11-20", "inputTokens": 300, "outputTokens": 50, "reasoningTokens": 0, "cachedInputTokens": 0, "totalTokens": 350}] }required: false(the runtime card and the controller-compiled kagent card; Codex and Claude do not emit usage and do not advertise it). The Go runtime echoesA2A-Extensionson activation. The Python A2A SDK has no server-side activation API, same as HITL today.kagent.dev/a2a/usagekey into the extension, are left for a later version of the URI.A call that reports no total gets one derived from
input + output + reasoningwhen it is added, so tasks mixing providers (Anthropic reports no total) and resumed tasks whose persisted total was derived stay correct. Partial streaming events are skipped.Aggregation runs from an ADK runner plugin in both languages (
plugin.Config.OnEventCallbackin Go,on_event_callbackin Python). The A2A after-event callback misses the event whose long-running function call is stripped before conversion, and that event carries the usage of the call that paused the task. Go stamps the payload fromAfterExecuteCallback.A cancel request is answered by a separate executor call, and A2A discards the interrupted execution's own final event. Both executors therefore index running executions' usage by task ID, so the
canceledstatus carries the calls completed before the cancel. A task parked ininput-requiredrepeats its persisted total. A call cut off mid-flight was never reported by the provider and is not counted.Known limits
toolUsePromptTokenCountis not broken out. It is insidetotalTokens, so on Vertex the breakdown fields do not sum to the total.