@sema-agent/core
Version:
Stateless, task-oriented AI agent core
108 lines (107 loc) • 7.89 kB
TypeScript
/**
* ONE TOOL CALL ATTEMPT, ONE RECORD — the occurrence.
*
* A tool call on one leg (one `toolCallId`, one attempt) has a single record here, and everything the engine
* says about the call is read off it: the `tool_start` / `tool_end` frames (projected in `tool-end-body.ts`), the
* `tool_progress` heartbeat (`tool-progress-lane.ts`), the trace row's `ok`, and the resumed leg's
* `executionOutcome` filing. The record has three parts:
*
* - the EXECUTION LIFE, monotonic: `announced` (the start frame is out) → `released` (the engine let the call go
* to its executor: the resumed leg right before it runs the tool body itself, a live leg at the gate's allow
* exit — the loop starts the body after it and the runner does not observe that moment) → `settled` or
* `interrupted`. A call that closes still `announced` was never let go: whatever the tool's body did, this call
* did not do it (`refusedBeforeRelease`, `tool-not-executed.ts`). Leaving a terminal state is an engine defect and throws;
* - the WAIT ON A PERSON, re-entrant: every ask put to the person's seat for this call counts one entry and one
* exit (several rounds of one gate pass, a confirmation asked during the execution) — a count, never a bit,
* because the waits nest and overlap;
* - the GATE ROUNDS: one record per gate exit for this call, and one more when a station AFTER the gate refuses
* it (the shell read admission that re-establishes what the gate judged, a resumed leg's belts). The latest
* record is the call's disposition: a refusal after the gate is a refusal, never a tool failure under an
* `allowed` record.
*
* WHAT IS NOT HERE: frames, clocks of a lane, the deny observer. This module is the record and its transitions;
* the projections read it. A durable park that crosses legs is two occurrences on two tables (the parking leg's,
* the resumed leg's), joined by the checkpoint row the resumed leg was opened from — never by a field here.
*/
import type { DeniedBy, GateOutcome } from "../gate-outcome.js";
/** The one clock the occurrence's heartbeat facts are stamped on. MONOTONIC for the reason the progress lane
* states: every reading is subtracted from another, and a wall-clock step backwards would silence the beat. A
* host without `performance` falls back to the wall clock and inherits its rollback behaviour. */
export declare const occurrenceClock: () => number;
/** Where a call's execution life stands — see the module header for the order. */
export type OccurrenceLife = "announced" | "released" | "settled" | "interrupted";
/** What opens an occurrence: the facts its start frame carries. */
export interface OccurrenceStart {
readonly toolCallId: string;
/** The name the frames carry — the model's spelling on a live leg, the parked row's spelling on a resumed one. */
readonly spelledName: string;
/** The name of the tool the call dispatches to on the live roster — the name every gate station judged (absent when
* the station opening the record does not know it). */
readonly wireName?: string;
/** The tool's display label, when it differs from the name. */
readonly label?: string;
/** The arguments the start frame carries (for a recovered start: the call's own input as the transcript holds it). */
readonly args: unknown;
}
/** The record, as its readers see it. Written only through {@link ToolOccurrenceTable}. */
export interface ToolOccurrence extends OccurrenceStart {
/** 1 for the first attempt of this id on this table, counting re-announcements of a reused id. */
readonly attempt: number;
readonly life: OccurrenceLife;
/** How many asks put to the person for this call are open right now. */
readonly awaitingUser: number;
/** One outcome per gate exit, plus one per refusal after the gate; the last one is the call's disposition. */
readonly gateRounds: readonly GateOutcome[];
/** The input the latest gate exit decided on, as a FROZEN snapshot (absent when no gate saw the call). */
readonly finalInput?: unknown;
/** The call's start frame is out on this leg. */
readonly startEmitted: boolean;
/** When the call was announced, on {@link occurrenceClock} — the heartbeat's elapsed origin. */
readonly startedAt: number;
/** The last moment anything was heard from the call (a frame for it, or its return from a wait on the person). */
readonly lastHeardAt: number;
/** The heartbeat covers it. Cleared when the loop leaves the call behind at a turn end with no end of its own:
* the record stays (a later settlement must still know its start frame went out) but no beat describes it. */
readonly beating: boolean;
}
/** The table of one leg's occurrences — the record's only writer. */
export interface ToolOccurrenceTable {
/** The live (unsettled) attempt for this id, if any. */
get(toolCallId: string): ToolOccurrence | undefined;
/** Every live attempt, in the order they were opened. */
live(): readonly ToolOccurrence[];
/** Open a new attempt. A live earlier attempt for the same id is superseded: the loop left it behind without an
* end (a reused id can only be re-announced after its earlier call stopped producing events), and nothing can
* settle it any more. */
open(start: OccurrenceStart): ToolOccurrence;
/** The live attempt for this id, opened from `start` when there is none — a call the engine is told about
* before it was announced (a frame the loop's own start never preceded). */
ensure(start: OccurrenceStart): ToolOccurrence;
/** The call's start frame is out. */
noteStartEmitted(toolCallId: string): void;
/** The engine lets the call go to its executor — the one transition into `released`, whichever leg makes it. */
release(toolCallId: string): void;
/** A gate exit for this call: its record and the input it decided on (frozen by the gate's exit). */
gateRound(toolCallId: string, outcome: GateOutcome, finalInput?: unknown): void;
/** A station after the gate refused the call: one more gate record, minted as a veto of whatever the latest one
* settled (a person's yes stays on the settlement; the refusing layer is the disposition). Returns the record. */
refuse(toolCallId: string, layer: DeniedBy): GateOutcome;
/** An ask for this call is put to the person: the count rises, and the returned `resumeFromUser` lowers it once
* (a second call is a no-op). The pair is bound to THIS attempt — a return after the call ended, or after a
* reused id re-opened, touches nothing — and an ask for a call this leg does not hold counts nothing: the wait
* is the person's, and no bookkeeping here may turn their answer into a failure. */
awaitUser(toolCallId: string): () => void;
/** The one bracket every asker uses: the count rises before `ask` puts its question and lowers when the answer
* settles, either way. Returns `ask`'s own value, so the asker awaits exactly what it awaited before; a synchronous
* answer (or anything that is not a promise) was no wait at all. */
whileAwaitingUser<T>(toolCallId: string, ask: () => T): T;
/** A frame for the call went out: its silence clock restarts. */
heard(toolCallId: string): void;
/** The call ended: it leaves the table and the record is returned for the end projection. */
close(toolCallId: string, life: "settled" | "interrupted"): ToolOccurrence | undefined;
/** The heartbeat stops covering every live attempt (turn end, leg teardown). Records stay. */
quiesce(): void;
/** Called whenever an attempt opens (the heartbeat arms its timer). One listener — the leg's one lane. */
onOpen(listener: () => void): void;
}
export declare function createToolOccurrenceTable(): ToolOccurrenceTable;