/** * The composition seam that turns a `Harness` into a served turn. * * `packages/vendo/src/harnesses/runtime.ts` owns the runtime — building the * `Turn`, mirroring tool calls, persisting, running the injected workspace wrap * that emits hot-path views. What it deliberately does NOT own is anything * that needs a `RunContext`, because a harness is permission-blind by * contract (§1). That leaves exactly this file's job: resolve the per-turn things * from the request's principal — the thread, the workspace, the `/host` * projection, the system prompt, the descriptor catalog — and hand the runtime a * `TurnRunInput`. * * It decides nothing about how to think. Every value below is a façade or a gate. */ import { type FilesAdapter, type Harness, type Membership, type Skill, type Principal, type ResolvedModels, type RunContext, type StoreOps, type ThreadId, type ToolRegistry, type WorkspaceFs, type UploadedFile } from "./core/index.js"; import { type NormalizedCatalog } from "./core/apps/index.js"; import { type Thread, type ThreadSummary } from "./threads.js"; import { type RenderSeamOptions } from "./apps/index.js"; import type { VendoGuard } from "./guard/index.js"; import { type VendoStore } from "./store/index.js"; import { type CapabilityMissConfig, type ToolDoorPort, type HarnessRuntimeDeps } from "./harnesses/index.js"; import type { VendoToolSearchConfig } from "./harnesses/vendo/index.js"; import { type LanguageModel, type UIMessage } from "ai"; import type { Limiter } from "./limits.js"; export interface HarnessTurnsConfig { /** The resolved harness. Composition (server.ts) resolves the default — * `vendo()` with its tool-search strategy — so there is exactly ONE * construction and the gate-checked value IS the served value. */ harness: Harness; store: VendoStore; /** THE deployment's files adapter (`selectStore`), so workspace blobs are * written where the erase cascade will look for them. */ files: FilesAdapter; /** THE deployment's named-operation surface (`selectStoreOps`), when it has * one. The delete cascade is its one caller here: `transcripts.deleteThread` * is a single transaction over three tables, and the row-at-a-time route it * replaces could only ever delete the first of them. */ ops?: StoreOps; guard: VendoGuard; /** The composed sandbox adapter (`selectSandbox`). A harness declaring * `requires: { sandbox: true }` — `claudeCode()` — is constructed by the HOST * at boot, where no composition exists, so composition fills its slot here * instead. Unset, such a harness must be handed one directly * (`claudeCode({ sandbox })`), and the boot gate refuses if neither happened. */ sandbox?: unknown; /** The guard-bound registry — the one choke point, already carrying the * connect gate and unique-title assertion. */ tools: ToolRegistry; /** Every merged skill, projected into the read-only `/host/skills` mount. */ skills: readonly Skill[]; /** The resolved component catalog — the SAME normalized value the prompt * summary is built from — projected into `/host/components` as one reference * file per entry. Unset ⇒ no component reference on the mount. */ catalog?: NormalizedCatalog; models: ResolvedModels; /** The venue-gated, guard-directions-carrying system prompt. Assembled per * turn by composition because it needs the ctx a `Turn` does not carry. * * `discovery` names which rail THIS turn's harness actually has, so the prompt * never teaches a tool that is not on the listing: an uncurated surface * (`toolSurface.curated === false`) has no `find_tools`, only the connector * pair — and `false` when it has neither. */ system: (ctx: RunContext, opts?: { discovery?: "find-tools" | "connectors" | false; }) => Promise; /** vendo()'s tool-search strategy — the loadout cap and the `find_tools` hand. * Composition passes it to the DEFAULT harness at construction * (compose-harness.ts); this copy fills the composed adapter slot so a * HOST-constructed `vendo()` gets the same strategy, like `claudeCode()`'s * sandbox. Unset → no search and every projected tool offered. */ toolSearch?: VendoToolSearchConfig; /** The shipped capability-miss rail. Load-bearing for evaluation E1's fifth ask: * an impossible request must produce an honest refusal, not an invention. */ capabilityMiss?: CapabilityMissConfig; /** Is the `find_service_tools` / `use_service_tool` pair projected at all? Only * when a configured connector can actually search and dispatch the broker's * catalog (server.ts gates the registry add on that) — otherwise an uncurated * surface, which has no `find_tools` either, would be taught two tools that are * not on its listing. */ connectorDiscovery?: boolean; /** The render seam's halves composition owns, per turn — like `bridge` below, * and for the same reason: the floor runs the screen's queries as the CALLER, * so it needs this turn's ctx. Wired into the runtime's generic * `wrapWorkspace` slot below — the runtime itself no longer knows the seam. */ render?: (ctx: RunContext) => Omit; /** The shipped tool-bridge rails composition owns, per turn (`toolOutputCap`, * the connect `preflight`, the capability-miss `onCall`). */ bridge?: (ctx: RunContext, threadId: ThreadId) => HarnessRuntimeDeps["bridge"]; /** The deployment-wide approval wait. Unset uses the frozen * APPROVAL_WAIT_MS; a single turn may override it (`stream`). */ approvalWaitMs?: number; /** Build contract §9.1 — the host's own org query, keyed on the Principal so * the workspace door can resolve it with no request in hand. It decides the * turn's `/orgs` mount set (§9.7); unset ⇒ no org mounts, exactly today's * single-player façade. */ memberships?: (principal: Principal) => Promise; /** Publish each turn in flight to the process's own doors — the MCP door's * turn credential (10-mcp §3b) is the one consumer. Composition owns the * registry because it is the only place that holds both ends. */ liveTurn?: HarnessRuntimeDeps["liveTurn"]; /** The host's own MCP door, for a harness whose thinker runs on a MACHINE and * therefore reaches `turn.tools` over the wire rather than in process. */ toolDoor?: ToolDoorPort; /** The host's `limits` policy, bound to the meter (limits.ts). Unset — the * host set no policy — and the turn below costs one undefined check. */ limiter?: Limiter; } export type { UploadedFile }; /** * Composition's word that this turn's opening WRITE needs nothing from its * opening read — which is what lets the two go out together instead of one * behind the other. * * It asserts two things at once, and both have to hold: the message was authored * by THIS process rather than posted by a client (so `validateUpsert` has no * history to protect), and `threadId` came off a row that only carries one * because a turn already ran on it (so the thread exists and already holds the * title the append would otherwise have to derive from the read). * * A SYMBOL, and not exported from the package, because those two claims can only * be made by code that watched them become true. `JSON.parse` cannot produce a * symbol key, so no request body can carry it however a door is later written, * and no host can reach it. The one caller is `runChannelTurn`. */ export declare const SERVER_AUTHORED: unique symbol; export interface HarnessTurns { /** One turn. Mirrors `VendoAgent.stream`'s signature so the wire route reads * the same either way — including the `x-vendo-thread-id` response header. */ stream(input: { threadId?: string; message: UIMessage; ctx: RunContext; signal?: AbortSignal; /** How long an interactive approval may block THIS turn. Unset keeps the * frozen APPROVAL_WAIT_MS (a web tab's bound); a turn served over a * channel where the person answers on a human clock passes its own. */ approvalWaitMs?: number; readonly [SERVER_AUTHORED]?: true; }): Promise; /** Prompt-cache warming (sub-1s shipment): ONE degenerate turn through the * normal assembly — same registry projection, same system prompt, same * initial loadout — so the provider writes its prefix cache before the * user's first real message would otherwise write it cold. Byte-identical * by construction: the code that builds the warm call IS the code that * builds a real turn. Nothing persists — the runtime is handed throwaway * in-memory doors — and the turn is capped at one step and one output * token, which can never complete a tool call, so nothing executes and * the guard never fires. */ warm(input: { ctx: RunContext; signal?: AbortSignal; }): Promise; /** The workspace as one principal sees it this turn. Exposed for the host and * for the history door; `open` builds a fresh path index per call. * The `/orgs` mounts (§9.7) come from the host's memberships seam, resolved * here — a caller may override with `memberships` when it already has them. */ workspace(principal: Principal, opts?: { host?: Record; memberships?: Membership[]; }): Promise; /** D4 — the thread LIFECYCLE, on the door that serves the turns. The same * `ThreadRepository` this door already resolves every turn through, so the * listing, the read and the delete a client sees are the ones the turn wrote. * Unlike `stream`, this needs no SQL: the repository is adapter-only, so these * work on a hosted store too. */ threads: { get(id: ThreadId, ctx: RunContext): Promise; list(ctx: RunContext): Promise; delete(id: ThreadId, ctx: RunContext): Promise; }; /** Put a file in one user's drawer — THE server-side write, shared by the * upload door (`POST /files`) and by `vendo.putUserFile`, so a file pushed * from host code is indistinguishable from one the user dropped in chat. * * Same name as an existing file REPLACES it: `/user` is last-write-wins * (build contract §3.2), which is what makes "here is the newer export" work * without the user naming files v2, v3, v4. * * The door's 5 MiB cap is the DOOR's, not this write's — a trusted caller is * bounded by whatever backs the `files:` adapter (unset: the store's blobs, * up to FILES_STORE_MAX_BYTES). `contentType` is advisory: the drawer stores * bytes, and what the file IS travels with its name's extension. */ putUserFile(input: { principal: Principal; name: string; content: Uint8Array | string; contentType?: string; }): Promise; /** The CHAT drop's landing pad. A dropped file is not a saved one — it belongs * to the conversation that is about to receive it, and the turn re-homes it * there. Until then it lives in staging under an address only the re-homer * and its sweep read. */ stageUpload(input: { principal: Principal; name: string; content: Uint8Array | string; contentType?: string; }): Promise; /** @internal The one write both file doors share. */ writeUserBytes(principal: Principal, path: string, content: Uint8Array | string): Promise; /** D6 — drop every thread a subject owns. */ evictSubject(subject: string): Promise; } export declare function createHarnessTurns(config: HarnessTurnsConfig): HarnessTurns;