UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

291 lines (290 loc) • 15.4 kB
import { wireOf } from "./tool-name-readings.js"; import { ruleSpellingOf } from "./permission-rule-syntax.js"; import { mountedToolPermissionSeat } from "./mode-policy.js"; import { inlineUntrusted } from "./untrusted-text.js"; import { boundInputHashOf } from "./canonical-json.js"; import { NOT_BINDABLE } from "./skill-grant-binding.js"; import { readToolNameList } from "./tool-name-list.js"; import { allowingRows, autoStripped, compileAllowList, readRuleEntry, inertEntryPhrase, judgeAllowEntries, nonListValueLabel, renderListEntry, spelledListEntry } from "./allow-entry.js"; export const SKILL_GRANT_ORIGINS = Object.freeze(["model", "user", "inherited"]); const FROZEN_SKILL_GRANT_ORIGINS = new Set(["model", "inherited"]); export function skillNamedBy(args, skills) { const named = args?.skill; return typeof named === "string" ? skills.find((s) => s.name === named) : undefined; } function readsAsNothingLabel(inert) { return `<reads as nothing (${inert.reason}): ${spelledListEntry(inert.entry)}>`; } function skillGrantBinding(skill) { const canonical = (entries, behavior) => { const read = compileAllowList((entries ?? []), behavior); const spelled = read.rows.map((r) => { const p = ruleSpellingOf(r.entry); if (p === undefined) return readsAsNothingLabel({ entry: r.entry, reason: "grammar" }); return p.ruleContent === undefined ? p.toolName : `${p.toolName}(${p.ruleContent})`; }); const unread = read.inert.map((i) => (i.valueType !== undefined ? i.entry : readsAsNothingLabel(i))); return [...new Set([...spelled, ...unread])].sort(); }; const boundGrants = { skill: skill?.name ?? null, allowedTools: canonical(skill?.allowedTools, "allow"), disallowedTools: canonical(skill?.disallowedTools, "deny") }; return { boundGrantDigest: boundInputHashOf(boundGrants), boundGrants }; } export function skillGrantDigest(skill) { return skillGrantBinding(skill).boundGrantDigest; } export function skillGrantBindingMembers(call, args, skills, skillToolName) { return isSkillActivationCall(call, skills, skillToolName) ? skillGrantBinding(skillNamedBy(args, skills)) : NOT_BINDABLE; } function isSkillActivationCall(call, skills, skillToolName) { return skills.length > 0 && wireOf("skillGrant.binding", call) === skillToolName; } export function parkedSkillApprovalRefusal(row, args, skills, skillToolName, site = "claim") { if (row.boundGrantDigest === undefined && !isSkillActivationCall(row, skills, skillToolName)) return undefined; if (skillGrantDigest(skillNamedBy(args, skills)) === row.boundGrantDigest) return undefined; const nameIn = (a) => a?.skill; const shown = (n) => inlineUntrusted(typeof n === "string" ? n : String(n), 120); const chosen = shown(nameIn(args)); const why = row.boundGrantDigest === undefined ? `this parked Skill activation of "${chosen}" carries no digest of the allowed-tools / disallowed-tools it was asked under (it was parked before Skill approvals bound them), so it cannot cover what the activation grants or refuses` : nameIn(args) !== nameIn(row.args) ? `this Skill approval was given on the card for "${shown(nameIn(row.args))}", and ${site === "claim" ? "this decision selects" : "the call would now activate"} "${chosen}" — not the Skill that card asked about, so the approval does not cover activating it` : `the allowed-tools / disallowed-tools of "${chosen}", as this resume serves it (one it does not load counts as absent), no longer bind to the digest this Skill approval was asked under — its definition changed, or this engine reads one of its entries differently than the engine that approved it (an entry that reads as nothing) — so the approval does not cover what activating it would now grant or refuse`; return site === "claim" ? `${why} — refusing pre-CAS (nothing ran; the checkpoint stays pending). Decide this checkpoint as deny and re-activate it: the new card shows the current lists.` : `The approved Skill call was not executed: ${why}. This approval is spent — to use "${chosen}", have the model activate it again: the new card shows its lists.`; } export function readSkillTable(value) { if (value === undefined || value === null) return undefined; const read = readToolNameList(value); return read.kind === "entries" ? read.entries : [nonListValueLabel(read)]; } export function detachSkillLists(skills) { return skills.map((s) => { if (typeof s !== "object" || s === null) return s; const { allowedTools: rawAllowed, disallowedTools: rawDenied, ...rest } = s; const allowedTools = readSkillTable(rawAllowed); const disallowedTools = readSkillTable(rawDenied); return { ...rest, ...(allowedTools !== undefined ? { allowedTools } : {}), ...(disallowedTools !== undefined ? { disallowedTools } : {}) }; }); } export function declaresSkillLists(skill) { return (skill.allowedTools?.length ?? 0) > 0 || (skill.disallowedTools?.length ?? 0) > 0; } export function compileSkillGrant(skill, origin, roster) { const rows = []; const inert = []; for (const [list, behavior, entries] of [ ["allowed-tools", "allow", skill.allowedTools ?? []], ["disallowed-tools", "deny", skill.disallowedTools ?? []], ]) { const compiled = compileAllowList(entries, behavior, roster); for (const r of compiled.rows) rows.push({ skill: skill.name, origin, behavior, entry: r.entry, compiled: r.compiled }); for (const i of compiled.inert) inert.push({ skill: skill.name, list, ...i }); } return { rows, inert }; } function judge(rows, req, behavior, ctx) { const hit = judgeAllowEntries(rows, req, behavior, ctx); return hit === undefined || hit[0] === undefined ? undefined : { skill: hit[0].skill, entries: hit.map((r) => r.entry) }; } export function skillDenyDecision(hit) { return { action: "deny", message: hit.readsAsNothing !== undefined ? skillDenyUnreadable(hit.skill, hit.readsAsNothing, "activation").message : `the active skill "${inlineUntrusted(hit.skill, 120)}" lists this tool in disallowed-tools (${hit.entries.map(renderListEntry).join(", ")}) — refused for the rest of this run`, decisionReason: "skill", }; } function unreadDenyOf(inert) { return inert.filter((i) => i.list === "disallowed-tools"); } export function skillRowRefusal(lane, req) { const hit = lane?.denying(req); return hit === undefined ? undefined : skillDenyDecision(hit); } export function skillAllowDecision(hit, updatedInput) { return { action: "allow", message: `the active skill "${inlineUntrusted(hit.skill, 120)}" lists this tool in allowed-tools (${hit.entries.map(renderListEntry).join(", ")})`, decisionReason: "skill", ...(updatedInput !== undefined ? { updatedInput } : {}), }; } const CARD_ENTRIES_MAX = 10; const CARD_ENTRY_MAX_CHARS = 120; export function skillActivationQuestion(skill, mode, roster) { const head = `Execute skill: ${inlineUntrusted(skill.name, 200)}`; const granting = compileSkillGrant(skill, "model", roster).rows.filter((r) => r.behavior === "allow" && !(mode === "auto" && autoStripped(r.compiled, undefined))); if (granting.length === 0) return head; const shown = granting.slice(0, CARD_ENTRIES_MAX).map((r) => inlineUntrusted(r.entry, CARD_ENTRY_MAX_CHARS)); const rest = granting.length - shown.length; return `${head}\nApproving also lets these tools be used without asking for the rest of this run: ${shown.join(", ")}${rest > 0 ? ` and ${rest} more` : ""}`; } export function skillSeatReader(skills, mounted) { if (skills.length === 0) return undefined; return (args, mode) => { const skill = skillNamedBy(args, skills); return skill !== undefined && declaresSkillLists(skill) ? { seat: "passthrough", question: skillActivationQuestion(skill, mode, mounted?.()) } : undefined; }; } export function skillGateWiring(opts) { const skillGrants = { ...opts.lane, ...(opts.onAllowed !== undefined ? { onAllowed: opts.onAllowed } : {}) }; return (calledName, wireName) => ({ ...(mountedToolPermissionSeat(opts.tools, calledName) === "passthrough" ? { permissionSeat: "passthrough" } : {}), ...(opts.seatOf !== undefined && wireName === opts.skillToolName ? { permissionSeatOf: (args) => opts.seatOf?.(args, opts.mode()) } : {}), skillGrants, ...opts.allowEntries, }); } export function seedSkillGrants(table, opts) { table.seed(opts.inherited ?? [], "inherited"); const named = (n) => skillNamedBy({ skill: n }, opts.skills); const invoked = opts.userInvokedSkills; if (invoked !== undefined) { const unknown = Array.isArray(invoked) ? invoked.filter((n) => named(n) === undefined) : [invoked]; if (unknown.length > 0) { const e = new Error(`TaskSpec.userInvokedSkills names ${unknown.map((n) => JSON.stringify(n)).join(", ")}, which ${unknown.length === 1 ? "is" : "are"} not a skill this run carries (the loaded skills: ${opts.skills.map((s) => s.name).join(", ") || "none"}) — refusing rather than running without the invocation`); e.code = USER_INVOKED_SKILL_UNKNOWN_CODE; throw e; } for (const name of invoked) table.activate(named(name), "user"); } const roster = table.mountedNames(); return opts.skills.flatMap((s) => compileSkillGrant(s, "model", roster).inert); } export function screenUserInvokedSkills(config) { if (config.userInvokedSkills === undefined) return; try { seedSkillGrants(new SkillGrantTable(), { skills: config.skills ?? [], userInvokedSkills: config.userInvokedSkills, inherited: [] }); } catch (e) { const code = e?.code; if (code === USER_INVOKED_SKILL_UNKNOWN_CODE || code === SKILL_DENY_UNREADABLE_CODE) throw e; } } export class SkillGrantTable { rowsHeld = []; rowKeys = new Set(); mounted; activations; bindMountedNames(mounted) { this.mounted = mounted; } bindSkills(skills, skillToolName) { this.activations = { skills, skillToolName }; } mountedNames() { return this.mounted?.(); } hold(row) { const key = JSON.stringify([row.skill, row.origin, row.behavior, row.entry]); if (this.rowKeys.has(key)) return; this.rowKeys.add(key); this.rowsHeld.push(row); } activate(skill, origin) { const { rows, inert } = compileSkillGrant(skill, origin, this.mountedNames()); const unreadDeny = unreadDenyOf(inert); if (unreadDeny.length > 0) throw skillDenyUnreadable(skill.name, unreadDeny, "activation"); for (const row of rows) this.hold(row); return inert; } seed(records, origin) { const roster = this.mountedNames(); const rows = []; const unreadDeny = []; for (const r of records) { const read = compileAllowList([r.entry], r.behavior, roster); const row = read.rows[0]; if (row !== undefined) rows.push({ skill: r.skill, origin: r.origin === "model" ? "model" : origin, behavior: r.behavior, entry: r.entry, compiled: row.compiled }); else if (r.behavior === "deny") unreadDeny.push(...read.inert.map((i) => ({ skill: r.skill, ...i }))); } const first = unreadDeny[0]; if (first !== undefined) throw skillDenyUnreadable(first.skill, unreadDeny.filter((i) => i.skill === first.skill), "carried"); for (const row of rows) this.hold(row); } records() { return this.rowsHeld.map((r) => ({ skill: r.skill, behavior: r.behavior, entry: r.entry, origin: r.origin })); } lane(ctx) { return { allowing: (req, mode) => judge(allowingRows(this.rowsHeld, req, mode), req, "allow", ctx), denying: (req) => judge(this.rowsHeld, req, "deny", ctx) ?? this.activationRefused(req), }; } activationRefused(req) { const at = this.activations; if (at === undefined || !isSkillActivationCall(req, at.skills, at.skillToolName)) return undefined; const skill = skillNamedBy(req.args, at.skills); const unread = skill === undefined ? [] : unreadDenyOf(compileSkillGrant(skill, "model", this.mountedNames()).inert); return skill === undefined || unread.length === 0 ? undefined : { skill: skill.name, entries: unread.map((i) => i.entry), readsAsNothing: unread }; } } function readCarriedRecords(raw, carrier) { if (raw === undefined) return []; if (!Array.isArray(raw)) return undefined; const out = []; for (const r of raw) { if (typeof r !== "object" || r === null) return undefined; const { skill, behavior, entry, origin } = r; if (typeof skill !== "string" || typeof entry !== "string" || (behavior !== "allow" && behavior !== "deny")) return undefined; if (readRuleEntry(entry, behavior).kind === "inert") return undefined; if (carrier === "capture") { out.push({ skill, behavior, entry }); continue; } if (!FROZEN_SKILL_GRANT_ORIGINS.has(origin)) return undefined; out.push({ skill, behavior, entry, origin: origin }); } return out; } export function readSkillGrantCapture(snapshot) { return readCarriedRecords(snapshot, "capture"); } export function frozenSkillGrants(table) { const rows = table.records().filter((r) => FROZEN_SKILL_GRANT_ORIGINS.has(r.origin)); return rows.length > 0 ? rows : undefined; } export function readFrozenSkillGrants(section) { return readCarriedRecords(section, "frozen"); } export const USER_INVOKED_SKILL_UNKNOWN_CODE = "config.user_invoked_skill_unknown"; export const SKILL_DENY_UNREADABLE_CODE = "config.skill_deny_unreadable"; function skillDenyUnreadable(skill, entries, site) { const listed = entries.map(inertEntryPhrase).join(", "); const one = entries.length === 1; const name = inlineUntrusted(skill, 120); const message = site === "activation" ? `Skill "${name}" was not activated: its disallowed-tools entr${one ? `y ${listed} reads` : `ies ${listed} read`} as nothing — a refusal this engine cannot read is not dropped, so the activation does not happen (its instructions are not loaded and nothing it lists is granted). Spell each entry as the tool is mounted, or remove it, in its definition.` : `the grant rows carried for Skill "${name}" (a park's frozen rows, or a parent's capture) hold the disallowed-tools entr${one ? `y ${listed}, which reads` : `ies ${listed}, which read`} as nothing for this run — a refusal this engine cannot read is not dropped, so this leg does not start without it. A parked task an earlier release wrote can be finished on a worker of that release; otherwise correct the entry in its disallowed-tools and submit the task again.`; const e = new Error(message); e.code = SKILL_DENY_UNREADABLE_CODE; return e; }