| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "strings" |
| 8 | |
| 9 | "reasonix/internal/checkpoint" |
| 10 | "reasonix/internal/event" |
| 11 | "reasonix/internal/evidence" |
| 12 | "reasonix/internal/instruction" |
| 13 | "reasonix/internal/jobs" |
| 14 | "reasonix/internal/memory" |
| 15 | "reasonix/internal/permission" |
| 16 | "reasonix/internal/planmode" |
| 17 | "reasonix/internal/provider" |
| 18 | "reasonix/internal/sandbox" |
| 19 | "reasonix/internal/tool" |
| 20 | ) |
| 21 | |
| 22 | // toolCallPlan holds the resolved, policy-checked state for one tool call. |
| 23 | // Package-private; not shared across goroutines beyond the single executeOne |
| 24 | // invocation that owns it. |
| 25 | type toolCallPlan struct { |
| 26 | call provider.ToolCall |
| 27 | tool tool.Tool |
| 28 | canonicalName string |
| 29 | |
| 30 | permName string |
| 31 | permArgs json.RawMessage |
| 32 | execTool tool.Tool |
| 33 | execArgs json.RawMessage |
| 34 | evidenceName string |
| 35 | evidenceArgs json.RawMessage |
| 36 | readOnly bool |
| 37 | |
| 38 | resolved tool.ResolvedCall |
| 39 | resolvedMeta *tool.ResolvedCall |
| 40 | |
| 41 | mutates bool |
| 42 | verification bool |
| 43 | planTransition bool |
| 44 | planBefore string |
| 45 | planAfter string |
| 46 | planReplacementAuthorized bool |
| 47 | recoveryGen uint64 |
| 48 | |
| 49 | runTool tool.Tool |
| 50 | runArgs json.RawMessage |
| 51 | cctx context.Context |
| 52 | releaseParentWrite func() |
| 53 | releaseMutationWrite func() |
| 54 | |
| 55 | // mutationPath is set when a Previewer described a concrete workspace path |
| 56 | // for AfterMutation fingerprint capture (success or failure). |
| 57 | mutationPath string |
| 58 | mutationObserved bool |
| 59 | mutationAfterDone bool |
| 60 | } |
| 61 | |
| 62 | // executeOne runs a single tool call. It is pure with respect to the event sink |
| 63 | // — the caller emits ToolDispatch/ToolResult — so it is safe to invoke from |
| 64 | // parallel goroutines. Stages: parse → policy → prepare → finish. |
| 65 | func (a *Agent) executeOne(ctx context.Context, call provider.ToolCall) (out toolOutcome) { |
| 66 | plan := &toolCallPlan{call: call} |
| 67 | defer func() { |
| 68 | if plan.mutationObserved && !plan.mutationAfterDone { |
| 69 | a.observeAfterMutation(plan) |
| 70 | } |
| 71 | if plan.releaseMutationWrite != nil { |
| 72 | plan.releaseMutationWrite() |
| 73 | } |
| 74 | if plan.releaseParentWrite != nil { |
| 75 | plan.releaseParentWrite() |
| 76 | } |
| 77 | if plan.resolvedMeta == nil { |
| 78 | return |
| 79 | } |
| 80 | out.resolved = true |
| 81 | out.resolvedName = plan.resolvedMeta.TargetName |
| 82 | out.capabilityID = plan.resolvedMeta.CapabilityID |
| 83 | out.resolvedReadOnly = plan.resolvedMeta.ReadOnly |
| 84 | }() |
| 85 | |
| 86 | if blocked, early := a.parseToolCall(plan); early { |
| 87 | return blocked |
| 88 | } |
| 89 | // tool.before: extensions rule on the parsed call before any policy or |
| 90 | // permission check. A valid replacement is re-parsed so every later stage |
| 91 | // sees the call that will actually execute. |
| 92 | if blocked, early := a.interceptToolBefore(ctx, plan); early { |
| 93 | return blocked |
| 94 | } |
| 95 | if blocked, early := a.resolveToolPolicy(ctx, plan); early { |
| 96 | return blocked |
| 97 | } |
| 98 | if blocked, early := a.prepareToolExecution(ctx, plan); early { |
| 99 | return blocked |
| 100 | } |
| 101 | return a.finishToolExecution(ctx, plan) |
| 102 | } |
| 103 | |
| 104 | // parseToolCall resolves the canonical tool, rejects ambiguity/unknown tools, |
| 105 | // and applies repeat-success and stale-anchor guards. |
| 106 | func (a *Agent) parseToolCall(plan *toolCallPlan) (toolOutcome, bool) { |
| 107 | t, canonicalName, ambiguous := a.tools.ResolveCall(plan.call.Name) |
| 108 | if len(ambiguous) > 0 { |
| 109 | msg := fmt.Sprintf("ambiguous MCP tool reference %q; use one of: %s", plan.call.Name, strings.Join(ambiguous, ", ")) |
| 110 | return toolOutcome{ |
| 111 | output: "error: " + msg, |
| 112 | errMsg: msg, |
| 113 | }, true |
| 114 | } |
| 115 | if t == nil { |
| 116 | if server, ok := completedMCPConnect(a.tools, plan.call.Name); ok { |
| 117 | return toolOutcome{ |
| 118 | output: fmt.Sprintf("MCP server %q is connected; its real tools are now available", server), |
| 119 | }, true |
| 120 | } |
| 121 | return toolOutcome{ |
| 122 | output: fmt.Sprintf("error: unknown tool %q", plan.call.Name), |
| 123 | errMsg: fmt.Sprintf("unknown tool %q", plan.call.Name), |
| 124 | }, true |
| 125 | } |
| 126 | if out, blocked := a.repeatedSuccessBlock(plan.call, t); blocked { |
| 127 | return toolOutcome{ |
| 128 | output: out, |
| 129 | blocked: true, |
| 130 | errMsg: loopGuardBlockErrMsg, |
| 131 | }, true |
| 132 | } |
| 133 | if out, blocked := a.repeatedFailureBlock(plan.call, t); blocked { |
| 134 | return toolOutcome{ |
| 135 | output: out, |
| 136 | blocked: true, |
| 137 | errMsg: loopGuardBlockErrMsg, |
| 138 | }, true |
| 139 | } |
| 140 | if out, blocked := a.staleAnchorEditBlock(plan.call); blocked { |
| 141 | return toolOutcome{ |
| 142 | output: out, |
| 143 | blocked: true, |
| 144 | errMsg: "blocked: fresh read required", |
| 145 | }, true |
| 146 | } |
| 147 | plan.tool = t |
| 148 | plan.canonicalName = canonicalName |
| 149 | plan.permName = canonicalName |
| 150 | plan.permArgs = json.RawMessage(plan.call.Arguments) |
| 151 | plan.execTool = t |
| 152 | plan.execArgs = json.RawMessage(plan.call.Arguments) |
| 153 | plan.evidenceName = canonicalName |
| 154 | plan.evidenceArgs = json.RawMessage(plan.call.Arguments) |
| 155 | plan.readOnly = t.ReadOnly() |
| 156 | if canonicalName == "bash" && permission.BashCommandIsReadOnly(plan.execArgs) { |
| 157 | // Bash is schema-level writer-capable, but the host can resolve a |
| 158 | // concrete invocation to read-only after parsing its arguments. Carry |
| 159 | // that fact through permission, mutation accounting, evidence, and the |
| 160 | // refreshed local tool receipt without changing the provider schema. |
| 161 | plan.readOnly = true |
| 162 | plan.resolvedMeta = &tool.ResolvedCall{TargetName: canonicalName, ReadOnly: true} |
| 163 | } |
| 164 | return toolOutcome{}, false |
| 165 | } |
| 166 | |
| 167 | // resolveToolPolicy applies Plan mode, proxy resolution, delivery gates, Auto |
| 168 | // Guard, and permission checks. Permission must complete before any write lease. |
| 169 | func (a *Agent) resolveToolPolicy(ctx context.Context, plan *toolCallPlan) (toolOutcome, bool) { |
| 170 | if blocked, early := a.applyPlanModeAndProxy(ctx, plan); early { |
| 171 | return blocked, true |
| 172 | } |
| 173 | if blocked, early := a.applyDeliveryPolicyGates(plan); early { |
| 174 | return blocked, true |
| 175 | } |
| 176 | // After proxy resolution, re-apply the batch mutation barrier using the |
| 177 | // real target classification. Provider-visible proxies such as |
| 178 | // use_capability advertise ReadOnly()==true before resolution and would |
| 179 | // otherwise slip past the pre-run skip pass. |
| 180 | if blocked, early := a.applyMutationDependencyBarrier(plan); early { |
| 181 | return blocked, true |
| 182 | } |
| 183 | if blocked, early := a.applyRecoveryAndPermission(ctx, plan); early { |
| 184 | return blocked, true |
| 185 | } |
| 186 | return toolOutcome{}, false |
| 187 | } |
| 188 | |
| 189 | // applyMutationDependencyBarrier blocks later mutations and verifications in the |
| 190 | // same provider batch after an earlier modification failed. Host-proven |
| 191 | // read-only diagnosis (resolved ReadOnly with no verification classification) |
| 192 | // still runs. |
| 193 | func (a *Agent) applyMutationDependencyBarrier(plan *toolCallPlan) (toolOutcome, bool) { |
| 194 | if a == nil || plan == nil || !a.mutationDependencyBarrier.Load() { |
| 195 | return toolOutcome{}, false |
| 196 | } |
| 197 | verification := plan.evidenceName == "bash" && evidence.IsDeliveryVerificationCommand(bashCommandFromArgs(plan.evidenceArgs)) |
| 198 | // Prefer the post-gate mutates flag; fall back to !readOnly so a resolved |
| 199 | // writer proxy cannot claim non-mutation by skipping ToolCallMutates. |
| 200 | mutates := plan.mutates || !plan.readOnly |
| 201 | if !mutates && !verification { |
| 202 | return toolOutcome{}, false |
| 203 | } |
| 204 | msg := "blocked: skipped because an earlier modification in this tool batch failed or was blocked. " + |
| 205 | "Fix or re-run the failed change first; verification was not executed." |
| 206 | var ex *tool.ShellExecution |
| 207 | // Structured shell metadata only for bash cards; other tools keep plain text. |
| 208 | if plan.evidenceName == "bash" || plan.call.Name == "bash" { |
| 209 | ex = shellPreflightExecution(plan, verification) |
| 210 | if ex != nil { |
| 211 | ex.FailurePhase = tool.ShellPhaseDependency |
| 212 | ex.State = tool.ShellStateNotRun |
| 213 | ex.MutationRisk = tool.ShellMutationNotStarted |
| 214 | if verification { |
| 215 | ex.Verification = tool.ShellVerificationNotRun |
| 216 | } |
| 217 | } |
| 218 | } |
| 219 | return toolOutcome{ |
| 220 | output: msg, |
| 221 | blocked: true, |
| 222 | errMsg: firstLine(msg), |
| 223 | execution: ex, |
| 224 | }, true |
| 225 | } |
| 226 | |
| 227 | // applyPlanModeAndProxy handles initial Plan mode, proxy resolution / skip path, |
| 228 | // resolved-target Plan re-check, and MCP Plan availability. |
| 229 | func (a *Agent) applyPlanModeAndProxy(ctx context.Context, plan *toolCallPlan) (toolOutcome, bool) { |
| 230 | t := plan.tool |
| 231 | call := plan.call |
| 232 | if a.planMode.Load() { |
| 233 | // Translate the tool's optional plan-mode self-report into the policy's |
| 234 | // tri-state. Mirrors the t.(tool.Previewer) assertion precedent below. |
| 235 | safety := planmode.PlanSafetyUnknown |
| 236 | if c, ok := t.(tool.PlanModeClassifier); ok { |
| 237 | if c.PlanModeSafe() { |
| 238 | safety = planmode.PlanSafetySafe |
| 239 | } else { |
| 240 | safety = planmode.PlanSafetyUnsafe |
| 241 | } |
| 242 | } |
| 243 | if decision := a.planModeDecision(plan.canonicalName, t.ReadOnly(), safety, json.RawMessage(call.Arguments)); decision.Blocked { |
| 244 | return toolOutcome{ |
| 245 | output: decision.Message, |
| 246 | blocked: true, |
| 247 | errMsg: "blocked: tool is unavailable during planning", |
| 248 | }, true |
| 249 | } |
| 250 | } |
| 251 | // Resolve proxy tools (use_capability) to the real MCP target before |
| 252 | // permission, hooks, and evidence. Provider transcript keeps call.Name. |
| 253 | if resolver, ok := t.(tool.CallResolver); ok { |
| 254 | rc, rerr := resolver.ResolveCall(ctx, json.RawMessage(call.Arguments)) |
| 255 | if rerr != nil { |
| 256 | return toolOutcome{ |
| 257 | output: fmt.Sprintf("error: %v", rerr), |
| 258 | errMsg: firstLine(rerr.Error()), |
| 259 | }, true |
| 260 | } |
| 261 | plan.resolved = rc |
| 262 | plan.resolvedMeta = &plan.resolved |
| 263 | if rc.TargetName != "" { |
| 264 | plan.permName = rc.TargetName |
| 265 | plan.evidenceName = rc.TargetName |
| 266 | } |
| 267 | if len(rc.Args) > 0 { |
| 268 | plan.permArgs = rc.Args |
| 269 | plan.evidenceArgs = rc.Args |
| 270 | plan.execArgs = rc.Args |
| 271 | } |
| 272 | if rc.Target != nil { |
| 273 | plan.execTool = rc.Target |
| 274 | } |
| 275 | plan.readOnly = rc.ReadOnly |
| 276 | if outcome, blocked := a.readOnlyExecutionBlock(t, &rc); blocked { |
| 277 | return outcome, true |
| 278 | } |
| 279 | if rc.Commit != nil { |
| 280 | if err := rc.Commit(); err != nil { |
| 281 | return toolOutcome{ |
| 282 | output: fmt.Sprintf("error: %v", err), |
| 283 | errMsg: firstLine(err.Error()), |
| 284 | }, true |
| 285 | } |
| 286 | } |
| 287 | if rc.SkipExecute { |
| 288 | // Resolution completed without target execution; still record a meta receipt. |
| 289 | // A connected mcp-server call completes during resolution by listing |
| 290 | // its live tools, so account for that successful call here too. |
| 291 | if rc.ProxyAction == "call" && !rc.Unavailable { |
| 292 | a.noteCapabilityInvocation(call.Name, json.RawMessage(call.Arguments), nil) |
| 293 | } |
| 294 | result := rc.Result |
| 295 | if a.evidence != nil { |
| 296 | // inspect/decline are not mutations; unavailable call targets are not success. |
| 297 | success := !rc.Unavailable |
| 298 | rec := evidence.ReceiptFromToolCall(call.Name, json.RawMessage(call.Arguments), success, true) |
| 299 | a.evidence.Record(rec) |
| 300 | } |
| 301 | if rc.Unavailable { |
| 302 | return toolOutcome{output: result, errMsg: firstLine(rc.UnavailableReason)}, true |
| 303 | } |
| 304 | body, truncMsg := truncateToolOutput(result) |
| 305 | return toolOutcome{output: body, truncated: truncMsg != "", truncMsg: truncMsg}, true |
| 306 | } |
| 307 | } else if outcome, blocked := a.readOnlyExecutionBlock(t, nil); blocked { |
| 308 | return outcome, true |
| 309 | } |
| 310 | |
| 311 | // A proxy resolution can point at a target with an explicit planning-phase |
| 312 | // opt-out even though the proxy itself has none. Re-check the resolved target |
| 313 | // before its ordinary permission and sandbox path. |
| 314 | if plan.resolved.TargetName != "" && a.planMode.Load() { |
| 315 | safety := planmode.PlanSafetyUnknown |
| 316 | if c, ok := plan.execTool.(tool.PlanModeClassifier); ok { |
| 317 | if c.PlanModeSafe() { |
| 318 | safety = planmode.PlanSafetySafe |
| 319 | } else { |
| 320 | safety = planmode.PlanSafetyUnsafe |
| 321 | } |
| 322 | } |
| 323 | if decision := a.planModeDecision(plan.permName, plan.resolved.ReadOnly, safety, plan.permArgs); decision.Blocked { |
| 324 | return toolOutcome{ |
| 325 | output: decision.Message, |
| 326 | blocked: true, |
| 327 | errMsg: "blocked: tool is unavailable during planning", |
| 328 | }, true |
| 329 | } |
| 330 | } |
| 331 | plannerTrustedMCP := a.plannerMCPExecution && isMCPExecutionTarget(plan.execTool, plan.permName) && mcpServerAuthorized(plan.execTool) && !mcpDestructiveHint(plan.execTool) |
| 332 | if a.planMode.Load() && isMCPExecutionTarget(plan.execTool, plan.permName) && !plannerTrustedMCP && (!plan.readOnly || !mcpServerAuthorized(plan.execTool) || mcpDestructiveHint(plan.execTool)) { |
| 333 | reason := "writer/destructive target" |
| 334 | if plan.readOnly && !mcpServerAuthorized(plan.execTool) { |
| 335 | reason = "reader from an unauthorized server" |
| 336 | } |
| 337 | return toolOutcome{ |
| 338 | output: fmt.Sprintf("blocked: MCP %s %q is unavailable during Plan mode; finish or exit Plan mode before requesting this call", reason, plan.permName), |
| 339 | blocked: true, |
| 340 | errMsg: "blocked: MCP target is unavailable during planning", |
| 341 | }, true |
| 342 | } |
| 343 | return toolOutcome{}, false |
| 344 | } |
| 345 | |
| 346 | // applyDeliveryPolicyGates enforces global deterministic shell contracts plus |
| 347 | // delivery-profile-only criteria rules, and classifies mutation/verification. |
| 348 | func (a *Agent) applyDeliveryPolicyGates(plan *toolCallPlan) (toolOutcome, bool) { |
| 349 | // Global deterministic shell contract (ordinary + Delivery). PowerShell 5.1 |
| 350 | // &&/|| is enforced inside the bash tool itself so descriptor and error text |
| 351 | // stay shell-accurate; the agent layers apply command-shape protections. |
| 352 | // Delivery keeps its longer recovery copy so existing delivery guidance tests |
| 353 | // and model recovery prompts stay stable. |
| 354 | // |
| 355 | // Ordinary mode blocks only shapes where a later segment can actually hide an |
| 356 | // earlier failure. Delivery keeps the broader classifier because a mutation |
| 357 | // invalidates the verification receipt even when the exit status is honest. |
| 358 | // Without that split, `go build ./... && go test ./...`, `npm install && |
| 359 | // npm test`, and every other short-circuit chain would be rejected for every |
| 360 | // user, though bash already reports the failing step's status for them. |
| 361 | if plan.evidenceName == "bash" { |
| 362 | if evidence.BashToolCallMasksVerificationExit(plan.evidenceArgs) { |
| 363 | msg := evidence.ShellContractPreflightMessage("mask_exit") |
| 364 | if a.deliveryProfile { |
| 365 | msg = "blocked: the trailing echo/printf of $? masks the verifier's exit status, so this command would look successful even when the check failed. Run the verifier or read-only extraction pipeline by itself and let its exit status be the tool result; for example: tail ... | head ... | node --check -" |
| 366 | } |
| 367 | return toolOutcome{ |
| 368 | output: msg, |
| 369 | blocked: true, |
| 370 | errMsg: "blocked: verification exit status masked", |
| 371 | execution: shellPreflightExecution(plan, true), |
| 372 | }, true |
| 373 | } |
| 374 | mixed := evidence.BashToolCallMixesMutationAndMaskableVerification |
| 375 | if a.deliveryProfile { |
| 376 | mixed = evidence.BashToolCallMixesMutationAndVerification |
| 377 | } |
| 378 | if mixed(plan.evidenceArgs) { |
| 379 | msg := evidence.ShellContractPreflightMessage("mixed") |
| 380 | if a.deliveryProfile { |
| 381 | msg = "blocked: this command mixes a verification check with a segment that may write state. Run the state-changing preparation separately while a todo is in_progress, then run a read-only verification command. For generated input, prefer a host-recognized read-only pipeline into the verifier (for example: tail ... | head ... | node --check -) instead of writing a temporary file." |
| 382 | } |
| 383 | return toolOutcome{ |
| 384 | output: msg, |
| 385 | blocked: true, |
| 386 | errMsg: "blocked: mixed mutation and verification command", |
| 387 | execution: shellPreflightExecution(plan, true), |
| 388 | }, true |
| 389 | } |
| 390 | if evidence.BashToolCallUsesNonTerminalInlineInterpreter(plan.evidenceArgs) { |
| 391 | msg := evidence.ShellContractPreflightMessage("inline_nonterminal") |
| 392 | return toolOutcome{ |
| 393 | output: msg, |
| 394 | blocked: true, |
| 395 | errMsg: "blocked: non-terminal inline interpreter command", |
| 396 | execution: shellPreflightExecution(plan, false), |
| 397 | }, true |
| 398 | } |
| 399 | } |
| 400 | // Delivery-only: any opaque inline interpreter is unauditable as evidence. |
| 401 | if a.deliveryProfile && plan.evidenceName == "bash" && evidence.BashToolCallUsesOpaqueInlineInterpreter(plan.evidenceArgs) { |
| 402 | return toolOutcome{ |
| 403 | output: "blocked: delivery mode cannot audit inline interpreter source such as node -e or python -c, so executing it would become an opaque mutation and invalidate prior verification. For inspection, use read_file/grep or another host-proven read-only command. For validation, use a conventional verifier such as node --check, a project test/check/lint command, or a read-only extraction pipeline into the verifier. For an intentional state change, use a file tool or a script file under the current in_progress todo. " + evidence.VerificationCommandSummary(), |
| 404 | blocked: true, |
| 405 | errMsg: "blocked: opaque inline interpreter command", |
| 406 | execution: shellPreflightExecution(plan, false), |
| 407 | }, true |
| 408 | } |
| 409 | |
| 410 | plan.mutates = evidence.ToolCallMutates(plan.evidenceName, plan.evidenceArgs, plan.readOnly) |
| 411 | persistentWorkflowCall := a.deliveryPersistentExpected && !a.deliveryMutationExpected && plan.evidenceName == "remember" |
| 412 | if a.deliveryProfile && !persistentWorkflowCall && evidence.ToolCallRequiresDeliveryCriteria(plan.evidenceName, plan.evidenceArgs, plan.readOnly) && !a.deliveryCriteriaEstablished { |
| 413 | return toolOutcome{ |
| 414 | output: "blocked: delivery-first mode requires acceptance criteria before state-changing work. Call todo_write with a concrete, verifiable task list, then retry this tool call.", |
| 415 | blocked: true, |
| 416 | errMsg: "blocked: delivery acceptance criteria required", |
| 417 | }, true |
| 418 | } |
| 419 | if a.deliveryProfile && !persistentWorkflowCall && plan.mutates && !a.hasActiveCanonicalTodo() { |
| 420 | return toolOutcome{ |
| 421 | output: "blocked: delivery-first mode requires every state change to belong to the current in_progress todo. Preserve the completed todo prefix, append a concrete new item if more work was discovered, mark that item in_progress with todo_write, then retry this mutation.", |
| 422 | blocked: true, |
| 423 | errMsg: "blocked: active delivery todo required", |
| 424 | }, true |
| 425 | } |
| 426 | return toolOutcome{}, false |
| 427 | } |
| 428 | |
| 429 | // applyRecoveryAndPermission runs Auto Guard then ordinary permission. Neither |
| 430 | // acquires a write lease; that happens only after permission in prepare. |
| 431 | func (a *Agent) applyRecoveryAndPermission(ctx context.Context, plan *toolCallPlan) (toolOutcome, bool) { |
| 432 | // Auto Guard: after resolution/mutation classification, before |
| 433 | // permission approval and workspace write-lock acquisition, so a waiting |
| 434 | // recovery card never holds a write lease. Consult on mutations, |
| 435 | // verification, plan transitions, and again for every tool once an Episode |
| 436 | // is exhausted so host-proven read-only diagnosis can remain available while |
| 437 | // further execution is quarantined. Ask/Yolo still bypass inside the gate. |
| 438 | plan.verification = plan.evidenceName == "bash" && evidence.IsDeliveryVerificationCommand(bashCommandFromArgs(plan.evidenceArgs)) |
| 439 | plan.planTransition, plan.planBefore, plan.planAfter = a.recoveryPlanTransition(plan.evidenceName, plan.evidenceArgs) |
| 440 | episodeStopped := false |
| 441 | if ctrl := a.recoveryEpisodeControl(); ctrl != nil { |
| 442 | plan.recoveryGen = ctrl.Generation() |
| 443 | episodeStopped = ctrl.EpisodeStopped(a.recoveryTaskID) |
| 444 | } |
| 445 | if a.recoveryGate != nil && (plan.mutates || plan.verification || plan.planTransition || episodeStopped) { |
| 446 | subject := recoverySubject(plan.evidenceName, plan.evidenceArgs) |
| 447 | if plan.planTransition { |
| 448 | subject = "Update the active execution plan" |
| 449 | } |
| 450 | preview := strings.TrimSpace(plan.call.Diff) |
| 451 | if preview == "" { |
| 452 | preview = subject |
| 453 | } |
| 454 | if plan.planTransition { |
| 455 | preview = plan.planAfter |
| 456 | } |
| 457 | episodeID := "" |
| 458 | if ctrl := a.recoveryEpisodeControl(); ctrl != nil { |
| 459 | episodeID = ctrl.EpisodeID() |
| 460 | } |
| 461 | dec, rerr := a.recoveryGate.BeforeMutation(ctx, RecoveryProposal{ |
| 462 | AgentID: a.recoveryAgentID, |
| 463 | TaskID: a.recoveryTaskID, |
| 464 | TaskScopeID: recoveryTaskScopeID(a.deliveryScopeID, a.recoveryRunSeq.Load()), |
| 465 | EpisodeID: episodeID, |
| 466 | TaskSummary: a.recoveryTaskSummary, |
| 467 | Tool: plan.evidenceName, |
| 468 | Args: plan.evidenceArgs, |
| 469 | Subject: subject, |
| 470 | Preview: preview, |
| 471 | ReadOnly: plan.readOnly, |
| 472 | Mutates: plan.mutates, |
| 473 | Verification: plan.verification, |
| 474 | PlanTransition: plan.planTransition, |
| 475 | PlanBefore: plan.planBefore, |
| 476 | PlanAfter: plan.planAfter, |
| 477 | }) |
| 478 | if dec.Generation != 0 { |
| 479 | plan.recoveryGen = dec.Generation |
| 480 | } |
| 481 | if rerr != nil && !dec.Blocked { |
| 482 | return toolOutcome{ |
| 483 | output: fmt.Sprintf("blocked: Auto Guard error: %v", rerr), |
| 484 | blocked: true, |
| 485 | errMsg: "blocked: Auto Guard error", |
| 486 | recoveryGeneration: plan.recoveryGen, |
| 487 | }, true |
| 488 | } |
| 489 | if dec.Blocked || !dec.Allow { |
| 490 | msg := strings.TrimSpace(dec.Message) |
| 491 | if msg == "" { |
| 492 | msg = "blocked: Auto Guard declined this mutation" |
| 493 | } |
| 494 | if !strings.HasPrefix(msg, "blocked:") { |
| 495 | msg = "blocked: " + msg |
| 496 | } |
| 497 | return toolOutcome{ |
| 498 | output: msg, |
| 499 | blocked: true, |
| 500 | // Surface the concrete stopped operation and next step in the |
| 501 | // failed tool card instead of exposing only an internal guard name. |
| 502 | errMsg: firstLine(msg), |
| 503 | recoveryGeneration: plan.recoveryGen, |
| 504 | recoveryStopTurn: dec.StopTurn, |
| 505 | recoveryStopReason: dec.StopReason, |
| 506 | }, true |
| 507 | } |
| 508 | plan.planReplacementAuthorized = plan.planTransition && dec.AuthorizePlanReplacement |
| 509 | } |
| 510 | // Trusted MCP fast path: installed tools and authorized lifecycle connects |
| 511 | // (mcp_connect__*) skip ordinary Ask/Auto/dontAsk gates. Only explicit deny |
| 512 | // and live authorization apply — first connect of an installed server must |
| 513 | // not re-prompt under headless or partial-auto policies. |
| 514 | if isInstalledMCPTool(plan.execTool) || isMCPLifecycleConnectTarget(plan.execTool) { |
| 515 | if !mcpServerAuthorized(plan.execTool) { |
| 516 | return toolOutcome{ |
| 517 | output: "blocked: this project MCP server identity has not been authorized; approve the server from a parent session and retry", |
| 518 | blocked: true, |
| 519 | errMsg: "blocked: MCP server identity is not authorized", |
| 520 | }, true |
| 521 | } |
| 522 | if denyGate, ok := a.gate.(ExplicitDenyGate); ok && denyGate.ExplicitlyDenies(plan.permName, plan.permArgs) { |
| 523 | return toolOutcome{ |
| 524 | 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.", |
| 525 | blocked: true, |
| 526 | errMsg: "blocked by permission policy", |
| 527 | }, true |
| 528 | } |
| 529 | } else if a.gate != nil { |
| 530 | allow, reason, err := a.gate.Check(ctx, plan.permName, plan.permArgs, plan.readOnly) |
| 531 | if err != nil { |
| 532 | return toolOutcome{ |
| 533 | output: fmt.Sprintf("blocked: %s (%v)", reason, err), |
| 534 | blocked: true, |
| 535 | errMsg: fmt.Sprintf("blocked: %v", err), |
| 536 | }, true |
| 537 | } |
| 538 | // permission.decision: the host verdict is computed first; the |
| 539 | // extension ruling may override it in either direction (an allow |
| 540 | // overriding a host deny is the full-trust contract and is audited). |
| 541 | if blocked, early := a.interceptExtensionPermission(ctx, plan, &allow); early { |
| 542 | return blocked, true |
| 543 | } |
| 544 | if !allow { |
| 545 | return toolOutcome{ |
| 546 | output: "blocked: " + reason, |
| 547 | blocked: true, |
| 548 | errMsg: "blocked by permission policy", |
| 549 | }, true |
| 550 | } |
| 551 | } |
| 552 | return toolOutcome{}, false |
| 553 | } |
| 554 | |
| 555 | // prepareToolExecution acquires write leases, parent write claims, runs |
| 556 | // PreToolUse hooks and preview checkpoints, and injects call context. All of |
| 557 | // this happens after permission and before the concrete Execute call. |
| 558 | func (a *Agent) prepareToolExecution(ctx context.Context, plan *toolCallPlan) (toolOutcome, bool) { |
| 559 | // Acquire after permission is granted but before PreToolUse: hooks are user |
| 560 | // shell code and can themselves change the workspace. This keeps readers |
| 561 | // concurrent and avoids holding the workspace during an approval prompt while |
| 562 | // still covering every write-side action that follows authorization. |
| 563 | if a.deliveryProfile && plan.mutates && a.workspaceLease != nil { |
| 564 | if err := a.workspaceLease.AcquireWrite(ctx); err != nil { |
| 565 | return toolOutcome{ |
| 566 | output: fmt.Sprintf("blocked: the workspace did not become available for Delivery writing: %v", err), |
| 567 | blocked: true, |
| 568 | errMsg: "blocked: workspace write lease unavailable", |
| 569 | }, true |
| 570 | } |
| 571 | } |
| 572 | // Resolve the concrete execution target before hooks. A proxy may carry a |
| 573 | // different target/name/argument set than the provider-visible call. |
| 574 | plan.runTool = plan.execTool |
| 575 | plan.runArgs = plan.execArgs |
| 576 | if plan.resolved.Target != nil { |
| 577 | plan.runTool = plan.resolved.Target |
| 578 | plan.runArgs = plan.resolved.Args |
| 579 | if len(plan.runArgs) == 0 { |
| 580 | plan.runArgs = json.RawMessage(`{}`) |
| 581 | } |
| 582 | } |
| 583 | // Hold the parent claim before PreToolUse: hooks are user shell code and may |
| 584 | // mutate the same workspace. The reservation remains live through hooks, |
| 585 | // checkpointing, and the concrete Execute call, closing both hook-side and |
| 586 | // check-before-write TOCTOU windows. Dynamic Economy/MCP tools are covered |
| 587 | // here after registry lookup without schema-changing wrappers. |
| 588 | // executeOne defers plan.releaseParentWrite so every return path releases. |
| 589 | if releaseParentWrite, perr := a.reserveParentWrite(plan.runTool, plan.runArgs, plan.readOnly); perr != nil { |
| 590 | return toolOutcome{ |
| 591 | output: "blocked: " + perr.Error(), |
| 592 | blocked: true, |
| 593 | errMsg: "blocked: write path claimed by background subagent", |
| 594 | }, true |
| 595 | } else if releaseParentWrite != nil { |
| 596 | plan.releaseParentWrite = releaseParentWrite |
| 597 | } |
| 598 | // Acquire the checkpoint barrier before preimage capture and any hook. It is |
| 599 | // held through post hooks and AfterMutation so rewind cannot interleave with |
| 600 | // writer-side user code. |
| 601 | if !plan.readOnly && a.mutationObserver != nil && a.mutationObserver.Store() != nil { |
| 602 | barrier := a.mutationObserver.Store().Barrier() |
| 603 | if err := barrier.EnterWrite(); err != nil { |
| 604 | return toolOutcome{output: "blocked: " + err.Error(), blocked: true, errMsg: "blocked: mutation barrier unavailable"}, true |
| 605 | } |
| 606 | plan.releaseMutationWrite = barrier.ExitWrite |
| 607 | } |
| 608 | // Checkpoint the file this writer is about to change before PreToolUse. |
| 609 | // A hook may mutate and then block the call, so the deferred AfterMutation |
| 610 | // still finalizes the fingerprint on every return path. Built-in |
| 611 | // Previewers get precise paths (complete coverage). Bash / opaque MCP |
| 612 | // writers record explicit coverage gaps instead of guessing targets. |
| 613 | if !plan.readOnly { |
| 614 | a.observeBeforeMutation(plan) |
| 615 | plan.mutationObserved = plan.mutationPath != "" |
| 616 | if toolHooksMayMutateWorkspace(a.hooks) && a.mutationObserver != nil { |
| 617 | a.mutationObserver.RecordGap(checkpoint.CoverageGap{Reason: checkpoint.GapHookWrite, Tool: plan.evidenceName, Detail: "tool hook may write paths that are not declared by the tool"}) |
| 618 | } |
| 619 | } |
| 620 | // Proxy tools fire hooks against the real MCP target name and arguments. |
| 621 | if a.hooks != nil { |
| 622 | if block, msg := a.hooks.PreToolUse(ctx, plan.permName, plan.permArgs); block { |
| 623 | if msg == "" { |
| 624 | msg = "blocked by a PreToolUse hook" |
| 625 | } |
| 626 | return toolOutcome{ |
| 627 | output: "blocked: " + msg, |
| 628 | blocked: true, |
| 629 | errMsg: "blocked by PreToolUse hook", |
| 630 | }, true |
| 631 | } |
| 632 | } |
| 633 | cctx := withCallContext(ctx, plan.call.ID, a.sink, a.asker, a.planMode.Load()) |
| 634 | cctx = WithSubagentDepth(cctx, a.subagentDepth) |
| 635 | if a.evidence != nil { |
| 636 | cctx = evidence.WithLedger(cctx, a.evidence) |
| 637 | cctx = evidence.WithSessionMessages(cctx, a.session.Snapshot()) |
| 638 | if a.deliveryProfile { |
| 639 | cctx = evidence.WithDeliveryProfile(cctx) |
| 640 | } |
| 641 | } |
| 642 | if !a.planMode.Load() { |
| 643 | cctx = evidence.WithTodoState(cctx, a.CanonicalTodoState()) |
| 644 | } |
| 645 | if plan.planReplacementAuthorized { |
| 646 | cctx = tool.WithPlanReplacementAuthorization(cctx) |
| 647 | } |
| 648 | if len(a.projectChecks) > 0 { |
| 649 | cctx = instruction.WithChecks(cctx, a.projectChecks) |
| 650 | } |
| 651 | if a.jobs != nil { |
| 652 | cctx = jobs.WithManager(cctx, a.jobs) |
| 653 | } |
| 654 | if a.sandboxEscapeApprover != nil { |
| 655 | cctx = sandbox.WithEscapeApprover(cctx, a.sandboxEscapeApprover) |
| 656 | } |
| 657 | if a.configWriteApprover != nil { |
| 658 | cctx = tool.WithConfigWriteApprover(cctx, a.configWriteApprover) |
| 659 | } |
| 660 | if v := a.responseLanguage.Load(); v != nil { |
| 661 | if lang, ok := v.(string); ok { |
| 662 | cctx = WithResponseLanguagePreference(cctx, lang) |
| 663 | } |
| 664 | } |
| 665 | if v := a.reasoningLanguage.Load(); v != nil { |
| 666 | if lang, ok := v.(string); ok { |
| 667 | cctx = WithReasoningLanguagePreference(cctx, lang) |
| 668 | } |
| 669 | } |
| 670 | if a.memQueue != nil { |
| 671 | cctx = memory.WithQueue(cctx, a.memQueue) |
| 672 | } |
| 673 | callID := plan.call.ID |
| 674 | cctx = tool.WithProgress(cctx, func(chunk string) { |
| 675 | a.sink.Emit(event.Event{Kind: event.ToolProgress, Tool: event.Tool{ID: callID, Output: chunk}}) |
| 676 | }) |
| 677 | plan.cctx = cctx |
| 678 | return toolOutcome{}, false |
| 679 | } |
| 680 | |
| 681 | type toolMutationHookReporter interface { |
| 682 | ToolMutationHooksEnabled() bool |
| 683 | } |
| 684 | |
| 685 | func toolHooksMayMutateWorkspace(hooks ToolHooks) bool { |
| 686 | if hooks == nil { |
| 687 | return false |
| 688 | } |
| 689 | if reporter, ok := hooks.(toolMutationHookReporter); ok { |
| 690 | return reporter.ToolMutationHooksEnabled() |
| 691 | } |
| 692 | // Custom ToolHooks implementations predate the capability report. Preserve |
| 693 | // conservative coverage for them because their callbacks may write files. |
| 694 | return true |
| 695 | } |
| 696 | |
| 697 | // finishToolExecution performs the concrete Execute, records evidence, runs |
| 698 | // post hooks and recovery observation, and truncates the model-facing result. |
| 699 | func (a *Agent) finishToolExecution(ctx context.Context, plan *toolCallPlan) toolOutcome { |
| 700 | cctx := plan.cctx |
| 701 | runTool := plan.runTool |
| 702 | runArgs := plan.runArgs |
| 703 | call := plan.call |
| 704 | t := plan.tool |
| 705 | readOnly := plan.readOnly |
| 706 | permName := plan.permName |
| 707 | permArgs := plan.permArgs |
| 708 | evidenceName := plan.evidenceName |
| 709 | evidenceArgs := plan.evidenceArgs |
| 710 | mutates := plan.mutates |
| 711 | recoveryGen := plan.recoveryGen |
| 712 | |
| 713 | var result string |
| 714 | var images []string |
| 715 | var err error |
| 716 | // A call that was authorized under reader classification carries that |
| 717 | // basis into dispatch: the MCP execution layer re-verifies it linearizably |
| 718 | // against server authorization and live safety metadata, and refuses to |
| 719 | // promote it into a writer lane if reclassification landed after the gate. |
| 720 | if readOnly && isInstalledMCPTool(runTool) && mcpServerAuthorized(runTool) && !mcpDestructiveHint(runTool) { |
| 721 | cctx = tool.WithReaderExecutionIntent(cctx) |
| 722 | } |
| 723 | // Planner-trusted MCP: authorized + non-destructive, even without |
| 724 | // readOnlyHint. Final dispatch re-checks live authorization/destructiveHint. |
| 725 | if a.plannerMCPExecution && isMCPExecutionTarget(runTool, permName) && mcpServerAuthorized(runTool) && !mcpDestructiveHint(runTool) { |
| 726 | cctx = tool.WithNonDestructiveMCPExecutionIntent(cctx) |
| 727 | } |
| 728 | var execution *tool.ShellExecution |
| 729 | if de, ok := runTool.(tool.DetailedExecutor); ok { |
| 730 | var detailed tool.DetailedResult |
| 731 | detailed, err = de.ExecuteDetailed(cctx, runArgs) |
| 732 | result, images, execution = detailed.Output, detailed.Images, detailed.Execution |
| 733 | // Annotate verification outcome when the host classified this call as a verifier. |
| 734 | if execution != nil && plan.verification { |
| 735 | switch { |
| 736 | case err != nil: |
| 737 | execution.Verification = tool.ShellVerificationFailed |
| 738 | default: |
| 739 | execution.Verification = tool.ShellVerificationPassed |
| 740 | } |
| 741 | } else if execution != nil && execution.Verification == "" { |
| 742 | execution.Verification = tool.ShellVerificationNotVerification |
| 743 | } |
| 744 | // Sole opaque inline interpreters are allowed outside Delivery but cannot |
| 745 | // prove mutation completeness. |
| 746 | if execution != nil && evidence.BashCommandMayBeOpaqueMutation(runArgs) && |
| 747 | execution.MutationRisk == tool.ShellMutationMayHaveCompleted { |
| 748 | execution.MutationRisk = tool.ShellMutationUnknown |
| 749 | } |
| 750 | } else if it, ok := runTool.(tool.ImageTool); ok { |
| 751 | result, images, err = it.ExecuteWithImages(cctx, runArgs) |
| 752 | } else { |
| 753 | result, err = runTool.Execute(cctx, runArgs) |
| 754 | } |
| 755 | // tool.after: extensions rule on the executed result (success or error) |
| 756 | // before evidence, hooks, and recovery observation, so every downstream |
| 757 | // consumer sees the final (possibly replaced) outcome. |
| 758 | result, err = a.interceptToolAfter(ctx, call, result, err) |
| 759 | if a.evidence != nil { |
| 760 | // Always record the model-visible call for audit, then the real target |
| 761 | // attributes for mutation/read classification when they differ. |
| 762 | if call.Name == "complete_step" { |
| 763 | rec := evidence.ReceiptFromToolCall(call.Name, json.RawMessage(call.Arguments), err == nil, readOnly) |
| 764 | a.evidence.Record(rec) |
| 765 | if err == nil { |
| 766 | a.advanceCanonicalTodo(rec.Step) |
| 767 | } |
| 768 | } else if evidenceName != call.Name { |
| 769 | // Proxy: meta receipt (non-mutation) + real target receipt. |
| 770 | a.evidence.Record(evidence.ReceiptFromToolCall(call.Name, json.RawMessage(call.Arguments), err == nil, true)) |
| 771 | rec := evidence.ReceiptFromToolCall(evidenceName, evidenceArgs, err == nil, readOnly) |
| 772 | rec.OutputBytes = len(strings.TrimSpace(result)) |
| 773 | a.evidence.Record(rec) |
| 774 | } else { |
| 775 | rec := evidence.ReceiptFromToolCall(call.Name, json.RawMessage(call.Arguments), err == nil, t.ReadOnly()) |
| 776 | rec.OutputBytes = len(strings.TrimSpace(result)) |
| 777 | a.evidence.Record(rec) |
| 778 | if err == nil && call.Name == "todo_write" { |
| 779 | a.setTodoState(rec.Todos) |
| 780 | if len(rec.Todos) > 0 { |
| 781 | a.deliveryCriteriaEstablished = true |
| 782 | } |
| 783 | } |
| 784 | } |
| 785 | } |
| 786 | // Track skill/capability outcomes for Delivery gates. |
| 787 | a.noteCapabilityInvocation(call.Name, json.RawMessage(call.Arguments), err) |
| 788 | // Success and failure hooks observe the result after the tool ran. Use the |
| 789 | // real target name for proxied tools. |
| 790 | if a.hooks != nil { |
| 791 | if err != nil { |
| 792 | a.hooks.PostToolUseFailure(ctx, permName, permArgs, result, err) |
| 793 | } else { |
| 794 | a.hooks.PostToolUse(ctx, permName, permArgs, result) |
| 795 | } |
| 796 | } |
| 797 | // Always re-read after post hooks — partial writes and hook side effects can |
| 798 | // change the previewed path even when the concrete tool returned an error. |
| 799 | a.observeAfterMutation(plan) |
| 800 | plan.mutationAfterDone = true |
| 801 | if a.recoveryGate != nil { |
| 802 | a.observeRecoveryResult(ctx, evidenceName, evidenceArgs, readOnly, mutates, result, err, false, false, recoveryGen) |
| 803 | } |
| 804 | if err != nil { |
| 805 | detail := result |
| 806 | // Malformed-args failures are a transient model JSON glitch (e.g. options |
| 807 | // written as ["a":"b"] → "invalid character ':' after array element"). The |
| 808 | // args can't be safely re-parsed, but echoing the tool's schema makes the |
| 809 | // retry land valid instead of repeating the same broken shape. |
| 810 | if !json.Valid([]byte(call.Arguments)) { |
| 811 | detail = strings.TrimRight(detail, "\n") + "\nThe arguments were not valid JSON. Re-emit them exactly per this schema:\n" + string(t.Schema()) |
| 812 | } |
| 813 | a.recordRepeatFailure(call, t, err) |
| 814 | body, truncMsg := truncateToolOutput(fmt.Sprintf("error: %v\n%s", err, detail)) |
| 815 | return toolOutcome{ |
| 816 | output: body, errMsg: firstLine(err.Error()), truncated: truncMsg != "", truncMsg: truncMsg, |
| 817 | execution: execution, recoveryGeneration: recoveryGen, |
| 818 | } |
| 819 | } |
| 820 | if mutates { |
| 821 | a.clearRepeatFailuresAfterMutation(evidenceName, evidenceArgs, readOnly) |
| 822 | } |
| 823 | a.recordRepeatSuccess(call, t) |
| 824 | // A foreground `task` sub-agent just finished — its result is the final answer. |
| 825 | // (A backgrounded one returns a "Started…" string and stops later in a job, so |
| 826 | // it doesn't fire here.) SubagentStop lets a hook react to delegated work. |
| 827 | if a.hooks != nil && call.Name == "task" && !isBackgroundTaskCall(call.Arguments) { |
| 828 | a.hooks.SubagentStop(ctx, result) |
| 829 | } |
| 830 | body, truncMsg := truncateToolOutput(result) |
| 831 | return toolOutcome{ |
| 832 | output: body, images: images, truncated: truncMsg != "", truncMsg: truncMsg, |
| 833 | execution: execution, recoveryGeneration: recoveryGen, |
| 834 | } |
| 835 | } |
| 836 | |
| 837 | // shellPreflightExecution builds not_run/preflight metadata for a blocked bash call. |
| 838 | func shellPreflightExecution(plan *toolCallPlan, hasVerification bool) *tool.ShellExecution { |
| 839 | ex := &tool.ShellExecution{ |
| 840 | Kind: "shell", |
| 841 | State: tool.ShellStateNotRun, |
| 842 | FailurePhase: tool.ShellPhasePreflight, |
| 843 | MutationRisk: tool.ShellMutationNotStarted, |
| 844 | Verification: tool.ShellVerificationNotVerification, |
| 845 | } |
| 846 | if hasVerification { |
| 847 | ex.Verification = tool.ShellVerificationNotRun |
| 848 | } |
| 849 | if plan != nil { |
| 850 | if de, ok := plan.execTool.(tool.DetailedExecutor); ok { |
| 851 | if desc := de.ExecutionDescriptor(plan.execArgs); desc != nil { |
| 852 | ex.Shell = desc.Shell |
| 853 | ex.ShellVersion = desc.ShellVersion |
| 854 | ex.Platform = desc.Platform |
| 855 | ex.SupportsAndAnd = desc.SupportsAndAnd |
| 856 | } |
| 857 | } |
| 858 | } |
| 859 | return ex |
| 860 | } |
| 861 | |
| 862 | // observeBeforeMutation captures preimages for Previewable writers and records |
| 863 | // explicit coverage gaps for bash / opaque MCP tools. Host-internal only. |
| 864 | func (a *Agent) observeBeforeMutation(plan *toolCallPlan) { |
| 865 | if a == nil || plan == nil { |
| 866 | return |
| 867 | } |
| 868 | toolName := plan.evidenceName |
| 869 | if toolName == "" { |
| 870 | toolName = plan.call.Name |
| 871 | } |
| 872 | obs := a.mutationObserver |
| 873 | if obs != nil { |
| 874 | if pv, ok := plan.execTool.(tool.Previewer); ok { |
| 875 | if change, perr := pv.Preview(plan.execArgs); perr == nil && change.Path != "" { |
| 876 | obs.BeforeMutationFromChange(change, toolName) |
| 877 | plan.mutationPath = change.Path |
| 878 | return |
| 879 | } |
| 880 | } |
| 881 | // Non-previewable writers: record a coverage gap (do not guess paths). |
| 882 | switch toolName { |
| 883 | case "bash": |
| 884 | obs.RecordGap(checkpoint.CoverageGap{Reason: checkpoint.GapBashSideEffect, Tool: toolName, Detail: "bash side effects are not path-tracked"}) |
| 885 | default: |
| 886 | // MCP or other writers without Previewer. |
| 887 | if !plan.readOnly { |
| 888 | obs.RecordGap(checkpoint.CoverageGap{Reason: checkpoint.GapMCPExternal, Tool: toolName, Detail: "tool cannot describe local write paths"}) |
| 889 | } |
| 890 | } |
| 891 | return |
| 892 | } |
| 893 | // Legacy onPreEdit path. |
| 894 | if a.onPreEdit != nil { |
| 895 | if pv, ok := plan.execTool.(tool.Previewer); ok { |
| 896 | if change, perr := pv.Preview(plan.execArgs); perr == nil { |
| 897 | a.onPreEdit(change) |
| 898 | plan.mutationPath = change.Path |
| 899 | } |
| 900 | } |
| 901 | } |
| 902 | } |
| 903 | |
| 904 | // observeAfterMutation records the after fingerprint when a concrete path was |
| 905 | // known before execution, regardless of tool success or failure. |
| 906 | func (a *Agent) observeAfterMutation(plan *toolCallPlan) { |
| 907 | if a == nil || plan == nil || plan.mutationPath == "" || a.mutationObserver == nil { |
| 908 | return |
| 909 | } |
| 910 | toolName := plan.evidenceName |
| 911 | if toolName == "" { |
| 912 | toolName = plan.call.Name |
| 913 | } |
| 914 | a.mutationObserver.AfterMutation(plan.mutationPath, toolName) |
| 915 | } |
| 916 |