@sema-agent/core
Version:
Stateless, task-oriented AI agent core
384 lines (383 loc) • 27.9 kB
TypeScript
/**
* The DELEGATION frames a parent run observes about its children: `BackgroundChildEvent` (the
* background-agent beat), `DelegationLifecycleEvent` (the spawn/terminal pair) and the ONE delivery
* form behind that seat — with its once-per-process malformed-seat latch, which is module-private
* state and is the reason the type and the function cannot live in different files. Layer 0
* vocabulary; `types.ts` re-exports every name below, so no consumer's import changes.
*/
import type { TaskStatus } from "./task-result.js";
import type { RunnerDeps } from "./runner-deps.js";
import type { TaskEvent } from "./task-event.js";
/**
* design/129-B — one lifecycle event of a BACKGROUND delegation child, delivered to the
* process-level {@link RunnerDeps.onBackgroundChildEvent} observer. Keys: `taskId` is the registry handle
* (`a*` domain — the TaskOutput/TaskStop key); `progressTaskId`/`sessionId` are the child RUN's id (uuid
* domain — the same key `task_progress` frames carry).
*
* `kind: "spawn"` announces a fleet ROW's birth, which is a different fact from a child RUN's start
* (`TaskEvent` `task_started`): an `a*` row is born when its lane registers it, before the child run
* prepares (and a child whose prepare throws has a row and no run start); a `wa*` row is born with the
* workflow's own agent record, including rows that never run a child at all (a replayed or re-parked
* agent). Each fact has one mint — the lane for the row, the child's spawn station for the run.
*/
export interface BackgroundChildEvent {
kind: "spawn" | "tick" | "terminal";
/** Row key, stable across all three kinds. The prefix is the DOMAIN DISCRIMINATOR (load-bearing,
* not opaque — design/149): `a*` = registry handle (background subagent —
* TaskOutput/TaskStop addressable, fleet-footer display lane); `wa*` = synthetic workflow-agent
* row (NOT a registry row — per-agent read face is the workflow's `TaskOutput(w*)` agent_runs
* projection + journal API). Display contract (CC anchor): CC's footer renders ONLY the
* workflow AGGREGATE row — `wa*` rows are read-face/observability rows and SHOULD NOT be
* rendered in the footer fleet tree; shells filter on the prefix. */
taskId: string;
/** design/129: `true` when the child outlives its parent turn (session-scoped). */
sessionScoped: boolean;
/** Registry owner key (task-scoped: the host taskId; session-scoped: the sessionId). */
owner?: string;
/** Registry scope (tenant/principal isolation key). */
scope?: string;
/** spawn: the child's short description (bounded, display-safe). */
description?: string;
/** spawn + tick: the RESOLVED subagent type ("general-purpose" / "Explore" /
* a roster name / "fork") — the fleet row's TYPE column. Never the description. On ticks it is
* the ROW's type (this spawn), not the nested frame's origin — `progressTaskId` keys that. Rides
* every tick so a consumer that only forwards ticks (server K-7) still gets the type without
* waiting for spawn-frame forwarding; `name` stays the design/99 DISPLAY label (description-backed)
* and was never a type field. */
agentType?: string;
/**
* spawn + tick: the model the ROW runs on — the resolved `ModelRef` the spawner selected for this
* child (a per-call `model`, an agent definition's, or the caller's own current model, inherited).
* It answers with the SERVED catalog model id wherever that is knowable, because this value sits
* beside the child's own `task_progress.model` (the leg's prepared id) and a consumer joins the
* two: a row saying "sonnet" next to ticks naming the catalog id it routed to is indistinguishable
* from two different children. A `Model` object carries its id — and a per-call word that passed
* the spawn gate arrives as exactly that resolved object, so for it the row and the ticks are one
* value by construction. A definition/tool-level STRING ref resolves through the catalog in force
* at the spawn judgement; the child's own prepare re-resolves such a ref against the runner's live
* table, so for those refs alone a catalog hot-swapped between the two reads can lag the row one
* generation behind the ticks. The DISPLAY rule survives only as the FALLBACK, for a string the
* judgement-time catalog cannot resolve (a CC tier alias then shows its sema tier, never the alias
* verbatim). It rides ticks as well as spawn for the same reason
* {@link agentType} does: a consumer that only forwards ticks must not have to wait for spawn-frame
* forwarding to fill its row. Like `agentType` it is the ROW's own fact and is NEVER copied off a
* forwarded frame — a nested descendant's `task_progress` names ITS model, not this row's.
*
* ABSENT is a fact, not a gap, and there are exactly three ways to get there:
* · the delegation named no model anywhere in the chain, so the child runs on the `subagent` ROLE
* and its concrete id is only decided at the child's own prepare;
* · the RETAIN-LEDGER wake lane (a `SendMessage` resume of a completed child), whose frames are
* projected from an `AccessibleTaskRow` — and that row has no model column, so this lane has
* nothing to state and will not invent one. Stated precisely because the sibling wake lane does
* NOT share the limitation: a tier-3 DURABLE revive re-enters through the ordinary background
* spawn, re-derives the model like a first spawn, and its `(revived)` frames carry it whenever
* that derivation lands on a model (the durable record keeps a `model` of its own; a recorded
* key the current catalog no longer resolves degrades the revival to the inherited model, and a
* mount with nothing to inherit then leaves these frames honestly silent);
* · a pre-key producer.
* In every case the child's own `task_progress` frames still carry the resolved answer, which is the
* authority; this field is the row-level convenience beside it.
*/
model?: string;
/** spawn: the HOST task's DECLARED task id (parent attribution). Omitted when the host
* run declared no task id (the `spec.taskId ?? sessionId` fallback would launder a session id into
* a task-id field — the orphan-pointer shape); {@link parentSessionId} is the always-on linkage. */
parentTaskId?: string;
/** spawn: the HOST run's session id (uuid domain) — the always-on parent
* linkage, present whether or not a task id was declared. */
parentSessionId?: string;
/** spawn: the ROOT host session of the delegation tree (uuid domain). For a depth-1
* child it equals {@link parentSessionId}; for a nested grandchild it is the TOP session while
* parentSessionId is the intermediate spawner — a recovery/enumeration consumer groups the whole
* tree under the root without walking intermediate (possibly dead) sessions. */
rootSessionId?: string;
/** spawn: epoch ms the child run was launched — the fleet row's time anchor
* (elapsed rendering without waiting for the first tick). */
startedAt?: number;
/** All kinds, `wa*` rows only (design/149 face 2): the owning workflow run's
* `w*` id. `parentTaskId` stays task-domain (the HOST task) so parent resolvers that only
* forward resolved task rows keep working; workflow grouping/nesting is THIS annotation's job. */
workflowRunId?: string;
/** All kinds: the delegating tool call's OWN `toolCallId` —
* the same identity {@link TaskEvent}'s `parentToolCallId` already carries on every forwarded child
* content event (via `RunInternals.parentToolCallId`), extended to this SEPARATE observer/fleet
* event family so a consumer does not have to reassemble parent attribution from spawn-frame
* ordering alone (the exact gap a cross-repo consumer hit: FIFO-timing + shape-sniffing to guess
* which spawn frame a tick/terminal belongs to). Present from spawn — known at birth like
* {@link transcriptId} — not just on the terminal frame.
*
* Which call that is depends on the lane. An `Agent` delegation (`a*` rows) carries the id of the
* `Agent` call that spawned the child. A workflow's agents (`wa*` rows: spawn, every tick and the terminal
* frame alike) carry the id of the `Workflow` call that launched the run — ONE id shared by every row of
* that run, so the relation is one card to N children and no two rows of a fan-out are told apart by this
* field (their own `taskId` is the per-child key). ABSENT, never invented, on a workflow started with no
* launching tool call. */
parentToolCallId?: string;
/** tick: the forwarded `task_progress` frame's own taskId (uuid domain; nested children carry theirs). */
progressTaskId?: string;
/** tick: the forwarded frame's parentTaskId (nested attribution) · terminal: absent. */
progressParentTaskId?: string;
/** tick: the child's display name, when the frame carried one. */
name?: string;
/** ALL kinds: the EXPLICIT `Agent({name})` spawn identity, when the spawn carried one — the teammate
* KEY every carrier of this child spells the same way (`task_progress.spawnName`, the roster row's
* `name`, the uplink `from` label), so a fleet consumer binds "@name → row" by string equality
* without reading the description-backed {@link name}. ABSENT = an unnamed delegation. */
spawnName?: string;
/**
* tick (residual observability, lane B): the child's most recent tool intent as one human line ("Bash npm test",
* "Edit src/x.ts") — derived from the last `tool_start` on the child's stream, so a parent can show
* "what is it doing right now" with zero polling of the child. Absent until the child runs a tool.
*/
currentAction?: string;
/**
* tick (structured Progress-section source): the SAME `{tool, target}` pair
* {@link currentAction} concatenates into prose, exposed separately so a consumer can look `toolName`
* up in its own tool registry (CC `renderToolActivity` parity) instead of parsing the human line.
* `target` is UNTRUSTED free text (a file path / command / pattern the child chose) — same trust
* posture as `currentAction` itself; bound + redact before rendering. Absent until the child runs a
* tool (mirrors {@link currentAction}'s own absence exactly — same source, same lifecycle).
*/
currentTool?: {
toolName: string;
target?: string;
};
/** ALL kinds, when known: the child RUN's session id (uuid domain). Every delegation lane resolves it at
* row BIRTH (a pre-minted id threaded as the child's `spec.sessionId` — the engine's create-on-miss
* acquire), so a running row is addressable too; RB-427-b brought the workflow-agent (`wa*`) lane, which
* published it on the terminal frame alone, onto the same contract. ABSENT only where no id is minted:
* the workflow's two fail-loud spec shapes (`requireExistingSession` / `resumeAt` without a session). */
sessionId?: string;
/**
* ALL kinds (residual observability, lane C): a handle to the child's persisted transcript — the child RUN's session id
* (uuid domain). A parent reads the last N steps on demand via the `AgentTranscript` tool (pull model:
* it does NOT inflate the parent's context). Mirror of {@link sessionId} — same value, same presence
* rules; named distinctly so a consumer keys "how do I read this child's history" without conflating it
* with routing. Present from spawn precisely so a viewer can seed a LIVE child's transcript rather than
* waiting for the row to finish.
*/
transcriptId?: string;
/**
* terminal (residual observability, lane A): the child's last ≤10 completed tool steps ({tool, target, outcome}) — the
* residual a parent needs to write a PRECISE resume prompt ("you stopped at file X, step Y") for a
* child that was stopped/killed mid-flight, WITHOUT reading the transcript. Bounded; absent if the
* child ran no tools.
*/
recentSteps?: import("../agents/subagent-steps.js").SubagentStep[];
/**
* terminal (residual observability, lane D — core half): files the child mutated (Write/Edit/MultiEdit/NotebookEdit)
* with edit counts. The LSP-aggregated `<new-diagnostics>` projection is the shell's half; core
* supplies only the edited-file list. Bounded; absent if the child edited nothing.
*/
editedFiles?: import("../agents/subagent-steps.js").SubagentEditedFile[];
/**
* terminal (residual observability, lane E): `true` when the child can be revived with `SendMessage` —
* either its session is retained live and the run was not killed, or it has a NAMED durable row that no
* USER stop closed (a parent teardown, a reap or a host death is precisely what the durable revival
* lane recovers from). `false` for a user-stopped child, an anonymous or store-less one, and for the
* session-teardown reap frame, whose lane cannot know what survives the teardown.
* Lets a parent's orchestration logic decide "continue it" vs "start fresh" without trial-and-error.
*/
resumable?: boolean;
/** terminal: the settled status — like every completion fact on the terminal frame (`seq`,
* `stoppedBy`, `summary`, `result`, the residuals, `resumable`, `usage`, `completionId`), read from
* the settle's one completion frame by {@link backgroundChildTerminal}, never re-stated beside it. */
status?: "completed" | "killed" | "failed";
/** terminal + spawn/tick (#258 widened the carriers): the stop-cycle number — on a TERMINAL frame
* the completion frame's `TaskNotificationPayload.seq` (same settle, same X3 snapshot), or the
* row's generation when that frame states none; on SPAWN and TICK frames the registry row's generation at emit (fresh spawn = 1, a
* revived cycle's bumped counter), so a fleet consumer can tell a LATE first frame from a revived
* cycle's frame without waiting for the terminal. One axis with `task_progress`'s `seq` — the tick
* mirrors the frame's own stamp. Same honest downgrade as ever: present whenever a cycle number is
* knowable (the registry handle / retain ledger's `cycleSeq`, the durable row's `seq` on a tier-3
* revival / parked resume), absent when no carrier exists (forging a period would lie). */
seq?: number;
/** terminal: the cross-channel completion correlation id — the completion frame's
* `TaskNotificationPayload.completionId` (one minted value for every face of one completion). Absent
* until the underlying task reaches a terminal settle, or on a pre-P1-3 core build. */
completionId?: string;
/** terminal, killed only (design/134 §3.3): who stopped it — the completion frame's own word, so its
* type is that frame's. OPEN ENUM: consumers must tolerate unknown values (a future "timeout" is not breaking). */
stoppedBy?: import("./task-notification.js").TaskNotificationPayload["stoppedBy"];
/** terminal: the completion frame's bounded human summary (the text the parent's model reads). */
summary?: string;
/** terminal: the child's report as the completion frame carries it — the same string, cut once at the
* frame's bound with its disclosure, so a host that wakes an idle conversation reads the report here
* rather than from a second, differently-cut copy. Absent when the frame carries none (a killed or
* failed leg with no report, a withheld hand-back). UNTRUSTED (the child's own text). */
result?: string;
/** tick: live rollup (`task_progress.usage`) · terminal: the settled
* `{tokens, turns, costMicroUsd?, toolUses?, durationMs}`. `costMicroUsd` is own+nested and present
* only when that total is KNOWN (RB-368); `toolUses` is the run's own `stats.toolCalls` and is
* absent — never zeroed — when the gateway reported no usage. The last two joined the terminal face
* to close a reporting asymmetry: the workflow lane's completion notification and the Agent tool's
* own sync `<usage>` footer had published both all along, so one child answered "how much work, how
* long" on one lane and refused on the other.
*
* `durationMs` MEASURES DIFFERENT SPANS on the two kinds, which is worth knowing before plotting it:
* on a TICK it is the child RUN's own elapsed (the forwarded frame's value, from the run's start);
* on the TERMINAL it is the delegation LEG's, from the lane's launch instant — which is earlier, so
* a consumer graphing one series sees a step up at settle. Both are true of what they name; neither
* can be computed from the other without the spawn/prepare interval, which is why they are not
* reconciled into one. The intra-turn activity beat carries a third, narrower shape (`{toolUses}`
* alone — there is no honest live token figure on that lane). */
usage?: {
totalTokens?: number;
toolUses?: number;
durationMs?: number;
tokens?: number;
turns?: number;
costMicroUsd?: number;
};
}
/** What a background child's terminal frame states beside its completion: the row's own facts. */
export interface BackgroundChildRow {
taskId: string;
sessionScoped: boolean;
/** The child run's session id, when known (the row-join anchor). */
sessionId?: string;
/** The read handle to the child's transcript — absent when it was released (a stillborn fork branch). */
transcriptId?: string;
parentToolCallId?: string;
/** The row's registry generation: the cycle the terminal names when the completion frame states none. */
generation?: number;
}
/**
* The ONE constructor of a background child's TERMINAL {@link BackgroundChildEvent}: a projection of the
* completion frame the same settle routes to the parent's model, so the fleet face reads every completion
* fact from that one record and cannot state a different one (a second spelling of the summary, a result
* cut twice, a cycle number read at another instant). The row supplies only what a completion does not
* state. The frame's spend is re-spelled in this face's vocabulary (`tool_uses` → `toolUses`,
* `duration_ms` → `durationMs`); a member the frame lacks stays absent.
*/
export declare function backgroundChildTerminal(completion: import("./task-notification.js").TaskNotificationPayload & {
status: "completed" | "killed" | "failed";
}, row: BackgroundChildRow): BackgroundChildEvent;
/**
* #281 件B — one lifecycle phase of a DELEGATED child leg, delivered to the process-level
* {@link RunnerDeps.onDelegationLifecycle} observer: the public, deps-level home of the delegation
* lifecycle that previously lived only on the trusted RunInternals third parameter
* (`onSubagentSpawn` — sync lane, a steer handle whose `settled` is a void promise) and on the
* background-lane-only {@link BackgroundChildEvent}. A deployment wired ONLY through `RunnerDeps`
* now sees every delegation lane through one seat.
*
* EMISSION CHOKEPOINT (the reason this seat covers every lane at once): frames are minted by the
* RUNNER at the child LEG itself — not by the individual spawn lanes. Every delegation lane
* (sync/steer/background/fork/revive, workflow-spawned agents) runs its child through this
* chokepoint, so none of them needs its own emission and none can drift.
*
* SPAWN IS A PROJECTION, TERMINAL IS PER LEG. The `spawn` phase is not a fact of its own: it is the
* projection of the child's `task_started` stream frame (`TaskEvent`), delivered from that frame's one
* mint at the leg's spawn station, at the same instant and under the same gate — so it fires exactly
* when a CYCLE starts (a first spawn, or a revival of a completed child) and never for a durable
* RESUME leg, which continues a cycle that already started. The `terminal` phase fires for EVERY
* delegated leg, resume legs included (a park ends a leg with `"suspended"`; the resume leg that later
* redeems it ends with its own terminal). Consumers correlate legs of one delegation by
* `identity.taskId` / `identity.parentToolCallId` (stable across legs), and `identity.legKind`
* (`"resume"`) names a resume leg's terminal.
*
* HONEST ABSENCES (recorded, not gaps to fix silently):
* · a child whose PREPARE throws emits neither frame (there is no leg identity to report);
* · the ROOT leg emits nothing here — the deployment called `runTask` itself and holds the result;
* this seat is the delegation observer, not a run observer;
* · a deps-only resume (`resume(token, outcome, config)` — no trusted internals re-supplied) of a
* checkpoint minted BEFORE the delegation axis was persisted
* ({@link import("./checkpoint-store.js").CheckpointState.isDelegatedChild}) emits neither frame:
* the row carries no evidence the parked leg was a delegated child, and fabricating the axis
* would stamp delegation frames onto host-resumed root tasks. Rows minted WITH the axis resume
* with their terminal even deps-only; a resume that re-supplies trusted internals (every
* in-engine lane does) was never affected.
* TERMINAL OWED (closed the one hole): every delegated leg that reached its wiring is closed by
* exactly one terminal frame — the ordinary path emits it where the `TaskResult` assembles, and a
* POST-WIRING throw that the stream layer converts into a synthesized failed result (e.g.
* `resume.tool_unavailable` re-thrown past the run tail) emits the failed terminal from that
* backstop, gated on a carrier set at the spawn station and cleared by the ordinary terminal (so the
* two sites can never both fire for one leg). A leg whose PROCESS dies mid-flight is the only one
* left without its terminal.
*
* OBSERVATION ONLY (matrix §5.2 Q3: frame-rate lifecycle facts ride an observer seat, never
* onNotice): no return capability, and delivery can never alter the child run —
* {@link deliverDelegationLifecycle} contains a throwing sink and an async sink's rejection alike.
*
* FRAME OBJECT SHAPE (consumer contract, not an implementation detail): every delivered frame is
* FROZEN and carries a NULL PROTOTYPE — re-minted at the one delivery point so a forged member
* cannot ride a frame through a writable `Object.prototype`, the same rule (and the same trade) as
* the {@link import("./hooks.js").HookInvocationIdentity} envelope inside it. `Object.keys`, spread,
* JSON serialization and direct member reads (`frame.status`) all behave normally, but
* `frame instanceof Object` is `false` and inherited methods are ABSENT — probe optional members
* with `Object.hasOwn(frame, "errorCode")` or `"errorCode" in frame`, never
* `frame.hasOwnProperty(...)` (throws) or implicit string coercion (`` `${frame}` `` throws).
*/
export type DelegationLifecycleEvent = {
/** A cycle started — the projection of the leg's `task_started` frame (one per first spawn or
* revival; never for a resume leg, so `identity.legKind` is `"child"` on every spawn frame). */
phase: "spawn";
/** The child LEG's identity envelope (#281 件A — the same frozen object that leg's own hook
* invocations carry). `isDelegatedChild` is `true` by construction on every frame here. */
identity: import("./hooks.js").HookInvocationIdentity;
} | {
phase: "terminal";
/** The leg's identity envelope (one mint per leg — the same object as the cycle's spawn frame on
* the leg that started it). */
identity: import("./hooks.js").HookInvocationIdentity;
/** The leg's settled status word — the projection of the `TaskResult.terminal` the spawning lane
* receives (the sync lane's previously-void `settled` payload, made public). `"suspended"` means a
* durable park: expect a later `"resume"`-leg terminal if it is redeemed (a resume leg starts no
* cycle, so no spawn frame precedes it). */
status: TaskStatus;
/** Turns the leg completed. */
turns: number;
/** The leg's terminal code (the failed cause's `code`), when one was stamped. */
errorCode?: string;
};
/**
* The ONE eligibility reading for a leg's delegated-child ROW frames — its `task_started`, its
* `task_progress` ticks and its terminal tick all read this and nothing else: a delegated leg, by the
* identity envelope's effective delegation fact. A row a consumer can open is a row it can close, so
* no frame of the family may ask a narrower question (whether a spawning tool call is known is
* attribution, carried on the frames when it exists — never eligibility).
*/
export declare function emitsChildRowFrames(identity: import("./hooks.js").HookInvocationIdentity): boolean;
/**
* The ONE constructor of a delegated child's `task_started` frame ({@link TaskEvent}); the leg's spawn
* station calls it once per started cycle. Every member is copied from the seat its `task_progress`
* twin (or the fleet row) reads — the run ids, the prepared model, the delegation facts the spawner
* threaded, and the display label as the identity envelope bounds it, present under the tick's own
* law (a label that bounds to nothing is no label) — so the frame introduces no second reading of any
* of them. `stamp` is the event identity the leg stamps on every frame.
*/
export declare function taskStartedFrame(run: {
taskId: string;
sessionId: string;
model: string;
identity: import("./hooks.js").HookInvocationIdentity;
}, spawner: {
delegationTaskType?: import("./task-event.js").DelegationTaskType;
cycleSeq?: number;
parentTaskId?: string;
parentSessionId?: string;
rootSessionId?: string;
explicitAgentName?: string;
spawnAgentType?: string;
} | undefined, stamp: import("./task-event.js").TaskEventIdentity): Extract<TaskEvent, {
type: "task_started";
}>;
/** Test seam (mirrors `__resetMalformedNoticeSeatAnnouncement`): never called by production code. */
export declare function __resetMalformedDelegationSeatAnnouncement(): void;
/**
* The ONE delivery form behind every {@link RunnerDeps.onDelegationLifecycle} emission point (both
* runner stations — spawn and terminal — call this; a second spelling of the swallow/announce rules
* would be the #170 triplication reborn). Contract:
* · a FUNCTION seat is invoked with the frame FROZEN (a mutating observer must not rewrite what a
* later frame consumer — or the shared identity envelope's other readers — see), contained in the
* caller's {@link SafeNotifier} against BOTH failure shapes the void-typed seat admits: a
* synchronous throw ({@link SafeNotifier.notify}) and an async sink's rejected promise
* ({@link observeThenableRejection} routes it back through the same notifier/site — the #253
* three-station form). A broken observer never faults the child run.
* · a PRESENT NON-function seat is a bad deployment value; #123 forbids folding it to silence. There
* is no per-frame console fallback (this is a frame-rate observer stream, not an announcement
* channel — an unwired seat means UNOBSERVED, and echoing every spawn to stderr would flood), so
* the loud exit is the seat DEFECT itself: announced via `console.warn` once per process.
* · an ABSENT seat is a plain no-op (the deployment chose not to observe).
*/
export declare function deliverDelegationLifecycle(seat: RunnerDeps["onDelegationLifecycle"], event: DelegationLifecycleEvent, notifier: import("./safe-notify.js").SafeNotifier, site: string): void;