| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "strings" |
| 8 | |
| 9 | "reasonix/internal/capability" |
| 10 | "reasonix/internal/skill" |
| 11 | ) |
| 12 | |
| 13 | // capabilityGateState is one user turn's gate memory, scoped to the same turn |
| 14 | // as the ledger it reads: whether the prefer reminder has already been spent, |
| 15 | // and which kind of miss was reported, so a later clean gate is audited as a |
| 16 | // recovery instead of a first pass. Zeroing the struct is the turn reset. |
| 17 | type capabilityGateState struct { |
| 18 | } |
| 19 | |
| 20 | // SeedCapabilityRoute installs the turn's route decision into the capability ledger. |
| 21 | func (a *Agent) SeedCapabilityRoute(decision capability.RouteDecision) { |
| 22 | if a == nil { |
| 23 | return |
| 24 | } |
| 25 | if a.capabilityLedger == nil { |
| 26 | a.capabilityLedger = capability.NewLedger() |
| 27 | } |
| 28 | a.capabilityLedger.Reset() |
| 29 | a.capabilityLedger.SeedCandidates(decision) |
| 30 | a.capabilityGate = capabilityGateState{} |
| 31 | } |
| 32 | |
| 33 | // CapabilityLedger returns the turn-scoped capability ledger (may be nil). |
| 34 | func (a *Agent) CapabilityLedger() *capability.Ledger { |
| 35 | if a == nil { |
| 36 | return nil |
| 37 | } |
| 38 | return a.capabilityLedger |
| 39 | } |
| 40 | |
| 41 | // CapabilityAudit returns the non-persisted capability metrics sink (may be nil). |
| 42 | func (a *Agent) CapabilityAudit() *capability.Audit { |
| 43 | if a == nil { |
| 44 | return nil |
| 45 | } |
| 46 | return a.capabilityAudit |
| 47 | } |
| 48 | |
| 49 | func (a *Agent) noteCapabilityInvocation(toolName string, args json.RawMessage, callErr error) { |
| 50 | if a == nil || a.capabilityLedger == nil { |
| 51 | return |
| 52 | } |
| 53 | // Successful/failed proxied MCP calls execute the resolved target |
| 54 | // directly, so this is the single audit point for action=call (inspect, |
| 55 | // decline, and resolve-time unavailability are counted in ResolveCall, |
| 56 | // which returns before this runs). |
| 57 | if toolName == "use_capability" && a.capabilityAudit != nil { |
| 58 | var p struct { |
| 59 | Action string `json:"action"` |
| 60 | } |
| 61 | _ = json.Unmarshal(args, &p) |
| 62 | if strings.EqualFold(strings.TrimSpace(p.Action), "call") { |
| 63 | a.capabilityAudit.RecordMCPProxy(false, true, callErr != nil) |
| 64 | } |
| 65 | } |
| 66 | id := capabilityIDFromToolCall(toolName, args) |
| 67 | if id == "" { |
| 68 | return |
| 69 | } |
| 70 | if callErr != nil { |
| 71 | a.capabilityLedger.MarkFailed(id, callErr.Error()) |
| 72 | if a.capabilityAudit != nil && strings.HasPrefix(id, "skill:") { |
| 73 | a.capabilityAudit.RecordSkill(true, errors.Is(callErr, skill.ErrInvocationUnavailable)) |
| 74 | } |
| 75 | return |
| 76 | } |
| 77 | a.capabilityLedger.MarkSucceeded(id) |
| 78 | if a.capabilityAudit != nil && strings.HasPrefix(id, "skill:") { |
| 79 | a.capabilityAudit.RecordSkill(false, false) |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | func capabilityIDFromToolCall(toolName string, args json.RawMessage) string { |
| 84 | switch toolName { |
| 85 | case "run_skill", "read_skill", "read_only_skill", "explore", "research", "review", "security_review": |
| 86 | var p struct { |
| 87 | Name string `json:"name"` |
| 88 | } |
| 89 | _ = json.Unmarshal(args, &p) |
| 90 | name := strings.TrimSpace(p.Name) |
| 91 | if name == "" { |
| 92 | // Dedicated wrappers use the tool name as the skill name. |
| 93 | switch toolName { |
| 94 | case "explore", "research", "review", "security_review": |
| 95 | name = toolName |
| 96 | } |
| 97 | } |
| 98 | if name == "security_review" { |
| 99 | name = "security-review" |
| 100 | } |
| 101 | if name == "" { |
| 102 | return "" |
| 103 | } |
| 104 | return "skill:" + name |
| 105 | case "use_capability": |
| 106 | var p struct { |
| 107 | CapabilityID string `json:"capability_id"` |
| 108 | } |
| 109 | _ = json.Unmarshal(args, &p) |
| 110 | return strings.TrimSpace(p.CapabilityID) |
| 111 | default: |
| 112 | if server, raw, ok := splitMCP(toolName); ok { |
| 113 | return "mcp-tool:" + server + "/" + raw |
| 114 | } |
| 115 | } |
| 116 | return "" |
| 117 | } |
| 118 | |
| 119 | func splitMCP(name string) (server, raw string, ok bool) { |
| 120 | const prefix = "mcp__" |
| 121 | if !strings.HasPrefix(name, prefix) { |
| 122 | return "", "", false |
| 123 | } |
| 124 | rest := name[len(prefix):] |
| 125 | parts := strings.SplitN(rest, "__", 2) |
| 126 | if len(parts) != 2 || parts[0] == "" || parts[1] == "" { |
| 127 | return "", "", false |
| 128 | } |
| 129 | return parts[0], parts[1], true |
| 130 | } |
| 131 | |
| 132 | // ReviewWarnings returns warn-level review findings collected this turn. |
| 133 | func (a *Agent) ReviewWarnings() []string { |
| 134 | if a == nil { |
| 135 | return nil |
| 136 | } |
| 137 | return append([]string(nil), a.turn.reviewWarnings...) |
| 138 | } |
| 139 | |
| 140 | // FormatReviewWarningsForSummary builds a short appendix for the final answer. |
| 141 | func FormatReviewWarningsForSummary(warnings []string) string { |
| 142 | if len(warnings) == 0 { |
| 143 | return "" |
| 144 | } |
| 145 | return "Review warnings:\n- " + strings.Join(warnings, "\n- ") |
| 146 | } |
| 147 | |
| 148 | // ensure string used |
| 149 | var _ = fmt.Sprintf |
| 150 |