UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

384 lines (383 loc) • 27.9 kB
/** * 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;