UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

109 lines (108 loc) • 8.08 kB
import type { ToolResultDeletionReport, ToolResultProvenance, ToolResultSlice, ToolResultStore } from "../../core/tool-result-store.js"; import { type CorruptReadInfo } from "./corrupt-read-sink.js"; /** * design/80 §2.5 — file {@link ToolResultStore}: write-once-idempotent, ONE file per ref. * * `ref = tr_<sessionId>~<toolCallId>` (engine-minted, already globally unique; the separator is the * backlog #119 injective form). `put` is an `O_CREAT|O_EXCL` create → on `EEXIST` it no-ops * (write-once-idempotent; never re-writes — a re-write risks a non-identical preview and breaks the * prompt cache, §2.5 / the in-memory store's `map.has(ref)` check). Content is fsync'd before return. * `get` slices the file by `{offset, limit}` over CHARS to match `ToolResultSlice.totalChars` semantics; * an unknown ref → `undefined` (the model is told it's gone — degrades, never crashes). * `withToolResultOffload` joins text with `\n` and stores the joined string verbatim, so the char-offset * slice here is exactly the in-memory store's slice. * * Provenance (#119) lives in a SIDECAR file next to the content one (`<encoded>.owner.json`), not in a * header inside it: the content file's bytes are the stored result verbatim, which is what makes the * char slice identical to every other backend's and what keeps files written by older versions readable * with no format migration. * * That is TWO published objects, so the window is stated rather than hidden (falsification review): the * content link is the write-once election and the sidecar follows it, so a crash in between leaves an * entry content-complete and momentarily UNOWNED. Publishing the sidecar FIRST would close that window * and open a worse one — an ownerless writer's row could then be claimed by whoever raced it — so the * order stands and the entry stays unowned: fail-closed, refused by a host read face, never * mis-attributed, and never adopted by a later write (adoption is an adjudicated contract rule, not a * local choice — see the `put` body). What a `put` no longer does is walk over DAMAGE: a sidecar that * is present but unreadable stops the write loudly, where treating it as "no owner" would report * success and leave the entry unclaimable forever. */ export interface FileToolResultStoreOptions { /** Disclosure seat for an owner sidecar that is PRESENT but unreadable: {@link * FileToolResultStore.ownerOf} answers `undefined` — the same word it uses for an entry nobody owns, * so a host that authorizes on it reads damage as "unowned". Swallow-guarded. */ onCorruptRead?: (info: CorruptReadInfo) => void; } export declare class FileToolResultStore implements ToolResultStore { private readonly dir; private readonly discloseCorrupt; constructor(root: string, opts?: FileToolResultStoreOptions); /** The one delivery point for {@link FileToolResultStoreOptions.onCorruptRead}. */ private disclose; /** The directory a ref's files live in, and their shared stem. See {@link encodeRefFilename} for why * a FOLDED name gets its own directory instead of sharing one with identity names. */ private locate; private pathFor; /** #119 — the provenance sidecar's path. Same directory and stem as {@link pathFor}, so the pair is * found by one encoding step and a ref that cannot be encoded has no sidecar either. */ private ownerPathFor; put(ref: string, content: string, provenance?: ToolResultProvenance): void; /** #119 — the recorded owner, or `undefined` for an unknown ref AND for a stored-but-unowned one. * Lenient by design on the READ face (see {@link readOwnerFile}). */ ownerOf(ref: string): ToolResultProvenance | undefined; /** * #119 — read the owner sidecar. `loud` splits the two faces on purpose, the same split this backend * already applies to ref safety: a READ (`ownerOf`, an authorization question) degrades an * unreadable or malformed sidecar to "unowned" so a corrupt file cannot crash a page-back, while a * WRITE refuses to proceed over one it cannot read — silently treating damage as "no owner" is how a * write reports success and leaves the entry unclaimable forever (falsification review round 4). * A MISSING sidecar is not damage in either face: it is the honest "unowned". */ private readOwnerFile; /** #119 — publish the owner sidecar (write-once, like the content file it accompanies). */ private writeOwnerFile; get(ref: string, opts?: { offset?: number; limit?: number; }): ToolResultSlice | undefined; /** * Erase every entry owned by `sessionId` (interface: {@link ToolResultStore.deleteBySession}). Without * it a session deletion leaves this store's files on disk for the lifetime of the data root. * * Selection matches on the SIDECAR's `sessionId`, never on the filename. That is not a preference, it * is the only thing available: the mint separates segments with `~`, which is outside this backend's * filename charset, so every engine-minted ref folds to `<base>-<sha256 of the raw ref>` and the * session segment is gone from the name (and the readable base is a lossy, many-to-one prefix, so * matching on it would reach a neighbouring session's files). `taskId`, where an older write site * stamped one, is ignored — it narrows the same session. * * Both namespaces are swept: identity-named files in the store dir (refs a caller minted inside the * filename charset, and everything written before the injective mint) and {@link FOLDED_SUBDIR}. * * Removal order is CONTENT then SIDECAR, and it is load-bearing. Interrupted after the content unlink, * the next sweep still finds the sidecar, matches the same session and converges; interrupted after a * sidecar-first unlink, the content file would be left owner-less — readable, attributable to nobody, * and beyond the reach of every future session sweep. Same reasoning as `put`'s publication order, in * the opposite direction. The pair is then re-examined ONCE after both unlinks, which is what keeps * a `put` racing the two from leaving bytes whose owner this sweep removed — and what keeps that * re-examination from deleting a row a DIFFERENT session claimed in the same window (see the body). * * What the re-examination is NOT is atomic: it reads the owner and then acts, so a writer landing * inside THAT gap is narrowed, not excluded. Stated rather than locked away, because the locking that * would close it is ruled out one layer down — `fs-atomic.ts` §2.4 admits exactly one file lock, the * boot lock, on the premise that a data root has a single writing process (`FileStorageBackend` * acquires it before constructing this store; cross-process correctness is a database backend's job, * as everywhere else in this family). Under that premise there is no gap at all: both `put` and this * sweep are wholly synchronous, so nothing can interleave with either. The narrowing above is what * remains worth doing for a root someone shares anyway, and it costs one `readFileSync`. * * Failure posture: an entry that vanished between `readdir` and the unlink is honest absence (ENOENT * ok, the concurrent sweeper's row). Any OTHER unlink error throws — a deletion face that cannot * delete has to be loud — and because the sweep is idempotent, the retry after the operator clears * the cause resumes where it stopped. A sidecar that cannot be READ (EACCES, torn JSON, a shape that * is not the whole record) is counted `unattributable` and its content is left alone: a file we could * not read carries no evidence about whose it is, and this store's rows stay readable through `get` * even with a damaged owner record, so removing one on a guess would destroy another session's output. */ deleteBySession(sessionId: string): ToolResultDeletionReport; }