@sema-agent/core
Version:
Stateless, task-oriented AI agent core
304 lines (303 loc) • 22.5 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";
/**
* 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).
*/
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 Agent 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. */
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. */
status?: "completed" | "killed" | "failed";
/** terminal + spawn/tick (#258 widened the carriers): the stop-cycle number — on a TERMINAL frame
* a MIRROR of the sibling task_notification's `TaskNotificationPayload.seq` (same settle, same X3
* snapshot); 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 —
* MIRROR of the sibling task_notification's `TaskNotificationPayload.completionId` (same settle,
* same minted value; the fleet lane was explicitly one of the four channels the original ask named
* — "同一完成今天从四条通道扇出" — so it gets the same mirror treatment `seq`/`stoppedBy` already
* have here, not a fifth divergent copy). 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. MIRROR of task-registry `StopSource`
* (the single source; inlined to avoid an import cycle — task-registry imports types.ts; third
* mirror: task-notification.ts `TaskNotificationPayload.stoppedBy` — keep all three in sync).
* OPEN ENUM: consumers must tolerate unknown values (a future "timeout" is not breaking). */
stoppedBy?: "user" | "parent" | "system" | (string & {});
/** terminal: bounded human summary (same text the task_notification carries). */
summary?: 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;
};
}
/**
* #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 — spawn right after the leg's `wiring_manifest` (prepared, nothing
* run yet), terminal when the leg's `TaskResult` assembles — 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.
*
* PER-LEG semantics, deliberately: a durable park + resume, or a retained child's revive, is a NEW
* leg — each emits its own spawn/terminal pair, and `identity.legKind` (`"resume"`) says which cycle
* a frame belongs to. Consumers correlate legs of one delegation by `identity.taskId` /
* `identity.parentToolCallId` (stable across cycles).
*
* 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 spawn/terminal pair even deps-only; a resume that re-supplies trusted internals
* (every in-engine lane does) was never affected.
* PAIRING (closed the one hole): a spawn frame is always closed by a terminal frame —
* the ordinary path emits it where the `TaskResult` assembles, and a POST-SPAWN 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 that is set
* only after the spawn emission 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 unpaired spawn.
*
* 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 = {
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";
/** Same envelope as the leg's spawn frame (one mint per leg). */
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 spawn/terminal pair if it is redeemed. */
status: TaskStatus;
/** Turns the leg completed. */
turns: number;
/** The leg's terminal code (the failed cause's `code`), when one was stamped. */
errorCode?: string;
};
/** 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;