import type { Span } from '@opentelemetry/api'; import type { AuthorizationGate } from '../../authorization/gate.js'; import type { WorkingStateManager } from '../../compaction/manager.js'; import type { PluginLifecycleManager } from '../../plugin/lifecycle.js'; import type { ProbeEnforcement } from '../../probe/registry.js'; import type { ActivityStore } from '../../store/activity/memory.js'; import type { ToolResultGuardrailSpec } from '../../types/guardrail/index.js'; import type { ToolCallEscalation } from '../../types/hitl/index.js'; import type { SessionId, TurnId } from '../../types/ids/index.js'; import type { InvocationState } from '../../types/invocation/index.js'; import { type Message, type ToolCall, type ToolResultContent } from '../../types/message/index.js'; import type { PermissionMode } from '../../types/permission/index.js'; import type { ChatCompletionResponse } from '../../types/provider/index.js'; import type { Sandbox } from '../../types/sandbox/index.js'; import type { AuditEventInput } from '../../types/session/audit.js'; import type { SessionRecord } from '../../types/session/records.js'; import type { FileReadTracker, PreparedToolExecution, RequestToolPause, SkillRegistryRef, ToolContext, ToolRegistryContract } from '../../types/tool/index.js'; import type { RepairToolCall } from '../../types/tool/repair.js'; import { type BackoffPolicy } from '../../utils/backoff.js'; import type { Logger } from '../../utils/logger.js'; import { type BackgroundJobRegistry } from '../jobs/registry.js'; import type { ToolResultObservation } from './project-instructions.js'; export type { EmitEvent } from './events.js'; import type { EmitEvent } from './events.js'; export type PreparedDirectCall = { readonly kind: 'ready'; readonly toolCall: ToolCall; readonly toolName: string; readonly input: unknown; readonly prepared: PreparedToolExecution; } | { readonly kind: 'legacy'; readonly toolCall: ToolCall; readonly toolName: string; readonly input: unknown; } | { readonly kind: 'synthetic'; readonly toolCall: ToolCall; readonly toolName: string; readonly input: unknown; readonly message: string; readonly isError: boolean; }; /** * Executor-owned, single-use preparation of one provider tool-call batch. * * Consumers may inspect `reviewCalls`; only the creating executor can consume * the opaque call preparations. This keeps schema transforms, plugin rewrites, * authorization and execution on one value instead of reparsing between them. */ export interface PreparedToolBatch { readonly reviewCalls: readonly { readonly id: string; readonly name: string; readonly input: unknown; /** What the prepared value reaches past the turn's boundary; see `ToolCallSummary.escalation`. */ readonly escalation?: ToolCallEscalation; }[]; } /** * Default per-tool deadline. Long enough for a real build or test run, * short enough that a wedged tool does not hold a turn open indefinitely. * A tool that legitimately runs longer declares its own `timeoutMs`. */ export declare const DEFAULT_TOOL_TIMEOUT_MS = 120000; /** * Cap on tools executing at once within a single batch. * * `executeBatch` used to `Promise.all` an unbounded fan-out, so a model * emitting fifty parallel reads opened fifty file handles and fifty * activity records at once. The serial chain is unaffected — it is * already one-at-a-time by construction. */ export declare const DEFAULT_TOOL_CONCURRENCY = 8; /** * Re-runs granted to a `post_tool_use` hook that returns `{action:'retry'}` * on a tool which did not opt into {@link ToolDefinition.maxRetries}. * * Small on purpose. The hook is host code reacting to one specific result, * which is a more specific signal than the tool's blanket idempotency * declaration — but the tool still never said it was safe to re-run, so * this buys one correction, not a loop. */ export declare const HOOK_RETRY_BUDGET = 1; /** * Wait between in-loop tool retry attempts. * * There was none. A tool that declared itself retryable was re-run the * instant it failed, as many times as its budget allowed — and the failures * worth retrying are the ones an immediate retry makes worse: a rate limit * answers the second call faster than it recovers, a contended lock is still * held, a connection that has not finished opening has not finished opening. * * The numbers are the provider policy's, deliberately, and not because a tool * is a model call. Nothing here has been measured against tools specifically, * and inventing a second curve to look considered would be a guess wearing * different digits; the shared one is at least the curve this codebase has * already run in anger. Full jitter draws each wait from `[0, curve]`, so the * first retry of a tool with the shipped budget waits under half a second on * average. * * The ceiling is inert at the budgets anyone sets — a tool declaring * `maxRetries: 3` never reaches 2s — and binds only a host that sets a large * one. Override with {@link ToolExecutorConfig.toolRetryBackoff}; set * `initialDelayMs: 0` for the previous no-wait behaviour. */ export declare const DEFAULT_TOOL_RETRY_BACKOFF: BackoffPolicy; export interface ToolExecutorConfig { fileReadTracker?: FileReadTracker; tools: ToolRegistryContract; sessionId: SessionId; turnId: TurnId; workingDirectory: string; /** See `ToolContext.additionalDirectories`. */ additionalDirectories?: readonly string[]; /** See `QueryParams.outsideRootAccess`. Default `'refuse'`. */ outsideRootAccess?: 'refuse' | 'review'; /** See `QueryParams.sandboxEscape`. Default `'refuse'`. */ sandboxEscape?: 'refuse' | 'review'; /** * Read LIVE, not frozen at turn start. * * The mode used to be resolved once per turn and copied in here, so * leaving plan mode meant ending the turn — discarding the in-flight step * and the tool-schema context to change one enum. A function lets an * approval flip it inside the same conversation. * * Sampled ONCE per batch and held for it: a toggle landing between two * calls the model issued together would half-apply, and a batch where * the first write is refused and the second succeeds is not a state * anyone can reason about. */ permissionMode: PermissionMode | (() => PermissionMode); env: Record; abortSignal: AbortSignal; allowedTools?: readonly string[]; sandbox?: Sandbox; /** * Where background jobs this turn starts are held. * * The registry is host-owned and shared; the executor binds it to THIS * turn's id before a tool ever sees it, so a tool cannot start a job * billed to another turn, nor read or kill one. Absent means the host * offers no background mode, and `bash run_in_background` refuses rather * than falling back to `cmd &` — see `runtime/jobs/registry.ts` for why * that fallback is a lie rather than a lesser version. */ backgroundJobs?: BackgroundJobRegistry; /** * Which owner the turn's jobs are bound to. The turn id by default, which * scopes them to the turn; a host that wants jobs to outlive a turn (a * dev server started in one, read in the next) binds them to its * session and stops them itself when the session ends. */ backgroundJobOwner?: string; /** * Where `wait_for_job` records that the model is waiting on a job. * * A callback rather than the recorder itself: the executor's part is to * hand the tools a bound ref, and what the turn does with the intent — * hold itself open for the job — is the iteration loop's business. Absent * means the bound ref has no `markAwaited` at all, so a host that wires no * recorder gets no hold rather than a marking call that goes nowhere. */ onJobAwaited?: (id: string) => void; /** * Where the `skill` tool reads from. * * Structural (`SkillRegistryRef`) rather than `SkillRegistry`, because * this config is host-facing and a host may hold its skills anywhere. */ skills?: SkillRegistryRef; /** How this turn reaches the web. See `ToolContext.web`. */ web?: ToolContext['web']; invocationState?: InvocationState; pluginManager?: PluginLifecycleManager; /** Turn-level default deadline; per-tool `timeoutMs` overrides it. */ toolTimeoutMs?: number; /** * Wait between in-loop retries of a failed tool call. Defaults to * {@link DEFAULT_TOOL_RETRY_BACKOFF}. * * Applies only to a tool that opted into retrying at all * ({@link ToolDefinition.maxRetries}) or to a `post_tool_use` hook that * asked for one, so a turn whose tools all take the shipped default of * zero retries never sleeps here. */ toolRetryBackoff?: Partial; /** Max concurrently-executing concurrency-safe tools. */ maxToolConcurrency?: number; /** Per-turn cumulative attempt admission limit; unset is unlimited. */ maxToolCalls?: number; /** Complete strict session-log read for restoring a configured call budget. */ readToolCallBudgetRecords?: () => Promise; /** * Builds the durable-pause seam handed to one tool call. * * Absent when the turn has no route to a human, which is why * {@link ToolContext.requestPause} is optional: a tool must be able to * run in a headless context and decide what to do without one. */ toolPause?: (toolUseId: string) => RequestToolPause; /** * Model-visible size cap for a single tool result. Defaults to * {@link DEFAULT_MAX_TOOL_OUTPUT_CHARS}; set `0` to disable. */ maxToolOutputChars?: number; /** See QueryParams.retainedToolPreviewChars; applies to the recorded host output. */ retainedToolPreviewChars?: number; /** * See QueryParams.toolResultGuardrails. Absent installs * {@link DEFAULT_TOOL_RESULT_GUARDRAILS}; an empty array installs none. */ toolResultGuardrails?: readonly ToolResultGuardrailSpec[]; /** * Cap on the RICH channel of a single tool result, in base64 * characters. `0` or absent disables it. * * Separate from {@link maxToolOutputChars} because the two are different * quantities with different costs: the text budget bounds characters the * model reads, and an image block of any size passed it untouched — the * single largest payload a tool result can carry was the one thing not * bounded on the turn that produced it. * * **Off by default, deliberately.** The right number depends entirely on * what a host's tools return and on the model's own image budget, and * inventing one here would either break screenshot workflows or be so * generous it bounds nothing. A host that knows its payloads sets it; * the steady state is already bounded, because reclamation clears * image-bearing results first. */ maxToolContentBytes?: number; /** * Where over-budget output is spilled so the model can read it back * with `read`/`grep`. Absent ⇒ over-budget output is middle-elided and * the overflow is lost. */ captureSessionEvidence?: ToolContext['captureSessionEvidence']; toolOutputDir?: string | (() => string | undefined); /** * Last chance to fix a tool call the model got wrong, before the error * reaches it. See {@link RepairToolCall}. */ repairToolCall?: RepairToolCall; /** * Operator policy applied to calls dispatched by another tool. * * Model-issued calls are reviewed by the iteration orchestrator. Nested * calls cannot open a second durable review while their parent is already * executing, so only an explicit `allow` may proceed; `deny` and `review` * both fail closed before the registry is touched. */ authorizationGate?: AuthorizationGate; /** Durable refusal sink paired with {@link authorizationGate}. */ recordAudit?: (input: AuditEventInput) => Promise; } export type PreToolHookOutcome = { kind: 'continue'; input: unknown; modified: boolean; } | { kind: 'skip'; input: unknown; output: string; } | { kind: 'error'; input: unknown; output: string; }; /** What one tool call produced, before it becomes a message. */ export interface ToolCallOutcome { toolCallId: string; /** Which tool produced it. */ toolName: string; /** Text form — what the host, the transcript and compaction see. */ output: string; /** Rich form for the model, when the tool supplied one. */ content?: ToolResultContent; isError?: boolean; } export interface ToolExecutionBatch { messages: Message[]; results: ToolCallOutcome[]; /** Actual registry executions, including calls dispatched by another tool. */ observations: ToolResultObservation[]; } /** * Denial reasons keyed by `tool_use` id. Any id present here is answered * with a synthetic `tool_result` carrying the reason INSTEAD of being * executed — see {@link ToolExecutor.executeBatch}. */ export type ToolCallDenials = ReadonlyMap; /** * Results for calls that already ran, keyed by `toolUseId`. * * A batch's results reach the history only when the whole batch settles, * so a hard kill part-way through loses whatever had already come back and * the resumed turn re-executes those calls. Supplying them here answers * those `tool_use` blocks from the record instead of by running the tool * again — which for a payment or an email is the difference between * resuming and repeating. */ export type PriorToolResults = ReadonlyMap; /** * Model-visible text for a tool call that was never executed. * * The reason travels INSIDE the `tool_result` rather than as a trailing * user message: a `tool_use` block must be answered by a `tool_result` * with the same id, and a denial is an answer, not an omission. Putting * the reason here is also what makes rejection *steer* — the model reads * it in the slot it already attends to for tool outcomes. */ export declare function deniedToolOutput(toolName: string, reason: string): string; export declare class ToolExecutor { private outputDirectory; private config; private activityStore; private emitEvent; private log; private workingStateManager?; private probes; private parentSpan?; private readonly toolCallBudget?; private readonly preparedBatches; /** Set per turn by the orchestrator; see {@link setStepAllowedTools}. */ private stepAllowedTools?; private readonly fileReadTracker; /** A ledger is rebuilt from history at most once; a second pass would re-append its chains. */ private fileObservationsSeeded; constructor(config: ToolExecutorConfig, activityStore: ActivityStore, emitEvent: EmitEvent, log: Logger, probes?: ProbeEnforcement); setWorkingStateManager(manager: WorkingStateManager): void; setSandbox(sandbox: Sandbox): void; /** * Rebuild this turn's observation ledger from history a resume restored. * * Once, and only from a history that has already been repaired — the ledger * has to describe what the model is about to be shown, not what was * checkpointed before the repair removed an abandoned call. `sandboxed` is * passed rather than read off this executor's config because a resumed turn * restores its history before it acquires a sandbox, so the config does not * know yet what the turn's tool paths will be keyed on. * * Awaited, and the only filesystem work anywhere in this feature: the seed * has to write its entries under the keys the mutation tools will look them * up under, which on a host means resolving each path through its symlinks * the way `write` and `edit` do. No file's content is read. */ seedFileObservations(messages: readonly Message[], sandboxed: boolean): Promise; /** Request-only evidence from the same ledger used by mutation admission. No filesystem I/O. */ describeFileEvidence(messages: readonly Message[]): string | undefined; /** * Span that this executor's tool spans should hang off — the current * iteration. Re-set each turn by the orchestrator, because a tool span * belongs under the iteration that requested it. */ setParentSpan(span: Span | undefined): void; /** * Narrow what this turn may call, or clear the narrowing. * * Re-set each turn by the orchestrator for the same reason the parent span * is: `prepareStep` can hand a different list to every step, and the turn's * own `allowedTools` is only the default when a step names none. * * Without this the executor could only ever see the TURN-level list, so a * per-step narrowing reached the request that was sent and nothing else — * the model was shown fewer tools and could still call all of them. */ setStepAllowedTools(names: readonly string[] | undefined): void; /** * Answer every `tool_use` block in `response` with exactly one * `tool_result`. * * `denials` marks ids that must NOT run: each is answered with a * synthetic error result carrying the caller's reason instead of being * executed. A gate denial, a human rejection and a partial approval all * leave the history valid, because there is exactly one place that turns * a batch of tool calls into messages and it covers all of them. * * **That is a property of every path that RETURNS, not of the batch as * a whole.** A per-call throw rejects the batch before the fill-the-holes * loop below can run: `serial = serial.then(run)` means one rejection * skips every LATER serial call, and `Promise.all([...parallel, serial])` * then rejects — so this method produces no messages at all and the * assistant turn keeps its `tool_use` blocks unanswered. A resume is what * repairs that turn; see the `unfinished` step `iteration/index.ts` * records for it. * * Reachable, not hypothetical, and demonstrated end to end by * `a-throwing-batch-answers-nothing.test.ts`: `executeSingle` rethrows a * retry's budget-admission error, and a `runPreToolHook` failure on a * call whose preparation did not already run the hook. * * So do not read the guarantee below as covering a throw. The invariant * holds for denials, for approvals, for a rejected batch and for a * generation that partially failed while still returning: each of those * leaves a hole that the fill-the-holes loop closes. * * Answering with `is_error` semantics rather than dropping the call is * the universal contract across providers: an unanswered `tool_use` * is a protocol violation, not a decline. */ /** * The mode sampled for the batch currently running, if one is. * * Belt-and-braces, and worth saying so. The per-batch property is * ALREADY structural: `buildToolContext()` runs once per batch and every * per-call context spreads its result, so the mode is read once whether * or not this field exists — removing it is an equivalent mutation * today, measured. * * Kept because that guarantee is incidental to where the context happens * to be built. Moving `permissionContext` into the per-call spread is a * plausible refactor and would silently make the read per-call, which is * a batch where the first write is refused and the second succeeds. */ private batchMode?; /** * The tool scope a loaded skill declared, and the batch it applies from. * * `allowed-tools` was parsed, stored and rendered into the prompt, and * read by nothing — advice phrased as a declaration. This is what makes * it a restriction, on the same line that already enforces the step's * list, because a narrowing the model can decline is not one. * * Two fields rather than one, and the second is the point: a skill * loaded MID-batch must not retroactively refuse the calls the model * issued alongside it. The model chose that batch under the old scope, * and refusing half of it teaches nothing except that tools fail at * random. `adoptedInBatch` is compared against the batch counter, so the * scope takes effect from the next one. * * **`adoptedInBatch` is redundant TODAY and kept deliberately**, the same * bargain `batchMode` above documents. `buildToolContext()` runs once per * batch, so every call in a batch already shares one `allowedTools` array * computed before any of them could adopt anything — remove this * comparison and no test changes, because the guarantee currently comes * from where the context happens to be built rather than from here. * Moving the context into the per-call spread is a plausible refactor, * and it would silently produce a batch whose second half is refused for * a scope its first half installed. That is precisely the incoherent * batch this line exists to make impossible. */ private skillScope?; private batchCounter; /** * The step's list, narrowed by any skill scope in force. * * An INTERSECTION, never a replacement: a skill cannot hand the model a * tool the step withheld. Widening has to be unexpressible rather than * discouraged — the same rule `CreateTaskOptions.toolScope` states for * delegation, and for the same reason: a skill file is content, and * content that can grant tools is a privilege-escalation surface wearing * the word "scope". * * The `skill` tool itself always survives. A skill that narrowed the * model out of reaching for another skill would be a one-way door, and * the tool reads instructions and changes nothing. */ private effectiveAllowedTools; private resolvePermissionMode; /** Evaluate the turn's operator policy against one already-prepared value. */ evaluatePreparedAuthorization(toolName: string, input: unknown): import("../../public-types.js").GateEvaluationResult | undefined; /** * Resolve repairs and pre-tool hooks, then decode each call exactly once. * The returned projection is what policy and a human review; execution later * consumes the registry-owned preparations rather than parsing again. */ prepareBatchForReview(response: ChatCompletionResponse): Promise; /** * What a prepared call reaches past the turn's boundary, decided on the * value that will execute — after repairs and pre-tool hooks, so a hook * that rewrites a path is reviewed under the path it wrote. * * Each half is computed only when the turn asked for it; with neither, * this is `undefined` for every call and the tools refuse as they always * have. Paths are looked at only without a sandbox: inside one the tools * resolve against the sandbox's own root, and a host path outside the * roots is not mounted there to be approved. The escape is looked at only * WITH one, since without one there is nothing to escape. */ private escalationOf; /** Re-prepare only calls whose raw input a reviewer actually changed. */ reprepareBatchForReview(response: ChatCompletionResponse, previous: PreparedToolBatch, changedCallIds: ReadonlySet): Promise; private publishPreparedBatch; executeBatch(response: ChatCompletionResponse, denials?: ToolCallDenials, prior?: PriorToolResults, preparedBatch?: PreparedToolBatch): Promise; private runBatch; /** * Run a tool on behalf of another tool, and put it on the record. * * These used to go straight to `registry.execute`, so they reached the * permission gate and reached the event stream not at all — a turn whose * transcript showed one `run_code` call and nothing about the eleven * writes it performed is a transcript nobody can audit. * * `via` names the dispatching call rather than merely marking this one * nested, and that is the load-bearing part: without it a consumer * counting tool calls double-counts the parent AND each child, and one * rendering a timeline draws eleven siblings where there is one call with * eleven children. */ private dispatchNested; private resultPresentation; private buildToolContext; private executeSingle; /** * Run a tool under a deadline, with the turn abort folded in. * * `ToolContext.abortSignal` existed but was produced and consumed by * nothing: a Stop tore down the model stream and then parked inside * `Promise.all` waiting for a tool that had no idea it should quit. A * hung MCP stdio server or a `bash` with the old one-hour default * could hold a turn open long after the user cancelled. * * Two mechanisms, because neither alone is enough: * * 1. The composed signal (run abort OR deadline) is handed to the tool * so a cooperative tool actually stops working. * 2. The `race` bounds the *executor's* wait regardless, so an * uncooperative tool becomes detached rather than blocking. * * A timeout is reported as a normal failed result, not a throw: the * model sees "this timed out" as a `tool_result` and can route around * it. A throw would end the turn over one slow tool. */ private executeWithDeadline; /** * The three things the admission family reads off this executor. * * Built per call rather than held: `setSandbox` REPLACES `config`, so a * host captured once would hand the next admission a stale sandbox. * * The one way this differs from the inline code it replaced, which * re-read `this.config` at every use: an admission that spans a * `setSandbox()` now finishes against the config it STARTED with rather * than against the new one. Distinguishing the two readings needs * `setSandbox` to be called from a hook awaited in the middle of one * admission — its only call site is the turn's sandbox acquisition, * before the loop, so nothing in this tree can tell them apart. */ private admissionHost; private prepareNestedCall; /** * One execution attempt, with a throw materialized as an error result. * * an unhandled throw from `tools.execute(...)` used to * propagate up to `result.ts` as `turn_failed` without emitting a * terminal `tool_completed`, leaving UI cards stuck in `executing`. * * The return is the full `ToolResult`, not a narrowed literal: the * narrow version silently DROPPED `content`, so a tool returning an * image block had it discarded here — before the wire mapper built to * carry it ever saw it. */ private runOnce; /** * @returns `override` — text replacing the tool's output, or `null`. * `retry` — the hook asked for the tool to run again. */ private runPostToolHook; /** * Answer a tool call that policy or a human refused, without executing * it. Emits the same `tool_executing` → `tool_completed` pair as a real * execution so UI cards reach a terminal state instead of hanging in * `executing`, and records a failed activity for the trace. */ private recordDenial; private recordCancelledBeforeExecution; private recordSyntheticHookOutcome; private recordSyntheticPreparation; /** * The host preview and model text may differ; each gets its own text * budget. Rich bytes are measured separately and withheld whole when * over their cap, with a notice inside the model text's same hard bound. */ private budgetContent; private maybeCompress; } //# sourceMappingURL=executor.d.ts.map