UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

366 lines (365 loc) • 17.1 kB
import { abortedSeatDeny, decisionText, describeThrown, receiveHookResult, isEngineRefusal } from "./tool-policy.js"; import { asThrownError } from "./thrown-value.js"; import { createSettlementLedger, engineFailClosedExit, hookDenyExit } from "./runner/gate-exit.js"; import { traceHookCrash } from "./gate-crash-trace.js"; import { runGateFold } from "./gate-fold.js"; import { runGateLanes } from "./gate-lanes.js"; export { normalizeOrgGateVerdict, normalizePersistedRuleHit } from "./rule-lane-reads.js"; export { cloneObserverInput } from "./runner/gate-exit.js"; import { brandPolicyAskClass } from "./ask-class.js"; import { isAskOrigin, screenAskClassMembers } from "./ask-origin.js"; import { inlineUntrusted } from "./untrusted-text.js"; export { formatHookFeedback } from "./reminder-mint.js"; import { createSafeNotifier } from "./safe-notify.js"; import { wireOf } from "./tool-name-readings.js"; export const DEFAULT_HOOK_TIMEOUT_MS = 600_000; export const MAX_HOOK_TIMEOUT_MS = 2_147_483_647; const POST_ABORT_GRACE_MS = 1_000; export function postAbortGraceMs(remainingMs) { return Math.max(0, Math.min(POST_ABORT_GRACE_MS, remainingMs)); } const badHookTimeoutReported = new WeakSet(); export function resolveHookTimeoutMs(supplied, report, owner) { if (supplied === undefined) return DEFAULT_HOOK_TIMEOUT_MS; if (Number.isFinite(supplied) && supplied >= 0 && supplied <= MAX_HOOK_TIMEOUT_MS) return supplied; if (report !== undefined && !(owner !== undefined && badHookTimeoutReported.has(owner))) { if (owner !== undefined) badHookTimeoutReported.add(owner); try { report(new Error(`Hooks.timeoutMs must be a non-negative finite number no greater than ${MAX_HOOK_TIMEOUT_MS} (got ${String(supplied)}) — ` + `every hook seat falls back to the ${DEFAULT_HOOK_TIMEOUT_MS}ms default`)); } catch { } } return DEFAULT_HOOK_TIMEOUT_MS; } export async function runHookSeat(seat, bound, call) { void seat; const timeoutMs = resolveHookTimeoutMs(bound.timeoutMs, bound.onBadTimeout, bound.owner); const taskSignal = bound.signal; const controller = new AbortController(); const pending = Promise.resolve(call(controller.signal)); const startedAt = performance.now(); return await new Promise((resolve, reject) => { let settled = false; let listening = false; let timer; const clearTimer = () => { if (timer !== undefined) clearTimeout(timer); timer = undefined; }; const finish = (act) => { if (settled) return; settled = true; clearTimer(); if (listening) taskSignal?.removeEventListener("abort", onAbort); act(); }; const expire = (cause) => finish(() => { void pending.then(undefined, () => { }); resolve({ expired: true, cause }); queueMicrotask(() => { try { controller.abort(); } catch { } }); }); const collapseToGrace = () => { if (settled) return; clearTimer(); timer = setTimeout(() => expire("aborted"), postAbortGraceMs(timeoutMs - (performance.now() - startedAt))); }; const onAbort = () => (bound.abortEnds === true ? expire("aborted") : collapseToGrace()); timer = setTimeout(() => expire("timeout"), timeoutMs); if (taskSignal !== undefined) { if (taskSignal.aborted) { onAbort(); } else { taskSignal.addEventListener("abort", onAbort, { once: true }); listening = true; } } pending.then((value) => finish(() => resolve({ expired: false, value })), (err) => finish(() => reject(asThrownError(err)))); }); } export function hookSeatExpiredError(seat, timeoutMs, cause, consequence) { return new Error(cause === "timeout" ? `the deployment's ${seat} hook did not answer within its ${timeoutMs}ms bound (Hooks.timeoutMs) — ${consequence}` : `the deployment's ${seat} hook had not answered when the task was cancelled — ${consequence}`); } const reflectApply = Reflect.apply; const getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; const getPrototypeOf = Object.getPrototypeOf; const objectPrototype = Object.prototype; function findEnvSlot(env, key) { for (let o = env; o !== null && o !== objectPrototype; o = getPrototypeOf(o)) { const desc = getOwnPropertyDescriptor(o, key); if (desc !== undefined) return { holder: o, desc }; } return undefined; } function readEnvPrimitive(env, key) { const slot = findEnvSlot(env, key); if (slot === undefined) return undefined; return slot.desc.get !== undefined ? reflectApply(slot.desc.get, env, []) : slot.desc.value; } function createCwdReader(env) { const slot = findEnvSlot(env, "cwd"); if (slot === undefined) return undefined; const getter = slot.desc.get; const initial = getter !== undefined ? reflectApply(getter, env, []) : slot.desc.value; if (typeof initial !== "string") return undefined; let last = initial; return () => { const desc = getOwnPropertyDescriptor(slot.holder, "cwd"); const value = desc === undefined ? undefined : desc.get !== undefined ? desc.get === getter ? reflectApply(desc.get, env, []) : undefined : desc.value; if (typeof value === "string") last = value; return last; }; } export function createHookEnvCapabilities(env) { const face = Object.create(null); const canonicalPath = readEnvPrimitive(env, "canonicalPath"); const exists = readEnvPrimitive(env, "exists"); const readLink = readEnvPrimitive(env, "readLink"); const cwd = createCwdReader(env); if (typeof canonicalPath === "function") { face.canonicalPath = (path, abortSignal) => reflectApply(canonicalPath, env, [path, abortSignal]); } if (typeof exists === "function") { face.exists = (path, abortSignal) => reflectApply(exists, env, [path, abortSignal]); } if (typeof readLink === "function") { face.readLink = (path, abortSignal) => reflectApply(readLink, env, [path, abortSignal]); } if (cwd !== undefined) { face.cwd = cwd; } return Object.freeze(face); } const IDENTITY_AGENT_NAME_MAX = 80; export function mintHookInvocationIdentity(facts) { return Object.freeze(Object.assign(Object.create(null), { sessionId: facts.sessionId, taskId: facts.taskId, legKind: facts.legKind, isDelegatedChild: facts.isDelegatedChild, ...(facts.insideFork === true ? { insideFork: true } : {}), ...(facts.agentName !== undefined ? { agentName: inlineUntrusted(facts.agentName.slice(0, 320), IDENTITY_AGENT_NAME_MAX) } : {}), ...(facts.spawnName !== undefined ? { spawnName: inlineUntrusted(facts.spawnName.slice(0, 320), IDENTITY_AGENT_NAME_MAX) } : {}), ...(facts.agentType !== undefined ? { agentType: inlineUntrusted(facts.agentType.slice(0, 320), IDENTITY_AGENT_NAME_MAX) } : {}), ...(facts.parentToolCallId !== undefined ? { parentToolCallId: facts.parentToolCallId } : {}), })); } function preToolUseCrashReason(subject, err) { const cause = inlineUntrusted(describeThrown(err), 200); return (`a PreToolUse hook crashed while screening ${subject}; the call was NOT executed (fail-closed). ` + `This is a failure of the deployment's hook, NOT of the tool itself — an identical retry reaches the ` + `same hook and fails the same way. Hook error: ${cause || "(no message)"}`); } function preToolUseUnansweredReason(subject, timeoutMs, cause) { return cause === "timeout" ? `a PreToolUse hook did not answer within its ${timeoutMs}ms bound while screening ${subject}; the call was ` + `NOT executed (fail-closed). This is a failure of the deployment's hook, NOT of the tool itself — the ` + `screening face never returned a verdict, so nothing was decided about this call either way.` : `the task was cancelled while a PreToolUse hook was still screening ${subject}; the call was NOT executed ` + `(fail-closed). No verdict was reached — this is the run ending, not the tool failing.`; } function screenPreToolUseResult(r) { if (r === undefined) return undefined; return receiveHookResult(r); } export function createPreToolUseConstraintPolicy(preToolUse, env, onCrash, timeoutMs) { return brandPolicyAskClass({ check: async (req, signal) => { if (signal?.aborted) return abortedSeatDeny(req, `the inherited PreToolUse screening of "${req.toolName}" was not consulted: the run or turn was already interrupted`, "hook"); let r; const boundMs = resolveHookTimeoutMs(timeoutMs, onCrash); try { const seat = await runHookSeat("preToolUse", { timeoutMs: boundMs }, (sig) => preToolUse(req.toolName, req.args, { toolCallId: req.toolCallId, toolName: req.toolName, signal: sig, ...(env !== undefined ? { env } : {}) })); if (seat.expired) { try { onCrash?.(hookSeatExpiredError("preToolUse", boundMs, seat.cause, `the inherited screening of "${req.toolName}" was refused fail-closed`)); } catch { } return { action: "deny", message: preToolUseUnansweredReason(`this call to "${req.toolName}"`, boundMs, seat.cause), decisionReason: "hook" }; } r = screenPreToolUseResult(seat.value); } catch (err) { try { onCrash?.(err); } catch { } return { action: "deny", message: preToolUseCrashReason(`this call to "${req.toolName}"`, err), decisionReason: "hook" }; } if (r === undefined) return { action: "allow" }; if (r.action === "deny") { return { action: "deny", message: decisionText(r) ?? `tool "${req.toolName}" blocked by a PreToolUse hook`, decisionReason: "hook" }; } if (r.action === "ask") { return { action: "ask", message: decisionText(r) ?? `approval required for "${req.toolName}" (inherited PreToolUse hook)`, decisionReason: "hook", ...(r.requiresRealApproval === true ? { requiresRealApproval: true } : {}), ...(r.updatedInput !== undefined ? { updatedInput: r.updatedInput } : {}), }; } const stated = r.action === "allow" ? { decisionReason: "hook" } : {}; return r.updatedInput !== undefined ? { action: "allow", updatedInput: r.updatedInput, ...stated } : { action: "allow", ...stated }; }, }, "hook"); } export function askCarryRowMembers(carry) { if (carry === undefined) return {}; const origin = carry.origin; const classed = screenAskClassMembers(carry.askClass, carry.classes); return { ...(carry.denialLimitFallback !== undefined ? { denialLimitFallback: { ...carry.denialLimitFallback } } : {}), ...(isAskOrigin(origin) ? { origin } : {}), ...(classed !== undefined ? classed : {}), }; } export function createPreToolUseScreening(preToolUse, input, hookSeatMs, notifier) { if (preToolUse === undefined) return undefined; const { toolCallId, toolName } = input.event; const screenedName = wireOf("gate.preToolUse", input.event); const hookCtx = (seatSignal) => ({ toolCallId, toolName: screenedName, ...(seatSignal !== undefined ? { signal: seatSignal } : {}), ...(() => { const c = input.trackedCwd?.(); return typeof c === "string" ? { cwd: c } : {}; })(), ...(input.hookEnv !== undefined ? { env: input.hookEnv } : {}), ...(input.identity !== undefined ? { identity: input.identity } : {}), }); const bound = { timeoutMs: hookSeatMs, ...(input.abortSignal !== undefined ? { signal: input.abortSignal } : {}), abortEnds: true }; return { screen: (args) => runHookSeat("preToolUse", bound, (sig) => preToolUse(screenedName, args, hookCtx(sig))), screened: screenPreToolUseResult, crashReason: preToolUseCrashReason, unansweredReason: async (subject, cause) => { if (cause === "timeout") traceHookCrash(input, hookSeatExpiredError("preToolUse", hookSeatMs, cause, `${subject} was NOT executed (fail-closed) and the screening face was signalled to stop`), notifier); return preToolUseUnansweredReason(subject, hookSeatMs, cause); }, deniedReason: (answer) => decisionText(answer) ?? `tool "${toolName}" blocked by a PreToolUse hook`, refusedReason: (answer) => String(answer.message ?? `the PreToolUse hook's result for "${toolName}" was refused by the engine`), }; } export async function runToolGate(input) { const { event, preToolUse, adjudicate, resolveAsk, suspendAsk } = input; const { toolCallId, toolName } = event; const callSignal = input.callSignal; const modelInput = event.input; const notifier = createSafeNotifier(input.onNotifyError !== undefined ? { onError: input.onNotifyError } : undefined); const hookSeatMs = resolveHookTimeoutMs(input.hookTimeoutMs, (err) => traceHookCrash(input, err, notifier)); const observerBound = { timeoutMs: hookSeatMs, ...(input.abortSignal !== undefined ? { signal: input.abortSignal } : {}) }; const notifyPermissionDeniedSeat = async (payload) => { if (input.permissionDenied === undefined) return; await notifier.notifyAsync(async () => { const seat = await runHookSeat("permissionDenied", observerBound, (sig) => input.permissionDenied?.({ ...payload, signal: sig })); if (seat.expired) throw hookSeatExpiredError("permissionDenied", hookSeatMs, seat.cause, "the deny observation was abandoned; the deny itself is unchanged"); }, "toolGate.permissionDenied"); }; const screening = createPreToolUseScreening(preToolUse, input, hookSeatMs, notifier); const pass = { input, event, adjudicate, resolveAsk, suspendAsk, toolName, toolCallId, callSignal, notifier, ledger: createSettlementLedger({ toolCallId, toolName }), notifyPermissionDenied: notifyPermissionDeniedSeat, screening, afterFold: runGateLanes, currentInput: modelInput, req: { toolName, args: modelInput, toolCallId, ...(() => { const f = input.toolFaceOf?.(toolName); return f !== undefined ? { face: f } : {}; })() }, preToolContext: [], hookAsk: undefined, parkFailed: undefined, deniedBy: undefined, denyCause: undefined, decision: { action: "allow" }, tightenedBy: undefined, policyRewrite: undefined, }; if (screening !== undefined) { let r; let unanswered; try { const seat = await screening.screen(pass.currentInput); if (seat.expired) unanswered = seat.cause; else r = screening.screened(seat.value); } catch (err) { const reason = screening.crashReason(`this call to "${toolName}"`, err); traceHookCrash(input, err, notifier); return await engineFailClosedExit(pass, reason); } if (unanswered !== undefined) { const reason = await screening.unansweredReason(`this call to "${toolName}"`, unanswered); return await engineFailClosedExit(pass, reason); } if (r) { if (isEngineRefusal(r)) { return await engineFailClosedExit(pass, screening.refusedReason(r)); } if (r.additionalContext) { pass.preToolContext.push(r.additionalContext); } if (r.action === "deny") { return hookDenyExit(pass, screening.deniedReason(r)); } if (r.updatedInput !== undefined) { pass.currentInput = r.updatedInput; } if (r.action === "ask") { pass.hookAsk = r; } if (r.action === "allow") pass.hookAllowed = true; } } return await runGateFold(pass); }