openvibers/openvibe.actor Commits
Actor: the runtime acts on the person's own OpenVibe.Bot robots (list_my_robots, robot_steps; only where the owner opened the robot to their agent) (#47)
Changes
@@@ -100,6 +100,7 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu- **OpenVibe.Network** (`OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`): sign-in (OAuth2 + PKCE, client `actor`), the JWKS that verifies session and app tokens, and the internal routes (with this service's own client-credentials token) where account export parts and deletion confirmations are pushed.- **OpenVibe.Search** (`ACTOR_SEARCH_URL`): content across the network, for the runtime's `search_openvibe` tool.- **OpenVibe.Tools** (`ACTOR_TOOLS_URL`): the catalog, the run API and the page reader, for `find_tools`, `run_tool` and `read_page`. Called as any anonymous caller would.++- **OpenVibe.Bot** (`ACTOR_BOT_URL`, default `http://127.0.0.1:4630`; empty turns it off): a signed-in person's own robots, for `list_my_robots` and `robot_steps`. Actor calls Bot with its own client-credentials token (audience `openvibe.bot`, `bot.robot.read` + `bot.robot.control`) and `X-OV-Subject` naming the person. Bot answers only for the robot's owner, and only on robots where that owner turned on "Let my OpenVibe.Actor agent drive this robot". Bot's gate, limits and e-stop judge every step. App, agent and service tasks never see these tools.- **OpenVibe.Events** (`ACTOR_EVENTS_URL`): the two ADR-033 subscriptions created at boot, and delivery to the loopback `/internal/events`.- **The model providers**: DeepSeek (`ACTOR_DEEPSEEK_API_KEY`, `api.deepseek.com`), OpenAI (`ACTOR_OPENAI_API_KEY`, `api.openai.com/v1`) for the web-search agent and the cross-family check, and an OpenAI-compatible local server (`ACTOR_LOCAL_LLM_URL`) for private mode.@@@ -44,6 +44,7 @@"features": ["agent router (OpenRouter for agents)","OpenVibe runtime acting through OpenVibe services",++ "the person's own robots (agents/robots.js): list_my_robots + robot_steps on OpenVibe.Bot for a signed-in person's task only, with Actor's service token (bot.robot.read/control) and X-OV-Subject = the person; Bot allows it only on robots whose owner turned on agent_control","other platforms' agents","private mode on OpenVibe hardware","cross-family answer checks",@@@ -6,7 +6,8 @@* task's cost and the person's spend are true even when a run is cancelled half-way. `budgetLeft()` is checked* before every paid call; an adapter that would go over stops and says so.*-- * openvibe-runtime DeepSeek with OpenVibe's services as tools (agents/tools.js), up to MAX_TURNS model calls++ * openvibe-runtime DeepSeek with OpenVibe's services as tools (agents/tools.js), up to MAX_TURNS model calls; a++ * signed-in person's task also gets their own robots (agents/robots.js)* openai-agent OpenAI's Responses agent with its hosted web search* open-model the open model on OpenVibe's own server, one call, no tools*/@@@ -17,8 +18,9 @@ const MAX_TURNS = 10;const today = () => new Date().toISOString().slice(0, 10);--const RUNTIME_SYSTEM = () => `You are OpenVibe Actor's runtime: an agent that completes a person's task by acting through OpenVibe's services.--Today is ${today()}. Your tools are OpenVibe's public APIs: search_openvibe (content on the OpenVibe network), find_tools then run_tool (OpenVibe.Tools: DNS, mail records, certificates, HTTP headers, WHOIS, conversions and 150 more), and read_page when it is offered.++const RUNTIME_SYSTEM = (robots = false) => `You are OpenVibe Actor's runtime: an agent that completes a person's task by acting through OpenVibe's services.++Today is ${today()}. Your tools are OpenVibe's public APIs: search_openvibe (content on the OpenVibe network), find_tools then run_tool (OpenVibe.Tools: DNS, mail records, certificates, HTTP headers, WHOIS, conversions and 150 more), and read_page when it is offered.${robots ? `++The person's own robots on OpenVibe.Bot: list_my_robots, then robot_steps with step lines that robot accepts. Act on a robot only when the task asks for it, keep motions short, and report what the robot answered (done, or stopped and why). If a robot is not opened to you, say how the person opens it.` : ''}Rules: use a tool whenever a fact can be checked with one instead of guessing; never invent a tool result, a URL or a number; if something cannot be found, say so plainly.Finish with the answer itself in Markdown, short and direct, then a "Sources" list of the links or tools you relied on (tool results as "OpenVibe.Tools <tool id>").`;@@@ -28,7 +30,7 @@ Never invent facts, numbers or links; if something cannot be found, say so.`;const summarize = (args) => {const a = args || {};-- const v = a.query || a.need || a.url || (a.tool ? `${a.tool} ${JSON.stringify(a.input || {})}` : JSON.stringify(a));++ const v = a.query || a.need || a.url || (a.tool ? `${a.tool} ${JSON.stringify(a.input || {})}` : a.robot ? `${a.robot}: ${[].concat(a.steps || []).join(', ')}` : JSON.stringify(a));return String(v).slice(0, 300);};@@@ -42,10 +44,11 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = conconst openaiAgent = p.openai.apiKey ? responsesAgent({ baseUrl: p.openai.baseUrl, apiKey: p.openai.apiKey, model: p.openai.model, rates: rates.openai, fetchImpl }) : null;const local = p.local.url && p.local.model ? chatClient({ baseUrl: p.local.url, apiKey: '', model: p.local.model, rates: rates.local, fetchImpl, timeoutMs: 120_000 }) : null;-- async function runtime({ task, signal, emit, meter, budgetLeft }) {++ async function runtime({ task, requester = null, signal, emit, meter, budgetLeft }) {if (!deepseek) throw new ProviderError('the runtime has no model configured', { code: 'actor.agent.unavailable' });-- const tools = await ovTools.definitions({ signal });-- const messages = [{ role: 'system', content: RUNTIME_SYSTEM() }, { role: 'user', content: task }];++ const tools = await ovTools.definitions({ signal, requester });++ const robots = tools.some((d) => d.function && d.function.name === 'robot_steps');++ const messages = [{ role: 'system', content: RUNTIME_SYSTEM(robots) }, { role: 'user', content: task }];let last = '';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' });@@@ -62,7 +65,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 });++ const out = await ovTools.call(name, args, { signal, requester });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 });nnew file mode 100644@@@ -0,0 +1,75 @@++'use strict';++++/**++ * The person's own robots on OpenVibe.Bot, as two runtime tools (plan T15/T17: an Actor agent pilots a robot under the++ * owner's grant, always through Bot). Actor calls Bot's public API with its own service token (audience openvibe.bot,++ * bot.robot.read + bot.robot.control) and X-OV-Subject naming the person the task is for. Bot answers only for the++ * robot's owner, and only where that owner turned on "Let my OpenVibe.Actor agent drive this robot"; its gate, limits,++ * cooldowns and e-stop judge every step, and its audit records each one as svc:actor.++ *++ * createRobots({ config, fetchImpl, tokens? }) → { enabled, mine(subject), steps(subject, robotId, lines) }++ * mine → [{ id, name, model, agent_control, online?, estop?, battery?, sensors?, steps?[], panel }]++ * steps → { result: 'done' | 'stopped: …', steps } or { error }++ *++ * Off (enabled false) without ACTOR_BOT_URL or without Actor's client secret.++ */++const { serviceAuth } = require('openvibe-contracts');++++const ROBOT_ID = /^rob_[0-9A-HJKMNP-TV-Z]{26}$/;++const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++++function createRobots({ config, fetchImpl = globalThis.fetch, tokens: tokensOverride = null }) {++ const base = config.services.botUrl;++ const pages = config.services.botPublicUrl || base;++ const enabled = !!(base && (tokensOverride || config.oauth.clientSecret));++ const tokens = !enabled ? null : tokensOverride || serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.bot', scope: 'bot.robot.read bot.robot.control', fetchImpl,++ });++++ async function call(method, path, subject, { body, signal, timeoutMs = 15_000 } = {}) {++ const sig = signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);++ const res = await fetchImpl(`${base}/api/v1${path}`, {++ method,++ headers: { Accept: 'application/json', 'X-OV-Subject': subject, ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokens.authHeaders()) },++ body: body ? JSON.stringify(body) : undefined, signal: sig,++ });++ const data = await res.json().catch(() => null);++ return { status: res.status, ok: res.ok, data };++ }++ const problem = (r, what) => ({ error: (r.data && (r.data.detail || r.data.title)) || `Bot answered ${r.status} for ${what}`, code: (r.data && r.data.code) || null });++++ async function mine(subject, { signal } = {}) {++ if (!USER_SUBJECT.test(subject)) return { error: 'robots are only for a signed-in person' };++ const r = await call('GET', `/robots?owner=${encodeURIComponent(subject)}`, subject, { signal });++ if (!r.ok) return problem(r, 'your robots');++ const out = [];++ for (const robot of (r.data && r.data.robots) || []) {++ const item = { id: robot.id, name: robot.name, model: robot.profile_id, agent_control: !!robot.agent_control, panel: `${pages}/panel/${robot.id}` };++ if (!robot.agent_control) {++ item.note = `Not open to the agent. The owner can turn on "Let my OpenVibe.Actor agent drive this robot" at ${pages}/panel/${robot.id}#agent`;++ } else {++ const s = await call('GET', `/robots/${robot.id}/steps`, subject, { signal });++ if (s.ok && s.data) Object.assign(item, { online: s.data.online, estop: s.data.estop, battery: s.data.battery, sensors: s.data.sensors, steps: s.data.steps, max_steps: s.data.max_steps, max_seconds: s.data.max_seconds });++ else item.note = problem(s, 'the robot').error;++ }++ out.push(item);++ }++ return out;++ }++++ async function steps(subject, robotId, lines, { signal } = {}) {++ if (!USER_SUBJECT.test(subject)) return { error: 'robots are only for a signed-in person' };++ if (!ROBOT_ID.test(String(robotId || ''))) return { error: 'robot must be a robot id from list_my_robots (rob_…)' };++ const text = Array.isArray(lines) ? lines.map(String).join('\n') : String(lines || '');++ if (!text.trim()) return { error: 'steps are one or more lines, for example "forward 1" and "say Hello"' };++ // The robot runs the steps before Bot answers: up to 30 s of steps, plus room for the device's replies.++ const r = await call('POST', `/robots/${robotId}/steps`, subject, { body: { steps: text.slice(0, 2000) }, signal, timeoutMs: 45_000 });++ if (!r.ok) return problem(r, 'the steps');++ return { result: r.data.result, steps: r.data.steps };++ }++++ return { enabled, mine, steps };++}++++module.exports = { createRobots };@@@ -9,19 +9,28 @@* run_tool(tool, input) OpenVibe.Tools' run API (POST /api/v1/tools/<id>/run): DNS, mail records, certificates,* headers, WHOIS, conversions, hashes, … — only tools the catalog marks callable by anyone* read_page(url) OpenVibe.Tools' page reader, when Tools offers it: a web page as text++ * list_my_robots() OpenVibe.Bot: the person's own robots, and for each one they opened to their agent its++ * state and the step lines it takes (agents/robots.js) — a signed-in person's task only++ * robot_steps(robot, steps) OpenVibe.Bot: run step lines on one of those robots, as the person, through Bot's gate** Tools' own guard decides what a tool may fetch (public addresses only, per-target limits); Actor adds nothing to* what an anonymous caller could already do, and every result is clipped before it goes back to the model.*/++const { createRobots } = require('./robots');++const CATALOG_TTL_MS = 10 * 60_000;const RESULT_CHARS = 6000;const clip = (s, n = RESULT_CHARS) => (s.length > n ? `${s.slice(0, n)}\n…(clipped)` : s);const json = (v) => clip(JSON.stringify(v));--function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {++/** The person a task is for, when it is a signed-in person ('user:usr_…' → 'usr_…'), else null. */++const personOf = (requester) => (typeof requester === 'string' && requester.startsWith('user:') ? requester.slice(5) : null);++++function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console, robots = null }) {const { searchUrl, toolsUrl } = config.services;++ const bot = robots || createRobots({ config, fetchImpl });let catalog = null;let catalogAt = 0;const schemas = new Map();@@@ -91,7 +100,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 } = {}) {++ async function definitions({ signal, requester = null } = {}) {const list = await tools({ signal });const defs = [{ type: 'function', function: { name: 'search_openvibe', description: 'Search everything published on the OpenVibe network (streams, clips, pastes, community posts, wiki pages, blog posts, threads). Returns titles, summaries and links.', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } } },@@@ -101,12 +110,19 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>if (list.some((t) => t.id === 'read')) {defs.push({ type: 'function', function: { name: 'read_page', description: 'Read a public web page as text (title, description, main text, links). Use it for URLs the task gives or that a result links to.', parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } } });}++ if (bot.enabled && personOf(requester)) defs.push(...ROBOT_DEFS);return defs;}-- async function call(name, args, { signal } = {}) {++ async function call(name, args, { signal, requester = null } = {}) {const a = args && typeof args === 'object' ? args : {};try {++ if (name === 'list_my_robots' || name === 'robot_steps') {++ const person = personOf(requester);++ if (!bot.enabled || !person) return json({ error: 'robots are only for a signed-in person\'s task' });++ if (name === 'list_my_robots') return json(await bot.mine(person, { signal }));++ return json(await bot.steps(person, a.robot, a.steps, { signal }));++ }if (name === 'search_openvibe') return json(await searchOpenVibe(a.query, { 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 }));@@@ -118,7 +134,12 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>}}-- return { definitions, call, tools, findTools, runTool, searchOpenVibe };++ return { definitions, call, tools, findTools, runTool, searchOpenVibe, robots: bot };}--module.exports = { createOpenVibeTools };++const ROBOT_DEFS = [++ { type: 'function', function: { name: 'list_my_robots', description: 'List the robots the person owns on OpenVibe.Bot. For each robot they opened to their agent: whether it is online, the e-stop, battery, sensor readings and the step lines it accepts. A robot not opened to the agent says how the person can open it.', parameters: { type: 'object', properties: {} } } },++ { type: 'function', function: { name: 'robot_steps', description: 'Make one of the person\'s robots act, by step lines it accepts (from list_my_robots), for example ["forward 1", "left 0.5", "say Hello", "headlights #00ff00", "wait 1", "stop"]. At most 20 steps and 30 seconds per call. Answers "done" or "stopped: <why>" once they end.', parameters: { type: 'object', properties: { robot: { type: 'string', description: 'the robot id (rob_…)' }, steps: { type: 'array', items: { type: 'string' } } }, required: ['robot', 'steps'] } } },++];++++module.exports = { createOpenVibeTools, personOf };@@@ -65,6 +65,9 @@ function load(env = process.env) {services: {searchUrl: trim(env.ACTOR_SEARCH_URL || 'https://search.openvibe.network'),toolsUrl: trim(env.ACTOR_TOOLS_URL || 'https://openvibe.tools'),++ // OpenVibe.Bot: the person's own robots (list_my_robots, robot_steps). Empty turns the robot tools off.++ botUrl: trim(env.ACTOR_BOT_URL != null ? env.ACTOR_BOT_URL : 'http://127.0.0.1:4630'),++ botPublicUrl: trim(env.ACTOR_BOT_PUBLIC_URL || 'https://openvibe.bot'),},// What one person may spend without paying (plan T17: "free tier from the bounded allowance"), and the@@@ -113,7 +113,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =await emit(id, { kind: 'output', step: attempt > 1 ? 'handoff' : 'plan', agent: agentId, text: `${agent.name} takes it${placement.reasons && placement.reasons[0] ? `: ${placement.reasons[0]}` : ''}.` });let out;try {-- out = await adapters.run[agentId]({ task: row.task, cls, mode: row.mode, signal, meter, budgetLeft, emit: (e) => emit(id, { kind: 'output', agent: agentId, ...e }) });++ out = await adapters.run[agentId]({ task: row.task, requester: row.requester, cls, mode: row.mode, signal, meter, budgetLeft, emit: (e) => emit(id, { kind: 'output', agent: agentId, ...e }) });} catch (err) {if (signal.aborted) throw err;const why = (err && err.message) || String(err);nnew file mode 100644@@@ -0,0 +1,87 @@++'use strict';++// The person's own robots as runtime tools (plan T15/T17, agents/robots.js): only a signed-in person's task sees++// list_my_robots and robot_steps; Actor calls OpenVibe.Bot with its own service token and X-OV-Subject naming that++// person; a robot not opened to the agent says how to open it; Bot's refusals come back to the model as errors.++const assert = require('assert');++const { createOpenVibeTools, personOf } = require('../server/agents/tools');++const { createRobots } = require('../server/agents/robots');++const { check, done } = require('./helpers/boot');++++++const ME = 'usr_01M4W5JBFEG3QG9ER8W1A2K0RX';++const OPEN = 'rob_01M4W5JBFDG2TJ8C4FRDMCWBZ9';++const CLOSED = 'rob_01M4W5JBFDG2TJ8C4FRDMCWBZ8';++const config = {++ networkInternalUrl: 'http://network.test', oauth: { clientId: 'actor', clientSecret: 'actor-secret' },++ services: { searchUrl: 'http://search.test', toolsUrl: 'http://tools.test', botUrl: 'http://bot.test', botPublicUrl: 'https://openvibe.bot' },++};++++/** A fake OpenVibe.Bot: the owner's two robots, one opened to the agent; records every call. */++function fakeBot() {++ const calls = [];++ const reply = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });++ const fetchImpl = async (url, init = {}) => {++ const u = new URL(url);++ const headers = init.headers || {};++ calls.push({ method: init.method || 'GET', path: u.pathname + u.search, subject: headers['X-OV-Subject'], auth: headers.Authorization, body: init.body ? JSON.parse(init.body) : null });++ if (u.host === 'tools.test') return reply(200, { tools: [] });++ if (u.pathname === '/api/v1/robots') return reply(200, { robots: [{ id: OPEN, name: 'Desk rover', profile_id: 'sim.rover', agent_control: true }, { id: CLOSED, name: 'Cozmo', profile_id: 'cozmo', agent_control: false }] });++ if (u.pathname === `/api/v1/robots/${OPEN}/steps` && (init.method || 'GET') === 'GET') return reply(200, { robot_id: OPEN, online: true, estop: { latched: false }, battery: 0.8, sensors: { distance_cm: 42 }, steps: ['forward 2', 'stop'], max_steps: 20, max_seconds: 30 });++ if (u.pathname === `/api/v1/robots/${OPEN}/steps`) return reply(200, { robot_id: OPEN, result: 'done', steps: 2 });++ if (u.pathname === `/api/v1/robots/${CLOSED}/steps`) return reply(403, { code: 'bot.agent_not_allowed', detail: 'the owner has not let their agent drive this robot' });++ return reply(404, { code: 'not_found' });++ };++ return { calls, fetchImpl };++}++const tokens = { authHeaders: async () => ({ Authorization: 'Bearer svc-token' }) };++++(async () => {++ await check('only a signed-in person\'s task gets the robot tools', async () => {++ const bot = fakeBot();++ const t = createOpenVibeTools({ config, fetchImpl: bot.fetchImpl, robots: createRobots({ config, fetchImpl: bot.fetchImpl, tokens }) });++ const names = async (requester) => (await t.definitions({ requester })).map((d) => d.function.name);++ assert.ok((await names(`user:${ME}`)).includes('robot_steps') && (await names(`user:${ME}`)).includes('list_my_robots'));++ for (const r of ['app:app_x', 'agent:agt_x', 'service:live', null]) assert.ok(!(await names(r)).includes('robot_steps'), String(r));++ assert.strictEqual(personOf(`user:${ME}`), ME);++ assert.strictEqual(personOf('app:app_x'), null);++ const refused = JSON.parse(await t.call('robot_steps', { robot: OPEN, steps: ['forward 1'] }, { requester: 'app:app_x' }));++ assert.match(refused.error, /signed-in person/);++ assert.ok(!bot.calls.some((c) => c.path.includes('/robots')), 'nothing reached Bot for an app');++ });++++ await check('list_my_robots: the person\'s robots, state and steps for the opened one, how to open the other', async () => {++ const bot = fakeBot();++ const t = createOpenVibeTools({ config, fetchImpl: bot.fetchImpl, robots: createRobots({ config, fetchImpl: bot.fetchImpl, tokens }) });++ const list = JSON.parse(await t.call('list_my_robots', {}, { requester: `user:${ME}` }));++ assert.strictEqual(list.length, 2);++ assert.deepStrictEqual([list[0].online, list[0].steps, list[0].sensors.distance_cm], [true, ['forward 2', 'stop'], 42]);++ assert.match(list[1].note, /Let my OpenVibe\.Actor agent drive this robot.*https:\/\/openvibe\.bot\/panel\/rob_\w+#agent/);++ assert.ok(bot.calls.filter((c) => c.path.startsWith('/api/v1/robots')).every((c) => c.subject === ME && c.auth === 'Bearer svc-token'), 'as the person, with Actor\'s token');++ assert.ok(bot.calls.some((c) => c.path === `/api/v1/robots?owner=${ME}`));++ assert.ok(!bot.calls.some((c) => c.path === `/api/v1/robots/${CLOSED}/steps`), 'a closed robot is not asked');++ });++++ await check('robot_steps: step lines go to Bot as the person; a refusal comes back as an error; bad input never leaves', async () => {++ const bot = fakeBot();++ const t = createOpenVibeTools({ config, fetchImpl: bot.fetchImpl, robots: createRobots({ config, fetchImpl: bot.fetchImpl, tokens }) });++ const ok = JSON.parse(await t.call('robot_steps', { robot: OPEN, steps: ['forward 1', 'stop'] }, { requester: `user:${ME}` }));++ assert.deepStrictEqual(ok, { result: 'done', steps: 2 });++ const sent = bot.calls.find((c) => c.method === 'POST');++ assert.deepStrictEqual([sent.path, sent.subject, sent.body], [`/api/v1/robots/${OPEN}/steps`, ME, { steps: 'forward 1\nstop' }]);++ const no = JSON.parse(await t.call('robot_steps', { robot: CLOSED, steps: ['forward 1'] }, { requester: `user:${ME}` }));++ assert.deepStrictEqual([no.code, /not let their agent/.test(no.error)], ['bot.agent_not_allowed', true]);++ const before = bot.calls.length;++ assert.match(JSON.parse(await t.call('robot_steps', { robot: '../admin', steps: ['x'] }, { requester: `user:${ME}` })).error, /robot id/);++ assert.match(JSON.parse(await t.call('robot_steps', { robot: OPEN, steps: [] }, { requester: `user:${ME}` })).error, /one or more lines/);++ assert.strictEqual(bot.calls.length, before, 'neither reached Bot');++ });++++ await check('off without a Bot URL or a client secret', async () => {++ assert.strictEqual(createRobots({ config: { ...config, services: { ...config.services, botUrl: '' } } }).enabled, false);++ assert.strictEqual(createRobots({ config: { ...config, oauth: { clientId: 'actor', clientSecret: '' } } }).enabled, false);++ const t = createOpenVibeTools({ config: { ...config, services: { ...config.services, botUrl: '' } }, fetchImpl: fakeBot().fetchImpl });++ assert.ok(!(await t.definitions({ requester: `user:${ME}` })).some((d) => d.function.name === 'robot_steps'));++ });++++ done();++})();@@@ -106,6 +106,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };'server/auth/keys.js': 'no request itself; openvibe-sdk/auth fetches the JWKS from the configured Network','server/auth/sso.js': 'Network OAuth token and revoke (configured networkInternalUrl)','server/agents/providers.js': 'the configured provider base URLs (DeepSeek, OpenAI, the open model) with the operator\'s keys',++ 'server/agents/robots.js': 'the configured OpenVibe.Bot base (ACTOR_BOT_URL) with Actor\'s own service token; the robot id must be rob_<ULID> and the subject usr_<ULID> before anything is sent (test/robots.test.js)','server/agents/tools.js': 'the configured OpenVibe.Tools and OpenVibe.Search bases; ids come from Tools\' catalog, a page URL goes to Tools as input','server/events-consumer.js': 'OpenVibe.Events: the configured events.url (ACTOR_EVENTS_URL), to create the two ADR-033 subscriptions at boot','server/net/webhook-post.js': 'a task\'s webhook URL (plan T17), the one caller-chosen address: https on 443/8443 to public addresses only, checked at creation and at send time through openvibe-shared/egress safeLookup, never a redirect (test/webhooks.test.js)',
@@ -100,6 +100,7 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
@@ -100,6 +100,7 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
- **OpenVibe.Network** (`OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`): sign-in (OAuth2 + PKCE, client `actor`), the JWKS that verifies session and app tokens, and the internal routes (with this service's own client-credentials token) where account export parts and deletion confirmations are pushed.
- **OpenVibe.Network** (`OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`): sign-in (OAuth2 + PKCE, client `actor`), the JWKS that verifies session and app tokens, and the internal routes (with this service's own client-credentials token) where account export parts and deletion confirmations are pushed.
- **OpenVibe.Search** (`ACTOR_SEARCH_URL`): content across the network, for the runtime's `search_openvibe` tool.
- **OpenVibe.Search** (`ACTOR_SEARCH_URL`): content across the network, for the runtime's `search_openvibe` tool.
- **OpenVibe.Tools** (`ACTOR_TOOLS_URL`): the catalog, the run API and the page reader, for `find_tools`, `run_tool` and `read_page`. Called as any anonymous caller would.
- **OpenVibe.Tools** (`ACTOR_TOOLS_URL`): the catalog, the run API and the page reader, for `find_tools`, `run_tool` and `read_page`. Called as any anonymous caller would.
+- **OpenVibe.Bot** (`ACTOR_BOT_URL`, default `http://127.0.0.1:4630`; empty turns it off): a signed-in person's own robots, for `list_my_robots` and `robot_steps`. Actor calls Bot with its own client-credentials token (audience `openvibe.bot`, `bot.robot.read` + `bot.robot.control`) and `X-OV-Subject` naming the person. Bot answers only for the robot's owner, and only on robots where that owner turned on "Let my OpenVibe.Actor agent drive this robot". Bot's gate, limits and e-stop judge every step. App, agent and service tasks never see these tools.
- **OpenVibe.Events** (`ACTOR_EVENTS_URL`): the two ADR-033 subscriptions created at boot, and delivery to the loopback `/internal/events`.
- **OpenVibe.Events** (`ACTOR_EVENTS_URL`): the two ADR-033 subscriptions created at boot, and delivery to the loopback `/internal/events`.
- **The model providers**: DeepSeek (`ACTOR_DEEPSEEK_API_KEY`, `api.deepseek.com`), OpenAI (`ACTOR_OPENAI_API_KEY`, `api.openai.com/v1`) for the web-search agent and the cross-family check, and an OpenAI-compatible local server (`ACTOR_LOCAL_LLM_URL`) for private mode.
- **The model providers**: DeepSeek (`ACTOR_DEEPSEEK_API_KEY`, `api.deepseek.com`), OpenAI (`ACTOR_OPENAI_API_KEY`, `api.openai.com/v1`) for the web-search agent and the cross-family check, and an OpenAI-compatible local server (`ACTOR_LOCAL_LLM_URL`) for private mode.
@@ -44,6 +44,7 @@
@@ -44,6 +44,7 @@
"features": [
"features": [
"agent router (OpenRouter for agents)",
"agent router (OpenRouter for agents)",
"OpenVibe runtime acting through OpenVibe services",
"OpenVibe runtime acting through OpenVibe services",
+ "the person's own robots (agents/robots.js): list_my_robots + robot_steps on OpenVibe.Bot for a signed-in person's task only, with Actor's service token (bot.robot.read/control) and X-OV-Subject = the person; Bot allows it only on robots whose owner turned on agent_control",
"other platforms' agents",
"other platforms' agents",
"private mode on OpenVibe hardware",
"private mode on OpenVibe hardware",
"cross-family answer checks",
"cross-family answer checks",
@@ -6,7 +6,8 @@
@@ -6,7 +6,8 @@
* task's cost and the person's spend are true even when a run is cancelled half-way. `budgetLeft()` is checked
* task's cost and the person's spend are true even when a run is cancelled half-way. `budgetLeft()` is checked
* before every paid call; an adapter that would go over stops and says so.
* before every paid call; an adapter that would go over stops and says so.
*
*
- * openvibe-runtime DeepSeek with OpenVibe's services as tools (agents/tools.js), up to MAX_TURNS model calls
+ * openvibe-runtime DeepSeek with OpenVibe's services as tools (agents/tools.js), up to MAX_TURNS model calls; a
+ * signed-in person's task also gets their own robots (agents/robots.js)
* openai-agent OpenAI's Responses agent with its hosted web search
* openai-agent OpenAI's Responses agent with its hosted web search
* open-model the open model on OpenVibe's own server, one call, no tools
* open-model the open model on OpenVibe's own server, one call, no tools
*/
*/
@@ -17,8 +18,9 @@ const MAX_TURNS = 10;
@@ -17,8 +18,9 @@ const MAX_TURNS = 10;
const today = () => new Date().toISOString().slice(0, 10);
const today = () => new Date().toISOString().slice(0, 10);
-const RUNTIME_SYSTEM = () => `You are OpenVibe Actor's runtime: an agent that completes a person's task by acting through OpenVibe's services.
-Today is ${today()}. Your tools are OpenVibe's public APIs: search_openvibe (content on the OpenVibe network), find_tools then run_tool (OpenVibe.Tools: DNS, mail records, certificates, HTTP headers, WHOIS, conversions and 150 more), and read_page when it is offered.
+const RUNTIME_SYSTEM = (robots = false) => `You are OpenVibe Actor's runtime: an agent that completes a person's task by acting through OpenVibe's services.
+Today is ${today()}. Your tools are OpenVibe's public APIs: search_openvibe (content on the OpenVibe network), find_tools then run_tool (OpenVibe.Tools: DNS, mail records, certificates, HTTP headers, WHOIS, conversions and 150 more), and read_page when it is offered.${robots ? `
+The person's own robots on OpenVibe.Bot: list_my_robots, then robot_steps with step lines that robot accepts. Act on a robot only when the task asks for it, keep motions short, and report what the robot answered (done, or stopped and why). If a robot is not opened to you, say how the person opens it.` : ''}
Rules: use a tool whenever a fact can be checked with one instead of guessing; never invent a tool result, a URL or a number; if something cannot be found, say so plainly.
Rules: use a tool whenever a fact can be checked with one instead of guessing; never invent a tool result, a URL or a number; if something cannot be found, say so plainly.
Finish with the answer itself in Markdown, short and direct, then a "Sources" list of the links or tools you relied on (tool results as "OpenVibe.Tools <tool id>").`;
Finish with the answer itself in Markdown, short and direct, then a "Sources" list of the links or tools you relied on (tool results as "OpenVibe.Tools <tool id>").`;
@@ -28,7 +30,7 @@ Never invent facts, numbers or links; if something cannot be found, say so.`;
@@ -28,7 +30,7 @@ Never invent facts, numbers or links; if something cannot be found, say so.`;
const summarize = (args) => {
const summarize = (args) => {
const a = args || {};
const a = args || {};
- const v = a.query || a.need || a.url || (a.tool ? `${a.tool} ${JSON.stringify(a.input || {})}` : JSON.stringify(a));
+ const v = a.query || a.need || a.url || (a.tool ? `${a.tool} ${JSON.stringify(a.input || {})}` : a.robot ? `${a.robot}: ${[].concat(a.steps || []).join(', ')}` : JSON.stringify(a));
return String(v).slice(0, 300);
return String(v).slice(0, 300);
};
};
@@ -42,10 +44,11 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
@@ -42,10 +44,11 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
const openaiAgent = p.openai.apiKey ? responsesAgent({ baseUrl: p.openai.baseUrl, apiKey: p.openai.apiKey, model: p.openai.model, rates: rates.openai, fetchImpl }) : null;
const openaiAgent = p.openai.apiKey ? responsesAgent({ baseUrl: p.openai.baseUrl, apiKey: p.openai.apiKey, model: p.openai.model, rates: rates.openai, fetchImpl }) : null;
const local = p.local.url && p.local.model ? chatClient({ baseUrl: p.local.url, apiKey: '', model: p.local.model, rates: rates.local, fetchImpl, timeoutMs: 120_000 }) : null;
const local = p.local.url && p.local.model ? chatClient({ baseUrl: p.local.url, apiKey: '', model: p.local.model, rates: rates.local, fetchImpl, timeoutMs: 120_000 }) : null;
- async function runtime({ task, signal, emit, meter, budgetLeft }) {
+ async function runtime({ task, requester = null, signal, emit, meter, budgetLeft }) {
if (!deepseek) throw new ProviderError('the runtime has no model configured', { code: 'actor.agent.unavailable' });
if (!deepseek) throw new ProviderError('the runtime has no model configured', { code: 'actor.agent.unavailable' });
- const tools = await ovTools.definitions({ signal });
- const messages = [{ role: 'system', content: RUNTIME_SYSTEM() }, { role: 'user', content: task }];
+ const tools = await ovTools.definitions({ signal, requester });
+ const robots = tools.some((d) => d.function && d.function.name === 'robot_steps');
+ const messages = [{ role: 'system', content: RUNTIME_SYSTEM(robots) }, { role: 'user', content: task }];
let last = '';
let last = '';
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' });
@@ -62,7 +65,7 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
@@ -62,7 +65,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 });
+ const out = await ovTools.call(name, args, { signal, requester });
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 });
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+'use strict';
+
+/**
+ * The person's own robots on OpenVibe.Bot, as two runtime tools (plan T15/T17: an Actor agent pilots a robot under the
+ * owner's grant, always through Bot). Actor calls Bot's public API with its own service token (audience openvibe.bot,
+ * bot.robot.read + bot.robot.control) and X-OV-Subject naming the person the task is for. Bot answers only for the
+ * robot's owner, and only where that owner turned on "Let my OpenVibe.Actor agent drive this robot"; its gate, limits,
+ * cooldowns and e-stop judge every step, and its audit records each one as svc:actor.
+ *
+ * createRobots({ config, fetchImpl, tokens? }) → { enabled, mine(subject), steps(subject, robotId, lines) }
+ * mine → [{ id, name, model, agent_control, online?, estop?, battery?, sensors?, steps?[], panel }]
+ * steps → { result: 'done' | 'stopped: …', steps } or { error }
+ *
+ * Off (enabled false) without ACTOR_BOT_URL or without Actor's client secret.
+ */
+const { serviceAuth } = require('openvibe-contracts');
+
+const ROBOT_ID = /^rob_[0-9A-HJKMNP-TV-Z]{26}$/;
+const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+function createRobots({ config, fetchImpl = globalThis.fetch, tokens: tokensOverride = null }) {
+ const base = config.services.botUrl;
+ const pages = config.services.botPublicUrl || base;
+ const enabled = !!(base && (tokensOverride || config.oauth.clientSecret));
+ const tokens = !enabled ? null : tokensOverride || serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.bot', scope: 'bot.robot.read bot.robot.control', fetchImpl,
+ });
+
+ async function call(method, path, subject, { body, signal, timeoutMs = 15_000 } = {}) {
+ const sig = signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
+ const res = await fetchImpl(`${base}/api/v1${path}`, {
+ method,
+ headers: { Accept: 'application/json', 'X-OV-Subject': subject, ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokens.authHeaders()) },
+ body: body ? JSON.stringify(body) : undefined, signal: sig,
+ });
+ const data = await res.json().catch(() => null);
+ return { status: res.status, ok: res.ok, data };
+ }
+ const problem = (r, what) => ({ error: (r.data && (r.data.detail || r.data.title)) || `Bot answered ${r.status} for ${what}`, code: (r.data && r.data.code) || null });
+
+ async function mine(subject, { signal } = {}) {
+ if (!USER_SUBJECT.test(subject)) return { error: 'robots are only for a signed-in person' };
+ const r = await call('GET', `/robots?owner=${encodeURIComponent(subject)}`, subject, { signal });
+ if (!r.ok) return problem(r, 'your robots');
+ const out = [];
+ for (const robot of (r.data && r.data.robots) || []) {
+ const item = { id: robot.id, name: robot.name, model: robot.profile_id, agent_control: !!robot.agent_control, panel: `${pages}/panel/${robot.id}` };
+ if (!robot.agent_control) {
+ item.note = `Not open to the agent. The owner can turn on "Let my OpenVibe.Actor agent drive this robot" at ${pages}/panel/${robot.id}#agent`;
+ } else {
+ const s = await call('GET', `/robots/${robot.id}/steps`, subject, { signal });
+ if (s.ok && s.data) Object.assign(item, { online: s.data.online, estop: s.data.estop, battery: s.data.battery, sensors: s.data.sensors, steps: s.data.steps, max_steps: s.data.max_steps, max_seconds: s.data.max_seconds });
+ else item.note = problem(s, 'the robot').error;
+ }
+ out.push(item);
+ }
+ return out;
+ }
+
+ async function steps(subject, robotId, lines, { signal } = {}) {
+ if (!USER_SUBJECT.test(subject)) return { error: 'robots are only for a signed-in person' };
+ if (!ROBOT_ID.test(String(robotId || ''))) return { error: 'robot must be a robot id from list_my_robots (rob_…)' };
+ const text = Array.isArray(lines) ? lines.map(String).join('\n') : String(lines || '');
+ if (!text.trim()) return { error: 'steps are one or more lines, for example "forward 1" and "say Hello"' };
+ // The robot runs the steps before Bot answers: up to 30 s of steps, plus room for the device's replies.
+ const r = await call('POST', `/robots/${robotId}/steps`, subject, { body: { steps: text.slice(0, 2000) }, signal, timeoutMs: 45_000 });
+ if (!r.ok) return problem(r, 'the steps');
+ return { result: r.data.result, steps: r.data.steps };
+ }
+
+ return { enabled, mine, steps };
+}
+
+module.exports = { createRobots };
@@ -9,19 +9,28 @@
@@ -9,19 +9,28 @@
* run_tool(tool, input) OpenVibe.Tools' run API (POST /api/v1/tools/<id>/run): DNS, mail records, certificates,
* run_tool(tool, input) OpenVibe.Tools' run API (POST /api/v1/tools/<id>/run): DNS, mail records, certificates,
* headers, WHOIS, conversions, hashes, … — only tools the catalog marks callable by anyone
* headers, WHOIS, conversions, hashes, … — only tools the catalog marks callable by anyone
* read_page(url) OpenVibe.Tools' page reader, when Tools offers it: a web page as text
* read_page(url) OpenVibe.Tools' page reader, when Tools offers it: a web page as text
+ * list_my_robots() OpenVibe.Bot: the person's own robots, and for each one they opened to their agent its
+ * state and the step lines it takes (agents/robots.js) — a signed-in person's task only
+ * robot_steps(robot, steps) OpenVibe.Bot: run step lines on one of those robots, as the person, through Bot's gate
*
*
* Tools' own guard decides what a tool may fetch (public addresses only, per-target limits); Actor adds nothing to
* Tools' own guard decides what a tool may fetch (public addresses only, per-target limits); Actor adds nothing to
* what an anonymous caller could already do, and every result is clipped before it goes back to the model.
* what an anonymous caller could already do, and every result is clipped before it goes back to the model.
*/
*/
+const { createRobots } = require('./robots');
+
const CATALOG_TTL_MS = 10 * 60_000;
const CATALOG_TTL_MS = 10 * 60_000;
const RESULT_CHARS = 6000;
const RESULT_CHARS = 6000;
const clip = (s, n = RESULT_CHARS) => (s.length > n ? `${s.slice(0, n)}\n…(clipped)` : s);
const clip = (s, n = RESULT_CHARS) => (s.length > n ? `${s.slice(0, n)}\n…(clipped)` : s);
const json = (v) => clip(JSON.stringify(v));
const json = (v) => clip(JSON.stringify(v));
-function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
+/** The person a task is for, when it is a signed-in person ('user:usr_…' → 'usr_…'), else null. */
+const personOf = (requester) => (typeof requester === 'string' && requester.startsWith('user:') ? requester.slice(5) : null);
+
+function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console, robots = null }) {
const { searchUrl, toolsUrl } = config.services;
const { searchUrl, toolsUrl } = config.services;
+ const bot = robots || createRobots({ config, fetchImpl });
let catalog = null;
let catalog = null;
let catalogAt = 0;
let catalogAt = 0;
const schemas = new Map();
const schemas = new Map();
@@ -91,7 +100,7 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
@@ -91,7 +100,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 } = {}) {
+ async function definitions({ signal, requester = null } = {}) {
const list = await tools({ signal });
const list = await tools({ signal });
const defs = [
const defs = [
{ type: 'function', function: { name: 'search_openvibe', description: 'Search everything published on the OpenVibe network (streams, clips, pastes, community posts, wiki pages, blog posts, threads). Returns titles, summaries and links.', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } } },
{ type: 'function', function: { name: 'search_openvibe', description: 'Search everything published on the OpenVibe network (streams, clips, pastes, community posts, wiki pages, blog posts, threads). Returns titles, summaries and links.', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } } },
@@ -101,12 +110,19 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
@@ -101,12 +110,19 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
if (list.some((t) => t.id === 'read')) {
if (list.some((t) => t.id === 'read')) {
defs.push({ type: 'function', function: { name: 'read_page', description: 'Read a public web page as text (title, description, main text, links). Use it for URLs the task gives or that a result links to.', parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } } });
defs.push({ type: 'function', function: { name: 'read_page', description: 'Read a public web page as text (title, description, main text, links). Use it for URLs the task gives or that a result links to.', parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } } });
}
}
+ if (bot.enabled && personOf(requester)) defs.push(...ROBOT_DEFS);
return defs;
return defs;
}
}
- async function call(name, args, { signal } = {}) {
+ async function call(name, args, { signal, requester = null } = {}) {
const a = args && typeof args === 'object' ? args : {};
const a = args && typeof args === 'object' ? args : {};
try {
try {
+ if (name === 'list_my_robots' || name === 'robot_steps') {
+ const person = personOf(requester);
+ if (!bot.enabled || !person) return json({ error: 'robots are only for a signed-in person\'s task' });
+ if (name === 'list_my_robots') return json(await bot.mine(person, { signal }));
+ return json(await bot.steps(person, a.robot, a.steps, { 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 === '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 }));
@@ -118,7 +134,12 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
@@ -118,7 +134,12 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
}
}
}
}
- return { definitions, call, tools, findTools, runTool, searchOpenVibe };
+ return { definitions, call, tools, findTools, runTool, searchOpenVibe, robots: bot };
}
}
-module.exports = { createOpenVibeTools };
+const ROBOT_DEFS = [
+ { type: 'function', function: { name: 'list_my_robots', description: 'List the robots the person owns on OpenVibe.Bot. For each robot they opened to their agent: whether it is online, the e-stop, battery, sensor readings and the step lines it accepts. A robot not opened to the agent says how the person can open it.', parameters: { type: 'object', properties: {} } } },
+ { type: 'function', function: { name: 'robot_steps', description: 'Make one of the person\'s robots act, by step lines it accepts (from list_my_robots), for example ["forward 1", "left 0.5", "say Hello", "headlights #00ff00", "wait 1", "stop"]. At most 20 steps and 30 seconds per call. Answers "done" or "stopped: <why>" once they end.', parameters: { type: 'object', properties: { robot: { type: 'string', description: 'the robot id (rob_…)' }, steps: { type: 'array', items: { type: 'string' } } }, required: ['robot', 'steps'] } } },
+];
+
+module.exports = { createOpenVibeTools, personOf };
@@ -65,6 +65,9 @@ function load(env = process.env) {
@@ -65,6 +65,9 @@ function load(env = process.env) {
services: {
services: {
searchUrl: trim(env.ACTOR_SEARCH_URL || 'https://search.openvibe.network'),
searchUrl: trim(env.ACTOR_SEARCH_URL || 'https://search.openvibe.network'),
toolsUrl: trim(env.ACTOR_TOOLS_URL || 'https://openvibe.tools'),
toolsUrl: trim(env.ACTOR_TOOLS_URL || 'https://openvibe.tools'),
+ // OpenVibe.Bot: the person's own robots (list_my_robots, robot_steps). Empty turns the robot tools off.
+ botUrl: trim(env.ACTOR_BOT_URL != null ? env.ACTOR_BOT_URL : 'http://127.0.0.1:4630'),
+ botPublicUrl: trim(env.ACTOR_BOT_PUBLIC_URL || 'https://openvibe.bot'),
},
},
// What one person may spend without paying (plan T17: "free tier from the bounded allowance"), and the
// What one person may spend without paying (plan T17: "free tier from the bounded allowance"), and the
@@ -113,7 +113,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -113,7 +113,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
await emit(id, { kind: 'output', step: attempt > 1 ? 'handoff' : 'plan', agent: agentId, text: `${agent.name} takes it${placement.reasons && placement.reasons[0] ? `: ${placement.reasons[0]}` : ''}.` });
await emit(id, { kind: 'output', step: attempt > 1 ? 'handoff' : 'plan', agent: agentId, text: `${agent.name} takes it${placement.reasons && placement.reasons[0] ? `: ${placement.reasons[0]}` : ''}.` });
let out;
let out;
try {
try {
- out = await adapters.run[agentId]({ task: row.task, cls, mode: row.mode, signal, meter, budgetLeft, emit: (e) => emit(id, { kind: 'output', agent: agentId, ...e }) });
+ out = await adapters.run[agentId]({ task: row.task, requester: row.requester, cls, mode: row.mode, signal, meter, budgetLeft, emit: (e) => emit(id, { kind: 'output', agent: agentId, ...e }) });
} catch (err) {
} catch (err) {
if (signal.aborted) throw err;
if (signal.aborted) throw err;
const why = (err && err.message) || String(err);
const why = (err && err.message) || String(err);
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
+'use strict';
+// The person's own robots as runtime tools (plan T15/T17, agents/robots.js): only a signed-in person's task sees
+// list_my_robots and robot_steps; Actor calls OpenVibe.Bot with its own service token and X-OV-Subject naming that
+// person; a robot not opened to the agent says how to open it; Bot's refusals come back to the model as errors.
+const assert = require('assert');
+const { createOpenVibeTools, personOf } = require('../server/agents/tools');
+const { createRobots } = require('../server/agents/robots');
+const { check, done } = require('./helpers/boot');
+
+
+const ME = 'usr_01M4W5JBFEG3QG9ER8W1A2K0RX';
+const OPEN = 'rob_01M4W5JBFDG2TJ8C4FRDMCWBZ9';
+const CLOSED = 'rob_01M4W5JBFDG2TJ8C4FRDMCWBZ8';
+const config = {
+ networkInternalUrl: 'http://network.test', oauth: { clientId: 'actor', clientSecret: 'actor-secret' },
+ services: { searchUrl: 'http://search.test', toolsUrl: 'http://tools.test', botUrl: 'http://bot.test', botPublicUrl: 'https://openvibe.bot' },
+};
+
+/** A fake OpenVibe.Bot: the owner's two robots, one opened to the agent; records every call. */
+function fakeBot() {
+ const calls = [];
+ const reply = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+ const fetchImpl = async (url, init = {}) => {
+ const u = new URL(url);
+ const headers = init.headers || {};
+ calls.push({ method: init.method || 'GET', path: u.pathname + u.search, subject: headers['X-OV-Subject'], auth: headers.Authorization, body: init.body ? JSON.parse(init.body) : null });
+ if (u.host === 'tools.test') return reply(200, { tools: [] });
+ if (u.pathname === '/api/v1/robots') return reply(200, { robots: [{ id: OPEN, name: 'Desk rover', profile_id: 'sim.rover', agent_control: true }, { id: CLOSED, name: 'Cozmo', profile_id: 'cozmo', agent_control: false }] });
+ if (u.pathname === `/api/v1/robots/${OPEN}/steps` && (init.method || 'GET') === 'GET') return reply(200, { robot_id: OPEN, online: true, estop: { latched: false }, battery: 0.8, sensors: { distance_cm: 42 }, steps: ['forward 2', 'stop'], max_steps: 20, max_seconds: 30 });
+ if (u.pathname === `/api/v1/robots/${OPEN}/steps`) return reply(200, { robot_id: OPEN, result: 'done', steps: 2 });
+ if (u.pathname === `/api/v1/robots/${CLOSED}/steps`) return reply(403, { code: 'bot.agent_not_allowed', detail: 'the owner has not let their agent drive this robot' });
+ return reply(404, { code: 'not_found' });
+ };
+ return { calls, fetchImpl };
+}
+const tokens = { authHeaders: async () => ({ Authorization: 'Bearer svc-token' }) };
+
+(async () => {
+ await check('only a signed-in person\'s task gets the robot tools', async () => {
+ const bot = fakeBot();
+ const t = createOpenVibeTools({ config, fetchImpl: bot.fetchImpl, robots: createRobots({ config, fetchImpl: bot.fetchImpl, tokens }) });
+ const names = async (requester) => (await t.definitions({ requester })).map((d) => d.function.name);
+ assert.ok((await names(`user:${ME}`)).includes('robot_steps') && (await names(`user:${ME}`)).includes('list_my_robots'));
+ for (const r of ['app:app_x', 'agent:agt_x', 'service:live', null]) assert.ok(!(await names(r)).includes('robot_steps'), String(r));
+ assert.strictEqual(personOf(`user:${ME}`), ME);
+ assert.strictEqual(personOf('app:app_x'), null);
+ const refused = JSON.parse(await t.call('robot_steps', { robot: OPEN, steps: ['forward 1'] }, { requester: 'app:app_x' }));
+ assert.match(refused.error, /signed-in person/);
+ assert.ok(!bot.calls.some((c) => c.path.includes('/robots')), 'nothing reached Bot for an app');
+ });
+
+ await check('list_my_robots: the person\'s robots, state and steps for the opened one, how to open the other', async () => {
+ const bot = fakeBot();
+ const t = createOpenVibeTools({ config, fetchImpl: bot.fetchImpl, robots: createRobots({ config, fetchImpl: bot.fetchImpl, tokens }) });
+ const list = JSON.parse(await t.call('list_my_robots', {}, { requester: `user:${ME}` }));
+ assert.strictEqual(list.length, 2);
+ assert.deepStrictEqual([list[0].online, list[0].steps, list[0].sensors.distance_cm], [true, ['forward 2', 'stop'], 42]);
+ assert.match(list[1].note, /Let my OpenVibe\.Actor agent drive this robot.*https:\/\/openvibe\.bot\/panel\/rob_\w+#agent/);
+ assert.ok(bot.calls.filter((c) => c.path.startsWith('/api/v1/robots')).every((c) => c.subject === ME && c.auth === 'Bearer svc-token'), 'as the person, with Actor\'s token');
+ assert.ok(bot.calls.some((c) => c.path === `/api/v1/robots?owner=${ME}`));
+ assert.ok(!bot.calls.some((c) => c.path === `/api/v1/robots/${CLOSED}/steps`), 'a closed robot is not asked');
+ });
+
+ await check('robot_steps: step lines go to Bot as the person; a refusal comes back as an error; bad input never leaves', async () => {
+ const bot = fakeBot();
+ const t = createOpenVibeTools({ config, fetchImpl: bot.fetchImpl, robots: createRobots({ config, fetchImpl: bot.fetchImpl, tokens }) });
+ const ok = JSON.parse(await t.call('robot_steps', { robot: OPEN, steps: ['forward 1', 'stop'] }, { requester: `user:${ME}` }));
+ assert.deepStrictEqual(ok, { result: 'done', steps: 2 });
+ const sent = bot.calls.find((c) => c.method === 'POST');
+ assert.deepStrictEqual([sent.path, sent.subject, sent.body], [`/api/v1/robots/${OPEN}/steps`, ME, { steps: 'forward 1\nstop' }]);
+ const no = JSON.parse(await t.call('robot_steps', { robot: CLOSED, steps: ['forward 1'] }, { requester: `user:${ME}` }));
+ assert.deepStrictEqual([no.code, /not let their agent/.test(no.error)], ['bot.agent_not_allowed', true]);
+ const before = bot.calls.length;
+ assert.match(JSON.parse(await t.call('robot_steps', { robot: '../admin', steps: ['x'] }, { requester: `user:${ME}` })).error, /robot id/);
+ assert.match(JSON.parse(await t.call('robot_steps', { robot: OPEN, steps: [] }, { requester: `user:${ME}` })).error, /one or more lines/);
+ assert.strictEqual(bot.calls.length, before, 'neither reached Bot');
+ });
+
+ await check('off without a Bot URL or a client secret', async () => {
+ assert.strictEqual(createRobots({ config: { ...config, services: { ...config.services, botUrl: '' } } }).enabled, false);
+ assert.strictEqual(createRobots({ config: { ...config, oauth: { clientId: 'actor', clientSecret: '' } } }).enabled, false);
+ const t = createOpenVibeTools({ config: { ...config, services: { ...config.services, botUrl: '' } }, fetchImpl: fakeBot().fetchImpl });
+ assert.ok(!(await t.definitions({ requester: `user:${ME}` })).some((d) => d.function.name === 'robot_steps'));
+ });
+
+ done();
+})();
@@ -106,6 +106,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -106,6 +106,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
'server/auth/keys.js': 'no request itself; openvibe-sdk/auth fetches the JWKS from the configured Network',
'server/auth/keys.js': 'no request itself; openvibe-sdk/auth fetches the JWKS from the configured Network',
'server/auth/sso.js': 'Network OAuth token and revoke (configured networkInternalUrl)',
'server/auth/sso.js': 'Network OAuth token and revoke (configured networkInternalUrl)',
'server/agents/providers.js': 'the configured provider base URLs (DeepSeek, OpenAI, the open model) with the operator\'s keys',
'server/agents/providers.js': 'the configured provider base URLs (DeepSeek, OpenAI, the open model) with the operator\'s keys',
+ 'server/agents/robots.js': 'the configured OpenVibe.Bot base (ACTOR_BOT_URL) with Actor\'s own service token; the robot id must be rob_<ULID> and the subject usr_<ULID> before anything is sent (test/robots.test.js)',
'server/agents/tools.js': 'the configured OpenVibe.Tools and OpenVibe.Search bases; ids come from Tools\' catalog, a page URL goes to Tools as input',
'server/agents/tools.js': 'the configured OpenVibe.Tools and OpenVibe.Search bases; ids come from Tools\' catalog, a page URL goes to Tools as input',
'server/events-consumer.js': 'OpenVibe.Events: the configured events.url (ACTOR_EVENTS_URL), to create the two ADR-033 subscriptions at boot',
'server/events-consumer.js': 'OpenVibe.Events: the configured events.url (ACTOR_EVENTS_URL), to create the two ADR-033 subscriptions at boot',
'server/net/webhook-post.js': 'a task\'s webhook URL (plan T17), the one caller-chosen address: https on 443/8443 to public addresses only, checked at creation and at send time through openvibe-shared/egress safeLookup, never a redirect (test/webhooks.test.js)',
'server/net/webhook-post.js': 'a task\'s webhook URL (plan T17), the one caller-chosen address: https on 443/8443 to public addresses only, checked at creation and at send time through openvibe-shared/egress safeLookup, never a redirect (test/webhooks.test.js)',