返回 DeepSeek-Reasonix
run_metrics.go
根目录 / internal / cli / run_metrics.go
1 package cli
2
3 import (
4 "encoding/json"
5 "maps"
6 "os"
7 "sort"
8 "strings"
9 "sync"
10 "time"
11
12 "reasonix/internal/billing"
13 "reasonix/internal/capability"
14 "reasonix/internal/event"
15 "reasonix/internal/evidence"
16 "reasonix/internal/fileutil"
17 )
18
19 // SourceUsage is one Usage origin's share of a run. Steps counts every billed
20 // model call regardless of origin, so a run can exceed the executor's max_steps
21 // budget without the main loop having done so; this breakdown is what makes
22 // that total explicable instead of alarming.
23 type SourceUsage struct {
24 Calls int `json:"calls"`
25 PromptTokens int `json:"prompt_tokens"`
26 CompletionTokens int `json:"completion_tokens"`
27 Cost float64 `json:"cost"`
28 // Original currency facts for mixed-currency runs (never summed across codes).
29 OriginalCosts map[string]float64 `json:"original_costs,omitempty"`
30 }
31
32 // RunMetrics is the machine-readable token/cache/cost summary `run --metrics`
33 // writes, so a benchmark harness can read a run's cost without scraping stdout.
34 type RunMetrics struct {
35 PromptTokens int `json:"prompt_tokens"`
36 CompletionTokens int `json:"completion_tokens"`
37 CacheHitTokens int `json:"cache_hit_tokens"`
38 CacheMissTokens int `json:"cache_miss_tokens"`
39 // PrefixChangeReasonCounts tallies how many usage events reported each
40 // cache-prefix-change reason (e.g. "compact_auto", "snip", "tools") across
41 // the run, so a regression in cache-reset frequency shows which operation
42 // is responsible instead of just a dropped hit-rate percentage.
43 PrefixChangeReasonCounts map[string]int `json:"prefix_change_reason_counts,omitempty"`
44 Steps int `json:"steps"` // model calls (one per stream, incl. tool rounds)
45 Cost float64 `json:"cost"`
46 Currency string `json:"currency"`
47 // CostComplete is false when any quote lacked a shared display valuation.
48 CostComplete bool `json:"cost_complete"`
49 DisplayComplete bool `json:"display_complete"`
50 DisplayStatus string `json:"display_status,omitempty"`
51 AggregateMode string `json:"aggregate_mode,omitempty"`
52 OriginalTotals []billing.Money `json:"original_totals,omitempty"`
53 // OriginalCosts is per-ISO original currency totals (never cross-added).
54 OriginalCosts map[string]float64 `json:"original_costs,omitempty"`
55 // CostQuotes retains occurrence-time quotes for audit (capped).
56 CostQuotes []billing.CostQuote `json:"cost_quotes,omitempty"`
57 Estimated bool `json:"estimated,omitempty"`
58 Compactions int `json:"compactions"`
59 ReadinessChecks int `json:"readiness_checks"`
60 ReadinessAllowed int `json:"readiness_allowed"`
61 ReadinessBlocks int `json:"readiness_blocks"`
62 ReadinessRecoveries int `json:"readiness_recoveries"`
63 ReadinessErrors int `json:"readiness_errors"`
64 ReadinessMissingProjectChecks int `json:"readiness_missing_project_checks"`
65 ReadinessIncompleteTodos int `json:"readiness_incomplete_todos"`
66 ReadinessCommandMismatches int `json:"readiness_command_mismatches"`
67 ReadinessMissingAcceptance int `json:"readiness_missing_acceptance_criteria"`
68 ReadinessMissingVerification int `json:"readiness_missing_verification"`
69 ReadinessMissingReview int `json:"readiness_missing_review"`
70 ReadinessMissingSignoff int `json:"readiness_missing_signoff"`
71 ReadinessMissingActionEvidence int `json:"readiness_missing_action_evidence"`
72 ReadinessMissingMutation int `json:"readiness_missing_mutation"`
73 // Delegation counters let one model be compared across orchestration arms
74 // without scraping prose. Child tool calls are already split out as
75 // SubagentToolCalls below; parent calls are ToolCalls minus that.
76 SubagentRuns int `json:"subagent_runs,omitempty"`
77 SubagentNestedRuns int `json:"subagent_nested_runs,omitempty"`
78 SubagentMutations int `json:"subagent_mutations,omitempty"`
79 CompletionReports int `json:"completion_reports,omitempty"`
80 CompletionsProsedOnly int `json:"completions_prose_only,omitempty"`
81 FalseCompletions int `json:"false_completions,omitempty"`
82 CriterionDowngrades int `json:"criterion_downgrades,omitempty"`
83 WriteScopeViolations int `json:"write_scope_violations,omitempty"`
84 DuplicateWorkPaths int `json:"duplicate_work_paths,omitempty"`
85 ParentScopeHints int `json:"parent_scope_hints,omitempty"`
86 ParentNamedFiles int `json:"parent_named_files,omitempty"`
87 ChildEvidencePaths int `json:"child_evidence_paths,omitempty"`
88 ChildDiscoveredPaths int `json:"child_discovered_paths,omitempty"`
89 MissingReasoningDetected int `json:"missing_reasoning_detected,omitempty"`
90 MissingReasoningRetries int `json:"missing_reasoning_retries,omitempty"`
91 MissingReasoningRecovered int `json:"missing_reasoning_recovered,omitempty"`
92 MissingReasoningReplaced int `json:"missing_reasoning_retry_replaced_response,omitempty"`
93 MissingReasoningSuppressed int `json:"missing_reasoning_retry_suppressed,omitempty"`
94 MissingReasoningFallbacks int `json:"missing_reasoning_fallbacks,omitempty"`
95 // Capability / Delivery routing counters (optional; zero for older readers).
96 CapabilityRoutes int `json:"capability_routes,omitempty"`
97 CapabilityRoutedCandidates int `json:"capability_routed_candidates,omitempty"`
98 CapabilityRoutedRequire int `json:"capability_routed_require,omitempty"`
99 CapabilityRoutedPrefer int `json:"capability_routed_prefer,omitempty"`
100 CapabilityRoutedSuggest int `json:"capability_routed_suggest,omitempty"`
101 CapabilityDeclines int `json:"capability_declines,omitempty"`
102 CapabilitySemanticRoutes int `json:"capability_semantic_routes,omitempty"`
103 CapabilitySemanticFallbacks int `json:"capability_semantic_fallbacks,omitempty"`
104 CapabilityRequireMissing int `json:"capability_require_missing,omitempty"`
105 CapabilityRequireRecovered int `json:"capability_require_recovered,omitempty"`
106 CapabilityPreferMissing int `json:"capability_prefer_missing,omitempty"`
107 CapabilityPreferRecovered int `json:"capability_prefer_recovered,omitempty"`
108 CapabilitySkillInvocations int `json:"capability_skill_invocations,omitempty"`
109 CapabilitySkillFailures int `json:"capability_skill_failures,omitempty"`
110 CapabilitySkillUnavailable int `json:"capability_skill_unavailable,omitempty"`
111 CapabilityMCPInspect int `json:"capability_mcp_inspect,omitempty"`
112 CapabilityMCPCall int `json:"capability_mcp_call,omitempty"`
113 CapabilityMCPCallFailures int `json:"capability_mcp_call_failures,omitempty"`
114 CapabilityReviewBlocks int `json:"capability_review_blocks,omitempty"`
115 CapabilitySecurityReviewBlocks int `json:"capability_security_review_blocks,omitempty"`
116 CapabilityRouterPromptTokens int `json:"capability_router_prompt_tokens,omitempty"`
117 CapabilityRouterCompletionTok int `json:"capability_router_completion_tokens,omitempty"`
118 CapabilityRouterCost float64 `json:"capability_router_cost,omitempty"`
119 CapabilityRouterLatencyMs int64 `json:"capability_router_latency_ms,omitempty"`
120 CapabilityDiscovery capability.DiscoveryAudit `json:"capability_discovery,omitempty"`
121 CapabilityArguments capability.ArgumentAudit `json:"capability_arguments,omitempty"`
122 CapabilityLoopGuard capability.LoopGuardAudit `json:"capability_loop_guard,omitempty"`
123 CapabilityMCPLists capability.MCPListAudit `json:"capability_mcp_lists,omitempty"`
124 CapabilityToolExec capability.ToolExecAudit `json:"capability_tool_exec,omitempty"`
125 CapabilityPhases capability.PhaseAudit `json:"capability_phases,omitempty"`
126
127 // Run accounting: what a benchmark needs to price one solved task and name
128 // the guard that ended a failed one.
129
130 // Complete distinguishes a final record from an in-flight snapshot. A killed
131 // agent leaves only the latter, and its numbers are lower bounds.
132 Complete bool `json:"complete"`
133 UsageBySource map[string]SourceUsage `json:"usage_by_source,omitempty"`
134 Arm string `json:"arm"`
135 DurationMs int64 `json:"duration_ms"`
136 Outcome string `json:"outcome"`
137 ToolCalls int `json:"tool_calls"`
138 ToolFailures int `json:"tool_failures"`
139 ToolDurationMs int64 `json:"tool_duration_ms"`
140 SubagentToolCalls int `json:"subagent_tool_calls"`
141 Retries int `json:"retries"`
142 ToolCallsByName map[string]int `json:"tool_calls_by_name,omitempty"`
143 ToolFailuresByName map[string]int `json:"tool_failures_by_name,omitempty"`
144 }
145
146 // metricsSink forwards every event to the real sink and accumulates the per-call
147 // Usage events into a RunMetrics. Cache totals are summed per call (not read from
148 // the cumulative SessionHit/Miss) so they match PromptTokens exactly.
149 type metricsSink struct {
150 inner event.Sink
151
152 // mu guards m. Emit alone is serialized by the session's event.Sync wrapper,
153 // but the final read from the run command races background job emission, and
154 // the snapshot goroutine reads the same fields.
155 mu sync.Mutex
156 m RunMetrics
157 // childMutations counts how many distinct children mutated each path, so
158 // two children racing on one file is measurable rather than anecdotal.
159 childMutations map[string]int
160
161 // partialPath receives throttled in-flight snapshots, so a run killed by a
162 // timeout still leaves accounting behind instead of nothing. Empty disables
163 // them; snapshotEvery bounds the write rate.
164 partialPath string
165 snapshotEvery time.Duration
166 lastSnapshot time.Time
167 clock func() time.Time
168 }
169
170 var _ event.OptionalSinkCapabilities = (*metricsSink)(nil)
171
172 func (s *metricsSink) now() time.Time {
173 if s.clock != nil {
174 return s.clock()
175 }
176 return time.Now()
177 }
178
179 // Snapshot returns a deep copy safe to marshal while the run continues.
180 func (s *metricsSink) Snapshot() RunMetrics {
181 s.mu.Lock()
182 defer s.mu.Unlock()
183 return s.m.clone()
184 }
185
186 func (m RunMetrics) clone() RunMetrics {
187 out := m
188 out.PrefixChangeReasonCounts = cloneCounts(m.PrefixChangeReasonCounts)
189 out.UsageBySource = cloneSourceUsage(m.UsageBySource)
190 out.ToolCallsByName = cloneCounts(m.ToolCallsByName)
191 out.ToolFailuresByName = cloneCounts(m.ToolFailuresByName)
192 out.OriginalCosts = cloneFloatMap(m.OriginalCosts)
193 out.OriginalTotals = append([]billing.Money(nil), m.OriginalTotals...)
194 if len(m.CostQuotes) > 0 {
195 out.CostQuotes = append([]billing.CostQuote(nil), m.CostQuotes...)
196 for i := range out.CostQuotes {
197 out.CostQuotes[i].Valuations = cloneQuoteValuations(m.CostQuotes[i].Valuations)
198 if m.CostQuotes[i].Selected != nil {
199 sel := *m.CostQuotes[i].Selected
200 out.CostQuotes[i].Selected = &sel
201 }
202 }
203 }
204 return out
205 }
206
207 func cloneFloatMap(in map[string]float64) map[string]float64 {
208 if in == nil {
209 return nil
210 }
211 out := make(map[string]float64, len(in))
212 maps.Copy(out, in)
213 return out
214 }
215
216 func cloneQuoteValuations(in map[string]billing.Valuation) map[string]billing.Valuation {
217 if in == nil {
218 return nil
219 }
220 out := make(map[string]billing.Valuation, len(in))
221 for k, v := range in {
222 if v.Rate != nil {
223 snap := *v.Rate
224 v.Rate = &snap
225 }
226 out[k] = v
227 }
228 return out
229 }
230
231 func cloneCounts(in map[string]int) map[string]int {
232 if in == nil {
233 return nil
234 }
235 out := make(map[string]int, len(in))
236 maps.Copy(out, in)
237 return out
238 }
239
240 func cloneSourceUsage(in map[string]SourceUsage) map[string]SourceUsage {
241 if in == nil {
242 return nil
243 }
244 out := make(map[string]SourceUsage, len(in))
245 for k, v := range in {
246 v.OriginalCosts = cloneFloatMap(v.OriginalCosts)
247 out[k] = v
248 }
249 return out
250 }
251
252 // writeSnapshot publishes the in-flight record to the sidecar. Callers hold mu.
253 // A snapshot is never marked complete, so a reader can always tell it apart
254 // from a final record even if the process dies immediately after.
255 func (s *metricsSink) writeSnapshot() {
256 if s.partialPath == "" {
257 return
258 }
259 now := s.now()
260 if !s.lastSnapshot.IsZero() && now.Sub(s.lastSnapshot) < s.snapshotEvery {
261 return
262 }
263 s.lastSnapshot = now
264 snap := s.m.clone()
265 snap.Complete = false
266 if data, err := json.MarshalIndent(snap, "", " "); err == nil {
267 _ = fileutil.AtomicWriteFile(s.partialPath, data, 0o644)
268 }
269 }
270
271 func (s *metricsSink) Emit(e event.Event) {
272 s.mu.Lock()
273 s.record(e)
274 s.writeSnapshot()
275 s.mu.Unlock()
276 s.inner.Emit(e)
277 }
278
279 func (s *metricsSink) record(e event.Event) {
280 if e.Kind == event.Usage && e.Usage != nil {
281 u := e.Usage
282 s.m.PromptTokens += u.PromptTokens
283 s.m.CompletionTokens += u.CompletionTokens
284 s.m.CacheHitTokens += u.CacheHitTokens
285 s.m.CacheMissTokens += u.CacheMissTokens
286 s.m.Steps++
287 s.m.Estimated = s.m.Estimated || u.Estimated
288 var stepCost float64
289 q := e.CostQuote
290 if q == nil && e.Pricing != nil {
291 q = event.EnsureCostQuote(e, nil)
292 }
293 if q != nil {
294 s.m.Estimated = true
295 if !q.CostComplete {
296 s.m.CostComplete = false
297 } else if s.m.Steps == 1 {
298 s.m.CostComplete = true
299 }
300 s.m.DisplayComplete = q.DisplayComplete
301 s.m.DisplayStatus = q.DisplayStatus
302 s.m.AggregateMode = q.AggregateMode
303 origCur := billing.NormalizeCurrency(q.Original.Currency)
304 if origCur != "" {
305 if s.m.OriginalCosts == nil {
306 s.m.OriginalCosts = map[string]float64{}
307 }
308 s.m.OriginalCosts[origCur] += q.Original.Float64()
309 s.m.OriginalTotals = originalTotalsFromFloatMap(s.m.OriginalCosts)
310 }
311 if q.Selected != nil {
312 cur := q.LegacyCurrencyCode()
313 if s.m.Currency != "" && s.m.Currency != cur {
314 s.m.Cost = 0
315 s.m.Currency = ""
316 } else {
317 stepCost = q.Selected.Float64()
318 s.m.Cost += stepCost
319 s.m.Currency = cur
320 }
321 }
322 if len(s.m.CostQuotes) < 64 {
323 s.m.CostQuotes = append(s.m.CostQuotes, *q)
324 }
325 }
326 s.recordSource(e.UsageSource, u.PromptTokens, u.CompletionTokens, stepCost, q)
327 if e.UsageSource == event.UsageSourceCapabilityRouter {
328 s.m.CapabilityRouterPromptTokens += u.PromptTokens
329 s.m.CapabilityRouterCompletionTok += u.CompletionTokens
330 s.m.CapabilityRouterCost += stepCost
331 }
332 if e.CacheDiagnostics != nil && len(e.CacheDiagnostics.PrefixChangeReasons) > 0 {
333 if s.m.PrefixChangeReasonCounts == nil {
334 s.m.PrefixChangeReasonCounts = map[string]int{}
335 }
336 for _, reason := range e.CacheDiagnostics.PrefixChangeReasons {
337 s.m.PrefixChangeReasonCounts[reason]++
338 }
339 }
340 }
341 if e.Kind == event.CompactionStarted {
342 s.m.Compactions++
343 }
344 if e.Kind == event.ToolResult {
345 s.recordToolResult(e.Tool)
346 }
347 if e.Kind == event.Retrying {
348 s.m.Retries++
349 }
350 }
351
352 func originalTotalsFromFloatMap(totals map[string]float64) []billing.Money {
353 if len(totals) == 0 {
354 return nil
355 }
356 codes := make([]string, 0, len(totals))
357 for code := range totals {
358 codes = append(codes, code)
359 }
360 sort.Strings(codes)
361 out := make([]billing.Money, 0, len(codes))
362 for _, code := range codes {
363 out = append(out, billing.MoneyOf(billing.NewAmountFromFloat(totals[code]), code))
364 }
365 return out
366 }
367
368 // recordSource buckets one model call by its origin. An empty source means the
369 // executor, per the Usage event contract. An unrecognised source is kept under
370 // its own key rather than dropped, so a future origin cannot silently vanish
371 // from a total that is meant to reconcile.
372 func (s *metricsSink) recordSource(source string, prompt, completion int, cost float64, q *billing.CostQuote) {
373 if strings.TrimSpace(source) == "" {
374 source = event.UsageSourceExecutor
375 }
376 if s.m.UsageBySource == nil {
377 s.m.UsageBySource = map[string]SourceUsage{}
378 }
379 agg := s.m.UsageBySource[source]
380 agg.Calls++
381 agg.PromptTokens += prompt
382 agg.CompletionTokens += completion
383 agg.Cost += cost
384 if q != nil {
385 cur := billing.NormalizeCurrency(q.Original.Currency)
386 if cur != "" {
387 if agg.OriginalCosts == nil {
388 agg.OriginalCosts = map[string]float64{}
389 }
390 agg.OriginalCosts[cur] += q.Original.Float64()
391 }
392 }
393 s.m.UsageBySource[source] = agg
394 }
395
396 // recordToolResult attributes a finished call by the name the model emitted,
397 // not Tool.ResolvedName — a wasted call is a wrong model decision, and the
398 // proxy target it resolved to would hide which name was picked.
399 func (s *metricsSink) recordToolResult(t event.Tool) {
400 name := strings.TrimSpace(t.Name)
401 if name == "" {
402 name = "unknown"
403 }
404 s.m.ToolCalls++
405 s.m.ToolDurationMs += t.DurationMs
406 if t.ParentID != "" {
407 s.m.SubagentToolCalls++
408 }
409 if s.m.ToolCallsByName == nil {
410 s.m.ToolCallsByName = map[string]int{}
411 }
412 s.m.ToolCallsByName[name]++
413 if t.Err == "" {
414 return
415 }
416 s.m.ToolFailures++
417 if s.m.ToolFailuresByName == nil {
418 s.m.ToolFailuresByName = map[string]int{}
419 }
420 s.m.ToolFailuresByName[name]++
421 }
422
423 // RecordDelegationAudit folds one finished child run into the arm totals.
424 func (s *metricsSink) RecordDelegationAudit(a evidence.DelegationAudit) {
425 if s == nil {
426 return
427 }
428 s.mu.Lock()
429 s.m.SubagentRuns++
430 if a.Depth > 1 {
431 s.m.SubagentNestedRuns++
432 }
433 s.m.SubagentMutations += a.Mutations
434 s.m.WriteScopeViolations += a.ClaimViolations
435 s.m.CriterionDowngrades += a.Downgrades
436 // Summed, never averaged: an independence rate is a ratio of these totals,
437 // so a child that read two files cannot outweigh one that read forty.
438 s.m.ParentScopeHints += a.ParentScopeHints
439 s.m.ParentNamedFiles += a.ParentNamedFiles
440 s.m.ChildEvidencePaths += a.EvidencePaths
441 s.m.ChildDiscoveredPaths += a.DiscoveredPaths
442 if a.HasReport {
443 s.m.CompletionReports++
444 } else {
445 s.m.CompletionsProsedOnly++
446 }
447 if a.FalseCompletion() {
448 s.m.FalseCompletions++
449 }
450 if s.childMutations == nil {
451 s.childMutations = map[string]int{}
452 }
453 for _, path := range a.MutationPaths {
454 s.childMutations[path]++
455 if s.childMutations[path] == 2 {
456 s.m.DuplicateWorkPaths++
457 }
458 }
459 s.mu.Unlock()
460 event.RecordDelegationAudit(s.inner, a)
461 }
462
463 func (s *metricsSink) RecordReadinessAudit(a evidence.ReadinessAudit) {
464 if s == nil {
465 return
466 }
467 s.mu.Lock()
468 s.m.ReadinessChecks++
469 switch a.Result {
470 case evidence.ReadinessAllowed:
471 s.m.ReadinessAllowed++
472 case evidence.ReadinessBlocked:
473 s.m.ReadinessBlocks++
474 case evidence.ReadinessErrored:
475 s.m.ReadinessErrors++
476 }
477 if a.Recovered {
478 s.m.ReadinessRecoveries++
479 }
480 s.m.ReadinessMissingProjectChecks += a.MissingProjectChecks
481 s.m.ReadinessIncompleteTodos += a.IncompleteTodos
482 s.m.ReadinessCommandMismatches += a.CommandMismatchMissing
483 s.m.ReadinessMissingAcceptance += a.MissingAcceptanceCriteria
484 s.m.ReadinessMissingVerification += a.MissingVerification
485 s.m.ReadinessMissingReview += a.MissingReview
486 s.m.ReadinessMissingSignoff += a.MissingSignoff
487 s.m.ReadinessMissingActionEvidence += a.MissingActionEvidence
488 s.m.ReadinessMissingMutation += a.MissingMutation
489 s.mu.Unlock()
490 event.RecordReadinessAudit(s.inner, a)
491 }
492
493 func (s *metricsSink) RecordAnchorSafetyAudit(a event.AnchorSafetyAudit) {
494 if s == nil {
495 return
496 }
497 event.RecordAnchorSafetyAudit(s.inner, a)
498 }
499
500 func (s *metricsSink) RecordProtocolRecovery(a event.ProtocolRecoveryAudit) {
501 s.mu.Lock()
502 switch a.Kind {
503 case event.ProtocolRecoveryMissingReasoningDetected:
504 s.m.MissingReasoningDetected++
505 case event.ProtocolRecoveryMissingReasoningRetryAttempted:
506 s.m.MissingReasoningRetries++
507 // Usage from the original and recovery responses is intentionally merged
508 // into one invisible UI event, but Steps remains a true model-call count.
509 s.m.Steps++
510 case event.ProtocolRecoveryMissingReasoningRetryRecovered:
511 s.m.MissingReasoningRecovered++
512 case event.ProtocolRecoveryMissingReasoningRetryReplaced:
513 s.m.MissingReasoningReplaced++
514 case event.ProtocolRecoveryMissingReasoningRetrySuppressed:
515 s.m.MissingReasoningSuppressed++
516 case event.ProtocolRecoveryMissingReasoningFallback:
517 s.m.MissingReasoningFallbacks++
518 }
519 s.mu.Unlock()
520 event.RecordProtocolRecovery(s.inner, a)
521 }
522
523 func (s *metricsSink) RecordTurnCompletion() {
524 event.RecordTurnCompletion(s.inner)
525 }
526
527 func (s *metricsSink) RecordContractShadow(a event.ContractShadowAudit) {
528 event.RecordContractShadow(s.inner, a)
529 }
530
531 func (s *metricsSink) RecordCompletionReport(a event.CompletionReportAudit) {
532 event.RecordCompletionReport(s.inner, a)
533 }
534
535 func (s *metricsSink) RecordMemoryRecall(a event.MemoryRecallAudit) {
536 event.RecordMemoryRecall(s.inner, a)
537 }
538
539 func (s *metricsSink) RecordDelegationAdmission(a event.DelegationAdmissionAudit) {
540 event.RecordDelegationAdmission(s.inner, a)
541 }
542
543 func (s *metricsSink) RecordOutcomeProgress(sample evidence.OutcomeSample) {
544 event.RecordOutcomeProgress(s.inner, sample)
545 }
546
547 func (s *metricsSink) RecordWorkspaceMutation(m event.WorkspaceMutation) {
548 event.RecordWorkspaceMutation(s.inner, m)
549 }
550
551 func (s *metricsSink) RecordRunBudget(sample event.RunBudgetSample) {
552 event.RecordRunBudget(s.inner, sample)
553 }
554
555 func (s *metricsSink) RecordSubagentLifecycle(info event.SubagentLifecycleInfo) {
556 event.RecordSubagentLifecycle(s.inner, info)
557 }
558
559 // MergeCapabilityAudit copies a capability audit snapshot plus process-local
560 // MCP tools/list stats into RunMetrics.
561 func (m *RunMetrics) MergeCapabilityAudit(snap *capability.Audit) {
562 if m == nil || snap == nil {
563 return
564 }
565 m.MergeCapabilityAuditCounters(
566 snap.Routes, snap.RoutedCandidates, snap.RoutedRequire, snap.RoutedPrefer, snap.RoutedSuggest, snap.Declines,
567 snap.SemanticRoutes, snap.SemanticFallbacks,
568 snap.RequireMissing, snap.RequireRecovered, snap.PreferMissing, snap.PreferRecovered,
569 snap.SkillInvocations, snap.SkillFailures, snap.SkillUnavailable,
570 snap.MCPInspect, snap.MCPCall, snap.MCPCallFailures,
571 snap.ReviewBlocks, snap.SecurityReviewBlocks,
572 snap.RouterPromptTokens, snap.RouterCompletionTokens,
573 snap.RouterCost, snap.RouterLatencyMs,
574 )
575 m.CapabilityDiscovery.Lists += snap.Discovery.Lists
576 m.CapabilityDiscovery.Searches += snap.Discovery.Searches
577 m.CapabilityDiscovery.Inspects += snap.Discovery.Inspects
578 m.CapabilityDiscovery.ResultCount += snap.Discovery.ResultCount
579 m.CapabilityDiscovery.ResultBytes += snap.Discovery.ResultBytes
580 m.CapabilityDiscovery.NetworkCalls += snap.Discovery.NetworkCalls
581 m.CapabilityArguments.Validations += snap.Arguments.Validations
582 m.CapabilityArguments.Fail += snap.Arguments.Fail
583 m.CapabilityArguments.Skip += snap.Arguments.Skip
584 m.CapabilityArguments.RemoteDispatch += snap.Arguments.RemoteDispatch
585 m.CapabilityLoopGuard.RepeatFailures += snap.LoopGuard.RepeatFailures
586 m.CapabilityLoopGuard.RepeatClarifications += snap.LoopGuard.RepeatClarifications
587 m.CapabilityLoopGuard.SoftBudgetNudges += snap.LoopGuard.SoftBudgetNudges
588 m.CapabilityLoopGuard.BlockedCalls += snap.LoopGuard.BlockedCalls
589 m.CapabilityMCPLists.SharedHost += snap.MCPLists.SharedHost
590 m.CapabilityMCPLists.DiskCache += snap.MCPLists.DiskCache
591 m.CapabilityMCPLists.Remote += snap.MCPLists.Remote
592 m.CapabilityMCPLists.DurationMs += snap.MCPLists.DurationMs
593 m.CapabilityMCPLists.ToolCount += snap.MCPLists.ToolCount
594 m.CapabilityMCPLists.SchemaBytes += snap.MCPLists.SchemaBytes
595 if len(snap.MCPLists.Triggers) > 0 {
596 if m.CapabilityMCPLists.Triggers == nil {
597 m.CapabilityMCPLists.Triggers = map[string]int{}
598 }
599 for trigger, count := range snap.MCPLists.Triggers {
600 m.CapabilityMCPLists.Triggers[trigger] += count
601 }
602 }
603 m.CapabilityToolExec.Calls += snap.ToolExec.Calls
604 m.CapabilityToolExec.ReadOnly += snap.ToolExec.ReadOnly
605 m.CapabilityToolExec.Parallel += snap.ToolExec.Parallel
606 m.CapabilityToolExec.QueueMs += snap.ToolExec.QueueMs
607 m.CapabilityToolExec.ExecMs += snap.ToolExec.ExecMs
608 m.CapabilityToolExec.RawBytes += snap.ToolExec.RawBytes
609 m.CapabilityToolExec.VisibleBytes += snap.ToolExec.VisibleBytes
610 m.CapabilityPhases.ProviderWaitMs += snap.Phases.ProviderWaitMs
611 m.CapabilityPhases.ToolExecMs += snap.Phases.ToolExecMs
612 m.CapabilityPhases.SubagentWaitMs += snap.Phases.SubagentWaitMs
613 m.CapabilityPhases.UserWaitMs += snap.Phases.UserWaitMs
614 m.CapabilityPhases.CompactMs += snap.Phases.CompactMs
615 m.CapabilityPhases.ReviewMs += snap.Phases.ReviewMs
616 }
617
618 // MergeCapabilityAuditCounters copies capability counters into RunMetrics.
619 func (m *RunMetrics) MergeCapabilityAuditCounters(
620 routes, routedCandidates, routedRequire, routedPrefer, routedSuggest, declines int,
621 semantic, fallbacks, requireMiss, requireRec, preferMiss, preferRec int,
622 skillInv, skillFail, skillUnavail int,
623 mcpInspect, mcpCall, mcpFail int,
624 reviewBlocks, securityBlocks int,
625 routerPrompt, routerCompletion int,
626 routerCost float64,
627 routerLatencyMs int64,
628 ) {
629 if m == nil {
630 return
631 }
632 m.CapabilityRoutes += routes
633 m.CapabilityRoutedCandidates += routedCandidates
634 m.CapabilityRoutedRequire += routedRequire
635 m.CapabilityRoutedPrefer += routedPrefer
636 m.CapabilityRoutedSuggest += routedSuggest
637 m.CapabilityDeclines += declines
638 m.CapabilitySemanticRoutes += semantic
639 m.CapabilitySemanticFallbacks += fallbacks
640 m.CapabilityRequireMissing += requireMiss
641 m.CapabilityRequireRecovered += requireRec
642 m.CapabilityPreferMissing += preferMiss
643 m.CapabilityPreferRecovered += preferRec
644 m.CapabilitySkillInvocations += skillInv
645 m.CapabilitySkillFailures += skillFail
646 m.CapabilitySkillUnavailable += skillUnavail
647 m.CapabilityMCPInspect += mcpInspect
648 m.CapabilityMCPCall += mcpCall
649 m.CapabilityMCPCallFailures += mcpFail
650 m.CapabilityReviewBlocks += reviewBlocks
651 m.CapabilitySecurityReviewBlocks += securityBlocks
652 m.CapabilityRouterPromptTokens += routerPrompt
653 m.CapabilityRouterCompletionTok += routerCompletion
654 m.CapabilityRouterCost += routerCost
655 m.CapabilityRouterLatencyMs += routerLatencyMs
656 }
657
658 // partialMetricsPath is the sidecar an unfinished run leaves behind. It is a
659 // distinct filename so a reader that predates snapshots cannot mistake one for
660 // a final record.
661 func partialMetricsPath(path string) string { return path + ".partial" }
662
663 // writeMetrics publishes the final record and retires the sidecar, so the two
664 // can never both be read and double-counted. The final file is written first:
665 // if the process dies between the two steps, a stale partial alongside a
666 // complete final is resolvable, whereas the reverse would lose everything.
667 func writeMetrics(path string, m RunMetrics) error {
668 m.Complete = true
669 b, err := json.MarshalIndent(m, "", " ")
670 if err != nil {
671 return err
672 }
673 if err := fileutil.AtomicWriteFile(path, b, 0o644); err != nil {
674 return err
675 }
676 _ = os.Remove(partialMetricsPath(path))
677 return nil
678 }
679
679 lines GO