@sema-agent/core
Version:
Stateless, task-oriented AI agent core
316 lines (315 loc) • 17.8 kB
TypeScript
/**
* 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[];