Skip to content

feat(a2a): report task token usage through a versioned A2A extension - #2461

Merged
EItanya merged 21 commits into
kagent-dev:mainfrom
QuentinBisson:feat/a2a-usage-total-v2
Oct 16, 2026
Merged

EItanya merged 21 commits into
kagent-dev:mainfrom
QuentinBisson:feat/a2a-usage-total-v2

Conversation

@QuentinBisson

@QuentinBisson QuentinBisson commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

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 in docs/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}]
}
  • Field names are provider-neutral, and counts are attributed per model instead of naming the last model on a combined total.
  • The value covers the task lifetime. Resumed tasks (HITL cycles, follow-up messages) seed from the stored task, so consumers replace earlier values with the latest one and never sum them.
  • The kagent ADK runtimes advertise the extension on their Agent Card with 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 echoes A2A-Extensions on activation. The Python A2A SDK has no server-side activation API, same as HITL today.
  • No kagent client reads the payload yet, so none of them activate the extension. A client that activates HITL and usage together sends one comma-joined header value, and a2a-go matches raw header values exactly, so the Go HITL and usage activation checks split the header on commas.
  • v1 is emitted whether or not the client activated it. Gating emission, and moving the per-event kagent.dev/a2a/usage key into the extension, are left for a later version of the URI.

A call that reports no total gets one derived from input + output + reasoning when 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.OnEventCallback in Go, on_event_callback in 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 from AfterExecuteCallback.

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 canceled status carries the calls completed before the cancel. A task parked in input-required repeats its persisted total. A call cut off mid-flight was never reported by the provider and is not counted.

Known limits

  • A client that disconnects mid-stream does not reach the terminal write, so the tokens of that in-flight execution are not added. Earlier executions keep theirs.
  • toolUsePromptTokenCount is not broken out. It is inside totalTokens, so on Vertex the breakdown fields do not sum to the total.
  • Calls whose response names no model are counted in the totals only.

@QuentinBisson
QuentinBisson force-pushed the feat/a2a-usage-total-v2 branch from 90d57f0 to c8981c7 Compare August 17, 2026 13:40
@github-actions github-actions Bot added the enhancement New feature or request label Aug 17, 2026
@QuentinBisson
QuentinBisson marked this pull request as ready for review August 17, 2026 14:00
@QuentinBisson
QuentinBisson requested review from a team and supreme-gg-gg as code owners August 17, 2026 14:00
Copilot AI lite review requested due to automatic review settings August 17, 2026 14:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@QuentinBisson

QuentinBisson commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor Author

Rebased onto main (bc92b68). Re-verified locally after the rebase: go build, go vet, and the touched Go/Python test packages pass.

The three remaining red checks are not from this branch, they reproduce identically on main at the same commit (run 32034286380):

  • go-unit-tests: the go/api/v1alpha2 and go/api/v1alpha3 CEL tests fail in setup, not in assertions. setup-envtest cannot write the 1.36.2-linux-amd64 assets ("unable to create file kubectl/etcd from archive to disk"), so the control plane never starts. Looks like parallel test packages racing on the same go/bin/k8s store.
  • upgrade-tests (prev-stable) / rolling-upgrade-tests (prev-stable): UPGRADE FAILED: context deadline exceeded in TestUpgrade/upgrade_with_helm.

@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Aug 18, 2026
…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>
@QuentinBisson
QuentinBisson force-pushed the feat/a2a-usage-total-v2 branch from cbe1b02 to 013356f Compare August 26, 2026 14:19
@QuentinBisson

Copy link
Copy Markdown
Contributor Author

@supreme-gg-gg this is clean against main and green. Could you take a look when you have a moment?

@EItanya EItanya left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 AI-generated review.

Reviewed 013356fc. I found one correctness issue and a simpler way to integrate this with ADK:

  1. 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 totalTokenCount stays at 120. Mixing calls with and without reported totals also undercounts. Reproduced both cases in both languages.

  2. Use Go's existing plugin.Config.OnEventCallback. It observes ADK events before A2A conversion, including the paused tool-call event. Registering it through RunnerConfig.PluginConfig lets 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.

  3. 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>
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Sep 11, 2026
…l-v2

Signed-off-by: QuentinBisson <quentin@giantswarm.io>

# Conflicts:
#	go/adk/pkg/a2a/executor.go
@EItanya

EItanya commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

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 metadata["kagent_usage_total"] is a valid use of A2A metadata. An extension would still use metadata, while making the schema and behavior discoverable and versioned. I'd suggest:

  • Define a versioned kagent usage extension, advertise it in AgentCard.capabilities.extensions with required: false, and support A2A's client opt-in mechanism.
  • Document the cumulative task-lifetime semantics: consumers replace earlier totals with the latest value, plus the behavior on resume, failure, and cancellation.
  • Prefer provider-neutral fields such as inputTokens and outputTokens over making Google's UsageMetadata the public contract. If model attribution is included, associate counts with each model rather than attaching the last model's name to totals across multiple models.

The aggregation work here would still be useful; this would formalize its public output. A2A extension guidance: https://a2a-protocol.org/latest/topics/extensions/

@QuentinBisson

Copy link
Copy Markdown
Contributor Author

Agreed, let's make it an extension, following the same pattern as the HITL one: a versioned kagent.dev URI, advertised on the Agent Card as optional, and a doc page describing the schema and the cumulative task-lifetime semantics. Provider-neutral field names, and counts attributed per model rather than the last model's name on a combined total.

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 kagent_usage_metadata keys move inside the extension too, since they carry strictly more information than the aggregate. That gives one way to read token usage instead of three, which is the better end state, but it moves every current reader at once: the UI, remote A2A sub-agent calls, and anything reading the 0.x adk_usage_metadata name.

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
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Sep 25, 2026
…l-v2

# Conflicts:
#	go/adk/pkg/a2a/executor.go
#	python/packages/kagent-adk/src/kagent/adk/_agent_executor.py
Comment thread docs/architecture/a2a-metadata.md Outdated
| `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 |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think the earlier comments said we want this as an extension instead of just metadata? Otherwise this is looking good

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@QuentinBisson
QuentinBisson force-pushed the feat/a2a-usage-total-v2 branch from e088188 to f625572 Compare October 13, 2026 21:51
@QuentinBisson

Copy link
Copy Markdown
Contributor Author

@EItanya I fixed go-lint and I'm fixing the DCO

EItanya
EItanya previously approved these changes Oct 14, 2026
Signed-off-by: QuentinBisson <quentin@giantswarm.io>
@QuentinBisson
QuentinBisson force-pushed the feat/a2a-usage-total-v2 branch from 003b4d3 to d437a13 Compare October 14, 2026 19:24
EItanya
EItanya previously approved these changes Oct 14, 2026
@QuentinBisson

Copy link
Copy Markdown
Contributor Author

Thanks for checking this PR :D

@EItanya

EItanya commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

I'm trying haha, such bad luck!

Comment thread ui/src/api/chat/a2aGrpcChatClient.ts Outdated
* The usage extension rides on the same header.
*/
[HITL_EXTENSION_HEADER]: HITL_EXTENSION_URI,
[HITL_EXTENSION_HEADER]: `${HITL_EXTENSION_URI},${USAGE_EXTENSION_URI}`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

what reads this payload, ui only sends the header

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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
@github-actions github-actions Bot added enhancement New feature or request and removed enhancement New feature or request labels Oct 15, 2026
…t read it

Signed-off-by: QuentinBisson <quentin@giantswarm.io>
@QuentinBisson
QuentinBisson force-pushed the feat/a2a-usage-total-v2 branch from 4100b9c to 0c4b2ee Compare October 15, 2026 21:43
EItanya
EItanya previously approved these changes Oct 15, 2026
@EItanya
EItanya enabled auto-merge October 15, 2026 22:05
@EItanya
EItanya added this pull request to the merge queue Oct 15, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Oct 15, 2026
…l-v2

# Conflicts:
#	go/core/internal/translator/kagent/compiler.go
@EItanya
EItanya enabled auto-merge October 16, 2026 15:58
@EItanya
EItanya added this pull request to the merge queue Oct 16, 2026
Merged via the queue into kagent-dev:main with commit 9097d0d Oct 16, 2026
29 checks passed
@QuentinBisson
QuentinBisson deleted the feat/a2a-usage-total-v2 branch October 18, 2026 12:13
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] A2A: report task token usage on the terminal status update

5 participants