@sema-agent/core
Version:
Stateless, task-oriented AI agent core
261 lines (260 loc) • 18.5 kB
TypeScript
import { type AutoModeDecider, type AutoModeDenialLimitOptions, type AutoModeDenialTracker, type DenialLimitFallback, type UnarmedDenialLimitFallback } from "../auto-mode.js";
import { type AutoModeArmingRecipe } from "../auto-mode-arming.js";
import { type ClassifierUnavailableSeat } from "../classifier-unavailable-arm.js";
import { type PermissionResult, type ResolvedAsk, type ToolCallRequest } from "../tool-policy.js";
import { type EngineNotice } from "../types.js";
import { type AskClass, type AskClassFacts, type AskClassification, type AskOrigin, type AskOriginFacts } from "../ask-origin.js";
import type { Prepared, StopForDenialLimit } from "./contracts.js";
/**
* The classifier DENIAL-LIMIT arms of the tool gate's inherited (delegation) lane, extracted
* from `prepareTask` as a phase module (extract, don't accrete). The gate's OWN
* classifier block site lives in tool-policy; these are the pieces the runner threads around it:
* · the tracker attached to a re-supplied chain entry ({@link attachRebuiltDenialTrackers});
* · the run's ONE typed stop seat + the closure that fills it ({@link createDenialLimitStop});
* · the frozen classifier's judgment with the count-then-fallback step, shared by the two inherited
* wrapper arms and the approved-edit re-check ({@link judgeInheritedClassifier});
* · the post-resolution settlement of a fallback ask — a person's allow resets the streak, a deny
* nobody made stops the run ({@link settleDenialLimitFallback});
* · the request-side carry of the ask's origin word and its fallback member, with the auto-deny window
* armed at the hand-out to a live approver and nowhere else ({@link gateAskCarry} for the gate's own
* station, {@link inheritedAskCarry} for the three inherited-lane ones).
* Each helper is pure over what prepare hands it; the fold/ask seams themselves stay in the runner.
*/
/** An ancestor chain entry's classifier half as the arms read it: the decider, the per-run denial
* tracker beside it (absent on an entry that counts nothing), and — on a re-supplied entry — the
* ancestor's recorded arming recipe, whose denial-limit bounds the fresh tracker is built under. */
export interface InheritedAutoMode {
decider: AutoModeDecider;
denialTracking?: AutoModeDenialTracker;
arming?: AutoModeArmingRecipe;
/** The ancestor's live mode seat: the decider answers only while it reads `auto` (absent ⇒ as recorded). */
liveMode?: {
readonly current: import("../ask-origin.js").PermissionMode;
};
}
/**
* A re-supplied chain entry (a cross-process redemption rebuilt its ancestor's classifier from the
* recorded recipe — `rebuildAutoModeDecider` hands back a decider and its arming, nothing live)
* carries no denial tracker, and an entry with a decider but no tracker would count nothing: the
* rebuilt classifier could block without bound on the redeemed leg, the exact gap the limit closes.
* Attach a FRESH tracker (a fresh COUNT is the documented cross-process semantics) under the ancestor's recorded BOUNDS tightened by this deployment's own
* (`tightenDenialLimit` — the same rule and the same call the arming fold makes, so the
* recipe's account of the criteria and the tracker's actual bounds cannot drift). Before this fix the
* bounds came from this deployment alone, so an ancestor that allowed three consecutive blocks
* before a person had to look could be redeemed into a leg that allowed five. An entry with no
* READABLE recipe leaves the deployment's own bounds in force — there is no ancestor account to bind
* by, and manufacturing one would state criteria nobody chose.
*
* Entries that already carry a tracker (the in-process chain from a live ancestor) are kept BY
* IDENTITY; only a tracker-less armed entry is re-wrapped (its `policy`, the identity the fold and
* the pass-through compare, is untouched). ONE tracker per DECIDER identity, not per entry: a parent
* attaches its one decider to both its hook entry and its caller-policy entry, and a count split
* across two trackers could alternate forever without either reaching the bound. The bounds are
* therefore folded across EVERY entry naming that decider before any tracker is minted — two entries
* of one decider carrying different recipes get the strictest reading of the two rather than
* whichever the iteration happened to reach first. The mint stays lazy, so a bad bound refuses only a
* prepare that actually has such an entry (the armed leg's own mint screens the knob first).
*/
export declare function attachRebuiltDenialTrackers<T extends {
autoMode?: InheritedAutoMode;
}>(entries: ReadonlyArray<T> | undefined, denialLimit: AutoModeDenialLimitOptions | undefined): T[] | undefined;
/**
* The gate's typed stop seat (`Prepared.gateStopRef`) and the one closure that fills it — the
* nobody-judged arm of the denial-limit fallback: the fallback ask ended with no one having judged it
* ⇒ stop the run with `classifier.denial_limit` (CC throws "Agent aborted: too many classifier denials
* in headless mode"). Once per run leg (the first such fallback owns the terminal); the deny that
* triggered it stands as that call's result. Minted once so every mint site — the main gate's seat, the
* inherited-lane arms, and the durable resume station — stops the SAME leg through the SAME seat.
* `abort` is the run's own abort (controller + harness — the one a cancel fires; handed in because the harness
* is built after the seat): the durable stop fires it before the resume leg's prompt, whose door reads it.
*
* TWO LANES, ONE SENTENCE. The synchronous lane reached no approver at all; the durable lane parked the
* fallback, and the decide that came back named a settlement word the ONE table
* ({@link SETTLEMENT_NOBODY_JUDGED}) reads as "nobody judged this". Both are the same fact — the
* classifier's bound fell back to a person and no person answered — so both stop through here and say
* the same thing, differing in ONE clause: which of the two ways nobody answered. Composing the
* sentence in one place is what keeps them from drifting into two accounts of one event.
*/
export declare function createDenialLimitStop(opts: {
sessionId: string;
runId: string;
onNotice: ((notice: EngineNotice) => void) | undefined;
abort: () => void;
}): {
gateStopRef: Prepared["gateStopRef"];
stopForDenialLimit: StopForDenialLimit;
};
/** The ask arm of a permission decision — what the inherited lane arbitrates. */
export type InheritedAsk = Extract<PermissionResult, {
action: "ask";
}>;
/**
* The delegation half of the live gate's classifier eligibility: the SAME class row (`classifyAsk` over the
* ask's own members) answers for the FROZEN classifier — an ask raised by a hook, minted by an
* explicit `ask` permission rule, or already a denial-limit fallback is exactly as out of the frozen
* decider's reach as the live one's, because the person's / hook's standing decision travels with the
* frozen entry and a frozen decider resolving it would re-open the same hole one process over. The
* three GATE-HELD facts of the derivation are not this station's to judge: an ancestor's frozen
* classifier runs inside the policy fold, BEFORE the live gate's org layer has spoken and before its
* question-tool and marked-call lanes run, so they read as absent here and those asks meet their
* exclusions downstream, where the facts exist. Excluded asks fall through to the frozen approver
* chain, the delegation lane's original chain. The word and the class set are derived inside
* {@link judgeInheritedClassifier} and threaded to every reader ({@link inheritedAskCarry}).
* The MANDATE marks here are empty (the base names no seat): the policy-chain phase builds each call's facts over this
* base with THIS task's tool seat — the seat the request's risk axes are read from — so a marked tool's ask is
* classified by the seat at the station as at the gate. What the ancestor's ask carries on ITSELF still answers.
*/
export declare const INHERITED_STATION_FACTS: AskOriginFacts;
/** The members of a surviving ask that ride onto the approval request with a COMPUTED value — what the
* four request mint stations (the gate's own and the three inherited-lane ones) spread after the seats
* they spell themselves: the ask's origin word, its class (the word and the set, carried together) and its
* denial-limit fallback. */
export interface AskRequestCarry {
origin?: AskOrigin;
askClass?: AskClass;
classes?: readonly AskClass[];
denialLimitFallback?: DenialLimitFallback;
}
/** The GATE's own mint station: every member is read off the surviving decision, which is the gate's
* OWN snapshot (its stamp point spreads the policy's object and stamps the word and the class onto the copy),
* so the origin and the class are the engine-derived ones and the fallback member is the one the gate's
* classifier step minted. */
export declare function gateAskCarry(decision: InheritedAsk, liveApprover: boolean, tracker: AutoModeDenialTracker | undefined): AskRequestCarry;
/** An INHERITED-lane mint station (the two wrapper arms and the approved-edit re-check): the ask is an
* ancestor policy's own decision, never engine-stamped, so EVERY member comes from the judgment
* {@link judgeInheritedClassifier} handed back — the word and the classification it derived under the station
* facts and the fallback member it captured — each read ONCE there and threaded here, never re-read off the
* caller-owned object (a stateful accessor could otherwise answer the card with one value and the
* settlement with another; a self-declared `origin` / `askClass` / `classes` on the ancestor's decision is never
* read). */
export declare function inheritedAskCarry(judged: Pick<Extract<InheritedClassifierJudgment<InheritedAsk>, {
kind: "resolve";
}>, "origin" | "classification" | "fallback" | "ask">, liveApprover: boolean, tracker: AutoModeDenialTracker | undefined): AskRequestCarry;
/** What {@link judgeInheritedClassifier} hands back to the arm that called it. */
export type InheritedClassifierJudgment<A extends InheritedAsk> =
/** The frozen classifier allowed. `result` is the allow the arm RETURNS — minted at the judging station so
* the model attribution rides the same one object the deny arm's does (an arm re-minting its own
* `{action:"allow"}` drops it); it already carries the ask's rewrite when the arm declared one. */
{
kind: "allow";
result: PermissionResult;
}
/** A block below the bound — the exact deny the ancestor's own gate would have produced. */
| {
kind: "deny";
result: PermissionResult;
}
/** Proceed to the frozen approver with `ask`: the incoming ask unchanged (classifier excluded or not
* armed), or — at the bound — the fallback ask minted from it (`mintedHere`).
* `fallback` is the member riding the ask either way (an incoming one is a deeper ancestor's).
* `origin` is the ask's word under the station facts, threaded to the request mint: on the two
* unchanged-ask branches it is the SAME read the exclusion judged by (a caller-owned decision with a
* stateful accessor cannot answer the exclusion with one word and the card with another); on the
* bound branch it is derived from the re-spoken ask this station minted (its own object).
* `classification` is the class set of the ask handed on, under the same station facts: on the two unchanged-ask
* branches the one read the exclusion judged by, on the unavailable and bound branches a read of the object this
* station minted (each adds a real-approval bit, which the set must reflect). */
/** `ask` is the incoming ask unchanged (the arm's receipt snapshot of the ancestor's decision), or — an UNAVAILABLE classification with a live frozen
* approver — that ask with the classifier sentence appended; without one it is a `deny` whose attested cause reads `unavailable`. */
| {
kind: "resolve";
ask: A;
fallback: UnarmedDenialLimitFallback | undefined;
mintedHere: boolean;
origin: AskOrigin;
classification: AskClassification;
};
/**
* The ancestor's frozen classifier, run BEFORE its frozen approver in the ancestor's own gate order,
* with the denial-limit count folded in (CC 2.1.250 count-then-judge): `allow` ⇒ the classifier's
* auto-allow (and the streak resets); `block` below the bound ⇒ deny, the exact verdict the ancestor's
* gate would have produced; `block` AT the bound ⇒ the ask is re-spoken in the classifier's voice as
* the fallback (`decisionReason:"classifier"`, `requiresRealApproval`, the member carrying counts +
* window) for the arm to resolve at the frozen approver; `parse_error` ⇒ the same block path with the CC
* parse-failure sentence (counted); `unavailable` ⇒ the gate's own exit (`classifierUnavailableArm`) on the
* frozen approver — a live one resolves the incoming ask, none ⇒ a DENY that says so (CC 2.1.250 form), never
* counted; excluded / no classifier ⇒ the incoming ask, unchanged. The member may ALREADY be on the incoming
* ask (a deeper ancestor's fallback, minted inside the nested fold — the case the exclusion names):
* it rides to the approver and the headless arm unchanged, whichever tracker minted it; only a
* tracker that counted THIS streak is reset by the person's later allow (`mintedHere`).
* `req` is the request as the classifier must see it (the arm's presented / edited args);
* `subject` is the noun the deny names ("this call" on the wrapper arms, "the approved edit" on the
* re-check).
*
* ONE MINT for the classification's model attribution: every decision this station hands back — the allow it
* mints from `allowRewrite` as much as either deny — carries it on the identity-keyed seat beside the one
* carrying the FORM, and the arms return the station's object instead of re-minting their own. The
* descendant's fold reads both seats at its single attestation read. A decider with no candidate ladder
* vouches for no name and the seat stays empty (absence = unknown); a block AT the bound reaches no seat at
* all, since it becomes an ask and the disposition then names that resolution.
*/
/** The provenance sentence an inherited card carries when the ancestor's ask brought no words of its own — the one spelling the request mint and the frame join share. */
export declare const inheritedAskFallbackMessage: (toolName: string) => string;
export declare function judgeInheritedClassifier<A extends InheritedAsk>(opts: {
autoMode: InheritedAutoMode | undefined;
/** The leg's live permission mode (the gate's seat): under `dontAsk` / `bypassPermissions` / `plan` the decider is not
* dispatched (the mode's arbitration decides first). Absent ⇒ a decider on the chain is dispatched (subject to the
* entry's own `autoMode.liveMode`). */
liveMode?: () => import("../ask-origin.js").PermissionMode;
ask: A;
req: ToolCallRequest;
signal: AbortSignal;
subject: "this call" | "the approved edit";
/** The rewrite the arm's allow would have carried (the ask's own `updatedInput`, the re-check's edited
* args) — handed IN so the allow is minted here, attested, once; absent ⇒ a bare allow. */
allowRewrite?: unknown;
/** An `unavailable` verdict's fork facts (`classifierUnavailableArm`): is the frozen approver live; who hears it. */
unavailableSeat?: ClassifierUnavailableSeat;
/** The station's facts for this call (the base with this task's tool seat); absent ⇒ {@link INHERITED_STATION_FACTS}. */
facts?: AskClassFacts;
}): Promise<InheritedClassifierJudgment<A>>;
/** The resolver's answer as the settlement reads it. */
export type FallbackResolution = Pick<ResolvedAsk, "action" | "settlement" | "approverUnavailable">;
/** A deny nobody JUDGED, at a wrapper arm — read off the one table over the settlement words
* ({@link SETTLEMENT_NOBODY_JUDGED}) rather than listed here, so a word added to the set answers this
* question by getting a row and not by falling out of a disjunction. `approver_unavailable` is NOT
* headless here — it floats to the main gate, which owns that arm (a park, or the fail-closed refusal). */
export declare function headlessDenyAtFold(r: FallbackResolution): boolean;
/** The re-check's reading: the approved-edit helper has NO park route (an approved edit is
* re-adjudicated in place), so an approver that reports unavailable is as headless as none at all. */
export declare function headlessDenyAtRecheck(r: FallbackResolution): boolean;
/**
* After the frozen approver answered a fallback ask (CC `yR`): a person's allow ends the ancestor's
* streak — only on the tracker that counted it (`mintedHere`; a deeper ancestor's fallback leaves
* this entry's count alone); a deny nobody made (`headless`, the arm's own reading) is the headless
* arm — the run stops through the one seat (`stop`). Any other answer settles nothing here. A no-op
* when the ask was not a fallback.
*/
export declare function settleDenialLimitFallback(opts: {
fallback: UnarmedDenialLimitFallback | undefined;
mintedHere: boolean;
tracker: AutoModeDenialTracker | undefined;
resolved: FallbackResolution;
headless: (r: FallbackResolution) => boolean;
stop: StopForDenialLimit;
toolName: string;
toolCallId: string;
}): void;
/**
* The observer `resolveAskBound` hands `resolveAsk` for a DETACHED approver's
* settlement (the race arm released the wait; the approver's promise lives on):
* · an approval the released wait never consumed becomes the `task.late_approval` notice a host
* can forward on its own wire (the wire-facing half of "release, not a verdict" — the notice
* asserts non-consumption only, never an arrival order the resolver cannot prove);
* · an unconsumed rejection goes to the deployment's error face (`onError(phase:"hook")`) — a
* callback failure the engine no longer awaits is still a callback failure, never silence.
*/
export declare function lateAskSettlementObserver(args: {
toolName: string;
toolCallId: string;
sessionId: string;
runId: string;
taskId?: string;
onNotice: import("../types.js").RunnerDeps["onNotice"];
onError: import("../types.js").RunnerDeps["onError"];
}): (late: {
kind: "approve";
} | {
kind: "error";
error: unknown;
}) => void;