Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@ reproduced **byte for byte** through the same builders, and both suites ran gree
same CI job. See [docs/tui-verification.md](docs/tui-verification.md) and
[tuiprobe/docs/parity.md](tuiprobe/docs/parity.md).

### Provider-reported token usage in the status bar

The status bar's `tokens:` counter now shows the number the provider reported, not
`len(text)/4` over the streamed answer. The request already asked every
OpenAI-compatible endpoint for usage (`stream_options: {include_usage: true}`), but the
usage-only chunk that answers it was thrown away twice over: the SSE parser skipped every
chunk without choices, and it stopped reading at `finish_reason`, which arrives *before*
that chunk. Ollama's `prompt_eval_count`/`eval_count` were dropped the same way.

The reported numbers now reach `Agent.UsageTotal` and the counter, which falls back to the
estimate for an endpoint that reports nothing. Because usage covers the prompt too,
`tokens:` is larger than it used to be — the estimate counted streamed output only. Roadmap
entry A2; cost and pricing accounting remain open with A3.

## v0.0.7 — 2026-10-01

### Language server integration
Expand Down
14 changes: 8 additions & 6 deletions CODEBASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
- `SystemPrompt string`, `MaxSteps int`, `MaxTokens int`
- `Verbose bool`, `ShowThinking bool`
- `StreamCallbacks *types.StreamCallbacks`
- `UsageTotal types.Usage` — every provider-reported usage record summed; a call whose endpoint reports nothing adds nothing
- `ContentStreamed bool`
- **`New(provider LLMProvider) *Agent`** — defaults: MaxSteps=20, MaxTokens=4096
- **`(*Agent) AddTool(t Tool)`** — registers a tool
Expand Down Expand Up @@ -99,13 +100,13 @@

- **`OpenAIProvider`** struct (unexported fields: client, model, apiKey, baseURL)
- **`NewOpenAIProvider(apiKey, baseURL, model string) *OpenAIProvider`**
- `Chat()` — SSE streaming via `net/http` (not go-openai SDK's streaming); accumulates reasoning/content deltas and tool calls by index; 120s timeout; 64KB→256KB scanner buffer; `stream_options: {include_usage: true}`
- `Chat()` — SSE streaming via `net/http` (not go-openai SDK's streaming); accumulates reasoning/content deltas and tool calls by index; 120s timeout; 64KB→256KB scanner buffer; `stream_options: {include_usage: true}`. The parse runs to `data: [DONE]` rather than stopping at `finish_reason`, because the usage chunk that answers that option comes *after* the finish chunk with an empty `choices` array; both it and the batch body's `usage` object land in `ChatResponse.Usage` (a pointer: "not reported" is not "zero"), and a `total_tokens` the endpoint omits is derived from the two halves

### `provider_ollama.go` — Ollama Provider

- **`OllamaProvider`** struct (unexported: baseURL, model, http client)
- **`NewOllamaProvider(baseURL, model string) *OllamaProvider`** — default baseURL: `http://localhost:11434`
- `Chat()` — line-delimited JSON (not SSE); tool results mapped to `role: "user"`; `thinking` field for reasoning
- `Chat()` — line-delimited JSON (not SSE); tool results mapped to `role: "user"`; `thinking` field for reasoning; `prompt_eval_count`/`eval_count` are read into `ChatResponse.Usage` — from the stream's `{"done":true,…}` line (read before the loop breaks) and from the batch body's top level. Both are optional counters, so a server that reports neither leaves `Usage` nil instead of claiming a zero-token call
- Bounded (issue #1): `ollamaRequestTimeout` (10 min) covers a whole non-streaming request, `ollamaIdleTimeout` (2 min) covers the silence between tokens on a stream and is reset by every line, so a long generation is not killed while a stalled one fails. Both are package variables (tests shrink them), and the cancel cause names the bound that fired instead of surfacing "context canceled". A stream that ends without its `{"done":true}` line is now an error rather than the partial text it managed to send.

### `compression.go` — Context Compression
Expand Down Expand Up @@ -411,7 +412,7 @@ Each tool exports a factory function returning `agent.Tool` with `Name`, `Descri

### `messages.go`
- **`TuiStatus`** (int): `StatusIdle=0`, `StatusStreaming`, `StatusError`
- TUI messages: `StreamMsg`, `StreamDone`, `ChatMsg`, `ToolCallMsg`, `ToolResultMsg`, `LSPDiagMsg`, `modeSwitchMsg`
- TUI messages: `StreamMsg`, `UsageMsg`, `StreamDone`, `ChatMsg`, `ToolCallMsg`, `ToolResultMsg`, `LSPDiagMsg`, `modeSwitchMsg`
- **`chatMessage`** (internal): `Role`, `Content`, `ReasoningContent`, `ReasoningFolded`, `ToolCalls []ToolCallInfo`, `Streaming`, `Blocks []ContentBlock`, `TodoSnapshot []tool.TodoItem`, `Banner *welcomeInfo`

### `welcome.go` — Startup Banner
Expand All @@ -432,7 +433,7 @@ Each tool exports a factory function returning `agent.Tool` with `Name`, `Descri
- `persistSession()` — the single save path for `/exit`, `/quit` and double-Ctrl+C; reuses `currentBranch` (preserving `AllowedPaths`) instead of minting a duplicate id
- `generateSessionTitleCmd()` — "title" agent call runs as a `tea.Cmd` (15s timeout) instead of blocking the event loop
- `lspDiagCmd()` reads `lsp.DiagnosticsSnapshot()` inside a `tea.Cmd` (never on the Update goroutine) and posts `LSPDiagMsg`; it is fired from the existing spinner tick (~10 Hz) and after every tool result, which is what feeds the status-bar `errors: N` counter and `/diagnostics`
- `/compress` refuses while a run is active (compression must not touch `History` concurrently) and runs the summarizer as a `tea.Cmd` instead of inline: on the Update goroutine a stalled provider froze the whole interface, and the Ctrl+C that would cancel it could never be delivered. `beginCompress`/`finishCompress` hold the compression lifecycle under `runMu` and `beginRun` refuses while it is held, so a run and a compression never touch `History` at once; Ctrl+C cancels the summarizer, and the result message carries the compressed/no-op/cancelled/failed outcome into the status bar. `sessionTokens`/`sessionToolCalls` are maintained from the stream and reset when the transcript is swapped
- `/compress` refuses while a run is active (compression must not touch `History` concurrently) and runs the summarizer as a `tea.Cmd` instead of inline: on the Update goroutine a stalled provider froze the whole interface, and the Ctrl+C that would cancel it could never be delivered. `beginCompress`/`finishCompress` hold the compression lifecycle under `runMu` and `beginRun` refuses while it is held, so a run and a compression never touch `History` at once; Ctrl+C cancels the summarizer, and the result message carries the compressed/no-op/cancelled/failed outcome into the status bar. `sessionTokens`/`sessionToolCalls` are maintained from the stream and reset when the transcript is swapped; a `UsageMsg` from a call whose endpoint reported usage replaces the estimate charged to that call (`callTokens`) with the reported total, so the counter is provider-accurate where a provider reports and keeps the estimate where it does not
- `View()` tolerates an empty transcript with a dirty todo list, and a message role with no component renders zero rows so `msgRowCount`/`lineSrcs` stay in sync

### `view.go`
Expand Down Expand Up @@ -485,8 +486,9 @@ adding a scenario — frame, black-box scenario file, or tool release — is in
- **`ToolCall`** struct: `ID`, `Name`, `Arguments string` (raw JSON)
- **`ToolDef`** struct: `Name`, `Description`, `Parameters map[string]any`
- **`ChatRequest`** struct: `Messages []Message`, `Tools []ToolDef`, `MaxTokens int`, `Model string`, `StreamCallbacks *StreamCallbacks`
- **`ChatResponse`** struct: `Content`, `ToolCalls []ToolCall`, `ReasoningContent`
- **`StreamCallbacks`** struct: `OnReasoningDelta`, `OnTextDelta`, `OnToolCall`, `OnToolResult`, `OnStepDone`
- **`ChatResponse`** struct: `Content`, `ToolCalls []ToolCall`, `ReasoningContent`, `Usage *Usage` (nil when the endpoint reported none)
- **`Usage`** struct: `PromptTokens`, `CompletionTokens`, `TotalTokens`; `Add(v Usage) Usage` sums two records
- **`StreamCallbacks`** struct: `OnReasoningDelta`, `OnTextDelta`, `OnToolCall`, `OnToolResult`, `OnStepDone`, `OnUsage` (fired once per LLM call that reported usage)
- **`Memory`** struct: `Key`, `Value`
- **`MemoryStore`** interface: `Remember`, `Recall`, `Forget`, `List`
- **`WithPlanWriteRestriction(ctx, bool)` / `PlanWriteRestricted(ctx)`** — plan-mode write restriction travels on the run context, so concurrent sub-agents cannot flip it for each other
Expand Down
55 changes: 41 additions & 14 deletions agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,28 @@ type Agent struct {
ShowThinking bool // when true, display reasoning_content from thinking mode
StreamCallbacks *types.StreamCallbacks // optional streaming callbacks (TUI mode)

// UsageTotal is every provider-reported usage record this Agent has seen,
// summed. A call whose endpoint reports nothing adds nothing, so the total
// is a lower bound rather than a substitute for an estimate.
UsageTotal types.Usage

ContentStreamed bool // true when content was streamed via SSE; skip glamour re-print
}

// recordUsage accumulates one provider-reported usage record and publishes it to
// the run's callbacks. Both halves are skipped when the endpoint reported
// nothing: an absent report must neither move the total nor replace a caller's
// estimate with a zero.
func (a *Agent) recordUsage(cb *types.StreamCallbacks, usage *types.Usage) {
if usage == nil {
return
}
a.UsageTotal = a.UsageTotal.Add(*usage)
if cb != nil && cb.OnUsage != nil {
cb.OnUsage(*usage)
}
}

// ANSI color codes for terminal output.
const (
colorCyan = "\033[36m"
Expand Down Expand Up @@ -237,6 +256,7 @@ func (a *Agent) Run(ctx context.Context, prompt string) (string, error) {
a.HandleContextError(err)
return "", fmt.Errorf("LLM call failed: %w", err)
}
a.recordUsage(callbacks, resp.Usage)

// Reasoning already handled by streaming callback (OnReasoningDelta)
if a.ShowThinking {
Expand Down Expand Up @@ -453,25 +473,32 @@ func (a *Agent) Run(ctx context.Context, prompt string) (string, error) {
Content: fmt.Sprintf("You have reached the maximum step limit (%d steps). No more tool calls are allowed. Please summarize what you have accomplished so far and what remains to be done.", maxSteps),
})
// Force one more LLM call with no tools available
resp, err := a.Provider.Chat(ctx, types.ChatRequest{
Messages: messages,
Tools: nil, // no tools — LLM must output text only
MaxTokens: a.MaxTokens,
Model: a.getModel(),
StreamCallbacks: &types.StreamCallbacks{
OnReasoningDelta: func(text string) {},
OnTextDelta: func(text string) {
if a.StreamCallbacks != nil && a.StreamCallbacks.OnTextDelta != nil {
a.StreamCallbacks.OnTextDelta(text)
} else {
fmt.Print(text)
}
},
summaryCallbacks := &types.StreamCallbacks{
OnReasoningDelta: func(text string) {},
OnTextDelta: func(text string) {
if a.StreamCallbacks != nil && a.StreamCallbacks.OnTextDelta != nil {
a.StreamCallbacks.OnTextDelta(text)
} else {
fmt.Print(text)
}
},
OnUsage: func(usage types.Usage) {
if a.StreamCallbacks != nil && a.StreamCallbacks.OnUsage != nil {
a.StreamCallbacks.OnUsage(usage)
}
},
}
resp, err := a.Provider.Chat(ctx, types.ChatRequest{
Messages: messages,
Tools: nil, // no tools — LLM must output text only
MaxTokens: a.MaxTokens,
Model: a.getModel(),
StreamCallbacks: summaryCallbacks,
})
if err != nil {
return "", fmt.Errorf("step limit summary failed: %w", err)
}
a.recordUsage(summaryCallbacks, resp.Usage)
return resp.Content, nil
}

Expand Down
31 changes: 30 additions & 1 deletion agent/provider_ollama.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,8 @@ func (p *OllamaProvider) ollamaBatch(body io.ReadCloser) (*types.ChatResponse, e
Thinking string `json:"thinking,omitempty"`
ToolCalls []ollamaToolCall `json:"tool_calls,omitempty"`
} `json:"message"`
PromptEvalCount *int `json:"prompt_eval_count,omitempty"`
EvalCount *int `json:"eval_count,omitempty"`
}

if err := json.NewDecoder(body).Decode(&ollamaResp); err != nil {
Expand All @@ -233,6 +235,7 @@ func (p *OllamaProvider) ollamaBatch(body io.ReadCloser) (*types.ChatResponse, e
result := &types.ChatResponse{
Content: ollamaResp.Message.Content,
ReasoningContent: ollamaResp.Message.Thinking,
Usage: ollamaUsage(ollamaResp.PromptEvalCount, ollamaResp.EvalCount),
}

if len(ollamaResp.Message.ToolCalls) > 0 {
Expand Down Expand Up @@ -286,15 +289,20 @@ func (p *OllamaProvider) ollamaStream(ctx context.Context, body io.ReadCloser, c
Thinking string `json:"thinking,omitempty"`
ToolCalls []ollamaToolCall `json:"tool_calls,omitempty"`
} `json:"message,omitempty"`
Done bool `json:"done,omitempty"`
Done bool `json:"done,omitempty"`
PromptEvalCount *int `json:"prompt_eval_count,omitempty"`
EvalCount *int `json:"eval_count,omitempty"`
}

if err := json.Unmarshal([]byte(line), &event); err != nil {
tlog.Debug("ollama.stream", "parse_error", "line", line, "error", err.Error())
continue
}

// The final line is where the token counters live, so they are read
// before the stream is left.
if event.Done {
result.Usage = ollamaUsage(event.PromptEvalCount, event.EvalCount)
break
}

Expand Down Expand Up @@ -338,3 +346,24 @@ func (p *OllamaProvider) ollamaStream(ctx context.Context, body io.ReadCloser, c

return result, nil
}

// ollamaUsage maps Ollama's eval counters onto the shared usage value. It
// returns nil when neither counter is present, so a server that reports neither
// leaves the caller's estimate in charge instead of claiming a zero-token call.
func ollamaUsage(promptEval, eval *int) *types.Usage {
if promptEval == nil && eval == nil {
return nil
}
var prompt, completion int
if promptEval != nil {
prompt = *promptEval
}
if eval != nil {
completion = *eval
}
return &types.Usage{
PromptTokens: prompt,
CompletionTokens: completion,
TotalTokens: prompt + completion,
}
}
44 changes: 37 additions & 7 deletions agent/provider_openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ func (p *OpenAIProvider) chatBatch(ctx context.Context, body io.ReadCloser, star
ReasoningContent string `json:"reasoning_content,omitempty"`
} `json:"message"`
} `json:"choices"`
Usage *usageJSON `json:"usage,omitempty"`
}

if err := json.Unmarshal(respBody, &rawResp); err != nil {
Expand All @@ -183,6 +184,7 @@ func (p *OpenAIProvider) chatBatch(ctx context.Context, body io.ReadCloser, star
result := &types.ChatResponse{
Content: choice.Content,
ReasoningContent: choice.ReasoningContent,
Usage: rawResp.Usage.toUsage(),
}

if len(choice.ToolCalls) > 0 {
Expand Down Expand Up @@ -244,15 +246,22 @@ func (p *OpenAIProvider) chatStream(ctx context.Context, body io.ReadCloser, sta
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []jsonToolCallRef `json:"tool_calls,omitempty"`
} `json:"delta"`
FinishReason string `json:"finish_reason,omitempty"`
} `json:"choices"`
Usage *usageJSON `json:"usage,omitempty"`
}

if err := json.Unmarshal([]byte(payload), &event); err != nil {
tlog.Debug("llm.provider", "sse_parse_error", "line", line, "error", err.Error())
continue
}

// Usage arrives in a chunk of its own, after the one carrying
// finish_reason and before [DONE]; that chunk has an empty choices
// array, so it has to be read before the choices check below.
if event.Usage != nil {
result.Usage = event.Usage.toUsage()
}

if len(event.Choices) == 0 {
continue
}
Expand Down Expand Up @@ -297,12 +306,6 @@ func (p *OpenAIProvider) chatStream(ctx context.Context, body io.ReadCloser, sta
existing.id = tc.ID
}
}

// Finish reason — last event before [DONE]
if event.Choices[0].FinishReason != "" {
// Signal end of real-time output if content was streamed
break
}
}

if err := scanner.Err(); err != nil {
Expand Down Expand Up @@ -338,3 +341,30 @@ type jsonToolCallRef struct {
Arguments string `json:"arguments,omitempty"`
} `json:"function"`
}

// usageJSON mirrors the OpenAI usage object, which the batch body and the final
// streaming chunk both carry. It is a pointer at every call site: an absent
// object means the endpoint reported nothing, which is not the same as zero.
type usageJSON struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}

// toUsage normalizes a reported usage object for the shared types.Usage. A
// compatible endpoint may omit total_tokens, so the sum is derived from the two
// halves when it is absent; a genuinely zero report stays zero.
func (u *usageJSON) toUsage() *types.Usage {
if u == nil {
return nil
}
total := u.TotalTokens
if total == 0 {
total = u.PromptTokens + u.CompletionTokens
}
return &types.Usage{
PromptTokens: u.PromptTokens,
CompletionTokens: u.CompletionTokens,
TotalTokens: total,
}
}
Loading
Loading