返回 DeepSeek-Reasonix
run_metrics.go
根目录 / internal / cli / run_metrics.go
1 package cli
2
3 import (
4 "encoding/json"
5 "os"
6 "strings"
7 "sync"
8 "time"
9
10 "reasonix/internal/event"
11 "reasonix/internal/evidence"
12 "reasonix/internal/fileutil"
13 )
14
15 // SourceUsage is one Usage origin's share of a run. Steps counts every billed
16 // model call regardless of origin, so a run can exceed the executor's max_steps
17 // budget without the main loop having done so; this breakdown is what makes
18 // that total explicable instead of alarming.
19 type SourceUsage struct {
20 Calls int `json:"calls"`
21 PromptTokens int `json:"prompt_tokens"`
22 CompletionTokens int `json:"completion_tokens"`
23 Cost float64 `json:"cost"`
24 }
25
26 // RunMetrics is the machine-readable token/cache/cost summary `run --metrics`
27 // writes, so a benchmark harness can read a run's cost without scraping stdout.
28 type RunMetrics struct {
29 PromptTokens int `json:"prompt_tokens"`
30 CompletionTokens int `json:"completion_tokens"`
31 CacheHitTokens int `json:"cache_hit_tokens"`
32 CacheMissTokens int `json:"cache_miss_tokens"`
33 // PrefixChangeReasonCounts tallies how many usage events reported each
34 // cache-prefix-change reason (e.g. "compact_auto", "snip", "tools") across
35 // the run, so a regression in cache-reset frequency shows which operation
36 // is responsible instead of just a dropped hit-rate percentage.
37 PrefixChangeReasonCounts map[string]int `json:"prefix_change_reason_counts,omitempty"`
38 Steps int `json:"steps"` // model calls (one per stream, incl. tool rounds)
39 Cost float64 `json:"cost"`
40 Currency string `json:"currency"`
41 Estimated bool `json:"estimated,omitempty"`
42 Compactions int `json:"compactions"`
43 ReadinessChecks int `json:"readiness_checks"`
44 ReadinessAllowed int `json:"readiness_allowed"`
45 ReadinessBlocks int `json:"readiness_blocks"`
46 ReadinessRecoveries int `json:"readiness_recoveries"`
47 ReadinessErrors int `json:"readiness_errors"`
48 ReadinessMissingProjectChecks int `json:"readiness_missing_project_checks"`
49 ReadinessIncompleteTodos int `json:"readiness_incomplete_todos"`
50 ReadinessCommandMismatches int `json:"readiness_command_mismatches"`
51 ReadinessMissingAcceptance int `json:"readiness_missing_acceptance_criteria"`
52 ReadinessMissingVerification int `json:"readiness_missing_verification"`
53 ReadinessMissingReview int `json:"readiness_missing_review"`
54 ReadinessMissingSignoff int `json:"readiness_missing_signoff"`
55 ReadinessMissingActionEvidence int `json:"readiness_missing_action_evidence"`
56 ReadinessMissingMutation int `json:"readiness_missing_mutation"`
57 MissingReasoningDetected int `json:"missing_reasoning_detected,omitempty"`
58 MissingReasoningRetries int `json:"missing_reasoning_retries,omitempty"`
59 MissingReasoningRecovered int `json:"missing_reasoning_recovered,omitempty"`
60 MissingReasoningReplaced int `json:"missing_reasoning_retry_replaced_response,omitempty"`
61 MissingReasoningSuppressed int `json:"missing_reasoning_retry_suppressed,omitempty"`
62 MissingReasoningFallbacks int `json:"missing_reasoning_fallbacks,omitempty"`
63 // Capability / Delivery routing counters (optional; zero for older readers).
64 CapabilityRoutes int `json:"capability_routes,omitempty"`
65 CapabilityRoutedCandidates int `json:"capability_routed_candidates,omitempty"`
66 CapabilityRoutedRequire int `json:"capability_routed_require,omitempty"`
67 CapabilityRoutedPrefer int `json:"capability_routed_prefer,omitempty"`
68 CapabilityRoutedSuggest int `json:"capability_routed_suggest,omitempty"`
69 CapabilityDeclines int `json:"capability_declines,omitempty"`
70 CapabilitySemanticRoutes int `json:"capability_semantic_routes,omitempty"`
71 CapabilitySemanticFallbacks int `json:"capability_semantic_fallbacks,omitempty"`
72 CapabilityRequireMissing int `json:"capability_require_missing,omitempty"`
73 CapabilityRequireRecovered int `json:"capability_require_recovered,omitempty"`
74 CapabilityPreferMissing int `json:"capability_prefer_missing,omitempty"`
75 CapabilityPreferRecovered int `json:"capability_prefer_recovered,omitempty"`
76 CapabilitySkillInvocations int `json:"capability_skill_invocations,omitempty"`
77 CapabilitySkillFailures int `json:"capability_skill_failures,omitempty"`
78 CapabilitySkillUnavailable int `json:"capability_skill_unavailable,omitempty"`
79 CapabilityMCPInspect int `json:"capability_mcp_inspect,omitempty"`
80 CapabilityMCPCall int `json:"capability_mcp_call,omitempty"`
81 CapabilityMCPCallFailures int `json:"capability_mcp_call_failures,omitempty"`
82 CapabilityReviewBlocks int `json:"capability_review_blocks,omitempty"`
83 CapabilitySecurityReviewBlocks int `json:"capability_security_review_blocks,omitempty"`
84 CapabilityRouterPromptTokens int `json:"capability_router_prompt_tokens,omitempty"`
85 CapabilityRouterCompletionTok int `json:"capability_router_completion_tokens,omitempty"`
86 CapabilityRouterCost float64 `json:"capability_router_cost,omitempty"`
87 CapabilityRouterLatencyMs int64 `json:"capability_router_latency_ms,omitempty"`
88
89 // Run accounting: what a benchmark needs to price one solved task and name
90 // the guard that ended a failed one.
91
92 // Complete distinguishes a final record from an in-flight snapshot. A killed
93 // agent leaves only the latter, and its numbers are lower bounds.
94 Complete bool `json:"complete"`
95 UsageBySource map[string]SourceUsage `json:"usage_by_source,omitempty"`
96 Arm string `json:"arm"`
97 DurationMs int64 `json:"duration_ms"`
98 Outcome string `json:"outcome"`
99 ToolCalls int `json:"tool_calls"`
100 ToolFailures int `json:"tool_failures"`
101 ToolDurationMs int64 `json:"tool_duration_ms"`
102 SubagentToolCalls int `json:"subagent_tool_calls"`
103 Retries int `json:"retries"`
104 ToolCallsByName map[string]int `json:"tool_calls_by_name,omitempty"`
105 ToolFailuresByName map[string]int `json:"tool_failures_by_name,omitempty"`
106 }
107
108 // metricsSink forwards every event to the real sink and accumulates the per-call
109 // Usage events into a RunMetrics. Cache totals are summed per call (not read from
110 // the cumulative SessionHit/Miss) so they match PromptTokens exactly.
111 type metricsSink struct {
112 inner event.Sink
113
114 // mu guards m. Emit alone is serialized by the session's event.Sync wrapper,
115 // but the final read from the run command races background job emission, and
116 // the snapshot goroutine reads the same fields.
117 mu sync.Mutex
118 m RunMetrics
119
120 // partialPath receives throttled in-flight snapshots, so a run killed by a
121 // timeout still leaves accounting behind instead of nothing. Empty disables
122 // them; snapshotEvery bounds the write rate.
123 partialPath string
124 snapshotEvery time.Duration
125 lastSnapshot time.Time
126 clock func() time.Time
127 }
128
129 func (s *metricsSink) now() time.Time {
130 if s.clock != nil {
131 return s.clock()
132 }
133 return time.Now()
134 }
135
136 // Snapshot returns a deep copy safe to marshal while the run continues.
137 func (s *metricsSink) Snapshot() RunMetrics {
138 s.mu.Lock()
139 defer s.mu.Unlock()
140 return s.m.clone()
141 }
142
143 func (m RunMetrics) clone() RunMetrics {
144 out := m
145 out.PrefixChangeReasonCounts = cloneCounts(m.PrefixChangeReasonCounts)
146 out.UsageBySource = cloneSourceUsage(m.UsageBySource)
147 out.ToolCallsByName = cloneCounts(m.ToolCallsByName)
148 out.ToolFailuresByName = cloneCounts(m.ToolFailuresByName)
149 return out
150 }
151
152 func cloneCounts(in map[string]int) map[string]int {
153 if in == nil {
154 return nil
155 }
156 out := make(map[string]int, len(in))
157 for k, v := range in {
158 out[k] = v
159 }
160 return out
161 }
162
163 func cloneSourceUsage(in map[string]SourceUsage) map[string]SourceUsage {
164 if in == nil {
165 return nil
166 }
167 out := make(map[string]SourceUsage, len(in))
168 for k, v := range in {
169 out[k] = v
170 }
171 return out
172 }
173
174 // writeSnapshot publishes the in-flight record to the sidecar. Callers hold mu.
175 // A snapshot is never marked complete, so a reader can always tell it apart
176 // from a final record even if the process dies immediately after.
177 func (s *metricsSink) writeSnapshot() {
178 if s.partialPath == "" {
179 return
180 }
181 now := s.now()
182 if !s.lastSnapshot.IsZero() && now.Sub(s.lastSnapshot) < s.snapshotEvery {
183 return
184 }
185 s.lastSnapshot = now
186 snap := s.m.clone()
187 snap.Complete = false
188 if data, err := json.MarshalIndent(snap, "", " "); err == nil {
189 _ = fileutil.AtomicWriteFile(s.partialPath, data, 0o644)
190 }
191 }
192
193 func (s *metricsSink) Emit(e event.Event) {
194 s.mu.Lock()
195 s.record(e)
196 s.writeSnapshot()
197 s.mu.Unlock()
198 s.inner.Emit(e)
199 }
200
201 func (s *metricsSink) record(e event.Event) {
202 if e.Kind == event.Usage && e.Usage != nil {
203 u := e.Usage
204 s.m.PromptTokens += u.PromptTokens
205 s.m.CompletionTokens += u.CompletionTokens
206 s.m.CacheHitTokens += u.CacheHitTokens
207 s.m.CacheMissTokens += u.CacheMissTokens
208 s.m.Steps++
209 s.m.Estimated = s.m.Estimated || u.Estimated
210 var stepCost float64
211 if p := e.Pricing; p != nil {
212 stepCost = p.Cost(u)
213 s.m.Cost += stepCost
214 s.m.Currency = p.Currency
215 }
216 s.recordSource(e.UsageSource, u.PromptTokens, u.CompletionTokens, stepCost)
217 if e.UsageSource == event.UsageSourceCapabilityRouter {
218 s.m.CapabilityRouterPromptTokens += u.PromptTokens
219 s.m.CapabilityRouterCompletionTok += u.CompletionTokens
220 s.m.CapabilityRouterCost += stepCost
221 }
222 if e.CacheDiagnostics != nil && len(e.CacheDiagnostics.PrefixChangeReasons) > 0 {
223 if s.m.PrefixChangeReasonCounts == nil {
224 s.m.PrefixChangeReasonCounts = map[string]int{}
225 }
226 for _, reason := range e.CacheDiagnostics.PrefixChangeReasons {
227 s.m.PrefixChangeReasonCounts[reason]++
228 }
229 }
230 }
231 if e.Kind == event.CompactionStarted {
232 s.m.Compactions++
233 }
234 if e.Kind == event.ToolResult {
235 s.recordToolResult(e.Tool)
236 }
237 if e.Kind == event.Retrying {
238 s.m.Retries++
239 }
240 s.inner.Emit(e)
241 }
242
243 // recordSource buckets one model call by its origin. An empty source means the
244 // executor, per the Usage event contract. An unrecognised source is kept under
245 // its own key rather than dropped, so a future origin cannot silently vanish
246 // from a total that is meant to reconcile.
247 func (s *metricsSink) recordSource(source string, prompt, completion int, cost float64) {
248 if strings.TrimSpace(source) == "" {
249 source = event.UsageSourceExecutor
250 }
251 if s.m.UsageBySource == nil {
252 s.m.UsageBySource = map[string]SourceUsage{}
253 }
254 agg := s.m.UsageBySource[source]
255 agg.Calls++
256 agg.PromptTokens += prompt
257 agg.CompletionTokens += completion
258 agg.Cost += cost
259 s.m.UsageBySource[source] = agg
260 }
261
262 // recordToolResult attributes a finished call by the name the model emitted,
263 // not Tool.ResolvedName — a wasted call is a wrong model decision, and the
264 // proxy target it resolved to would hide which name was picked.
265 func (s *metricsSink) recordToolResult(t event.Tool) {
266 name := strings.TrimSpace(t.Name)
267 if name == "" {
268 name = "unknown"
269 }
270 s.m.ToolCalls++
271 s.m.ToolDurationMs += t.DurationMs
272 if t.ParentID != "" {
273 s.m.SubagentToolCalls++
274 }
275 if s.m.ToolCallsByName == nil {
276 s.m.ToolCallsByName = map[string]int{}
277 }
278 s.m.ToolCallsByName[name]++
279 if t.Err == "" {
280 return
281 }
282 s.m.ToolFailures++
283 if s.m.ToolFailuresByName == nil {
284 s.m.ToolFailuresByName = map[string]int{}
285 }
286 s.m.ToolFailuresByName[name]++
287 }
288
289 func (s *metricsSink) RecordReadinessAudit(a evidence.ReadinessAudit) {
290 if s == nil {
291 return
292 }
293 s.mu.Lock()
294 defer s.mu.Unlock()
295 s.m.ReadinessChecks++
296 switch a.Result {
297 case evidence.ReadinessAllowed:
298 s.m.ReadinessAllowed++
299 case evidence.ReadinessBlocked:
300 s.m.ReadinessBlocks++
301 case evidence.ReadinessErrored:
302 s.m.ReadinessErrors++
303 }
304 if a.Recovered {
305 s.m.ReadinessRecoveries++
306 }
307 s.m.ReadinessMissingProjectChecks += a.MissingProjectChecks
308 s.m.ReadinessIncompleteTodos += a.IncompleteTodos
309 s.m.ReadinessCommandMismatches += a.CommandMismatchMissing
310 s.m.ReadinessMissingAcceptance += a.MissingAcceptanceCriteria
311 s.m.ReadinessMissingVerification += a.MissingVerification
312 s.m.ReadinessMissingReview += a.MissingReview
313 s.m.ReadinessMissingSignoff += a.MissingSignoff
314 s.m.ReadinessMissingActionEvidence += a.MissingActionEvidence
315 s.m.ReadinessMissingMutation += a.MissingMutation
316 }
317
318 func (s *metricsSink) RecordProtocolRecovery(a event.ProtocolRecoveryAudit) {
319 s.mu.Lock()
320 switch a.Kind {
321 case event.ProtocolRecoveryMissingReasoningDetected:
322 s.m.MissingReasoningDetected++
323 case event.ProtocolRecoveryMissingReasoningRetryAttempted:
324 s.m.MissingReasoningRetries++
325 // Usage from the original and recovery responses is intentionally merged
326 // into one invisible UI event, but Steps remains a true model-call count.
327 s.m.Steps++
328 case event.ProtocolRecoveryMissingReasoningRetryRecovered:
329 s.m.MissingReasoningRecovered++
330 case event.ProtocolRecoveryMissingReasoningRetryReplaced:
331 s.m.MissingReasoningReplaced++
332 case event.ProtocolRecoveryMissingReasoningRetrySuppressed:
333 s.m.MissingReasoningSuppressed++
334 case event.ProtocolRecoveryMissingReasoningFallback:
335 s.m.MissingReasoningFallbacks++
336 }
337 s.mu.Unlock()
338 event.RecordProtocolRecovery(s.inner, a)
339 }
340
341 // MergeCapabilityAuditCounters copies capability counters into RunMetrics.
342 func (m *RunMetrics) MergeCapabilityAuditCounters(
343 routes, routedCandidates, routedRequire, routedPrefer, routedSuggest, declines int,
344 semantic, fallbacks, requireMiss, requireRec, preferMiss, preferRec int,
345 skillInv, skillFail, skillUnavail int,
346 mcpInspect, mcpCall, mcpFail int,
347 reviewBlocks, securityBlocks int,
348 routerPrompt, routerCompletion int,
349 routerCost float64,
350 routerLatencyMs int64,
351 ) {
352 if m == nil {
353 return
354 }
355 m.CapabilityRoutes += routes
356 m.CapabilityRoutedCandidates += routedCandidates
357 m.CapabilityRoutedRequire += routedRequire
358 m.CapabilityRoutedPrefer += routedPrefer
359 m.CapabilityRoutedSuggest += routedSuggest
360 m.CapabilityDeclines += declines
361 m.CapabilitySemanticRoutes += semantic
362 m.CapabilitySemanticFallbacks += fallbacks
363 m.CapabilityRequireMissing += requireMiss
364 m.CapabilityRequireRecovered += requireRec
365 m.CapabilityPreferMissing += preferMiss
366 m.CapabilityPreferRecovered += preferRec
367 m.CapabilitySkillInvocations += skillInv
368 m.CapabilitySkillFailures += skillFail
369 m.CapabilitySkillUnavailable += skillUnavail
370 m.CapabilityMCPInspect += mcpInspect
371 m.CapabilityMCPCall += mcpCall
372 m.CapabilityMCPCallFailures += mcpFail
373 m.CapabilityReviewBlocks += reviewBlocks
374 m.CapabilitySecurityReviewBlocks += securityBlocks
375 m.CapabilityRouterPromptTokens += routerPrompt
376 m.CapabilityRouterCompletionTok += routerCompletion
377 m.CapabilityRouterCost += routerCost
378 m.CapabilityRouterLatencyMs += routerLatencyMs
379 }
380
381 // partialMetricsPath is the sidecar an unfinished run leaves behind. It is a
382 // distinct filename so a reader that predates snapshots cannot mistake one for
383 // a final record.
384 func partialMetricsPath(path string) string { return path + ".partial" }
385
386 // writeMetrics publishes the final record and retires the sidecar, so the two
387 // can never both be read and double-counted. The final file is written first:
388 // if the process dies between the two steps, a stale partial alongside a
389 // complete final is resolvable, whereas the reverse would lose everything.
390 func writeMetrics(path string, m RunMetrics) error {
391 m.Complete = true
392 b, err := json.MarshalIndent(m, "", " ")
393 if err != nil {
394 return err
395 }
396 if err := fileutil.AtomicWriteFile(path, b, 0o644); err != nil {
397 return err
398 }
399 _ = os.Remove(partialMetricsPath(path))
400 return nil
401 }
402
402 lines GO