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