@sema-agent/core
Version:
Stateless, task-oriented AI agent core
331 lines (330 loc) • 26.8 kB
TypeScript
/**
* The tool gate's RECORD FLOOR — everything about how one gate pass is recorded and how it ends:
*
* - the ONE mint of a {@link GateOutcome} ({@link mintGateOutcome}) — the record of what a tool-gate pass
* decided, what ask it settled and who asked. The gate accumulates two facts on its way to an exit (WHO
* refused, written once at the refusing site; WHAT was settled, written at the resolution site) and hands
* them here; the mint assembles the record, runs the invariant screen, and refuses a record the engine
* composed wrong. Every projection of the outcome (the deny observer's payload, the call's `tool_end`
* frame, the engine's per-call sideband) is the object minted here, never a re-derivation;
* - the settlement LEDGER of a pass ({@link createSettlementLedger}) — the one rule every attestation read
* goes through;
* - the PASS RECORD ({@link GatePass}) — the per-call cells the gate's station groups read and write, and
* the hook-machinery seats they borrow, built once per call by `runToolGate` and handed to every station;
* - the EXITS — the two phase-1 short-circuits ({@link engineFailClosedExit}, {@link hookDenyExit}) and the
* single deny / allow exit of the adjudicate chain ({@link exitGate}), the only places a pass turns into a
* `ToolGateResult`; plus the two observer-facing helpers every exit uses (the payload isolation clone and
* the deployment-lane crash report).
*
* A floor module (layer 0 of the prepare/run path): the two station modules (`gate-fold.ts`, `gate-lanes.ts`)
* and the gate's dispatch in `hooks.ts` all reach DOWN for it, and it reaches down only for the vocabulary,
* the reminder envelope and the text helpers. Its `import type` edges up to `hooks.ts` name the gate's public
* contract (`ToolGateInput`, `ToolGateResult`, the observer payload), which stays declared there.
*/
import { type ClassifierDenyCause, type DeniedBy, type GateOutcome, type ModeRefusal, type ModeRelease, type Settlement } from "../gate-outcome.js";
import type { AutoModeClassifierRound } from "../auto-mode.js";
import { type Arbitration, type AskClassification, type AskOrigin, type AskOriginFacts } from "../ask-origin.js";
import { type PermissionResult, type ResolvedAsk, type ToolCallRequest } from "../tool-policy.js";
import type { SafeNotifier } from "../safe-notify.js";
import type { HookSeatOutcome, PermissionDeniedPayload, PreToolUseResult, ToolGateInput, ToolGateResult } from "../hooks.js";
/** The ask a gate pass settled, as the gate captured it at its resolution site: the settlement the
* resolver composed and the origin word the ask carried. Both or neither (invariant I1). */
export interface SettledAsk {
settlement: Settlement;
origin: AskOrigin;
}
/** The facts a gate pass hands the mint. `deniedBy` is present exactly when the pass refused; `cause` only
* beside it, when the refusing round was a classifier round that did not rule (the station's attestation,
* read once by the fold or the classifier step); `settled` exactly when the pass consumed an ask's
* settlement (a person's yes that was later vetoed included). */
export interface GateExitFacts {
deniedBy?: DeniedBy;
cause?: ClassifierDenyCause;
settled?: SettledAsk;
/** The classifier CLASSIFICATION this pass ran, when it ran one on a decider that can name its model.
* Orthogonal to `deniedBy`: one that ALLOWED is attributed too (a fallback model's allow is exactly the case
* an operator needs to see), which is why it rides the disposition's shared member, not the deny arm. */
classifier?: AutoModeClassifierRound;
/** The permission mode RELEASED the surviving ask (the allow exit's account of it); never beside `deniedBy`. */
released?: ModeRelease;
/** The permission mode REFUSED the surviving ask — only beside `deniedBy: "mode"`. */
modeRefusal?: ModeRefusal;
}
/**
* Mint the outcome. A defect here is an ENGINE defect (a refusing site that did not say who refused, a
* settlement without its origin, a person's yes beside a non-veto layer's deny) and throws: the gate's
* own composition is not a caller input, so there is no fail-closed "deny" to fall back to that would be
* more honest than the fault.
*/
export declare function mintGateOutcome(facts: GateExitFacts): GateOutcome;
/** A direct refusal by one layer, with no ask settled — the five short-circuit exits (a hook that threw or
* never answered, the hook's own deny, the compliance lock, the plan-mode block). */
export declare function directDeny(deniedBy: Exclude<DeniedBy, "ask_resolution">): GateOutcome;
/**
* The gate's settlement LEDGER for one pass — the one place "which ask did this pass consume" is written,
* with ONE rule for every attestation read (the main adjudication, the approval-edit re-check, the gate's
* own ask resolutions), so the three sites cannot disagree about what an attested decision means:
* - an engine attestation on a decision object (the approval factory's wait ended inside a policy
* `check()`; an inherited lane resolved an ancestor's ask) is a settled ask of THIS call — read off the
* identity-keyed seat, never off a caller-composable member;
* - a deny carrying a REFUSAL attestation is the ask's own resolution (`ask_resolution`); a deny carrying a
* person's YES is a later layer's VETO (the yes stays on the settlement, the layer is the disposition —
* invariant I3);
* - the LATEST resolution wins (a re-check refusal replaces the first approval; a re-approval renews it);
* - a SURVIVING ask supersedes any settlement read off an earlier decision (a tighten minted a new
* question; whoever resolves it settles it afresh).
* For a resolver's object the attestation outranks the exposed member; the member is the fallback for a
* resolver seat that composes without attesting (a hand-built resolver; the ask lane's own intercept
* deny). TRUST BOUNDARY, stated: `ToolGateInput.resolveAsk` is wired by prepare from the engine's own
* resolver — a wrapper that spreads a resolved object (dropping the identity-keyed attestation) and
* REPLACES the member is inside the engine's trust line, not a deployment's; making the member
* attestation-only is the follow-up that migrates the ask lane's composition and the test resolvers
* onto an engine-owned mint.
*/
export interface SettlementLedger {
/** The ask this pass consumed, or undefined when no ask was settled (a direct deny, a plain allow). */
readonly settled: SettledAsk | undefined;
/** An engine-composed settlement the gate itself minted (the unshown content ask's refusal). */
set(settled: SettledAsk): void;
/** Record an ask's resolution: the resolver's settlement beside the ask's own origin word. */
fromResolved(resolved: ResolvedAsk, asked: PermissionResult, toolName: string): void;
/** Read an adjudication's attestation. For a deny, returns the refusing layer word (`ask_resolution`
* for an attested refusal, `policy` otherwise — a veto when a yes rides the settlement); undefined
* for an allow or an ask. */
fromDecision(decision: PermissionResult): DeniedBy | undefined;
/** Read a deny's attested classifier CAUSE for this call (an inherited station's `unavailable` /
* `parse_error` round travelling through an ancestor wrapper into this fold); undefined for a ruling
* block, a non-classifier deny, an allow or an ask. */
causeOf(decision: PermissionResult): ClassifierDenyCause | undefined;
/** Read a decision's attested CLASSIFICATION for this call — which model an ancestor's frozen classifier
* asked and which answered, on the allow arm as much as the deny arm. Undefined when no inherited
* classifier judged the call, or when the ancestor's decider could vouch for no name. */
classifierRoundOf(decision: PermissionResult): AutoModeClassifierRound | undefined;
/** A surviving ask supersedes whatever an earlier decision settled. */
supersede(): void;
}
export declare function createSettlementLedger(call: {
toolCallId: string;
toolName: string;
}): SettlementLedger;
/**
* Observe-only payload isolation for {@link import("../hooks.js").Hooks.permissionDenied}:
* clone the tool args before they ride the observer payload, so a hook mutating `payload.input` can never
* pollute the LIVE args object (later events / audit records share it). Same posture as the postToolUseFailure
* details clone (the gate-stations phase): `structuredClone` first; a non-structured-cloneable graph
* (functions/handles) falls back to a SHALLOW plain object/array copy (top-level mutation isolated);
* a non-object primitive passes through as-is (immutable anyway).
*/
export declare function cloneObserverInput(input: unknown): unknown;
/**
* The PreToolUse screening face as the gate's stations see it — the hook machinery of `runToolGate` (the seat
* runner, the per-call seat bound, the fresh per-screening context, the result screen, the two fail-closed
* reason texts) behind three methods, so the approved-edit re-screen in the lanes runs the SAME screening the
* phase-1 pass ran without the lanes module reaching into the hook machinery. Present on the pass exactly when
* a `preToolUse` face is wired.
*/
export interface PreToolUseScreening {
/** Run the deployment's PreToolUse face over `args` under the seat bound. The answer is the SEAT's, unscreened —
* the caller passes it through {@link PreToolUseScreening.screened} in the seat's own continuation. The seat
* promise is returned as is (no wrapping `async`): what the gate does after the face answers — the policy in
* phase 1, the re-check in the edit chain — runs on the tick the seat settles, as it did when this call was the
* seat runner's own; a wrapper that screened the answer inside a second `async` cost one more tick there. An
* expired seat is reported, not thrown; a crashing face rejects. */
screen(args: unknown): Promise<HookSeatOutcome<PreToolUseResult | undefined>>;
/** The result screen over an answered seat's value: an out-of-contract decision becomes the ordinary hook deny. */
screened(answer: PreToolUseResult | undefined): PreToolUseResult | undefined;
/** The model-facing text for a face that THREW while screening `subject`. */
crashReason(subject: string, err: unknown): string;
/** The model-facing text for a face that never ANSWERED while screening `subject` (the timeout arm also
* reports on the deployment lane; a task-abort expiry does not). */
unansweredReason(subject: string, cause: "timeout" | "aborted"): Promise<string>;
/** The model-facing text for the face's OWN deny of this call (unwrapped; every exit wraps it with the leg's mark). */
deniedReason(answer: PreToolUseResult): string;
/** The model-facing text for an answer the decision receipt refused (an ENGINE fail-closed, not the face's deny). */
refusedReason(answer: PreToolUseResult): string;
}
/**
* ONE gate pass — the record the station groups (`runGateFold`, `runGateLanes`) and the exits read and write,
* built once per call by `runToolGate` before the screening pass. What used to be the closure-captured `let`
* cells of a 1 500-line function are its MUTABLE cells (each written by the station named on it, read by the
* ones after); what the stations borrow from the hook machinery are its SEATS (fixed for the pass). Ownership
* classes follow the phase-Input convention: `borrowed-readonly` = fixed for the pass, the stations only read
* it; `borrowed-mutable` = a cell a station writes.
*/
export interface GatePass {
/** borrowed-readonly — the gate's input: every deployment seat the stations read by name (`egress`,
* `orgRules`, `persistedRules`, `autoMode`, …), and the identity / reminder mark the exits stamp. */
readonly input: ToolGateInput;
/** borrowed-readonly — the gate's event, read ONCE at entry (the dispatch's entry destructure): the exit compares the
* final args against THIS event's input, never against a re-read of the input record. */
readonly event: ToolGateInput["event"];
/** borrowed-readonly — the deployment's policy face, captured ONCE at entry before the screening pass. The fold
* adjudicates through it and the edit chain re-adjudicates through the SAME binding: a stateful accessor on the
* input cannot hand the fold one policy and the approved-edit re-check another (or none). */
readonly adjudicate: ToolGateInput["adjudicate"];
/** borrowed-readonly — the ask resolver, captured ONCE at entry (same rule: the first-round resolution and the edit
* round's re-ask resolve through one binding). */
readonly resolveAsk: ToolGateInput["resolveAsk"];
/** borrowed-readonly — the durable park closure, captured ONCE at entry (the three park sites hand it the same
* thirteen seats; presence is judged off this one read). */
readonly suspendAsk: ToolGateInput["suspendAsk"];
/** borrowed-readonly — `input.event.toolName`, read once. */
readonly toolName: string;
/** borrowed-readonly — `input.event.toolCallId`, read once. */
readonly toolCallId: string;
/** borrowed-readonly — ONE read of the per-call signal for the whole gate call (the one-read
* discipline the settlement screen already states): every conditional seat invocation (the three wait
* seats plus the three suspendAsk park sites) judges presence and passes the value from this same cell, so
* a stateful accessor cannot answer the arity check with a signal and the call with something else. */
readonly callSignal: AbortSignal | undefined;
/** borrowed-readonly — the one host-callback isolation scope of this gate call (the deny observer,
* the hook-error trace sink, the org and rule lanes' disclosure sinks). */
readonly notifier: SafeNotifier;
/** borrowed-readonly — the settlement LEDGER of this pass (the binding is fixed; the ledger's own methods
* are how the resolution sites write it — one rule per read, see {@link createSettlementLedger}). */
readonly ledger: SettlementLedger;
/** borrowed-readonly — the ONE emission seat of the deny observer for this pass, bounded and contained like
* every other seat (the hook machinery owns the seat runner; the exits call this). */
readonly notifyPermissionDenied: (payload: Omit<PermissionDeniedPayload, "signal">) => Promise<void>;
/** borrowed-readonly — the PreToolUse screening face ({@link PreToolUseScreening}), or undefined when no
* `preToolUse` face is wired (the edit chain then re-screens nothing, as before). */
readonly screening: PreToolUseScreening | undefined;
/** borrowed-readonly — the station group that runs AFTER the fold (the lanes: org layer, rule lane, classifier,
* parks, resolution, edit chain, and the exit they end in), handed the pass by the fold's LAST station on the
* fold's own completing tick. A seat rather than an import because the two station modules sit on one layer
* and may not name each other; a seat rather than a second `await` in the dispatch because that await is a
* suspension point of its own: the org face and the rule store read HOST state the host may refresh
* concurrently, and the tick on which they are consulted is the contract — when the station bodies were one
* function, the org face was invoked in the same continuation that ran the last tighten, and a refresh queued
* behind the policy's answer could not land in between. Returns the gate result (a park's suspension or the
* exit's record). Writer: the dispatch (`runToolGate`, which wires `runGateLanes`). Reader: the fold's tail. */
readonly afterFold: (pass: GatePass) => Promise<ToolGateResult>;
/** borrowed-mutable — the FINAL args: the model's input, rewritten by the phase-1 hook, by the policy
* rewrite on the ask path, by the content-ask lane's presented snapshot and by the exits' rewrite fold.
* Writers: the dispatch (phase 1), the lanes, the exits. */
currentInput: unknown;
/** borrowed-mutable — the adjudication request. Minted over the model's input so the record is whole from
* the first station, and RE-MINTED by the adjudication station over the FINAL post-screen args (the
* pre-existing `const req` site); `args` is re-pointed at the policy rewrite on the ask path and at the
* content-ask lane's presented snapshot. Writers: the fold (mint), the lanes (`args`). */
req: ToolCallRequest;
/** borrowed-mutable — the PreToolUse `additionalContext` collected for this call (pushed into; the binding
* is fixed). Writers: the dispatch (phase 1), the lanes (sandbox admission note, the edit re-screen). */
readonly preToolContext: string[];
/** borrowed-mutable — the phase-1 hook's ASK, remembered for the fold. Only ever an ask (assigned under the
* `r.action === "ask"` arm); typed as the narrowed member so the fold's reconstruction reads the ask-only
* fields without a runtime re-test that could, if it ever failed, silently skip the promotion (i.e. loosen).
* Writer: the dispatch (phase 1). Readers: the fold, the sandbox-admission conjunct. */
hookAsk: Extract<PermissionResult, {
action: "ask";
}> | undefined;
/** borrowed-mutable — the phase-1 hook answered its own `allow` (the exact word): the allow layer's hook member, which
* speaks only while no policy rewrote the arguments after the hook saw them. Writer: the dispatch (phase 1). Reader: the
* lanes' allow layer (its one reader). */
hookAllowed?: true;
/** borrowed-mutable — set by a park attempt that FAILED (see `ParkAttemptFailed`). Read at the single deny
* exit — whatever the fallback chain then decides to say, it says it knowing the park was tried first.
* Writers: the three park sites. */
parkFailed: string | undefined;
/** borrowed-mutable — WHO refused, written ONCE by the refusing site (a direct policy / org / classifier
* deny, a hook re-screen refusal, the write-protection re-judge, the edit chain's cap, an ask resolution
* that ended as a refusal). The exit mints the outcome from it beside the ledger's `settled`; a refusal
* that reaches the exit without it is an engine defect and the mint throws. */
deniedBy: DeniedBy | undefined;
/** borrowed-mutable — a refusing station's STRUCTURED details (the shell read station's refusal card), written
* beside `deniedBy` by that station alone; the exit puts it on the gate result for the loop's error result.
* OPTIONAL (the `probeUnanswered` form): the dispatch that builds the pass has nothing to declare here. */
denyDetails?: unknown;
/** borrowed-mutable — the shell read station's containment card behind a standing MANDATED ask (an out-of-root
* or unestablishable read): when that ask ends as a refusal (a person's no, nobody to ask), the exit puts the
* card on the gate result, so the refusal's `tool_end` still says WHAT was found (`readonly_out_of_root`) beside
* WHO refused (`ask_resolution`). Writer: the fold's read station. */
shellReadDetails?: unknown;
/** The read station PLACED every write the command makes inside the write roots (`ShellReadVerdict.writesPlaced`) — the
* one precondition the mode's file-command acceptance reads (the reference's path checks precede its acceptEdits arm). */
shellWritesPlaced?: true;
/** The read station did NOT place the command's writes because a `cp` / `mv` segment carries an option word
* (`ShellReadVerdict.unplacedOptions`) — the one non-placement reason the mode's card and the headless refusal name. */
shellUnplacedOptions?: {
readonly program: "cp" | "mv";
};
/** borrowed-mutable — the FORM of the classifier round behind a deny (`unavailable` / `parse_error`), written
* beside `deniedBy` by the gate's own classifier step (its attestation, read once) or by the fold / the
* re-check when an inherited station's attested deny arrives through the chain; undefined for a ruling
* block and every non-classifier deny. The exit mints `GateDisposition.cause` from it. */
denyCause: ClassifierDenyCause | undefined;
/** borrowed-mutable — the classifier CLASSIFICATION this pass ran ({@link AutoModeClassifierRound}).
* Written ONCE by the gate's classifier step from the verdict the decider handed back, on EVERY arm (allow
* included — a fallback model's allow is the fact the record exists for); absent when no classifier was
* consulted or the decider could name no model. Both exits mint `GateDisposition.classifier` from it.
* OPTIONAL rather than `| undefined` (the `probeUnanswered` form): the dispatch that builds the pass is
* not a station of this lane and has nothing to declare here. */
classifierRound?: AutoModeClassifierRound;
/** borrowed-mutable — the standing verdict. The no-policy default until the adjudication station writes the
* policy's answer; then rewritten by every station that changes the verdict (fold, tightens, org, rule
* lane, stamp, classifier, sandbox, content lane, resolution, edit chain). */
decision: PermissionResult;
/** borrowed-mutable — which of the gate's OWN post-fold stations (the tightens and the unstated station) minted the standing ask, if one did — an
* input of the ask-origin derivation (`AskOriginFacts.tightened`). Written at most once per pass: every
* tighten fires only over a surviving `allow`, and the first one to fire leaves an `ask` the others do not
* touch. Recorded by the gate rather than read off `decisionReason` because that member is a policy's to
* compose (a policy stamping `"safety"` on its own ask must read as `policy`). Writer: the fold. */
tightenedBy: AskOriginFacts["tightened"];
/** borrowed-mutable — the reversibility probe was consulted on this pass and DID NOT ANSWER (it threw or
* timed out). The out-of-root mandate has no other source than the probe's answer, so an unanswered probe
* leaves the call's boundary UNKNOWN: the allow layer (a stored allow rule, the read-only reader) must
* not clear the surviving ask — a person confirms it. Writer: the fold. Reader: the lanes' mandate. */
probeUnanswered?: true;
/** borrowed-mutable — the probe ANSWERED and reported that the read boundary DECLINED to judge where this
* command reads ({@link import("../tool-spec.js").ReversibilityVerdict.boundaryDeclined}). The member above's
* twin on the answered side, and narrower: the ask stays ordinary for every ANSWERER, and only the allow
* layer's read-only-command arm stands down. Writer: the fold. Reader: that arm, and nothing else. */
probeBoundaryDeclined?: true;
/** borrowed-mutable — the shell read station could not establish what this command reads (finding `unestablishable`
* or `declined`): a mode's release of the call is a release past the leg's read-deny table, announced as such.
* Writer: the fold's read station. Reader: the release point. */
shellReadUnestablished?: true;
/** borrowed-mutable — the shell read station minted the standing ask's directory-grant offer
* (`probeReadRootCandidate`). Read by the classify seat's shape probe, which withdraws the offer when its
* own objection stands beside the station's — the grant answers the station's finding, not the shape's.
* Writer: the fold's read station. */
shellReadOffer?: true;
/** borrowed-mutable — the write-protection judge covered this call and its table MATCHED: the allow
* layer must not clear the surviving ask, a live decider still may. Writers: the fold's fourth tighten,
* on BOTH its arms (a write tool's target), and the fold's read station (a shell command's written targets,
* `ShellReadVerdict.writeProtected`). Reader: the lanes' mandate. */
writeProtected?: true;
/** borrowed-mutable — the same judge could NOT RESOLVE the target: no automatic lane may answer the
* ask. Writer and reader as above; the pair is mutually exclusive (one verdict per judgment). */
writeProtectionUnresolved?: true;
/** borrowed-mutable — the rewrite remembered BEFORE a hook-ask promotion / tighten, re-applied on the
* approved path (a redaction is not cancelled because a person confirmed). Writer: the fold. Readers: the
* fold's probe and write-protection judges, the lanes, the allow exit. */
policyRewrite: unknown;
/** borrowed-mutable — the standing ask's CLASS SET and folded row (`classifyAsk`), written at the lanes' origin
* stamp and at every re-mint (the denial-limit fallback, the approved-edit round); read by the classifier
* station, the sandbox leg and the arbitration point. Writer: the lanes. */
askClassification?: AskClassification;
/** borrowed-mutable — the mode arbitration's verdict when it RELEASED or REFUSED the standing ask (the exit
* projects it onto the disposition). Writer: the lanes' arbitration point. */
arbitration?: Exclude<Arbitration, {
kind: "ask";
}>;
}
/**
* The engine's fail-closed block of phase 1 — the PreToolUse face THREW or never ANSWERED, so the engine
* decides, on its own rule, that the call does not run. BOUNDARY (deliberate asymmetry with
* {@link hookDenyExit} — do NOT unify the two): a hook `deny` is the HOOK's decision and the observer
* exclusion covers it; a crash / an unanswered seat produces no decision at all, so the deny OBSERVER fires
* here — an attribution channel whose job is "why was this call denied" must see an engine deny, or the
* deployment's only evidence of a broken hook is a tool that mysteriously stops working.
*/
export declare function engineFailClosedExit(pass: GatePass, reason: string): Promise<ToolGateResult>;
/** The hook's OWN deny in phase 1. A PreToolUse hook deny does NOT fire `permissionDenied`
* (the exclusion recorded on `Hooks.permissionDenied`) — this return stays observer-free. */
export declare function hookDenyExit(pass: GatePass, deniedReason: string): ToolGateResult;
/**
* The exit of the adjudicate chain — the pass has a standing `deny` or `allow` (every ask was resolved or
* parked before this), and this is where it becomes the gate result and the ONE record of the pass.
*/
export declare function exitGate(pass: GatePass): Promise<ToolGateResult>;