UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

108 lines (107 loc) • 8.39 kB
import { canonicalizeCapturedPlainData, classifyQuestionOutcome, isLiveQuestionFace } from "../ask-question.js"; import { CHECKPOINT_FEATURES, CheckpointError, checkpointVersionOf, winnerFromOutcome } from "../checkpoint-store.js"; import { sanitizeUntrustedText } from "../untrusted-text.js"; import { assertOutcomeText, captureHostDecision, describeSuppliedValue, pendingContentAskCallId, refuseSuppliedKeys, sameWinner, SUPPLIED_RECORD_KEY } from "./decide-continuation.js"; const REFUSED_DECIDE_KEYS = [ SUPPLIED_RECORD_KEY, ["settledBy", 'a retired key — the host supplies decision FACTS as hostDecision{decidedBy: "person" | "sla_timeout" | "policy", approver?} and core mints the settlement word from them'], ["approver", "a retired key — the attribution rides the facts it belongs to, as hostDecision.approver"], ]; export function resumePolicyOutcome(input) { const { cp, outcome, config, runner } = input; let suppliedAnswer; let redeemedAnswer; let plainPolicyOutcome; if (cp.gate.kind === "human" || cp.gate.kind === "irreversible_ask") { const decide = outcome; suppliedAnswer = decide.answer; if (suppliedAnswer !== undefined) { const reading = classifyQuestionOutcome(suppliedAnswer); if (reading.shape !== "answered") { throw new CheckpointError("checkpoint.invalid_outcome", "resume carried a content-ask `answer` that does not read as an answer (malformed, sparse, or not capturable as plain data) — " + "refusing pre-CAS rather than fabricating an empty human response; the checkpoint stays pending", { field: "answer" }); } redeemedAnswer = reading.answer; } const updatedInputRaw = decide.updatedInput; let updatedInput; try { updatedInput = updatedInputRaw === undefined ? undefined : structuredClone(updatedInputRaw); } catch { throw new CheckpointError("checkpoint.invalid_outcome", "resume carried an `updatedInput` that could not be captured as plain data — refusing pre-CAS; the checkpoint stays pending"); } if (Object.is(updatedInput, -0)) updatedInput = 0; if (updatedInput !== undefined && !canonicalizeCapturedPlainData(updatedInput)) { throw new CheckpointError("checkpoint.invalid_outcome", "resume carried an `updatedInput` outside the plain-JSON value domain (a Map/Set/Date/buffer, a sparse or expando-carrying array, a non-finite number, or a cycle) — " + "such a value reads differently on the two sides of the durable store; refusing pre-CAS, the checkpoint stays pending"); } const decision = decide.decision; if (decision !== "allow" && decision !== "deny") { throw new CheckpointError("checkpoint.invalid_outcome", `resume decision "${describeSuppliedValue(decision)}" is outside the policy_ask domain — a decide is exactly "allow" or "deny"; refusing pre-CAS, the checkpoint stays pending`); } refuseSuppliedKeys(decide, REFUSED_DECIDE_KEYS); const hostDecision = captureHostDecision(cp, decide.hostDecision, "policy_ask", { word: decision, allows: decision === "allow" }); const reason = decide.reason; assertOutcomeText(reason, "reason"); plainPolicyOutcome = { gate: "policy_ask", boundCallId: decide.boundCallId, boundInputHash: decide.boundInputHash, decision, ...(updatedInput !== undefined ? { updatedInput } : {}), ...(reason !== undefined ? { reason } : {}), ...(redeemedAnswer !== undefined ? { answer: redeemedAnswer } : {}), hostDecision, }; if (plainPolicyOutcome.decision === "deny" && plainPolicyOutcome.reason && sanitizeUntrustedText(plainPolicyOutcome.reason) !== plainPolicyOutcome.reason) { throw new CheckpointError("checkpoint.invalid_outcome", "resume deny/reject reason must not contain a </system-reminder> tag"); } } if (plainPolicyOutcome !== undefined) { const boundTo = cp.pendingAction.kind === "tool_approval" ? cp.pendingAction.toolCallId : undefined; if (plainPolicyOutcome.boundCallId !== boundTo) { throw new CheckpointError("checkpoint.invalid_outcome", `resume boundCallId "${plainPolicyOutcome.boundCallId}" does not match the checkpoint's pending tool call "${boundTo ?? "(none)"}" — the decision-action binding (design/80 D-1) failed; refusing to apply a decision bound to a different action`, { field: "boundCallId" }); } const boundHash = cp.pendingAction.kind === "tool_approval" ? cp.pendingAction.boundInputHash : undefined; if (boundHash !== undefined) { if (plainPolicyOutcome.boundInputHash !== boundHash) { throw new CheckpointError("checkpoint.invalid_outcome", "resume boundInputHash does not match the checkpoint's pending tool call input — the decision-action input binding (design/80 D-1 §2) failed; refusing to apply an approval bound to a different input (TOCTOU re-mint guard)", { field: "boundInputHash" }); } } else if (checkpointVersionOf(cp) >= CHECKPOINT_FEATURES.binding.version) { throw new CheckpointError("checkpoint.invalid_outcome", `resume of a binding-version (v${checkpointVersionOf(cp)}) checkpoint whose pending tool call has no persisted boundInputHash — refusing to resume an unbound binding-version checkpoint (corruption / downgrade guard)`); } if (cp.reopenReason === "env_failed") { if (cp.resolvedOutcome === undefined) { throw new CheckpointError("checkpoint.reopen_revote", "checkpoint was reopened after an env-restore failure (env_failed) but carries no persisted winner to replay — refusing to resume (inconsistent row, fail-closed)"); } const incoming = winnerFromOutcome(plainPolicyOutcome); const persistedWinner = (() => { const w = cp.resolvedOutcome; if (w?.answer === undefined) return w; const reading = classifyQuestionOutcome(w.answer); return reading.shape === "answered" ? { ...w, answer: reading.answer } : w; })(); if (incoming === undefined || !sameWinner(incoming, persistedWinner)) { throw new CheckpointError("checkpoint.reopen_revote", `this checkpoint was reopened after an env-restore failure (env_failed) — a re-resume is a system retry of the already-approved action, not a re-vote; the supplied decision must replay the persisted winner (boundCallId "${cp.resolvedOutcome.boundCallId}", decision "${cp.resolvedOutcome.decision}"), refusing a different decision`); } } const contentAskCallId = pendingContentAskCallId(cp); if (suppliedAnswer !== undefined && (contentAskCallId === undefined || plainPolicyOutcome.decision !== "allow")) { throw new CheckpointError("checkpoint.invalid_outcome", contentAskCallId === undefined ? "resume carried a content-ask `answer` but the checkpoint's pending call is not the reserved question tool — an answer has no consumer on a side-effecting tool's approval; refusing rather than dropping it silently" : "resume carried a content-ask `answer` on a `deny` — a denial injects a refusal, never an answer; refusing rather than dropping it silently", { field: "answer" }); } if (contentAskCallId !== undefined && plainPolicyOutcome.decision === "allow" && suppliedAnswer === undefined) { if (!isLiveQuestionFace(config.onQuestion ?? runner.deps.onQuestion)) { throw new CheckpointError("checkpoint.invalid_outcome", "resume approved a content-ask (the reserved question tool) without an `answer`, and this resume has no live answering face — " + 'executing the question against nothing would hand the model a fabricated "no human is available" default while consuming the approval; ' + "re-resume with the operator's answer on the outcome (or deny it), the checkpoint stays pending", { field: "answer" }); } } } return { plainPolicyOutcome, redeemedAnswer }; }