openvibers/openvibe.actor Commits

Actor v2 stage V2-7: sub-agents in Best mode, and only the offered tools may run (#198)

OP OpenVibers committed 14 days ago · 2198d3fbd5 · 5 files, +317 −39

- The allow-list: a run (and a revision of it) runs only the tools it was
  offered. A call to any other name is refused before the registry, whatever
  its scope, and is not evidence. Until now a model could call a tool that
  was filtered out for the task as long as its scope allowed it.
- delegate (Best mode only; ACTOR_LOOP_SUB_AGENTS=0 turns it off): one
  self-contained sub-goal runs as 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 and
  never delegate, and cannot call past them; a share of what is left of the
  budget (ACTOR_LOOP_SUB_AGENT_SHARE, 0.25), metered into the task; a cap on
  its model calls (ACTOR_LOOP_SUB_AGENT_CALLS, 8); no reflection pass.
- Its evidence joins the parent's ledger, renumbered, and its answer's
  citations follow the new ids. Its report is a model's words, so it is
  never evidence: the parent cites what the child's tools returned.
- The loop body is drive(), shared by the run and its sub-agents.
- The task page names it "Sub-agent".

Changes

ddiff --git a/server/config.js b/server/config.js
iindex 255bd56..3571883 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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: {
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 6a73358..22c9674 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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".
ddiff --git a/server/runtime/loop.js b/server/runtime/loop.js
iindex 3f47b1a..67fa036 100644
---- a/server/runtime/loop.js
++++ b/server/runtime/loop.js
@@@ -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).sli
function 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 };
ddiff --git a/test/runtime-subagents.test.js b/test/runtime-subagents.test.js
nnew file mode 100644
iindex 0000000..65f33d0
---- /dev/null
++++ b/test/runtime-subagents.test.js
@@@ -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); });
ddiff --git a/test/tasks.test.js b/test/tasks.test.js
iindex 1f6423d..eef4c0e 100644
---- a/test/tasks.test.js
++++ b/test/tasks.test.js
@@@ -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; }
});