UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

134 lines (133 loc) • 10.9 kB
import { canonicalizeCapturedPlainData, classifyQuestionOutcome, isLiveQuestionFace } from "../ask-question.js"; import { CHECKPOINT_FEATURES, CheckpointError, checkpointVersionOf, winnerFromOutcome } from "../checkpoint-store.js"; import { policyCannotAnswer, screenApproverAttribution } from "../tool-policy.js"; import { sanitizeUntrustedText } from "../untrusted-text.js"; import { assertOutcomeText, describeSuppliedValue, pendingContentAskCallId, sameWinner } from "./decide-continuation.js"; const REFUSED_DECIDE_KEYS = [ ["gateOutcome", "the settlement record is minted by core from the host's decision facts and the row's origin, never supplied"], ["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`); } for (const [key, why] of REFUSED_DECIDE_KEYS) { if (Reflect.get(decide, key) !== undefined) { throw new CheckpointError("checkpoint.invalid_outcome", `resume carries \`${key}\` — ${why}; refusing pre-CAS, the checkpoint stays pending`, { field: "hostDecision" }); } } const hostDecisionRaw = decide.hostDecision; if (typeof hostDecisionRaw !== "object" || hostDecisionRaw === null) { throw new CheckpointError("checkpoint.invalid_outcome", `resume carries no hostDecision — a policy_ask decide names who decided it ({ decidedBy: "person" | "sla_timeout" | "policy", approver? }); refusing pre-CAS, the checkpoint stays pending`, { field: "hostDecision" }); } const decidedBy = hostDecisionRaw.decidedBy; if (decidedBy !== "person" && decidedBy !== "sla_timeout" && decidedBy !== "policy") { throw new CheckpointError("checkpoint.invalid_outcome", `resume hostDecision.decidedBy "${describeSuppliedValue(decidedBy)}" is outside the closed set — it is exactly "person", "sla_timeout" or "policy"; refusing pre-CAS, the checkpoint stays pending`, { field: "hostDecision" }); } const cannotAllow = decision !== "allow" ? undefined : decidedBy === "sla_timeout" ? ` — that word names a wait that ended with nobody answering, and it cannot approve an action that EXECUTES (an allow is decided by "person" or "policy")` : policyCannotAnswer(cp, decidedBy) ? ` on a row that requires real approval — a policy's answer is not a person's, and this row demands a person (decide it as "person", or refuse it as "policy")` : undefined; if (cannotAllow !== undefined) throw new CheckpointError("checkpoint.invalid_outcome", `resume carries decision "allow" decided by "${decidedBy}"${cannotAllow}; refusing pre-CAS, the checkpoint stays pending`, { field: "hostDecision" }); const attribution = screenApproverAttribution(hostDecisionRaw.approver); if (attribution.defect !== undefined) { throw new CheckpointError("checkpoint.invalid_outcome", `resume carries an approver attribution this engine refuses: ${attribution.defect}; refusing pre-CAS, the checkpoint stays pending`, { field: "approver" }); } const reason = decide.reason; assertOutcomeText(reason, "reason"); const approverCell = attribution.approver !== undefined ? { approver: attribution.approver } : {}; plainPolicyOutcome = { gate: "policy_ask", boundCallId: decide.boundCallId, boundInputHash: decide.boundInputHash, decision, ...(updatedInput !== undefined ? { updatedInput } : {}), ...(reason !== undefined ? { reason } : {}), ...(redeemedAnswer !== undefined ? { answer: redeemedAnswer } : {}), hostDecision: { decidedBy, ...approverCell }, }; 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 }; }