| 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 |