UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

316 lines (315 loc) • 17.8 kB
/** * design/178 — the engine-memory retrieval tools (`memory_search` / `memory_get` / `memory_index`), * the runner-wired consumers of the `MemoryBackend.search()`/`getByIds()` contract: a deterministic * keyword lookup over the session's mounted scopes, a paged full-content read, and a paged * enumeration of EVERY mounted entry. That enumeration is the completeness floor under the other * two — any committed entry is reachable through it regardless of the injected index's truncation * window and of whether its words match the question (out-of-window ∧ lexically disjoint is * otherwise structurally unreachable). The tools sit entirely ON TOP of the backend seam. * * READ-ONLY BY STRUCTURE. No tool here can write; the write path stays the file channel + harvest * gate. Entry content returned to the model is DATA, not instructions: every body fragment rides an * untrusted fence (`delimitUntrusted`) and every model-authorable header field (name/description/ * slug/scope) is neutralized inline. Results are byte-bounded. * * THE TRIO IS ATOMIC: each tool names the others in its description, so a partial mount would teach a * tool that is not there — the mount site (runner/prepare-task.ts) mounts all or none. * * RESULT ORDER IS THE BACKEND CONTRACT ORDER: ascending cosine-distance score with the * deterministic id tie-break, exactly what `MemoryBackend.search()` promises. Multi-plane sessions * merge by the same comparator. Under `provenance: "carry"` (design/336 §5.2) the band key LEADS * that comparator on both sides of the seam — unmarked entries first, marked after, contract order * within a band — and marked hits render as opaque handles. No other signal participates: in * particular the retrieved account is WRITTEN here and never read. * * REFUSALS ARE VALUES: every negative outcome is a structured result with a machine reason; exceptions * are reserved for defects and for cancellation, which is re-thrown so the abort path stays the abort path. */ import type { ToolSpec } from "../types.js"; import type { MemoryBackend, MemoryEntryHeader } from "./types.js"; export declare const MEMORY_SEARCH_TOOL_NAME = "memory_search"; export declare const MEMORY_GET_TOOL_NAME = "memory_get"; /** #493 件① — the enumeration face. NOT `memory_list`: that wire name (and the `core.memory_list@1` * contract id) belongs to the design/177 shared-memory store pair, and both families can mount in * one session. */ export declare const MEMORY_INDEX_TOOL_NAME = "memory_index"; /** The atomic trio, in mount order (same shape as SHARED_MEMORY_TOOL_NAMES). */ export declare const MEMORY_ENGINE_TOOL_NAMES: readonly ["memory_search", "memory_get", "memory_index"]; /** Default / maximum hit counts for one search call. */ export declare const MEMORY_SEARCH_DEFAULT_LIMIT = 8; export declare const MEMORY_SEARCH_MAX_LIMIT = 20; /** Default / maximum rows of one memory_index page (a row is ≲600 bytes by the inline caps, so the * extremes stay well under one tool-result's budget; the per-page byte bound below is the backstop). */ export declare const MEMORY_INDEX_PAGE_ROWS = 100; export declare const MEMORY_INDEX_MAX_PAGE_ROWS = 500; /** Byte bound of one memory_index page (rows past it defer to the next page, disclosed). */ export declare const MEMORY_INDEX_PAGE_CAP_BYTES: number; /** Per-hit body-fragment bound (code points, enforced by the fence itself). */ export declare const MEMORY_SEARCH_SNIPPET_CAP = 600; /** Default / maximum lines of one memory_get page. */ export declare const MEMORY_GET_PAGE_LINES = 200; export declare const MEMORY_GET_MAX_PAGE_LINES = 1000; /** Byte bound of one memory_get page body (lines past it defer to the next page). */ export declare const MEMORY_GET_PAGE_CAP_BYTES: number; /** * One retrieval plane: a backend plus the scopes THIS SESSION mounted on it (dual-root sessions have * two, single-root sessions one). `scopes` is the containment boundary — `getByIds` is id-keyed * across every scope the backend knows, so results are filtered back to the mounted set here. * `recordRetrieved` feeds the plane's control-plane retrieved account (collect-only, never throws). */ export interface MemoryEnginePlane { backend: MemoryBackend; scopes: readonly string[]; recordRetrieved: (ids: readonly string[]) => void; /** * design/180 B-2' — the plane's EXCLUSION view: entry id → why its content is withheld from the * model face (`challenged` = an unresolved S-grade challenge generation; `lineage_pending` = the * dirty latch, its commit bookkeeping is unsettled). THROWS when the underlying ledger's * integrity is unknowable — both tools then REFUSE SERVICE on that plane (fail-closed: a corrupt * ledger must never read as "nothing withheld"). Absent ⇒ a plane with no control plane (direct * hosts / tests) — no exclusion, v1 behavior byte-identical. */ challengeExclusions?: () => ReadonlyMap<string, { code: "challenged" | "lineage_pending"; generation?: number; at?: number; }>; /** * design/336 §5.5 — the recall-taint propagation seat, fired at the ONE tool-face content * delivery point of a marked entry (`memory_get`'s successful delivery; the search list never * fires it — a hit line delivers no content). The runner wires it to the session's pollution * mark with cause `"derived"`, closing source-scope laundering at session granularity: a session * that took up marked content writes marked entries from then on. Consulted only under * `provenance: "carry"` (the seat is not even wired under "off"). * * A THROW here withholds the delivery, fail-closed (the tool answers a structured failure): if * the taint cannot be recorded, the content must not enter the transcript — the same law as the * settlement account's record-failure arm. */ onTaintedDelivery?: (ids: readonly string[]) => void; /** * design/383 §1.7 — the provenance mode of the WRITE-SIDE engine that owns this plane, read off * the engine itself (`MemoryEngine.provenanceMode`), never re-declared by hand. Present ⇒ the * factory ASSERTS it against its own {@link MemoryEngineToolsOptions.provenance}: a wiring that * writes under `"carry"` while reading under `"off"` mints origin markers no read face will ever * honour (marked entries served through the clean shape, no banner, no recall-taint propagation) * — the two halves are one deployment posture and a split between them is a configuration fault, * refused at construction (`config.memory_provenance`) rather than discovered as a quiet leak. * * Scope, stated honestly: this kills CONFIGURATION divergence only. It proves nothing about * whether any individual entry is classified correctly — that is the 178 fail-closed default's * job, not this assertion's. Absent ⇒ no engine mode is declared for this plane (engine-direct * hosts and tests) and nothing is asserted: the factory's behaviour is byte-identical to before. */ enginePlaneProvenance?: "off" | "carry"; /** * The plane's COMMITTED header listing — the name source of the write-home rule * ({@link import("./shadowing.js").deriveShadowedSet}). Wired to * `MemoryEngine.listCommittedHeaders`, it is what makes an in-session edit of a `name:` field * retire nothing before the harvest boundary admits it. ABSENT ⇒ this plane contributes no * write-home names, so nothing on any plane is shadowed and every face below is byte-identical * to a deployment without the rule (engine-direct hosts and tests). */ committedHeaders?: () => Promise<MemoryEntryHeader[]>; } export interface MemoryEngineToolsOptions { planes: ReadonlyArray<MemoryEnginePlane>; /** Clock (tests); default Date.now. Drives the natural-language age rendering only. */ now?: () => number; /** * design/178 §3 (#324b) — the session's pollution READ face: the record when this session is marked * polluted, `undefined` when it is clean. Read-only and side-effect-free (the mark is one-way and * lives at the runner's mark seat); consulted ONLY on the empty outcomes, where a session whose * writes are being withheld deserves to hear it instead of being told to search again. * * Absent ⇒ no pollution face is wired (engine-direct hosts, tests): the empty-outcome wording is * then exactly the clean-session wording — nothing is inferred from the seat's absence. */ sessionPollution?: () => { reason: string; } | undefined; /** * design/336 §13-3 — the provenance mode the write engine runs under. Two consumers here: * - the pollution sentence states what the mark actually DOES (under `"carry"` writes commit * with an origin marker; the pre-336 "not admitted" sentence would be false); * - the READ-SIDE treatment (§5.2/§5.3/§5.5) arms only under `"carry"`: two-band search order, * opaque handles for marked hits, the memory_get banner + `exposure` detail, and the * recall-taint propagation. Absent ≡ `"off"` keeps every read face byte-identical to pre-336 * even over a store that carries marked entries (the knob's whole meaning). */ provenance?: "off" | "carry"; /** * The session's WRITE HOME scope — the layer every write of this session routes to. The * write-home name rule's one input beside the headers: a read-only layer's entry whose name this * scope already carries leaves the DEFAULT face of all three tools (retrievable through * `includeShadowed` and by id). ABSENT or `null` ⇒ no write home is declared to these faces and * nothing is ever shadowed — the pre-rule bytes. */ writeScope?: string | null; } /** * An unmarked search hit — the pre-336 hit shape, `slug` required as before. `exposure` is a * `never` seat so the union below discriminates on it (`hit.exposure === "external"` narrows). */ export interface CleanMemorySearchHit { id: string; scope: string; slug: string; name?: string; description?: string; /** Cosine-distance ∈ [0,2], 0 best — the backend contract's score, untransformed (v1). */ score: number; mtimeMs: number; sizeBytes: number; exposure?: never; /** design/339 §3.4 — present ⇔ this hit is SUPERSEDED (retrieved via `includeSuperseded`; the * default face filters such hits out) — the value is the standing carrier entry's id. */ supersededBy?: string; /** Present ⇔ this hit is SHADOWED (retrieved via `includeShadowed`; the default face filters * such hits out) — the value is the write-home entry id whose name retires it. */ shadowedBy?: string; } /** * design/336 §5.2 — a MARKED hit is an OPAQUE HANDLE: id/scope/score/age/size plus the exposure * discriminant, and the text seats (`slug`/`name`/`description`) structurally NEVER present — a * marked entry's model-authored strings are content positions, and the passive face carries zero * of them (an imperative sentence inside a `name:` would otherwise ride every hit line untaxed). * The content is read through `memory_get` by id (the active face, banner + propagation). */ export interface ExposedMemorySearchHit { exposure: "external"; id: string; scope: string; /** Cosine-distance ∈ [0,2], 0 best — same contract score; the hit stays ranked, only banded. */ score: number; mtimeMs: number; sizeBytes: number; slug?: never; name?: never; description?: never; /** design/339 §3.4 — see {@link CleanMemorySearchHit.supersededBy} (same seat on the handle shape). */ supersededBy?: string; /** See {@link CleanMemorySearchHit.shadowedBy} (same seat on the handle shape). */ shadowedBy?: string; } /** * The search hit union (design/336 B5 — a DISCRIMINATED union on `exposure`, deliberately not an * "optional members" widening: a consumer must branch to touch the text seats, so a marked hit can * never be rendered through the clean shape by accident). Under `provenance: "off"` every hit is * the clean shape, byte-identical to pre-336. */ export type MemorySearchHit = CleanMemorySearchHit | ExposedMemorySearchHit; export interface MemorySearchDetails { outcome: "ok" | "refused" | "failed"; reason?: string; hits?: MemorySearchHit[]; } export interface MemoryGetDetails { outcome: "ok" | "not_found" | "ambiguous" | "refused" | "failed"; reason?: string; id?: string; scope?: string; slug?: string; /** Candidates of an ambiguous bare-slug lookup — scope-qualified, never silently picked from. */ candidates?: Array<{ scope: string; slug: string; id: string; }>; offset?: number; lines?: number; totalLines?: number; /** design/180 — the OPAQUE withheld refusal's structured half (`reason: "challenged" | * "lineage_pending"`): the unresolved challenge generation and its timestamp. Deliberately the * ONLY fields beyond the id — name/description/free reason text are content positions and live * on the host audit face exclusively. */ generation?: number; challengedAt?: number; /** design/180 (intra-line cursor) — byte offset into the (neutralized) single line at `offset` * this page continued from / where the next page should continue. */ lineCursor?: number; nextLineCursor?: number; /** design/336 §5.3 — present on an `ok` delivery of a MARKED entry under `provenance: "carry"` * (the structured half of the banner). Absent under "off" and for unmarked entries. */ exposure?: "external"; /** design/339 §3.4 — present on an `ok` delivery of a SUPERSEDED entry (mode-free, data-driven): * the standing carrier entry's id (the disclosure note's structured half). Delivery proceeds — * supersession is disclosure/ordering, never withholding. */ supersededBy?: string; /** Present on an `ok` delivery of a SHADOWED entry: the write-home entry id whose name retires * it from this session's default face (the head note's structured half). Delivery proceeds — * the rule is disclosure/ordering, never withholding. */ shadowedBy?: string; } /** * #493 件① — one row of the memory_index enumeration. The same discriminated shape law as the * search hits (design/336 B5): a clean row carries the text seats, a marked row (carry mode) is an * opaque id-only handle — a consumer must branch on `exposure` to touch the text seats. No score * seat: the enumeration is structural, not ranked. */ export interface CleanMemoryIndexRow { id: string; scope: string; slug: string; name?: string; description?: string; mtimeMs: number; sizeBytes: number; exposure?: never; /** Present ⇔ this row is SUPERSEDED (listed via `includeSuperseded`; the default face filters * such rows out) — the value is the standing carrier entry's id. */ supersededBy?: string; /** Present ⇔ this row is SHADOWED (listed via `includeShadowed`; the default face filters such * rows out) — the value is the write-home entry id whose name retires it. */ shadowedBy?: string; } /** The opaque marked-row handle (carry mode) — same posture as {@link ExposedMemorySearchHit}. */ export interface ExposedMemoryIndexRow { exposure: "external"; id: string; scope: string; mtimeMs: number; sizeBytes: number; slug?: never; name?: never; description?: never; supersededBy?: string; shadowedBy?: string; } export type MemoryIndexRow = CleanMemoryIndexRow | ExposedMemoryIndexRow; export interface MemoryIndexDetails { outcome: "ok" | "refused" | "failed"; reason?: string; entries?: MemoryIndexRow[]; /** Zero-based row offset this page started at (the enumeration order is deterministic). */ offset?: number; /** Rows on THIS page. */ shown?: number; /** Total rows the enumeration serves (after the exclusion/superseded filters). */ total?: number; /** Superseded rows the default face hid (absent when zero or when `includeSuperseded` was passed). */ supersededHidden?: number; /** Shadowed rows the default face hid (absent when zero or when `includeShadowed` was passed). */ shadowedHidden?: number; /** Present ⇔ more rows follow — pass it as the next call's `offset`. */ nextOffset?: number; /** The listing revision this page was served against (a stateless digest of the ordered id * sequence the offsets address). Pass it back on continuation pages of a complete pass; a * mismatch refuses with reason `listing_changed` instead of silently skipping shifted rows. */ listingRev?: string; } /** Cut `text` to at most `maxBytes` UTF-8 bytes on a CODE POINT boundary (a byte-wise slice would * strand half a character), reporting how many bytes were dropped. Unchanged text reports 0. * Exported for the truncation-invariant registry — a bound-taking function has to be provable. */ export declare function cutToBytes(text: string, maxBytes: number): { text: string; omittedBytes: number; }; /** Skip `startBytes` UTF-8 bytes of `text` on a code-point boundary (the intra-line cursor's seek * half — {@link cutToBytes} is its cut half). A cursor landing mid-character (never minted by this * module) rounds FORWARD to the character's end, so no half-character is ever emitted. */ export declare function skipBytes(text: string, startBytes: number): { text: string; skippedBytes: number; }; export declare function createMemoryEngineTools(opts: MemoryEngineToolsOptions): ToolSpec[];