Repository navigation
feat(harness): write chat and execute_tool spans from the runtime - #3121
Conversation
The Claude and Codex drivers report each model call and tool call, and the executor records them under invoke_agent. The native child resource sets kagent.genai.producer=adapter so the collector skips its own chat and tool spans. Signed-off-by: Kristy Tian <[email protected]>
krisztianfekete
left a comment
There was a problem hiding this comment.
Thank you, this is really awesome work! I tested it locally with Claude (will do a run with Codex later) and left some comments!
| if t.Runtime.NativeHarness() { | ||
| attributes = append(attributes, attribute.String(AttributeGenAIProducer, conv.KagentGenaiProducerAdapter)) | ||
| } |
There was a problem hiding this comment.
Nothing reads kagent.genai.producer yet. Should we ship the collector rule that uses it, or drop it?
There was a problem hiding this comment.
ResourceIdentity (L233-234) puts gen_ai.provider.name and gen_ai.request.model on the child resource, and Claude Code copies resource attributes onto every native span, so consumers with a legacy semconv fallback count every claude_code.* span as a model call (38 vs. 9 in my run). Can we keep gen_ai.* off ChildResource?
| - key: kagent.genai.producer | ||
| brief: The component that writes the GenAI model and tool spans of an agent. | ||
| note: > | ||
| Set on the resource of a native harness process. With the value `adapter`, the | ||
| collector counts the spans of the kagent harness adapter for that agent. When absent, | ||
| the harness writes its own native spans, as in older images. |
There was a problem hiding this comment.
Which collector? Codex handle_responses already has gen_ai.usage.*, so usage counts twice. Can we add a transform to collector-values.yaml that gets rid of it?
| type: | ||
| members: | ||
| - id: adapter | ||
| value: adapter | ||
| brief: The kagent harness adapter writes the model and tool spans. |
There was a problem hiding this comment.
adapter clashes with the internal/adapter package. Maybe runtime?
There was a problem hiding this comment.
yeah, I changed from runtime to adapter, because I found runtime a bit confusing since we also got kagent.runtime as enum. I can change it back to runtime
| if failed { | ||
| span.SetStatus(codes.Error, errorTypeModel) | ||
| } | ||
| span.End(trace.WithTimestamp(event.End)) | ||
| return nil |
There was a problem hiding this comment.
Can we also record gen_ai.client.inference.duration and the usage counters here, as proposed in #2943?
| if invocation == nil && !flushed { | ||
| flushed = true | ||
| if err := tracing.InvocationFromContext(ctx).Flush(ctx); err != nil { | ||
| a2alog.Error(ctx, "failed to flush runtime spans", err) | ||
| } |
There was a problem hiding this comment.
Can TestE2ECompletedChatFlushesTraces assert one chat span with the mock token counts before suspension?
| failed := event.StopReason == tracing.FinishReasonError && !event.Canceled | ||
| if event.StopReason != "" { | ||
| attributes = append(attributes, conv.GenAIResponseFinishReasonsKey.StringSlice([]string{event.StopReason})) | ||
| } |
There was a problem hiding this comment.
Chat spans have raw end_turn/tool_use, but output messages use stop/tool_call. Let's normalize these?
| - key: kagent.model_call.usage_partial | ||
| brief: Whether the call ended without final usage, so its output tokens are a lower bound. | ||
| note: > |
There was a problem hiding this comment.
Upstream now prefers "inference". Maybe kagent.inference.usage_partial?
| - ref: gen_ai.usage.input_tokens | ||
| requirement_level: opt_in | ||
| note: Never set here. Token usage is on the chat spans. | ||
| - ref: gen_ai.usage.output_tokens | ||
| requirement_level: opt_in |
There was a problem hiding this comment.
Can we drop these opt_in overrides and state "usage and finish reasons are only on inference spans" once in the telemetry.md Usage section?
| // holdSnapshot keeps a subagent call until a newer message replaces it, so it carries the latest usage. | ||
| // Subagent calls arrive only as assistant snapshots whose usage predates the final delta. | ||
| func (p *parser) holdSnapshot(parent, messageID string, call runtime.ModelCall, emit func(Event) error) error { | ||
| // Subagent calls carry no timing, so the span runs from the first to the last snapshot of one message. |
There was a problem hiding this comment.
Subagent lines have timestamps. Can we use it instead of the time we parse the line?
| // Usage can arrive after an approval pause; keep the call under the segment it ran in. | ||
| if s.paused != nil && !event.Start.IsZero() && event.Start.Before(owner.began) { | ||
| owner = *s.paused | ||
| } | ||
| span := owner.start(strings.TrimSpace("chat "+event.RequestModel), trace.SpanKindClient, event.Start, attributes...) |
There was a problem hiding this comment.
Can we nest subagent calls under execute_tool Agent?
| if s.tools == nil { | ||
| s.tools = map[string]pendingTool{} | ||
| } | ||
| s.tools[event.ID] = pendingTool{id: event.ID, name: event.Name, start: time.Now(), owner: s.owner} |
There was a problem hiding this comment.
event.Arguments is dropped here. With content capture on, can we record gen_ai.tool.call.arguments and gen_ai.tool.call.result on the execute_tool span, under the same capture budget as invoke_agent?
| }{ | ||
| {conv.GenAIProviderNameKey, event.Provider}, | ||
| {conv.GenAIRequestModelKey, event.RequestModel}, | ||
| {conv.GenAIResponseModelKey, event.ResponseModel}, |
There was a problem hiding this comment.
Can we add gen_ai.response.id here from the Claude message id (and keep its request_id)? It lets backends join a chat span with provider logs or a proxy span of the same request, and dedupe.
| if t.Runtime.NativeHarness() { | ||
| attributes = append(attributes, attribute.String(AttributeGenAIProducer, conv.KagentGenaiProducerAdapter)) | ||
| } |
There was a problem hiding this comment.
ResourceIdentity (L233-234) puts gen_ai.provider.name and gen_ai.request.model on the child resource, and Claude Code copies resource attributes onto every native span, so consumers with a legacy semconv fallback count every claude_code.* span as a model call (38 vs. 9 in my run). Can we keep gen_ai.* off ChildResource?
| func (tool pendingTool) record(end time.Time, errorType string) { | ||
| span := tool.owner.start("execute_tool "+tool.name, trace.SpanKindInternal, tool.start, | ||
| attribute.String(tracing.AttributeOperationName, conv.GenAIOperationNameExecuteTool), | ||
| conv.GenAIToolNameKey.String(tool.name), |
There was a problem hiding this comment.
Claude names MCP tools mcp__<server>__<tool>, while adk-go reports the actual MCP tool name for the same tool. Should gen_ai.tool.name be the MCP tool name, with the server in its own attribute, so tools group the same across runtimes?
dhaifley
left a comment
There was a problem hiding this comment.
This is looking great. Nice work. I've added a few comments that I found running some AI tests.
| return runtime.Outcome{}, err | ||
| } | ||
| // The next call's span must not include the human wait for this input. | ||
| p.session.translator.lastCallEnd = p.session.translator.now() |
There was a problem hiding this comment.
If I'm reading the captures right, Codex reports a call's usage only after that call's tools finish. In codex-tools.jsonl each thread/tokenUsage/updated comes right after the tool's item/completed. For an approval-gated tool, that puts the usage of the call that asked for the approval after the resume. This line moves lastCallEnd to the resume time before that frame is read, so the call starts at the resume, and I think the s.paused check at tracing.go:206 can never fire for Codex. I tried it with a scratch test that runs the real ProcessDriver under a2a.New against a fake app server (item/started, elicitation, usage, then after the approval item/completed, usage, turn/completed). The first segment's trace held only invoke_agent. Both chat spans landed in the resumed trace, and the first one started at the resume and lasted 54 microseconds. So the segment that made the call would show zero model calls, and its tokens would sit in another trace, which seems to go against the resume_origin note that a link does not move token usage. TestModelCallReportedAfterThePauseStaysInItsSegment uses Claude-style start times, and the approval scripts in process_test.go send no token usage, so I don't think anything covers this yet. If the span ended at the first item/started, as Krisztian suggested on events.go:144, I think that would also fix this, as long as the reset here doesn't move the start past that end. Another option might be to remember when the turn paused, and give the first usage after the resume the pre-pause start and the pause time as its end.
| // Cancel resolves the outstanding request when possible, interrupts the Codex | ||
| // turn, and always reaps the App Server process. | ||
| func (p *pendingTurn) Cancel(ctx context.Context) error { | ||
| func (p *pendingTurn) Cancel(ctx context.Context, _ runtime.ModelCallSink) error { |
There was a problem hiding this comment.
The new doc at runtime.go:115 says Cancel emits every ModelCall the harness reported before it returns, but this Cancel ignores the sink. Frames already buffered in client.frames are never read before stopSession reaps the process, and rpcClient.call skips notifications while it waits for the turn/interrupt reply (rpc.go:110-116). In a scratch test with a parked approval, one usage frame buffered and one more sent between reading the interrupt and replying to it, Cancel recorded 0 of 2 calls. Together with the comment on line 384, I think a cancel at an approval would always lose the tokens of the call that asked for it, while the description says the runtime keeps the calls the harness reported before the cancel. The ctx.Done() path in consume (lines 200 to 215) has the same gap, with 0 of 1 calls kept in the same kind of test. Would it make sense for Cancel to drain the frames through the sink, the way the Claude stopSession now does, and for call to pass the notifications it skips to the sink while it waits?
| if subagent { | ||
| return nil | ||
| } | ||
| if err := p.flushOpenCall(emit); err != nil { |
There was a problem hiding this comment.
If I'm reading Claude Code's retry paths right, an attempt it gives up on mid-stream ends with message_stop and no message_delta. That covers a mid-stream 529, a stale connection, and the fallback to non-streaming. I looked at a newer 2.1.296 build, not the pinned 2.1.285. The message_stop case at line 283 leaves the open call alone, so it stays open until this line closes it at the next message_start. With a fake clock through parseLine, a retry after an 8 s backoff gave the abandoned attempt an end 10 s after its start, error as its finish reason, and the full prompt's 15002 input tokens. The retry then reported the same 15002 again. The same thing happened when Claude Code finalizes a partial response and carries on with a tool. There the open call stayed open across a 30s tool run and was then marked as a failed call in a turn that succeeded. So I think a successful turn could show a failed chat span with the wrong duration, and the summed input tokens would run one prompt over Claude's own result.usage per retry. Would closing the open call at a message_stop that had no message_delta fix most of this? Whether the abandoned attempt should keep its input tokens probably depends on whether it is billed, which I don't know.
| call := p.openCall | ||
| p.openCall = nil | ||
| p.openUsage.merge(event.Usage).apply(call) | ||
| call.StopReason, call.End = event.Delta.StopReason, p.now() |
There was a problem hiding this comment.
The start and end here come from p.now() when the line is parsed, not when Claude printed it. While a turn is parked for an approval, the parser goroutine is blocked on an earlier emit, so anything Claude has already printed waits in the pipe until the resume. I tried a fake Claude that prints one message with two tool_use blocks, the first one gated, and then message_delta, and approved after 1.5s. The chat span came out 1.52s long, with its end 1.50s after the delta line was printed, in 3 of 3 runs. Because the start predates the pause, tracing.go:206 keeps the span in the first segment, so it ends after its invoke_agent parent, which could be hours later for a slow approval. A single-tool message seemed fine, since its delta is parsed before the park. I don't think the subagent timestamp comment covers this, because main-thread stream_event lines carry no timestamp. Maybe a reader that stamps each line when it arrives, ahead of the emit, would fix it? That would matter more if the inference duration metric lands.
| // Claude Code writes "<synthetic>" messages itself, without a model call. | ||
| unstreamed := message.ID != "" && !streamed && message.Model != "<synthetic>" | ||
| if unstreamed { | ||
| call := runtime.ModelCall{ResponseModel: message.Model, StopReason: message.StopReason, UsagePartial: true} |
There was a problem hiding this comment.
A main-thread message that was never streamed also lands here, with parent "". I think that is what Claude Code prints when it falls back to non-streaming mode. Its usage is final, but it is marked usage_partial, and its start equals its end, since both come from the parse time. It is counted once, so the token totals are right. If I'm reading Claude Code right, it also falls back on every call when the streaming endpoint returns 404, so behind a gateway like that every main-thread call would be partial and zero-length. The registry note names only calls cut short and subagent calls. Would it make sense to treat an unstreamed message with parent "" as a complete call, and keep UsagePartial for the subagent snapshots?
Signed-off-by: Kristy Tian <[email protected]>
Claude Agent and Codex spawnAgent subagents write their chat and tool spans under the parent tool span. Signed-off-by: Kristy Tian <[email protected]>
Model call duration and token usage, tool duration, and model and tool calls per invocation, following the GenAI conventions. Codex call times are estimated per thread, so subagent calls get durations too. Signed-off-by: Kristy Tian <[email protected]>
One chat span covers a call and its retries, with kagent.inference.retry.count. Codex error notifications give failed calls, and Claude retried or cut-off attempts no longer show as separate failed calls. Signed-off-by: Kristy Tian <[email protected]>
…enai-spans Signed-off-by: Kristy Tian <[email protected]>
Substrate restores every session from one snapshot, so metrics built at startup made all sessions one writer and their totals overwrote each other. Signed-off-by: Kristy Tian <[email protected]>
Signed-off-by: Kristy Tian <[email protected]>
d89e3d2 to
e31cb1f
Compare
krisztianfekete
left a comment
There was a problem hiding this comment.
This is great and it's very close to get merged! Did some testing with Codex as well and I couldn't find any issues. Added some smaller comments on Claude Code as currently call durations seems to be shorter, and subagent calls are undercounted.
| p.openCallDone = false | ||
| p.streamedMessages[event.Message.ID] = struct{}{} | ||
| p.openCall = &runtime.ModelCall{ResponseModel: event.Message.Model, ResponseID: event.Message.ID, Start: p.now()} | ||
| p.openUsage = event.Message.Usage | ||
| event.Message.Usage.apply(p.openCall) |
There was a problem hiding this comment.
The span starts at message_start, so it leaves out time to first token. On my local kind cluster it started exactly native ttft_ms late on every call I ran (692/696/520/529ms), and model time came out 36% low (3.998s vs. 6.255s). Can it start at the status: requesting line Claude Code prints before each request?
| lineTime := p.lineTime(envelope.Timestamp) | ||
| // A prompt or tool result: that thread's next model call starts here. | ||
| p.callStart[envelope.ParentToolUseID] = lineTime | ||
| if envelope.ParentToolUseID == "" && p.openCall != nil { | ||
| p.openCallDone = true | ||
| } |
There was a problem hiding this comment.
callStart already tracks when the next mai -thread call starts, but only failed and retried calls use it. Should normal calls use it too, if status: requesting doesn't work out?
| // A Codex call from a thread not known yet has no duration, so it adds only its tokens. | ||
| if event.End.After(event.Start) { | ||
| m.inferenceDuration.Record(ctx, event.End.Sub(event.Start).Seconds(), withError(attributes, errorType)) | ||
| } | ||
| // A partial call's input count is exact, but its output count is only a lower bound. | ||
| perCall := metric.WithAttributes(attributes...) |
There was a problem hiding this comment.
This metric copies the span duration, so Claude's gen_ai.client.inference.duration is low by the same amount. The conventions measure it from when the request is sent.
| // token count is final. A subagent's count can still grow, so it stays partial. | ||
| call := runtime.ModelCall{ResponseModel: message.Model, ResponseID: message.ID, RequestID: requestID, | ||
| StopReason: claudeFinishReason(message.StopReason), UsagePartial: parent != ""} | ||
| message.Usage.apply(&call) | ||
| // A main-thread stream cut off without an end is retried without streaming. | ||
| if parent == "" && p.openCall != nil { | ||
| p.retry() | ||
| } |
There was a problem hiding this comment.
Did some local testing:
Partial subagent calls only get the 1 to 4 output tokens from the snapshot, against 205 to 698 in native in my testing. On kind the turn came out 34% low in the foreground (792 vs. 1200) and 66% low in the background (706 vs. 2100), and Claude picks background mode on its own.
| if err := p.flushCalls(emit); err != nil { | ||
| return err | ||
| } |
There was a problem hiding this comment.
The result line's modelUsage has exact per-model totals, subagents included. Can we reconcile the usage counters against it here and keep the spans marked partial?
| {m.input, event.InputTokens}, | ||
| {m.output, event.OutputTokens}, | ||
| {m.cacheRead, event.CacheReadTokens}, | ||
| {m.cacheWrite, event.CacheWriteTokens}, | ||
| {m.reasoning, event.ReasoningOutputTokens}, |
There was a problem hiding this comment.
The counters could be exact if the Claude driver emits result.modelUsage minus what its calls already reported as a usage-only event, recorded on the counters only. Spans and the per-call histogram stay partial. Wdyt?
There was a problem hiding this comment.
yes, agree. modeUsage isn't per turn. So a turn could still report earlier turn tokens. So before each turn we read the total Claude saved in its session file (the cost-state line it restores on resume). At the end of the turn we add modelUsage − saved total − what our calls already reported
On kind testing: 15 sessions (5 per case) output-token counters vs native Claude counts
- foreground subagent plus a resumed turn: counter was 27–34% low, now exact in 5 of 5
- background subagent: counter was 38–50% low, now exact in 5 of 5
- subagent plus an approval pause: counter was 18–24% low, now exact in 5 of 5
- plain and tool turns: still exact
…okens from session totals Signed-off-by: Kristy Tian <[email protected]>
krisztianfekete
left a comment
There was a problem hiding this comment.
Thanks @xytian315, LGTM!
The Claude Code and Codex harness runtimes now write a GenAI
chatspan for each model call. They also writean
execute_toolspan for each tool call. Both spans are children ofinvoke_agent. Each driver turns itsharness output into common runtime events. The shared executor writes the spans and metrics from these events.
The runtime marks the harness process with
kagent.genai.producer=runtime. A trace backend can use this markerto skip the native spans of the same process. The Weaver registry defines the marker, the spans and the metrics.
Special cases
stops gets
kagent.inference.usage_partial=trueand no error status.tool call gets no span.
error.typeunfinished. A failed call gets the harness error type, such as model_not_found, or model_error when the harness gives none.Known limits
report. The span can include tool time.
Testing
except for the known limits. Approval, cancel, a killed Claude Code process and a bad model name also pass.