| 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 |