UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

300 lines (299 loc) • 22 kB
import { Type } from "typebox"; import type { RunnerSelfSeat } from "../core/runner/contracts.js"; import { type TaskNotificationPayload } from "../core/task-notification.js"; import { type ToolCtxEnricher } from "../core/tools.js"; import { type TaskAccess } from "../core/task-registry.js"; import type { PermissionModeClass } from "./cross-session-envelope.js"; import { type PeerDirectory } from "./peer-directory.js"; import { SubagentRetainLedger } from "./retain-ledger.js"; import { type SubagentSteerHandle } from "./subagent.js"; import { type PeerLaneConfig, type PeerInboundChainRef, type PeerSelfRef } from "./peer-admission.js"; import { type SubagentTranscriptTier } from "../core/wiring-manifest.js"; /** Model-facing name of the background-agent continuation tool (CC 2.1.2xx `SendMessage`). */ export declare const SEND_MESSAGE_TOOL_NAME = "SendMessage"; export interface SendMessageToolOptions { /** Runner used to execute the resumed child run (the design/122 resume face's delegation runner). */ runner: RunnerSelfSeat; /** The unified task registry the background Agent lane registered its a* handles in. */ registry: import("../core/task-registry.js").TaskRegistry; /** design/122 D1 — the parent run's retain ledger (present only when `TaskSpec.retainSubagentSessions` * opted in). Absent ⇒ the tool stays mounted but every call returns the honest not-retained text. */ retain?: SubagentRetainLedger; /** Registry access identity (mirrors TaskOutput's closure identity — the mount fills these). */ owner?: string; scope?: string; sessionId?: string; /** SESSION RETAIN LEDGER key only — never an access axis. `sessionId` above wears two hats * (row-reach predicate axis AND the process-global retain-ledger map key); a caller that must * strip the predicate axes (the continuation verb: access IS the identity) still needs the * ledger route, because the ledger is a STORE seat consulted for an already-gated row by its * own toolUseId — routing through it cannot widen reach. Consulted after ctx/options sessionId. */ sessionRetainLedgerKey?: string; /** Completion-notify sink for the resumed run. The Runner mount wires the SENDING run's own * notification injector here (its runtask-wrapped `injectTaskNotification`), so the completion * notice lands in the sender's live injection queue at a turn boundary; a direct mount may wire a * deployment-level background-notify sink instead. Absent ⇒ completion is silent. */ notify?: (n: TaskNotificationPayload, opts?: { priority?: "now" | "next" | "later"; }) => void; /** Whether a completion notification really reaches the sender — drives every "you will be notified" * sentence on this face (description + four receipts), mirroring TaskOutput's `notificationWired`. * Default = `notify !== undefined`, which is the truth on both the first-party mount and a direct * mount that wires its own sink here. Pass `true` only when the deployment announces a finished * agent through a channel of its own (this tool then keeps the wired wording); `false` states the * degraded truth explicitly. */ notificationWired?: boolean; /** RB-220 mirror of {@link import("../core/types.js").TaskSpec.oneShot} (the Runner mount fills it): * this run's process exits when the turn ends, so no completion notification can ever land — checked * AHEAD of {@link notificationWired}, since no amount of wiring makes a later turn exist. */ oneShot?: boolean; /** * Whether a RETRIEVAL tool (`TaskOutput`) is mounted beside this tool on the same face. Every * degraded completion promise on this face (the one-shot and silent arms of the description and * the receipt/refusal texts) tells the model how to collect a continued agent's result; naming * `TaskOutput(task_id, block: true)` as an imperative is only true where that tool exists. Since * design/385 §1.5 this tool also mounts on the delegated-child arm WITHOUT the background door * TaskOutput/TaskStop keep, so the two can no longer be assumed to travel together. Three states: * `true` — the advice names TaskOutput plainly; `false` — this face has no retrieval tool, and the * advice says the result cannot be collected by this run instead of naming a tool the model cannot * call (an unknown-tool error on a one-shot run is the turn where the result is lost); absent — the * mount did not say, and the advice is hedged ("where that tool is mounted"). */ retrievalToolMounted?: boolean; /** Steer-handle sink: the revived run re-emits a FRESH handle (design/122 risk-table contract). */ sink?: (handle: SubagentSteerHandle) => void; /** * design/147 S1a — the PARENT run's notification injector (RunInternals.parentNotify), present only * when THIS run is a delegated child. Enables the "main" uplink: SendMessage("main", …) lands in the * parent's injection queue as a `background_agent`/`event` frame at a turn boundary. Absent ⇒ the * honest placeholder text stays. */ uplink?: (n: TaskNotificationPayload, opts?: { priority?: "now" | "next" | "later"; }) => unknown; /** * The session `"main"` names for this run ({@link import("../core/runner/contracts.js").RunInternals.uplinkSessionId} * — the spawner's, or the reviver's, or on a sibling/host revival the tree root's). The uplink * receipt names it so the model knows WHICH conversation "main" is; its presence without * {@link uplink} marks a delegated child whose main has no live lane from here (the honest * not-deliverable text names the session). Absent on a top-level run. */ uplinkSessionId?: string; /** design/147 S1a — this run's own spawn name (teammate identity) for uplink attribution. */ senderName?: string; /** design/147 S1c — the deployment's durable roster, consulted after the live registry misses. */ roster?: import("./roster-store.js").RosterStore; /** design/147 S3a — the PARENT run's retain ledger (RunInternals.parentRetainLedger): * the sibling leg's retain entries live there. Consulted AFTER the own-run and session ledgers. */ siblingRetain?: SubagentRetainLedger; /** RB-390 — the SPAWNING run's taskId when THIS run is a delegated child (RunInternals.parentTaskId, * mount-filled from the trusted internals chain — never a model argument). Historically the ONLY * way this fact could reach a first-party mount: the adapter handed a `defineTool` product's body * just `{toolCallId, signal}`, so `ctx.parentTaskId` never arrived and the design/147 S3a sibling * legs (byName parent-view retry, tier-3 parent view) were dead on every auto-mounted child run — * teammates could not reach each other by name ("no agent matches" while the parent's view * resolved fine). RB-409 opened the ctx route as well ({@link enrichCtx}), and the Runner mount * fills BOTH from the same internals chain; this seat remains the fallback (and the only route for * a host that wires the tool itself). A `ctx.parentTaskId` wins when present. SendMessage ONLY — * the TaskStop/TaskOutput mounts deliberately have no such seat (no widening of the kill/read * surface). */ parentTaskId?: string; /** RB-390 — companion session axis of {@link parentTaskId} (RunInternals.parentSessionId): a * session-scoped sibling registers with owner = the parent's sessionId, which the parent's * taskId alone cannot satisfy when the two differ (same rationale as ToolExecuteContext.parentSessionId). */ parentSessionId?: string; /** the process-level background-child observer (RunnerDeps.onBackgroundChildEvent) — the * mount fills this so a SendMessage resume re-emits the spawn→tick→terminal family (fleet row * revival). Rides the OPTS closure like notify; RB-409's {@link enrichCtx} feeds the ctx twin. */ onBackgroundChildEvent?: (event: import("../core/types.js").BackgroundChildEvent) => void; /** design/151 §7 S3c — the durable execution-row ledger ({@link RunnerDeps.backgroundAgentStore}): * the tier-3 resolution rung (exact a* handle / roster name → durable row) and the claim-CAS * target. Tier-3 activates only when THIS + {@link mailbox} + {@link reviveSpawn} are all * present; anything less keeps the pre-S3c honest refusal texts byte for byte. */ agentStore?: import("../core/background-agent-store.js").BackgroundAgentStore; /** design/151 §7.1 — the durable mailbox ({@link RunnerDeps.mailboxStore}): a message is parked * here ONLY on the claim-winner path (append after the row CAS is won), consumed via * claim/lease + ack at spawn success — never a destructive drain. */ mailbox?: import("../core/mailbox-store.js").MailboxStore; /** design/151 §6.3 案 B — the trusted revival spawner: prepare-task wraps the MOUNTED delegation * tool's execute (object identity via the `agentListing` marker — never wire-name lookup, so a * caller's same-named shadow tool can never receive the trusted claim) with a fresh toolCallId * and `ctx.reviveClaim`. The revival is thereby the FULL spawn chain (observer frames, durable * lane re-arm, notify, park semantics) — nothing re-implemented here. */ reviveSpawn?: (req: { row: import("../core/background-agent-store.js").BackgroundAgentRecord; rev: number; prompt: string; peerSeed?: { hopChain: string[]; }; }) => Promise<{ isError?: boolean; content: string; details?: unknown; }>; /** RB-473 batch 2b — bounded disclosure for a deployment sink throwing inside the resume face's * isolation scope (threaded into {@link createSubagentResume}); absent = counted only. */ onNotifyError?: (failure: import("../core/safe-notify.js").SafeNotifyFailure) => void; /** * RB-409 — the mount's per-call ctx builder, forwarded to {@link defineTool} (see * {@link ToolCtxEnricher}). This tool is a `defineTool` PRODUCT, so before this seat existed its * body could only ever see `{toolCallId, signal}` and every `ctx.*` read below fell to its `opts` * fallback — the ctx-first arms were structurally dead on the first-party mount. The Runner mount * now passes its own trusted ctx builder here (the SAME one it applies to `spec.tools` tools), so * the two sources agree by construction rather than by a copied table. Absent ⇒ unchanged * behavior: the opts closure alone supplies the identity axes. */ enrichCtx?: ToolCtxEnricher; /** design/176 — caller tuning for the always-on peer-message admission gate (per-field * range-validated; a value outside its range — or one that is not a finite number — falls back to * that field's default and is ANNOUNCED once per process through {@link onNotice}; there is no off * switch). The Runner mount threads {@link import("../core/types.js").RunnerDeps.peerLane} * here. Read per call — a changed value governs the next message. */ lane?: Partial<PeerLaneConfig>; /** #551 — the deployment's structured notice sink, used by this face for ONE fact: a * {@link lane} field the resolver could not use falls back to its default and announces * `config.peer_admission_out_of_range` through here. The Runner mount threads * {@link import("../core/types.js").RunnerDeps.onNotice}; a direct mount that leaves it absent * still gets the line on `console.warn` (the shared delivery throat's unwired arm). */ onNotice?: (notice: import("../core/types.js").EngineNotice) => void; /** design/176 — the SENDING run's peer-identity carrier (trusted internals chain; ctx twin * {@link import("../core/types.js").ToolExecuteContext.peerSelfRef} wins when present). Supplies * the sender's bucket key and the hop token appended to every outbound chain. A direct mount * without it degrades to axis synthesis from `sessionId`/`owner`; with no axes at all the sender * is identity-less (rate/dedup skipped — hop and queue bounds still apply). */ peerSelf?: PeerSelfRef; /** design/176 — the SENDING run's inbound peer-chain ref (ctx twin wins): the chain of the most * recently consumed peer message, which this run's outbound sends extend. */ peerInbound?: PeerInboundChainRef; /** design/176 — the RECIPIENT identity of the {@link uplink} callback, PAIR-MINTED at the same * trust point that bound the callback (spawn internals assembly / the resume face's * currentParentNotify replacement): `uplink` is an opaque void callback and the parent axes * deliberately keep the ORIGINAL spawner on the revive arm while the callback points at the * WAKER — so the uplink's admission identity can only travel WITH the binding, never be derived. * A direct mount that wires `uplink` without this leaves L1 ungated (recorded honest boundary: * the library does not fabricate an identity on the host's behalf). */ uplinkRecipient?: PeerSelfRef; /** * Subagent transcript persistence — the "declared durability contradicted by reality" disclosure * sink: called (at most once per (scope, handle) per process, {@link announceTranscriptIntegrityGapOnce}) * when a durable row with a bound sessionId meets a session store that attests `not_found`. The * first-party mount wires an EngineNotice (`delegation.transcript_integrity`); absent ⇒ the * per-call honest refusal is the only loudness (unchanged behavior). */ onTranscriptIntegrityGap?: (handle: string, scope: string | undefined) => void; /** * design/385 §2 — the peer-session DIRECTORY ({@link import("../core/types.js").RunnerDeps.peerDirectory}): * opens the cross-session lane's two rungs on this face — the explicit `session.<id>` address arm * (ranked beside the precise a* id) and the LAST name rung (`name [ref]` over the directory's full * set, after every agent rung missed). Needs {@link mailbox} beside it AND that store's * `crossProcessSafe: true` declaration: with the directory wired but either missing, the lane stays * closed and every peer-addressed send refuses with the named reason (never a silent fall-through to * "no agent matches"). Absent ⇒ byte-identical pre-385 behavior. */ peerDirectory?: PeerDirectory; /** * design/385 — THIS session's own identity on the cross-session lane: its session id (the sender's * `from-session` / reply address, and the self-target guard), the scope its directory rows live in, * its display name (the `from-name` attestation), and the live fold of its own permission-mode class * ({@link import("./cross-session-judge.js").foldPermissionModeClass} over the wiring manifest's * `ask.effective`) — read at SEND time, attested on the parked record's typed `peerMeta.fromMode`. * The Runner mount fills it; a direct mount without it sends with no session attestation (the * recipient then judges it as "no class asserted"). */ peerSelfSession?: { sessionId: string; scope?: string; name?: string; modeClass?: () => PermissionModeClass | "unknown"; }; /** design/385 §5.1 — is `ListAgents` mounted beside this tool? Drives the "list them with ListAgents" * phrase (a face never names a tool the roster does not carry — an `excludeTools` may drop one of the * pair). Absent = unknown ⇒ the phrase is hedged. */ peerListingMounted?: boolean; } /** RB-382 — max chars of the `summary` arg (the short recap label, distinct from `message`'s much * larger {@link UPLINK_RESULT_MAX}) before ITS OWN truncation. Exported for direct unit-testing only * (module-internal path, same convention as MONITOR_SPILL_CAP_BYTES — not part of the src/index.ts * public surface): the tool's OWN schema already declares `summary: Type.String({ maxLength: 200 })`, * so a call arriving through the normal `AgentTool.execute` gate (`defineTool`'s `Value.Check`, see * core/tools.ts) can never carry a summary longer than this — the clip below is a defense-in-depth * belt for any caller that reaches this function body without going through that gate, not a live * truncation a model-facing call can trigger today. */ export declare const SEND_MESSAGE_SUMMARY_MAX = 200; /** RB-382 — clip a trimmed, non-empty `summary` to {@link SEND_MESSAGE_SUMMARY_MAX} chars, appending * the SAME "[… truncated: N chars total]" marker style every `message` truncation site in this file * already uses (the four delivery legs each silently did `slice(0, 200)` with no marker at all — a * double standard against `message`'s disclosed truncation in the same file, at least on paper — see * this constant's own doc comment for why the schema gate makes the branch unreachable in practice * today). The cap itself is unchanged; only the missing disclosure is added. RB-419 then folded those * four re-derivations into a SINGLE call site, immediately after the gate that makes the argument * non-empty — so this helper now has exactly one caller in the tool body. */ export declare function clipSendMessageSummary(raw: string): string; /** * design/276 (direct-mount guidance): the engine's first-party auto-mount declares this tool's safety * axes itself (`effect:"write"`, explicit `egress:false`). A host mounting this factory's product via * `spec.tools` suppresses that auto-mount, so its own ToolSpec declaration is what the safety scan * reads — declare `effect:"write"` there (a message is repeat-unsafe: replay = second delivery / * second revive). The auto-mode peer-referral tighten keys on the WIRE NAME and applies to a direct * mount all the same. */ /** * The continuation promise's REACH — how far "continue a finished agent" goes on a SendMessage mount — by the * deployment's subagent transcript tier: the ONE derivation (`resolveSubagentTranscriptTier`, the wiring * manifest's `subagentTranscripts` cell) over the mount's own seats. Without all three tier-3 seats (row * store, mailbox, reviver) there is no durable revival at all — session retention is the only lane (`none`). * With them, the session store the revival runs through (`runner.sessions`) decides: `full` keeps the * conversation across a restart, `rows` keeps the record but not the conversation (a process-local * transcript store), so only `full` may say "across restarts". Exported so every face that describes the * same reach (the ListAgents mount beside this tool) reads the same answer from the same seats. */ export declare function sendMessageContinuationTier(seats: { agentStore?: unknown; mailbox?: unknown; reviveSpawn?: unknown; runner: Pick<RunnerSelfSeat, "sessions">; }): SubagentTranscriptTier; export declare function createSendMessageTool(opts: SendMessageToolOptions): import("../internal/harness.js").AgentTool<Type.TObject<{ to: Type.TString; message: Type.TString; summary: Type.TOptional<Type.TString>; }>, unknown>; /** The receipt {@link createAgentContinuationVerb} resolves with — the SendMessage tool's own * receipt family, flattened to one text (same content, same `details.error` code vocabulary). */ export interface AgentContinuationReceipt { content: string; details?: unknown; isError?: boolean; } /** * Subagent transcript persistence — the OPERATOR continuation verb: the SendMessage delivery ladder * as a reusable, non-model face for a deployment's own continuation endpoint. SINGLE-SOURCE by * construction: the verb routes THROUGH `createSendMessageTool`'s product (status arms, admission * gates, preflight order, claim CAS + rollback, parked/running refusals and the whole refusal-code * family are the tool's own, byte-shared — never a twin implementation). The one fork is the * `userInitiated` mode bit ({@link OPERATOR_CONTINUATION_CTX}): `true` waives BOTH user-stop gates — * the durable row's `stoppedBy === "user"` arm and the live registry's stop-attribution arm — so an * explicit human "continue it" overrides an earlier human "stop it" (the winning claim's * clear-terminal-payload erases the durable mark; the model-facing tool NEVER softens). * * Authorization is three-layered and this verb owns only the first: ① the same * `canAccessAgentRecord` row predicate the tool runs, against the caller's `access` argument and * NOTHING ELSE (an out-of-scope handle reads not_found — non-leaking); ② "this really is an explicit * human instruction" is the DEPLOYMENT's obligation — * the same trust seat that stamps `stopSource:"user"` on the stop side (a server exposes this only * through a verified interactive-user principal endpoint, never to unattended machine clients); ③ the model * tool face has no such parameter (the bit travels on a module-private symbol — zero forgeable * surface from arguments). * * ACCESS TOTALITY: the `access` argument is the WHOLE row-reach identity of the call — a partially * specified one leaves the axes it omits UNDECLARED (default-deny), never backfilled from the mount. * See the identity-strip note in the body for why that has to be spelled at the options seat too. * * Store-conditional like every continuation face: with no durable stores wired the live-gate waiver * still works for same-process handles, and the durable leg refuses honestly. */ export declare function createAgentContinuationVerb(opts: SendMessageToolOptions): (handle: string, content: string, access: TaskAccess, o?: { userInitiated?: true; summary?: string; }) => Promise<AgentContinuationReceipt>;