返回 DeepSeek-Reasonix
agent.go
根目录 / internal / agent / agent.go
1 package agent
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "strings"
10 "sync"
11 "sync/atomic"
12 "time"
13 "unicode/utf8"
14
15 "mvdan.cc/sh/v3/syntax"
16
17 "reasonix/internal/ablation"
18 "reasonix/internal/capability"
19 "reasonix/internal/checkpoint"
20 "reasonix/internal/diff"
21 "reasonix/internal/event"
22 "reasonix/internal/evidence"
23 "reasonix/internal/extension/dispatch"
24 "reasonix/internal/instruction"
25 "reasonix/internal/jobs"
26 "reasonix/internal/memory"
27 "reasonix/internal/nilutil"
28 "reasonix/internal/permission"
29 "reasonix/internal/planmode"
30 "reasonix/internal/provider"
31 "reasonix/internal/sandbox"
32 "reasonix/internal/shellparse"
33 "reasonix/internal/tool"
34 "reasonix/internal/workspacelease"
35 )
36
37 // maxToolOutputBytes caps a single tool result before it goes into the model's
38 // context. ~32KB is roughly 8K tokens — enough for a full file read or a busy
39 // grep, while preventing one accidental "read this 5 MB log" from blowing the
40 // window before the next compaction runs.
41 const maxToolOutputBytes = 32 * 1024
42
43 const maxEmptyFinalBlocks = 3
44
45 // maxStreamRecoveries is the number of body-phase stream retries after the
46 // initial sampling attempt (Codex-aligned default: 1 + 5 = 6 attempts total).
47 const maxStreamRecoveries = 5
48 const maxSamplingAttempts = maxStreamRecoveries + 1
49 const maxExecutorHandoffNudges = 1
50
51 const defaultReasoningByteLimit = 128 * 1024
52
53 const finishReasonClientReasoningLimit = "client_reasoning_limit"
54
55 var errReasoningByteLimitExceeded = errors.New("reasoning output exceeded client byte limit")
56
57 // DeliveryRuntimeMarker is the delivery-mode contract block appended to user
58 // turns (withTurnPreferences). Exported as the single source of truth for the
59 // byte-exact suffix strip in preview derivation and for cross-package tests;
60 // its text is cache-frozen — changing it breaks steer replay matching and the
61 // prefix stability of every live delivery session.
62 const DeliveryRuntimeMarker = `<delivery-runtime>
63 This session is in delivery-first mode. Before any state-changing tool call,
64 establish concrete, verifiable acceptance criteria with todo_write. After the
65 change, inspect the result, run relevant verification, and sign off each step
66 with complete_step citing the successful verification command. The host enforces
67 these gates and will reject mutation or finalization when evidence is missing.
68 </delivery-runtime>`
69
70 // Renderer redraws the assistant's final-answer text as styled output. It is
71 // applied only after a turn's text stream completes, so the user sees raw
72 // markdown stream live, then a single redraw replaces it with formatted
73 // output. The renderer is intentionally interface-shaped so the agent stays
74 // independent of the cli's markdown library choice. Consumed by TextSink.
75 type Renderer interface {
76 Render(text string) string
77 }
78
79 // Asker puts structured multiple-choice questions to the user and blocks for the
80 // answers. The agent consults it for the `ask` tool. It is interface-shaped so
81 // the agent stays independent of the frontend; a nil asker means no interactive
82 // user (headless runs), where `ask` returns a "decide for yourself" result. The
83 // interactive frontends wire the controller in as the Asker.
84 type Asker interface {
85 Ask(ctx context.Context, questions []event.AskQuestion) ([]event.AskAnswer, error)
86 }
87
88 // callContextKey carries the executing tool call's identity into Execute.
89 type callContextKey struct{}
90 type parentSessionContextKey struct{}
91 type subagentDepthContextKey struct{}
92 type userImagesContextKey struct{}
93
94 // callContext is the per-call context a tool can read. parentID is the call being
95 // executed and sink is the agent's event sink (the `task` tool uses both to nest
96 // a sub-agent's events under this call); asker lets the `ask` tool reach the user.
97 type callContext struct {
98 parentID string
99 sink event.Sink
100 asker Asker
101 planMode bool
102 }
103
104 // withCallContext stamps ctx with the executing call's ID, the agent's sink, and
105 // the asker. executeOne sets this before every Execute; `task` reads it (via
106 // CallContext) to nest sub-agent events, and `ask` reads the asker to prompt.
107 // The plan-mode flag is mirrored onto the leaf planmode key so tools that must
108 // not import this package (for example internal/tool/builtin) can still read it.
109 func withCallContext(ctx context.Context, parentID string, sink event.Sink, asker Asker, planMode bool) context.Context {
110 ctx = planmode.WithActive(ctx, planMode)
111 return context.WithValue(ctx, callContextKey{}, callContext{parentID: parentID, sink: sink, asker: asker, planMode: planMode})
112 }
113
114 // WithToolCallContext stamps ctx as a host-initiated top-level tool call.
115 // Normal model-selected tools receive this context from executeOne; controller
116 // entry points that deliberately invoke the same tool machinery (for example a
117 // user typing /<subagent-skill>) use this exported wrapper so nested sub-agent
118 // activity still reaches the parent event stream and plan-mode policy remains
119 // visible to the invoked runner.
120 func WithToolCallContext(ctx context.Context, parentID string, sink event.Sink, asker Asker, planMode bool) context.Context {
121 return withCallContext(ctx, parentID, sink, asker, planMode)
122 }
123
124 // CallContext returns the executing call's ID, the agent's sink, and the asker,
125 // if the context was set by an agent's executeOne. ok is false for a plain
126 // context (headless tool tests, calls made outside the run loop).
127 func CallContext(ctx context.Context) (parentID string, sink event.Sink, asker Asker, ok bool) {
128 cc, ok := ctx.Value(callContextKey{}).(callContext)
129 if !ok {
130 return "", nil, nil, false
131 }
132 return cc.parentID, cc.sink, cc.asker, true
133 }
134
135 // PlanModeFromContext reports whether the tool call is executing during the
136 // plan-first workflow. Tools may use it for phase-specific behavior, but it is
137 // not a permission or read-only boundary.
138 func PlanModeFromContext(ctx context.Context) bool {
139 cc, ok := ctx.Value(callContextKey{}).(callContext)
140 return ok && cc.planMode
141 }
142
143 // WithParentSession stamps the active parent session ID onto a turn context so
144 // persisted sub-agents can record and enforce their owning conversation.
145 func WithParentSession(ctx context.Context, parentSession string) context.Context {
146 return context.WithValue(ctx, parentSessionContextKey{}, strings.TrimSpace(parentSession))
147 }
148
149 // ParentSession returns the active parent session ID carried by a turn context.
150 func ParentSession(ctx context.Context) string {
151 parentSession, _ := ctx.Value(parentSessionContextKey{}).(string)
152 return strings.TrimSpace(parentSession)
153 }
154
155 // WithSubagentDepth carries the current subagent depth through nested tool calls.
156 // The root agent runs at depth 0; each spawned subagent increments by one.
157 func WithSubagentDepth(ctx context.Context, depth int) context.Context {
158 if depth < 0 {
159 depth = 0
160 }
161 return context.WithValue(ctx, subagentDepthContextKey{}, depth)
162 }
163
164 // SubagentDepth returns the current subagent depth carried by a turn context.
165 func SubagentDepth(ctx context.Context) int {
166 depth, _ := ctx.Value(subagentDepthContextKey{}).(int)
167 if depth < 0 {
168 return 0
169 }
170 return depth
171 }
172
173 // WithUserImages carries the data URLs of images the user attached to this turn,
174 // resolved by the controller (which owns attachments) since the agent must not
175 // depend on it. Run embeds them on the user message; the provider sends them only
176 // when the model is vision-capable.
177 func WithUserImages(ctx context.Context, images []string) context.Context {
178 return context.WithValue(ctx, userImagesContextKey{}, images)
179 }
180
181 func userImages(ctx context.Context) []string {
182 images, _ := ctx.Value(userImagesContextKey{}).([]string)
183 return images
184 }
185
186 // Gate decides, per tool call, whether it may run. The agent consults it at
187 // execute time after any explicit planning-phase opt-out. It is interface-shaped so the agent
188 // stays independent of the permission package and of how "ask" is resolved
189 // (silently in headless runs, interactively in the chat TUI). A nil gate means
190 // no gating — every call runs, preserving behaviour for callers that don't wire
191 // one in. reason is fed back to the model when allow is false; a non-nil err
192 // (e.g. ctx cancelled awaiting approval) is treated as a block for that call.
193 type Gate interface {
194 Check(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (allow bool, reason string, err error)
195 }
196
197 // ExplicitDenyGate exposes the only global permission decision that applies to
198 // an already-authorized MCP server. Installing or approving a server is the
199 // user's authorization boundary; ordinary ask/fallback posture must not add a
200 // second per-call prompt, while explicit deny rules remain authoritative.
201 type ExplicitDenyGate interface {
202 ExplicitlyDenies(toolName string, args json.RawMessage) bool
203 }
204
205 const PlanModeReadOnlyCommandApprovalTool = "plan_mode_read_only_command"
206
207 // PlanModeReadOnlyTrustRequest describes a bash command that is safe enough to
208 // ask the user to accept as read-only during planning. Command is the concrete
209 // attempted command and Prefix is the reusable prefix to trust.
210 type PlanModeReadOnlyTrustRequest struct {
211 ToolName string
212 Command string
213 Prefix string
214 Args json.RawMessage
215 }
216
217 // PlanModeReadOnlyTrustGate is the legacy Plan bash trust bridge. It remains in
218 // the internal API for controller compatibility, but ordinary Plan execution no
219 // longer invokes it; bash calls use the normal permission gate.
220 type PlanModeReadOnlyTrustGate interface {
221 CheckPlanModeReadOnlyTrust(ctx context.Context, req PlanModeReadOnlyTrustRequest) (allow bool, reason string, err error)
222 }
223
224 const DefaultMaxSubagentDepth = 2
225
226 // NormalizeMaxSubagentDepth applies the public config contract: values below 1
227 // preserve the old single-delegation boundary.
228 func NormalizeMaxSubagentDepth(depth int) int {
229 if depth < 1 {
230 return 1
231 }
232 return depth
233 }
234
235 // ToolHooks fires user-configured shell hooks around each tool call. PreToolUse
236 // runs before the call and may block it (block=true; message is the reason fed
237 // back to the model); PostToolUse runs after and only surfaces output to the
238 // user (it can't block). It is interface-shaped so the agent stays independent
239 // of the hook package — a nil hooks field disables hook firing entirely.
240 type ToolHooks interface {
241 PreToolUse(ctx context.Context, name string, args json.RawMessage) (block bool, message string)
242 PostToolUse(ctx context.Context, name string, args json.RawMessage, result string)
243 PostToolUseFailure(ctx context.Context, name string, args json.RawMessage, result string, err error)
244 // PostLLMCall fires after each model turn completes (streaming finishes)
245 // but before reasoning_content is stored. It returns the (possibly
246 // translated) reasoning string — the original when no hook is configured.
247 // HasPostLLMCall reports whether such a hook exists, so the agent keeps
248 // streaming reasoning live when none is wired up.
249 PostLLMCall(ctx context.Context, reasoning string, turn int) string
250 HasPostLLMCall() bool
251 // SubagentStop fires when a `task` sub-agent finishes (foreground). PreCompact
252 // fires just before a compaction pass and returns extra summary guidance (its
253 // hooks' stdout) to fold into the summary prompt; "" when no hook contributes.
254 SubagentStop(ctx context.Context, last string)
255 PreCompact(ctx context.Context, trigger string) string
256 }
257
258 // Agent drives a single task: a Provider, a tool Registry, and a Session wired
259 // into the main loop.
260 type Agent struct {
261 prov provider.Provider
262 tools *tool.Registry
263 session *Session
264 sessMu sync.Mutex // guards the session pointer for external Session()/SetSession
265 maxSteps int
266 maxStepsKey string
267 reasoningByteLimit int
268 maxOutputTokens int
269 // executorHandoffGuard is enabled by Coordinator for the executor agent. The
270 // per-turn marker check in Run keeps ordinary single-model turns unaffected.
271 executorHandoffGuard bool
272 temperature float64
273 pricing *provider.Pricing
274 usageSource string
275 modelRef string
276 responseLanguage atomic.Value // string: auto|zh|en
277 reasoningLanguage atomic.Value // string: auto|zh|en
278
279 // sink receives the turn's typed event stream (reasoning/text deltas, tool
280 // dispatch/results, usage, notices). The agent no longer formats output
281 // itself — a frontend's Sink decides how to render. Never nil; New defaults
282 // it to event.Discard.
283 sink event.Sink
284
285 // lastUsage caches the most recent per-turn telemetry the provider reported so
286 // the CLI can expose a context gauge without re-scraping the usage line. The
287 // run loop writes it while a frontend's status line reads it, so it is atomic.
288 lastUsage atomic.Pointer[provider.Usage]
289
290 // sessCacheHit/sessCacheMiss accumulate cache tokens across every API call
291 // this session, so frontends can show the aggregate hit-rate (Σhit/Σ(hit+miss))
292 // — a steadier, cost-oriented number than the single-turn rate. They are NOT
293 // reset on compaction (compaction only rewrites session.Messages), so the
294 // aggregate never craters when the prefix is summarized away. Atomic: the run
295 // loop accumulates them while the status line reads them.
296 sessCacheHit atomic.Int64
297 sessCacheMiss atomic.Int64
298
299 // lastPrefixShape records the previous provider request's cacheable prefix
300 // so usage events can explain prefix churn on the next request.
301 lastPrefixShape PrefixShape
302 haveLastPrefixShape bool
303
304 // warnedMissingToolCallReasoning marks one active missing-reasoning incident
305 // within this agent. The legacy name is retained because the persisted state
306 // predates silent recovery; it now gates one automatic retry rather than a
307 // user-visible warning. A healthy tool-call turn clears it. Loop-owned;
308 // reset by SetSession.
309 warnedMissingToolCallReasoning bool
310 // missingReasoningWarnStateChecked avoids a file transaction on every
311 // healthy tool-call turn. It resets with the session so a new Agent can
312 // continue or confirm an incident persisted by an earlier process.
313 missingReasoningWarnStateChecked bool
314 // missingReasoningHealthyStreak provides the same three-turn anti-flapping
315 // policy when no cross-process state directory is configured.
316 missingReasoningHealthyStreak int
317 // missingReasoningWarnPendingResolveAt keeps a healthy observation retryable
318 // when its state write fails. The next missing turn retries that watermark
319 // before consulting the persisted incident and otherwise fails visible.
320 missingReasoningWarnPendingResolveAt time.Time
321
322 // missingReasoningWarnState rate-limits recovery retries across sessions and
323 // processes by an opaque provider-configuration fingerprint (#7059). The
324 // legacy type/file names preserve the on-disk v2 contract. nil (no dir in
325 // Options) keeps in-memory active-incident gating only.
326 missingReasoningWarnState *missingReasoningWarnState
327
328 // planMode enables planning workflow instructions and explicit phase opt-outs.
329 // It does not replace the permission or sandbox boundary. The system prompt and
330 // tool list never change with the toggle, preserving the provider-cache prefix.
331 planMode atomic.Bool
332
333 // readOnlyExecution is a construction-time defense for planner/research
334 // agents. Unlike planMode it is not a collaboration toggle: it remains on
335 // for the agent's lifetime and validates proxy calls after resolution.
336 readOnlyExecution bool
337
338 // mutationDependencyBarrier is set for the remainder of a provider tool
339 // batch after any mutating call fails or is blocked. executeOne re-checks
340 // it after proxy resolution so use_capability cannot bypass the barrier by
341 // advertising schema-level ReadOnly()==true. Parallel read-only segments
342 // never set it. Cleared at the start of each executeBatch.
343 mutationDependencyBarrier atomic.Bool
344
345 // plannerMCPExecution relaxes the strict read-only MCP boundary for the
346 // two-model Planner only: authorized, non-destructive MCP targets may run
347 // through use_capability even without readOnlyHint. Ordinary writers, bash,
348 // and destructive MCP stay blocked. Strict read-only sub-agents leave this
349 // false and still require readOnlyHint.
350 plannerMCPExecution bool
351
352 // gate, when non-nil, is the per-call permission gate for both standard and
353 // Plan workflows. nil disables gating entirely.
354 gate Gate
355
356 // extensions, when non-nil, is the frozen Extension Protocol v1 dispatcher
357 // for this controller generation. The run loop consults it at the
358 // agent-side intercept points (see extensions.go); nil means no v1 runtime
359 // packages are installed and every point passes through byte-identically.
360 extensions *dispatch.Dispatcher
361
362 // recoveryGate, when non-nil, is the Auto Guard boundary for Auto mode.
363 // Shared by root and sub-agents for the same controller task. nil disables
364 // recovery checks (Ask/YOLO, headless without wiring, or feature off).
365 recoveryGate RecoveryGate
366 // recoveryAgentID labels this agent on recovery cards (empty = root).
367 recoveryAgentID string
368 // recoveryTaskID isolates recovery state across concurrent top-level tasks.
369 // Empty shares the root task bucket.
370 recoveryTaskID string
371 // recoveryTaskSummary is the bounded task text for this Agent.Run. It lets a
372 // shared recovery gate review sub-agent mutations against the child task,
373 // rather than the root controller transcript.
374 recoveryTaskSummary string
375 // recoveryRunSeq gives ordinary (non-goal) runs a collision-free host scope.
376 // Goal runs use their stable delivery scope instead.
377 recoveryRunSeq atomic.Uint64
378
379 // planModeReadOnlyTrust is retained for legacy controller wiring. The main
380 // Plan execution path no longer consults it.
381 planModeReadOnlyTrust PlanModeReadOnlyTrustGate
382
383 // sandboxEscapeApprover, when non-nil, can ask the user whether one shell
384 // command may rerun unconfined after the OS sandbox failed to start.
385 sandboxEscapeApprover sandbox.EscapeApprover
386
387 // configWriteApprover, when non-nil, can ask the user whether a file tool
388 // may write a Reasonix-managed config file outside the workspace roots.
389 configWriteApprover tool.ConfigWriteApprover
390
391 // hooks, when non-nil, fires PreToolUse / PostToolUse shell hooks around each
392 // tool call. nil disables hook firing.
393 hooks ToolHooks
394
395 // asker, when non-nil, lets the `ask` tool put questions to the user. nil in
396 // headless runs (no interactive user). Set via SetAsker.
397 asker Asker
398
399 // onPreEdit, when non-nil, is called with a writer tool's previewed change
400 // just before it runs — the seam the checkpoint store uses to snapshot a
401 // file's pre-edit content. Only fires for non-ReadOnly tools that implement
402 // tool.Previewer (so bash, whose targets are unknowable, is never tracked).
403 // Set via SetPreEditHook. Prefer mutationObserver when both are set.
404 onPreEdit func(diff.Change)
405
406 // mutationObserver is the host-side unified file mutation observer. It
407 // captures preimages before tools run and after-fingerprints regardless of
408 // success/failure. Passed through Options to sub-agents; never changes
409 // provider-visible tool schemas or prompts.
410 mutationObserver *checkpoint.MutationObserver
411
412 // jobs, when non-nil, is the session's background-job manager. executeOne
413 // stamps it onto each tool call's context so the background tools (bash
414 // run_in_background, task run_in_background, bash_output/kill_shell/wait) can
415 // reach it. nil leaves those tools to degrade gracefully.
416 jobs *jobs.Manager
417
418 // writeScheduler coordinates parent-agent writes against background
419 // subagent write claims. Set on the parent executor only (subagentDepth 0);
420 // reservation is taken around Execute so late-loaded MCP/Economy tools are
421 // covered without registry wrapping. Provider-visible schemas are unchanged.
422 writeScheduler *SubagentScheduler
423 // writeWorkspaceRoot is the workspace used to normalize parent write
424 // reservations when writeScheduler is set.
425 writeWorkspaceRoot string
426
427 // workspaceLease is shared by every writer-capable agent in one Delivery
428 // session. It is acquired lazily on the first mutation and held through the
429 // final participating run/background job so verification remains isolated.
430 workspaceLease *workspacelease.Owner
431
432 // steerQueue holds mid-turn user messages queued while the agent is
433 // running. Each is consumed once per loop iteration, persisted to the
434 // session for history replay, and sent to the model as guidance (not a
435 // new task). Cache miss for the next API call is unavoidable but limited
436 // to one call — the prefix stays stable otherwise.
437 steerMu sync.Mutex
438 steerQueue []string
439 steerConsumed bool
440 // steerRunActive is true while Run is executing. Steer only queues while
441 // it is set; once the turn's exit flush has drained the queue, later
442 // steers are rejected so the caller can deliver them as a regular turn
443 // instead of leaving them in a queue no loop will ever consume.
444 steerRunActive bool
445
446 // evidence is a per-user-turn ledger of host-observed tool receipts. It lets
447 // complete_step validate that cited evidence happened before the claim.
448 evidence *evidence.Ledger
449
450 // todoState is the host's canonical task list: the latest successful
451 // todo_write with completions applied by complete_step. Unlike the per-turn
452 // ledger it survives turn boundaries and compaction (it never rides in the
453 // prompt), so the final-answer gate still sees an unfinished plan a later
454 // turn would otherwise hide. Rebuilt from the session in SetSession.
455 todoMu sync.Mutex
456 todoState []evidence.TodoItem
457
458 // hostAdvanceSeq guarantees unique tool IDs across turns: every
459 // emitTodoState call increments it so the frontend always sees a fresh
460 // dispatch even when the same panel index is signed off in different turns.
461 hostAdvanceSeq atomic.Int64
462
463 // projectChecks are structured project instructions that complete_step can
464 // verify against same-turn bash receipts after a write-backed completion.
465 projectChecks []instruction.VerifyCheck
466
467 // deliveryProfile enables the runtime-enforced delivery contract. The stable
468 // profile prompt explains intent; these fields are host state and never enter
469 // the provider-cached prefix. deliveryCriteriaEstablished resets per user turn
470 // but may inherit an unfinished canonical task list on continuation.
471 deliveryProfile bool
472 deliveryCriteriaEstablished bool
473 deliveryTaskExpected bool
474 deliveryMutationExpected bool
475 deliveryPersistentExpected bool
476 deliveryScopeID string
477 deliveryScopeActive bool
478 deliveryCheckpoint evidence.DeliveryCheckpoint
479
480 // ablation names the subsystems a benchmark arm switched off. The zero value
481 // is the control arm.
482 ablation ablation.Set
483
484 // classifierTaskText is the host-trusted task text for delivery intent
485 // classification, set by sub-agent spawners whose Run input carries host
486 // framing. Empty means classify the raw input verbatim.
487 classifierTaskText string
488
489 // preserveEvidenceOnce makes the next Run keep the turn evidence ledger
490 // instead of resetting it. RunSubAgentWithSession sets it before a
491 // review_report completion nudge so the retry can cite the read receipts
492 // the subagent already earned; consumed (cleared) by that Run.
493 preserveEvidenceOnce bool
494 // deliveryRecoveryPending is armed only when this agent exhausts final
495 // readiness. An explicit host recovery action can consume it to preserve the
496 // failed turn's receipts once; an ordinary user turn still resets evidence.
497 deliveryRecoveryPending bool
498 // readinessRecovered marks a run that started with evidence preserved from
499 // (or a pending recovery of) a prior readiness failure, so the final allowed
500 // audit can report Recovered=true. Set per turn in beginRunTurn.
501 readinessRecovered bool
502
503 // capabilityLedger tracks require/prefer outcomes for this user turn only.
504 // Never serialized into prompts or session state.
505 capabilityLedger *capability.Ledger
506 // capabilityAudit accumulates non-persisted routing/proxy counters.
507 capabilityAudit *capability.Audit
508 // lastCapabilityGate tracks prefer-reminder state across final-answer retries.
509 capabilityPreferReminded bool
510 // capabilityRequireMissSeen / capabilityPreferMissSeen remember that the
511 // final gate reported a miss earlier this turn, so a later clean gate is
512 // audited as a recovery. Reset per turn in SeedCapabilityRoute.
513 capabilityRequireMissSeen bool
514 capabilityPreferMissSeen bool
515 // pendingReviewWarnings are warn-level findings to surface in the final summary.
516 pendingReviewWarnings []string
517
518 // memQueue, when non-nil, lets the remember/forget tools fold a turn-tail note
519 // about a just-made memory change into the next turn, so it applies this
520 // session without touching the cache-stable prefix. Set via SetMemoryQueue.
521 memQueue memory.Queue
522
523 // subagentDepth tracks the current agent's nesting depth. maxSubagentDepth
524 // caps delegation; when reached, recursive agent/skill tools are excluded.
525 subagentDepth int
526 maxSubagentDepth int
527
528 // Context management: when a turn's prompt nears contextWindow, the older
529 // middle of the session is summarized away, keeping a token-bounded recent
530 // tail verbatim (recentKeep is the message floor) and archiving the originals
531 // under archiveDir. compactStuck latches when compaction can't get the prompt
532 // under the window (consecutiveCompacts crosses the limit), so auto-compaction
533 // pauses instead of looping. softCompactNoticed gates the one-shot soft-ratio
534 // notice so it fires once per approach, not every turn.
535 contextWindow int
536 softCompactRatio float64
537 toolResultSnipRatio float64
538 compactRatio float64
539 compactForceRatio float64
540 softCompactNoticed bool
541 recentKeep int
542 archiveDir string
543 keepPolicy KeepPolicy
544 compactStuck bool
545 consecutiveCompacts int
546 // activeTurnCreatedAt identifies the real/synthetic user message that began
547 // the currently running turn. Compaction may rewrite older history while a
548 // tool loop is active, but it must keep this message and everything after it
549 // verbatim so cancellation/crash recovery can retain completed tool pairs.
550 activeTurnCreatedAt atomic.Int64
551
552 // stormSig / stormCount track a run of turns that keep failing or getting
553 // blocked the same way so the loop can break a death-spiral. The signature is
554 // each call's (tool, error/blocker) in order, NOT (tool, args): a stuck model
555 // reliably reworks the arguments cosmetically (a re-worded essay, a reordered
556 // object, a different shell command) while the host returns the same refusal or
557 // failure every time — keying on args misses the loop entirely. Because errors
558 // that embed their subject (e.g. "file not found: /x") differ per target,
559 // genuine varied probing does not collapse to one signature. Reset whenever a
560 // turn does anything else (a different failure/block shape, or any success).
561 // See applyStormBreaker.
562 stormSig string
563 stormCount int
564
565 // blockedTurnStreak counts consecutive turns in which every tool call was
566 // blocked by the host (permission, plan mode, hook, or loop guard).
567 // stormSig catches a model fixated on one call shape; this catches a model
568 // rotating between blocked shapes — alternating tools, reordering a batch,
569 // or blockers whose text varies per attempt — which is zero progress all
570 // the same. Reset by any turn containing a non-blocked outcome and at the
571 // start of each user turn. See applyStormBreaker.
572 blockedTurnStreak int
573
574 // loopGuardArmed / loopGuardReceiptMark let final readiness stand down
575 // after a loop guard fired this user turn: once the host has told the model
576 // to stop retrying and report the blocker, demanding the receipts that the
577 // blocker prevents would restart the loop the guard just broke. The mark is
578 // the evidence-ledger receipt count from just before the guarded batch, so
579 // real progress — a successful write or command receipt landing after it —
580 // revokes the pass, while the bookkeeping the guard itself recommends
581 // (ask, todo_write, complete_step) keeps it. Host state, not message text:
582 // tool output that merely quotes "[loop guard]" must not unlock readiness.
583 // Reset at the start of each user turn. See loopGuardAllowsFinal.
584 loopGuardArmed bool
585 loopGuardReceiptMark int
586
587 // repeatSuccessCounts tracks write-like tool calls that have already
588 // succeeded in this user turn. This catches the complementary loop shape to
589 // stormSig: a model keeps doing the same successful write, so there is no
590 // error for the failure-only storm breaker to see.
591 repeatSuccessCounts map[string]int
592
593 // repeatFailureCounts tracks semantically identical write-like calls that
594 // keep failing with the same failure class. Unlike stormSig, successful
595 // reads do not blindly clear this state: re-reading a file and then
596 // resending the same stale anchor is still zero progress. Stale-anchor
597 // records also survive target mutations until Preview proves the anchor is
598 // applicable again. Ordinary turns reset the map at Run start; Goal
599 // continuations retain it while their stable delivery scope is unchanged.
600 repeatFailureCounts map[string]repeatFailureRecord
601 repeatFailureScope string
602 }
603
604 type repeatFailureRecord struct {
605 count int
606 errClass string
607 paths []string
608 stateRecheck bool
609 }
610
611 // KeepPolicy is a bitmask controlling which messages are preserved beyond the
612 // recent tail during compaction.
613 type KeepPolicy int
614
615 const (
616 KeepErrors KeepPolicy = 1 << iota
617 KeepUserMarked
618 )
619
620 // SetPlanMode toggles the plan-first workflow flag. Ordinary calls still use
621 // Permissions/Sandbox; only explicit phase opt-outs are refused. The system
622 // prompt and tool schemas stay untouched, while the caller supplies the
623 // model-facing Marker in a user turn.
624 func (a *Agent) SetPlanMode(v bool) { a.planMode.Store(v) }
625
626 // SetTools replaces the agent's tool registry. The next API call picks up the
627 // new tool schema; tools already cached in the provider prefix are unaffected
628 // until the prefix is invalidated. Safe to call between turns.
629 func (a *Agent) SetTools(tools *tool.Registry) {
630 if a == nil {
631 return
632 }
633 a.tools = tools
634 }
635
636 // SetReasoningLanguage updates the visible reasoning language preference for
637 // subsequent user-role messages emitted by this agent.
638 func (a *Agent) SetReasoningLanguage(lang string) {
639 if a == nil {
640 return
641 }
642 a.reasoningLanguage.Store(NormalizeReasoningLanguage(lang))
643 }
644
645 // SetResponseLanguage updates the final-answer language preference for
646 // subsequent user-role messages emitted by this agent.
647 func (a *Agent) SetResponseLanguage(lang string) {
648 if a == nil {
649 return
650 }
651 a.responseLanguage.Store(NormalizeResponseLanguage(lang))
652 }
653
654 // SetGate installs the per-call permission gate. Used by interactive CLI sessions to swap the
655 // headless gate built in setup for an interactive one that prompts the user;
656 // nil disables gating. Safe to call before the run loop starts.
657 func (a *Agent) SetGate(g Gate) {
658 if nilutil.IsNil(g) {
659 g = nil
660 }
661 a.gate = g
662 }
663
664 // SetExtensions installs the extension dispatcher after construction. Boot
665 // uses it because sidecars — and therefore the dispatcher — only exist after
666 // snapshot assembly, which runs after the agent is built. Safe to call before
667 // the run loop starts; nil disables interception.
668 func (a *Agent) SetExtensions(d *dispatch.Dispatcher) {
669 if a == nil {
670 return
671 }
672 a.extensions = d
673 }
674
675 // SetRecoveryGate installs Auto Guard. Safe to call before the run loop starts;
676 // nil disables its checks.
677 func (a *Agent) SetRecoveryGate(g RecoveryGate) {
678 if a == nil {
679 return
680 }
681 if nilutil.IsNil(g) {
682 g = nil
683 }
684 a.recoveryGate = g
685 }
686
687 // SetRecoveryIdentity sets the agent/task labels used on recovery cards.
688 func (a *Agent) SetRecoveryIdentity(agentID, taskID string) {
689 if a == nil {
690 return
691 }
692 a.recoveryAgentID = strings.TrimSpace(agentID)
693 a.recoveryTaskID = strings.TrimSpace(taskID)
694 }
695
696 // RecoveryGate returns the attached Auto Guard (may be nil).
697 func (a *Agent) RecoveryGate() RecoveryGate {
698 if a == nil {
699 return nil
700 }
701 return a.recoveryGate
702 }
703
704 // SetPlanModeReadOnlyTrustGate retains the legacy confirmation bridge for old
705 // controller/session data. Main Plan execution no longer calls it.
706 func (a *Agent) SetPlanModeReadOnlyTrustGate(g PlanModeReadOnlyTrustGate) {
707 if nilutil.IsNil(g) {
708 g = nil
709 }
710 a.planModeReadOnlyTrust = g
711 }
712
713 // SetSandboxEscapeApprover installs the optional one-shot approval path used by
714 // the bash tool when an enforced OS sandbox fails to start.
715 func (a *Agent) SetSandboxEscapeApprover(g sandbox.EscapeApprover) {
716 if nilutil.IsNil(g) {
717 g = nil
718 }
719 a.sandboxEscapeApprover = g
720 }
721
722 // SetConfigWriteApprover installs the optional per-write approval path used by
723 // the file tools when a target is a Reasonix-managed config file outside the
724 // workspace write roots.
725 func (a *Agent) SetConfigWriteApprover(g tool.ConfigWriteApprover) {
726 if nilutil.IsNil(g) {
727 g = nil
728 }
729 a.configWriteApprover = g
730 }
731
732 func (a *Agent) withTurnPreferences(input string) string {
733 if a == nil {
734 return input
735 }
736 responseLang := "auto"
737 if v := a.responseLanguage.Load(); v != nil {
738 if s, ok := v.(string); ok {
739 responseLang = s
740 }
741 }
742 input = WithResponseLanguage(input, responseLang)
743
744 lang := "auto"
745 if v := a.reasoningLanguage.Load(); v != nil {
746 if s, ok := v.(string); ok {
747 lang = s
748 }
749 }
750 input = WithReasoningLanguage(input, lang)
751 if a.deliveryProfile && !strings.Contains(input, "<delivery-runtime>") {
752 input = strings.TrimSpace(input) + "\n\n" + DeliveryRuntimeMarker
753 }
754 return input
755 }
756
757 // SetAsker installs the asker the `ask` tool uses to question the user.
758 // Interactive frontends wire one in; headless runs leave it nil.
759 func (a *Agent) SetAsker(as Asker) { a.asker = as }
760
761 // SetMemoryQueue installs the sink the remember/forget tools use to apply a
762 // memory change in the current session. The controller wires itself in.
763 func (a *Agent) SetMemoryQueue(q memory.Queue) { a.memQueue = q }
764
765 // SetPreEditHook installs the pre-edit snapshot hook (see onPreEdit). The
766 // controller wires it to its per-session checkpoint store; nil disables capture.
767 // Prefer SetMutationObserver for v2 capture (before+after fingerprints).
768 func (a *Agent) SetPreEditHook(fn func(diff.Change)) { a.onPreEdit = fn }
769
770 // SetMutationObserver installs the unified mutation observer. When set, it
771 // supersedes onPreEdit for capture and also records after-mutation fingerprints.
772 // When a task tool is already registered it inherits the observer for sub-agents.
773 func (a *Agent) SetMutationObserver(obs *checkpoint.MutationObserver) {
774 a.mutationObserver = obs
775 if a.tools == nil || obs == nil {
776 return
777 }
778 if t, ok := a.tools.Get("task"); ok {
779 if task, ok := t.(*TaskTool); ok {
780 task.WithMutationObserver(obs)
781 }
782 }
783 }
784
785 // MutationObserver returns the installed observer (may be nil).
786 func (a *Agent) MutationObserver() *checkpoint.MutationObserver {
787 if a == nil {
788 return nil
789 }
790 return a.mutationObserver
791 }
792
793 // Session returns the agent's current conversation, useful for persistence
794 // hooks that need to read the message log between turns. sessMu serialises this
795 // pointer read against SetSession, so a frontend (serve's concurrent /history and
796 // /new handlers) can't race the swap. The run loop touches a.session directly and
797 // only swaps it via SetSession while idle, so its reads need no lock.
798 func (a *Agent) Session() *Session {
799 a.sessMu.Lock()
800 defer a.sessMu.Unlock()
801 return a.session
802 }
803
804 // SetSession replaces the agent's conversation wholesale. Used by
805 // `reasonix --resume` to load a saved JSONL transcript before the first turn,
806 // so the model picks up exactly where it left off. Callers serialise it against a
807 // running turn (it only fires while idle); sessMu guards the pointer swap itself.
808 func (a *Agent) SetSession(s *Session) {
809 a.sessMu.Lock()
810 a.session = s
811 a.sessMu.Unlock()
812 a.sessCacheHit.Store(0)
813 a.sessCacheMiss.Store(0)
814 a.warnedMissingToolCallReasoning = false
815 a.missingReasoningWarnStateChecked = false
816 a.missingReasoningHealthyStreak = 0
817 a.repeatFailureCounts = nil
818 a.repeatFailureScope = ""
819 if s != nil {
820 a.rebuildTodoState(s.Snapshot())
821 }
822 }
823
824 // LastUsage returns the most recent per-turn token telemetry the provider
825 // reported (nil if no turn has run yet). The TUI uses it to show a context
826 // gauge alongside the prompt; the actual cache decisions still live inside
827 // maybeCompact.
828 func (a *Agent) LastUsage() *provider.Usage { return a.lastUsage.Load() }
829
830 // SessionCache returns the cumulative cache hit/miss prompt tokens across every
831 // API call this session — the basis for the status line's aggregate hit-rate.
832 func (a *Agent) SessionCache() (hit, miss int) {
833 return int(a.sessCacheHit.Load()), int(a.sessCacheMiss.Load())
834 }
835
836 // ContextWindow returns the configured context-window size in tokens. 0
837 // means compaction is disabled for this agent.
838 func (a *Agent) ContextWindow() int { return a.contextWindow }
839
840 // mid-turn steer marker.
841 // MidTurnSteerPrefix marks user messages that were injected mid-turn as
842 // guidance (via Steer). The model sees them as instructions; frontends
843 // display them as a notice, not a regular user bubble.
844 const MidTurnSteerPrefix = "[Mid-turn steer queued by the user. Do not treat this as a new task; use it only as additional guidance for the current task after completing the current step.]"
845
846 func midTurnSteerMessage(text string) string {
847 return MidTurnSteerPrefix + "\n" + text
848 }
849
850 // SteerText checks whether content is a mid-turn steer message and, if so,
851 // returns the original user text without the wrapper prefix. The returned
852 // text preserves the user's exact input — it only strips the prefix and the
853 // "\n" separator that midTurnSteerMessage inserts between the prefix and the
854 // user text; it does not trim spaces so the history replay matches the live
855 // Steer event rendering character-for-character.
856 //
857 // Steers are persisted through withTurnPreferences, which can prepend
858 // transient language blocks (for Chinese text even in auto mode) and append
859 // the delivery-runtime marker. Both are transport framing, not steer text:
860 // leading blocks are skipped before matching the prefix and a trailing
861 // marker is cut from the returned text, so replay recognizes steers
862 // regardless of the session's language and profile settings.
863 func SteerText(content string) (string, bool) {
864 s := content
865 for {
866 if after, found := strings.CutPrefix(s, MidTurnSteerPrefix); found {
867 // Strip only the "\n" separator, preserving the user's original text.
868 after = strings.TrimPrefix(after, "\n")
869 if trimmed, cut := strings.CutSuffix(after, "\n\n"+DeliveryRuntimeMarker); cut {
870 after = trimmed
871 }
872 return after, true
873 }
874 next, ok := trimLeadingSteerWrapper(s)
875 if !ok {
876 return "", false
877 }
878 s = next
879 }
880 }
881
882 // trimLeadingSteerWrapper removes one leading transient preference block that
883 // withTurnPreferences may have placed ahead of the steer prefix. It reports
884 // false when content does not start with such a block.
885 func trimLeadingSteerWrapper(content string) (string, bool) {
886 s := strings.TrimLeft(content, " \t\r\n")
887 for _, tag := range []string{"response-language", "reasoning-language"} {
888 if !strings.HasPrefix(s, "<"+tag+">") {
889 continue
890 }
891 if rest, ok := trimLeadingTransientBlock(s, tag); ok {
892 return rest, true
893 }
894 }
895 return content, false
896 }
897
898 // Steer queues a message for mid-turn injection. It reports whether an active
899 // turn accepted the text; on false nothing was queued and the caller must
900 // deliver it another way (typically as a new turn). Without the active check,
901 // a steer landing in the window between the turn's exit flush and the
902 // controller observing running=false would sit in the queue unconsumed and
903 // unpersisted — invisible to both the model and history.
904 func (a *Agent) Steer(text string) bool {
905 a.steerMu.Lock()
906 defer a.steerMu.Unlock()
907 if !a.steerRunActive {
908 return false
909 }
910 a.steerQueue = append(a.steerQueue, text)
911 a.steerConsumed = false
912 return true
913 }
914
915 // SteerConsumed returns true when the steer queue became empty after the last consume.
916 func (a *Agent) SteerConsumed() bool {
917 a.steerMu.Lock()
918 defer a.steerMu.Unlock()
919 return a.steerConsumed
920 }
921
922 func (a *Agent) consumeSteer() (string, bool) {
923 a.steerMu.Lock()
924 defer a.steerMu.Unlock()
925 if len(a.steerQueue) == 0 {
926 return "", false
927 }
928 t := a.steerQueue[0]
929 a.steerQueue = a.steerQueue[1:]
930 a.steerConsumed = len(a.steerQueue) == 0
931 return t, true
932 }
933
934 // closeSteerIntakeIfIdle atomically closes the normal-completion race between
935 // the final queue check and Run returning. A steer accepted before this check
936 // keeps the loop alive; one arriving after it is rejected so the host can keep
937 // the user's draft and retry it as a regular follow-up.
938 func (a *Agent) closeSteerIntakeIfIdle() bool {
939 a.steerMu.Lock()
940 defer a.steerMu.Unlock()
941 if len(a.steerQueue) > 0 {
942 return false
943 }
944 a.steerRunActive = false
945 return true
946 }
947
948 // flushSteerQueue ends the turn's steer intake. Guidance that arrived too late
949 // to be consumed is persisted for transcript visibility but marked local-only:
950 // replaying it to the model on the next unrelated user turn can execute a stale
951 // historical task (#7045). An explicit warning keeps the transcript honest
952 // without presenting the text as successfully applied guidance (#6238).
953 func (a *Agent) flushSteerQueue() {
954 a.steerMu.Lock()
955 pending := a.steerQueue
956 a.steerQueue = nil
957 if len(pending) > 0 {
958 a.steerConsumed = true
959 }
960 a.steerRunActive = false
961 a.steerMu.Unlock()
962 for _, text := range pending {
963 a.RecordUnappliedSteer(text)
964 }
965 }
966
967 // UnappliedSteerNotice returns the durable warning shown for guidance that was
968 // accepted during an abnormal turn exit but never reached a provider request.
969 func UnappliedSteerNotice(text string) string {
970 return "Guidance was not applied because the turn ended before it could be processed. Send it again if it is still needed:\n" + text
971 }
972
973 // RecordUnappliedSteer stores guidance that could not affect its intended
974 // in-flight turn. The orphan-tool sentinel makes older readers drop the record
975 // during wire normalization, while current readers use LocalOnly to exclude it
976 // before every provider request.
977 func (a *Agent) RecordUnappliedSteer(text string) {
978 if a == nil || a.session == nil {
979 return
980 }
981 a.session.Add(provider.Message{
982 Role: provider.RoleTool,
983 Content: a.withTurnPreferences(midTurnSteerMessage(text)),
984 ToolCallID: provider.LocalOnlyToolID,
985 Name: provider.LocalOnlyToolName,
986 LocalOnly: true,
987 })
988 a.sink.Emit(event.Event{
989 Kind: event.Notice,
990 Level: event.LevelWarn,
991 Code: event.NoticeCodeUnappliedSteer,
992 Text: UnappliedSteerNotice(text),
993 })
994 }
995
996 func (a *Agent) steerQueueLen() int {
997 a.steerMu.Lock()
998 defer a.steerMu.Unlock()
999 return len(a.steerQueue)
1000 }
1001
1002 // CompactRatio returns the fraction of the window at which auto-compaction
1003 // fires (e.g. 0.8). The status line uses it to show headroom to the next compact.
1004 func (a *Agent) CompactRatio() float64 { return a.compactRatio }
1005
1006 // CompactNow runs one compaction pass immediately, regardless of the
1007 // usage-ratio threshold maybeCompact normally honours. Used by the chat
1008 // TUI's `/compact` command so the user can reset the prefix before it
1009 // naturally fills up.
1010 func (a *Agent) CompactNow(ctx context.Context, instructions string) error {
1011 return a.compact(ctx, "manual", instructions, true)
1012 }
1013
1014 // Options configures an Agent.
1015 type Options struct {
1016 MaxSteps int
1017 // MaxStepsKey names the explicit runtime control shown when the MaxSteps guard
1018 // is hit. Empty defaults to the generic max_steps tool/runtime parameter.
1019 MaxStepsKey string
1020 // ReasoningByteLimit bounds a single stream's hidden reasoning bytes. Zero
1021 // uses the default guard; a negative value disables only this client guard.
1022 // Provider output budgets are a separate protocol/model capability.
1023 ReasoningByteLimit int
1024 // MaxOutputTokens overrides the provider's configured/default total output
1025 // budget. Zero delegates to the provider; a negative value asks optional
1026 // protocols to omit the budget (Anthropic still requires max_tokens).
1027 MaxOutputTokens int
1028 Temperature float64
1029 Pricing *provider.Pricing // optional, for per-turn cost display
1030 UsageSource string // optional billable usage source; default executor
1031 // ModelRef names the canonical "provider/model" ref backing this agent's
1032 // provider instance. It is attached to emitted Usage events so downstream
1033 // usage accounting can attribute tokens to the exact model.
1034 ModelRef string
1035
1036 // Gate is the per-call permission gate. nil disables gating.
1037 Gate Gate
1038
1039 // ReadOnlyExecution enables a permanent host-side read-only boundary for
1040 // planner and research agents. It is intentionally independent of Plan mode
1041 // so a stale collaboration flag cannot authorize a dynamic writer target.
1042 ReadOnlyExecution bool
1043
1044 // PlannerMCPExecution enables Planner-trusted MCP through use_capability:
1045 // authorized, non-destructive tools may run without readOnlyHint. Only
1046 // NewPlannerAgent sets this; strict read-only sub-agents must not.
1047 PlannerMCPExecution bool
1048
1049 // PlanModeReadOnlyTrustGate is retained for legacy controller compatibility.
1050 // The main Plan execution path no longer invokes it.
1051 PlanModeReadOnlyTrustGate PlanModeReadOnlyTrustGate
1052
1053 // SandboxEscapeApprover confirms a one-shot unconfined shell rerun after an
1054 // enforced OS sandbox fails. nil keeps fail-closed behavior.
1055 SandboxEscapeApprover sandbox.EscapeApprover
1056
1057 // ConfigWriteApprover confirms file-tool writes to Reasonix-managed config
1058 // files outside the workspace roots. nil keeps fail-closed behavior.
1059 ConfigWriteApprover tool.ConfigWriteApprover
1060
1061 // Context management. ContextWindow <= 0 disables compaction. Ratios and
1062 // RecentKeep fall back to defaults when unset.
1063 ContextWindow int
1064 SoftCompactRatio float64
1065 ToolResultSnipRatio float64
1066 CompactRatio float64
1067 CompactForceRatio float64
1068 RecentKeep int
1069 ArchiveDir string
1070 KeepPolicy KeepPolicy
1071
1072 // Hooks fires PreToolUse / PostToolUse shell hooks around tool calls. nil
1073 // disables hook firing.
1074 Hooks ToolHooks
1075
1076 // MissingReasoningWarnStateDir, when non-empty, points at the shared
1077 // directory where missing tool-call thinking recovery retries are gated by
1078 // opaque provider-configuration fingerprint (#7059). The field name is kept
1079 // for source compatibility. Boot always supplies it; direct construction
1080 // with an empty value keeps in-memory gating.
1081 MissingReasoningWarnStateDir string
1082
1083 // Jobs is the session's background-job manager (nil disables background tools).
1084 Jobs *jobs.Manager
1085
1086 // WriteScheduler is the session-scoped subagent concurrency/write-claim
1087 // controller. When set on the parent executor, write-capable tools reserve
1088 // paths for the duration of Execute so background writers cannot TOCTOU
1089 // race parent writes. Subagents leave this nil (or depth > 0 skips it).
1090 WriteScheduler *SubagentScheduler
1091 // WriteWorkspaceRoot normalizes parent write reservations.
1092 WriteWorkspaceRoot string
1093
1094 // WorkspaceLease serializes Delivery mutations across sessions that target
1095 // the same workspace. nil preserves source compatibility for direct Agent
1096 // construction; boot always supplies it for Delivery sessions.
1097 WorkspaceLease *workspacelease.Owner
1098
1099 // ProjectChecks are host-observable structured checks extracted during boot.
1100 ProjectChecks []instruction.VerifyCheck
1101
1102 // DeliveryProfile enforces acceptance criteria before mutations and requires
1103 // post-change review, verification, and evidence-backed sign-off before a
1104 // final answer. It changes host control flow, not tool schemas.
1105 DeliveryProfile bool
1106
1107 // Ablation switches subsystems off for a benchmark arm. The zero value runs
1108 // everything, so ordinary callers leave it unset.
1109 Ablation ablation.Set
1110
1111 // ClassifierTaskText, when non-empty, is the pristine task text delivery
1112 // intent classification should judge instead of the raw Run input. Sub-agent
1113 // spawners set it before prepending host framing (subagent/workspace context,
1114 // review contracts) so framing verbs cannot arm expectations and user input
1115 // dressed up as framing cannot disarm them.
1116 ClassifierTaskText string
1117
1118 // CapabilityLedger is the optional turn-scoped capability route ledger for
1119 // Delivery require/prefer gates. Nil disables capability gates.
1120 CapabilityLedger *capability.Ledger
1121 // CapabilityAudit is the optional non-persisted metrics sink for routing.
1122 CapabilityAudit *capability.Audit
1123
1124 // RequireReviewReportKind, when non-empty, makes RunSubAgentWithSession fail
1125 // unless the subagent recorded a successful review_report of this kind —
1126 // review/security subagents must return typed, host-verifiable reports.
1127 RequireReviewReportKind evidence.ReviewKind
1128
1129 // ReasoningLanguage controls visible reasoning language preference as transient
1130 // user-turn context. Empty/auto injects nothing.
1131 ReasoningLanguage string
1132
1133 // ResponseLanguage controls final-answer language preference as transient
1134 // user-turn context. Empty/auto keeps the stable same-as-user policy.
1135 ResponseLanguage string
1136
1137 // PlanModeReadOnlyCommands is retained for old config/controller data. Main
1138 // Plan execution classifies bash through Permissions instead.
1139 PlanModeReadOnlyCommands []string
1140
1141 // RecoveryGate is the optional Auto Guard boundary. It checks deterministic
1142 // high-risk mutations and failure recovery before permission approval and
1143 // write-lock acquisition.
1144 RecoveryGate RecoveryGate
1145 // RecoveryAgentID labels this agent on recovery cards (empty = root).
1146 RecoveryAgentID string
1147 // RecoveryTaskID isolates recovery state for this agent (empty = root task).
1148 RecoveryTaskID string
1149
1150 // SubagentDepth is the current nesting depth for this agent. Root sessions are
1151 // depth 0; child subagents are depth 1. MaxSubagentDepth caps delegation.
1152 SubagentDepth int
1153 MaxSubagentDepth int
1154
1155 // Extensions is the frozen extension dispatcher for this agent's controller
1156 // generation (Extension Protocol v1). Nil means no v1 runtime packages are
1157 // installed; the run loop then passes every intercept point through
1158 // byte-identically. Boot installs it with SetExtensions once sidecars are
1159 // live (they start after the agent is constructed).
1160 Extensions *dispatch.Dispatcher
1161
1162 // MutationObserver is the host-side file mutation observer shared with
1163 // (or cloned for) sub-agents. nil disables v2 capture. Does not affect
1164 // provider-visible tool schemas or prompts.
1165 MutationObserver *checkpoint.MutationObserver
1166 }
1167
1168 // New constructs an Agent. MaxSteps <= 0 means no cap — the run loop continues
1169 // until the model gives a final answer, the context is cancelled, or the
1170 // provider errors (compaction keeps the context bounded). A nil sink is replaced
1171 // with event.Discard so the agent can always emit unconditionally.
1172 func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Options, sink event.Sink) *Agent {
1173 if opts.SoftCompactRatio <= 0 {
1174 opts.SoftCompactRatio = defaultSoftCompactRatio
1175 }
1176 if opts.ToolResultSnipRatio <= 0 {
1177 opts.ToolResultSnipRatio = defaultToolResultSnipRatio
1178 }
1179 if opts.CompactRatio <= 0 {
1180 opts.CompactRatio = defaultCompactRatio
1181 }
1182 if opts.ToolResultSnipRatio >= opts.CompactRatio {
1183 opts.ToolResultSnipRatio = opts.CompactRatio
1184 }
1185 if opts.CompactForceRatio <= 0 {
1186 opts.CompactForceRatio = defaultCompactForceRatio
1187 }
1188 if opts.RecentKeep <= 0 {
1189 opts.RecentKeep = minRecentKeep
1190 }
1191 if nilutil.IsNil(sink) {
1192 sink = event.Discard
1193 }
1194 gate := opts.Gate
1195 if nilutil.IsNil(gate) {
1196 gate = nil
1197 }
1198 planModeReadOnlyTrust := opts.PlanModeReadOnlyTrustGate
1199 if nilutil.IsNil(planModeReadOnlyTrust) {
1200 planModeReadOnlyTrust = nil
1201 }
1202 sandboxEscapeApprover := opts.SandboxEscapeApprover
1203 if nilutil.IsNil(sandboxEscapeApprover) {
1204 sandboxEscapeApprover = nil
1205 }
1206 configWriteApprover := opts.ConfigWriteApprover
1207 if nilutil.IsNil(configWriteApprover) {
1208 configWriteApprover = nil
1209 }
1210 hooks := opts.Hooks
1211 if nilutil.IsNil(hooks) {
1212 hooks = nil
1213 }
1214 maxStepsKey := opts.MaxStepsKey
1215 if strings.TrimSpace(maxStepsKey) == "" {
1216 maxStepsKey = "max_steps"
1217 }
1218 maxSubagentDepth := opts.MaxSubagentDepth
1219 if maxSubagentDepth == 0 {
1220 maxSubagentDepth = DefaultMaxSubagentDepth
1221 } else {
1222 maxSubagentDepth = NormalizeMaxSubagentDepth(maxSubagentDepth)
1223 }
1224 subagentDepth := opts.SubagentDepth
1225 if subagentDepth < 0 {
1226 subagentDepth = 0
1227 }
1228 reasoningByteLimit := opts.ReasoningByteLimit
1229 if reasoningByteLimit == 0 {
1230 reasoningByteLimit = defaultReasoningByteLimit
1231 }
1232 a := &Agent{
1233 prov: prov,
1234 tools: tools,
1235 session: session,
1236 maxSteps: opts.MaxSteps,
1237 maxStepsKey: maxStepsKey,
1238 reasoningByteLimit: reasoningByteLimit,
1239 maxOutputTokens: opts.MaxOutputTokens,
1240 temperature: opts.Temperature,
1241 pricing: opts.Pricing,
1242 usageSource: usageSourceOrDefault(opts.UsageSource, event.UsageSourceExecutor),
1243 modelRef: strings.TrimSpace(opts.ModelRef),
1244 sink: sink,
1245 gate: gate,
1246 extensions: opts.Extensions,
1247 recoveryGate: opts.RecoveryGate,
1248 recoveryAgentID: strings.TrimSpace(opts.RecoveryAgentID),
1249 recoveryTaskID: strings.TrimSpace(opts.RecoveryTaskID),
1250 readOnlyExecution: opts.ReadOnlyExecution,
1251 plannerMCPExecution: opts.PlannerMCPExecution,
1252 planModeReadOnlyTrust: planModeReadOnlyTrust,
1253 sandboxEscapeApprover: sandboxEscapeApprover,
1254 configWriteApprover: configWriteApprover,
1255 hooks: hooks,
1256 jobs: opts.Jobs,
1257 writeScheduler: opts.WriteScheduler,
1258 writeWorkspaceRoot: strings.TrimSpace(opts.WriteWorkspaceRoot),
1259 workspaceLease: opts.WorkspaceLease,
1260 missingReasoningWarnState: missingReasoningWarnStateFor(opts.MissingReasoningWarnStateDir),
1261 evidence: evidence.NewLedger(),
1262 projectChecks: append([]instruction.VerifyCheck(nil), opts.ProjectChecks...),
1263 deliveryProfile: opts.DeliveryProfile,
1264 ablation: opts.Ablation,
1265 classifierTaskText: opts.ClassifierTaskText,
1266 capabilityLedger: opts.CapabilityLedger,
1267 capabilityAudit: opts.CapabilityAudit,
1268 contextWindow: opts.ContextWindow,
1269 softCompactRatio: opts.SoftCompactRatio,
1270 toolResultSnipRatio: opts.ToolResultSnipRatio,
1271 compactRatio: opts.CompactRatio,
1272 compactForceRatio: opts.CompactForceRatio,
1273 recentKeep: opts.RecentKeep,
1274 archiveDir: opts.ArchiveDir,
1275 keepPolicy: opts.KeepPolicy,
1276 subagentDepth: subagentDepth,
1277 maxSubagentDepth: maxSubagentDepth,
1278 mutationObserver: opts.MutationObserver,
1279 }
1280 a.SetResponseLanguage(opts.ResponseLanguage)
1281 a.SetReasoningLanguage(opts.ReasoningLanguage)
1282 return a
1283 }
1284
1285 func usageSourceOrDefault(source, fallback string) string {
1286 source = strings.TrimSpace(source)
1287 if source != "" {
1288 return source
1289 }
1290 return fallback
1291 }
1292
1293 // missingReasoningWarnStateFor returns nil when no state dir is configured, so
1294 // direct Agent construction keeps the historical once-per-session notice scope.
1295 func missingReasoningWarnStateFor(dir string) *missingReasoningWarnState {
1296 if strings.TrimSpace(dir) == "" {
1297 return nil
1298 }
1299 return newMissingReasoningWarnState(dir)
1300 }
1301
1302 // reserveParentWrite holds write claims for the duration of a parent-agent
1303 // write tool call. Returns a no-op release when reservation is not needed
1304 // (subagent, read-only, no scheduler, or non-write tool).
1305 func (a *Agent) reserveParentWrite(runTool tool.Tool, args json.RawMessage, readOnly bool) (release func(), err error) {
1306 noop := func() {}
1307 if a == nil || a.writeScheduler == nil || a.subagentDepth > 0 || readOnly || runTool == nil {
1308 return noop, nil
1309 }
1310 name := runTool.Name()
1311 if !parentWriteGuardTarget(name) {
1312 return noop, nil
1313 }
1314 claim, err := parentWriteReservation(a.writeWorkspaceRoot, name, args)
1315 if err != nil {
1316 return noop, err
1317 }
1318 return a.writeScheduler.ReserveParentWrite(claim)
1319 }
1320
1321 // Run appends the user input and drives the tool loop until the model returns a
1322 // final answer (no tool calls), the context is cancelled, or the provider errors.
1323 // With maxSteps <= 0 the loop is unbounded — the natural termination is the model
1324 // finishing, and the real safety bounds are user cancellation and compaction, not
1325 // a round count. A positive maxSteps imposes an optional hard guard, surfaced as
1326 // a resumable notice when hit.
1327 // Run is the agent lifecycle entry point: lifecycle setup, turn initialization,
1328 // tool-round loop, and deferred cleanup. Turn policy lives in beginRunTurn /
1329 // runToolLoop / handleFinalResponse / handleToolRound so the state machine stays
1330 // readable without changing provider-visible behavior or lock ownership.
1331 func (a *Agent) Run(ctx context.Context, input string) (runErr error) {
1332 runMaxSteps := a.maxSteps
1333 runMaxStepsKey := a.maxStepsKey
1334 runLimitHostOwned := false
1335 if limit, ok := runStepLimitFromContext(ctx); ok {
1336 runMaxSteps = limit.steps
1337 runLimitHostOwned = true
1338 if limit.key != "" {
1339 runMaxStepsKey = limit.key
1340 }
1341 }
1342 a.recoveryRunSeq.Add(1)
1343 if a.deliveryProfile && a.workspaceLease != nil {
1344 a.workspaceLease.BeginRun()
1345 defer a.workspaceLease.EndRun()
1346 }
1347 turnStartedAt := time.Now()
1348 workDurationMs := func() int64 {
1349 if elapsed := time.Since(turnStartedAt).Milliseconds(); elapsed > 0 {
1350 return elapsed
1351 }
1352 return 1
1353 }
1354 defer a.flushSteerQueue()
1355 a.steerMu.Lock()
1356 a.steerConsumed = false
1357 a.steerRunActive = true
1358 a.steerMu.Unlock()
1359
1360 // Commit background-job evidence leases only after this turn delivers.
1361 // wait/bash_output merge a finished background writer's receipts into the
1362 // ledger provisionally; if the turn reaches a final answer (runErr == nil)
1363 // the delivery gates have verified and reviewed those mutations, so the
1364 // job's evidence can be permanently drained. A failed or cancelled turn
1365 // leaves the lease uncommitted so the next turn re-collects it.
1366 defer func() {
1367 if runErr != nil || a.evidence == nil || a.jobs == nil {
1368 return
1369 }
1370 for _, lease := range a.evidence.BackgroundLeases() {
1371 a.jobs.CommitEvidenceForSession(lease.Session, lease.JobID)
1372 }
1373 }()
1374 if _, scoped := DeliveryExecutionScopeFromContext(ctx); scoped {
1375 defer func() { a.updateDeliveryCheckpoint(runErr) }()
1376 }
1377 defer a.activeTurnCreatedAt.Store(0)
1378
1379 // agent.before_start: an extension may abort the run before the user turn
1380 // is appended. The redacted reason surfaces like a normal run error.
1381 if err := a.interceptAgentStart(ctx); err != nil {
1382 return err
1383 }
1384
1385 _, state := a.beginRunTurn(ctx, input)
1386 state.runMaxSteps = runMaxSteps
1387 state.runMaxStepsKey = runMaxStepsKey
1388 state.runLimitHostOwned = runLimitHostOwned
1389 state.workDurationMs = workDurationMs
1390 return a.runToolLoop(ctx, state)
1391 }
1392
1393 // observeMissingToolCallReasoning classifies a thinking-mode tool-call turn and
1394 // claims the single silent retry allowed for its active compatibility incident.
1395 // DeepSeek requires provider-issued thinking content to be replayed, so a
1396 // missing value is retried once before tools execute. Persistent broken rounds
1397 // use the existing exact-configuration cooldown; a healthy round resolves the
1398 // incident after three consecutive healthy turns and re-arms a future isolated
1399 // regression (#6259, #7059).
1400 func (a *Agent) observeMissingToolCallReasoning(calls []provider.ToolCall, reasoning string) (missing, shouldRetry bool) {
1401 if len(calls) == 0 || !provider.WarnOnMissingToolCallReasoning(a.prov) {
1402 return false, false
1403 }
1404 fingerprint := provider.MissingToolCallReasoningWarningFingerprint(a.prov)
1405 observedAt := time.Now()
1406 if strings.TrimSpace(reasoning) != "" {
1407 if a.missingReasoningWarnState == nil {
1408 if a.warnedMissingToolCallReasoning {
1409 a.missingReasoningHealthyStreak++
1410 if a.missingReasoningHealthyStreak >= missingReasoningHealthyResolveStreak {
1411 a.warnedMissingToolCallReasoning = false
1412 a.missingReasoningHealthyStreak = 0
1413 }
1414 }
1415 return false, false
1416 }
1417 shouldResolve := !a.missingReasoningWarnStateChecked || a.warnedMissingToolCallReasoning
1418 if shouldResolve {
1419 result := missingReasoningResolveResult{Recorded: true, Resolved: true}
1420 if pending := a.missingReasoningWarnPendingResolveAt; !pending.IsZero() {
1421 result = a.missingReasoningWarnState.resolveAt(fingerprint, pending)
1422 if result.Recorded {
1423 a.missingReasoningWarnPendingResolveAt = time.Time{}
1424 }
1425 }
1426 if result.Recorded {
1427 result = a.missingReasoningWarnState.resolveAt(fingerprint, observedAt)
1428 }
1429 if !result.Recorded {
1430 if observedAt.After(a.missingReasoningWarnPendingResolveAt) {
1431 a.missingReasoningWarnPendingResolveAt = observedAt
1432 }
1433 a.warnedMissingToolCallReasoning = true
1434 a.missingReasoningWarnStateChecked = false
1435 } else if result.Resolved {
1436 a.warnedMissingToolCallReasoning = false
1437 a.missingReasoningWarnStateChecked = true
1438 } else {
1439 a.warnedMissingToolCallReasoning = true
1440 a.missingReasoningWarnStateChecked = false
1441 }
1442 }
1443 return false, false
1444 }
1445 a.missingReasoningHealthyStreak = 0
1446 if s := a.missingReasoningWarnState; s != nil {
1447 stateReady := true
1448 alreadyActive := a.warnedMissingToolCallReasoning
1449 if pending := a.missingReasoningWarnPendingResolveAt; !pending.IsZero() {
1450 result := s.resolveAt(fingerprint, pending)
1451 stateReady = result.Recorded
1452 if result.Recorded {
1453 a.missingReasoningWarnPendingResolveAt = time.Time{}
1454 if result.Resolved {
1455 alreadyActive = false
1456 a.warnedMissingToolCallReasoning = false
1457 }
1458 }
1459 }
1460 claimed := stateReady && s.claimAt(fingerprint, observedAt)
1461 if !claimed || alreadyActive {
1462 // This exact configuration already attempted recovery for the active
1463 // incident, so keep the empty-key fallback without doubling requests.
1464 a.warnedMissingToolCallReasoning = true
1465 a.missingReasoningWarnStateChecked = true
1466 return true, false
1467 }
1468 if !stateReady {
1469 a.missingReasoningWarnStateChecked = false
1470 }
1471 } else if a.warnedMissingToolCallReasoning {
1472 return true, false
1473 }
1474 a.warnedMissingToolCallReasoning = true
1475 if a.missingReasoningWarnPendingResolveAt.IsZero() {
1476 a.missingReasoningWarnStateChecked = true
1477 }
1478 return true, true
1479 }
1480
1481 // maxStepsPause is the deliberate stop when a positive tool-call budget runs
1482 // out: the session already holds the completed work and the user is asked to
1483 // continue. It is a control-flow signal, not a provider failure. Coordinator
1484 // treats planner research budgets specially: ordinary plan-and-execute work
1485 // falls back to the executor, while explicit execution boundaries fail closed.
1486 type maxStepsPause struct {
1487 steps int
1488 key string
1489 }
1490
1491 func (e *maxStepsPause) Error() string {
1492 return fmt.Sprintf("paused after %d tool-call rounds (%s) — the work so far is saved; send another message to continue, or set %s higher or to 0 for no limit", e.steps, e.key, e.key)
1493 }
1494
1495 type todoStallPause struct {
1496 rounds int
1497 }
1498
1499 func (e *todoStallPause) Error() string {
1500 return fmt.Sprintf("paused after %d tool-call rounds without advancing the current todo — the work so far is saved; inspect the blocker or send another message to continue", e.rounds)
1501 }
1502
1503 func isToolLoopPause(err error) bool {
1504 var maxPause *maxStepsPause
1505 var stallPause *todoStallPause
1506 return errors.As(err, &maxPause) || errors.As(err, &stallPause)
1507 }
1508
1509 // ReadinessResult is the host-consumable outcome of the Delivery final-answer
1510 // readiness check. The Controller reads it after each goal turn; plain turns
1511 // receive the same outcome as a FinalReadinessError.
1512 type ReadinessResult struct {
1513 // Ready is true when no missing requirement remains.
1514 Ready bool
1515 // Missing lists stable category ids of the missing requirements
1516 // (project_check, todo, criteria, verification, review, signoff, action,
1517 // mutation, capability). Empty when Ready.
1518 Missing []string
1519 // Reason is the user-facing summary of what is still missing.
1520 Reason string
1521 // ProgressKey is the host-verifiable progress signature of the current
1522 // evidence state. Identical ProgressKey across consecutive goal turns
1523 // means no host-observable progress was made.
1524 ProgressKey string
1525 }
1526
1527 // ReadinessResult returns the current final-readiness outcome for the host.
1528 func (a *Agent) ReadinessResult() ReadinessResult {
1529 check := a.finalReadinessCheckFor()
1530 if check.reason == "" {
1531 return ReadinessResult{Ready: true, ProgressKey: check.progressSignature()}
1532 }
1533 return ReadinessResult{
1534 Ready: false,
1535 Missing: check.missingIDs(),
1536 Reason: check.reason,
1537 ProgressKey: check.progressSignature(),
1538 }
1539 }
1540
1541 // HostProgressSignature returns a compact signature of host-observable progress
1542 // across the current delivery scope: successful writes, commands, todo writes,
1543 // signoffs, and reviews. Identical signatures across consecutive goal turns
1544 // mean no host-verifiable progress was made — reads, reworded answers, and
1545 // repeated continue reasons never reset the stall counter.
1546 func (a *Agent) HostProgressSignature() string {
1547 if a == nil || a.evidence == nil {
1548 return ""
1549 }
1550 s := a.evidence.ReceiptProgressSummary()
1551 return fmt.Sprintf("w=%d;c=%d;t=%d;s=%d;r=%d", s.Writes, s.Commands, s.Todos, s.Signoffs, s.Reviews)
1552 }
1553
1554 type finalReadinessCheck struct {
1555 applies bool
1556 reason string
1557 missingProjectChecks int
1558 incompleteTodos int
1559 missingAcceptanceCriteria int
1560 missingVerification int
1561 missingReview int
1562 missingSignoff int
1563 missingActionEvidence int
1564 missingMutation int
1565 missingCapabilities int
1566 }
1567
1568 func (c finalReadinessCheck) progressSignature() string {
1569 return fmt.Sprintf("%d/%d/%d/%d/%d/%d/%d/%d/%d/%d\x00%s",
1570 c.missingProjectChecks,
1571 c.incompleteTodos,
1572 c.missingAcceptanceCriteria,
1573 c.missingVerification,
1574 c.missingReview,
1575 c.missingSignoff,
1576 c.missingActionEvidence,
1577 c.missingMutation,
1578 c.missingCapabilities,
1579 boolInt(c.applies),
1580 c.reason,
1581 )
1582 }
1583
1584 func (c finalReadinessCheck) missingIDs() []string {
1585 missing := make([]string, 0, 9)
1586 add := func(id string, count int) {
1587 if count > 0 {
1588 missing = append(missing, id)
1589 }
1590 }
1591 add("project_check", c.missingProjectChecks)
1592 add("todo", c.incompleteTodos)
1593 add("criteria", c.missingAcceptanceCriteria)
1594 add("verification", c.missingVerification)
1595 add("review", c.missingReview)
1596 add("signoff", c.missingSignoff)
1597 add("action", c.missingActionEvidence)
1598 add("mutation", c.missingMutation)
1599 add("capability", c.missingCapabilities)
1600 return missing
1601 }
1602
1603 func boolInt(v bool) int {
1604 if v {
1605 return 1
1606 }
1607 return 0
1608 }
1609
1610 func (c finalReadinessCheck) audit(result evidence.ReadinessAuditResult, recovered bool) evidence.ReadinessAudit {
1611 return evidence.ReadinessAudit{
1612 Result: result,
1613 Recovered: recovered,
1614 MissingProjectChecks: c.missingProjectChecks,
1615 IncompleteTodos: c.incompleteTodos,
1616 CommandMismatchMissing: c.missingProjectChecks,
1617 MissingAcceptanceCriteria: c.missingAcceptanceCriteria,
1618 MissingVerification: c.missingVerification,
1619 MissingReview: c.missingReview,
1620 MissingSignoff: c.missingSignoff,
1621 MissingActionEvidence: c.missingActionEvidence,
1622 MissingMutation: c.missingMutation,
1623 MissingCapabilities: c.missingCapabilities,
1624 }
1625 }
1626
1627 func (a *Agent) finalReadinessCheckFor() finalReadinessCheck {
1628 if a.evidence == nil || a.ablation.Off(ablation.Evidence) {
1629 return finalReadinessCheck{}
1630 }
1631 var missing []string
1632 out := finalReadinessCheck{}
1633 // Planning returns a proposal to the controller, which owns the approval gate
1634 // and starts a fresh execution turn after Plan is disabled. Delivery completion
1635 // requirements, including required capabilities, wait for that execution turn:
1636 // forcing them here could make a writer requirement contradict the plan-first
1637 // workflow. This is a workflow boundary only; model-initiated tool calls above
1638 // still use the normal Permissions/Sandbox path.
1639 if a.planMode.Load() {
1640 return out
1641 }
1642 {
1643 incomplete, hasTodos := a.evidence.IncompleteLatestTodos()
1644 if !hasTodos && a.evidence.HasAnySuccessfulReceipt() {
1645 incomplete, hasTodos = a.incompleteCanonicalTodos()
1646 }
1647 if hasTodos && len(incomplete) > 0 && a.evidence.HasSuccessfulTodoProgressReceipt() {
1648 out.applies = true
1649 out.incompleteTodos = len(incomplete)
1650 missing = append(missing, finalReadinessIncompleteTodos(incomplete))
1651 }
1652 }
1653 writer, hasWriter := a.evidence.LatestSuccessfulWriterIndex()
1654 deliveryMutation := false
1655 deliveryVerificationOnly := false
1656 checkpoint := a.deliveryCheckpoint
1657 checkpointApplies := a.deliveryScopeActive && checkpoint.ScopeID == a.deliveryScopeID
1658 if a.deliveryProfile {
1659 if mutation, ok := a.evidence.LatestSuccessfulMutationIndex(); ok {
1660 writer, hasWriter = mutation, true
1661 deliveryMutation = true
1662 } else if checkpointApplies && checkpoint.PendingMutation {
1663 // The mutation happened before a controller rebuild/restart. Treat it as
1664 // the baseline so this run can satisfy verification/review/sign-off
1665 // without manufacturing another write.
1666 writer, hasWriter = -1, true
1667 deliveryMutation = true
1668 } else if checkpointApplies && checkpoint.MutationObserved {
1669 deliveryMutation = true
1670 }
1671 workObserved := a.evidence.HasSuccessfulWorkReceipt() || (checkpointApplies && checkpoint.WorkObserved)
1672 if a.deliveryTaskExpected && !a.deliveryPersistentExpected && !workObserved {
1673 out.missingActionEvidence++
1674 missing = append(missing, "perform host-observable work for this technical task before answering")
1675 }
1676 if a.deliveryPersistentExpected && !a.evidence.HasSuccessfulToolReceipt("remember") {
1677 out.missingMutation++
1678 missing = append(missing, "save the requested durable memory with the remember tool before answering")
1679 }
1680 if a.deliveryMutationExpected && !deliveryMutation {
1681 out.missingMutation++
1682 missing = append(missing, "the request requires a state change, but no successful mutation was observed")
1683 }
1684 if !hasWriter && a.evidence.HasSuccessfulVerificationCommand() {
1685 writer, hasWriter = -1, true
1686 deliveryVerificationOnly = true
1687 }
1688 // Required/preferred capability gates apply before the no-writer fast
1689 // path below: a user-required Skill/MCP must not be skippable by
1690 // answering from ordinary reads alone.
1691 if msg := a.capabilityGateFailure(); msg != "" {
1692 out.applies = true
1693 out.missingCapabilities++
1694 missing = append(missing, msg)
1695 }
1696 if a.deliveryPersistentExpected && !a.deliveryMutationExpected && !a.evidence.HasSuccessfulMutationOtherThan("remember") {
1697 // A durable-memory-only request has its own concrete receipt contract.
1698 // It must not inherit code-delivery todo/test/diff/review ceremonies;
1699 // any unrelated mutation falls through to the full contract below.
1700 out.applies = true
1701 if len(missing) > 0 {
1702 out.reason = strings.Join(missing, "; ")
1703 }
1704 return out
1705 }
1706 }
1707 if !hasWriter {
1708 if len(missing) > 0 {
1709 if a.loopGuardAllowsFinal() {
1710 return out
1711 }
1712 out.reason = strings.Join(missing, "; ")
1713 }
1714 return out
1715 }
1716 hasProjectChecks := len(a.projectChecks) > 0
1717 hasTodoReceipt := a.evidence.HasSuccessfulTodoWrite()
1718 if !a.deliveryProfile && !hasProjectChecks && !hasTodoReceipt && len(missing) == 0 {
1719 return finalReadinessCheck{}
1720 }
1721 out.applies = true
1722 if a.deliveryProfile {
1723 criteriaEstablished := a.deliveryCriteriaEstablished || (checkpointApplies && checkpoint.CriteriaEstablished)
1724 if !criteriaEstablished {
1725 out.missingAcceptanceCriteria++
1726 missing = append(missing, "establish concrete acceptance criteria with todo_write before changing state")
1727 }
1728 hasCompleteStep := a.evidence.HasSuccessfulCompleteStepAfter(writer)
1729 if !hasCompleteStep {
1730 out.missingSignoff++
1731 missing = append(missing, "call complete_step after the latest mutation")
1732 }
1733 if !a.evidence.HasSuccessfulDeliverySignoffAfter(writer) {
1734 out.missingVerification++
1735 missing = append(missing, "run relevant verification after the latest mutation and cite that successful command in complete_step")
1736 }
1737 if deliveryMutation && !a.evidence.HasSuccessfulReviewAfter(writer) {
1738 out.missingReview++
1739 missing = append(missing, "inspect the changed result after the latest mutation (read the touched file or run git diff/status)")
1740 }
1741 if msg := a.deliveryReviewGateFailure(); msg != "" {
1742 out.missingReview++
1743 missing = append(missing, msg)
1744 }
1745 // The capability gate already ran before the no-writer fast path above.
1746 }
1747 for _, check := range a.projectChecks {
1748 if deliveryVerificationOnly {
1749 break
1750 }
1751 command := strings.TrimSpace(check.Command)
1752 if command == "" {
1753 continue
1754 }
1755 if !a.evidence.HasSuccessfulCommandAfter(command, writer) {
1756 out.missingProjectChecks++
1757 missing = append(missing, fmt.Sprintf("run %q from %s after the latest write", command, finalReadinessCheckSource(check)))
1758 }
1759 }
1760
1761 if len(missing) == 0 {
1762 return out
1763 }
1764 if a.loopGuardAllowsFinal() {
1765 return out
1766 }
1767 out.reason = strings.Join(missing, "; ")
1768 return out
1769 }
1770
1771 // DeliveryCheckpoint returns the compact Goal-scoped delivery state. It is safe
1772 // to persist next to the Goal sidecar because it contains no raw arguments.
1773 func (a *Agent) DeliveryCheckpoint() evidence.DeliveryCheckpoint {
1774 return a.deliveryCheckpoint
1775 }
1776
1777 // RestoreDeliveryCheckpoint seeds a rebuilt controller before its next Goal
1778 // run. A mismatched/empty scope is ignored conservatively.
1779 func (a *Agent) RestoreDeliveryCheckpoint(checkpoint evidence.DeliveryCheckpoint) {
1780 checkpoint.ScopeID = strings.TrimSpace(checkpoint.ScopeID)
1781 if checkpoint.ScopeID == "" {
1782 return
1783 }
1784 a.deliveryCheckpoint = checkpoint
1785 a.deliveryScopeID = checkpoint.ScopeID
1786 }
1787
1788 // PrepareDeliveryRecovery preserves the exhausted turn's evidence for exactly
1789 // one explicit continuation. It returns false when there is no matching
1790 // readiness failure, so normal follow-up turns cannot inherit stale mutations.
1791 func (a *Agent) PrepareDeliveryRecovery() bool {
1792 if !a.deliveryProfile || !a.deliveryRecoveryPending {
1793 return false
1794 }
1795 a.preserveEvidenceOnce = true
1796 a.deliveryRecoveryPending = false
1797 return true
1798 }
1799
1800 func (a *Agent) updateDeliveryCheckpoint(runErr error) {
1801 if !a.deliveryScopeActive || a.deliveryScopeID == "" || a.evidence == nil {
1802 return
1803 }
1804 cp := a.deliveryCheckpoint
1805 if cp.ScopeID != a.deliveryScopeID {
1806 cp = evidence.DeliveryCheckpoint{ScopeID: a.deliveryScopeID}
1807 }
1808 cp.CriteriaEstablished = cp.CriteriaEstablished || a.deliveryCriteriaEstablished || a.evidence.HasSuccessfulTodoWrite()
1809 cp.WorkObserved = cp.WorkObserved || a.evidence.HasSuccessfulWorkReceipt()
1810 persistentOnlyReady := a.deliveryPersistentExpected && !a.deliveryMutationExpected &&
1811 a.evidence.HasSuccessfulToolReceipt("remember") && !a.evidence.HasSuccessfulMutationOtherThan("remember")
1812 if _, ok := a.evidence.LatestSuccessfulMutationIndex(); ok && !persistentOnlyReady {
1813 cp.MutationObserved = true
1814 cp.PendingMutation = true
1815 }
1816 if persistentOnlyReady {
1817 cp.MutationObserved = true
1818 }
1819 if runErr == nil && cp.PendingMutation && a.deliveryMutationCheckpointReady() {
1820 cp.PendingMutation = false
1821 }
1822 a.deliveryCheckpoint = cp
1823 }
1824
1825 func (a *Agent) deliveryMutationCheckpointReady() bool {
1826 if a.evidence == nil || !a.deliveryCriteriaEstablished {
1827 return false
1828 }
1829 mutation, ok := a.evidence.LatestSuccessfulMutationIndex()
1830 if !ok {
1831 mutation = -1
1832 }
1833 return a.evidence.HasSuccessfulCompleteStepAfter(mutation) &&
1834 a.evidence.HasSuccessfulDeliverySignoffAfter(mutation) &&
1835 a.evidence.HasSuccessfulReviewAfter(mutation) &&
1836 a.deliveryReviewGateFailure() == ""
1837 }
1838
1839 // armLoopGuardPass records that a loop guard fired this user turn.
1840 // receiptMark is the evidence-ledger receipt count from just before the
1841 // guarded batch ran, so a successful write or command receipt recorded after
1842 // it counts as real progress and revokes the pass (see loopGuardAllowsFinal).
1843 func (a *Agent) armLoopGuardPass(receiptMark int) {
1844 a.loopGuardArmed = true
1845 a.loopGuardReceiptMark = receiptMark
1846 }
1847
1848 // loopGuardAllowsFinal reports whether final readiness should stand down: a
1849 // loop guard fired this user turn and no host-observable progress — a
1850 // successful write or command receipt — has landed since. In that state the
1851 // missing receipts are exactly what the blocker prevents, so demanding them
1852 // would restart the retry loop the guard just broke; the model must be free to
1853 // report the blocker instead. The bookkeeping the guard recommends (ask,
1854 // todo_write, complete_step) produces neither write nor command receipts, so
1855 // it keeps the pass; real progress revokes it because receipts are obtainable
1856 // again and readiness should resume enforcing them.
1857 func (a *Agent) loopGuardAllowsFinal() bool {
1858 if a == nil || !a.loopGuardArmed {
1859 return false
1860 }
1861 if a.evidence == nil {
1862 return true
1863 }
1864 return !a.evidence.HasWriteOrCommandSince(a.loopGuardReceiptMark)
1865 }
1866
1867 func finalReadinessIncompleteTodos(items []evidence.TodoStepMatch) string {
1868 parts := make([]string, 0, len(items))
1869 for _, item := range items {
1870 label := strings.TrimSpace(item.Content)
1871 if label == "" {
1872 label = fmt.Sprintf("todo %d", item.Index)
1873 }
1874 parts = append(parts, fmt.Sprintf("%s: %s", label, item.Status))
1875 }
1876 return "latest successful todo_write still has incomplete items: " + strings.Join(parts, ", ")
1877 }
1878
1879 func (a *Agent) setTodoState(todos []evidence.TodoItem) {
1880 a.todoMu.Lock()
1881 a.todoState = evidence.NormalizeSerialTodos(todos)
1882 a.todoMu.Unlock()
1883 }
1884
1885 // SeedTodoState initializes the canonical task list from a host-generated
1886 // starter list, such as an approved plan. A new host seed replaces stale state
1887 // from earlier work so complete_step matches the plan the UI just displayed.
1888 func (a *Agent) SeedTodoState(todos []evidence.TodoItem) {
1889 if len(todos) == 0 {
1890 return
1891 }
1892 a.setTodoState(todos)
1893 }
1894
1895 // ReplaceTodoState mirrors a host-generated todo list into the canonical state.
1896 // It is used when the host, rather than the model, owns the full state transition.
1897 func (a *Agent) ReplaceTodoState(todos []evidence.TodoItem) {
1898 a.setTodoState(todos)
1899 a.recordTodoState(a.CanonicalTodoState())
1900 }
1901
1902 // CanonicalTodoState returns a copy of the host-reconstructed task list.
1903 func (a *Agent) CanonicalTodoState() []evidence.TodoItem {
1904 a.todoMu.Lock()
1905 defer a.todoMu.Unlock()
1906 return append([]evidence.TodoItem(nil), a.todoState...)
1907 }
1908
1909 func (a *Agent) incompleteCanonicalTodos() ([]evidence.TodoStepMatch, bool) {
1910 a.todoMu.Lock()
1911 defer a.todoMu.Unlock()
1912 if len(a.todoState) == 0 {
1913 return nil, false
1914 }
1915 return evidence.IncompleteTodos(a.todoState), true
1916 }
1917
1918 func (a *Agent) hasIncompleteCanonicalCriteria() bool {
1919 a.todoMu.Lock()
1920 defer a.todoMu.Unlock()
1921 return len(a.todoState) > 0 && len(evidence.IncompleteTodos(a.todoState)) > 0
1922 }
1923
1924 func (a *Agent) hasActiveCanonicalTodo() bool {
1925 a.todoMu.Lock()
1926 defer a.todoMu.Unlock()
1927 for _, todo := range a.todoState {
1928 if canonicalTodoStatus(todo.Status) == "in_progress" {
1929 return true
1930 }
1931 }
1932 return false
1933 }
1934
1935 func (a *Agent) canonicalTodoProgress() (int, bool) {
1936 a.todoMu.Lock()
1937 defer a.todoMu.Unlock()
1938 completed := 0
1939 incomplete := false
1940 for _, todo := range a.todoState {
1941 status := canonicalTodoStatus(todo.Status)
1942 if status == "completed" {
1943 completed++
1944 } else {
1945 incomplete = true
1946 }
1947 }
1948 return completed, incomplete
1949 }
1950
1951 // registryHasWriterTools reports whether any registered tool can mutate state.
1952 // A strictly read-only registry (read_only_task / read_only_skill subagents)
1953 // can never satisfy a "state change required" delivery expectation, so that
1954 // expectation must not be armed for it.
1955 func registryHasWriterTools(reg *tool.Registry) bool {
1956 if reg == nil {
1957 return false
1958 }
1959 for _, name := range reg.Names() {
1960 if t, ok := reg.Get(name); ok && !t.ReadOnly() {
1961 return true
1962 }
1963 }
1964 return false
1965 }
1966
1967 type deliveryTaskIntent uint8
1968
1969 const (
1970 deliveryIntentConversation deliveryTaskIntent = iota
1971 deliveryIntentAdvisory
1972 deliveryIntentObservableRead
1973 deliveryIntentMutation
1974 deliveryIntentPersistentAction
1975 )
1976
1977 func classifyDeliveryTaskIntent(input string) deliveryTaskIntent {
1978 switch {
1979 case deliveryTaskHasMutationIntent(input):
1980 return deliveryIntentMutation
1981 case deliveryTaskNeedsPersistentAction(input):
1982 return deliveryIntentPersistentAction
1983 case deliveryTaskIsConversationOnly(input):
1984 return deliveryIntentConversation
1985 case !heuristicInputIsTask(input):
1986 return deliveryIntentConversation
1987 case deliveryTaskIsAdvisory(input):
1988 return deliveryIntentAdvisory
1989 default:
1990 return deliveryIntentObservableRead
1991 }
1992 }
1993
1994 func deliveryTaskNeedsEvidence(input string) bool {
1995 intent := classifyDeliveryTaskIntent(input)
1996 return intent == deliveryIntentObservableRead || intent == deliveryIntentMutation || intent == deliveryIntentPersistentAction
1997 }
1998
1999 var deliveryMutationNeedles = []string{
2000 "fix", "repair", "resolve", "create", "add", "write", "edit", "update", "change", "delete", "remove", "rename",
2001 "implement", "refactor", "apply", "install", "publish", "commit", "push", "continue work",
2002 "modify", "patch", "replace", "move", "configure", "upgrade", "downgrade", "bump", "enable", "disable", "merge",
2003 "make changes", "make a change", "make the changes", "make the requested changes", "make the necessary changes", "make these changes", "make those changes", "make code changes",
2004 "修复", "解决", "创建", "新建", "添加", "编写", "编辑", "修改", "更新", "删除", "移除", "重命名", "实现", "重构",
2005 "实施", "落地", "安装", "发布", "提交", "继续处理", "调整", "替换", "移动", "升级", "降级", "启用", "禁用", "合并", "改动", "打补丁",
2006 }
2007
2008 var deliveryAdvisoryPhrases = []string{
2009 "what's wrong", "what is wrong", "why", "what should i do", "what can i do", "how should i", "how do i", "how can i",
2010 "can you explain", "could you explain", "give me advice", "any advice", "help me understand",
2011 "为什么", "怎么回事", "怎么办", "怎么", "怎样", "如何", "是什么问题", "什么原因", "的原因", "给我建议", "有什么建议",
2012 }
2013
2014 func deliveryTaskNeedsMutation(input string) bool {
2015 intent := classifyDeliveryTaskIntent(input)
2016 return intent == deliveryIntentMutation || intent == deliveryIntentPersistentAction
2017 }
2018
2019 func deliveryTaskHasMutationIntent(input string) bool {
2020 affirmative, _ := deliveryTaskMutationIntent(input)
2021 return affirmative
2022 }
2023
2024 func deliveryTaskNeedsPersistentAction(input string) bool {
2025 normalized := strings.ToLower(strings.TrimSpace(input))
2026 if normalized == "" {
2027 return false
2028 }
2029 actionNeedles := []string{
2030 "remember", "save", "store", "keep this", "keep that",
2031 "记住", "记下来", "保存", "存下来", "记录下来",
2032 }
2033 durableNeedles := []string{
2034 "permanently", "durable", "long-term", "long term", "across sessions", "future sessions", "every session", "after restart", "after restarting",
2035 "永久", "长期", "持久", "跨会话", "以后每次", "未来会话", "重启后", "下次启动",
2036 }
2037 for _, clause := range deliveryTaskClauses(normalized) {
2038 action := false
2039 for _, needle := range actionNeedles {
2040 affirmative, _ := deliveryTaskNeedleIntent(clause, needle)
2041 action = action || affirmative
2042 }
2043 durable := false
2044 for _, needle := range durableNeedles {
2045 affirmative, _ := deliveryTaskNeedleIntent(clause, needle)
2046 durable = durable || affirmative
2047 }
2048 if action && durable && !deliveryTaskClauseIsAdvisory(clause) {
2049 return true
2050 }
2051 }
2052 return false
2053 }
2054
2055 func deliveryTaskIsConversationOnly(input string) bool {
2056 normalized := strings.ToLower(strings.TrimSpace(input))
2057 if normalized == "" || deliveryTaskHasHostAnchor(normalized) || deliveryTaskHasCommand(normalized) {
2058 return false
2059 }
2060 localCue := containsAnySubstring(normalized, []string{
2061 "next turn", "next message", "later in this chat", "this conversation", "when i ask again", "when i ask next",
2062 "下一轮", "下轮", "下一条消息", "稍后再问", "待会再问", "这个对话", "本次对话", "本轮会话",
2063 })
2064 conversationAction := containsAnySubstring(normalized, []string{
2065 "remember", "keep in mind", "keep this", "keep that", "answer", "respond", "reply",
2066 "记住", "记一下", "回答", "回复", "再告诉我",
2067 })
2068 return localCue && conversationAction
2069 }
2070
2071 // TaskNeedsMutation reports whether a task text looks like a mutation request
2072 // under the existing task-intent classification. The host uses it to pick a
2073 // Goal budget class; it never gates permissions or whether writes are allowed.
2074 func TaskNeedsMutation(input string) bool {
2075 return deliveryTaskNeedsMutation(input)
2076 }
2077
2078 func deliveryTaskMutationIntent(input string) (affirmative, negated bool) {
2079 normalized := strings.ToLower(strings.TrimSpace(input))
2080 for _, clause := range deliveryTaskClauses(normalized) {
2081 clauseAffirmative := false
2082 clauseNegated := false
2083 if deliveryMutationClauseNegated(clause) {
2084 clauseNegated = true
2085 }
2086 for _, needle := range deliveryMutationNeedles {
2087 hasAffirmative, hasNegated := deliveryTaskNeedleIntent(clause, needle)
2088 clauseAffirmative = clauseAffirmative || hasAffirmative
2089 clauseNegated = clauseNegated || hasNegated
2090 }
2091 if clauseAffirmative && deliveryTaskClauseIsAdvisory(clause) && !deliveryTaskAdvisoryClauseRequestsMutation(clause) {
2092 clauseAffirmative = false
2093 clauseNegated = true
2094 }
2095 affirmative = affirmative || clauseAffirmative
2096 negated = negated || clauseNegated
2097 }
2098 return affirmative, negated
2099 }
2100
2101 func deliveryTaskIsAdvisory(input string) bool {
2102 normalized := strings.ToLower(strings.TrimSpace(input))
2103
2104 // Concrete targets and commands always remain host-observable, including
2105 // when the request is phrased as a "why" question.
2106 if deliveryTaskHasHostAnchor(normalized) || deliveryTaskHasCommand(normalized) {
2107 return false
2108 }
2109
2110 // Question wording is scoped per clause. This keeps remote troubleshooting
2111 // such as "analyze why WPS won't open" advisory, while a separate imperative
2112 // clause such as "reproduce the crash" still requires observable work.
2113 sawAdvisory := false
2114 for _, clause := range deliveryTaskClauses(normalized) {
2115 if deliveryTaskClauseIsAdvisory(clause) {
2116 sawAdvisory = true
2117 continue
2118 }
2119 if deliveryTaskClauseHasObservableWork(clause) {
2120 return false
2121 }
2122 }
2123 if sawAdvisory {
2124 return true
2125 }
2126
2127 // A standalone refusal, inability, or constraint around a mutation verb is
2128 // advisory rather than work Reasonix can perform. Affirmative mixed intent is
2129 // handled by deliveryTaskNeedsMutation before this function is consulted.
2130 _, negatedMutation := deliveryTaskMutationIntent(normalized)
2131 return negatedMutation
2132 }
2133
2134 func deliveryTaskHasHostAnchor(input string) bool {
2135 for _, anchor := range []string{
2136 "this repo", "this repository", "current repository", "codebase", "workspace", "pull request", "this pr", "ci job",
2137 "/pull/", "actions/runs/",
2138 "当前仓库", "这个仓库", "当前项目", "这个项目", "代码库", "工作区", "这个 pr", "这个pr", "此 pr", "此pr",
2139 } {
2140 if strings.Contains(input, anchor) {
2141 return true
2142 }
2143 }
2144 return deliveryTaskHasFileReference(input)
2145 }
2146
2147 func deliveryTaskHasFileReference(input string) bool {
2148 previous := rune(0)
2149 for index, current := range input {
2150 if current == '@' && index+1 < len(input) &&
2151 (index == 0 || strings.ContainsRune(" \t\r\n([{<,:;(【《,。;:", previous)) {
2152 next, _ := utf8.DecodeRuneInString(input[index+1:])
2153 if !strings.ContainsRune(" \t\r\n", next) {
2154 return true
2155 }
2156 }
2157 previous = current
2158 }
2159
2160 for _, raw := range strings.FieldsFunc(input, func(r rune) bool {
2161 switch r {
2162 case ' ', '\t', '\r', '\n', '`', '\'', '"', '(', ')', '[', ']', '{', '}', '<', '>', ',', ',', ';', ';', '!', '!', '?', '?':
2163 return true
2164 default:
2165 return false
2166 }
2167 }) {
2168 token := strings.ToLower(strings.TrimSpace(raw))
2169 if token == "" || strings.Contains(token, "://") {
2170 continue
2171 }
2172 if strings.HasPrefix(token, "./") || strings.HasPrefix(token, "../") ||
2173 strings.HasPrefix(token, "/") || strings.Contains(token, `\`) {
2174 return true
2175 }
2176 base := token
2177 if slash := strings.LastIndexByte(base, '/'); slash >= 0 {
2178 base = base[slash+1:]
2179 }
2180 switch base {
2181 case "dockerfile", "makefile", "cmakelists.txt", "justfile", "license", "readme", "changelog":
2182 return true
2183 }
2184 dot := strings.LastIndexByte(base, '.')
2185 if dot < 0 {
2186 continue
2187 }
2188 switch base[dot:] {
2189 case ".go", ".mod", ".sum", ".js", ".jsx", ".ts", ".tsx", ".py", ".rs", ".java", ".kt", ".swift",
2190 ".c", ".cc", ".cpp", ".h", ".hpp", ".cs", ".rb", ".php", ".sh", ".zsh", ".fish", ".ps1",
2191 ".md", ".json", ".yaml", ".yml", ".toml", ".xml", ".sql", ".proto", ".html", ".css", ".scss",
2192 ".vue", ".svelte", ".txt", ".log", ".csv", ".pdf", ".env", ".ini", ".conf", ".lock":
2193 return true
2194 }
2195 }
2196 return false
2197 }
2198
2199 func deliveryTaskHasCommand(input string) bool {
2200 tokens := strings.FieldsFunc(strings.ToLower(input), func(r rune) bool {
2201 asciiWord := r >= 'a' && r <= 'z' || r >= '0' && r <= '9'
2202 return !asciiWord && r != '_' && r != '-' && r != '.' && r != '/' && r != '\\' && r != ':'
2203 })
2204 for i := range tokens {
2205 if deliveryCommandStartsAt(tokens, i) {
2206 return true
2207 }
2208 }
2209 return false
2210 }
2211
2212 func deliveryCommandStartsAt(tokens []string, index int) bool {
2213 command := strings.TrimSpace(tokens[index])
2214 if command == "" {
2215 return false
2216 }
2217 if strings.HasPrefix(command, "./") || strings.HasPrefix(command, "../") ||
2218 strings.HasPrefix(command, "/") || strings.Contains(command, `\`) {
2219 return true
2220 }
2221 next := ""
2222 if index+1 < len(tokens) {
2223 next = tokens[index+1]
2224 }
2225 if next != "--" && len(next) > 1 && strings.HasPrefix(next, "-") {
2226 return true
2227 }
2228 previous := ""
2229 if index > 0 {
2230 previous = tokens[index-1]
2231 }
2232 switch command {
2233 case "go":
2234 switch next {
2235 case "build", "clean", "doc", "env", "fmt", "generate", "get", "install", "list", "mod", "run", "test", "tool", "version", "vet", "work":
2236 return true
2237 }
2238 case "git", "npm", "npx", "pnpm", "yarn", "bun", "deno", "cargo", "rustc", "python", "python3",
2239 "bash", "sh", "zsh", "fish", "powershell", "pwsh", "docker", "docker-compose", "kubectl", "helm", "terraform",
2240 "gradle", "gradlew", "mvn", "dotnet", "xcodebuild", "gcc", "g++", "clang", "clang++":
2241 return deliveryCommandHasExplicitCue(previous) || deliveryCommandHasSubcommand(next)
2242 case "node":
2243 return deliveryCommandHasExplicitCue(previous) || next == "inspect" || next == "test"
2244 case "swift":
2245 return next == "build" || next == "package" || next == "run" || next == "test"
2246 case "make", "just":
2247 switch next {
2248 case "all", "build", "check", "clean", "fail", "failed", "failing", "install", "lint", "test":
2249 return true
2250 }
2251 case "pytest", "cmake", "ninja", "eslint", "tsc", "vitest", "jest":
2252 return deliveryCommandHasExplicitCue(previous) || next == "fail" || next == "failed" || next == "failing"
2253 }
2254 return false
2255 }
2256
2257 func deliveryCommandHasExplicitCue(previous string) bool {
2258 switch previous {
2259 case "command", "execute", "executing", "run", "running", "using", "with":
2260 return true
2261 default:
2262 return false
2263 }
2264 }
2265
2266 func deliveryCommandHasSubcommand(next string) bool {
2267 switch next {
2268 case "add", "apply", "branch", "build", "check", "checkout", "clean", "clone", "commit", "config", "container",
2269 "deploy", "describe", "destroy", "dev", "diff", "down", "env", "exec", "fetch", "fmt", "generate", "get", "image",
2270 "init", "install", "lint", "list", "log", "logs", "login", "logout", "merge", "mod", "package", "plan", "ps", "publish",
2271 "pull", "push", "rebase", "remote", "remove", "reset", "restore", "run", "serve", "show", "start", "stash", "status",
2272 "switch", "tag", "test", "tool", "uninstall", "up", "update", "upgrade", "version", "vet", "work", "worktree":
2273 return true
2274 default:
2275 return false
2276 }
2277 }
2278
2279 func deliveryTaskClauseHasObservableWork(clause string) bool {
2280 for _, needle := range []string{
2281 "review", "inspect", "analyze", "check", "reproduce", "audit", "verify",
2282 "评审", "审查", "检查", "分析", "复现", "审计", "验证",
2283 } {
2284 affirmative, _ := deliveryTaskNeedleIntent(clause, needle)
2285 if affirmative {
2286 return true
2287 }
2288 }
2289 return false
2290 }
2291
2292 func deliveryTaskClauseIsAdvisory(clause string) bool {
2293 for _, phrase := range deliveryAdvisoryPhrases {
2294 if strings.Contains(clause, phrase) {
2295 return true
2296 }
2297 }
2298 return false
2299 }
2300
2301 func deliveryTaskAdvisoryClauseRequestsMutation(clause string) bool {
2302 advisoryIndex := len(clause)
2303 for _, phrase := range deliveryAdvisoryPhrases {
2304 if index := strings.Index(clause, phrase); index >= 0 && index < advisoryIndex {
2305 advisoryIndex = index
2306 }
2307 }
2308 if advisoryIndex == len(clause) {
2309 return false
2310 }
2311 if deliveryTaskStartsWithMutation(clause[:advisoryIndex]) {
2312 return true
2313 }
2314
2315 for _, cue := range []string{" please ", " then ", " so ", " therefore ", "然后", "所以", "而是", "转而"} {
2316 for rest := clause[advisoryIndex:]; ; {
2317 index := strings.Index(rest, cue)
2318 if index < 0 {
2319 break
2320 }
2321 rest = rest[index+len(cue):]
2322 if deliveryTaskStartsWithMutation(rest) {
2323 return true
2324 }
2325 }
2326 }
2327 for rest, offset := clause[advisoryIndex:], advisoryIndex; ; {
2328 index := strings.Index(rest, "请")
2329 if index < 0 {
2330 break
2331 }
2332 absolute := offset + index
2333 after := clause[absolute+len("请"):]
2334 requestWord := strings.HasSuffix(clause[:absolute], "申") || strings.HasPrefix(after, "求")
2335 if !requestWord && deliveryTaskStartsWithMutation(after) {
2336 return true
2337 }
2338 offset = absolute + len("请")
2339 rest = clause[offset:]
2340 }
2341
2342 for _, cue := range []string{" and ", "并且", "并"} {
2343 if index := strings.LastIndex(clause[advisoryIndex:], cue); index >= 0 {
2344 cueStart := advisoryIndex + index
2345 tail := clause[cueStart+len(cue):]
2346 if !deliveryTaskClauseHasNegation(clause[:cueStart]) && deliveryTaskStartsWithMutation(tail) {
2347 return true
2348 }
2349 }
2350 }
2351 return false
2352 }
2353
2354 func deliveryTaskStartsWithMutation(input string) bool {
2355 input = strings.TrimSpace(input)
2356 for {
2357 stripped := false
2358 for _, prefix := range []string{"please ", "can you ", "could you ", "would you ", "you should ", "帮我", "请你", "直接", "继续", "再"} {
2359 if strings.HasPrefix(input, prefix) {
2360 input = strings.TrimSpace(strings.TrimPrefix(input, prefix))
2361 stripped = true
2362 break
2363 }
2364 }
2365 if !stripped {
2366 break
2367 }
2368 }
2369 for _, needle := range deliveryMutationNeedles {
2370 if containsTaskNeedle(input, needle) {
2371 if containsNonASCII(needle) {
2372 return strings.HasPrefix(input, needle)
2373 }
2374 tokens := strings.FieldsFunc(input, func(r rune) bool {
2375 return !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') && r != '_' && r != '\''
2376 })
2377 needleTokens := strings.Fields(needle)
2378 if len(tokens) >= len(needleTokens) {
2379 matches := true
2380 for i := range needleTokens {
2381 matches = matches && tokens[i] == needleTokens[i]
2382 }
2383 if matches {
2384 return true
2385 }
2386 }
2387 }
2388 }
2389 return false
2390 }
2391
2392 func deliveryTaskClauseHasNegation(clause string) bool {
2393 clause = strings.ReplaceAll(clause, "’", "'")
2394 for _, phrase := range []string{
2395 " not ", " never ", " without ", "cannot", "can't", " cant ", "don't", " dont ", "won't", " wont ", "unable",
2396 "不要", "别", "勿", "不能", "无法", "不想", "不敢", "无需", "不需要", "不可", "没法", "没有", "禁止", "拒绝",
2397 } {
2398 if strings.Contains(" "+clause+" ", phrase) {
2399 return true
2400 }
2401 }
2402 return false
2403 }
2404
2405 func deliveryTaskClauses(input string) []string {
2406 input = strings.NewReplacer(
2407 " but ", "\n",
2408 " however ", "\n",
2409 " nevertheless ", "\n",
2410 "但请", "\n请",
2411 "但是", "\n",
2412 "不过", "\n",
2413 ).Replace(input)
2414 return strings.FieldsFunc(input, func(r rune) bool {
2415 switch r {
2416 case '\n', '\r', '.', '。', ',', ',', ';', ';', '!', '!', '?', '?':
2417 return true
2418 default:
2419 return false
2420 }
2421 })
2422 }
2423
2424 func deliveryMutationClauseNegated(clause string) bool {
2425 for _, phrase := range []string{
2426 "without changing", "without modifying", "analysis only", "review only",
2427 "不要改动", "只分析", "仅分析", "只检查", "仅检查", "只评审", "仅评审",
2428 } {
2429 if strings.Contains(clause, phrase) {
2430 return true
2431 }
2432 }
2433 return false
2434 }
2435
2436 func deliveryTaskNeedleIntent(clause, needle string) (affirmative, negated bool) {
2437 if containsNonASCII(needle) {
2438 for offset := 0; offset < len(clause); {
2439 relative := strings.Index(clause[offset:], needle)
2440 if relative < 0 {
2441 break
2442 }
2443 index := offset + relative
2444 prefix := []rune(clause[:index])
2445 if deliveryMutationRunesNegated(prefix) {
2446 negated = true
2447 } else {
2448 affirmative = true
2449 }
2450 offset = index + len(needle)
2451 }
2452 return affirmative, negated
2453 }
2454
2455 clause = strings.ReplaceAll(clause, "’", "'")
2456 tokens := strings.FieldsFunc(clause, func(r rune) bool {
2457 return !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') && r != '_' && r != '\''
2458 })
2459 needleTokens := strings.Fields(needle)
2460 for i := 0; i+len(needleTokens) <= len(tokens); i++ {
2461 matches := true
2462 for j, token := range needleTokens {
2463 if tokens[i+j] != token {
2464 matches = false
2465 break
2466 }
2467 }
2468 if !matches {
2469 continue
2470 }
2471 if deliveryMutationTokensNegated(tokens[:i]) {
2472 negated = true
2473 } else {
2474 affirmative = true
2475 }
2476 }
2477 return affirmative, negated
2478 }
2479
2480 func deliveryMutationTokensNegated(prefix []string) bool {
2481 if len(prefix) > 6 {
2482 prefix = prefix[len(prefix)-6:]
2483 }
2484 boundary := -1
2485 for i, token := range prefix {
2486 switch token {
2487 case "but", "however", "nevertheless", "instead", "so", "then", "therefore", "please":
2488 boundary = i
2489 }
2490 }
2491 if boundary >= 0 {
2492 prefix = prefix[boundary+1:]
2493 }
2494 for i, token := range prefix {
2495 if token == "not" && i+1 < len(prefix) && prefix[i+1] == "only" {
2496 continue
2497 }
2498 switch token {
2499 case "not", "never", "without", "cannot", "can't", "cant", "don't", "dont", "won't", "wont", "unable", "avoid", "avoiding", "afraid", "refuse", "refusing", "needn't":
2500 return true
2501 case "no":
2502 if i+1 < len(prefix) && prefix[i+1] == "need" {
2503 return true
2504 }
2505 }
2506 }
2507 return false
2508 }
2509
2510 func deliveryMutationRunesNegated(prefix []rune) bool {
2511 if len(prefix) > 12 {
2512 prefix = prefix[len(prefix)-12:]
2513 }
2514 window := string(prefix)
2515 scopeStart := 0
2516 for _, boundary := range []string{"所以", "然后", "而是", "转而", "改为"} {
2517 if index := strings.LastIndex(window, boundary); index >= 0 {
2518 end := index + len(boundary)
2519 if end > scopeStart {
2520 scopeStart = end
2521 }
2522 }
2523 }
2524 if index := strings.LastIndex(window, "请"); index >= 0 {
2525 before, after := window[:index], window[index+len("请"):]
2526 requestWord := strings.HasSuffix(before, "申") || strings.HasPrefix(after, "求")
2527 negatedRequest := false
2528 for _, marker := range []string{"不要", "不能", "无法", "不想", "不敢", "无需", "不需要", "不可", "没法", "禁止", "拒绝"} {
2529 if strings.HasSuffix(before, marker) || strings.Contains(after, marker) {
2530 negatedRequest = true
2531 break
2532 }
2533 }
2534 if !requestWord && !negatedRequest && index+len("请") > scopeStart {
2535 scopeStart = index + len("请")
2536 }
2537 }
2538 window = window[scopeStart:]
2539 for _, marker := range []string{"不要", "别", "勿", "不能", "无法", "不想", "不敢", "无需", "不需要", "不可", "没法", "没有", "禁止", "拒绝"} {
2540 if strings.Contains(window, marker) {
2541 return true
2542 }
2543 }
2544 return false
2545 }
2546
2547 // advanceCanonicalTodo flips the canonical todo matching a signed-off step to
2548 // completed (promoting the next pending item to in_progress) and emits a
2549 // synthetic todo_write so the task panel reflects it without the model
2550 // re-sending the whole list. No-op when nothing matches or it is already done.
2551 func (a *Agent) advanceCanonicalTodo(step string) {
2552 a.todoMu.Lock()
2553 if len(a.todoState) == 0 {
2554 a.todoMu.Unlock()
2555 return
2556 }
2557 m, ok := evidence.MatchStep(step, a.todoState)
2558 if !ok || !evidence.AdvanceSerialTodo(a.todoState, m.Index-1) {
2559 a.todoMu.Unlock()
2560 return
2561 }
2562 snapshot := append([]evidence.TodoItem(nil), a.todoState...)
2563 a.todoMu.Unlock()
2564 a.recordTodoState(snapshot)
2565 a.emitTodoState(snapshot, m.Index)
2566 }
2567
2568 // recordTodoState logs the host-advanced list as a synthetic todo_write receipt
2569 // so the per-turn final gate (which reads the ledger's latest todo_write) sees
2570 // the advance — the model no longer has to re-send a todo_write to mark the
2571 // completion. It bypasses the todo_write tool, so the completion-transition
2572 // guard never runs on it.
2573 func (a *Agent) recordTodoState(todos []evidence.TodoItem) {
2574 if a.evidence == nil {
2575 return
2576 }
2577 args, err := json.Marshal(map[string]any{"todos": todos})
2578 if err != nil {
2579 return
2580 }
2581 a.evidence.Record(evidence.ReceiptFromToolCall("todo_write", json.RawMessage(args), true, true))
2582 }
2583
2584 func canonicalTodoStatus(s string) string {
2585 s = strings.TrimSpace(s)
2586 if s == "" {
2587 return "pending"
2588 }
2589 return s
2590 }
2591
2592 // emitTodoState emits a synthetic todo_write event so the frontend task panel
2593 // reflects a host-advanced completion without the model re-sending the list.
2594 // itemIndex is the 1-based position of the completed todo in the panel.
2595 func (a *Agent) emitTodoState(todos []evidence.TodoItem, itemIndex int) {
2596 args, err := json.Marshal(map[string]any{"todos": todos})
2597 if err != nil {
2598 return
2599 }
2600 id := fmt.Sprintf("host-advance-%d-%d", a.hostAdvanceSeq.Add(1), itemIndex)
2601 t := event.Tool{ID: id, Name: "todo_write", Args: string(args), ReadOnly: true}
2602 a.sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: t})
2603 t.Output = "task list advanced by complete_step"
2604 a.sink.Emit(event.Event{Kind: event.ToolResult, Tool: t})
2605 }
2606
2607 // RebuildTodoState re-derives canonical task state from the current session
2608 // transcript. Call after externally truncating the session (e.g. after a
2609 // user-cancel strip) so Agent.todoState stays consistent with the messages.
2610 func (a *Agent) RebuildTodoState() {
2611 a.rebuildTodoState(a.Session().Snapshot())
2612 }
2613
2614 // rebuildTodoState reconstructs the canonical task list from a transcript: the
2615 // latest successful todo_write is the base, then every complete_step after it
2616 // advances an item. Deterministic from persisted messages, so it survives a
2617 // fresh load or a rewind (the truncated history yields the historical state).
2618 // Empty after compaction drops the todo_write — no worse than no canonical list.
2619 func (a *Agent) rebuildTodoState(msgs []provider.Message) {
2620 successful := successfulToolCallIDs(msgs)
2621 var todos []evidence.TodoItem
2622 baseIdx := -1
2623 for i, msg := range msgs {
2624 for _, tc := range msg.ToolCalls {
2625 if tc.Name != "todo_write" || !successful[tc.ID] {
2626 continue
2627 }
2628 rec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)
2629 // A successful empty todo_write is an explicit clear. Preserve it as the
2630 // latest base so history reloads do not resurrect an older non-empty list.
2631 todos = evidence.NormalizeSerialTodos(rec.Todos)
2632 baseIdx = i
2633 }
2634 }
2635 if baseIdx < 0 {
2636 a.setTodoState(nil)
2637 return
2638 }
2639 for i := baseIdx; i < len(msgs); i++ {
2640 for _, tc := range msgs[i].ToolCalls {
2641 if tc.Name != "complete_step" || !successful[tc.ID] {
2642 continue
2643 }
2644 rec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)
2645 if m, ok := evidence.MatchStep(rec.Step, todos); ok {
2646 evidence.AdvanceSerialTodo(todos, m.Index-1)
2647 }
2648 }
2649 }
2650 a.setTodoState(todos)
2651 }
2652
2653 func successfulToolCallIDs(msgs []provider.Message) map[string]bool {
2654 successful := map[string]bool{}
2655 for _, msg := range msgs {
2656 if msg.Role != provider.RoleTool || msg.ToolCallID == "" {
2657 continue
2658 }
2659 if !toolResultFailed(msg.Content) {
2660 successful[msg.ToolCallID] = true
2661 }
2662 }
2663 return successful
2664 }
2665
2666 func toolResultFailed(content string) bool {
2667 content = strings.TrimSpace(content)
2668 return strings.HasPrefix(content, "error:") ||
2669 strings.HasPrefix(content, "blocked:") ||
2670 strings.HasPrefix(content, "Error:") ||
2671 strings.HasPrefix(content, "[error")
2672 }
2673
2674 func finalReadinessCheckSource(check instruction.VerifyCheck) string {
2675 source := strings.TrimSpace(check.SourcePath)
2676 if source == "" {
2677 source = "project memory"
2678 }
2679 if check.Line > 0 {
2680 return fmt.Sprintf("%s:%d", source, check.Line)
2681 }
2682 return source
2683 }
2684
2685 func shouldNudgeExecutorHandoff(input, answer string) bool {
2686 return !executorHandoffAllowsTextOnly(input, answer)
2687 }
2688
2689 func executorHandoffAllowsTextOnly(input, answer string) bool {
2690 if looksLikeExecutorHandoffDeferral(answer) {
2691 return false
2692 }
2693 task, plan, ok := parseExecutorHandoff(input)
2694 if !ok {
2695 return false
2696 }
2697 if handoffTaskLooksTextOnly(task) {
2698 return true
2699 }
2700 return handoffPlanLooksTextOnly(plan)
2701 }
2702
2703 func parseExecutorHandoff(input string) (task, plan string, ok bool) {
2704 input = StripTransientUserBlocks(input)
2705 marker := "# " + executorHandoffMarker
2706 i := strings.Index(input, marker)
2707 if i < 0 {
2708 return "", "", false
2709 }
2710 input = input[i+len(marker):]
2711 _, input, ok = strings.Cut(input, "\n\nOriginal task:\n")
2712 if !ok {
2713 return "", "", false
2714 }
2715 task, input, ok = strings.Cut(input, "\n\nPlanner output:\n")
2716 if !ok {
2717 return "", "", false
2718 }
2719 plan, _, ok = strings.Cut(input, "\n\nExecutor instructions:")
2720 if !ok {
2721 return "", "", false
2722 }
2723 if beforeToolContext, _, found := strings.Cut(plan, "\n\nExecutor tool context:"); found {
2724 plan = beforeToolContext
2725 }
2726 return strings.TrimSpace(task), strings.TrimSpace(plan), true
2727 }
2728
2729 func looksLikeExecutorHandoffDeferral(answer string) bool {
2730 lower := strings.ToLower(strings.TrimSpace(answer))
2731 if lower == "" {
2732 return true
2733 }
2734 if containsAnySubstring(lower, executorHandoffDeferralPhrases) {
2735 return true
2736 }
2737 switch strings.Trim(lower, " \t\r\n.!?。!?") {
2738 case "ok", "okay", "sounds good", "done", "好的", "可以", "没问题", "收到":
2739 return true
2740 default:
2741 return false
2742 }
2743 }
2744
2745 func handoffTaskLooksTextOnly(task string) bool {
2746 lower := strings.ToLower(strings.TrimSpace(task))
2747 if lower == "" {
2748 return false
2749 }
2750 if containsAnySubstring(lower, executorHandoffWorkRequestTerms) {
2751 return false
2752 }
2753 return containsAnySubstring(lower, executorHandoffTextOnlyTaskTerms)
2754 }
2755
2756 func handoffPlanLooksTextOnly(plan string) bool {
2757 lower := strings.ToLower(strings.TrimSpace(plan))
2758 if lower == "" {
2759 return false
2760 }
2761 if containsAnySubstring(lower, executorHandoffLocalActionTerms) {
2762 return false
2763 }
2764 if containsAnySubstring(lower, executorHandoffTextOnlyPlanTerms) {
2765 return true
2766 }
2767 return strings.Contains(lower, "?")
2768 }
2769
2770 func containsAnySubstring(s string, terms []string) bool {
2771 for _, term := range terms {
2772 if strings.Contains(s, term) {
2773 return true
2774 }
2775 }
2776 return false
2777 }
2778
2779 var executorHandoffDeferralPhrases = []string{
2780 "plan looks", "looks good", "should be easy", "should be straightforward",
2781 "i can implement", "i'll implement", "i will implement", "i'll get started",
2782 "let me ", "i will now", "i'll now", "i can do that",
2783 "计划看起来", "可以实现", "我会", "我将", "接下来我", "马上开始",
2784 }
2785
2786 var executorHandoffWorkRequestTerms = []string{
2787 "implement", "fix", "refactor", "migrate", "edit", "write", "create", "delete",
2788 "update", "remove", "add ", "test", "build", "repair", "patch",
2789 "修改", "修复", "实现", "新增", "重构", "迁移", "补齐", "更新", "删除", "移除",
2790 }
2791
2792 var executorHandoffTextOnlyTaskTerms = []string{
2793 "now what", "what next", "tl;dr", "tldr", "summarize", "summary", "explain",
2794 "i installed", "i just installed", "i turned on", "i enabled", "it's on", "it is on",
2795 "怎么办", "下一步", "然后呢", "总结", "解释", "说明", "装了", "装好了", "安装了", "开了", "开启了", "打开了",
2796 }
2797
2798 var executorHandoffLocalActionTerms = []string{
2799 "write_file", "read_file", "apply_patch", "bash",
2800 "workspace", "repo", "repository", "codebase", "file", "path",
2801 "write ", "edit ", "modify ", "create ", "delete ", "remove ", "update ", "add ", "patch ", "refactor ", "implement ",
2802 "run ", "command", "test", "build",
2803 "文件", "路径", "仓库", "代码", "写入", "编辑", "修改", "创建", "删除", "移除", "更新", "新增", "运行", "命令", "测试", "构建",
2804 }
2805
2806 var executorHandoffTextOnlyPlanTerms = []string{
2807 "tell the user", "ask the user", "guide the user", "explain to the user",
2808 "summarize", "summary", "tl;dr", "tldr", "answer the user", "respond to the user",
2809 "provide guidance", "walk the user", "instruct the user", "have the user",
2810 "user should", "the user should", "user can", "the user can", "manual", "manually",
2811 "no tools needed", "no tool calls needed", "does not need tools", "needs no tools",
2812 "listen", "play a song", "compare the difference", "checkbox",
2813 "告诉用户", "询问用户", "问用户", "让用户", "请用户", "指导用户", "解释", "总结", "回答",
2814 "手动", "无需工具", "不需要工具", "试听", "听歌", "对比", "勾选",
2815 }
2816
2817 func executorHandoffRetryMessage() string {
2818 return `You are already in the executor phase. The planner's read-only limitations do not apply to you.
2819
2820 The tool schema is still attached to this executor request. Do not invent that MCP servers or tools are unavailable; only report an unavailable tool after a real tool call or host error proves it.
2821
2822 Do not answer as the planner and do not ask how to trigger the executor.
2823 Use your available tools now to carry out the task. If carrying out the planner's instructions requires a user-owned choice or review, call the ask tool with concrete options and wait for its tool result; do not ask in prose, and do not claim the user answered unless an actual ask tool result or a new user message says so. If a write or command is blocked by permissions or workspace boundaries, state that specific blocker and ask for the needed approval/path.`
2824 }
2825
2826 func hasVisibleFinalAnswer(text string) bool {
2827 return strings.TrimSpace(text) != ""
2828 }
2829
2830 // reasoningOnlyFinishHonoured reports whether the model finished with a stop
2831 // signal but placed its answer in the reasoning stream rather than the content
2832 // block. DeepSeek thinking mode does this occasionally: it streams a long
2833 // reasoning_content, then returns finish_reason="stop" with an empty content.
2834 // The model has signalled completion, so the host accepts the turn instead of
2835 // retrying and forcing another expensive thinking round.
2836 //
2837 // The accept is scoped to DeepSeek thinking mode (ToolCallReasoningPolicy):
2838 // for other providers a reasoning-only turn keeps the empty-final retry
2839 // safety net — local <think>-tag models often recover a visible answer on
2840 // the second attempt, and a gateway that mislabels truncation as "stop"
2841 // must not have a degenerate turn committed as the final answer.
2842 func reasoningOnlyFinishHonoured(p provider.Provider, u *provider.Usage, reasoning string) bool {
2843 if !provider.RequiresToolCallReasoning(p) {
2844 return false
2845 }
2846 if u == nil || u.FinishReason != "stop" {
2847 return false
2848 }
2849 return strings.TrimSpace(reasoning) != ""
2850 }
2851
2852 func emptyFinalRetryMessage() string {
2853 return "The previous assistant response finished without any visible answer text. Continue the same task now and provide a concise visible answer to the user. Do not send reasoning only."
2854 }
2855
2856 func emptyFinalNotice() string {
2857 return "No visible answer was produced; asking the assistant to respond again."
2858 }
2859
2860 func emptyFinalNoticeDetail(prov string, u *provider.Usage, reasoningLen int) string {
2861 finish := "unknown"
2862 if u != nil && u.FinishReason != "" {
2863 finish = u.FinishReason
2864 }
2865 return fmt.Sprintf("empty final answer blocked: %s returned no visible answer text (finish=%s, reasoning=%d chars); retrying", prov, finish, reasoningLen)
2866 }
2867
2868 func executorHandoffNoticeText() string {
2869 return "The assistant answered before taking action; asking it to use the required tools."
2870 }
2871
2872 func toolBudgetNoticeText() string {
2873 return "Tool round limit reached; asking the assistant to summarize progress."
2874 }
2875
2876 // samplingRequest is a once-prepared, frozen provider request for one model
2877 // round. All stream retries replay this exact payload — no synthetic recovery
2878 // messages, no schema reorder, no previous_response_id drift from failed attempts.
2879 type samplingRequest struct {
2880 req provider.Request
2881 }
2882
2883 // prepareSamplingRequest runs interceptors and schema fetch once per model
2884 // round. Callers deep-copy via freezeProviderRequest before each Stream so
2885 // providers cannot mutate the shared freeze across retries.
2886 func (a *Agent) prepareSamplingRequest(ctx context.Context) (samplingRequest, error) {
2887 // CreatedAt is durable UI metadata, not model input. Strip it from the
2888 // transport copy so wall-clock differences never invalidate the provider's
2889 // prompt-cache prefix (and custom providers cannot accidentally send it).
2890 requestMessages := append([]provider.Message(nil), provider.ModelMessages(a.session.Messages)...)
2891 for i := range requestMessages {
2892 requestMessages[i].CreatedAt = 0
2893 }
2894 // context.prepare: extensions may rewrite the message copy feeding THIS
2895 // request. The session log is never touched — the replacement is
2896 // ephemeral, so the next request starts from the unmodified history and
2897 // the prompt-cache prefix stays intact across turns.
2898 requestMessages, err := a.interceptContextPrepare(ctx, requestMessages)
2899 if err != nil {
2900 return samplingRequest{}, err
2901 }
2902 req := provider.Request{
2903 Messages: requestMessages,
2904 Tools: a.tools.Schemas(),
2905 MaxTokens: a.maxOutputTokens,
2906 Temperature: provider.OptionalTemperature(a.temperature),
2907 ResponseFormat: responseFormatFromRequest(ctx),
2908 }
2909 // provider.request: the fully assembled request gets one last ruling
2910 // (revalidated by the payload registry) before it goes on the wire.
2911 req, err = a.interceptProviderRequest(ctx, req)
2912 if err != nil {
2913 return samplingRequest{}, err
2914 }
2915 return samplingRequest{req: freezeProviderRequest(req)}, nil
2916 }
2917
2918 // freezeProviderRequest deep-copies the provider-visible request surface so
2919 // retries share identical messages, tools order, temperature, and format.
2920 func freezeProviderRequest(req provider.Request) provider.Request {
2921 out := req
2922 if len(req.Messages) > 0 {
2923 out.Messages = append([]provider.Message(nil), req.Messages...)
2924 for i := range out.Messages {
2925 if len(out.Messages[i].ToolCalls) > 0 {
2926 out.Messages[i].ToolCalls = append([]provider.ToolCall(nil), out.Messages[i].ToolCalls...)
2927 }
2928 if len(out.Messages[i].Images) > 0 {
2929 out.Messages[i].Images = append([]string(nil), out.Messages[i].Images...)
2930 }
2931 if len(out.Messages[i].ResponsesItems) > 0 {
2932 items := make([]json.RawMessage, len(out.Messages[i].ResponsesItems))
2933 for j, item := range out.Messages[i].ResponsesItems {
2934 items[j] = append(json.RawMessage(nil), item...)
2935 }
2936 out.Messages[i].ResponsesItems = items
2937 }
2938 }
2939 }
2940 if len(req.Tools) > 0 {
2941 out.Tools = make([]provider.ToolSchema, len(req.Tools))
2942 for i, schema := range req.Tools {
2943 out.Tools[i] = schema
2944 if len(schema.Parameters) > 0 {
2945 out.Tools[i].Parameters = append(json.RawMessage(nil), schema.Parameters...)
2946 }
2947 }
2948 }
2949 if req.Temperature != nil {
2950 t := *req.Temperature
2951 out.Temperature = &t
2952 }
2953 if req.ResponseFormat != nil {
2954 rf := *req.ResponseFormat
2955 out.ResponseFormat = &rf
2956 }
2957 return out
2958 }
2959
2960 // stream runs one completion, emitting reasoning and text deltas as typed
2961 // events and collecting complete tool calls. A Message event closes the text
2962 // stream so a sink can re-render the streamed raw text as styled markdown. The
2963 // accumulated text and reasoning are also returned so the caller can round-trip
2964 // reasoning on the next turn.
2965 //
2966 // When frozen is non-nil, the request is not rebuilt from session — retries
2967 // must replay the same provider-visible body.
2968 func (a *Agent) stream(ctx context.Context, turn int, sink event.Sink) (string, string, string, string, string, []provider.ToolCall, []json.RawMessage, *provider.Usage, bool, bool, []provider.ToolCall, int, error) {
2969 return a.streamWithFrozen(ctx, turn, sink, nil, "")
2970 }
2971
2972 func (a *Agent) streamWithFrozen(ctx context.Context, turn int, sink event.Sink, frozen *samplingRequest, attemptID string) (string, string, string, string, string, []provider.ToolCall, []json.RawMessage, *provider.Usage, bool, bool, []provider.ToolCall, int, error) {
2973 ctx = provider.WithRetryNotify(ctx, func(info provider.RetryInfo) {
2974 sink.Emit(event.Event{Kind: event.Retrying, RetryAttempt: info.Attempt, RetryMax: info.Max, RetryScope: event.RetryScopeHeaders})
2975 })
2976 // Reuse a parent attempt counter when present so stream retries accumulate
2977 // into one RequestCount; otherwise install a fresh counter for this call.
2978 ctx = provider.WithRequestAttemptCounter(ctx)
2979 // A stream can terminate locally before the provider channel closes (for
2980 // example when the client-side reasoning guard fires). Own a child context
2981 // here so every return path aborts the HTTP request and releases the provider
2982 // reader instead of leaving generation and billing running in the background.
2983 ctx, cancel := context.WithCancel(ctx)
2984 defer cancel()
2985
2986 var req provider.Request
2987 var err error
2988 if frozen != nil {
2989 req = freezeProviderRequest(frozen.req)
2990 } else {
2991 prepared, perr := a.prepareSamplingRequest(ctx)
2992 if perr != nil {
2993 return "", "", "", "", "", nil, nil, nil, false, false, nil, 0, perr
2994 }
2995 req = prepared.req
2996 }
2997 // After #7725 Goal token request admission was removed, stream goes
2998 // directly to the provider. Provider-visible cache controls stay stable
2999 // across retries and request timing because they are derived from req alone.
3000 ch, err := a.prov.Stream(ctx, req)
3001 if err != nil {
3002 return "", "", "", "", "", nil, nil, provider.UsageWithRequestAttemptCount(ctx, nil), false, false, nil, 0, err
3003 }
3004
3005 // A PostLLMCall hook rewrites the whole reasoning block, so when one is wired
3006 // up we buffer reasoning silently and emit the transformed text once after the
3007 // stream. With no such hook the reasoning streams live, chunk by chunk, as
3008 // before — the common case must not lose its live "thinking…" display.
3009 transformReasoning := a.hooks != nil && a.hooks.HasPostLLMCall()
3010
3011 var text, reasoning strings.Builder
3012 var signature string // provider-issued proof for the reasoning (Anthropic thinking)
3013 var reasoningID, reasoningStatus string // Responses reasoning item id/status (meta chunk)
3014 var calls []provider.ToolCall
3015 var responsesItems []json.RawMessage
3016 var partialCalls []provider.ToolCall
3017 var usage *provider.Usage
3018 var partialToolStarted bool
3019 var maxArgChars int
3020 var lastArgProgress time.Time
3021 finishReasoning := func() (stored, display string) {
3022 original := reasoning.String()
3023 display = original
3024 if transformReasoning && original != "" {
3025 display = a.hooks.PostLLMCall(ctx, original, turn)
3026 if display != "" {
3027 sink.Emit(event.Event{Kind: event.Reasoning, Text: display})
3028 }
3029 }
3030 stored = display
3031 providerBound := signature != "" || reasoningID != "" || reasoningStatus != ""
3032 if providerBound || provider.RequiresReasoningRoundTrip(a.prov) || (len(calls) > 0 && provider.RequiresToolCallReasoning(a.prov)) {
3033 stored = original
3034 }
3035 return stored, display
3036 }
3037 for {
3038 var chunk provider.Chunk
3039 select {
3040 case <-ctx.Done():
3041 stored, _ := finishReasoning()
3042 usage = bestEffortStreamUsage(usage, text.Len(), reasoning.Len(), "interrupted")
3043 usage = provider.UsageWithRequestAttemptCount(ctx, usage)
3044 return text.String(), stored, signature, reasoningID, reasoningStatus, calls, responsesItems, usage, false, partialToolStarted, partialCalls, maxArgChars, ctx.Err()
3045 case c, ok := <-ch:
3046 if !ok {
3047 if err := ctx.Err(); err != nil {
3048 stored, _ := finishReasoning()
3049 usage = bestEffortStreamUsage(usage, text.Len(), reasoning.Len(), "interrupted")
3050 usage = provider.UsageWithRequestAttemptCount(ctx, usage)
3051 return text.String(), stored, signature, reasoningID, reasoningStatus, calls, responsesItems, usage, false, partialToolStarted, partialCalls, maxArgChars, err
3052 }
3053 stored, display := finishReasoning()
3054 // provider.response: extensions rule on the assembled terminal
3055 // response before it is persisted. A replacement becomes the
3056 // visible assistant turn (the user's transcript); a block fails
3057 // the turn.
3058 providerSignature := signature
3059 finalText, finalReasoning, signature, calls, usage, err := a.interceptProviderResponse(
3060 ctx, text.String(), stored, signature, calls, usage)
3061 if err != nil {
3062 return "", "", "", "", "", nil, nil, nil, false, partialToolStarted, partialCalls, maxArgChars, err
3063 }
3064 // Responses reasoning IDs/status and Anthropic signatures are
3065 // provider-bound metadata. Never attach the provider's metadata
3066 // to reasoning that an extension replaced.
3067 if finalReasoning != stored || signature != providerSignature {
3068 reasoningID, reasoningStatus = "", ""
3069 }
3070 if finalReasoning != stored {
3071 // The extension replaced the reasoning: what is persisted
3072 // and what the closing Message event re-renders must agree.
3073 display = finalReasoning
3074 }
3075 if finalText != "" || display != "" {
3076 sink.Emit(event.Event{
3077 Kind: event.Message,
3078 Text: DisplayAssistantText(finalText),
3079 Reasoning: display,
3080 })
3081 }
3082 usage = provider.UsageWithRequestAttemptCount(ctx, usage)
3083 return finalText, finalReasoning, signature, reasoningID, reasoningStatus, calls, responsesItems, usage, false, false, partialCalls, maxArgChars, nil
3084 }
3085 chunk = c
3086 }
3087 switch chunk.Type {
3088 case provider.ChunkReasoning:
3089 reasoning.WriteString(chunk.Text)
3090 if chunk.Signature != "" {
3091 signature = chunk.Signature
3092 }
3093 // 元数据 chunk(空 Text):reasoning item id/status 贯通
3094 // SSE → session → 下一轮回传(评审 #7234 第 1 点)。
3095 if chunk.ReasoningID != "" {
3096 reasoningID = chunk.ReasoningID
3097 }
3098 if chunk.ReasoningStatus != "" {
3099 reasoningStatus = chunk.ReasoningStatus
3100 }
3101 if chunk.Text != "" && !transformReasoning {
3102 sink.Emit(event.Event{Kind: event.Reasoning, Text: chunk.Text})
3103 }
3104 if a.reasoningByteLimit > 0 && reasoning.Len() > a.reasoningByteLimit {
3105 stored, _ := finishReasoning()
3106 usage = bestEffortStreamUsage(usage, text.Len(), reasoning.Len(), finishReasonClientReasoningLimit)
3107 usage = provider.UsageWithRequestAttemptCount(ctx, usage)
3108 a.lastUsage.Store(usage)
3109 return text.String(), stored, signature, reasoningID, reasoningStatus, calls, responsesItems, usage, false, partialToolStarted, partialCalls, maxArgChars, errReasoningByteLimitExceeded
3110 }
3111 case provider.ChunkText:
3112 text.WriteString(chunk.Text)
3113 sink.Emit(event.Event{Kind: event.Text, Text: chunk.Text})
3114 case provider.ChunkToolCallStart:
3115 partialToolStarted = true
3116 // Surface the tool card as soon as the call begins — before its
3117 // (possibly large) arguments finish streaming — so the user sees it
3118 // working instead of a stall. executeBatch emits the full dispatch
3119 // (with args) once the call completes; the frontend merges by ID.
3120 if tc := chunk.ToolCall; tc != nil {
3121 partialCalls = upsertPartialToolCall(partialCalls, *tc)
3122 sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{
3123 ID: tc.ID, Name: tc.Name, ReadOnly: a.toolReadOnly(tc.Name), Partial: true, AttemptID: attemptID,
3124 }})
3125 }
3126 case provider.ChunkToolCallArgsDelta:
3127 partialToolStarted = true
3128 // Liveness ticks while a large argument payload streams: re-emit the
3129 // partial dispatch with the cumulative size (time-throttled) so the
3130 // UI can show progress instead of a dead counter for the duration of
3131 // a 30KB write_file body.
3132 if chunk.ArgChars > maxArgChars {
3133 maxArgChars = chunk.ArgChars
3134 }
3135 if tc := chunk.ToolCall; tc != nil && time.Since(lastArgProgress) >= 250*time.Millisecond {
3136 partialCalls = upsertPartialToolCall(partialCalls, *tc)
3137 lastArgProgress = time.Now()
3138 sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{
3139 ID: tc.ID, Name: tc.Name, ReadOnly: a.toolReadOnly(tc.Name), Partial: true, ArgChars: chunk.ArgChars, AttemptID: attemptID,
3140 }})
3141 }
3142 case provider.ChunkToolCall:
3143 partialToolStarted = true
3144 if chunk.ToolCall != nil {
3145 calls = append(calls, *chunk.ToolCall)
3146 partialCalls = upsertPartialToolCall(partialCalls, *chunk.ToolCall)
3147 if n := len(chunk.ToolCall.Arguments); n > maxArgChars {
3148 maxArgChars = n
3149 }
3150 }
3151 case provider.ChunkResponsesItem:
3152 if len(chunk.ResponsesItem) > 0 {
3153 responsesItems = append(responsesItems, append(json.RawMessage(nil), chunk.ResponsesItem...))
3154 }
3155 case provider.ChunkUsage:
3156 usage = chunk.Usage
3157 a.lastUsage.Store(chunk.Usage)
3158 a.sessCacheHit.Add(int64(chunk.Usage.CacheHitTokens))
3159 a.sessCacheMiss.Add(int64(chunk.Usage.CacheMissTokens))
3160 case provider.ChunkError:
3161 if provider.IsStreamInterrupted(chunk.Err) {
3162 stored, _ := finishReasoning()
3163 usage = bestEffortStreamUsage(usage, text.Len(), reasoning.Len(), "interrupted")
3164 usage = provider.UsageWithRequestAttemptCount(ctx, usage)
3165 return text.String(), stored, signature, reasoningID, reasoningStatus, calls, responsesItems, usage, true, partialToolStarted, partialCalls, maxArgChars, chunk.Err
3166 }
3167 stored, _ := finishReasoning()
3168 if errors.Is(chunk.Err, context.Canceled) || errors.Is(chunk.Err, context.DeadlineExceeded) {
3169 usage = bestEffortStreamUsage(usage, text.Len(), reasoning.Len(), "interrupted")
3170 }
3171 usage = provider.UsageWithRequestAttemptCount(ctx, usage)
3172 return text.String(), stored, signature, reasoningID, reasoningStatus, calls, responsesItems, usage, false, partialToolStarted, partialCalls, maxArgChars, chunk.Err
3173 }
3174 }
3175 }
3176
3177 func bestEffortStreamUsage(current *provider.Usage, textBytes, reasoningBytes int, finishReason string) *provider.Usage {
3178 if current == nil && textBytes == 0 && reasoningBytes == 0 {
3179 return nil
3180 }
3181 var usage provider.Usage
3182 if current != nil {
3183 usage = *current
3184 }
3185 if finishReason != "" {
3186 usage.FinishReason = finishReason
3187 }
3188 reasoningTokens := estimateTokensFromBytes(reasoningBytes)
3189 textTokens := estimateTokensFromBytes(textBytes)
3190 completionTokens := reasoningTokens + textTokens
3191 if usage.ReasoningTokens < reasoningTokens {
3192 usage.ReasoningTokens = reasoningTokens
3193 usage.Estimated = true
3194 }
3195 if usage.CompletionTokens < completionTokens {
3196 usage.CompletionTokens = completionTokens
3197 usage.Estimated = true
3198 }
3199 if minTotal := usage.PromptTokens + usage.CompletionTokens; usage.TotalTokens < minTotal {
3200 usage.TotalTokens = minTotal
3201 usage.Estimated = true
3202 }
3203 return &usage
3204 }
3205
3206 func estimateTokensFromBytes(n int) int {
3207 if n <= 0 {
3208 return 0
3209 }
3210 tokens := n / 4
3211 if n%4 != 0 {
3212 tokens++
3213 }
3214 if tokens <= 0 {
3215 return 1
3216 }
3217 return tokens
3218 }
3219
3220 func upsertPartialToolCall(calls []provider.ToolCall, call provider.ToolCall) []provider.ToolCall {
3221 for i := range calls {
3222 if call.ID != "" && calls[i].ID == call.ID {
3223 calls[i] = call
3224 return calls
3225 }
3226 }
3227 return append(calls, call)
3228 }
3229
3230 func (a *Agent) recordInterruptedDisplay(text, reasoning string, calls []provider.ToolCall, pending bool, workDurationMs int64) {
3231 displayCalls := make([]provider.ToolCall, 0, len(calls))
3232 interrupted := make([]string, 0, len(calls))
3233 seen := make(map[string]struct{}, len(calls))
3234 for _, call := range calls {
3235 name := strings.TrimSpace(call.Name)
3236 key := call.ID + "\x00" + name
3237 if _, ok := seen[key]; ok {
3238 continue
3239 }
3240 seen[key] = struct{}{}
3241 displayCalls = append(displayCalls, provider.ToolCall{ID: call.ID, Name: name})
3242 if name != "" {
3243 interrupted = append(interrupted, name)
3244 }
3245 }
3246 a.session.Add(provider.Message{
3247 Role: provider.RoleTool,
3248 Content: text,
3249 ReasoningContent: reasoning,
3250 ToolCalls: displayCalls,
3251 ToolCallID: provider.LocalOnlyToolID,
3252 Name: provider.LocalOnlyToolName,
3253 WorkDurationMs: workDurationMs,
3254 LocalOnly: true,
3255 InterruptedTurn: &provider.InterruptedTurnRecovery{
3256 Pending: pending,
3257 InterruptedTools: interrupted,
3258 DroppedPartialText: strings.TrimSpace(text) != "",
3259 DroppedPartialReasoning: strings.TrimSpace(reasoning) != "",
3260 },
3261 })
3262 }
3263
3264 func (a *Agent) capturePrefixShape(schemas []provider.ToolSchema) PrefixShape {
3265 return CaptureShape(a.systemPrompt(), schemas, a.session.RewriteVersion())
3266 }
3267
3268 func (a *Agent) systemPrompt() string {
3269 var b strings.Builder
3270 for _, m := range a.session.Messages {
3271 if m.Role != provider.RoleSystem {
3272 continue
3273 }
3274 if b.Len() > 0 {
3275 b.WriteByte('\n')
3276 }
3277 b.WriteString(m.Content)
3278 }
3279 return b.String()
3280 }
3281
3282 // batchExecution is the result of one provider tool-call batch.
3283 type batchExecution struct {
3284 results []string
3285 images [][]string
3286 executions []*tool.ShellExecution
3287 recoveryStopTurn bool
3288 recoveryStopReason string
3289 }
3290
3291 // executeBatch dispatches one model turn's tool calls. A ToolDispatch event is
3292 // emitted for every call up front, in call order, so a frontend can show the
3293 // timeline chronologically. Contiguous known ReadOnly calls fan out across
3294 // goroutines; unknown and writer calls run as single-call serial segments so
3295 // write/read ordering stays provider-ordered. ToolResult events are emitted
3296 // after the batch in call order, so emission stays serial even when execution
3297 // parallelised. Images are aligned by index with results.
3298 func (a *Agent) executeBatch(ctx context.Context, calls []provider.ToolCall) batchExecution {
3299 // The assistant message already stored this slice in Session. Keep execution
3300 // state separate so refreshing a dependent preview never mutates shared
3301 // session memory outside Session's lock.
3302 calls = append([]provider.ToolCall(nil), calls...)
3303 for _, c := range calls {
3304 a.emitFullToolDispatch(c, false)
3305 }
3306
3307 results := make([]string, len(calls))
3308 outcomes := make([]toolOutcome, len(calls))
3309 durations := make([]int64, len(calls))
3310 completedStepInBatch := false
3311 // Snapshot the receipt count before the batch runs: if a loop guard fires
3312 // for this batch, successes recorded during it (a mixed batch where only one
3313 // call was guard-blocked) must already count as progress against the pass.
3314 receiptMark := 0
3315 if a.evidence != nil {
3316 receiptMark = a.evidence.Len()
3317 }
3318 // Full dispatches are prepared against the batch's initial file state. After
3319 // one writer runs, a dependent later writer may only become previewable (or
3320 // its original preview may become stale). Refresh even after a failed writer:
3321 // commands and filesystem calls can mutate disk before reporting an error.
3322 // The first writer stays on the single-preview fast path.
3323 earlierWriterRan := false
3324 surfaceWriters := make([]bool, len(calls))
3325 run := func(i int) {
3326 t, _, ambiguous := a.tools.ResolveCall(calls[i].Name)
3327 known := t != nil && len(ambiguous) == 0
3328 writer := known && !t.ReadOnly()
3329 surfaceWriters[i] = writer
3330 if earlierWriterRan && writer {
3331 if refreshed, changed := refreshCurrentFileDiff(t, calls[i]); changed {
3332 calls[i] = refreshed
3333 a.session.UpdateToolCallPreview(refreshed)
3334 a.emitFullToolDispatch(refreshed, true)
3335 }
3336 }
3337 start := time.Now()
3338 if calls[i].Name == "complete_step" && completedStepInBatch {
3339 output := "blocked: only one successful complete_step is allowed per tool-call round. Continue from the newly promoted in_progress todo in the next round instead of batching sign-offs."
3340 outcomes[i] = toolOutcome{output: output, blocked: true, errMsg: "blocked: complete_step sign-offs must be serial"}
3341 if a.evidence != nil {
3342 a.evidence.Record(evidence.ReceiptFromToolCall(calls[i].Name, json.RawMessage(calls[i].Arguments), false, true))
3343 }
3344 durations[i] = time.Since(start).Milliseconds()
3345 results[i] = output
3346 return
3347 }
3348 outcomes[i] = a.executeOne(ctx, calls[i])
3349 if outcomes[i].resolved {
3350 readOnly := outcomes[i].resolvedReadOnly
3351 calls[i].ResolvedName = outcomes[i].resolvedName
3352 calls[i].CapabilityID = outcomes[i].capabilityID
3353 calls[i].ResolvedReadOnly = &readOnly
3354 surfaceWriters[i] = !readOnly
3355 }
3356 if calls[i].Name == "complete_step" && outcomes[i].errMsg == "" {
3357 completedStepInBatch = true
3358 }
3359 durations[i] = time.Since(start).Milliseconds()
3360 results[i] = outcomes[i].output
3361 }
3362 finalize := func(i int) {
3363 if calls[i].ResolvedReadOnly != nil {
3364 a.session.UpdateToolCallResolution(calls[i])
3365 a.emitResolvedToolDispatch(calls[i])
3366 }
3367 if surfaceWriters[i] || (outcomes[i].resolved && !outcomes[i].resolvedReadOnly) {
3368 earlierWriterRan = true
3369 }
3370 }
3371 cancelled := false
3372 markCancelled := func(start int) {
3373 errMsg := context.Canceled.Error()
3374 if err := ctx.Err(); err != nil {
3375 errMsg = err.Error()
3376 }
3377 output := "cancelled: context cancelled before execution"
3378 for j := start; j < len(calls); j++ {
3379 results[j] = output
3380 outcomes[j] = toolOutcome{output: output, errMsg: errMsg}
3381 }
3382 cancelled = true
3383 }
3384
3385 // recoveryBatchStop blocks remaining tools after Episode budgets are
3386 // exhausted so tool-call / result pairs stay complete for the provider.
3387 recoveryBatchStop := false
3388 recoveryStopReason := ""
3389 markRecoveryStopped := func(start int, reason string) {
3390 msg := "blocked: Auto recovery paused this turn; do not call more tools. Summarize completed work for the user."
3391 for j := start; j < len(calls); j++ {
3392 if results[j] != "" {
3393 continue
3394 }
3395 results[j] = msg
3396 outcomes[j] = toolOutcome{
3397 output: msg,
3398 blocked: true,
3399 errMsg: firstLine(msg),
3400 recoveryStopTurn: true,
3401 recoveryStopReason: reason,
3402 }
3403 }
3404 recoveryBatchStop = true
3405 if reason != "" {
3406 recoveryStopReason = reason
3407 }
3408 }
3409
3410 // mutationBatchStop is the deterministic dependency barrier: after any
3411 // mutating call fails or is blocked, later mutating and verification calls
3412 // in the same provider batch are skipped (not_run/dependency). Host-proven
3413 // read-only diagnosis may still run. executeOne also re-checks after proxy
3414 // resolution so use_capability cannot bypass this pass.
3415 mutationBatchStop := false
3416 a.mutationDependencyBarrier.Store(false)
3417 markDependencySkipped := func(start int) {
3418 a.mutationDependencyBarrier.Store(true)
3419 for j := start; j < len(calls); j++ {
3420 if results[j] != "" {
3421 continue
3422 }
3423 // Pre-classify when statically certain. Proxies and ambiguous
3424 // targets fall through to run() so executeOne can resolve the real
3425 // target and re-apply the barrier before Commit/Execute.
3426 if !batchCallStaticallySkippable(a, calls[j]) {
3427 continue
3428 }
3429 isVerification := calls[j].Name == "bash" && evidence.IsDeliveryVerificationCommand(bashCommandFromArgs(json.RawMessage(calls[j].Arguments)))
3430 msg := "blocked: skipped because an earlier modification in this tool batch failed or was blocked. " +
3431 "Fix or re-run the failed change first; verification was not executed."
3432 var ex *tool.ShellExecution
3433 if calls[j].Name == "bash" {
3434 ex = &tool.ShellExecution{
3435 Kind: "shell",
3436 State: tool.ShellStateNotRun,
3437 FailurePhase: tool.ShellPhaseDependency,
3438 MutationRisk: tool.ShellMutationNotStarted,
3439 Verification: tool.ShellVerificationNotVerification,
3440 }
3441 if isVerification {
3442 ex.Verification = tool.ShellVerificationNotRun
3443 }
3444 if t, _, amb := a.tools.ResolveCall(calls[j].Name); t != nil && len(amb) == 0 {
3445 if bt, ok := t.(tool.DetailedExecutor); ok {
3446 if desc := bt.ExecutionDescriptor(json.RawMessage(calls[j].Arguments)); desc != nil {
3447 ex.Shell = desc.Shell
3448 ex.ShellVersion = desc.ShellVersion
3449 ex.Platform = desc.Platform
3450 ex.SupportsAndAnd = desc.SupportsAndAnd
3451 }
3452 }
3453 }
3454 }
3455 results[j] = msg
3456 outcomes[j] = toolOutcome{
3457 output: msg,
3458 blocked: true,
3459 errMsg: firstLine(msg),
3460 execution: ex,
3461 }
3462 durations[j] = 0
3463 }
3464 mutationBatchStop = true
3465 }
3466
3467 for _, batch := range partitionToolCalls(a.tools, calls) {
3468 if ctx.Err() != nil {
3469 markCancelled(batch.start)
3470 break
3471 }
3472 if recoveryBatchStop {
3473 markRecoveryStopped(batch.start, recoveryStopReason)
3474 break
3475 }
3476 if batch.parallel && batch.end-batch.start > 1 {
3477 // Parallel segments are read-only by construction; no mutation barrier.
3478 ranUntil := runParallel(ctx, batch.start, batch.end, run)
3479 for i := batch.start; i < ranUntil; i++ {
3480 finalize(i)
3481 }
3482 // After parallel execution completes, check if context was cancelled.
3483 // The individual tool executions should have detected ctx.Done(), but
3484 // we verify here to ensure we don't continue to subsequent batches.
3485 if ctx.Err() != nil {
3486 markCancelled(ranUntil)
3487 break
3488 }
3489 for i := batch.start; i < batch.end; i++ {
3490 if outcomes[i].recoveryStopTurn {
3491 recoveryBatchStop = true
3492 recoveryStopReason = outcomes[i].recoveryStopReason
3493 markRecoveryStopped(batch.end, recoveryStopReason)
3494 break
3495 }
3496 }
3497 if recoveryBatchStop {
3498 break
3499 }
3500 continue
3501 }
3502 for i := batch.start; i < batch.end; i++ {
3503 // Before executing the next tool, check if context was cancelled.
3504 // This prevents starting new tools when a previous tool's execution
3505 // triggered cancellation.
3506 if ctx.Err() != nil {
3507 markCancelled(i)
3508 break
3509 }
3510 if recoveryBatchStop {
3511 markRecoveryStopped(i, recoveryStopReason)
3512 break
3513 }
3514 if mutationBatchStop {
3515 // Fill dependency skips for remaining mutating/verify calls, then
3516 // allow any residual read-only diagnosis to run individually.
3517 if results[i] != "" {
3518 continue
3519 }
3520 t, _, ambiguous := a.tools.ResolveCall(calls[i].Name)
3521 known := t != nil && len(ambiguous) == 0
3522 readOnly := known && t.ReadOnly()
3523 if calls[i].Name == "bash" && permission.BashCommandIsReadOnly(json.RawMessage(calls[i].Arguments)) {
3524 readOnly = true
3525 }
3526 isVerification := calls[i].Name == "bash" && evidence.IsDeliveryVerificationCommand(bashCommandFromArgs(json.RawMessage(calls[i].Arguments)))
3527 mutates := evidence.ToolCallMutates(calls[i].Name, json.RawMessage(calls[i].Arguments), readOnly)
3528 if mutates || isVerification {
3529 markDependencySkipped(i)
3530 // markDependencySkipped fills this index; move on.
3531 if results[i] != "" {
3532 continue
3533 }
3534 }
3535 }
3536 if results[i] != "" {
3537 // Pre-filled dependency skip.
3538 finalize(i)
3539 continue
3540 }
3541 run(i)
3542 finalize(i)
3543 if outcomes[i].recoveryStopTurn {
3544 recoveryBatchStop = true
3545 recoveryStopReason = outcomes[i].recoveryStopReason
3546 markRecoveryStopped(i+1, recoveryStopReason)
3547 break
3548 }
3549 // Mutation/verification failure barrier for the rest of this batch.
3550 if batchCallIsMutatingFailure(a, calls[i], outcomes[i]) {
3551 mutationBatchStop = true
3552 markDependencySkipped(i + 1)
3553 }
3554 // After each tool execution, also check if the context was cancelled.
3555 // If so, stop executing remaining tools and return immediately so
3556 // the agent loop can detect the cancellation and exit.
3557 if ctx.Err() != nil {
3558 markCancelled(i + 1)
3559 break
3560 }
3561 }
3562 if cancelled || recoveryBatchStop {
3563 break
3564 }
3565 }
3566
3567 for i, c := range calls {
3568 o := outcomes[i]
3569 t, _, ambiguous := a.tools.ResolveCall(c.Name)
3570 ok := t != nil && len(ambiguous) == 0
3571 readOnly := ok && t.ReadOnly()
3572 if c.ResolvedReadOnly != nil {
3573 readOnly = *c.ResolvedReadOnly
3574 }
3575 tr := event.Tool{
3576 ID: c.ID,
3577 Name: c.Name,
3578 Args: c.Arguments,
3579 ResolvedName: c.ResolvedName,
3580 CapabilityID: c.CapabilityID,
3581 Output: o.output,
3582 Err: o.errMsg,
3583 ReadOnly: readOnly,
3584 Truncated: o.truncated,
3585 DurationMs: durations[i],
3586 Execution: toEventShellExecution(o.execution, durations[i]),
3587 }
3588 a.sink.Emit(event.Event{Kind: event.ToolResult, Tool: tr})
3589 if o.truncated && o.truncMsg != "" {
3590 a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: o.truncMsg})
3591 }
3592 }
3593 if !cancelled {
3594 a.applyStormBreaker(calls, outcomes, results, receiptMark)
3595 }
3596 images := make([][]string, len(calls))
3597 executions := make([]*tool.ShellExecution, len(calls))
3598 for i := range outcomes {
3599 images[i] = outcomes[i].images
3600 executions[i] = outcomes[i].execution
3601 if outcomes[i].recoveryStopTurn {
3602 recoveryBatchStop = true
3603 if outcomes[i].recoveryStopReason != "" {
3604 recoveryStopReason = outcomes[i].recoveryStopReason
3605 }
3606 }
3607 }
3608 return batchExecution{
3609 results: results,
3610 images: images,
3611 executions: executions,
3612 recoveryStopTurn: recoveryBatchStop,
3613 recoveryStopReason: recoveryStopReason,
3614 }
3615 }
3616
3617 func toEventShellExecution(in *tool.ShellExecution, durationMs int64) *event.ShellExecution {
3618 if in == nil {
3619 return nil
3620 }
3621 out := &event.ShellExecution{
3622 Kind: in.Kind,
3623 Shell: in.Shell,
3624 ShellVersion: in.ShellVersion,
3625 Platform: in.Platform,
3626 SupportsAndAnd: in.SupportsAndAnd,
3627 State: in.State,
3628 FailurePhase: in.FailurePhase,
3629 OutputTail: in.OutputTail,
3630 MutationRisk: in.MutationRisk,
3631 Verification: in.Verification,
3632 DurationMs: in.DurationMs,
3633 }
3634 if out.DurationMs == 0 && durationMs > 0 {
3635 out.DurationMs = durationMs
3636 }
3637 if in.ExitCode != nil {
3638 code := *in.ExitCode
3639 out.ExitCode = &code
3640 }
3641 return out
3642 }
3643
3644 func toProviderToolExecution(in *tool.ShellExecution) *provider.ToolExecution {
3645 if in == nil {
3646 return nil
3647 }
3648 out := &provider.ToolExecution{
3649 Kind: in.Kind,
3650 Shell: in.Shell,
3651 ShellVersion: in.ShellVersion,
3652 Platform: in.Platform,
3653 SupportsAndAnd: in.SupportsAndAnd,
3654 State: in.State,
3655 FailurePhase: in.FailurePhase,
3656 OutputTail: in.OutputTail,
3657 MutationRisk: in.MutationRisk,
3658 Verification: in.Verification,
3659 DurationMs: in.DurationMs,
3660 }
3661 if in.ExitCode != nil {
3662 code := *in.ExitCode
3663 out.ExitCode = &code
3664 }
3665 return out
3666 }
3667
3668 // batchCallIsMutatingFailure reports whether a finished call was a mutation
3669 // (file write / non-readonly bash mutation) that failed or was blocked, so later
3670 // mutations and verifications in the same batch must not run.
3671 func batchCallIsMutatingFailure(a *Agent, call provider.ToolCall, o toolOutcome) bool {
3672 if o.errMsg == "" && !o.blocked {
3673 return false
3674 }
3675 readOnly := false
3676 t, _, ambiguous := a.tools.ResolveCall(call.Name)
3677 known := t != nil && len(ambiguous) == 0
3678 if known {
3679 readOnly = t.ReadOnly()
3680 }
3681 if call.ResolvedReadOnly != nil {
3682 readOnly = *call.ResolvedReadOnly
3683 }
3684 if o.resolved {
3685 readOnly = o.resolvedReadOnly
3686 }
3687 if call.Name == "bash" && permission.BashCommandIsReadOnly(json.RawMessage(call.Arguments)) {
3688 readOnly = true
3689 }
3690 // Verification failures do not open the dependency barrier by themselves —
3691 // only a failed modification does.
3692 if call.Name == "bash" && evidence.IsDeliveryVerificationCommand(bashCommandFromArgs(json.RawMessage(call.Arguments))) {
3693 return false
3694 }
3695 // Resolved writers (including MCP targets behind use_capability) count even
3696 // when the provider-visible proxy advertised ReadOnly.
3697 if o.resolved && !o.resolvedReadOnly {
3698 return true
3699 }
3700 if evidence.ToolCallMutates(call.Name, json.RawMessage(call.Arguments), readOnly) {
3701 return true
3702 }
3703 // Fail closed only for a target the host could not classify at all. A blanket
3704 // !readOnly fallback here would re-admit exactly the writers ToolCallMutates
3705 // deliberately exempts (todo_write, complete_step, ask, bash_output, wait and
3706 // the other non-mutation meta tools): a failed todo update would then block
3707 // every real edit left in the batch. Resolved writer proxies already returned
3708 // true above, so narrowing this does not reopen the use_capability path.
3709 return !known
3710 }
3711
3712 // batchCallStaticallySkippable reports whether a remaining call can be marked
3713 // not_run/dependency without resolving a proxy. Proxies and unknown tools
3714 // return false so executeOne can resolve the real target first.
3715 func batchCallStaticallySkippable(a *Agent, call provider.ToolCall) bool {
3716 t, _, ambiguous := a.tools.ResolveCall(call.Name)
3717 if t == nil || len(ambiguous) > 0 {
3718 // Unknown / ambiguous: fail closed via executeOne path.
3719 return false
3720 }
3721 // Proxy resolution may consult a live connected capability and its result
3722 // can change between calls. Do not resolve here merely to pre-fill a skip:
3723 // executeOne resolves exactly once, then applyMutationDependencyBarrier
3724 // classifies the real target before Commit or Execute.
3725 if _, ok := t.(tool.CallResolver); ok {
3726 return false
3727 }
3728 readOnly := t.ReadOnly()
3729 if call.Name == "bash" && permission.BashCommandIsReadOnly(json.RawMessage(call.Arguments)) {
3730 readOnly = true
3731 }
3732 isVerification := call.Name == "bash" && evidence.IsDeliveryVerificationCommand(bashCommandFromArgs(json.RawMessage(call.Arguments)))
3733 if isVerification {
3734 return true
3735 }
3736 return !readOnly || evidence.ToolCallMutates(call.Name, json.RawMessage(call.Arguments), readOnly)
3737 }
3738
3739 func (a *Agent) emitFullToolDispatch(c provider.ToolCall, refreshed bool) {
3740 t, _, ambiguous := a.tools.ResolveCall(c.Name)
3741 ok := t != nil && len(ambiguous) == 0
3742 ev := event.Tool{ID: c.ID, Name: c.Name, Args: c.Arguments, ReadOnly: ok && t.ReadOnly(), Refreshed: refreshed}
3743 ev.FileDiff = event.FileDiff{Diff: c.Diff, Added: c.Added, Removed: c.Removed}
3744 if ok && ev.Diff == "" && ev.Added == 0 && ev.Removed == 0 {
3745 if ch, ok := tool.PreviewChange(t, json.RawMessage(c.Arguments)); ok {
3746 ev.FileDiff = event.FileDiff{Diff: ch.Diff, Added: ch.Added, Removed: ch.Removed}
3747 }
3748 }
3749 if ok {
3750 if pr, ok := t.(interface {
3751 ResolveProfile(json.RawMessage) *event.Profile
3752 }); ok {
3753 ev.Profile = pr.ResolveProfile(json.RawMessage(c.Arguments))
3754 }
3755 }
3756 a.sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: ev})
3757 }
3758
3759 // emitResolvedToolDispatch upserts the real target classification of a stable
3760 // proxy call without changing the provider-visible Name/Args. Append-only sinks
3761 // ignore Refreshed events; stateful frontends replace the existing card by ID.
3762 func (a *Agent) emitResolvedToolDispatch(c provider.ToolCall) {
3763 if c.ResolvedReadOnly == nil {
3764 return
3765 }
3766 if c.ResolvedName != "" && c.ResolvedName != c.Name {
3767 EmitProxyAudit(a.sink, tool.ResolvedCall{
3768 DisplayName: c.Name,
3769 TargetName: c.ResolvedName,
3770 CapabilityID: c.CapabilityID,
3771 })
3772 }
3773 a.sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{
3774 ID: c.ID,
3775 Name: c.Name,
3776 Args: c.Arguments,
3777 ResolvedName: c.ResolvedName,
3778 CapabilityID: c.CapabilityID,
3779 ReadOnly: *c.ResolvedReadOnly,
3780 Refreshed: true,
3781 FileDiff: event.FileDiff{
3782 Diff: c.Diff, Added: c.Added, Removed: c.Removed,
3783 },
3784 }})
3785 }
3786
3787 // refreshCurrentFileDiff recomputes a writer preview against the state left by
3788 // earlier successful writers in the same provider batch. Preview failures clear
3789 // any stale initial diff; a later Execute will then fail or ask for recovery
3790 // without presenting the user with a preview that no longer describes disk.
3791 func refreshCurrentFileDiff(t tool.Tool, call provider.ToolCall) (provider.ToolCall, bool) {
3792 pv, ok := t.(tool.Previewer)
3793 if !ok {
3794 return call, false
3795 }
3796 refreshed := call
3797 refreshed.Diff = ""
3798 refreshed.Added = 0
3799 refreshed.Removed = 0
3800 if change, err := pv.Preview(json.RawMessage(call.Arguments)); err == nil {
3801 refreshed.Diff = change.Diff
3802 refreshed.Added = change.Added
3803 refreshed.Removed = change.Removed
3804 }
3805 changed := refreshed.Diff != call.Diff || refreshed.Added != call.Added || refreshed.Removed != call.Removed
3806 return refreshed, changed
3807 }
3808
3809 func (a *Agent) withPreviewFileDiffs(calls []provider.ToolCall) []provider.ToolCall {
3810 if len(calls) == 0 {
3811 return calls
3812 }
3813 out := make([]provider.ToolCall, len(calls))
3814 copy(out, calls)
3815 for i := range out {
3816 if out[i].Diff != "" || out[i].Added != 0 || out[i].Removed != 0 {
3817 continue
3818 }
3819 t, _, ambiguous := a.tools.ResolveCall(out[i].Name)
3820 ok := t != nil && len(ambiguous) == 0
3821 if !ok {
3822 continue
3823 }
3824 if ch, ok := tool.PreviewChange(t, json.RawMessage(out[i].Arguments)); ok {
3825 out[i].Diff = ch.Diff
3826 out[i].Added = ch.Added
3827 out[i].Removed = ch.Removed
3828 }
3829 }
3830 return out
3831 }
3832
3833 type toolCallBatch struct {
3834 start int
3835 end int
3836 parallel bool
3837 }
3838
3839 // partitionToolCalls keeps provider order while letting contiguous known
3840 // read-only tools run together. Unknown and writer tools are single-call serial
3841 // batches so they cannot reorder around reads or produce surprising errors.
3842 // complete_step and todo_write read the turn's evidence ledger. wait and
3843 // bash_output can merge a background task's receipts into that ledger. These
3844 // evidence-sensitive tools never join a parallel run, so provider order stays
3845 // receipt order. use_capability is always serial because its provider-visible
3846 // read-only surface can resolve to a real MCP writer only inside executeOne;
3847 // batching it as a reader would let multiple database/API mutations race.
3848 func partitionToolCalls(r *tool.Registry, calls []provider.ToolCall) []toolCallBatch {
3849 var batches []toolCallBatch
3850 for i := 0; i < len(calls); {
3851 if parallelisable(r, calls[i].Name) {
3852 start := i
3853 i++
3854 for i < len(calls) && parallelisable(r, calls[i].Name) {
3855 i++
3856 }
3857 batches = append(batches, toolCallBatch{start: start, end: i, parallel: true})
3858 continue
3859 }
3860 batches = append(batches, toolCallBatch{start: i, end: i + 1})
3861 i++
3862 }
3863 return batches
3864 }
3865
3866 func parallelisable(r *tool.Registry, name string) bool {
3867 switch name {
3868 case "complete_step", "todo_write", "wait", "bash_output", "use_capability":
3869 return false
3870 }
3871 t, _, ambiguous := r.ResolveCall(name)
3872 return t != nil && len(ambiguous) == 0 && t.ReadOnly()
3873 }
3874
3875 func runParallel(ctx context.Context, start, end int, run func(int)) int {
3876 const maxParallel = 8
3877 sem := make(chan struct{}, maxParallel)
3878 var wg sync.WaitGroup
3879 ranUntil := start
3880 launch:
3881 for i := start; i < end; i++ {
3882 if ctx.Err() != nil {
3883 break
3884 }
3885 select {
3886 case sem <- struct{}{}:
3887 case <-ctx.Done():
3888 break launch
3889 }
3890 if ctx.Err() != nil {
3891 <-sem
3892 break
3893 }
3894 i := i
3895 wg.Add(1)
3896 ranUntil = i + 1
3897 go func() {
3898 defer wg.Done()
3899 defer func() { <-sem }()
3900 run(i)
3901 }()
3902 }
3903 wg.Wait()
3904 return ranUntil
3905 }
3906
3907 // stormBreakThreshold is how many times in a row the same tool may fail the same
3908 // way before the loop stops echoing the raw error back and instead returns a
3909 // directive to change approach. Two natural self-corrections are healthy; the
3910 // third identical failure is a death-spiral — the dominant case being a tool call
3911 // whose arguments are truncated at the output-token ceiling, which the model then
3912 // re-emits (re-worded but still over-long), truncating the same way again.
3913 const stormBreakThreshold = 3
3914
3915 // repeatSuccessBreakThreshold is how many identical write-like successes the
3916 // agent allows before refusing another copy in the same user turn. Two gives the
3917 // model room for a natural self-correction; the third repeat is usually a
3918 // no-op/write loop and should be redirected to a different tool or final answer.
3919 const repeatSuccessBreakThreshold = 2
3920
3921 const (
3922 // todoProgressNudgeRounds is the first adaptive checkpoint. The host asks
3923 // the model to reassess, but keeps the turn alive so it can recover.
3924 todoProgressNudgeRounds = 8
3925 // maxTodoStallRounds pauses only after the reassessment also failed to
3926 // produce a new completion or unique host-observed work receipt.
3927 maxTodoStallRounds = 16
3928 )
3929
3930 func todoProgressNudgeMessage(rounds int) string {
3931 return fmt.Sprintf("Host progress check: the current todo has produced no new completion, unique read, command, or mutation for %d tool-call rounds. Reassess before using more tools: sign off the current item if it is done, narrow the remaining work without replacing the active item, or explain/ask about a real blocker. Do not repeat reads, commands, or writes just to reset this guard.", rounds)
3932 }
3933
3934 // loopGuardBlockErrMsg is the errMsg carried by a repeat-success loop-guard
3935 // block. applyStormBreaker matches it to arm the final-readiness loop-guard
3936 // pass, since that guard also invites the model to report the blocker.
3937 const loopGuardBlockErrMsg = "blocked by loop guard"
3938
3939 // applyStormBreaker detects a run of zero-progress turns and, past the
3940 // threshold, rewrites the model-facing result (results[0]) into a directive to
3941 // change approach. Two detectors, because a stuck model varies its retries two
3942 // ways. The signature detector keys on each call's (tool, error/blocker) — not
3943 // its args — since a stuck model reworks the arguments cosmetically while
3944 // hitting the same host refusal or failure (see the stormSig field doc). The
3945 // streak detector counts consecutive turns in which every call was blocked,
3946 // regardless of shape: rotating tools, reordering a batch, or a blocker whose
3947 // text varies per attempt escapes the signature but is still zero progress —
3948 // only a host refusal (not a plain error) proves that, so the streak requires
3949 // blocked outcomes. Any success resets both. When a guard fires — or when a
3950 // call in the batch was already blocked by the per-call repeat-success guard —
3951 // the final-readiness loop-guard pass is armed so the model may report the
3952 // blocker (see loopGuardAllowsFinal). The hard maxSteps guard remains the
3953 // ultimate backstop; this just keeps the loop from burning that whole budget
3954 // bouncing off the same host refusals.
3955 func (a *Agent) applyStormBreaker(calls []provider.ToolCall, outcomes []toolOutcome, results []string, receiptMark int) {
3956 allBlocked := len(outcomes) > 0
3957 for _, outcome := range outcomes {
3958 if !outcome.blocked {
3959 allBlocked = false
3960 break
3961 }
3962 }
3963 if allBlocked {
3964 a.blockedTurnStreak++
3965 } else {
3966 a.blockedTurnStreak = 0
3967 }
3968 for _, outcome := range outcomes {
3969 if outcome.blocked && outcome.errMsg == loopGuardBlockErrMsg {
3970 a.armLoopGuardPass(receiptMark)
3971 break
3972 }
3973 }
3974
3975 sig, ok := batchStormSignature(calls, outcomes)
3976 switch {
3977 case !ok:
3978 a.stormSig, a.stormCount = "", 0
3979 case sig != a.stormSig:
3980 a.stormSig, a.stormCount = sig, 1
3981 default:
3982 a.stormCount++
3983 }
3984 stormHit := ok && a.stormCount >= stormBreakThreshold
3985 streakHit := allBlocked && a.blockedTurnStreak >= stormBreakThreshold
3986 if !stormHit && !streakHit {
3987 return
3988 }
3989
3990 const blockedAdvice = "Change approach: do not keep retrying a blocked tool by changing the tool, command, or arguments. Respect the permission, plan-mode, hook, or loop-guard blocker; use an already-allowed tool, ask the user for the specific approval or choice if appropriate, or explain the blocker in your final answer."
3991 var guard, detail string
3992 if stormHit {
3993 subject := fmt.Sprintf("%q", calls[0].Name)
3994 short := calls[0].Name
3995 if len(calls) > 1 {
3996 subject = fmt.Sprintf("this batch of %d tool calls", len(calls))
3997 short = fmt.Sprintf("a batch of %d calls", len(calls))
3998 }
3999 anyBlocked := false
4000 for _, outcome := range outcomes {
4001 if outcome.blocked {
4002 anyBlocked = true
4003 break
4004 }
4005 }
4006 action := "failed"
4007 advice := "Change approach: if an argument is being truncated, write less in one call and split the work into several smaller calls; otherwise fix the arguments, use a different tool, or explain the blocker in your final answer."
4008 if anyBlocked {
4009 action = "been blocked or failed"
4010 advice = blockedAdvice
4011 }
4012 guard = fmt.Sprintf(
4013 "[loop guard] %s has now %s %d times in a row with the same host response. Re-sending it — even with the wording changed — will not help: the calls keep hitting the same outcome. %s",
4014 subject, action, a.stormCount, advice)
4015 detail = fmt.Sprintf(
4016 "loop guard: %s hit the same host response %d× — nudging the model to change approach",
4017 short, a.stormCount)
4018 } else {
4019 guard = fmt.Sprintf(
4020 "[loop guard] every tool call in the last %d turns has been blocked by the host (permission, plan mode, hook, or loop guard). Switching tools, reordering calls, or rewording arguments will not help while the blockers stand. %s",
4021 a.blockedTurnStreak, blockedAdvice)
4022 detail = fmt.Sprintf(
4023 "loop guard: every tool call blocked %d turns in a row — nudging the model to change approach",
4024 a.blockedTurnStreak)
4025 }
4026 results[0] = outcomes[0].output + "\n\n" + guard
4027 a.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Code: event.NoticeCodeLoopGuard, Text: loopGuardNoticeText(), Detail: detail})
4028 a.armLoopGuardPass(receiptMark)
4029 }
4030
4031 func loopGuardNoticeText() string {
4032 return "The assistant is not making progress; asking it to change approach."
4033 }
4034
4035 // batchStormSignature returns a per-turn fixation signature — each call's
4036 // (name, error/blocker) in order — and ok=true only when every call errored or
4037 // was blocked. ok=false (any success) means the turn made progress, so the
4038 // caller resets the counter. Keying on the host response rather than the args is
4039 // deliberate: a stuck model reworks the arguments while hitting the same
4040 // response, so identical-args matching would miss the loop.
4041 func batchStormSignature(calls []provider.ToolCall, outcomes []toolOutcome) (string, bool) {
4042 if len(calls) == 0 {
4043 return "", false
4044 }
4045 var sb strings.Builder
4046 for i := range calls {
4047 if outcomes[i].errMsg == "" {
4048 return "", false
4049 }
4050 sb.WriteString(calls[i].Name)
4051 sb.WriteByte(0)
4052 sb.WriteString(outcomes[i].errMsg)
4053 sb.WriteByte(0)
4054 }
4055 return sb.String(), true
4056 }
4057
4058 // toolOutcome is one tool call's result, split into the model-facing output and
4059 // the display-facing notice bits. errMsg is the short failure reason (empty on
4060 // success) — a refused call, an unknown tool, or an execution error — so a sink
4061 // renders the result as failed ("⊘ name <errMsg>" / a red card) instead of OK;
4062 // blocked narrows that to a refusal (plan mode / permission). truncMsg is set
4063 // (without the "· " prefix) when the output was head+tailed. images carries
4064 // data URLs from a tool.ImageTool result; they ride outside output so text
4065 // truncation can never corrupt an image payload.
4066 type toolOutcome struct {
4067 output string
4068 images []string
4069 blocked bool
4070 errMsg string
4071 truncated bool
4072 truncMsg string
4073 resolved bool
4074 resolvedName string
4075 capabilityID string
4076 resolvedReadOnly bool
4077 // execution is local shell metadata (optional). Provider messages strip it
4078 // via ModelMessages; UI/event sinks surface it on ToolResult cards.
4079 execution *tool.ShellExecution
4080 // recoveryGeneration is the gate generation captured before execution so
4081 // ObserveResult can ignore stale results after a mode switch.
4082 recoveryGeneration uint64
4083 // recoveryStopTurn is set when Auto Episode budgets are exhausted.
4084 recoveryStopTurn bool
4085 recoveryStopReason string
4086 }
4087
4088 // completedMCPConnect recognizes a synthetic cache-miss connect call whose
4089 // background discovery finished after the provider request was serialized. The
4090 // connect placeholder is intentionally absent once real tools replace it, but
4091 // the already-advertised call still completed its only job and must not surface
4092 // as an unknown tool.
4093 func completedMCPConnect(reg *tool.Registry, name string) (string, bool) {
4094 server, rawName, ok := tool.SplitMCPName(name)
4095 if !ok || rawName != "connect" {
4096 return "", false
4097 }
4098 prefix := tool.MCPNamePrefix + server + "__"
4099 for _, current := range reg.Names() {
4100 if current != name && strings.HasPrefix(current, prefix) {
4101 return server, true
4102 }
4103 }
4104 return "", false
4105 }
4106
4107 // recoveryPlanTransition detects structural rewrites of an active canonical
4108 // task list. Initial plans and progress-only status updates stay on the fast
4109 // path; changing step identity, order, or hierarchy while work remains is a
4110 // semantic transition for the independent Auto reviewer.
4111 func (a *Agent) recoveryPlanTransition(toolName string, args json.RawMessage) (bool, string, string) {
4112 if a == nil || toolName != "todo_write" || a.planMode.Load() {
4113 return false, "", ""
4114 }
4115 before := a.CanonicalTodoState()
4116 if len(before) == 0 || len(evidence.IncompleteTodos(before)) == 0 {
4117 return false, "", ""
4118 }
4119 after := evidence.ReceiptFromToolCall("todo_write", args, true, true).Todos
4120 if len(after) == 0 || evidence.ValidateSerialTodos(after) != nil || !evidence.PreservesCompletedTodoPositions(before, after) {
4121 // Let todo_write report malformed or invalid state directly; an invalid
4122 // task list is not a meaningful plan proposal for the reviewer.
4123 return false, "", ""
4124 }
4125 if samePlanStructure(before, after) {
4126 return false, "", ""
4127 }
4128 return true, planReviewText(before), planReviewText(after)
4129 }
4130
4131 func samePlanStructure(a, b []evidence.TodoItem) bool {
4132 if len(a) != len(b) {
4133 return false
4134 }
4135 for i := range a {
4136 if a[i].Level != b[i].Level || normalizePlanStep(a[i].Content) != normalizePlanStep(b[i].Content) {
4137 return false
4138 }
4139 }
4140 return true
4141 }
4142
4143 func normalizePlanStep(s string) string {
4144 return strings.Join(strings.Fields(strings.TrimSpace(s)), " ")
4145 }
4146
4147 func planReviewText(todos []evidence.TodoItem) string {
4148 var b strings.Builder
4149 for i, todo := range todos {
4150 indent := ""
4151 if todo.Level == 1 {
4152 indent = " "
4153 }
4154 fmt.Fprintf(&b, "%s%d. %s [%s]", indent, i+1, normalizePlanStep(todo.Content), canonicalTodoStatus(todo.Status))
4155 if i+1 < len(todos) {
4156 b.WriteByte('\n')
4157 }
4158 }
4159 return b.String()
4160 }
4161
4162 func recoveryTaskScopeID(deliveryScopeID string, runSeq uint64) string {
4163 if scope := strings.TrimSpace(deliveryScopeID); scope != "" {
4164 return "goal:" + scope
4165 }
4166 return fmt.Sprintf("turn:%d", runSeq)
4167 }
4168
4169 func (a *Agent) readOnlyExecutionBlock(visible tool.Tool, resolved *tool.ResolvedCall) (toolOutcome, bool) {
4170 if a == nil || !a.readOnlyExecution {
4171 return toolOutcome{}, false
4172 }
4173 block := func(reason string) (toolOutcome, bool) {
4174 return toolOutcome{
4175 output: "blocked: read-only agent cannot " + reason,
4176 blocked: true,
4177 errMsg: "blocked by read-only execution boundary",
4178 }, true
4179 }
4180 // Destructive MCP is left for the Executor; Planner must not misread this
4181 // as missing configuration or an unavailable MCP server.
4182 blockDestructiveForExecutor := func(name string) (toolOutcome, bool) {
4183 msg := "blocked: MCP capability " + name + " is destructive and is reserved for the Executor. Write the required operation into the plan/handoff so the Coordinator can hand it to the Executor; do not treat this as missing MCP configuration or an unavailable capability."
4184 return toolOutcome{
4185 output: msg,
4186 blocked: true,
4187 errMsg: "blocked: destructive MCP reserved for executor",
4188 }, true
4189 }
4190 if resolved == nil {
4191 if a.plannerMCPExecution && isMCPExecutionTarget(visible, "") {
4192 if !mcpServerAuthorized(visible) {
4193 return block("execute an MCP capability from an unauthorized server")
4194 }
4195 if readOnlyExecutionMCPDestructive(visible) {
4196 return blockDestructiveForExecutor(visible.Name())
4197 }
4198 return toolOutcome{}, false
4199 }
4200 if visible == nil || !visible.ReadOnly() {
4201 if reasoner, ok := visible.(tool.ReadOnlyExecutionBlockReason); ok && strings.TrimSpace(reasoner.ReadOnlyExecutionBlockReason()) != "" {
4202 return block(reasoner.ReadOnlyExecutionBlockReason())
4203 }
4204 return block("execute a state-changing tool")
4205 }
4206 if isInstalledMCPTool(visible) && !mcpServerAuthorized(visible) {
4207 return block("execute a reader from an unauthorized MCP server")
4208 }
4209 if readOnlyExecutionMCPDestructive(visible) {
4210 return block("execute a destructive MCP capability")
4211 }
4212 if h, ok := visible.(tool.ReadOnlyExecutionHostMutation); ok && h.ReadOnlyExecutionHostMutation() && !readOnlyExecutionAllowsMCPStartup(visible) {
4213 return block("start or mutate a host capability")
4214 }
4215 return toolOutcome{}, false
4216 }
4217
4218 switch resolved.ProxyAction {
4219 case "list", "inspect":
4220 if !resolved.SkipExecute || resolved.Target != nil || !resolved.ReadOnly {
4221 return block("execute a malformed dynamic inspection")
4222 }
4223 return toolOutcome{}, false
4224 case "decline":
4225 return block("decline a capability decision")
4226 case "call":
4227 if resolved.Target == nil {
4228 if a.plannerMCPExecution && resolved.HostCompleted && resolved.SkipExecute && resolved.ReadOnly && !resolved.Unavailable {
4229 if _, ok := parseMCPServerCapabilityID(resolved.CapabilityID); ok {
4230 return toolOutcome{}, false
4231 }
4232 }
4233 return block("execute an unresolved dynamic capability")
4234 }
4235 if a.plannerMCPExecution && plannerAllowsMCPTarget(resolved.Target, resolved.TargetName) {
4236 if isMCPLifecycleConnectTarget(resolved.Target) {
4237 if !plannerMCPConnectAllowed(resolved.Target) {
4238 return block("start an unauthorized MCP server")
4239 }
4240 } else if !mcpServerAuthorized(resolved.Target) {
4241 return block("execute an MCP capability from an unauthorized server")
4242 }
4243 if readOnlyExecutionMCPDestructive(resolved.Target) {
4244 name := resolved.TargetName
4245 if name == "" {
4246 name = resolved.CapabilityID
4247 }
4248 return blockDestructiveForExecutor(name)
4249 }
4250 return toolOutcome{}, false
4251 }
4252 if !resolved.ReadOnly {
4253 if reasoner, ok := resolved.Target.(tool.ReadOnlyExecutionBlockReason); ok && strings.TrimSpace(reasoner.ReadOnlyExecutionBlockReason()) != "" {
4254 return block(reasoner.ReadOnlyExecutionBlockReason())
4255 }
4256 return block("execute a state-changing dynamic capability")
4257 }
4258 if isInstalledMCPTool(resolved.Target) && !mcpServerAuthorized(resolved.Target) {
4259 return block("execute a dynamic reader from an unauthorized MCP server")
4260 }
4261 if readOnlyExecutionMCPDestructive(resolved.Target) {
4262 return block("execute a destructive MCP capability")
4263 }
4264 if h, ok := resolved.Target.(tool.ReadOnlyExecutionHostMutation); ok && h.ReadOnlyExecutionHostMutation() && !readOnlyExecutionAllowsMCPStartup(resolved.Target) {
4265 return block("start or mutate a host capability")
4266 }
4267 return toolOutcome{}, false
4268 default:
4269 return block("execute an unknown dynamic capability action")
4270 }
4271 }
4272
4273 func readOnlyExecutionMCPDestructive(t tool.Tool) bool {
4274 return mcpDestructiveHint(t)
4275 }
4276
4277 func readOnlyExecutionAllowsMCPStartup(t tool.Tool) bool {
4278 if t == nil || !t.ReadOnly() || readOnlyExecutionMCPDestructive(t) {
4279 return false
4280 }
4281 if !mcpServerAuthorized(t) {
4282 return false
4283 }
4284 meta, ok := t.(tool.MCPMetadata)
4285 if !ok || strings.TrimSpace(meta.MCPServerName()) == "" || strings.TrimSpace(meta.MCPRawToolName()) == "" {
4286 return false
4287 }
4288 return true
4289 }
4290
4291 // plannerAllowsMCPTarget reports whether a resolved use_capability target is an
4292 // MCP tool or lifecycle connect that Planner may consider under
4293 // PlannerMCPExecution (authorization and destructive checks run separately).
4294 func plannerAllowsMCPTarget(t tool.Tool, targetName string) bool {
4295 if t == nil {
4296 return false
4297 }
4298 if isInstalledMCPTool(t) || isMCPLifecycleConnectTarget(t) {
4299 return true
4300 }
4301 return isMCPExecutionTarget(t, targetName)
4302 }
4303
4304 // isMCPLifecycleConnectTarget identifies on-demand MCP connect-and-list targets
4305 // (mcp_connect__<server>) used by use_capability action=call on mcp-server ids.
4306 func isMCPLifecycleConnectTarget(t tool.Tool) bool {
4307 if t == nil {
4308 return false
4309 }
4310 if _, ok := t.(mcpLifecycleConnect); ok {
4311 return true
4312 }
4313 name := strings.TrimSpace(t.Name())
4314 return strings.HasPrefix(name, "mcp_connect__")
4315 }
4316
4317 // mcpLifecycleConnect is implemented by deferred connect targets so Planner
4318 // can authorize lifecycle actions without relying on name prefixes alone.
4319 type mcpLifecycleConnect interface {
4320 MCPLifecycleConnect() bool
4321 MCPServerAuthorized() bool
4322 }
4323
4324 func plannerMCPConnectAllowed(t tool.Tool) bool {
4325 if life, ok := t.(mcpLifecycleConnect); ok {
4326 return life.MCPServerAuthorized()
4327 }
4328 return mcpServerAuthorized(t)
4329 }
4330
4331 func isInstalledMCPTool(t tool.Tool) bool {
4332 meta, ok := t.(tool.MCPMetadata)
4333 return ok && strings.TrimSpace(meta.MCPServerName()) != "" && strings.TrimSpace(meta.MCPRawToolName()) != ""
4334 }
4335
4336 func isMCPExecutionTarget(t tool.Tool, name string) bool {
4337 return isInstalledMCPTool(t) || strings.HasPrefix(strings.TrimSpace(name), "mcp__")
4338 }
4339
4340 func mcpServerAuthorized(t tool.Tool) bool {
4341 authority, ok := t.(tool.MCPServerAuthorization)
4342 return ok && authority.MCPServerAuthorized()
4343 }
4344
4345 func mcpDestructiveHint(t tool.Tool) bool {
4346 annotations, ok := t.(tool.MCPAnnotations)
4347 return ok && annotations.MCPDestructiveHint()
4348 }
4349
4350 func (a *Agent) planModeDecision(toolName string, readOnly bool, safety planmode.PlanSafety, args json.RawMessage) planmode.Decision {
4351 return (planmode.Policy{}).Decide(planmode.Call{
4352 Name: toolName,
4353 ReadOnly: readOnly,
4354 Safety: safety,
4355 Args: args,
4356 })
4357 }
4358
4359 func (a *Agent) repeatedSuccessBlock(call provider.ToolCall, t tool.Tool) (string, bool) {
4360 sig, ok := repeatSuccessSignature(call, t)
4361 if !ok || a.repeatSuccessCounts == nil {
4362 return "", false
4363 }
4364 count := a.repeatSuccessCounts[sig]
4365 if count < repeatSuccessBreakThreshold {
4366 return "", false
4367 }
4368 return fmt.Sprintf(
4369 "blocked: [loop guard] %q has already succeeded %d times with the same write-like arguments in this user turn. Re-running it is unlikely to help and may burn tokens or repeat file writes. Change approach: use edit_file or multi_edit for file changes, verify with a read/test command, or explain the blocker in your final answer.",
4370 call.Name, count), true
4371 }
4372
4373 func (a *Agent) staleAnchorEditBlock(call provider.ToolCall) (string, bool) {
4374 if a.evidence == nil || !anchorBasedEditTool(call.Name) {
4375 return "", false
4376 }
4377 rec := evidence.ReceiptFromToolCall(call.Name, json.RawMessage(call.Arguments), true, false)
4378 if len(rec.Paths) == 0 {
4379 return "", false
4380 }
4381 writeIndex, ok := a.evidence.LatestSuccessfulWriteIndex(rec.Paths)
4382 if !ok || a.evidence.HasSuccessfulAnchorRefreshReadAfter(rec.Paths, writeIndex) {
4383 return "", false
4384 }
4385 return fmt.Sprintf(
4386 "blocked: [fresh read required] %q targets %s, which was already modified earlier this turn. Re-read the current file with read_file without offset/limit before another range deletion, or use multi_edit with exact replacements when possible. This prevents stale start/end anchors from selecting an unintended destructive span.",
4387 call.Name, strings.Join(rec.Paths, ", ")), true
4388 }
4389
4390 func anchorBasedEditTool(name string) bool {
4391 switch name {
4392 // edit_file synchronously reads the current file, requires a unique exact
4393 // or narrowly fuzzy match, and returns the actual applied diff. Let it try
4394 // optimistically; a stale old_string fails without writing and tells the
4395 // model to re-read. delete_range remains guarded because two independently
4396 // resolved anchors can otherwise select an unintended destructive span.
4397 case "delete_range":
4398 return true
4399 default:
4400 return false
4401 }
4402 }
4403
4404 func (a *Agent) recordRepeatSuccess(call provider.ToolCall, t tool.Tool) {
4405 sig, ok := repeatSuccessSignature(call, t)
4406 if !ok {
4407 return
4408 }
4409 if a.repeatSuccessCounts == nil {
4410 a.repeatSuccessCounts = make(map[string]int)
4411 }
4412 a.repeatSuccessCounts[sig]++
4413 }
4414
4415 func repeatSuccessSignature(call provider.ToolCall, t tool.Tool) (string, bool) {
4416 if t.ReadOnly() {
4417 return "", false
4418 }
4419 switch call.Name {
4420 case "write_file", "edit_file", "multi_edit", "move_file", "notebook_edit":
4421 return call.Name + "\x00" + canonicalToolArgs(call.Arguments), true
4422 case "bash":
4423 var p struct {
4424 Command string `json:"command"`
4425 RunInBackground bool `json:"run_in_background"`
4426 }
4427 if err := json.Unmarshal([]byte(call.Arguments), &p); err != nil {
4428 return "", false
4429 }
4430 if p.RunInBackground || !isShellFileWriteCommand(p.Command) {
4431 return "", false
4432 }
4433 return "bash\x00" + normalizeShellCommand(p.Command), true
4434 default:
4435 return "", false
4436 }
4437 }
4438
4439 func canonicalToolArgs(raw string) string {
4440 var v any
4441 if err := json.Unmarshal([]byte(raw), &v); err != nil {
4442 return strings.TrimSpace(raw)
4443 }
4444 b, err := json.Marshal(v)
4445 if err != nil {
4446 return strings.TrimSpace(raw)
4447 }
4448 var compact bytes.Buffer
4449 if err := json.Compact(&compact, b); err != nil {
4450 return string(b)
4451 }
4452 return compact.String()
4453 }
4454
4455 func normalizeShellCommand(command string) string {
4456 if fields, malformed := shellparse.StaticFields(command); malformed == "" && len(fields) > 0 {
4457 return strings.Join(fields, " ")
4458 }
4459 return strings.Join(strings.Fields(command), " ")
4460 }
4461
4462 func isShellFileWriteCommand(command string) bool {
4463 lower := strings.ToLower(command)
4464 switch {
4465 case shellPythonOpenWrites(lower):
4466 return true
4467 case strings.Contains(lower, "set-content") || strings.Contains(lower, "add-content") || strings.Contains(lower, "out-file"):
4468 return true
4469 case strings.Contains(lower, "sed -i") || strings.Contains(lower, "perl -pi"):
4470 return true
4471 case hasShellWriteRedirect(command):
4472 return true
4473 default:
4474 return false
4475 }
4476 }
4477
4478 func shellPythonOpenWrites(lower string) bool {
4479 if !strings.Contains(lower, "open(") {
4480 return false
4481 }
4482 if strings.Contains(lower, ".write(") {
4483 return true
4484 }
4485 for _, marker := range []string{", 'w", `, "w`, ", 'a", `, "a`, ", 'x", `, "x`, "mode='w", `mode="w`, "mode='a", `mode="a`, "mode='x", `mode="x`} {
4486 if strings.Contains(lower, marker) {
4487 return true
4488 }
4489 }
4490 return false
4491 }
4492
4493 func hasShellWriteRedirect(command string) bool {
4494 file, err := shellparse.ParseBash(command)
4495 if err == nil {
4496 hasWrite := false
4497 syntax.Walk(file, func(node syntax.Node) bool {
4498 redir, ok := node.(*syntax.Redirect)
4499 if !ok {
4500 return true
4501 }
4502 if bashRedirectWritesFile(command, redir) {
4503 hasWrite = true
4504 return false
4505 }
4506 return true
4507 })
4508 return hasWrite
4509 }
4510 return hasShellWriteRedirectFallback(command)
4511 }
4512
4513 func bashRedirectWritesFile(source string, redir *syntax.Redirect) bool {
4514 if redir == nil {
4515 return false
4516 }
4517 switch redir.Op {
4518 case syntax.RdrOut, syntax.AppOut, syntax.RdrClob, syntax.AppClob,
4519 syntax.RdrAll, syntax.RdrAllClob, syntax.AppAll, syntax.AppAllClob,
4520 syntax.RdrInOut:
4521 return !redirectWordIsNullSink(source, redir.Word)
4522 default:
4523 return false
4524 }
4525 }
4526
4527 func redirectWordIsNullSink(source string, word *syntax.Word) bool {
4528 if word == nil {
4529 return false
4530 }
4531 if value, ok := shellparse.StaticWord(word); ok {
4532 if isNullSinkWord(strings.TrimSpace(value)) {
4533 return true
4534 }
4535 }
4536 value := strings.TrimSpace(redirectWordSource(source, word))
4537 if isNullSinkWord(value) {
4538 return true
4539 }
4540 if len(value) >= 2 && ((value[0] == '\'' && value[len(value)-1] == '\'') || (value[0] == '"' && value[len(value)-1] == '"')) {
4541 return isNullSinkWord(value[1 : len(value)-1])
4542 }
4543 return false
4544 }
4545
4546 func isNullSinkWord(value string) bool {
4547 if value == "/dev/null" {
4548 return true
4549 }
4550 return strings.EqualFold(value, "$null") || strings.EqualFold(value, "nul")
4551 }
4552
4553 func redirectWordSource(source string, word *syntax.Word) string {
4554 if word == nil || !word.Pos().IsValid() || !word.End().IsValid() {
4555 return ""
4556 }
4557 start := int(word.Pos().Offset())
4558 end := int(word.End().Offset())
4559 if start < 0 || end < start || end > len(source) {
4560 return ""
4561 }
4562 return source[start:end]
4563 }
4564
4565 func hasShellWriteRedirectFallback(command string) bool {
4566 var quote rune
4567 var prev rune
4568 for _, r := range command {
4569 if quote != 0 {
4570 if r == quote {
4571 quote = 0
4572 }
4573 prev = r
4574 continue
4575 }
4576 if r == '\'' || r == '"' {
4577 quote = r
4578 prev = r
4579 continue
4580 }
4581 if r == '>' {
4582 if prev == '2' {
4583 prev = r
4584 continue
4585 }
4586 return true
4587 }
4588 prev = r
4589 }
4590 return false
4591 }
4592
4593 // isBackgroundTaskCall reports whether a `task` call set run_in_background, so a
4594 // fire-and-return dispatch isn't mistaken for a sub-agent that has stopped.
4595 func isBackgroundTaskCall(args string) bool {
4596 var p struct {
4597 RunInBackground bool `json:"run_in_background"`
4598 }
4599 _ = json.Unmarshal([]byte(args), &p)
4600 return p.RunInBackground
4601 }
4602
4603 // toolReadOnly reports a tool's ReadOnly classification by name (false for an
4604 // unknown tool), for stamping early ToolDispatch events.
4605 func (a *Agent) toolReadOnly(name string) bool {
4606 t, _, ambiguous := a.tools.ResolveCall(name)
4607 return t != nil && len(ambiguous) == 0 && t.ReadOnly()
4608 }
4609
4610 // firstLine returns s up to its first newline — a one-line failure summary for
4611 // the display Err, while the full error stays in the model-facing output.
4612 func firstLine(s string) string {
4613 if i := strings.IndexByte(s, '\n'); i >= 0 {
4614 return s[:i]
4615 }
4616 return s
4617 }
4618
4619 // truncateToolOutput head+tails s when it exceeds maxToolOutputBytes, slicing
4620 // on rune boundaries so we never split a multibyte glyph. Returns the possibly
4621 // trimmed body plus a one-line user-facing notice when truncation happened
4622 // (empty when it didn't, without the "· " display prefix).
4623 func truncateToolOutput(s string) (string, string) {
4624 if len(s) <= maxToolOutputBytes {
4625 return s, ""
4626 }
4627 keep := maxToolOutputBytes / 2
4628 head := snapToRuneBoundary(s, 0, keep)
4629 tail := snapToRuneBoundary(s, len(s)-keep, len(s))
4630 omitted := len(s) - len(head) - len(tail)
4631 notice := fmt.Sprintf("tool output truncated: %d of %d bytes elided", omitted, len(s))
4632 body := head + fmt.Sprintf("\n\n…[truncated %d of %d bytes — rerun with narrower args to see the middle]…\n\n", omitted, len(s)) + tail
4633 return body, notice
4634 }
4635
4636 // snapToRuneBoundary returns s[lo:hi] with the bounds nudged outward until
4637 // both land on rune-start positions.
4638 func snapToRuneBoundary(s string, lo, hi int) string {
4639 for lo > 0 && !utf8.RuneStart(s[lo]) {
4640 lo--
4641 }
4642 for hi < len(s) && !utf8.RuneStart(s[hi]) {
4643 hi++
4644 }
4645 return s[lo:hi]
4646 }
4647
4648 // finishReasonMessage maps an abnormal finish_reason to a one-line warning,
4649 // returning ok=false for the normal terminations ("stop", "tool_calls") and a
4650 // nil usage. The sink renders the message; the "! " prefix is presentation.
4651 func finishReasonMessage(u *provider.Usage) (string, bool) {
4652 if u == nil {
4653 return "", false
4654 }
4655 switch u.FinishReason {
4656 case "length":
4657 return "response truncated: hit max output tokens", true
4658 case finishReasonClientReasoningLimit:
4659 return "response stopped: hit the client reasoning safety limit", true
4660 case "content_filter":
4661 return "response blocked by content filter", true
4662 case "repetition_truncation":
4663 return "response truncated: model repetition detected", true
4664 default:
4665 return "", false
4666 }
4667 }
4668
4668 lines GO