@sema-agent/core
Version:
Stateless, task-oriented AI agent core
300 lines (299 loc) • 22 kB
TypeScript
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>;