Skip to content

Commit 1fb8bc9

Browse files
committed
loop: emit per-turn Usage event for a live cost/usage HUD (M4)
Dispatch a durable EventUsage after each turn with the turn's token usage and the running session total, so an embedder can render a cost/usage HUD. Add the hooks.UsagePayload + public topos.EventUsage constant.
1 parent 59bd6c9 commit 1fb8bc9

4 files changed

Lines changed: 74 additions & 2 deletions

File tree

harness/hooks/events.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,21 @@ type AssistantMessagePayload struct {
132132
Turn int `json:"turn"`
133133
}
134134

135+
// EventUsage carries the running token usage after a turn completes, so an
136+
// embedder can render a live cost/usage HUD. Durable (one per turn).
137+
const EventUsage EventName = "Usage"
138+
139+
// UsagePayload is the versioned payload for EventUsage: the just-completed
140+
// turn's usage and the session's running total.
141+
type UsagePayload struct {
142+
Version string `json:"version"`
143+
SessionID string `json:"session_id"`
144+
AgentID string `json:"agent_id"`
145+
Turn int `json:"turn"`
146+
TurnUsage models.Usage `json:"turn_usage"`
147+
Total models.Usage `json:"total"`
148+
}
149+
135150
// TextDeltaPayload is the versioned payload for EventTextDelta: one streamed
136151
// fragment of assistant text within a turn. SessionID and AgentID let a consumer
137152
// route the fragment to the right transcript and lineage node; Turn is the

runtime/loop/loop.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,8 +305,19 @@ func Run(ctx context.Context, cfg Config) (*Result, error) {
305305
}
306306
_ = stream.Close()
307307

308-
// Accumulate usage.
308+
// Accumulate usage and emit it so embedders can render a live cost/usage
309+
// HUD (durable: one per turn, present on reattach).
309310
result.TotalUsage.Add(turnUsage)
311+
if turnUsage != (models.Usage{}) {
312+
cfg.Bus.Dispatch(hooks.EventUsage, &hooks.UsagePayload{
313+
Version: "1",
314+
SessionID: cfg.SessionID,
315+
AgentID: cfg.AgentID,
316+
Turn: iter + 1,
317+
TurnUsage: turnUsage,
318+
Total: result.TotalUsage,
319+
})
320+
}
310321

311322
finalStopReason = stopReason
312323

runtime/loop/loop_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -762,6 +762,50 @@ func TestLoopEmitsTokenDeltasEphemerally(t *testing.T) {
762762
}
763763
}
764764

765+
// TestLoopEmitsUsageEvent asserts the loop dispatches a durable Usage event per
766+
// turn carrying the turn and running-total token usage.
767+
func TestLoopEmitsUsageEvent(t *testing.T) {
768+
p := local.New()
769+
ctx := context.Background()
770+
sb, _ := p.Create(ctx, sandbox.CreateOptions{})
771+
defer p.Destroy(ctx, sb.ID) //nolint:errcheck
772+
773+
bus := hooks.New()
774+
var totals []models.Usage
775+
bus.Register("usage-sink", []hooks.EventName{hooks.EventUsage}, func(_ hooks.EventName, payload any) hooks.Decision {
776+
if p, ok := payload.(*hooks.UsagePayload); ok {
777+
totals = append(totals, p.Total)
778+
}
779+
return hooks.Allow()
780+
})
781+
782+
cfg := loop.Config{
783+
Model: &fakeModel{prompt: "hi"}, Sandbox: p, SandboxID: sb.ID,
784+
Tools: tools.Builtins(), Bus: bus, SessionID: "usage-test", UserPrompt: "hi",
785+
}
786+
if _, err := loop.Run(ctx, cfg); err != nil {
787+
t.Fatalf("Run: %v", err)
788+
}
789+
if len(totals) == 0 {
790+
t.Fatal("no Usage events emitted")
791+
}
792+
// The running total must be non-decreasing and end positive.
793+
last := totals[len(totals)-1]
794+
if last.InputTokens == 0 && last.OutputTokens == 0 {
795+
t.Fatalf("final usage total is zero: %+v", last)
796+
}
797+
// Usage events are durable (recorded in the bus log).
798+
durable := false
799+
for _, e := range bus.EventLog() {
800+
if e.EventName == hooks.EventUsage {
801+
durable = true
802+
}
803+
}
804+
if !durable {
805+
t.Fatal("Usage event should be durable (in the event log)")
806+
}
807+
}
808+
765809
// hasErrorResultContaining reports whether the transcript holds a tool-role
766810
// message with an error result whose content contains sub.
767811
func hasErrorResultContaining(transcript []models.Message, sub string) bool {

topos.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,9 @@ const (
165165
// EventTextDelta carries one streamed fragment of assistant text (a token or
166166
// few). An observer receives many of these per turn for token-by-token
167167
// rendering, followed by the assembled EventAssistantMessage for the turn.
168-
EventTextDelta = "TextDelta"
168+
EventTextDelta = "TextDelta"
169+
// EventUsage carries running token usage after each turn (for a cost/usage HUD).
170+
EventUsage = "Usage"
169171
EventPostToolUse = "PostToolUse"
170172
EventSubagentStart = "SubagentStart"
171173
EventSubagentStop = "SubagentStop"

0 commit comments

Comments
 (0)