返回 DeepSeek-Reasonix
recovery_gate.go
根目录 / internal / agent / recovery_gate.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "strings"
8
9 "reasonix/internal/evidence"
10 )
11
12 // RecoveryGate is the host-side Auto Guard consulted by the agent around tool
13 // execution. It is independent of the permission Gate and of
14 // how the Controller surfaces confirmations (desktop card, bot prompt, headless
15 // blocker). A nil gate means the feature is off for this agent.
16 //
17 // ObserveResult runs after a tool result is produced. BeforeMutation also checks
18 // host-observed structured plan transitions; it runs after call resolution and
19 // mutation classification, and before permission approval and workspace
20 // write-lock acquisition, so a waiting decision never holds a write lease.
21 type RecoveryGate interface {
22 // ObserveResult records a completed call and returns optional guidance for
23 // the same agent's active turn. The caller, not the gate, owns delivery so a
24 // root or sub-agent failure can never start a concurrent controller turn.
25 ObserveResult(ctx context.Context, result RecoveryObservation) string
26 BeforeMutation(ctx context.Context, proposal RecoveryProposal) (RecoveryDecision, error)
27 }
28
29 // RecoveryEpisodeControl is an optional Gate capability for host-owned Episode
30 // rotation, generation stamping, and turn-stop finalization. Controllers and
31 // the live recovery.Gate implement it; simple test doubles may omit it.
32 type RecoveryEpisodeControl interface {
33 EpisodeID() string
34 Generation() uint64
35 BeginEpisode()
36 EpisodeStopped(taskID string) bool
37 MarkFinalizationOffered(taskID string)
38 // ConsumeFinalization marks the one-shot finalization round as used when it
39 // was already offered. Returns (offered, alreadyConsumed).
40 ConsumeFinalization(taskID string) (offered, alreadyConsumed bool)
41 // OnModeChange rotates Episode/generation on a real mode change and returns
42 // dismissed recovery approval ids. Same-value replays are no-ops.
43 OnModeChange(mode string) []string
44 }
45
46 // RecoveryObservation is one finished tool call the checkpoint may react to.
47 type RecoveryObservation struct {
48 // AgentID identifies the agent that produced the result (root or sub-agent).
49 // Empty means the root agent.
50 AgentID string
51 // TaskID isolates recovery state across concurrent top-level tasks.
52 TaskID string
53 // TaskScopeID is the host-owned Goal/task-grant scope that produced this
54 // result. Ordinary user turns get a fresh scope; goal continuations reuse
55 // one. It is independent of EpisodeID (failure/reviewer budgets).
56 TaskScopeID string
57 // EpisodeID is the host-owned temporary Recovery Episode. It is never
58 // model-supplied and never persisted. Failure/reviewer/stop budgets key on it.
59 EpisodeID string
60 // Generation is the gate generation captured before tool execution. Stale
61 // observations from an older generation are ignored after mode switches.
62 Generation uint64
63 // Tool is the permission/evidence name used for the call.
64 Tool string
65 // Args are the resolved arguments for the call.
66 Args json.RawMessage
67 // Subject is a short human-readable subject (command, path, MCP action).
68 Subject string
69 // ReadOnly is true when the host classified the call as non-mutating.
70 ReadOnly bool
71 // Mutates is true when the host classifies the call as state-changing.
72 Mutates bool
73 // Verification is true when the host recognizes a verification command
74 // (test/lint/build/typecheck/compile or project check).
75 Verification bool
76 // Success is true when the tool completed without error.
77 Success bool
78 // Blocked is true when a host policy blocked the call before execution
79 // (permission deny, plan mode, delivery gate, user rejection). These do not
80 // activate recovery.
81 Blocked bool
82 // UserRejected is true when the user actively declined an approval prompt.
83 UserRejected bool
84 // ProviderError is true for transport/provider failures handled by the
85 // existing retry path; they do not activate recovery.
86 ProviderError bool
87 // Cancelled is true for context cancellation / user cancel.
88 Cancelled bool
89 // EmptySearch is true for a successful empty search result (no matches).
90 // It must not activate recovery.
91 EmptySearch bool
92 // ErrSummary is a short error summary for diagnosis cards.
93 ErrSummary string
94 // Output is a bounded tool output excerpt for diagnosis context.
95 Output string
96 }
97
98 // RecoveryProposal is the next candidate action Auto Guard may classify.
99 type RecoveryProposal struct {
100 AgentID string
101 TaskID string
102 // TaskScopeID is a host-owned Goal/task-grant scope. Goal continuations
103 // reuse their delivery scope; ordinary runs get a unique turn scope. It
104 // never comes from model output and is independent of Episode budgets.
105 TaskScopeID string
106 // EpisodeID is the host-owned Recovery Episode (optional on proposal; the
107 // gate uses its live Episode when empty).
108 EpisodeID string
109 // TaskSummary is the bounded task text for the agent proposing the action.
110 // Sub-agents must carry their own task instead of borrowing the root
111 // controller session's latest user message.
112 TaskSummary string
113 Tool string
114 Args json.RawMessage
115 Subject string
116 Preview string
117 ReadOnly bool
118 Mutates bool
119 Verification bool
120 // PlanTransition marks a structural rewrite of an already-active task plan.
121 // The host derives it from canonical todo state; it is never model-asserted.
122 PlanTransition bool
123 // PlanBefore and PlanAfter are bounded, human-readable snapshots supplied to
124 // the isolated reviewer. They are internal evidence, not persisted wire state.
125 PlanBefore string
126 PlanAfter string
127 // SafeRetry is true when the host can prove this is a same-strategy
128 // verification/idempotent retry (e.g. re-running the same test command).
129 SafeRetry bool
130 // HighRisk is retained as reviewer evidence and for compatibility helpers.
131 // Auto does not turn execution risk into a user decision; permission,
132 // sandbox, and tool-specific policy own that boundary.
133 HighRisk bool
134 // ExpandedScope marks a write range wider than the failed event's range.
135 ExpandedScope bool
136 // StrategyChanged marks an explicit tool/method change vs the failed call.
137 StrategyChanged bool
138 }
139
140 // RecoveryDecision is the host's decision for a proposed mutation.
141 type RecoveryDecision struct {
142 // Allow continues without a user card.
143 Allow bool
144 // AuthorizePlanReplacement grants this one todo_write call permission to
145 // replace the current in_progress step. Only the active Auto Gate may issue
146 // it after reviewing a host-detected structural plan transition; it is never
147 // derived from model arguments or persisted beyond the call.
148 AuthorizePlanReplacement bool
149 // Blocked is true when the mutation must not run (reviewer/user revise, or
150 // headless blocker). Message is fed back to the model.
151 Blocked bool
152 // Message is model-facing text when Blocked is true.
153 Message string
154 // Generation is the gate generation that authorized or blocked this call.
155 // Tool results must carry the same generation for ObserveResult.
156 Generation uint64
157 // StopTurn means the Recovery Episode execution budget is exhausted; the
158 // current batch stops and the agent gets one summarize-only finalization.
159 // Host-proven read-only diagnosis may be admitted before that final stop.
160 StopTurn bool
161 // StopReason is an internal classifier (episode_failures, review_rejects, …).
162 // User-facing surfaces must not expose it.
163 StopReason string
164 }
165
166 // RecoveryAction is the user decision for a recovery confirmation card.
167 type RecoveryAction string
168
169 const (
170 RecoveryActionContinue RecoveryAction = "continue"
171 RecoveryActionContinueTask RecoveryAction = "continue_task"
172 RecoveryActionRevise RecoveryAction = "revise"
173 )
174
175 func (a *Agent) observeRecoveryResult(ctx context.Context, toolName string, args json.RawMessage, readOnly, mutates bool, result string, err error, blocked, userRejected bool, generation uint64) {
176 if a == nil || a.recoveryGate == nil {
177 return
178 }
179 verification := toolName == "bash" && evidence.IsDeliveryVerificationCommand(bashCommandFromArgs(args))
180 success := err == nil && !blocked
181 emptySearch := false
182 if success && readOnly {
183 emptySearch = recoveryEmptySearch(toolName, result)
184 }
185 errSummary := ""
186 if err != nil {
187 errSummary = firstLine(err.Error())
188 } else if blocked {
189 errSummary = firstLine(result)
190 }
191 // A tool may own a shorter internal deadline while the parent turn remains
192 // active (for example an MCP call timeout). That is a qualifying transient
193 // execution failure, not a user cancellation. Parent context state remains
194 // the source of truth for turn cancellation/deadline; a direct Canceled
195 // result is still treated as cancellation for adapters that return it first.
196 cancelled := errors.Is(err, context.Canceled)
197 if ctx != nil && ctx.Err() != nil {
198 cancelled = true
199 }
200 episodeID := ""
201 if ctrl, ok := a.recoveryGate.(RecoveryEpisodeControl); ok {
202 episodeID = ctrl.EpisodeID()
203 if generation == 0 {
204 generation = ctrl.Generation()
205 }
206 }
207 guidance := a.recoveryGate.ObserveResult(ctx, RecoveryObservation{
208 AgentID: a.recoveryAgentID,
209 TaskID: a.recoveryTaskID,
210 TaskScopeID: recoveryTaskScopeID(a.deliveryScopeID, a.recoveryRunSeq.Load()),
211 EpisodeID: episodeID,
212 Generation: generation,
213 Tool: toolName,
214 Args: args,
215 Subject: recoverySubject(toolName, args),
216 ReadOnly: readOnly,
217 Mutates: mutates,
218 Verification: verification,
219 Success: success,
220 Blocked: blocked,
221 UserRejected: userRejected,
222 Cancelled: cancelled,
223 EmptySearch: emptySearch,
224 ErrSummary: errSummary,
225 Output: result,
226 })
227 if strings.TrimSpace(guidance) != "" {
228 // Tool execution happens inside Agent.Run, so this targets the exact root
229 // or sub-agent turn that failed. Never fall back to Controller.Steer here:
230 // synchronous headless Run does not participate in controller admission,
231 // and a fallback would start a second Agent.Run concurrently.
232 _ = a.Steer(guidance)
233 }
234 }
235
236 func (a *Agent) recoveryEpisodeControl() RecoveryEpisodeControl {
237 if a == nil || a.recoveryGate == nil {
238 return nil
239 }
240 ctrl, _ := a.recoveryGate.(RecoveryEpisodeControl)
241 return ctrl
242 }
243
244 func bashCommandFromArgs(args json.RawMessage) string {
245 if len(args) == 0 {
246 return ""
247 }
248 var fields map[string]json.RawMessage
249 if err := json.Unmarshal(args, &fields); err != nil {
250 return ""
251 }
252 raw, ok := fields["command"]
253 if !ok {
254 return ""
255 }
256 var cmd string
257 if err := json.Unmarshal(raw, &cmd); err != nil {
258 return ""
259 }
260 return strings.TrimSpace(cmd)
261 }
262
263 func recoverySubject(toolName string, args json.RawMessage) string {
264 // Prefer command/path fields for readable cards.
265 if toolName == "bash" {
266 if cmd := bashCommandFromArgs(args); cmd != "" {
267 return cmd
268 }
269 }
270 if len(args) > 0 {
271 var fields map[string]any
272 if err := json.Unmarshal(args, &fields); err == nil {
273 for _, key := range []string{"path", "file_path", "file", "target", "command", "query", "pattern"} {
274 if v, ok := fields[key].(string); ok && strings.TrimSpace(v) != "" {
275 return strings.TrimSpace(v)
276 }
277 }
278 }
279 }
280 return strings.TrimSpace(toolName)
281 }
282
283 func recoveryEmptySearch(toolName, output string) bool {
284 switch strings.TrimSpace(toolName) {
285 case "grep", "glob", "ls", "code_index", "codeindex":
286 default:
287 return false
288 }
289 out := strings.TrimSpace(output)
290 if out == "" {
291 return true
292 }
293 lower := strings.ToLower(out)
294 for _, marker := range []string{"no matches", "no files found", "0 matches", "not found", "no results"} {
295 if strings.Contains(lower, marker) {
296 return true
297 }
298 }
299 return false
300 }
301
302 func boundedRecoveryTaskSummary(task string) string {
303 task = strings.TrimSpace(task)
304 const maxRunes = 800
305 runes := []rune(task)
306 if len(runes) <= maxRunes {
307 return task
308 }
309 return string(runes[:maxRunes]) + "…"
310 }
311
311 lines GO