返回 DeepSeek-Reasonix
goalusage.go
根目录 / internal / control / goalusage.go
1 package control
2
3 import (
4 "sync"
5
6 "reasonix/internal/event"
7 "reasonix/internal/evidence"
8 "reasonix/internal/provider"
9 )
10
11 // goalUsageTee wraps the controller's event sink and attributes billable usage
12 // events to the active goal turn's recorder, so every model request under the
13 // same Goal scope — executor, planner, subagent, compaction, classifier,
14 // capability router, recovery reviewer, and goal evaluator — accumulates into
15 // the goal's observational token total. There is no token hard limit; the
16 // total is for display and diagnostics only. Title generation and unrelated
17 // background calls are excluded. The tee forwards every event unchanged.
18 type goalUsageTee struct {
19 inner event.Sink
20 mu sync.Mutex
21 // active is the current goal turn's recorder; nil when no goal turn is
22 // running. Writes happen on the turn goroutine; the tee serializes reads.
23 active *goalTurnRecorder
24 }
25
26 // NewGoalUsageTee wraps inner in a usage-accounting tee. Pass the returned sink
27 // to both the agent/executor and the Controller (control.New detects it and
28 // attaches the goal machine).
29 func NewGoalUsageTee(inner event.Sink) event.Sink {
30 if inner == nil {
31 inner = event.Discard
32 }
33 return &goalUsageTee{inner: inner}
34 }
35
36 // Emit forwards the event and, for billable usage while a goal turn is active,
37 // folds the tokens into the turn recorder.
38 func (t *goalUsageTee) Emit(e event.Event) {
39 if t == nil {
40 return
41 }
42 if e.Kind == event.Usage && e.Usage != nil && e.UsageSource != event.UsageSourceTitle {
43 t.mu.Lock()
44 rec := t.active
45 t.mu.Unlock()
46 if rec != nil {
47 rec.addUsage(usageTotalTokens(e.Usage))
48 }
49 }
50 if t.inner != nil {
51 t.inner.Emit(e)
52 }
53 }
54
55 // RecordTurnCompletion forwards the optional completion accounting to the
56 // inner sink when it opts in, so wrapping the sink never loses lifecycle
57 // bookkeeping.
58 func (t *goalUsageTee) RecordTurnCompletion() {
59 if t == nil || t.inner == nil {
60 return
61 }
62 if ts, ok := t.inner.(event.TurnCompletionSink); ok {
63 ts.RecordTurnCompletion()
64 }
65 }
66
67 // RecordReadinessAudit forwards the optional readiness audit receipts.
68 func (t *goalUsageTee) RecordReadinessAudit(a evidence.ReadinessAudit) {
69 if t == nil || t.inner == nil {
70 return
71 }
72 if rs, ok := t.inner.(event.ReadinessAuditSink); ok {
73 rs.RecordReadinessAudit(a)
74 }
75 }
76
77 // setActiveRecorder binds the current goal turn's recorder (nil clears it).
78 func (t *goalUsageTee) setActiveRecorder(rec *goalTurnRecorder) {
79 if t == nil {
80 return
81 }
82 t.mu.Lock()
83 t.active = rec
84 t.mu.Unlock()
85 }
86
87 // activeRecorder returns the current goal turn's recorder, if any.
88 func (t *goalUsageTee) activeRecorder() *goalTurnRecorder {
89 if t == nil {
90 return nil
91 }
92 t.mu.Lock()
93 defer t.mu.Unlock()
94 return t.active
95 }
96
97 // usageTotalTokens prefers TotalTokens and falls back to the non-overlapping
98 // prompt + completion sum, so cache hit/miss tokens are never double-counted.
99 func usageTotalTokens(u *provider.Usage) int {
100 if u == nil {
101 return 0
102 }
103 if u.TotalTokens > 0 {
104 return u.TotalTokens
105 }
106 return u.PromptTokens + u.CompletionTokens
107 }
108
108 lines GO