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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ Please choose versions by [Semantic Versioning](http://semver.org/).
* MINOR version when you add functionality in a backwards-compatible manner, and
* PATCH version when you make backwards-compatible bug fixes.

## Unreleased

- feat: claude: `ClaudeResult` now carries the CLI session's token counts (input, output, cache-creation, cache-read) and turn count, parsed from the terminal result event's usage summary; absent or partial usage parses as zeros without error
- feat: metrics: `JobMetrics` gains `RecordUsage(JobUsage)`, backed by two new pre-initialized counters `agent_job_tokens_total` (label `type`: input, output, cache_read, cache_creation) and `agent_job_turns_total`; negative values are skipped instead of panicking
- feat: metrics: exported `TokenType` newtype with `TokenTypeInput` / `TokenTypeOutput` / `TokenTypeCacheRead` / `TokenTypeCacheCreation` and the `AvailableTokenTypes` collection, so the token-label set is type-checked and pre-initialization iterates the closed set

## v0.79.0

- deliverer: `AgentStatusDone` with empty `NextPhase` is now an in-place save (`status: in_progress`, phase preserved) instead of terminating the task (`phase: done`, `status: completed`) — enforces the documented `Result.NextPhase` contract ("Empty means stay in current phase"). Fixes multi-step agents whose Done+ContinueToNext preflight steps marked live tasks completed mid-run (observed: github-update-go-agent planning preflight republish, ~13 min false-completed window). Applies to both the Kafka deliverer and the content generators (`applyStatusFrontmatter`), which previously clobbered phase to `done` unconditionally.
Expand Down
51 changes: 47 additions & 4 deletions claude/claude-event.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,56 @@

package claude

import "encoding/json"
import (
"encoding/json"
"strconv"
)

// claudeEvent represents a single event in the Claude CLI stream-json output.
type claudeEvent struct {
Type string `json:"type"`
Result string `json:"result"`
Message claudeMsg `json:"message"`
Type string `json:"type"`
Result string `json:"result"`
Message claudeMsg `json:"message"`
Usage json.RawMessage `json:"usage"`
NumTurns json.Number `json:"num_turns"`
}

// resultHolder safely extracts type and result from a JSON line without failing
// on schema-level errors (e.g. a json.Number field receiving a string).
type resultHolder struct {
Type string `json:"type"`
Result string `json:"result"`
NumTurns json.Number `json:"num_turns"`
}

// sessionUsage is the token and turn summary captured from the Claude CLI's
// terminal result event. The zero value means no usage was reported and is a
// valid, non-error outcome.
type sessionUsage struct {
inputTokens int64
outputTokens int64
cacheCreationTokens int64
cacheReadTokens int64
numTurns int64
}

// numberToInt64 converts a JSON number to int64, yielding 0 when the value is
// absent, non-integer, or otherwise unconvertible. Usage accounting is
// best-effort telemetry: a malformed count must never fail the run.
// It falls back to ParseFloat to handle decimal-formatted numbers like "100.0".
func numberToInt64(n json.Number) int64 {
if n == "" {
return 0
}
v, err := n.Int64()
if err == nil {
return v
}
f, err := strconv.ParseFloat(string(n), 64)
if err != nil {
return 0
}
return int64(f)
}

type claudeMsg struct {
Expand Down
11 changes: 11 additions & 0 deletions claude/claude-result.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,15 @@ package claude
// ClaudeResult holds the parsed output from a Claude Code CLI session.
type ClaudeResult struct {
Result string `json:"result"`
// InputTokens is the count of fresh (non-cached) input tokens the session consumed.
InputTokens int64 `json:"input_tokens,omitempty"`
// OutputTokens is the count of output tokens the session produced.
OutputTokens int64 `json:"output_tokens,omitempty"`
// CacheCreationTokens is the count of input tokens written into the prompt cache.
CacheCreationTokens int64 `json:"cache_creation_tokens,omitempty"`
// CacheReadTokens is the count of input tokens served from the prompt cache.
CacheReadTokens int64 `json:"cache_read_input_tokens,omitempty"`
// NumTurns is the number of conversation turns the session took. Zero when the
// CLI reported no usage summary.
NumTurns int64 `json:"num_turns,omitempty"`
}
82 changes: 74 additions & 8 deletions claude/claude-runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ func (r *claudeRunner) Run(ctx context.Context, prompt string) (*ClaudeResult, e
return nil, errors.Wrap(ctx, err, "start claude CLI")
}

resultText, tail := scanOutput(ctx, stdoutPipe)
resultText, usage, tail := scanOutput(ctx, stdoutPipe)

if err := cmd.Wait(); err != nil {
var tailMsg string
Expand All @@ -74,7 +74,14 @@ func (r *claudeRunner) Run(ctx context.Context, prompt string) (*ClaudeResult, e
return nil, errors.New(ctx, "no result event found in claude CLI output")
}

return &ClaudeResult{Result: resultText}, nil
return &ClaudeResult{
Result: resultText,
InputTokens: usage.inputTokens,
OutputTokens: usage.outputTokens,
CacheCreationTokens: usage.cacheCreationTokens,
CacheReadTokens: usage.cacheReadTokens,
NumTurns: usage.numTurns,
}, nil
}

func (r *claudeRunner) buildCommand(
Expand Down Expand Up @@ -137,19 +144,59 @@ func appendTail(tail []string, line []byte) []string {
return tail
}

// scanOutput reads stream-json lines from stdout, logs events, and returns the result text and a bounded tail of all non-empty lines.
// parseUsage extracts token counts from a raw usage JSON block and the num_turns field
// from the parent event. Each token field is unmarshalled individually from a map so that a
// decode error on one field does not roll back valid values from other fields.
// Malformed values degrade to 0 without error, per the best-effort telemetry contract.
func parseUsage(usageRaw json.RawMessage, numTurns json.Number) sessionUsage {
var usage sessionUsage
var usageMap map[string]json.RawMessage
if err := json.Unmarshal(usageRaw, &usageMap); err != nil {
return usage
}

var inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens json.Number

//nolint:errcheck // Intentional: malformed fields degrade to 0, which is the correct behaviour.
if v, ok := usageMap["input_tokens"]; ok {
json.Unmarshal(v, &inputTokens)
}
//nolint:errcheck
if v, ok := usageMap["output_tokens"]; ok {
json.Unmarshal(v, &outputTokens)
}
//nolint:errcheck
if v, ok := usageMap["cache_creation_input_tokens"]; ok {
json.Unmarshal(v, &cacheCreationTokens)
}
//nolint:errcheck
if v, ok := usageMap["cache_read_input_tokens"]; ok {
json.Unmarshal(v, &cacheReadTokens)
}

usage.inputTokens = numberToInt64(inputTokens)
usage.outputTokens = numberToInt64(outputTokens)
usage.cacheCreationTokens = numberToInt64(cacheCreationTokens)
usage.cacheReadTokens = numberToInt64(cacheReadTokens)
usage.numTurns = numberToInt64(numTurns)
return usage
}

// scanOutput reads stream-json lines from stdout, logs events, and returns the result
// text, the captured usage summary, and a bounded tail of all non-empty lines.
func scanOutput(
ctx context.Context,
reader interface{ Read([]byte) (int, error) },
) (string, []string) {
) (string, sessionUsage, []string) {
var resultText string
var usage sessionUsage
var tail []string
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
for scanner.Scan() {
select {
case <-ctx.Done():
return "", nil
return "", sessionUsage{}, nil
default:
}

Expand All @@ -158,13 +205,32 @@ func scanOutput(

tail = appendTail(tail, line)

// Two-pass unmarshal: first extract type/result safely (never fails on schema issues),
// then attempt full unmarshal for usage fields (may fail on e.g. json.Number receiving
// a string). The first pass always succeeds for syntactically valid JSON, ensuring
// resultText survives schema drift in the usage subtree.
var holder resultHolder
if err := json.Unmarshal(line, &holder); err != nil {
continue
}

if holder.Type == "result" && holder.Result != "" {
resultText = holder.Result
}

// Full unmarshal: may fail due to schema-level issues in usage fields.
// On failure we still keep the resultText captured above.
var event claudeEvent
if err := json.Unmarshal(line, &event); err != nil {
continue
}

if event.Type == "result" && event.Result != "" {
resultText = event.Result
// Usage capture is deliberately gated on the presence of a usage object, NOT
// on a non-empty result text: a later result event carrying fresh usage but an
// empty result string must update the numbers while leaving the previously
// captured text intact. Last usage object wins.
if event.Type == "result" && len(event.Usage) > 0 {
usage = parseUsage(event.Usage, event.NumTurns)
}

for _, c := range event.Message.Content {
Expand All @@ -176,7 +242,7 @@ func scanOutput(
}
}
}
return resultText, tail
return resultText, usage, tail
}

// buildSubprocessEnv constructs the env var slice for the Claude CLI subprocess.
Expand Down
Loading