openvibers/openvibe.actor Commits
Actor v2 stage V2-7: sub-agents in Best mode, and only the offered tools may run (#198)
Changes
@@@ -149,6 +149,11 @@ function load(env = process.env) {selfReflections: Math.max(0, int(env.ACTOR_LOOP_SELF_REFLECTIONS, 2)),checkReflections: Math.min(2, Math.max(0, int(env.ACTOR_LOOP_CHECK_REFLECTIONS, 2))),toolResultChars: Math.max(500, int(env.ACTOR_LOOP_TOOL_RESULT_CHARS, 6000)),++ // Sub-agents (stage V2-7), "best" mode only: a self-contained sub-goal runs as a child loop with a share++ // of what is left of the budget and a cap on its model calls. At most 4 at once, depth 1.++ subAgents: env.ACTOR_LOOP_SUB_AGENTS !== '0',++ subAgentShare: Math.min(0.5, Math.max(0.05, Number(env.ACTOR_LOOP_SUB_AGENT_SHARE) || 0.25)),++ subAgentCalls: Math.max(1, int(env.ACTOR_LOOP_SUB_AGENT_CALLS, 8)),},// Task webhooks (server/tasks/webhooks.js): how often due deliveries are sent, and how long one may take.webhooks: {@@@ -94,6 +94,7 @@ const TOOL_LABEL = {convert_time: 'Time zones', geocode: 'Place lookup', place_info: 'Place', sports_scores: 'Sports scores',calculate: 'Calculator', make_file: 'File', make_chart: 'Chart', run_tool: 'OpenVibe tool', find_tools: 'Find a tool',codes_task: 'Coding task', robot_steps: 'Robot', list_my_robots: 'Your robots', list_my_computers: 'Your computers',++ delegate: 'Sub-agent',};const toolLabel = (name) => TOOL_LABEL[name] || String(name || 'tool').replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase());// A model id as people say it: "deepseek:deepseek-flash" → "DeepSeek Flash", "openai:gpt-5.1" → "GPT-5.1".@@@ -16,6 +16,14 @@* Every tool result becomes one ledger item (evidence.js) and its id goes back to the model in the tool message, so* the answer can cite [E3]. Parallel calls are run together; their results are recorded in call order.*++ * Only the tools a run was offered may run: a call to any other name is refused, whatever its scope.++ *++ * Sub-agents (stage V2-7, "best" mode): `delegate` hands one self-contained sub-goal to a child loop, at most 4 at++ * once, depth 1. A child gets the parent's tools at most (narrowed by the call's `tools`, never `delegate`), a share of++ * what is left of the budget (config.loop.subAgentShare) metered into the task, a cap on its model calls and no++ * reflection pass. Its evidence joins the parent's ledger, renumbered, so the parent cites what the child's tools++ * returned; the child's own summary is never evidence, so nothing reaches the answer that a tool did not return.++ ** createLoop({ config, models, registry, log, store, now }) → { run(args), revise(state, claims) }*/@@@ -24,6 +32,24 @@ const { createArtifactStore } = require('./artifacts');const { deskRun, closeDesks } = require('./tools/_person');const MAX_ANSWER_TOKENS = 5000;++const MAX_SUB_AGENTS = 4;++++const DELEGATE = Object.freeze({++ type: 'function',++ function: {++ name: 'delegate',++ description: 'Hand one self-contained part of a hard task (research one subject, compare two options, gather one set of facts) to a sub-agent. It works alone with a share of your budget and of your tools, then reports back with evidence ids you can cite. Use it for independent parts only, at most 4 at once; never for a single lookup.',++ parameters: {++ type: 'object',++ properties: {++ goal: { type: 'string', description: 'What the sub-agent must find or do, in one or two sentences' },++ tools: { type: 'array', items: { type: 'string' }, description: 'Optional: which of your tools it may use (by default all of yours except delegate)' },++ },++ required: ['goal'],++ },++ },++});++const CHILD_NOTE = '\n\nYou are a sub-agent working on one part of a bigger task. Do only your part, briefly, and cite [E#] for every fact. You cannot hand work to anyone else.';const MAX_PLAN_CHARS = 8000;const today = (now) => new Date(now()).toISOString().slice(0, 10);@@@ -104,7 +130,7 @@ const clip = (s, n) => (String(s == null ? '' : s).length > n ? `${String(s).slifunction summarize(args) {const a = args || {};-- const v = a.query || a.need || a.url || a.instruction || (a.tool ? `${a.tool} ${JSON.stringify(a.input || {})}` : a.robot ? `${a.robot}: ${[].concat(a.steps || []).join(', ')}`++ const v = a.query || a.need || a.goal || a.url || a.instruction || (a.tool ? `${a.tool} ${JSON.stringify(a.input || {})}` : a.robot ? `${a.robot}: ${[].concat(a.steps || []).join(', ')}`: a.machine ? `${a.machine}: ${Array.isArray(a.argv) ? a.argv.join(' ') : a.path || a.what || a.action || ''}` : JSON.stringify(a));return clip(v, 300);}@@@ -184,6 +210,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.const callsOut = toolCalls.slice(maxCalls).map((c) => ({ skipped: c, why: `not run: at most ${maxCalls} tool calls in one turn; make the rest in the next turn` }));const planned = [];let looping = false;++ let delegated = 0;for (const call of calls) {const fn = call.function || {};let args = {};@@@ -191,11 +218,13 @@ function createLoop({ config, models, registry, log = console, now = () => Date.const key = `${fn.name}|${JSON.stringify(args)}`;const n = (state.counts.get(key) || 0) + 1;state.counts.set(key, n);-- if (n >= 3) { looping = true; planned.push({ call, name: fn.name, args, skipped: 'the same tool and input were already called twice' }); }++ if (state.allow && !state.allow.has(fn.name)) planned.push({ call, name: fn.name, args, skipped: 'that tool is not offered for this task' });++ else if (fn.name === DELEGATE.function.name && ++delegated > MAX_SUB_AGENTS) planned.push({ call, name: fn.name, args, skipped: `at most ${MAX_SUB_AGENTS} sub-agents at once; hand the rest over in the next turn` });++ else if (n >= 3) { looping = true; planned.push({ call, name: fn.name, args, skipped: 'the same tool and input were already called twice' }); }else planned.push({ call, name: fn.name, args });}const runnable = planned.filter((p) => !p.skipped);-- const results = await Promise.all(runnable.map((p) => registry.run(p.name, p.args, ctx)));++ const results = await Promise.all(runnable.map((p) => (p.name === DELEGATE.function.name ? runChild(state, ctx, p.args) : registry.run(p.name, p.args, ctx))));let i = 0;for (const p of planned) {if (p.skipped) {@@@ -206,6 +235,14 @@ function createLoop({ config, models, registry, log = console, now = () => Date.}const out = results[i++];await ctx.emit({ step: 'tool_call', tool: p.name, input: summarize(p.args) });++ if (out && out.delegate) {++ // A sub-agent's report: its answer and the evidence ids it brought into this run's ledger. The report++ // itself is not evidence (it is a model's words), so the parent cites the ids, never the report.++ const report = out.error ? { error: out.error } : { sub_agent: true, goal: out.goal, answer: out.answer, evidence: out.evidence };++ state.messages.push({ role: 'tool', tool_call_id: p.call.id, content: JSON.stringify(report) });++ await ctx.emit({ step: 'tool_result', tool: p.name, text: out.error || clip(`Sub-agent: ${out.calls} step${out.calls === 1 ? '' : 's'}, ${out.evidence.length} finding${out.evidence.length === 1 ? '' : 's'}. ${out.answer}`, 600), is_error: out.error ? true : undefined });++ continue;++ }const item = state.ledger.add({ tool: p.name, input: p.args, text: out.text, data: out.data, sources: out.sources, artifacts: out.artifacts });state.messages.push({ role: 'tool', tool_call_id: p.call.id, content: JSON.stringify({ evidence: item.id, tool: p.name, result: clip(out.text, resultChars) }) });await ctx.emit({ step: 'tool_result', tool: p.name, text: clip(out.text, 600), is_error: out.isError || undefined, ...(out.artifacts && out.artifacts.length ? { artifacts: out.artifacts } : {}) });@@@ -254,6 +291,94 @@ function createLoop({ config, models, registry, log = console, now = () => Date.};}++ /**++ * The loop itself, for a run or a sub-agent: a model turn, its tool calls, until an answer, the budget, the clock,++ * a loop or (for a sub-agent) its cap on model calls.++ */++ async function drive(state, ctx, { reflections = 0, maxModelCalls = Infinity } = {}) {++ const signal = state.signal;++ for (;;) {++ if (signal && signal.aborted) throw Object.assign(new Error('cancelled'), { code: 'actor.task.cancelled' });++ if (ctx.budgetLeft && ctx.budgetLeft() <= 0) {++ state.lastText = await forceAnswer(state, ctx, 'the task reached its budget');++ break;++ }++ if (state.calls >= maxModelCalls) {++ state.lastText = await forceAnswer(state, ctx, 'the sub-agent used its steps');++ break;++ }++ const r = await callModel(state, ctx, { tools: state.tools });++ if (!r) { state.lastText = await forceAnswer(state, ctx, 'the task reached its budget'); break; }++ state.messages.push(r.message);++ if (r.text && r.text.trim()) state.lastText = r.text.trim();++ if (!r.toolCalls || !r.toolCalls.length) {++ // A draft answer: the writer's own reflection before it is delivered.++ if (state.selfReflections < reflections && (!ctx.budgetLeft || ctx.budgetLeft() > 0)) {++ state.selfReflections += 1;++ if (state.lastText) await ctx.emit({ step: 'text', text: clip(state.lastText, 2000) });++ state.messages.push({ role: 'user', content: `Reflect before this is delivered. Check the draft claim by claim: which claims have no evidence? A live fact (a price, score, weather reading, count, current status, release, date) must cite an [E#] from this run. If one has no evidence and a tool can fetch it, call that tool now. If it cannot be verified, remove it or say plainly that it is unverified. Then give the final answer, keeping the citations.` });++ continue;++ }++ break;++ }++ if (r.text && r.text.trim()) await ctx.emit({ step: 'text', text: clip(r.text.trim(), 2000) });++ const { looping } = await act(state, ctx, r.toolCalls);++ if (state.depth === 0) {++ state.plan.steps.forEach((s, i) => {++ if (s.state === 'active' && i < state.plan.steps.length - 1) { s.state = 'done'; state.plan.steps[i + 1].state = 'active'; }++ });++ await emitPlan(state, ctx);++ }++ if (looping) {++ state.lastText = await forceAnswer(state, ctx, 'the same tool and input were called three times');++ break;++ }++ }++ }++++ /**++ * A sub-agent for one sub-goal (stage V2-7): its own messages and ledger, the parent's tools at most, a share of++ * what is left of the budget, no plan of its own and no reflection. Its evidence joins the parent's ledger.++ */++ async function runChild(parent, ctx, args) {++ const goal = clip(String((args && args.goal) || '').replace(/\s+/g, ' ').trim(), 600);++ if (!goal) return { delegate: true, error: 'a sub-agent needs a goal' };++ 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).++ const tools = parent.tools.filter((t) => t.function.name !== DELEGATE.function.name && (!want || want.has(t.function.name)));++ const share = config.loop && config.loop.subAgentShare != null ? config.loop.subAgentShare : 0.25;++ const startLeft = ctx.budgetLeft ? ctx.budgetLeft() : Infinity;++ const slice = Number.isFinite(startLeft) ? Math.max(0, startLeft) * share : Infinity;++ let spent = 0;++ const cctx = {++ ...ctx,++ meter: async (usd) => { spent += Number(usd) || 0; await ctx.meter(usd); },++ budgetLeft: () => Math.min(ctx.budgetLeft ? ctx.budgetLeft() : Infinity, slice - spent),++ // A child's steps are its parent's one `delegate` step on the page; the report says what it did.++ emit: async () => {},++ };++ const child = {++ task: goal, requester: parent.requester, taskId: parent.taskId, mode: parent.mode, tier: parent.tier, signal: parent.signal,++ messages: [], lastText: '', model: null, family: null, provider: null, calls: 0, ledger: createLedger({ now }),++ plan: { assumptions: [], steps: [{ goal, state: 'active' }] }, counts: new Map(), stopped: null, selfReflections: 0,++ ctx: cctx, context: [], depth: 1, tools, allow: new Set(tools.map((t) => t.function.name)),++ };++ child.messages.push(++ { role: 'system', content: SYSTEM({ date: today(now), plan: planText(child.plan), maxCalls }) + CHILD_NOTE },++ { 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 });++ // 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) {++ const next = parent.ledger.add({ tool: item.tool, input: item.input, text: item.text, data: item.data, sources: item.sources, artifacts: item.artifacts, at: item.at });++ ids.set(item.id, next.id);++ }++ const answer = clip(withoutMeta(child.lastText).replace(/\[E(\d{1,3})\]/g, (all, n) => (ids.has(`E${n}`) ? `[${ids.get(`E${n}`)}]` : '')), 3000);++ return { delegate: true, goal, answer, evidence: [...ids.values()], calls: child.calls, spent, stopped: child.stopped || 'answer' };++ }++async function run({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null, context = [] }) {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' });@@@ -270,6 +395,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.task: String(task), requester, taskId, mode, tier, signal, messages: [], lastText: '', model: null, family: null, provider: null,calls: 0, ledger: createLedger({ now }), plan: { assumptions: [], steps: [] }, counts: new Map(), stopped: null, selfReflections: 0,ctx, context: Array.isArray(context) ? context.filter((c) => c && c.task && c.answer).slice(-3) : [],++ depth: 0, tools: [], allow: null,};try {state.plan = await makePlan(state, ctx);@@@ -278,40 +404,14 @@ function createLoop({ config, models, registry, log = console, now = () => Date.// A follow-up (stage V2-5b): the conversation it continues, as it went, then the new question.for (const c of state.context) state.messages.push({ role: 'user', content: c.task }, { role: 'assistant', content: c.answer });state.messages.push({ role: 'user', content: state.context.length ? `${FOLLOW_NOTE}\n\n${state.task}` : state.task });-- const tools = registry.definitions({ requester, mode, config, models, signal, artifacts });++ const offered = registry.definitions({ requester, mode, config, models, signal, artifacts });++ // "best" mode may hand self-contained sub-goals to sub-agents (stage V2-7); no other mode is offered it.++ const subAgents = mode === 'best' && offered.length > 0 && !(config.loop && config.loop.subAgents === false);++ state.tools = subAgents ? offered.concat([DELEGATE]) : offered;++ state.allow = new Set(state.tools.map((t) => t.function.name));// Read from config each run so a test (or an operator's change) takes effect without rebuilding the loop.const maxSelf = config.loop && config.loop.selfReflections != null ? config.loop.selfReflections : selfReflections;-- for (;;) {-- if (signal && signal.aborted) throw Object.assign(new Error('cancelled'), { code: 'actor.task.cancelled' });-- if (budgetLeft && budgetLeft() <= 0) {-- state.lastText = await forceAnswer(state, ctx, 'the task reached its budget');-- break;-- }-- const r = await callModel(state, ctx, { tools });-- if (!r) { state.lastText = await forceAnswer(state, ctx, 'the task reached its budget'); break; }-- state.messages.push(r.message);-- if (r.text && r.text.trim()) state.lastText = r.text.trim();-- if (!r.toolCalls || !r.toolCalls.length) {-- // A draft answer: the writer's own reflection before it is delivered.-- if (state.selfReflections < maxSelf && (!budgetLeft || budgetLeft() > 0)) {-- state.selfReflections += 1;-- if (state.lastText) await ctx.emit({ step: 'text', text: clip(state.lastText, 2000) });-- state.messages.push({ role: 'user', content: `Reflect before this is delivered. Check the draft claim by claim: which claims have no evidence? A live fact (a price, score, weather reading, count, current status, release, date) must cite an [E#] from this run. If one has no evidence and a tool can fetch it, call that tool now. If it cannot be verified, remove it or say plainly that it is unverified. Then give the final answer, keeping the citations.` });-- continue;-- }-- break;-- }-- if (r.text && r.text.trim()) await ctx.emit({ step: 'text', text: clip(r.text.trim(), 2000) });-- const { looping } = await act(state, ctx, r.toolCalls);-- state.plan.steps.forEach((s, i) => {-- if (s.state === 'active' && i < state.plan.steps.length - 1) { s.state = 'done'; state.plan.steps[i + 1].state = 'active'; }-- });-- await emitPlan(state, ctx);-- if (looping) {-- state.lastText = await forceAnswer(state, ctx, 'the same tool and input were called three times');-- break;-- }-- }++ await drive(state, ctx, { reflections: maxSelf });state.plan.steps.forEach((s) => { s.state = 'done'; });await emitPlan(state, ctx);return resultOf(state);@@@ -341,7 +441,9 @@ function createLoop({ config, models, registry, log = console, now = () => Date.if (!list.length) return prev;if (ctx.budgetLeft && ctx.budgetLeft() <= 0) return prev;state.messages.push({ role: 'user', content: `The verifier found claims in your answer with no evidence in this run:\n${list.map((c) => `- ${clip(c, 300)}`).join('\n')}\nFetch each one with a tool now if a tool can fetch it, or remove it. Then answer again, citing [E#] for every live fact.` });-- const tools = registry.definitions({ requester: state.requester, mode: state.mode, config, models, signal: ctx.signal, artifacts: ctx.artifacts });++ // The run's own tools and allow-list (sub-agents included in "best" mode), so a revision can do no more than the run.++ const tools = state.tools && state.tools.length ? state.tools : registry.definitions({ requester: state.requester, mode: state.mode, config, models, signal: ctx.signal, artifacts: ctx.artifacts });++ if (!state.allow) state.allow = new Set(tools.map((t) => t.function.name));const r = await callModel(state, ctx, { tools });if (r) {state.messages.push(r.message);@@@ -365,4 +467,4 @@ function createLoop({ config, models, registry, log = console, now = () => Date.return { run, revise, close, planText, SYSTEM };}--module.exports = { createLoop, planText, SYSTEM, withoutMeta };++module.exports = { createLoop, planText, SYSTEM, withoutMeta, DELEGATE, MAX_SUB_AGENTS };nnew file mode 100644@@@ -0,0 +1,153 @@++'use strict';++/**++ * Sub-agents (Actor v2, stage V2-7) and the allow-list under them, against a scripted fake model and a fake registry:++ * - only the tools a run was offered may run, whatever a model asks for;++ * - `delegate` is offered in "best" mode only;++ * - a sub-agent never gets more than its parent: the parent's tools at most, narrowed by the call, never `delegate`++ * (depth 1), and it cannot call anything outside that (the Attenu rule: hand-offs must not widen authority);++ * - its evidence joins the parent's ledger, renumbered, its answer's citations follow, and its report is not++ * evidence;++ * - at most 4 at once; its share of the budget and its cap on model calls stop it.++ */++const assert = require('assert');++const { createLoop, DELEGATE, MAX_SUB_AGENTS } = require('../server/runtime/loop');++const configLib = require('../server/config');++++const results = [];++const ok = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`); } catch (e) { results.push(e); console.log(` ✗ ${name}\n ${(e.stack || String(e)).split('\n').slice(0, 7).join('\n ')}`); } };++++let seq = 0;++const call = (name, args) => ({ id: `c${++seq}`, type: 'function', function: { name, arguments: JSON.stringify(args) } });++const reply = ({ text = '', tools = [], cost = 0.0001 } = {}) => ({++ message: { role: 'assistant', content: text || null, ...(tools.length ? { tool_calls: tools } : {}) },++ text, toolCalls: tools, cost_usd: cost, model: 'fake-1', family: 'fake', provider: 'fake',++});++const isChild = (req) => String((req.messages[0] || {}).content || '').includes('You are a sub-agent');++const toolNames = (req) => (req.tools || []).map((t) => t.function.name);++++function make({ parent, child, mode = 'best', config = {}, budget = 1 } = {}) {++ const chats = [];++ const ran = [];++ let p = 0;++ const c = new Map();++ const models = {++ resolve(tier) { return { tier, provider: 'fake', model: 'fake-1', family: 'fake', available: true }; },++ async chat(req) {++ chats.push({ ...req, messages: req.messages.map((m) => ({ ...m })) });++ if (req.tier === 'fast') return reply({ text: JSON.stringify({ assumptions: [], steps: [{ goal: 'Split the work' }, { goal: 'Answer' }] }) });++ if (isChild(req)) {++ const goal = String(req.messages[1].content).split('\n')[0];++ const n = c.get(goal) || 0; c.set(goal, n + 1);++ return reply(child(goal, n, req));++ }++ return reply(parent(p++, req));++ },++ };++ const registry = {++ definitions: () => ['lookup', 'fetch'].map((name) => ({ type: 'function', function: { name, description: name, parameters: { type: 'object', properties: {} } } })),++ run: async (name, input) => { ran.push({ name, input }); return { text: JSON.stringify({ name, input, value: 42 }), data: { name, input }, sources: [{ url: `https://example.org/${name}/${input.q || ''}`, title: name }] }; },++ };++ const cfg = configLib.load({ NODE_ENV: 'test', ACTOR_LOOP_SELF_REFLECTIONS: '0', ...config });++ const loop = createLoop({ config: cfg, models, registry, log: { warn() {}, log() {} } });++ let spent = 0;++ const events = [];++ const args = {++ task: 'Compare two things and answer', requester: 'user:usr_01JAB2C3D4E5F6G7H8J9K0MNPT', taskId: 'tsk_01JAB2C3D4E5F6G7H8J9K0MNPQ',++ mode, signal: undefined, emit: async (e) => events.push(e), meter: async (usd) => { spent += Number(usd) || 0; },++ budgetLeft: () => budget - spent,++ };++ return { loop, chats, ran, events, args, spent: () => spent };++}++const toolMessages = (chats) => {++ const last = chats.filter((r) => !isChild(r) && r.tier !== 'fast').pop();++ return last.messages.filter((m) => m.role === 'tool').map((m) => JSON.parse(m.content));++};++++(async () => {++ await ok('only the tools a run was offered may run: any other name is refused, whatever its scope', async () => {++ const m = make({ mode: 'balanced', parent: (n) => (n === 0 ? { tools: [call('make_payment', { to: 'x' }), call('lookup', { q: 'a' })] } : { text: 'It is **42** [E1].' }) });++ const out = await m.loop.run(m.args);++ assert.deepStrictEqual(m.ran.map((r) => r.name), ['lookup'], 'make_payment never ran');++ const msgs = toolMessages(m.chats);++ assert.deepStrictEqual(msgs[0], { error: 'that tool is not offered for this task', tool: 'make_payment' });++ assert.strictEqual(out.evidence.length, 1, 'and it is no evidence');++ });++++ await ok('delegate is offered in "best" mode only', async () => {++ const best = make({ parent: () => ({ text: 'Done.' }) });++ await best.loop.run(best.args);++ assert.ok(toolNames(best.chats.find((r) => r.tier !== 'fast')).includes('delegate'));++ const balanced = make({ mode: 'balanced', parent: () => ({ text: 'Done.' }) });++ await balanced.loop.run(balanced.args);++ assert.ok(!toolNames(balanced.chats.find((r) => r.tier !== 'fast')).includes('delegate'));++ const off = make({ config: { ACTOR_LOOP_SUB_AGENTS: '0' }, parent: () => ({ text: 'Done.' }) });++ await off.loop.run(off.args);++ assert.ok(!toolNames(off.chats.find((r) => r.tier !== 'fast')).includes('delegate'), 'and the operator can turn it off');++ });++++ await ok('a sub-agent gets the parent\'s tools at most, narrowed by the call, never delegate, and cannot call past them', async () => {++ const m = make({++ parent: (n) => (n === 0 ? { tools: [call('delegate', { goal: 'Price of A', tools: ['lookup', 'make_payment', 'delegate'] })] } : { text: 'A costs **42** [E1].' }),++ child: (goal, n) => (n === 0 ? { tools: [call('fetch', { q: 'A' }), call('delegate', { goal: 'deeper' }), call('make_payment', { to: 'x' }), call('lookup', { q: 'A' })] } : { text: 'A is 42 [E1].' }),++ });++ await m.loop.run(m.args);++ const childReq = m.chats.find(isChild);++ assert.deepStrictEqual(toolNames(childReq), ['lookup'], 'only lookup: make_payment is not the parent\'s, delegate is never a child\'s, fetch was not asked for');++ assert.deepStrictEqual(m.ran.map((r) => r.name), ['lookup'], 'fetch, delegate and make_payment were refused inside the child');++ const childTools = m.chats.filter(isChild).pop().messages.filter((x) => x.role === 'tool').map((x) => JSON.parse(x.content));++ assert.deepStrictEqual(childTools.slice(0, 3).map((x) => x.error), ['that tool is not offered for this task', 'that tool is not offered for this task', 'that tool is not offered for this task']);++ });++++ await ok('its evidence joins the parent\'s ledger renumbered, its citations follow, and its report is not evidence', async () => {++ const m = make({++ parent: (n) => (n === 0 ? { tools: [call('lookup', { q: 'mine' })] }++ : n === 1 ? { tools: [call('delegate', { goal: 'Price of B' })] }++ : { text: 'Mine is 42 [E1] and B is 42 [E2].' }),++ child: (goal, n) => (n === 0 ? { tools: [call('lookup', { q: 'B' })] } : { text: 'B is **42** [E1].' }),++ });++ const out = await m.loop.run(m.args);++ assert.deepStrictEqual(out.evidence.map((e) => [e.id, e.tool]), [['E1', 'lookup'], ['E2', 'lookup']], 'two items: the parent\'s and the child\'s, renumbered; no delegate item');++ assert.ok(out.evidence[1].sources[0].url.endsWith('/lookup/B'), 'the child\'s item is what its tool returned');++ const report = toolMessages(m.chats).find((x) => x.sub_agent);++ assert.deepStrictEqual([report.answer, report.evidence], ['B is **42** [E2].', ['E2']], 'the report cites the parent\'s new id');++ assert.deepStrictEqual(out.citations, ['E1', 'E2']);++ const shown = m.events.filter((e) => e.tool === 'delegate');++ assert.deepStrictEqual(shown.map((e) => e.step), ['tool_call', 'tool_result'], 'one step on the page');++ assert.ok(/^Sub-agent: 2 steps, 1 finding\. B is/.test(shown[1].text));++ });++++ await ok(`at most ${MAX_SUB_AGENTS} sub-agents at once`, async () => {++ const m = make({++ parent: (n) => (n === 0 ? { tools: ['a', 'b', 'c', 'd', 'e'].map((g) => call('delegate', { goal: `Part ${g}` })) } : { text: 'Done.' }),++ child: () => ({ text: 'Nothing found.' }),++ });++ await m.loop.run(m.args);++ const childGoals = new Set(m.chats.filter(isChild).map((r) => String(r.messages[1].content).split('\n')[0]));++ assert.strictEqual(childGoals.size, MAX_SUB_AGENTS, 'four ran');++ assert.ok(toolMessages(m.chats).some((x) => /at most 4 sub-agents at once/.test(x.error || '')), 'the fifth is told to wait for the next turn');++ });++++ await ok('its share of the budget, and its cap on model calls, stop it; its cost is the task\'s', async () => {++ // Budget 1.0, share 0.25: the child stops once its calls cost 0.25, then answers from what it has.++ const m = make({++ config: { ACTOR_LOOP_SUB_AGENT_SHARE: '0.25', ACTOR_LOOP_SUB_AGENT_CALLS: '50' },++ parent: (n) => (n === 0 ? { tools: [call('delegate', { goal: 'Dig' })] } : { text: 'Done.' }),++ child: (goal, n) => ({ tools: [call('lookup', { q: `dig ${n}` })], cost: 0.1 }),++ });++ await m.loop.run(m.args);++ const childCalls = m.chats.filter(isChild).length;++ assert.ok(childCalls >= 3 && childCalls <= 4, `three model calls reach the share, then one answer (${childCalls})`);++ assert.ok(m.spent() >= 0.3 && m.spent() < 0.5, `the child's cost is metered into the task (${m.spent()})`);++++ const capped = make({++ config: { ACTOR_LOOP_SUB_AGENT_CALLS: '2' },++ parent: (n) => (n === 0 ? { tools: [call('delegate', { goal: 'Dig' })] } : { text: 'Done.' }),++ child: (goal, n) => ({ tools: [call('lookup', { q: `dig ${n}` })] }),++ });++ await capped.loop.run(capped.args);++ assert.strictEqual(capped.chats.filter(isChild).length, 3, 'two steps, then the answer from what it found');++ });++++ if (results.length) { console.log(`\n${results.length} failed`); process.exit(1); }++ console.log('\nall passed');++})().catch((e) => { console.error(e); process.exit(1); });@@@ -114,7 +114,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };const saved = t.config.loop.selfReflections;t.config.loop.selfReflections = 0;t.providers.state.script = [-- { content: 'Trying a tool.', tool_calls: [{ id: 'c3', type: 'function', function: { name: 'no_such_tool', arguments: '{}' } }] },++ { content: 'Trying a tool.', tool_calls: [{ id: 'c3', type: 'function', function: { name: 'calculate', arguments: JSON.stringify({ expression: '2 +* )' }) } }] },{ content: 'That step failed, but I can still answer from this run [E1].' },];try {@@@ -122,7 +122,24 @@ const SAME = { 'sec-fetch-site': 'same-origin' };await t.waitFor(id);const task = (await t.get(`/api/v1/tasks/${id}`, { as: kim })).json();assert.strictEqual(task.state, 'succeeded', JSON.stringify(task.error));-- assert.match(task.result.evidence[0].text, /unknown tool/);++ assert.match(task.result.evidence[0].text, /unexpected/);++ } finally { t.config.loop.selfReflections = saved; }++ });++++ await check('a tool the run was not offered never runs and is no evidence (stage V2-7: the allow-list)', async () => {++ t.providers.reset();++ const saved = t.config.loop.selfReflections;++ t.config.loop.selfReflections = 0;++ t.providers.state.script = [++ { content: 'Trying a tool.', tool_calls: [{ id: 'c3b', type: 'function', function: { name: 'no_such_tool', arguments: '{}' } }] },++ { content: 'I could not do that step.' },++ ];++ try {++ const id = (await create({ task: 'Try the other thing' })).json().id;++ await t.waitFor(id);++ const task = (await t.get(`/api/v1/tasks/${id}`, { as: kim })).json();++ assert.strictEqual(task.state, 'succeeded', JSON.stringify(task.error));++ assert.strictEqual(task.result.evidence.length, 0, 'a refused call is not evidence');} finally { t.config.loop.selfReflections = saved; }});
@@ -149,6 +149,11 @@ function load(env = process.env) {
@@ -149,6 +149,11 @@ function load(env = process.env) {
selfReflections: Math.max(0, int(env.ACTOR_LOOP_SELF_REFLECTIONS, 2)),
selfReflections: Math.max(0, int(env.ACTOR_LOOP_SELF_REFLECTIONS, 2)),
checkReflections: Math.min(2, Math.max(0, int(env.ACTOR_LOOP_CHECK_REFLECTIONS, 2))),
checkReflections: Math.min(2, Math.max(0, int(env.ACTOR_LOOP_CHECK_REFLECTIONS, 2))),
toolResultChars: Math.max(500, int(env.ACTOR_LOOP_TOOL_RESULT_CHARS, 6000)),
toolResultChars: Math.max(500, int(env.ACTOR_LOOP_TOOL_RESULT_CHARS, 6000)),
+ // Sub-agents (stage V2-7), "best" mode only: a self-contained sub-goal runs as a child loop with a share
+ // of what is left of the budget and a cap on its model calls. At most 4 at once, depth 1.
+ subAgents: env.ACTOR_LOOP_SUB_AGENTS !== '0',
+ subAgentShare: Math.min(0.5, Math.max(0.05, Number(env.ACTOR_LOOP_SUB_AGENT_SHARE) || 0.25)),
+ subAgentCalls: Math.max(1, int(env.ACTOR_LOOP_SUB_AGENT_CALLS, 8)),
},
},
// Task webhooks (server/tasks/webhooks.js): how often due deliveries are sent, and how long one may take.
// Task webhooks (server/tasks/webhooks.js): how often due deliveries are sent, and how long one may take.
webhooks: {
webhooks: {
@@ -94,6 +94,7 @@ const TOOL_LABEL = {
@@ -94,6 +94,7 @@ const TOOL_LABEL = {
convert_time: 'Time zones', geocode: 'Place lookup', place_info: 'Place', sports_scores: 'Sports scores',
convert_time: 'Time zones', geocode: 'Place lookup', place_info: 'Place', sports_scores: 'Sports scores',
calculate: 'Calculator', make_file: 'File', make_chart: 'Chart', run_tool: 'OpenVibe tool', find_tools: 'Find a tool',
calculate: 'Calculator', make_file: 'File', make_chart: 'Chart', run_tool: 'OpenVibe tool', find_tools: 'Find a tool',
codes_task: 'Coding task', robot_steps: 'Robot', list_my_robots: 'Your robots', list_my_computers: 'Your computers',
codes_task: 'Coding task', robot_steps: 'Robot', list_my_robots: 'Your robots', list_my_computers: 'Your computers',
+ delegate: 'Sub-agent',
};
};
const toolLabel = (name) => TOOL_LABEL[name] || String(name || 'tool').replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase());
const toolLabel = (name) => TOOL_LABEL[name] || String(name || 'tool').replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase());
// A model id as people say it: "deepseek:deepseek-flash" → "DeepSeek Flash", "openai:gpt-5.1" → "GPT-5.1".
// A model id as people say it: "deepseek:deepseek-flash" → "DeepSeek Flash", "openai:gpt-5.1" → "GPT-5.1".
@@ -16,6 +16,14 @@
@@ -16,6 +16,14 @@
* Every tool result becomes one ledger item (evidence.js) and its id goes back to the model in the tool message, so
* Every tool result becomes one ledger item (evidence.js) and its id goes back to the model in the tool message, so
* the answer can cite [E3]. Parallel calls are run together; their results are recorded in call order.
* the answer can cite [E3]. Parallel calls are run together; their results are recorded in call order.
*
*
+ * Only the tools a run was offered may run: a call to any other name is refused, whatever its scope.
+ *
+ * Sub-agents (stage V2-7, "best" mode): `delegate` hands one self-contained sub-goal to a child loop, at most 4 at
+ * once, depth 1. A child gets the parent's tools at most (narrowed by the call's `tools`, never `delegate`), a share of
+ * what is left of the budget (config.loop.subAgentShare) metered into the task, a cap on its model calls and no
+ * reflection pass. Its evidence joins the parent's ledger, renumbered, so the parent cites what the child's tools
+ * returned; the child's own summary is never evidence, so nothing reaches the answer that a tool did not return.
+ *
* createLoop({ config, models, registry, log, store, now }) → { run(args), revise(state, claims) }
* createLoop({ config, models, registry, log, store, now }) → { run(args), revise(state, claims) }
*/
*/
@@ -24,6 +32,24 @@ const { createArtifactStore } = require('./artifacts');
@@ -24,6 +32,24 @@ const { createArtifactStore } = require('./artifacts');
const { deskRun, closeDesks } = require('./tools/_person');
const { deskRun, closeDesks } = require('./tools/_person');
const MAX_ANSWER_TOKENS = 5000;
const MAX_ANSWER_TOKENS = 5000;
+const MAX_SUB_AGENTS = 4;
+
+const DELEGATE = Object.freeze({
+ type: 'function',
+ function: {
+ name: 'delegate',
+ description: 'Hand one self-contained part of a hard task (research one subject, compare two options, gather one set of facts) to a sub-agent. It works alone with a share of your budget and of your tools, then reports back with evidence ids you can cite. Use it for independent parts only, at most 4 at once; never for a single lookup.',
+ parameters: {
+ type: 'object',
+ properties: {
+ goal: { type: 'string', description: 'What the sub-agent must find or do, in one or two sentences' },
+ tools: { type: 'array', items: { type: 'string' }, description: 'Optional: which of your tools it may use (by default all of yours except delegate)' },
+ },
+ required: ['goal'],
+ },
+ },
+});
+const CHILD_NOTE = '\n\nYou are a sub-agent working on one part of a bigger task. Do only your part, briefly, and cite [E#] for every fact. You cannot hand work to anyone else.';
const MAX_PLAN_CHARS = 8000;
const MAX_PLAN_CHARS = 8000;
const today = (now) => new Date(now()).toISOString().slice(0, 10);
const today = (now) => new Date(now()).toISOString().slice(0, 10);
@@ -104,7 +130,7 @@ const clip = (s, n) => (String(s == null ? '' : s).length > n ? `${String(s).sli
@@ -104,7 +130,7 @@ const clip = (s, n) => (String(s == null ? '' : s).length > n ? `${String(s).sli
function summarize(args) {
function summarize(args) {
const a = args || {};
const a = args || {};
- const v = a.query || a.need || a.url || a.instruction || (a.tool ? `${a.tool} ${JSON.stringify(a.input || {})}` : a.robot ? `${a.robot}: ${[].concat(a.steps || []).join(', ')}`
+ const v = a.query || a.need || a.goal || a.url || a.instruction || (a.tool ? `${a.tool} ${JSON.stringify(a.input || {})}` : a.robot ? `${a.robot}: ${[].concat(a.steps || []).join(', ')}`
: a.machine ? `${a.machine}: ${Array.isArray(a.argv) ? a.argv.join(' ') : a.path || a.what || a.action || ''}` : JSON.stringify(a));
: a.machine ? `${a.machine}: ${Array.isArray(a.argv) ? a.argv.join(' ') : a.path || a.what || a.action || ''}` : JSON.stringify(a));
return clip(v, 300);
return clip(v, 300);
}
}
@@ -184,6 +210,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -184,6 +210,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
const callsOut = toolCalls.slice(maxCalls).map((c) => ({ skipped: c, why: `not run: at most ${maxCalls} tool calls in one turn; make the rest in the next turn` }));
const callsOut = toolCalls.slice(maxCalls).map((c) => ({ skipped: c, why: `not run: at most ${maxCalls} tool calls in one turn; make the rest in the next turn` }));
const planned = [];
const planned = [];
let looping = false;
let looping = false;
+ let delegated = 0;
for (const call of calls) {
for (const call of calls) {
const fn = call.function || {};
const fn = call.function || {};
let args = {};
let args = {};
@@ -191,11 +218,13 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -191,11 +218,13 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
const key = `${fn.name}|${JSON.stringify(args)}`;
const key = `${fn.name}|${JSON.stringify(args)}`;
const n = (state.counts.get(key) || 0) + 1;
const n = (state.counts.get(key) || 0) + 1;
state.counts.set(key, n);
state.counts.set(key, n);
- if (n >= 3) { looping = true; planned.push({ call, name: fn.name, args, skipped: 'the same tool and input were already called twice' }); }
+ if (state.allow && !state.allow.has(fn.name)) planned.push({ call, name: fn.name, args, skipped: 'that tool is not offered for this task' });
+ else if (fn.name === DELEGATE.function.name && ++delegated > MAX_SUB_AGENTS) planned.push({ call, name: fn.name, args, skipped: `at most ${MAX_SUB_AGENTS} sub-agents at once; hand the rest over in the next turn` });
+ else if (n >= 3) { looping = true; planned.push({ call, name: fn.name, args, skipped: 'the same tool and input were already called twice' }); }
else planned.push({ call, name: fn.name, args });
else planned.push({ call, name: fn.name, args });
}
}
const runnable = planned.filter((p) => !p.skipped);
const runnable = planned.filter((p) => !p.skipped);
- const results = await Promise.all(runnable.map((p) => registry.run(p.name, p.args, ctx)));
+ const results = await Promise.all(runnable.map((p) => (p.name === DELEGATE.function.name ? runChild(state, ctx, p.args) : registry.run(p.name, p.args, ctx))));
let i = 0;
let i = 0;
for (const p of planned) {
for (const p of planned) {
if (p.skipped) {
if (p.skipped) {
@@ -206,6 +235,14 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -206,6 +235,14 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
}
}
const out = results[i++];
const out = results[i++];
await ctx.emit({ step: 'tool_call', tool: p.name, input: summarize(p.args) });
await ctx.emit({ step: 'tool_call', tool: p.name, input: summarize(p.args) });
+ if (out && out.delegate) {
+ // A sub-agent's report: its answer and the evidence ids it brought into this run's ledger. The report
+ // itself is not evidence (it is a model's words), so the parent cites the ids, never the report.
+ const report = out.error ? { error: out.error } : { sub_agent: true, goal: out.goal, answer: out.answer, evidence: out.evidence };
+ state.messages.push({ role: 'tool', tool_call_id: p.call.id, content: JSON.stringify(report) });
+ await ctx.emit({ step: 'tool_result', tool: p.name, text: out.error || clip(`Sub-agent: ${out.calls} step${out.calls === 1 ? '' : 's'}, ${out.evidence.length} finding${out.evidence.length === 1 ? '' : 's'}. ${out.answer}`, 600), is_error: out.error ? true : undefined });
+ continue;
+ }
const item = state.ledger.add({ tool: p.name, input: p.args, text: out.text, data: out.data, sources: out.sources, artifacts: out.artifacts });
const item = state.ledger.add({ tool: p.name, input: p.args, text: out.text, data: out.data, sources: out.sources, artifacts: out.artifacts });
state.messages.push({ role: 'tool', tool_call_id: p.call.id, content: JSON.stringify({ evidence: item.id, tool: p.name, result: clip(out.text, resultChars) }) });
state.messages.push({ role: 'tool', tool_call_id: p.call.id, content: JSON.stringify({ evidence: item.id, tool: p.name, result: clip(out.text, resultChars) }) });
await ctx.emit({ step: 'tool_result', tool: p.name, text: clip(out.text, 600), is_error: out.isError || undefined, ...(out.artifacts && out.artifacts.length ? { artifacts: out.artifacts } : {}) });
await ctx.emit({ step: 'tool_result', tool: p.name, text: clip(out.text, 600), is_error: out.isError || undefined, ...(out.artifacts && out.artifacts.length ? { artifacts: out.artifacts } : {}) });
@@ -254,6 +291,94 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -254,6 +291,94 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
};
};
}
}
+ /**
+ * The loop itself, for a run or a sub-agent: a model turn, its tool calls, until an answer, the budget, the clock,
+ * a loop or (for a sub-agent) its cap on model calls.
+ */
+ async function drive(state, ctx, { reflections = 0, maxModelCalls = Infinity } = {}) {
+ const signal = state.signal;
+ for (;;) {
+ if (signal && signal.aborted) throw Object.assign(new Error('cancelled'), { code: 'actor.task.cancelled' });
+ if (ctx.budgetLeft && ctx.budgetLeft() <= 0) {
+ state.lastText = await forceAnswer(state, ctx, 'the task reached its budget');
+ break;
+ }
+ if (state.calls >= maxModelCalls) {
+ state.lastText = await forceAnswer(state, ctx, 'the sub-agent used its steps');
+ break;
+ }
+ const r = await callModel(state, ctx, { tools: state.tools });
+ if (!r) { state.lastText = await forceAnswer(state, ctx, 'the task reached its budget'); break; }
+ state.messages.push(r.message);
+ if (r.text && r.text.trim()) state.lastText = r.text.trim();
+ if (!r.toolCalls || !r.toolCalls.length) {
+ // A draft answer: the writer's own reflection before it is delivered.
+ if (state.selfReflections < reflections && (!ctx.budgetLeft || ctx.budgetLeft() > 0)) {
+ state.selfReflections += 1;
+ if (state.lastText) await ctx.emit({ step: 'text', text: clip(state.lastText, 2000) });
+ state.messages.push({ role: 'user', content: `Reflect before this is delivered. Check the draft claim by claim: which claims have no evidence? A live fact (a price, score, weather reading, count, current status, release, date) must cite an [E#] from this run. If one has no evidence and a tool can fetch it, call that tool now. If it cannot be verified, remove it or say plainly that it is unverified. Then give the final answer, keeping the citations.` });
+ continue;
+ }
+ break;
+ }
+ if (r.text && r.text.trim()) await ctx.emit({ step: 'text', text: clip(r.text.trim(), 2000) });
+ const { looping } = await act(state, ctx, r.toolCalls);
+ if (state.depth === 0) {
+ state.plan.steps.forEach((s, i) => {
+ if (s.state === 'active' && i < state.plan.steps.length - 1) { s.state = 'done'; state.plan.steps[i + 1].state = 'active'; }
+ });
+ await emitPlan(state, ctx);
+ }
+ if (looping) {
+ state.lastText = await forceAnswer(state, ctx, 'the same tool and input were called three times');
+ break;
+ }
+ }
+ }
+
+ /**
+ * A sub-agent for one sub-goal (stage V2-7): its own messages and ledger, the parent's tools at most, a share of
+ * what is left of the budget, no plan of its own and no reflection. Its evidence joins the parent's ledger.
+ */
+ async function runChild(parent, ctx, args) {
+ const goal = clip(String((args && args.goal) || '').replace(/\s+/g, ' ').trim(), 600);
+ if (!goal) return { delegate: true, error: 'a sub-agent needs a goal' };
+ 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).
+ const tools = parent.tools.filter((t) => t.function.name !== DELEGATE.function.name && (!want || want.has(t.function.name)));
+ const share = config.loop && config.loop.subAgentShare != null ? config.loop.subAgentShare : 0.25;
+ const startLeft = ctx.budgetLeft ? ctx.budgetLeft() : Infinity;
+ const slice = Number.isFinite(startLeft) ? Math.max(0, startLeft) * share : Infinity;
+ let spent = 0;
+ const cctx = {
+ ...ctx,
+ meter: async (usd) => { spent += Number(usd) || 0; await ctx.meter(usd); },
+ budgetLeft: () => Math.min(ctx.budgetLeft ? ctx.budgetLeft() : Infinity, slice - spent),
+ // A child's steps are its parent's one `delegate` step on the page; the report says what it did.
+ emit: async () => {},
+ };
+ const child = {
+ task: goal, requester: parent.requester, taskId: parent.taskId, mode: parent.mode, tier: parent.tier, signal: parent.signal,
+ messages: [], lastText: '', model: null, family: null, provider: null, calls: 0, ledger: createLedger({ now }),
+ plan: { assumptions: [], steps: [{ goal, state: 'active' }] }, counts: new Map(), stopped: null, selfReflections: 0,
+ ctx: cctx, context: [], depth: 1, tools, allow: new Set(tools.map((t) => t.function.name)),
+ };
+ child.messages.push(
+ { role: 'system', content: SYSTEM({ date: today(now), plan: planText(child.plan), maxCalls }) + CHILD_NOTE },
+ { 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 });
+ // 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) {
+ const next = parent.ledger.add({ tool: item.tool, input: item.input, text: item.text, data: item.data, sources: item.sources, artifacts: item.artifacts, at: item.at });
+ ids.set(item.id, next.id);
+ }
+ const answer = clip(withoutMeta(child.lastText).replace(/\[E(\d{1,3})\]/g, (all, n) => (ids.has(`E${n}`) ? `[${ids.get(`E${n}`)}]` : '')), 3000);
+ return { delegate: true, goal, answer, evidence: [...ids.values()], calls: child.calls, spent, stopped: child.stopped || 'answer' };
+ }
+
async function run({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null, context = [] }) {
async function run({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null, context = [] }) {
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' });
@@ -270,6 +395,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -270,6 +395,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
task: String(task), requester, taskId, mode, tier, signal, messages: [], lastText: '', model: null, family: null, provider: null,
task: String(task), requester, taskId, mode, tier, signal, messages: [], lastText: '', model: null, family: null, provider: null,
calls: 0, ledger: createLedger({ now }), plan: { assumptions: [], steps: [] }, counts: new Map(), stopped: null, selfReflections: 0,
calls: 0, ledger: createLedger({ now }), plan: { assumptions: [], steps: [] }, counts: new Map(), stopped: null, selfReflections: 0,
ctx, context: Array.isArray(context) ? context.filter((c) => c && c.task && c.answer).slice(-3) : [],
ctx, context: Array.isArray(context) ? context.filter((c) => c && c.task && c.answer).slice(-3) : [],
+ depth: 0, tools: [], allow: null,
};
};
try {
try {
state.plan = await makePlan(state, ctx);
state.plan = await makePlan(state, ctx);
@@ -278,40 +404,14 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -278,40 +404,14 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
// A follow-up (stage V2-5b): the conversation it continues, as it went, then the new question.
// A follow-up (stage V2-5b): the conversation it continues, as it went, then the new question.
for (const c of state.context) state.messages.push({ role: 'user', content: c.task }, { role: 'assistant', content: c.answer });
for (const c of state.context) state.messages.push({ role: 'user', content: c.task }, { role: 'assistant', content: c.answer });
state.messages.push({ role: 'user', content: state.context.length ? `${FOLLOW_NOTE}\n\n${state.task}` : state.task });
state.messages.push({ role: 'user', content: state.context.length ? `${FOLLOW_NOTE}\n\n${state.task}` : state.task });
- const tools = registry.definitions({ requester, mode, config, models, signal, artifacts });
+ const offered = registry.definitions({ requester, mode, config, models, signal, artifacts });
+ // "best" mode may hand self-contained sub-goals to sub-agents (stage V2-7); no other mode is offered it.
+ const subAgents = mode === 'best' && offered.length > 0 && !(config.loop && config.loop.subAgents === false);
+ state.tools = subAgents ? offered.concat([DELEGATE]) : offered;
+ state.allow = new Set(state.tools.map((t) => t.function.name));
// Read from config each run so a test (or an operator's change) takes effect without rebuilding the loop.
// Read from config each run so a test (or an operator's change) takes effect without rebuilding the loop.
const maxSelf = config.loop && config.loop.selfReflections != null ? config.loop.selfReflections : selfReflections;
const maxSelf = config.loop && config.loop.selfReflections != null ? config.loop.selfReflections : selfReflections;
- for (;;) {
- if (signal && signal.aborted) throw Object.assign(new Error('cancelled'), { code: 'actor.task.cancelled' });
- if (budgetLeft && budgetLeft() <= 0) {
- state.lastText = await forceAnswer(state, ctx, 'the task reached its budget');
- break;
- }
- const r = await callModel(state, ctx, { tools });
- if (!r) { state.lastText = await forceAnswer(state, ctx, 'the task reached its budget'); break; }
- state.messages.push(r.message);
- if (r.text && r.text.trim()) state.lastText = r.text.trim();
- if (!r.toolCalls || !r.toolCalls.length) {
- // A draft answer: the writer's own reflection before it is delivered.
- if (state.selfReflections < maxSelf && (!budgetLeft || budgetLeft() > 0)) {
- state.selfReflections += 1;
- if (state.lastText) await ctx.emit({ step: 'text', text: clip(state.lastText, 2000) });
- state.messages.push({ role: 'user', content: `Reflect before this is delivered. Check the draft claim by claim: which claims have no evidence? A live fact (a price, score, weather reading, count, current status, release, date) must cite an [E#] from this run. If one has no evidence and a tool can fetch it, call that tool now. If it cannot be verified, remove it or say plainly that it is unverified. Then give the final answer, keeping the citations.` });
- continue;
- }
- break;
- }
- if (r.text && r.text.trim()) await ctx.emit({ step: 'text', text: clip(r.text.trim(), 2000) });
- const { looping } = await act(state, ctx, r.toolCalls);
- state.plan.steps.forEach((s, i) => {
- if (s.state === 'active' && i < state.plan.steps.length - 1) { s.state = 'done'; state.plan.steps[i + 1].state = 'active'; }
- });
- await emitPlan(state, ctx);
- if (looping) {
- state.lastText = await forceAnswer(state, ctx, 'the same tool and input were called three times');
- break;
- }
- }
+ await drive(state, ctx, { reflections: maxSelf });
state.plan.steps.forEach((s) => { s.state = 'done'; });
state.plan.steps.forEach((s) => { s.state = 'done'; });
await emitPlan(state, ctx);
await emitPlan(state, ctx);
return resultOf(state);
return resultOf(state);
@@ -341,7 +441,9 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -341,7 +441,9 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
if (!list.length) return prev;
if (!list.length) return prev;
if (ctx.budgetLeft && ctx.budgetLeft() <= 0) return prev;
if (ctx.budgetLeft && ctx.budgetLeft() <= 0) return prev;
state.messages.push({ role: 'user', content: `The verifier found claims in your answer with no evidence in this run:\n${list.map((c) => `- ${clip(c, 300)}`).join('\n')}\nFetch each one with a tool now if a tool can fetch it, or remove it. Then answer again, citing [E#] for every live fact.` });
state.messages.push({ role: 'user', content: `The verifier found claims in your answer with no evidence in this run:\n${list.map((c) => `- ${clip(c, 300)}`).join('\n')}\nFetch each one with a tool now if a tool can fetch it, or remove it. Then answer again, citing [E#] for every live fact.` });
- const tools = registry.definitions({ requester: state.requester, mode: state.mode, config, models, signal: ctx.signal, artifacts: ctx.artifacts });
+ // The run's own tools and allow-list (sub-agents included in "best" mode), so a revision can do no more than the run.
+ const tools = state.tools && state.tools.length ? state.tools : registry.definitions({ requester: state.requester, mode: state.mode, config, models, signal: ctx.signal, artifacts: ctx.artifacts });
+ if (!state.allow) state.allow = new Set(tools.map((t) => t.function.name));
const r = await callModel(state, ctx, { tools });
const r = await callModel(state, ctx, { tools });
if (r) {
if (r) {
state.messages.push(r.message);
state.messages.push(r.message);
@@ -365,4 +467,4 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -365,4 +467,4 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
return { run, revise, close, planText, SYSTEM };
return { run, revise, close, planText, SYSTEM };
}
}
-module.exports = { createLoop, planText, SYSTEM, withoutMeta };
+module.exports = { createLoop, planText, SYSTEM, withoutMeta, DELEGATE, MAX_SUB_AGENTS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,153 @@
@@ -0,0 +1,153 @@
+'use strict';
+/**
+ * Sub-agents (Actor v2, stage V2-7) and the allow-list under them, against a scripted fake model and a fake registry:
+ * - only the tools a run was offered may run, whatever a model asks for;
+ * - `delegate` is offered in "best" mode only;
+ * - a sub-agent never gets more than its parent: the parent's tools at most, narrowed by the call, never `delegate`
+ * (depth 1), and it cannot call anything outside that (the Attenu rule: hand-offs must not widen authority);
+ * - its evidence joins the parent's ledger, renumbered, its answer's citations follow, and its report is not
+ * evidence;
+ * - at most 4 at once; its share of the budget and its cap on model calls stop it.
+ */
+const assert = require('assert');
+const { createLoop, DELEGATE, MAX_SUB_AGENTS } = require('../server/runtime/loop');
+const configLib = require('../server/config');
+
+const results = [];
+const ok = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`); } catch (e) { results.push(e); console.log(` ✗ ${name}\n ${(e.stack || String(e)).split('\n').slice(0, 7).join('\n ')}`); } };
+
+let seq = 0;
+const call = (name, args) => ({ id: `c${++seq}`, type: 'function', function: { name, arguments: JSON.stringify(args) } });
+const reply = ({ text = '', tools = [], cost = 0.0001 } = {}) => ({
+ message: { role: 'assistant', content: text || null, ...(tools.length ? { tool_calls: tools } : {}) },
+ text, toolCalls: tools, cost_usd: cost, model: 'fake-1', family: 'fake', provider: 'fake',
+});
+const isChild = (req) => String((req.messages[0] || {}).content || '').includes('You are a sub-agent');
+const toolNames = (req) => (req.tools || []).map((t) => t.function.name);
+
+function make({ parent, child, mode = 'best', config = {}, budget = 1 } = {}) {
+ const chats = [];
+ const ran = [];
+ let p = 0;
+ const c = new Map();
+ const models = {
+ resolve(tier) { return { tier, provider: 'fake', model: 'fake-1', family: 'fake', available: true }; },
+ async chat(req) {
+ chats.push({ ...req, messages: req.messages.map((m) => ({ ...m })) });
+ if (req.tier === 'fast') return reply({ text: JSON.stringify({ assumptions: [], steps: [{ goal: 'Split the work' }, { goal: 'Answer' }] }) });
+ if (isChild(req)) {
+ const goal = String(req.messages[1].content).split('\n')[0];
+ const n = c.get(goal) || 0; c.set(goal, n + 1);
+ return reply(child(goal, n, req));
+ }
+ return reply(parent(p++, req));
+ },
+ };
+ const registry = {
+ definitions: () => ['lookup', 'fetch'].map((name) => ({ type: 'function', function: { name, description: name, parameters: { type: 'object', properties: {} } } })),
+ run: async (name, input) => { ran.push({ name, input }); return { text: JSON.stringify({ name, input, value: 42 }), data: { name, input }, sources: [{ url: `https://example.org/${name}/${input.q || ''}`, title: name }] }; },
+ };
+ const cfg = configLib.load({ NODE_ENV: 'test', ACTOR_LOOP_SELF_REFLECTIONS: '0', ...config });
+ const loop = createLoop({ config: cfg, models, registry, log: { warn() {}, log() {} } });
+ let spent = 0;
+ const events = [];
+ const args = {
+ task: 'Compare two things and answer', requester: 'user:usr_01JAB2C3D4E5F6G7H8J9K0MNPT', taskId: 'tsk_01JAB2C3D4E5F6G7H8J9K0MNPQ',
+ mode, signal: undefined, emit: async (e) => events.push(e), meter: async (usd) => { spent += Number(usd) || 0; },
+ budgetLeft: () => budget - spent,
+ };
+ return { loop, chats, ran, events, args, spent: () => spent };
+}
+const toolMessages = (chats) => {
+ const last = chats.filter((r) => !isChild(r) && r.tier !== 'fast').pop();
+ return last.messages.filter((m) => m.role === 'tool').map((m) => JSON.parse(m.content));
+};
+
+(async () => {
+ await ok('only the tools a run was offered may run: any other name is refused, whatever its scope', async () => {
+ const m = make({ mode: 'balanced', parent: (n) => (n === 0 ? { tools: [call('make_payment', { to: 'x' }), call('lookup', { q: 'a' })] } : { text: 'It is **42** [E1].' }) });
+ const out = await m.loop.run(m.args);
+ assert.deepStrictEqual(m.ran.map((r) => r.name), ['lookup'], 'make_payment never ran');
+ const msgs = toolMessages(m.chats);
+ assert.deepStrictEqual(msgs[0], { error: 'that tool is not offered for this task', tool: 'make_payment' });
+ assert.strictEqual(out.evidence.length, 1, 'and it is no evidence');
+ });
+
+ await ok('delegate is offered in "best" mode only', async () => {
+ const best = make({ parent: () => ({ text: 'Done.' }) });
+ await best.loop.run(best.args);
+ assert.ok(toolNames(best.chats.find((r) => r.tier !== 'fast')).includes('delegate'));
+ const balanced = make({ mode: 'balanced', parent: () => ({ text: 'Done.' }) });
+ await balanced.loop.run(balanced.args);
+ assert.ok(!toolNames(balanced.chats.find((r) => r.tier !== 'fast')).includes('delegate'));
+ const off = make({ config: { ACTOR_LOOP_SUB_AGENTS: '0' }, parent: () => ({ text: 'Done.' }) });
+ await off.loop.run(off.args);
+ assert.ok(!toolNames(off.chats.find((r) => r.tier !== 'fast')).includes('delegate'), 'and the operator can turn it off');
+ });
+
+ await ok('a sub-agent gets the parent\'s tools at most, narrowed by the call, never delegate, and cannot call past them', async () => {
+ const m = make({
+ parent: (n) => (n === 0 ? { tools: [call('delegate', { goal: 'Price of A', tools: ['lookup', 'make_payment', 'delegate'] })] } : { text: 'A costs **42** [E1].' }),
+ child: (goal, n) => (n === 0 ? { tools: [call('fetch', { q: 'A' }), call('delegate', { goal: 'deeper' }), call('make_payment', { to: 'x' }), call('lookup', { q: 'A' })] } : { text: 'A is 42 [E1].' }),
+ });
+ await m.loop.run(m.args);
+ const childReq = m.chats.find(isChild);
+ assert.deepStrictEqual(toolNames(childReq), ['lookup'], 'only lookup: make_payment is not the parent\'s, delegate is never a child\'s, fetch was not asked for');
+ assert.deepStrictEqual(m.ran.map((r) => r.name), ['lookup'], 'fetch, delegate and make_payment were refused inside the child');
+ const childTools = m.chats.filter(isChild).pop().messages.filter((x) => x.role === 'tool').map((x) => JSON.parse(x.content));
+ assert.deepStrictEqual(childTools.slice(0, 3).map((x) => x.error), ['that tool is not offered for this task', 'that tool is not offered for this task', 'that tool is not offered for this task']);
+ });
+
+ await ok('its evidence joins the parent\'s ledger renumbered, its citations follow, and its report is not evidence', async () => {
+ const m = make({
+ parent: (n) => (n === 0 ? { tools: [call('lookup', { q: 'mine' })] }
+ : n === 1 ? { tools: [call('delegate', { goal: 'Price of B' })] }
+ : { text: 'Mine is 42 [E1] and B is 42 [E2].' }),
+ child: (goal, n) => (n === 0 ? { tools: [call('lookup', { q: 'B' })] } : { text: 'B is **42** [E1].' }),
+ });
+ const out = await m.loop.run(m.args);
+ assert.deepStrictEqual(out.evidence.map((e) => [e.id, e.tool]), [['E1', 'lookup'], ['E2', 'lookup']], 'two items: the parent\'s and the child\'s, renumbered; no delegate item');
+ assert.ok(out.evidence[1].sources[0].url.endsWith('/lookup/B'), 'the child\'s item is what its tool returned');
+ const report = toolMessages(m.chats).find((x) => x.sub_agent);
+ assert.deepStrictEqual([report.answer, report.evidence], ['B is **42** [E2].', ['E2']], 'the report cites the parent\'s new id');
+ assert.deepStrictEqual(out.citations, ['E1', 'E2']);
+ const shown = m.events.filter((e) => e.tool === 'delegate');
+ assert.deepStrictEqual(shown.map((e) => e.step), ['tool_call', 'tool_result'], 'one step on the page');
+ assert.ok(/^Sub-agent: 2 steps, 1 finding\. B is/.test(shown[1].text));
+ });
+
+ await ok(`at most ${MAX_SUB_AGENTS} sub-agents at once`, async () => {
+ const m = make({
+ parent: (n) => (n === 0 ? { tools: ['a', 'b', 'c', 'd', 'e'].map((g) => call('delegate', { goal: `Part ${g}` })) } : { text: 'Done.' }),
+ child: () => ({ text: 'Nothing found.' }),
+ });
+ await m.loop.run(m.args);
+ const childGoals = new Set(m.chats.filter(isChild).map((r) => String(r.messages[1].content).split('\n')[0]));
+ assert.strictEqual(childGoals.size, MAX_SUB_AGENTS, 'four ran');
+ assert.ok(toolMessages(m.chats).some((x) => /at most 4 sub-agents at once/.test(x.error || '')), 'the fifth is told to wait for the next turn');
+ });
+
+ await ok('its share of the budget, and its cap on model calls, stop it; its cost is the task\'s', async () => {
+ // Budget 1.0, share 0.25: the child stops once its calls cost 0.25, then answers from what it has.
+ const m = make({
+ config: { ACTOR_LOOP_SUB_AGENT_SHARE: '0.25', ACTOR_LOOP_SUB_AGENT_CALLS: '50' },
+ parent: (n) => (n === 0 ? { tools: [call('delegate', { goal: 'Dig' })] } : { text: 'Done.' }),
+ child: (goal, n) => ({ tools: [call('lookup', { q: `dig ${n}` })], cost: 0.1 }),
+ });
+ await m.loop.run(m.args);
+ const childCalls = m.chats.filter(isChild).length;
+ assert.ok(childCalls >= 3 && childCalls <= 4, `three model calls reach the share, then one answer (${childCalls})`);
+ assert.ok(m.spent() >= 0.3 && m.spent() < 0.5, `the child's cost is metered into the task (${m.spent()})`);
+
+ const capped = make({
+ config: { ACTOR_LOOP_SUB_AGENT_CALLS: '2' },
+ parent: (n) => (n === 0 ? { tools: [call('delegate', { goal: 'Dig' })] } : { text: 'Done.' }),
+ child: (goal, n) => ({ tools: [call('lookup', { q: `dig ${n}` })] }),
+ });
+ await capped.loop.run(capped.args);
+ assert.strictEqual(capped.chats.filter(isChild).length, 3, 'two steps, then the answer from what it found');
+ });
+
+ if (results.length) { console.log(`\n${results.length} failed`); process.exit(1); }
+ console.log('\nall passed');
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -114,7 +114,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -114,7 +114,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
const saved = t.config.loop.selfReflections;
const saved = t.config.loop.selfReflections;
t.config.loop.selfReflections = 0;
t.config.loop.selfReflections = 0;
t.providers.state.script = [
t.providers.state.script = [
- { content: 'Trying a tool.', tool_calls: [{ id: 'c3', type: 'function', function: { name: 'no_such_tool', arguments: '{}' } }] },
+ { content: 'Trying a tool.', tool_calls: [{ id: 'c3', type: 'function', function: { name: 'calculate', arguments: JSON.stringify({ expression: '2 +* )' }) } }] },
{ content: 'That step failed, but I can still answer from this run [E1].' },
{ content: 'That step failed, but I can still answer from this run [E1].' },
];
];
try {
try {
@@ -122,7 +122,24 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -122,7 +122,24 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
await t.waitFor(id);
await t.waitFor(id);
const task = (await t.get(`/api/v1/tasks/${id}`, { as: kim })).json();
const task = (await t.get(`/api/v1/tasks/${id}`, { as: kim })).json();
assert.strictEqual(task.state, 'succeeded', JSON.stringify(task.error));
assert.strictEqual(task.state, 'succeeded', JSON.stringify(task.error));
- assert.match(task.result.evidence[0].text, /unknown tool/);
+ assert.match(task.result.evidence[0].text, /unexpected/);
+ } finally { t.config.loop.selfReflections = saved; }
+ });
+
+ await check('a tool the run was not offered never runs and is no evidence (stage V2-7: the allow-list)', async () => {
+ t.providers.reset();
+ const saved = t.config.loop.selfReflections;
+ t.config.loop.selfReflections = 0;
+ t.providers.state.script = [
+ { content: 'Trying a tool.', tool_calls: [{ id: 'c3b', type: 'function', function: { name: 'no_such_tool', arguments: '{}' } }] },
+ { content: 'I could not do that step.' },
+ ];
+ try {
+ const id = (await create({ task: 'Try the other thing' })).json().id;
+ await t.waitFor(id);
+ const task = (await t.get(`/api/v1/tasks/${id}`, { as: kim })).json();
+ assert.strictEqual(task.state, 'succeeded', JSON.stringify(task.error));
+ assert.strictEqual(task.result.evidence.length, 0, 'a refused call is not evidence');
} finally { t.config.loop.selfReflections = saved; }
} finally { t.config.loop.selfReflections = saved; }
});
});