返回 DeepSeek-Reasonix
ledger.go
根目录 / internal / capability / ledger.go
1 package capability
2
3 import (
4 "strings"
5 "sync"
6 "time"
7 )
8
9 // Outcome tracks what happened for a routed capability this turn.
10 type Outcome string
11
12 const (
13 OutcomePending Outcome = "pending"
14 OutcomeInvoked Outcome = "invoked"
15 OutcomeSucceeded Outcome = "succeeded"
16 OutcomeFailed Outcome = "failed"
17 OutcomeUnavailable Outcome = "unavailable"
18 OutcomeDeclined Outcome = "declined"
19 )
20
21 // LedgerEntry is one turn-scoped capability tracking record.
22 type LedgerEntry struct {
23 ID string
24 Policy AutoUse
25 Reason string
26 Outcome Outcome
27 FailureReason string
28 DeclinedReason string
29 InvokedAt time.Time
30 Reminded bool // prefer: host already issued one retry reminder
31 }
32
33 // Ledger records capability route candidates and host-proven outcomes for one turn.
34 type Ledger struct {
35 mu sync.Mutex
36 entries map[string]*LedgerEntry
37 order []string
38 }
39
40 // NewLedger builds an empty turn-scoped capability ledger.
41 func NewLedger() *Ledger {
42 return &Ledger{entries: map[string]*LedgerEntry{}}
43 }
44
45 // Reset clears the ledger between user turns.
46 func (l *Ledger) Reset() {
47 if l == nil {
48 return
49 }
50 l.mu.Lock()
51 defer l.mu.Unlock()
52 l.entries = map[string]*LedgerEntry{}
53 l.order = nil
54 }
55
56 // SeedCandidates records the route decision for this turn.
57 func (l *Ledger) SeedCandidates(decision RouteDecision) {
58 if l == nil {
59 return
60 }
61 l.mu.Lock()
62 defer l.mu.Unlock()
63 for _, c := range decision.Candidates {
64 id := c.Entry.ID
65 if id == "" {
66 continue
67 }
68 if _, ok := l.entries[id]; ok {
69 // Keep strongest policy.
70 if rank(c.Policy) > rank(l.entries[id].Policy) {
71 l.entries[id].Policy = c.Policy
72 l.entries[id].Reason = c.Reason
73 }
74 continue
75 }
76 l.entries[id] = &LedgerEntry{
77 ID: id,
78 Policy: c.Policy,
79 Reason: c.Reason,
80 Outcome: OutcomePending,
81 }
82 l.order = append(l.order, id)
83 }
84 }
85
86 // MarkInvoked records that the agent called a capability.
87 func (l *Ledger) MarkInvoked(id string) {
88 l.setOutcome(id, OutcomeInvoked, "", "")
89 }
90
91 // MarkSucceeded records a successful capability call.
92 func (l *Ledger) MarkSucceeded(id string) {
93 l.setOutcome(id, OutcomeSucceeded, "", "")
94 }
95
96 // MarkFailed records a failed capability call with host-proven detail.
97 func (l *Ledger) MarkFailed(id, reason string) {
98 l.setOutcome(id, OutcomeFailed, reason, "")
99 }
100
101 // MarkUnavailable records a host-proven unavailable state.
102 func (l *Ledger) MarkUnavailable(id, reason string) {
103 l.setOutcome(id, OutcomeUnavailable, reason, "")
104 }
105
106 // MarkDeclined records a prefer decline with a non-empty reason.
107 func (l *Ledger) MarkDeclined(id, reason string) error {
108 reason = strings.TrimSpace(reason)
109 if reason == "" {
110 return errEmptyDecline
111 }
112 l.setOutcome(id, OutcomeDeclined, "", reason)
113 return nil
114 }
115
116 // MarkReminded records that prefer was missing once and the host reminded.
117 func (l *Ledger) MarkReminded(id string) {
118 if l == nil {
119 return
120 }
121 l.mu.Lock()
122 defer l.mu.Unlock()
123 if e, ok := l.entries[id]; ok {
124 e.Reminded = true
125 }
126 }
127
128 func (l *Ledger) setOutcome(id string, outcome Outcome, failReason, declineReason string) {
129 if l == nil {
130 return
131 }
132 id = strings.TrimSpace(id)
133 if id == "" {
134 return
135 }
136 l.mu.Lock()
137 defer l.mu.Unlock()
138 e, ok := l.entries[id]
139 if !ok {
140 e = &LedgerEntry{ID: id, Policy: AutoUseSuggest, Outcome: OutcomePending}
141 l.entries[id] = e
142 l.order = append(l.order, id)
143 }
144 // Terminal outcomes stick; invoked upgrades pending.
145 switch e.Outcome {
146 case OutcomeSucceeded, OutcomeUnavailable, OutcomeDeclined:
147 if outcome == OutcomeSucceeded || outcome == OutcomeUnavailable {
148 e.Outcome = outcome
149 }
150 default:
151 e.Outcome = outcome
152 }
153 if failReason != "" {
154 e.FailureReason = failReason
155 }
156 if declineReason != "" {
157 e.DeclinedReason = declineReason
158 }
159 if outcome == OutcomeInvoked || outcome == OutcomeSucceeded || outcome == OutcomeFailed {
160 e.InvokedAt = time.Now()
161 }
162 }
163
164 // Snapshot returns a copy of ledger entries in seed order.
165 func (l *Ledger) Snapshot() []LedgerEntry {
166 if l == nil {
167 return nil
168 }
169 l.mu.Lock()
170 defer l.mu.Unlock()
171 out := make([]LedgerEntry, 0, len(l.order))
172 for _, id := range l.order {
173 if e, ok := l.entries[id]; ok {
174 out = append(out, *e)
175 }
176 }
177 return out
178 }
179
180 // Get returns one entry by ID.
181 func (l *Ledger) Get(id string) (LedgerEntry, bool) {
182 if l == nil {
183 return LedgerEntry{}, false
184 }
185 l.mu.Lock()
186 defer l.mu.Unlock()
187 e, ok := l.entries[strings.TrimSpace(id)]
188 if !ok {
189 return LedgerEntry{}, false
190 }
191 return *e, true
192 }
193
194 // GateFailure returns a non-empty reason when final answer must be blocked for
195 // capability policy. preferMissingAllowReminder is true on the first prefer gap.
196 type GateFailure struct {
197 Reason string
198 PreferRemind bool
199 PreferIDs []string
200 RequireIDs []string
201 UnavailableOK bool // require is host-unavailable; may end with blocker, not success claim
202 }
203
204 // CheckFinalGate evaluates require/prefer policy for the final answer.
205 func (l *Ledger) CheckFinalGate() GateFailure {
206 if l == nil {
207 return GateFailure{}
208 }
209 l.mu.Lock()
210 defer l.mu.Unlock()
211 var requireMissing, preferMissing, unavailable []string
212 var preferRemind []string
213 for _, id := range l.order {
214 e := l.entries[id]
215 if e == nil {
216 continue
217 }
218 switch e.Policy {
219 case AutoUseRequire:
220 switch e.Outcome {
221 case OutcomeSucceeded:
222 // ok
223 case OutcomeUnavailable:
224 unavailable = append(unavailable, id+": "+e.FailureReason)
225 default:
226 requireMissing = append(requireMissing, id)
227 }
228 case AutoUsePrefer:
229 switch e.Outcome {
230 case OutcomeSucceeded, OutcomeDeclined, OutcomeUnavailable:
231 // ok
232 default:
233 if !e.Reminded {
234 preferRemind = append(preferRemind, id)
235 } else {
236 preferMissing = append(preferMissing, id)
237 }
238 }
239 }
240 }
241 if len(requireMissing) > 0 {
242 return GateFailure{
243 Reason: "required capabilities not successfully invoked: " + strings.Join(requireMissing, ", "),
244 RequireIDs: requireMissing,
245 }
246 }
247 if len(unavailable) > 0 {
248 return GateFailure{
249 Reason: "required capabilities unavailable (host-proven): " + strings.Join(unavailable, "; "),
250 UnavailableOK: true,
251 }
252 }
253 if len(preferRemind) > 0 {
254 return GateFailure{
255 Reason: "preferred capabilities not yet used; call them or use_capability(action=\"decline\", reason=...): " + strings.Join(preferRemind, ", "),
256 PreferRemind: true,
257 PreferIDs: preferRemind,
258 }
259 }
260 if len(preferMissing) > 0 {
261 return GateFailure{
262 Reason: "preferred capabilities still unused after reminder; call them or decline with a non-empty reason: " + strings.Join(preferMissing, ", "),
263 PreferIDs: preferMissing,
264 }
265 }
266 return GateFailure{}
267 }
268
269 var errEmptyDecline = errString("decline reason must be non-empty")
270
271 type errString string
272
273 func (e errString) Error() string { return string(e) }
274
274 lines GO