openvibers/openvibe.actor Commits
Actor: the answer check reads what the tools really returned (it failed a true report of a computer's output as invented) (#56)
Changes
@@@ -55,6 +55,7 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = conconst computers = tools.some((d) => d.function && d.function.name === 'computer_run');const messages = [{ role: 'system', content: RUNTIME_SYSTEM(robots, computers) }, { role: 'user', content: task }];let last = '';++ const evidence = []; // what each tool answered, for the check (agents/check.js)for (let turn = 1; turn <= MAX_TURNS; turn++) {if (budgetLeft() <= 0) throw new ProviderError('the task reached its budget before the runtime finished', { code: 'actor.budget.exceeded' });const finalTurn = turn === MAX_TURNS;@@@ -63,7 +64,7 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = conawait meter(r.cost_usd);messages.push(r.message);if (r.text && r.text.trim()) { last = r.text.trim(); }-- if (!r.toolCalls.length) return { text: last, sources: [], model: r.model };++ if (!r.toolCalls.length) return { text: last, sources: [], model: r.model, evidence };if (r.text && r.text.trim()) await emit({ step: 'text', text: r.text.trim().slice(0, 2000) });for (const call of r.toolCalls) {const name = call.function && call.function.name;@@@ -71,13 +72,14 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = contry { args = JSON.parse((call.function && call.function.arguments) || '{}'); } catch { args = {}; }await emit({ step: 'tool_call', tool: name, input: summarize(args) });const out = await ovTools.call(name, args, { signal, requester });++ evidence.push({ tool: name, result: out.slice(0, 2000) });const isError = /^\{"error"/.test(out);await emit({ step: 'tool_result', tool: name, text: out.slice(0, 600), is_error: isError });messages.push({ role: 'tool', tool_call_id: call.id, content: out });}}if (!last) throw new ProviderError('the runtime used all its turns without an answer', { code: 'actor.agent.no_answer' });-- return { text: last, sources: [], model: p.deepseek.model };++ return { text: last, sources: [], model: p.deepseek.model, evidence };}async function openai({ task, mode, signal, emit, meter, budgetLeft }) {@@@ -20,6 +20,8 @@ ok is true when the answer addresses the task and nothing in it is clearly invenok is false when the answer is empty, refuses without a good reason, answers a different question, contradicts itself, or states something that is plainly impossible.The agent may have searched the web or run tools: do not fail an answer only because it states recent facts, versions, prices or dates you cannot verify yourself, especially when it cites a source for them.The agent may have made the person's own robot act through OpenVibe.Bot: an answer that reports what the robot did, or why it stopped or was refused, addresses the task.++The agent may have used the person's own computer through OpenVibe.Bot (listed or read files, ran a program, wrote a file): an answer that reports what it found, what a program printed, or why the computer refused, addresses the task.++When the tool results the agent received are given below, they are real: judge the answer against them, and fail it only when it contradicts them or claims what they do not show.The agent may have run a coding task on the person's own machine through OpenVibe.Codes: an answer that reports what changed (a summary, the files, a patch), or that the machine is still working on it with a link to follow it, addresses the task.When the task is a greeting, small talk, a test message or too vague to act on, a short friendly reply, a summary of what the agent can do or a clarifying question is a good answer: ok is true.`;@@@ -35,16 +37,27 @@ const checkerFor = (agentId, checkers, opts) => checkersFor(agentId, checkers, o* Ask each checker in turn until one gives a verdict: a checker that answers nothing usable (a provider error, no JSON)* passes the turn to the next; only when none gives a verdict is the result unavailable (and never delivered).*/--async function check({ task, answer, sources = [], agentId, mode, checkers, signal }) {++async function check({ task, answer, sources = [], evidence = [], agentId, mode, checkers, signal }) {const list = checkersFor(agentId, checkers, { mode });if (!list.length) return { ok: false, unavailable: true, reason: 'no model is available to check this answer', cost_usd: 0 };let cost = 0;let last = 'no checker gave a verdict';const cited = sources.length ? `\n\nSources the agent cites:\n${sources.slice(0, 10).join('\n')}` : '';++ // What the agent's tools answered (the runtime's own calls, never the model's words): the check reads the answer++ // against them instead of guessing whether a reported fact was invented. At most 6000 characters in all.++ let room = 6000;++ const seen = [];++ for (const e of evidence) {++ if (room <= 0) break;++ const line = `${e.tool}: ${String(e.result || '').slice(0, Math.min(1500, room))}`;++ room -= line.length;++ seen.push(line);++ }++ const tools = seen.length ? `\n\nTool results the agent received (real):\n${seen.join('\n')}` : '';for (const c of list) {try {const r = await c.chat({-- messages: [{ role: 'system', content: SYSTEM }, { role: 'user', content: `Task:\n${String(task).slice(0, 6000)}\n\nAnswer:\n${String(answer).slice(0, 12000)}${cited}` }],++ messages: [{ role: 'system', content: SYSTEM }, { role: 'user', content: `Task:\n${String(task).slice(0, 6000)}\n\nAnswer:\n${String(answer).slice(0, 12000)}${cited}${tools}` }],json: true, maxTokens: 160, signal,});cost += r.cost_usd || 0;@@@ -144,7 +144,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =continue;}await setState(id, 'verifying', { agentForEvent: agentId });-- const verdict = await check({ task: row.task, answer: out.text, sources: out.sources || [], agentId, mode: row.mode, checkers: adapters.checkers, signal });++ const verdict = await check({ task: row.task, answer: out.text, sources: out.sources || [], evidence: out.evidence || [], agentId, mode: row.mode, checkers: adapters.checkers, signal });await meter(verdict.cost_usd);await emit(id, { kind: 'output', step: 'check', agent: agentId, text: `${verdict.ok ? 'Checked' : 'Check failed'}${verdict.by ? ` by ${verdict.by}${verdict.cross ? '' : ' (same family)'}` : ''}: ${verdict.reason || ''}`.slice(0, 2000), is_error: !verdict.ok });if (verdict.ok) {@@@ -113,6 +113,21 @@ const as = { requester: `user:${ME}` };assert.strictEqual(placed.selected, 'openvibe-runtime', JSON.stringify(placed.candidates));});++ await check('the answer check reads the tool results the agent received, capped', async () => {++ const { check: checkAnswer } = require('../server/agents/check');++ const seen = [];++ const chat = async ({ messages }) => { seen.push(messages); return { text: '{"ok":true,"reason":"matches the tool results"}', cost_usd: 0 }; };++ const evidence = [{ tool: 'computer_run', result: '{"exit_code":0,"stdout":"Linux\\n"}' }, ...Array.from({ length: 10 }, (_, i) => ({ tool: 'computer_read', result: 'x'.repeat(2000) + i }))];++ const v = await checkAnswer({ task: 'run uname -s on my computer', answer: 'uname printed Linux.', evidence, agentId: 'openvibe-runtime', mode: 'balanced', checkers: { openai: chat } });++ assert.strictEqual(v.ok, true);++ const [system, user] = seen[0];++ assert.match(system.content, /person's own computer through OpenVibe\.Bot/);++ assert.match(user.content, /Tool results the agent received \(real\):\ncomputer_run: \{"exit_code":0,"stdout":"Linux/);++ assert.ok(user.content.length < 6000 + 6000 + 2000, 'the evidence is capped');++ await checkAnswer({ task: 't', answer: 'a', agentId: 'openvibe-runtime', mode: 'balanced', checkers: { openai: chat } });++ assert.doesNotMatch(seen[1][1].content, /Tool results/, 'no tools, no section');++ });++await check('off without a Bot URL or a client secret', async () => {assert.strictEqual(createComputers({ config: { ...config, services: { ...config.services, botUrl: '' } } }).enabled, false);assert.strictEqual(createComputers({ config: { ...config, oauth: { clientId: 'actor', clientSecret: '' } } }).enabled, false);
@@ -55,6 +55,7 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
@@ -55,6 +55,7 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
const computers = tools.some((d) => d.function && d.function.name === 'computer_run');
const computers = tools.some((d) => d.function && d.function.name === 'computer_run');
const messages = [{ role: 'system', content: RUNTIME_SYSTEM(robots, computers) }, { role: 'user', content: task }];
const messages = [{ role: 'system', content: RUNTIME_SYSTEM(robots, computers) }, { role: 'user', content: task }];
let last = '';
let last = '';
+ const evidence = []; // what each tool answered, for the check (agents/check.js)
for (let turn = 1; turn <= MAX_TURNS; turn++) {
for (let turn = 1; turn <= MAX_TURNS; turn++) {
if (budgetLeft() <= 0) throw new ProviderError('the task reached its budget before the runtime finished', { code: 'actor.budget.exceeded' });
if (budgetLeft() <= 0) throw new ProviderError('the task reached its budget before the runtime finished', { code: 'actor.budget.exceeded' });
const finalTurn = turn === MAX_TURNS;
const finalTurn = turn === MAX_TURNS;
@@ -63,7 +64,7 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
@@ -63,7 +64,7 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
await meter(r.cost_usd);
await meter(r.cost_usd);
messages.push(r.message);
messages.push(r.message);
if (r.text && r.text.trim()) { last = r.text.trim(); }
if (r.text && r.text.trim()) { last = r.text.trim(); }
- if (!r.toolCalls.length) return { text: last, sources: [], model: r.model };
+ if (!r.toolCalls.length) return { text: last, sources: [], model: r.model, evidence };
if (r.text && r.text.trim()) await emit({ step: 'text', text: r.text.trim().slice(0, 2000) });
if (r.text && r.text.trim()) await emit({ step: 'text', text: r.text.trim().slice(0, 2000) });
for (const call of r.toolCalls) {
for (const call of r.toolCalls) {
const name = call.function && call.function.name;
const name = call.function && call.function.name;
@@ -71,13 +72,14 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
@@ -71,13 +72,14 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
try { args = JSON.parse((call.function && call.function.arguments) || '{}'); } catch { args = {}; }
try { args = JSON.parse((call.function && call.function.arguments) || '{}'); } catch { args = {}; }
await emit({ step: 'tool_call', tool: name, input: summarize(args) });
await emit({ step: 'tool_call', tool: name, input: summarize(args) });
const out = await ovTools.call(name, args, { signal, requester });
const out = await ovTools.call(name, args, { signal, requester });
+ evidence.push({ tool: name, result: out.slice(0, 2000) });
const isError = /^\{"error"/.test(out);
const isError = /^\{"error"/.test(out);
await emit({ step: 'tool_result', tool: name, text: out.slice(0, 600), is_error: isError });
await emit({ step: 'tool_result', tool: name, text: out.slice(0, 600), is_error: isError });
messages.push({ role: 'tool', tool_call_id: call.id, content: out });
messages.push({ role: 'tool', tool_call_id: call.id, content: out });
}
}
}
}
if (!last) throw new ProviderError('the runtime used all its turns without an answer', { code: 'actor.agent.no_answer' });
if (!last) throw new ProviderError('the runtime used all its turns without an answer', { code: 'actor.agent.no_answer' });
- return { text: last, sources: [], model: p.deepseek.model };
+ return { text: last, sources: [], model: p.deepseek.model, evidence };
}
}
async function openai({ task, mode, signal, emit, meter, budgetLeft }) {
async function openai({ task, mode, signal, emit, meter, budgetLeft }) {
@@ -20,6 +20,8 @@ ok is true when the answer addresses the task and nothing in it is clearly inven
@@ -20,6 +20,8 @@ ok is true when the answer addresses the task and nothing in it is clearly inven
ok is false when the answer is empty, refuses without a good reason, answers a different question, contradicts itself, or states something that is plainly impossible.
ok is false when the answer is empty, refuses without a good reason, answers a different question, contradicts itself, or states something that is plainly impossible.
The agent may have searched the web or run tools: do not fail an answer only because it states recent facts, versions, prices or dates you cannot verify yourself, especially when it cites a source for them.
The agent may have searched the web or run tools: do not fail an answer only because it states recent facts, versions, prices or dates you cannot verify yourself, especially when it cites a source for them.
The agent may have made the person's own robot act through OpenVibe.Bot: an answer that reports what the robot did, or why it stopped or was refused, addresses the task.
The agent may have made the person's own robot act through OpenVibe.Bot: an answer that reports what the robot did, or why it stopped or was refused, addresses the task.
+The agent may have used the person's own computer through OpenVibe.Bot (listed or read files, ran a program, wrote a file): an answer that reports what it found, what a program printed, or why the computer refused, addresses the task.
+When the tool results the agent received are given below, they are real: judge the answer against them, and fail it only when it contradicts them or claims what they do not show.
The agent may have run a coding task on the person's own machine through OpenVibe.Codes: an answer that reports what changed (a summary, the files, a patch), or that the machine is still working on it with a link to follow it, addresses the task.
The agent may have run a coding task on the person's own machine through OpenVibe.Codes: an answer that reports what changed (a summary, the files, a patch), or that the machine is still working on it with a link to follow it, addresses the task.
When the task is a greeting, small talk, a test message or too vague to act on, a short friendly reply, a summary of what the agent can do or a clarifying question is a good answer: ok is true.`;
When the task is a greeting, small talk, a test message or too vague to act on, a short friendly reply, a summary of what the agent can do or a clarifying question is a good answer: ok is true.`;
@@ -35,16 +37,27 @@ const checkerFor = (agentId, checkers, opts) => checkersFor(agentId, checkers, o
@@ -35,16 +37,27 @@ const checkerFor = (agentId, checkers, opts) => checkersFor(agentId, checkers, o
* Ask each checker in turn until one gives a verdict: a checker that answers nothing usable (a provider error, no JSON)
* Ask each checker in turn until one gives a verdict: a checker that answers nothing usable (a provider error, no JSON)
* passes the turn to the next; only when none gives a verdict is the result unavailable (and never delivered).
* passes the turn to the next; only when none gives a verdict is the result unavailable (and never delivered).
*/
*/
-async function check({ task, answer, sources = [], agentId, mode, checkers, signal }) {
+async function check({ task, answer, sources = [], evidence = [], agentId, mode, checkers, signal }) {
const list = checkersFor(agentId, checkers, { mode });
const list = checkersFor(agentId, checkers, { mode });
if (!list.length) return { ok: false, unavailable: true, reason: 'no model is available to check this answer', cost_usd: 0 };
if (!list.length) return { ok: false, unavailable: true, reason: 'no model is available to check this answer', cost_usd: 0 };
let cost = 0;
let cost = 0;
let last = 'no checker gave a verdict';
let last = 'no checker gave a verdict';
const cited = sources.length ? `\n\nSources the agent cites:\n${sources.slice(0, 10).join('\n')}` : '';
const cited = sources.length ? `\n\nSources the agent cites:\n${sources.slice(0, 10).join('\n')}` : '';
+ // What the agent's tools answered (the runtime's own calls, never the model's words): the check reads the answer
+ // against them instead of guessing whether a reported fact was invented. At most 6000 characters in all.
+ let room = 6000;
+ const seen = [];
+ for (const e of evidence) {
+ if (room <= 0) break;
+ const line = `${e.tool}: ${String(e.result || '').slice(0, Math.min(1500, room))}`;
+ room -= line.length;
+ seen.push(line);
+ }
+ const tools = seen.length ? `\n\nTool results the agent received (real):\n${seen.join('\n')}` : '';
for (const c of list) {
for (const c of list) {
try {
try {
const r = await c.chat({
const r = await c.chat({
- messages: [{ role: 'system', content: SYSTEM }, { role: 'user', content: `Task:\n${String(task).slice(0, 6000)}\n\nAnswer:\n${String(answer).slice(0, 12000)}${cited}` }],
+ messages: [{ role: 'system', content: SYSTEM }, { role: 'user', content: `Task:\n${String(task).slice(0, 6000)}\n\nAnswer:\n${String(answer).slice(0, 12000)}${cited}${tools}` }],
json: true, maxTokens: 160, signal,
json: true, maxTokens: 160, signal,
});
});
cost += r.cost_usd || 0;
cost += r.cost_usd || 0;
@@ -144,7 +144,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -144,7 +144,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
continue;
continue;
}
}
await setState(id, 'verifying', { agentForEvent: agentId });
await setState(id, 'verifying', { agentForEvent: agentId });
- const verdict = await check({ task: row.task, answer: out.text, sources: out.sources || [], agentId, mode: row.mode, checkers: adapters.checkers, signal });
+ const verdict = await check({ task: row.task, answer: out.text, sources: out.sources || [], evidence: out.evidence || [], agentId, mode: row.mode, checkers: adapters.checkers, signal });
await meter(verdict.cost_usd);
await meter(verdict.cost_usd);
await emit(id, { kind: 'output', step: 'check', agent: agentId, text: `${verdict.ok ? 'Checked' : 'Check failed'}${verdict.by ? ` by ${verdict.by}${verdict.cross ? '' : ' (same family)'}` : ''}: ${verdict.reason || ''}`.slice(0, 2000), is_error: !verdict.ok });
await emit(id, { kind: 'output', step: 'check', agent: agentId, text: `${verdict.ok ? 'Checked' : 'Check failed'}${verdict.by ? ` by ${verdict.by}${verdict.cross ? '' : ' (same family)'}` : ''}: ${verdict.reason || ''}`.slice(0, 2000), is_error: !verdict.ok });
if (verdict.ok) {
if (verdict.ok) {
@@ -113,6 +113,21 @@ const as = { requester: `user:${ME}` };
@@ -113,6 +113,21 @@ const as = { requester: `user:${ME}` };
assert.strictEqual(placed.selected, 'openvibe-runtime', JSON.stringify(placed.candidates));
assert.strictEqual(placed.selected, 'openvibe-runtime', JSON.stringify(placed.candidates));
});
});
+ await check('the answer check reads the tool results the agent received, capped', async () => {
+ const { check: checkAnswer } = require('../server/agents/check');
+ const seen = [];
+ const chat = async ({ messages }) => { seen.push(messages); return { text: '{"ok":true,"reason":"matches the tool results"}', cost_usd: 0 }; };
+ const evidence = [{ tool: 'computer_run', result: '{"exit_code":0,"stdout":"Linux\\n"}' }, ...Array.from({ length: 10 }, (_, i) => ({ tool: 'computer_read', result: 'x'.repeat(2000) + i }))];
+ const v = await checkAnswer({ task: 'run uname -s on my computer', answer: 'uname printed Linux.', evidence, agentId: 'openvibe-runtime', mode: 'balanced', checkers: { openai: chat } });
+ assert.strictEqual(v.ok, true);
+ const [system, user] = seen[0];
+ assert.match(system.content, /person's own computer through OpenVibe\.Bot/);
+ assert.match(user.content, /Tool results the agent received \(real\):\ncomputer_run: \{"exit_code":0,"stdout":"Linux/);
+ assert.ok(user.content.length < 6000 + 6000 + 2000, 'the evidence is capped');
+ await checkAnswer({ task: 't', answer: 'a', agentId: 'openvibe-runtime', mode: 'balanced', checkers: { openai: chat } });
+ assert.doesNotMatch(seen[1][1].content, /Tool results/, 'no tools, no section');
+ });
+
await check('off without a Bot URL or a client secret', async () => {
await check('off without a Bot URL or a client secret', async () => {
assert.strictEqual(createComputers({ config: { ...config, services: { ...config.services, botUrl: '' } } }).enabled, false);
assert.strictEqual(createComputers({ config: { ...config, services: { ...config.services, botUrl: '' } } }).enabled, false);
assert.strictEqual(createComputers({ config: { ...config, oauth: { clientId: 'actor', clientSecret: '' } } }).enabled, false);
assert.strictEqual(createComputers({ config: { ...config, oauth: { clientId: 'actor', clientSecret: '' } } }).enabled, false);