返回 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 // These bounds cover corrected tool arguments and protocol/context repairs.
47 // Transport failures never replay the failed request automatically.
48 const maxToolArgumentRepairs = 3
49 const maxSamplingAttempts = 4
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 automatic 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 automatic 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 // Reuse a parent counter so protocol/context repair requests accumulate into
1326 // one RequestCount; otherwise install a fresh counter for this call.
1327 ctx = provider.WithRequestAttemptCounter(ctx)
1328 // A stream can terminate locally before the provider channel closes (for
1329 // example when the client-side reasoning guard fires). Own a child context
1330 // here so every return path aborts the HTTP request and releases the provider
1331 // reader instead of leaving generation and billing running in the background.
1332 ctx, cancel := context.WithCancel(ctx)
1333 defer cancel()
1334
1335 var req provider.Request
1336 var err error
1337 if frozen != nil {
1338 req = freezeProviderRequest(frozen.req)
1339 } else {
1340 prepared, perr := a.prepareSamplingRequest(ctx)
1341 if perr != nil {
1342 return streamedTurn{err: perr}
1343 }
1344 req = prepared.req
1345 }
1346 // Host stream cancels on generation drain (OpenAI/Anthropic HTTP reads).
1347 defer trackPublishedHostStream(ctx, cancel)()
1348 ch, err := a.streamProviderRequest(ctx, req)
1349 if err != nil {
1350 return streamedTurn{usage: provider.UsageWithRequestAttemptCount(ctx, nil), err: err}
1351 }
1352
1353 // A PostLLMCall hook rewrites the whole reasoning block, so when one is wired
1354 // up we buffer reasoning silently and emit the transformed text once after the
1355 // stream. With no such hook the reasoning streams live, chunk by chunk, as
1356 // before — the common case must not lose its live "thinking…" display.
1357 transformReasoning := a.svc.hooks != nil && a.svc.hooks.HasPostLLMCall()
1358
1359 var text, reasoning strings.Builder
1360 meta := reasoningStreamMeta{complete: true}
1361 var calls []provider.ToolCall
1362 var responsesItems []json.RawMessage
1363 search := newSearchTurn()
1364 var partialCalls []provider.ToolCall
1365 var usage *provider.Usage
1366 var partialToolStarted bool
1367 var maxArgChars int
1368 var lastArgProgress time.Time
1369 // collect packages the stream state accumulated so far; stored is the
1370 // finishReasoning output that becomes the round-tripped reasoning.
1371 collect := func(stored string, err error) streamedTurn {
1372 return streamedTurn{
1373 text: text.String(), reasoning: stored, signature: meta.signature,
1374 reasoningID: meta.id, reasoningStatus: meta.status, reasoningComplete: meta.complete,
1375 reasoningState: meta.state, thinkingBlocks: meta.blocks,
1376 calls: calls, responsesItems: responsesItems, serverSearch: search.calls, usage: usage,
1377 partialToolStarted: partialToolStarted, partialCalls: partialCalls,
1378 maxArgChars: maxArgChars, err: err,
1379 }
1380 }
1381 finishReasoning := func() (stored, display string) {
1382 original := reasoning.String()
1383 display = original
1384 if transformReasoning && original != "" {
1385 display = a.svc.hooks.PostLLMCall(ctx, original, turn)
1386 if display != "" {
1387 sink.Emit(event.Event{Kind: event.Reasoning, Text: display})
1388 }
1389 }
1390 stored = display
1391 if a.preserveRawReasoning(original, meta.signature, meta.id, meta.status, calls, search.calls) {
1392 stored = original
1393 }
1394 return stored, display
1395 }
1396 for {
1397 var chunk provider.Chunk
1398 // Cancellation wins over already buffered provider tokens.
1399 if ctx.Err() != nil {
1400 stored, _ := finishReasoning()
1401 usage = provider.UsageWithRequestAttemptCount(ctx, bestEffortStreamUsage(usage, text.Len(), reasoning.Len(), "interrupted"))
1402 return collect(stored, ctx.Err())
1403 }
1404 select {
1405 case <-ctx.Done():
1406 stored, _ := finishReasoning()
1407 usage = bestEffortStreamUsage(usage, text.Len(), reasoning.Len(), "interrupted")
1408 usage = provider.UsageWithRequestAttemptCount(ctx, usage)
1409 return collect(stored, ctx.Err())
1410 case c, ok := <-ch:
1411 if !ok {
1412 if err := ctx.Err(); err != nil {
1413 stored, _ := finishReasoning()
1414 usage = bestEffortStreamUsage(usage, text.Len(), reasoning.Len(), "interrupted")
1415 usage = provider.UsageWithRequestAttemptCount(ctx, usage)
1416 return collect(stored, err)
1417 }
1418 stored, display := finishReasoning()
1419 // provider.response: extensions rule on the assembled terminal
1420 // response before it is persisted. A replacement becomes the
1421 // visible assistant turn (the user's transcript); a block fails
1422 // the turn.
1423 providerSignature := meta.signature
1424 finalText, finalReasoning, finalSignature, calls, usage, err := a.interceptProviderResponse(
1425 ctx, text.String(), stored, meta.signature, calls, usage)
1426 if err != nil {
1427 return streamedTurn{partialToolStarted: partialToolStarted, partialCalls: partialCalls, maxArgChars: maxArgChars, err: err}
1428 }
1429 // Responses reasoning IDs/status and Anthropic signatures are
1430 // provider-bound metadata. Never attach the provider's metadata
1431 // to reasoning that an extension replaced.
1432 if finalReasoning != stored || finalSignature != providerSignature {
1433 meta.id, meta.status = "", ""
1434 meta.blocks = nil
1435 responsesItems = provider.WithoutResponsesReasoning(responsesItems)
1436 }
1437 if finalReasoning != stored {
1438 // The extension replaced the reasoning: what is persisted
1439 // and what the closing Message event re-renders must agree.
1440 display = finalReasoning
1441 }
1442 usage = provider.UsageWithRequestAttemptCount(ctx, usage)
1443 // A clean terminal never reports partialToolStarted: the calls
1444 // slice is now authoritative and the partial cards were merged.
1445 return streamedTurn{
1446 displayReasoning: display,
1447 text: finalText, reasoning: finalReasoning, signature: finalSignature,
1448 reasoningID: meta.id, reasoningStatus: meta.status,
1449 reasoningComplete: meta.complete,
1450 reasoningState: meta.state, thinkingBlocks: meta.blocks,
1451 calls: calls, responsesItems: responsesItems, serverSearch: search.calls, usage: usage,
1452 partialCalls: partialCalls, maxArgChars: maxArgChars,
1453 }
1454 }
1455 chunk = c
1456 }
1457 switch chunk.Type {
1458 case provider.ChunkReasoning:
1459 meta.ingest(chunk, &reasoning, a.reasoningByteLimit)
1460 if chunk.Text != "" && !transformReasoning {
1461 sink.Emit(event.Event{Kind: event.Reasoning, Text: chunk.Text})
1462 }
1463 case provider.ChunkText:
1464 text.WriteString(chunk.Text)
1465 sink.Emit(event.Event{Kind: event.Text, Text: chunk.Text})
1466 case provider.ChunkToolCallStart:
1467 partialToolStarted = true
1468 // Surface the tool card as soon as the call begins — before its
1469 // (possibly large) arguments finish streaming — so the user sees it
1470 // working instead of a stall. executeBatch emits the full dispatch
1471 // (with args) once the call completes; the frontend merges by ID.
1472 if tc := chunk.ToolCall; tc != nil {
1473 partialCalls = upsertPartialToolCall(partialCalls, *tc)
1474 sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{
1475 ID: tc.ID, Name: tc.Name, ReadOnly: a.toolReadOnly(tc.Name), Partial: true, AttemptID: attemptID,
1476 }})
1477 }
1478 case provider.ChunkToolCallArgsDelta:
1479 partialToolStarted = true
1480 // Liveness ticks while a large argument payload streams: re-emit the
1481 // partial dispatch with the cumulative size (time-throttled) so the
1482 // UI can show progress instead of a dead counter for the duration of
1483 // a 30KB write_file body.
1484 if chunk.ArgChars > maxArgChars {
1485 maxArgChars = chunk.ArgChars
1486 }
1487 if tc := chunk.ToolCall; tc != nil && time.Since(lastArgProgress) >= 250*time.Millisecond {
1488 partialCalls = upsertPartialToolCall(partialCalls, *tc)
1489 lastArgProgress = time.Now()
1490 sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{
1491 ID: tc.ID, Name: tc.Name, ReadOnly: a.toolReadOnly(tc.Name), Partial: true, ArgChars: chunk.ArgChars, AttemptID: attemptID,
1492 }})
1493 }
1494 case provider.ChunkToolCall:
1495 partialToolStarted = true
1496 if chunk.ToolCall != nil {
1497 calls = append(calls, *chunk.ToolCall)
1498 partialCalls = upsertPartialToolCall(partialCalls, *chunk.ToolCall)
1499 if n := len(chunk.ToolCall.Arguments); n > maxArgChars {
1500 maxArgChars = n
1501 }
1502 }
1503 case provider.ChunkResponsesItem:
1504 responsesItems = meta.ingestResponsesItem(responsesItems, chunk.ResponsesItem, a.reasoningByteLimit)
1505 case provider.ChunkServerSearch:
1506 search.onChunk(sink, chunk, attemptID)
1507 case provider.ChunkUsage:
1508 usage, a.turn.lastReasoning = chunk.Usage, chunk.Usage.ReasoningTokens
1509 a.storeLatestRequestUsage(chunk.Usage)
1510 a.sess.cacheHit.Add(int64(chunk.Usage.CacheHitTokens))
1511 a.sess.cacheMiss.Add(int64(chunk.Usage.CacheMissTokens))
1512 case provider.ChunkError:
1513 if provider.IsStreamInterrupted(chunk.Err) {
1514 stored, _ := finishReasoning()
1515 usage = bestEffortStreamUsage(usage, text.Len(), reasoning.Len(), "interrupted")
1516 usage = provider.UsageWithRequestAttemptCount(ctx, usage)
1517 st := collect(stored, chunk.Err)
1518 st.interrupted = true
1519 return st
1520 }
1521 stored, _ := finishReasoning()
1522 if errors.Is(chunk.Err, context.Canceled) || errors.Is(chunk.Err, context.DeadlineExceeded) {
1523 usage = bestEffortStreamUsage(usage, text.Len(), reasoning.Len(), "interrupted")
1524 }
1525 usage = provider.UsageWithRequestAttemptCount(ctx, usage)
1526 return collect(stored, chunk.Err)
1527 }
1528 }
1529 }
1530
1531 func boundReasoningReplay(reasoning *strings.Builder, latest string, byteLimit int, complete bool) bool {
1532 if byteLimit <= 0 || reasoning.Len() <= byteLimit {
1533 return complete
1534 }
1535 reasoning.Reset()
1536 reasoning.WriteString(snapToRuneBoundary(latest, 0, min(len(latest), byteLimit)))
1537 return false
1538 }
1539
1540 func bestEffortStreamUsage(current *provider.Usage, textBytes, reasoningBytes int, finishReason string) *provider.Usage {
1541 if current == nil && textBytes == 0 && reasoningBytes == 0 {
1542 return nil
1543 }
1544 var usage provider.Usage
1545 usage.Unknown = current == nil
1546 if current != nil {
1547 usage = *current
1548 }
1549 if finishReason != "" {
1550 usage.FinishReason = finishReason
1551 }
1552 reasoningTokens := estimateTokensFromBytes(reasoningBytes)
1553 textTokens := estimateTokensFromBytes(textBytes)
1554 completionTokens := reasoningTokens + textTokens
1555 if usage.ReasoningTokens < reasoningTokens {
1556 usage.ReasoningTokens = reasoningTokens
1557 usage.Estimated = true
1558 }
1559 if usage.CompletionTokens < completionTokens {
1560 usage.CompletionTokens = completionTokens
1561 usage.Estimated = true
1562 }
1563 if minTotal := usage.PromptTokens + usage.CompletionTokens; usage.TotalTokens < minTotal {
1564 usage.TotalTokens = minTotal
1565 usage.Estimated = true
1566 }
1567 return &usage
1568 }
1569
1570 func estimateTokensFromBytes(n int) int {
1571 if n <= 0 {
1572 return 0
1573 }
1574 tokens := n / 4
1575 if n%4 != 0 {
1576 tokens++
1577 }
1578 if tokens <= 0 {
1579 return 1
1580 }
1581 return tokens
1582 }
1583
1584 func upsertPartialToolCall(calls []provider.ToolCall, call provider.ToolCall) []provider.ToolCall {
1585 for i := range calls {
1586 if call.ID != "" && calls[i].ID == call.ID {
1587 calls[i] = call
1588 return calls
1589 }
1590 }
1591 return append(calls, call)
1592 }
1593
1594 func (a *Agent) capturePrefixShape(schemas []provider.ToolSchema) PrefixShape {
1595 return captureTurnContextShape(a.systemPrompt(), schemas, a.sess.conversation.RewriteVersion(), a.modelVisibleMessages())
1596 }
1597
1598 func (a *Agent) systemPrompt() string {
1599 var b strings.Builder
1600 for _, m := range a.sess.conversation.Messages {
1601 if m.Role != provider.RoleSystem {
1602 continue
1603 }
1604 if b.Len() > 0 {
1605 b.WriteByte('\n')
1606 }
1607 b.WriteString(m.Content)
1608 }
1609 return b.String()
1610 }
1611
1612 func toEventShellExecution(in *tool.ShellExecution, durationMs int64) *event.ShellExecution {
1613 if in == nil {
1614 return nil
1615 }
1616 out := &event.ShellExecution{
1617 Kind: in.Kind,
1618 Shell: in.Shell,
1619 ShellVersion: in.ShellVersion,
1620 Platform: in.Platform,
1621 SupportsAndAnd: in.SupportsAndAnd,
1622 State: in.State,
1623 FailurePhase: in.FailurePhase,
1624 OutputTail: in.OutputTail,
1625 MutationRisk: in.MutationRisk,
1626 Verification: in.Verification,
1627 DurationMs: in.DurationMs,
1628 }
1629 if out.DurationMs == 0 && durationMs > 0 {
1630 out.DurationMs = durationMs
1631 }
1632 if in.ExitCode != nil {
1633 code := *in.ExitCode
1634 out.ExitCode = &code
1635 }
1636 return out
1637 }
1638
1639 func toProviderToolExecution(in *tool.ShellExecution) *provider.ToolExecution {
1640 if in == nil {
1641 return nil
1642 }
1643 out := &provider.ToolExecution{
1644 Kind: in.Kind,
1645 Shell: in.Shell,
1646 ShellVersion: in.ShellVersion,
1647 Platform: in.Platform,
1648 SupportsAndAnd: in.SupportsAndAnd,
1649 State: in.State,
1650 FailurePhase: in.FailurePhase,
1651 OutputTail: in.OutputTail,
1652 MutationRisk: in.MutationRisk,
1653 Verification: in.Verification,
1654 DurationMs: in.DurationMs,
1655 }
1656 if in.ExitCode != nil {
1657 code := *in.ExitCode
1658 out.ExitCode = &code
1659 }
1660 return out
1661 }
1662
1663 func (a *Agent) emitFullToolDispatch(ctx context.Context, c provider.ToolCall, refreshed bool) error {
1664 t, _, ambiguous := a.svc.tools.ResolveCall(c.Name)
1665 ok := t != nil && len(ambiguous) == 0
1666 ev := event.Tool{ID: c.ID, Name: c.Name, Args: c.Arguments, ReadOnly: ok && t.ReadOnly(), Refreshed: refreshed, RunState: provider.ToolRunPending}
1667 ev.FileDiff = event.FileDiff{Diff: c.Diff, Added: c.Added, Removed: c.Removed}
1668 if ok && ev.Diff == "" && ev.Added == 0 && ev.Removed == 0 {
1669 if ch, ok := tool.PreviewChange(ctx, t, json.RawMessage(c.Arguments)); ok {
1670 ev.FileDiff = event.FileDiff{Diff: ch.Diff, Added: ch.Added, Removed: ch.Removed}
1671 }
1672 }
1673 if ok {
1674 if pr, ok := t.(interface {
1675 ResolveProfile(json.RawMessage) *event.Profile
1676 }); ok {
1677 ev.Profile = pr.ResolveProfile(json.RawMessage(c.Arguments))
1678 }
1679 }
1680 return event.EmitChecked(a.svc.sink, event.Event{Kind: event.ToolDispatch, MessageID: messageIdentity(ctx), Tool: ev})
1681 }
1682
1683 // emitResolvedToolDispatch upserts the real target classification of a stable
1684 // proxy call without changing the provider-visible Name/Args. Append-only sinks
1685 // ignore Refreshed events; stateful frontends replace the existing card by ID.
1686 func (a *Agent) emitResolvedToolDispatch(ctx context.Context, c provider.ToolCall) {
1687 if c.ResolvedReadOnly == nil {
1688 return
1689 }
1690 if c.ResolvedName != "" && c.ResolvedName != c.Name {
1691 EmitProxyAudit(a.svc.sink, tool.ResolvedCall{
1692 DisplayName: c.Name,
1693 TargetName: c.ResolvedName,
1694 CapabilityID: c.CapabilityID,
1695 }, c.ID)
1696 }
1697 a.svc.sink.Emit(event.Event{Kind: event.ToolDispatch, MessageID: messageIdentity(ctx), Tool: event.Tool{
1698 ID: c.ID,
1699 Name: c.Name,
1700 Args: c.Arguments,
1701 ResolvedName: c.ResolvedName,
1702 CapabilityID: c.CapabilityID,
1703 ReadOnly: *c.ResolvedReadOnly,
1704 Refreshed: true,
1705 FileDiff: event.FileDiff{
1706 Diff: c.Diff, Added: c.Added, Removed: c.Removed,
1707 },
1708 }})
1709 }
1710
1711 // refreshCurrentFileDiff recomputes a writer preview against the state left by
1712 // earlier successful writers in the same provider batch. Preview failures clear
1713 // any stale initial diff; a later Execute will then fail or ask for recovery
1714 // without presenting the user with a preview that no longer describes disk.
1715 func refreshCurrentFileDiff(ctx context.Context, t tool.Tool, call provider.ToolCall) (provider.ToolCall, bool) {
1716 pv, ok := t.(tool.Previewer)
1717 if !ok {
1718 return call, false
1719 }
1720 refreshed := call
1721 refreshed.Diff = ""
1722 refreshed.Added = 0
1723 refreshed.Removed = 0
1724 if change, err := pv.Preview(ctx, json.RawMessage(call.Arguments)); err == nil {
1725 refreshed.Diff = change.Diff
1726 refreshed.Added = change.Added
1727 refreshed.Removed = change.Removed
1728 }
1729 changed := refreshed.Diff != call.Diff || refreshed.Added != call.Added || refreshed.Removed != call.Removed
1730 return refreshed, changed
1731 }
1732
1733 func (a *Agent) withPreviewFileDiffs(ctx context.Context, calls []provider.ToolCall) []provider.ToolCall {
1734 if len(calls) == 0 {
1735 return calls
1736 }
1737 out := make([]provider.ToolCall, len(calls))
1738 copy(out, calls)
1739 for i := range out {
1740 if out[i].Diff != "" || out[i].Added != 0 || out[i].Removed != 0 {
1741 continue
1742 }
1743 t, _, ambiguous := a.svc.tools.ResolveCall(out[i].Name)
1744 ok := t != nil && len(ambiguous) == 0
1745 if !ok {
1746 continue
1747 }
1748 if ch, ok := tool.PreviewChange(ctx, t, json.RawMessage(out[i].Arguments)); ok {
1749 out[i].Diff = ch.Diff
1750 out[i].Added = ch.Added
1751 out[i].Removed = ch.Removed
1752 }
1753 }
1754 return out
1755 }
1756
1757 // completedMCPConnect recognizes a synthetic cache-miss connect call whose
1758 // background discovery finished after the provider request was serialized. The
1759 // connect placeholder is intentionally absent once real tools replace it, but
1760 // the already-advertised call still completed its only job and must not surface
1761 // as an unknown tool.
1762 func completedMCPConnect(reg *tool.Registry, name string) (string, bool) {
1763 server, rawName, ok := tool.SplitMCPName(name)
1764 if !ok || rawName != "connect" {
1765 return "", false
1766 }
1767 prefix := tool.MCPNamePrefix + server + "__"
1768 for _, current := range reg.Names() {
1769 if current != name && strings.HasPrefix(current, prefix) {
1770 return server, true
1771 }
1772 }
1773 return "", false
1774 }
1775
1776 func (a *Agent) readOnlyExecutionBlock(visible tool.Tool, resolved *tool.ResolvedCall) (toolOutcome, bool) {
1777 if a == nil || !a.readOnlyExecution {
1778 return toolOutcome{}, false
1779 }
1780 block := func(reason string) (toolOutcome, bool) {
1781 return toolOutcome{
1782 output: "blocked: read-only agent cannot " + reason,
1783 blocked: true,
1784 errMsg: "blocked by read-only execution boundary",
1785 }, true
1786 }
1787 // Destructive MCP is left for the Executor; Planner must not misread this
1788 // as missing configuration or an unavailable MCP server.
1789 blockDestructiveForExecutor := func(name string) (toolOutcome, bool) {
1790 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."
1791 return toolOutcome{
1792 output: msg,
1793 blocked: true,
1794 errMsg: "blocked: destructive MCP reserved for executor",
1795 }, true
1796 }
1797 if resolved == nil {
1798 if a.plannerMCPExecution && isMCPExecutionTarget(visible, "") {
1799 if !mcpServerAuthorized(visible) {
1800 return block("execute an MCP capability from an unauthorized server")
1801 }
1802 if readOnlyExecutionMCPDestructive(visible) {
1803 return blockDestructiveForExecutor(visible.Name())
1804 }
1805 return toolOutcome{}, false
1806 }
1807 if visible == nil || !visible.ReadOnly() {
1808 if reasoner, ok := visible.(tool.ReadOnlyExecutionBlockReason); ok && strings.TrimSpace(reasoner.ReadOnlyExecutionBlockReason()) != "" {
1809 return block(reasoner.ReadOnlyExecutionBlockReason())
1810 }
1811 return block("execute a state-changing tool")
1812 }
1813 if isInstalledMCPTool(visible) && !mcpServerAuthorized(visible) {
1814 return block("execute a reader from an unauthorized MCP server")
1815 }
1816 if readOnlyExecutionMCPDestructive(visible) {
1817 return block("execute a destructive MCP capability")
1818 }
1819 if h, ok := visible.(tool.ReadOnlyExecutionHostMutation); ok && h.ReadOnlyExecutionHostMutation() && !readOnlyExecutionAllowsMCPStartup(visible) {
1820 return block("start or mutate a host capability")
1821 }
1822 return toolOutcome{}, false
1823 }
1824
1825 switch resolved.ProxyAction {
1826 case "list", "inspect":
1827 if !resolved.SkipExecute || resolved.Target != nil || !resolved.ReadOnly {
1828 return block("execute a malformed dynamic inspection")
1829 }
1830 return toolOutcome{}, false
1831 case "decline":
1832 return block("decline a capability decision")
1833 case "call":
1834 if resolved.Target == nil {
1835 if a.plannerMCPExecution && resolved.HostCompleted && resolved.SkipExecute && resolved.ReadOnly && !resolved.Unavailable {
1836 if _, ok := parseMCPServerCapabilityID(resolved.CapabilityID); ok {
1837 return toolOutcome{}, false
1838 }
1839 }
1840 return block("execute an unresolved dynamic capability")
1841 }
1842 if a.plannerMCPExecution && plannerAllowsMCPTarget(resolved.Target, resolved.TargetName) {
1843 if isMCPLifecycleConnectTarget(resolved.Target) {
1844 if !plannerMCPConnectAllowed(resolved.Target) {
1845 return block("start an unauthorized MCP server")
1846 }
1847 } else if !mcpServerAuthorized(resolved.Target) {
1848 return block("execute an MCP capability from an unauthorized server")
1849 }
1850 if readOnlyExecutionMCPDestructive(resolved.Target) {
1851 name := resolved.TargetName
1852 if name == "" {
1853 name = resolved.CapabilityID
1854 }
1855 return blockDestructiveForExecutor(name)
1856 }
1857 return toolOutcome{}, false
1858 }
1859 if !resolved.ReadOnly {
1860 if reasoner, ok := resolved.Target.(tool.ReadOnlyExecutionBlockReason); ok && strings.TrimSpace(reasoner.ReadOnlyExecutionBlockReason()) != "" {
1861 return block(reasoner.ReadOnlyExecutionBlockReason())
1862 }
1863 return block("execute a state-changing dynamic capability")
1864 }
1865 if isInstalledMCPTool(resolved.Target) && !mcpServerAuthorized(resolved.Target) {
1866 return block("execute a dynamic reader from an unauthorized MCP server")
1867 }
1868 if readOnlyExecutionMCPDestructive(resolved.Target) {
1869 return block("execute a destructive MCP capability")
1870 }
1871 if h, ok := resolved.Target.(tool.ReadOnlyExecutionHostMutation); ok && h.ReadOnlyExecutionHostMutation() && !readOnlyExecutionAllowsMCPStartup(resolved.Target) {
1872 return block("start or mutate a host capability")
1873 }
1874 return toolOutcome{}, false
1875 default:
1876 return block("execute an unknown dynamic capability action")
1877 }
1878 }
1879
1880 func readOnlyExecutionMCPDestructive(t tool.Tool) bool {
1881 return mcpDestructiveHint(t)
1882 }
1883
1884 func readOnlyExecutionAllowsMCPStartup(t tool.Tool) bool {
1885 if t == nil || !t.ReadOnly() || readOnlyExecutionMCPDestructive(t) {
1886 return false
1887 }
1888 if !mcpServerAuthorized(t) {
1889 return false
1890 }
1891 meta, ok := t.(tool.MCPMetadata)
1892 if !ok || strings.TrimSpace(meta.MCPServerName()) == "" || strings.TrimSpace(meta.MCPRawToolName()) == "" {
1893 return false
1894 }
1895 return true
1896 }
1897
1898 // plannerAllowsMCPTarget reports whether a resolved use_capability target is an
1899 // MCP tool or lifecycle connect that Planner may consider under
1900 // PlannerMCPExecution (authorization and destructive checks run separately).
1901 func plannerAllowsMCPTarget(t tool.Tool, targetName string) bool {
1902 if t == nil {
1903 return false
1904 }
1905 if isInstalledMCPTool(t) || isMCPLifecycleConnectTarget(t) {
1906 return true
1907 }
1908 return isMCPExecutionTarget(t, targetName)
1909 }
1910
1911 // isMCPLifecycleConnectTarget identifies on-demand MCP connect-and-list targets
1912 // (mcp_connect__<server>) used by use_capability action=call on mcp-server ids.
1913 func isMCPLifecycleConnectTarget(t tool.Tool) bool {
1914 if t == nil {
1915 return false
1916 }
1917 if _, ok := t.(mcpLifecycleConnect); ok {
1918 return true
1919 }
1920 name := strings.TrimSpace(t.Name())
1921 return strings.HasPrefix(name, "mcp_connect__")
1922 }
1923
1924 // mcpLifecycleConnect is implemented by deferred connect targets so Planner
1925 // can authorize lifecycle actions without relying on name prefixes alone.
1926 type mcpLifecycleConnect interface {
1927 MCPLifecycleConnect() bool
1928 MCPServerAuthorized() bool
1929 }
1930
1931 func plannerMCPConnectAllowed(t tool.Tool) bool {
1932 if life, ok := t.(mcpLifecycleConnect); ok {
1933 return life.MCPServerAuthorized()
1934 }
1935 return mcpServerAuthorized(t)
1936 }
1937
1938 func isInstalledMCPTool(t tool.Tool) bool {
1939 meta, ok := t.(tool.MCPMetadata)
1940 return ok && strings.TrimSpace(meta.MCPServerName()) != "" && strings.TrimSpace(meta.MCPRawToolName()) != ""
1941 }
1942
1943 func isMCPExecutionTarget(t tool.Tool, name string) bool {
1944 return isInstalledMCPTool(t) || strings.HasPrefix(strings.TrimSpace(name), "mcp__")
1945 }
1946
1947 func mcpServerAuthorized(t tool.Tool) bool {
1948 authority, ok := t.(tool.MCPServerAuthorization)
1949 return ok && authority.MCPServerAuthorized()
1950 }
1951
1952 func mcpDestructiveHint(t tool.Tool) bool {
1953 annotations, ok := t.(tool.MCPAnnotations)
1954 return ok && annotations.MCPDestructiveHint()
1955 }
1956
1957 func (a *Agent) planModeDecision(toolName string, readOnly bool, safety planmode.PlanSafety, args json.RawMessage) planmode.Decision {
1958 return (planmode.Policy{}).Decide(planmode.Call{
1959 Name: toolName,
1960 ReadOnly: readOnly,
1961 Safety: safety,
1962 Args: args,
1963 })
1964 }
1965
1966 // isBackgroundTaskCall reports whether a `task` call set run_in_background, so a
1967 // fire-and-return dispatch isn't mistaken for a sub-agent that has stopped.
1968 func isBackgroundTaskCall(args string) bool {
1969 var p struct {
1970 RunInBackground bool `json:"run_in_background"`
1971 }
1972 _ = json.Unmarshal([]byte(args), &p)
1973 return p.RunInBackground
1974 }
1975
1976 // toolReadOnly reports a tool's ReadOnly classification by name (false for an
1977 // unknown tool), for stamping early ToolDispatch events.
1978 func (a *Agent) toolReadOnly(name string) bool {
1979 t, _, ambiguous := a.svc.tools.ResolveCall(name)
1980 return t != nil && len(ambiguous) == 0 && t.ReadOnly()
1981 }
1982
1983 // firstLine returns s up to its first newline — a one-line failure summary for
1984 // the display Err, while the full error stays in the model-facing output.
1985 func firstLine(s string) string {
1986 if before, _, ok := strings.Cut(s, "\n"); ok {
1987 return before
1988 }
1989 return s
1990 }
1991
1992 // truncateToolOutput builds the stable provider-visible Content form for a tool
1993 // result. Under-cap bodies are byte-identical; over-cap bodies keep a tool-aware
1994 // preview while RawContent stores the full local original. read_file is special:
1995 // its preview is a contiguous prefix so an exact recovery cursor can never skip
1996 // source text that the model did not actually see.
1997 func truncateToolOutput(s string) (string, string) {
1998 return truncateToolOutputFor(s, "", "")
1999 }
2000
2001 // truncateToolOutputFor is the tool-aware provider-input limiter. toolName and
2002 // toolCallID populate the recovery marker.
2003 func truncateToolOutputFor(s, toolName, toolCallID string) (string, string) {
2004 if len(s) <= maxToolOutputBytes {
2005 return s, ""
2006 }
2007 if toolName == "read_file" {
2008 return truncateReadFileOutput(s, toolName, toolCallID)
2009 }
2010 strategy := snipStrategy{head: 40, tail: 40, headChars: 8000, tailChars: 8000}
2011 switch {
2012 case tool.IsShellToolName(toolName) || strings.Contains(toolName, "bash"):
2013 strategy = snipStrategy{head: 40, tail: 40, headChars: 8000, tailChars: 8000}
2014 case toolName == "read_file" || toolName == "web_fetch" || strings.Contains(toolName, "read"):
2015 strategy = snipStrategy{head: 120, tail: 12, headChars: 12000, tailChars: 2000}
2016 case toolName == "grep" || toolName == "glob" || toolName == "ls" || toolName == "list_dir":
2017 strategy = snipStrategy{head: 80, tail: 8, headChars: 10000, tailChars: 1000}
2018 }
2019 headKeep := strategy.headChars
2020 tailKeep := strategy.tailChars
2021 if headKeep+tailKeep > maxToolOutputBytes-512 {
2022 headKeep = maxToolOutputBytes * 2 / 3
2023 tailKeep = maxToolOutputBytes - headKeep - 512
2024 }
2025 if headKeep < 1024 {
2026 headKeep = maxToolOutputBytes / 2
2027 tailKeep = maxToolOutputBytes / 2
2028 }
2029 // Prefer more tail when the body looks like a failure.
2030 lower := strings.ToLower(s)
2031 if strings.Contains(lower, "error:") || strings.Contains(lower, "panic:") || strings.Contains(lower, "fatal:") {
2032 tailKeep = max(tailKeep, maxToolOutputBytes/3)
2033 if headKeep+tailKeep > maxToolOutputBytes-512 {
2034 headKeep = maxToolOutputBytes - 512 - tailKeep
2035 }
2036 }
2037 head := snapToRuneBoundary(s, 0, headKeep)
2038 tail := snapToRuneBoundary(s, len(s)-tailKeep, len(s))
2039 resultRef := toolResultRef(toolCallID, s)
2040 marker := toolOutputRecoveryMarker(toolName, toolCallID, resultRef, len(s), len(head)+len(tail))
2041 for range 3 {
2042 bodyLen := len(head) + len(marker) + len(tail)
2043 if bodyLen <= maxToolOutputBytes {
2044 break
2045 }
2046 overflow := bodyLen - maxToolOutputBytes
2047 trimHead := overflow / 2
2048 trimTail := overflow - trimHead
2049 if trimHead < len(head) {
2050 head = snapToRuneBoundary(head, 0, len(head)-trimHead)
2051 }
2052 if trimTail < len(tail) {
2053 tail = snapToRuneBoundary(tail, trimTail, len(tail))
2054 }
2055 marker = toolOutputRecoveryMarker(toolName, toolCallID, resultRef, len(s), len(head)+len(tail))
2056 }
2057 notice := fmt.Sprintf(i18n.M.ToolOutputTruncatedFmt, len(s)-len(head)-len(tail), len(s))
2058 return head + marker + tail, notice
2059 }
2060
2061 // finishReasonMessage maps an abnormal finish_reason to a one-line warning,
2062 // returning ok=false for the normal terminations ("stop", "tool_calls") and a
2063 // nil usage. The sink renders the message; the "! " prefix is presentation.
2064 func finishReasonMessage(u *provider.Usage) (string, bool) {
2065 if u == nil {
2066 return "", false
2067 }
2068 switch u.FinishReason {
2069 case "length":
2070 return i18n.M.FinishReasonLength, true
2071 case "content_filter":
2072 return i18n.M.FinishReasonContentFilter, true
2073 case "repetition_truncation":
2074 return i18n.M.FinishReasonRepetition, true
2075 default:
2076 return "", false
2077 }
2078 }
2079
2080 // streamInterruptNotice explains why a provider stream never reached a clean
2081 // terminal, in words a user can act on. Only the closed StreamInterrupt* enum
2082 // is rendered — the wrapped transport error can carry URLs or gateway bodies
2083 // and must not reach the transcript (#9560).
2084 func streamInterruptNotice(err error) (code, text string) {
2085 switch provider.StreamInterruptReason(err) {
2086 case provider.StreamInterruptIdleTimeout:
2087 return event.NoticeCodeStreamInterruptedIdleTimeout, i18n.M.StreamInterruptedIdleTimeout
2088 case provider.StreamInterruptPrematureEOF:
2089 return event.NoticeCodeStreamInterruptedPrematureEOF, i18n.M.StreamInterruptedPrematureEOF
2090 case provider.StreamInterruptConnectionReset:
2091 return event.NoticeCodeStreamInterruptedConnectionReset, i18n.M.StreamInterruptedConnectionReset
2092 default:
2093 return "", ""
2094 }
2095 }
2096
2096 lines GO