openvibers/openvibe.actor Commits
A-2: the approval floor reaches every acting tool, and the assignment tree (migration 0036) (#321)
Changes
nnew file mode 100644@@@ -0,0 +1,42 @@++-- phase: expand++-- A-2 (plan §12): one record for all delegated work. actor_assignments is the tree of hand-offs — a person's task,++-- a routine or Watch run, an agent handing work to another agent, a Codes or Space task Actor started. Existing tasks++-- are not backfilled: the first read that needs one makes its root node lazily (server/agents/assignments.js). The++-- root's budget is the tree's budget; a child is refused once it is spent. A cancel cascades down the tree.++--++-- The approval inbox learns its second kind, actor.action: an acting tool (a send, a post, a purchase, a password++-- field, a deletion, a computer_run, a browser action, a Space publish) asks through the same gate as a coding task.++-- Widening the check keeps every old row valid; the old release can still write codes.task rows. Nothing is renamed++-- or dropped.++CREATE TABLE actor_assignments (++ id text COLLATE "C" PRIMARY KEY, -- asg_<ULID>++ owner text COLLATE "C" NOT NULL, -- user:usr_…++ root text COLLATE "C" NOT NULL, -- the root node whose budget the tree spends++ parent text COLLATE "C", -- the node that handed this work over (null on a root)++ depth integer NOT NULL DEFAULT 0 CHECK (depth >= 0),++ origin text COLLATE "C" NOT NULL CHECK (origin IN ('person', 'routine', 'outside')),++ mode text COLLATE "C" NOT NULL CHECK (mode IN ('work', 'consult', 'notify')),++ reply text COLLATE "C" NOT NULL CHECK (reply IN ('resume', 'poll', 'none')),++ agent_id text COLLATE "C", -- agt_… that ran this hand-off++ task_id text COLLATE "C" REFERENCES tasks (id) ON DELETE CASCADE,++ level text COLLATE "C" NOT NULL CHECK (level IN ('ask', 'auto-edits', 'approve-for-me', 'full')),++ effective_level text COLLATE "C" NOT NULL CHECK (effective_level IN ('ask', 'auto-edits', 'approve-for-me', 'full')),++ budget_usd double precision NOT NULL DEFAULT 0, -- the root's budget, the same on every node++ budget_spent_usd double precision NOT NULL DEFAULT 0, -- the root's spend as last written++ state text COLLATE "C" NOT NULL CHECK (state IN ('queued', 'running', 'waiting', 'succeeded', 'failed', 'cancelled')),++ created_at text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL,++ started_at text COLLATE "C",++ ended_at text COLLATE "C"++);++CREATE INDEX actor_assignments_owner ON actor_assignments (owner, created_at DESC);++CREATE INDEX actor_assignments_root ON actor_assignments (root, created_at);++CREATE INDEX actor_assignments_parent ON actor_assignments (parent, created_at);++-- One root per task; a task may be named by several child nodes (a sub-agent's consult, a Codes task) but only one++-- root is made lazily for it.++CREATE UNIQUE INDEX actor_assignments_one_root ON actor_assignments (task_id) WHERE depth = 0 AND task_id IS NOT NULL;++++ALTER TABLE tasks ADD COLUMN assignment_id text COLLATE "C";++++ALTER TABLE actor_approvals DROP CONSTRAINT actor_approvals_kind_check;++ALTER TABLE actor_approvals ADD CONSTRAINT actor_approvals_kind_check CHECK (kind IN ('codes.task', 'actor.action'));@@@ -15,7 +15,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.328.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.136.0","pg": "^8.23.0"@@@ -1146,9 +1146,8 @@}},"node_modules/openvibe-contracts": {-- "version": "0.327.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0",-- "integrity": "sha512-LssZZeTdg9OkPPpry6YSEVhbIv13MjLYOlNxpoZ/0bpIATy0zfJrzfLAaJyUY+UMq5Tqf1FLzx2a/3xcJ7UJDw==",++ "version": "0.328.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.328.0","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -28,7 +28,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.328.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.136.0","pg": "^8.23.0"nnew file mode 100644@@@ -0,0 +1,155 @@++'use strict';++++/**++ * Which acting tool steps must see the approval gate first (plan §12 A-2). The gate itself is the++ * engine's: levels.classify + levels.decide, then a row on /me/approvals. This module only says what++ * a tool call is — its act ('edit' or 'act') and an always-ask category when the call names one — and++ * keeps a secret out of the approval card and the payload.++ *++ * A tool that only reads or writes Actor's own working state (a file or chart of the task, a memory++ * note) returns null: reading the agent's own files never asks. The generic OpenVibe.Tools call is++ * gated only when its input falls in an always-ask category, so a DNS lookup stays a lookup.++ *++ * stepFor(name, input) → { act, category, summary, payload, recipientKnown? } | null++ */++++const levels = require('./levels');++++const SECRET_KEYS = new Set(['password', 'passwd', 'passcode', 'secret', 'token', 'api_key', 'apikey', 'apiKey', 'access_token', 'refresh_token', 'private_key', 'card_number', 'cvv', 'cvc']);++const DELETE_CMD = /^(?:rm|rmdir|del|erase|shred|truncate|unlink|srm|rd)$/i;++const PASSWORD_FIELD = /\b(password|passwd|passphrase|passcode|otp|2fa|two-factor|secret|token|api.?key|card.?number|cvv|cvc|security.?code)\b/i;++++const text = (v, n = 200) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);++const quoted = (v, n = 120) => (v ? `"${text(v, n)}"` : '');++++/** A copy of the input with credentials removed (a card or a password never reaches the approvals table). */++function safeInput(input, { removeText = false } = {}) {++ const out = {};++ for (const [k, v] of Object.entries(input && typeof input === 'object' ? input : {})) {++ if (SECRET_KEYS.has(k)) out[k] = '[redacted]';++ else if (removeText && k === 'text') out[k] = '[redacted]';++ else if (k === 'files' && Array.isArray(v)) out[k] = v.slice(0, 20).map((f) => ({ path: text(f && f.path, 80), content: '[not shown]' }));++ else if (k === 'content' || k === 'text') out[k] = k === 'content' ? '[not shown]' : text(v, 300);++ else if (typeof v === 'string') out[k] = text(v, 300);++ else if (Array.isArray(v)) out[k] = v.slice(0, 30).map((x) => (typeof x === 'string' ? text(x, 120) : x));++ else out[k] = v;++ }++ return out;++}++++const commandOf = (argv) => (Array.isArray(argv) ? argv.map((a) => text(a, 120)) : []);++++/** Does this argv delete files (rm, del, shred, truncate, unlink, …)? */++function deletes(argv) {++ const parts = commandOf(argv);++ return parts.some((a, i) => DELETE_CMD.test(a) || (i > 0 && /\b(?:rm|del|erase|shred|truncate|unlink)\b/i.test(a)));++}++++function stepFor(name, input) {++ const a = input && typeof input === 'object' && !Array.isArray(input) ? input : {};++ switch (name) {++ // ── A person's computer (the old runtime and the modular tools share these names) ──++ case 'computer_run': {++ const argv = commandOf(a.argv);++ return {++ act: 'act',++ ...(deletes(argv) ? { category: 'delete' } : {}),++ summary: `Run ${quoted(argv.join(' '), 220) || 'a program'} on your computer`,++ payload: safeInput({ machine: a.machine, argv, cwd: a.cwd, desk: a.desk }),++ };++ }++ case 'computer_write': {++ const path = text(a.path, 300);++ return {++ act: 'edit',++ summary: `Write ${quoted(path, 220) || 'a file'} on your computer (${text(a.mode, 20) || 'create'})`,++ payload: safeInput({ machine: a.machine, path, mode: a.mode, text: a.text }, { removeText: true }),++ };++ }++ case 'computer_input': {++ const action = text(a.action, 20) || 'act';++ const password = PASSWORD_FIELD.test(text(a.keys, 80));++ return {++ act: 'act',++ ...(password ? { category: 'password' } : {}),++ summary: action === 'type' ? 'Type on your computer' : `Use ${action || 'the mouse or keyboard'} on your computer`,++ payload: safeInput({ machine: a.machine, action, x: a.x, y: a.y, keys: a.keys, text: a.text }, { removeText: action === 'type' }),++ };++ }++ case 'computer_browser': {++ const action = text(a.action, 20) || 'open';++ if (action === 'read' || action === 'close') return null; // reading and closing never wait++ const target = action === 'fill' ? text(a.selector || a.ref, 220) : text(a.text || a.selector || a.ref || a.url, 220);++ const password = PASSWORD_FIELD.test(`${text(a.selector, 120)} ${text(a.ref, 120)}`);++ const summary = action === 'open' ? `Open ${quoted(a.url, 220)} in your browser`++ : action === 'fill' ? `Fill ${quoted(target, 180) || 'a field'} in your browser`++ : action === 'click' ? `Click ${quoted(target, 180) || 'an element'} in your browser`++ : `${text(a.action, 20) || 'Use'} the browser on your computer`;++ return {++ act: 'act',++ ...(password ? { category: 'password' } : {}),++ summary,++ payload: safeInput({ machine: a.machine, action, url: a.url, ref: a.ref, selector: a.selector, text: a.text, key: a.key, desk: a.desk }, { removeText: action === 'fill' }),++ };++ }++ case 'computer_desk': {++ const action = text(a.action, 20) || 'status';++ if (action === 'status') return null;++ return {++ act: 'act',++ summary: `${action === 'start' ? 'Start' : 'Stop'} your agent's desk${a.machine && a.machine !== 'any' ? ` on ${text(a.machine, 80)}` : ''}`,++ payload: safeInput({ machine: a.machine, action, kind: a.kind, width: a.width, height: a.height }),++ };++ }++ case 'robot_steps':++ return {++ act: 'act',++ summary: `Move your robot ${text(a.robot, 80)}: ${quoted(commandOf(a.steps).join(', '), 220)}`,++ payload: safeInput({ robot: a.robot, steps: a.steps }),++ };++++ // ── OpenVibe.Space: the person's own spaces ──++ case 'space_create':++ return { act: 'act', summary: `Create a space${a.name ? ` ${quoted(a.name, 80)}` : ''} on OpenVibe.Space`, payload: safeInput({ name: a.name, description: a.description }) };++ case 'space_files_write':++ return {++ act: 'edit',++ summary: `Write ${Array.isArray(a.files) ? Math.min(a.files.length, 20) : 0} file(s) in your space ${text(a.space_id, 64)}`,++ payload: safeInput({ space_id: a.space_id, files: a.files }),++ };++ case 'space_publish':++ return { act: 'act', summary: `Publish your space ${text(a.space_id, 64)} on the open web`, payload: safeInput({ space_id: a.space_id }) };++++ // ── Coding on the person's machine ──++ case 'codes_task': {++ const repo = text(a.repo, 300);++ return {++ act: 'edit',++ summary: `Start a coding task on your machine${repo ? ` in ${repo}` : ''}`,++ payload: safeInput({ instruction: a.instruction, repo }),++ };++ }++++ // ── A generic OpenVibe.Tools tool: a lookup stays a lookup, a floor category asks ──++ case 'run_tool': {++ const summary = `Run the OpenVibe.Tools tool ${text(a.tool, 80)}`;++ const categories = levels.classify({ summary, payload: a.input });++ if (!categories.length) return null;++ return { act: 'act', category: categories[0], summary, payload: safeInput(a.input) };++ }++++ // ── Actor's own working state: never asks ──++ case 'make_file':++ case 'make_chart':++ case 'remember':++ case 'hand_to_person':++ case 'wait_for':++ return null;++++ // A connector (MCP/A2A) tool this Actor does not know: its acts:true says it may change something.++ default:++ return { act: 'act', summary: `Use ${text(name, 80)}`, payload: safeInput(a) };++ }++}++++module.exports = { stepFor, deletes, safeInput, SECRET_KEYS, PASSWORD_FIELD };@@@ -95,7 +95,7 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = conconst runtimeModel = own ? chatClient({ baseUrl: own.url, apiKey: own.apiKey, model: own.model, rates: ownRates, fetchImpl, timeoutMs: 180_000 }) : deepseek;const runtimeModelName = own ? own.model : p.deepseek.model;-- async function runtime({ task, requester = null, taskId = null, signal, emit, meter, budgetLeft, waitForPerson = null }) {++ async function runtime({ task, requester = null, taskId = null, signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null }) {if (!runtimeModel) throw new ProviderError('the runtime has no model configured', { code: 'actor.agent.unavailable' });const tools = await ovTools.definitions({ signal, requester });const robots = tools.some((d) => d.function && d.function.name === 'robot_steps');@@@ -121,7 +121,7 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = conlet args = {};try { args = JSON.parse((call.function && call.function.arguments) || '{}'); } catch { args = {}; }await emit({ step: 'tool_call', tool: name, input: summarize(args) });-- const out = await ovTools.call(name, args, { signal, requester, meter, budgetLeft, desks, emit, waitForPerson });++ const out = await ovTools.call(name, args, { signal, requester, meter, budgetLeft, desks, emit, waitForPerson, requestApproval });evidence.push({ tool: name, result: out.slice(0, 2000) });const isError = /^\{"error"/.test(out);await emit({ step: 'tool_result', tool: name, text: out.slice(0, 600), is_error: isError });@@@ -3,9 +3,11 @@/*** The approval inbox (plan T17: "an approval inbox for consequential actions"). A person's own agent runs while they* are away (a schedule, a watch): when its work would act outside Actor on their behalf — today, a coding task on their-- * own machine through OpenVibe.Codes — the run asks instead of acting. The request waits on /me/approvals; approving-- * starts that work as the person's own task (they are present now), declining or letting it expire (7 days) runs-- * nothing. A person's direct task never asks: they are the one asking.++ * own machine through OpenVibe.Codes, or one of its acting tools (a send, a purchase, a deletion, a password field, a++ * computer_run, a browser action, a Space publish) — the run asks instead of acting. The request waits on /me/approvals;++ * approving a codes.task starts that work as the person's own task (they are present now), declining or letting it++ * expire (7 days) runs nothing. An actor.action approval records the person's yes for a retry: the run that asked had++ * already stopped, so nothing is replayed on its own. A person's direct task never asks: they are the one asking.** request(s, { owner, agentId, taskId, kind, summary, payload }) → the row (pending)* list(s, owner) / get(s, owner, id) → rows, pending past their expiry marked expired@@@ -14,7 +16,7 @@*/const { createTask } = require('../tasks/create');--const KINDS = ['codes.task'];++const KINDS = ['codes.task', 'actor.action'];const EXPIRES_DAYS = 7;const ID = /^apr_[0-9A-HJKMNP-TV-Z]{26}$/;@@@ -65,6 +67,12 @@ async function decide(ctx, owner, id, decision, { idempotent = false } = {}) {return { row: done };}if (decision !== 'approve') return { error: 'Approve or decline.', status: 422 };++ // An action had already stopped when it asked: a yes is consent for the person to run it again, never a replay++ // of a call whose arguments only exist in the old run's context.++ if (row.kind === 'actor.action') {++ const done = await s.db.one("UPDATE actor_approvals SET state = 'approved', decided_at = $2 WHERE id = $1 AND state = 'pending' RETURNING *", [id, s.iso()]);++ return { row: done, note: 'Approved. The run that asked had already stopped; run it again to let the agent go on.' };++ }const payload = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;// The person just approved this exact work, so the thread starts at full access: the engine's approval gate must not// ask again. The always-ask floor still applies (the text may still be a deletion or a payment).nnew file mode 100644@@@ -0,0 +1,226 @@++'use strict';++++/**++ * The assignment tree (plan §12, A-2): one record for all delegated work. Every hand-off is one node —++ * a person giving Actor a task, a routine or Watch trigger starting a run, an agent handing work to++ * another agent, or Actor starting a Codes or Space task. A task that predates the tree gets its root++ * node lazily, the first time one is needed.++ *++ * The fields are actor.assignment@1. The effective level is the higher of the agent's own and the one++ * inherited from the parent, and only a person's or a routine's line passes a level down: outside work++ * never does. The root's budget is the tree's budget; a child is refused once it is spent. A cancel++ * cascades down the tree, and the tasks the nodes ran are stopped by the API.++ *++ * ensureForTask(s, config, task, { origin }) → the task's root node (created lazily)++ * openChild(s, { owner, parent, ... }) → one hand-off below a node | { error, status, code }++ * closeChild(s, owner, id, state) → the node, ended++ * tree(s, owner, id) → { assignment, children } | null (owner-only)++ * cancelTree(s, owner, id) → { assignment, cancelled } | null (owner-only)++ * syncTaskState(s, task) / syncSpent(s, root) / spendOf(s, root)++ * wire(row, budget), ID, ORIGINS, MODES, REPLIES, STATES, TERMINAL++ */++++const levels = require('./levels');++const contacts = require('./contacts');++++const ID = /^asg_[0-9A-HJKMNP-TV-Z]{26}$/;++const ORIGINS = ['person', 'routine', 'outside'];++const MODES = ['work', 'consult', 'notify'];++const REPLIES = ['resume', 'poll', 'none'];++const STATES = ['queued', 'running', 'waiting', 'succeeded', 'failed', 'cancelled'];++const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);++const OPEN_STATES = new Set(['queued', 'running', 'waiting']);++++const round = (n) => Math.round(Math.max(0, Number(n) || 0) * 1e6) / 1e6;++const isoOrNull = (v) => (v ? String(v) : null);++++/** A task's state as the assignment state that shows it on the tree. */++const stateOf = (state) => (state === 'needs_you' ? 'waiting' : ['running', 'verifying'].includes(state) ? 'running' : STATES.includes(state) ? state : 'queued');++++/** The level a node's steps run at: its own and the agent's, plus the inherited level when the line passes one down. */++function effectiveLevel(own, agentLevel = 'ask', parent = null) {++ const inherited = parent && (parent.origin === 'person' || parent.origin === 'routine') ? parent.effective_level : null;++ return levels.maxLevel(own, agentLevel, inherited);++}++++/** The node as actor.assignment@1 answers it. */++function wire(row, budget = null) {++ const b = budget || {++ limit_usd: round(row.budget_usd),++ spent_usd: round(row.budget_spent_usd),++ remaining_usd: round(Math.max(0, Number(row.budget_usd) - Number(row.budget_spent_usd))),++ };++ return {++ id: row.id,++ root: row.root,++ parent: row.parent || null,++ depth: Number(row.depth) || 0,++ origin: row.origin,++ mode: row.mode,++ reply: row.reply,++ agent_id: row.agent_id || null,++ task_id: row.task_id || null,++ level: row.level,++ effective_level: row.effective_level,++ budget: { limit_usd: round(b.limit_usd), spent_usd: round(b.spent_usd), remaining_usd: round(b.remaining_usd) },++ state: row.state,++ created_at: row.created_at,++ updated_at: row.updated_at,++ started_at: isoOrNull(row.started_at),++ ended_at: isoOrNull(row.ended_at),++ };++}++++const byId = (s, owner, id) => (ID.test(String(id || '')) ? s.db.maybe('SELECT * FROM actor_assignments WHERE id = $1 AND owner = $2', [id, owner]) : null);++const rootOf = async (s, row) => (row && row.root === row.id ? row : row ? s.db.maybe('SELECT * FROM actor_assignments WHERE id = $1 AND owner = $2', [row.root, row.owner]) : null);++++/** What a root has spent: the task ids of its tree count once, whatever number of nodes names them. */++async function spendOf(s, rootId) {++ const value = await s.db.value(`SELECT COALESCE(SUM(cost_usd), 0) FROM tasks WHERE id IN++ (SELECT DISTINCT task_id FROM actor_assignments WHERE root = $1 AND task_id IS NOT NULL)`, [rootId]);++ return round(value);++}++++/** The root's budget with what the tree has spent so far. */++async function budgetOf(s, root) {++ const limit = round(root && root.budget_usd);++ const spent = await spendOf(s, root && root.root);++ return { limit_usd: limit, spent_usd: spent, remaining_usd: round(Math.max(0, limit - spent)) };++}++++/** Who started the line, when the caller did not say. */++function originOf(task, explicit) {++ if (ORIGINS.includes(explicit)) return explicit;++ if (!String((task && task.requester) || '').startsWith('user:')) return 'outside';++ // An agent-started run without a caller's origin is a routine: a person's manual run passes 'person'.++ if (/^agent:agt_/.test(String((task && task.via) || ''))) return 'routine';++ return 'person';++}++++/** The agent of a task (its own, the personal agent that started it, or the person's Vibe). */++const agentOf = (s, config, task) => contacts.agentForTask(s, config, task).catch(() => null);++++/** Make the task's root node, or return the one it already has. */++async function ensureForTask(s, config, task, { origin = null } = {}) {++ if (!task || !task.id || !String(task.requester || '').startsWith('user:')) return null;++ if (task.assignment_id) {++ const linked = await byId(s, task.requester, task.assignment_id);++ if (linked) return linked;++ }++ const existing = await s.db.maybe('SELECT * FROM actor_assignments WHERE task_id = $1 AND depth = 0 ORDER BY created_at, id LIMIT 1', [task.id]);++ if (existing) {++ if (!task.assignment_id) await s.db.query('UPDATE tasks SET assignment_id = $2 WHERE id = $1 AND assignment_id IS NULL', [task.id, existing.id]);++ return existing;++ }++ const agent = await agentOf(s, config, task);++ const own = levels.LEVELS.includes(task.level) ? task.level : 'ask';++ const effective = effectiveLevel(own, agent && agent.level);++ const now = s.iso();++ const id = s.newId('asg');++ let row = await s.db.maybe(`INSERT INTO actor_assignments++ (id, owner, root, parent, depth, origin, mode, reply, agent_id, task_id, level, effective_level, budget_usd, budget_spent_usd, state, created_at, updated_at, started_at, ended_at)++ VALUES ($1,$2,$1,NULL,0,$3,'work','resume',$4,$5,$6,$7,$8,0,$9,$10,$10,$11,$12)++ ON CONFLICT DO NOTHING RETURNING *`,++ [id, task.requester, originOf(task, origin), agent ? agent.id : (task.agent_id || null), task.id, own, effective,++ round(task.budget_task), stateOf(task.state), now, OPEN_STATES.has(stateOf(task.state)) ? now : null, TERMINAL.has(stateOf(task.state)) ? task.finished_at || now : null]);++ if (!row) row = await s.db.maybe('SELECT * FROM actor_assignments WHERE task_id = $1 AND depth = 0 ORDER BY created_at, id LIMIT 1', [task.id]);++ if (row) await s.db.query('UPDATE tasks SET assignment_id = $2 WHERE id = $1 AND assignment_id IS NULL', [task.id, row.id]);++ return row;++}++++/** One hand-off below a node. Refused when the root's budget is spent. */++async function openChild(s, { owner, parent, origin = null, mode = 'work', reply = 'resume', agentId = null, taskId = null, level = null, state = 'queued' }) {++ if (!parent || parent.owner !== owner) return { error: 'no such assignment of yours', status: 404, code: 'actor.assignment.not_found' };++ if (TERMINAL.has(parent.state)) return { error: 'that assignment has ended', status: 409, code: 'actor.assignment.ended' };++ const root = await rootOf(s, parent);++ if (!root) return { error: 'the root of that assignment is gone', status: 409, code: 'actor.assignment.ended' };++ const budget = await budgetOf(s, root);++ if (budget.limit_usd > 0 && budget.remaining_usd <= 0) return { error: 'the assignment tree has spent its budget', status: 429, code: 'actor.assignment.budget' };++ let agentLevel = 'ask';++ if (agentId) {++ const row = await s.db.maybe('SELECT level FROM actor_agents WHERE id = $1 AND owner = $2', [agentId, owner]);++ if (row) agentLevel = row.level;++ }++ const own = levels.LEVELS.includes(level) ? level : agentLevel;++ const effective = effectiveLevel(own, agentLevel, parent);++ const now = s.iso();++ const stateNow = STATES.includes(state) ? state : 'queued';++ const row = await s.db.maybe(`INSERT INTO actor_assignments++ (id, owner, root, parent, depth, origin, mode, reply, agent_id, task_id, level, effective_level, budget_usd, budget_spent_usd, state, created_at, updated_at, started_at, ended_at)++ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,0,$14,$15,$15,$16,NULL) RETURNING *`,++ [s.newId('asg'), owner, root.id, parent.id, Number(parent.depth) + 1, ORIGINS.includes(origin) ? origin : parent.origin,++ MODES.includes(mode) ? mode : 'work', REPLIES.includes(reply) ? reply : 'resume', agentId || null, taskId || null, own, effective,++ round(root.budget_usd), stateNow, now, stateNow === 'running' || stateNow === 'waiting' ? now : null]);++ return row;++}++++/** End one child node (a Codes task, a Space action, a sub-agent's consult). */++async function closeChild(s, owner, id, state) {++ const next = STATES.includes(state) ? state : 'failed';++ return s.db.maybe(`UPDATE actor_assignments SET state = $3, updated_at = $4,++ started_at = COALESCE(started_at, CASE WHEN $3 IN ('running','waiting','succeeded','failed','cancelled') THEN $4 END),++ ended_at = CASE WHEN $3 IN ('succeeded','failed','cancelled') THEN COALESCE(ended_at, $4) ELSE ended_at END++ WHERE id = $1 AND owner = $2 RETURNING *`, [id, owner, next, s.iso()]);++}++++/** The node and its direct children, owner-only. */++async function tree(s, owner, id) {++ const node = await byId(s, owner, id);++ if (!node) return null;++ const root = await rootOf(s, node);++ const budget = root ? await budgetOf(s, root) : null;++ const children = await s.db.many('SELECT * FROM actor_assignments WHERE parent = $1 AND owner = $2 ORDER BY created_at, id LIMIT 100', [node.id, owner]);++ return { assignment: wire(node, budget), children: children.map((c) => wire(c, budget)) };++}++++/**++ * Cancel a node and every node below it. Nodes that already ended keep their end: a cancel stops work,++ * it does not rewrite history. The caller (the API) stops the tasks the returned nodes ran.++ */++async function cancelTree(s, owner, id) {++ const node = await byId(s, owner, id);++ if (!node) return null;++ const now = s.iso();++ const cancelled = await s.db.many(`WITH RECURSIVE subtree AS (++ SELECT id FROM actor_assignments WHERE id = $1 AND owner = $2++ UNION ALL++ SELECT a.id FROM actor_assignments a JOIN subtree t ON a.parent = t.id WHERE a.owner = $2++ )++ UPDATE actor_assignments SET state = 'cancelled', updated_at = $3, ended_at = COALESCE(ended_at, $3)++ WHERE id IN (SELECT id FROM subtree) AND owner = $2++ AND state NOT IN ('succeeded', 'failed', 'cancelled')++ RETURNING *`, [id, owner, now]);++ const after = await byId(s, owner, id);++ const root = after ? await rootOf(s, after) : null;++ const budget = root ? await budgetOf(s, root) : null;++ return {++ assignment: after ? wire(after, budget) : null,++ cancelled: cancelled.map((row) => wire(row, budget)),++ task_ids: [...new Set(cancelled.map((row) => row.task_id).filter(Boolean))],++ };++}++++/** Keep the root node's state with its task's (the engine calls this after every state change). */++async function syncTaskState(s, task) {++ if (!task || !task.id) return;++ const state = stateOf(task.state);++ const now = s.iso();++ await s.db.query(`UPDATE actor_assignments SET state = $2, updated_at = $3,++ started_at = COALESCE(started_at, CASE WHEN $2 IN ('running','waiting') THEN $3 END),++ ended_at = CASE WHEN $2 IN ('succeeded','failed','cancelled') THEN COALESCE(ended_at, $3) ELSE ended_at END++ WHERE task_id = $1 AND depth = 0`, [task.id, state, now]);++ const roots = await s.db.many('SELECT DISTINCT root FROM actor_assignments WHERE task_id = $1', [task.id]);++ for (const r of roots) await syncSpent(s, r.root);++}++++/** Write the tree's spend onto the root (a read of the tree also computes it live). */++async function syncSpent(s, rootId) {++ const spent = await spendOf(s, rootId);++ await s.db.query('UPDATE actor_assignments SET budget_spent_usd = $2 WHERE id = $1', [rootId, spent]);++ return spent;++}++++module.exports = {++ ID, ORIGINS, MODES, REPLIES, STATES, TERMINAL, stateOf, effectiveLevel, wire,++ byId, ensureForTask, openChild, closeChild, tree, cancelTree, syncTaskState, syncSpent, spendOf, budgetOf,++};@@@ -193,12 +193,12 @@ function personText(task) {return end < 0 ? t : t.slice(0, end);}--async function run(ctx, agent, input = '') {++async function run(ctx, agent, input = '', { origin = 'person' } = {}) {const { s, config, engine, webhooks } = ctx;if (typeof input !== 'string' || input.length > 8000) return { status: 422, code: 'actor.agent.invalid', detail: 'input must be at most 8000 characters.' };// The person's own active memory and this agent's active notes; a suggestion is not put in a run.const notes = await memoryStore.forRun(s, agent.owner, agent.id);-- const out = await createTask({ s, config, engine, webhooks, principal: { requester: agent.owner, via: `agent:${agent.id}`, project: null }, body: { task: prompt(agent, notes, input), mode: agent.mode, budget: { per_task_usd: Number(agent.budget_per_run_usd) } }, beforeInsert: async () => {++ const out = await createTask({ s, config, engine, webhooks, principal: { requester: agent.owner, via: `agent:${agent.id}`, project: null }, body: { task: prompt(agent, notes, input), mode: agent.mode, budget: { per_task_usd: Number(agent.budget_per_run_usd) } }, assignmentOrigin: origin, beforeInsert: async () => {const current = await s.db.maybe('SELECT daily_runs_max FROM actor_agents WHERE id = $1 AND owner = $2 FOR UPDATE', [agent.id, agent.owner]);if (!current) return { status: 404, code: 'actor.agent.not_found', detail: 'No such agent.' };const count = Number(await s.db.value('SELECT count(*) FROM tasks WHERE via = $1 AND created_at >= $2', [`agent:${agent.id}`, `${s.iso().slice(0, 10)}T00:00:00.000Z`]));@@@ -219,7 +219,7 @@ async function tick(ctx) {while (next && next <= s.iso()) next = nextRun(agent, next);const claimed = await s.db.maybe('UPDATE actor_agents SET next_run_at = $1, updated_at = $2 WHERE id = $3 AND status = $4 AND next_run_at = $5 RETURNING *', [next, s.iso(), agent.id, 'active', agent.next_run_at]);if (!claimed) continue;-- const out = await run(ctx, claimed);++ const out = await run(ctx, claimed, '', { origin: 'routine' });if (out.code) await s.db.query('UPDATE actor_agents SET last_skip_reason = $1 WHERE id = $2', [`${out.code}: ${out.detail}`.slice(0, 500), agent.id]);}}@@@ -3,6 +3,7 @@const crypto = require('crypto');const looks = require('./looks');const { createNotifier } = require('../notify/client');++const { stepFor } = require('./actions');/*** What the OpenVibe runtime can do, as model tools — every one of them an OpenVibe service's public API, called the@@@ -296,8 +297,19 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>return { done: false, note: took ? 'The person was still using the desk when the wait ended: look at it, and tell them what is left if it is not done.' : `The person did not take the desk in ${Math.round(granted / 60000)} minutes: tell them in your answer what is left for them to do, with the link.`, watch };}-- async function call(name, args, { signal, requester = null, meter = null, budgetLeft = null, desks = null, emit = null, waitForPerson = null } = {}) {++ async function call(name, args, { signal, requester = null, meter = null, budgetLeft = null, desks = null, emit = null, waitForPerson = null, requestApproval = null } = {}) {let a = args && typeof args === 'object' ? args : {};++ // The approval gate (plan §12 A-2): the act and its always-ask category go through levels.decide++ // before the step runs. A read, or Actor's own working state, says null and goes on.++ if (typeof requestApproval === 'function') {++ const step = stepFor(name, a);++ if (step) {++ let ask;++ try { ask = await requestApproval({ kind: 'actor.action', ...step }); }++ catch (err) { return json({ error: `the approval gate could not be asked, so nothing ran: ${(err && err.message) || err}`.slice(0, 300) }); }++ if (ask && ask.pending) return json({ error: `not started: the person must approve it first at ${ask.url}`, approval: { id: ask.id, url: ask.url } });++ }++ }try {if (name === 'list_my_robots' || name === 'robot_steps') {const person = personOf(requester);@@@ -39,7 +39,8 @@ function createA2APages(ctx) {}const text = messageText(req.body.message);if (!text) return contracts.http.sendProblem(res, 422, 'actor.a2a.empty', { detail: 'the task has no text', ctx: req.ov });-- const out = await createTask({ s, config, engine, principal: { ...p, requester: 'user:' + subject }, body: { task: text, mode: 'balanced' } });++ // Work from another agent is outside work: it never passes an approval level down to its children.++ const out = await createTask({ s, config, engine, principal: { ...p, requester: 'user:' + subject }, body: { task: text, mode: 'balanced' }, assignmentOrigin: 'outside' });if (!out || !out.task) {return contracts.http.sendProblem(res, (out && out.status) || 500, (out && out.code) || 'actor.task.failed', { detail: (out && out.detail) || 'the task could not be started', ctx: req.ov });}@@@ -121,7 +121,7 @@ ${ui.roster(rows.map((row) => ({ row, agent: contacts.wire(row) })), { when })}if (!signed(req)) return login(req, res);const rows = await approvals.list(s, owner(req));page(req, res, 'Approvals', html`<header class="pa-head"><p class="pa-eyebrow">OpenVibe.Actor · approvals</p><h1>Approvals</h1>--<p class="pa-lede">When one of your agents runs while you are away and its work would act for you outside Actor (a coding task on your own machine), it asks here first. Nothing runs until you approve. A request you leave expires after ${String(approvals.EXPIRES_DAYS)} days.</p></header>++<p class="pa-lede">When one of your agents runs while you are away and its work would act for you outside Actor (a coding task on your own machine, a send, a purchase, a deletion, a password field, a Space publish), it asks here first. Nothing runs until you approve. An action's run had already stopped when it asked, so approving it is consent for you to run the task again. A request you leave expires after ${String(approvals.EXPIRES_DAYS)} days.</p></header>${rows.length ? html`<ul class="pa-cards">${rows.map((a) => html`<li class="pa-card"><a href="/me/approvals/${a.id}"><strong>${a.summary}</strong></a> ${approvalPill(a)}<span class="pa-when">Asked ${time(a.created_at)}</span></li>`)}</ul>`: html`<p class="pa-empty">Nothing to approve. Your agents have not asked for anything yet.</p>`}<p><a href="/me/agents">Your agents</a></p>`);@@@ -133,9 +133,11 @@ ${rows.length ? html`<ul class="pa-cards">${rows.map((a) => html`<li class="pa-cconst p = typeof a.payload === 'string' ? JSON.parse(a.payload) : a.payload;page(req, res, 'Approval', html`<p class="pa-eyebrow"><a href="/me/approvals">Approvals</a></p><h1>${a.summary}</h1><p>${approvalPill(a)} <span class="pa-when">Asked ${time(a.created_at)}${a.agent_id ? html` by <a href="/me/agents/${a.agent_id}">your agent</a>` : ''}${a.task_id ? html` in <a href="/tasks/${a.task_id}">this run</a>` : ''}</span></p>--<section class="pa-panel"><h2>What it would do</h2><p>Start a coding task on your own machine through <a href="https://openvibe.codes/run">OpenVibe.Codes</a> with this request. You get the summary, the changed files and the patch; it pushes nowhere unless the request asks for a branch and your machine holds a GitHub token.</p>--<pre class="pa-pre">${String(p.task || '').slice(0, 8000)}</pre></section>--${a.state === 'pending' ? html`<form method="post" action="/me/approvals/${a.id}" class="pa-actions"><button type="submit" name="decision" value="approve" class="pa-btn pa-primary">Approve and start it</button> <button type="submit" name="decision" value="decline" class="pa-btn">Decline</button></form>`++${a.kind === 'actor.action'++ ? html`<section class="pa-panel"><h2>What it would do</h2><p>${a.summary}</p><pre class="pa-pre">${JSON.stringify(p, null, 2).slice(0, 8000)}</pre></section>`++ : html`<section class="pa-panel"><h2>What it would do</h2><p>Start a coding task on your own machine through <a href="https://openvibe.codes/run">OpenVibe.Codes</a> with this request. You get the summary, the changed files and the patch; it pushes nowhere unless the request asks for a branch and your machine holds a GitHub token.</p>++<pre class="pa-pre">${String(p.task || '').slice(0, 8000)}</pre></section>`}++${a.state === 'pending' ? html`<form method="post" action="/me/approvals/${a.id}" class="pa-actions"><button type="submit" name="decision" value="approve" class="pa-btn pa-primary">${a.kind === 'actor.action' ? 'Approve for a retry' : 'Approve and start it'}</button> <button type="submit" name="decision" value="decline" class="pa-btn">Decline</button></form>`: a.result_task_id ? html`<p>Started as <a href="/tasks/${a.result_task_id}">this task</a>.</p>` : ''}`);});r.post('/me/approvals/:id', async (req, res) => {@@@ -19,6 +19,8 @@* GET /tasks/:id/events actor.task.read its live stream (actor.task-event@2, Last-Event-ID)* GET /tasks/:id/artifacts/:mediaId actor.task.read one file or chart the task made, from Actor's own database* POST /tasks/:id/cancel actor.task.create stop it++ * GET /assignments/:id actor.assignment.read one node of the delegated-work tree and its children++ * POST /assignments/:id/cancel actor.assignment.cancel cancel that node and every node below it** A task is visible to its requester only (or to tokens of the project it belongs to); anyone else gets 404, the same* as a task that does not exist. Creating one goes through tasks/create.js (money limits before any work).@@@ -39,6 +41,7 @@ const memoryStore = require('../memory');const approvals = require('../agents/approvals');const items = require('../actions/items');const timeline = require('../agents/timeline');++const assignments = require('../agents/assignments');const modelPrefs = require('../runtime/model-prefs');const driving = require('../agents/driving');@@@ -396,6 +399,8 @@ function createApi(ctx) {}out.level = row.level || 'ask';try { out.effective_level = await contacts.effectiveLevelFor(s, row, agent); } catch (err) { out.effective_level = out.level; }++ // An existing task gets its assignment root the first time it is read (plan §12 A-2), never by a backfill.++ try { await assignments.ensureForTask(s, config, row); } catch (err) { /* the task still reads */ }return out;};@@@ -540,6 +545,26 @@ function createApi(ctx) {res.status(store.OPEN.includes(after.state) ? 202 : 200).json(await wireTask(after));});++++ // ── The assignment tree (plan §12 A-2) ───────────────────++ // One record for all delegated work. Both routes are owner-only: another person's node is the same 404 as one++ // that does not exist. A cancel cascades down the tree and stops the tasks still running under it.++ r.get('/assignments/:id', principal.requireCapability('actor.assignment.read'), limits.reads('actor.assignment.read'), async (req, res) => {++ const p = req.principal;++ if (!p.requester || !p.requester.startsWith('user:')) return problem(req, res, 404, 'actor.assignment.not_found', 'No such assignment.');++ const out = await assignments.tree(s, p.requester, req.params.id);++ if (!out) return problem(req, res, 404, 'actor.assignment.not_found', 'No such assignment.');++ res.json(out);++ });++ r.post('/assignments/:id/cancel', principal.requireCapability('actor.assignment.cancel'), limits.budget('actor.assignment.cancel'), async (req, res) => {++ const p = req.principal;++ if (!p.requester || !p.requester.startsWith('user:')) return problem(req, res, 404, 'actor.assignment.not_found', 'No such assignment.');++ const out = await assignments.cancelTree(s, p.requester, req.params.id);++ if (!out) return problem(req, res, 404, 'actor.assignment.not_found', 'No such assignment.');++ const { task_ids: taskIds, ...result } = out;++ for (const taskId of taskIds) await engine.cancel(taskId, p.requester);++ res.json(result);++ });return r;}@@@ -35,6 +35,8 @@ const BUDGETS = {'actor.route': { minute: 30, hour: 600 },'actor.task.stream': { minute: 30, hour: 600 },'actor.approval.decide': { minute: 30, hour: 600 },++ // Cancelling a subtree is a write, not a task: the tree is small (one node per hand-off).++ 'actor.assignment.cancel': { minute: 30, hour: 600 },// The kill switch and the other controls of the agent (design 2026-11-07).'actor.resource.control': { minute: 30, hour: 600 },};@@@ -1457,11 +1457,13 @@ data: {"task_id":"tsk_…","seq":3,"kind":"output","agent":"openvibe-runtime","s${table(['Route', 'Capability', 'What it does'], [[html`<code>POST /api/v1/tasks</code>`, html`<code>actor.task.create</code>`, html`Create a task: <code>{ task, mode?, budget?: { per_task_usd, per_day_usd }, agent?, agent_id?, level?, idempotency_key?, webhooks?: [{ url, events, secret_ref? }] }</code>. <code>agent</code> pins an agent system; <code>agent_id</code> names one of the person's contacts (default Vibe) and <code>level</code> is the thread's own level. Answers 201 with the task (queued); with webhooks, also its <code>webhook_secret</code>, this once, unless every webhook names a project secret (<a href="#webhooks">webhooks</a>).`],[html`<code>GET /api/v1/tasks/:id</code>`, html`<code>actor.task.read</code>`, html`The task: state, result, cost, explanation, error. While it runs on a desk of its own, <code>desk</code> (<code>url</code>: the person's page to watch it live and take it over; <code>kind</code>: <code>browser</code> or <code>desktop</code>); while it waits for its person to do a step there, <code>needs_you</code> (<code>what</code>, <code>url</code>).`],-- [html`<code>GET /api/v1/tasks/:id/events</code>`, html`<code>actor.task.read</code>`, html`Server-sent events (<code>actor.task-event@2</code>): <code>state</code>, <code>output</code> (each step; a tool result that made a file or chart carries <code>artifacts</code>), <code>end</code>. Resumes with <code>Last-Event-ID</code>.`],++ [html`<code>GET /api/v1/tasks/:id/events</code>`, html`<code>actor.task.read</code>`, html`Server-sent events (<code>actor.task-event@2</code>): <code>state</code>, <code>output</code> (each step; a tool result that made a file or chart carries <code>artifacts</code>, and a step names its assignment node in <code>assignment_id</code>), <code>end</code>. Resumes with <code>Last-Event-ID</code>.`],[html`<code>GET /api/v1/tasks/:id/trace</code>`, html`<code>actor.task.read</code>`, html`The span numbers and names only: each model, tool, check, judge and sub-agent call with its kind, name, tier, family, start, ms, cost, tokens, ok, error code and loop step, plus model cost, tool cost and time. Another person gets the same 404 as a task that does not exist.`],[html`<code>GET /tasks/:id/files/:artifact</code>`, html`<code>the task's own page</code>`, html`A file or chart the task made, out of Actor's own database and private to the person who asked: their own page opens it, another person gets the same 404 as a task that does not exist.`],[html`<code>POST /api/v1/tasks/:id/cancel</code>`, html`<code>actor.task.create</code>`, 'Stop it. What was spent stays spent.'],[html`<code>GET /api/v1/tasks</code>`, html`<code>actor.task.list</code>`, html`Your tasks, newest first; <code>?limit=</code>, <code>?before=</code> (the <code>next</code> cursor).`],++ [html`<code>GET /api/v1/assignments/:id</code>`, html`<code>actor.assignment.read</code>`, html`One node of your delegated-work tree and its children (<code>actor.assignment-tree@1</code>, each node <code>actor.assignment@1</code>): origin, mode, reply, agent, task, level and effective level, the root's budget, state and times. Another person gets 404.`],++ [html`<code>POST /api/v1/assignments/:id/cancel</code>`, html`<code>actor.assignment.cancel</code>`, html`Cancel that node and every node below it; the tasks still running under the tree are stopped. Answers the node and the nodes it cancelled (<code>actor.assignment-cancel-result@1</code>). Another person gets 404.`],[html`<code>GET /api/v1/agents</code>`, 'public / person', html`With no person: the agent systems, what each can do, its price and whether it works now (<code>actor.agent-list-result@2</code>). With a person: their own agent contacts, Vibe first (<code>actor.agent-list@1</code>).`],[html`<code>GET /api/v1/agent-systems</code>`, 'public', 'The agent systems even when signed in (the explicit catalog path).'],[html`<code>GET /api/v1/agents/presets</code>`, 'public', 'The four presets a create can copy: Researcher, Inbox, Studio editor and Party host.'],@@@ -30,7 +30,7 @@ const subject = require('./subject');const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;--const CAPABILITIES = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.agent.manage', 'actor.agent.run', 'actor.resource.read', 'actor.resource.control', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage', 'actor.timeline.read'];++const CAPABILITIES = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.agent.manage', 'actor.agent.run', 'actor.resource.read', 'actor.resource.control', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage', 'actor.timeline.read', 'actor.assignment.read', 'actor.assignment.cancel'];function decodePayload(token) {const parts = String(token || '').split('.');@@@ -42,13 +42,17 @@ const TABLES = [// shows. Before the tasks, so the rows are counted before the task's cascade would take them.{ table: 'actor_waits', subject: 'requester', value: (usr) => `user:${usr}`, file: 'waits.json',columns: ['id', 'task_id', 'requester', 'step', 'kind', 'until', 'filter', 'deadline_at', 'on_timeout', 'line', 'status', 'created_at', 'updated_at', 'finished_at'] },++ // The tree of delegated work (plan §12 A-2): one row per hand-off, with the agent, task, level and budget++ // state. Before tasks, so the rows are counted before a task's cascade could take them.++ { table: 'actor_assignments', subject: 'owner', value: (usr) => `user:${usr}`, file: 'assignments.json',++ columns: ['id', 'owner', 'root', 'parent', 'depth', 'origin', 'mode', 'reply', 'agent_id', 'task_id', 'level', 'effective_level', 'budget_usd', 'budget_spent_usd', 'state', 'created_at', 'updated_at', 'started_at', 'ended_at'] },// Every column but webhook_secret (a task's signing secret is a credential, not the person's data).{table: 'tasks', subject: 'requester', value: (usr) => `user:${usr}`, file: 'tasks.json',columns: ['id', 'requester', 'project_id', 'task', 'mode', 'agent', 'budget_task', 'budget_day', 'state', 'result', 'error_code', 'error_detail','cost_usd', 'free_usd', 'explanation', 'cancel_at', 'cancel_by', 'last_seq', 'idem_key', 'idem_hash', 'created_at', 'finished_at', 'timeout_ms', 'via', 'webhooks', 'parent_id',// A fork's parent step and the one change it was made with (stage V2-9), and how often it resumed.-- 'fork_of_step', 'fork_change', 'resumes', 'agent_id', 'level'],++ 'fork_of_step', 'fork_change', 'resumes', 'agent_id', 'level', 'assignment_id'],},// The person's own per-UTC-day spend accounting. Deleted: nothing in Actor keeps a person's spend record for a// reason that outlives them. The `*` row (everyone's daily ceiling) does not match `user:usr_…` and stays — it is@@@ -651,6 +651,10 @@ function createLoop({ config, models, registry, log = console, now = () => Date.async function runChild(parent, ctx, args, childTrace = null) {const goal = clip(String((args && args.goal) || '').replace(/\s+/g, ' ').trim(), 600);if (!goal) return { delegate: true, error: 'a sub-agent needs a goal' };++ // An agent asking another agent is one node on the assignment tree (plan §12 A-2); the root's budget++ // decides whether it may start at all.++ const node = typeof ctx.assign === 'function' ? await ctx.assign({ mode: 'consult', reply: 'resume', state: 'running' }) : null;++ if (node && node.error) return { delegate: true, error: `not started: ${node.error}` };const want = Array.isArray(args.tools) && args.tools.length ? new Set(args.tools.map(String)) : null;// Never wider than the parent: its offered tools, narrowed by the call, and never `delegate` (depth 1).// A child never gets an acting tool (stage V2-9): it has no checkpoint of its own, so a crash inside it could@@@ -681,7 +685,13 @@ function createLoop({ config, models, registry, log = console, now = () => Date.{ role: 'user', content: `${goal}\n\n(One part of a bigger task: ${clip(parent.task, 300)})` },);const maxModelCalls = config.loop && config.loop.subAgentCalls != null ? config.loop.subAgentCalls : 8;-- await drive(child, cctx, { reflections: 0, maxModelCalls });++ try {++ await drive(child, cctx, { reflections: 0, maxModelCalls });++ if (node && typeof ctx.closeAssignment === 'function') await ctx.closeAssignment(node.id, 'succeeded');++ } catch (err) {++ if (node && typeof ctx.closeAssignment === 'function') await ctx.closeAssignment(node.id, 'failed');++ throw err;++ }// Its evidence joins the parent's ledger, renumbered; its answer's citations follow the new ids.const ids = new Map();for (const item of child.ledger.items) {@@@ -846,7 +856,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.* a span; and drives on from the step it stopped at. A checkpoint whose last step already answered is delivered* without another model call.*/-- async function continueFrom({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null, chatPolicy = null, approveChatTool = null, context = [], personText = null, agentId = null, notifyTask = null, askPerson = null, control = null, resume = null, fork = null, spent = null, waits = null, waitEvent = null, delta = null }) {++ async function continueFrom({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null, assign = null, closeAssignment = null, chatPolicy = null, approveChatTool = null, context = [], personText = null, agentId = null, notifyTask = null, askPerson = null, control = null, resume = null, fork = null, spent = null, waits = null, waitEvent = null, delta = null }) {const source = resume && resume.state ? resume.state : (fork && fork.checkpoint && fork.checkpoint.state);const restored = restoreState(source);if (!restored) throw Object.assign(new Error('the saved step could not be read'), { code: 'actor.checkpoint.invalid' });@@@ -867,7 +877,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.// delivers a saved final answer without calling the model again.if (fork) state.answered = false;const ctx = {-- config, log, models, signal, requester, taskId, meter, budgetLeft, waitForPerson, requestApproval, chatPolicy, approveChatTool, artifacts,++ config, log, models, signal, requester, taskId, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, chatPolicy, approveChatTool, artifacts,store, now, agentId, personText: personText == null ? String(task) : String(personText),trace: createTracer({ store, taskId, log, now }),readOutside: () => state.readOutside === true, notifyTask,@@@ -1004,9 +1014,9 @@ function createLoop({ config, models, registry, log = console, now = () => Date.}}-- async function run({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null, chatPolicy = null, approveChatTool = null, context = [], personText = null, agentId = null, notifyTask = null, askPerson = null, control = null, resume = null, fork = null, spent = null, waits = null, waitEvent = null, attachments = [], delta = null }) {++ async function run({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null, assign = null, closeAssignment = null, chatPolicy = null, approveChatTool = null, context = [], personText = null, agentId = null, notifyTask = null, askPerson = null, control = null, resume = null, fork = null, spent = null, waits = null, waitEvent = null, attachments = [], delta = null }) {if (resume || fork) {-- return await continueFrom({ task, requester, taskId, mode, signal, emit, meter, budgetLeft, waitForPerson, requestApproval, chatPolicy, approveChatTool, context, personText, agentId, notifyTask, askPerson, control, resume, fork, spent, waits, waitEvent, delta });++ return await continueFrom({ task, requester, taskId, mode, signal, emit, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, chatPolicy, approveChatTool, context, personText, agentId, notifyTask, askPerson, control, resume, fork, spent, waits, waitEvent, delta });}const tier = mode === 'best' && models.resolve('best').available ? 'best' : 'smart';if (!models.resolve(tier).available) throw Object.assign(new Error(models.resolve(tier).reason || 'no model is available for the loop'), { code: 'actor.model.unavailable' });@@@ -1030,7 +1040,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.},};const ctx = {-- config, log, models, signal, requester, taskId, meter, budgetLeft, waitForPerson, requestApproval, chatPolicy, approveChatTool, artifacts,++ config, log, models, signal, requester, taskId, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, chatPolicy, approveChatTool, artifacts,store, now, agentId, personText: personText == null ? String(task) : String(personText),trace: createTracer({ store, taskId, log, now }),readOutside: () => state.readOutside === true, notifyTask,@@@ -6,9 +6,10 @@* answers the summary, changed files and patch. The model use is on the person's machine and keys, so nothing is* metered here.*-- * A personal agent's run that would start coding work on its owner's machine still asks first (the approval inbox,-- * agents/approvals.js): the engine passes requestApproval into the loop's context for those runs, and this tool-- * returns the request's link instead of running anything.++ * A personal agent's run that would start coding work on its owner's machine asks first (the approval gate,++ * agents/levels.js and agents/actions.js): the registry calls requestApproval with this tool's act and category++ * before run(), so nothing reaches Codes until the person approves. Starting Codes is also one node on the++ * assignment tree.*/const { createCodes, repoIn } = require('../../agents/codes');@@@ -36,14 +37,16 @@ module.exports = {if (!person) return { text: JSON.stringify({ error: 'coding tasks run on your own machine through OpenVibe.Codes, so they need you signed in' }), isError: true };const task = [String(input.instruction || '').slice(0, 8000), input.repo ? String(input.repo).slice(0, 500) : ''].filter(Boolean).join('\n\n');const repo = repoIn(task);-- // A person's own agent, running while they are away, asks before it starts work on their machine.-- if (repo && typeof ctx.requestApproval === 'function') {-- const ask = await ctx.requestApproval({ kind: 'codes.task', summary: `Start a coding task on your machine in ${repo}`, payload: { task } });-- if (ask && ask.pending) {-- return { text: JSON.stringify({ error: `not started: the person must approve it first at ${ask.url}` }), isError: true, data: ask };-- }++ // One hand-off to Codes on the tree: work, its result polled by the parent run.++ const node = typeof ctx.assign === 'function' ? await ctx.assign({ mode: 'work', reply: 'poll', agentId: ctx.agentId || null, state: 'running' }) : null;++ if (node && node.error) return { text: JSON.stringify({ error: `not started: ${node.error}` }), isError: true, data: node };++ try {++ const out = await codes.adapter({ task, requester: ctx.requester, signal: ctx.signal, emit: ctx.emit, taskId: ctx.taskId || null });++ if (node && typeof ctx.closeAssignment === 'function') await ctx.closeAssignment(node.id, 'succeeded');++ return { text: out.text, data: { sources: out.sources, model: out.model }, sources: (out.sources || []).map((url) => ({ url })) };++ } catch (err) {++ if (node && typeof ctx.closeAssignment === 'function') await ctx.closeAssignment(node.id, 'failed');++ throw err;}-- const out = await codes.adapter({ task, requester: ctx.requester, signal: ctx.signal, emit: ctx.emit, taskId: ctx.taskId || null });-- return { text: out.text, data: { sources: out.sources, model: out.model }, sources: (out.sources || []).map((url) => ({ url })) };},};@@@ -43,6 +43,7 @@const fs = require('fs');const path = require('path');++const { stepFor } = require('../../agents/actions');const SCOPES = ['public', 'person', 'private-ok'];const NAME = /^[a-z][a-z0-9_]{1,63}$/;@@@ -153,6 +154,26 @@ function createRegistry({ dir = __dirname, log = console, strict = false, fetchIif (ctx.chatPolicy && tool.acts === true) {if (!ctx.approveChatTool || !await ctx.approveChatTool(name, input)) return normalize({ text: 'The action was not approved. Nothing ran.', isError: true });}++ // The approval gate (plan §12 A-2): every acting tool sees levels.decide with its act and its++ // always-ask category before it changes anything. A tool that only writes Actor's own working++ // state says null and goes on. A chat has its own consent card above, so it is not asked twice.++ if (!ctx.chatPolicy && tool.acts === true && typeof ctx.requestApproval === 'function') {++ const step = stepFor(name, input);++ if (step) {++ let ask;++ try {++ ask = await ctx.requestApproval({ kind: 'actor.action', ...step });++ } catch (err) {++ return normalize({ text: JSON.stringify({ error: `the approval gate could not be asked, so nothing ran: ${(err && err.message) || err}`.slice(0, 300) }), isError: true });++ }++ if (ask && ask.pending) {++ return normalize({++ text: JSON.stringify({ error: `not started: the person must approve it first at ${ask.url}`, approval: { id: ask.id, url: ask.url } }),++ data: ask, isError: true,++ });++ }++ }++ }const signal = ctx.signal;const timeoutMs = Math.min(tool.timeoutMs || DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);const timeout = AbortSignal.timeout(timeoutMs);@@@ -25,11 +25,17 @@ module.exports = {const space = spaceFor(ctx);if (!space.enabled) return { text: JSON.stringify({ error: 'OpenVibe.Space is not connected to this server' }), isError: true };const person = personOf(ctx.requester);++ const node = typeof ctx.assign === 'function' ? await ctx.assign({ mode: 'work', reply: 'resume', agentId: ctx.agentId || null, state: 'running' }) : null;++ if (node && node.error) return { text: JSON.stringify({ error: `not started: ${node.error}` }), isError: true, data: node };const body = {};if (input && input.name) body.name = String(input.name).slice(0, 40);if (input && input.description) body.description = String(input.description).slice(0, 400);const out = await space.call('POST', '/api/v1/builder/spaces', person, body);-- if (!out.ok) return { text: JSON.stringify({ error: out.error }), isError: true };++ if (!out.ok) {++ if (node && typeof ctx.closeAssignment === 'function') await ctx.closeAssignment(node.id, 'failed');++ return { text: JSON.stringify({ error: out.error }), isError: true };++ }++ if (node && typeof ctx.closeAssignment === 'function') await ctx.closeAssignment(node.id, 'succeeded');const s = out.data && out.data.space;return { text: JSON.stringify({ space: s || null }), data: { space: s || null } };},@@@ -45,8 +45,14 @@ module.exports = {const list = Array.isArray(input && input.files) ? input.files : [];if (!list.length) return { text: JSON.stringify({ error: 'send the files the space is made of' }), isError: true };const files = list.slice(0, 20).map((f) => ({ path: String((f && f.path) || '').slice(0, 80), content: String((f && f.content) || '').slice(0, 200000) }));++ const node = typeof ctx.assign === 'function' ? await ctx.assign({ mode: 'work', reply: 'resume', agentId: ctx.agentId || null, state: 'running' }) : null;++ if (node && node.error) return { text: JSON.stringify({ error: `not started: ${node.error}` }), isError: true, data: node };const out = await space.call('PUT', `/api/v1/builder/spaces/${id}/files`, person, { files });-- if (!out.ok) return { text: JSON.stringify({ error: out.error }), isError: true };++ if (!out.ok) {++ if (node && typeof ctx.closeAssignment === 'function') await ctx.closeAssignment(node.id, 'failed');++ return { text: JSON.stringify({ error: out.error }), isError: true };++ }++ if (node && typeof ctx.closeAssignment === 'function') await ctx.closeAssignment(node.id, 'succeeded');const written = (out.data && out.data.files) || [];return { text: JSON.stringify({ space_id: id, files: written.map((f) => ({ path: f.path, bytes: String(f.content || '').length })) }), data: { files: written } };},@@@ -27,8 +27,14 @@ module.exports = {const person = personOf(ctx.requester);const id = spaceIdOf(input);if (!id) return { text: JSON.stringify({ error: 'that is not a space id (spc_…)' }), isError: true };++ const node = typeof ctx.assign === 'function' ? await ctx.assign({ mode: 'work', reply: 'resume', agentId: ctx.agentId || null, state: 'running' }) : null;++ if (node && node.error) return { text: JSON.stringify({ error: `not started: ${node.error}` }), isError: true, data: node };const out = await space.call('POST', `/api/v1/builder/spaces/${id}/publish`, person, {});-- if (!out.ok) return { text: JSON.stringify({ error: out.error }), isError: true };++ if (!out.ok) {++ if (node && typeof ctx.closeAssignment === 'function') await ctx.closeAssignment(node.id, 'failed');++ return { text: JSON.stringify({ error: out.error }), isError: true };++ }++ if (node && typeof ctx.closeAssignment === 'function') await ctx.closeAssignment(node.id, 'succeeded');const data = out.data || {};if (data.state === 'published') return { text: JSON.stringify({ space_id: id, state: 'published', url: data.url || null }), data };return {@@@ -14,6 +14,7 @@ const { checkWebhookUrl } = require('../net/webhook-post');const { mintSecret } = require('./webhooks');const learn = require('../runtime/brain/learn');const contacts = require('../agents/contacts');++const assignments = require('../agents/assignments');/*** The sharing system (design 2026-11-06), injected once by server/app.js. A run is its owner's until the Share sheet@@@ -30,7 +31,7 @@ function untilMidnight(nowMs) {return Math.ceil((Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), n.getUTCDate() + 1) - n.getTime()) / 1000);}--async function createTask({ s, config, engine, principal, body, webhooks: webhookQueue = null, beforeInsert = null, chatContext = null, afterInsert = null, parentId = null, forkOfStep = null, forkChange = null }) {++async function createTask({ s, config, engine, principal, body, webhooks: webhookQueue = null, beforeInsert = null, chatContext = null, afterInsert = null, parentId = null, forkOfStep = null, forkChange = null, assignmentOrigin = null }) {const v = contracts.validate('actor.task-create-request@1', body);if (!v.valid) return { status: 422, code: 'actor.task.invalid', detail: (v.errors || []).map((e) => `${e.path || e.instancePath || ''} ${e.message}`.trim()).join('; ').slice(0, 500) || 'invalid request' };const tier = config.allowance;@@@ -99,8 +100,9 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhooconst refused = await beforeInsert();if (refused) return refused;}++ const id = s.newId('tsk');const row = await store.insertTask(s, {-- id: s.newId('tsk'), requester: principal.requester, via: principal.via || null, project_id: principal.project || null, task: body.task, mode: body.mode || 'balanced', agent: body.agent || null,++ id, requester: principal.requester, via: principal.via || null, project_id: principal.project || null, task: body.task, mode: body.mode || 'balanced', agent: body.agent || null,budget_task: budgetTask, budget_day: perDay, idem_key: body.idempotency_key || null, idem_hash: body.idempotency_key ? hash : null, created_at: s.iso(),webhooks, webhook_secret: ownSecret ? mintSecret() : null, parent_id: parentId,// A fork (stage V2-9) starts from its parent's checkpoint at this step, with the one change the person@@@ -113,9 +115,13 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo});// What belongs with the task from its first moment (the chat's attached files), in the same transaction: the// engine starts it only after this commits, so the run never begins without them.-- if (afterInsert) await afterInsert(row);-- if (chatContext) await s.db.query('INSERT INTO actor_chat_tasks (task_id, agent_id, policy, conversation) VALUES ($1, $2, $3, $4)', [row.id, chatContext.agentId, chatContext.policy, chatContext.conversation]);-- return { status: 201, task: row };++ // Every hand-off is one node (plan §12 A-2). The root is made with the task, so the tree exists from++ // its first step; an older task without one gets it lazily (assignments.ensureForTask).++ const node = await assignments.ensureForTask(s, config, row, { origin: assignmentOrigin });++ const task = node ? { ...row, assignment_id: node.id } : row;++ if (afterInsert) await afterInsert(task);++ if (chatContext) await s.db.query('INSERT INTO actor_chat_tasks (task_id, agent_id, policy, conversation) VALUES ($1, $2, $3, $4)', [task.id, chatContext.agentId, chatContext.policy, chatContext.conversation]);++ return { status: 201, task };});// The engine starts the task only once its row has committed; a webhook that asked for `queued` hears it now.if (out.status === 201) {@@@ -30,6 +30,7 @@ const { check } = require('../agents/check');const { check: checkClaims } = require('../runtime/check');const approvals = require('../agents/approvals');const contacts = require('../agents/contacts');++const assignments = require('../agents/assignments');const levels = require('../agents/levels');const { repoIn: codesRepoIn } = require('../agents/codes');const memory = require('../memory');@@@ -112,15 +113,41 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =return text;}++ // A step event names its assignment node (plan §12 A-2), so the Dock can draw the tree later. New tasks++ // carry the id on the row; an older task gets its root lazily here, once.++ const assignmentIds = new Map();++ async function assignmentIdFor(taskId, row = null) {++ if (assignmentIds.has(taskId)) return assignmentIds.get(taskId);++ let id = row && row.assignment_id;++ if (!id) {++ const task = row || await store.getTask(s, taskId);++ if (task) {++ const node = await assignments.ensureForTask(s, config, task).catch((err) => {++ log.warn && log.warn('[Actor] assignment:', taskId, (err && err.message) || err);++ return null;++ });++ id = node && node.id;++ }++ }++ if (id) assignmentIds.set(taskId, id);++ return id || null;++ }async function emit(id, body) {-- const event = await store.appendEvent(s, id, { at: iso(), ...body });-- stream.publish(id, event);-- return event;++ const event = { at: iso(), ...body };++ if (event.kind === 'output' && !event.assignment_id) {++ const assignmentId = await assignmentIdFor(id);++ if (assignmentId) event.assignment_id = assignmentId;++ }++ const saved = await store.appendEvent(s, id, event);++ stream.publish(id, saved);++ return saved;}async function setState(id, state, { agentForEvent, ...patch } = {}) {const row = await store.updateTask(s, id, { state, ...patch });const ended = ['succeeded', 'failed', 'cancelled'].includes(state);await emit(id, { kind: ended ? 'end' : 'state', state, ...(agentForEvent ? { agent: agentForEvent } : {}) });++ // The assignment tree follows the task it runs (plan §12 A-2), and its root's spend is written with it.++ if (row) await assignments.syncTaskState(s, row).catch((err) => log.warn && log.warn('[Actor] assignment state:', id, (err && err.message) || err));if (ended) stream.end(id);// "Tell me when it's done" (stage V2-6): done or failed, once per task. Cancelled is the person's own action.if (ended && row && (state === 'succeeded' || state === 'failed')) await tellPerson(row, state);@@@ -283,12 +310,20 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =// personal agent that started it, else the person's Vibe. Another person's or a service's work has no contact.let taskAgent = null;let taskLevel = 'ask';++ let assignment = null;try {taskAgent = await contacts.agentForTask(s, config, row);taskLevel = await contacts.effectiveLevelFor(s, row, taskAgent);++ assignment = await assignments.ensureForTask(s, config, row);++ if (assignment && assignment.effective_level) taskLevel = assignment.effective_level;} catch (err) {log.warn && log.warn('[Actor] task agent/level:', id, (err && err.message) || err);}++ // Both execution paths get the same gate and assignment callbacks: the one loop passes them into its++ // tools, and an adapter that starts a Codes task gets them too.++ let requestApproval = null;++ let assign = null;++ let closeAssignment = null;// One place every approval gate asks through: the level, then the always-ask floor, then (for approve-for-me)// a reviewer model of another family. Returns { asked: false } when the step may go ahead.const askForApproval = async ({ kind, summary, payload, category = null, act = 'act', recipientKnown = null, agent = LOOP_AGENT }) => {@@@ -301,6 +336,20 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =await tellPerson(row, 'needs_you', { what: `approve: ${summary}`, url });return { asked: true, decision, ask, url };};++ // A child hand-off (a sub-agent's consult, a Codes task, a Space action) is one node below this task's.++ // The root's budget decides whether it may start at all: when it is spent, nothing below it starts.++ assign = assignment ? async (spec = {}) => {++ try {++ return await assignments.openChild(s, { owner: row.requester, parent: assignment, taskId: id, agentId: taskAgent ? taskAgent.id : null, ...spec });++ } catch (err) {++ log.warn && log.warn('[Actor] assignment child:', id, (err && err.message) || err);++ return { error: 'the assignment could not be recorded, so nothing ran', status: 500, code: 'actor.assignment.failed' };++ }++ } : null;++ closeAssignment = async (nodeId, state) => {++ try { return await assignments.closeChild(s, row.requester, nodeId, state); }++ catch (err) { log.warn && log.warn('[Actor] assignment close:', nodeId, (err && err.message) || err); return null; }++ };const explanation = [];const tried = new Map();// The quality tier a hand-off may not go below: the tier of the best agent tried so far (catalog quality).@@@ -370,7 +419,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =// A task that would start coding work on the person's machine asks through the same gate (levels.js):// the level first, then the always-ask floor. Full access or auto-edits may let an edit through; a// payment, password, deletion or a new recipient never does.-- const requestApproval = taskAgent ? async ({ kind, summary, payload, category = null, act = 'code', recipientKnown = null }) => {++ requestApproval = taskAgent ? async ({ kind, summary, payload, category = null, act = 'act', recipientKnown = null }) => {const out = await askForApproval({ kind, summary, payload, category, act, recipientKnown });if (!out.asked) return null;return { pending: true, id: out.ask.id, url: out.url };@@@ -405,7 +454,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =// The answer as it is written (plan L55 C2): to the readers that asked, never stored.// The loop streams a model call only while such a reader is there (wanted).delta: typeof stream.delta === 'function' ? Object.assign((d) => stream.delta(id, d), { wanted: () => stream.wants(id) }) : null,-- meter, budgetLeft, waitForPerson, requestApproval,++ meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment,chatPolicy, approveChatTool: chatPolicy ? (tool, input) => require('../chat-apps/policy').approve(s, { taskId: id, owner: row.requester, tool, input, signal }) : null,personText, agentId: ownAgent, notifyTask,askPerson, control, resume, fork, spent: () => spent,@@@ -598,7 +647,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =if (agentId === 'codes' && taskAgent && codesRepoIn(row.task)) {const asked = await askForApproval({kind: 'codes.task', summary: `Start a coding task on your machine in ${codesRepoIn(row.task)}`,-- payload: { task: row.task }, category: 'code', act: 'code', agent: agentId,++ payload: { task: row.task }, act: 'edit', agent: agentId,});if (asked.asked) {const result = {@@@ -613,7 +662,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =let out;try {// From chat the runtime is never offered the person's own robots or computers (no requester for its tools).-- out = await adapters.run[agentId]({ task: row.task, requester: fromChat ? null : row.requester, taskId: id, cls, mode: row.mode, signal, meter, budgetLeft, waitForPerson, emit: (e) => emit(id, { kind: 'output', agent: agentId, ...e }) });++ out = await adapters.run[agentId]({ task: row.task, requester: fromChat ? null : row.requester, taskId: id, cls, mode: row.mode, signal, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, emit: (e) => emit(id, { kind: 'output', agent: agentId, ...e }) });} catch (err) {if (signal.aborted) throw err;const why = (err && err.message) || String(err);@@@ -22,12 +22,12 @@ function subjectRef(requester) {async function insertTask(s, t) {await s.db.query(-- `INSERT INTO tasks (id, requester, project_id, task, mode, agent, budget_task, budget_day, state, idem_key, idem_hash, created_at, via, webhooks, webhook_secret, timeout_ms, parent_id, fork_of_step, fork_change, agent_id, level)-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'queued', $9, $10, $11, $12, $13::jsonb, $14, $15, $16, $17, $18::jsonb, $19, $20)`,++ `INSERT INTO tasks (id, requester, project_id, task, mode, agent, budget_task, budget_day, state, idem_key, idem_hash, created_at, via, webhooks, webhook_secret, timeout_ms, parent_id, fork_of_step, fork_change, agent_id, level, assignment_id)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'queued', $9, $10, $11, $12, $13::jsonb, $14, $15, $16, $17, $18::jsonb, $19, $20, $21)`,[t.id, t.requester, t.project_id || null, t.task, t.mode, t.agent || null, t.budget_task, t.budget_day, t.idem_key || null, t.idem_hash || null, t.created_at, t.via || null,t.webhooks && t.webhooks.length ? JSON.stringify(t.webhooks) : null, t.webhook_secret || null, t.timeout_ms || null, t.parent_id || null,t.fork_of_step == null ? null : Number(t.fork_of_step), t.fork_change ? JSON.stringify(t.fork_change) : null,-- t.agent_id || null, t.level || 'ask']);++ t.agent_id || null, t.level || 'ask', t.assignment_id || null]);await addSpend(s, t.requester, 0, { tasks: 1 });return getTask(s, t.id);}nnew file mode 100644@@@ -0,0 +1,171 @@++'use strict';++/**++ * The assignment tree (plan §12 A-2): every hand-off is one node — a person's task, a routine run, outside work,++ * an agent's consult, a Codes or Space task. The root's budget is the tree's budget; the effective level is++ * inherited only along a person's or routine's line; a cancel cascades and stops the tasks below. The API is++ * owner-only (another person gets 404), an existing task gets its root lazily, and every step event carries the++ * assignment id so the Dock can draw the tree later.++ */++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++const assignments = require('../server/agents/assignments');++const personal = require('../server/agents/personal');++const contacts = require('../server/agents/contacts');++const store = require('../server/tasks/store');++++const SAME = { 'sec-fetch-site': 'same-origin' };++++(async () => {++ const state = { hang: false };++ const t = await boot({++ loop: {++ close: async () => {},++ run: async (args) => {++ if (state.hang) {++ return new Promise((resolve, reject) => {++ const stop = () => reject(Object.assign(new Error('cancelled'), { code: 'actor.task.cancelled' }));++ if (args.signal.aborted) return stop();++ args.signal.addEventListener('abort', stop, { once: true });++ });++ }++ return { text: 'Done.', sources: [], evidence: [], model: 'fake' };++ },++ },++ });++ try {++ const ada = t.network.addUser('ada');++ const bo = t.network.addUser('bo');++ const owner = `user:${ada.subject}`;++ const s = t.ctx.s;++ const create = (json) => t.get('/api/v1/tasks', { as: ada, json, headers: SAME });++ const rootOf = (taskId) => s.db.maybe('SELECT * FROM actor_assignments WHERE task_id = $1 AND depth = 0', [taskId]);++ const validNode = (node) => { const v = contracts.validate('actor.assignment@1', node); assert.ok(v.valid, JSON.stringify(v.errors)); };++++ const first = await create({ task: 'Say hi' });++ assert.strictEqual(first.status, 201, first.text);++ const firstRow = await t.waitFor(first.json().id);++ const firstRoot = await rootOf(firstRow.id);++++ await check('a person\'s task is one root node, and its owner reads the node and its children', async () => {++ assert.ok(firstRoot, 'the root exists with the task');++ assert.deepStrictEqual([firstRoot.origin, firstRoot.depth, firstRoot.parent, firstRoot.task_id, firstRoot.state], ['person', 0, null, firstRow.id, 'succeeded']);++ validNode(assignments.wire(firstRoot));++ const res = await t.get(`/api/v1/assignments/${firstRoot.id}`, { as: ada });++ assert.strictEqual(res.status, 200, res.text);++ const body = res.json();++ assert.strictEqual(body.assignment.id, firstRoot.id);++ assert.deepStrictEqual(body.children, []);++ assert.ok(contracts.validate('actor.assignment-tree@1', body).valid);++ assert.strictEqual((await t.get(`/api/v1/assignments/${firstRoot.id}`, { as: bo })).status, 404, 'another person gets 404');++ assert.strictEqual((await t.get('/api/v1/assignments/asg_01JAB2C3D4E5F6G7H8J9K0MNPS', { as: ada })).status, 404, 'a node that does not exist is the same 404');++ });++++ await check('every step event names its assignment node', async () => {++ const rows = await s.db.many('SELECT body FROM task_events WHERE task_id = $1 ORDER BY seq', [firstRow.id]);++ const events = rows.map((r) => (typeof r.body === 'string' ? JSON.parse(r.body) : r.body));++ const outputs = events.filter((e) => e.kind === 'output');++ assert.ok(outputs.length > 0, 'the run emitted steps');++ for (const e of outputs) {++ assert.strictEqual(e.assignment_id, firstRoot.id, JSON.stringify(e));++ assert.ok(contracts.validate('actor.task-event@2', e).valid, JSON.stringify(contracts.validate('actor.task-event@2', e).errors));++ }++ });++++ await check('the level is inherited along a person\'s line, never from outside work', async () => {++ state.hang = true;++ const full = (await contacts.create(s, t.config, owner, { name: 'Full', level: 'full' })).row;++ const r = await create({ task: 'Full work', agent_id: full.id, level: 'full' });++ const row = await t.waitFor(r.json().id, ['running']);++ const root = await rootOf(row.id);++ assert.strictEqual(root.effective_level, 'full');++ const child = await assignments.openChild(s, { owner, parent: root, mode: 'consult', reply: 'resume', level: 'ask' });++ assert.ok(child && child.id, JSON.stringify(child));++ assert.strictEqual(child.level, 'ask', 'the child names its own level');++ assert.strictEqual(child.effective_level, 'full', 'the person\'s line passes its level down');++ assert.strictEqual(child.root, root.id);++ assert.strictEqual(child.depth, 1);++ assert.strictEqual(child.budget_usd, root.budget_usd);++ await t.ctx.engine.cancel(row.id, owner);++++ const outside = await require('../server/tasks/create').createTask({++ s, config: t.config, engine: t.ctx.engine, webhooks: t.ctx.webhooks,++ principal: { requester: owner, via: null, project: null },++ body: { task: 'Outside work', level: 'full' }, assignmentOrigin: 'outside',++ });++ assert.strictEqual(outside.status, 201, JSON.stringify(outside));++ await t.waitFor(outside.task.id, ['running']);++ const outsideRoot = await rootOf(outside.task.id);++ assert.strictEqual(outsideRoot.origin, 'outside');++ const below = await assignments.openChild(s, { owner, parent: outsideRoot, mode: 'consult', reply: 'resume', level: 'ask' });++ assert.ok(below && below.id, JSON.stringify(below));++ assert.strictEqual(below.effective_level, 'ask', 'outside work passes no level down');++ await t.ctx.engine.cancel(outside.task.id, owner);++ state.hang = false;++ });++++ await check('a routine starting a run is origin routine', async () => {++ const agent = (await personal.create(s, t.config, owner, { name: 'Morning', instructions: 'Summarise the inbox.' })).row;++ const out = await personal.run({ ...t.ctx, s, config: t.config }, agent, '', { origin: 'routine' });++ assert.strictEqual(out.status, 201, JSON.stringify(out));++ await t.waitFor(out.task.id);++ const root = await rootOf(out.task.id);++ assert.strictEqual(root.origin, 'routine');++ assert.strictEqual(root.agent_id, agent.id);++ });++++ await check('the root\'s budget stops a child when it is spent', async () => {++ const r = await create({ task: 'Budget work', level: 'full' });++ const row = await t.waitFor(r.json().id);++ const root = await rootOf(row.id);++ await s.db.query('UPDATE actor_assignments SET state = \'running\', budget_usd = 0.05 WHERE id = $1', [root.id]);++ await s.db.query('UPDATE tasks SET cost_usd = 0.06 WHERE id = $1', [row.id]);++ const before = Number(await s.db.value('SELECT count(*) FROM actor_assignments WHERE parent = $1', [root.id]));++ const out = await assignments.openChild(s, { owner, parent: { ...root, state: 'running', budget_usd: 0.05 }, level: 'ask' });++ assert.strictEqual(out.code, 'actor.assignment.budget');++ assert.strictEqual(out.status, 429);++ const after = Number(await s.db.value('SELECT count(*) FROM actor_assignments WHERE parent = $1', [root.id]));++ assert.strictEqual(after, before, 'nothing was added');++ });++++ await check('a cancel cascades down the tree and stops the tasks below it', async () => {++ state.hang = true;++ const a = await create({ task: 'Long work A' });++ const b = await create({ task: 'Long work B' });++ const aRow = await t.waitFor(a.json().id, ['running']);++ const bRow = await t.waitFor(b.json().id, ['running']);++ const aRoot = await rootOf(aRow.id);++ const child = await assignments.openChild(s, { owner, parent: aRoot, mode: 'work', reply: 'poll', taskId: bRow.id, state: 'running' });++ assert.ok(child && child.id, JSON.stringify(child));++ const grandchild = await assignments.openChild(s, { owner, parent: child, mode: 'consult', reply: 'resume', state: 'queued' });++ assert.ok(grandchild && grandchild.id, JSON.stringify(grandchild));++ const res = await t.get(`/api/v1/assignments/${aRoot.id}/cancel`, { as: ada, method: 'POST', headers: SAME });++ assert.strictEqual(res.status, 200, res.text);++ const body = res.json();++ assert.ok(contracts.validate('actor.assignment-cancel-result@1', body).valid, JSON.stringify(contracts.validate('actor.assignment-cancel-result@1', body).errors));++ assert.deepStrictEqual(body.cancelled.map((n) => n.id).sort(), [aRoot.id, child.id, grandchild.id].sort());++ for (const node of body.cancelled) assert.strictEqual(node.state, 'cancelled');++ assert.strictEqual((await t.waitFor(aRow.id, ['cancelled'])).state, 'cancelled');++ assert.strictEqual((await t.waitFor(bRow.id, ['cancelled'])).state, 'cancelled', 'the task the child named was stopped too');++ assert.strictEqual((await t.get(`/api/v1/assignments/${aRoot.id}/cancel`, { as: bo, method: 'POST', headers: SAME })).status, 404);++ state.hang = false;++ });++++ await check('an existing task gets its root lazily, on the first read', async () => {++ const id = s.newId('tsk');++ await store.insertTask(s, {++ id, requester: owner, via: null, project_id: null, task: 'An older task', mode: 'balanced', agent: null,++ budget_task: 0.01, budget_day: 1, created_at: s.iso(),++ });++ assert.strictEqual(await rootOf(id), null, 'nothing was backfilled');++ const res = await t.get(`/api/v1/tasks/${id}`, { as: ada });++ assert.strictEqual(res.status, 200, res.text);++ const root = await rootOf(id);++ assert.ok(root, 'the read made its root');++ assert.strictEqual(root.origin, 'person');++ });++ } finally {++ await t.close();++ }++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -30,6 +30,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };'actor.task.stream': { minute: 30, hour: 600 },'actor.approval.decide': { minute: 30, hour: 600 },'actor.resource.control': { minute: 30, hour: 600 },++ 'actor.assignment.cancel': { minute: 30, hour: 600 },});});@@@ -13,7 +13,7 @@ const contracts = require('openvibe-contracts');const configLib = require('../server/config');const { boot, check, done } = require('./helpers/boot');--const CAPS = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.resource.read', 'actor.resource.control', 'actor.agent.manage', 'actor.agent.run', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage', 'actor.timeline.read'];++const CAPS = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.resource.read', 'actor.resource.control', 'actor.agent.manage', 'actor.agent.run', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage', 'actor.timeline.read', 'actor.assignment.read', 'actor.assignment.cancel'];const INDEX_ROUTES = ['GET /api/v1/resources', 'GET /api/v1/resources/:ovrn'];// Routes added after the pinned contracts release but covered by an existing grant (or public on purpose). The trace is// the task's own read, so actor.task.read decides it exactly as it decides GET /api/v1/tasks/:id. The live-stats readsnnew file mode 100644@@@ -0,0 +1,187 @@++'use strict';++/**++ * The approval floor reaches every acting tool (plan §12 A-2). levels.decide already asks for money, a++ * password, a deletion and a new recipient at every level (test/agent-levels.test.js); this proves the++ * tools themselves go through it: the act and category are named, a full-level agent still asks, an++ * auto-edits agent passes an edit and asks for a send, reading the agent's own files never asks, the++ * old runtime path asks too, and the engine hands the gate to a loop with the task's effective level.++ */++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const { boot, check, done } = require('./helpers/boot');++const { createRegistry } = require('../server/runtime/tools');++const { createOpenVibeTools } = require('../server/agents/tools');++const { stepFor } = require('../server/agents/actions');++const levels = require('../server/agents/levels');++const approvals = require('../server/agents/approvals');++const contacts = require('../server/agents/contacts');++++const ME = 'user:usr_01M4W5JBFEG3QG9ER8W1A2K0RX';++const OTHER = 'user:usr_01M4W5JBFEG3QG9ER8W1A2K0RY';++const SAME = { 'sec-fetch-site': 'same-origin' };++++/** A small registry of the tool names under test: nothing reaches a computer or Space, a counter says who ran. */++function testRegistry() {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'actor-gate-'));++ for (const name of ['computer_run', 'computer_write', 'computer_browser']) {++ fs.writeFileSync(path.join(dir, `${name}.js`), `module.exports = {++ name: '${name}', description: 'test ${name}', input: { type: 'object', properties: {} }, scope: 'person', acts: true,++ async run() { globalThis.__gateRuns.push('${name}'); return { text: 'ran' }; },++ };`);++ }++ fs.writeFileSync(path.join(dir, 'computer_read.js'), `module.exports = {++ name: 'computer_read', description: 'test read', input: { type: 'object', properties: {} }, scope: 'person',++ async run() { globalThis.__gateRuns.push('computer_read'); return { text: 'read' }; },++ };`);++ fs.writeFileSync(path.join(dir, 'make_file.js'), `module.exports = {++ name: 'make_file', description: 'test own file', input: { type: 'object', properties: {} }, scope: 'person', acts: true,++ async run() { globalThis.__gateRuns.push('make_file'); return { text: 'made' }; },++ };`);++ return createRegistry({ dir, strict: true, log: { warn() {}, log() {} } });++}++++/** The engine's gate, without a database: classify the step, let levels.decide, and stop when it asks. */++function gate(level, asked) {++ return async (step) => {++ const categories = levels.classify({ category: step.category, summary: step.summary, payload: step.payload });++ const decision = await levels.decide({ level, categories, act: step.act, models: null, summary: step.summary, payload: step.payload });++ if (!decision.ask) return null;++ asked.push({ ...step, categories, floor: decision.floor });++ return { pending: true, id: 'apr_01JAB2C3D4E5F6G7H8J9K0MNPS', url: 'https://openvibe.actor/me/approvals/apr_01JAB2C3D4E5F6G7H8J9K0MNPS' };++ };++}++++(async () => {++ const registry = testRegistry();++ globalThis.__gateRuns = [];++++ await check('one per category: a full-level agent still asks, and the tool does not run', async () => {++ const cases = [++ ['money', 'computer_browser', { machine: 'dev_01JAB2C3D4E5F6G7H8J9K0MNPS', action: 'click', text: 'Buy now' }],++ ['password', 'computer_browser', { machine: 'dev_01JAB2C3D4E5F6G7H8J9K0MNPS', action: 'fill', selector: '#password', text: 'hunter2' }],++ ['delete', 'computer_run', { machine: 'dev_01JAB2C3D4E5F6G7H8J9K0MNPS', argv: ['rm', '-rf', 'build'] }],++ ['new_recipient', 'computer_browser', { machine: 'dev_01JAB2C3D4E5F6G7H8J9K0MNPS', action: 'click', text: 'Send to [email protected]' }],++ ];++ for (const [category, tool, input] of cases) {++ const asked = [];++ const out = await registry.run(tool, input, { requester: ME, requestApproval: gate('full', asked) });++ assert.strictEqual(out.isError, true, `${category}: the call was refused`);++ assert.match(out.text, /not started: the person must approve it first/, `${category}: ${out.text}`);++ assert.strictEqual(asked.length, 1, `${category}: asked once`);++ assert.ok(asked[0].categories.includes(category), `${category}: got ${JSON.stringify(asked[0])}`);++ assert.strictEqual(asked[0].act === 'edit' || asked[0].act === 'act', true, `${category}: a known act`);++ assert.ok(!globalThis.__gateRuns.includes(tool), `${category}: ${tool} never ran`);++ if (category === 'password') {++ assert.strictEqual(asked[0].payload.text, '[redacted]', 'the password is not in the approval card');++ assert.ok(!JSON.stringify(asked[0]).includes('hunter2'), 'the password is nowhere in the request');++ }++ }++ });++++ await check('auto-edits passes an edit and asks for a send', async () => {++ const asked = [];++ const edit = await registry.run('computer_write', { machine: 'dev_x', path: '/tmp/a.txt', text: 'hi' }, { requester: ME, requestApproval: gate('auto-edits', asked) });++ assert.ok(!edit.isError, edit.text);++ assert.strictEqual(globalThis.__gateRuns.filter((n) => n === 'computer_write').length, 1, 'the edit ran');++ assert.strictEqual(asked.length, 0, 'the edit did not ask');++ const send = await registry.run('computer_browser', { machine: 'dev_x', action: 'click', text: 'Send to [email protected]' }, { requester: ME, requestApproval: gate('auto-edits', asked) });++ assert.strictEqual(send.isError, true);++ assert.strictEqual(asked.length, 1, 'the send asked');++ assert.strictEqual(globalThis.__gateRuns.filter((n) => n === 'computer_browser').length, 0, 'the send did not run');++ });++++ await check('approve-for-me with no reviewer asks the person', async () => {++ const asked = [];++ const out = await registry.run('computer_browser', { machine: 'dev_x', action: 'click', text: 'Open the page' }, { requester: ME, requestApproval: gate('approve-for-me', asked) });++ assert.strictEqual(out.isError, true);++ assert.strictEqual(asked.length, 1, 'the ordinary act asked when no reviewer exists');++ });++++ await check('reading the agent\'s own files never asks; Actor\'s own working state does not either', async () => {++ const asked = [];++ globalThis.__gateRuns = [];++ const read = await registry.run('computer_read', { machine: 'dev_x', path: '/tmp/a.txt' }, { requester: ME, requestApproval: gate('ask', asked) });++ assert.ok(!read.isError, read.text);++ const own = await registry.run('make_file', { name: 'notes.md', text: 'hi' }, { requester: ME, requestApproval: gate('ask', asked) });++ assert.ok(!own.isError, own.text);++ assert.strictEqual(asked.length, 0, 'nothing asked');++ assert.deepStrictEqual(globalThis.__gateRuns, ['computer_read', 'make_file']);++ });++++ await check('the old runtime path asks through the same gate, and nothing reaches the computer', async () => {++ let ran = 0;++ const computers = { enabled: true, run: async () => { ran++; return { ok: true }; } };++ const tools = createOpenVibeTools({ config: { services: {} }, computers, now: () => 0 });++ const out = await tools.call('computer_run', { machine: 'dev_x', argv: ['rm', '-rf', 'build'] }, {++ requester: ME,++ requestApproval: async (step) => ({ pending: true, id: 'apr_01JAB2C3D4E5F6G7H8J9K0MNPS', url: `https://openvibe.actor/me/approvals/${step.category}` }),++ });++ assert.match(out, /not started: the person must approve it first/);++ assert.strictEqual(ran, 0, 'the computer was never asked to run');++ });++++ // ── The engine hands the same gate to the loop, with the task's assignment level ──++ const seen = [];++ const t = await boot({++ loop: {++ close: async () => {},++ run: async (args) => {++ seen.push(args);++ if (args.task.includes('delete')) {++ await args.requestApproval({ kind: 'actor.action', ...stepFor('computer_run', { machine: 'dev_x', argv: ['rm', '-rf', 'build'] }) });++ } else {++ await args.requestApproval({ kind: 'actor.action', ...stepFor('computer_write', { machine: 'dev_x', path: '/tmp/a.txt', text: 'hi' }) });++ await args.requestApproval({ kind: 'actor.action', ...stepFor('computer_browser', { machine: 'dev_x', action: 'click', text: 'Send to [email protected]' }) });++ }++ return { text: 'Done.', sources: [], evidence: [], model: 'fake' };++ },++ },++ });++ try {++ const ada = t.network.addUser('ada');++ const bo = t.network.addUser('bo');++ const owner = `user:${ada.subject}`;++ const agent = async (name, level) => (await contacts.create(t.ctx.s, t.config, owner, { name, level })).row;++ const full = await agent('Full', 'full');++ const auto = await agent('Auto', 'auto-edits');++ const create = (body) => t.get('/api/v1/tasks', { as: ada, json: body, headers: SAME });++++ await check('a full-level agent still asks before a deletion, through the engine\'s gate', async () => {++ const r = await create({ task: 'delete the build directory', agent_id: full.id, level: 'full' });++ assert.strictEqual(r.status, 201, r.text);++ const row = await t.waitFor(r.json().id);++ assert.strictEqual(row.state, 'succeeded', JSON.stringify(row.error_detail));++ const ask = await t.ctx.s.db.maybe('SELECT * FROM actor_approvals WHERE task_id = $1', [row.id]);++ assert.ok(ask, 'the approval was recorded');++ assert.deepStrictEqual([ask.kind, ask.state], ['actor.action', 'pending']);++ assert.strictEqual(JSON.parse(typeof ask.payload === 'string' ? ask.payload : JSON.stringify(ask.payload)).argv[0], 'rm');++ });++++ await check('an auto-edits agent passes the edit and asks for the send', async () => {++ const r = await create({ task: 'edit the file and send it', agent_id: auto.id, level: 'auto-edits' });++ assert.strictEqual(r.status, 201, r.text);++ const row = await t.waitFor(r.json().id);++ assert.strictEqual(row.state, 'succeeded', `${row.error_detail}\n${t.logs().slice(-2000)}`);++ const asks = await t.ctx.s.db.many('SELECT * FROM actor_approvals WHERE task_id = $1 ORDER BY created_at', [row.id]);++ assert.strictEqual(asks.length, 1, 'only the send asked');++ assert.match(asks[0].summary, /Send to bob@example\.com/);++ const args = seen.find((s) => s.task.includes('send it'));++ assert.ok(args, 'the loop ran with the gate');++ });++++ await check('the approval belongs to its owner only', async () => {++ const rows = await t.ctx.s.db.many('SELECT * FROM actor_approvals ORDER BY created_at DESC LIMIT 1');++ const a = rows[0];++ assert.strictEqual((await t.get(`/me/approvals/${a.id}`, { as: bo })).status, 404);++ assert.strictEqual((await t.get(`/me/approvals/${a.id}`, { as: ada })).status, 200);++ assert.strictEqual(approvals.KINDS.includes('actor.action'), true);++ assert.strictEqual((await t.get('/api/v1/assignments/not-an-id', { as: bo })).status, 404);++ });++ } finally {++ await t.close();++ }++++ done();++})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
+-- phase: expand
+-- A-2 (plan §12): one record for all delegated work. actor_assignments is the tree of hand-offs — a person's task,
+-- a routine or Watch run, an agent handing work to another agent, a Codes or Space task Actor started. Existing tasks
+-- are not backfilled: the first read that needs one makes its root node lazily (server/agents/assignments.js). The
+-- root's budget is the tree's budget; a child is refused once it is spent. A cancel cascades down the tree.
+--
+-- The approval inbox learns its second kind, actor.action: an acting tool (a send, a post, a purchase, a password
+-- field, a deletion, a computer_run, a browser action, a Space publish) asks through the same gate as a coding task.
+-- Widening the check keeps every old row valid; the old release can still write codes.task rows. Nothing is renamed
+-- or dropped.
+CREATE TABLE actor_assignments (
+ id text COLLATE "C" PRIMARY KEY, -- asg_<ULID>
+ owner text COLLATE "C" NOT NULL, -- user:usr_…
+ root text COLLATE "C" NOT NULL, -- the root node whose budget the tree spends
+ parent text COLLATE "C", -- the node that handed this work over (null on a root)
+ depth integer NOT NULL DEFAULT 0 CHECK (depth >= 0),
+ origin text COLLATE "C" NOT NULL CHECK (origin IN ('person', 'routine', 'outside')),
+ mode text COLLATE "C" NOT NULL CHECK (mode IN ('work', 'consult', 'notify')),
+ reply text COLLATE "C" NOT NULL CHECK (reply IN ('resume', 'poll', 'none')),
+ agent_id text COLLATE "C", -- agt_… that ran this hand-off
+ task_id text COLLATE "C" REFERENCES tasks (id) ON DELETE CASCADE,
+ level text COLLATE "C" NOT NULL CHECK (level IN ('ask', 'auto-edits', 'approve-for-me', 'full')),
+ effective_level text COLLATE "C" NOT NULL CHECK (effective_level IN ('ask', 'auto-edits', 'approve-for-me', 'full')),
+ budget_usd double precision NOT NULL DEFAULT 0, -- the root's budget, the same on every node
+ budget_spent_usd double precision NOT NULL DEFAULT 0, -- the root's spend as last written
+ state text COLLATE "C" NOT NULL CHECK (state IN ('queued', 'running', 'waiting', 'succeeded', 'failed', 'cancelled')),
+ created_at text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL,
+ started_at text COLLATE "C",
+ ended_at text COLLATE "C"
+);
+CREATE INDEX actor_assignments_owner ON actor_assignments (owner, created_at DESC);
+CREATE INDEX actor_assignments_root ON actor_assignments (root, created_at);
+CREATE INDEX actor_assignments_parent ON actor_assignments (parent, created_at);
+-- One root per task; a task may be named by several child nodes (a sub-agent's consult, a Codes task) but only one
+-- root is made lazily for it.
+CREATE UNIQUE INDEX actor_assignments_one_root ON actor_assignments (task_id) WHERE depth = 0 AND task_id IS NOT NULL;
+
+ALTER TABLE tasks ADD COLUMN assignment_id text COLLATE "C";
+
+ALTER TABLE actor_approvals DROP CONSTRAINT actor_approvals_kind_check;
+ALTER TABLE actor_approvals ADD CONSTRAINT actor_approvals_kind_check CHECK (kind IN ('codes.task', 'actor.action'));
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.328.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.136.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.136.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1146,9 +1146,8 @@
@@ -1146,9 +1146,8 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.327.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0",
- "integrity": "sha512-LssZZeTdg9OkPPpry6YSEVhbIv13MjLYOlNxpoZ/0bpIATy0zfJrzfLAaJyUY+UMq5Tqf1FLzx2a/3xcJ7UJDw==",
+ "version": "0.328.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.328.0",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.328.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.136.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.136.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
new file mode 100644
new file mode 100644
@@ -0,0 +1,155 @@
@@ -0,0 +1,155 @@
+'use strict';
+
+/**
+ * Which acting tool steps must see the approval gate first (plan §12 A-2). The gate itself is the
+ * engine's: levels.classify + levels.decide, then a row on /me/approvals. This module only says what
+ * a tool call is — its act ('edit' or 'act') and an always-ask category when the call names one — and
+ * keeps a secret out of the approval card and the payload.
+ *
+ * A tool that only reads or writes Actor's own working state (a file or chart of the task, a memory
+ * note) returns null: reading the agent's own files never asks. The generic OpenVibe.Tools call is
+ * gated only when its input falls in an always-ask category, so a DNS lookup stays a lookup.
+ *
+ * stepFor(name, input) → { act, category, summary, payload, recipientKnown? } | null
+ */
+
+const levels = require('./levels');
+
+const SECRET_KEYS = new Set(['password', 'passwd', 'passcode', 'secret', 'token', 'api_key', 'apikey', 'apiKey', 'access_token', 'refresh_token', 'private_key', 'card_number', 'cvv', 'cvc']);
+const DELETE_CMD = /^(?:rm|rmdir|del|erase|shred|truncate|unlink|srm|rd)$/i;
+const PASSWORD_FIELD = /\b(password|passwd|passphrase|passcode|otp|2fa|two-factor|secret|token|api.?key|card.?number|cvv|cvc|security.?code)\b/i;
+
+const text = (v, n = 200) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);
+const quoted = (v, n = 120) => (v ? `"${text(v, n)}"` : '');
+
+/** A copy of the input with credentials removed (a card or a password never reaches the approvals table). */
+function safeInput(input, { removeText = false } = {}) {
+ const out = {};
+ for (const [k, v] of Object.entries(input && typeof input === 'object' ? input : {})) {
+ if (SECRET_KEYS.has(k)) out[k] = '[redacted]';
+ else if (removeText && k === 'text') out[k] = '[redacted]';
+ else if (k === 'files' && Array.isArray(v)) out[k] = v.slice(0, 20).map((f) => ({ path: text(f && f.path, 80), content: '[not shown]' }));
+ else if (k === 'content' || k === 'text') out[k] = k === 'content' ? '[not shown]' : text(v, 300);
+ else if (typeof v === 'string') out[k] = text(v, 300);
+ else if (Array.isArray(v)) out[k] = v.slice(0, 30).map((x) => (typeof x === 'string' ? text(x, 120) : x));
+ else out[k] = v;
+ }
+ return out;
+}
+
+const commandOf = (argv) => (Array.isArray(argv) ? argv.map((a) => text(a, 120)) : []);
+
+/** Does this argv delete files (rm, del, shred, truncate, unlink, …)? */
+function deletes(argv) {
+ const parts = commandOf(argv);
+ return parts.some((a, i) => DELETE_CMD.test(a) || (i > 0 && /\b(?:rm|del|erase|shred|truncate|unlink)\b/i.test(a)));
+}
+
+function stepFor(name, input) {
+ const a = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
+ switch (name) {
+ // ── A person's computer (the old runtime and the modular tools share these names) ──
+ case 'computer_run': {
+ const argv = commandOf(a.argv);
+ return {
+ act: 'act',
+ ...(deletes(argv) ? { category: 'delete' } : {}),
+ summary: `Run ${quoted(argv.join(' '), 220) || 'a program'} on your computer`,
+ payload: safeInput({ machine: a.machine, argv, cwd: a.cwd, desk: a.desk }),
+ };
+ }
+ case 'computer_write': {
+ const path = text(a.path, 300);
+ return {
+ act: 'edit',
+ summary: `Write ${quoted(path, 220) || 'a file'} on your computer (${text(a.mode, 20) || 'create'})`,
+ payload: safeInput({ machine: a.machine, path, mode: a.mode, text: a.text }, { removeText: true }),
+ };
+ }
+ case 'computer_input': {
+ const action = text(a.action, 20) || 'act';
+ const password = PASSWORD_FIELD.test(text(a.keys, 80));
+ return {
+ act: 'act',
+ ...(password ? { category: 'password' } : {}),
+ summary: action === 'type' ? 'Type on your computer' : `Use ${action || 'the mouse or keyboard'} on your computer`,
+ payload: safeInput({ machine: a.machine, action, x: a.x, y: a.y, keys: a.keys, text: a.text }, { removeText: action === 'type' }),
+ };
+ }
+ case 'computer_browser': {
+ const action = text(a.action, 20) || 'open';
+ if (action === 'read' || action === 'close') return null; // reading and closing never wait
+ const target = action === 'fill' ? text(a.selector || a.ref, 220) : text(a.text || a.selector || a.ref || a.url, 220);
+ const password = PASSWORD_FIELD.test(`${text(a.selector, 120)} ${text(a.ref, 120)}`);
+ const summary = action === 'open' ? `Open ${quoted(a.url, 220)} in your browser`
+ : action === 'fill' ? `Fill ${quoted(target, 180) || 'a field'} in your browser`
+ : action === 'click' ? `Click ${quoted(target, 180) || 'an element'} in your browser`
+ : `${text(a.action, 20) || 'Use'} the browser on your computer`;
+ return {
+ act: 'act',
+ ...(password ? { category: 'password' } : {}),
+ summary,
+ payload: safeInput({ machine: a.machine, action, url: a.url, ref: a.ref, selector: a.selector, text: a.text, key: a.key, desk: a.desk }, { removeText: action === 'fill' }),
+ };
+ }
+ case 'computer_desk': {
+ const action = text(a.action, 20) || 'status';
+ if (action === 'status') return null;
+ return {
+ act: 'act',
+ summary: `${action === 'start' ? 'Start' : 'Stop'} your agent's desk${a.machine && a.machine !== 'any' ? ` on ${text(a.machine, 80)}` : ''}`,
+ payload: safeInput({ machine: a.machine, action, kind: a.kind, width: a.width, height: a.height }),
+ };
+ }
+ case 'robot_steps':
+ return {
+ act: 'act',
+ summary: `Move your robot ${text(a.robot, 80)}: ${quoted(commandOf(a.steps).join(', '), 220)}`,
+ payload: safeInput({ robot: a.robot, steps: a.steps }),
+ };
+
+ // ── OpenVibe.Space: the person's own spaces ──
+ case 'space_create':
+ return { act: 'act', summary: `Create a space${a.name ? ` ${quoted(a.name, 80)}` : ''} on OpenVibe.Space`, payload: safeInput({ name: a.name, description: a.description }) };
+ case 'space_files_write':
+ return {
+ act: 'edit',
+ summary: `Write ${Array.isArray(a.files) ? Math.min(a.files.length, 20) : 0} file(s) in your space ${text(a.space_id, 64)}`,
+ payload: safeInput({ space_id: a.space_id, files: a.files }),
+ };
+ case 'space_publish':
+ return { act: 'act', summary: `Publish your space ${text(a.space_id, 64)} on the open web`, payload: safeInput({ space_id: a.space_id }) };
+
+ // ── Coding on the person's machine ──
+ case 'codes_task': {
+ const repo = text(a.repo, 300);
+ return {
+ act: 'edit',
+ summary: `Start a coding task on your machine${repo ? ` in ${repo}` : ''}`,
+ payload: safeInput({ instruction: a.instruction, repo }),
+ };
+ }
+
+ // ── A generic OpenVibe.Tools tool: a lookup stays a lookup, a floor category asks ──
+ case 'run_tool': {
+ const summary = `Run the OpenVibe.Tools tool ${text(a.tool, 80)}`;
+ const categories = levels.classify({ summary, payload: a.input });
+ if (!categories.length) return null;
+ return { act: 'act', category: categories[0], summary, payload: safeInput(a.input) };
+ }
+
+ // ── Actor's own working state: never asks ──
+ case 'make_file':
+ case 'make_chart':
+ case 'remember':
+ case 'hand_to_person':
+ case 'wait_for':
+ return null;
+
+ // A connector (MCP/A2A) tool this Actor does not know: its acts:true says it may change something.
+ default:
+ return { act: 'act', summary: `Use ${text(name, 80)}`, payload: safeInput(a) };
+ }
+}
+
+module.exports = { stepFor, deletes, safeInput, SECRET_KEYS, PASSWORD_FIELD };
@@ -95,7 +95,7 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
@@ -95,7 +95,7 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
const runtimeModel = own ? chatClient({ baseUrl: own.url, apiKey: own.apiKey, model: own.model, rates: ownRates, fetchImpl, timeoutMs: 180_000 }) : deepseek;
const runtimeModel = own ? chatClient({ baseUrl: own.url, apiKey: own.apiKey, model: own.model, rates: ownRates, fetchImpl, timeoutMs: 180_000 }) : deepseek;
const runtimeModelName = own ? own.model : p.deepseek.model;
const runtimeModelName = own ? own.model : p.deepseek.model;
- async function runtime({ task, requester = null, taskId = null, signal, emit, meter, budgetLeft, waitForPerson = null }) {
+ async function runtime({ task, requester = null, taskId = null, signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null }) {
if (!runtimeModel) throw new ProviderError('the runtime has no model configured', { code: 'actor.agent.unavailable' });
if (!runtimeModel) throw new ProviderError('the runtime has no model configured', { code: 'actor.agent.unavailable' });
const tools = await ovTools.definitions({ signal, requester });
const tools = await ovTools.definitions({ signal, requester });
const robots = tools.some((d) => d.function && d.function.name === 'robot_steps');
const robots = tools.some((d) => d.function && d.function.name === 'robot_steps');
@@ -121,7 +121,7 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
@@ -121,7 +121,7 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
let args = {};
let args = {};
try { args = JSON.parse((call.function && call.function.arguments) || '{}'); } catch { args = {}; }
try { args = JSON.parse((call.function && call.function.arguments) || '{}'); } catch { args = {}; }
await emit({ step: 'tool_call', tool: name, input: summarize(args) });
await emit({ step: 'tool_call', tool: name, input: summarize(args) });
- const out = await ovTools.call(name, args, { signal, requester, meter, budgetLeft, desks, emit, waitForPerson });
+ const out = await ovTools.call(name, args, { signal, requester, meter, budgetLeft, desks, emit, waitForPerson, requestApproval });
evidence.push({ tool: name, result: out.slice(0, 2000) });
evidence.push({ tool: name, result: out.slice(0, 2000) });
const isError = /^\{"error"/.test(out);
const isError = /^\{"error"/.test(out);
await emit({ step: 'tool_result', tool: name, text: out.slice(0, 600), is_error: isError });
await emit({ step: 'tool_result', tool: name, text: out.slice(0, 600), is_error: isError });
@@ -3,9 +3,11 @@
@@ -3,9 +3,11 @@
/**
/**
* The approval inbox (plan T17: "an approval inbox for consequential actions"). A person's own agent runs while they
* The approval inbox (plan T17: "an approval inbox for consequential actions"). A person's own agent runs while they
* are away (a schedule, a watch): when its work would act outside Actor on their behalf — today, a coding task on their
* are away (a schedule, a watch): when its work would act outside Actor on their behalf — today, a coding task on their
- * own machine through OpenVibe.Codes — the run asks instead of acting. The request waits on /me/approvals; approving
- * starts that work as the person's own task (they are present now), declining or letting it expire (7 days) runs
- * nothing. A person's direct task never asks: they are the one asking.
+ * own machine through OpenVibe.Codes, or one of its acting tools (a send, a purchase, a deletion, a password field, a
+ * computer_run, a browser action, a Space publish) — the run asks instead of acting. The request waits on /me/approvals;
+ * approving a codes.task starts that work as the person's own task (they are present now), declining or letting it
+ * expire (7 days) runs nothing. An actor.action approval records the person's yes for a retry: the run that asked had
+ * already stopped, so nothing is replayed on its own. A person's direct task never asks: they are the one asking.
*
*
* request(s, { owner, agentId, taskId, kind, summary, payload }) → the row (pending)
* request(s, { owner, agentId, taskId, kind, summary, payload }) → the row (pending)
* list(s, owner) / get(s, owner, id) → rows, pending past their expiry marked expired
* list(s, owner) / get(s, owner, id) → rows, pending past their expiry marked expired
@@ -14,7 +16,7 @@
@@ -14,7 +16,7 @@
*/
*/
const { createTask } = require('../tasks/create');
const { createTask } = require('../tasks/create');
-const KINDS = ['codes.task'];
+const KINDS = ['codes.task', 'actor.action'];
const EXPIRES_DAYS = 7;
const EXPIRES_DAYS = 7;
const ID = /^apr_[0-9A-HJKMNP-TV-Z]{26}$/;
const ID = /^apr_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -65,6 +67,12 @@ async function decide(ctx, owner, id, decision, { idempotent = false } = {}) {
@@ -65,6 +67,12 @@ async function decide(ctx, owner, id, decision, { idempotent = false } = {}) {
return { row: done };
return { row: done };
}
}
if (decision !== 'approve') return { error: 'Approve or decline.', status: 422 };
if (decision !== 'approve') return { error: 'Approve or decline.', status: 422 };
+ // An action had already stopped when it asked: a yes is consent for the person to run it again, never a replay
+ // of a call whose arguments only exist in the old run's context.
+ if (row.kind === 'actor.action') {
+ const done = await s.db.one("UPDATE actor_approvals SET state = 'approved', decided_at = $2 WHERE id = $1 AND state = 'pending' RETURNING *", [id, s.iso()]);
+ return { row: done, note: 'Approved. The run that asked had already stopped; run it again to let the agent go on.' };
+ }
const payload = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;
const payload = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;
// The person just approved this exact work, so the thread starts at full access: the engine's approval gate must not
// The person just approved this exact work, so the thread starts at full access: the engine's approval gate must not
// ask again. The always-ask floor still applies (the text may still be a deletion or a payment).
// ask again. The always-ask floor still applies (the text may still be a deletion or a payment).
new file mode 100644
new file mode 100644
@@ -0,0 +1,226 @@
@@ -0,0 +1,226 @@
+'use strict';
+
+/**
+ * The assignment tree (plan §12, A-2): one record for all delegated work. Every hand-off is one node —
+ * a person giving Actor a task, a routine or Watch trigger starting a run, an agent handing work to
+ * another agent, or Actor starting a Codes or Space task. A task that predates the tree gets its root
+ * node lazily, the first time one is needed.
+ *
+ * The fields are actor.assignment@1. The effective level is the higher of the agent's own and the one
+ * inherited from the parent, and only a person's or a routine's line passes a level down: outside work
+ * never does. The root's budget is the tree's budget; a child is refused once it is spent. A cancel
+ * cascades down the tree, and the tasks the nodes ran are stopped by the API.
+ *
+ * ensureForTask(s, config, task, { origin }) → the task's root node (created lazily)
+ * openChild(s, { owner, parent, ... }) → one hand-off below a node | { error, status, code }
+ * closeChild(s, owner, id, state) → the node, ended
+ * tree(s, owner, id) → { assignment, children } | null (owner-only)
+ * cancelTree(s, owner, id) → { assignment, cancelled } | null (owner-only)
+ * syncTaskState(s, task) / syncSpent(s, root) / spendOf(s, root)
+ * wire(row, budget), ID, ORIGINS, MODES, REPLIES, STATES, TERMINAL
+ */
+
+const levels = require('./levels');
+const contacts = require('./contacts');
+
+const ID = /^asg_[0-9A-HJKMNP-TV-Z]{26}$/;
+const ORIGINS = ['person', 'routine', 'outside'];
+const MODES = ['work', 'consult', 'notify'];
+const REPLIES = ['resume', 'poll', 'none'];
+const STATES = ['queued', 'running', 'waiting', 'succeeded', 'failed', 'cancelled'];
+const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
+const OPEN_STATES = new Set(['queued', 'running', 'waiting']);
+
+const round = (n) => Math.round(Math.max(0, Number(n) || 0) * 1e6) / 1e6;
+const isoOrNull = (v) => (v ? String(v) : null);
+
+/** A task's state as the assignment state that shows it on the tree. */
+const stateOf = (state) => (state === 'needs_you' ? 'waiting' : ['running', 'verifying'].includes(state) ? 'running' : STATES.includes(state) ? state : 'queued');
+
+/** The level a node's steps run at: its own and the agent's, plus the inherited level when the line passes one down. */
+function effectiveLevel(own, agentLevel = 'ask', parent = null) {
+ const inherited = parent && (parent.origin === 'person' || parent.origin === 'routine') ? parent.effective_level : null;
+ return levels.maxLevel(own, agentLevel, inherited);
+}
+
+/** The node as actor.assignment@1 answers it. */
+function wire(row, budget = null) {
+ const b = budget || {
+ limit_usd: round(row.budget_usd),
+ spent_usd: round(row.budget_spent_usd),
+ remaining_usd: round(Math.max(0, Number(row.budget_usd) - Number(row.budget_spent_usd))),
+ };
+ return {
+ id: row.id,
+ root: row.root,
+ parent: row.parent || null,
+ depth: Number(row.depth) || 0,
+ origin: row.origin,
+ mode: row.mode,
+ reply: row.reply,
+ agent_id: row.agent_id || null,
+ task_id: row.task_id || null,
+ level: row.level,
+ effective_level: row.effective_level,
+ budget: { limit_usd: round(b.limit_usd), spent_usd: round(b.spent_usd), remaining_usd: round(b.remaining_usd) },
+ state: row.state,
+ created_at: row.created_at,
+ updated_at: row.updated_at,
+ started_at: isoOrNull(row.started_at),
+ ended_at: isoOrNull(row.ended_at),
+ };
+}
+
+const byId = (s, owner, id) => (ID.test(String(id || '')) ? s.db.maybe('SELECT * FROM actor_assignments WHERE id = $1 AND owner = $2', [id, owner]) : null);
+const rootOf = async (s, row) => (row && row.root === row.id ? row : row ? s.db.maybe('SELECT * FROM actor_assignments WHERE id = $1 AND owner = $2', [row.root, row.owner]) : null);
+
+/** What a root has spent: the task ids of its tree count once, whatever number of nodes names them. */
+async function spendOf(s, rootId) {
+ const value = await s.db.value(`SELECT COALESCE(SUM(cost_usd), 0) FROM tasks WHERE id IN
+ (SELECT DISTINCT task_id FROM actor_assignments WHERE root = $1 AND task_id IS NOT NULL)`, [rootId]);
+ return round(value);
+}
+
+/** The root's budget with what the tree has spent so far. */
+async function budgetOf(s, root) {
+ const limit = round(root && root.budget_usd);
+ const spent = await spendOf(s, root && root.root);
+ return { limit_usd: limit, spent_usd: spent, remaining_usd: round(Math.max(0, limit - spent)) };
+}
+
+/** Who started the line, when the caller did not say. */
+function originOf(task, explicit) {
+ if (ORIGINS.includes(explicit)) return explicit;
+ if (!String((task && task.requester) || '').startsWith('user:')) return 'outside';
+ // An agent-started run without a caller's origin is a routine: a person's manual run passes 'person'.
+ if (/^agent:agt_/.test(String((task && task.via) || ''))) return 'routine';
+ return 'person';
+}
+
+/** The agent of a task (its own, the personal agent that started it, or the person's Vibe). */
+const agentOf = (s, config, task) => contacts.agentForTask(s, config, task).catch(() => null);
+
+/** Make the task's root node, or return the one it already has. */
+async function ensureForTask(s, config, task, { origin = null } = {}) {
+ if (!task || !task.id || !String(task.requester || '').startsWith('user:')) return null;
+ if (task.assignment_id) {
+ const linked = await byId(s, task.requester, task.assignment_id);
+ if (linked) return linked;
+ }
+ const existing = await s.db.maybe('SELECT * FROM actor_assignments WHERE task_id = $1 AND depth = 0 ORDER BY created_at, id LIMIT 1', [task.id]);
+ if (existing) {
+ if (!task.assignment_id) await s.db.query('UPDATE tasks SET assignment_id = $2 WHERE id = $1 AND assignment_id IS NULL', [task.id, existing.id]);
+ return existing;
+ }
+ const agent = await agentOf(s, config, task);
+ const own = levels.LEVELS.includes(task.level) ? task.level : 'ask';
+ const effective = effectiveLevel(own, agent && agent.level);
+ const now = s.iso();
+ const id = s.newId('asg');
+ let row = await s.db.maybe(`INSERT INTO actor_assignments
+ (id, owner, root, parent, depth, origin, mode, reply, agent_id, task_id, level, effective_level, budget_usd, budget_spent_usd, state, created_at, updated_at, started_at, ended_at)
+ VALUES ($1,$2,$1,NULL,0,$3,'work','resume',$4,$5,$6,$7,$8,0,$9,$10,$10,$11,$12)
+ ON CONFLICT DO NOTHING RETURNING *`,
+ [id, task.requester, originOf(task, origin), agent ? agent.id : (task.agent_id || null), task.id, own, effective,
+ round(task.budget_task), stateOf(task.state), now, OPEN_STATES.has(stateOf(task.state)) ? now : null, TERMINAL.has(stateOf(task.state)) ? task.finished_at || now : null]);
+ if (!row) row = await s.db.maybe('SELECT * FROM actor_assignments WHERE task_id = $1 AND depth = 0 ORDER BY created_at, id LIMIT 1', [task.id]);
+ if (row) await s.db.query('UPDATE tasks SET assignment_id = $2 WHERE id = $1 AND assignment_id IS NULL', [task.id, row.id]);
+ return row;
+}
+
+/** One hand-off below a node. Refused when the root's budget is spent. */
+async function openChild(s, { owner, parent, origin = null, mode = 'work', reply = 'resume', agentId = null, taskId = null, level = null, state = 'queued' }) {
+ if (!parent || parent.owner !== owner) return { error: 'no such assignment of yours', status: 404, code: 'actor.assignment.not_found' };
+ if (TERMINAL.has(parent.state)) return { error: 'that assignment has ended', status: 409, code: 'actor.assignment.ended' };
+ const root = await rootOf(s, parent);
+ if (!root) return { error: 'the root of that assignment is gone', status: 409, code: 'actor.assignment.ended' };
+ const budget = await budgetOf(s, root);
+ if (budget.limit_usd > 0 && budget.remaining_usd <= 0) return { error: 'the assignment tree has spent its budget', status: 429, code: 'actor.assignment.budget' };
+ let agentLevel = 'ask';
+ if (agentId) {
+ const row = await s.db.maybe('SELECT level FROM actor_agents WHERE id = $1 AND owner = $2', [agentId, owner]);
+ if (row) agentLevel = row.level;
+ }
+ const own = levels.LEVELS.includes(level) ? level : agentLevel;
+ const effective = effectiveLevel(own, agentLevel, parent);
+ const now = s.iso();
+ const stateNow = STATES.includes(state) ? state : 'queued';
+ const row = await s.db.maybe(`INSERT INTO actor_assignments
+ (id, owner, root, parent, depth, origin, mode, reply, agent_id, task_id, level, effective_level, budget_usd, budget_spent_usd, state, created_at, updated_at, started_at, ended_at)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,0,$14,$15,$15,$16,NULL) RETURNING *`,
+ [s.newId('asg'), owner, root.id, parent.id, Number(parent.depth) + 1, ORIGINS.includes(origin) ? origin : parent.origin,
+ MODES.includes(mode) ? mode : 'work', REPLIES.includes(reply) ? reply : 'resume', agentId || null, taskId || null, own, effective,
+ round(root.budget_usd), stateNow, now, stateNow === 'running' || stateNow === 'waiting' ? now : null]);
+ return row;
+}
+
+/** End one child node (a Codes task, a Space action, a sub-agent's consult). */
+async function closeChild(s, owner, id, state) {
+ const next = STATES.includes(state) ? state : 'failed';
+ return s.db.maybe(`UPDATE actor_assignments SET state = $3, updated_at = $4,
+ started_at = COALESCE(started_at, CASE WHEN $3 IN ('running','waiting','succeeded','failed','cancelled') THEN $4 END),
+ ended_at = CASE WHEN $3 IN ('succeeded','failed','cancelled') THEN COALESCE(ended_at, $4) ELSE ended_at END
+ WHERE id = $1 AND owner = $2 RETURNING *`, [id, owner, next, s.iso()]);
+}
+
+/** The node and its direct children, owner-only. */
+async function tree(s, owner, id) {
+ const node = await byId(s, owner, id);
+ if (!node) return null;
+ const root = await rootOf(s, node);
+ const budget = root ? await budgetOf(s, root) : null;
+ const children = await s.db.many('SELECT * FROM actor_assignments WHERE parent = $1 AND owner = $2 ORDER BY created_at, id LIMIT 100', [node.id, owner]);
+ return { assignment: wire(node, budget), children: children.map((c) => wire(c, budget)) };
+}
+
+/**
+ * Cancel a node and every node below it. Nodes that already ended keep their end: a cancel stops work,
+ * it does not rewrite history. The caller (the API) stops the tasks the returned nodes ran.
+ */
+async function cancelTree(s, owner, id) {
+ const node = await byId(s, owner, id);
+ if (!node) return null;
+ const now = s.iso();
+ const cancelled = await s.db.many(`WITH RECURSIVE subtree AS (
+ SELECT id FROM actor_assignments WHERE id = $1 AND owner = $2
+ UNION ALL
+ SELECT a.id FROM actor_assignments a JOIN subtree t ON a.parent = t.id WHERE a.owner = $2
+ )
+ UPDATE actor_assignments SET state = 'cancelled', updated_at = $3, ended_at = COALESCE(ended_at, $3)
+ WHERE id IN (SELECT id FROM subtree) AND owner = $2
+ AND state NOT IN ('succeeded', 'failed', 'cancelled')
+ RETURNING *`, [id, owner, now]);
+ const after = await byId(s, owner, id);
+ const root = after ? await rootOf(s, after) : null;
+ const budget = root ? await budgetOf(s, root) : null;
+ return {
+ assignment: after ? wire(after, budget) : null,
+ cancelled: cancelled.map((row) => wire(row, budget)),
+ task_ids: [...new Set(cancelled.map((row) => row.task_id).filter(Boolean))],
+ };
+}
+
+/** Keep the root node's state with its task's (the engine calls this after every state change). */
+async function syncTaskState(s, task) {
+ if (!task || !task.id) return;
+ const state = stateOf(task.state);
+ const now = s.iso();
+ await s.db.query(`UPDATE actor_assignments SET state = $2, updated_at = $3,
+ started_at = COALESCE(started_at, CASE WHEN $2 IN ('running','waiting') THEN $3 END),
+ ended_at = CASE WHEN $2 IN ('succeeded','failed','cancelled') THEN COALESCE(ended_at, $3) ELSE ended_at END
+ WHERE task_id = $1 AND depth = 0`, [task.id, state, now]);
+ const roots = await s.db.many('SELECT DISTINCT root FROM actor_assignments WHERE task_id = $1', [task.id]);
+ for (const r of roots) await syncSpent(s, r.root);
+}
+
+/** Write the tree's spend onto the root (a read of the tree also computes it live). */
+async function syncSpent(s, rootId) {
+ const spent = await spendOf(s, rootId);
+ await s.db.query('UPDATE actor_assignments SET budget_spent_usd = $2 WHERE id = $1', [rootId, spent]);
+ return spent;
+}
+
+module.exports = {
+ ID, ORIGINS, MODES, REPLIES, STATES, TERMINAL, stateOf, effectiveLevel, wire,
+ byId, ensureForTask, openChild, closeChild, tree, cancelTree, syncTaskState, syncSpent, spendOf, budgetOf,
+};
@@ -193,12 +193,12 @@ function personText(task) {
@@ -193,12 +193,12 @@ function personText(task) {
return end < 0 ? t : t.slice(0, end);
return end < 0 ? t : t.slice(0, end);
}
}
-async function run(ctx, agent, input = '') {
+async function run(ctx, agent, input = '', { origin = 'person' } = {}) {
const { s, config, engine, webhooks } = ctx;
const { s, config, engine, webhooks } = ctx;
if (typeof input !== 'string' || input.length > 8000) return { status: 422, code: 'actor.agent.invalid', detail: 'input must be at most 8000 characters.' };
if (typeof input !== 'string' || input.length > 8000) return { status: 422, code: 'actor.agent.invalid', detail: 'input must be at most 8000 characters.' };
// The person's own active memory and this agent's active notes; a suggestion is not put in a run.
// The person's own active memory and this agent's active notes; a suggestion is not put in a run.
const notes = await memoryStore.forRun(s, agent.owner, agent.id);
const notes = await memoryStore.forRun(s, agent.owner, agent.id);
- const out = await createTask({ s, config, engine, webhooks, principal: { requester: agent.owner, via: `agent:${agent.id}`, project: null }, body: { task: prompt(agent, notes, input), mode: agent.mode, budget: { per_task_usd: Number(agent.budget_per_run_usd) } }, beforeInsert: async () => {
+ const out = await createTask({ s, config, engine, webhooks, principal: { requester: agent.owner, via: `agent:${agent.id}`, project: null }, body: { task: prompt(agent, notes, input), mode: agent.mode, budget: { per_task_usd: Number(agent.budget_per_run_usd) } }, assignmentOrigin: origin, beforeInsert: async () => {
const current = await s.db.maybe('SELECT daily_runs_max FROM actor_agents WHERE id = $1 AND owner = $2 FOR UPDATE', [agent.id, agent.owner]);
const current = await s.db.maybe('SELECT daily_runs_max FROM actor_agents WHERE id = $1 AND owner = $2 FOR UPDATE', [agent.id, agent.owner]);
if (!current) return { status: 404, code: 'actor.agent.not_found', detail: 'No such agent.' };
if (!current) return { status: 404, code: 'actor.agent.not_found', detail: 'No such agent.' };
const count = Number(await s.db.value('SELECT count(*) FROM tasks WHERE via = $1 AND created_at >= $2', [`agent:${agent.id}`, `${s.iso().slice(0, 10)}T00:00:00.000Z`]));
const count = Number(await s.db.value('SELECT count(*) FROM tasks WHERE via = $1 AND created_at >= $2', [`agent:${agent.id}`, `${s.iso().slice(0, 10)}T00:00:00.000Z`]));
@@ -219,7 +219,7 @@ async function tick(ctx) {
@@ -219,7 +219,7 @@ async function tick(ctx) {
while (next && next <= s.iso()) next = nextRun(agent, next);
while (next && next <= s.iso()) next = nextRun(agent, next);
const claimed = await s.db.maybe('UPDATE actor_agents SET next_run_at = $1, updated_at = $2 WHERE id = $3 AND status = $4 AND next_run_at = $5 RETURNING *', [next, s.iso(), agent.id, 'active', agent.next_run_at]);
const claimed = await s.db.maybe('UPDATE actor_agents SET next_run_at = $1, updated_at = $2 WHERE id = $3 AND status = $4 AND next_run_at = $5 RETURNING *', [next, s.iso(), agent.id, 'active', agent.next_run_at]);
if (!claimed) continue;
if (!claimed) continue;
- const out = await run(ctx, claimed);
+ const out = await run(ctx, claimed, '', { origin: 'routine' });
if (out.code) await s.db.query('UPDATE actor_agents SET last_skip_reason = $1 WHERE id = $2', [`${out.code}: ${out.detail}`.slice(0, 500), agent.id]);
if (out.code) await s.db.query('UPDATE actor_agents SET last_skip_reason = $1 WHERE id = $2', [`${out.code}: ${out.detail}`.slice(0, 500), agent.id]);
}
}
}
}
@@ -3,6 +3,7 @@
@@ -3,6 +3,7 @@
const crypto = require('crypto');
const crypto = require('crypto');
const looks = require('./looks');
const looks = require('./looks');
const { createNotifier } = require('../notify/client');
const { createNotifier } = require('../notify/client');
+const { stepFor } = require('./actions');
/**
/**
* What the OpenVibe runtime can do, as model tools — every one of them an OpenVibe service's public API, called the
* What the OpenVibe runtime can do, as model tools — every one of them an OpenVibe service's public API, called the
@@ -296,8 +297,19 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
@@ -296,8 +297,19 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
return { done: false, note: took ? 'The person was still using the desk when the wait ended: look at it, and tell them what is left if it is not done.' : `The person did not take the desk in ${Math.round(granted / 60000)} minutes: tell them in your answer what is left for them to do, with the link.`, watch };
return { done: false, note: took ? 'The person was still using the desk when the wait ended: look at it, and tell them what is left if it is not done.' : `The person did not take the desk in ${Math.round(granted / 60000)} minutes: tell them in your answer what is left for them to do, with the link.`, watch };
}
}
- async function call(name, args, { signal, requester = null, meter = null, budgetLeft = null, desks = null, emit = null, waitForPerson = null } = {}) {
+ async function call(name, args, { signal, requester = null, meter = null, budgetLeft = null, desks = null, emit = null, waitForPerson = null, requestApproval = null } = {}) {
let a = args && typeof args === 'object' ? args : {};
let a = args && typeof args === 'object' ? args : {};
+ // The approval gate (plan §12 A-2): the act and its always-ask category go through levels.decide
+ // before the step runs. A read, or Actor's own working state, says null and goes on.
+ if (typeof requestApproval === 'function') {
+ const step = stepFor(name, a);
+ if (step) {
+ let ask;
+ try { ask = await requestApproval({ kind: 'actor.action', ...step }); }
+ catch (err) { return json({ error: `the approval gate could not be asked, so nothing ran: ${(err && err.message) || err}`.slice(0, 300) }); }
+ if (ask && ask.pending) return json({ error: `not started: the person must approve it first at ${ask.url}`, approval: { id: ask.id, url: ask.url } });
+ }
+ }
try {
try {
if (name === 'list_my_robots' || name === 'robot_steps') {
if (name === 'list_my_robots' || name === 'robot_steps') {
const person = personOf(requester);
const person = personOf(requester);
@@ -39,7 +39,8 @@ function createA2APages(ctx) {
@@ -39,7 +39,8 @@ function createA2APages(ctx) {
}
}
const text = messageText(req.body.message);
const text = messageText(req.body.message);
if (!text) return contracts.http.sendProblem(res, 422, 'actor.a2a.empty', { detail: 'the task has no text', ctx: req.ov });
if (!text) return contracts.http.sendProblem(res, 422, 'actor.a2a.empty', { detail: 'the task has no text', ctx: req.ov });
- const out = await createTask({ s, config, engine, principal: { ...p, requester: 'user:' + subject }, body: { task: text, mode: 'balanced' } });
+ // Work from another agent is outside work: it never passes an approval level down to its children.
+ const out = await createTask({ s, config, engine, principal: { ...p, requester: 'user:' + subject }, body: { task: text, mode: 'balanced' }, assignmentOrigin: 'outside' });
if (!out || !out.task) {
if (!out || !out.task) {
return contracts.http.sendProblem(res, (out && out.status) || 500, (out && out.code) || 'actor.task.failed', { detail: (out && out.detail) || 'the task could not be started', ctx: req.ov });
return contracts.http.sendProblem(res, (out && out.status) || 500, (out && out.code) || 'actor.task.failed', { detail: (out && out.detail) || 'the task could not be started', ctx: req.ov });
}
}
@@ -121,7 +121,7 @@ ${ui.roster(rows.map((row) => ({ row, agent: contacts.wire(row) })), { when })}
@@ -121,7 +121,7 @@ ${ui.roster(rows.map((row) => ({ row, agent: contacts.wire(row) })), { when })}
if (!signed(req)) return login(req, res);
if (!signed(req)) return login(req, res);
const rows = await approvals.list(s, owner(req));
const rows = await approvals.list(s, owner(req));
page(req, res, 'Approvals', html`<header class="pa-head"><p class="pa-eyebrow">OpenVibe.Actor · approvals</p><h1>Approvals</h1>
page(req, res, 'Approvals', html`<header class="pa-head"><p class="pa-eyebrow">OpenVibe.Actor · approvals</p><h1>Approvals</h1>
-<p class="pa-lede">When one of your agents runs while you are away and its work would act for you outside Actor (a coding task on your own machine), it asks here first. Nothing runs until you approve. A request you leave expires after ${String(approvals.EXPIRES_DAYS)} days.</p></header>
+<p class="pa-lede">When one of your agents runs while you are away and its work would act for you outside Actor (a coding task on your own machine, a send, a purchase, a deletion, a password field, a Space publish), it asks here first. Nothing runs until you approve. An action's run had already stopped when it asked, so approving it is consent for you to run the task again. A request you leave expires after ${String(approvals.EXPIRES_DAYS)} days.</p></header>
${rows.length ? html`<ul class="pa-cards">${rows.map((a) => html`<li class="pa-card"><a href="/me/approvals/${a.id}"><strong>${a.summary}</strong></a> ${approvalPill(a)}<span class="pa-when">Asked ${time(a.created_at)}</span></li>`)}</ul>`
${rows.length ? html`<ul class="pa-cards">${rows.map((a) => html`<li class="pa-card"><a href="/me/approvals/${a.id}"><strong>${a.summary}</strong></a> ${approvalPill(a)}<span class="pa-when">Asked ${time(a.created_at)}</span></li>`)}</ul>`
: html`<p class="pa-empty">Nothing to approve. Your agents have not asked for anything yet.</p>`}
: html`<p class="pa-empty">Nothing to approve. Your agents have not asked for anything yet.</p>`}
<p><a href="/me/agents">Your agents</a></p>`);
<p><a href="/me/agents">Your agents</a></p>`);
@@ -133,9 +133,11 @@ ${rows.length ? html`<ul class="pa-cards">${rows.map((a) => html`<li class="pa-c
@@ -133,9 +133,11 @@ ${rows.length ? html`<ul class="pa-cards">${rows.map((a) => html`<li class="pa-c
const p = typeof a.payload === 'string' ? JSON.parse(a.payload) : a.payload;
const p = typeof a.payload === 'string' ? JSON.parse(a.payload) : a.payload;
page(req, res, 'Approval', html`<p class="pa-eyebrow"><a href="/me/approvals">Approvals</a></p><h1>${a.summary}</h1>
page(req, res, 'Approval', html`<p class="pa-eyebrow"><a href="/me/approvals">Approvals</a></p><h1>${a.summary}</h1>
<p>${approvalPill(a)} <span class="pa-when">Asked ${time(a.created_at)}${a.agent_id ? html` by <a href="/me/agents/${a.agent_id}">your agent</a>` : ''}${a.task_id ? html` in <a href="/tasks/${a.task_id}">this run</a>` : ''}</span></p>
<p>${approvalPill(a)} <span class="pa-when">Asked ${time(a.created_at)}${a.agent_id ? html` by <a href="/me/agents/${a.agent_id}">your agent</a>` : ''}${a.task_id ? html` in <a href="/tasks/${a.task_id}">this run</a>` : ''}</span></p>
-<section class="pa-panel"><h2>What it would do</h2><p>Start a coding task on your own machine through <a href="https://openvibe.codes/run">OpenVibe.Codes</a> with this request. You get the summary, the changed files and the patch; it pushes nowhere unless the request asks for a branch and your machine holds a GitHub token.</p>
-<pre class="pa-pre">${String(p.task || '').slice(0, 8000)}</pre></section>
-${a.state === 'pending' ? html`<form method="post" action="/me/approvals/${a.id}" class="pa-actions"><button type="submit" name="decision" value="approve" class="pa-btn pa-primary">Approve and start it</button> <button type="submit" name="decision" value="decline" class="pa-btn">Decline</button></form>`
+${a.kind === 'actor.action'
+ ? html`<section class="pa-panel"><h2>What it would do</h2><p>${a.summary}</p><pre class="pa-pre">${JSON.stringify(p, null, 2).slice(0, 8000)}</pre></section>`
+ : html`<section class="pa-panel"><h2>What it would do</h2><p>Start a coding task on your own machine through <a href="https://openvibe.codes/run">OpenVibe.Codes</a> with this request. You get the summary, the changed files and the patch; it pushes nowhere unless the request asks for a branch and your machine holds a GitHub token.</p>
+<pre class="pa-pre">${String(p.task || '').slice(0, 8000)}</pre></section>`}
+${a.state === 'pending' ? html`<form method="post" action="/me/approvals/${a.id}" class="pa-actions"><button type="submit" name="decision" value="approve" class="pa-btn pa-primary">${a.kind === 'actor.action' ? 'Approve for a retry' : 'Approve and start it'}</button> <button type="submit" name="decision" value="decline" class="pa-btn">Decline</button></form>`
: a.result_task_id ? html`<p>Started as <a href="/tasks/${a.result_task_id}">this task</a>.</p>` : ''}`);
: a.result_task_id ? html`<p>Started as <a href="/tasks/${a.result_task_id}">this task</a>.</p>` : ''}`);
});
});
r.post('/me/approvals/:id', async (req, res) => {
r.post('/me/approvals/:id', async (req, res) => {
@@ -19,6 +19,8 @@
@@ -19,6 +19,8 @@
* GET /tasks/:id/events actor.task.read its live stream (actor.task-event@2, Last-Event-ID)
* GET /tasks/:id/events actor.task.read its live stream (actor.task-event@2, Last-Event-ID)
* GET /tasks/:id/artifacts/:mediaId actor.task.read one file or chart the task made, from Actor's own database
* GET /tasks/:id/artifacts/:mediaId actor.task.read one file or chart the task made, from Actor's own database
* POST /tasks/:id/cancel actor.task.create stop it
* POST /tasks/:id/cancel actor.task.create stop it
+ * GET /assignments/:id actor.assignment.read one node of the delegated-work tree and its children
+ * POST /assignments/:id/cancel actor.assignment.cancel cancel that node and every node below it
*
*
* A task is visible to its requester only (or to tokens of the project it belongs to); anyone else gets 404, the same
* A task is visible to its requester only (or to tokens of the project it belongs to); anyone else gets 404, the same
* as a task that does not exist. Creating one goes through tasks/create.js (money limits before any work).
* as a task that does not exist. Creating one goes through tasks/create.js (money limits before any work).
@@ -39,6 +41,7 @@ const memoryStore = require('../memory');
@@ -39,6 +41,7 @@ const memoryStore = require('../memory');
const approvals = require('../agents/approvals');
const approvals = require('../agents/approvals');
const items = require('../actions/items');
const items = require('../actions/items');
const timeline = require('../agents/timeline');
const timeline = require('../agents/timeline');
+const assignments = require('../agents/assignments');
const modelPrefs = require('../runtime/model-prefs');
const modelPrefs = require('../runtime/model-prefs');
const driving = require('../agents/driving');
const driving = require('../agents/driving');
@@ -396,6 +399,8 @@ function createApi(ctx) {
@@ -396,6 +399,8 @@ function createApi(ctx) {
}
}
out.level = row.level || 'ask';
out.level = row.level || 'ask';
try { out.effective_level = await contacts.effectiveLevelFor(s, row, agent); } catch (err) { out.effective_level = out.level; }
try { out.effective_level = await contacts.effectiveLevelFor(s, row, agent); } catch (err) { out.effective_level = out.level; }
+ // An existing task gets its assignment root the first time it is read (plan §12 A-2), never by a backfill.
+ try { await assignments.ensureForTask(s, config, row); } catch (err) { /* the task still reads */ }
return out;
return out;
};
};
@@ -540,6 +545,26 @@ function createApi(ctx) {
@@ -540,6 +545,26 @@ function createApi(ctx) {
res.status(store.OPEN.includes(after.state) ? 202 : 200).json(await wireTask(after));
res.status(store.OPEN.includes(after.state) ? 202 : 200).json(await wireTask(after));
});
});
+
+ // ── The assignment tree (plan §12 A-2) ───────────────────
+ // One record for all delegated work. Both routes are owner-only: another person's node is the same 404 as one
+ // that does not exist. A cancel cascades down the tree and stops the tasks still running under it.
+ r.get('/assignments/:id', principal.requireCapability('actor.assignment.read'), limits.reads('actor.assignment.read'), async (req, res) => {
+ const p = req.principal;
+ if (!p.requester || !p.requester.startsWith('user:')) return problem(req, res, 404, 'actor.assignment.not_found', 'No such assignment.');
+ const out = await assignments.tree(s, p.requester, req.params.id);
+ if (!out) return problem(req, res, 404, 'actor.assignment.not_found', 'No such assignment.');
+ res.json(out);
+ });
+ r.post('/assignments/:id/cancel', principal.requireCapability('actor.assignment.cancel'), limits.budget('actor.assignment.cancel'), async (req, res) => {
+ const p = req.principal;
+ if (!p.requester || !p.requester.startsWith('user:')) return problem(req, res, 404, 'actor.assignment.not_found', 'No such assignment.');
+ const out = await assignments.cancelTree(s, p.requester, req.params.id);
+ if (!out) return problem(req, res, 404, 'actor.assignment.not_found', 'No such assignment.');
+ const { task_ids: taskIds, ...result } = out;
+ for (const taskId of taskIds) await engine.cancel(taskId, p.requester);
+ res.json(result);
+ });
return r;
return r;
}
}
@@ -35,6 +35,8 @@ const BUDGETS = {
@@ -35,6 +35,8 @@ const BUDGETS = {
'actor.route': { minute: 30, hour: 600 },
'actor.route': { minute: 30, hour: 600 },
'actor.task.stream': { minute: 30, hour: 600 },
'actor.task.stream': { minute: 30, hour: 600 },
'actor.approval.decide': { minute: 30, hour: 600 },
'actor.approval.decide': { minute: 30, hour: 600 },
+ // Cancelling a subtree is a write, not a task: the tree is small (one node per hand-off).
+ 'actor.assignment.cancel': { minute: 30, hour: 600 },
// The kill switch and the other controls of the agent (design 2026-11-07).
// The kill switch and the other controls of the agent (design 2026-11-07).
'actor.resource.control': { minute: 30, hour: 600 },
'actor.resource.control': { minute: 30, hour: 600 },
};
};
@@ -1457,11 +1457,13 @@ data: {"task_id":"tsk_…","seq":3,"kind":"output","agent":"openvibe-runtime","s
@@ -1457,11 +1457,13 @@ data: {"task_id":"tsk_…","seq":3,"kind":"output","agent":"openvibe-runtime","s
${table(['Route', 'Capability', 'What it does'], [
${table(['Route', 'Capability', 'What it does'], [
[html`<code>POST /api/v1/tasks</code>`, html`<code>actor.task.create</code>`, html`Create a task: <code>{ task, mode?, budget?: { per_task_usd, per_day_usd }, agent?, agent_id?, level?, idempotency_key?, webhooks?: [{ url, events, secret_ref? }] }</code>. <code>agent</code> pins an agent system; <code>agent_id</code> names one of the person's contacts (default Vibe) and <code>level</code> is the thread's own level. Answers 201 with the task (queued); with webhooks, also its <code>webhook_secret</code>, this once, unless every webhook names a project secret (<a href="#webhooks">webhooks</a>).`],
[html`<code>POST /api/v1/tasks</code>`, html`<code>actor.task.create</code>`, html`Create a task: <code>{ task, mode?, budget?: { per_task_usd, per_day_usd }, agent?, agent_id?, level?, idempotency_key?, webhooks?: [{ url, events, secret_ref? }] }</code>. <code>agent</code> pins an agent system; <code>agent_id</code> names one of the person's contacts (default Vibe) and <code>level</code> is the thread's own level. Answers 201 with the task (queued); with webhooks, also its <code>webhook_secret</code>, this once, unless every webhook names a project secret (<a href="#webhooks">webhooks</a>).`],
[html`<code>GET /api/v1/tasks/:id</code>`, html`<code>actor.task.read</code>`, html`The task: state, result, cost, explanation, error. While it runs on a desk of its own, <code>desk</code> (<code>url</code>: the person's page to watch it live and take it over; <code>kind</code>: <code>browser</code> or <code>desktop</code>); while it waits for its person to do a step there, <code>needs_you</code> (<code>what</code>, <code>url</code>).`],
[html`<code>GET /api/v1/tasks/:id</code>`, html`<code>actor.task.read</code>`, html`The task: state, result, cost, explanation, error. While it runs on a desk of its own, <code>desk</code> (<code>url</code>: the person's page to watch it live and take it over; <code>kind</code>: <code>browser</code> or <code>desktop</code>); while it waits for its person to do a step there, <code>needs_you</code> (<code>what</code>, <code>url</code>).`],
- [html`<code>GET /api/v1/tasks/:id/events</code>`, html`<code>actor.task.read</code>`, html`Server-sent events (<code>actor.task-event@2</code>): <code>state</code>, <code>output</code> (each step; a tool result that made a file or chart carries <code>artifacts</code>), <code>end</code>. Resumes with <code>Last-Event-ID</code>.`],
+ [html`<code>GET /api/v1/tasks/:id/events</code>`, html`<code>actor.task.read</code>`, html`Server-sent events (<code>actor.task-event@2</code>): <code>state</code>, <code>output</code> (each step; a tool result that made a file or chart carries <code>artifacts</code>, and a step names its assignment node in <code>assignment_id</code>), <code>end</code>. Resumes with <code>Last-Event-ID</code>.`],
[html`<code>GET /api/v1/tasks/:id/trace</code>`, html`<code>actor.task.read</code>`, html`The span numbers and names only: each model, tool, check, judge and sub-agent call with its kind, name, tier, family, start, ms, cost, tokens, ok, error code and loop step, plus model cost, tool cost and time. Another person gets the same 404 as a task that does not exist.`],
[html`<code>GET /api/v1/tasks/:id/trace</code>`, html`<code>actor.task.read</code>`, html`The span numbers and names only: each model, tool, check, judge and sub-agent call with its kind, name, tier, family, start, ms, cost, tokens, ok, error code and loop step, plus model cost, tool cost and time. Another person gets the same 404 as a task that does not exist.`],
[html`<code>GET /tasks/:id/files/:artifact</code>`, html`<code>the task's own page</code>`, html`A file or chart the task made, out of Actor's own database and private to the person who asked: their own page opens it, another person gets the same 404 as a task that does not exist.`],
[html`<code>GET /tasks/:id/files/:artifact</code>`, html`<code>the task's own page</code>`, html`A file or chart the task made, out of Actor's own database and private to the person who asked: their own page opens it, another person gets the same 404 as a task that does not exist.`],
[html`<code>POST /api/v1/tasks/:id/cancel</code>`, html`<code>actor.task.create</code>`, 'Stop it. What was spent stays spent.'],
[html`<code>POST /api/v1/tasks/:id/cancel</code>`, html`<code>actor.task.create</code>`, 'Stop it. What was spent stays spent.'],
[html`<code>GET /api/v1/tasks</code>`, html`<code>actor.task.list</code>`, html`Your tasks, newest first; <code>?limit=</code>, <code>?before=</code> (the <code>next</code> cursor).`],
[html`<code>GET /api/v1/tasks</code>`, html`<code>actor.task.list</code>`, html`Your tasks, newest first; <code>?limit=</code>, <code>?before=</code> (the <code>next</code> cursor).`],
+ [html`<code>GET /api/v1/assignments/:id</code>`, html`<code>actor.assignment.read</code>`, html`One node of your delegated-work tree and its children (<code>actor.assignment-tree@1</code>, each node <code>actor.assignment@1</code>): origin, mode, reply, agent, task, level and effective level, the root's budget, state and times. Another person gets 404.`],
+ [html`<code>POST /api/v1/assignments/:id/cancel</code>`, html`<code>actor.assignment.cancel</code>`, html`Cancel that node and every node below it; the tasks still running under the tree are stopped. Answers the node and the nodes it cancelled (<code>actor.assignment-cancel-result@1</code>). Another person gets 404.`],
[html`<code>GET /api/v1/agents</code>`, 'public / person', html`With no person: the agent systems, what each can do, its price and whether it works now (<code>actor.agent-list-result@2</code>). With a person: their own agent contacts, Vibe first (<code>actor.agent-list@1</code>).`],
[html`<code>GET /api/v1/agents</code>`, 'public / person', html`With no person: the agent systems, what each can do, its price and whether it works now (<code>actor.agent-list-result@2</code>). With a person: their own agent contacts, Vibe first (<code>actor.agent-list@1</code>).`],
[html`<code>GET /api/v1/agent-systems</code>`, 'public', 'The agent systems even when signed in (the explicit catalog path).'],
[html`<code>GET /api/v1/agent-systems</code>`, 'public', 'The agent systems even when signed in (the explicit catalog path).'],
[html`<code>GET /api/v1/agents/presets</code>`, 'public', 'The four presets a create can copy: Researcher, Inbox, Studio editor and Party host.'],
[html`<code>GET /api/v1/agents/presets</code>`, 'public', 'The four presets a create can copy: Researcher, Inbox, Studio editor and Party host.'],
@@ -30,7 +30,7 @@ const subject = require('./subject');
@@ -30,7 +30,7 @@ const subject = require('./subject');
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
-const CAPABILITIES = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.agent.manage', 'actor.agent.run', 'actor.resource.read', 'actor.resource.control', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage', 'actor.timeline.read'];
+const CAPABILITIES = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.agent.manage', 'actor.agent.run', 'actor.resource.read', 'actor.resource.control', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage', 'actor.timeline.read', 'actor.assignment.read', 'actor.assignment.cancel'];
function decodePayload(token) {
function decodePayload(token) {
const parts = String(token || '').split('.');
const parts = String(token || '').split('.');
@@ -42,13 +42,17 @@ const TABLES = [
@@ -42,13 +42,17 @@ const TABLES = [
// shows. Before the tasks, so the rows are counted before the task's cascade would take them.
// shows. Before the tasks, so the rows are counted before the task's cascade would take them.
{ table: 'actor_waits', subject: 'requester', value: (usr) => `user:${usr}`, file: 'waits.json',
{ table: 'actor_waits', subject: 'requester', value: (usr) => `user:${usr}`, file: 'waits.json',
columns: ['id', 'task_id', 'requester', 'step', 'kind', 'until', 'filter', 'deadline_at', 'on_timeout', 'line', 'status', 'created_at', 'updated_at', 'finished_at'] },
columns: ['id', 'task_id', 'requester', 'step', 'kind', 'until', 'filter', 'deadline_at', 'on_timeout', 'line', 'status', 'created_at', 'updated_at', 'finished_at'] },
+ // The tree of delegated work (plan §12 A-2): one row per hand-off, with the agent, task, level and budget
+ // state. Before tasks, so the rows are counted before a task's cascade could take them.
+ { table: 'actor_assignments', subject: 'owner', value: (usr) => `user:${usr}`, file: 'assignments.json',
+ columns: ['id', 'owner', 'root', 'parent', 'depth', 'origin', 'mode', 'reply', 'agent_id', 'task_id', 'level', 'effective_level', 'budget_usd', 'budget_spent_usd', 'state', 'created_at', 'updated_at', 'started_at', 'ended_at'] },
// Every column but webhook_secret (a task's signing secret is a credential, not the person's data).
// Every column but webhook_secret (a task's signing secret is a credential, not the person's data).
{
{
table: 'tasks', subject: 'requester', value: (usr) => `user:${usr}`, file: 'tasks.json',
table: 'tasks', subject: 'requester', value: (usr) => `user:${usr}`, file: 'tasks.json',
columns: ['id', 'requester', 'project_id', 'task', 'mode', 'agent', 'budget_task', 'budget_day', 'state', 'result', 'error_code', 'error_detail',
columns: ['id', 'requester', 'project_id', 'task', 'mode', 'agent', 'budget_task', 'budget_day', 'state', 'result', 'error_code', 'error_detail',
'cost_usd', 'free_usd', 'explanation', 'cancel_at', 'cancel_by', 'last_seq', 'idem_key', 'idem_hash', 'created_at', 'finished_at', 'timeout_ms', 'via', 'webhooks', 'parent_id',
'cost_usd', 'free_usd', 'explanation', 'cancel_at', 'cancel_by', 'last_seq', 'idem_key', 'idem_hash', 'created_at', 'finished_at', 'timeout_ms', 'via', 'webhooks', 'parent_id',
// A fork's parent step and the one change it was made with (stage V2-9), and how often it resumed.
// A fork's parent step and the one change it was made with (stage V2-9), and how often it resumed.
- 'fork_of_step', 'fork_change', 'resumes', 'agent_id', 'level'],
+ 'fork_of_step', 'fork_change', 'resumes', 'agent_id', 'level', 'assignment_id'],
},
},
// The person's own per-UTC-day spend accounting. Deleted: nothing in Actor keeps a person's spend record for a
// The person's own per-UTC-day spend accounting. Deleted: nothing in Actor keeps a person's spend record for a
// reason that outlives them. The `*` row (everyone's daily ceiling) does not match `user:usr_…` and stays — it is
// reason that outlives them. The `*` row (everyone's daily ceiling) does not match `user:usr_…` and stays — it is
@@ -651,6 +651,10 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -651,6 +651,10 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
async function runChild(parent, ctx, args, childTrace = null) {
async function runChild(parent, ctx, args, childTrace = null) {
const goal = clip(String((args && args.goal) || '').replace(/\s+/g, ' ').trim(), 600);
const goal = clip(String((args && args.goal) || '').replace(/\s+/g, ' ').trim(), 600);
if (!goal) return { delegate: true, error: 'a sub-agent needs a goal' };
if (!goal) return { delegate: true, error: 'a sub-agent needs a goal' };
+ // An agent asking another agent is one node on the assignment tree (plan §12 A-2); the root's budget
+ // decides whether it may start at all.
+ const node = typeof ctx.assign === 'function' ? await ctx.assign({ mode: 'consult', reply: 'resume', state: 'running' }) : null;
+ if (node && node.error) return { delegate: true, error: `not started: ${node.error}` };
const want = Array.isArray(args.tools) && args.tools.length ? new Set(args.tools.map(String)) : null;
const want = Array.isArray(args.tools) && args.tools.length ? new Set(args.tools.map(String)) : null;
// Never wider than the parent: its offered tools, narrowed by the call, and never `delegate` (depth 1).
// Never wider than the parent: its offered tools, narrowed by the call, and never `delegate` (depth 1).
// A child never gets an acting tool (stage V2-9): it has no checkpoint of its own, so a crash inside it could
// A child never gets an acting tool (stage V2-9): it has no checkpoint of its own, so a crash inside it could
@@ -681,7 +685,13 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -681,7 +685,13 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
{ role: 'user', content: `${goal}\n\n(One part of a bigger task: ${clip(parent.task, 300)})` },
{ role: 'user', content: `${goal}\n\n(One part of a bigger task: ${clip(parent.task, 300)})` },
);
);
const maxModelCalls = config.loop && config.loop.subAgentCalls != null ? config.loop.subAgentCalls : 8;
const maxModelCalls = config.loop && config.loop.subAgentCalls != null ? config.loop.subAgentCalls : 8;
- await drive(child, cctx, { reflections: 0, maxModelCalls });
+ try {
+ await drive(child, cctx, { reflections: 0, maxModelCalls });
+ if (node && typeof ctx.closeAssignment === 'function') await ctx.closeAssignment(node.id, 'succeeded');
+ } catch (err) {
+ if (node && typeof ctx.closeAssignment === 'function') await ctx.closeAssignment(node.id, 'failed');
+ throw err;
+ }
// Its evidence joins the parent's ledger, renumbered; its answer's citations follow the new ids.
// Its evidence joins the parent's ledger, renumbered; its answer's citations follow the new ids.
const ids = new Map();
const ids = new Map();
for (const item of child.ledger.items) {
for (const item of child.ledger.items) {
@@ -846,7 +856,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -846,7 +856,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
* a span; and drives on from the step it stopped at. A checkpoint whose last step already answered is delivered
* a span; and drives on from the step it stopped at. A checkpoint whose last step already answered is delivered
* without another model call.
* without another model call.
*/
*/
- async function continueFrom({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null, chatPolicy = null, approveChatTool = null, context = [], personText = null, agentId = null, notifyTask = null, askPerson = null, control = null, resume = null, fork = null, spent = null, waits = null, waitEvent = null, delta = null }) {
+ async function continueFrom({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null, assign = null, closeAssignment = null, chatPolicy = null, approveChatTool = null, context = [], personText = null, agentId = null, notifyTask = null, askPerson = null, control = null, resume = null, fork = null, spent = null, waits = null, waitEvent = null, delta = null }) {
const source = resume && resume.state ? resume.state : (fork && fork.checkpoint && fork.checkpoint.state);
const source = resume && resume.state ? resume.state : (fork && fork.checkpoint && fork.checkpoint.state);
const restored = restoreState(source);
const restored = restoreState(source);
if (!restored) throw Object.assign(new Error('the saved step could not be read'), { code: 'actor.checkpoint.invalid' });
if (!restored) throw Object.assign(new Error('the saved step could not be read'), { code: 'actor.checkpoint.invalid' });
@@ -867,7 +877,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -867,7 +877,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
// delivers a saved final answer without calling the model again.
// delivers a saved final answer without calling the model again.
if (fork) state.answered = false;
if (fork) state.answered = false;
const ctx = {
const ctx = {
- config, log, models, signal, requester, taskId, meter, budgetLeft, waitForPerson, requestApproval, chatPolicy, approveChatTool, artifacts,
+ config, log, models, signal, requester, taskId, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, chatPolicy, approveChatTool, artifacts,
store, now, agentId, personText: personText == null ? String(task) : String(personText),
store, now, agentId, personText: personText == null ? String(task) : String(personText),
trace: createTracer({ store, taskId, log, now }),
trace: createTracer({ store, taskId, log, now }),
readOutside: () => state.readOutside === true, notifyTask,
readOutside: () => state.readOutside === true, notifyTask,
@@ -1004,9 +1014,9 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -1004,9 +1014,9 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
}
}
}
}
- async function run({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null, chatPolicy = null, approveChatTool = null, context = [], personText = null, agentId = null, notifyTask = null, askPerson = null, control = null, resume = null, fork = null, spent = null, waits = null, waitEvent = null, attachments = [], delta = null }) {
+ async function run({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null, assign = null, closeAssignment = null, chatPolicy = null, approveChatTool = null, context = [], personText = null, agentId = null, notifyTask = null, askPerson = null, control = null, resume = null, fork = null, spent = null, waits = null, waitEvent = null, attachments = [], delta = null }) {
if (resume || fork) {
if (resume || fork) {
- return await continueFrom({ task, requester, taskId, mode, signal, emit, meter, budgetLeft, waitForPerson, requestApproval, chatPolicy, approveChatTool, context, personText, agentId, notifyTask, askPerson, control, resume, fork, spent, waits, waitEvent, delta });
+ return await continueFrom({ task, requester, taskId, mode, signal, emit, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, chatPolicy, approveChatTool, context, personText, agentId, notifyTask, askPerson, control, resume, fork, spent, waits, waitEvent, delta });
}
}
const tier = mode === 'best' && models.resolve('best').available ? 'best' : 'smart';
const tier = mode === 'best' && models.resolve('best').available ? 'best' : 'smart';
if (!models.resolve(tier).available) throw Object.assign(new Error(models.resolve(tier).reason || 'no model is available for the loop'), { code: 'actor.model.unavailable' });
if (!models.resolve(tier).available) throw Object.assign(new Error(models.resolve(tier).reason || 'no model is available for the loop'), { code: 'actor.model.unavailable' });
@@ -1030,7 +1040,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -1030,7 +1040,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
},
},
};
};
const ctx = {
const ctx = {
- config, log, models, signal, requester, taskId, meter, budgetLeft, waitForPerson, requestApproval, chatPolicy, approveChatTool, artifacts,
+ config, log, models, signal, requester, taskId, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, chatPolicy, approveChatTool, artifacts,
store, now, agentId, personText: personText == null ? String(task) : String(personText),
store, now, agentId, personText: personText == null ? String(task) : String(personText),
trace: createTracer({ store, taskId, log, now }),
trace: createTracer({ store, taskId, log, now }),
readOutside: () => state.readOutside === true, notifyTask,
readOutside: () => state.readOutside === true, notifyTask,
@@ -6,9 +6,10 @@
@@ -6,9 +6,10 @@
* answers the summary, changed files and patch. The model use is on the person's machine and keys, so nothing is
* answers the summary, changed files and patch. The model use is on the person's machine and keys, so nothing is
* metered here.
* metered here.
*
*
- * A personal agent's run that would start coding work on its owner's machine still asks first (the approval inbox,
- * agents/approvals.js): the engine passes requestApproval into the loop's context for those runs, and this tool
- * returns the request's link instead of running anything.
+ * A personal agent's run that would start coding work on its owner's machine asks first (the approval gate,
+ * agents/levels.js and agents/actions.js): the registry calls requestApproval with this tool's act and category
+ * before run(), so nothing reaches Codes until the person approves. Starting Codes is also one node on the
+ * assignment tree.
*/
*/
const { createCodes, repoIn } = require('../../agents/codes');
const { createCodes, repoIn } = require('../../agents/codes');
@@ -36,14 +37,16 @@ module.exports = {
@@ -36,14 +37,16 @@ module.exports = {
if (!person) return { text: JSON.stringify({ error: 'coding tasks run on your own machine through OpenVibe.Codes, so they need you signed in' }), isError: true };
if (!person) return { text: JSON.stringify({ error: 'coding tasks run on your own machine through OpenVibe.Codes, so they need you signed in' }), isError: true };
const task = [String(input.instruction || '').slice(0, 8000), input.repo ? String(input.repo).slice(0, 500) : ''].filter(Boolean).join('\n\n');
const task = [String(input.instruction || '').slice(0, 8000), input.repo ? String(input.repo).slice(0, 500) : ''].filter(Boolean).join('\n\n');
const repo = repoIn(task);
const repo = repoIn(task);
- // A person's own agent, running while they are away, asks before it starts work on their machine.
- if (repo && typeof ctx.requestApproval === 'function') {
- const ask = await ctx.requestApproval({ kind: 'codes.task', summary: `Start a coding task on your machine in ${repo}`, payload: { task } });
- if (ask && ask.pending) {
- return { text: JSON.stringify({ error: `not started: the person must approve it first at ${ask.url}` }), isError: true, data: ask };
- }
+ // One hand-off to Codes on the tree: work, its result polled by the parent run.
+ const node = typeof ctx.assign === 'function' ? await ctx.assign({ mode: 'work', reply: 'poll', agentId: ctx.agentId || null, state: 'running' }) : null;
+ if (node && node.error) return { text: JSON.stringify({ error: `not started: ${node.error}` }), isError: true, data: node };
+ try {
+ const out = await codes.adapter({ task, requester: ctx.requester, signal: ctx.signal, emit: ctx.emit, taskId: ctx.taskId || null });
+ if (node && typeof ctx.closeAssignment === 'function') await ctx.closeAssignment(node.id, 'succeeded');
+ return { text: out.text, data: { sources: out.sources, model: out.model }, sources: (out.sources || []).map((url) => ({ url })) };
+ } catch (err) {
+ if (node && typeof ctx.closeAssignment === 'function') await ctx.closeAssignment(node.id, 'failed');
+ throw err;
}
}
- const out = await codes.adapter({ task, requester: ctx.requester, signal: ctx.signal, emit: ctx.emit, taskId: ctx.taskId || null });
- return { text: out.text, data: { sources: out.sources, model: out.model }, sources: (out.sources || []).map((url) => ({ url })) };
},
},
};
};
@@ -43,6 +43,7 @@
@@ -43,6 +43,7 @@
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
+const { stepFor } = require('../../agents/actions');
const SCOPES = ['public', 'person', 'private-ok'];
const SCOPES = ['public', 'person', 'private-ok'];
const NAME = /^[a-z][a-z0-9_]{1,63}$/;
const NAME = /^[a-z][a-z0-9_]{1,63}$/;
@@ -153,6 +154,26 @@ function createRegistry({ dir = __dirname, log = console, strict = false, fetchI
@@ -153,6 +154,26 @@ function createRegistry({ dir = __dirname, log = console, strict = false, fetchI
if (ctx.chatPolicy && tool.acts === true) {
if (ctx.chatPolicy && tool.acts === true) {
if (!ctx.approveChatTool || !await ctx.approveChatTool(name, input)) return normalize({ text: 'The action was not approved. Nothing ran.', isError: true });
if (!ctx.approveChatTool || !await ctx.approveChatTool(name, input)) return normalize({ text: 'The action was not approved. Nothing ran.', isError: true });
}
}
+ // The approval gate (plan §12 A-2): every acting tool sees levels.decide with its act and its
+ // always-ask category before it changes anything. A tool that only writes Actor's own working
+ // state says null and goes on. A chat has its own consent card above, so it is not asked twice.
+ if (!ctx.chatPolicy && tool.acts === true && typeof ctx.requestApproval === 'function') {
+ const step = stepFor(name, input);
+ if (step) {
+ let ask;
+ try {
+ ask = await ctx.requestApproval({ kind: 'actor.action', ...step });
+ } catch (err) {
+ return normalize({ text: JSON.stringify({ error: `the approval gate could not be asked, so nothing ran: ${(err && err.message) || err}`.slice(0, 300) }), isError: true });
+ }
+ if (ask && ask.pending) {
+ return normalize({
+ text: JSON.stringify({ error: `not started: the person must approve it first at ${ask.url}`, approval: { id: ask.id, url: ask.url } }),
+ data: ask, isError: true,
+ });
+ }
+ }
+ }
const signal = ctx.signal;
const signal = ctx.signal;
const timeoutMs = Math.min(tool.timeoutMs || DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
const timeoutMs = Math.min(tool.timeoutMs || DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS);
const timeout = AbortSignal.timeout(timeoutMs);
const timeout = AbortSignal.timeout(timeoutMs);
@@ -25,11 +25,17 @@ module.exports = {
@@ -25,11 +25,17 @@ module.exports = {
const space = spaceFor(ctx);
const space = spaceFor(ctx);
if (!space.enabled) return { text: JSON.stringify({ error: 'OpenVibe.Space is not connected to this server' }), isError: true };
if (!space.enabled) return { text: JSON.stringify({ error: 'OpenVibe.Space is not connected to this server' }), isError: true };
const person = personOf(ctx.requester);
const person = personOf(ctx.requester);
+ const node = typeof ctx.assign === 'function' ? await ctx.assign({ mode: 'work', reply: 'resume', agentId: ctx.agentId || null, state: 'running' }) : null;
+ if (node && node.error) return { text: JSON.stringify({ error: `not started: ${node.error}` }), isError: true, data: node };
const body = {};
const body = {};
if (input && input.name) body.name = String(input.name).slice(0, 40);
if (input && input.name) body.name = String(input.name).slice(0, 40);
if (input && input.description) body.description = String(input.description).slice(0, 400);
if (input && input.description) body.description = String(input.description).slice(0, 400);
const out = await space.call('POST', '/api/v1/builder/spaces', person, body);
const out = await space.call('POST', '/api/v1/builder/spaces', person, body);
- if (!out.ok) return { text: JSON.stringify({ error: out.error }), isError: true };
+ if (!out.ok) {
+ if (node && typeof ctx.closeAssignment === 'function') await ctx.closeAssignment(node.id, 'failed');
+ return { text: JSON.stringify({ error: out.error }), isError: true };
+ }
+ if (node && typeof ctx.closeAssignment === 'function') await ctx.closeAssignment(node.id, 'succeeded');
const s = out.data && out.data.space;
const s = out.data && out.data.space;
return { text: JSON.stringify({ space: s || null }), data: { space: s || null } };
return { text: JSON.stringify({ space: s || null }), data: { space: s || null } };
},
},
@@ -45,8 +45,14 @@ module.exports = {
@@ -45,8 +45,14 @@ module.exports = {
const list = Array.isArray(input && input.files) ? input.files : [];
const list = Array.isArray(input && input.files) ? input.files : [];
if (!list.length) return { text: JSON.stringify({ error: 'send the files the space is made of' }), isError: true };
if (!list.length) return { text: JSON.stringify({ error: 'send the files the space is made of' }), isError: true };
const files = list.slice(0, 20).map((f) => ({ path: String((f && f.path) || '').slice(0, 80), content: String((f && f.content) || '').slice(0, 200000) }));
const files = list.slice(0, 20).map((f) => ({ path: String((f && f.path) || '').slice(0, 80), content: String((f && f.content) || '').slice(0, 200000) }));
+ const node = typeof ctx.assign === 'function' ? await ctx.assign({ mode: 'work', reply: 'resume', agentId: ctx.agentId || null, state: 'running' }) : null;
+ if (node && node.error) return { text: JSON.stringify({ error: `not started: ${node.error}` }), isError: true, data: node };
const out = await space.call('PUT', `/api/v1/builder/spaces/${id}/files`, person, { files });
const out = await space.call('PUT', `/api/v1/builder/spaces/${id}/files`, person, { files });
- if (!out.ok) return { text: JSON.stringify({ error: out.error }), isError: true };
+ if (!out.ok) {
+ if (node && typeof ctx.closeAssignment === 'function') await ctx.closeAssignment(node.id, 'failed');
+ return { text: JSON.stringify({ error: out.error }), isError: true };
+ }
+ if (node && typeof ctx.closeAssignment === 'function') await ctx.closeAssignment(node.id, 'succeeded');
const written = (out.data && out.data.files) || [];
const written = (out.data && out.data.files) || [];
return { text: JSON.stringify({ space_id: id, files: written.map((f) => ({ path: f.path, bytes: String(f.content || '').length })) }), data: { files: written } };
return { text: JSON.stringify({ space_id: id, files: written.map((f) => ({ path: f.path, bytes: String(f.content || '').length })) }), data: { files: written } };
},
},
@@ -27,8 +27,14 @@ module.exports = {
@@ -27,8 +27,14 @@ module.exports = {
const person = personOf(ctx.requester);
const person = personOf(ctx.requester);
const id = spaceIdOf(input);
const id = spaceIdOf(input);
if (!id) return { text: JSON.stringify({ error: 'that is not a space id (spc_…)' }), isError: true };
if (!id) return { text: JSON.stringify({ error: 'that is not a space id (spc_…)' }), isError: true };
+ const node = typeof ctx.assign === 'function' ? await ctx.assign({ mode: 'work', reply: 'resume', agentId: ctx.agentId || null, state: 'running' }) : null;
+ if (node && node.error) return { text: JSON.stringify({ error: `not started: ${node.error}` }), isError: true, data: node };
const out = await space.call('POST', `/api/v1/builder/spaces/${id}/publish`, person, {});
const out = await space.call('POST', `/api/v1/builder/spaces/${id}/publish`, person, {});
- if (!out.ok) return { text: JSON.stringify({ error: out.error }), isError: true };
+ if (!out.ok) {
+ if (node && typeof ctx.closeAssignment === 'function') await ctx.closeAssignment(node.id, 'failed');
+ return { text: JSON.stringify({ error: out.error }), isError: true };
+ }
+ if (node && typeof ctx.closeAssignment === 'function') await ctx.closeAssignment(node.id, 'succeeded');
const data = out.data || {};
const data = out.data || {};
if (data.state === 'published') return { text: JSON.stringify({ space_id: id, state: 'published', url: data.url || null }), data };
if (data.state === 'published') return { text: JSON.stringify({ space_id: id, state: 'published', url: data.url || null }), data };
return {
return {
@@ -14,6 +14,7 @@ const { checkWebhookUrl } = require('../net/webhook-post');
@@ -14,6 +14,7 @@ const { checkWebhookUrl } = require('../net/webhook-post');
const { mintSecret } = require('./webhooks');
const { mintSecret } = require('./webhooks');
const learn = require('../runtime/brain/learn');
const learn = require('../runtime/brain/learn');
const contacts = require('../agents/contacts');
const contacts = require('../agents/contacts');
+const assignments = require('../agents/assignments');
/**
/**
* The sharing system (design 2026-11-06), injected once by server/app.js. A run is its owner's until the Share sheet
* The sharing system (design 2026-11-06), injected once by server/app.js. A run is its owner's until the Share sheet
@@ -30,7 +31,7 @@ function untilMidnight(nowMs) {
@@ -30,7 +31,7 @@ function untilMidnight(nowMs) {
return Math.ceil((Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), n.getUTCDate() + 1) - n.getTime()) / 1000);
return Math.ceil((Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), n.getUTCDate() + 1) - n.getTime()) / 1000);
}
}
-async function createTask({ s, config, engine, principal, body, webhooks: webhookQueue = null, beforeInsert = null, chatContext = null, afterInsert = null, parentId = null, forkOfStep = null, forkChange = null }) {
+async function createTask({ s, config, engine, principal, body, webhooks: webhookQueue = null, beforeInsert = null, chatContext = null, afterInsert = null, parentId = null, forkOfStep = null, forkChange = null, assignmentOrigin = null }) {
const v = contracts.validate('actor.task-create-request@1', body);
const v = contracts.validate('actor.task-create-request@1', body);
if (!v.valid) return { status: 422, code: 'actor.task.invalid', detail: (v.errors || []).map((e) => `${e.path || e.instancePath || ''} ${e.message}`.trim()).join('; ').slice(0, 500) || 'invalid request' };
if (!v.valid) return { status: 422, code: 'actor.task.invalid', detail: (v.errors || []).map((e) => `${e.path || e.instancePath || ''} ${e.message}`.trim()).join('; ').slice(0, 500) || 'invalid request' };
const tier = config.allowance;
const tier = config.allowance;
@@ -99,8 +100,9 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
@@ -99,8 +100,9 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
const refused = await beforeInsert();
const refused = await beforeInsert();
if (refused) return refused;
if (refused) return refused;
}
}
+ const id = s.newId('tsk');
const row = await store.insertTask(s, {
const row = await store.insertTask(s, {
- id: s.newId('tsk'), requester: principal.requester, via: principal.via || null, project_id: principal.project || null, task: body.task, mode: body.mode || 'balanced', agent: body.agent || null,
+ id, requester: principal.requester, via: principal.via || null, project_id: principal.project || null, task: body.task, mode: body.mode || 'balanced', agent: body.agent || null,
budget_task: budgetTask, budget_day: perDay, idem_key: body.idempotency_key || null, idem_hash: body.idempotency_key ? hash : null, created_at: s.iso(),
budget_task: budgetTask, budget_day: perDay, idem_key: body.idempotency_key || null, idem_hash: body.idempotency_key ? hash : null, created_at: s.iso(),
webhooks, webhook_secret: ownSecret ? mintSecret() : null, parent_id: parentId,
webhooks, webhook_secret: ownSecret ? mintSecret() : null, parent_id: parentId,
// A fork (stage V2-9) starts from its parent's checkpoint at this step, with the one change the person
// A fork (stage V2-9) starts from its parent's checkpoint at this step, with the one change the person
@@ -113,9 +115,13 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
@@ -113,9 +115,13 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
});
});
// What belongs with the task from its first moment (the chat's attached files), in the same transaction: the
// What belongs with the task from its first moment (the chat's attached files), in the same transaction: the
// engine starts it only after this commits, so the run never begins without them.
// engine starts it only after this commits, so the run never begins without them.
- if (afterInsert) await afterInsert(row);
- if (chatContext) await s.db.query('INSERT INTO actor_chat_tasks (task_id, agent_id, policy, conversation) VALUES ($1, $2, $3, $4)', [row.id, chatContext.agentId, chatContext.policy, chatContext.conversation]);
- return { status: 201, task: row };
+ // Every hand-off is one node (plan §12 A-2). The root is made with the task, so the tree exists from
+ // its first step; an older task without one gets it lazily (assignments.ensureForTask).
+ const node = await assignments.ensureForTask(s, config, row, { origin: assignmentOrigin });
+ const task = node ? { ...row, assignment_id: node.id } : row;
+ if (afterInsert) await afterInsert(task);
+ if (chatContext) await s.db.query('INSERT INTO actor_chat_tasks (task_id, agent_id, policy, conversation) VALUES ($1, $2, $3, $4)', [task.id, chatContext.agentId, chatContext.policy, chatContext.conversation]);
+ return { status: 201, task };
});
});
// The engine starts the task only once its row has committed; a webhook that asked for `queued` hears it now.
// The engine starts the task only once its row has committed; a webhook that asked for `queued` hears it now.
if (out.status === 201) {
if (out.status === 201) {
@@ -30,6 +30,7 @@ const { check } = require('../agents/check');
@@ -30,6 +30,7 @@ const { check } = require('../agents/check');
const { check: checkClaims } = require('../runtime/check');
const { check: checkClaims } = require('../runtime/check');
const approvals = require('../agents/approvals');
const approvals = require('../agents/approvals');
const contacts = require('../agents/contacts');
const contacts = require('../agents/contacts');
+const assignments = require('../agents/assignments');
const levels = require('../agents/levels');
const levels = require('../agents/levels');
const { repoIn: codesRepoIn } = require('../agents/codes');
const { repoIn: codesRepoIn } = require('../agents/codes');
const memory = require('../memory');
const memory = require('../memory');
@@ -112,15 +113,41 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -112,15 +113,41 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
return text;
return text;
}
}
+ // A step event names its assignment node (plan §12 A-2), so the Dock can draw the tree later. New tasks
+ // carry the id on the row; an older task gets its root lazily here, once.
+ const assignmentIds = new Map();
+ async function assignmentIdFor(taskId, row = null) {
+ if (assignmentIds.has(taskId)) return assignmentIds.get(taskId);
+ let id = row && row.assignment_id;
+ if (!id) {
+ const task = row || await store.getTask(s, taskId);
+ if (task) {
+ const node = await assignments.ensureForTask(s, config, task).catch((err) => {
+ log.warn && log.warn('[Actor] assignment:', taskId, (err && err.message) || err);
+ return null;
+ });
+ id = node && node.id;
+ }
+ }
+ if (id) assignmentIds.set(taskId, id);
+ return id || null;
+ }
async function emit(id, body) {
async function emit(id, body) {
- const event = await store.appendEvent(s, id, { at: iso(), ...body });
- stream.publish(id, event);
- return event;
+ const event = { at: iso(), ...body };
+ if (event.kind === 'output' && !event.assignment_id) {
+ const assignmentId = await assignmentIdFor(id);
+ if (assignmentId) event.assignment_id = assignmentId;
+ }
+ const saved = await store.appendEvent(s, id, event);
+ stream.publish(id, saved);
+ return saved;
}
}
async function setState(id, state, { agentForEvent, ...patch } = {}) {
async function setState(id, state, { agentForEvent, ...patch } = {}) {
const row = await store.updateTask(s, id, { state, ...patch });
const row = await store.updateTask(s, id, { state, ...patch });
const ended = ['succeeded', 'failed', 'cancelled'].includes(state);
const ended = ['succeeded', 'failed', 'cancelled'].includes(state);
await emit(id, { kind: ended ? 'end' : 'state', state, ...(agentForEvent ? { agent: agentForEvent } : {}) });
await emit(id, { kind: ended ? 'end' : 'state', state, ...(agentForEvent ? { agent: agentForEvent } : {}) });
+ // The assignment tree follows the task it runs (plan §12 A-2), and its root's spend is written with it.
+ if (row) await assignments.syncTaskState(s, row).catch((err) => log.warn && log.warn('[Actor] assignment state:', id, (err && err.message) || err));
if (ended) stream.end(id);
if (ended) stream.end(id);
// "Tell me when it's done" (stage V2-6): done or failed, once per task. Cancelled is the person's own action.
// "Tell me when it's done" (stage V2-6): done or failed, once per task. Cancelled is the person's own action.
if (ended && row && (state === 'succeeded' || state === 'failed')) await tellPerson(row, state);
if (ended && row && (state === 'succeeded' || state === 'failed')) await tellPerson(row, state);
@@ -283,12 +310,20 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -283,12 +310,20 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
// personal agent that started it, else the person's Vibe. Another person's or a service's work has no contact.
// personal agent that started it, else the person's Vibe. Another person's or a service's work has no contact.
let taskAgent = null;
let taskAgent = null;
let taskLevel = 'ask';
let taskLevel = 'ask';
+ let assignment = null;
try {
try {
taskAgent = await contacts.agentForTask(s, config, row);
taskAgent = await contacts.agentForTask(s, config, row);
taskLevel = await contacts.effectiveLevelFor(s, row, taskAgent);
taskLevel = await contacts.effectiveLevelFor(s, row, taskAgent);
+ assignment = await assignments.ensureForTask(s, config, row);
+ if (assignment && assignment.effective_level) taskLevel = assignment.effective_level;
} catch (err) {
} catch (err) {
log.warn && log.warn('[Actor] task agent/level:', id, (err && err.message) || err);
log.warn && log.warn('[Actor] task agent/level:', id, (err && err.message) || err);
}
}
+ // Both execution paths get the same gate and assignment callbacks: the one loop passes them into its
+ // tools, and an adapter that starts a Codes task gets them too.
+ let requestApproval = null;
+ let assign = null;
+ let closeAssignment = null;
// One place every approval gate asks through: the level, then the always-ask floor, then (for approve-for-me)
// One place every approval gate asks through: the level, then the always-ask floor, then (for approve-for-me)
// a reviewer model of another family. Returns { asked: false } when the step may go ahead.
// a reviewer model of another family. Returns { asked: false } when the step may go ahead.
const askForApproval = async ({ kind, summary, payload, category = null, act = 'act', recipientKnown = null, agent = LOOP_AGENT }) => {
const askForApproval = async ({ kind, summary, payload, category = null, act = 'act', recipientKnown = null, agent = LOOP_AGENT }) => {
@@ -301,6 +336,20 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -301,6 +336,20 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
await tellPerson(row, 'needs_you', { what: `approve: ${summary}`, url });
await tellPerson(row, 'needs_you', { what: `approve: ${summary}`, url });
return { asked: true, decision, ask, url };
return { asked: true, decision, ask, url };
};
};
+ // A child hand-off (a sub-agent's consult, a Codes task, a Space action) is one node below this task's.
+ // The root's budget decides whether it may start at all: when it is spent, nothing below it starts.
+ assign = assignment ? async (spec = {}) => {
+ try {
+ return await assignments.openChild(s, { owner: row.requester, parent: assignment, taskId: id, agentId: taskAgent ? taskAgent.id : null, ...spec });
+ } catch (err) {
+ log.warn && log.warn('[Actor] assignment child:', id, (err && err.message) || err);
+ return { error: 'the assignment could not be recorded, so nothing ran', status: 500, code: 'actor.assignment.failed' };
+ }
+ } : null;
+ closeAssignment = async (nodeId, state) => {
+ try { return await assignments.closeChild(s, row.requester, nodeId, state); }
+ catch (err) { log.warn && log.warn('[Actor] assignment close:', nodeId, (err && err.message) || err); return null; }
+ };
const explanation = [];
const explanation = [];
const tried = new Map();
const tried = new Map();
// The quality tier a hand-off may not go below: the tier of the best agent tried so far (catalog quality).
// The quality tier a hand-off may not go below: the tier of the best agent tried so far (catalog quality).
@@ -370,7 +419,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -370,7 +419,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
// A task that would start coding work on the person's machine asks through the same gate (levels.js):
// A task that would start coding work on the person's machine asks through the same gate (levels.js):
// the level first, then the always-ask floor. Full access or auto-edits may let an edit through; a
// the level first, then the always-ask floor. Full access or auto-edits may let an edit through; a
// payment, password, deletion or a new recipient never does.
// payment, password, deletion or a new recipient never does.
- const requestApproval = taskAgent ? async ({ kind, summary, payload, category = null, act = 'code', recipientKnown = null }) => {
+ requestApproval = taskAgent ? async ({ kind, summary, payload, category = null, act = 'act', recipientKnown = null }) => {
const out = await askForApproval({ kind, summary, payload, category, act, recipientKnown });
const out = await askForApproval({ kind, summary, payload, category, act, recipientKnown });
if (!out.asked) return null;
if (!out.asked) return null;
return { pending: true, id: out.ask.id, url: out.url };
return { pending: true, id: out.ask.id, url: out.url };
@@ -405,7 +454,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -405,7 +454,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
// The answer as it is written (plan L55 C2): to the readers that asked, never stored.
// The answer as it is written (plan L55 C2): to the readers that asked, never stored.
// The loop streams a model call only while such a reader is there (wanted).
// The loop streams a model call only while such a reader is there (wanted).
delta: typeof stream.delta === 'function' ? Object.assign((d) => stream.delta(id, d), { wanted: () => stream.wants(id) }) : null,
delta: typeof stream.delta === 'function' ? Object.assign((d) => stream.delta(id, d), { wanted: () => stream.wants(id) }) : null,
- meter, budgetLeft, waitForPerson, requestApproval,
+ meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment,
chatPolicy, approveChatTool: chatPolicy ? (tool, input) => require('../chat-apps/policy').approve(s, { taskId: id, owner: row.requester, tool, input, signal }) : null,
chatPolicy, approveChatTool: chatPolicy ? (tool, input) => require('../chat-apps/policy').approve(s, { taskId: id, owner: row.requester, tool, input, signal }) : null,
personText, agentId: ownAgent, notifyTask,
personText, agentId: ownAgent, notifyTask,
askPerson, control, resume, fork, spent: () => spent,
askPerson, control, resume, fork, spent: () => spent,
@@ -598,7 +647,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -598,7 +647,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
if (agentId === 'codes' && taskAgent && codesRepoIn(row.task)) {
if (agentId === 'codes' && taskAgent && codesRepoIn(row.task)) {
const asked = await askForApproval({
const asked = await askForApproval({
kind: 'codes.task', summary: `Start a coding task on your machine in ${codesRepoIn(row.task)}`,
kind: 'codes.task', summary: `Start a coding task on your machine in ${codesRepoIn(row.task)}`,
- payload: { task: row.task }, category: 'code', act: 'code', agent: agentId,
+ payload: { task: row.task }, act: 'edit', agent: agentId,
});
});
if (asked.asked) {
if (asked.asked) {
const result = {
const result = {
@@ -613,7 +662,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -613,7 +662,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
let out;
let out;
try {
try {
// From chat the runtime is never offered the person's own robots or computers (no requester for its tools).
// From chat the runtime is never offered the person's own robots or computers (no requester for its tools).
- out = await adapters.run[agentId]({ task: row.task, requester: fromChat ? null : row.requester, taskId: id, cls, mode: row.mode, signal, meter, budgetLeft, waitForPerson, emit: (e) => emit(id, { kind: 'output', agent: agentId, ...e }) });
+ out = await adapters.run[agentId]({ task: row.task, requester: fromChat ? null : row.requester, taskId: id, cls, mode: row.mode, signal, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, emit: (e) => emit(id, { kind: 'output', agent: agentId, ...e }) });
} catch (err) {
} catch (err) {
if (signal.aborted) throw err;
if (signal.aborted) throw err;
const why = (err && err.message) || String(err);
const why = (err && err.message) || String(err);
@@ -22,12 +22,12 @@ function subjectRef(requester) {
@@ -22,12 +22,12 @@ function subjectRef(requester) {
async function insertTask(s, t) {
async function insertTask(s, t) {
await s.db.query(
await s.db.query(
- `INSERT INTO tasks (id, requester, project_id, task, mode, agent, budget_task, budget_day, state, idem_key, idem_hash, created_at, via, webhooks, webhook_secret, timeout_ms, parent_id, fork_of_step, fork_change, agent_id, level)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'queued', $9, $10, $11, $12, $13::jsonb, $14, $15, $16, $17, $18::jsonb, $19, $20)`,
+ `INSERT INTO tasks (id, requester, project_id, task, mode, agent, budget_task, budget_day, state, idem_key, idem_hash, created_at, via, webhooks, webhook_secret, timeout_ms, parent_id, fork_of_step, fork_change, agent_id, level, assignment_id)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'queued', $9, $10, $11, $12, $13::jsonb, $14, $15, $16, $17, $18::jsonb, $19, $20, $21)`,
[t.id, t.requester, t.project_id || null, t.task, t.mode, t.agent || null, t.budget_task, t.budget_day, t.idem_key || null, t.idem_hash || null, t.created_at, t.via || null,
[t.id, t.requester, t.project_id || null, t.task, t.mode, t.agent || null, t.budget_task, t.budget_day, t.idem_key || null, t.idem_hash || null, t.created_at, t.via || null,
t.webhooks && t.webhooks.length ? JSON.stringify(t.webhooks) : null, t.webhook_secret || null, t.timeout_ms || null, t.parent_id || null,
t.webhooks && t.webhooks.length ? JSON.stringify(t.webhooks) : null, t.webhook_secret || null, t.timeout_ms || null, t.parent_id || null,
t.fork_of_step == null ? null : Number(t.fork_of_step), t.fork_change ? JSON.stringify(t.fork_change) : null,
t.fork_of_step == null ? null : Number(t.fork_of_step), t.fork_change ? JSON.stringify(t.fork_change) : null,
- t.agent_id || null, t.level || 'ask']);
+ t.agent_id || null, t.level || 'ask', t.assignment_id || null]);
await addSpend(s, t.requester, 0, { tasks: 1 });
await addSpend(s, t.requester, 0, { tasks: 1 });
return getTask(s, t.id);
return getTask(s, t.id);
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,171 @@
@@ -0,0 +1,171 @@
+'use strict';
+/**
+ * The assignment tree (plan §12 A-2): every hand-off is one node — a person's task, a routine run, outside work,
+ * an agent's consult, a Codes or Space task. The root's budget is the tree's budget; the effective level is
+ * inherited only along a person's or routine's line; a cancel cascades and stops the tasks below. The API is
+ * owner-only (another person gets 404), an existing task gets its root lazily, and every step event carries the
+ * assignment id so the Dock can draw the tree later.
+ */
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+const assignments = require('../server/agents/assignments');
+const personal = require('../server/agents/personal');
+const contacts = require('../server/agents/contacts');
+const store = require('../server/tasks/store');
+
+const SAME = { 'sec-fetch-site': 'same-origin' };
+
+(async () => {
+ const state = { hang: false };
+ const t = await boot({
+ loop: {
+ close: async () => {},
+ run: async (args) => {
+ if (state.hang) {
+ return new Promise((resolve, reject) => {
+ const stop = () => reject(Object.assign(new Error('cancelled'), { code: 'actor.task.cancelled' }));
+ if (args.signal.aborted) return stop();
+ args.signal.addEventListener('abort', stop, { once: true });
+ });
+ }
+ return { text: 'Done.', sources: [], evidence: [], model: 'fake' };
+ },
+ },
+ });
+ try {
+ const ada = t.network.addUser('ada');
+ const bo = t.network.addUser('bo');
+ const owner = `user:${ada.subject}`;
+ const s = t.ctx.s;
+ const create = (json) => t.get('/api/v1/tasks', { as: ada, json, headers: SAME });
+ const rootOf = (taskId) => s.db.maybe('SELECT * FROM actor_assignments WHERE task_id = $1 AND depth = 0', [taskId]);
+ const validNode = (node) => { const v = contracts.validate('actor.assignment@1', node); assert.ok(v.valid, JSON.stringify(v.errors)); };
+
+ const first = await create({ task: 'Say hi' });
+ assert.strictEqual(first.status, 201, first.text);
+ const firstRow = await t.waitFor(first.json().id);
+ const firstRoot = await rootOf(firstRow.id);
+
+ await check('a person\'s task is one root node, and its owner reads the node and its children', async () => {
+ assert.ok(firstRoot, 'the root exists with the task');
+ assert.deepStrictEqual([firstRoot.origin, firstRoot.depth, firstRoot.parent, firstRoot.task_id, firstRoot.state], ['person', 0, null, firstRow.id, 'succeeded']);
+ validNode(assignments.wire(firstRoot));
+ const res = await t.get(`/api/v1/assignments/${firstRoot.id}`, { as: ada });
+ assert.strictEqual(res.status, 200, res.text);
+ const body = res.json();
+ assert.strictEqual(body.assignment.id, firstRoot.id);
+ assert.deepStrictEqual(body.children, []);
+ assert.ok(contracts.validate('actor.assignment-tree@1', body).valid);
+ assert.strictEqual((await t.get(`/api/v1/assignments/${firstRoot.id}`, { as: bo })).status, 404, 'another person gets 404');
+ assert.strictEqual((await t.get('/api/v1/assignments/asg_01JAB2C3D4E5F6G7H8J9K0MNPS', { as: ada })).status, 404, 'a node that does not exist is the same 404');
+ });
+
+ await check('every step event names its assignment node', async () => {
+ const rows = await s.db.many('SELECT body FROM task_events WHERE task_id = $1 ORDER BY seq', [firstRow.id]);
+ const events = rows.map((r) => (typeof r.body === 'string' ? JSON.parse(r.body) : r.body));
+ const outputs = events.filter((e) => e.kind === 'output');
+ assert.ok(outputs.length > 0, 'the run emitted steps');
+ for (const e of outputs) {
+ assert.strictEqual(e.assignment_id, firstRoot.id, JSON.stringify(e));
+ assert.ok(contracts.validate('actor.task-event@2', e).valid, JSON.stringify(contracts.validate('actor.task-event@2', e).errors));
+ }
+ });
+
+ await check('the level is inherited along a person\'s line, never from outside work', async () => {
+ state.hang = true;
+ const full = (await contacts.create(s, t.config, owner, { name: 'Full', level: 'full' })).row;
+ const r = await create({ task: 'Full work', agent_id: full.id, level: 'full' });
+ const row = await t.waitFor(r.json().id, ['running']);
+ const root = await rootOf(row.id);
+ assert.strictEqual(root.effective_level, 'full');
+ const child = await assignments.openChild(s, { owner, parent: root, mode: 'consult', reply: 'resume', level: 'ask' });
+ assert.ok(child && child.id, JSON.stringify(child));
+ assert.strictEqual(child.level, 'ask', 'the child names its own level');
+ assert.strictEqual(child.effective_level, 'full', 'the person\'s line passes its level down');
+ assert.strictEqual(child.root, root.id);
+ assert.strictEqual(child.depth, 1);
+ assert.strictEqual(child.budget_usd, root.budget_usd);
+ await t.ctx.engine.cancel(row.id, owner);
+
+ const outside = await require('../server/tasks/create').createTask({
+ s, config: t.config, engine: t.ctx.engine, webhooks: t.ctx.webhooks,
+ principal: { requester: owner, via: null, project: null },
+ body: { task: 'Outside work', level: 'full' }, assignmentOrigin: 'outside',
+ });
+ assert.strictEqual(outside.status, 201, JSON.stringify(outside));
+ await t.waitFor(outside.task.id, ['running']);
+ const outsideRoot = await rootOf(outside.task.id);
+ assert.strictEqual(outsideRoot.origin, 'outside');
+ const below = await assignments.openChild(s, { owner, parent: outsideRoot, mode: 'consult', reply: 'resume', level: 'ask' });
+ assert.ok(below && below.id, JSON.stringify(below));
+ assert.strictEqual(below.effective_level, 'ask', 'outside work passes no level down');
+ await t.ctx.engine.cancel(outside.task.id, owner);
+ state.hang = false;
+ });
+
+ await check('a routine starting a run is origin routine', async () => {
+ const agent = (await personal.create(s, t.config, owner, { name: 'Morning', instructions: 'Summarise the inbox.' })).row;
+ const out = await personal.run({ ...t.ctx, s, config: t.config }, agent, '', { origin: 'routine' });
+ assert.strictEqual(out.status, 201, JSON.stringify(out));
+ await t.waitFor(out.task.id);
+ const root = await rootOf(out.task.id);
+ assert.strictEqual(root.origin, 'routine');
+ assert.strictEqual(root.agent_id, agent.id);
+ });
+
+ await check('the root\'s budget stops a child when it is spent', async () => {
+ const r = await create({ task: 'Budget work', level: 'full' });
+ const row = await t.waitFor(r.json().id);
+ const root = await rootOf(row.id);
+ await s.db.query('UPDATE actor_assignments SET state = \'running\', budget_usd = 0.05 WHERE id = $1', [root.id]);
+ await s.db.query('UPDATE tasks SET cost_usd = 0.06 WHERE id = $1', [row.id]);
+ const before = Number(await s.db.value('SELECT count(*) FROM actor_assignments WHERE parent = $1', [root.id]));
+ const out = await assignments.openChild(s, { owner, parent: { ...root, state: 'running', budget_usd: 0.05 }, level: 'ask' });
+ assert.strictEqual(out.code, 'actor.assignment.budget');
+ assert.strictEqual(out.status, 429);
+ const after = Number(await s.db.value('SELECT count(*) FROM actor_assignments WHERE parent = $1', [root.id]));
+ assert.strictEqual(after, before, 'nothing was added');
+ });
+
+ await check('a cancel cascades down the tree and stops the tasks below it', async () => {
+ state.hang = true;
+ const a = await create({ task: 'Long work A' });
+ const b = await create({ task: 'Long work B' });
+ const aRow = await t.waitFor(a.json().id, ['running']);
+ const bRow = await t.waitFor(b.json().id, ['running']);
+ const aRoot = await rootOf(aRow.id);
+ const child = await assignments.openChild(s, { owner, parent: aRoot, mode: 'work', reply: 'poll', taskId: bRow.id, state: 'running' });
+ assert.ok(child && child.id, JSON.stringify(child));
+ const grandchild = await assignments.openChild(s, { owner, parent: child, mode: 'consult', reply: 'resume', state: 'queued' });
+ assert.ok(grandchild && grandchild.id, JSON.stringify(grandchild));
+ const res = await t.get(`/api/v1/assignments/${aRoot.id}/cancel`, { as: ada, method: 'POST', headers: SAME });
+ assert.strictEqual(res.status, 200, res.text);
+ const body = res.json();
+ assert.ok(contracts.validate('actor.assignment-cancel-result@1', body).valid, JSON.stringify(contracts.validate('actor.assignment-cancel-result@1', body).errors));
+ assert.deepStrictEqual(body.cancelled.map((n) => n.id).sort(), [aRoot.id, child.id, grandchild.id].sort());
+ for (const node of body.cancelled) assert.strictEqual(node.state, 'cancelled');
+ assert.strictEqual((await t.waitFor(aRow.id, ['cancelled'])).state, 'cancelled');
+ assert.strictEqual((await t.waitFor(bRow.id, ['cancelled'])).state, 'cancelled', 'the task the child named was stopped too');
+ assert.strictEqual((await t.get(`/api/v1/assignments/${aRoot.id}/cancel`, { as: bo, method: 'POST', headers: SAME })).status, 404);
+ state.hang = false;
+ });
+
+ await check('an existing task gets its root lazily, on the first read', async () => {
+ const id = s.newId('tsk');
+ await store.insertTask(s, {
+ id, requester: owner, via: null, project_id: null, task: 'An older task', mode: 'balanced', agent: null,
+ budget_task: 0.01, budget_day: 1, created_at: s.iso(),
+ });
+ assert.strictEqual(await rootOf(id), null, 'nothing was backfilled');
+ const res = await t.get(`/api/v1/tasks/${id}`, { as: ada });
+ assert.strictEqual(res.status, 200, res.text);
+ const root = await rootOf(id);
+ assert.ok(root, 'the read made its root');
+ assert.strictEqual(root.origin, 'person');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -30,6 +30,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -30,6 +30,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
'actor.task.stream': { minute: 30, hour: 600 },
'actor.task.stream': { minute: 30, hour: 600 },
'actor.approval.decide': { minute: 30, hour: 600 },
'actor.approval.decide': { minute: 30, hour: 600 },
'actor.resource.control': { minute: 30, hour: 600 },
'actor.resource.control': { minute: 30, hour: 600 },
+ 'actor.assignment.cancel': { minute: 30, hour: 600 },
});
});
});
});
@@ -13,7 +13,7 @@ const contracts = require('openvibe-contracts');
@@ -13,7 +13,7 @@ const contracts = require('openvibe-contracts');
const configLib = require('../server/config');
const configLib = require('../server/config');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
-const CAPS = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.resource.read', 'actor.resource.control', 'actor.agent.manage', 'actor.agent.run', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage', 'actor.timeline.read'];
+const CAPS = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.resource.read', 'actor.resource.control', 'actor.agent.manage', 'actor.agent.run', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage', 'actor.timeline.read', 'actor.assignment.read', 'actor.assignment.cancel'];
const INDEX_ROUTES = ['GET /api/v1/resources', 'GET /api/v1/resources/:ovrn'];
const INDEX_ROUTES = ['GET /api/v1/resources', 'GET /api/v1/resources/:ovrn'];
// Routes added after the pinned contracts release but covered by an existing grant (or public on purpose). The trace is
// Routes added after the pinned contracts release but covered by an existing grant (or public on purpose). The trace is
// the task's own read, so actor.task.read decides it exactly as it decides GET /api/v1/tasks/:id. The live-stats reads
// the task's own read, so actor.task.read decides it exactly as it decides GET /api/v1/tasks/:id. The live-stats reads
new file mode 100644
new file mode 100644
@@ -0,0 +1,187 @@
@@ -0,0 +1,187 @@
+'use strict';
+/**
+ * The approval floor reaches every acting tool (plan §12 A-2). levels.decide already asks for money, a
+ * password, a deletion and a new recipient at every level (test/agent-levels.test.js); this proves the
+ * tools themselves go through it: the act and category are named, a full-level agent still asks, an
+ * auto-edits agent passes an edit and asks for a send, reading the agent's own files never asks, the
+ * old runtime path asks too, and the engine hands the gate to a loop with the task's effective level.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { boot, check, done } = require('./helpers/boot');
+const { createRegistry } = require('../server/runtime/tools');
+const { createOpenVibeTools } = require('../server/agents/tools');
+const { stepFor } = require('../server/agents/actions');
+const levels = require('../server/agents/levels');
+const approvals = require('../server/agents/approvals');
+const contacts = require('../server/agents/contacts');
+
+const ME = 'user:usr_01M4W5JBFEG3QG9ER8W1A2K0RX';
+const OTHER = 'user:usr_01M4W5JBFEG3QG9ER8W1A2K0RY';
+const SAME = { 'sec-fetch-site': 'same-origin' };
+
+/** A small registry of the tool names under test: nothing reaches a computer or Space, a counter says who ran. */
+function testRegistry() {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'actor-gate-'));
+ for (const name of ['computer_run', 'computer_write', 'computer_browser']) {
+ fs.writeFileSync(path.join(dir, `${name}.js`), `module.exports = {
+ name: '${name}', description: 'test ${name}', input: { type: 'object', properties: {} }, scope: 'person', acts: true,
+ async run() { globalThis.__gateRuns.push('${name}'); return { text: 'ran' }; },
+ };`);
+ }
+ fs.writeFileSync(path.join(dir, 'computer_read.js'), `module.exports = {
+ name: 'computer_read', description: 'test read', input: { type: 'object', properties: {} }, scope: 'person',
+ async run() { globalThis.__gateRuns.push('computer_read'); return { text: 'read' }; },
+ };`);
+ fs.writeFileSync(path.join(dir, 'make_file.js'), `module.exports = {
+ name: 'make_file', description: 'test own file', input: { type: 'object', properties: {} }, scope: 'person', acts: true,
+ async run() { globalThis.__gateRuns.push('make_file'); return { text: 'made' }; },
+ };`);
+ return createRegistry({ dir, strict: true, log: { warn() {}, log() {} } });
+}
+
+/** The engine's gate, without a database: classify the step, let levels.decide, and stop when it asks. */
+function gate(level, asked) {
+ return async (step) => {
+ const categories = levels.classify({ category: step.category, summary: step.summary, payload: step.payload });
+ const decision = await levels.decide({ level, categories, act: step.act, models: null, summary: step.summary, payload: step.payload });
+ if (!decision.ask) return null;
+ asked.push({ ...step, categories, floor: decision.floor });
+ return { pending: true, id: 'apr_01JAB2C3D4E5F6G7H8J9K0MNPS', url: 'https://openvibe.actor/me/approvals/apr_01JAB2C3D4E5F6G7H8J9K0MNPS' };
+ };
+}
+
+(async () => {
+ const registry = testRegistry();
+ globalThis.__gateRuns = [];
+
+ await check('one per category: a full-level agent still asks, and the tool does not run', async () => {
+ const cases = [
+ ['money', 'computer_browser', { machine: 'dev_01JAB2C3D4E5F6G7H8J9K0MNPS', action: 'click', text: 'Buy now' }],
+ ['password', 'computer_browser', { machine: 'dev_01JAB2C3D4E5F6G7H8J9K0MNPS', action: 'fill', selector: '#password', text: 'hunter2' }],
+ ['delete', 'computer_run', { machine: 'dev_01JAB2C3D4E5F6G7H8J9K0MNPS', argv: ['rm', '-rf', 'build'] }],
+ ['new_recipient', 'computer_browser', { machine: 'dev_01JAB2C3D4E5F6G7H8J9K0MNPS', action: 'click', text: 'Send to [email protected]' }],
+ ];
+ for (const [category, tool, input] of cases) {
+ const asked = [];
+ const out = await registry.run(tool, input, { requester: ME, requestApproval: gate('full', asked) });
+ assert.strictEqual(out.isError, true, `${category}: the call was refused`);
+ assert.match(out.text, /not started: the person must approve it first/, `${category}: ${out.text}`);
+ assert.strictEqual(asked.length, 1, `${category}: asked once`);
+ assert.ok(asked[0].categories.includes(category), `${category}: got ${JSON.stringify(asked[0])}`);
+ assert.strictEqual(asked[0].act === 'edit' || asked[0].act === 'act', true, `${category}: a known act`);
+ assert.ok(!globalThis.__gateRuns.includes(tool), `${category}: ${tool} never ran`);
+ if (category === 'password') {
+ assert.strictEqual(asked[0].payload.text, '[redacted]', 'the password is not in the approval card');
+ assert.ok(!JSON.stringify(asked[0]).includes('hunter2'), 'the password is nowhere in the request');
+ }
+ }
+ });
+
+ await check('auto-edits passes an edit and asks for a send', async () => {
+ const asked = [];
+ const edit = await registry.run('computer_write', { machine: 'dev_x', path: '/tmp/a.txt', text: 'hi' }, { requester: ME, requestApproval: gate('auto-edits', asked) });
+ assert.ok(!edit.isError, edit.text);
+ assert.strictEqual(globalThis.__gateRuns.filter((n) => n === 'computer_write').length, 1, 'the edit ran');
+ assert.strictEqual(asked.length, 0, 'the edit did not ask');
+ const send = await registry.run('computer_browser', { machine: 'dev_x', action: 'click', text: 'Send to [email protected]' }, { requester: ME, requestApproval: gate('auto-edits', asked) });
+ assert.strictEqual(send.isError, true);
+ assert.strictEqual(asked.length, 1, 'the send asked');
+ assert.strictEqual(globalThis.__gateRuns.filter((n) => n === 'computer_browser').length, 0, 'the send did not run');
+ });
+
+ await check('approve-for-me with no reviewer asks the person', async () => {
+ const asked = [];
+ const out = await registry.run('computer_browser', { machine: 'dev_x', action: 'click', text: 'Open the page' }, { requester: ME, requestApproval: gate('approve-for-me', asked) });
+ assert.strictEqual(out.isError, true);
+ assert.strictEqual(asked.length, 1, 'the ordinary act asked when no reviewer exists');
+ });
+
+ await check('reading the agent\'s own files never asks; Actor\'s own working state does not either', async () => {
+ const asked = [];
+ globalThis.__gateRuns = [];
+ const read = await registry.run('computer_read', { machine: 'dev_x', path: '/tmp/a.txt' }, { requester: ME, requestApproval: gate('ask', asked) });
+ assert.ok(!read.isError, read.text);
+ const own = await registry.run('make_file', { name: 'notes.md', text: 'hi' }, { requester: ME, requestApproval: gate('ask', asked) });
+ assert.ok(!own.isError, own.text);
+ assert.strictEqual(asked.length, 0, 'nothing asked');
+ assert.deepStrictEqual(globalThis.__gateRuns, ['computer_read', 'make_file']);
+ });
+
+ await check('the old runtime path asks through the same gate, and nothing reaches the computer', async () => {
+ let ran = 0;
+ const computers = { enabled: true, run: async () => { ran++; return { ok: true }; } };
+ const tools = createOpenVibeTools({ config: { services: {} }, computers, now: () => 0 });
+ const out = await tools.call('computer_run', { machine: 'dev_x', argv: ['rm', '-rf', 'build'] }, {
+ requester: ME,
+ requestApproval: async (step) => ({ pending: true, id: 'apr_01JAB2C3D4E5F6G7H8J9K0MNPS', url: `https://openvibe.actor/me/approvals/${step.category}` }),
+ });
+ assert.match(out, /not started: the person must approve it first/);
+ assert.strictEqual(ran, 0, 'the computer was never asked to run');
+ });
+
+ // ── The engine hands the same gate to the loop, with the task's assignment level ──
+ const seen = [];
+ const t = await boot({
+ loop: {
+ close: async () => {},
+ run: async (args) => {
+ seen.push(args);
+ if (args.task.includes('delete')) {
+ await args.requestApproval({ kind: 'actor.action', ...stepFor('computer_run', { machine: 'dev_x', argv: ['rm', '-rf', 'build'] }) });
+ } else {
+ await args.requestApproval({ kind: 'actor.action', ...stepFor('computer_write', { machine: 'dev_x', path: '/tmp/a.txt', text: 'hi' }) });
+ await args.requestApproval({ kind: 'actor.action', ...stepFor('computer_browser', { machine: 'dev_x', action: 'click', text: 'Send to [email protected]' }) });
+ }
+ return { text: 'Done.', sources: [], evidence: [], model: 'fake' };
+ },
+ },
+ });
+ try {
+ const ada = t.network.addUser('ada');
+ const bo = t.network.addUser('bo');
+ const owner = `user:${ada.subject}`;
+ const agent = async (name, level) => (await contacts.create(t.ctx.s, t.config, owner, { name, level })).row;
+ const full = await agent('Full', 'full');
+ const auto = await agent('Auto', 'auto-edits');
+ const create = (body) => t.get('/api/v1/tasks', { as: ada, json: body, headers: SAME });
+
+ await check('a full-level agent still asks before a deletion, through the engine\'s gate', async () => {
+ const r = await create({ task: 'delete the build directory', agent_id: full.id, level: 'full' });
+ assert.strictEqual(r.status, 201, r.text);
+ const row = await t.waitFor(r.json().id);
+ assert.strictEqual(row.state, 'succeeded', JSON.stringify(row.error_detail));
+ const ask = await t.ctx.s.db.maybe('SELECT * FROM actor_approvals WHERE task_id = $1', [row.id]);
+ assert.ok(ask, 'the approval was recorded');
+ assert.deepStrictEqual([ask.kind, ask.state], ['actor.action', 'pending']);
+ assert.strictEqual(JSON.parse(typeof ask.payload === 'string' ? ask.payload : JSON.stringify(ask.payload)).argv[0], 'rm');
+ });
+
+ await check('an auto-edits agent passes the edit and asks for the send', async () => {
+ const r = await create({ task: 'edit the file and send it', agent_id: auto.id, level: 'auto-edits' });
+ assert.strictEqual(r.status, 201, r.text);
+ const row = await t.waitFor(r.json().id);
+ assert.strictEqual(row.state, 'succeeded', `${row.error_detail}\n${t.logs().slice(-2000)}`);
+ const asks = await t.ctx.s.db.many('SELECT * FROM actor_approvals WHERE task_id = $1 ORDER BY created_at', [row.id]);
+ assert.strictEqual(asks.length, 1, 'only the send asked');
+ assert.match(asks[0].summary, /Send to bob@example\.com/);
+ const args = seen.find((s) => s.task.includes('send it'));
+ assert.ok(args, 'the loop ran with the gate');
+ });
+
+ await check('the approval belongs to its owner only', async () => {
+ const rows = await t.ctx.s.db.many('SELECT * FROM actor_approvals ORDER BY created_at DESC LIMIT 1');
+ const a = rows[0];
+ assert.strictEqual((await t.get(`/me/approvals/${a.id}`, { as: bo })).status, 404);
+ assert.strictEqual((await t.get(`/me/approvals/${a.id}`, { as: ada })).status, 200);
+ assert.strictEqual(approvals.KINDS.includes('actor.action'), true);
+ assert.strictEqual((await t.get('/api/v1/assignments/not-an-id', { as: bo })).status, 404);
+ });
+ } finally {
+ await t.close();
+ }
+
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });