UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

110 lines (109 loc) • 7.95 kB
import type { ToolSpec } from "../core/types.js"; /** * design/119 (CC 2.1.198 parity) — the structured task-list tool family: `TaskCreate` / `TaskGet` / * `TaskUpdate` / `TaskList`. CC 198's upgrade path over TodoWrite: individual tasks with stable ids, * status transitions, dependencies (blocks/blockedBy), an optional owner, and free metadata — instead * of TodoWrite's replace-the-whole-list. CC209 对齐批B B3: the two families are MUTUALLY EXCLUSIVE and * this one is the DEFAULT — TodoWrite mounts only when the task list is explicitly off (the CC 209 * shape: TodoWrite isEnabled = !taskList; see `scenarios/full-body.ts`). * * State default: like TodoWrite, the default store lives in this factory's closure — FRESH per task * and torn down with it; nothing durable, no deps, no egress (the model's own scratch, projected to * observers only through each call's tool result / `details`). Descriptions follow CC 2.1.198 * (product-specific lines — teammate assignment, spinner/UI wording — trimmed or generalized). * * design/147 §6 D2 (CC 2.1.216 shared team task list): pass a {@link TaskListStore} to share ONE * task list across several agents — CC 216's team coordination artifact ("Check the task list * periodically. Create new tasks when work should be divided. Mark tasks resolved when complete.") * is a task-list file under the team directory; the sema equivalent is this storage seam. A * deployment mounts the SAME store instance into each teammate's tool bundle (same session = same * trust domain — the list is model scratch either way, sharing adds no egress). The `owner` field's * teammate-assignment semantics (trimmed in the 198 port) come back through this seam. */ export interface TaskListItem { id: string; subject: string; description: string; activeForm?: string; owner?: string; status: "pending" | "in_progress" | "completed"; blocks: string[]; blockedBy: string[]; metadata?: Record<string, unknown>; } /** * design/147 §6 D2 — the task-list persistence seam (storage-tier abstraction, same family as * `RosterStore`/checkpoint store: core bundles a memory implementation; a deployment plugs pg/file * behind the same interface and hands ONE instance to every teammate's tool bundle). * * Contract: * - Methods MAY be async (a pg-backed store is); the bundled memory store is synchronous. * - Each single method call must be atomic in the store. Multi-step read-modify-write sequences * (update, delete-with-pruning) are serialized PER STORE by the tools themselves within this * process ({@link createTaskListTools} keeps a per-store promise chain) — a store shared across * PROCESSES additionally needs its own transactional guarantees for those sequences, which is the * deployment's half (documented, not solved here; CC's file-backed team list has the same window). * - `allocateId` is store-owned so concurrent teammates never mint colliding ids, and the * high-water mark is MONOTONIC ACROSS REOPENS for any backend that outlives one wrapper * instance: a restart/reconnect must never re-mint an id the backend has already handed out * (the conformance suite's durable tier pins exactly this — a counter that resets while the * rows survive makes every receipt lie and every subsequent update miss). * - Value semantics: `get`/`list` return detached copies; `set` stores a detached copy. Callers * mutate their copy and write it back — a live-reference store would alias already-emitted events. * - RB-274: `list` is ordered by ID (numeric ascending), not by write order. The tool lane always * allocates before it sets, so the two coincide there — but a store shared with an OUTSIDE writer * (a real CC instance on the same directory) can set an id out of order, and then the backends * disagreed: memory replayed its Map's insertion order while file/cc read the id order back off * disk. Ordering is part of the contract, not each backend's incidental iteration order. * * @contract task_list.partition_is_root_session — a deployment that mounts a TaskListStore per session keys it by the * delegation tree's ROOT session: every child of the tree (spawned live, or revived by a host verb after a durable park) * reads the one list the leader reads; in-engine children share by construction, a per-call or per-reviver store is the divergence. */ export interface TaskListStore { get(id: string): TaskListItem | undefined | Promise<TaskListItem | undefined>; set(id: string, item: TaskListItem): void | Promise<void>; delete(id: string): boolean | Promise<boolean>; list(): TaskListItem[] | Promise<TaskListItem[]>; allocateId(): string | Promise<string>; /** the transaction boundary for adapters whose backend outlives one wrapper * instance (pg, shared files): when present, the tools run EVERY read-modify-write sequence * inside `mutate` and the adapter supplies sequence-level atomicity (a DB transaction, an * advisory lock keyed on the backend — not on this object). Absent ⇒ the tools fall back to the * in-process per-store promise chain, which is exactly right for the bundled memory store (its * backend IS this object) and wrong for anything shared wider — implement `mutate` there. */ mutate?<T>(fn: (tx: TaskListStore) => Promise<T>): Promise<T>; } /** the clone policy behind the detached-copy contract: metadata must be JSON-shaped * (structuredClone would accept — and SHARE — SharedArrayBuffer backing memory, and throws opaquely * on functions/proxies; the tool lane is always JSON, so this guards DIRECT store users). */ /** * RB-168 — the dependency arrays are STRUCTURAL, and a store must refuse a row that lacks them. * * The in-memory store rejected such a row incidentally: its snapshot helper spreads `t.blocks`, so a * missing array threw "blocks is not iterable" at write. The file store had no such accident and wrote the * row to disk, where it survived until `TaskUpdate` reached `t.blocks.includes(...)` — a contaminated row that * passes the write and detonates at an unrelated later call, in a different session, with no trace of where * it came from. Two backends disagreeing about what a valid row is means a test written against one has * different semantics on the other. * * NORMALIZE rather than reject: the harm is a stored row that EXPLODES AT READ, not an incomplete one. * Defaulting the arrays removes the harm without inventing a new failure mode for callers that legitimately * write a partial row (a task with no dependencies has empty arrays — that is what the absence meant). * Rejecting instead would have been a behaviour change dressed as a fix: the first attempt did exactly that * and broke an in-tree test whose subject was id allocation, not row shape. */ export declare function normalizeTaskShape<T>(item: T): T; export declare function assertJsonMetadata(value: unknown, path?: string): void; /** * RB-274 — the contract's `list` order: numeric ids ascending (CC's own task order), any non-numeric id * after them, lexicographically. Ties broken lexicographically so the comparator is total (and the * order therefore stable across repeated calls, which the contract also requires). */ export declare function compareTaskIds(a: string, b: string): number; /** The bundled in-memory {@link TaskListStore} — detached copies both ways (see interface contract). */ export declare function createMemoryTaskListStore(): TaskListStore; /** Build the four task-list tools sharing one task list. Mount all four together. * No `store` ⇒ a fresh private in-memory list (the design/119 per-run scratch shape, unchanged). * With `store` ⇒ the shared team task list (design/147 §6 D2) — mount the same instance everywhere. */ export declare function createTaskListTools(store?: TaskListStore): ToolSpec[];