UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

154 lines (153 loc) • 9.65 kB
import { type MemorySyncCursor } from "./sync.js"; import { type MemoryBackend, type MemoryEntry, type PatchReport } from "./types.js"; /** The injected HTTP seam — core never bundles a fetch. The deployment maps `path` (e.g. * `/v1/memory/sync/user%3Aalice`) onto its server base URL, attaches auth, and returns the * DECODED JSON body of a 2xx response; any non-2xx / network failure should THROW (the round then * aborts with zero local side effects — the transport runs before any local write). */ export type MemorySyncTransport = (path: string, body: unknown) => Promise<unknown>; /** The wire REQUEST of one round (the server's `parseMemorySyncRequest` twin): the push * half as computed against the client's own cursor. `deletes` ⊆ `Object.keys(baseRevs)` by * construction (a delete is CAS'd on its baseline; an id never synced cannot be delete-propagated). */ export interface MemorySyncRequestBody { peer: string; /** id → rev at the last completed round (the cursor's baseRevs; empty = first round). */ baseRevs: Record<string, string>; /** Full state of every entry new/changed here since the baseline. When * {@link SyncMemoryScopeOptions.maxPushEntries} is set this is the id-ascending PREFIX of the * full push set (batching — the remainder rides later rounds). */ entries: MemoryEntry[]; /** Ids deleted here since the baseline (tombstoned or gone from the local plane). Deletes are * NEVER batched/split — they are id+baseRev pairs, weightless next to entry bodies. */ deletes: string[]; /** pull-half batching (wire contract; server implements the response side): ask the * server to cap this round's pull half at `limit` items. Absent ⇔ unbatched (the prior * wire shape byte-for-byte — old servers never see the key). */ pull?: { limit?: number; }; } /** The wire RESPONSE of one round (server `MemorySyncResponse` twin: one RTT, both * directions; `serverDeletes` is the delete-propagation leg [without it a central * removal never crosses = livelock]; `cursor` is the server-computed next baseline with the * retention discipline ALREADY applied to the pull half). */ export interface MemorySyncResponseBody { /** Ops the server actually landed from our push (empty on an idempotent replay). */ applied: PatchReport["applied"]; /** Server-side rejections/divergences: inbound-gate rejects, rev lies, CAS losses, both-sides * divergence (resolve via the §3 ladder — loser minted as a sibling, never dropped). */ conflicts: Array<{ id: string; reason: string; baseRev?: string; currentRev?: string; }>; /** The pull half: entries new/changed centrally since our baseline. */ serverEntries: MemoryEntry[]; /** The pull half's delete leg: centrally deleted, unchanged here since baseline (CAS baseRev). */ serverDeletes: Array<{ id: string; baseRev: string; }>; /** The next baseline to persist AFTER the pull half lands (the client cannot compute it — it * does not hold the central set). Pull-half ids are retained at the OLD baseline inside; the * client advances the LANDED ones to their landed state before persisting (see ⑤ — otherwise * every landed pull echoes back next round and a central delete can be resurrected). */ cursor: { peer: string; baseRevs: Record<string, string>; updatedAtMs: number; }; /** pull-half batching (wire contract; server sets it): true ⇔ the server withheld part * of the pull half under the requested `pull.limit` — the caller should run another round. * The server MUST retain every withheld id at the OLD baseline inside `cursor` (the client * refuses prefilled undelivered revs — see ⑤ defense — so violating this only costs the * server an ignored advance plus a disclosed warning, never a permanently-missed pull). */ pullTruncated?: true; } /** One aggregated conflict: `server` = reported by the central half (its gate/CAS/divergence), * `local` = produced landing the pull half here (our CAS refusals, inbound-gate rejects, rev * lies). Nothing is hidden; the caller resolves via the §3 ladder (loser-as-sibling). */ export interface MemorySyncClientConflict { side: "server" | "local"; id: string; reason: string; baseRev?: string; currentRev?: string; } export interface SyncMemoryScopeOptions { /** The LOCAL authority plane (TOC: the File backend). */ backend: MemoryBackend; /** The scope this round syncs (one round = one scope, mirroring the server route). */ scope: string; /** The injected HTTP seam. */ transport: MemorySyncTransport; /** The persisted cursor from the last completed round with this peer; absent = first round. */ cursor?: MemorySyncCursor; /** Peer name for the cursor partition (defaults to the cursor's peer, else "central"). */ peer?: string; /** Caller-injected clock (engine precedent) — stamps the returned cursor's updatedAtMs. */ now?: () => number; /** push-half batching: cap the number of push-half ENTRIES sent this round. The full * push set is put in STABLE order (id ascending — deterministic across rounds/replicas) and the * first `maxPushEntries` ride the wire; a cut round reports `pushTruncated: true`. Deletes are * never split (id+baseRev pairs are weightless next to bodies). CALLER CONTRACT: on * `pushTruncated`/`pullTruncated`, persist the returned cursor and run ANOTHER round until both * flags clear — core deliberately never loops internally (the runner loop is the deployment's, * server-side semantics apply); bound your loop (e.g. a fixed max-rounds cap ≥ * ⌈expected entries / limit⌉ + slack) so a misbehaving peer cannot spin it forever. Must be a * POSITIVE integer (≥ 1 — C5: a 0 cap would flag pushTruncated forever and livelock the caller * loop); absent ⇔ unbatched (prior behavior byte-for-byte). */ maxPushEntries?: number; /** pull-half batching: forwarded verbatim as the wire request's `pull.limit`; a server * that withholds part of its pull half answers `pullTruncated`, passed through on the result. * Same caller contract as {@link maxPushEntries}: continue rounds (bounded) until it clears. * Must be a POSITIVE integer (≥ 1 — C5, same livelock gate); absent ⇔ the request carries NO * `pull` key (old-server compatible, pre- wire bytes). */ maxPullEntries?: number; /** design/178 §3 — per-entry byte bound for the PUSH gate, matched against the entry's serialized * file form (the same domain the harvest gate measures). Defaults to {@link MAX_MEMORY_BYTES}, * the engine's own per-file cap, so the wire cannot carry an entry the local gate would have * refused: `scanMemoryWrite`'s size rung only runs when it is given a bound, and an entry that * reached the backend out of band is exactly the population this gate exists for. */ maxEntryBytes?: number; } export interface MemorySyncClientResult { /** True ⇔ the round was fully clean: cursor advanced AND zero conflicts on either side. */ ok: boolean; /** What the push half contained (ids only — the bodies already went over the wire). */ pushed: { entries: string[]; deletes: string[]; }; /** Ops the server landed from our push (response `applied` verbatim). */ serverApplied: PatchReport["applied"]; /** Ops we landed locally from the pull half. */ localApplied: PatchReport["applied"]; /** Aggregated server-reported + local conflicts (never hidden). */ conflicts: MemorySyncClientConflict[]; /** True ⇔ every pull-half item landed cleanly, so `cursor` is the NEW baseline to persist. */ cursorAdvanced: boolean; /** The cursor the caller should now persist: advanced on full success, otherwise the INPUT * cursor unchanged (undefined on a failed first round) — the retention discipline. */ cursor: MemorySyncCursor | undefined; /** present (true) ⇔ `maxPushEntries` cut this round's push set — run another round off * the persisted cursor until it clears (see the {@link SyncMemoryScopeOptions.maxPushEntries} * caller contract). Never present on an uncut round (additive: old callers see no new key). */ pushTruncated?: true; /** present (true) ⇔ the server reported its pull half was cut under `pull.limit` — * same continue-until-clear caller contract. */ pullTruncated?: true; /** Honest-disclosure channel for tolerated protocol violations (present only when non-empty). * Today's single producer is the ⑤ cursor-prefill defense: a server that advances a * NOT-DELIVERED-HERE id's rev inside the response cursor gets that advance REFUSED (old * baseline kept / unknown id dropped) and disclosed here — silently swallowing it would turn * the next round's pull judgment into a permanent miss (baseline == central rev ⇒ never * pulled) or, for an unknown id, a spurious push-half DELETE of an entry never seen here. */ warnings?: string[]; } /** * One full CLIENT sync round for one (scope, peer) against the central sync API — see the * module header for the ①-⑤ flow and the cursor retention discipline. Pure orchestration: all I/O * rides the injected backend/transport; the caller persists the returned cursor. */ export declare function syncMemoryScope(opts: SyncMemoryScopeOptions): Promise<MemorySyncClientResult>;