返回 DeepSeek-Reasonix
agent.go
根目录 / internal / agent / agent.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "strings"
9 "sync"
10 "sync/atomic"
11 "time"
12
13 "reasonix/internal/ablation"
14 "reasonix/internal/capability"
15 "reasonix/internal/checkpoint"
16 "reasonix/internal/diff"
17 "reasonix/internal/event"
18 "reasonix/internal/evidence"
19 "reasonix/internal/extension/dispatch"
20 "reasonix/internal/fileops"
21 "reasonix/internal/i18n"
22 "reasonix/internal/imageinput"
23 "reasonix/internal/instruction"
24 "reasonix/internal/jobs"
25 "reasonix/internal/mcpinteraction"
26 "reasonix/internal/memory"
27 "reasonix/internal/nilutil"
28 "reasonix/internal/plancontract"
29 "reasonix/internal/planmode"
30 "reasonix/internal/provider"
31 "reasonix/internal/runtimepolicy"
32 "reasonix/internal/sandbox"
33 "reasonix/internal/sessiontemp"
34 "reasonix/internal/tool"
35 "reasonix/internal/workspacelease"
36 )
37
38 // maxToolOutputBytes bounds the stable provider-visible Content. RawContent
39 // retains the complete local result for explicit session-scoped paging.
40 const maxToolOutputBytes = 32 * 1024
41
42 var deprecatedContextRetentionWarning sync.Once
43
44 const maxEmptyFinalBlocks = 3
45
46 // maxStreamRecoveries is the number of body-phase stream retries after the
47 // initial sampling attempt (Pi-style default: 1 + 3 = 4 attempts total).
48 const maxStreamRecoveries = 3
49 const maxSamplingAttempts = maxStreamRecoveries + 1
50
51 // defaultReasoningByteLimit caps stored hidden reasoning for one stream.
52 // It does not cancel generation; official DeepSeek may emit up to 384K tokens.
53 const defaultReasoningByteLimit = 8 << 20
54
55 // DeliveryRuntimeMarker is the delivery-mode contract block appended to user
56 // turns (withTurnPreferences). Exported as the single source of truth for the
57 // byte-exact suffix strip in preview derivation and for cross-package tests;
58 // its text is cache-frozen — changing it breaks steer replay matching and the
59 // prefix stability of every live delivery session.
60 const DeliveryRuntimeMarker = `<delivery-runtime>
61 This session is in delivery-first mode. Use todo_write when a task benefits from
62 an explicit task list, update it from your own assessment, and finish when the
63 user's request is handled. Structured file tools require a current host-observed
64 file version; reading any useful window establishes that observation.
65 </delivery-runtime>`
66
67 // Renderer redraws the assistant's final-answer text as styled output. It is
68 // applied only after a turn's text stream completes, so the user sees raw
69 // markdown stream live, then a single redraw replaces it with formatted
70 // output. The renderer is intentionally interface-shaped so the agent stays
71 // independent of the cli's markdown library choice. Consumed by TextSink.
72 type Renderer interface {
73 Render(text string) string
74 }
75
76 // Asker puts structured multiple-choice questions to the user and blocks for the
77 // answers. The agent consults it for the `ask` tool. It is interface-shaped so
78 // the agent stays independent of the frontend; a nil asker means no interactive
79 // user (headless runs), where `ask` returns a "decide for yourself" result. The
80 // interactive frontends wire the controller in as the Asker.
81 type Asker interface {
82 Ask(ctx context.Context, questions []event.AskQuestion) ([]event.AskAnswer, error)
83 }
84
85 // callContextKey carries the executing tool call's identity into Execute.
86 type callContextKey struct{}
87 type parentSessionContextKey struct{}
88 type subagentDepthContextKey struct{}
89 type userImagesContextKey struct{}
90
91 // callContext is the per-call context a tool can read. parentID is the call being
92 // executed and sink is the agent's event sink (the `task` tool uses both to nest
93 // a sub-agent's events under this call); asker lets the `ask` tool reach the user.
94 type callContext struct {
95 parentID string
96 sink event.Sink
97 asker Asker
98 planMode bool
99 }
100
101 // withCallContext stamps ctx with the executing call's ID, the agent's sink, and
102 // the asker. executeOne sets this before every Execute; `task` reads it (via
103 // CallContext) to nest sub-agent events, and `ask` reads the asker to prompt.
104 // The plan-mode flag is mirrored onto the leaf planmode key so tools that must
105 // not import this package (for example internal/tool/builtin) can still read it.
106 func withCallContext(ctx context.Context, parentID string, sink event.Sink, asker Asker, planMode bool) context.Context {
107 ctx = planmode.WithActive(ctx, planMode)
108 return context.WithValue(ctx, callContextKey{}, callContext{parentID: parentID, sink: sink, asker: asker, planMode: planMode})
109 }
110
111 // WithToolCallContext stamps ctx as a host-initiated top-level tool call.
112 // Normal model-selected tools receive this context from executeOne; controller
113 // entry points that deliberately invoke the same tool machinery (for example a
114 // user typing /<subagent-skill>) use this exported wrapper so nested sub-agent
115 // activity still reaches the parent event stream and plan-mode policy remains
116 // visible to the invoked runner.
117 func WithToolCallContext(ctx context.Context, parentID string, sink event.Sink, asker Asker, planMode bool) context.Context {
118 return withCallContext(ctx, parentID, sink, asker, planMode)
119 }
120
121 // CallContext returns the executing call's ID, the agent's sink, and the asker,
122 // if the context was set by an agent's executeOne. ok is false for a plain
123 // context (headless tool tests, calls made outside the run loop).
124 func CallContext(ctx context.Context) (parentID string, sink event.Sink, asker Asker, ok bool) {
125 cc, ok := ctx.Value(callContextKey{}).(callContext)
126 if !ok {
127 return "", nil, nil, false
128 }
129 return cc.parentID, cc.sink, cc.asker, true
130 }
131
132 // PlanModeFromContext reports whether the tool call is executing during the
133 // plan-first workflow. Tools may use it for phase-specific behavior, but it is
134 // not a permission or read-only boundary.
135 func PlanModeFromContext(ctx context.Context) bool {
136 cc, ok := ctx.Value(callContextKey{}).(callContext)
137 return ok && cc.planMode
138 }
139
140 // withAgentContext establishes the agent-owned workflow capabilities for a
141 // model round and for tool availability checks. Missing capabilities shadow
142 // inherited values so child agents cannot reach parent Goal, Jobs, or memory
143 // state accidentally.
144 func (a *Agent) withAgentContext(ctx context.Context) context.Context {
145 if a == nil {
146 return ctx
147 }
148 if a.svc.jobs != nil {
149 ctx = jobs.WithManager(ctx, a.svc.jobs)
150 } else {
151 ctx = jobs.WithoutManager(ctx)
152 }
153 if a.svc.memQueue != nil {
154 ctx = memory.WithQueue(ctx, a.svc.memQueue)
155 } else {
156 ctx = memory.WithoutQueue(ctx)
157 }
158 return planmode.WithActive(ctx, a.planMode.Load())
159 }
160
161 // WithParentSession stamps the active parent session ID onto a turn context so
162 // persisted sub-agents can record and enforce their owning conversation.
163 func WithParentSession(ctx context.Context, parentSession string) context.Context {
164 return context.WithValue(ctx, parentSessionContextKey{}, strings.TrimSpace(parentSession))
165 }
166
167 // ParentSession returns the active parent session ID carried by a turn context.
168 func ParentSession(ctx context.Context) string {
169 parentSession, _ := ctx.Value(parentSessionContextKey{}).(string)
170 return strings.TrimSpace(parentSession)
171 }
172
173 // WithSubagentDepth carries the current subagent depth through nested tool calls.
174 // The root agent runs at depth 0; each spawned subagent increments by one.
175 func WithSubagentDepth(ctx context.Context, depth int) context.Context {
176 if depth < 0 {
177 depth = 0
178 }
179 return context.WithValue(ctx, subagentDepthContextKey{}, depth)
180 }
181
182 // SubagentDepth returns the current subagent depth carried by a turn context.
183 func SubagentDepth(ctx context.Context) int {
184 depth, _ := ctx.Value(subagentDepthContextKey{}).(int)
185 if depth < 0 {
186 return 0
187 }
188 return depth
189 }
190
191 // WithUserImages carries the data URLs of images the user attached to this turn,
192 // resolved by the controller (which owns attachments) since the agent must not
193 // depend on it. Run embeds them on the user message; the provider sends them only
194 // when the model is vision-capable.
195 func WithUserImages(ctx context.Context, images []string) context.Context {
196 return context.WithValue(ctx, userImagesContextKey{}, images)
197 }
198
199 func userImages(ctx context.Context) []string {
200 images, _ := ctx.Value(userImagesContextKey{}).([]string)
201 return images
202 }
203
204 // Gate decides, per tool call, whether it may run. The agent consults it at
205 // execute time after any explicit planning-phase opt-out. It is interface-shaped so the agent
206 // stays independent of the permission package and of how "ask" is resolved
207 // (silently in headless runs, interactively in the chat TUI). A nil gate means
208 // no gating — every call runs, preserving behaviour for callers that don't wire
209 // one in. reason is fed back to the model when allow is false; a non-nil err
210 // (e.g. ctx cancelled awaiting approval) is treated as a block for that call.
211 type Gate interface {
212 Check(ctx context.Context, toolName string, args json.RawMessage, readOnly bool) (allow bool, reason string, err error)
213 }
214
215 // ExplicitDenyGate exposes the only global permission decision that applies to
216 // an already-authorized MCP server. Installing or approving a server is the
217 // user's authorization boundary; ordinary ask/fallback posture must not add a
218 // second per-call prompt, while explicit deny rules remain authoritative.
219 type ExplicitDenyGate interface {
220 ExplicitlyDenies(toolName string, args json.RawMessage) bool
221 }
222
223 const PlanModeReadOnlyCommandApprovalTool = "plan_mode_read_only_command"
224
225 // PlanModeReadOnlyTrustRequest describes a bash command that is safe enough to
226 // ask the user to accept as read-only during planning. Command is the concrete
227 // attempted command and Prefix is the reusable prefix to trust.
228 type PlanModeReadOnlyTrustRequest struct {
229 ToolName string
230 Command string
231 Prefix string
232 Args json.RawMessage
233 }
234
235 // PlanModeReadOnlyTrustGate is the legacy Plan bash trust bridge. It remains in
236 // the internal API for controller compatibility, but ordinary Plan execution no
237 // longer invokes it; bash calls use the normal permission gate.
238 type PlanModeReadOnlyTrustGate interface {
239 CheckPlanModeReadOnlyTrust(ctx context.Context, req PlanModeReadOnlyTrustRequest) (allow bool, reason string, err error)
240 }
241
242 const DefaultMaxSubagentDepth = 2
243
244 // NormalizeMaxSubagentDepth applies the public config contract: values below 1
245 // preserve the old single-delegation boundary.
246 func NormalizeMaxSubagentDepth(depth int) int {
247 if depth < 1 {
248 return 1
249 }
250 return depth
251 }
252
253 // ToolHooks fires user-configured shell hooks around each tool call. PreToolUse
254 // runs before the call and may block it (block=true; message is the reason fed
255 // back to the model); PostToolUse runs after and only surfaces output to the
256 // user (it can't block). It is interface-shaped so the agent stays independent
257 // of the hook package — a nil hooks field disables hook firing entirely.
258 type ToolHooks interface {
259 PreToolUse(ctx context.Context, name string, args json.RawMessage) (block bool, message string)
260 PostToolUse(ctx context.Context, name string, args json.RawMessage, result string)
261 PostToolUseFailure(ctx context.Context, name string, args json.RawMessage, result string, err error)
262 // PostLLMCall fires after each model turn completes (streaming finishes)
263 // but before reasoning_content is stored. It returns the (possibly
264 // translated) reasoning string — the original when no hook is configured.
265 // HasPostLLMCall reports whether such a hook exists, so the agent keeps
266 // streaming reasoning live when none is wired up.
267 PostLLMCall(ctx context.Context, reasoning string, turn int) string
268 HasPostLLMCall() bool
269 // SubagentStop fires when a `task` sub-agent finishes (foreground). PreCompact
270 // fires just before a compaction pass and returns extra summary guidance (its
271 // hooks' stdout) to fold into the summary prompt; "" when no hook contributes.
272 SubagentStop(ctx context.Context, last string)
273 PreCompact(ctx context.Context, trigger string) string
274 }
275
276 // Agent drives a single task: a Provider, a tool Registry, and a Session wired
277 // into the main loop.
278 type Agent struct {
279 // protocolRunSeq scopes provider-protocol recovery records across runs. It
280 // is unrelated to the retired Auto Guard execution gate.
281 protocolRunSeq atomic.Uint64
282
283 imageInput agentImageInput
284 imageResolver ImageRequestResolver
285 agentConfig
286 // reads groups the run-scoped read registry and its generation: both are
287 // replaced at each run start so cursors from an earlier run never continue.
288 reads readState
289 // fileObservations is the non-persisted, per-agent observation table used by
290 // structured file tools. A new Agent (resume, fork, rollback, or sub-agent)
291 // always starts with an empty table.
292 fileObservations *fileops.Store
293 stragglers runStragglers
294 // svc are the collaborators this agent talks to; see services.go.
295 svc agentServices
296 // sess is the state one conversation owns; SetSession restarts it. See
297 // sessionstate.go.
298 sess sessionRuntime
299 responseLanguage atomic.Value // string: auto|zh|en
300 reasoningLanguage atomic.Value // string: auto|zh|en
301
302 requireVisibleFinal bool // internal callers require final Content
303 continuationPolicy ContinuationPolicy
304
305 // unwrittenResolve is the resolve watermark a failed state write still owes.
306 // It outlives the conversation, which is why it is not in sessionRuntime.
307 unwrittenResolve unwrittenResolve
308
309 // planMode enables planning workflow instructions and explicit phase opt-outs.
310 // It does not replace the permission or sandbox boundary. The system prompt and
311 // tool list never change with the toggle, preserving the provider-cache prefix.
312 planMode atomic.Bool
313
314 // readOnlyExecution is a construction-time defense for planner/research
315 // agents. Unlike planMode it is not a collaboration toggle: it remains on
316 // for the agent's lifetime and validates proxy calls after resolution.
317 readOnlyExecution bool
318
319 // plannerMCPExecution relaxes the strict read-only MCP boundary for the
320 // two-model Planner only: authorized, non-destructive MCP targets may run
321 // through use_capability even without readOnlyHint. Ordinary writers, bash,
322 // and destructive MCP stay blocked. Strict read-only sub-agents leave this
323 // false and still require readOnlyHint.
324 plannerMCPExecution bool
325
326 // writeWorkspaceRoot is the workspace used to normalize parent write
327 // reservations when writeScheduler is set.
328
329 // steerQueue holds mid-turn guidance admitted while the agent is running.
330 // Entries keep a durable inbox item ID plus a loader so full bodies are not
331 // retained in the agent heap beyond need. Cache miss for the next API call
332 // is unavoidable but limited to one call — the prefix stays stable otherwise.
333 steerMu sync.Mutex
334 steerQueue []steerEntry
335 steerConsumed bool
336 steerUnapplied bool
337 // steerRunActive is true while Run is executing. Steer only queues while
338 // it is set; once the turn's exit flush has drained the queue, later
339 // steers are rejected so the caller can deliver them as a regular turn
340 // instead of leaving them in a queue no loop will ever consume.
341 steerRunActive bool
342
343 // task is the state shared by every Run continuing one delivery scope: the
344 // spend that outlives a single Run and resource limits keyed to the task
345 // rather than the turn. See taskstate.go.
346 task taskRuntime
347
348 planContract *plancontract.Plan // approved plan this turn executes, if any
349
350 // inheritedExec is the writer parent's host execution context.
351 inheritedExec *runtimepolicy.InheritedExecutionContext
352
353 // turn is the state of the Run currently executing; beginRunTurn replaces
354 // it wholesale. See turnruntime.go.
355 turn turnRuntime
356
357 // ablation names the subsystems a benchmark arm switched off. The zero value
358 // is the control arm.
359 ablation ablation.Set
360
361 // pending is what an external caller arms before the next Run; see
362 // turnruntime.go.
363 pending pendingTurn
364
365 // capabilityLedger tracks require/prefer outcomes for this user turn only.
366 // Never serialized into prompts or session state.
367 capabilityLedger *capability.Ledger
368 // capabilityAudit accumulates non-persisted routing/proxy counters.
369 capabilityAudit *capability.Audit
370 // capabilityGate is the turn's gate memory across final-answer retries.
371 capabilityGate capabilityGateState
372
373 // subagentDepth tracks the current agent's nesting depth. maxSubagentDepth
374 // caps delegation; when reached, recursive agent/skill tools are excluded.
375
376 // Context management keeps the canonical transcript immutable and installs
377 // at most one provider-visible checkpoint each time compactRatio is crossed.
378 keepPolicy KeepPolicy
379 strictAlternatingRoles bool // coalesce adjacent user turns on provider request copies
380 // activeTurnCreatedAt identifies the real/synthetic user message that began
381 // the currently running turn. Compaction may rewrite older history while a
382 // tool loop is active, but it must keep this message and everything after it
383 // verbatim so cancellation/crash recovery can retain completed tool pairs.
384 activeTurnCreatedAt atomic.Int64
385 // Pinned revisions are staged after admission and appended with the user turn.
386 pinned pinnedContextRuntime
387 }
388
389 // KeepPolicy is a bitmask controlling which messages are preserved beyond the
390 // recent tail during compaction.
391 type KeepPolicy int
392
393 const (
394 KeepErrors KeepPolicy = 1 << iota
395 KeepUserMarked
396 )
397
398 // SetPlanMode toggles the plan-first workflow flag. Ordinary calls still use
399 // Permissions/Sandbox; only explicit phase opt-outs are refused. The system
400 // prompt and tool schemas stay untouched, while the caller supplies the
401 // model-facing Marker in a user turn.
402 func (a *Agent) SetPlanMode(v bool) { a.planMode.Store(v) }
403
404 // SetTools replaces the agent's tool registry. The next API call picks up the
405 // new tool schema; tools already cached in the provider prefix are unaffected
406 // until the prefix is invalidated. Safe to call between turns.
407 func (a *Agent) SetTools(tools *tool.Registry) {
408 if a == nil {
409 return
410 }
411 a.svc.tools = tools
412 }
413
414 // SetReasoningLanguage updates the visible reasoning language preference for
415 // subsequent user-role messages emitted by this agent.
416 func (a *Agent) SetReasoningLanguage(lang string) {
417 if a == nil {
418 return
419 }
420 a.reasoningLanguage.Store(NormalizeReasoningLanguage(lang))
421 }
422
423 // SetResponseLanguage updates the final-answer language preference for
424 // subsequent user-role messages emitted by this agent.
425 func (a *Agent) SetResponseLanguage(lang string) {
426 if a == nil {
427 return
428 }
429 a.responseLanguage.Store(NormalizeResponseLanguage(lang))
430 }
431
432 // SetGate installs the per-call permission gate. Interactive frontends also use
433 // it to switch approval modes while a turn is running, so readers take an
434 // atomic snapshot through agentServices. nil disables gating.
435 func (a *Agent) SetGate(g Gate) {
436 if nilutil.IsNil(g) {
437 g = nil
438 }
439 a.svc.setGate(g)
440 }
441
442 // SetExtensions installs the extension dispatcher after construction. Boot
443 // uses it because sidecars — and therefore the dispatcher — only exist after
444 // snapshot assembly, which runs after the agent is built. Safe to call before
445 // the run loop starts; nil disables interception.
446 func (a *Agent) SetExtensions(d *dispatch.Dispatcher) {
447 if a == nil {
448 return
449 }
450 a.svc.extensions = d
451 }
452
453 // SetRecoveryGate is retained for source compatibility. Auto Guard is retired,
454 // so no caller can reinstall its execution gates.
455 func (a *Agent) SetRecoveryGate(g RecoveryGate) {
456 }
457
458 // SetRecoveryIdentity sets the agent/task labels used on recovery cards.
459 func (a *Agent) SetRecoveryIdentity(agentID, taskID string) {
460 }
461
462 // RecoveryGate is retained for source compatibility and always returns nil.
463 func (a *Agent) RecoveryGate() RecoveryGate {
464 return nil
465 }
466
467 // SetPlanModeReadOnlyTrustGate retains the legacy confirmation bridge for old
468 // controller/session data. Main Plan execution no longer calls it.
469 func (a *Agent) SetPlanModeReadOnlyTrustGate(g PlanModeReadOnlyTrustGate) {
470 if nilutil.IsNil(g) {
471 g = nil
472 }
473 a.svc.planTrust = g
474 }
475
476 // SetSandboxEscapeApprover installs the optional one-shot approval path used by
477 // the bash tool when an enforced OS sandbox fails to start.
478 func (a *Agent) SetSandboxEscapeApprover(g sandbox.EscapeApprover) {
479 if nilutil.IsNil(g) {
480 g = nil
481 }
482 a.svc.sandboxEscape = g
483 }
484
485 func (a *Agent) withTurnPreferences(input string) string {
486 if a == nil {
487 return input
488 }
489 responseLang := "auto"
490 if v := a.responseLanguage.Load(); v != nil {
491 if s, ok := v.(string); ok {
492 responseLang = s
493 }
494 }
495 input = WithResponseLanguage(input, responseLang)
496
497 lang := "auto"
498 if v := a.reasoningLanguage.Load(); v != nil {
499 if s, ok := v.(string); ok {
500 lang = s
501 }
502 }
503 input = WithReasoningLanguage(input, lang)
504 return input
505 }
506
507 // SetAsker installs the asker the `ask` tool uses to question the user.
508 // Interactive frontends wire one in; headless runs leave it nil.
509 func (a *Agent) SetAsker(as Asker) { a.svc.asker = as }
510
511 // SetInteractionBroker installs the broker that carries MCP server-initiated
512 // elicitations to the user. Headless runs leave it nil so requests cancel.
513 func (a *Agent) SetInteractionBroker(b mcpinteraction.Broker) { a.svc.interactionBroker = b }
514
515 // SetMemoryQueue installs the sink the remember/forget tools use to apply a
516 // memory change in the current session. The controller wires itself in.
517 func (a *Agent) SetMemoryQueue(q memory.Queue) { a.svc.memQueue = q }
518
519 // SetPreEditHook installs the pre-edit snapshot hook (see onPreEdit). The
520 // controller wires it to its per-session checkpoint store; nil disables capture.
521 // Prefer SetMutationObserver for v2 capture (before+after fingerprints).
522 func (a *Agent) SetPreEditHook(fn func(diff.Change)) { a.svc.preEdit = fn }
523
524 // SetMutationObserver installs the unified mutation observer. When set, it
525 // supersedes onPreEdit for capture and also records after-mutation fingerprints.
526 // When a task tool is already registered it inherits the observer for sub-agents.
527 func (a *Agent) SetMutationObserver(obs *checkpoint.MutationObserver) {
528 a.svc.mutationObserver = obs
529 if a.svc.tools == nil || obs == nil {
530 return
531 }
532 if t, ok := a.svc.tools.Get("task"); ok {
533 if task, ok := t.(*TaskTool); ok {
534 task.WithMutationObserver(obs)
535 }
536 }
537 }
538
539 // MutationObserver returns the installed observer (may be nil).
540 func (a *Agent) MutationObserver() *checkpoint.MutationObserver {
541 if a == nil {
542 return nil
543 }
544 return a.svc.mutationObserver
545 }
546
547 // LastUsage returns the most recent per-turn token telemetry the provider
548 // reported (nil if no turn has run yet). The TUI uses it to show a context
549 // gauge alongside the prompt; ContextManager.Prepare owns cache-breaking
550 // maintenance decisions.
551 func (a *Agent) LastUsage() *provider.Usage { return a.sess.output.lastUsage.Load() }
552
553 // SessionCache returns the cumulative cache hit/miss prompt tokens across every
554 // API call this session — the basis for the status line's aggregate hit-rate.
555 func (a *Agent) SessionCache() (hit, miss int) {
556 return int(a.sess.cacheHit.Load()), int(a.sess.cacheMiss.Load())
557 }
558
559 // ContextWindow returns the configured context-window size in tokens. 0
560 // means compaction is disabled for this agent.
561 func (a *Agent) ContextWindow() int { return a.contextWindow }
562
563 // mid-turn steer marker.
564 // MidTurnSteerPrefix marks user messages that were injected mid-turn as
565 // guidance (via Steer). The model sees them as instructions; frontends
566 // display them as a notice, not a regular user bubble.
567 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.]"
568
569 func midTurnSteerMessage(text string) string {
570 return MidTurnSteerPrefix + "\n" + text
571 }
572
573 // SteerText checks whether content is a mid-turn steer message and, if so,
574 // returns the original user text without the wrapper prefix. The returned
575 // text preserves the user's exact input — it only strips the prefix and the
576 // "\n" separator that midTurnSteerMessage inserts between the prefix and the
577 // user text; it does not trim spaces so the history replay matches the live
578 // Steer event rendering character-for-character.
579 //
580 // Steers are persisted through withTurnPreferences, which can prepend
581 // transient language blocks (for Chinese text even in auto mode) and append
582 // the delivery-runtime marker. Both are transport framing, not steer text:
583 // leading blocks are skipped before matching the prefix and a trailing
584 // marker is cut from the returned text, so replay recognizes steers
585 // regardless of the session's language and profile settings.
586 func SteerText(content string) (string, bool) {
587 s := content
588 for {
589 if after, found := strings.CutPrefix(s, MidTurnSteerPrefix); found {
590 // Strip only the "\n" separator, preserving the user's original text.
591 after = strings.TrimPrefix(after, "\n")
592 if trimmed, cut := strings.CutSuffix(after, "\n\n"+DeliveryRuntimeMarker); cut {
593 after = trimmed
594 }
595 return after, true
596 }
597 next, ok := trimLeadingSteerWrapper(s)
598 if !ok {
599 return "", false
600 }
601 s = next
602 }
603 }
604
605 // trimLeadingSteerWrapper removes one leading transient preference block that
606 // withTurnPreferences may have placed ahead of the steer prefix. It reports
607 // false when content does not start with such a block.
608 func trimLeadingSteerWrapper(content string) (string, bool) {
609 s := strings.TrimLeft(content, " \t\r\n")
610 for _, tag := range []string{"response-language", "reasoning-language"} {
611 if !strings.HasPrefix(s, "<"+tag+">") {
612 continue
613 }
614 if rest, ok := trimLeadingTransientBlock(s, tag); ok {
615 return rest, true
616 }
617 }
618 return content, false
619 }
620
621 // steerEntry is one mid-turn guidance admission.
622 type steerEntry struct {
623 itemID string
624 load func() (string, error)
625 // text is a fallback when load is nil (legacy Steer(string) path).
626 text string
627 }
628
629 // ErrSteerWithdrawn tells the agent that a durable steer was intentionally
630 // removed by a concurrent user cancellation. It must not be recorded as an
631 // unapplied failure in the transcript.
632 var ErrSteerWithdrawn = errors.New("steer withdrawn")
633
634 // Steer queues a message for mid-turn injection. It reports whether an active
635 // turn accepted the text; on false nothing was queued and the caller must
636 // deliver it another way (typically as a new turn). Without the active check,
637 // a steer landing in the window between the turn's exit flush and the
638 // controller observing running=false would sit in the queue unconsumed and
639 // unpersisted — invisible to both the model and history.
640 func (a *Agent) Steer(text string) bool {
641 return a.SteerItem("", func() (string, error) { return text, nil })
642 }
643
644 // SteerItem queues durable-inbox guidance identified by itemID. load is called
645 // only when the entry is consumed so the agent does not retain every body.
646 func (a *Agent) SteerItem(itemID string, load func() (string, error)) bool {
647 a.steerMu.Lock()
648 defer a.steerMu.Unlock()
649 if !a.steerRunActive {
650 return false
651 }
652 a.steerQueue = append(a.steerQueue, steerEntry{itemID: itemID, load: load})
653 a.steerConsumed = false
654 return true
655 }
656
657 // SteerConsumed returns true when the steer queue became empty after the last consume.
658 func (a *Agent) SteerConsumed() bool {
659 a.steerMu.Lock()
660 defer a.steerMu.Unlock()
661 return a.steerConsumed
662 }
663
664 // HasUnappliedSteer reports whether the last Run ended with user guidance that
665 // arrived too late to be consumed. Hosts use it to yield before starting an
666 // automatic synthetic continuation.
667 func (a *Agent) HasUnappliedSteer() bool {
668 a.steerMu.Lock()
669 defer a.steerMu.Unlock()
670 return a.steerUnapplied
671 }
672
673 // SetSink replaces the agent's event sink. Controllers use this to wrap the
674 // sink after construction (e.g. durable inbox observation) without rebuilding
675 // the agent.
676 func (a *Agent) SetSink(sink event.Sink) {
677 if a == nil {
678 return
679 }
680 if nilutil.IsNil(sink) {
681 sink = event.Discard
682 }
683 a.svc.sink = sink
684 }
685
686 func (a *Agent) consumeSteer() (text, itemID string, ok bool) {
687 a.steerMu.Lock()
688 defer a.steerMu.Unlock()
689 if len(a.steerQueue) == 0 {
690 return "", "", false
691 }
692 e := a.steerQueue[0]
693 a.steerQueue = a.steerQueue[1:]
694 a.steerConsumed = len(a.steerQueue) == 0
695 if e.load != nil {
696 t, err := e.load()
697 if err != nil {
698 if errors.Is(err, ErrSteerWithdrawn) {
699 return "", "", false
700 }
701 return "", e.itemID, false
702 }
703 return t, e.itemID, true
704 }
705 return e.text, e.itemID, true
706 }
707
708 // closeSteerIntakeIfIdle atomically closes the normal-completion race between
709 // the final queue check and Run returning. A steer accepted before this check
710 // keeps the loop alive; one arriving after it is rejected so the host can keep
711 // the user's draft and retry it as a regular follow-up.
712 func (a *Agent) closeSteerIntakeIfIdle() bool {
713 a.steerMu.Lock()
714 defer a.steerMu.Unlock()
715 if len(a.steerQueue) > 0 {
716 return false
717 }
718 a.steerRunActive = false
719 return true
720 }
721
722 // flushSteerQueue ends the turn's steer intake. Guidance that arrived too late
723 // to be consumed is persisted for transcript visibility but marked local-only:
724 // replaying it to the model on the next unrelated user turn can execute a stale
725 // historical task (#7045). An explicit warning keeps the transcript honest
726 // without presenting the text as successfully applied guidance (#6238).
727 func (a *Agent) flushSteerQueue() {
728 a.steerMu.Lock()
729 pending := a.steerQueue
730 a.steerQueue = nil
731 a.steerUnapplied = false
732 if len(pending) > 0 {
733 a.steerConsumed = true
734 }
735 a.steerRunActive = false
736 a.steerMu.Unlock()
737 unapplied := false
738 for _, e := range pending {
739 text := e.text
740 if e.load != nil {
741 if t, err := e.load(); errors.Is(err, ErrSteerWithdrawn) {
742 continue
743 } else if err == nil {
744 text = t
745 }
746 }
747 a.RecordUnappliedSteer(text, e.itemID)
748 unapplied = true
749 }
750 a.steerMu.Lock()
751 a.steerUnapplied = unapplied
752 a.steerMu.Unlock()
753 }
754
755 func (a *Agent) steerQueueLen() int {
756 a.steerMu.Lock()
757 defer a.steerMu.Unlock()
758 return len(a.steerQueue)
759 }
760
761 // CompactRatio returns the fraction of the window at which auto-compaction
762 // fires (e.g. 0.8). The status line uses it to show headroom to the next compact.
763 func (a *Agent) CompactRatio() float64 { return a.compactRatio }
764
765 // CompactNow forces one projection compaction (canonical transcript untouched).
766 func (a *Agent) CompactNow(ctx context.Context, instructions string) error {
767 _, err := a.contextManager().Prepare(ctx, ContextPreparePolicy{
768 Trigger: CompactionTriggerManual,
769 Instructions: instructions,
770 Force: true,
771 AllowChunkedFallback: true,
772 })
773 return err
774 }
775
776 // Options configures an Agent.
777 type Options struct {
778 ImageInput *imageinput.Config
779 ImageRequestResolver ImageRequestResolver
780 MaxSteps int
781 // MaxStepsKey names the explicit runtime control shown when the MaxSteps guard
782 // is hit. Empty defaults to the generic max_steps tool/runtime parameter.
783 MaxStepsKey string
784 // ReasoningByteLimit bounds a single stream's hidden reasoning bytes. Zero
785 // uses the default guard; a negative value disables only this client guard.
786 // Provider output budgets are a separate protocol/model capability.
787 ReasoningByteLimit int
788 // MaxOutputTokens overrides the provider's configured/default total output
789 // budget. Zero delegates to the provider; a negative value asks optional
790 // protocols to omit the budget (Anthropic still requires max_tokens).
791 MaxOutputTokens int
792 Temperature float64
793 // TaskBudget bounds a task's spend; zero uses DefaultTaskBudget.
794 TaskBudget TaskBudget
795 Pricing *provider.Pricing // optional, for per-turn cost display
796 // QuoteContext is shared with the host CostQuote sink so budget accounting
797 // and emitted usage consume the exact same occurrence-time quote.
798 QuoteContext *event.QuoteContext
799 UsageSource string // optional billable usage source; default executor
800 // ModelRef names the canonical "provider/model" ref backing this agent's
801 // provider instance. It is attached to emitted Usage events so downstream
802 // usage accounting can attribute tokens to the exact model.
803 ModelRef string
804 // RequireVisibleFinal makes internal callers reject reasoning-only responses.
805 RequireVisibleFinal bool
806 // ContinuationPolicy is the internal host policy for synthetic same-Run
807 // continuation. The zero value (ContinuationDisabled) is the product default.
808 ContinuationPolicy ContinuationPolicy
809 // Gate is the per-call permission gate. nil disables gating.
810 Gate Gate
811 // ReadOnlyExecution enables a permanent host-side read-only boundary for
812 // planner and research agents. It is intentionally independent of Plan mode
813 // so a stale collaboration flag cannot authorize a dynamic writer target.
814 ReadOnlyExecution bool
815 // PlannerMCPExecution enables Planner-trusted MCP through use_capability:
816 // authorized, non-destructive tools may run without readOnlyHint. Only
817 // NewPlannerAgent sets this; strict read-only sub-agents must not.
818 PlannerMCPExecution bool
819
820 // PlanModeReadOnlyTrustGate is retained for legacy controller compatibility.
821 // The main Plan execution path no longer invokes it.
822 PlanModeReadOnlyTrustGate PlanModeReadOnlyTrustGate
823
824 // SandboxEscapeApprover confirms a one-shot unconfined shell rerun after an
825 // enforced OS sandbox fails. nil keeps fail-closed behavior.
826 SandboxEscapeApprover sandbox.EscapeApprover
827
828 // ConfigWriteApprover confirms file-tool writes to Reasonix-managed config
829 // files outside the workspace roots. nil keeps fail-closed behavior.
830 ConfigWriteApprover tool.ConfigWriteApprover
831
832 // Context management. ContextWindow <= 0 disables compaction. Ratios and
833 // RecentKeep fall back to defaults when unset.
834 ContextWindow int
835 CompactRatio float64
836 // Deprecated compatibility inputs. New agents ignore these fields; automatic
837 // maintenance is controlled only by CompactRatio.
838 SoftCompactRatio float64
839 ToolResultSnipRatio float64
840 CompactForceRatio float64
841 RecentKeep int
842 ArchiveDir string
843 KeepPolicy KeepPolicy
844 SessionPath string // projection sidecar path; empty = memory only
845 WorkspaceID string // prompt-cache lineage component
846 StrictAlternatingRoles bool // merge adjacent user turns for strict providers at request time
847 ContextEditing string // deprecated; native provider editing was removed
848
849 // Hooks fires PreToolUse / PostToolUse shell hooks around tool calls. nil
850 // disables hook firing.
851 Hooks ToolHooks
852
853 // MissingReasoningWarnStateDir, when non-empty, points at the shared
854 // directory where missing tool-call thinking recovery retries are gated by
855 // opaque provider-configuration fingerprint (#7059). The field name is kept
856 // for source compatibility. Boot always supplies it; direct construction
857 // with an empty value keeps in-memory gating.
858 MissingReasoningWarnStateDir string
859
860 // Jobs is the session's background-job manager (nil disables background tools).
861 Jobs *jobs.Manager
862 // MemoryQueue optionally gives a child agent an explicitly owned live-memory
863 // queue. When nil, child construction shadows inherited queues.
864 MemoryQueue memory.Queue
865
866 // WriteScheduler is the session-scoped subagent concurrency/write-claim
867 // controller. When set on the parent executor, write-capable tools reserve
868 // paths for the duration of Execute so background writers cannot TOCTOU
869 // race parent writes. Subagents leave this nil (or depth > 0 skips it).
870 WriteScheduler *SubagentScheduler
871 // WriteWorkspaceRoot normalizes parent write reservations.
872 WriteWorkspaceRoot string
873 // SessionTemp owns the exact private scratch root for delivery accounting.
874 SessionTemp *sessiontemp.Manager
875 // WriteRoots is the session-scoped writable directory manager.
876 WriteRoots *sandbox.WritableRootSet
877 // WriteAccessGate authorizes extra writable directories. nil is fail-closed
878 // for missing dirs when WriteRoots is set.
879 WriteAccessGate WriteAccessGate
880 // DisableWriteAccessExpand prevents this agent from requesting new write
881 // directories. Sub-agents set this.
882 DisableWriteAccessExpand bool
883 // HomeDir and StateRoot are used to normalize and reject write directories.
884 HomeDir string
885 StateRoot string
886
887 // WorkspaceLease serializes writer mutations across sessions that target
888 // the same workspace. nil preserves source compatibility for direct Agent
889 // construction; boot always supplies it for writer-capable sessions.
890 WorkspaceLease *workspacelease.Owner
891
892 // ProjectChecks is a retired compatibility option. Project instructions
893 // remain in normal model context and are not compiled into host obligations.
894 ProjectChecks []instruction.VerifyCheck
895
896 // InheritedExecution is the writer parent's host execution context.
897 InheritedExecution *runtimepolicy.InheritedExecutionContext
898
899 // Ablation switches subsystems off for a benchmark arm. The zero value runs
900 // everything, so ordinary callers leave it unset.
901 Ablation ablation.Set
902
903 // ClassifierTaskText, when non-empty, is the pristine task text, set by
904 // sub-agent spawners before host framing is prepended. Delivery intent
905 // classification judges it instead of the raw Run input, so framing verbs
906 // cannot arm expectations; the delegation audit scores evidence origin
907 // against it, so only locations the parent wrote itself count as hints.
908 ClassifierTaskText string
909
910 // CapabilityLedger is the optional turn-scoped capability route ledger for
911 // Delivery require/prefer gates. Nil disables capability gates.
912 CapabilityLedger *capability.Ledger
913 // CapabilityAudit is the optional non-persisted metrics sink for routing.
914 CapabilityAudit *capability.Audit
915
916 // RequireReviewReportKind is a retired compatibility field. Subagents return
917 // their ordinary final answer without a proof tool.
918 RequireReviewReportKind evidence.ReviewKind
919
920 // ReasoningLanguage controls visible reasoning language preference as transient
921 // user-turn context. Empty/auto injects nothing.
922 ReasoningLanguage string
923
924 // ResponseLanguage controls final-answer language preference as transient
925 // user-turn context. Empty/auto keeps the stable same-as-user policy.
926 ResponseLanguage string
927
928 // PlanModeReadOnlyCommands is retained for old config/controller data. Main
929 // Plan execution classifies bash through Permissions instead.
930 PlanModeReadOnlyCommands []string
931
932 // RecoveryGate and the identity fields are retired compatibility inputs.
933 // Agent construction ignores them.
934 RecoveryGate RecoveryGate
935 // RecoveryAgentID labels this agent on recovery cards (empty = root).
936 RecoveryAgentID string
937 // RecoveryTaskID isolates recovery state for this agent (empty = root task).
938 RecoveryTaskID string
939
940 // SubagentDepth is the current nesting depth for this agent. Root sessions are
941 // depth 0; child subagents are depth 1. MaxSubagentDepth caps delegation.
942 SubagentDepth int
943 MaxSubagentDepth int
944
945 // Extensions is the frozen extension dispatcher for this agent's controller
946 // generation (Extension Protocol v2). Nil means no runtime packages are
947 // installed; the run loop then passes every intercept point through
948 // byte-identically. Boot installs it with SetExtensions once sidecars are
949 // live (they start after the agent is constructed).
950 Extensions *dispatch.Dispatcher
951
952 // MutationObserver is the host-side file mutation observer shared with
953 // (or cloned for) sub-agents. nil disables v2 capture. Does not affect
954 // provider-visible tool schemas or prompts.
955 MutationObserver *checkpoint.MutationObserver
956
957 // SessionCheckpointer flushes the accepted session event prefix at semantic
958 // boundaries before model and top-level tool side effects.
959 SessionCheckpointer SessionCheckpointer
960 }
961
962 // New constructs an Agent. MaxSteps <= 0 means no cap — the run loop continues
963 // until the model gives a final answer, the context is cancelled, or the
964 // provider errors (compaction keeps the context bounded). A nil sink is replaced
965 // with event.Discard so the agent can always emit unconditionally.
966 func New(prov provider.Provider, tools *tool.Registry, session *Session, opts Options, sink event.Sink) *Agent {
967 warnDeprecatedRetention := deprecatedContextRetentionConfigured(opts)
968 if opts.CompactRatio <= 0 {
969 opts.CompactRatio = defaultCompactRatio
970 }
971 if opts.RecentKeep <= 0 {
972 opts.RecentKeep = minRecentKeep
973 }
974 if nilutil.IsNil(sink) {
975 sink = event.Discard
976 }
977 gate := opts.Gate
978 if nilutil.IsNil(gate) {
979 gate = nil
980 }
981 planModeReadOnlyTrust := opts.PlanModeReadOnlyTrustGate
982 if nilutil.IsNil(planModeReadOnlyTrust) {
983 planModeReadOnlyTrust = nil
984 }
985 sandboxEscapeApprover := opts.SandboxEscapeApprover
986 if nilutil.IsNil(sandboxEscapeApprover) {
987 sandboxEscapeApprover = nil
988 }
989 configWriteApprover := opts.ConfigWriteApprover
990 if nilutil.IsNil(configWriteApprover) {
991 configWriteApprover = nil
992 }
993 hooks := opts.Hooks
994 if nilutil.IsNil(hooks) {
995 hooks = nil
996 }
997 maxStepsKey := opts.MaxStepsKey
998 if strings.TrimSpace(maxStepsKey) == "" {
999 maxStepsKey = "max_steps"
1000 }
1001 maxSubagentDepth := opts.MaxSubagentDepth
1002 if maxSubagentDepth == 0 {
1003 maxSubagentDepth = DefaultMaxSubagentDepth
1004 } else {
1005 maxSubagentDepth = NormalizeMaxSubagentDepth(maxSubagentDepth)
1006 }
1007 subagentDepth := max(opts.SubagentDepth, 0)
1008 reasoningByteLimit := opts.ReasoningByteLimit
1009 if reasoningByteLimit == 0 {
1010 reasoningByteLimit = defaultReasoningByteLimit
1011 }
1012 a := &Agent{
1013 imageInput: newImageInput(opts.ImageInput, prov),
1014 imageResolver: opts.ImageRequestResolver,
1015 svc: newAgentServices(prov, tools, sink, gate, planModeReadOnlyTrust,
1016 sandboxEscapeApprover, configWriteApprover, hooks, opts),
1017 reads: readState{},
1018 fileObservations: fileops.NewStore(),
1019 agentConfig: agentConfig{
1020 maxSteps: opts.MaxSteps,
1021 maxStepsKey: maxStepsKey,
1022 reasoningByteLimit: reasoningByteLimit,
1023 maxOutputTokens: opts.MaxOutputTokens,
1024 temperature: opts.Temperature,
1025 usageSource: usageSourceOrDefault(opts.UsageSource, event.UsageSourceExecutor),
1026 modelRef: strings.TrimSpace(opts.ModelRef),
1027 workspaceID: strings.TrimSpace(opts.WorkspaceID),
1028 classifierTaskText: opts.ClassifierTaskText,
1029 writeWorkspaceRoot: strings.TrimSpace(opts.WriteWorkspaceRoot),
1030 subagentDepth: subagentDepth,
1031 maxSubagentDepth: maxSubagentDepth,
1032 contextWindow: opts.ContextWindow,
1033 compactRatio: opts.CompactRatio,
1034 recentKeep: opts.RecentKeep,
1035 archiveDir: opts.ArchiveDir,
1036 },
1037 sess: sessionRuntime{
1038 conversation: session,
1039 path: strings.TrimSpace(opts.SessionPath),
1040 cacheState: CacheStateUnknown,
1041 },
1042 task: taskRuntime{
1043 ledger: evidence.NewLedger(),
1044 budget: runBudget{limit: normalizeTaskBudget(opts.TaskBudget)},
1045 },
1046 requireVisibleFinal: opts.RequireVisibleFinal,
1047 continuationPolicy: opts.ContinuationPolicy,
1048 readOnlyExecution: opts.ReadOnlyExecution,
1049 plannerMCPExecution: opts.PlannerMCPExecution,
1050 inheritedExec: opts.InheritedExecution,
1051 ablation: opts.Ablation,
1052 capabilityLedger: opts.CapabilityLedger,
1053 capabilityAudit: opts.CapabilityAudit,
1054 keepPolicy: opts.KeepPolicy,
1055 strictAlternatingRoles: opts.StrictAlternatingRoles,
1056 }
1057 a.sess.output.outputBudget = outputBudgetOf(prov)
1058 if a.sess.path != "" {
1059 a.LoadProjectionSidecar(a.sess.path)
1060 }
1061 a.SetResponseLanguage(opts.ResponseLanguage)
1062 a.SetReasoningLanguage(opts.ReasoningLanguage)
1063 a.bindCapabilityObservers()
1064 a.maybeArmForkFromEnv()
1065 a.maybeWrapForkCaptureProvider()
1066 if warnDeprecatedRetention {
1067 deprecatedContextRetentionWarning.Do(func() {
1068 a.svc.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn,
1069 Text: i18n.M.DeprecatedContextRetention,
1070 Detail: "Harness-style compaction now retains only the newest 16% of the context window; legacy retention fields are preserved in configuration but ignored at runtime."})
1071 })
1072 }
1073 return a
1074 }
1075
1076 func deprecatedContextRetentionConfigured(opts Options) bool {
1077 recentNonDefault := opts.RecentKeep > 0 && opts.RecentKeep != minRecentKeep
1078 defaultKeepPolicy := KeepErrors | KeepUserMarked
1079 keepNonDefault := opts.KeepPolicy != 0 && opts.KeepPolicy != KeepErrors && opts.KeepPolicy != defaultKeepPolicy
1080 return recentNonDefault || keepNonDefault
1081 }
1082
1083 // closedLoopActive reports whether the current (or most recent) turn must
1084 // close the evidence loop. It replaces every historical deliveryProfile gate:
1085 // acceptance criteria before mutations, todo ownership, opaque-bash limits,
1086 // capability call preference, post-write verification, review, and sign-off.
1087 // It is authoritative only for host control flow, never for tool schemas.
1088 func (a *Agent) closedLoopActive() bool {
1089 return a != nil && a.turn.deliveryScopeActive
1090 }
1091
1092 func usageSourceOrDefault(source, fallback string) string {
1093 source = strings.TrimSpace(source)
1094 if source != "" {
1095 return source
1096 }
1097 return fallback
1098 }
1099
1100 // missingReasoningWarnStateFor returns nil when no state dir is configured, so
1101 // direct Agent construction keeps the historical once-per-session notice scope.
1102 func missingReasoningWarnStateFor(dir string) *missingReasoningWarnState {
1103 if strings.TrimSpace(dir) == "" {
1104 return nil
1105 }
1106 return newMissingReasoningWarnState(dir)
1107 }
1108
1109 // reserveParentWrite holds write claims for the duration of a parent-agent
1110 // write tool call. Returns a no-op release when reservation is not needed
1111 // (subagent, read-only, no scheduler, or non-write tool).
1112 func (a *Agent) reserveParentWrite(runTool tool.Tool, args json.RawMessage, readOnly bool) (release func(), err error) {
1113 noop := func() {}
1114 if a == nil || a.svc.writeScheduler == nil || a.subagentDepth > 0 || readOnly || runTool == nil {
1115 return noop, nil
1116 }
1117 name := runTool.Name()
1118 if !parentWriteGuardTarget(name) {
1119 return noop, nil
1120 }
1121 claim, err := parentWriteReservation(a.writeWorkspaceRoot, name, args)
1122 if err != nil {
1123 return noop, err
1124 }
1125 return a.svc.writeScheduler.ReserveParentWrite(claim)
1126 }
1127
1128 // Run appends the user input and drives the tool loop until the model returns a
1129 // final answer, the context is cancelled, or the provider errors. maxSteps <= 0
1130 // leaves the loop unbounded here: bounding it is the host's call, and the
1131 // adaptive stop is the no-progress ladder rather than a round count. Turn policy
1132 // lives in beginRunTurn / runToolLoop / handleFinalResponse / handleToolRound.
1133 func (a *Agent) Run(ctx context.Context, input string) (runErr error) {
1134 defer a.finishRunRecovery(&runErr)
1135 if err := a.prepareProtocolRecovery(ctx); err != nil {
1136 return err
1137 }
1138 a.restoreProtocolProjection()
1139 ctx = a.withProviderCacheSession(ctx)
1140 runMaxSteps := a.maxSteps
1141 runMaxStepsKey := a.maxStepsKey
1142 a.protocolRunSeq.Add(1)
1143 // Participate in the run lease; per-tool write leases end with execution.
1144 if a.svc.workspaceLease != nil {
1145 a.svc.workspaceLease.BeginRun()
1146 defer a.svc.workspaceLease.EndRun()
1147 }
1148 turnStartedAt := time.Now()
1149 workDurationMs := func() int64 {
1150 if elapsed := time.Since(turnStartedAt).Milliseconds(); elapsed > 0 {
1151 return elapsed
1152 }
1153 return 1
1154 }
1155 defer a.flushSteerQueue()
1156 a.steerMu.Lock()
1157 a.steerConsumed = false
1158 a.steerUnapplied = false
1159 a.steerRunActive = true
1160 a.steerMu.Unlock()
1161
1162 // Commit background-job evidence leases only after this turn delivers.
1163 // job_output (and replay-only wait/bash_output aliases) merges a finished background writer's receipts into the
1164 // ledger provisionally; if the turn reaches a final answer (runErr == nil)
1165 // the delivery gates have verified and reviewed those mutations, so the
1166 // job's evidence can be permanently drained. A failed or cancelled turn
1167 // leaves the lease uncommitted so the next turn re-collects it.
1168 defer func() {
1169 if runErr != nil || a.task.ledger == nil || a.svc.jobs == nil {
1170 return
1171 }
1172 for _, lease := range a.task.ledger.BackgroundLeases() {
1173 a.svc.jobs.CommitEvidenceForSession(lease.Session, lease.JobID)
1174 }
1175 }()
1176 if _, scoped := DeliveryExecutionScopeFromContext(ctx); scoped {
1177 defer func() { a.updateDeliveryCheckpoint(runErr) }()
1178 }
1179 defer a.activeTurnCreatedAt.Store(0)
1180
1181 // agent.before_start: an extension may abort the run before the user turn
1182 // is appended. The redacted reason surfaces like a normal run error.
1183 if err := a.interceptAgentStart(ctx); err != nil {
1184 a.discardStagedPinnedContext()
1185 return err
1186 }
1187
1188 pinned, err := a.preparePinnedRevision()
1189 if err != nil {
1190 return err
1191 }
1192 _, state, err := a.beginRunTurn(ctx, input, pinned)
1193 if err != nil {
1194 return err
1195 }
1196 if a.pending.forkRestore != nil {
1197 a.pending.forkRestore(state)
1198 }
1199 state.runMaxSteps = runMaxSteps
1200 state.runMaxStepsKey = runMaxStepsKey
1201 state.workDurationMs = workDurationMs
1202 ctx = runtimepolicy.WithContext(ctx, a.turn.constraints)
1203 ctx = runtimepolicy.WithInherited(ctx, runtimepolicy.InheritedExecutionContext{
1204 Constraints: a.turn.constraints,
1205 PlanReadOnly: a.planMode.Load() || a.readOnlyExecution,
1206 GoalScopeID: a.task.scopeID,
1207 })
1208 return a.runToolLoop(ctx, state)
1209 }
1210
1211 // ReadinessResult is the host-consumable outcome of the Delivery final-answer
1212 // readiness check. The Controller reads it after each Goal/approved-Plan turn;
1213 // plain Standard turns end after the visible answer and do not enter the Goal
1214 // continuation path.
1215 type ReadinessResult struct {
1216 // Ready is true when no missing requirement remains.
1217 Ready bool
1218 // Missing lists stable category ids of the missing requirements
1219 // (project_check, todo, criteria, verification, review, signoff, action,
1220 // mutation, capability). Empty when Ready.
1221 Missing []string
1222 // Reason is the user-facing summary of what is still missing.
1223 Reason string
1224 // ProgressKey is the host-verifiable progress signature of the current
1225 // evidence state. Identical ProgressKey across consecutive goal turns
1226 // means no host-observable progress was made.
1227 ProgressKey string
1228 }
1229
1230 // ReadinessResult returns the current final-readiness outcome for the host.
1231 func (a *Agent) ReadinessResult() ReadinessResult {
1232 // Compatibility query only: quality assessments are no longer execution
1233 // conditions. Historical missing checks remain in their original records.
1234 return ReadinessResult{Ready: true}
1235 }
1236
1237 // DeliveryCheckpoint returns the compact Goal-scoped delivery state. It is safe
1238 // to persist next to the Goal sidecar because it contains no raw arguments.
1239 func (a *Agent) DeliveryCheckpoint() evidence.DeliveryCheckpoint {
1240 return a.task.checkpoint
1241 }
1242
1243 // RestoreDeliveryCheckpoint seeds a rebuilt controller before its next Goal
1244 // run. A mismatched/empty scope is ignored conservatively.
1245 func (a *Agent) RestoreDeliveryCheckpoint(checkpoint evidence.DeliveryCheckpoint) {
1246 checkpoint.ScopeID = strings.TrimSpace(checkpoint.ScopeID)
1247 if checkpoint.ScopeID == "" {
1248 return
1249 }
1250 a.task.checkpoint = checkpoint
1251 a.task.scopeID = checkpoint.ScopeID
1252 }
1253
1254 func (a *Agent) updateDeliveryCheckpoint(runErr error) {
1255 if !a.turn.deliveryScopeActive || a.task.scopeID == "" || a.task.ledger == nil {
1256 return
1257 }
1258 cp := a.task.checkpoint
1259 if cp.ScopeID != a.task.scopeID {
1260 cp = evidence.DeliveryCheckpoint{ScopeID: a.task.scopeID}
1261 }
1262 cp.WorkObserved = cp.WorkObserved || a.task.ledger.HasSuccessfulWorkReceipt()
1263 if _, ok := a.task.ledger.LatestSuccessfulMutationIndex(); ok {
1264 cp.MutationObserved = true
1265 }
1266 if a.task.ledger.HasSuccessfulToolReceipt("remember") && !a.task.ledger.HasSuccessfulMutationOtherThan("remember") {
1267 cp.MutationObserved = true
1268 }
1269 a.task.checkpoint = cp
1270 }
1271
1272 func (a *Agent) setTodoState(todos []evidence.TodoItem) {
1273 a.sess.todoMu.Lock()
1274 a.sess.todoState = append([]evidence.TodoItem(nil), todos...)
1275 a.sess.todoWritten = true
1276 a.sess.todoMu.Unlock()
1277 }
1278
1279 func executorHandoffRetryMessage() string {
1280 return `You are already in the executor phase. The planner's read-only limitations do not apply to you.
1281
1282 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.
1283
1284 Do not answer as the planner and do not ask how to trigger the executor.
1285 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.`
1286 }
1287
1288 func hasVisibleFinalAnswer(text string) bool {
1289 return strings.TrimSpace(text) != ""
1290 }
1291
1292 func emptyFinalRetryMessage() string {
1293 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."
1294 }
1295
1296 func emptyFinalNotice() string {
1297 return i18n.M.EmptyFinal
1298 }
1299
1300 func emptyFinalNoticeDetail(prov string, u *provider.Usage, reasoningLen int) string {
1301 finish := "unknown"
1302 if u != nil && u.FinishReason != "" {
1303 finish = u.FinishReason
1304 }
1305 return fmt.Sprintf("empty final answer blocked: %s returned no visible answer text (finish=%s, reasoning=%d chars); retrying", prov, finish, reasoningLen)
1306 }
1307
1308 func toolBudgetNoticeText() string {
1309 return i18n.M.ToolBudget
1310 }
1311
1312 // stream runs one completion, emitting reasoning and text deltas as typed
1313 // events and collecting complete tool calls. A Message event closes the text
1314 // stream so a sink can re-render the streamed raw text as styled markdown. The
1315 // accumulated text and reasoning are also returned so the caller can round-trip
1316 // reasoning on the next turn.
1317 //
1318 // When frozen is non-nil, the request is not rebuilt from session — retries
1319 // must replay the same provider-visible body.
1320 func (a *Agent) stream(ctx context.Context, turn int, sink event.Sink) streamedTurn {
1321 return a.streamWithFrozen(ctx, turn, sink, nil, "")
1322 }
1323
1324 func (a *Agent) streamWithFrozen(ctx context.Context, turn int, sink event.Sink, frozen *samplingRequest, attemptID string) streamedTurn {
1325 ctx = provider.WithRetryNotify(ctx, func(info provider.RetryInfo) {
1326 sink.Emit(event.Event{Kind: event.Retrying, RetryAttempt: info.Attempt, RetryMax: info.Max, RetryScope: event.RetryScopeHeaders})
1327 })
1328 // Reuse a parent attempt counter when present so stream retries accumulate
1329 // into one RequestCount; otherwise install a fresh counter for this call.
1330 ctx = provider.WithRequestAttemptCounter(ctx)
1331 // A stream can terminate locally before the provider channel closes (for
1332 // example when the client-side reasoning guard fires). Own a child context
1333 // here so every return path aborts the HTTP request and releases the provider
1334 // reader instead of leaving generation and billing running in the background.
1335 ctx, cancel := context.WithCancel(ctx)
1336 defer cancel()
1337
1338 var req provider.Request
1339 var err error
1340 if frozen != nil {
1341 req = freezeProviderRequest(frozen.req)
1342 } else {
1343 prepared, perr := a.prepareSamplingRequest(ctx)
1344 if perr != nil {
1345 return streamedTurn{err: perr}
1346 }
1347 req = prepared.req
1348 }
1349 // Host stream cancels on generation drain (OpenAI/Anthropic HTTP reads).
1350 defer trackPublishedHostStream(ctx, cancel)()
1351 ch, err := a.streamProviderRequest(ctx, req)
1352 if err != nil {
1353 return streamedTurn{usage: provider.UsageWithRequestAttemptCount(ctx, nil), err: err}
1354 }
1355
1356 // A PostLLMCall hook rewrites the whole reasoning block, so when one is wired
1357 // up we buffer reasoning silently and emit the transformed text once after the
1358 // stream. With no such hook the reasoning streams live, chunk by chunk, as
1359 // before — the common case must not lose its live "thinking…" display.
1360 transformReasoning := a.svc.hooks != nil && a.svc.hooks.HasPostLLMCall()
1361
1362 var text, reasoning strings.Builder
1363 meta := reasoningStreamMeta{complete: true}
1364 var calls []provider.ToolCall
1365 var responsesItems []json.RawMessage
1366 search := newSearchTurn()
1367 var partialCalls []provider.ToolCall
1368 var usage *provider.Usage
1369 var partialToolStarted bool
1370 var maxArgChars int
1371 var lastArgProgress time.Time
1372 // collect packages the stream state accumulated so far; stored is the
1373 // finishReasoning output that becomes the round-tripped reasoning.
1374 collect := func(stored string, err error) streamedTurn {
1375 return streamedTurn{
1376 text: text.String(), reasoning: stored, signature: meta.signature,
1377 reasoningID: meta.id, reasoningStatus: meta.status, reasoningComplete: meta.complete,
1378 reasoningState: meta.state, thinkingBlocks: meta.blocks,
1379 calls: calls, responsesItems: responsesItems, serverSearch: search.calls, usage: usage,
1380 partialToolStarted: partialToolStarted, partialCalls: partialCalls,
1381 maxArgChars: maxArgChars, err: err,
1382 }
1383 }
1384 finishReasoning := func() (stored, display string) {
1385 original := reasoning.String()
1386 display = original
1387 if transformReasoning && original != "" {
1388 display = a.svc.hooks.PostLLMCall(ctx, original, turn)
1389 if display != "" {
1390 sink.Emit(event.Event{Kind: event.Reasoning, Text: display})
1391 }
1392 }
1393 stored = display
1394 if a.preserveRawReasoning(original, meta.signature, meta.id, meta.status, calls, search.calls) {
1395 stored = original
1396 }
1397 return stored, display
1398 }
1399 for {
1400 var chunk provider.Chunk
1401 // Cancellation wins over already buffered provider tokens.
1402 if ctx.Err() != nil {
1403 stored, _ := finishReasoning()
1404 usage = provider.UsageWithRequestAttemptCount(ctx, bestEffortStreamUsage(usage, text.Len(), reasoning.Len(), "interrupted"))
1405 return collect(stored, ctx.Err())
1406 }
1407 select {
1408 case <-ctx.Done():
1409 stored, _ := finishReasoning()
1410 usage = bestEffortStreamUsage(usage, text.Len(), reasoning.Len(), "interrupted")
1411 usage = provider.UsageWithRequestAttemptCount(ctx, usage)
1412 return collect(stored, ctx.Err())
1413 case c, ok := <-ch:
1414 if !ok {
1415 if err := ctx.Err(); err != nil {
1416 stored, _ := finishReasoning()
1417 usage = bestEffortStreamUsage(usage, text.Len(), reasoning.Len(), "interrupted")
1418 usage = provider.UsageWithRequestAttemptCount(ctx, usage)
1419 return collect(stored, err)
1420 }
1421 stored, display := finishReasoning()
1422 // provider.response: extensions rule on the assembled terminal
1423 // response before it is persisted. A replacement becomes the
1424 // visible assistant turn (the user's transcript); a block fails
1425 // the turn.
1426 providerSignature := meta.signature
1427 finalText, finalReasoning, finalSignature, calls, usage, err := a.interceptProviderResponse(
1428 ctx, text.String(), stored, meta.signature, calls, usage)
1429 if err != nil {
1430 return streamedTurn{partialToolStarted: partialToolStarted, partialCalls: partialCalls, maxArgChars: maxArgChars, err: err}
1431 }
1432 // Responses reasoning IDs/status and Anthropic signatures are
1433 // provider-bound metadata. Never attach the provider's metadata
1434 // to reasoning that an extension replaced.
1435 if finalReasoning != stored || finalSignature != providerSignature {
1436 meta.id, meta.status = "", ""
1437 meta.blocks = nil
1438 responsesItems = provider.WithoutResponsesReasoning(responsesItems)
1439 }
1440 if finalReasoning != stored {
1441 // The extension replaced the reasoning: what is persisted
1442 // and what the closing Message event re-renders must agree.
1443 display = finalReasoning
1444 }
1445 usage = provider.UsageWithRequestAttemptCount(ctx, usage)
1446 // A clean terminal never reports partialToolStarted: the calls
1447 // slice is now authoritative and the partial cards were merged.
1448 return streamedTurn{
1449 displayReasoning: display,
1450 text: finalText, reasoning: finalReasoning, signature: finalSignature,
1451 reasoningID: meta.id, reasoningStatus: meta.status,
1452 reasoningComplete: meta.complete,
1453 reasoningState: meta.state, thinkingBlocks: meta.blocks,
1454 calls: calls, responsesItems: responsesItems, serverSearch: search.calls, usage: usage,
1455 partialCalls: partialCalls, maxArgChars: maxArgChars,
1456 }
1457 }
1458 chunk = c
1459 }
1460 switch chunk.Type {
1461 case provider.ChunkReasoning:
1462 meta.ingest(chunk, &reasoning, a.reasoningByteLimit)
1463 if chunk.Text != "" && !transformReasoning {
1464 sink.Emit(event.Event{Kind: event.Reasoning, Text: chunk.Text})
1465 }
1466 case provider.ChunkText:
1467 text.WriteString(chunk.Text)
1468 sink.Emit(event.Event{Kind: event.Text, Text: chunk.Text})
1469 case provider.ChunkToolCallStart:
1470 partialToolStarted = true
1471 // Surface the tool card as soon as the call begins — before its
1472 // (possibly large) arguments finish streaming — so the user sees it
1473 // working instead of a stall. executeBatch emits the full dispatch
1474 // (with args) once the call completes; the frontend merges by ID.
1475 if tc := chunk.ToolCall; tc != nil {
1476 partialCalls = upsertPartialToolCall(partialCalls, *tc)
1477 sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{
1478 ID: tc.ID, Name: tc.Name, ReadOnly: a.toolReadOnly(tc.Name), Partial: true, AttemptID: attemptID,
1479 }})
1480 }
1481 case provider.ChunkToolCallArgsDelta:
1482 partialToolStarted = true
1483 // Liveness ticks while a large argument payload streams: re-emit the
1484 // partial dispatch with the cumulative size (time-throttled) so the
1485 // UI can show progress instead of a dead counter for the duration of
1486 // a 30KB write_file body.
1487 if chunk.ArgChars > maxArgChars {
1488 maxArgChars = chunk.ArgChars
1489 }
1490 if tc := chunk.ToolCall; tc != nil && time.Since(lastArgProgress) >= 250*time.Millisecond {
1491 partialCalls = upsertPartialToolCall(partialCalls, *tc)
1492 lastArgProgress = time.Now()
1493 sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{
1494 ID: tc.ID, Name: tc.Name, ReadOnly: a.toolReadOnly(tc.Name), Partial: true, ArgChars: chunk.ArgChars, AttemptID: attemptID,
1495 }})
1496 }
1497 case provider.ChunkToolCall:
1498 partialToolStarted = true
1499 if chunk.ToolCall != nil {
1500 calls = append(calls, *chunk.ToolCall)
1501 partialCalls = upsertPartialToolCall(partialCalls, *chunk.ToolCall)
1502 if n := len(chunk.ToolCall.Arguments); n > maxArgChars {
1503 maxArgChars = n
1504 }
1505 }
1506 case provider.ChunkResponsesItem:
1507 responsesItems = meta.ingestResponsesItem(responsesItems, chunk.ResponsesItem, a.reasoningByteLimit)
1508 case provider.ChunkServerSearch:
1509 search.onChunk(sink, chunk, attemptID)
1510 case provider.ChunkUsage:
1511 usage, a.turn.lastReasoning = chunk.Usage, chunk.Usage.ReasoningTokens
1512 a.storeLatestRequestUsage(chunk.Usage)
1513 a.sess.cacheHit.Add(int64(chunk.Usage.CacheHitTokens))
1514 a.sess.cacheMiss.Add(int64(chunk.Usage.CacheMissTokens))
1515 case provider.ChunkError:
1516 if provider.IsStreamInterrupted(chunk.Err) {
1517 stored, _ := finishReasoning()
1518 usage = bestEffortStreamUsage(usage, text.Len(), reasoning.Len(), "interrupted")
1519 usage = provider.UsageWithRequestAttemptCount(ctx, usage)
1520 st := collect(stored, chunk.Err)
1521 st.interrupted = true
1522 return st
1523 }
1524 stored, _ := finishReasoning()
1525 if errors.Is(chunk.Err, context.Canceled) || errors.Is(chunk.Err, context.DeadlineExceeded) {
1526 usage = bestEffortStreamUsage(usage, text.Len(), reasoning.Len(), "interrupted")
1527 }
1528 usage = provider.UsageWithRequestAttemptCount(ctx, usage)
1529 return collect(stored, chunk.Err)
1530 }
1531 }
1532 }
1533
1534 func boundReasoningReplay(reasoning *strings.Builder, latest string, byteLimit int, complete bool) bool {
1535 if byteLimit <= 0 || reasoning.Len() <= byteLimit {
1536 return complete
1537 }
1538 reasoning.Reset()
1539 reasoning.WriteString(snapToRuneBoundary(latest, 0, min(len(latest), byteLimit)))
1540 return false
1541 }
1542
1543 func bestEffortStreamUsage(current *provider.Usage, textBytes, reasoningBytes int, finishReason string) *provider.Usage {
1544 if current == nil && textBytes == 0 && reasoningBytes == 0 {
1545 return nil
1546 }
1547 var usage provider.Usage
1548 usage.Unknown = current == nil
1549 if current != nil {
1550 usage = *current
1551 }
1552 if finishReason != "" {
1553 usage.FinishReason = finishReason
1554 }
1555 reasoningTokens := estimateTokensFromBytes(reasoningBytes)
1556 textTokens := estimateTokensFromBytes(textBytes)
1557 completionTokens := reasoningTokens + textTokens
1558 if usage.ReasoningTokens < reasoningTokens {
1559 usage.ReasoningTokens = reasoningTokens
1560 usage.Estimated = true
1561 }
1562 if usage.CompletionTokens < completionTokens {
1563 usage.CompletionTokens = completionTokens
1564 usage.Estimated = true
1565 }
1566 if minTotal := usage.PromptTokens + usage.CompletionTokens; usage.TotalTokens < minTotal {
1567 usage.TotalTokens = minTotal
1568 usage.Estimated = true
1569 }
1570 return &usage
1571 }
1572
1573 func estimateTokensFromBytes(n int) int {
1574 if n <= 0 {
1575 return 0
1576 }
1577 tokens := n / 4
1578 if n%4 != 0 {
1579 tokens++
1580 }
1581 if tokens <= 0 {
1582 return 1
1583 }
1584 return tokens
1585 }
1586
1587 func upsertPartialToolCall(calls []provider.ToolCall, call provider.ToolCall) []provider.ToolCall {
1588 for i := range calls {
1589 if call.ID != "" && calls[i].ID == call.ID {
1590 calls[i] = call
1591 return calls
1592 }
1593 }
1594 return append(calls, call)
1595 }
1596
1597 func (a *Agent) capturePrefixShape(schemas []provider.ToolSchema) PrefixShape {
1598 return captureTurnContextShape(a.systemPrompt(), schemas, a.sess.conversation.RewriteVersion(), a.modelVisibleMessages())
1599 }
1600
1601 func (a *Agent) systemPrompt() string {
1602 var b strings.Builder
1603 for _, m := range a.sess.conversation.Messages {
1604 if m.Role != provider.RoleSystem {
1605 continue
1606 }
1607 if b.Len() > 0 {
1608 b.WriteByte('\n')
1609 }
1610 b.WriteString(m.Content)
1611 }
1612 return b.String()
1613 }
1614
1615 func toEventShellExecution(in *tool.ShellExecution, durationMs int64) *event.ShellExecution {
1616 if in == nil {
1617 return nil
1618 }
1619 out := &event.ShellExecution{
1620 Kind: in.Kind,
1621 Shell: in.Shell,
1622 ShellVersion: in.ShellVersion,
1623 Platform: in.Platform,
1624 SupportsAndAnd: in.SupportsAndAnd,
1625 State: in.State,
1626 FailurePhase: in.FailurePhase,
1627 OutputTail: in.OutputTail,
1628 MutationRisk: in.MutationRisk,
1629 Verification: in.Verification,
1630 DurationMs: in.DurationMs,
1631 }
1632 if out.DurationMs == 0 && durationMs > 0 {
1633 out.DurationMs = durationMs
1634 }
1635 if in.ExitCode != nil {
1636 code := *in.ExitCode
1637 out.ExitCode = &code
1638 }
1639 return out
1640 }
1641
1642 func toProviderToolExecution(in *tool.ShellExecution) *provider.ToolExecution {
1643 if in == nil {
1644 return nil
1645 }
1646 out := &provider.ToolExecution{
1647 Kind: in.Kind,
1648 Shell: in.Shell,
1649 ShellVersion: in.ShellVersion,
1650 Platform: in.Platform,
1651 SupportsAndAnd: in.SupportsAndAnd,
1652 State: in.State,
1653 FailurePhase: in.FailurePhase,
1654 OutputTail: in.OutputTail,
1655 MutationRisk: in.MutationRisk,
1656 Verification: in.Verification,
1657 DurationMs: in.DurationMs,
1658 }
1659 if in.ExitCode != nil {
1660 code := *in.ExitCode
1661 out.ExitCode = &code
1662 }
1663 return out
1664 }
1665
1666 func (a *Agent) emitFullToolDispatch(ctx context.Context, c provider.ToolCall, refreshed bool) error {
1667 t, _, ambiguous := a.svc.tools.ResolveCall(c.Name)
1668 ok := t != nil && len(ambiguous) == 0
1669 ev := event.Tool{ID: c.ID, Name: c.Name, Args: c.Arguments, ReadOnly: ok && t.ReadOnly(), Refreshed: refreshed, RunState: provider.ToolRunPending}
1670 ev.FileDiff = event.FileDiff{Diff: c.Diff, Added: c.Added, Removed: c.Removed}
1671 if ok && ev.Diff == "" && ev.Added == 0 && ev.Removed == 0 {
1672 if ch, ok := tool.PreviewChange(ctx, t, json.RawMessage(c.Arguments)); ok {
1673 ev.FileDiff = event.FileDiff{Diff: ch.Diff, Added: ch.Added, Removed: ch.Removed}
1674 }
1675 }
1676 if ok {
1677 if pr, ok := t.(interface {
1678 ResolveProfile(json.RawMessage) *event.Profile
1679 }); ok {
1680 ev.Profile = pr.ResolveProfile(json.RawMessage(c.Arguments))
1681 }
1682 }
1683 return event.EmitChecked(a.svc.sink, event.Event{Kind: event.ToolDispatch, MessageID: messageIdentity(ctx), Tool: ev})
1684 }
1685
1686 // emitResolvedToolDispatch upserts the real target classification of a stable
1687 // proxy call without changing the provider-visible Name/Args. Append-only sinks
1688 // ignore Refreshed events; stateful frontends replace the existing card by ID.
1689 func (a *Agent) emitResolvedToolDispatch(ctx context.Context, c provider.ToolCall) {
1690 if c.ResolvedReadOnly == nil {
1691 return
1692 }
1693 if c.ResolvedName != "" && c.ResolvedName != c.Name {
1694 EmitProxyAudit(a.svc.sink, tool.ResolvedCall{
1695 DisplayName: c.Name,
1696 TargetName: c.ResolvedName,
1697 CapabilityID: c.CapabilityID,
1698 }, c.ID)
1699 }
1700 a.svc.sink.Emit(event.Event{Kind: event.ToolDispatch, MessageID: messageIdentity(ctx), Tool: event.Tool{
1701 ID: c.ID,
1702 Name: c.Name,
1703 Args: c.Arguments,
1704 ResolvedName: c.ResolvedName,
1705 CapabilityID: c.CapabilityID,
1706 ReadOnly: *c.ResolvedReadOnly,
1707 Refreshed: true,
1708 FileDiff: event.FileDiff{
1709 Diff: c.Diff, Added: c.Added, Removed: c.Removed,
1710 },
1711 }})
1712 }
1713
1714 // refreshCurrentFileDiff recomputes a writer preview against the state left by
1715 // earlier successful writers in the same provider batch. Preview failures clear
1716 // any stale initial diff; a later Execute will then fail or ask for recovery
1717 // without presenting the user with a preview that no longer describes disk.
1718 func refreshCurrentFileDiff(ctx context.Context, t tool.Tool, call provider.ToolCall) (provider.ToolCall, bool) {
1719 pv, ok := t.(tool.Previewer)
1720 if !ok {
1721 return call, false
1722 }
1723 refreshed := call
1724 refreshed.Diff = ""
1725 refreshed.Added = 0
1726 refreshed.Removed = 0
1727 if change, err := pv.Preview(ctx, json.RawMessage(call.Arguments)); err == nil {
1728 refreshed.Diff = change.Diff
1729 refreshed.Added = change.Added
1730 refreshed.Removed = change.Removed
1731 }
1732 changed := refreshed.Diff != call.Diff || refreshed.Added != call.Added || refreshed.Removed != call.Removed
1733 return refreshed, changed
1734 }
1735
1736 func (a *Agent) withPreviewFileDiffs(ctx context.Context, calls []provider.ToolCall) []provider.ToolCall {
1737 if len(calls) == 0 {
1738 return calls
1739 }
1740 out := make([]provider.ToolCall, len(calls))
1741 copy(out, calls)
1742 for i := range out {
1743 if out[i].Diff != "" || out[i].Added != 0 || out[i].Removed != 0 {
1744 continue
1745 }
1746 t, _, ambiguous := a.svc.tools.ResolveCall(out[i].Name)
1747 ok := t != nil && len(ambiguous) == 0
1748 if !ok {
1749 continue
1750 }
1751 if ch, ok := tool.PreviewChange(ctx, t, json.RawMessage(out[i].Arguments)); ok {
1752 out[i].Diff = ch.Diff
1753 out[i].Added = ch.Added
1754 out[i].Removed = ch.Removed
1755 }
1756 }
1757 return out
1758 }
1759
1760 // completedMCPConnect recognizes a synthetic cache-miss connect call whose
1761 // background discovery finished after the provider request was serialized. The
1762 // connect placeholder is intentionally absent once real tools replace it, but
1763 // the already-advertised call still completed its only job and must not surface
1764 // as an unknown tool.
1765 func completedMCPConnect(reg *tool.Registry, name string) (string, bool) {
1766 server, rawName, ok := tool.SplitMCPName(name)
1767 if !ok || rawName != "connect" {
1768 return "", false
1769 }
1770 prefix := tool.MCPNamePrefix + server + "__"
1771 for _, current := range reg.Names() {
1772 if current != name && strings.HasPrefix(current, prefix) {
1773 return server, true
1774 }
1775 }
1776 return "", false
1777 }
1778
1779 func (a *Agent) readOnlyExecutionBlock(visible tool.Tool, resolved *tool.ResolvedCall) (toolOutcome, bool) {
1780 if a == nil || !a.readOnlyExecution {
1781 return toolOutcome{}, false
1782 }
1783 block := func(reason string) (toolOutcome, bool) {
1784 return toolOutcome{
1785 output: "blocked: read-only agent cannot " + reason,
1786 blocked: true,
1787 errMsg: "blocked by read-only execution boundary",
1788 }, true
1789 }
1790 // Destructive MCP is left for the Executor; Planner must not misread this
1791 // as missing configuration or an unavailable MCP server.
1792 blockDestructiveForExecutor := func(name string) (toolOutcome, bool) {
1793 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."
1794 return toolOutcome{
1795 output: msg,
1796 blocked: true,
1797 errMsg: "blocked: destructive MCP reserved for executor",
1798 }, true
1799 }
1800 if resolved == nil {
1801 if a.plannerMCPExecution && isMCPExecutionTarget(visible, "") {
1802 if !mcpServerAuthorized(visible) {
1803 return block("execute an MCP capability from an unauthorized server")
1804 }
1805 if readOnlyExecutionMCPDestructive(visible) {
1806 return blockDestructiveForExecutor(visible.Name())
1807 }
1808 return toolOutcome{}, false
1809 }
1810 if visible == nil || !visible.ReadOnly() {
1811 if reasoner, ok := visible.(tool.ReadOnlyExecutionBlockReason); ok && strings.TrimSpace(reasoner.ReadOnlyExecutionBlockReason()) != "" {
1812 return block(reasoner.ReadOnlyExecutionBlockReason())
1813 }
1814 return block("execute a state-changing tool")
1815 }
1816 if isInstalledMCPTool(visible) && !mcpServerAuthorized(visible) {
1817 return block("execute a reader from an unauthorized MCP server")
1818 }
1819 if readOnlyExecutionMCPDestructive(visible) {
1820 return block("execute a destructive MCP capability")
1821 }
1822 if h, ok := visible.(tool.ReadOnlyExecutionHostMutation); ok && h.ReadOnlyExecutionHostMutation() && !readOnlyExecutionAllowsMCPStartup(visible) {
1823 return block("start or mutate a host capability")
1824 }
1825 return toolOutcome{}, false
1826 }
1827
1828 switch resolved.ProxyAction {
1829 case "list", "inspect":
1830 if !resolved.SkipExecute || resolved.Target != nil || !resolved.ReadOnly {
1831 return block("execute a malformed dynamic inspection")
1832 }
1833 return toolOutcome{}, false
1834 case "decline":
1835 return block("decline a capability decision")
1836 case "call":
1837 if resolved.Target == nil {
1838 if a.plannerMCPExecution && resolved.HostCompleted && resolved.SkipExecute && resolved.ReadOnly && !resolved.Unavailable {
1839 if _, ok := parseMCPServerCapabilityID(resolved.CapabilityID); ok {
1840 return toolOutcome{}, false
1841 }
1842 }
1843 return block("execute an unresolved dynamic capability")
1844 }
1845 if a.plannerMCPExecution && plannerAllowsMCPTarget(resolved.Target, resolved.TargetName) {
1846 if isMCPLifecycleConnectTarget(resolved.Target) {
1847 if !plannerMCPConnectAllowed(resolved.Target) {
1848 return block("start an unauthorized MCP server")
1849 }
1850 } else if !mcpServerAuthorized(resolved.Target) {
1851 return block("execute an MCP capability from an unauthorized server")
1852 }
1853 if readOnlyExecutionMCPDestructive(resolved.Target) {
1854 name := resolved.TargetName
1855 if name == "" {
1856 name = resolved.CapabilityID
1857 }
1858 return blockDestructiveForExecutor(name)
1859 }
1860 return toolOutcome{}, false
1861 }
1862 if !resolved.ReadOnly {
1863 if reasoner, ok := resolved.Target.(tool.ReadOnlyExecutionBlockReason); ok && strings.TrimSpace(reasoner.ReadOnlyExecutionBlockReason()) != "" {
1864 return block(reasoner.ReadOnlyExecutionBlockReason())
1865 }
1866 return block("execute a state-changing dynamic capability")
1867 }
1868 if isInstalledMCPTool(resolved.Target) && !mcpServerAuthorized(resolved.Target) {
1869 return block("execute a dynamic reader from an unauthorized MCP server")
1870 }
1871 if readOnlyExecutionMCPDestructive(resolved.Target) {
1872 return block("execute a destructive MCP capability")
1873 }
1874 if h, ok := resolved.Target.(tool.ReadOnlyExecutionHostMutation); ok && h.ReadOnlyExecutionHostMutation() && !readOnlyExecutionAllowsMCPStartup(resolved.Target) {
1875 return block("start or mutate a host capability")
1876 }
1877 return toolOutcome{}, false
1878 default:
1879 return block("execute an unknown dynamic capability action")
1880 }
1881 }
1882
1883 func readOnlyExecutionMCPDestructive(t tool.Tool) bool {
1884 return mcpDestructiveHint(t)
1885 }
1886
1887 func readOnlyExecutionAllowsMCPStartup(t tool.Tool) bool {
1888 if t == nil || !t.ReadOnly() || readOnlyExecutionMCPDestructive(t) {
1889 return false
1890 }
1891 if !mcpServerAuthorized(t) {
1892 return false
1893 }
1894 meta, ok := t.(tool.MCPMetadata)
1895 if !ok || strings.TrimSpace(meta.MCPServerName()) == "" || strings.TrimSpace(meta.MCPRawToolName()) == "" {
1896 return false
1897 }
1898 return true
1899 }
1900
1901 // plannerAllowsMCPTarget reports whether a resolved use_capability target is an
1902 // MCP tool or lifecycle connect that Planner may consider under
1903 // PlannerMCPExecution (authorization and destructive checks run separately).
1904 func plannerAllowsMCPTarget(t tool.Tool, targetName string) bool {
1905 if t == nil {
1906 return false
1907 }
1908 if isInstalledMCPTool(t) || isMCPLifecycleConnectTarget(t) {
1909 return true
1910 }
1911 return isMCPExecutionTarget(t, targetName)
1912 }
1913
1914 // isMCPLifecycleConnectTarget identifies on-demand MCP connect-and-list targets
1915 // (mcp_connect__<server>) used by use_capability action=call on mcp-server ids.
1916 func isMCPLifecycleConnectTarget(t tool.Tool) bool {
1917 if t == nil {
1918 return false
1919 }
1920 if _, ok := t.(mcpLifecycleConnect); ok {
1921 return true
1922 }
1923 name := strings.TrimSpace(t.Name())
1924 return strings.HasPrefix(name, "mcp_connect__")
1925 }
1926
1927 // mcpLifecycleConnect is implemented by deferred connect targets so Planner
1928 // can authorize lifecycle actions without relying on name prefixes alone.
1929 type mcpLifecycleConnect interface {
1930 MCPLifecycleConnect() bool
1931 MCPServerAuthorized() bool
1932 }
1933
1934 func plannerMCPConnectAllowed(t tool.Tool) bool {
1935 if life, ok := t.(mcpLifecycleConnect); ok {
1936 return life.MCPServerAuthorized()
1937 }
1938 return mcpServerAuthorized(t)
1939 }
1940
1941 func isInstalledMCPTool(t tool.Tool) bool {
1942 meta, ok := t.(tool.MCPMetadata)
1943 return ok && strings.TrimSpace(meta.MCPServerName()) != "" && strings.TrimSpace(meta.MCPRawToolName()) != ""
1944 }
1945
1946 func isMCPExecutionTarget(t tool.Tool, name string) bool {
1947 return isInstalledMCPTool(t) || strings.HasPrefix(strings.TrimSpace(name), "mcp__")
1948 }
1949
1950 func mcpServerAuthorized(t tool.Tool) bool {
1951 authority, ok := t.(tool.MCPServerAuthorization)
1952 return ok && authority.MCPServerAuthorized()
1953 }
1954
1955 func mcpDestructiveHint(t tool.Tool) bool {
1956 annotations, ok := t.(tool.MCPAnnotations)
1957 return ok && annotations.MCPDestructiveHint()
1958 }
1959
1960 func (a *Agent) planModeDecision(toolName string, readOnly bool, safety planmode.PlanSafety, args json.RawMessage) planmode.Decision {
1961 return (planmode.Policy{}).Decide(planmode.Call{
1962 Name: toolName,
1963 ReadOnly: readOnly,
1964 Safety: safety,
1965 Args: args,
1966 })
1967 }
1968
1969 // isBackgroundTaskCall reports whether a `task` call set run_in_background, so a
1970 // fire-and-return dispatch isn't mistaken for a sub-agent that has stopped.
1971 func isBackgroundTaskCall(args string) bool {
1972 var p struct {
1973 RunInBackground bool `json:"run_in_background"`
1974 }
1975 _ = json.Unmarshal([]byte(args), &p)
1976 return p.RunInBackground
1977 }
1978
1979 // toolReadOnly reports a tool's ReadOnly classification by name (false for an
1980 // unknown tool), for stamping early ToolDispatch events.
1981 func (a *Agent) toolReadOnly(name string) bool {
1982 t, _, ambiguous := a.svc.tools.ResolveCall(name)
1983 return t != nil && len(ambiguous) == 0 && t.ReadOnly()
1984 }
1985
1986 // firstLine returns s up to its first newline — a one-line failure summary for
1987 // the display Err, while the full error stays in the model-facing output.
1988 func firstLine(s string) string {
1989 if before, _, ok := strings.Cut(s, "\n"); ok {
1990 return before
1991 }
1992 return s
1993 }
1994
1995 // truncateToolOutput builds the stable provider-visible Content form for a tool
1996 // result. Under-cap bodies are byte-identical; over-cap bodies keep a tool-aware
1997 // preview while RawContent stores the full local original. read_file is special:
1998 // its preview is a contiguous prefix so an exact recovery cursor can never skip
1999 // source text that the model did not actually see.
2000 func truncateToolOutput(s string) (string, string) {
2001 return truncateToolOutputFor(s, "", "")
2002 }
2003
2004 // truncateToolOutputFor is the tool-aware provider-input limiter. toolName and
2005 // toolCallID populate the recovery marker.
2006 func truncateToolOutputFor(s, toolName, toolCallID string) (string, string) {
2007 if len(s) <= maxToolOutputBytes {
2008 return s, ""
2009 }
2010 if toolName == "read_file" {
2011 return truncateReadFileOutput(s, toolName, toolCallID)
2012 }
2013 strategy := snipStrategy{head: 40, tail: 40, headChars: 8000, tailChars: 8000}
2014 switch {
2015 case tool.IsShellToolName(toolName) || strings.Contains(toolName, "bash"):
2016 strategy = snipStrategy{head: 40, tail: 40, headChars: 8000, tailChars: 8000}
2017 case toolName == "read_file" || toolName == "web_fetch" || strings.Contains(toolName, "read"):
2018 strategy = snipStrategy{head: 120, tail: 12, headChars: 12000, tailChars: 2000}
2019 case toolName == "grep" || toolName == "glob" || toolName == "ls" || toolName == "list_dir":
2020 strategy = snipStrategy{head: 80, tail: 8, headChars: 10000, tailChars: 1000}
2021 }
2022 headKeep := strategy.headChars
2023 tailKeep := strategy.tailChars
2024 if headKeep+tailKeep > maxToolOutputBytes-512 {
2025 headKeep = maxToolOutputBytes * 2 / 3
2026 tailKeep = maxToolOutputBytes - headKeep - 512
2027 }
2028 if headKeep < 1024 {
2029 headKeep = maxToolOutputBytes / 2
2030 tailKeep = maxToolOutputBytes / 2
2031 }
2032 // Prefer more tail when the body looks like a failure.
2033 lower := strings.ToLower(s)
2034 if strings.Contains(lower, "error:") || strings.Contains(lower, "panic:") || strings.Contains(lower, "fatal:") {
2035 tailKeep = max(tailKeep, maxToolOutputBytes/3)
2036 if headKeep+tailKeep > maxToolOutputBytes-512 {
2037 headKeep = maxToolOutputBytes - 512 - tailKeep
2038 }
2039 }
2040 head := snapToRuneBoundary(s, 0, headKeep)
2041 tail := snapToRuneBoundary(s, len(s)-tailKeep, len(s))
2042 resultRef := toolResultRef(toolCallID, s)
2043 marker := toolOutputRecoveryMarker(toolName, toolCallID, resultRef, len(s), len(head)+len(tail))
2044 for range 3 {
2045 bodyLen := len(head) + len(marker) + len(tail)
2046 if bodyLen <= maxToolOutputBytes {
2047 break
2048 }
2049 overflow := bodyLen - maxToolOutputBytes
2050 trimHead := overflow / 2
2051 trimTail := overflow - trimHead
2052 if trimHead < len(head) {
2053 head = snapToRuneBoundary(head, 0, len(head)-trimHead)
2054 }
2055 if trimTail < len(tail) {
2056 tail = snapToRuneBoundary(tail, trimTail, len(tail))
2057 }
2058 marker = toolOutputRecoveryMarker(toolName, toolCallID, resultRef, len(s), len(head)+len(tail))
2059 }
2060 notice := fmt.Sprintf(i18n.M.ToolOutputTruncatedFmt, len(s)-len(head)-len(tail), len(s))
2061 return head + marker + tail, notice
2062 }
2063
2064 // finishReasonMessage maps an abnormal finish_reason to a one-line warning,
2065 // returning ok=false for the normal terminations ("stop", "tool_calls") and a
2066 // nil usage. The sink renders the message; the "! " prefix is presentation.
2067 func finishReasonMessage(u *provider.Usage) (string, bool) {
2068 if u == nil {
2069 return "", false
2070 }
2071 switch u.FinishReason {
2072 case "length":
2073 return i18n.M.FinishReasonLength, true
2074 case "content_filter":
2075 return i18n.M.FinishReasonContentFilter, true
2076 case "repetition_truncation":
2077 return i18n.M.FinishReasonRepetition, true
2078 default:
2079 return "", false
2080 }
2081 }
2082
2083 // streamInterruptNotice explains why a provider stream never reached a clean
2084 // terminal, in words a user can act on. Only the closed StreamInterrupt* enum
2085 // is rendered — the wrapped transport error can carry URLs or gateway bodies
2086 // and must not reach the transcript (#9560).
2087 func streamInterruptNotice(err error) (code, text string) {
2088 switch provider.StreamInterruptReason(err) {
2089 case provider.StreamInterruptIdleTimeout:
2090 return event.NoticeCodeStreamInterruptedIdleTimeout, i18n.M.StreamInterruptedIdleTimeout
2091 case provider.StreamInterruptPrematureEOF:
2092 return event.NoticeCodeStreamInterruptedPrematureEOF, i18n.M.StreamInterruptedPrematureEOF
2093 case provider.StreamInterruptConnectionReset:
2094 return event.NoticeCodeStreamInterruptedConnectionReset, i18n.M.StreamInterruptedConnectionReset
2095 default:
2096 return "", ""
2097 }
2098 }
2099
2099 lines GO