@sema-agent/core
Version:
Stateless, task-oriented AI agent core
493 lines (492 loc) • 37.3 kB
TypeScript
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;