Skip to content

Commit fe7fd5d

Browse files
localai-botmudlerclaude
authored
feat: expose cumulative token usage per ExecuteTools run (#55)
* feat(agent): expose AgentState.Background for spawned background agents Add an exported Background flag on AgentState, set true for background spawns (spawn_agent background=true) and false for foreground ones. This lets embedders tell unattended background work apart from a foreground sub-agent whose result is consumed inline — e.g. to auto-notify on completion only for background agents. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: add per-run token usage accumulator (CumulativeUsage) Adds a counting LLM decorator and a CumulativeUsage field on Status so a full ExecuteTools run's token usage can be summed and exposed. Preserves StreamingLLM so wrapping does not disable streaming. * refactor: harden counting stream forwarder and document usage limits Buffer the forwarded stream channel and make the send context-aware to match the client convention and prevent goroutine leaks. Document that streaming-path usage is unpopulated by the bundled clients today. * feat: expose cumulative token usage on the ExecuteTools result Wraps the run LLM in a counting decorator and stamps the summed usage onto the returned fragment's Status.CumulativeUsage via a deferred named return, covering all exit paths. Each sub-agent run reports its own total. --------- Co-authored-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 81ce500 commit fe7fd5d

5 files changed

Lines changed: 252 additions & 1 deletion

File tree

fragment.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ type InjectedMessage struct {
3333

3434
type Status struct {
3535
LastUsage LLMUsage // Track token usage from the last LLM call
36+
CumulativeUsage LLMUsage // Sum of token usage across every LLM call in the run
3637
Iterations int
3738
ToolsCalled Tools
3839
ToolResults []ToolStatus

tools.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1143,7 +1143,7 @@ func askWithStreaming(ctx context.Context, llm LLM, f Fragment, streamCB StreamC
11431143

11441144
// ExecuteTools runs a fragment through an LLM, and executes Tools. It returns a new fragment with the tool result at the end
11451145
// The result is guaranteed that can be called afterwards with llm.Ask() to explain the result to the user.
1146-
func ExecuteTools(llm LLM, f Fragment, opts ...Option) (Fragment, error) {
1146+
func ExecuteTools(llm LLM, f Fragment, opts ...Option) (result Fragment, retErr error) {
11471147
o := defaultOptions()
11481148
o.Apply(opts...)
11491149

@@ -1206,6 +1206,18 @@ func ExecuteTools(llm LLM, f Fragment, opts ...Option) (Fragment, error) {
12061206
o.messageInjectionChan = make(chan openai.ChatCompletionMessage, 16)
12071207
}
12081208

1209+
// Accumulate token usage across every LLM call in this run and stamp the
1210+
// total onto the returned fragment, so callers (and sub-agent completion
1211+
// callbacks) can report cumulative usage. The sub-agent fallback LLM
1212+
// (agentLLM, captured above) stays unwrapped so its usage is not folded in.
1213+
runUsage := &usageCounter{}
1214+
llm = newCountingLLM(llm, runUsage)
1215+
defer func() {
1216+
if result.Status != nil {
1217+
result.Status.CumulativeUsage = runUsage.snapshot()
1218+
}
1219+
}()
1220+
12091221
// should I plan?
12101222
if o.autoPlan {
12111223
xlog.Debug("Checking if planning is needed")

tools_cumulative_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package cogito_test
2+
3+
import (
4+
. "github.com/mudler/cogito"
5+
"github.com/mudler/cogito/tests/mock"
6+
. "github.com/onsi/ginkgo/v2"
7+
. "github.com/onsi/gomega"
8+
"github.com/sashabaranov/go-openai"
9+
)
10+
11+
var _ = Describe("ExecuteTools cumulative usage", func() {
12+
It("sums token usage across every LLM call in the run", func() {
13+
mockLLM := mock.NewMockOpenAIClient()
14+
15+
// One tool round then a final text answer => >= 2 CreateChatCompletion
16+
// calls plus one Ask. Each configured call reports 100 total tokens.
17+
mockLLM.AddCreateChatCompletionFunction("search", `{"query": "test"}`)
18+
mockTool := mock.NewMockTool("search", "Search for information")
19+
mock.SetRunResult(mockTool, "Result")
20+
mockLLM.SetAskResponse("Final answer")
21+
mockLLM.SetCreateChatCompletionResponse(openai.ChatCompletionResponse{
22+
Choices: []openai.ChatCompletionChoice{
23+
{Message: openai.ChatCompletionMessage{Role: "assistant", Content: "No more tools needed."}},
24+
},
25+
})
26+
mockLLM.SetUsage(40, 60, 100)
27+
mockLLM.SetUsage(40, 60, 100)
28+
mockLLM.SetUsage(40, 60, 100)
29+
30+
fragment := NewEmptyFragment().AddMessage(UserMessageRole, "Task")
31+
result, err := ExecuteTools(mockLLM, fragment, WithTools(mockTool))
32+
Expect(err).ToNot(HaveOccurred())
33+
34+
// Expected = the total tokens of every usage entry the mock dispensed.
35+
expected := 0
36+
for i := 0; i < mockLLM.CreateChatCompletionUsageIndex; i++ {
37+
expected += mockLLM.CreateChatCompletionUsage[i].TotalTokens
38+
}
39+
for i := 0; i < mockLLM.AskUsageIndex; i++ {
40+
expected += mockLLM.AskUsage[i].TotalTokens
41+
}
42+
43+
Expect(expected).To(BeNumerically(">", 100), "test must drive at least two billed calls")
44+
Expect(result.Status.CumulativeUsage.TotalTokens).To(Equal(expected))
45+
Expect(result.Status.CumulativeUsage.TotalTokens).To(
46+
BeNumerically(">", result.Status.LastUsage.TotalTokens),
47+
"cumulative must exceed the last single call",
48+
)
49+
})
50+
})

usage_counter.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package cogito
2+
3+
import (
4+
"context"
5+
"sync/atomic"
6+
7+
"github.com/sashabaranov/go-openai"
8+
)
9+
10+
// usageCounter accumulates token usage across every LLM call routed through a
11+
// countingLLM. Safe for concurrent use (sub-agents run in their own goroutines,
12+
// each with its own counter, but streaming delivery may add from a goroutine).
13+
type usageCounter struct {
14+
prompt atomic.Int64
15+
completion atomic.Int64
16+
total atomic.Int64
17+
}
18+
19+
func (c *usageCounter) add(u LLMUsage) {
20+
c.prompt.Add(int64(u.PromptTokens))
21+
c.completion.Add(int64(u.CompletionTokens))
22+
c.total.Add(int64(u.TotalTokens))
23+
}
24+
25+
func (c *usageCounter) snapshot() LLMUsage {
26+
return LLMUsage{
27+
PromptTokens: int(c.prompt.Load()),
28+
CompletionTokens: int(c.completion.Load()),
29+
TotalTokens: int(c.total.Load()),
30+
}
31+
}
32+
33+
// countingLLM wraps an LLM, accumulating token usage from every call into
34+
// counter. CreateChatCompletion returns usage directly; Ask discards it from
35+
// its signature but records it on the returned fragment's Status.LastUsage,
36+
// which is where we read it.
37+
type countingLLM struct {
38+
LLM
39+
counter *usageCounter
40+
}
41+
42+
func (c *countingLLM) CreateChatCompletion(ctx context.Context, req openai.ChatCompletionRequest) (LLMReply, LLMUsage, error) {
43+
reply, usage, err := c.LLM.CreateChatCompletion(ctx, req)
44+
if err == nil {
45+
c.counter.add(usage)
46+
}
47+
return reply, usage, err
48+
}
49+
50+
// Ask recovers per-call usage from the returned fragment's Status.LastUsage,
51+
// which every cogito Ask implementation (and the test mock) refreshes on each
52+
// call. If a future Ask returned a fragment carrying a stale LastUsage, this
53+
// would re-add it — the assumption is that Ask always sets LastUsage fresh.
54+
func (c *countingLLM) Ask(ctx context.Context, f Fragment) (Fragment, error) {
55+
res, err := c.LLM.Ask(ctx, f)
56+
if err == nil && res.Status != nil {
57+
c.counter.add(res.Status.LastUsage)
58+
}
59+
return res, err
60+
}
61+
62+
// countingStreamingLLM preserves StreamingLLM so wrapping does not disable the
63+
// streaming code path for callers that use it. Usage is accumulated from the
64+
// StreamEventDone event's Usage field.
65+
//
66+
// NOTE: cogito's bundled clients (clients/openai_client.go, clients/localai_client.go)
67+
// do not currently populate StreamEvent.Usage on the done event, so streaming-path
68+
// token accumulation is zero in production until those clients request usage from
69+
// the API (e.g. StreamOptions{IncludeUsage: true}). The non-streaming path
70+
// (CreateChatCompletion / Ask) is fully counted.
71+
type countingStreamingLLM struct {
72+
countingLLM
73+
streaming StreamingLLM
74+
}
75+
76+
func (c *countingStreamingLLM) CreateChatCompletionStream(ctx context.Context, req openai.ChatCompletionRequest) (<-chan StreamEvent, error) {
77+
in, err := c.streaming.CreateChatCompletionStream(ctx, req)
78+
if err != nil {
79+
return nil, err
80+
}
81+
// Buffer to match the client convention (clients/openai_client.go) and make
82+
// the forward context-aware so a stopped consumer cannot leak this goroutine.
83+
out := make(chan StreamEvent, 64)
84+
go func() {
85+
defer close(out)
86+
for ev := range in {
87+
if ev.Type == StreamEventDone {
88+
c.counter.add(ev.Usage)
89+
}
90+
select {
91+
case out <- ev:
92+
case <-ctx.Done():
93+
return
94+
}
95+
}
96+
}()
97+
return out, nil
98+
}
99+
100+
// newCountingLLM wraps llm so token usage accumulates into counter. When llm is
101+
// streaming-capable, the returned wrapper is too, so the streaming path is
102+
// preserved.
103+
func newCountingLLM(llm LLM, counter *usageCounter) LLM {
104+
base := countingLLM{LLM: llm, counter: counter}
105+
if s, ok := llm.(StreamingLLM); ok {
106+
return &countingStreamingLLM{countingLLM: base, streaming: s}
107+
}
108+
return &base
109+
}

usage_counter_internal_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package cogito
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/sashabaranov/go-openai"
8+
)
9+
10+
// fakeLLM is a minimal LLM that returns a fixed usage per CreateChatCompletion
11+
// call and records a fixed usage on the fragment it returns from Ask.
12+
type fakeLLM struct {
13+
ccUsage LLMUsage
14+
askUsage LLMUsage
15+
}
16+
17+
func (f *fakeLLM) CreateChatCompletion(ctx context.Context, req openai.ChatCompletionRequest) (LLMReply, LLMUsage, error) {
18+
return LLMReply{ChatCompletionResponse: openai.ChatCompletionResponse{
19+
Choices: []openai.ChatCompletionChoice{{Message: openai.ChatCompletionMessage{Role: "assistant"}}},
20+
}}, f.ccUsage, nil
21+
}
22+
23+
func (f *fakeLLM) Ask(ctx context.Context, frag Fragment) (Fragment, error) {
24+
out := Fragment{Status: &Status{}}
25+
out.Status.LastUsage = f.askUsage
26+
return out, nil
27+
}
28+
29+
func TestCountingLLMAccumulatesBothPaths(t *testing.T) {
30+
inner := &fakeLLM{
31+
ccUsage: LLMUsage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15},
32+
askUsage: LLMUsage{PromptTokens: 7, CompletionTokens: 3, TotalTokens: 10},
33+
}
34+
counter := &usageCounter{}
35+
llm := newCountingLLM(inner, counter)
36+
37+
if _, _, err := llm.CreateChatCompletion(context.Background(), openai.ChatCompletionRequest{}); err != nil {
38+
t.Fatalf("CreateChatCompletion: %v", err)
39+
}
40+
if _, _, err := llm.CreateChatCompletion(context.Background(), openai.ChatCompletionRequest{}); err != nil {
41+
t.Fatalf("CreateChatCompletion: %v", err)
42+
}
43+
if _, err := llm.Ask(context.Background(), NewEmptyFragment()); err != nil {
44+
t.Fatalf("Ask: %v", err)
45+
}
46+
47+
got := counter.snapshot()
48+
if got.TotalTokens != 40 { // 15 + 15 + 10
49+
t.Errorf("TotalTokens = %d, want 40", got.TotalTokens)
50+
}
51+
if got.PromptTokens != 27 { // 10 + 10 + 7
52+
t.Errorf("PromptTokens = %d, want 27", got.PromptTokens)
53+
}
54+
if got.CompletionTokens != 13 { // 5 + 5 + 3
55+
t.Errorf("CompletionTokens = %d, want 13", got.CompletionTokens)
56+
}
57+
}
58+
59+
// streamingFake additionally implements StreamingLLM.
60+
type streamingFake struct{ fakeLLM }
61+
62+
func (s *streamingFake) CreateChatCompletionStream(ctx context.Context, req openai.ChatCompletionRequest) (<-chan StreamEvent, error) {
63+
ch := make(chan StreamEvent, 1)
64+
ch <- StreamEvent{Type: StreamEventDone, Usage: LLMUsage{TotalTokens: 99}}
65+
close(ch)
66+
return ch, nil
67+
}
68+
69+
func TestNewCountingLLMPreservesStreaming(t *testing.T) {
70+
plain := newCountingLLM(&fakeLLM{}, &usageCounter{})
71+
if _, ok := plain.(StreamingLLM); ok {
72+
t.Error("wrapping a non-streaming LLM must not yield a StreamingLLM")
73+
}
74+
75+
streaming := newCountingLLM(&streamingFake{}, &usageCounter{})
76+
if _, ok := streaming.(StreamingLLM); !ok {
77+
t.Error("wrapping a StreamingLLM must yield a StreamingLLM")
78+
}
79+
}

0 commit comments

Comments
 (0)