返回 DeepSeek-Reasonix
capability_gate.go
根目录 / internal / agent / capability_gate.go
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
327 lines GO