// H8a — the production `ThetaProducerDeps` for the shipped composition root. // // The `V19e` composition producer (`composeThetaFixture`) maps a parsed `.theta` // to a runnable `ThetaFixture` by composing three injected collaborators: // // - `runBinder` — the `V11a` frontmatter binder over the slash arguments, // run before the theta interpreter; a non-binding envelope short-circuits; // - `bindPromptConversation` — bind `V19d`'s effectful executor to the shared // user session (`V12a`/`V9c`) so `@`-queries drive real user-visible turns; // - `spawnSubagentConversation` — spawn an isolated `AgentSession` (`V9i`) and // bind the executor to that private session for subagent-mode thetas. // // This module assembles those collaborators against the live host `pi` surface // and the runtime root's seams, so the shipped extension drives real // prompt-mode / typed / subagent turns. // // Spec (narrative): pi-integration-contract/extension-bootstrap-and-per-theta.md // (§"Per-theta registration"), conversation-drive.md, slash-invocation.md, // binder/binder-model-and-context.md, subagent.md. import type { ExtensionAPI, ExtensionCommandContext, ModelRegistry, } from "@earendil-works/pi-coding-agent"; import { dirname, isAbsolute, resolve as resolvePath } from "node:path"; import { parseDocument } from "yaml"; // RFC-0005: `buildSessionContext` remains for the prompt-mode drive; the former // in-process subagent satellites (`createAgentSession` / `DefaultResourceLoader` // / `SessionManager` / `getAgentDir` / `defineTool`) are retired — the subagent // drive spawns a child `pi` process (subagent.md, RFC-0005). import { buildSessionContext } from "@earendil-works/pi-coding-agent"; import { runSubagentChildTeardown } from "../runtime/subagent-isolation"; import type { SubagentChildProcess } from "../runtime/subagent-launcher"; import { inferChildTrust, launchSubagentChild, routeSubagentSpawnFailure, } from "../runtime/subagent-launcher"; import { attachSubagentCancellation, driveSubagentChild, type SubagentInvocationResult, } from "../runtime/subagent-json-driver"; import { intakeChildParams, marshalParams, type ChildParamsIntake, type ParamsMarshalDeps, type ParamsSchemaValidator, } from "../runtime/subagent-params"; import type { RootRegime } from "../runtime/subagent-root-regime"; import { resolveDispatchLadder, type DispatchLadderProbe, type EncodedToolRequest, type HostToolResult, } from "../runtime/host-loop-dispatch"; import { confirmChildModel, guardResolvedModel, SUBAGENT_MODEL_UNRESOLVED_MESSAGE, } from "../runtime/subagent-model-guard"; import { serializeErrEnvelope, serializeOkEnvelope, } from "../runtime/subagent-envelope"; import { SUBAGENT_CALLABLE_HASHES_ENV } from "../runtime/subagent-callable-hash"; import { runPromptSuspendInvoke } from "../runtime/invoke-prompt-suspend"; import type { ThetaMode } from "../parser/frontmatter"; import type { Api, AssistantMessage, Message, Model, Tool, ToolCall, ToolResultMessage, } from "@earendil-works/pi-ai"; // pi-ai 0.80.x moved the streaming free functions off the package root into // the publicly-exported `/compat` subpath (package.json `exports["./compat"]` // -> dist/compat.d.ts); the root barrel no longer re-exports `complete`. import { complete } from "@earendil-works/pi-ai/compat"; import type { Clock } from "../seams/clock"; import type { RuntimeRoot } from "../runtime-root"; import type { ActiveInvocationEntry, ActiveInvocationRegistry, } from "../runtime/active-invocation-registry"; import type { ForwardingSignalSource } from "./session-shutdown"; import type { BinderRunInput, BinderRunResult, ConversationBinding, ConversationBindInput, ThetaCompositionInput, ThetaProducerDeps, } from "./theta-composition-producer"; import type { EffectfulStatementHostDeps, QueryHostDispatch, SubagentFnSession, } from "../runtime/effectful-statement-host"; import { createEffectfulStatementHost } from "../runtime/effectful-statement-host"; import { buildEnvironment, type EnumRegistration, type LexicalEnvironment, type MaterializedImport, } from "../runtime/lexical-environment"; import { executeBody, ThetaFnArityError, type BodyExecution, type ExecuteBodyDeps, } from "../runtime/statement-executor"; import { extractTrailingTurnText } from "../runtime/conversation-drive"; import { extractPromptModeQueryResult, mapPromptModeSyncThrow, } from "../runtime/prompt-transport-mapping"; import { enforceInvokeParamsDepth, enforceInvokeReturnDepth, } from "../runtime/invoke-ceiling-depth"; import type { ForcedRespondTurn, FreePhaseTurn, QueryModelDriver, QueryToolLoopConfig, } from "../runtime/query-tool-loop"; import type { AgentToolResultEnvelope, CodeSideToolCall, ToolLoweringSink, } from "../runtime/tool-call-execute"; import { filterJoinToolText, lowerToolExecuteThrow } from "../runtime/tool-call-execute"; import { enforceCodeToolArgDepth, enforceModelToolArgDepth } from "../runtime/tool-call"; import type { CommittedSideEffect } from "../runtime/no-rollback"; import type { InvokeChild } from "../runtime/invoke-cancellation"; import { runInvokeChild } from "../runtime/invoke-cancellation"; import type { InvokeInfraError, TransportError } from "../runtime/query-error"; import { InvokeInfraCauseError } from "../runtime/query-error"; import { newInvokeChainAtDepth, pushCountableFrame, surfaceDepthOverflow, InvokeDepthExceededPanic, type InvokeChain, } from "../runtime/invoke-depth-cycle"; import { recheckInvokePathAtRuntime } from "../runtime/invocation"; import type { FileSystem } from "../seams/file-system"; import type { CommittedConversationMutator, CommittedSurface, } from "../runtime/terminal-outcomes"; import { createThetaAbort, deriveChildThetaAbort, forwardSlashCommandCancel, abortForAgentEnd, makeCancelledError, } from "../runtime/cancellation-core"; import { runCheckpointedBinderCall } from "../runtime/checkpoint-granularity"; import { runBinderCallWithCancellation } from "../binder/binder-cancellation"; import { guardToolExecutePromise } from "../runtime/tool-call-swallowing-handler"; import { guardQueryProviderPromise } from "../runtime/query-swallowing-handler"; import { guardInvokeExecutionPromise } from "../runtime/invoke-swallowing-handler"; import type { CheckpointSite } from "../seams/checkpoint"; import { brandSchemaValue, isEnumValue, isResultValue, makeErr, makeOk, schemaTagOf, valuesEqual, type ThetaValue, type ResultValue, } from "../runtime/value"; import { evaluateStringMember } from "../runtime/stdlib-string"; import { evaluateArrayMember } from "../runtime/stdlib-array"; import { evaluateObjectMember } from "../runtime/stdlib-object"; import type { Block, CallExpr, Expr, FnDecl, InvokeExpr, ThetaBody, QueryExpr, SchemaDecl, Stmt, SubagentSessionConfig, } from "../parser/theta-document"; import { parseExpressionSource } from "../parser/theta-document"; import { renderSystemPrompt } from "../parser/system-interpolation"; import { lowerQueryResponseSchema } from "../runtime/query-schema-lowering"; import type { LoweredSchema } from "../seams/schema-validator"; import type { ResolvedCallable } from "../parser/callable-set"; import type { TypedQuerySchemaValidation } from "../runtime/query-tool-loop"; import { buildTypedQueryValidation, parseStructuredPayload, payloadForRespond, } from "../runtime/typed-query-validation"; import { evaluateIndexAccess, evaluateMemberAccess, HostFatal } from "../runtime/runtime-panics"; import { routeThetaCallableSetupThrow } from "../runtime/tool-call-off-surface"; import { deriveToolLabel } from "../runtime/tool-registration"; import { lexQueryTemplate, renderEmptyShortCircuit, renderTemplateText, stringifyInterpolatedValue, type InterpolationType, } from "../render/query-render"; import { applyBinderBypass, buildBinderEnvelopeSchema, classifyBinderBypass, trimSlashArgumentWhitespace, } from "../binder/binder-envelope"; import { fillDefaultsAndRevalidate, type DefaultedField } from "../binder/defaulting"; import { matchAvailableModel } from "../binder/binder-model"; import { renderBinderSystemNote, type BinderAttemptOutcome, type BinderFailureSurface, } from "../binder/retry-taxonomy"; import { classifyProviderResponse } from "../binder/provider-error-mapping"; import { walkSessionContext } from "../binder/session-context-walk"; import { renderCompactTranscript, renderCustomTypeUnsafeNote, } from "../binder/compact-transcript"; import { coerceUnderlyingString } from "../diagnostics/placeholder"; import type { Diagnostic, SourceRange } from "../diagnostics/diagnostic"; import { capSystemNote, classifyModelContent } from "../binder/system-note"; import { renderArgumentEcho, type EchoParam, type EchoType, } from "../render/argument-echo"; import { renderNoParamsOverflowNote } from "../runtime/slash-dispatch"; import { renderTopLevelErrNote } from "../runtime/err-note-render"; import type { QueryError } from "../runtime/query-error"; import { SYSTEM_NOTE_CHANNEL } from "./system-note-channel"; import { PromptToolLoopGovernor, type PromptToolLoopExhaustion, } from "./prompt-tool-loop-governor"; /** * H8b: one resolved host Pi tool the code-side tool-call path dispatches * `execute` against. `execute` invokes the host tool's `execute(...)` and maps * its `AgentToolResult` to the theta-load-bearing `AgentToolResultEnvelope` * (`content` only), or throws when the tool signals failure — the V14g lowering * (`runCodeSideToolCall`) turns a clean resolve into `Ok(text)` and a throw into * `Err(CodeToolError{cause:"execution"})`. */ export interface PiToolDispatch { readonly toolName: string; /** * Optional because an extension-supplied entry is execute-less by * construction: the §Resolution-snapshot entry pins only the tool's name and * `parameters`, and PIC-64 reaches its `execute` through the host loop rather * than by handle. The dispatch site narrows on `typeof … === "function"` * before calling, and routes the execute-less shape to the PIC-64 ladder. */ execute?( toolCallId: string, params: unknown, signal: AbortSignal, ): Promise; } /** Construction inputs for the production per-theta producer collaborators. */ export interface ProductionProducerInput { /** The live host extension API (turn drive, message send, command surface). */ readonly pi: ExtensionAPI; /** The runtime root over the real host seams (schema validator, clock, …). */ readonly root: RuntimeRoot; /** The host model registry (binder-model resolution, structured-output turns). */ readonly modelRegistry: ModelRegistry; /** * H8b: resolve a Pi-tool name from the theta's callable set (frontmatter * `tools:`) to its `execute` dispatch, or `undefined` when the name is not a * known host tool. Constructed at the composition root over the live host * `cwd` / `ctx`. Absent on non-production harnesses, in which case a code-side * `(args)` call surfaces `Err(CodeToolError{cause:"execution"})` for the * unknown host tool rather than fabricating a value. */ readonly resolvePiTool?: (name: string) => PiToolDispatch | undefined; /** * RFC-0005 subagent launch seams (subagent.md #subagent-launch-contract). The * child-`pi`-process spawn function, the executable-resolution host snapshot, * the parent environment inherited by the child (full inheritance is the * credential mechanism), and the parent PID carried on the env marker. All are * wired at the production composition root; absent on non-production harnesses * (where a subagent bind fails with an internal error rather than launching). */ readonly subagentSpawn?: import("../runtime/subagent-launcher").SpawnFn; readonly subagentExecutableHost?: import("../runtime/subagent-launcher").ExecutableHost; readonly subagentParentEnv?: Readonly>; readonly subagentParentPid?: number; /** * INV-4 (invocation.md §INV-4): the inbound per-chain invoke depth this * process was launched at. Non-zero only when THIS process is a subagent * child `pi` process: the parent marshalled its current chain depth on the * child env (`SUBAGENT_INVOKE_DEPTH_ENV`) and the composition root parsed it * (fresh chain at depth 0 on a malformed carriage). The child's top-level * invoke chain seeds from it so the depth-32 ceiling continues across the * process hop. Absent (→ 0) on the parent / harness paths. */ readonly subagentInboundInvokeDepth?: number; /** * #subagent-isolation-and-trust: the `pi.getAllTools()` name+`sourceInfo.scope` * snapshot the project-local trust inference (`--approve` / `--no-approve`) * reads. Absent on non-production harnesses (yields `--no-approve`). */ readonly getAllTools?: () => readonly import("../runtime/subagent-launcher").ToolSourceScope[]; /** * RFC-0006 (PIC-60): the params-channel filesystem seam — `writeTempFile` * (parent-side 0600 temp-file write for the at/above-threshold channel) and * `unlink` (the parent-`finally` backstop delete). Wired at the composition * root over Windows-safe `node:fs`; absent on non-production harnesses (small * env-channel params never touch it, so it is only needed for ≥8 KB payloads). */ readonly subagentParamsFs?: { readonly writeTempFile: (contents: string, mode: number) => string; readonly unlink: (path: string) => void; readonly readFile: (path: string) => string; }; /** * RFC-0006 (PIC-58): the subagent-root regime detected from the process env at * the composition root (`detectSubagentRootRegime`). Active only inside a * spawned subagent child; drives `isSubagentRootFor` / `driveSubagentRootRegime`. * Absent (→ inactive) on the parent / harness paths. */ readonly subagentRootRegime?: RootRegime; /** * RFC-0006 (PIC-59): the child-side stdout envelope writer — emits the single * `theta_result` JSONL line on the child's stdout. Wired at the composition * root over `process.stdout.write`; a fake in tests asserts the emitted line. * Absent on the parent / harness paths (the child-root drive is never entered * there). */ readonly emitResultEnvelope?: (line: string) => void; /** * PIC-64: the code-side extension-tool dispatch ladder probe — which rungs * are EXECUTABLE in THIS process, mode-independently. The probe contract is * executability, not bare surface presence: the same probe gates load-time * registration (rung 3) and runtime rung routing, so a recorded rung must * have a dispatcher behind it or registration would outrun dispatchability. * `getToolDefinitionAvailable` is the upstream surface probe AND a wired * rung-1 dispatcher (reads false at the pin — no rung-1 dispatcher exists); * `hostLoopAvailable` is `true` wherever a host agent loop backs host-loop * dispatch (`hostLoopDispatch` wired) — the parent's live user session and * the subagent-root child alike. Absent → no rung → a theta whose CODE calls * an extension tool refuses fail-closed with * `theta/load/extension-tool-unreachable`. */ readonly dispatchLadderProbe?: DispatchLadderProbe; /** * PIC-64 rung 2: the host-loop dispatch seam — register a theta-controlled * provider authoring the `tool_use`, run the backing host session's agent-loop * turn, read the result back, restore the model. Wired at the composition * root over the live host agent loop (a live-only mechanism) in BOTH modes; * only the backing session differs (the user's live session in prompt mode, * the child's private discarded session in subagent mode). Absent here → the * ladder is fail-closed pending the upstream `getToolDefinition` exposure. * The `signal` is the code-side tool call's abort signal (the theta abort), * threaded so a thetaAbort mid-fabricated-turn resolves the settle barrier * and the model is restored (never left on the bridge) — the leaf * `dispatchViaHostLoop` seam itself is signal-agnostic; this producer dep * carries the signal into the production collaborators. */ readonly hostLoopDispatch?: ( request: EncodedToolRequest, signal: AbortSignal, ) => Promise; /** Runtime-defect diagnostic sink (advisory teardown / spawn-failure / wire failures). */ readonly emitDiagnostic?: (diagnostic: Diagnostic) => void; /** * H8b: parse a `.theta`-callable / `invoke(...)` callee referenced from * `callerPath` into a runnable composition input (resolving the callee path * against the caller's directory), or `undefined` when the callee is missing * / unparseable. Constructed at the composition root over the real * `FileSystem` seam and the shared parser deps. */ readonly parseCallee?: ( callerPath: string | undefined, calleePath: string, ) => Promise; /** * INV-5 (invocation.md §Resolution, INV-1 seam): the `FileSystem.realpath` * seam and the union of currently-active discovery roots, used by the runtime * open-time containment re-check. Absent on non-production harnesses, in which * case the runtime re-check is skipped (the load-time check remains the * primary guard). */ readonly fileSystem?: Pick; readonly activeRoots?: readonly string[]; /** * Decision 6 / Increment B1 (active-invocation-registry.md §"Active * invocation registry"): the extension-instance-scoped registry of in-flight * theta invocations, shared with the factory's `session_shutdown` teardown so * its sub-step 2 (cancel in-flight) + sub-step 3 (await dispose) operate on * REAL entries. Each `bindPromptConversation` / `spawnSubagentConversation` * choke point registers one `ActiveInvocationEntry` here (covering all four * invocation types: top-level prompt/subagent + nested prompt/subagent * callees via `#driveCallee`). Absent on non-production harnesses, in which * case the choke points register nothing (the `?.` no-ops) — the pre-B1 * behaviour. */ readonly activeInvocations?: ActiveInvocationRegistry; /** * Decision 6 / Increment B2 (session-shutdown-semantics.md sub-step 5): the * extension-instance-scoped mutable sink of INVOCATION-SCOPED forwarding * listeners, shared with the factory's `session_shutdown` teardown so * sub-step 5 detaches the listeners still attached for an invocation in-flight * at shutdown time. Each `bindPromptConversation` / `spawnSubagentConversation` * choke point pushes one `ForwardingSignalSource` per invocation-scoped * forward (the bind-time `ctx.signal` forward; the derived-child parent-invoke * listener) and splices+detaches them in `finishInvocation`, so only a * still-in-flight-at-shutdown invocation leaves entries for sub-step 5. Absent * on non-production harnesses, in which case the choke points push nothing * (the `?.` no-ops). PER-TURN forwards (the query-loop `ctx.signal` re-forward) * are deliberately NOT collected — their `{once:true}` listeners sit on * per-turn-transient `ctx.signal` objects that self-clean, so collecting them * would only add per-turn push/splice churn for no lifetime benefit. */ readonly forwardingSignals?: ForwardingSignalSource[]; } /** * Assemble the production `ThetaProducerDeps` the shipped composition root * injects into `composeThetaFixture` for every discovered `.theta`. */ export function createProductionProducerDeps( input: ProductionProducerInput, ): ThetaProducerDeps { return new ProductionThetaProducer(input); } /** * PIC-65 spawn-failure. Raised (a specific type, never a broad throw) when the * subagent child `pi` process cannot be launched (executable unresolved / spawn * throw / missing spawn seam). It unwinds the bind so the invocation fails and * routes as an unanticipated SDK reject (`theta/runtime/internal-error`). */ class SubagentSpawnFailedError extends Error {} /** * CANCEL-3 (cancellation.md §"Race semantics — swallowing-handler attachment on * every abandonable Promise"): the two emit channels a late abandonable-Promise * settlement could reach. The `unhandledRejection` channel is closed * structurally by attaching the swallowing handler at construction, so it takes * no member; these two are noops because the runtime's primary `await` owns the * timely settlement and a discarded late settlement emits nothing on any * channel (no second `RuntimeEvent`, no diagnostic of any severity). */ function noopSwallowChannels(): { readonly emitRuntimeEvent: () => void; readonly emitDiagnostic: () => void; } { return { emitRuntimeEvent: (): void => {}, emitDiagnostic: (): void => {}, }; } /** * CANCEL-3: a live cancellation-guard view backed by the theta `signal`, read at * settlement time (not snapshotted at construction) — the checkpoint that * surfaces `cause: "cancelled"` reads the same `signal.aborted`, so a late * settlement observed while it is aborted is the abandoned case the swallowing * handler discards. */ function signalGuard(signal: AbortSignal): { readonly cancellationSurfaced: boolean } { return { get cancellationSurfaced(): boolean { return signal.aborted; }, }; } /** A fresh `ToolLoweringSink` that discards every channel — the test thetas carry no code-tool calls. */ function noopSink(): ToolLoweringSink { return { runtimeEvent(): void {}, diagnostic(): void {}, systemNote(): void {}, }; } /** * An inert `CommittedConversationMutator`. A prompt-mode terminal event routes * through `handlePartialTerminalOutcome`, which calls nothing on the mutator for * the cancel path (ERR-8 … ERR-12: no committed surface is mutated); the shipped * user session's committed transcript is Pi-owned and never rewritten by theta. */ class NoopConversationMutator implements CommittedConversationMutator { truncate(): void {} rewrite(): void {} replace(): void {} remove(): void {} injectCompensatingTurn(_surface: CommittedSurface): void {} } /** * H8b. Raised (a specific type, never a broad throw) when a code-side * `(args)` call names a host tool the composition root cannot resolve (no * `resolvePiTool` collaborator, or the name is not a known host tool). Thrown * from the `CodeSideToolCall.dispatch()` so the V14g lowering surfaces it as * `Err(CodeToolError{cause:"execution"})` rather than fabricating a value. */ class UnknownHostToolError extends Error {} /** The basename of a `.theta`-callable path, minus its `.theta` extension. */ function thetaCallableName(path: string): string { const base = path.slice(path.replace(/\\/g, "/").lastIndexOf("/") + 1); return base.endsWith(".theta") ? base.slice(0, -".theta".length) : base; } /** * The production per-theta producer. Constructed once per `session_start` * discovery pass and shared across every discovered theta's `composeThetaFixture` * call; it holds only its injected collaborators (no cross-invocation mutable * state), constructing a fresh conversation binding per dispatch. */ class ProductionThetaProducer implements ThetaProducerDeps { readonly #input: ProductionProducerInput; /** * STAGE B (ceiling #2): bounds pi's native prompt-mode agentic tool loop to * the theta's `tool_loop.max_rounds`. Registered once on the host `pi` (lazily, * on the first prompt-mode query drive) and guarded by a per-drive active * state, so it never affects unrelated user turns. */ readonly #promptToolLoopGovernor = new PromptToolLoopGovernor(); constructor(input: ProductionProducerInput) { this.#input = input; } async runBinder(binderInput: BinderRunInput): Promise { // The `V11a` frontmatter binder binds typed `params:` from the slash // arguments before the interpreter. A theta with no `params:` (or one whose // block did not lower cleanly) has nothing to bind, so the bind step is a // no-op and the body runs unconditionally. const params = binderInput.theta.frontmatter.params; if (params === undefined || params.loweredSchema === undefined) { // A theta with no declared `params:` has nothing to bind: the body runs // with an empty params object (no slots installed). SLSH-1: a no-params // theta bypasses the binder, so the overflow note is emitted here before // the body runs. this.#emitNoParamsOverflowNote(binderInput); return { bound: true, args: {} }; } // Load-time bypass classification (§Binder bypass): the no-params and // single-string bypasses skip the binder call (and the LLM inference) // entirely and the body runs with the trivially-derived args. Only a // `binder` decision drives a real binder pass. const decision = classifyBinderBypass(params.fields); if (decision.kind !== "binder") { // SLSH-1: the no-params bypass (`params: {}`) also overflows on extra // slash arguments; the single-string bypass consumes the argument as its // sole param, so it never overflows. if (decision.kind === "no-params-bypass") { this.#emitNoParamsOverflowNote(binderInput); } // The bypass args are derived without any binder / LLM call and threaded // into body scope (the single-string bypass sets the sole field to the // trimmed slash-argument string; the no-params bypass yields `{}`). const bypass = applyBinderBypass({ decision, slashArguments: binderInput.args }); return { bound: true, args: bypass.args }; } // A genuine binder pass over the declared params. DECISION (production // conformance): the binder runs OFF-session and INVISIBLE — no user-visible // streamed turn, no transcript card, and the envelope JSON NEVER reaches the // user session (BND-3). It runs against the RESOLVED BINDER MODEL // (`bind_model:` → `theta.binderModel`, resolved at load time and carried on // the theta), NOT the ambient session model (DISCO-1 runtime facet). The // reference is resolved to a concrete `Model` via the model registry // by the same exact-match rule the load-time resolution used; a registered // non-bypass theta always carries a resolvable binder model (an unresolvable // one failed to load), so `model === undefined` is a defensive guard only. const binderModelRef = binderInput.theta.binderModel; const model = binderModelRef !== undefined ? matchAvailableModel(binderModelRef, this.#input.modelRegistry.getAvailable()) : undefined; if (model === undefined) { // Defensive (unreachable for a registered non-bypass theta): surface the // malformed failure note rather than crash the dispatch, and do not run // the body. this.#emitBinderFailureNote(binderInput.theta.slashName, { kind: "malformed" }); return { bound: false }; } const envelopeSchema = buildBinderEnvelopeSchema({ paramsSchema: params.loweredSchema, defaultedFields: params.defaultedFields, }); // BNDR-10 (binder/binder-model-and-context.md §Binder context): a // `bind_context: session` prompt-mode theta grounds the binder in a *Recent // session context* block — the newest→oldest truncation walk (≤20 turns ∧ // ≤8000 tokens) rendered as a compact transcript. A BNDR-9 transcript-unsafe // `customType` aborts binding (the theta does not run) with the // custom-type-unsafe note. `bind_context: none` (or subagent-mode) → no block. const sessionContext = this.#buildBinderSessionContext(binderInput); if (sessionContext.kind === "unsafe") { this.#emitCustomTypeUnsafeNote(binderInput.theta.slashName, sessionContext.value); return { bound: false }; } const prompt = renderBinderTurnPrompt({ slashName: binderInput.theta.slashName, args: binderInput.args, paramsSchema: params.loweredSchema, defaultedFields: params.defaultedFields, envelopeSchema, ...(sessionContext.kind === "block" ? { sessionContext: sessionContext.body } : {}), }); // OFF-session completion via pi-ai `complete()` against the resolved binder // model (never `driveStreamedUserTurn`, never `ctx.model`): the reply text // is parsed as the envelope but is NEVER sent to the user session. Auth is // resolved off the model registry and passed as request options — the // out-of-band `complete()` free function does not inherit the session's // resolved credentials, so an un-authed call would return an empty // error-stop reply. // // CANCEL-4 (cancellation.md §Granularity binder-call clause; §Surfacing // cancelled-binder arm): the `binder-call` checkpoint fires immediately // before the LLM call (`runCheckpointedBinderCall`) and `thetaAbort.signal` // is forwarded INTO the provider invocation as `options.signal` // (`runBinderCallWithCancellation` threads it per attempt), so an abort // observed BEFORE or DURING the binder call suppresses it. A cancelled // binder never surfaces a `Result` to theta code — the theta does not run — // and produces the cancelled-binder system note instead. const signal = binderInput.thetaAbort?.signal ?? createThetaAbort().signal; const binderSite: CheckpointSite = { file: binderInput.theta.slashName, line: 1, column: 1, }; // The binder attempt is CLASSIFIED per determinism-cancellation-failure.md // §Failure-class taxonomy so the per-class retry budget (HC3-a transport / // HC3-b malformed, driven by `runBinderCallWithCancellation`) actually // re-drives a transient failure — a provider throw / `stopReason:"error"` / // overflow classifies as `transport` (one retry), an unparseable envelope as // `malformed` (one retry); `ok`/`needs_info`/`ambiguous` are terminal. The // winning `ok` attempt's parsed args are captured for the defaults-merge. let okArgs: Record = {}; const phase = await runCheckpointedBinderCall( this.#input.root.checkpoint, signal, binderSite, () => runBinderCallWithCancellation({ thetaName: binderInput.theta.slashName, signal, attempt: async (_attemptIndex, attemptSignal) => { const classified = await this.#classifyBinderAttempt(model, prompt, attemptSignal); if (classified.okArgs !== undefined) { okArgs = classified.okArgs; } return classified.outcome; }, }), ); if (phase.cancelled) { // Pre-call checkpoint abort: the LLM call was never issued. this.#emitBinderFailureNote(binderInput.theta.slashName, { kind: "cancelled" }); return { bound: false }; } if (phase.value.kind === "cancelled") { // In-flight abort: the provider observed the forwarded `options.signal`. this.#emitBinderFailureNote(binderInput.theta.slashName, { kind: "cancelled" }); return { bound: false }; } // Route on the terminal (most-recent, HC3-e) classified outcome. The theta // body runs only on the `ok` arm; every failure arm (`needs_info` / // `ambiguous` / `malformed` / `transport`-budget-exhausted) emits the mapped // failure-mode system note and short-circuits (the body never runs). The // envelope is runtime-internal and is never surfaced verbatim. const outcome = phase.value.outcome; if (outcome.kind !== "ok") { this.#emitBinderFailureNote(binderInput.theta.slashName, outcome); return { bound: false }; } // §Defaulting (defaulting-system-note-echo.md#post-default-merge-ajv-validation; // binder-bypass-and-envelope.md#binder-envelope): defaults are filled by the // runtime AFTER the binder returns, not by the binder. The binder is told // which fields have defaults and MAY omit them from `args`; the runtime then // fills any defaulted wire name absent from `args` (fill-if-absent) and // AJV-validates the merged result before the body runs. Without this merge a // declared default (`count: integer = 3`) never reaches body scope and the // body sees the field as absent (BND-2). Only the genuine binder pass reaches // here — a defaulted field forces the `binder` classification (the // single-string / no-params bypasses carry no defaults), so the bypass arms // above are intentionally left unchanged. const binderArgs = okArgs; const mergedArgs = await this.#mergeDeclaredDefaults(binderInput.theta, params, binderArgs); // §"Echo policy" (BND-1): on a successful bind the runtime appends the // one-line success echo note (`Running /: …`) on the theta-system-note // channel immediately before the theta starts, UNLESS `bind_echo: false`. The // bypass arms auto-suppress the echo independently and never reach here. this.#emitBinderEchoNote(binderInput.theta, params, binderArgs, mergedArgs); return { bound: true, args: mergedArgs }; } /** * §"Echo policy" success echo (BND-1): render and emit the one-line * `Running /: ` system note on the theta-system-note * channel — the SAME `pi.sendMessage` delivery the SLSH-1 overflow / SNOTE-1 * notes use — unless `bind_echo:` is `false`. Each top-level `params:` field * renders in declaration order; a field that took its declared default this * run (absent from the binder-supplied `args`) is tagged `(default)`. The echo * is rendered off the resolved runtime values (value-driven `EchoType` * derivation, disambiguating `integer` vs `number` from the lowered schema) * and passed through the shared 120-code-point cap. */ #emitBinderEchoNote( theta: ConversationBindInput["theta"], params: NonNullable, binderArgs: Readonly>, mergedArgs: Readonly>, ): void { if (theta.frontmatter.bindEcho === false) { return; } const defaulted = new Set(params.defaultedFields); const properties = params.loweredSchema !== undefined ? ((params.loweredSchema as Record)["properties"] as | Record | undefined) : undefined; const echoParams: EchoParam[] = params.fields.map((field) => { const value = (mergedArgs[field.wireName] ?? null) as ThetaValue; // The `(default)` tag fires only when the field took its declared default // this run (fill-if-absent): a wire name ABSENT from the binder-supplied // args and declared defaulted. const tookDefault = defaulted.has(field.wireName) && !Object.prototype.hasOwnProperty.call(binderArgs, field.wireName); return { name: field.wireName, value, type: echoTypeFromValue(value, properties?.[field.wireName]), tookDefault, }; }); const content = capSystemNote( renderArgumentEcho({ thetaName: theta.slashName, params: echoParams }), ); this.#input.pi.sendMessage( { customType: SYSTEM_NOTE_CHANNEL, content, display: true, details: { event: {} }, }, { triggerTurn: false }, ); } /** * Classify ONE binder attempt (determinism-cancellation-failure.md * §Failure-class taxonomy) into a `BinderAttemptOutcome` the per-class retry * budget driver consumes. A synchronous/async throw from the provider call, or * a reply carrying `stopReason:"error"`/`"length"`/overflow, classifies as * `transport` (folding ContextOverflow into transport per the taxonomy) with * `provider = Model.api`; a clean reply is parsed as the envelope and * routed `ok`/`needs_info`/`ambiguous`/`malformed`. The `ok` arm's parsed args * are returned for the defaults-merge. `temperature: 0` is set (spec * Determinism); the FNV-1a seed / forced-tool structured output are NOT used * (the pinned forced-tool mechanism is unrealizable against the available * providers — a top-level `anyOf` envelope is not a valid tool `input_schema`, * yielding empty tool args — so the binder stays a free-text envelope call). */ async #classifyBinderAttempt( model: Model, prompt: string, signal: AbortSignal, ): Promise<{ readonly outcome: BinderAttemptOutcome; readonly okArgs?: Record }> { const provider = String(model.api); let reply: AssistantMessage; try { reply = await this.#completeBinderReply(model, prompt, signal); } catch (thrown: unknown) { // allow-broad-catch: pi-sdk-boundary — a provider transport throw → HC3-a transport class // A cancellation abort is surfaced by the caller's before/after-attempt // signal checks, not misclassified as a retryable transport failure. if (signal.aborted) { return { outcome: { kind: "transport", provider, message: "cancelled" } }; } return { outcome: { kind: "transport", provider, message: coerceUnderlyingString(thrown) } }; } const stopReason = (reply as { readonly stopReason?: string }).stopReason ?? ""; // A non-turn-boundary stop reason (provider error / output-limit overflow) // classifies through the shared provider-error taxonomy; ContextOverflow // folds into the transport class before the retry driver (HC3-a). if (stopReason === "error" || stopReason === "length" || stopReason === "content_filter") { const classified = classifyProviderResponse({ api: provider, httpStatus: 200, stopReason, ...(typeof (reply as { errorMessage?: string }).errorMessage === "string" ? { errorMessage: (reply as { errorMessage?: string }).errorMessage } : {}), }); const message = classified.kind === "transport" && classified.message !== "" ? classified.message : "provider transport failure"; return { outcome: { kind: "transport", provider, message } }; } // Clean reply: parse and route the free-text envelope. const routing = await parseBinderEnvelope(assistantText(reply)); if (routing.kind === "ok") { const okArgs = await parseOkEnvelopeArgs(assistantText(reply)); return { outcome: { kind: "ok" }, okArgs }; } return { outcome: routing }; } /** * Issue ONE OFF-session binder `complete()` against the resolved binder * `Model` and return the raw reply. Resolves the model's request auth * (apiKey / headers) off the injected model registry and passes it as request * options; the fixed `context.messages` is the rendered prompt as a single * `user` message, at `temperature: 0` (Determinism). No user-session turn, no * transcript card — the reply is runtime-internal (BND-3). */ async #completeBinderReply( model: Model, prompt: string, signal: AbortSignal, ): Promise { const auth = await this.#input.modelRegistry.getApiKeyAndHeaders(model); const options: Record = {}; // CANCEL-4 in-flight forwarding: thread `thetaAbort.signal` into the binder // provider invocation as `options.signal` (pi-ai `StreamOptions.signal`), so // an abort observed during the call propagates to the provider's abort path. options["signal"] = signal; // Determinism (determinism-cancellation-failure.md §Determinism): the binder // call is `temperature: 0` (the FNV-1a seed is omitted here — omitted for the // anthropic-messages / amazon-bedrock transports the binder guidance steers // toward, and the forced-tool structured-output call it belongs to is not // realizable against the available providers). options["temperature"] = 0; if (auth.ok) { if (auth.apiKey !== undefined) { options["apiKey"] = auth.apiKey; } if (auth.headers !== undefined) { options["headers"] = auth.headers; } } return complete( model, { messages: [{ role: "user", content: prompt, timestamp: 0 }] }, options, ); } /** * Emit the mapped binder failure-mode system note (BND-3) on the * theta-system-note channel: `needs_info` / `ambiguous` render their * fixed-phrase row with the model's message; a non-parse / empty-message reply * is the malformed-envelope row (`could not parse arguments`). The raw * envelope JSON is NEVER surfaced. */ /** * BNDR-10 (binder/binder-model-and-context.md §Binder context): build the * binder's *Recent session context* transcript body for a `bind_context: * session` prompt-mode theta. Sources the chronological message list from the * live session, runs the newest→oldest truncation walk (≤20 turns ∧ ≤8000 * tokens via the injected `TokenEstimator`), and renders the included slice as * a compact transcript. Returns `none` when the feature is off (subagent-mode, * `bind_context: none`, or the walk produced zero turns — BNDR-7i void * truncation), `block` with the transcript body when ≥1 turn was included, or * `unsafe` when an included `custom` message's `customType` is not * transcript-safe (BNDR-9: binding must not proceed). */ #buildBinderSessionContext( binderInput: BinderRunInput, ): { readonly kind: "none" } | { readonly kind: "block"; readonly body: string } | { readonly kind: "unsafe"; readonly value: string } { const fm = binderInput.theta.frontmatter; if (fm.bindContext !== "session" || fm.mode !== "prompt") { return { kind: "none" }; } const messages = buildSessionContext( binderInput.ctx.sessionManager.getEntries(), binderInput.ctx.sessionManager.getLeafId(), ).messages as unknown as readonly import("@earendil-works/pi-agent-core").AgentMessage[]; const walk = walkSessionContext({ messages, estimator: this.#input.root.tokenEstimator, mode: fm.mode, bindContext: "session", }); if (!walk.applies || walk.includedMessages.length === 0) { return { kind: "none" }; } const rendered = renderCompactTranscript(walk.includedMessages); if (rendered.kind === "custom-type-unsafe") { return { kind: "unsafe", value: rendered.value }; } if (rendered.sessionContext === undefined) { return { kind: "none" }; } return { kind: "block", body: rendered.sessionContext.transcriptBody }; } /** * BNDR-9: emit the `theta/runtime/custom-type-unsafe` user-facing note on the * theta-system-note channel when an included session-context `custom` message * carries a transcript-unsafe `customType`; binding does not proceed. */ #emitCustomTypeUnsafeNote(thetaName: string, value: string): void { this.#input.pi.sendMessage( { customType: SYSTEM_NOTE_CHANNEL, content: renderCustomTypeUnsafeNote(thetaName, value), display: true, details: { event: {} }, }, { triggerTurn: false }, ); } #emitBinderFailureNote(thetaName: string, surface: BinderFailureSurface): void { this.#input.pi.sendMessage( { customType: SYSTEM_NOTE_CHANNEL, content: renderBinderSystemNote(thetaName, surface), display: true, details: { event: {} }, }, { triggerTurn: false }, ); } /** * Fill-if-absent the theta's declared `params:` defaults into the binder-returned * `args`, then run the post-default-merge AJV validation, reusing the * unit-tested `fillDefaultsAndRevalidate` (`binder/defaulting.ts`). A wire name * PRESENT in `args` is preserved unchanged (a user-supplied value wins over the * default); a wire name ABSENT takes its declared default. * * The declared default VALUES are not carried on the parsed `ParsedParams` * (it retains only the defaulted wire names, not their literals), so they are * recovered here from the theta's own source: the `params:` field scalar is * re-read via the `FileSystem` seam, its `= ` default RHS is split * off, and the literal is parsed + evaluated through the same pure evaluator * the body uses. Recovery is best-effort — a theta with no on-disk `sourcePath` * (an in-memory fixture), an unreadable file, or a default that does not parse * simply leaves that field unfilled (the prior behaviour for it), never throws. */ async #mergeDeclaredDefaults( theta: ConversationBindInput["theta"], params: NonNullable, binderArgs: Readonly>, ): Promise>> { if (params.defaultedFields.length === 0 || params.loweredSchema === undefined) { return binderArgs; } const defaults = await this.#recoverDeclaredDefaults(theta, params.defaultedFields); if (defaults.length === 0) { return binderArgs; } // Post-default-merge AJV validation runs against the MERGED args (§Defaulting). // Its verdict routes, on failure, to the AJV-on-`args` retry class owned // elsewhere in the runtime; this leaf owns only the fill-if-absent merge and // invoking the named validation hook, so the merged args are returned // regardless of the verdict (the body-run vs short-circuit routing is not // this leaf's to change). const validator = this.#input.root.schemaValidator.compile(params.loweredSchema); const result = fillDefaultsAndRevalidate({ binderArgs, defaults, validator }); return result.args; } /** * Recover the declared default literal VALUE for each defaulted wire name from * the theta's source file. The parsed `ParsedParams` drops the default literals * (retaining only the wire names), so this re-reads the `.theta`, extracts the * frontmatter YAML, reads each `params:` field's scalar, splits its `= ` * default RHS, and parses + evaluates the literal with the body's pure evaluator * (so an enum / schema-literal default resolves against the body's declarations). */ async #recoverDeclaredDefaults( theta: ConversationBindInput["theta"], defaultedFields: readonly string[], ): Promise { const sourcePath = theta.sourcePath; if (sourcePath === undefined) { return []; } const bytes = await this.#input.root.fileSystem.readBytes(sourcePath).then( (value) => value, () => undefined, ); if (bytes === undefined) { return []; } const yamlText = extractFrontmatterYaml(new TextDecoder().decode(bytes)); if (yamlText === undefined) { return []; } const doc = parseDocument(yamlText); const env = buildBoundEnvironment(theta.body, undefined, theta.imports); const defaults: DefaultedField[] = []; for (const wireName of defaultedFields) { const raw = doc.getIn(["params", wireName]); if (typeof raw !== "string") { continue; } const defaultSource = splitParamDefaultSource(raw); if (defaultSource === undefined) { continue; } const parsed = parseExpressionSource(defaultSource); if (parsed === null) { continue; } defaults.push({ wireName, defaultValue: evaluatePureExpression(parsed, env) }); } return defaults; } /** * SLSH-1 no-params overflow note (slash-invocation.md#slsh-1): a no-params * theta bypasses the binder; the runtime trims slash-argument whitespace and, * if the remainder is non-empty, emits exactly ONE * `theta /: ignoring extra arguments — this theta takes no parameters` * note on the `theta-system-note` channel BEFORE the body runs (a * whitespace-only remainder emits no note). `runBinder` is only reached on the * slash-invocation path (invoke/tool callers spawn callees directly), so no * caller-kind guard is needed. Routed through `pi.sendMessage` — the same * channel the shipped system-note delivery uses. */ #emitNoParamsOverflowNote(binderInput: BinderRunInput): void { if (trimSlashArgumentWhitespace(binderInput.args).length === 0) { return; } this.#input.pi.sendMessage( { customType: SYSTEM_NOTE_CHANNEL, content: renderNoParamsOverflowNote(binderInput.theta.slashName), display: true, details: { event: {} }, }, { triggerTurn: false }, ); } /** * SLSH-3/SLSH-4/SLSH-5 top-level `Err` note. `composeThetaFixture.run` — the * slash-dispatch entry point, reached only for a slash caller with no invoke * parent — calls this when the mode's `surface` yields an `Err`. The * `renderTopLevelErrNote` renderer emits the SNK per-kind row verbatim * (em-dash U+2014). `chain: []` renders the correct leaf row for every * reachable kind (including an `invoke_callee` wrapper, which the renderer * walks to its leaf); the SLSH-5 chain suffix is a deferred refinement — no * readily-usable invoke provenance reaches this boundary. Routed through the * same `pi.sendMessage` `theta-system-note` delivery as the SLSH-1 overflow * note. */ emitTopLevelErrNote(thetaName: string, error: QueryError): void { this.#input.pi.sendMessage( { customType: SYSTEM_NOTE_CHANNEL, content: renderTopLevelErrNote({ thetaName, error, chain: [] }), display: true, details: { event: {} }, }, { triggerTurn: false }, ); } /** * Top-level runtime-defect / panic note (errors-and-results/error-model.md * §"Runtime panics"; runtime-event-channel.md §"system-note-details-shapes" * group B). `composeThetaFixture.run`'s outer catch calls this when a runtime * defect is thrown at slash dispatch — a `ThetaPanic` * (`theta / aborted: `) or a catchable interpreter / adapter * throw routed to `theta/runtime/internal-error` * (`theta / aborted with internal error: `). Mirrors * `emitTopLevelErrNote`'s single `pi.sendMessage` delivery on the same * `theta-system-note` channel, but carries the group-B * `details: { diagnostics: [Diagnostic] }` shape (the SAME shape the * load-phase pre-eval diagnostics use). Emits * EXACTLY ONE note; the session is NOT torn down. `HostFatal` never reaches * here — the outer catch re-raises it (fail-fast, NOCEIL-3) before calling. */ emitPanicNote(framing: string, diagnostic: Diagnostic): void { this.#input.pi.sendMessage( { customType: SYSTEM_NOTE_CHANNEL, content: framing, display: true, details: { diagnostics: [diagnostic] }, }, { triggerTurn: false }, ); } /** * SUBAG-2 / tool-calls.md:30 (`.theta`-callable adapter pre-eval setup-throw * row). A GENUINE pre-dispatch dispatch-setup throw inside the model-driven * `.theta` adapter (raised before the callee body runs) is routed through * `routeThetaCallableSetupThrow`. Gap-1: a callee-BODY panic no longer reaches * here — `driveCallee` drives through `runInvokeChild`, which converts a * callee-subtree throw into an `Err(InvokeInfraError{cause:"panic"| * "internal_error"})` VALUE that lowers as a plain `isError` result with no * operator note. This routes only the true setup throw: * `routeThetaCallableSetupThrow` returns the clean `{ isError: true }` * envelope carrying the BARE callable-set name (never `/`), and emits * exactly one `theta/runtime/internal-error` diagnostic + one * `theta-system-note`. The sink captures the diagnostic and delivers the ONE * framed note through `emitPanicNote` — the SAME group-B * `details: { diagnostics: [Diagnostic] }` `theta-system-note` shape/channel * the top-level internal-error surface uses — so the model observes the tool * failure while the operator observes the framed defect. */ #emitThetaCallableSetupThrow( thrown: unknown, callableName: string, theta: ConversationBindInput["theta"], ): LoweredThetaCallableResult { let captured: Diagnostic | undefined; const sink: ToolLoweringSink = { runtimeEvent: (): void => {}, diagnostic: (diag): void => { captured = diag; }, systemNote: (framing): void => { if (captured !== undefined) { this.emitPanicNote(framing, captured); } }, }; const routed = routeThetaCallableSetupThrow( thrown, callableName, { file: theta.sourcePath ?? theta.slashName, range: ZERO_BODY_RANGE }, sink, ); return { text: routed.content[0]?.text ?? "", isError: routed.isError }; } /** * Decision 6 / Increment B2: push the invocation-scoped forwarding sources * onto the shared `forwardingSignals` sink and return a teardown closure that * detaches each listener and splices it back off. `finishInvocation` runs the * closure on a NORMAL settle so only a still-in-flight-at-shutdown invocation * leaves entries for `session_shutdown` sub-step 5. No-ops when the sink is * absent (non-production harness) or there are no sources. The detach closures * are `removeEventListener` calls that never throw, so no broad catch is * needed (conventions.md — specific exception types only). */ #trackForwardingSources( sources: readonly ForwardingSignalSource[], ): () => void { const sink = this.#input.forwardingSignals; if (sink === undefined || sources.length === 0) { return (): void => {}; } sink.push(...sources); return (): void => { for (const source of sources) { source.removeEventListener(); const index = sink.indexOf(source); if (index !== -1) { sink.splice(index, 1); } } }; } bindPromptConversation(bindInput: ConversationBindInput): ConversationBinding { const { pi, root } = this.#input; const { theta, ctx } = bindInput; // INV-4 / ceiling #1: a top-level dispatch starts a fresh chain, seeded at // the inbound subagent-child depth (0 on the parent / harness paths, the // marshalled parent depth inside a subagent child — invocation.md §INV-4 // wire-level carriage); a nested invoke carries the parent's pushed chain in // `bindInput.chain`. const chain = bindInput.chain ?? newInvokeChainAtDepth(this.#input.subagentInboundInvokeDepth ?? 0); // CANCEL-2 (cancellation.md §Signal source): the executor and every // checkpoint gate on the per-invocation `thetaAbort.signal` — NEVER // `ctx.signal` directly, and NEVER a pinned never-aborting fallback. The // dispatch entry (`composeThetaFixture.run`) owns `thetaAbort` and forwards // `ctx.signal` into it; an in-memory harness that binds directly gets a // fresh controller here. A second `forwardSlashCommandCancel` is idempotent // (the one-shot guard on `thetaAbort.abort()` makes a re-forward a no-op) and // re-observes `ctx.signal` in case it became defined after run-entry. // CANCEL-5 (cancellation.md §`invoke(...)` entry): a prompt→prompt child // invoke attaches to this user session but must still derive its `thetaAbort` // downward-only from the parent's signal (child aborts when the parent // aborts, never the reverse — `deriveChildThetaAbort`). A top-level prompt // dispatch (or in-memory harness) carries no `parentSignal` and gets the // dispatch-owned controller (or a fresh one). // Decision 6 / Increment B2: collect the INVOCATION-SCOPED forwarding // listeners so `session_shutdown` sub-step 5 can detach any still attached // for an invocation in-flight at shutdown. Strictly additive — the abort // forwarding is byte-identical; only the detach handles are now captured. const forwardingSources: ForwardingSignalSource[] = []; let thetaAbort: AbortController; if (bindInput.parentSignal !== undefined) { const derived = deriveChildThetaAbort(bindInput.parentSignal); thetaAbort = derived.controller; forwardingSources.push({ label: "parentInvokeSignal.removeEventListener", removeEventListener: derived.detach, }); } else { thetaAbort = bindInput.thetaAbort ?? createThetaAbort(); } // The bind-time `ctx.signal` forward is the ONE invocation-scoped `ctx.signal` // source collected per invocation: the redundant drive-seam forward // (`composeThetaFixture.run`) attaches a second `{once:true}` listener to the // same per-turn-transient `ctx.signal` and is deliberately NOT double-counted // here (it self-cleans like the per-turn listeners). forwardingSources.push({ label: "ctx.signal.removeEventListener", removeEventListener: forwardSlashCommandCancel(thetaAbort, ctx.signal), }); const signal = thetaAbort.signal; // The user session's resolved chronological message list — the PIC-53 // trailing-turn read surface. Recomputed per read from the live // `ReadonlySessionManager` so each turn's freshly-committed assistant text // is visible. const readMessages = (): readonly Message[] => buildSessionContext( ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId(), ).messages as unknown as readonly Message[]; const hostDeps: EffectfulStatementHostDeps = { checkpoint: root.checkpoint, signal, sink: noopSink(), file: theta.slashName, evaluatePure: (expr, env) => evaluatePureExpression(expr, env), resolveQuery: (expr, env) => { // SLSH-2: EVERY non-short-circuit prompt-mode query is a user-visible // streamed turn against the user session — assistant tokens for every // query (not just the first) stream into the transcript in real time. // Prompt→prompt invokes and the body run strictly SEQUENTIALLY (the // executor awaits each query), so there is no stream-interleaving risk. // QRY-6/QRY-8: a query whose rendered template is empty short-circuits // to `Err(empty_template)` with NO provider turn (not user-visible — no // turn is issued at all). const shortCircuits = renderEmptyShortCircuit(renderQueryText(expr, env)) !== undefined; const userVisible = !shortCircuits; return this.#resolvePromptQuery(expr, env, { pi, ctx, theta, signal, thetaAbort, readMessages, userVisible, }); }, resolveToolCall: (expr, env, evaluatedToolArgs) => this.#resolveToolCall(theta, expr, env, signal, evaluatedToolArgs), // CANCEL-5 / cross-mode: the caller's mode (`prompt`) is threaded to // `#driveCallee` so an `invoke`d prompt-mode callee attaches to this user // session (prompt→prompt) rather than spawning fresh. resolveInvoke: (expr, env) => this.#resolveInvoke(theta, expr, env, ctx, chain, signal, "prompt"), classifyCall: (expr) => this.#classifyCall(theta, expr), resolveCallAsInvoke: (expr, env) => this.#resolveCallAsInvoke(theta, expr, env, ctx, chain, signal, "prompt"), // RFC 0001 (`subagent fn`, FN-8): a prompt-mode theta may call a // `subagent fn` — the safe prompt→subagent direction. Each call spawns a // fresh isolated session under the resolved config; the depth frame // (INV-4 / FN-6) is pushed on `chain` inside the spawn. spawnSubagentFnSession: (config) => this.#spawnSubagentFnSession(theta, config, ctx, chain, signal), }; const executeDeps: ExecuteBodyDeps = { env: buildBoundEnvironment(theta.body, bindInput.paramBindings, theta.imports), host: createEffectfulStatementHost(hostDeps), checkpoint: root.checkpoint, signal, mutator: new NoopConversationMutator(), mode: "prompt", file: theta.slashName, }; // Decision 6 / Increment B1 (active-invocation-registry.md §"Active // invocation registry"): register this invocation in the shared registry // the factory's `session_shutdown` teardown reads, so sub-step 2 (cancel // in-flight) can abort THIS `thetaAbort` and sub-step 3 (await dispose) can // await its `disposeBarrier`. The per-invocation `thetaAbort` above is reused // verbatim (never a fresh controller). `invocationId` is minted through the // PIC-20 `IdSource` seam and `theta` is the canonical slash name (no leading // `/`). The entry is added LAST — this method is synchronous and cannot // throw between here and the return — and its removal is deferred to // `finishInvocation`, which the DRIVE seam calls in a `finally` AFTER the // body runs, so the entry SPANS the real in-flight window. Prompt mode has // no `AgentSession.dispose()` analogue, so the barrier settles immediately // at finish. const activeInvocations = this.#input.activeInvocations; let settleDispose: () => void = (): void => {}; const disposeBarrier = new Promise((resolve) => { settleDispose = resolve; }); const entry: ActiveInvocationEntry = { thetaAbort, disposeBarrier, shutdownReason: undefined, theta: theta.slashName, invocationId: root.idSource.newInvocationId(), }; activeInvocations?.add(entry); // Publish the invocation-scoped forwarding sources onto the shared sink LAST // (this method is synchronous and cannot throw between here and the return), // so a normal settle removes them via `finishInvocation` and only a // still-in-flight-at-shutdown invocation leaves them for sub-step 5. const detachForwarding = this.#trackForwardingSources(forwardingSources); let finished = false; // Idempotent: the DRIVE `finally` calls this once; a defensive caller may // call again with no effect. A NORMAL settle detaches the forwarding // listeners and splices them off the shared sink (no accumulation). const finishInvocation = (): void => { if (finished) return; finished = true; detachForwarding(); settleDispose(); activeInvocations?.remove(entry); }; return { drivenAgainst: "prompt-user-session", executeDeps, // RFC 0001 (`subagent fn`): expose the session-scoped effect resolvers so a // `subagent fn` spawn can re-bind them for a fresh isolated session. effectHostDeps: hostDeps, // PIC-53: the prompt-mode return value is the trailing turn's accumulated // assistant text of the driven user session on the SUCCESS path. A failed // run surfaces its real terminal outcome (mirroring the subagent surface): // a `?`-propagated `Err` carries its `QueryError` payload so the // slash-dispatch boundary (SLSH-3) can emit the top-level err note, and // any other fail / cancel surfaces the terminal cancellation `Err` — never // a masking `Ok`. Without this a failed prompt theta was indistinguishable // from a successful one and the SLSH-3 note was never emitted. surface: (execution: BodyExecution): ResultValue => { if (execution.outcome === "success") { return makeOk(extractTrailingTurnText(readMessages())); } // A `fail` outcome carries the terminating `Err` — a `?`-propagation OR // an unhandled non-cancel effect-`Err` in tail position (ERR-19, e.g. a // `tool_loop_exhausted` breach). Project that real error so the caller // reads the true leaf kind; NEVER fabricate a `cancelled` for a fail // (STL-6). Only a genuine `cancel` outcome (an aborted checkpoint) // yields `CancelledError`. if (execution.outcome === "fail") { return makeErr(execution.error ?? (makeCancelledError() as unknown as ThetaValue)); } return makeErr(makeCancelledError() as unknown as ThetaValue); }, finishInvocation, }; } /** * RFC-0006 (PIC-58/59/60/62/63). Parent-side subagent-mode binding. Under this * RFC the WHOLE callee runs in a spawned child `pi --theta … --mode json -p * "/" --no-session` process; the parent no longer drives a remote * session. The returned binding's `drive()` (PIC-59) launches the child, * marshals params structurally (PIC-60), awaits the single `theta_result` * stdout envelope, and maps `ok`/`err` to `Ok`/`Err` — the parent runs no * per-query extraction and never executes the callee body in-process. The * legacy RFC-0005 RPC drive is retired (deleted, not a fallback). * * The binding also exposes an IN-PROCESS `effectHostDeps` for the RFC-0001 * `subagent fn` inline-body path (`#spawnSubagentFnSession`), which has no * `.theta` file / slug to launch and so runs its inline body against an * isolated off-session conversation in the parent; that path uses * `effectHostDeps` and never triggers the child launch inside `drive()`. */ async spawnSubagentConversation( bindInput: ConversationBindInput, ): Promise { const { root } = this.#input; const { theta, ctx } = bindInput; // INV-4 / ceiling #1: carry the parent's pushed chain into the spawned // subagent invocation so the per-chain depth counter crosses the process // boundary unchanged; a top-level subagent dispatch starts a fresh chain // seeded at the inbound subagent-child depth (0 on the parent / harness // paths, the marshalled parent depth inside a subagent child). const chain = bindInput.chain ?? newInvokeChainAtDepth(this.#input.subagentInboundInvokeDepth ?? 0); // PIC-62 obligation 1 (pre-spawn model guard): the subagent's resolved model // is the theta's frontmatter `model:` resolved into the inherited session // model — here the inherited `ctx.model`. Refuse the spawn when it is // `undefined` rather than launching a modelless child, emitting the pinned // `theta/runtime/subagent-model-unresolved` diagnostic and surfacing the // precise `invoke_infra` cause `subagent_model_unresolved` to an `invoke` // parent. // PIC-62 single source of truth: the parent-side pre-spawn guard is the // `guardResolvedModel` leaf (`subagent-model-guard.ts`); the retired RFC-0005 // `preSpawnModelGuard` duplicate is deleted. const model = ctx.model; const modelGuard = guardResolvedModel(model?.id); if (!modelGuard.ok || model === undefined) { if (!modelGuard.ok) { (this.#input.emitDiagnostic ?? ((): void => {}))(modelGuard.diagnostic); } throw new InvokeInfraCauseError( SUBAGENT_MODEL_UNRESOLVED_MESSAGE, "subagent_model_unresolved", ); } // `thetaAbort` — the per-invocation cancel controller (cancellation.md §Signal // source). CANCEL-5: a child `invoke` binding (carrying `parentSignal`) // constructs its `thetaAbort` as a DERIVED controller (downward-only); a // top-level dispatch gets a fresh controller (shared with the dispatch entry // when `bindInput.thetaAbort` is present). const forwardingSources: ForwardingSignalSource[] = []; let thetaAbort: AbortController; if (bindInput.parentSignal !== undefined) { const derived = deriveChildThetaAbort(bindInput.parentSignal); thetaAbort = derived.controller; forwardingSources.push({ label: "parentInvokeSignal.removeEventListener", removeEventListener: derived.detach, }); } else { thetaAbort = bindInput.thetaAbort ?? createThetaAbort(); } // SUBAG-1: render the theta's `system:` frontmatter into the child's // `--system-prompt` (subagent.md §state-isolation matrix: `system:` inherited // from frontmatter, `${param}` interpolation resolved at spawn time). A // malformed `system:` was rejected at load; on the unexpected `!ok` path fall // back to no system prompt rather than crashing the spawn. let systemPrompt: string | undefined; const systemTemplate = theta.frontmatter.system; if (systemTemplate !== undefined) { const params: Record = {}; if (bindInput.paramBindings !== undefined) { for (const [name, value] of bindInput.paramBindings) { params[name] = value; } } const rendered = renderSystemPrompt({ template: systemTemplate, params }); if (rendered.ok) { systemPrompt = rendered.text; } } // PIC-58 launch contract: the theta's callable set becomes the child's // `--tools` allowlist (defence-in-depth; the child theta enforces its own // callable set regardless). `tools: []` maps to `--no-tools` (empty ≠ // omission — omission would re-enable Pi's default built-ins). const piToolNames = callableSetPiToolNames(theta); const thetaCallableEntries = callableSetThetaEntries(theta); const callableNames = [ ...piToolNames, ...thetaCallableEntries.map((entry) => entry.presentedName), ]; const emptyCallableSet = callableNames.length === 0; // #subagent-isolation-and-trust: pass `--approve` iff the callable set holds a // project-local tool (the operator already trusted its extension in the // parent session), else `--no-approve` (least privilege). const allTools = this.#input.getAllTools?.() ?? []; const approve = inferChildTrust(callableNames, allTools); // §Resolution snapshot (widened): marshal each `.theta` callable's // transitive-closure content hash captured AT LOAD on the frozen callable-set // entry (`entry.closureHash`) — NOT recomputed here — so the child's // recompute-and-compare detects a load-to-spawn edit and refuses fail-closed. const callableHashes: Record = {}; for (const entry of thetaCallableEntries) { if (entry.closureHash !== undefined) { callableHashes[entry.presentedName] = entry.closureHash; } } // The runtime-defect diagnostic sink (advisory teardown / spawn-failure / // envelope failures). Absent on non-production harnesses (a no-op). const emitDiagnostic = this.#input.emitDiagnostic ?? ((): void => {}); // ---- IN-PROCESS host for the RFC-0001 `subagent fn` inline-body path ---- // A `subagent fn` body has no slug/file to launch, so it runs in the parent // against an isolated OFF-SESSION conversation (queries resolve via // `#resolvePromptQuery(..., userVisible: false)` — a private `complete()` // conversation, never the caller's). `#spawnSubagentFnSession` consumes this // `effectHostDeps`; the file-callee path (below) uses `drive()` and never // touches it. const signal = thetaAbort.signal; const hostDeps: EffectfulStatementHostDeps = { checkpoint: root.checkpoint, signal, sink: noopSink(), file: theta.slashName, evaluatePure: (expr, env) => evaluatePureExpression(expr, env), resolveQuery: (expr, env) => this.#resolvePromptQuery(expr, env, { pi: this.#input.pi, ctx, theta, signal, thetaAbort, readMessages: () => [], userVisible: false, }), resolveToolCall: (expr, env, evaluatedToolArgs) => this.#resolveToolCall(theta, expr, env, signal, evaluatedToolArgs), resolveInvoke: (expr, env) => this.#resolveInvoke(theta, expr, env, ctx, chain, signal, "subagent"), classifyCall: (expr) => this.#classifyCall(theta, expr), resolveCallAsInvoke: (expr, env) => this.#resolveCallAsInvoke(theta, expr, env, ctx, chain, signal, "subagent"), spawnSubagentFnSession: (config) => this.#spawnSubagentFnSession(theta, config, ctx, chain, signal), }; const executeDeps: ExecuteBodyDeps = { env: buildBoundEnvironment(theta.body, bindInput.paramBindings, theta.imports), host: createEffectfulStatementHost(hostDeps), checkpoint: root.checkpoint, signal, mutator: new NoopConversationMutator(), mode: "subagent", file: theta.slashName, }; // Decision 6 / Increment B1: register this invocation in the shared registry // the factory's `session_shutdown` teardown reads. Registered here (before // the lazy child launch inside `drive()`), so the entry SPANS the real // in-flight window; removal is deferred to `finishInvocation`. const activeInvocations = this.#input.activeInvocations; let settleDispose: () => void = (): void => {}; const disposeBarrier = new Promise((resolve) => { settleDispose = resolve; }); const entry: ActiveInvocationEntry = { thetaAbort, disposeBarrier, shutdownReason: undefined, theta: theta.slashName, invocationId: root.idSource.newInvocationId(), }; activeInvocations?.add(entry); const detachForwarding = this.#trackForwardingSources(forwardingSources); let finished = false; const finishInvocation = (): void => { if (finished) return; finished = true; detachForwarding(); settleDispose(); activeInvocations?.remove(entry); }; // ---- EAGER child-process launch (PIC-65 / PIC-58 / PIC-60 / PIC-66) ---- // The launch is initiated NOW (not lazily in `drive()`): PIC-22 requires the // spawn to be initiated at bind time (parallel fan-out), and the launch // contract is observable here. `drive()` below only awaits the envelope on // the already-spawned child. // // PIC-60: marshal the already-typed params structurally (canonical JSON on // `PI_THETA_PARAMS`, or a 0600 temp file on `PI_THETA_PARAMS_FILE` at/above // the pinned threshold). The child validates against the same `params:` // schema and skips the binder entirely. const paramValues: Record = {}; if (bindInput.paramBindings !== undefined) { for (const [name, value] of bindInput.paramBindings) { paramValues[name] = value; } } const marshalled = marshalParams(paramValues, this.#paramsMarshalDeps()); const paramsCleanup = marshalled.cleanup; const baseParentEnv = this.#input.subagentParentEnv ?? {}; const parentEnv: Record = { ...baseParentEnv, ...marshalled.env, ...(Object.keys(callableHashes).length > 0 ? { [SUBAGENT_CALLABLE_HASHES_ENV]: JSON.stringify(callableHashes) } : {}), }; // PIC-65 launch. The spawn seam + executable host are wired at the composition // root; their absence on a non-production harness is a configuration defect // surfaced as an internal error (never a modelless / childless drive). const spawn = this.#input.subagentSpawn; const executableHost = this.#input.subagentExecutableHost; if (spawn === undefined || executableHost === undefined) { paramsCleanup(); finishInvocation(); throw new SubagentSpawnFailedError( "subagent child launch is unavailable: no spawn seam / executable host wired", ); } const launch = launchSubagentChild( { argv: { slug: theta.slashName, thetaDirs: this.#input.activeRoots ?? [], systemPrompt: systemPrompt ?? "", tools: callableNames, emptyCallableSet, provider: String(model.provider), model: model.id, approve, }, cwd: ctx.cwd, parentEnv, parentPid: this.#input.subagentParentPid ?? 0, // INV-4: marshal the CURRENT per-chain depth so the child continues the // depth-32 ceiling across the process hop (wire-level carriage). invokeDepth: chain.depth, host: executableHost, }, { spawn, emitDiagnostic }, ); if (!launch.ok) { // PIC-65 spawn-failure rule: `launchSubagentChild` already emitted the // operator-triage diagnostic; dually route the failure as an unanticipated // SDK reject (theta/runtime/internal-error). No child → nothing to tear // down; clean up params + drop the registry entry the bind just added. paramsCleanup(); finishInvocation(); const reason = launch.reason === "unresolved" ? "subagent child executable unresolved" : "subagent child spawn failed"; routeSubagentSpawnFailure(new Error(reason), theta.sourcePath ?? theta.slashName, { emitDiagnostic, }); throw new SubagentSpawnFailedError(reason); } const child = launch.child; // PIC-66: forward cancellation to the `-p` child by killing it (the // child's stdin is spawned closed — bug 0002 — so no in-band stop // channel exists). Handles the spawn-then-immediate-cancel path // synchronously, so correctness does not depend on microtask ordering. const cancellation = attachSubagentCancellation(thetaAbort, child, { emitDiagnostic, }); /** * PIC-59. Await the child's `theta_result` envelope (stray-line tolerant) and * map `ok`/`err` to the invocation `Result`. A child that exits WITHOUT an * envelope maps fail-closed to Err(InvokeInfraError{cause:"internal_error"}). * The file-callee slash/invoke drive seam calls this INSTEAD of executing the * body in-process (the whole callee body ran in the child). */ const drive = async (): Promise => { const result: SubagentInvocationResult = await driveSubagentChild({ child, thetaAbort, calleePath: theta.sourcePath ?? theta.slashName, provider: String(model.provider), emitDiagnostic, clock: root.clock, }); if (result.ok) { return makeOk(result.value as ThetaValue); } return makeErr(result.error as unknown as ThetaValue); }; // PIC-65 / PIC-66 child-process teardown. Runs on EVERY exit of the drive // seam's `finally`. Bounded-awaits child exit (already settled on the normal // path — the child self-exits after its envelope) and kills on timeout // (process-tree kill on Windows); detaches the one-shot cancellation listener; deletes any // `PI_THETA_PARAMS_FILE` temp file (PIC-60 backstop). Idempotent; a no-op // when no child was launched (the `subagent fn` in-process path). let toreDown = false; const teardown = async (): Promise => { if (toreDown) return; toreDown = true; // PIC-60 backstop: delete the params temp file regardless of launch outcome. try { paramsCleanup(); } catch (cleanupError: unknown) { // allow-broad-catch: PIC-60 temp-file backstop — pi-integration-contract/subagent.md void cleanupError; } await runSubagentChildTeardown(child, { emitDiagnostic, detachAbortListener: cancellation.detach, settleDisposeBarrier: settleDispose, clock: root.clock, }); }; return { drivenAgainst: "subagent-private-session", executeDeps, effectHostDeps: hostDeps, drive, // FN-5: on the in-process `subagent fn` path the caller's executor runs the // inline body against `effectHostDeps`, then surfaces the body's terminal // final value the same way the file-callee `drive()` maps its envelope. surface: (execution: BodyExecution): ResultValue => surfaceCalleeFinalValue(execution), teardown, finishInvocation, }; } /** * PIC-60 params-channel fs seam adapter. The env channel (small params) never * touches the fs, so a missing seam throws only if the file channel is reached * (≥8 KB payload) — fail-loud at the boundary rather than silent narrowing. */ #paramsMarshalDeps(): ParamsMarshalDeps { const fs = this.#input.subagentParamsFs; return { writeTempFile: (contents: string, mode: number): string => { if (fs === undefined) { throw new SubagentSpawnFailedError( "subagent params temp-file channel unavailable: no params-fs seam wired", ); } return fs.writeTempFile(contents, mode); }, unlink: (path: string): void => { fs?.unlink(path); }, }; } /** * RFC-0006 (PIC-60, child-side). Intake the marshalled params from the child * env and validate them against the callee's lowered `params:` schema. Reuses * the pure `intakeChildParams` seam (offline-tested) with a validator built * from the theta's load-time lowered schema over the root AJV `SchemaValidator` * and the params-channel fs seam (read + delete the temp file). A theta with * no `params:` admits an empty object. */ #intakeSubagentRootParams(theta: ConversationBindInput["theta"]): ChildParamsIntake { const env = this.#input.subagentParentEnv ?? {}; const fs = this.#input.subagentParamsFs; const intakeFsDeps = { readFile: (path: string): string => { if (fs === undefined) { throw new Error("subagent params file channel unavailable: no params-fs seam wired"); } return fs.readFile(path); }, unlink: (path: string): void => { fs?.unlink(path); }, }; const lowered = theta.frontmatter.params?.loweredSchema; const validator: ParamsSchemaValidator = { validate: (params: unknown) => { if (lowered === undefined) { // No declared `params:` — admit any received payload (nothing to validate). return { ok: true as const }; } const compiled = this.#input.root.schemaValidator.compile(lowered); const verdict = compiled.validate(params); if (verdict.ok) { return { ok: true as const }; } const detail = Array.isArray(verdict.errors) && verdict.errors.length > 0 ? String(verdict.errors[0]?.message ?? "schema validation failed") : "schema validation failed"; const errorPath = Array.isArray(verdict.errors) && verdict.errors.length > 0 ? String(verdict.errors[0]?.instancePath ?? "") : ""; return { ok: false as const, errorPath, detail }; }, }; return intakeChildParams(env, validator, intakeFsDeps); } /** * RFC-0006 (PIC-58). Whether THIS process is the spawned subagent-root child * for `theta` — the regime marker is active and names `theta`'s slug and the * theta is `mode: subagent`. The regime, not the mode, selects the child-side * in-process driver (`selectSubagentDriver` encodes the mode-regress guard: a * NESTED `mode: subagent` callee still spawns its own child). */ isSubagentRootFor(theta: ConversationBindInput["theta"]): boolean { const regime = this.#input.subagentRootRegime ?? { active: false as const }; return ( regime.active && regime.slug === theta.slashName && theta.frontmatter.mode === "subagent" ); } /** * RFC-0006 (PIC-58/59/60/62). Child-side subagent-root drive. Runs INSIDE the * spawned child for the process-root subagent theta: intake the marshalled * params (binder bypassed, PIC-60), confirm the marshalled model reference * re-resolved child-side (PIC-62), drive the callee in-process against the * child's own host session (prompt-mode mechanics under the subagent * frontmatter contract), and emit the single `theta_result` stdout envelope on * EVERY exit path — `Ok`, every `Err`, and a panic routed as internal-error * (PIC-59). The child transcript is process-private (`--no-session`). */ async driveSubagentRootRegime(bindInput: ConversationBindInput): Promise { const { theta, ctx } = bindInput; const calleePath = theta.sourcePath ?? theta.slashName; const emitEnvelope = this.#input.emitResultEnvelope ?? ((): void => {}); const emitErr = (error: QueryError): void => { emitEnvelope(serializeErrEnvelope(error)); }; // PIC-62 obligation 2 (child-side model confirmation): re-resolve the // marshalled `--provider`/`--model` reference against the child's own model // registry and confirm it matches the intended model; on mismatch fail the // invocation and report it through the envelope (never over any RPC surface). const model = ctx.model; if (model !== undefined) { const available = this.#input.modelRegistry.getAvailable(); const resolved = matchAvailableModel(model.id, available); // PIC-62 obligation 2: `resolved === undefined` is TOTAL non-resolution — // the child's own model registry holds no match for the marshalled // `--provider`/`--model` reference. Falling back to `model.id` here would // make `confirmChildModel(model.id, model.id)` trivially PASS and silently // admit a child whose model never resolved; instead surface an explicit // unresolved marker as the child-resolved value so the pre-flight mismatch // is real and the diagnostic names expected vs. "(unresolved)". const resolvedId = resolved?.id ?? "(unresolved: no matching model)"; const confirmation = confirmChildModel(model.id, resolvedId); if (!confirmation.ok) { (this.#input.emitDiagnostic ?? ((): void => {}))(confirmation.diagnostic); emitErr({ ...confirmation.error, callee_path: calleePath, } as unknown as QueryError); return; } } // PIC-60 (child-side): intake the marshalled params from the child env, // validate them against the callee's `params:` schema, and bind them DIRECTLY // (the binder is bypassed on the marshalled path). A parse / schema-validation // failure refuses the invocation fail-closed and reports it through the // envelope as Err(InvokeInfraError{cause:"validation"}). const intake = this.#intakeSubagentRootParams(theta); if (!intake.ok) { (this.#input.emitDiagnostic ?? ((): void => {}))(intake.diagnostic); emitErr({ ...intake.error, callee_path: calleePath } as unknown as QueryError); return; } const paramBindings = new Map(); // `intake.params` is `undefined` when no params carrier was marshalled (a // callee with no `params:` / a no-arg invocation) — an empty binding set. if (intake.params !== undefined && intake.params !== null) { for (const [name, value] of Object.entries(intake.params)) { paramBindings.set(name, value as ThetaValue); } } const rootBindInput: ConversationBindInput = { ...bindInput, ...(paramBindings.size > 0 ? { paramBindings } : {}), }; // PIC-58: drive the root theta against the child process's own host session // with PROMPT-MODE driver mechanics while applying the subagent frontmatter // contract (its `system:` was installed via `--system-prompt` at launch; the // callable set governs the child session's active tools). The binding runs // the body in-process against the child's session. const binding = this.bindPromptConversation(rootBindInput); try { const execution = await executeBody(theta.body, binding.executeDeps); // FN-5 / PIC-59: the envelope carries the callee's terminal FINAL VALUE // with `Result` fidelity — NOT the prompt-mode PIC-53 trailing-turn text // `binding.surface` computes. The regime borrows prompt-mode driver // MECHANICS (active-tool set, in-process session) but the subagent // return-value contract, so the final value is projected the same way the // parent-side file-callee `drive()` maps its envelope. const terminal = surfaceCalleeFinalValue(execution); // PIC-59: emit the single machine-readable envelope for the terminal Result. if (terminal.ok) { emitEnvelope(serializeOkEnvelope(terminal.value as unknown)); } else { emitErr(terminal.error as unknown as QueryError); } } catch (thrown: unknown) { // allow-broad-catch: PIC-59 panic→internal-error envelope — pi-integration-contract/subagent.md if (thrown instanceof HostFatal) { throw thrown; } // PIC-59: a panic (or any catchable interpreter/adapter throw) is routed as // the internal-error `Err` on the envelope — never a fabricated value. const message = thrown instanceof Error ? thrown.message : String(thrown); emitErr({ kind: "invoke_infra", message: `internal error: ${message}`, callee_path: calleePath, cause: "internal_error", } as unknown as QueryError); } finally { await binding.teardown?.(); binding.finishInvocation?.(); } } /** * RFC 0001 (`subagent fn`) production spawn seam. Drive a `subagent fn` body * under the resolved `SubagentSessionConfig` and return the session-scoped * effect resolvers the calling body's effectful host routes the body's * `@`-queries / calls / invokes through while the session is active (FN-6 * isolation), plus a `dispose()` that discards it on return. * * Unlike an `invoke`d subagent-mode `.theta` callee — which * `spawnSubagentConversation` launches as a fresh child `pi` process * (`-p "/"`) — a `subagent fn` is an INLINE body with no `.theta` * file / slug to launch, so it does NOT spawn a child process. Consistent with * revised INV-5, its body runs IN-PROCESS against an isolated OFF-SESSION * conversation in the parent (queries resolve via `#resolvePromptQuery` with * `userVisible: false` — a private `complete()` conversation, never the * caller's session), preserving FN-6 transcript isolation without a child * process or a PIC-65 child teardown. See the inline RFC-0006 note below for the * mechanics. * * INV-4 / FN-6 (threaded through the production chain): a countable * `subagent-fn` frame is pushed on `chain` BEFORE the spawn, exactly as * `#buildInvokeChild` pushes a `direct-invoke` frame; the spawned session * carries the pushed `childChain`, so a nested `invoke` / `subagent fn` inside * the body pushes further frames and a deep chain trips the depth-32 ceiling. * A ceiling breach on the push raises `InvokeDepthExceededPanic`, which the * executor's subagent boundary downgrades to the caller's * `Err(InvokeInfraError{cause:"panic"})` — the runtime backstop against * unbounded subagent-fn recursion. * * FN-7 config: the resolved `config` (`system` / `model` / `tools` / * `tool_loop` / `respond_repair`) is applied by re-binding the enclosing theta * under an overridden frontmatter + callable set; a `with { tools }` override * resolves against the CALLING theta's callable set (FN-9). Defaults inherit * the calling theta's configuration. */ async #spawnSubagentFnSession( theta: ConversationBindInput["theta"], config: SubagentSessionConfig, ctx: ExtensionCommandContext, chain: InvokeChain, parentSignal: AbortSignal, ): Promise { // INV-4 / FN-6: push the countable `subagent-fn` frame on the chain before // spawning. A breach raises `InvokeDepthExceededPanic`; it propagates out of // this async spawn and the executor's subagent boundary downgrades it. const childChain = pushCountableFrame(chain, "subagent-fn"); // FN-7: apply the resolved session config by re-binding the enclosing theta // under an overridden frontmatter + callable set. `system` sets the spawned // session's system prompt (legitimate even from a prompt-mode enclosing // theta, FN-7); `tool_loop` / `respond_repair` override the loop budgets; // `model` overrides the inherited session model. const overriddenFrontmatter = { ...theta.frontmatter, ...(config.system !== undefined ? { system: { parts: [{ kind: "text" as const, value: config.system }] } } : {}), ...(config.toolLoop !== undefined ? { toolLoop: config.toolLoop } : {}), ...(config.respondRepair !== undefined ? { respondRepair: config.respondRepair } : {}), }; const spawnedCallableSet = subagentFnCallableSet(theta.callableSet, config); const overriddenTheta: ConversationBindInput["theta"] = { ...theta, frontmatter: overriddenFrontmatter, ...(spawnedCallableSet !== undefined ? { callableSet: spawnedCallableSet } : {}), }; // FN-7 `model` override: PIC-40 reads the resolved model from `ctx.model`, // so an explicit `with { model }` overrides the inherited session model. The // reference string resolves to a concrete `Model` by the same // exact-match rule the load-time / binder resolution uses. An unresolvable // override is already rejected at LOAD (`checkSubagentFnModelOverrides` → // `theta/load/model-unresolved` un-registers the theta), so a registered // theta reaching here always resolves; the `?? undefined` fall-through keeps // the inherited session model only for the (now load-unreachable) no-match, // never silently masking an unresolvable reference. const overrideModel = config.model !== undefined ? matchAvailableModel(config.model, this.#input.modelRegistry.getAvailable()) : undefined; const effectiveCtx = overrideModel !== undefined ? { ...ctx, model: overrideModel } : ctx; // RFC-0006 note: a `subagent fn` is an INLINE body with no `.theta` file / // slug to launch as `-p "/"`, so it does NOT go through the child-launch // `spawnSubagentConversation`. It runs its inline body IN-PROCESS against an // isolated OFF-SESSION conversation (queries resolve via `#resolvePromptQuery` // with `userVisible: false` — a private `complete()` conversation offering the // model no tools, never the caller's), preserving FN-6 isolation without a // child process. FN-6's isolation is scoped to the body's CONVERSATION only: // the body's code-side extension-tool calls resolve through `resolveToolCall` // below to the producer-wide `hostLoopDispatch` seam and so dispatch through // the PROCESS's backing host session per PIC-64 — the child's private, // discarded session inside a subagent-root child, the user's live session in // the parent — exactly as the enclosing theta's own code-side calls do. const { root } = this.#input; const derived = deriveChildThetaAbort(parentSignal); const thetaAbort = derived.controller; const forwardingSources: ForwardingSignalSource[] = [ { label: "parentInvokeSignal.removeEventListener", removeEventListener: derived.detach }, ]; const signal = thetaAbort.signal; const isolatedCtx = effectiveCtx; const hostDeps: EffectfulStatementHostDeps = { checkpoint: root.checkpoint, signal, sink: noopSink(), file: overriddenTheta.slashName, evaluatePure: (expr, env) => evaluatePureExpression(expr, env), resolveQuery: (expr, env) => this.#resolvePromptQuery(expr, env, { pi: this.#input.pi, ctx: isolatedCtx, theta: overriddenTheta, signal, thetaAbort, readMessages: () => [], userVisible: false, }), resolveToolCall: (expr, env, evaluatedToolArgs) => this.#resolveToolCall(overriddenTheta, expr, env, signal, evaluatedToolArgs), resolveInvoke: (expr, env) => this.#resolveInvoke(overriddenTheta, expr, env, isolatedCtx, childChain, signal, "subagent"), classifyCall: (expr) => this.#classifyCall(overriddenTheta, expr), resolveCallAsInvoke: (expr, env) => this.#resolveCallAsInvoke(overriddenTheta, expr, env, isolatedCtx, childChain, signal, "subagent"), spawnSubagentFnSession: (nestedConfig) => this.#spawnSubagentFnSession(overriddenTheta, nestedConfig, isolatedCtx, childChain, signal), }; // Decision 6 / Increment B1: register the in-flight invocation so the // factory's `session_shutdown` teardown operates on it; settle the barrier on // dispose (there is no child exit to observe on the in-process path). const activeInvocations = this.#input.activeInvocations; let settleDispose: () => void = (): void => {}; const disposeBarrier = new Promise((resolve) => { settleDispose = resolve; }); const entry: ActiveInvocationEntry = { thetaAbort, disposeBarrier, shutdownReason: undefined, theta: overriddenTheta.slashName, invocationId: root.idSource.newInvocationId(), }; activeInvocations?.add(entry); const detachForwarding = this.#trackForwardingSources(forwardingSources); let finished = false; return { deps: hostDeps, dispose: async (): Promise => { if (finished) return; finished = true; settleDispose(); detachForwarding(); activeInvocations?.remove(entry); }, }; } /** * Resolve one `@`-query to its live dispatch: render the template against the * lexical environment and bind a live `QueryModelDriver` that drives real * user-visible turns into the shared session. An untyped query drives one * plain-text turn (`PIC-53`); a schema-typed query forces a structured * respond turn. */ #resolvePromptQuery( expr: QueryExpr, env: LexicalEnvironment, deps: { readonly pi: ExtensionAPI; readonly ctx: ExtensionCommandContext; readonly theta: ConversationBindInput["theta"]; readonly signal: AbortSignal; /** CANCEL-2: the per-invocation controller the live turn driver re-forwards `ctx.signal` into. */ readonly thetaAbort: AbortController; readonly readMessages: () => readonly Message[]; readonly userVisible: boolean; }, ): QueryHostDispatch { const { root } = this.#input; const typed = expr.schema !== null; // QTL-4: the theta's callable-set underlying Pi-tool names installed as the // model's active tools for each user-visible query turn. const activeTools = callableSetPiToolNames(deps.theta); // QRY-22: a typed prompt-mode query drives respond-repair follow-ups as new // user turns against the SAME conversation (a user-visible turn when the // query streams, else off-session), extracting each follow-up's reply text. const driveFollowUp = (prompt: string): Promise => deps.userVisible ? driveStreamedUserTurn({ pi: deps.pi, ctx: deps.ctx, clock: root.clock, queryText: prompt, activeTools, }) : offSessionComplete(deps.ctx.model, prompt); const validation = typed ? this.#buildTypedValidation(expr, env, deps.theta, driveFollowUp) : undefined; // A typed query instructs the model to emit only a JSON object of the // declared (lowered) shape, so its user-visible turn streams the structured // value as its assistant text (shared with the subagent path via // `renderTypedAwareQueryText`; an off-session typed turn's reply parses the // same). const queryText = renderTypedAwareQueryText(expr, env, validation?.lowered); // STAGE B (ceiling #2): bound the native prompt-mode agentic tool loop to // the theta's `tool_loop.max_rounds` for the untyped free-phase turn. A typed // query's forced-respond turn is the exempt-routed terminator (FRNT-1) and // is NOT bounded. `max_rounds: 0` untyped is handled upstream by // `runUntypedQueryLoop` (it exhausts at query start before any turn), so the // governor is only consulted for `max_rounds >= 1` free-phase turns. const maxRounds = deps.theta.frontmatter.toolLoop?.maxRounds ?? 25; if (!typed && deps.userVisible) { this.#promptToolLoopGovernor.ensureRegistered(deps.pi); } const model = deps.userVisible ? new LivePromptQueryModel({ pi: deps.pi, ctx: deps.ctx, clock: root.clock, queryText, readMessages: deps.readMessages, activeTools, thetaAbort: deps.thetaAbort, // Only the untyped free-phase native turn is bounded (typed → exempt). governor: typed ? undefined : this.#promptToolLoopGovernor, maxRounds, // PIC-50/51: the resolved provider for the synthesised `TransportError` // (mirrors the subagent path's `provider: String(model.provider)`). provider: String(deps.ctx.model?.provider ?? "unknown"), }) : new OffSessionQueryModel({ model: deps.ctx.model, queryText, signal: deps.signal }); // QRY-6: the bare rendered template body (typed-query schema conveyance // excluded) the empty-template short-circuit is evaluated over before any // provider turn is issued. const renderedText = renderQueryText(expr, env); const config: QueryToolLoopConfig = { // A typed query dispatches only the forced-respond terminator (no // free-phase provider call), so its `max_rounds`-final branch fires at // typed-query start; an untyped query drives one user-visible free-phase // turn under the theta's configured cap. maxRounds: typed ? 0 : deps.theta.frontmatter.toolLoop?.maxRounds ?? 25, querySite: { file: deps.theta.slashName, line: expr.range.start.line, column: expr.range.start.column, }, thetaSlashName: deps.theta.slashName, invocationId: root.idSource.newInvocationId(), occurredAt: root.clock.wallNow(), }; return { typed, renderedText, model, config, ...(validation !== undefined ? { schemaValidation: validation.validation } : {}), }; } /** * Build the typed-query schema-validation collaborator (QRY-22) for a typed * `@`-query: lower the declared schema (a named `schema` decl resolved * whole-file, or an inline object/type annotation) to the validating JSON * Schema, and assemble the `TypedQuerySchemaValidation` over the root's AJV * `SchemaValidator` and the `V13d` respond-repair loop, threading the mode's * follow-up turn drive. Returns `undefined` when the query is untyped or the * declared schema does not lower. */ #buildTypedValidation( expr: QueryExpr, env: LexicalEnvironment, theta: ConversationBindInput["theta"], driveFollowUp: (prompt: string) => Promise, ): { readonly validation: TypedQuerySchemaValidation; readonly lowered: LoweredSchema } | undefined { if (expr.schema === null) { return undefined; } const lowered = lowerQueryResponseSchema(expr.schema, schemaDeclsOf(theta.body)); if (lowered === undefined) { return undefined; } const validation = buildTypedQueryValidation({ lowered, resolveShape: resolveDeclaredShape(expr, env), schemaValidator: this.#input.root.schemaValidator, attempts: theta.frontmatter.respondRepair?.attempts ?? 3, maxRounds: theta.frontmatter.toolLoop?.maxRounds ?? 25, driveFollowUp, }); return { validation, lowered }; } /** * H8b call-kind routing. A `(args)` call whose callee resolves to a * `.theta`-callable in the theta's callable set (frontmatter `tools:`) is * semantically an invoke; every other call is a Pi tool. The resolution is * against the callable set alone — a name bound to a `./x.theta` entry routes * to the invoke spawn-and-drive path, all else to the tool-`execute` path. */ #classifyCall( theta: ConversationBindInput["theta"], expr: CallExpr, ): "pi-tool" | "theta-callable" { return thetaCalleePath(theta, expr.callee) !== undefined ? "theta-callable" : "pi-tool"; } /** * H8b live tool-call resolver. Resolve `expr.callee` against the theta's frozen * `tools:` callable set (QTL-2 runtime enforcement) and return a * `CodeSideToolCall` whose `dispatch()` invokes the resolved host tool's * `execute(...)` (V14g lowering turns a clean resolve into `Ok(text)`, a throw * into `Err(CodeToolError{cause:"execution"})`). A callable name that is NOT * in the set (or resolves to no host tool) throws `UnknownHostToolError` from * `dispatch()`, lowering to the code-tool `Err` rather than executing an * ambient host tool or fabricating a value. */ #resolveToolCall( theta: ConversationBindInput["theta"], expr: CallExpr, env: LexicalEnvironment, signal: AbortSignal, evaluatedToolArgs?: Record, ): CodeSideToolCall { const toolName = expr.callee; const tool = this.#resolvePiToolForTheta(theta, toolName); // RFC 0002: when the executor has already evaluated the Pi-tool argument's // computed field values left-to-right (nested effects / `?`), those concrete // values ARE the params object; otherwise lower the inline object literal's // pure field values here. const params = evaluatedToolArgs ?? lowerToolCallParams(expr, env); // Ceiling #4 (hard-ceilings/ceilings-3-and-4.md#ceiling-4-table, the // code-driven tool-call args row; schema-subset.md §Depth Enforcement // point #3; CIO-3 depth-walk-before-AJV): enforce the JSON-document // depth-≤5 cap on the CONSTRUCTED argument value — the single object-literal // params object the tool receives — before AJV and before the tool executes. // A depth-6+ argument surfaces to theta code as // `Err(CodeToolError { cause: "validation" })`, carried on the returned // `CodeSideToolCall` so `runCodeSideToolCall` short-circuits without ever // dispatching `execute()`. `params` IS the sole positional argument (a Pi // tool call takes exactly one object literal), so the walk runs over it // directly — walking `expr.args` (an array wrapper) would add a spurious // level and false-trip a legitimately within-cap params object. Mirrors the // invoke `params`-boundary breach `enforceInvokeParamsDepth` surfaces in // `#driveCallee`, differing only in the carrier (`CodeToolError` vs // `InvokeInfraError`) per the per-boundary table. const argDepthBreach = enforceCodeToolArgDepth(toolName, params); const toolCallId = `theta-direct:${this.#input.root.idSource.newInvocationId()}`; return { toolName, committed: [], ...(argDepthBreach !== undefined ? { argDepthBreach: { result: argDepthBreach.result, error: argDepthBreach.error } } : {}), dispatch: (): Promise => { // PIC-64 (#subagent-host-loop-dispatch): an EXTENSION tool's snapshot // entry pins only the tool's name + `parameters` schema — the public // extension API strips `execute` — so an execute-less `pi-tool` entry // classifies as extension-shaped and routes through the code-side // dispatch ladder in BOTH modes (the prompt parent leg against the // user's live host session, and the subagent child leg alike; the // classification is not regime-gated). The dispatched request carries // the entry's UNDERLYING `toolName` — the only name the host registry / // active set knows; `as` renames are theta-side presentation only. if (tool === undefined) { // A name the frozen snapshot does not hold at all — unreachable from // a REGISTERED theta: parse rejects an out-of-scope callee // (`theta/parse/unknown-identifier`) and load-time admission froze // every `tools:` name into the snapshot, so only a caller that // bypasses load admission (a harness fixture, e.g. the child-leg // wiring suites) can present one. The two arms differ because the // QTL-2 ambient-execution EXPOSURE differs per backing session, not // because QTL-2 binds less in the child: // - regime inactive (parent): the backing session is the USER's // live session carrying the full ambient tool set — routing an // un-snapshotted name through the host loop could execute an // ambient tool the theta never declared, so the QTL-2 rejection // stands. // - regime active (subagent-root child): PIC-58 bounds the child // session's tools to the theta's OWN callable set (the `--tools` // allowlist derived from the same snapshot), so no undeclared // ambient tool exists for the host loop to execute — ladder // routing cannot widen reach (an outside-the-allowlist name reads // back the fail-closed isError no-result) and stays the PIC-64 // rung-3 fail-closed floor the child-leg wiring suites drive, // never a fabricated value. const regime = this.#input.subagentRootRegime ?? { active: false as const }; if (regime.active) { return this.#dispatchExtensionToolViaLadder(toolName, params, signal); } return Promise.reject( new UnknownHostToolError(`code-side call names no resolvable host tool '${toolName}'`), ); } if (typeof tool.execute !== "function") { return this.#dispatchExtensionToolViaLadder(tool.toolName, params, signal); } // CANCEL-3 (cancellation.md §swallowing-handler attachment): attach the // swallowing handler to the underlying code-side `execute()` Promise at // its construction site, before the first microtask boundary, so a late // rejection arriving after the `tool-call` checkpoint surfaced // `cause: "cancelled"` is absorbed and never reaches Node's // `unhandledRejection` process event. return guardToolExecutePromise( tool.execute(toolCallId, params, signal), signalGuard(signal), noopSwallowChannels(), ); }, }; } /** * PIC-64. Code-side extension-tool dispatch through the probe-asserted, * fail-closed ladder — MODE-INDEPENDENT (the shared adapter for the prompt * parent leg and the subagent child leg): prefer the upstream * `getToolDefinition` rung when available, else host-loop dispatch; with * NEITHER rung available the invocation refuses with * `theta/load/extension-tool-unreachable` (the runtime never silently falls * through). Host-loop dispatch itself is the injected `hostLoopDispatch` seam * (a live-only mechanism, behind the leaf-tested `dispatchViaHostLoop` * contract); its result is adapted to the tool-result envelope shape the * code-side lowering consumes, and a seam rejection propagates unwrapped so * the V14g execute-throw lowering carries its message (Resolution snapshot: * a pinned handle unusable at call time raises a precise `CodeToolError`). * * DEFENCE-IN-DEPTH backstop: PIC-64 rung 3 is enforced at LOAD (option (a), * `checkExtensionToolReachability`), which walks the ROOT body's code-side call * sites (direct + local-`fn`), so a REGISTERED theta cannot reach this refusal * with an unreachable extension tool its own code names — the load-time check * already un-registered it. A transitive-import code-side call cannot arise * either: an imported `.thetalib` `fn` naming a caller-scoped extension tool * fails `.thetalib` parse with `theta/parse/unknown-identifier` and un-registers * the importer before this producer runs. This runtime rung is retained as the * fail-closed floor for any path that bypasses the load check. */ async #dispatchExtensionToolViaLadder( toolName: string, params: Record, signal: AbortSignal, ): Promise { const probe: DispatchLadderProbe = this.#input.dispatchLadderProbe ?? { getToolDefinitionAvailable: false, hostLoopAvailable: this.#input.hostLoopDispatch !== undefined, }; const ladder = resolveDispatchLadder(toolName, probe); if (ladder.kind === "unreachable") { // Fail-closed: no code-side dispatch rung. Surface the pinned refusal // diagnostic and reject so the code-side lowering yields an `Err` — never a // fabricated value, never a silent model-only fallthrough. (this.#input.emitDiagnostic ?? ((): void => {}))(ladder.diagnostic); throw new UnknownHostToolError(ladder.diagnostic.message); } // Route by the RESOLVED rung — the ladder's choice IS the routing decision // (PIC-64 pins the rung-1-preferred ordering as normative; dispatching // through a rung the ladder did not choose would silently reorder it). if (ladder.rung === "get-tool-definition") { // No rung-1 dispatcher is implemented at the pin, and the composition // root records rung-1 availability as surface AND dispatcher — so this // resolution can only come from a probe that recorded the rung without a // dispatcher behind it (a harness shape). Refuse precisely rather than // fabricate or reroute; a landed rung-1 dispatcher slots its dispatch in // here. throw new UnknownHostToolError( `extension tool '${toolName}' resolved the get-tool-definition rung but no rung-1 dispatcher is wired`, ); } const dispatch = this.#input.hostLoopDispatch; if (dispatch === undefined) { // Defensive: the probe reported the host-loop rung but no seam is wired — // refuse rather than fabricate. (Unreachable when the probe is derived // from the seam.) throw new UnknownHostToolError( `extension tool '${toolName}' host-loop dispatch seam is not wired`, ); } const request: EncodedToolRequest = { toolName, args: params }; // Thread the code-side tool-call abort signal into host-loop dispatch so a // thetaAbort mid-fabricated-turn releases the settle barrier and the model // is restored (PIC-64 cancellation) rather than left on the bridge. const hostResult = await dispatch(request, signal); // F-1578 (host-interfaces-core.md §"Tool execution from theta code"): the // code-side `AgentToolResultEnvelope` carries NO `isError` — lowering an // isError result to a `{ content }` envelope would let `routeToolReturnShape` // fabricate `Ok(text)` from a failed tool. THROW the joined host text // instead, so the standard V14g execute-throw lowering yields // `Err(CodeToolError { cause: "execution" })` carrying the host text // (tool-calls.md: the `execution` cause covers "returned `isError: true`"; // PIC-64 (d): the read-back's `isError` is preserved to code). if (hostResult.isError) { const text = hostResult.content .map((block) => (block.type === "text" && block.text !== undefined ? block.text : "")) .filter((t) => t.length > 0) .join("\n"); throw new Error( text.length > 0 ? text : `extension tool '${toolName}' reported isError with no text`, ); } // Adapt the host-loop result to the `content`-only envelope the code-side // lowering consumes. return { content: hostResult.content.map((block) => block.text !== undefined ? { type: block.type, text: block.text } : { type: block.type }, ), }; } /** * QTL-2. Resolve a code-driven callable name against the theta's frozen `tools:` * callable set: the name must be a `pi-tool` entry in the snapshot, and the * call dispatches through that entry's HELD `PiToolDispatch` reference — the * runtime never re-queries Pi's tool registry by name * (frontmatter-fields-b-and-templates.md §Resolution snapshot). A name absent * from the set (or bound to a `.theta` callee, which `#classifyCall` routes to * the invoke path instead) resolves to `undefined`, so the code-side path * surfaces the unavailable-tool `Err` rather than executing an ambient tool. * Honours `as`-renames because the snapshot is keyed by the post-rename * callable name. * * A theta carrying no snapshot (an in-memory harness fixture) falls back to the * producer-wide `resolvePiTool` collaborator — production discovered thetas * always carry a (possibly empty) snapshot, so the fallback never widens a * real theta's ambient reach. */ #resolvePiToolForTheta( theta: ConversationBindInput["theta"], callableName: string, ): PiToolDispatch | undefined { const callableSet = theta.callableSet; if (callableSet === undefined) { return this.#input.resolvePiTool?.(callableName); } const entry = callableSet.entries.get(callableName); if (entry === undefined || entry.kind !== "pi-tool") { return undefined; } return entry.toolDefinition as PiToolDispatch; } /** * H8b live invoke resolver for an `invoke("./x.theta", ...args)` expression: * bind the positional args, resolve+parse the callee against the caller's * directory, spawn/drive it, and return its top-level `Result` (FN-5). */ #resolveInvoke( theta: ConversationBindInput["theta"], expr: InvokeExpr, env: LexicalEnvironment, ctx: ExtensionCommandContext, chain: InvokeChain, parentSignal: AbortSignal, /** The invoking theta's own `mode:` — selects the cross-mode attach cell. */ callerMode: ThetaMode, ): InvokeChild { // `expr.args[0]` is the callee path literal; the remaining args are the // positional invocation arguments bound to the callee's params. const argValues = expr.args.slice(1).map((arg) => evaluatePureExpression(arg, env)); // INV-6: the `invoke` return annotation drives the runtime AJV // return-value validation on the child's `Ok` payload (invocation.md §Typed // return; hard-ceilings ceiling #4). return this.#buildInvokeChild( theta, expr.path, argValues, ctx, chain, expr.returnSchema, parentSignal, callerMode, ); } /** * H8b live invoke resolver for a `.theta`-callable `(args)` call: resolve * the callee path from the callable set, bind the positional args, and drive * the callee, returning its typed top-level `Result` across the boundary * (FN-5). */ #resolveCallAsInvoke( theta: ConversationBindInput["theta"], expr: CallExpr, env: LexicalEnvironment, ctx: ExtensionCommandContext, chain: InvokeChain, parentSignal: AbortSignal, /** The invoking theta's own `mode:` — threaded to `#driveCallee`. */ callerMode: ThetaMode, ): InvokeChild { const calleePath = thetaCalleePath(theta, expr.callee) ?? `./${expr.callee}.theta`; const argValues = expr.args.map((arg) => evaluatePureExpression(arg, env)); // A `.theta`-callable call through `tools:` carries no `invoke` // annotation, so there is no parse-time return-type site; the runtime AJV // net still applies at the query/typed boundary inside the callee. return this.#buildInvokeChild(theta, calleePath, argValues, ctx, chain, null, parentSignal, callerMode); } /** Build the `InvokeChild` whose `drive()` parses, spawns, and drives the callee. */ #buildInvokeChild( theta: ConversationBindInput["theta"], calleePath: string, argValues: readonly ThetaValue[], ctx: ExtensionCommandContext, chain: InvokeChain, returnSchema: string | null, parentSignal: AbortSignal, callerMode: ThetaMode, ): InvokeChild { return { calleePath, committed: [], drive: (): Promise => { // INV-4 / ceiling #1 (invocation.md §INV-4, CIO-2): push a countable // frame BEFORE the callee body runs. The cap is breached when about to // push the 33rd frame; the nested overflow surfaces to this invoke // parent as `Err(InvokeInfraError{cause:"panic"})` — the runtime backstop // that (with load-time cycle detection) bounds a self-referential theta. let childChain: InvokeChain; try { childChain = pushCountableFrame(chain, "direct-invoke"); } catch (panic) { // allow-broad-catch: theta/runtime/invoke-depth-exceeded — hard-ceilings.md // Narrow-and-rethrow: only the ceiling panic is handled (surfaced as // the nested Err backstop); any other throw propagates unchanged. if (panic instanceof InvokeDepthExceededPanic) { const surfaced = surfaceDepthOverflow(panic, { topLevel: false, calleePath, }); if (surfaced.mode === "nested") { return Promise.resolve(makeErr(surfaced.error as unknown as ThetaValue)); } } throw panic; } // CANCEL-3 (cancellation.md §swallowing-handler attachment): attach the // swallowing handler to the `invoke` child's top-level execution Promise // at its construction site, before the first microtask boundary, so a // late rejection after the `invoke` checkpoint surfaced cancellation is // absorbed and never reaches Node's `unhandledRejection` process event. return guardInvokeExecutionPromise( this.#driveCallee( theta, calleePath, argValues, ctx, childChain, returnSchema, parentSignal, callerMode, ), signalGuard(parentSignal), noopSwallowChannels(), ); }, }; } /** * Parse the callee `.theta`, spawn a fresh isolated subagent session for it * (V15l: a subagent callee spawns fresh; the caller's settings are not * inherited), bind the positional args onto its declared params, run its body * through the executor, and surface its top-level `Result` (FN-5). An * unparseable / missing callee surfaces `Err(InvokeInfraError{cause: * "load_failure"})` — never a fabricated `Ok(null)`. */ async #driveCallee( theta: ConversationBindInput["theta"], calleePath: string, argValues: readonly ThetaValue[], ctx: ExtensionCommandContext, chain: InvokeChain, returnSchema: string | null, parentSignal: AbortSignal, callerMode: ThetaMode, ): Promise { // INV-5 (invocation.md §Resolution, INV-1 seam): re-run the realpath + // discovery-root containment check at the moment the runtime opens the // callee, against the *currently* active roots. An escape fails closed with // `Err(InvokeInfraError{cause:"load_failure"})` — the runtime backstop to the // load-time `theta/load/invoke-path-escape` guard. // Ceiling #4 (hard-ceilings/ceilings-3-and-4.md#ceiling-4-table, the // `params` / `invoke(...)` row; CIO-3 depth-walk-before-AJV): enforce the // JSON-document depth-≤5 cap at the runtime `invoke(...)` `params` argument // boundary. Each positional arg is a JSON document in its own right, so the // walk runs per-arg (a legitimate depth-5 arg stays valid; walking a wrapper // object would false-trip it); a depth-6+ arg surfaces to the invoke parent // as `Err(InvokeInfraError { cause: "validation" })` — distinct from ceiling // #1 chain-depth. Runs before the containment re-check / callee load so a // caller-side depth breach is reported regardless of callee state. for (const argValue of argValues) { const breach = enforceInvokeParamsDepth(calleePath, argValue); if (breach !== undefined) { return breach.result; } } const escape = await this.#recheckCalleeContainment(theta, calleePath); if (escape !== undefined) { return makeErr(escape as unknown as ThetaValue); } const callee = await this.#input.parseCallee?.(theta.sourcePath, calleePath); if (callee === undefined) { const error: InvokeInfraError = { kind: "invoke_infra", message: `invoke callee '${calleePath}' could not be loaded`, callee_path: calleePath, cause: "load_failure", }; return makeErr(error as unknown as ThetaValue); } const paramNames = callee.frontmatter.params?.fields.map((field) => field.wireName) ?? []; const paramBindings = new Map(); paramNames.forEach((name, index) => { paramBindings.set(name, argValues[index] ?? null); }); // Prompt→prompt cross-mode cell (invocation.md §Cross-mode semantics): an // `invoke`d prompt-mode callee whose caller is ALSO prompt-mode ATTACHES to // the caller's current user session — its queries stream as user-visible // turns in the same conversation, not a fresh isolated spawn. The parent // suspends at the call site until the child settles (the executor awaits // this Promise, so the suspend is structural), and the child's callable set // replaces the parent's for the child's WHOLE body (the PIC-17 per-query // snapshot/restore generalised to the body window, owned by // `runPromptSuspendInvoke`); the ambient snapshot is restored on every settle // path — success, returned `Err`, cancel, or throw — with the inner failure // surfaced unmasked. CANCEL-5: the child binding derives its `thetaAbort` from // `parentSignal` (downward-only). Every other cell (a subagent-mode callee, // or a subagent-mode caller) spawns fresh below. if (callerMode === "prompt" && callee.frontmatter.mode === "prompt") { const childBinding = this.bindPromptConversation({ theta: callee, args: "", ctx, paramBindings, chain, parentSignal, }); // Decision 6 / Increment B1: the child bind registered an // ActiveInvocationRegistry entry; the `finally` calls its // `finishInvocation` AFTER the child body (`runPromptSuspendInvoke`, whose // `childBody` runs `executeBody`) + the INV-6 return validation, so the // entry SPANS the nested callee's real in-flight window. try { const outcome = await runPromptSuspendInvoke({ cell: { callerMode: "prompt", calleeMode: "prompt" }, childCallableSet: callableSetPiToolNames(callee), pi: this.#input.pi, childBody: async () => { const execution = await executeBody(callee.body, childBinding.executeDeps); // FN-5 (invocation.md §Final-value propagation across callees): an // invoke callee returns its body's terminal FINAL VALUE across the // boundary — NOT the PIC-53 trailing-turn text that // `childBinding.surface` computes for a top-level prompt dispatch. // The callee's user-visible turns already streamed into the shared // session; the value that flows back to the parent is the tail // expression, surfaced by the same FN-5 projection as the subagent // path. return surfaceCalleeFinalValue(execution); }, }); // INV-6 (invocation.md §Typed return): apply the `invoke` return // validation to the child's `Ok` payload, exactly as the spawn path below. return this.#validateInvokeReturn(theta, calleePath, returnSchema, outcome.result); } finally { childBinding.finishInvocation?.(); } } // CANCEL-5 (cancellation.md §`invoke(...)` entry): hand the parent's // `thetaAbort.signal` to the child binding so it constructs its `thetaAbort` // as a DERIVED controller (downward-only: the child aborts when the parent // aborts, never the reverse — `deriveChildThetaAbort`). const binding = await this.spawnSubagentConversation({ theta: callee, args: "", ctx, paramBindings, chain, parentSignal, }); // Decision 6 / Increment B1: the spawn bind registered an // ActiveInvocationRegistry entry; the `finally` calls its `finishInvocation` // AFTER `executeBody` + `surface` (which runs the spawned session's // `dispose()`) + the INV-6 return validation, so the entry SPANS the nested // subagent callee's real in-flight window and its barrier settles // post-dispose. try { // RFC-0006 (PIC-59): a subagent-mode callee runs its whole body in the // spawned child; the parent resolves the invocation through the binding's // self-contained `drive()` (launch → await envelope → map), NOT by running // `executeBody` in-parent. `drive` is always present on the subagent // binding; `surface(executeBody(...))` is the harness fallback. const result = binding.drive !== undefined ? await binding.drive() : binding.surface(await executeBody(callee.body, binding.executeDeps)); // INV-6 (invocation.md §Typed return; hard-ceilings ceiling #4): AJV-validate // the child's returned value against the `invoke` annotation. A // mismatch (e.g. a `string` under `invoke`) is // `Err(InvokeInfraError{cause:"return_validation"})`, aborting the parent. return this.#validateInvokeReturn(theta, calleePath, returnSchema, result); } finally { // PIC-65: await the (idempotent, non-throwing) child-process teardown BEFORE // `finishInvocation`, so the child is killed / has exited (abort listener // detached, `disposeBarrier` settled on observed exit) on EVERY exit — // including a genuine throw unwinding past `surface` — before the registry // entry is removed. await binding.teardown?.(); binding.finishInvocation?.(); } } /** * INV-5 runtime re-check: resolve the callee path against the caller's * directory and re-run the shared realpath + discovery-root containment check * against the currently-active roots. Returns the `load_failure` * `InvokeInfraError` on escape, or `undefined` when contained (or when the * production seams needed for the check are absent). */ async #recheckCalleeContainment( theta: ConversationBindInput["theta"], calleePath: string, ): Promise { const fileSystem = this.#input.fileSystem; const activeRoots = this.#input.activeRoots; if (fileSystem === undefined || activeRoots === undefined) { return undefined; } const baseDir = theta.sourcePath !== undefined ? dirname(theta.sourcePath) : undefined; const resolvedPath = baseDir !== undefined && !isAbsolute(calleePath) ? resolvePath(baseDir, calleePath) : calleePath; const verdict = await recheckInvokePathAtRuntime({ deps: { fs: fileSystem }, resolvedPath, literalPath: calleePath, activeRoots, }); return verdict.kind === "escape" ? verdict.error : undefined; } /** * INV-6 runtime return-value validation: lower the `invoke` annotation * against the caller theta's `schema` decls, compile it, and AJV-validate the * child's `Ok` payload. An untyped invoke (`returnSchema === null`) or an * `Err` result passes through unchanged; a validation failure is surfaced as * `Err(InvokeInfraError{cause:"return_validation"})`. */ #validateInvokeReturn( theta: ConversationBindInput["theta"], calleePath: string, returnSchema: string | null, result: ResultValue, ): ResultValue { if (returnSchema === null || !result.ok) { return result; } // Ceiling #4 (ceilings-3-and-4.md#ceiling-4-table, the `invoke` return-value // row; CIO-3): the theta-owned depth walk is the FIRST sub-check at the // return-value AJV boundary. A depth-6+ `Ok` payload surfaces to the invoke // parent as `Err(InvokeInfraError { cause: "return_validation" })` before AJV // is consulted. const depthBreach = enforceInvokeReturnDepth(calleePath, result.value as unknown); if (depthBreach !== undefined) { return depthBreach.result; } const lowered = lowerQueryResponseSchema(returnSchema, schemaDeclsOf(theta.body)); if (lowered === undefined) { return result; } const validator = this.#input.root.schemaValidator.compile(lowered); const verdict = validator.validate(result.value as unknown); if (verdict.ok) { return result; } const error: InvokeInfraError = { kind: "invoke_infra", message: `invoke<${returnSchema}> return value failed validation`, callee_path: calleePath, cause: "return_validation", }; return makeErr(error as unknown as ThetaValue); } } /** * QTL-4. The underlying Pi-tool names in the theta's frozen `tools:` callable set * — the host tool each `pi-tool` entry dispatches to (an `as`-rename entry * carries the underlying tool's own registered name, which is what the model's * active-tool set must reference). A theta with no snapshot (an in-memory * fixture) or no Pi tools yields `[]`, so the prompt-mode active set stays empty * and no ambient tool is installed. */ /** * FN-5 (invocation.md §Final-value propagation across callees): project an * `invoke` callee body's terminal execution onto the `Result` value that crosses * the invoke boundary. Shared by the subagent spawn path and the prompt→prompt * attach path — a callee's final value crosses the boundary identically in * either mode (the prompt callee's user-visible turns stream into the shared * session, but the value that flows BACK is still the body's final value, not * the PIC-53 trailing-turn text of a top-level prompt dispatch). * * On success the produced value flows as `Ok`, with the CONV-6 / FN-3 implicit * wrap applied ONLY to a non-`Result` operand (a `Result`-typed tail passes * through unchanged so `invoke` return validation sees `T`, not `Ok(T)`, and * a tail `Err(e)` is not masked as success). A `fail` outcome carries the * terminating `Err` (a `?`-propagation or an unhandled non-cancel effect-`Err` * in tail position, ERR-19) so the parent's XMODE-1 wrap reads the true leaf * kind rather than a fabricated `cancelled` (STL-6); only a genuine `cancel` * yields `CancelledError`. */ function surfaceCalleeFinalValue(execution: BodyExecution): ResultValue { if (execution.outcome === "success") { const value = execution.result.value ?? null; return isResultValue(value) ? value : makeOk(value); } if (execution.outcome === "fail") { return makeErr(execution.error ?? (makeCancelledError() as unknown as ThetaValue)); } return makeErr(makeCancelledError() as unknown as ThetaValue); } /** * RFC 0001 FN-7/FN-9: resolve a `subagent fn`'s spawned-session callable set. * With no `with { tools }` override the spawned session INHERITS the calling * theta's full frozen callable set. A `with { tools: […] }` override resolves * against the CALLING theta's callable set (FN-9): the spawned set is the named * SUBSET of the calling theta's entries (matched by presented name or, for a Pi * tool, its underlying tool name) — a name absent from the calling set simply * does not appear, and the code-driven `(args)` path re-resolves * independently, so no name is widened here. */ function subagentFnCallableSet( callingSet: ConversationBindInput["theta"]["callableSet"], config: SubagentSessionConfig, ): ConversationBindInput["theta"]["callableSet"] { if (callingSet === undefined || config.toolsOverridden !== true) { return callingSet; } const wanted = new Set(config.tools ?? []); const entries = new Map(); for (const [name, entry] of callingSet.entries) { const underlying = entry.kind === "pi-tool" ? (entry.toolDefinition as PiToolDispatch).toolName : undefined; if (wanted.has(name) || (underlying !== undefined && wanted.has(underlying))) { entries.set(name, entry); } } return Object.freeze({ entries }); } function callableSetPiToolNames( theta: ConversationBindInput["theta"], ): readonly string[] { const set = theta.callableSet; if (set === undefined) { return []; } const names: string[] = []; for (const entry of set.entries.values()) { if (entry.kind === "pi-tool") { names.push((entry.toolDefinition as PiToolDispatch).toolName); } } return names; } /** SUBAG-2: a resolved model-callable `.theta` in a subagent's callable set. */ interface ResolvedThetaCallable { /** The callable-set name the model calls (post-`as`, post-hyphen→underscore). */ readonly presentedName: string; /** The callee `.theta` path relative to the caller's directory. */ readonly calleePath: string; /** The callee's declared `params:` wire names, in DECLARATION ORDER. */ readonly paramOrder: readonly string[]; /** The callee's lowered `params:` object schema (the model-facing `parameters`). */ readonly loweredSchema: LoweredSchema | undefined; /** The callee's frontmatter `description` (the model-facing tool description). */ readonly description: string; } /** SUBAG-2: the model-facing text/`isError` pair a `.theta` model call lowers to. */ export interface LoweredThetaCallableResult { readonly text: string; readonly isError: boolean; } /** * SUBAG-2: the `.theta`-callable entries in the theta's frozen `tools:` callable * set — each carrying its presented (post-`as` / post-hyphen→underscore) * callable name and the resolved callee `.theta` path (relative to the caller's * directory) read from the frozen entry's `calleePath` (Gap-2: the load-time * resolver recorded it from the `tools:` `spec`, so renamed / hyphenated callees * carry their real path). Mirrors `callableSetPiToolNames`; the callee schema / * param order / description are resolved asynchronously at spawn time via * `parseCallee` (production freezes each entry with `callee: undefined`, so the * parsed callee itself is not held on the snapshot). A theta with no snapshot * yields `[]`. */ function callableSetThetaEntries( theta: ConversationBindInput["theta"], ): readonly { readonly presentedName: string; readonly calleePath: string; readonly closureHash?: string; }[] { const set = theta.callableSet; if (set === undefined) { return []; } const entries: { readonly presentedName: string; readonly calleePath: string; readonly closureHash?: string; }[] = []; for (const [presentedName, entry] of set.entries) { if (entry.kind !== "theta") { continue; } // Gap-2: read the authoritative callee path the load-time resolver recorded // on the frozen entry (from the `tools:` `spec`), NOT a basename // re-derivation — so renamed / hyphenated callees are presented + dispatchable. // #subagent-theta-callable-hash: carry the LOAD-TIME closure hash the // resolution snapshot captured, so the launch marshals the stored value. entries.push({ presentedName, calleePath: entry.calleePath, ...(entry.closureHash !== undefined ? { closureHash: entry.closureHash } : {}), }); } return entries; } /** * SUBAG-2: lower a `.theta`-callable's returned `Result` (FN-5) to the * model-facing tool-result text / `isError` pair. `Ok(string)` surfaces the * string verbatim; `Ok()` its JSON form; an `Err` surfaces * `isError: true` carrying the error's `message` (or its JSON form) so the model * observes the failure and the loop continues — the same disposition a failing * Pi-tool sibling receives (tool-calls.md §Concurrency). */ function lowerThetaCallableModelResult(result: ResultValue): LoweredThetaCallableResult { if (result.ok) { const value = result.value ?? null; return { text: typeof value === "string" ? value : JSON.stringify(value), isError: false, }; } const error = result.error as unknown; const message = (error as { readonly message?: unknown }).message; return { text: typeof message === "string" ? message : JSON.stringify(error), isError: true, }; } /** * The zero-width body range for a `.theta`-adapter internal-error diagnostic that * carries no source position of its own (mirrors the top-level panic-note site's * `ZERO_BODY_RANGE` in `theta-composition-producer.ts`). */ const ZERO_BODY_RANGE: SourceRange = { start: { line: 0, column: 0 }, end: { line: 0, column: 0 }, } as const; /** * The callable-set entry (a `./x.theta` path) that a call name resolves to, or * `undefined` when the name binds to no `.theta`-callable (so it is a Pi tool). * * Gap-2: resolve the callee path from the FROZEN callable-set snapshot keyed by * the presented (post-`as` / post-hyphen→underscore) name, using the * `calleePath` the load-time resolver (`resolveCallableSet`) recorded from the * entry's `spec`. This replaces the previous basename string-match against * `frontmatter.tools`, which dropped renamed (`./c.theta as foo`) and hyphenated * (`./my-tool.theta` → `my_tool`) callees — silently omitting them from BOTH the * code-driven `(args)` path and the model-driven adapter. * * A theta carrying NO snapshot (an in-memory harness fixture built with * `frontmatter.tools` but no `callableSet`) falls back to the pre-Gap-2 basename * match against `frontmatter.tools` — the same snapshot-absent fallback pattern * `#resolvePiToolForTheta` uses. Production discovered thetas always carry a * (possibly empty) snapshot, so the fallback never serves a real theta and thus * cannot re-open the Gap-2 hole for production (renamed / hyphenated resolve * from the snapshot). */ function thetaCalleePath( theta: ConversationBindInput["theta"], calleeName: string, ): string | undefined { const set = theta.callableSet; if (set !== undefined) { const entry = set.entries.get(calleeName); return entry !== undefined && entry.kind === "theta" ? entry.calleePath : undefined; } const tools = theta.frontmatter.tools ?? []; return tools.find( (entry) => entry.endsWith(".theta") && thetaCallableName(entry) === calleeName, ); } /** * Lower a code-side `(args)` call's arguments to the JSON params object the * host tool's `execute(...)` receives (V14g). The call convention is a single * object-literal argument (`grep({ pattern, path })`): its fields are evaluated * against the environment and become the JSON params object. A call with no * object argument (or a non-object first argument) lowers to an empty params * object. */ function lowerToolCallParams(expr: CallExpr, env: LexicalEnvironment): Record { const first = expr.args[0]; if (first === undefined || first.kind !== "object") { return {}; } const params: Record = {}; for (const field of first.fields) { params[field.name] = evaluatePureExpression(field.value, env) as unknown; } return params; } /** * Build the executor's root environment for a body, binding any invoke-supplied * positional args onto the callee's declared params as local slots (V15k final * value / arg binding) so the body can read them. */ function buildBoundEnvironment( body: ThetaBody, paramBindings: ReadonlyMap | undefined, imports: readonly MaterializedImport[] | undefined, ): LexicalEnvironment { // Register top-level `enum` declarations (with their captured variant names // and any explicit `= "..."` wire values) so `Enum.Variant` access resolves // to a first-class enum value — carrying the correct wire form — rather than // panicking on a member access against an unresolved name. const enums: EnumRegistration[] = []; for (const stmt of body.statements) { if (stmt.kind === "enum" && stmt.variants !== undefined) { enums.push({ name: stmt.name, variants: stmt.variants, ...(stmt.variantValues !== undefined ? { values: stmt.variantValues } : {}), }); } } const env = buildEnvironment({ body, enums, ...(imports !== undefined ? { imports } : {}), }); if (paramBindings !== undefined) { for (const [name, value] of paramBindings) { env.defineLocal(name, value, false); } } return env; } /** * The live prompt-mode `QueryModelDriver` (`V12a`/`V9c`): it drives real * user-visible turns into the shared user session. `nextFreePhaseTurn` issues * the rendered query as a streamed user turn (`pi.sendUserMessage`) and awaits * `ctx.waitForIdle()` so the assistant streams into the transcript before the * interpreter resumes (SLSH-2), then extracts the trailing-turn assistant text * (PIC-53) as the plain-text terminating turn. */ class LivePromptQueryModel implements QueryModelDriver { readonly #pi: ExtensionAPI; readonly #ctx: ExtensionCommandContext; readonly #clock: Clock; readonly #queryText: string; readonly #readMessages: () => readonly Message[]; readonly #activeTools: readonly string[]; readonly #thetaAbort: AbortController; /** STAGE B: bounds the native tool loop; `undefined` for the exempt typed path. */ readonly #governor: PromptToolLoopGovernor | undefined; readonly #maxRounds: number; /** PIC-50/51: the resolved provider for a synthesised `TransportError`. */ readonly #provider: string; /** The exhaustion snapshot captured after the bounded free-phase turn settled. */ #exhaustion: PromptToolLoopExhaustion | undefined = undefined; /** PIC-50: a `TransportError` synthesised from a `sendUserMessage` sync-throw. */ #transportFromThrow: TransportError | undefined = undefined; constructor(deps: { readonly pi: ExtensionAPI; readonly ctx: ExtensionCommandContext; readonly clock: Clock; readonly queryText: string; readonly readMessages: () => readonly Message[]; /** QTL-4: the theta's callable-set underlying Pi-tool names to install for the turn. */ readonly activeTools: readonly string[]; /** CANCEL-2: the per-invocation controller `ctx.signal` is re-forwarded into per turn. */ readonly thetaAbort: AbortController; /** STAGE B: the round-cap governor for the untyped free-phase turn (undefined = exempt/typed). */ readonly governor: PromptToolLoopGovernor | undefined; /** STAGE B: the theta's `tool_loop.max_rounds` for this query. */ readonly maxRounds: number; /** PIC-50/51: the resolved provider for a synthesised `TransportError`. */ readonly provider: string; }) { this.#pi = deps.pi; this.#ctx = deps.ctx; this.#clock = deps.clock; this.#queryText = deps.queryText; this.#readMessages = deps.readMessages; this.#activeTools = deps.activeTools; this.#thetaAbort = deps.thetaAbort; this.#governor = deps.governor; this.#maxRounds = deps.maxRounds; this.#provider = deps.provider; } async nextFreePhaseTurn(round: number): Promise { if (round === 0) { // SLSH-2: issue the rendered query as one streamed user-visible turn and // await its completion so the assistant text is committed before the // interpreter resumes. pi runs its NATIVE agentic tool loop for this turn; // the governor (STAGE B) bounds it to `tool_loop.max_rounds` by blocking // any tool-use round beyond the cap (ceiling #2 / CIO-4). await this.#driveUserVisibleTurn(true); // PIC-50: a synchronous throw from `pi.sendUserMessage` was mapped to a // `TransportError` (no turn was issued); surface it as the free-phase // transport failure ahead of any exhaustion / text extraction. if (this.#transportFromThrow !== undefined) { return { kind: "transport", error: this.#transportFromThrow }; } if (this.#exhaustion?.exhausted === true) { // The native loop attempted a round beyond `max_rounds`; the governor // blocked it. Represent that as a `tool_use` round so the enclosing // `runUntypedQueryLoop` reaches its `max_rounds`-final branch and // surfaces the canonical `Err(ToolLoopExhaustedError)` with the recorded // `last_tool_name` (ERR-19). The native turn already committed its side // effects (ERR-13 no-rollback); this batch is not re-executed // (`runToolBatch` is a no-op below). return this.#exhaustionTurn(); } // PIC-51: probe the driven turn's trailing `assistant` `stopReason` before // extracting text. A `stopReason: "error"` turn maps to // `Err(TransportError)` (never masked as `Ok(text)`); the cancellation and // plain-text paths are unchanged (cancellation is handled by the loop's // checkpoint, so only a `transport` verdict diverts here). const probe = extractPromptModeQueryResult(this.#readMessages(), { aborted: this.#thetaAbort.signal.aborted, provider: this.#provider, }); if (!probe.ok && probe.error.kind === "transport") { return { kind: "transport", error: probe.error as TransportError }; } // Completed within the cap: the terminating plain-text turn. return { kind: "text", text: extractTrailingTurnText(this.#readMessages()) }; } // Only reachable on the exhausted path: keep returning the synthetic // `tool_use` round until `runUntypedQueryLoop`'s slot count reaches // `max_rounds` and it surfaces `tool_loop_exhausted`. if (this.#exhaustion?.exhausted === true) { return this.#exhaustionTurn(); } // Defensive: a non-exhausted round beyond the first is unreachable (round 0 // returned text) — a terminating turn keeps the loop total. return { kind: "text", text: "" }; } /** * The synthetic single-call `tool_use` round that drives `runUntypedQueryLoop` * to its `max_rounds`-final branch on the exhausted path. Its `toolName` is the * last tool the model tried (surfaced as ERR-19 `last_tool_name`). */ #exhaustionTurn(): FreePhaseTurn { const toolName = this.#exhaustion?.lastToolName ?? "respond"; return { kind: "tool_use", batch: [{ toolName, toolUseId: "theta-prompt-loop-exhausted" }], }; } runToolBatch(): Promise { // pi's native loop executes and commits the real tool calls inside the // streamed turn; the theta-level batch (only ever the STAGE-B synthetic // exhaustion round) executes nothing. return Promise.resolve([]); } async forcedRespondTurn(): Promise { // A schema-typed query's forced-respond terminator drives one user-visible // turn that streams the structured JSON as its assistant text, then parses // that text as the candidate structured payload. The typed-query response // schema is lowered from the declared annotation (`V5d`); the respond loop // depth-walks and validates the payload against it. A non-JSON reply is // surfaced as its raw text (never a thrown `JSON.parse`, never a bound // `null`) so the schema validation reports the mismatch. // // STAGE B: the forced-respond turn is the exempt-routed terminator (FRNT-1) // and is NOT bounded by `tool_loop.max_rounds` — driven UNBOUNDED. await this.#driveUserVisibleTurn(false); // PIC-50/51: a transport failure on the forced-respond turn (send sync-throw // or trailing `stopReason: "error"`) surfaces as the typed query's // `Err(TransportError)` rather than being parsed as a structured payload. if (this.#transportFromThrow !== undefined) { return { kind: "transport", error: this.#transportFromThrow }; } const probe = extractPromptModeQueryResult(this.#readMessages(), { aborted: this.#thetaAbort.signal.aborted, provider: this.#provider, }); if (!probe.ok && probe.error.kind === "transport") { return { kind: "transport", error: probe.error as TransportError }; } const text = extractTrailingTurnText(this.#readMessages()); const parse = await parseStructuredPayload(text); return { kind: "respond", payload: payloadForRespond(parse) }; } /** * Issue one streamed user-visible turn and await its full completion. * * `pi.sendUserMessage` is fire-and-forget: it schedules a fresh agent run but * returns before that run installs its active-run handle, and * `ctx.waitForIdle()` resolves immediately while no run is active. So the * driver first waits for the run to become observably non-idle (bounded, on * the injected `Clock` macrotask queue, so a turn that never starts cannot * hang), then awaits idle for the run's `agent_end`. */ async #driveUserVisibleTurn(bound: boolean): Promise { // STAGE B: when `bound`, arm the governor around the native turn so pi's // internal agentic tool loop is capped at `tool_loop.max_rounds`. The bound // is armed IMMEDIATELY before `sendUserMessage` and disarmed right after the // turn settles, so it never affects unrelated turns or other queries. The // exhaustion snapshot is read by `nextFreePhaseTurn` after this resolves. // A typed query's forced-respond turn passes `bound: false` (exempt). if (bound && this.#governor !== undefined) { this.#governor.begin(this.#maxRounds); } // `pi.sendUserMessage` is fire-and-forget: it schedules a fresh agent run // but returns before that run installs its active-run handle. `waitForIdle` // is not a reliable barrier here — in a session bound without // `commandContextActions` it is a no-op that resolves immediately — so the // driver observes the run through `ctx.isIdle()` (the real `!isStreaming` // flag): wait for the run to begin streaming, then for it to go idle again // (its `agent_end`). Both waits are bounded on the injected `Clock` so a run // that never starts (or one that starts and ends within a single tick) // cannot hang. The final `waitForIdle` is the real-host completion barrier // (PIC-18) when the session binds one. // PIC-17 active-set gating (QTL-4): install exactly the theta's callable set // — its underlying Pi-tool names — as the model's active tools for the query // turn and restore the ambient set in a `finally`. The model can then call a // declared `tools:` entry (and query-time tool loops / ceiling #2 become // reachable), while the host session's ambient tools stay deliberately not // inherited (a theta with no Pi tools in its set installs `[]`). const ambientTools = this.#pi.getActiveTools(); this.#pi.setActiveTools([...this.#activeTools]); try { // PIC-50: `pi.sendUserMessage` is the only failure the call surface itself // can signal synchronously. Map such a throw to a `TransportError` (never // `theta/runtime/internal-error`, never a swallowed `Ok("")`) and return // without issuing a turn; the driver surfaces it as the query's transport // `Err`. The `finally` still restores the ambient active set. try { this.#pi.sendUserMessage(this.#queryText); } catch (thrown: unknown) { // allow-broad-catch: pi-sdk-boundary — PIC-50 sendUserMessage sync-throw → TransportError this.#transportFromThrow = mapPromptModeSyncThrow(thrown, this.#provider); return; } await this.#pollWhile(() => this.#ctx.isIdle(), TURN_START_POLL_BOUND); // CANCEL-2 (cancellation.md §Forwarding into `thetaAbort`, slash-command // entry): the turn is now streaming, so `ctx.signal` is defined for THIS // turn (it is `undefined` at idle slash-entry). Re-forward it INTO // `thetaAbort` so an Esc during the `@`-query turn flips the single source // of truth every checkpoint gates on — the end-to-end "Esc during // `@`-query" path. Idempotent: the one-shot guard on `thetaAbort.abort()` // makes a repeat forward a no-op, and the listener is `{ once: true }` on // the per-turn transient `ctx.signal`, so no long-lived controller leaks. // Decision 6 / Increment B2: this PER-TURN forward's detach is deliberately // NOT collected onto the shared `forwardingSignals` sink — the listener sits // on a per-turn-transient `ctx.signal` that self-cleans (`{once:true}` and // GC'd with the turn), so collecting it would add per-turn push/splice // churn for no shutdown-lifetime benefit. Only the invocation-scoped bind // forwards are collected (sub-step 5 detaches those). forwardSlashCommandCancel(this.#thetaAbort, this.#ctx.signal); await this.#pollWhile(() => !this.#ctx.isIdle(), TURN_END_POLL_BOUND); await this.#ctx.waitForIdle(); // CANCEL-2 (agent_end user-cancel trigger, CNCL-4 synthesised reason): a // turn that ended aborted without a forwarded source reason flips // `thetaAbort` with the synthesised `"theta cancelled by agent_end"` reason, // so the next checkpoint observes the cancellation. if (this.#ctx.signal?.aborted === true && !this.#thetaAbort.signal.aborted) { abortForAgentEnd(this.#thetaAbort); } } finally { this.#pi.setActiveTools(ambientTools); // STAGE B: disarm the governor and capture the exhaustion snapshot the // moment the turn settles, even on an error/abort path. if (bound && this.#governor !== undefined) { this.#exhaustion = this.#governor.end(); } } } /** Release the event loop, polling `condition` on the `Clock` up to `bound` times. */ async #pollWhile(condition: () => boolean, bound: number): Promise { for (let i = 0; i < bound && condition(); i += 1) { await macrotask(this.#clock, POLL_INTERVAL_MS); } } } /** Poll cadence (ms) while waiting for a fire-and-forget user turn's stream lifecycle. */ const POLL_INTERVAL_MS = 10; /** Bound on start-phase polls (≈ waiting for the run to begin streaming). */ const TURN_START_POLL_BOUND = 1000; /** Bound on end-phase polls (≈ waiting for the streamed run to complete). */ const TURN_END_POLL_BOUND = 60000; /** Release the event loop for one poll interval through the injected `Clock` seam. */ function macrotask(clock: Clock, ms: number): Promise { return new Promise((resolve) => { clock.setTimeout(() => resolve(), ms); }); } /** * An off-session `QueryModelDriver`: it resolves the query through pi-ai's * `complete()` free function (no user session turn, no transcript card), so a * chained follow-up query in a body does not stream into the transcript * alongside the dispatch's primary user-visible turn. The untyped path returns * the assistant text; the typed path parses it as the structured payload. */ class OffSessionQueryModel implements QueryModelDriver { readonly #model: Model | undefined; readonly #queryText: string; readonly #signal: AbortSignal; constructor(deps: { readonly model: Model | undefined; readonly queryText: string; /** CANCEL-3: the theta signal the provider-Promise swallowing guard reads at settlement. */ readonly signal: AbortSignal; }) { this.#model = deps.model; this.#queryText = deps.queryText; this.#signal = deps.signal; } async nextFreePhaseTurn(round: number): Promise { if (round === 0) { return { kind: "text", text: await this.#complete() }; } return { kind: "text", text: "" }; } runToolBatch(): Promise { return Promise.resolve([]); } async forcedRespondTurn(): Promise { const parse = await parseStructuredPayload(await this.#complete()); return { kind: "respond", payload: payloadForRespond(parse) }; } #complete(): Promise { // CANCEL-3 (cancellation.md §"Race semantics — swallowing-handler // attachment on every abandonable Promise"): attach the swallowing handler // to the underlying `@`-query provider Promise at its construction site, // before the first microtask boundary, so a late rejection arriving after // the query checkpoint surfaced `cause: "cancelled"` is absorbed and never // reaches Node's `unhandledRejection` process event. return guardQueryProviderPromise( offSessionComplete(this.#model, this.#queryText), signalGuard(this.#signal), noopSwallowChannels(), ); } } /** The off-session `complete()` path has no resolved model to dispatch against. */ class OffSessionModelUnavailableError extends Error {} /** * STAGE A / ceiling #4 (model-driven row): lower ONE model-driven `tool_use` * call over the theta's callable set to the tool-result turn fed back on the * next `complete()` turn, reusing the SAME `#resolvePiToolForTheta` / `execute` * path the code-driven `(args)` calls use. Extracted from the STAGE-A * closure so the model-driven ceiling-#4 seam is deterministically testable * against a scripted `PiToolDispatch`. * * Dispositions, in order: * - a name outside the callable set (`dispatch === undefined`) is an * unavailable-tool `isError` result — ambient tools are never inherited * (frontmatter.md §`tools:`); * - CEILING #4 (ceilings-3-and-4.md#ceiling-4-table, model-driven row; * schema-subset.md §Depth Enforcement point #2; CIO-3 depth-walk-before-AJV): * the theta-owned depth walk runs over the MODEL-produced `call.arguments` * *before* the tool body runs. A depth-6+ argument is fed back to the model * as an `isError` tool-result carrying the canonical depth message — NEVER * dispatched (the host tool's `execute()` is not called), NEVER surfaced as * a theta `Err` or `ModelToolError`. The round still counts against * `tool_loop.max_rounds` (this call runs inside a counted free-phase round) * and the loop continues, re-trying naturally on the model's next turn. AJV * against the presented tool schema cannot catch this — JSON Schema 2020-12 * has no `maxDepth` keyword, so the presented schema carries no depth bound; * - a clean resolve lowers to the V14g filter/join text; * - an `execute()` throw lowers to the V14g execution message on an `isError` * result so the model observes the failure and the loop continues. */ export async function lowerModelDrivenToolCall( call: ToolCall, dispatch: PiToolDispatch | undefined, toolSignal: AbortSignal, ): Promise { // An execute-less entry is the PIC-64 extension shape (name + `parameters` // only). On the MODEL-driven path the host loop holds the tool's `execute` and // runs the call itself, so this lowering is never the executor for one; a // dispatch that reaches here without an `execute` handle is fed back as an // unavailable-tool `isError` result rather than fabricating a success. if (dispatch === undefined || typeof dispatch.execute !== "function") { return subagentToolResult( call, `tool '${call.name}' is not available in this theta's callable set`, true, ); } // Ceiling #4 model-driven row (CIO-3): depth-walk the model-produced // arguments before the tool body runs; a breach is fed back to the model and // the tool never executes. const argDepthBreach = enforceModelToolArgDepth(call.arguments); if (argDepthBreach !== undefined) { return subagentToolResult(call, argDepthBreach.message, true); } try { const envelope = await dispatch.execute(call.id, call.arguments, toolSignal); return subagentToolResult(call, filterJoinToolText(envelope.content), false); } catch (thrown: unknown) { // allow-broad-catch: pi-sdk-boundary — execute() throw lowered to an error tool-result // A model-driven tool `execute()` throw is fed back as an `isError` // tool-result (ceiling #4 model-driven row); the loop continues under the // same `tool_loop.max_rounds` cap. return subagentToolResult(call, lowerToolExecuteThrow(thrown, call.name).message, true); } } /** Lower a subagent model tool call's outcome text to a fed-back tool-result turn. */ function subagentToolResult(call: ToolCall, text: string, isError: boolean): ToolResultMessage { return { role: "toolResult", toolCallId: call.id, toolName: call.name, content: [{ type: "text", text }], isError, timestamp: 0, }; } /** * SUBAG-2 model-callable `.theta`: the injected drive + setup-throw + param-order * collaborators the model-driven `.theta` adapter core dispatches through. * Extracted so the model-driven `.theta` seam (arg-mapping declaration order, * ceiling-#4 depth block, `Result` lowering, setup-throw translation, * re-entrancy) is deterministically testable against scripted collaborators — * the same extraction rationale as `lowerModelDrivenToolCall` for the Pi-tool * seam. */ export interface ModelDrivenThetaCall { /** The callee's declared `params:` wire names, in DECLARATION ORDER. */ readonly paramOrder: readonly string[]; /** * Drive the callee (equivalent to `#driveCallee` bound to the caller theta / * ctx / chain) over the positional `argValues` mapped from the model's object * arguments, returning the callee's top-level `Result` (FN-5). */ readonly driveCallee: ( argValues: readonly ThetaValue[], toolSignal: AbortSignal, ) => Promise; /** * Translate a non-`HostFatal` pre-eval setup / body throw into the model-facing * `{ text, isError: true }` pair, emitting the paired * `theta/runtime/internal-error` diagnostic + `theta-system-note` as a side * effect (tool-calls.md:30). A `HostFatal` is NEVER passed here — the core * re-raises it (NOCEIL-3) before calling. */ readonly onSetupThrow: (thrown: unknown) => LoweredThetaCallableResult; } /** * SUBAG-2 model-callable `.theta` (tool-calls.md §"Argument shape" / §Concurrency; * ceiling #4 model-driven row). Lower ONE model-driven `.theta`-callable * `tool_use` call to the model-facing text / `isError` pair, in order: * * - CEILING #4 (ceilings-3-and-4.md#ceiling-4-table, model-driven row; CIO-3): * the theta-owned depth walk runs over the MODEL-produced `args` document * BEFORE the callee spawns — a depth-6+ argument is fed back as an `isError` * result and the callee never spawns (identical to `lowerModelDrivenToolCall` * for the Pi-tool arm; `#driveCallee`'s own per-arg `enforceInvokeParamsDepth` * is the separate code-path net); * - the model's object arguments are bound to positional `argValues` in the * callee's `params:` DECLARATION ORDER (the SAME binding a code-side * `(args)` / `invoke(...)` uses) and the callee is driven; * - a clean `Result` lowers via `lowerThetaCallableModelResult` (Ok → text; * Err → `isError`); * - a non-`HostFatal` setup / body throw routes through `onSetupThrow` * (tool-calls.md:30); a `HostFatal` re-raises (NOCEIL-3). * * Re-entrant: it holds no state; two concurrent calls dispatch through their own * `spec.driveCallee`, which spawns an independent `AgentSession` each * (tool-calls.md §Concurrency). */ export async function lowerModelDrivenThetaCall( args: Record, spec: ModelDrivenThetaCall, toolSignal: AbortSignal, ): Promise { const argDepthBreach = enforceModelToolArgDepth(args); if (argDepthBreach !== undefined) { return { text: argDepthBreach.message, isError: true }; } const argValues = spec.paramOrder.map((name) => (args[name] ?? null) as ThetaValue); try { return lowerThetaCallableModelResult(await spec.driveCallee(argValues, toolSignal)); } catch (thrown: unknown) { // allow-broad-catch: theta/runtime/internal-error — `.theta`-adapter pre-eval setup throw (tool-calls.md §"Outcome enumeration") // NOCEIL-3 (hard-ceilings): a host fatal is the ONLY thing that propagates // (fail-fast); every other throw routes to the internal-error framing. if (thrown instanceof HostFatal) { throw thrown; } return spec.onSetupThrow(thrown); } } /** * Render one `@`-query to its wire text, appending the typed-query JSON-only * instruction for a schema-typed query. Shared by the prompt-mode and * subagent-mode drivers so both convey the declared shape identically. The * conveyance carries the LOWERED response schema (QRY-22) when the declared * schema lowered cleanly — not the bare type name — so the model sees the JSON * shape its response is validated against; it falls back to the annotation text * only when the schema did not lower. */ function renderTypedAwareQueryText( expr: QueryExpr, env: LexicalEnvironment, lowered?: LoweredSchema, ): string { const base = renderQueryText(expr, env); if (expr.schema === null) { return base; } const shape = lowered !== undefined ? JSON.stringify(lowered) : expr.schema; return ( `${base}\n\nRespond with ONLY a single minified JSON object matching this JSON ` + `schema, and nothing else — no prose, no markdown, no code fences: ${shape}` ); } /** The theta body's `schema` declarations, for whole-file named-type resolution. */ function schemaDeclsOf(body: ThetaBody): SchemaDecl[] { return body.statements.filter((stmt): stmt is SchemaDecl => stmt.kind === "schema"); } /** An identifier-shaped `@` annotation names a `schema` decl. */ const SCHEMA_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; /** * A `resolveDeclaredSchema` step (QRY-22): a named `@` annotation * resolves whole-file via `env.resolveSchema` (previously uncalled); an inline * annotation resolves to its verbatim source. */ function resolveDeclaredShape(expr: QueryExpr, env: LexicalEnvironment): () => unknown { const annotation = (expr.schema ?? "").trim(); return () => SCHEMA_NAME.test(annotation) ? env.resolveSchema(annotation) : annotation; } /** Concatenate the text content of an assistant message (thinking / tool calls omitted). */ function assistantText(message: AssistantMessage): string { return message.content .filter((part): part is Extract => part.type === "text") .map((part) => part.text) .join(""); } /** * Resolve a query / respond-repair follow-up prompt off-session through pi-ai's * `complete()` free function (no user session turn), returning the assistant * text. Shared by the off-session query driver and the off-session respond-repair * follow-up drive. */ async function offSessionComplete( model: Model | undefined, prompt: string, ): Promise { if (model === undefined) { throw new OffSessionModelUnavailableError( "H8a: an off-session chained query has no resolved model (ctx.model is undefined).", ); } const reply: AssistantMessage = await complete(model, { messages: [{ role: "user", content: prompt, timestamp: 0 }], }); return assistantText(reply); } /** * Drive ONE user-visible streamed turn against the shared user session and * return its trailing-turn assistant text. Mirrors `LivePromptQueryModel`'s turn * drive: install the caller-supplied active tools for the turn (the theta's * callable set for a query follow-up, `[]` for the binder), issue the * fire-and-forget `pi.sendUserMessage`, then observe the run through * `ctx.isIdle()` (wait for it to begin streaming, then to go idle again) and the * `ctx.waitForIdle()` completion barrier — all bounded on the injected `Clock`. */ async function driveStreamedUserTurn(deps: { readonly pi: ExtensionAPI; readonly ctx: ExtensionCommandContext; readonly clock: Clock; readonly queryText: string; /** * QTL-4: the active tool names to install for the turn (the theta's * callable-set underlying Pi-tool names for a query follow-up; `[]` for the * binder turn, which emits a structured envelope and calls no tools). */ readonly activeTools: readonly string[]; }): Promise { const readMessages = (): readonly Message[] => buildSessionContext( deps.ctx.sessionManager.getEntries(), deps.ctx.sessionManager.getLeafId(), ).messages as unknown as readonly Message[]; const pollWhile = async (condition: () => boolean, bound: number): Promise => { for (let i = 0; i < bound && condition(); i += 1) { await macrotask(deps.clock, POLL_INTERVAL_MS); } }; const ambientTools = deps.pi.getActiveTools(); deps.pi.setActiveTools([...deps.activeTools]); try { deps.pi.sendUserMessage(deps.queryText); await pollWhile(() => deps.ctx.isIdle(), TURN_START_POLL_BOUND); await pollWhile(() => !deps.ctx.isIdle(), TURN_END_POLL_BOUND); await deps.ctx.waitForIdle(); } finally { deps.pi.setActiveTools(ambientTools); } return extractTrailingTurnText(readMessages()); } /** * Render the binder-turn prompt: instruct the model to bind the raw slash * arguments into the theta's typed `params:` object and emit ONLY the minified * three-arm envelope JSON (`ok | needs_info | ambiguous`) validating against the * per-theta envelope schema — no prose, no markdown, no code fences. */ function renderBinderTurnPrompt(input: { readonly slashName: string; readonly args: string; readonly paramsSchema: Readonly>; readonly defaultedFields: readonly string[]; readonly envelopeSchema: Readonly>; /** BNDR-10 Recent session context transcript body (`bind_context: session`). */ readonly sessionContext?: string; }): string { const defaulted = input.defaultedFields.length > 0 ? input.defaultedFields.join(", ") : "(none)"; // BNDR-10: ground the binder in the recent session transcript when present. const sessionBlock = input.sessionContext !== undefined ? `Recent session context (most recent 20 turns / 8000 tokens):\n${input.sessionContext}\n` : ""; return ( `You are the argument binder for the theta slash command /${input.slashName}. ` + `Bind the raw slash-command arguments to the theta's typed parameters.\n\n` + sessionBlock + `Raw arguments: ${JSON.stringify(input.args)}\n\n` + `Parameter schema (JSON Schema): ${JSON.stringify(input.paramsSchema)}\n` + `Defaulted parameters (may be omitted from your args — defaults are applied ` + `downstream): ${defaulted}\n\n` + `Respond with ONLY a single minified JSON object and nothing else — no prose, ` + `no markdown, no code fences — matching exactly one of these three arms:\n` + ` {"kind":"ok","args":{ ...bound parameters... }}\n` + ` {"kind":"needs_info","message":"..."}\n` + ` {"kind":"ambiguous","message":"...","candidates":["..."]}\n\n` + `Prefer the "ok" arm when the arguments can be bound. Your object MUST ` + `validate against this envelope JSON Schema: ${JSON.stringify(input.envelopeSchema)}` ); } /** * The routed classification of the off-session binder reply. `ok` runs the theta * body; `needs_info` / `ambiguous` carry the model's message for their * failure-mode note; `malformed` is a reply that does not parse as an envelope * object, carries an out-of-set `kind`, or whose model message is empty after * rule-1 stripping (§"System-note rendering" rule 4). */ type BinderReplyRouting = | { readonly kind: "ok" } | { readonly kind: "needs_info"; readonly message: string } | { readonly kind: "ambiguous"; readonly message: string } | { readonly kind: "malformed" }; /** * Parse and route the off-session binder reply text, NON-throwing (it reuses the * `V13e` `parseStructuredPayload` promise-rejection handler, never a broad * `catch`). A reply that does not parse as a JSON object, carries a `kind` * outside `ok | needs_info | ambiguous`, or whose `message` is empty after * rule-1 stripping is routed to `malformed`. The parsed text is never surfaced * verbatim to the user (BND-3) — only the routed note is. */ async function parseBinderEnvelope(text: string): Promise { const parse = await parseStructuredPayload(text); if (!parse.parsed || typeof parse.value !== "object" || parse.value === null) { return { kind: "malformed" }; } const obj = parse.value as Record; const kind = obj["kind"]; if (kind === "ok") { return { kind: "ok" }; } if (kind === "needs_info" || kind === "ambiguous") { const message = typeof obj["message"] === "string" ? (obj["message"] as string) : ""; // Rule 4: a message empty after rule-1 stripping is a malformed envelope, // not an empty note. if (classifyModelContent({ message }) === "empty-malformed") { return { kind: "malformed" }; } return { kind, message }; } return { kind: "malformed" }; } /** * Derive the argument-echo `EchoType` for a bound value, VALUE-driven so it can * never mismatch the value's runtime shape and crash the renderer. The lowered * params property (when available) disambiguates `integer` from `number`; every * other arm is decided from the runtime value. An enum value is a string at * runtime and renders identically to a string through the quote predicate, so * the `string` arm is used for it. Object fields are taken from the value's own * keys in insertion order (declaration order for a binder-returned object). */ function echoTypeFromValue(value: ThetaValue, property: unknown): EchoType { if (typeof value === "string") { return { kind: "string" }; } if (typeof value === "number") { return { kind: loweredSchemaKindIsInteger(property, value) ? "integer" : "number" }; } if (typeof value === "boolean") { return { kind: "boolean" }; } if (value === null) { return { kind: "null" }; } if (Array.isArray(value)) { const itemProp = typeof property === "object" && property !== null ? (property as Record)["items"] : undefined; const element = value.length > 0 ? echoTypeFromValue(value[0] as ThetaValue, itemProp) : ({ kind: "string" } as EchoType); return { kind: "array", element }; } // A plain object value: render by its own keys in insertion order. const props = typeof property === "object" && property !== null ? ((property as Record)["properties"] as | Record | undefined) : undefined; const fields = Object.entries(value as Record).map( ([name, fieldValue]) => ({ name, type: echoTypeFromValue(fieldValue, props?.[name]), }), ); return { kind: "object", fields }; } /** * Whether the lowered params property declares `integer` (BNDR-4 renders * `integer` vs `number` from the static kind, never runtime integrality). Falls * back to the runtime value's integrality when the property is unavailable. */ function loweredSchemaKindIsInteger(property: unknown, value: number): boolean { if (typeof property === "object" && property !== null) { const type = (property as Record)["type"]; if (type === "integer") { return true; } if (type === "number") { return false; } if (Array.isArray(type)) { if (type.includes("integer") && !type.includes("number")) { return true; } if (type.includes("number")) { return false; } } } return Number.isInteger(value); } /** * Extract the `ok` envelope's `args` object from the streamed binder reply, * NON-throwing: it reuses the `V13e` `parseStructuredPayload` (a promise * rejection handler, never a broad `catch`). A reply that does not parse as a * JSON object, or an `ok` envelope carrying no `args` object, yields `{}` (the * body still runs on the `ok` arm, with no param slots). The authoritative * envelope schema validation lives in the acceptance runner. */ /** * Extract the YAML frontmatter block (the text between the leading `---` fence * and the next `---` line) from a `.theta` source, or `undefined` when the file * carries no fenced frontmatter. Mirrors the parser's own block isolation so the * re-read reads the same YAML the loader parsed; the `\r` trim handles CRLF * files. Used only to recover declared `params:` default literals the parsed * frontmatter does not retain. */ function extractFrontmatterYaml(source: string): string | undefined { const lines = source.split("\n"); const isFence = (line: string | undefined): boolean => line !== undefined && line.replace(/\r$/, "") === "---"; if (!isFence(lines[0])) { return undefined; } for (let i = 1; i < lines.length; i += 1) { if (isFence(lines[i])) { return lines.slice(1, i).join("\n"); } } return undefined; } /** * Split a `params:` field value scalar (`` optionally followed by * `= `) at the first top-level `=` — one not nested inside `<...>` * angles, `{...}` braces, `[...]` brackets, or a `"`/`'` string literal (so * `array = []` and `Author = { name: "x" }` split correctly, and an * `==`/`>=` inside a default is not mistaken for the separator) — returning the * default RHS, or `undefined` when the field declared no default. Kept in step * with the parser's own `splitParamValue` so a recovered default matches the * literal the loader validated. */ function splitParamDefaultSource(raw: string): string | undefined { let depth = 0; let quote: string | undefined; for (let i = 0; i < raw.length; i += 1) { const c = raw[i]; if (quote !== undefined) { if (c === "\\" && i + 1 < raw.length) { i += 1; } else if (c === quote) { quote = undefined; } continue; } if (c === '"' || c === "'") { quote = c; continue; } if (c === "<" || c === "{" || c === "[") { depth += 1; continue; } if (c === ">" || c === "}" || c === "]") { depth -= 1; continue; } if (depth === 0 && c === "=" && raw[i + 1] !== "=" && raw[i - 1] !== "=") { return raw.slice(i + 1).trim(); } } return undefined; } async function parseOkEnvelopeArgs(text: string): Promise>> { const parse = await parseStructuredPayload(text); if (!parse.parsed || typeof parse.value !== "object" || parse.value === null) { return {}; } const args = (parse.value as Record)["args"]; if (typeof args === "object" && args !== null && !Array.isArray(args)) { return args as Readonly>; } return {}; } /** * Render one `@`-query template to its wire text against the lexical * environment: lex the template into literal / `${…}` interpolation parts, * evaluate each interpolation as a full expression (expressions.md * §"Supported forms" — not a dotted-path subset), stringify the resulting * runtime value by the QRY-18 rule, and apply the QRY-7 newline-trim → dedent * normalisation. An interpolation whose source does not parse, or that has no * pure runtime value (an effectful `fn` body / tool-call), yields the inert * `null` render (the expressions.md safety net), never a throw. */ function renderQueryText(expr: QueryExpr, env: LexicalEnvironment): string { const lexed = lexQueryTemplate(expr.template); let text = ""; for (const part of lexed.parts) { if (part.kind === "text") { text += part.value; continue; } text += stringifyInterpolation(part.exprSource, env); } return renderTemplateText(text); } /** * Evaluate one `${…}` interpolation source and stringify its runtime value by * the QRY-18 rule. The source is parsed into the same `Expr` a `let` RHS parses * to and evaluated by the shared pure evaluator, so arithmetic, indexing, calls, * method calls, ternaries, and `Enum.Variant` access all render their value * (EXPR-1/6/7/8, QRY-2/3/4). The `InterpolationType` discriminator is derived * from the resulting runtime `ThetaValue` — numbers route through the canonical * decimal renderer (so `Infinity`/`NaN` render as `Infinity`/`NaN`, not * `null`), an enum renders its bare unquoted wire value, and arrays/objects * render as compact JSON. A `Result` value is not statically rejectable here * (this is the runtime render, not a parse) — it renders as compact JSON, * preserving the prior non-crashing behaviour rather than emitting a diagnostic. */ function stringifyInterpolation(source: string, env: LexicalEnvironment): string { const parsed = parseExpressionSource(source); if (parsed === null) { // An unparseable interpolation has no value; render the inert `null` rather // than throwing out of the render path (the expressions.md safety net). return "null"; } const value = evaluatePureExpression(parsed, env); const type = interpolationTypeOf(value); if (type.kind === "object" || type.kind === "array") { // QRY-18: a Schema-typed object / `array` interpolation renders as compact // `JSON.stringify` with wire-name translation applied recursively. The // outbound pass rewrites every renamed field to its wire name at every // nesting level, driven by each object value's declaring-schema brand (with // the declared field type as a fallback for un-branded nested values); theta // code never sees a wire name, and the model never sees a theta-side name. return JSON.stringify(translateInterpolationOutbound(value, env)); } const rendered = stringifyInterpolatedValue(value, type); // `stringifyInterpolatedValue` only reports `ok: false` for the static // `result` arm, which `interpolationTypeOf` never selects, so the value branch // always holds; the JSON fallback keeps this total without a throw. return rendered.ok ? rendered.text : JSON.stringify(value); } /** * Recursively lower an object/array interpolation value to its wire-named JSON * form (QRY-18 outbound wire-name translation, runtime-value-model.md §Wire-name * translation). Each object-schema value renames its fields theta→wire using the * schema resolved from the value's declaring-schema brand (attached at * construction) — falling back to the declared field type `typeHint` for a value * that carries no brand (e.g. a bare object literal in a schema-typed field). * Enum values collapse to their bare wire string; arrays recurse element-wise; * primitives pass through. A value whose schema cannot be resolved recurses with * its keys unchanged (the safe no-rename default). */ function translateInterpolationOutbound( value: ThetaValue, env: LexicalEnvironment, typeHint?: string, ): unknown { if (isEnumValue(value)) { // The enum brand is dropped; the model only ever sees the bare wire string. return String(value); } if (Array.isArray(value)) { const elementHint = typeHint !== undefined ? arrayElementTypeSource(typeHint) : undefined; return value.map((element) => translateInterpolationOutbound(element, env, elementHint)); } if (typeof value !== "object" || value === null) { return value; } // Resolve the declaring schema: the construction-time brand is authoritative; // an un-branded value falls back to the declared field type when that names a // resolvable schema (a `Result` value / bare literal resolves to neither and // recurses with its keys unchanged). const hintName = typeHint !== undefined ? identifierTypeSource(typeHint) : undefined; const brand = schemaTagOf(value); const schemaName = brand ?? (hintName !== undefined && env.resolveSchema(hintName) !== undefined ? hintName : undefined); const decl = schemaName !== undefined ? env.resolveSchema(schemaName) : undefined; const fields = new Map(); if (decl?.fields !== undefined) { for (const field of decl.fields) { fields.set(field.name, { wire: field.wireName ?? field.name, type: field.typeSource }); } } const result: Record = {}; for (const [thetaKey, fieldValue] of Object.entries(value)) { const field = fields.get(thetaKey); const wireKey = field?.wire ?? thetaKey; result[wireKey] = translateInterpolationOutbound(fieldValue, env, field?.type); } return result; } /** The leading identifier of a type-expression source (`Inner`), else `undefined`. */ function identifierTypeSource(source: string): string | undefined { const s = source.trim(); return /^[A-Za-z_][A-Za-z0-9_]*$/.test(s) ? s : undefined; } /** The element type source of an `array` type-expression source, else `undefined`. */ function arrayElementTypeSource(source: string): string | undefined { const m = /^array<(.+)>$/.exec(source.trim()); return m !== null ? (m[1] as string).trim() : undefined; } /** * Derive the QRY-18 `InterpolationType` discriminator from a runtime * `ThetaValue`. A number uses the `number` rule (canonical decimal, no trailing * `.0`, `Infinity`/`NaN` verbatim); an enum uses the bare-wire `enum` rule; a * `Result` is rendered as compact JSON via the `object` arm, preserving the * prior non-crashing render (the static `result`-rejection arm is a parse-time * concern, not reachable on this runtime render path). */ function interpolationTypeOf(value: ThetaValue): InterpolationType { if (typeof value === "string") { return { kind: "string" }; } if (typeof value === "number") { return { kind: "number" }; } if (typeof value === "boolean") { return { kind: "boolean" }; } if (value === null) { return { kind: "null" }; } if (isEnumValue(value)) { return { kind: "enum" }; } if (Array.isArray(value)) { return { kind: "array" }; } // A plain object schema value or a `Result` — compact JSON (a `Result` // serialises through its `ok`/`value`/`error` shape, preserving the prior // non-crashing behaviour). return { kind: "object" }; } /** * Evaluate a pure (non-checkpointed) sub-expression against the environment. * The shipped test thetas' pure sub-expressions are literal / identifier reads; * an identifier that resolves to a local binding yields its value, any other * resolution arm (a bare `fn` / callable name, or an unresolved name) has no * first-class readable value and yields `null` (the expressions.md runtime * safety net) rather than throwing out of the executor. */ function evaluatePureExpression(expr: Expr, env: LexicalEnvironment): ThetaValue { switch (expr.kind) { case "number": return Number(expr.text); case "string": case "bool": return expr.value; case "null": return null; case "ident": { const resolution = env.resolve(expr.name); return resolution.arm === "local" ? resolution.value ?? null : null; } case "array": return expr.elements.map((element) => evaluatePureExpression(element, env)); case "object": { // An object-literal / schema-constructor value (expressions.md §"Object // construction"): the runtime value is the plain field object keyed by // theta-side names. When the constructor names a declared `schema`, brand // the value (non-enumerably, so no theta-visible surface changes) with that // schema name so the QRY-18 interpolation render path can recover the // schema and apply outbound wire-name translation recursively. const obj: Record = {}; for (const field of expr.fields) { obj[field.name] = evaluatePureExpression(field.value, env); } if (expr.typeName !== null && env.resolveSchema(expr.typeName) !== undefined) { return brandSchemaValue(obj, expr.typeName); } return obj; } case "member": { // `Enum.Variant` access: a member on an identifier that names a registered // enum (not a local binding) is a pure enum-value read, NOT a generic // member access on a null target (runtime-value-model.md, enum row). if (expr.target.kind === "ident" && env.resolve(expr.target.name).arm !== "local") { const variant = env.resolveEnumVariant(expr.target.name, expr.field); if (variant !== undefined) { return variant; } } // `.field` access — a `null` target raises `NullMemberAccessPanic` (V4b). return evaluateMemberAccess(evaluatePureExpression(expr.target, env), expr.field); } case "index": { // `[i]` access — a `null` target / out-of-bounds / missing key panics (V4b). const target = evaluatePureExpression(expr.target, env); const index = evaluatePureExpression(expr.index, env); return evaluateIndexAccess(target, typeof index === "number" ? index : String(index)); } case "call": { // A `(args)` call whose callee resolves to a user `fn` executes the // function body (functions.md FN-1…FN-5). In a pure sub-expression // position (a binary/ternary operand, an argument, a template // interpoland) the value is produced synchronously against a pure body; // an effectful `fn` body cannot run on the pure path and yields the inert // `null` safety net (its effects are driven only by the executor). A // non-`fn` callee (a Pi tool / `.theta`-callable) is an effect with no // synchronous value — also the `null` safety net. const resolution = env.resolve(expr.callee); const fn = (resolution.arm === "fn" || resolution.arm === "import") && resolution.fn !== undefined ? resolution.fn : undefined; return fn !== undefined ? evaluatePureFnCall(fn, expr, env) : null; } case "result-ctor": // `Ok(arg)` / `Err(arg)` — a pure Result construction (never a tool-call). return expr.ctor === "Ok" ? makeOk(evaluatePureExpression(expr.arg, env)) : makeErr(evaluatePureExpression(expr.arg, env)); case "method-call": { // `target.method(args)` — evaluate the receiver and arguments, then // dispatch to the stdlib member surface by the receiver's runtime type // (expressions.md §"Built-in methods and properties"). const receiver = evaluatePureExpression(expr.target, env); const args = expr.args.map((arg) => evaluatePureExpression(arg, env)); return evaluateStdlibMethod(receiver, expr.method, args); } case "binary": return evaluateBinaryExpression(expr.op, expr.left, expr.right, env); case "ternary": { // `cond ? a : b` — only the taken branch is evaluated (short-circuit). const condition = evaluatePureExpression(expr.condition, env); return condition === true ? evaluatePureExpression(expr.consequent, env) : evaluatePureExpression(expr.alternate, env); } default: // `try` / `match` / effect forms are driven by the executor (not the pure // host); a query / tool-call / invoke expression reaching here has no pure // value and yields the inert `null` (the expressions.md safety net). return null; } } /** * Evaluate a pure user `fn` call synchronously (functions.md FN-1…FN-5) for a * pure sub-expression position: validate arity (a mismatch is a defect surfaced * as `ThetaFnArityError`, shared with the executor's async path), evaluate each * argument in the caller scope, bind it as an immutable local in a fresh child * scope, then evaluate the `fn` body's pure statements + tail. The evaluator * covers the pure body forms (`let`, `if`/`else`, `return`, expression * statements, and the tail expression); an effect statement or a `while`/`for` * loop has no synchronous pure value and short-circuits to the `null` safety * net, matching the surrounding pure-evaluator convention. */ function evaluatePureFnCall(fn: FnDecl, expr: CallExpr, env: LexicalEnvironment): ThetaValue { if (expr.args.length !== fn.params.length) { throw new ThetaFnArityError(fn.name, fn.params.length, expr.args.length); } const scope = env.child(); fn.params.forEach((param, index) => { scope.defineLocal(param.name, evaluatePureExpression(expr.args[index] as Expr, env), false); }); return evaluatePureBlock(fn.body, scope).value; } /** The outcome of evaluating a pure block: a fallen-through value or an explicit `return`. */ type PureBlockOutcome = | { readonly kind: "value"; readonly value: ThetaValue } | { readonly kind: "return"; readonly value: ThetaValue }; /** * Evaluate a pure `fn` body `Block` synchronously: walk its statements, then * yield the tail expression's value (or `null` for a statement-terminated body). * An explicit `return` short-circuits the block to its operand (FN-3…FN-5). */ function evaluatePureBlock(block: Block, env: LexicalEnvironment): PureBlockOutcome { for (const stmt of block.statements) { const outcome = evaluatePureStatement(stmt, env); if (outcome.kind === "return") { return outcome; } } return { kind: "value", value: block.tail !== null ? evaluatePureExpression(block.tail, env) : null, }; } /** * Evaluate one pure statement of a `fn` body. `let` binds a local; `if`/`else` * takes the matching arm's block; `return` short-circuits; an expression * statement is evaluated for its (discarded) value. A form with no synchronous * pure value (an effect statement, a `while`/`for` loop, a reassignment against * a captured slot) falls through as a plain value — the pure evaluator does not * model the effect/loop control flow the async executor owns. */ function evaluatePureStatement(stmt: Stmt, env: LexicalEnvironment): PureBlockOutcome { switch (stmt.kind) { case "let": { const value = stmt.init !== null ? evaluatePureExpression(stmt.init, env) : null; env.defineLocal(stmt.name, value, stmt.mutable); return { kind: "value", value: null }; } case "return": return { kind: "return", value: stmt.operand !== null ? evaluatePureExpression(stmt.operand, env) : null, }; case "if": return evaluatePureIf(stmt, env); case "expr": return { kind: "value", value: evaluatePureExpression(stmt.expr, env) }; default: return { kind: "value", value: null }; } } /** Evaluate a pure statement-form `if` / `else if` / `else` chain. */ function evaluatePureIf( stmt: Extract, env: LexicalEnvironment, ): PureBlockOutcome { if (evaluatePureExpression(stmt.condition, env) === true) { return evaluatePureBlock(stmt.then, env.child()); } if (stmt.otherwise === null) { return { kind: "value", value: null }; } return "statements" in stmt.otherwise ? evaluatePureBlock(stmt.otherwise, env.child()) : evaluatePureIf(stmt.otherwise, env); } /** * Dispatch a `target.method(args)` stdlib member by the receiver's runtime type * (expressions.md §"Built-in methods and properties"), reusing the runtime * stdlib modules so `replace`'s `$`-literal insertion and the `valuesEqual` * structural equality of `includes` / `indexOf` match the reference semantics. * A receiver with no stdlib member surface (number / boolean / null) has no * theta-1.0 method and yields the inert `null` safety net rather than throwing * out of the executor. */ function evaluateStdlibMethod( receiver: ThetaValue, method: string, args: readonly ThetaValue[], ): ThetaValue { if (typeof receiver === "string") { return evaluateStringMember(receiver, method, args); } if (Array.isArray(receiver)) { return evaluateArrayMember(receiver, method, args); } if (typeof receiver === "object" && receiver !== null) { return evaluateObjectMember(receiver as { readonly [k: string]: ThetaValue }, method, args); } return null; } /** * Evaluate a pure binary / unary-modelled expression against the environment, * reusing the V2c structural-equality relation for `==` / `!=`. `&&` / `||` * short-circuit; arithmetic and ordering use native IEEE-754 semantics (no * div/mod-by-zero panic — expressions.md §"Other arithmetic"). Unary `!` / `-` * are modelled by the parser as a binary with a synthetic `null` left operand. */ function evaluateBinaryExpression( op: string, leftExpr: Expr, rightExpr: Expr, env: LexicalEnvironment, ): ThetaValue { if (op === "!") { return !(evaluatePureExpression(rightExpr, env) as boolean); } if (op === "-" && leftExpr.kind === "null") { return -(evaluatePureExpression(rightExpr, env) as number); } const left = evaluatePureExpression(leftExpr, env); if (op === "&&") { return left === true ? evaluatePureExpression(rightExpr, env) === true : false; } if (op === "||") { return left === true ? true : evaluatePureExpression(rightExpr, env) === true; } const right = evaluatePureExpression(rightExpr, env); switch (op) { case "==": return valuesEqual(left, right); case "!=": return !valuesEqual(left, right); case "+": return typeof left === "string" && typeof right === "string" ? left + right : (left as number) + (right as number); case "-": return (left as number) - (right as number); case "*": return (left as number) * (right as number); case "/": return (left as number) / (right as number); case "%": return (left as number) % (right as number); case "<": return (left as number | string) < (right as number | string); case "<=": return (left as number | string) <= (right as number | string); case ">": return (left as number | string) > (right as number | string); case ">=": return (left as number | string) >= (right as number | string); default: return null; } }