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