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