openvibers/openvibe.actor Commits
Add opted-in history context tool (#409)
Changes
@@@ -97,7 +97,7 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = conasync function runtime({ task, requester = null, taskId = null, signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null }) {if (!runtimeModel) throw new ProviderError('the runtime has no model configured', { code: 'actor.agent.unavailable' });-- const tools = await ovTools.definitions({ signal, requester });++ const tools = await ovTools.definitions({ signal, requester, taskId });const robots = tools.some((d) => d.function && d.function.name === 'robot_steps');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 }];@@@ -121,7 +121,7 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = conlet args = {};try { args = JSON.parse((call.function && call.function.arguments) || '{}'); } catch { args = {}; }await emit({ step: 'tool_call', tool: name, input: summarize(args) });-- const out = await ovTools.call(name, args, { signal, requester, meter, budgetLeft, desks, emit, waitForPerson, requestApproval });++ const out = await ovTools.call(name, args, { signal, requester, taskId, meter, budgetLeft, desks, emit, waitForPerson, requestApproval });evidence.push({ tool: name, result: out.slice(0, 2000) });const isError = /^\{"error"/.test(out);await emit({ step: 'tool_result', tool: name, text: out.slice(0, 600), is_error: isError });@@@ -27,6 +27,8 @@ const { stepFor } = require('./actions');* a folder, a file, a program (argv, never a shell line) or a write on one of them, as the* person, through Bot; at level "ask" each run or write waits for the person's approval* on the machine itself++ * read_my_history(limit) OpenVibe.Network: recent titles and links from the person's history, only when that++ * person enabled agent access; uses the short-lived person token kept for this task* computer_screen(machine, question)* the machine's screen now, read by a vision model (`vision`, OpenAI) that answers the* question about it; the runtime model gets the answer, never the picture, and the@@@ -50,6 +52,7 @@ const { stepFor } = require('./actions');const { serviceAuth } = require('openvibe-contracts');const { createRobots } = require('./robots');const { createComputers } = require('./computers');++const runTokens = require('../triggers/tokens');const CATALOG_TTL_MS = 10 * 60_000;const RESULT_CHARS = 6000;@@@ -161,7 +164,7 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>}/** The function definitions the runtime's model sees, and the dispatcher that runs one call. */-- async function definitions({ signal, requester = null } = {}) {++ async function definitions({ signal, requester = null, taskId = null } = {}) {const list = await tools({ signal });// A self-hosted Actor may leave OpenVibe.Search or OpenVibe.Tools unset (ACTOR_SEARCH_URL / ACTOR_TOOLS_URL empty):// the runtime is then offered only the tools it can reach.@@@ -175,6 +178,7 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>}if (bot.enabled && personOf(requester)) defs.push(...ROBOT_DEFS);if (pcs.enabled && personOf(requester)) defs.push(...COMPUTER_DEFS, BROWSER_DEF, DESK_DEF, HAND_DEF, ...(vision ? [SCREEN_DEF, INPUT_DEF] : []));++ if (personOf(requester) && runTokens.get(taskId)?.token) defs.push({ type: 'function', function: { name: 'read_my_history', description: 'Read the recent OpenVibe pages this person visited. Available only if they enabled “My agents may read my history”; returns titles, kinds, sites, links and times, never page contents.', parameters: { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: 50 } } } } });return defs;}@@@ -297,7 +301,7 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>return { done: false, note: took ? 'The person was still using the desk when the wait ended: look at it, and tell them what is left if it is not done.' : `The person did not take the desk in ${Math.round(granted / 60000)} minutes: tell them in your answer what is left for them to do, with the link.`, watch };}-- async function call(name, args, { signal, requester = null, meter = null, budgetLeft = null, desks = null, emit = null, waitForPerson = null, requestApproval = null } = {}) {++ async function call(name, args, { signal, requester = null, taskId = null, meter = null, budgetLeft = null, desks = null, emit = null, waitForPerson = null, requestApproval = null } = {}) {let a = args && typeof args === 'object' ? args : {};// The approval gate (plan §12 A-2): the act and its always-ask category go through levels.decide// before the step runs. A read, or Actor's own working state, says null and goes on.@@@ -384,6 +388,16 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>return json(await pcs.write(person, a.machine, a.path, a.text, { mode: a.mode || 'create', signal }));}if (name === 'search_openvibe') return json(await searchOpenVibe(a.query, { signal }));++ if (name === 'read_my_history') {++ const person = personOf(requester);++ const token = runTokens.get(taskId)?.token;++ if (!person || !token) return json({ error: 'history is only available to the signed-in person whose task carries their short-lived authorization' });++ const limit = Math.min(50, Math.max(1, parseInt(a.limit, 10) || 20));++ const r = await getJson(`${config.networkInternalUrl}/api/v1/history/agents/recent?limit=${limit}`, { signal, headers: { Authorization: `Bearer ${token}` } });++ if (r.status === 403) return json({ error: 'not_allowed', note: 'The person has not enabled agent access to history.' });++ if (!r.ok || !r.data) return json({ error: `Network history answered ${r.status}` });++ return json(r.data);++ }if (name === 'search_web') return json(await searchWeb(a.query, personOf(requester), { signal }));if (name === 'find_tools') return json(await findTools(a.need, { signal }));if (name === 'run_tool') return json(await runTool(String(a.tool || ''), a.input, { signal }));nnew file mode 100644@@@ -0,0 +1,33 @@++'use strict';++// Actor can read titles and links through Network only with the signed-in person's task token; Network enforces opt-in.++const assert = require('assert');++const { createOpenVibeTools } = require('../server/agents/tools');++const runTokens = require('../server/triggers/tokens');++++const taskId = 'history-fixture-task';++const requester = 'user:usr_01M4W5JBFEG3QG9ER8W1A2K0RX';++const config = { networkInternalUrl: 'http://network.test', services: { searchUrl: '', toolsUrl: '' } };++++(async () => {++ const calls = [];++ let status = 200;++ const fetchImpl = async (url, init = {}) => {++ calls.push({ url, authorization: init.headers?.Authorization });++ return new Response(JSON.stringify(status === 200 ? { items: [{ title: 'A watched stream' }] } : { error: 'not_allowed' }), { status, headers: { 'content-type': 'application/json' } });++ };++ const tools = createOpenVibeTools({ config, fetchImpl });++ assert.ok(!(await tools.definitions({ requester, taskId })).some((d) => d.function.name === 'read_my_history'), 'no user token means no history tool');++ runTokens.put(taskId, { token: 'fixture-user-token' });++ try {++ assert.ok((await tools.definitions({ requester, taskId })).some((d) => d.function.name === 'read_my_history'));++ const result = JSON.parse(await tools.call('read_my_history', { limit: 7 }, { requester, taskId }));++ assert.strictEqual(result.items[0].title, 'A watched stream');++ assert.strictEqual(calls[0].url, 'http://network.test/api/v1/history/agents/recent?limit=7');++ assert.strictEqual(calls[0].authorization, 'Bearer fixture-user-token');++ status = 403;++ const denied = JSON.parse(await tools.call('read_my_history', {}, { requester, taskId }));++ assert.strictEqual(denied.error, 'not_allowed', 'Network opt-in refusal is preserved');++ assert.strictEqual(calls.length, 2);++ } finally { runTokens.drop(taskId); }++ console.log('Actor history tool: all checks passed');++})().catch((err) => { console.error(err); process.exitCode = 1; });
@@ -97,7 +97,7 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
@@ -97,7 +97,7 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
async function runtime({ task, requester = null, taskId = null, signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null }) {
async function runtime({ task, requester = null, taskId = null, signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null }) {
if (!runtimeModel) throw new ProviderError('the runtime has no model configured', { code: 'actor.agent.unavailable' });
if (!runtimeModel) throw new ProviderError('the runtime has no model configured', { code: 'actor.agent.unavailable' });
- const tools = await ovTools.definitions({ signal, requester });
+ const tools = await ovTools.definitions({ signal, requester, taskId });
const robots = tools.some((d) => d.function && d.function.name === 'robot_steps');
const robots = tools.some((d) => d.function && d.function.name === 'robot_steps');
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 }];
@@ -121,7 +121,7 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
@@ -121,7 +121,7 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
let args = {};
let args = {};
try { args = JSON.parse((call.function && call.function.arguments) || '{}'); } catch { args = {}; }
try { args = JSON.parse((call.function && call.function.arguments) || '{}'); } catch { args = {}; }
await emit({ step: 'tool_call', tool: name, input: summarize(args) });
await emit({ step: 'tool_call', tool: name, input: summarize(args) });
- const out = await ovTools.call(name, args, { signal, requester, meter, budgetLeft, desks, emit, waitForPerson, requestApproval });
+ const out = await ovTools.call(name, args, { signal, requester, taskId, meter, budgetLeft, desks, emit, waitForPerson, requestApproval });
evidence.push({ tool: name, result: out.slice(0, 2000) });
evidence.push({ tool: name, result: out.slice(0, 2000) });
const isError = /^\{"error"/.test(out);
const isError = /^\{"error"/.test(out);
await emit({ step: 'tool_result', tool: name, text: out.slice(0, 600), is_error: isError });
await emit({ step: 'tool_result', tool: name, text: out.slice(0, 600), is_error: isError });
@@ -27,6 +27,8 @@ const { stepFor } = require('./actions');
@@ -27,6 +27,8 @@ const { stepFor } = require('./actions');
* a folder, a file, a program (argv, never a shell line) or a write on one of them, as the
* a folder, a file, a program (argv, never a shell line) or a write on one of them, as the
* person, through Bot; at level "ask" each run or write waits for the person's approval
* person, through Bot; at level "ask" each run or write waits for the person's approval
* on the machine itself
* on the machine itself
+ * read_my_history(limit) OpenVibe.Network: recent titles and links from the person's history, only when that
+ * person enabled agent access; uses the short-lived person token kept for this task
* computer_screen(machine, question)
* computer_screen(machine, question)
* the machine's screen now, read by a vision model (`vision`, OpenAI) that answers the
* the machine's screen now, read by a vision model (`vision`, OpenAI) that answers the
* question about it; the runtime model gets the answer, never the picture, and the
* question about it; the runtime model gets the answer, never the picture, and the
@@ -50,6 +52,7 @@ const { stepFor } = require('./actions');
@@ -50,6 +52,7 @@ const { stepFor } = require('./actions');
const { serviceAuth } = require('openvibe-contracts');
const { serviceAuth } = require('openvibe-contracts');
const { createRobots } = require('./robots');
const { createRobots } = require('./robots');
const { createComputers } = require('./computers');
const { createComputers } = require('./computers');
+const runTokens = require('../triggers/tokens');
const CATALOG_TTL_MS = 10 * 60_000;
const CATALOG_TTL_MS = 10 * 60_000;
const RESULT_CHARS = 6000;
const RESULT_CHARS = 6000;
@@ -161,7 +164,7 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
@@ -161,7 +164,7 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
}
}
/** The function definitions the runtime's model sees, and the dispatcher that runs one call. */
/** The function definitions the runtime's model sees, and the dispatcher that runs one call. */
- async function definitions({ signal, requester = null } = {}) {
+ async function definitions({ signal, requester = null, taskId = null } = {}) {
const list = await tools({ signal });
const list = await tools({ signal });
// A self-hosted Actor may leave OpenVibe.Search or OpenVibe.Tools unset (ACTOR_SEARCH_URL / ACTOR_TOOLS_URL empty):
// A self-hosted Actor may leave OpenVibe.Search or OpenVibe.Tools unset (ACTOR_SEARCH_URL / ACTOR_TOOLS_URL empty):
// the runtime is then offered only the tools it can reach.
// the runtime is then offered only the tools it can reach.
@@ -175,6 +178,7 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
@@ -175,6 +178,7 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
}
}
if (bot.enabled && personOf(requester)) defs.push(...ROBOT_DEFS);
if (bot.enabled && personOf(requester)) defs.push(...ROBOT_DEFS);
if (pcs.enabled && personOf(requester)) defs.push(...COMPUTER_DEFS, BROWSER_DEF, DESK_DEF, HAND_DEF, ...(vision ? [SCREEN_DEF, INPUT_DEF] : []));
if (pcs.enabled && personOf(requester)) defs.push(...COMPUTER_DEFS, BROWSER_DEF, DESK_DEF, HAND_DEF, ...(vision ? [SCREEN_DEF, INPUT_DEF] : []));
+ if (personOf(requester) && runTokens.get(taskId)?.token) defs.push({ type: 'function', function: { name: 'read_my_history', description: 'Read the recent OpenVibe pages this person visited. Available only if they enabled “My agents may read my history”; returns titles, kinds, sites, links and times, never page contents.', parameters: { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: 50 } } } } });
return defs;
return defs;
}
}
@@ -297,7 +301,7 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
@@ -297,7 +301,7 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
return { done: false, note: took ? 'The person was still using the desk when the wait ended: look at it, and tell them what is left if it is not done.' : `The person did not take the desk in ${Math.round(granted / 60000)} minutes: tell them in your answer what is left for them to do, with the link.`, watch };
return { done: false, note: took ? 'The person was still using the desk when the wait ended: look at it, and tell them what is left if it is not done.' : `The person did not take the desk in ${Math.round(granted / 60000)} minutes: tell them in your answer what is left for them to do, with the link.`, watch };
}
}
- async function call(name, args, { signal, requester = null, meter = null, budgetLeft = null, desks = null, emit = null, waitForPerson = null, requestApproval = null } = {}) {
+ async function call(name, args, { signal, requester = null, taskId = null, meter = null, budgetLeft = null, desks = null, emit = null, waitForPerson = null, requestApproval = null } = {}) {
let a = args && typeof args === 'object' ? args : {};
let a = args && typeof args === 'object' ? args : {};
// The approval gate (plan §12 A-2): the act and its always-ask category go through levels.decide
// The approval gate (plan §12 A-2): the act and its always-ask category go through levels.decide
// before the step runs. A read, or Actor's own working state, says null and goes on.
// before the step runs. A read, or Actor's own working state, says null and goes on.
@@ -384,6 +388,16 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
@@ -384,6 +388,16 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
return json(await pcs.write(person, a.machine, a.path, a.text, { mode: a.mode || 'create', signal }));
return json(await pcs.write(person, a.machine, a.path, a.text, { mode: a.mode || 'create', signal }));
}
}
if (name === 'search_openvibe') return json(await searchOpenVibe(a.query, { signal }));
if (name === 'search_openvibe') return json(await searchOpenVibe(a.query, { signal }));
+ if (name === 'read_my_history') {
+ const person = personOf(requester);
+ const token = runTokens.get(taskId)?.token;
+ if (!person || !token) return json({ error: 'history is only available to the signed-in person whose task carries their short-lived authorization' });
+ const limit = Math.min(50, Math.max(1, parseInt(a.limit, 10) || 20));
+ const r = await getJson(`${config.networkInternalUrl}/api/v1/history/agents/recent?limit=${limit}`, { signal, headers: { Authorization: `Bearer ${token}` } });
+ if (r.status === 403) return json({ error: 'not_allowed', note: 'The person has not enabled agent access to history.' });
+ if (!r.ok || !r.data) return json({ error: `Network history answered ${r.status}` });
+ return json(r.data);
+ }
if (name === 'search_web') return json(await searchWeb(a.query, personOf(requester), { signal }));
if (name === 'search_web') return json(await searchWeb(a.query, personOf(requester), { signal }));
if (name === 'find_tools') return json(await findTools(a.need, { signal }));
if (name === 'find_tools') return json(await findTools(a.need, { signal }));
if (name === 'run_tool') return json(await runTool(String(a.tool || ''), a.input, { signal }));
if (name === 'run_tool') return json(await runTool(String(a.tool || ''), a.input, { signal }));
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+'use strict';
+// Actor can read titles and links through Network only with the signed-in person's task token; Network enforces opt-in.
+const assert = require('assert');
+const { createOpenVibeTools } = require('../server/agents/tools');
+const runTokens = require('../server/triggers/tokens');
+
+const taskId = 'history-fixture-task';
+const requester = 'user:usr_01M4W5JBFEG3QG9ER8W1A2K0RX';
+const config = { networkInternalUrl: 'http://network.test', services: { searchUrl: '', toolsUrl: '' } };
+
+(async () => {
+ const calls = [];
+ let status = 200;
+ const fetchImpl = async (url, init = {}) => {
+ calls.push({ url, authorization: init.headers?.Authorization });
+ return new Response(JSON.stringify(status === 200 ? { items: [{ title: 'A watched stream' }] } : { error: 'not_allowed' }), { status, headers: { 'content-type': 'application/json' } });
+ };
+ const tools = createOpenVibeTools({ config, fetchImpl });
+ assert.ok(!(await tools.definitions({ requester, taskId })).some((d) => d.function.name === 'read_my_history'), 'no user token means no history tool');
+ runTokens.put(taskId, { token: 'fixture-user-token' });
+ try {
+ assert.ok((await tools.definitions({ requester, taskId })).some((d) => d.function.name === 'read_my_history'));
+ const result = JSON.parse(await tools.call('read_my_history', { limit: 7 }, { requester, taskId }));
+ assert.strictEqual(result.items[0].title, 'A watched stream');
+ assert.strictEqual(calls[0].url, 'http://network.test/api/v1/history/agents/recent?limit=7');
+ assert.strictEqual(calls[0].authorization, 'Bearer fixture-user-token');
+ status = 403;
+ const denied = JSON.parse(await tools.call('read_my_history', {}, { requester, taskId }));
+ assert.strictEqual(denied.error, 'not_allowed', 'Network opt-in refusal is preserved');
+ assert.strictEqual(calls.length, 2);
+ } finally { runTokens.drop(taskId); }
+ console.log('Actor history tool: all checks passed');
+})().catch((err) => { console.error(err); process.exitCode = 1; });