UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

346 lines (345 loc) • 24.5 kB
import type { AgentTool } from "../internal/harness-types.js"; import type { ToolPolicy } from "./tool-policy.js"; /** The reserved tool name for AskUserQuestion — shared so the durable-resume path (runtask) can recognize it * as a content-ask whose SHOWN questions must not be rewritten by an operator `updatedInput` (design/80 D-F). * * RESERVED means the whole engine keys content-ask semantics on this name: a policy `ask` on it is NEVER * routed to a synchronous PERMISSION approver — on the main gate, the degraded fall-through, and the * inherited ancestor wrappers alike (a permission frame's allow/deny cannot carry an ANSWER; CC parity: * AskUserQuestion never raises a permission card). What it DOES reach, in this order, is the run's own * content-ask channel: a LIVE question face answers it in-stream (the gate routes the question, not a * verdict), and only with no live face — or with the durable gate forced — does it park durably or answer * the typed refusal. Permission-GATING this tool stays unsupported by design; content-ROUTING it is the * supported path. A caller-supplied tool reusing this name inherits the same routing; don't. */ export declare const ASK_USER_QUESTION_TOOL_NAME = "AskUserQuestion"; /** * AskUserQuestion (design/64 §5). A model-callable tool that lets the agent ask the USER a structured * multiple-choice question (which approach? which of these?) — distinct from the design/37 permission `ask` * ("may I run this tool?"). The model only EMITS the call; the harness routes it to the caller via the * {@link OnQuestion} seam (deployment supplies a real human / UI / HITL). **LLM has zero IO** — it cannot * collect answers itself. Headless (no `onQuestion`) returns a typed "no human available" so the model * proceeds with a default instead of hanging. **design/80 D-F (answer fence):** the answer no longer assumes a * trusted control plane — in the Supervisor topology it arrives over the wire (an operator `/decide`), an * injection vector. So a `selected` value is echoed as a chosen option ONLY if it is one of the offered option * labels (`selected ⊆ options` — the model's own text), and the operator's free-text (`note`, plus any off-list * input) is wrapped in an untrusted-DATA fence so it can never be read as instructions. Abort while waiting is * handled by the §9 interrupt path (the pending call is closed). Mounting (CC non-interactive `isEnabled` * parity): only when a delivery face exists — a live `onQuestion`, or a durable park consumer (`checkpointStore` * + `durableApproval`/`forceDurableGate`), or an explicit `TaskSpec.interactiveTools: true` — see that knob. */ export interface AskQuestionOption { /** The choice text the user picks. */ label: string; /** What this option means / its trade-off. */ description: string; /** design/116 W3 (CC parity): optional preview content (mockup / code snippet / comparison) shown while this * option is focused. The engine only TRANSPORTS it — it rides the {@link AskQuestionRequest} to `onQuestion` * untouched; rendering (markdown box, side-by-side, …) is the shell/HITL surface's job. */ preview?: string; } export interface AskQuestion { /** The full question. */ question: string; /** A short header/label (≤ ~12 chars) for UIs. */ header: string; /** 2-4 mutually-exclusive choices (unless multiSelect). */ options: AskQuestionOption[]; /** Allow selecting more than one option. */ multiSelect?: boolean; } export interface AskQuestionRequest { toolCallId: string; /** 1-4 questions. */ questions: AskQuestion[]; /** Runner-filled source identity (parallel to {@link import("./tool-policy.js").AskRequest}.principal, * design S1d §1): the issuing task's end-user principal (design/62), for per-user attribution by an * AGGREGATING onQuestion. Worker/tool cannot set it. Undefined ⇒ none (a non-aggregating onQuestion * ignores it — additive, no-op). `readonly`: Runner-populated metadata. Populated on every path that * actually CALLS a question face: the in-tool synchronous call, and the gate's pre-execution content-ask * routing. A durable suspend invokes no face at all, so it carries none. */ readonly principal?: string; /** Runner-filled source-task identity (= issuing task's session id), for per-worker attribution by an * aggregating onQuestion. **worker-unforgeable for Runner-created delegated subagents/team members** * (those child specs do not pass TaskSpec.sessionId — the Runner mints the id); a top-level caller CAN * still set `TaskSpec.sessionId`, so this is NOT a globally unforgeable run id (design S1d §1.3). * `readonly` + same "only where a face is really called" caveat as `principal`. */ readonly sourceTaskId?: string; /** * The canonical hash of the tool-call arguments this question was built from, as computed by the * engine's one `boundInputHashOf` (the same digest the durable park binds its checkpoint to, so the * two lanes are reconcilable against each other). Present only when the engine routed the question * — a directly-mounted tool calling its own face carries none. * * The split to keep straight, because the two words are easy to swap: * - `(sourceTaskId, toolCallId, boundInputHash)` is the RECONCILIATION key — "is this the same * question, on the same call, in the same run?". It is what lets an in-stream delivery be matched * against a parked one, and what an audit trail joins on. It is NOT an identity: all three * components repeat for two sequential deliveries of an identical question. * - {@link deliveryId} is the IDENTITY — "which delivery attempt is this?" — and is the only one of * the two a registry may key open requests on. * Keying a registry on the reconciliation key closes the second delivery on the first one's ticket. */ readonly boundInputHash?: string; /** * The IDENTITY of this delivery attempt: engine-minted, unique per delivery, never derived from * anything a model or a caller supplies. This is the key for the single-winner obligation in * {@link OnQuestion} — a registry keys open requests on it (scoped by tenant), so a deadline expiry * and a UI submission for the SAME delivery resolve to exactly one winner, and a late answer is * refused instead of applied to a question nobody is waiting on any more. * * Why not the tuple you might reach for first: `toolCallId` comes from the model/provider and is not * guaranteed unique within a session, `sourceTaskId` can be caller-supplied, and the argument hash * is identical for two identical questions. Their combination therefore repeats for two sequential * deliveries of the same question — under which a registry would close the second on the first's * ticket, or hand the second the first's late answer. That tuple remains the RECONCILIATION key * (see {@link boundInputHash}); this field is the identity. One names the question, the other names * the attempt, and only the attempt is unique. * * Present on EVERY delivery the engine makes, by either route (the tool's own call and the gate's * pre-execution routing). Optional on the type only because a deployment may mount the tool itself. */ readonly deliveryId?: string; } /** One question's answer: the selected option label(s) + an optional free-text note. */ export interface QuestionAnswerItem { header: string; selected: string[]; note?: string; } export interface QuestionAnswer { answers: QuestionAnswerItem[]; } /** * The "nobody could be reached for this one" arm of {@link OnQuestionOutcome}. Its contract is narrow and * load-bearing: return it ONLY when the deployment made ZERO interaction on this question — no card was * shown, no partial selection was collected, nothing was submitted. That is what lets the engine re-route * the same question (park it durably for a later human, or answer the honest "it was NOT shown" refusal) * without any risk of the same question being decided twice. * * A face that DID show the question and then failed (disconnect, deadline after render, adapter error) * must THROW instead — the two are deliberately different values with different engine handling, and * folding a failure into this arm is what would put one question in front of two deciders. */ export interface QuestionUnavailable { readonly kind: "unavailable"; } /** * The three-valued outcome of a content-ask delivery (one value per thing that can actually happen): * · a {@link QuestionAnswer} — the human answered (including "selected nothing", which IS an answer); * · {@link QuestionUnavailable} — nobody was reachable and nothing was shown (see its contract); * · a THROW — delivery was attempted and failed. Never collapse a throw into `unavailable`. */ export type OnQuestionOutcome = QuestionAnswer | QuestionUnavailable; /** How an {@link OnQuestionOutcome} reads. `"contradictory"` covers a value claiming BOTH arms at * once (the `unavailable` discriminant alongside an `answers` array — structural typing permits it, and * the outcome is deployment-supplied and may cross a wire), a value claiming NEITHER arm (a failed * decode must not read as "the user selected nothing"), and a value that cannot be read at all. */ export type QuestionOutcomeReading = /** The answer, already narrowed AND captured as an inert, density-validated clone — a caller * neither re-reads the caller's value to get at it nor needs a `structuredClone` of its own. */ { readonly shape: "answered"; readonly answer: QuestionAnswer; } | { readonly shape: "unavailable"; } | { readonly shape: "contradictory"; }; /** * THE reader for a question outcome, and the only place its fields are touched. Total by construction: * every way the value can misbehave — a throwing getter, both arms at once — lands on * `"contradictory"`, which the callers refuse rather than resolve. That refusal is the point: reading a * contradictory value as unavailable would discard an answer somebody may have given AND re-route the * question to a second decider, while reading it as answered would deliver an answer the face may have * meant to withhold. Neither guess is safe, so neither is made. */ export declare function classifyQuestionOutcome(outcome: OnQuestionOutcome): QuestionOutcomeReading; export declare function canonicalizeCapturedPlainData(node: unknown, seen?: WeakSet<object>): boolean; /** * Narrow an {@link OnQuestionOutcome} to its no-interaction arm — the ONLY value that licenses * re-routing a question to a later decider, so it is read strictly (the discriminant must be present * and the value must not also carry answers). * * For a FORWARDING wrapper relaying this seam. The engine's own decision points do not use it: they * call {@link classifyQuestionOutcome} ONCE and switch on the result, because the value is * deployment-supplied and its fields may be getters — classifying twice lets an outcome read as * answered on one call and unavailable on the next, which is precisely how one question would reach * two deciders. A wrapper that needs more than "is this the unavailable arm" should classify once too. */ export declare function isQuestionUnavailable(outcome: OnQuestionOutcome): outcome is QuestionUnavailable; /** * The content-ask seam (design/64 §5.2): the deployment routes an AskUserQuestion to a real human/UI and * returns their selection. Parallel to (and independent of) {@link import("./tool-policy.js").OnAsk} — a * permission ask is allow/deny, a content ask is a choice. `signal` fires on task abort: race your wait so * an unanswered question is released at the deadline (it does not hang the worker). * * **Single-winner obligation (the deployment's half).** A face that races a deadline against a real UI * owns the atomicity: before returning {@link QuestionUnavailable} it must CLOSE the open request, so a * submission arriving one tick later is refused rather than applied. A signal race alone is not enough — * two independent resolutions of one question is the shape this seam exists to prevent. Key the registry * on {@link AskQuestionRequest.deliveryId} (scoped by tenant) — NOT on the call id or the argument hash, * neither of which is unique per delivery. The engine consumes exactly one outcome per call id * and never asks the same call twice, so honoring this contract makes the whole path single-winner; core * does NOT serialize question delivery ACROSS runs (a shared UI seat is the deployment's closure), only * within a run — the tool executes sequentially, so one run never has two questions in flight. */ export type OnQuestion = (req: AskQuestionRequest, signal?: AbortSignal) => Promise<OnQuestionOutcome>; /** * design/173 §8.1/§8.2 — why a synthetic self-answer instruction (the NO_HUMAN text) was returned: * `"seam_absent"` = no `onQuestion` face was wired at all; `"callback_failed"` = a face WAS wired * and threw (shell disconnect / timeout — the more common field case, and the more deceptive one: * the deployment believes it has this limb); `"declined_unavailable"` = a face was wired, it was * called, and it affirmed that nobody was reachable for this question (nothing was shown). The three * must never collapse — "not installed", "installed but broken" and "installed, working, and nobody * home" are three different operator actions. */ export type SyntheticContinuationReason = "seam_absent" | "callback_failed" | "declined_unavailable"; /** design/173 §8.1 — who produced the answer the model continues on. The engine never fabricates an * answer: the synthetic arm returns a self-answer INSTRUCTION, hence the honest name. */ export type AskAnswerContinuationSource = "human_response" | "synthetic_self_answer_instruction"; /** * design/173 §8.1 — the AskUserQuestion tool card (`details.type: "ask-question"`, registered in * `CC_DETAIL_TYPES` — the card set is a two-way contract: mint and registration travel in the SAME * change). The in-band structured marker is the AUTHORITATIVE provenance signal (disclosure frames * can drop or reorder; a consumer must never match the NO_HUMAN prose). Two shapes: * - the CONTINUATION card — every answered exit, human or synthetic (`runContinues: true` is the * warning-not-error semantic: the run proceeds by design, a headless deployment must not be * marked failed over it); * - the INTERACTIVE-FAILURE card — the run refuses the silent synthetic continuation and reports a * coded tool failure instead (`isError: true`). Two codes, kept apart for the same reason the * reasons above are: `"question.human_channel_failed"` = the wired channel threw; * `"question.human_unavailable"` = the channel worked and said nobody was reachable. */ export type AskQuestionCardDetails = { type: "ask-question"; /** The question's id (= the tool call id — the same key the park/HITL faces use). */ questionId: string; continuationSource: AskAnswerContinuationSource; runContinues: true; /** Present iff `continuationSource` is synthetic. */ reason?: SyntheticContinuationReason; } | { type: "ask-question"; questionId: string; code: "question.human_channel_failed" | "question.human_unavailable"; reason: "callback_failed" | "declined_unavailable"; }; /** * The ONE mint point for the continuation card. Every exit that continues the run on * an answer — human or synthetic, in the tool or on the engine's routing lane — builds its card here, so * a new delivery point cannot quietly reopen the silent-degrade hole by shipping without a card. */ export declare function askQuestionContinuationCard(questionId: string, continuationSource: AskAnswerContinuationSource, reason?: SyntheticContinuationReason): AskQuestionCardDetails; /** * The ONE shape validator for a question batch, shared by the tool body and by the engine's routing lane * (which validates BEFORE it puts anything in front of a person — a malformed batch must be refused to * the model, not rendered). Returns the model-facing error text, or `undefined` when the batch is well * formed. Total: it never throws on untrusted field shapes. */ export declare function validateAskQuestions(questions: unknown): string | undefined; /** design/173 件3 — engine-side options for the mounted tool (prepare wires them; a deployment * mounting the tool directly may too). All optional: absent = pre-173 behavior. */ export interface AskUserQuestionToolOptions { /** The run's RESOLVED interaction posture — see {@link import("./types.js").TaskSpec.interactionPosture} * for the three-leg resolution (spec > the spawning run's resolved posture > deps); prepare passes * that one resolved value, the same one the posture door reads. * Under `"interactive"` BOTH no-answer arms of a wired channel are coded tool FAILURES instead of a * silent synthetic continuation — a channel that THROWS returns `question.human_channel_failed`, one * that reports nobody reachable returns `question.human_unavailable` — because an interactive * deployment must face both. `"headless"`/absent keep the existing degrade. */ posture?: "interactive" | "headless"; /** The EXPLICIT knob (default off) letting posture `"interactive"` continue with the synthetic * self-answer instruction after all, on BOTH hardened arms (channel threw / nobody reachable); * disclosure still minted. */ interactiveFallback?: boolean; /** Engine sink for the synthetic-continuation disclosure (the onError parallel leg — phase * `"degraded"`, classification `"no-human-autoanswered"`). Deduped **per ask-call** (the dedup key * is the tool-call id; one call may carry 1–4 questions and still discloses ONCE — "per question" * would misread against that array), not per run: a second ask-call in the same run still * discloses. The suppression set lives for ONE prepare (one leg): a continuation leg re-discloses * the same call id — deliberate, or the second leg's host would be blind; a host aggregating * across legs dedups on (sessionId, toolCallId) itself. Swallow-guarded — an observer bug never * faults the ask. */ onSyntheticContinuation?: (info: { questionId: string; reason: SyntheticContinuationReason; }) => void; /** * The call id of the ONE pending question this leg is redeeming an operator's approval for. An * approval was spent to get that question executed, so an `unavailable` outcome on THAT call must not * be absorbed into the synthetic self-answer: the operator has to learn their decision bought a * question nobody answered, and it becomes the coded failure `question.human_unavailable` instead. * Scoped to the id rather than to the leg — a NEW question raised later on the same leg had no * approval spent on it and keeps the ordinary continuation. (A THROW keeps its own posture-driven * handling on every call; that arm is unchanged.) */ redeemedApprovalCallId?: string; /** Digest of the question batch that approval was granted for, paired with * {@link redeemedApprovalCallId}. Call ids are provider-supplied and can repeat, so the id alone * would let a LATER question inherit the claim that an operator approved it. */ redeemedApprovalQuestionsHash?: string; } export declare function createDurableQuestionPolicy(): ToolPolicy; /** * Placeholder {@link OnQuestion} that mounts the AskUserQuestion tool on a worker with NO live human * (the durable-ask suspend side, TC-5.4). It must never actually run — the durable-question policy * suspends the call before execution — so reaching it means the wiring is missing the policy/store; * it throws a configuration error rather than silently telling the model "no human is available". */ export declare const QUESTION_AWAITS_RESUME: OnQuestion; /** * Brand a face that can answer exactly ONE pre-bound call and nothing else — the engine builds one * when a resume carries an operator's answer for the checkpointed question. It is a real face for that * one call id and a dead end for every other question, which is why it must not be mistaken for a run's * live question channel: routing a NEW question to it would turn "ask a human" into a guaranteed * failure on a leg where the honest outcome is to park the new question for its own decision. * Returns the same function, so it can wrap the construction site inline. */ export declare function markBoundOnlyQuestionFace<F extends OnQuestion>(face: F): F; /** * THE named predicate for "this run has a question face that can actually answer a question put to it", * shared by every reader (the gate's routing decision, the tool mount, the wiring manifest's * `question.wired`, and the resume guard). One derivation, because the ways of getting it wrong point in * different directions and all of them are silent: * · a non-function seat value (a JS caller passing junk) is not a face at all; * · {@link QUESTION_AWAITS_RESUME} IS a function, and IS the documented standard assembly of the durable * topology — but its whole contract is that it must never run. Counting it as live would route real * questions into a configuration error instead of the park the deployment built; * · a {@link markBoundOnlyQuestionFace} face answers one already-decided call and refuses the rest. * Callers resolve the seat first (`spec.onQuestion ?? deps.onQuestion`) and pass the resolved value. */ export declare function isLiveQuestionFace(face: unknown): face is OnQuestion; /** Build the AskUserQuestion tool. When `onQuestion` is undefined the tool still mounts but returns the * headless default (so a run started without a human in the loop never hangs). */ export declare function createAskUserQuestionTool(onQuestion?: OnQuestion, /** design S1d §1.2: Runner-filled source identity, merged into the {@link AskQuestionRequest} so an * aggregating onQuestion can attribute per worker/user. Omitted ⇒ fields stay undefined (no-op). */ source?: { principal?: string; sourceTaskId?: string; }, opts?: AskUserQuestionToolOptions): AgentTool; /** * The ONE producer of the "engine stripped the spawn turn's question face" fact. The strip (removing `onQuestion` * from a child spec — or from the parent SPEC seat a child is built from — when the child OUTLIVES the request that * spawned it) and the disclosure flag (`RunInternals.questionFaceStripped` → the wiring manifest's * `question.wired: "stripped_bg_lane"` + the posture door's exemption) are PAIR-PRODUCED from this single expression — * never inferred from spec shape, never minted anywhere else. Three families ride it: the Agent tool's session-scoped * background lanes (spec-override form), its retain snapshots (destructure-drop form; the flag then rides * `internalsSnapshot`, so a revived leg keeps the exemption), and the Workflow tool (submit-and-return: every agent it * spawns outlives the launching request, so the host's seat is stripped before it reaches the launch site). WHY: * `spec.onQuestion` is by contract a per-REQUEST lane torn down with its turn; handing it to a leg that runs hours later * would strand the child asking into a closed surface (`AskUserQuestion` awaits the answer). A key REMOVED and a key set * `undefined` resolve identically through the engine's `spec.onQuestion ?? deps.onQuestion` reads — a process-level * `RunnerDeps.onQuestion` still reaches the child; only the turn-bound value drops. Trust posture: RunInternals is a * PUBLIC trusted-caller parameter, so the flag is an HONESTY device (the engine disclosing its own strip), not a * security gate. The flag states "a face WAS removed here" — minted only when one actually existed (an inherently * faceless lane reads `absent`, not `stripped_bg_lane`, and collects no exemption). */ export declare function stripSpawnTurnQuestionFace<S extends { onQuestion?: OnQuestion; }>(spec: S): { spec: Omit<S, "onQuestion">; internalsFlag: { questionFaceStripped?: true; }; };