UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

261 lines (260 loc) • 18.5 kB
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;