返回 DeepSeek-Reasonix
execute_one.go
根目录 / internal / agent / execute_one.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "strings"
9
10 "reasonix/internal/checkpoint"
11 "reasonix/internal/event"
12 "reasonix/internal/evidence"
13 "reasonix/internal/fileops"
14 "reasonix/internal/jobs"
15 "reasonix/internal/mcpinteraction"
16 "reasonix/internal/memory"
17 "reasonix/internal/planmode"
18 "reasonix/internal/provider"
19 "reasonix/internal/sandbox"
20 "reasonix/internal/tool"
21 )
22
23 // executeOne runs a single tool call. It is pure with respect to the event sink
24 // — the caller emits ToolDispatch/ToolResult — so it is safe to invoke fromparallel goroutines. Stages:
25 // parse → policy → prepare → finish.
26 func (a *Agent) executeOne(ctx context.Context, turn *turnRuntime, call provider.ToolCall) (out toolOutcome) {
27 defer func() { out.runState = outcomeRunState(out) }()
28 ctx = fileops.WithStore(withTurnState(a.withAgentContext(ctx), turn), a.fileObservations)
29 plan := &toolCallPlan{call: call}
30 defer func() {
31 out.evidenceSource = cloneEvidenceTarget(plan.expectedWriteSource)
32 out.readTaskID = plan.readTaskID
33 out.readEnvelope = plan.readEnvelope
34 out.readActiveMillis = plan.readActiveMillis
35 if plan.mutationObserved && !plan.mutationAfterDone {
36 a.observeAfterMutation(plan)
37 }
38 if plan.releaseMutationWrite != nil {
39 plan.releaseMutationWrite()
40 }
41 if plan.releaseParentWrite != nil {
42 plan.releaseParentWrite()
43 }
44 if plan.releaseLease != nil {
45 plan.releaseLease()
46 }
47 if plan.resolvedMeta == nil {
48 return
49 }
50 out.readTaskID = plan.readTaskID
51 out.resolved = true
52 out.resolvedName = plan.resolvedMeta.TargetName
53 out.capabilityID = plan.resolvedMeta.CapabilityID
54 out.resolvedReadOnly = plan.resolvedMeta.ReadOnly
55 }()
56 defer finalizeWorkspaceMutationOutcome(&out, plan)
57
58 if blocked, early := a.parseToolCall(ctx, turn, plan); early {
59 return blocked
60 }
61 if blocked, early := a.resolveToolPolicy(ctx, turn, plan); early {
62 return blocked
63 }
64 if blocked, early := a.prepareToolExecution(ctx, plan); early {
65 return blocked
66 }
67 if blocked, early := a.checkToolRecoveryStart(ctx, plan); early {
68 return blocked
69 }
70 return a.finishToolExecution(ctx, plan)
71 }
72
73 // resolveToolPolicy applies Plan mode, proxy resolution, delivery gates, Auto
74 // Guard, and permission checks. Permission must complete before any write lease.
75 func (a *Agent) resolveToolPolicy(ctx context.Context, turn *turnRuntime, plan *toolCallPlan) (toolOutcome, bool) {
76 if blocked, early := a.applyPlanModeAndProxy(ctx, plan); early {
77 return blocked, true
78 }
79 if blocked, early := a.applyResolvedTargetGates(plan); early {
80 return blocked, true
81 }
82 // Resolve and validate before invoking the extension sidecar. A replacement
83 // is resolved and validated again before permission, hooks, leases, process
84 // startup, or tools/call.
85 originalName, originalArgs := plan.call.Name, plan.call.Arguments
86 if blocked, early := a.interceptToolBefore(ctx, plan); early {
87 return blocked, true
88 }
89 if plan.call.Name != originalName || plan.call.Arguments != originalArgs {
90 replacement := plan.call
91 *plan = toolCallPlan{call: replacement}
92 if blocked, early := a.parseToolCall(ctx, turn, plan); early {
93 return blocked, true
94 }
95 if blocked, early := a.applyPlanModeAndProxy(ctx, plan); early {
96 return blocked, true
97 }
98 if blocked, early := a.applyResolvedTargetGates(plan); early {
99 return blocked, true
100 }
101 }
102 if blocked, early := a.commitResolvedSkip(plan); early {
103 return blocked, true
104 }
105 if blocked, early := a.applyContextualToolGate(ctx, plan); early {
106 return blocked, true
107 }
108 if blocked, early := a.applyExecutionPreflight(turn, plan); early {
109 return blocked, true
110 }
111 if blocked, early := a.applyRecoveryAndPermission(ctx, plan); early {
112 return blocked, true
113 }
114 return toolOutcome{}, false
115 }
116
117 func (a *Agent) applyContextualToolGate(ctx context.Context, plan *toolCallPlan) (toolOutcome, bool) {
118 if plan == nil || plan.tool == nil {
119 return toolOutcome{}, false
120 }
121 if outcome, blocked := contextualToolGateOutcome(ctx, plan.tool, plan.canonicalName); blocked {
122 return outcome, true
123 }
124 if plan.execTool != nil {
125 if outcome, blocked := contextualToolGateOutcome(ctx, plan.execTool, plan.permName); blocked {
126 return outcome, true
127 }
128 }
129 return toolOutcome{}, false
130 }
131
132 func contextualToolGateOutcome(ctx context.Context, target tool.Tool, name string) (toolOutcome, bool) {
133 contextual, ok := target.(tool.ContextualTool)
134 if !ok || contextual.ProviderVisible(ctx) {
135 return toolOutcome{}, false
136 }
137 msg := fmt.Sprintf("blocked: tool %q is unavailable in the current workflow context", name)
138 switch name {
139 case "get_goal", "create_goal", "update_goal":
140 msg = "goal tools require the current top-level host-attested goal context — no goal state was changed"
141 case "job_output", "job_kill", "bash_output", "wait", "kill_shell":
142 msg = "background jobs are not available in this context"
143 }
144 return toolOutcome{output: msg, blocked: true, errMsg: firstLine(msg)}, true
145 }
146
147 // applyPlanModeAndProxy handles initial Plan mode, proxy resolution / skip path,
148 // resolved-target Plan re-check, and MCP Plan availability.
149 func (a *Agent) applyPlanModeAndProxy(ctx context.Context, plan *toolCallPlan) (toolOutcome, bool) {
150 t := plan.tool
151 call := plan.call
152 if a.planMode.Load() {
153 // Translate the tool's optional plan-mode self-report into the policy'stri-state.
154 // Mirrorsthet.(tool.Previewer) assertion precedent below.
155 safety := planmode.PlanSafetyUnknown
156 if c, ok := t.(tool.PlanModeClassifier); ok {
157 if c.PlanModeSafe() {
158 safety = planmode.PlanSafetySafe
159 } else {
160 safety = planmode.PlanSafetyUnsafe
161 }
162 }
163 if decision := a.planModeDecision(plan.canonicalName, t.ReadOnly(), safety, json.RawMessage(call.Arguments)); decision.Blocked {
164 return toolOutcome{
165 output: decision.Message,
166 blocked: true,
167 errMsg: "blocked: tool is unavailable during planning",
168 }, true
169 }
170 }
171 // Resolve proxy tools (use_capability) to the real MCP target beforepermission, hooks, and evidence.
172 // Provider transcript keeps call.Name.
173 if resolver, ok := t.(tool.CallResolver); ok {
174 rc, rerr := resolver.ResolveCall(ctx, json.RawMessage(call.Arguments))
175 if rerr != nil {
176 return a.proxyResolutionError(plan, rerr), true
177 }
178 plan.resolved = rc
179 plan.resolvedMeta = &plan.resolved
180 if rc.TargetName != "" {
181 plan.permName = rc.TargetName
182 plan.evidenceName = rc.TargetName
183 }
184 // An unavailable resolution has no concrete target contract. Keep the
185 // original proxy arguments so host validation checks use_capability itself
186 // and the deterministic disabled/unregistered reason remains intact.
187 if len(rc.Args) > 0 && !rc.Unavailable {
188 plan.permArgs = rc.Args
189 plan.evidenceArgs = rc.Args
190 plan.execArgs = rc.Args
191 }
192 if rc.Target != nil {
193 plan.execTool = rc.Target
194 }
195 if outcome, blocked := contextualToolGateOutcome(ctx, plan.execTool, plan.permName); blocked {
196 return outcome, true
197 }
198 plan.readOnly = rc.ReadOnly
199 plan.classifyEffects()
200 if outcome, blocked := a.readOnlyExecutionBlock(t, &rc); blocked {
201 return blockedShellOutcome(outcome, plan), true
202 }
203 } else if outcome, blocked := a.readOnlyExecutionBlock(t, nil); blocked {
204 return blockedShellOutcome(outcome, plan), true
205 }
206
207 // A proxy resolution can point at atargetwithanexplicitplanning-phaseopt-outeventhoughtheproxyitselfhasnone.
208 // Re-check the resolved targetbefore its ordinary permission and sandbox path.
209 if plan.resolved.TargetName != "" && a.planMode.Load() {
210 safety := planmode.PlanSafetyUnknown
211 if c, ok := plan.execTool.(tool.PlanModeClassifier); ok {
212 if c.PlanModeSafe() {
213 safety = planmode.PlanSafetySafe
214 } else {
215 safety = planmode.PlanSafetyUnsafe
216 }
217 }
218 if decision := a.planModeDecision(plan.permName, plan.resolved.ReadOnly, safety, plan.permArgs); decision.Blocked {
219 return toolOutcome{
220 output: decision.Message,
221 blocked: true,
222 errMsg: "blocked: tool is unavailable during planning",
223 }, true
224 }
225 }
226 plannerTrustedMCP := a.plannerMCPExecution && isMCPExecutionTarget(plan.execTool, plan.permName) && mcpServerAuthorized(plan.execTool) && !mcpDestructiveHint(plan.execTool)
227 if a.planMode.Load() && isMCPExecutionTarget(plan.execTool, plan.permName) && !plannerTrustedMCP && (!plan.readOnly || !mcpServerAuthorized(plan.execTool) || mcpDestructiveHint(plan.execTool)) {
228 reason := "writer/destructive target"
229 if plan.readOnly && !mcpServerAuthorized(plan.execTool) {
230 reason = "reader from an unauthorized server"
231 }
232 return toolOutcome{
233 output: fmt.Sprintf("blocked: MCP %s %q is unavailable during Plan mode; finish or exit Plan mode before requesting this call", reason, plan.permName),
234 blocked: true,
235 errMsg: "blocked: MCP target is unavailable during planning",
236 }, true
237 }
238 return toolOutcome{}, false
239 }
240
241 // commitResolvedSkip applies deferred proxy bookkeeping only after the
242 // validated call has passed tool.before. Discovery and decline actions then
243 // finish locally without entering permission, hook, lease, or Execute paths.
244 func (a *Agent) commitResolvedSkip(plan *toolCallPlan) (toolOutcome, bool) {
245 if plan == nil || plan.resolvedMeta == nil {
246 return toolOutcome{}, false
247 }
248 resolved := plan.resolved
249 if resolved.Commit != nil {
250 if err := resolved.Commit(); err != nil {
251 return toolOutcome{output: fmt.Sprintf("error: %v", err), errMsg: firstLine(err.Error())}, true
252 }
253 }
254 if resolved.SkipExecute {
255 return a.resolvedSkipOutcome(plan, resolved), true
256 }
257 return toolOutcome{}, false
258 }
259
260 // proxyResolutionError preserves non-input resolver failures while diagnosing
261 // only the private, repairable envelope errors marked by the resolver.
262 func (a *Agent) proxyResolutionError(plan *toolCallPlan, err error) toolOutcome {
263 var inputErr *capabilityInputError
264 if errors.As(err, &inputErr) {
265 return a.diagnoseCapabilityInputFailure(plan, err)
266 }
267 return toolOutcome{output: fmt.Sprintf("error: %v", err), errMsg: firstLine(err.Error())}
268 }
269
270 // applyRecoveryAndPermission applies write-scope and ordinary permission
271 // policy. Recovery review is informational and never participates in tool
272 // availability or execution decisions.
273 func (a *Agent) applyRecoveryAndPermission(ctx context.Context, plan *toolCallPlan) (toolOutcome, bool) {
274 if blocked, early := a.applyWriteAccess(ctx, plan); early {
275 return blocked, true
276 }
277 // Trusted MCP fast path: installed tools and authorized lifecycle connects
278 // (mcp_connect__*) skip ordinary Ask/Auto/dontAsk gates. Only explicit denyand live authorization apply —
279 // first connect of an installed server mustnot re-prompt under headless or partial-auto policies.
280 gate := a.svc.gateSnapshot()
281 trustedMCP := isInstalledMCPTool(plan.execTool) || isMCPLifecycleConnectTarget(plan.execTool)
282 readOnlyPresetNeedsGate := a.svc.permissionPreset != nil && a.svc.permissionPreset() == "read-only" && !plan.readOnly
283 if trustedMCP && !readOnlyPresetNeedsGate {
284 if !mcpServerAuthorized(plan.execTool) {
285 return toolOutcome{
286 output: "blocked: this project MCP server identity has not been authorized; approve the server from a parent session and retry",
287 blocked: true,
288 errMsg: "blocked: MCP server identity is not authorized",
289 }, true
290 }
291 if denyGate, ok := gate.(ExplicitDenyGate); ok && denyGate.ExplicitlyDenies(plan.permName, plan.permArgs) {
292 return toolOutcome{
293 output: "blocked: denied by permission policy — this tool/command is on the deny list. Do not retry it; choose another approach or stop and explain.",
294 blocked: true,
295 errMsg: "blocked by permission policy",
296 }, true
297 }
298 } else if gate != nil && !plan.skipOrdinaryGate {
299 allow, reason, err := gate.Check(ctx, plan.permName, plan.permArgs, plan.readOnly)
300 if err != nil {
301 return toolOutcome{
302 output: fmt.Sprintf("blocked: %s (%v)", reason, err),
303 blocked: true,
304 errMsg: fmt.Sprintf("blocked: %v", err),
305 }, true
306 }
307 // permission.decision: the host verdict is computed first; theextension rulingmayoverrideitineitherdirection
308 // (an allowoverriding a host deny is the full-trust contract and is audited).
309 if blocked, early := a.interceptExtensionPermission(ctx, plan, &allow); early {
310 return blocked, true
311 }
312 if !allow {
313 return toolOutcome{
314 output: "blocked: " + reason,
315 blocked: true,
316 errMsg: "blocked by permission policy",
317 }, true
318 }
319 // A write explicitly authorized while the session is read-only runs this
320 // one call in the workspace sandbox. The session preset itself stays
321 // read-only; session-scoped approval only reuses the same narrow grant.
322 if !plan.readOnly && a.svc.permissionPreset != nil && a.svc.permissionPreset() == "read-only" {
323 plan.permissionPreset = "workspace-write"
324 }
325 }
326 return toolOutcome{}, false
327 }
328
329 // prepareToolExecution acquires write leases, parent write claims, runs
330 // PreToolUse hooks and preview checkpoints, and injects call context.
331 // Allofthishappensafterpermissionandbefore the concrete Execute call.
332 func (a *Agent) prepareToolExecution(ctx context.Context, plan *toolCallPlan) (toolOutcome, bool) {
333 if blocked, early := a.prepareWriteCoordination(ctx, plan); early {
334 return blocked, true
335 }
336 // Acquire the checkpoint barrier before preimage capture and any hook. It isheld through post hooks and
337 // AfterMutation so rewind cannot interleave withwriter-side user code.
338 if (plan.effects.WorkspaceMutation || plan.hooksMayMutateWorkspace) &&
339 a.svc.mutationObserver != nil && a.svc.mutationObserver.Store() != nil {
340 barrier := a.svc.mutationObserver.Store().Barrier()
341 if err := barrier.EnterWrite(); err != nil {
342 return toolOutcome{output: "blocked: " + err.Error(), blocked: true, errMsg: "blocked: mutation barrier unavailable"}, true
343 }
344 plan.releaseMutationWrite = barrier.ExitWrite
345 }
346 // Checkpoint the file this writer is about to change before PreToolUse.
347 // A hook may mutate and then block the call, so the deferred
348 // AfterMutationstillfinalizesthefingerprintonevery return path. Built-in
349 // Previewers get precise paths (complete coverage). Bash / opaque
350 // MCPwritersrecordexplicitcoveragegapsinstead of guessing targets.
351 if plan.effects.WorkspaceMutation {
352 a.observeBeforeMutation(ctx, plan)
353 plan.mutationObserved = plan.mutationPath != ""
354 }
355 if plan.hooksMayMutateWorkspace && a.svc.mutationObserver != nil {
356 a.svc.mutationObserver.RecordGap(checkpoint.CoverageGap{Reason: checkpoint.GapHookWrite, Tool: plan.evidenceName, Detail: "tool hook may write paths that are not declared by the tool"})
357 }
358 // Proxy tools fire hooks against the real MCP target name and arguments.
359 if a.svc.hooks != nil {
360 if block, msg := a.svc.hooks.PreToolUse(ctx, plan.permName, plan.permArgs); block {
361 if msg == "" {
362 msg = "blocked by a PreToolUse hook"
363 }
364 return toolOutcome{
365 output: "blocked: " + msg,
366 blocked: true,
367 errMsg: "blocked by PreToolUse hook",
368 }, true
369 }
370 }
371 cctx := tool.WithContextCompressor(withCallContext(ctx, plan.call.ID, a.svc.sink, a.svc.asker, a.planMode.Load()), a)
372 if a.svc.interactionBroker != nil {
373 cctx = mcpinteraction.WithBroker(cctx, a.svc.interactionBroker)
374 }
375 cctx, plan.mcpApp = tool.WithMCPAppCollector(cctx)
376 cctx, plan.presentedFiles = tool.WithPresentedFilesCollector(cctx)
377 cctx = WithSubagentDepth(cctx, a.subagentDepth)
378 if a.task.ledger != nil {
379 cctx = evidence.WithLedger(cctx, a.task.ledger)
380 }
381 if a.svc.jobs != nil {
382 cctx = jobs.WithManager(cctx, a.svc.jobs)
383 }
384 if a.svc.sandboxEscape != nil {
385 cctx = sandbox.WithEscapeApprover(cctx, a.svc.sandboxEscape)
386 }
387 if a.svc.configWrite != nil {
388 cctx = tool.WithConfigWriteApprover(cctx, a.svc.configWrite)
389 }
390 if v := a.responseLanguage.Load(); v != nil {
391 if lang, ok := v.(string); ok {
392 cctx = WithResponseLanguagePreference(cctx, lang)
393 }
394 }
395 if v := a.reasoningLanguage.Load(); v != nil {
396 if lang, ok := v.(string); ok {
397 cctx = WithReasoningLanguagePreference(cctx, lang)
398 }
399 }
400 if a.svc.memQueue != nil {
401 cctx = memory.WithQueue(cctx, a.svc.memQueue)
402 }
403 callID := plan.call.ID
404 cctx = tool.WithProgress(cctx, func(chunk string) {
405 a.svc.sink.Emit(event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: callID, Output: chunk}})
406 })
407 plan.cctx = a.stampWriteRoots(cctx, plan)
408 return toolOutcome{}, false
409 }
410
411 // finishToolExecution performs the concrete Execute, records evidence, runspost hooksandrecoveryobservation,
412 // and truncates the model-facing result.
413 func (a *Agent) finishToolExecution(ctx context.Context, plan *toolCallPlan) toolOutcome {
414 if err := a.checkpointSession(ctx, CheckpointBeforeTopTool); err != nil {
415 message := "session durability checkpoint failed before tool dispatch: " + err.Error()
416 return toolOutcome{output: "error: " + message, errMsg: message, blocked: true}
417 }
418 plan.executed = true
419 cctx := a.withWriteRecovery(plan.cctx, plan.call)
420 if plan.expectedWriteSource.Path != "" {
421 cctx = tool.WithExpectedWriteSource(cctx, plan.expectedWriteSource)
422 }
423 runTool := plan.runTool
424 call := plan.call
425 t := plan.tool
426 readOnly := plan.readOnly
427 permName := plan.permName
428 permArgs := plan.permArgs
429 var result string
430 var images []string
431 var err error
432 // A call that was authorized under reader classification carries thatbasis into dispatch: the
433 // MCPexecutionlayer re-verifies it linearizablyagainst server authorization and live safety metadata,
434 // andrefuses topromote it into a writerlaneifreclassification landed after the gate.
435 if readOnly && isInstalledMCPTool(runTool) && mcpServerAuthorized(runTool) && !mcpDestructiveHint(runTool) {
436 cctx = tool.WithReaderExecutionIntent(cctx)
437 }
438 // Planner-trusted MCP: authorized + non-destructive, even withoutreadOnlyHint.
439 // Finaldispatchre-checksliveauthorization/destructiveHint.
440 if a.plannerMCPExecution && isMCPExecutionTarget(runTool, permName) && mcpServerAuthorized(runTool) && !mcpDestructiveHint(runTool) {
441 cctx = tool.WithNonDestructiveMCPExecutionIntent(cctx)
442 }
443 if a.capabilityAudit != nil {
444 cctx = tool.WithRemoteDispatchObserver(cctx, a.capabilityAudit.RecordRemoteDispatch)
445 }
446 plan.cctx = cctx
447 var execution *tool.ShellExecution
448 if plan.verification && a.svc.sink != nil {
449 a.svc.sink.Emit(event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: call.ID, Verifying: true}})
450 }
451 result, images, execution, err = a.dispatchResolvedTool(cctx, plan)
452 // tool.after: extensions rule on the executed result (success or error)
453 // before evidence, hooks, and recovery observation, so every downstreamconsumer sees the final
454 // (possiblyreplaced) outcome.
455 result, err = a.interceptToolAfter(ctx, call, result, err)
456 // A tool that refused its own call never ran:
457 // reportitlikethepermissionandplan-modeblocksaboveratherthanasanexecution failure.
458 if msg, refused := tool.BlockedMessage(err); refused {
459 return a.blockedToolOutcome(plan, msg)
460 }
461 // Track skill/capability outcomes for Delivery gates.
462 a.noteCapabilityInvocation(call.Name, json.RawMessage(call.Arguments), err)
463 // Success and failure hooks observe the result after the tool ran. Use thereal target name for proxiedtools.
464 if a.svc.hooks != nil {
465 if err != nil {
466 a.svc.hooks.PostToolUseFailure(ctx, permName, permArgs, result, err)
467 } else {
468 a.svc.hooks.PostToolUse(ctx, permName, permArgs, result)
469 }
470 }
471 // Always re-read after post hooks —
472 // partialwritesandhooksideeffectscanchangethepreviewedpathevenwhentheconcrete tool returned an error.
473 a.finalizeObservedToolReceipts(plan, result, execution, err)
474 if err != nil {
475 detail := result
476 // Malformed-args failures are a transient model JSON glitch (e.g. optionswritten as ["a":"b"] →
477 // "invalidcharacter ':' after array element"). Theargs can't be safely re-parsed,
478 // butechoingthetool'sschemamakes theretry land valid insteadofrepeating the same broken shape.
479 if !json.Valid([]byte(call.Arguments)) {
480 detail = strings.TrimRight(detail, "\n") + "\nThe arguments were not valid JSON. Re-emit them exactly per this schema:\n" + string(t.Schema())
481 }
482 rawErr := fmt.Sprintf("error: %v\n%s", err, detail)
483 body, truncMsg, original := a.boundProviderVisibleResult(rawErr, call.Name, call.ID)
484 out := toolOutcome{
485 runState: recoveryFailureState(err),
486 output: body, errMsg: firstLine(err.Error()), truncated: truncMsg != "" || original != "", truncMsg: truncMsg,
487 execution: execution, mcpApp: toProviderMCPApp(plan.mcpApp), subagentOutcome: subagentOutcomeFromError(err),
488 }
489 var operationErr *tool.OperationError
490 if errors.As(err, &operationErr) {
491 d := operationErr.Diagnostic
492 d.OperationID = call.ID
493 out.diagnostic = &d
494 }
495 if original != "" {
496 out.rawOutput = original
497 }
498 return out
499 }
500 // A foreground `task` sub-agent just finished — its result is the final answer.
501 // (A backgrounded one returns a "Started…" string and stops later in a job, soit doesn't fire here.)
502 // SubagentStop lets a hook react to delegated work.
503 if a.svc.hooks != nil && call.Name == "task" && !isBackgroundTaskCall(call.Arguments) {
504 a.svc.hooks.SubagentStop(ctx, result)
505 }
506 runState := outcomeRunState(toolOutcome{executed: true, output: result})
507 var visionSummary *provider.VisionSummary
508 if runState == provider.ToolRunCompleted {
509 processed := a.processToolImages(cctx, result, images)
510 result, visionSummary = processed.text, processed.summary
511 }
512 body, truncMsg, original := a.boundProviderVisibleResult(result, call.Name, call.ID)
513 out := toolOutcome{
514 runState: runState, output: body, images: images, visionSummary: visionSummary, truncated: truncMsg != "" || original != "", truncMsg: truncMsg,
515 execution: execution, mcpApp: toProviderMCPApp(plan.mcpApp),
516 }
517 if plan.presentedFiles != nil {
518 for _, file := range plan.presentedFiles() {
519 out.presentedFiles = append(out.presentedFiles, provider.PresentedFile{Path: file.Path, Description: file.Description})
520 }
521 }
522 if original != "" {
523 out.rawOutput = original
524 }
525 return out
526 }
527
528 // observeBeforeMutation captures writer preimages and opaque-tool coverage gaps.
529 func (a *Agent) observeBeforeMutation(ctx context.Context, plan *toolCallPlan) {
530 if a == nil || plan == nil {
531 return
532 }
533 toolName := plan.evidenceName
534 if toolName == "" {
535 toolName = plan.call.Name
536 }
537 obs := a.svc.mutationObserver
538 if obs != nil {
539 if pv, ok := plan.execTool.(tool.Previewer); ok {
540 if change, perr := pv.Preview(ctx, plan.execArgs); perr == nil && change.Path != "" {
541 if evidence.ClassifyWriteScope(change.Path, a.writeWorkspaceRoot, a.scratchRoots()) == evidence.WriteScopeScratch {
542 obs.RecordGap(checkpoint.CoverageGap{Reason: checkpoint.GapScratch, Tool: toolName, Path: change.Path, Detail: "scratch path is not a project file"})
543 plan.mutationPath = change.Path
544 return
545 }
546 obs.BeforeMutationFromChange(change, toolName)
547 plan.mutationPath = change.Path
548 return
549 }
550 }
551 // Non-previewable writers: record a coverage gap (do not guess paths).
552 switch {
553 case tool.IsShellToolName(toolName):
554 obs.RecordGap(checkpoint.CoverageGap{Reason: checkpoint.GapBashSideEffect, Tool: toolName, Detail: "shell side effects are not path-tracked"})
555 default:
556 // MCP or other writers without Previewer.
557 if !plan.readOnly {
558 obs.RecordGap(checkpoint.CoverageGap{Reason: checkpoint.GapMCPExternal, Tool: toolName, Detail: "tool cannot describe local write paths"})
559 }
560 }
561 return
562 }
563 // Legacy onPreEdit path.
564 if a.svc.preEdit != nil {
565 if pv, ok := plan.execTool.(tool.Previewer); ok {
566 if change, perr := pv.Preview(ctx, plan.execArgs); perr == nil {
567 a.svc.preEdit(change)
568 plan.mutationPath = change.Path
569 }
570 }
571 }
572 }
573
574 // observeAfterMutation records the after fingerprint when a concrete path wasknown before execution,
575 // regardless of tool success or failure.
576 func (a *Agent) observeAfterMutation(plan *toolCallPlan) bool {
577 if a == nil || plan == nil || plan.mutationPath == "" || a.svc.mutationObserver == nil {
578 return false
579 }
580 toolName := plan.evidenceName
581 if toolName == "" {
582 toolName = plan.call.Name
583 }
584 changed := a.svc.mutationObserver.AfterMutation(plan.mutationPath, toolName)
585 if changed {
586 plan.effects.StateMutation = true
587 plan.effects.WorkspaceMutation = true
588 plan.effects.ContentMutation = true
589 }
590 return changed
591 }
592
592 lines GO