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