UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

304 lines (303 loc) • 22.5 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"; /** * 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;