| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "strings" |
| 8 | |
| 9 | "reasonix/internal/capability" |
| 10 | "reasonix/internal/evidence" |
| 11 | "reasonix/internal/skill" |
| 12 | "reasonix/internal/tool" |
| 13 | ) |
| 14 | |
| 15 | // SeedCapabilityRoute installs the turn's route decision into the capability ledger. |
| 16 | func (a *Agent) SeedCapabilityRoute(decision capability.RouteDecision) { |
| 17 | if a == nil { |
| 18 | return |
| 19 | } |
| 20 | if a.capabilityLedger == nil { |
| 21 | a.capabilityLedger = capability.NewLedger() |
| 22 | } |
| 23 | a.capabilityLedger.Reset() |
| 24 | a.capabilityLedger.SeedCandidates(decision) |
| 25 | a.capabilityPreferReminded = false |
| 26 | a.capabilityRequireMissSeen = false |
| 27 | a.capabilityPreferMissSeen = false |
| 28 | } |
| 29 | |
| 30 | // CapabilityLedger returns the turn-scoped capability ledger (may be nil). |
| 31 | func (a *Agent) CapabilityLedger() *capability.Ledger { |
| 32 | if a == nil { |
| 33 | return nil |
| 34 | } |
| 35 | return a.capabilityLedger |
| 36 | } |
| 37 | |
| 38 | // CapabilityAudit returns the non-persisted capability metrics sink (may be nil). |
| 39 | func (a *Agent) CapabilityAudit() *capability.Audit { |
| 40 | if a == nil { |
| 41 | return nil |
| 42 | } |
| 43 | return a.capabilityAudit |
| 44 | } |
| 45 | |
| 46 | func (a *Agent) noteCapabilityInvocation(toolName string, args json.RawMessage, callErr error) { |
| 47 | if a == nil || a.capabilityLedger == nil { |
| 48 | return |
| 49 | } |
| 50 | // Successful/failed proxied MCP calls execute the resolved target |
| 51 | // directly, so this is the single audit point for action=call (inspect, |
| 52 | // decline, and resolve-time unavailability are counted in ResolveCall, |
| 53 | // which returns before this runs). |
| 54 | if toolName == "use_capability" && a.capabilityAudit != nil { |
| 55 | var p struct { |
| 56 | Action string `json:"action"` |
| 57 | } |
| 58 | _ = json.Unmarshal(args, &p) |
| 59 | if strings.EqualFold(strings.TrimSpace(p.Action), "call") { |
| 60 | a.capabilityAudit.RecordMCPProxy(false, true, callErr != nil) |
| 61 | } |
| 62 | } |
| 63 | id := capabilityIDFromToolCall(toolName, args) |
| 64 | if id == "" { |
| 65 | return |
| 66 | } |
| 67 | if callErr != nil { |
| 68 | a.capabilityLedger.MarkFailed(id, callErr.Error()) |
| 69 | if a.capabilityAudit != nil && strings.HasPrefix(id, "skill:") { |
| 70 | a.capabilityAudit.RecordSkill(true, errors.Is(callErr, skill.ErrInvocationUnavailable)) |
| 71 | } |
| 72 | return |
| 73 | } |
| 74 | a.capabilityLedger.MarkSucceeded(id) |
| 75 | if a.capabilityAudit != nil && strings.HasPrefix(id, "skill:") { |
| 76 | a.capabilityAudit.RecordSkill(false, false) |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | func capabilityIDFromToolCall(toolName string, args json.RawMessage) string { |
| 81 | switch toolName { |
| 82 | case "run_skill", "read_skill", "read_only_skill", "explore", "research", "review", "security_review": |
| 83 | var p struct { |
| 84 | Name string `json:"name"` |
| 85 | } |
| 86 | _ = json.Unmarshal(args, &p) |
| 87 | name := strings.TrimSpace(p.Name) |
| 88 | if name == "" { |
| 89 | // Dedicated wrappers use the tool name as the skill name. |
| 90 | switch toolName { |
| 91 | case "explore", "research", "review", "security_review": |
| 92 | name = toolName |
| 93 | } |
| 94 | } |
| 95 | if name == "security_review" { |
| 96 | name = "security-review" |
| 97 | } |
| 98 | if name == "" { |
| 99 | return "" |
| 100 | } |
| 101 | return "skill:" + name |
| 102 | case "use_capability": |
| 103 | var p struct { |
| 104 | CapabilityID string `json:"capability_id"` |
| 105 | } |
| 106 | _ = json.Unmarshal(args, &p) |
| 107 | return strings.TrimSpace(p.CapabilityID) |
| 108 | default: |
| 109 | if server, raw, ok := splitMCP(toolName); ok { |
| 110 | return "mcp-tool:" + server + "/" + raw |
| 111 | } |
| 112 | } |
| 113 | return "" |
| 114 | } |
| 115 | |
| 116 | func splitMCP(name string) (server, raw string, ok bool) { |
| 117 | const prefix = "mcp__" |
| 118 | if !strings.HasPrefix(name, prefix) { |
| 119 | return "", "", false |
| 120 | } |
| 121 | rest := name[len(prefix):] |
| 122 | parts := strings.SplitN(rest, "__", 2) |
| 123 | if len(parts) != 2 || parts[0] == "" || parts[1] == "" { |
| 124 | return "", "", false |
| 125 | } |
| 126 | return parts[0], parts[1], true |
| 127 | } |
| 128 | |
| 129 | // capabilityGateFailure is checked during final readiness for Delivery. |
| 130 | func (a *Agent) capabilityGateFailure() string { |
| 131 | if a == nil || !a.deliveryProfile || a.capabilityLedger == nil { |
| 132 | return "" |
| 133 | } |
| 134 | gate := a.capabilityLedger.CheckFinalGate() |
| 135 | if gate.Reason == "" { |
| 136 | // A clean gate after an earlier miss this turn is a recovery — the |
| 137 | // model was nudged and then actually invoked the capability. |
| 138 | if a.capabilityRequireMissSeen || a.capabilityPreferMissSeen { |
| 139 | if a.capabilityAudit != nil { |
| 140 | a.capabilityAudit.RecordGateRecovery(a.capabilityRequireMissSeen, a.capabilityPreferMissSeen) |
| 141 | } |
| 142 | a.capabilityRequireMissSeen = false |
| 143 | a.capabilityPreferMissSeen = false |
| 144 | } |
| 145 | return "" |
| 146 | } |
| 147 | if gate.PreferRemind && !a.capabilityPreferReminded { |
| 148 | for _, id := range gate.PreferIDs { |
| 149 | a.capabilityLedger.MarkReminded(id) |
| 150 | } |
| 151 | a.capabilityPreferReminded = true |
| 152 | a.capabilityPreferMissSeen = true |
| 153 | if a.capabilityAudit != nil { |
| 154 | a.capabilityAudit.RecordGate(false, true, false) |
| 155 | } |
| 156 | return gate.Reason |
| 157 | } |
| 158 | if gate.UnavailableOK { |
| 159 | // Host-proven unavailable: allow final answer that reports the blocker, |
| 160 | // but do not treat it as successful delivery. The reason is returned so |
| 161 | // the model is nudged once; if it still claims success, missing mutation |
| 162 | // / sign-off gates still apply. For pure capability blockers with no |
| 163 | // mutation, we surface the reason and allow the loop-guard path. |
| 164 | if a.capabilityAudit != nil { |
| 165 | a.capabilityAudit.RecordGate(true, false, false) |
| 166 | } |
| 167 | // Do not hard-block forever: once reported, allow final if no mutation pending. |
| 168 | if _, ok := a.evidence.LatestSuccessfulMutationIndex(); !ok { |
| 169 | return "" |
| 170 | } |
| 171 | return gate.Reason |
| 172 | } |
| 173 | if len(gate.RequireIDs) > 0 { |
| 174 | a.capabilityRequireMissSeen = true |
| 175 | if a.capabilityAudit != nil { |
| 176 | a.capabilityAudit.RecordGate(true, false, false) |
| 177 | } |
| 178 | return gate.Reason |
| 179 | } |
| 180 | if len(gate.PreferIDs) > 0 { |
| 181 | a.capabilityPreferMissSeen = true |
| 182 | if a.capabilityAudit != nil { |
| 183 | a.capabilityAudit.RecordGate(false, true, false) |
| 184 | } |
| 185 | return gate.Reason |
| 186 | } |
| 187 | return gate.Reason |
| 188 | } |
| 189 | |
| 190 | // deliveryReviewGateFailure enforces risk-adaptive structured review after the |
| 191 | // latest mutation. Low keeps the existing light review; Medium requires review; |
| 192 | // High requires review + security_review with structured reports. |
| 193 | func (a *Agent) deliveryReviewGateFailure() string { |
| 194 | if a == nil || !a.deliveryProfile || a.evidence == nil { |
| 195 | return "" |
| 196 | } |
| 197 | if a.subagentDepth > 0 { |
| 198 | // Structured review is the parent's contract. A child's mutation |
| 199 | // receipts merge into the parent ledger (mergeChildEvidence), so the |
| 200 | // parent cannot final-answer without review coverage of those writes. |
| 201 | // Demanding review_report inside a depth-capped sub-agent — which may |
| 202 | // not even have the review tools — wedges the child against a gate it |
| 203 | // cannot satisfy. The light post-mutation review (read the touched |
| 204 | // file or run git diff/status) still applies via finalReadinessCheck. |
| 205 | return "" |
| 206 | } |
| 207 | mutation, ok := a.evidence.LatestSuccessfulMutationIndex() |
| 208 | if !ok { |
| 209 | return "" |
| 210 | } |
| 211 | risk := a.evidence.MutationRiskAfter(mutation) |
| 212 | paths := productionPaths(a.evidence.PathsSince(mutation)) |
| 213 | hasReviewTool := a.tools != nil && (toolPresent(a.tools, "review") || toolPresent(a.tools, "run_skill")) |
| 214 | hasSecurityTool := a.tools != nil && (toolPresent(a.tools, "security_review") || toolPresent(a.tools, "run_skill")) |
| 215 | switch risk { |
| 216 | case evidence.RiskLow: |
| 217 | // Existing light review (read/diff) already checked elsewhere. |
| 218 | return "" |
| 219 | case evidence.RiskMedium: |
| 220 | if !hasReviewTool { |
| 221 | // Test/minimal registries without review keep the light review gate. |
| 222 | return "" |
| 223 | } |
| 224 | ok, blocking, report := a.evidence.HasStructuredReviewAfter(evidence.ReviewKindReview, mutation, paths) |
| 225 | if blocking { |
| 226 | if a.capabilityAudit != nil { |
| 227 | a.capabilityAudit.RecordReviewBlock(false) |
| 228 | } |
| 229 | return "structured review reported blocking findings; fix them and re-run review" |
| 230 | } |
| 231 | if !ok { |
| 232 | hostProof := a.evidence.HasSuccessfulDeliverySignoffAfter(mutation) && |
| 233 | a.evidence.HasHostReviewCoverageAfter(mutation, paths) |
| 234 | if !hostProof { |
| 235 | return "medium-risk changes require either a successful structured review or host-proven verification plus diff/file inspection after the latest mutation" + reviewCoverageHint(paths) |
| 236 | } |
| 237 | } |
| 238 | if report != nil { |
| 239 | a.pendingReviewWarnings = append(a.pendingReviewWarnings, report.WarningSummaries()...) |
| 240 | } |
| 241 | case evidence.RiskHigh: |
| 242 | if !hasReviewTool && !hasSecurityTool { |
| 243 | return "high-risk changes require review and security_review tools after the latest mutation" |
| 244 | } |
| 245 | okR, blockR, repR := a.evidence.HasStructuredReviewAfter(evidence.ReviewKindReview, mutation, paths) |
| 246 | if blockR { |
| 247 | if a.capabilityAudit != nil { |
| 248 | a.capabilityAudit.RecordReviewBlock(false) |
| 249 | } |
| 250 | return "structured review reported blocking findings; fix them and re-run review" |
| 251 | } |
| 252 | if !okR { |
| 253 | return "high-risk changes require review with review_report after the latest mutation" + reviewCoverageHint(paths) |
| 254 | } |
| 255 | okS, blockS, repS := a.evidence.HasStructuredReviewAfter(evidence.ReviewKindSecurity, mutation, paths) |
| 256 | if blockS { |
| 257 | if a.capabilityAudit != nil { |
| 258 | a.capabilityAudit.RecordReviewBlock(true) |
| 259 | } |
| 260 | return "security_review reported blocking findings; fix them and re-run security_review" |
| 261 | } |
| 262 | if !okS { |
| 263 | return "high-risk changes require security_review with review_report after the latest mutation" + reviewCoverageHint(paths) |
| 264 | } |
| 265 | if repR != nil { |
| 266 | a.pendingReviewWarnings = append(a.pendingReviewWarnings, repR.WarningSummaries()...) |
| 267 | } |
| 268 | if repS != nil { |
| 269 | a.pendingReviewWarnings = append(a.pendingReviewWarnings, repS.WarningSummaries()...) |
| 270 | } |
| 271 | } |
| 272 | return "" |
| 273 | } |
| 274 | |
| 275 | func reviewCoverageHint(paths []string) string { |
| 276 | if len(paths) == 0 { |
| 277 | return "; the mutation did not report file paths, so first inspect `git status --short` and `git diff` to identify the changed files, then submit reviewed_paths for the files inspected" |
| 278 | } |
| 279 | return " covering: " + strings.Join(paths, ", ") |
| 280 | } |
| 281 | |
| 282 | func toolPresent(reg *tool.Registry, name string) bool { |
| 283 | if reg == nil { |
| 284 | return false |
| 285 | } |
| 286 | _, ok := reg.Get(name) |
| 287 | return ok |
| 288 | } |
| 289 | |
| 290 | func productionPaths(paths []string) []string { |
| 291 | var out []string |
| 292 | for _, p := range paths { |
| 293 | if p == "" { |
| 294 | continue |
| 295 | } |
| 296 | // Skip pure test/doc paths for coverage requirements when mixed sets exist. |
| 297 | lower := strings.ToLower(p) |
| 298 | if strings.HasSuffix(lower, "_test.go") || strings.Contains(lower, "/docs/") { |
| 299 | continue |
| 300 | } |
| 301 | out = append(out, p) |
| 302 | } |
| 303 | if len(out) == 0 { |
| 304 | return paths |
| 305 | } |
| 306 | return out |
| 307 | } |
| 308 | |
| 309 | // ReviewWarnings returns warn-level review findings collected this turn. |
| 310 | func (a *Agent) ReviewWarnings() []string { |
| 311 | if a == nil { |
| 312 | return nil |
| 313 | } |
| 314 | return append([]string(nil), a.pendingReviewWarnings...) |
| 315 | } |
| 316 | |
| 317 | // FormatReviewWarningsForSummary builds a short appendix for the final answer. |
| 318 | func FormatReviewWarningsForSummary(warnings []string) string { |
| 319 | if len(warnings) == 0 { |
| 320 | return "" |
| 321 | } |
| 322 | return "Review warnings:\n- " + strings.Join(warnings, "\n- ") |
| 323 | } |
| 324 | |
| 325 | // ensure string used |
| 326 | var _ = fmt.Sprintf |
| 327 |