Problem
Any turn that ends via a tool result with endTurn: true never fires usage:updated or inference:completed, so UsageTracker, CallLedger, and any TUI/webUI display driven by those events silently under-reports — or entirely misses — real usage for that turn. This isn't provider-specific; it reproduces on any agent that replies through a channel-style tool (e.g. connectome-host's channels module skip_reply/reply tools set endTurn: true on essentially every real turn).
Root cause
In framework.ts's driveStream, when a tool result has endTurn: true (shouldEndTurn), the framework takes a short-circuit exit: it calls stream.cancel() directly and emits inference:turn_ended — bypassing the stream's natural for await completion.
if (shouldEndTurn) {
...
this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' });
this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name });
}
usageTracker.onInferenceCompleted() — the only call site that emits usage:updated — lives exclusively inside the case 'complete': branch of driveStream's stream-event switch, reached only on a normal stream completion. The endTurn shortcut never reaches it, so for these turns:
usage:updated never fires (no cumulative usage tracking)
inference:completed never fires (no per-turn completion trace with tokenUsage)
Reproduction
Minimal repro via connectome-host (a downstream consumer), headless mode, single turn:
{"type":"text","content":"Say the word \"test\" and nothing else."}
Observed trace event sequence (types only):
lifecycle(ready), process:received, message:added, gate:decision, process:completed,
inference:started, inference:usage, inference:tool_calls_yielded, tool:started, tool:completed,
process:received, inference:turn_ended, process:completed, lifecycle(idle)
Note: inference:usage (a separate, per-round trace fired from Membrane's own stream.emit({type:'usage',...}), case 'usage': in driveStream) does fire and does carry tokenUsage.cacheCreation/tokenUsage.cacheRead correctly — so per-round cache/usage data is available on the wire, it's just never accumulated or surfaced through usage:updated/inference:completed when the turn ends via endTurn.
The triggering tool result in the repro:
{"type":"tool-result","callId":"toolu_...","result":{"success":true,"endTurn":true,"data":{"skipped":true,"note":"Turn ended; nothing sent."}}}
Impact
UsageTracker totals (and anything reading them, e.g. connectome-host's CallLedger/webUI cost display) undercount for any agent whose turns typically end via a channel/reply tool's endTurn — which in practice is most channel-routed agents' normal operation, not an edge case.
- Cost/cache-monitoring dashboards built on
usage:updated are unreliable for these agents even though the underlying per-round data (inference:usage) is correct and available.
Suggested fix direction
Call usageTracker.onInferenceCompleted() (and/or emit inference:completed) from the shouldEndTurn branch too, using the same per-round usage accumulation that already feeds inference:usage, rather than only from the natural-completion case 'complete': path.
Environment
@animalabs/agent-framework 0.6.8
- Reproduced via
connectome-host (anima-research/connectome-host), headless mode, provider: openrouter, model: anthropic/claude-sonnet-5 — but the bypass is provider-agnostic; it's triggered by the endTurn tool-result flag, not by any provider-specific behavior.
Problem
Any turn that ends via a tool result with
endTurn: truenever firesusage:updatedorinference:completed, soUsageTracker,CallLedger, and any TUI/webUI display driven by those events silently under-reports — or entirely misses — real usage for that turn. This isn't provider-specific; it reproduces on any agent that replies through a channel-style tool (e.g. connectome-host'schannelsmoduleskip_reply/reply tools setendTurn: trueon essentially every real turn).Root cause
In
framework.ts'sdriveStream, when a tool result hasendTurn: true(shouldEndTurn), the framework takes a short-circuit exit: it callsstream.cancel()directly and emitsinference:turn_ended— bypassing the stream's naturalfor awaitcompletion.usageTracker.onInferenceCompleted()— the only call site that emitsusage:updated— lives exclusively inside thecase 'complete':branch ofdriveStream's stream-event switch, reached only on a normal stream completion. TheendTurnshortcut never reaches it, so for these turns:usage:updatednever fires (no cumulative usage tracking)inference:completednever fires (no per-turn completion trace withtokenUsage)Reproduction
Minimal repro via connectome-host (a downstream consumer), headless mode, single turn:
{"type":"text","content":"Say the word \"test\" and nothing else."}Observed trace event sequence (types only):
Note:
inference:usage(a separate, per-round trace fired from Membrane's ownstream.emit({type:'usage',...}),case 'usage':indriveStream) does fire and does carrytokenUsage.cacheCreation/tokenUsage.cacheReadcorrectly — so per-round cache/usage data is available on the wire, it's just never accumulated or surfaced throughusage:updated/inference:completedwhen the turn ends viaendTurn.The triggering tool result in the repro:
{"type":"tool-result","callId":"toolu_...","result":{"success":true,"endTurn":true,"data":{"skipped":true,"note":"Turn ended; nothing sent."}}}Impact
UsageTrackertotals (and anything reading them, e.g. connectome-host'sCallLedger/webUI cost display) undercount for any agent whose turns typically end via a channel/reply tool'sendTurn— which in practice is most channel-routed agents' normal operation, not an edge case.usage:updatedare unreliable for these agents even though the underlying per-round data (inference:usage) is correct and available.Suggested fix direction
Call
usageTracker.onInferenceCompleted()(and/or emitinference:completed) from theshouldEndTurnbranch too, using the same per-round usage accumulation that already feedsinference:usage, rather than only from the natural-completioncase 'complete':path.Environment
@animalabs/agent-framework0.6.8connectome-host(anima-research/connectome-host), headless mode,provider: openrouter,model: anthropic/claude-sonnet-5— but the bypass is provider-agnostic; it's triggered by theendTurntool-result flag, not by any provider-specific behavior.