UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

493 lines (492 loc) • 37.3 kB
import type { AgentTool } from "../internal/harness-types.js"; /** * Pluggable backing store for **offloaded large tool results** (design/30). When a tool returns more * text than the threshold, the full content is moved here and the conversation keeps only a preview + * a `ref`; the model fetches the rest via the injected `read_tool_result` tool. This is the local, * provider-agnostic equivalent of CC's `toolResultStorage` (no Anthropic `cache_edits` dependency). * * Default is {@link InMemoryToolResultStore} (task-scoped, lives only for the run). Inject a durable * implementation (TiDB/blob, keyed by the opaque `ref`) so an offloaded result survives wake/resume; * with the in-memory default, a fetch after a cross-process wake returns undefined and the model is told * the output is no longer available — the preview still stands in context, so it degrades, never crashes. */ export interface ToolResultStore { /** * Managed-retention declaration (ruled 2026-08-05, retention.ts): `"managed"` promises the * `ManagedRetentionCapability` contract (scheduled, tenant-scoped, audited deletion); `"none"` (or * ABSENT — read fail-closed as `"none"`) states the store cannot delete on schedule. A deployment * with a LOCKED retention policy refuses to start over a `"none"` store. Declaration, never * duck-typing. */ readonly retention?: import("./retention.js").RetentionDeclaration; /** * Persist full content under a stable `ref`. **Write-once and idempotent**: if `ref` already exists, * keep the existing content and do nothing (the aggregate budget re-runs every query with the same * deterministic ref, so `put` for an already-stored ref MUST be a no-op — re-writing would risk a * non-identical preview and break the prompt cache). A durable backend treats `ref` as the primary key * and should validate/sanitize it (it is composed from sessionId+toolCallId, not raw model input, but a * durable store still owns key hygiene for its storage layer). The floor of that hygiene is * NOT per-backend — every implementation rejects the unsafe ref set via {@link assertSafeToolResultRef} * (a volatile backend silently accepting what a durable one rejects is the drift this closes). * * `provenance` (backlog #119, 2026-08-10) states WHO the stored bytes belong to, so a host-side read * face can authorize a fetch without parsing the ref. Its rules, uniform on every backend: * - **the write-once election decides the owner too** — whoever wins the race to store the content * is the owner, and no later writer can change that. A backend that publishes content and owner * as two objects (the file one does) may, if it is interrupted between them, leave an entry * content-complete and UNOWNED; that is the fail-closed direction (a read face refuses it) and it * is never mis-attribution, but it does mean "unowned" is a reachable state, not only a * pre-provenance one. A single-row backend (memory/SQL) has no such window; * - a later `put` on the same ref carrying the **same** provenance (or none) is the usual idempotent * no-op — the stored owner is neither re-written nor erased; * - a later `put` carrying a **different** provenance is a typed refusal * ({@link ToolResultRefConflictError}, `code: "tool_result.ref_conflict"`), never a silent no-op: * `ref` is the storage PRIMARY KEY, and with a host-supplied `sessionId` two distinct owners can * land on one ref — under a silent write-once the second owner would read back the first owner's * bytes, which is an identity fault, not a caching detail; * - provenance must never be FINER-GRAINED than the ref's own key. A ref keyed on * (session, tool call) is legitimately written by two different tasks of one session — the * Runner shares one store across its tasks on purpose, and a BYOM brain may mint the same * tool-call id in both — so stamping a `taskId` there would turn a designed sharing case into a * refusal. Every engine write site therefore states the session only (#167) — including the * background-agent and monitor spills, whose ref DOES carry the registry handle id: that id is * an addressing segment of the ref, not an ownership coordinate a read-face caller can present * (see {@link ToolResultProvenance.taskId} for the namespace rule); * - an entry stored **without** provenance (written by an older engine, or by a call site that has * none) stays UNOWNED: `put` never back-fills an owner onto it (that would be adoption by a party * that cannot prove it wrote it) and {@link ownerOf} keeps answering `undefined`, which a read face * reads fail-closed. Back-filling such rows is an explicit migration, never a side effect of a write. */ put(ref: string, content: string, provenance?: ToolResultProvenance): Promise<void> | void; /** Read a slice of the stored content. Unknown `ref` → undefined. */ get(ref: string, opts?: { offset?: number; limit?: number; }): Promise<ToolResultSlice | undefined> | ToolResultSlice | undefined; /** * Backlog #119 — the READ-side half of provenance: who owns `ref`. `undefined` for an unknown ref AND * for a stored-but-unowned one (a row whose write stated no owner), which a read face treats identically: * fail-closed, nobody is authorized. Without this operation a `put`-only provenance parameter would be * write-only bookkeeping — the read face would still have nothing to decide on. * * Typed OPTIONAL so an implementation compiled against an older engine still satisfies the interface; * the published contract kit (`toolResultStoreContract`) nevertheless REQUIRES it, because a backend * that cannot answer this cannot back a host read face at all. */ ownerOf?(ref: string): Promise<ToolResultProvenance | undefined> | ToolResultProvenance | undefined; /** * Erase every entry this store recorded as belonging to `sessionId` — the DELETE half of the same * ownership coordinate {@link ownerOf} answers on. Without it a durable backend keeps a deleted * session's offloaded and spilled results on its media indefinitely: `put`/`get` alone give a * deployment no way to complete a session deletion, and the engine ships no scheduler that would * eventually collect them. * * Rules every implementation follows: * - **selection is by RECORDED PROVENANCE, never by parsing the ref.** A ref is an opaque handle: * its session segment may be folded ({@link buildToolResultRef}), refs minted before the * injective form decompose two ways (`tr_team_blue_x` is both ("team","blue_x") and * ("team_blue","x")), and a caller may mint its own. Prefix-matching a ref therefore both * misses rows and reaches rows of a NEIGHBOURING session, and over-deletion here destroys a * live session's readable bytes. The owner recorded at the winning write is the one coordinate * that means exactly one thing, and it is the same one a read face authorizes against; * - **`taskId` is ignored in the match.** A stored `{sessionId, taskId}` is a NARROWING of the * same session, not a different owner — write sites stamped one before the namespace rule * settled the shape (#167), and such rows still belong to the session being deleted; * - **an UNOWNED entry is never deleted.** A row whose write stated no provenance (an older * engine, a call site that has none, or a two-object backend interrupted between publishing * content and owner) belongs to no session this sweep can name, and it is still READABLE via * `get` — deleting it on a guess would destroy another session's output. It is counted instead * ({@link ToolResultDeletionReport.unattributable}) so the caller learns the deletion was * incomplete rather than being told a clean "done"; * - **idempotent**: deleting a session with no entries is a no-op, and a repeat call after a * partial (loudly failed) sweep resumes it; * - **concurrency-tolerant**: an entry that disappears between enumeration and removal is honest * absence, not an error. * * Typed OPTIONAL, and an OPTIONAL leg of the published contract kit (`toolResultStoreContract`, * its #196 case): unlike `ownerOf`, a backend that cannot enumerate by owner is still a usable * offload store, so absence is never a contract breach — the kit verifies the four-state * semantics when the member is present and reports absence through its `onOptionalMember` * callback (a checkable fact, not a failure). Present ⇒ the store can complete a session * deletion; absent ⇒ the deployment owns that gap. * * Implementing it does NOT make a store `retention: "managed"` — that declaration promises the whole * {@link import("./retention.js").ManagedRetentionCapability} (domain enumeration, tombstones, * audit receipts, scheduled execution), of which this is one caller-driven erase. */ deleteBySession?(sessionId: string): Promise<ToolResultDeletionReport> | ToolResultDeletionReport; } /** * What a {@link ToolResultStore.deleteBySession} sweep actually did. A count, not a `void`, because the * sweep has a documented INCOMPLETE outcome (unowned entries it must not delete) and a caller running a * session-deletion face has to be able to tell "the session's results are gone" from "some rows survive * that no session can clear". */ export interface ToolResultDeletionReport { /** Entries removed by THIS call. A retry of an already-swept session reports `0`, not an error. */ readonly deleted: number; /** * Entries the sweep could not attribute to ANY session and therefore left in place: stored without * provenance, or with an owner record that is present but unreadable. Store-wide, not * session-specific — an unowned row may or may not have been this session's, which is precisely why * it cannot be deleted — so the same figure recurs on every session's sweep until a migration or an * operator clears those rows. */ readonly unattributable: number; } /** * Backlog #119 — WHO a stored tool result belongs to. Structured (not a single opaque string) because * the read face authorizes by COMPARING fields it already knows; `taskId` is present when the write site * has one (it is a narrowing of the session, never a substitute for it). * * A stamped `taskId` must name a coordinate the read face's CALLER presents — * the host's own task/run id — never an engine-internal registry handle id. A handle id lives in a * namespace only the engine's in-memory registry can resolve, so an owner stamped with one compares * unequal to every caller coordinate forever: the entry becomes readable by NOBODY across the wire * (fail-closed, but pointlessly — the disclosure text tells the caller to read it back). The engine's * own write sites all state the session only; the handle id, where a ref needs it, is an ADDRESSING * segment of the ref, not part of the owner. * * Deliberately NOT part of it: `toolCallId`. It is a retention/addressing coordinate, not an ownership * one — two different tool calls in one session are the same owner. */ export interface ToolResultProvenance { readonly sessionId: string; readonly taskId?: string; } /** Backlog #119 — the `code` on {@link ToolResultRefConflictError}, so callers branch on a constant. */ export declare const TOOL_RESULT_REF_CONFLICT_CODE = "tool_result.ref_conflict"; /** * Backlog #119 — a `put` whose provenance disagrees with the stored one. Typed (a `code`, like * `CheckpointError`/`WorkflowRunStoreError`) rather than a silent no-op: this is the one write-once * outcome where "keep what is there" would hand the second caller a reference to somebody else's bytes. */ export declare class ToolResultRefConflictError extends Error { readonly ref: string; readonly stored: ToolResultProvenance; readonly incoming: ToolResultProvenance; readonly code = "tool_result.ref_conflict"; constructor(ref: string, stored: ToolResultProvenance, incoming: ToolResultProvenance); } /** * Backlog #119 — the ONE comparison every backend uses, so "same owner" cannot mean two things in two * stores. A missing PROVENANCE on either side ⇒ no verdict (an unowned entry is never adopted, and a * put with no provenance never disturbs a stored owner). Both present ⇒ every field must agree, * including `taskId`: a missing task id is a DIFFERENT owner from a stated one, not a wildcard that * matches it — two writers that disagree about how narrowly they own a key have not proven they are * the same writer. Unequal ⇒ {@link ToolResultRefConflictError}. */ export declare function assertToolResultProvenanceMatch(ref: string, stored: ToolResultProvenance | undefined, incoming: ToolResultProvenance | undefined): void; /** Normalize to the stored shape: an ABSENT `taskId` and an explicit `undefined` one are one value, so * round-tripping through a durable backend (NULL column, missing JSON key) compares equal. */ export declare function normalizeToolResultProvenance(p: ToolResultProvenance): ToolResultProvenance; /** * The ref-safety half of the {@link ToolResultStore} contract, shared by every backend. * * Why it is central and not per-backend: the file backend has always rejected these refs (its path * component guard, `stores/file/fs-atomic.ts:48`), while the in-memory backend accepted them * silently — the SAME ref stored fine in dev and threw in a durable deployment. That is the * "backend outside the shared contract drifts on its own" mechanism (same family as RB-86/MB1 on * MailboxStore), and the honest direction is the strict one: a ref that no durable backend can key * is a defect at the WRITE, not something a volatile backend should quietly absorb. * * The rejected set is deliberately the intersection-unsafe one — empty / `.` / `..` / path * separators / control characters — i.e. refs that are unusable as a key ANYWHERE (they traverse in * a path-backed store and are ambiguous in every other). Backends may still be stricter about their * own storage layer (the file backend additionally enforces a filename charset); that extra * strictness is a backend capability, not part of this contract. * * Read face stays lenient by design: `get` with such a ref returns `undefined` (an unknown ref), it * does NOT throw — the model is told the output is gone and the run degrades instead of crashing. * * RB-273 amends one clause of the above: a backend may be stricter about its own STORAGE LAYER, but not * about which refs it accepts. A ref this function accepts must round-trip on every backend — a backend * whose native key space is narrower (the file one's filename charset) owns an injective ENCODING into * that space, not a rejection. Otherwise the same "dev green / deployment red" split this rule closed * for `..` simply reappears one charset over (it did: a `:` in a provider-minted toolCallId). */ export declare function assertSafeToolResultRef(ref: string): void; /** * The SINGLE mint point for a tool-result `ref`. Three call sites compose the same * `tr_<sessionId>~<toolCallId>` string (`withToolResultOffload` here, the aggregate budget's preview * pass, and the runner's clear-with-offload persist); this is that string's one definition. * * Why it must sanitize: `sessionId` is engine-minted, but **`toolCallId` is PROVIDER-minted** — it is * whatever the model gateway put in the tool-call frame, not something core controls. Providers ship * ids with `:` in them, and nothing stops one shipping a `/`. Composed raw, that produced two distinct * failures, both far from their cause: * - `/` or a control char ⇒ the ref violates {@link assertSafeToolResultRef} ⇒ `put` THROWS at mint, * i.e. an offload (a context-economy optimization) turns a working tool call into a failed one; * - `:` ⇒ accepted by memory/pg, rejected by the file backend's filename charset ⇒ the classic * dev-green/deployment-red split (`probe-r5-store-parity-matrix`'s last two divergent cells). * * The mapping is IDENTITY for every id already in the native charset — which is all of them in practice * (`toolu_…`, `call_…`, uuids) — so refs minted before this existed are byte-identical afterwards and * the prompt-cache stability the preview rests on is untouched. Only an out-of-charset (or absurdly * long) segment is folded, and then INJECTIVELY: readable base + a sha256 of the RAW segment, the same * construction (and the same reason: two distinct ids must never collide onto one ref, which would * cross-serve one tool's output as another's) as `sanitizeScope` in the file store. * * Read-back of refs minted BEFORE this change is unaffected in both directions: safe ids are unchanged, * and an unsafe one only ever lived in a memory/pg deployment, where the ref the model reads back comes * from the `<persisted-output ref="…">` text already in its context — the read path does not re-mint, * and `get` is keyed on that literal string. * * ── Backlog #119 (2026-08-10): the composition is now INJECTIVE ─────────────────────────────────── * It was not. With `_` between the segments — a character legal INSIDE both — `tr_team_blue_x` * decomposed as ("team","blue_x") and as ("team_blue","x"), i.e. two distinct (session, tool call) * pairs minted one ref. `ref` is the store's PRIMARY KEY under a write-once contract, so the second * pair did not get an error, it got the FIRST pair's bytes back — and `sessionId` is host-supplied in a * BYOM deployment, so the two pairs need not be the same tenant. (Measured on the published artifact: * ("s","1_a_b") and ("s_1","a_b") produced the same ref.) * * The separator is now {@link REF_SEGMENT_SEPARATOR} — a character OUTSIDE {@link NATIVE_REF_CHARSET}. * `refSegment` emits only characters from that charset (identity branch by test; fold branch by * construction: a readable base with every out-of-charset char replaced, plus hex), so the separator * can never occur inside a segment and `tr_<seg>~<seg>` decomposes exactly one way. Injectivity of the * whole map then follows from `refSegment`'s own injectivity per segment. * * Why this is NOT a breaking change, spelled out because it looks like one: * - refs are OPAQUE HANDLES. A ref reaches the model inside the preview text and comes back verbatim * to `ReadToolResult`; `get` is keyed on that literal string and no read path re-mints; * - so an entry written under the old form stays readable forever: an old ref in an old context reads * back byte-for-byte, on every backend (the file backend's filename encoding is likewise keyed on * the literal ref, and is identity for the old form); * - the mint is deterministic per process, so a ref MINTED here is also RESOLVED here — there is no * window in which one process writes `~` and another expects `_` for the same pair. The upgrade * boundary is a process restart, and it needs no double-read window; * - the only observable change is the ref STRING for newly offloaded results, which no wire contract * pins (it is generated text, and the length ceiling downstream validators apply is unchanged). * The store-key charset is unchanged as far as the shared rule is concerned ({@link assertSafeToolResultRef} * accepts `~`: it is not empty/dot/separator/control). A backend whose NATIVE key space excludes `~` * (the file one's filename charset) already owns an injective encoding into it — the RB-273 rule — so * for that backend this simply moves engine refs from the identity branch to the digest branch. * * `extraSegments` (also #119) is how a call site that addresses something FINER than one tool call — * a `details` member's JSON path, a stale-projection content digest — keeps that coordinate in its own * segment instead of splicing it into the `toolCallId` one. Splicing was the ticket's other residue: * a ref built from the compound `"<callId> <path>"` is exactly the ref another call would mint if its * provider-assigned toolCallId happened to BE that whole string. With the coordinate in its own * segment the two can no longer coincide — the separator cannot occur inside a segment, so refs with * different segment COUNTS are always distinct, and same-count refs are distinct unless every segment * matches. */ export declare function buildToolResultRef(sessionId: string, toolCallId: string, ...extraSegments: string[]): string; /** * Backlog #119 — the ceiling on a ref this module can mint, so a durable backend can size its key * column from a stated invariant instead of a guess (TiDB's was `VARCHAR(191)`, which a long * three-segment ref overruns — a truncating key aliases distinct refs). * * Derivation: `"tr_"` (3) + four segments at their own ceiling + three separators. A segment is either * an identity one (≤ {@link MAX_REF_SEGMENT_CHARS}) or a folded one (marker + ≤32 base + `-` + 64 hex * = 102), so {@link MAX_REF_SEGMENT_CHARS} is the larger. Four segments is the engine's own widest * shape (session + tool call + a `details` path + the content coordinate); `buildToolResultRef` is * variadic, so a caller that passes more owns the wider bound. */ export declare const MAX_MINTED_TOOL_RESULT_REF_CHARS: number; /** * Backlog #119 (falsification review, round 2) — the CONTENT coordinate of an offload ref. * * A tool-call id carries no cross-turn uniqueness contract: a BYOM brain legitimately mints recurring * ids like `call-1`, and the Runner shares one store across a session's tasks on purpose. Keyed on * (session, tool call) alone, the second result under a recurring id hits a write-once no-op — a * SUCCESSFUL put that stored nothing — while its own preview goes out naming that ref, so paging it * back returns the FIRST result's bytes. Provenance cannot see this (both writers are the same * session, and they legitimately are), so the ref itself has to carry content identity. * * 128 bits, the same width and the same reasoning as the stale-result projection that closed this in * its own lane earlier: tool results are untrusted text (web content rides them), and a 48-bit * truncation is birthday-collidable at ~2^24 work — a deliberate collision plus a recurring id would * re-open exactly the cross-serving this closes. Deterministic, so a re-derived ref for identical * content is byte-identical and the preview stays prompt-cache stable. */ export declare function toolResultContentSegment(text: string): string; export interface ToolResultSlice { content: string; /** Byte/char offset this slice starts at. */ offset: number; /** Total chars of the full stored content. */ totalChars: number; } /** Default in-memory store (no cross-process durability); write-once per ref. * * Field-reported (`ReadToolResult(ref)` came back empty for a workflow child's * offloaded output): the Runner now shares ONE instance across its tasks (instead of a per-task * island), so a parent can deref a child's ref within the process. A shared long-lived instance * needs a bound — `maxTotalChars` FIFO-evicts the oldest refs past the cap (an evicted deref lands * on the existing honest "may have expired" text). Durability across restarts still requires a real * store (e.g. FileToolResultStore) via `RunnerDeps.toolResultStore`. */ export declare class InMemoryToolResultStore implements ToolResultStore { private readonly opts?; /** put/get only — no scheduled deletion contract: honestly `"none"` (retention.ts). */ readonly retention: "none"; /** The entry is content AND owner as ONE value — the write-once election that decides the * content decides the owner in the same step (no window where an entry exists ownerless). */ private readonly map; private totalChars; constructor(opts?: { maxTotalChars?: number; } | undefined); put(ref: string, content: string, provenance?: ToolResultProvenance): void; /** The owner recorded at the winning write; `undefined` for unknown AND for unowned entries, * which a read face treats identically (fail-closed). */ ownerOf(ref: string): ToolResultProvenance | undefined; /** Erase this session's entries, selected on the owner recorded at the winning write (the interface * states the rules). Implemented here as well as on the durable backends so the bundled pair does * not answer the same operation two ways: the entry holds content and owner as ONE value, so the * same provenance match applies with none of the file backend's two-object bookkeeping. Deleting * from a Map while iterating it is defined (a removed key is simply not revisited). */ deleteBySession(sessionId: string): ToolResultDeletionReport; /** design/80 D-2: true when NOTHING has been offloaded — a durable suspend can then proceed safely even on * this in-memory store, because a cross-replica resume has no offloaded result to deref to null. */ isEmpty(): boolean; get(ref: string, opts?: { offset?: number; limit?: number; }): ToolResultSlice | undefined; } /** Marker subclass for the Runner's own shared fallback instance (no injected store). prepare-task * detects it and wraps a per-task {@link ScopedToolResultStore} around it — an INJECTED store is * never wrapped (a multi-tenant deployment that injects a durable store owns its own scoping). */ export declare class RunnerSharedToolResultStore extends InMemoryToolResultStore { } /** * Per-task view over the Runner-shared fallback (impl-review 2026-07-03): * 1. **Tenant isolation**: refs are namespaced by the task's trust scope (principal), so tenant B's * `ReadToolResult` can never deref tenant A's ref — the per-task-instance era made this isolation * implicit; sharing one instance re-opened it. Same-scope cross-task deref (the A2 fix's whole * point) still works: same scope ⇒ same namespace. * 2. **D-2 durable-suspend gate fidelity**: `isEmpty()` reflects only THIS task's offloads (local put * count) — matching the per-task-instance semantics the gate was written against — so an unrelated * prior task's offloaded entry can no longer disable durable suspend for a task that offloaded * nothing (prepare-task's fail-fast reads `isEmpty()`). */ export declare class ScopedToolResultStore implements ToolResultStore { private readonly inner; private readonly scope; private localPuts; /** True when the underlying shared store is process-memory (volatile across replicas) — the D-2 * suspend gate treats a NON-empty volatile store as unsafe to durably suspend. */ readonly volatileBacking: boolean; constructor(inner: ToolResultStore, scope: string); /** Length-prefixed namespace — unambiguous for any scope string (no delimiter-injection ambiguity). * The scope segment is ENCODED (injective, and its output charset carries no path separator * or control character), so a deployment whose trust scope contains one cannot compose a key the * inner store must reject — the ref-safety rule then applies to the caller's ref alone. */ private key; put(ref: string, content: string, provenance?: ToolResultProvenance): Promise<void> | void; get(ref: string, opts?: { offset?: number; limit?: number; }): Promise<ToolResultSlice | undefined> | ToolResultSlice | undefined; /** Same namespacing as {@link get}. `undefined` when the inner store predates the operation * (an unowned answer, which the read face reads fail-closed — never a fabricated owner). */ ownerOf(ref: string): Promise<ToolResultProvenance | undefined> | ToolResultProvenance | undefined; /** THIS task's offload count only — the D-2 gate's per-task semantics (see class doc). */ isEmpty(): boolean; } /** True when a durable suspend would lose offloaded results held only in process memory (D-2 gate). */ export declare function isVolatileOffloadStore(store: ToolResultStore): boolean; /** Reserved name of the injected large-result reader (design/108 PascalCase: `read_tool_result`→`ReadToolResult`). */ export declare const OFFLOAD_TOOL_NAME = "ReadToolResult"; /** * The runner's clear-with-offload persist, as ONE construction point (the closure used to * live inline in prepare-task). Fire-and-forget by design (the caller returns the ref synchronously). * * A failed put REPORTS the loss, it never writes under the ref. The previous arm * wrote a lost-marker row under the SAME ref "so the page-back face reports the true cause", and that * one write carried two defects: * - **revival**: the put and a session purge can race. When the put loses, the marker write lands * AFTER the purge — re-inserting a row into a session the deployment just deleted, alive until the * backend's own TTL. A failure arm must not be able to out-write a deletion; * - **retry contamination**: the per-request re-clear legitimately re-invokes this persist with the same * (toolCallId, fullText) — the write-once retry path. With a marker occupying the ref, that * retry's put of the REAL bytes hit the write-once no-op (same owner ⇒ keep what is there), so a * transient first failure served the marker forever even though the full text was still in hand. * Now the failure arm writes NOTHING (a later persist call retries the real bytes against an empty * ref) and announces the failed write loudly instead: through the deployment's structured notice sink * when one is wired, else `console.warn` (the loud-bad-value announcement dialect — a silent catch * here would be a silent data loss). The announcement claims only what this arm can know: THIS * attempt stored nothing — the same (toolCallId, fullText) persist recurs per request (write-once * idempotent re-put), so an EARLIER attempt may already have landed the row and a rejected re-put is * then no loss at all; declaring "the ref is empty" here would fabricate a data-loss incident on a * transient outage. Page-back of a ref no attempt ever landed serves the generic miss, which is * honest: nothing is stored under it. */ export declare function createOffloadPersist(store: ToolResultStore, sessionId: string, /** Structured sink for the loss announcement, delivered through the shared guarded form * (`deliverEngineNotice`, #170): a wired FUNCTION seat REPLACES the console line, swallow-guarded * (a throwing/rejecting sink never turns an announcement into a failure); a present NON-function * seat falls back to `console.warn` plus the once-per-process seat-defect announcement; absent ⇒ * `console.warn`. */ onNotice?: (notice: import("./types.js").EngineNotice) => void): (toolCallId: string, fullText: string) => string; /** Backlog #119 — build the provenance a write site records, from the two coordinates every write site * already has in hand. `taskId` is omitted (not `undefined`-valued) when the run declares none, so the * stored shape compares equal across a durable round-trip. */ export declare function toolResultProvenanceOf(sessionId: string, taskId?: string): ToolResultProvenance; /** * RB-374① — the ONE source of the "page the offloaded text back" suggestion sentence, shared by * {@link buildPreview} (offload preview tail) and context-edit's cleared-marker refNote. The two * used to hard-code the same advice in independently drifting wordings, and neither knew whether * the reader tool is even CALLABLE in the current session: with {@link OFFLOAD_TOOL_NAME} deferred * (a design/36 placeholder), the suggestion was a self-referential dead end — the model follows it * and the placeholder rejects with "not active yet". * * `reachableTools` is OPTIONAL: omitted ⇒ byte-identical to the historic sentences (the * determinism/prompt-cache contract of both call sites rests on this default — notably the * per-request aggregate-budget pass, which re-derives previews every query and therefore must * never receive a session-varying set). Passed and missing {@link OFFLOAD_TOOL_NAME} ⇒ the honest * two-step form (activate via ToolSearch first — wording kept close to tool-disclosure.ts's * placeholder hint). The `core` fragment below is deliberately shared by both forms so the two * faces cannot drift apart again (a wording change moves both, and both pins move with it). */ export declare function offloadPagebackHint(ref: string, form: "preview" | "cleared", reachableTools?: ReadonlySet<string>): string; /** Leading marker of an offload preview (see {@link buildPreview}) — single source so the aggregate * budget (design/64 §17.2) can recognize an already-offloaded result without re-deriving the string. */ export declare const PERSISTED_OUTPUT_PREFIX = "<persisted-output ref="; /** Default per-result offload threshold (chars). ~5K tokens; configurable globally or per tool. */ export declare const DEFAULT_TOOL_RESULT_THRESHOLD_CHARS = 20000; /** Head+tail preview of an offloaded result, pointing at {@link OFFLOAD_TOOL_NAME} via `ref`. Exported so * the per-message aggregate budget (design/64 §17.2) produces the SAME preview format (deterministic → * byte-identical across queries → prompt-cache safe). * * The truncation line states WHERE the rest is, not what anyone can do with it. * "read more" alone reads as a promise that the full output is retrievable, and the model relays that * promise to the person it is talking to; what is actually guaranteed is narrower and exactly two * things: the text is retained in this run's tool-result store, and THIS chain can page it back. A * host-side read face (an HTTP route that hands the user the whole output) is a separate deployment * feature that may or may not exist — so the line names it as a deployment fact instead of implying * it, and the model can answer "can I get the full log?" without guessing. */ export declare function buildPreview(full: string, ref: string, sizes?: { head: number; tail: number; }, reachableTools?: ReadonlySet<string>): string; /** * Wrap an {@link AgentTool} so that an oversized text result is offloaded to `store` and replaced with a * preview + `ref` before it ever reaches the session/model. `ref = tr_<sessionId>~<toolCallId>` is stable and the * store is write-once: a **same-process** replay keeps an identical (cacheable) preview; a **cross-process** * wake needs a durable store (the default in-memory store loses the full text — the preview still stands). * Image blocks are left untouched; only text is offloaded. Forwards the `onUpdate` progress callback so a * wrapped (e.g. MCP) streaming tool isn't broken. * * Backlog #175 — a failing store NEVER turns the wrapped tool's success into a failure: the content face * falls back to the original (un-offloaded) result, and the `details` walk to a ref-free notice per lost * member (see the two arms for why the degrades differ). The wrapper adds a storage step to a tool that * has already run; it must not be able to invalidate that run. */ export declare function withToolResultOffload(tool: AgentTool, store: ToolResultStore, thresholdChars: number, sessionId: string, /** RB-374① — LIVE accessor for the session's currently CALLABLE tool set (evaluated per offload, * so a mid-run ToolSearch activation is reflected by the next preview). Absent/`undefined`-returning * ⇒ byte-identical historic preview. An accessor (not a snapshot) because the wrap happens at * prepare time, before the deferred classification exists and before any activation can. */ reachableTools?: () => ReadonlySet<string> | undefined): AgentTool; /** The machine-recognizable start of an offloaded-detail replacement notice. Exported for consumers * that treat a `details` string as SEMANTIC INPUT (not display) — e.g. the session-continuation * read-state replay — so they can tell "this member was offloaded, the bytes here are a preview" * and degrade honestly instead of consuming preview bytes as the real value. */ export declare const OFFLOADED_DETAIL_NOTICE_PREFIX = "\u2026[offloaded \u2014 "; /** True when `s` carries an offloaded-detail replacement notice (the newline-anchored prefix form the * walk emits). A semantic consumer treats such a string as NOT the member's real bytes. */ export declare function isOffloadedDetailReplacement(s: string): boolean; /** The injected `read_tool_result` tool: pages through an offloaded result. `effect:"read"` (verifier/reconcile-safe). */ export declare function createReadToolResultTool(store: ToolResultStore): AgentTool;