UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

343 lines (342 loc) • 24 kB
export { StoredSession } from "../internal/harness.js"; export type { Session } from "../internal/harness.js"; export { InMemorySessionRepo, InMemorySessionStorage, uuidv7, validateEntriesForImport, StreamingImportValidator, } from "../internal/harness.js"; export { BaseSessionStorage, leafIdAfterEntry, } from "../internal/harness.js"; export type { SessionStorage, SessionRepo, SessionMetadata, SessionTreeEntry, } from "../internal/harness.js"; export { SessionError } from "../internal/harness.js"; export type { SessionWriteOptions } from "../internal/harness.js"; import type { Session } from "../internal/harness.js"; import type { SessionTreeEntry } from "../internal/harness.js"; export type { SessionPlacementRecord } from "../internal/harness.js"; /** True when `err` is a session optimistic-lock conflict (a concurrent writer won the branch leaf). Both * halves read what a HOST `SessionStorage` threw, so both are guarded — unreadable is not a conflict, * never a raise inside the classify-and-rethrow guard. The class test is KEPT: widening the verdict to * any value carrying the word would arm the CAS retry loop on a foreign error (KL-942b). */ export declare function isSessionConflict(err: unknown): boolean; /** * Subagent transcript persistence — the ACQUIRE-form placement declaration a trusted spawner passes * when it creates a subagent's transcript session: the session is created INTO the store's subagent * partition instead of the host lane. The minter reports the fact; the store never guesses residency * from call shape. `placedAt` is stamped by the store at creation (see * {@link SessionPlacementRecord}). Reaches stores ONLY through the engine's trusted internals chain * (never a public TaskSpec key — a public key would let any caller push arbitrary sessions into the * partition and poison store-side retention/enumeration). */ export interface SessionPlacement { kind: "subagent"; /** Tenant key, same source as the durable agent row's `scope`. */ scope?: string; /** The spawning host session (retrieval/partition metadata; authorization stays on the row predicate). */ parentSessionId?: string; /** The delegation tree's root host session (fixed point). */ rootSessionId?: string; /** The durable agent row key (`a…`) — the store-side join key for orphan GC. */ handle?: string; } /** * One row of {@link SessionStore.listPlaced}. The row-store join key is the FULL `(scope, handle)` * tuple — a listing that cannot supply both returns the discriminated `tupleIncomplete` variant * instead (consumers fail closed on it: never age-reaped, reported honestly). */ export type PlacedSessionRow = { sessionId: string; placedAt: number; } & ({ scope: string; handle: string; } | { tupleIncomplete: true; }); /** A session handed back by a {@link SessionStore}: the harness `Session` plus its resolved id. */ export interface AcquiredSession { session: Session; sessionId: string; } /** * A lightweight, read-only projection of one session for a `/resume` list (shell K-5 — re-attach without an * N+1 walk). Returned in bulk by {@link SessionStore.list}, newest-first by `lastActiveAt` by convention. * `lastTaskId` is the engine's term for what the service/wire surfaces as the session's **`lastRunId`** — the * most recent task run on this session, so a shell re-attaches in ONE hop. * * Named `*StoreSummary` (not the bare `SessionSummary` an earlier version used) because that name collided * with an unrelated, differently-shaped `SessionSummary` downstream (the SDK's wire GET /v1/sessions row) — * same name, two shapes, the exact "同名异形" pattern a cross-repo cleanup was independently eradicating * elsewhere. This type is this store interface's own projection, never itself * serialized onto the wire — a service backend re-projects/renames its fields (`lastTaskId` → `lastRunId`) * when building the actual wire response. */ export interface SessionStoreSummary { /** The session id. */ sessionId: string; /** When the session was created ({@link SessionMetadata.createdAt}, ISO string); absent when the backend's * cache view doesn't carry it. */ createdAt?: string; /** Epoch ms the session was last acquired / touched / ran — the newest-first sort key for "recent sessions". */ lastActiveAt: number; /** The most recent task run on this session (core's `taskId`; the service projects it as `lastRunId`). * Absent until a task has run on the session (recorded via {@link SessionStore.noteTaskRun}). */ lastTaskId?: string; /** * An ENGINE-minted run identity for this session ({@link import("./types.js").TaskResult.runId}), * recorded by the most recent {@link SessionStore.noteTaskRun} write to LAND. * {@link lastTaskId} above is `spec.taskId ?? sessionId`, so on the ordinary deployment that supplies no * host task id it answers the SESSION's own id: a "last run" handle that names the session it hangs off * cannot say WHICH run it means. This one names a specific run — read the ORDERING clause below for * exactly how strong "most recent" is here, which is weaker than the sibling field's wording implies. * * Named `lastTaskRunId`, NOT `lastRunId`: the paragraph above this interface records that a service * backend renames `lastTaskId` → the WIRE field `lastRunId`, so a store field spelled `lastRunId` would * be the same name carrying a different identity one hop apart — a mixed-version mapper (or an object * spread) could then publish either value under the one wire name with no type error. Same * collision-avoidance as {@link import("../orchestration/workflow-types.js").WorkflowAgentRun.taskRunId}. * * ORDERING (honest limit): the Runner records this fire-and-forget, exactly as it always has for * `lastTaskId`, so on a store whose write is asynchronous two runs of one session race and the loser's * id can land last. That race was invisible while both runs wrote the same `lastTaskId`; it is * observable here. Read this as "an id of a recent run", and take strict ordering from the runs' * own results, not from this projection. * * Additive and optional: absent on a store that predates it, absent until a task has run, and absent * whenever the recording caller had no run id (see {@link SessionStore.noteTaskRun}). */ lastTaskRunId?: string; /** F7 fork-GC seam: the parent session this one was forked from ({@link SessionMetadata.forkedFrom}). * Lets a consumer render/reap fork lineage; absent on non-forked sessions and on cache views that * predate the fork. The durable source of truth is the repo's persisted metadata. */ forkedFrom?: string; } /** * The session lifecycle/caching layer the {@link Runner} depends on. * * `TtlSessionStore` is the default (in-memory, 7-day idle TTL). Provide a custom implementation * to back sessions with durable, external storage (the "Session center"): `acquire` reconstructs * a session from the persisted event log so any stateless runner instance can resume it. * * Implementations must be safe under concurrent `acquire` of the same id (return one session). */ export interface SessionStore { /** * design/173 §2.3 — durability declaration (see * {@link import("./checkpoint-store.js").StoreDurability}): whether sessions this store accepts * SURVIVE A PROCESS RESTART. That is the whole claim of this axis — multi-replica * coordination/concurrency is a separate backend property this field does not license (review * fold r1-F2/r2-F3; a single-instance file-backed store declares `"durable"` honestly). * Declaration, never duck-typing (the wiring manifest relays it verbatim — it never sniffs class * names); ABSENT is read fail-closed as `"process-local"` (under-promise, never over-promise). * `TtlSessionStore` over a durable {@link SessionRepo} declares via its `durability` option — the * store cannot inspect the repo's persistence, so the DEPLOYMENT that wired the repo states the * fact. */ readonly durability?: import("./checkpoint-store.js").StoreDurability; /** * Subagent transcript persistence — the store's PER-PLACEMENT durability declaration: "this store * understands and honors placement semantics (partitioning + the obligations below), and sessions * placed into the `subagent` partition have THIS durability". Declaration, never duck-typing (the * wiring manifest relays it verbatim). The store-level {@link durability} continues to describe * only UN-placed sessions — a routing/composite store whose transient host lane and durable * subagent partition differ can therefore be honest on both axes. ABSENT = the store does not * understand placement: an `acquire` carrying one is legally IGNORED (the session is then an * ordinary one, and the deployment's delegation-durability tier reads as rows-only). * * Declaring this commits the store to the placement obligations: * 1. a placed session enjoys the declared durability (`acquire(id, {requireExisting:true})` * reaches it after a restart when `"durable"`); * 2. placed sessions do NOT appear on host enumeration faces (`list()` and every deployment * listing path — restart-stable); * 3. acquire semantics for sessions WITHOUT a placement are byte-unchanged; * 4. `release(placedId)` is a REAL deletion (durable history included) — the joint reap's * "row and session both gone" depends on it; * 5. placement is FIRST-WRITE IMMUTABLE — a re-open (`requireExisting`, with or without a * placement argument) returns the same session and never rewrites the tuple; * 6. a placed id cannot be CLAIMED: a later claim-form acquire (no `requireExisting`, no * creation placement) of an existing placed id refuses loudly * (`SessionError("placement_refused")`) instead of attaching. */ readonly placements?: { subagent?: { durability: "durable" | "process-local"; }; }; /** Get an existing session by id, or create one (optionally with a caller-supplied id). design/114 Phase3: * `opts.requireExisting` ⇒ a store MUST fail loud (throw a `not_found` {@link SessionError}) on a genuinely * missing id rather than create-on-miss — so a reuse-style warm-resume of a gone session errors instead of * silently starting a fresh empty run. A store that cannot honor it MUST still not silently create (either * implement the check or reject the option). * `opts.placement` (subagent transcript persistence): CREATE the session into the named placement * partition (see {@link SessionPlacement} — trusted-internals channel only). Ignored by stores * that do not declare {@link placements}; on a store that does, the creating call is exempt from * obligation 6 above and idempotent re-acquire by the same trusted chain stays legal. */ acquire(sessionId?: string, opts?: { requireExisting?: boolean; placement?: SessionPlacement; }): Promise<AcquiredSession>; /** Mark a session recently active (resets idle TTL where applicable). May be async for durable stores. */ touch(sessionId: string): void | Promise<void>; /** Drop a session from the live set (e.g. a throwaway task). Durable history may be retained by the backend. */ release(sessionId: string): Promise<void>; /** * Drop only the CACHED view of a session — **never** deletes history (unlike `release`, which with the * in-memory `TtlSessionStore`'s default `evict: "delete"` policy removes the session from the repo too). * The Runner uses this where the intent is "discard a stale view and re-wake", and on a prepare-phase * throw after acquire (audit B-17 + the 1.93.0 throw-cleanup contract gap: calling `release` there * silently DELETED an existing session's whole history on the default store — the re-acquire then * continued on an empty same-id session). **Optional, but custom stores SHOULD implement it**: when * absent the Runner performs NO fallback (it never substitutes `release`, whose contract permits * deletion — B-17 council) — the cached view is simply not dropped, so a persistent reconcile conflict * exhausts its retry budget and propagates instead of risking history. A store whose `release` is * already history-safe can implement `forget = release`. */ forget?(sessionId: string): void | Promise<void>; /** * Protect a session from idle eviction while a design/45 checkpoint references it (a suspended task * must still be resumable). `pin` before suspend, `unpin` on resume/expiry. **Optional**: a durable * backend that never idle-GCs sessions (service's `tidb-session-store`) implements both as no-ops and * is still safe; only the in-memory `TtlSessionStore` needs a real pin (its `sweep` skips pinned ids). * See design/45 §5 / M3. */ pin?(sessionId: string): void | Promise<void>; unpin?(sessionId: string): void | Promise<void>; /** * Record that a task run STARTED on a session (the Runner calls this at task start, when both ids are * known) so the store can surface it as {@link SessionStoreSummary.lastTaskId} — letting a `/resume` list * re-attach to the most recent run, even one still in-flight or suspended. **Optional + best-effort**: a * store that surfaces no session list may ignore it; it MUST NOT throw. The engine's `taskId` is the * service's `lastRunId`. * * `runId` (#499, additive third parameter — an existing implementation keeps typechecking and keeps * working): the engine's own identity for THIS run, for {@link SessionStoreSummary.lastTaskRunId}. It * is supplied because `taskId` cannot answer "which run": with no host task id it IS the session id. * * The two seats describe ONE run and MUST move together: a call that omits `runId` records the new * `lastTaskId` and CLEARS `lastTaskRunId`. Keeping the previous run's id beside a newer task id would * assert that an id belonging to an older run identifies the latest one — absence is the honest answer * to "which run was last" when the caller does not know. */ noteTaskRun?(sessionId: string, taskId: string, runId?: string): void | Promise<void>; /** * Enumerate known sessions as lightweight {@link SessionStoreSummary} projections (newest-first by * `lastActiveAt`) so a shell's `/resume` lists sessions + their last run in ONE call. **Optional**: the * in-memory `TtlSessionStore` lists its live cache; a durable backend lists persisted sessions. A store * that cannot enumerate omits this (the shell then has no session-list affordance — honest degrade). */ list?(): Promise<SessionStoreSummary[]>; /** * Subagent transcript persistence — enumerate the PLACED partition (the input to the joint reap's * partition leg: sessions whose owning agent row is gone are the orphan shape it collects). * **Optional**: absent = the partition-GC leg is simply unavailable on this store (documented, * fail-closed — placed sessions then age out only through the row-joined reap path). `olderThanMs` * filters by session AGE (last activity where knowable, else `placedAt`); `scope` narrows to one * tenant. Rows return the FULL `(scope, handle)` join tuple or the honest `tupleIncomplete` * variant (see {@link PlacedSessionRow}) — consumers must skip incomplete tuples, never guess. */ listPlaced?(kind: "subagent", opts?: { olderThanMs?: number; scope?: string; }): Promise<PlacedSessionRow[]>; /** * design/110 — FORK a session: copy `sourceId`'s durable history (root→leaf) into a NEW session and return its * id (or `null` if the source doesn't exist). The forked session is independent (writes to it don't touch the * source). Backs CC `/fork` (continue with the parent's full context) AND the inheriting-context subagent fork * (run a child on the fork so it inherits the parent context + shares the prompt-cache prefix). **Optional**: * a store implements it only if it can replay history. `TtlSessionStore` DOES — it delegates to its * {@link SessionRepo} (so {@link hasSessionFork} is true) — BUT the fork is only as durable as that repo: * an in-memory repo forks IN-PROCESS yet loses the copy on restart, while a durable repo (file/pg) makes the * fork survive restart/replica. **Capability presence ≠ durability** — a cross-replica consumer (design/114 * warm-resume) MUST wire a durable repo; core cannot verify durability, only fail-loud on a missing source at * fork time (returns `null`). `owner` (if given) scopes the new session for multi-tenant stores, and a store that * also implements {@link register} MUST make the fork's ownership row agree with it — a fork COPIES the source's * history, so the copy must never be weaker than the original: with no `owner` argument the fork inherits the * SOURCE's row (a source with no row mints none, keeping the authorizer's claim branch intact). An EXPLICIT * `owner: null` follows the same inheritance, deliberately: "anonymous" on a copy of an OWNED source would * make that history claimable by anyone — a downgrade this face exists to prevent — so `null` only takes * effect when the source itself is unowned/anonymous. MUST copy the COMMITTED leaf (an in-flight * uncommitted turn of an active source is not included — a fork is a snapshot at the fork point). */ fork?(sourceId: string, owner?: string | null): Promise<string | null>; /** * Session-ownership capability face (multi-tenant admission — the deployment's authorizer probes for * BOTH methods and runs its owner gate only when they exist; a store without them forces the * deployment into its fail-closed arm, refusing caller-supplied session ids under multi-tenancy). * * `ownerOf` is THREE-state and the states must never collapse: * - `undefined` — the session has NO ownership row (unknown id): the authorizer claims it for the * calling principal (or 404s under require-existing). * - `null` — the session EXISTS and is anonymous (unowned): admitted (single-tenant/dev sharing). * - `string` — owned: a different principal is refused. * Collapsing `undefined` into `null` breaks the gate in both directions at once (an unknown id * reads as an admissible anonymous session; an anonymous session reads as claimable). */ ownerOf?(sessionId: string): Promise<string | null | undefined>; /** * Idempotent ownership upsert, FIRST-WRITE-WINS and atomic: the first registered owner is kept, * later calls neither overwrite nor throw. Deliberately makes NO authorization decision — the * caller re-reads {@link ownerOf} after registering and judges there (which is exactly why the * first-write-wins read-back must be atomic: a concurrent loser has to SEE the winner's owner on * re-read, or it silently attaches to the winner's session). In-memory synchronous writes satisfy * this trivially; durable implementations need a real uniqueness guarantee, not read-then-write. * The ownership row must never expire BEFORE its session (a live session reading as `undefined` * would be re-claimable by anyone); same-lifetime or longer both satisfy the contract — so a store that * deletes the row when it deletes the history must gate that on the history delete having SUCCEEDED. * An implementation must never store a value that {@link ownerOf} reads back as `undefined` (a row that * EXISTS yet reads as "no row" is permanently claimable and never ownable): a FIRST write whose `owner` is * neither a string nor `null` is refused loudly instead — the no-overwrite-no-throw rule above governs * LATER calls, which stay silent no-ops whatever they carry. */ register?(sessionId: string, owner: string | null): Promise<void>; /** * WHICH occupant holds `sessionId` now: the session row's {@link SessionMetadata.incarnation} cell, * minted by the repo when the row came into existence and stable for the row's whole life (restarts, * cache eviction, replicas). `undefined` = UNKNOWN — no row for this id, or a row written before the * cell existed; a reader treats unknown as "cannot tell occupants apart", never as a different one. * A rejection is a read fault, not an answer. * * The BY-ID read, for a reader that did not acquire the session (hosts; this read and the metadata of * a session `acquire` hands back must agree — the incarnation kit pins both). A run's own memory seat * reads the session it ACQUIRED instead, never a second lookup: the cached view `acquire` returns and * a by-id read can answer two different rows. **Optional**: absent ⇒ no by-id read. */ incarnationOf?(sessionId: string): Promise<string | undefined>; /** Number of live/cached sessions (best-effort). */ readonly size: number; /** Release resources (timers, connections). Best-effort; must not throw. */ dispose(): void | Promise<void>; } /** Structural detection: does this store expose the optional {@link SessionStore.fork}? Mounts the fork * capability only when true (INERT otherwise — mirrors `hasScheduler`/`hasBackgroundShell`). */ export declare function hasSessionFork(store: SessionStore): store is SessionStore & { fork: NonNullable<SessionStore["fork"]>; }; /** * F3 bounded-wake recipe, packaged. Given the **path-ordered** entries of a branch * (root→leaf, e.g. `await session.getBranch(leafId)` or a durable store's leaf-walk), * returns the smallest self-contained tail a stateless runner needs to resume: * * - `floorEntryId` = the **latest** compaction's `firstKeptEntryId` (null if never compacted), * - `tail` = entries from that floor to the leaf — i.e. the surviving compaction summary * plus every message kept after it. Everything before the floor is already folded into * that summary, so it can stay cold in durable storage. * * Hand the result straight to a floored storage: * `new InMemorySessionStorage({ entries: tail, floorEntryId })` (or your durable equivalent). * `buildContext()` on that is identical to waking from the full tree — see `session-floor.test.ts`. * * Pure and allocation-light: it scans for the last compaction and slices. If the tree was never * compacted (or the floor isn't on this path — shouldn't happen for a real branch) it returns the * full input unchanged with `floorEntryId: null`, so callers can always use the result verbatim. */ export declare function boundedTail(pathEntries: readonly SessionTreeEntry[]): { tail: SessionTreeEntry[]; floorEntryId: string | null; }; /** * The ONE "has this session seen conversation?" predicate — the freshness question is asked three * times in one prepare (project-memory phase, design/148 center adoption, the epoch-pin * discriminator), and an entry-COUNT proxy at ANY of them re-creates the same lie: a host may write * session_info / model_change / … rows before the first turn (naming a new chat, recording a model * pick), and none of that is evidence of a prior session (the count proxy * survived at the adoption arm after the pin arm was fixed, silently costing a pre-named session * its published center prompt for life). Conversation content — and only that — is the evidence. */ export declare function hasConversationContent(branch: ReadonlyArray<{ type: string; }>): boolean;