UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

355 lines (354 loc) • 18.3 kB
import { deliverEngineNotice } from "../core/types.js"; import { tightenTaskSpec } from "../core/tighten-task-spec.js"; import { sanitizeUntrustedText } from "../core/untrusted-text.js"; import { CLAMP_SET, SCRIPT_SEATS } from "../core/child-clamp-seats.js"; import { WorkflowScriptError } from "./workflow-meta.js"; import { uuidv7 } from "../engine/session/uuid.js"; import { retiredCheckpointStoreNull } from "../core/checkpoint-store.js"; import { thrownText } from "../core/thrown-value.js"; export class WorkflowModelNotAllowedError extends Error { modelName; code = "workflow.model_not_allowed"; constructor(modelName, reason) { super(`workflow model "${modelName}" is not allowed: ${reason}`); this.modelName = modelName; this.name = "WorkflowModelNotAllowedError"; } } const WORK_FIELD_KEYS = ["objective", "modelName", "thinking", "systemPrompt", "images", "limits"]; export const WHITELIST_KEYS = Object.freeze([...WORK_FIELD_KEYS, ...SCRIPT_SEATS]); const WHITELIST_KEY_SET = new Set(WHITELIST_KEYS); const RESTRICTING_SCRIPT_VALUES = SCRIPT_SEATS.flatMap((seat) => { const row = CLAMP_SET[seat]; return row.law === "restricting" ? [`${seat}: ${JSON.stringify(row.value)}`] : []; }).join(", "); export const SPEC_KEY_ALIASES = new Map([["model", "modelName"]]); export const STRIPPED_KEYS_NOTICE_CODE = "workflow.governance_key_stripped"; const MAX_STRIPPED_KEYS_ANNOUNCED = 20; const MAX_STRIPPED_KEY_CHARS = 64; const UNSAFE_KEY_CODE_POINT = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}]/u; function renderStrippedKey(key) { let out = ""; let seen = 0; for (const point of key) { if (seen === MAX_STRIPPED_KEY_CHARS) return `${out}…`; out += UNSAFE_KEY_CODE_POINT.test(point) ? "?" : point; seen++; } return out; } function emitStrippedKeysNotice(survey, onNotice) { const shown = survey.sample.map((s) => ({ key: renderStrippedKey(s.key), reason: s.reason })); const omitted = survey.total - shown.length; const list = shown.map((s) => JSON.stringify(s.key)).join(", ") + (omitted > 0 ? `, +${omitted} more` : ""); const message = `workflow governance: ${survey.total} field(s) of an agent spec were NOT applied to the child (${list}). ` + `A governed workflow script may set only: ${WHITELIST_KEYS.join(", ")} — and each tighten-only seat only in its ` + `tightening direction (${RESTRICTING_SCRIPT_VALUES}; the list seats only add entries). Every other field ` + `of the child comes from the deployment baseline.`; try { deliverEngineNotice(onNotice, { code: STRIPPED_KEYS_NOTICE_CODE, message, detail: { total: survey.total, stripped: shown, ...(omitted > 0 ? { omitted } : {}), occurrenceId: uuidv7() }, }); } catch { } } export const IGNORED_AGENT_OPTIONS_NOTICE_CODE = "workflow.agent_option_ignored"; export const MAX_ANNOUNCED_KEYS = MAX_STRIPPED_KEYS_ANNOUNCED; export function emitIgnoredAgentOptionsNotice(total, sample, accepted, onNotice) { const shown = sample.map(renderStrippedKey); const omitted = total - shown.length; const list = shown.map((k) => JSON.stringify(k)).join(", ") + (omitted > 0 ? `, +${omitted} more` : ""); const message = `workflow: ${total} key(s) of an \`agent(spec, opts)\` OPTIONS object (the SECOND argument) were not applied (${list}). ` + `The options object accepts only: ${accepted.join(", ")} — an unrecognized key, or a known one carrying a value its ` + `option does not accept, is ignored and the agent runs without it. Everything about the CHILD's behavior belongs on ` + `the spec (the first argument).`; try { deliverEngineNotice(onNotice, { code: IGNORED_AGENT_OPTIONS_NOTICE_CODE, message, detail: { total, ignored: shown, ...(omitted > 0 ? { omitted } : {}), occurrenceId: uuidv7() }, }); } catch { } } const VALID_THINKING = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); export function overlayWorktreeBaseline(base, worktreeBase) { let overlay = worktreeBase; for (const key of Object.keys(worktreeBase)) { const v = worktreeBase[key]; if (key === "checkpointStore" && v === null) throw retiredCheckpointStoreNull(); if (v !== undefined && v !== null) continue; if (overlay === worktreeBase) overlay = { ...worktreeBase }; delete overlay[key]; } return { ...base, ...overlay }; } export function resolveModelName(name, allowlist, models) { if (!allowlist || allowlist.length === 0) { throw new WorkflowModelNotAllowedError(name, "no workflowModelAllowlist is configured (fail-closed: an LLM-authored script cannot pick a model)"); } if (!allowlist.includes(name)) { throw new WorkflowModelNotAllowedError(name, `not in the workflow model allowlist [${allowlist.join(", ")}]`); } const model = models !== undefined && Object.hasOwn(models, name) ? models[name] : undefined; if (!model) { throw new WorkflowModelNotAllowedError(name, "allowed by name but not present in RunnerDeps.models"); } return model; } function minDefined(...vals) { const xs = vals.filter((v) => typeof v === "number" && Number.isFinite(v)); return xs.length ? Math.min(...xs) : undefined; } function minPositiveDefined(...vals) { const xs = vals.filter((v) => typeof v === "number" && Number.isFinite(v) && v > 0); return xs.length ? Math.min(...xs) : undefined; } const NON_POSITIVE_LIMIT_MSG = (field, what) => `agent(spec): \`limits.${field}\` must be a POSITIVE number — a value of 0 or less DISABLES the ${what} ` + `downstream (it is a sentinel, not a tighter ceiling), and a workflow script may only tighten a limit, never remove it`; function pickImageInput(el, i) { const at = `agent(spec): \`images[${i}]\``; if (typeof el !== "object" || el === null || Array.isArray(el)) { throw new WorkflowScriptError(`${at} must be an object — either { url } or { data, mimeType }`); } const o = el; if ("url" in o) { const url = o.url; if (typeof url !== "string" || url.length === 0) { throw new WorkflowScriptError(`${at}.url must be a non-empty string`); } return { url }; } const data = o.data; const mimeType = o.mimeType; if (typeof data !== "string") { throw new WorkflowScriptError(`${at}.data must be a base64 string (or supply { url } instead)`); } if (typeof mimeType !== "string" || mimeType.length === 0) { throw new WorkflowScriptError(`${at}.mimeType must be a non-empty string, e.g. "image/png"`); } return { data, mimeType }; } const MAX_SCRIPT_IMAGES = 32; function mapUntrustedArray(value, at, atMost, why, build) { const declared = value.length; if (typeof declared !== "number" || !Number.isSafeInteger(declared) || declared < 0) { throw new WorkflowScriptError(`${at} does not report a valid array length.`); } if (declared > atMost) { throw new WorkflowScriptError(`${at} carries ${declared} entries — a workflow script may set at most ${atMost}. ${why}`); } const out = []; for (let i = 0; i < declared; i++) out.push(build(value[i], i)); return out; } function pickWhitelist(scriptSpec) { if (typeof scriptSpec !== "object" || scriptSpec === null || Array.isArray(scriptSpec)) { throw new WorkflowScriptError("agent(spec): spec must be an object with at least an `objective` string"); } const s = scriptSpec; const stripped = { total: 0, sample: [] }; let canonicalIsOwn = false; let aliasIsOwn = false; for (const k of Object.keys(s)) { if (k === "modelName") canonicalIsOwn = true; else if (k === "model") aliasIsOwn = true; if (WHITELIST_KEY_SET.has(k) || SPEC_KEY_ALIASES.has(k)) continue; stripped.total++; if (stripped.sample.length < MAX_STRIPPED_KEYS_ANNOUNCED) stripped.sample.push({ key: k, reason: "not_whitelisted" }); } const canonicalDesc = Object.getOwnPropertyDescriptor(s, "modelName"); const aliasDesc = Object.getOwnPropertyDescriptor(s, "model"); if ((canonicalIsOwn && canonicalDesc === undefined) || (aliasIsOwn && aliasDesc === undefined)) { throw new WorkflowScriptError("agent(spec): this spec reports its own properties inconsistently between two reads of the same object — " + "the model field cannot be resolved from a shape that changes while it is being read. Pass a plain object."); } const hiddenOwnSpelling = !canonicalIsOwn && canonicalDesc !== undefined ? "modelName" : !aliasIsOwn && aliasDesc !== undefined ? "model" : undefined; if (hiddenOwnSpelling !== undefined) { throw new WorkflowScriptError(`agent(spec): \`${hiddenOwnSpelling}\` is an OWN property of this spec that its own-key enumeration does not report ` + `(a non-enumerable own key, or an object that answers differently across two reads). A model spelling the spec ` + `hides from its own shape is refused rather than read as an inherited default — write it as an ordinary property.`); } const valueOfSnapshot = (d) => (d === undefined ? undefined : d.get !== undefined ? d.get.call(s) : d.value); const modelNameInheritedRaw = canonicalIsOwn ? undefined : s.modelName; const modelNameOwnRaw = valueOfSnapshot(canonicalDesc); const modelAliasRaw = valueOfSnapshot(aliasDesc); const objective = s.objective; const thinking = s.thinking; const systemPrompt = s.systemPrompt; if (typeof objective !== "string" || objective.length === 0) { throw new WorkflowScriptError("agent(spec): `objective` is required and must be a non-empty string"); } const safe = { objective }; if (thinking !== undefined) { if (typeof thinking !== "string" || !VALID_THINKING.has(thinking)) { throw new WorkflowScriptError(`agent(spec): invalid \`thinking\` (must be one of ${[...VALID_THINKING].join(", ")})`); } safe.thinking = thinking; } if (systemPrompt !== undefined) { if (typeof systemPrompt !== "string") throw new WorkflowScriptError("agent(spec): `systemPrompt` must be a string"); safe.systemPrompt = `[workflow-script-authored persona — task guidance, not engine authority]\n${sanitizeUntrustedText(systemPrompt)}`; } const images = s.images; if (images !== undefined) { if (!Array.isArray(images)) throw new WorkflowScriptError("agent(spec): `images` must be an array"); safe.images = mapUntrustedArray(images, "agent(spec): `images`", MAX_SCRIPT_IMAGES, "Each one is decoded and shipped on every request of the child's turn.", pickImageInput); } const limitsRaw = s.limits; if (limitsRaw !== undefined) { if (typeof limitsRaw !== "object" || limitsRaw === null || Array.isArray(limitsRaw)) { throw new WorkflowScriptError("agent(spec): `limits` must be an object { maxTurns?, maxWalltimeMs?, maxTokens?, maxCostUsd? }"); } const l = limitsRaw; const limits = {}; const readAxis = (field, what) => { const raw = l[field]; if (raw === undefined) return; if (typeof raw !== "number" || !Number.isFinite(raw)) throw new WorkflowScriptError(`agent(spec): \`limits.${field}\` must be a number`); if (raw <= 0) throw new WorkflowScriptError(NON_POSITIVE_LIMIT_MSG(field, what)); limits[field] = raw; }; readAxis("maxTurns", "turn cap"); readAxis("maxWalltimeMs", "wall-clock window"); readAxis("maxTokens", "token budget"); readAxis("maxCostUsd", "spend ceiling"); safe.limits = limits; } for (const seat of SCRIPT_SEATS) { const row = CLAMP_SET[seat]; const declared = s[seat]; if (declared === undefined) continue; if (row.law === "restricting") { if (declared === row.value) Object.assign(safe, { [seat]: row.value }); else { stripped.total++; stripped.sample.unshift({ key: seat, reason: "not_a_tightening_value" }); if (stripped.sample.length > MAX_STRIPPED_KEYS_ANNOUNCED) stripped.sample.pop(); } continue; } const at = `agent(spec): \`${seat}\``; if (!Array.isArray(declared)) throw new WorkflowScriptError(`${at} must be an array of ${row.script.shape}`); const entries = mapUntrustedArray(declared, at, row.script.atMost, row.script.why, (el, i) => { try { return row.script.entry(el, `agent(spec): \`${seat}[${i}]\``); } catch (err) { throw new WorkflowScriptError(thrownText(err)); } }); for (const entry of entries) { const problem = row.entry(entry, `agent(spec).${seat}`); if (problem !== undefined) throw new WorkflowScriptError(problem); } Object.assign(safe, { [seat]: entries }); } const readModelName = (raw, spelling) => { if (raw === undefined) return undefined; if (typeof raw !== "string") { throw new WorkflowScriptError(`agent(spec): \`${spelling}\` must be a string (a model NAME, never a Model object)` + (spelling === "modelName" ? "" : ` — \`${spelling}\` is accepted as an alias for \`modelName\`, and carries a name just like it`)); } return raw; }; const authoredCanonical = readModelName(modelNameOwnRaw, "modelName"); const aliasModelName = readModelName(modelAliasRaw, "model"); if (authoredCanonical !== undefined && aliasModelName !== undefined && authoredCanonical !== aliasModelName) { throw new WorkflowScriptError(`agent(spec): \`modelName\` and \`model\` are two spellings of ONE field but name DIFFERENT models ` + `("${authoredCanonical}" vs "${aliasModelName}") — write exactly one of them (\`modelName\` is the canonical spelling).`); } const modelName = authoredCanonical ?? aliasModelName ?? readModelName(modelNameInheritedRaw, "modelName"); return { safe, modelName, stripped }; } function clampResourceLimits(safe, base, caps) { const trustedCandidates = [ ["baseline.limits.maxCostUsd", base.limits?.maxCostUsd], ["baseline.limits.maxTokens", base.limits?.maxTokens], ["baseline.limits.maxWalltimeMs", base.limits?.maxWalltimeMs], ["baseline.limits.maxTurns", base.limits?.maxTurns], ["caps.childMaxCostUsd", caps?.childMaxCostUsd], ["caps.childMaxTokens", caps?.childMaxTokens], ["caps.perAgentMaxWalltimeMs", caps?.perAgentMaxWalltimeMs], ["caps.childMaxTurns", caps?.childMaxTurns], ]; for (const [label, value] of trustedCandidates) { if (value === undefined) continue; if (typeof value !== "number" || Number.isNaN(value) || value === Infinity || value === -Infinity) { const e = new Error(`workflow governance: ${label} must be a real number (got ${String(value)}) — an unevaluable ceiling is not a ceiling, and dropping it would let a child run this axis unbounded under a limit nobody chose. Use 0 or a negative number to declare "no ceiling from this source" explicitly.`); e.code = "config.limit_invalid"; throw e; } } const notes = []; const requestedCost = safe.limits?.maxCostUsd; const requestedTokens = safe.limits?.maxTokens; const requestedWalltimeMs = safe.limits?.maxWalltimeMs; const requestedMaxTurns = safe.limits?.maxTurns; const cost = minDefined(safe.limits?.maxCostUsd, base.limits?.maxCostUsd, caps?.childMaxCostUsd); const tokens = minDefined(safe.limits?.maxTokens, base.limits?.maxTokens, caps?.childMaxTokens); const maxWalltimeMs = minPositiveDefined(safe.limits?.maxWalltimeMs, base.limits?.maxWalltimeMs, caps?.perAgentMaxWalltimeMs); const maxTurns = minPositiveDefined(safe.limits?.maxTurns, base.limits?.maxTurns, caps?.childMaxTurns); if (cost !== undefined && cost !== requestedCost) notes.push({ field: "maxCostUsd", requested: requestedCost, applied: cost }); if (tokens !== undefined && tokens !== requestedTokens) notes.push({ field: "maxTokens", requested: requestedTokens, applied: tokens }); if (maxWalltimeMs !== undefined && maxWalltimeMs !== requestedWalltimeMs) { notes.push({ field: "maxWalltimeMs", requested: requestedWalltimeMs, applied: maxWalltimeMs }); } if (maxTurns !== undefined && maxTurns !== requestedMaxTurns) { notes.push({ field: "maxTurns", requested: requestedMaxTurns, applied: maxTurns }); } const baseOtherLimits = { ...base.limits }; delete baseOtherLimits.maxTurns; delete baseOtherLimits.maxWalltimeMs; delete baseOtherLimits.maxTokens; delete baseOtherLimits.maxCostUsd; const rebuilt = { ...baseOtherLimits, ...(maxTurns !== undefined ? { maxTurns } : {}), ...(maxWalltimeMs !== undefined ? { maxWalltimeMs } : {}), ...(tokens !== undefined ? { maxTokens: tokens } : {}), ...(cost !== undefined ? { maxCostUsd: cost } : {}), }; if (base.limits !== undefined || Object.keys(rebuilt).length > 0) safe.limits = rebuilt; return notes; } export function buildGovernedChildSpec(scriptSpec, baseline, models, caps, onResourceClamp, onNotice) { const { safe, modelName, stripped } = pickWhitelist(scriptSpec); if (stripped.total > 0) emitStrippedKeysNotice(stripped, onNotice); if (modelName !== undefined) { safe.model = resolveModelName(modelName, baseline.workflowModelAllowlist, models); } const clampNotes = clampResourceLimits(safe, baseline.base, caps); if (clampNotes.length > 0 && onResourceClamp) onResourceClamp(clampNotes); return tightenTaskSpec(baseline.base, safe); }