openvibers/openvibe.bot Commits
An ai command may carry tools (openvibe-contracts v0.277.0) (#283)
Changes
@@@ -235,7 +235,7 @@ Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras BotOpenVibe.Sites no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json marks the vhost as owned by OpenVibe.Bot), and Bot serves nothing from a Sites checkout: the legal pages, `robots.txt`, `sitemap.xml`, `manifest.webmanifest` and the 404 page all come from the app. A Sites deploy leaves an installed vhost it no longer generates in place, so the current Sites vhost keeps serving openvibe.bot until Bot's first deploy installs this file over it; later Sites deploys never overwrite Bot's `/device` and `/control` routes.<!-- versions:start -->--- openvibe-contracts: v0.261.0++- openvibe-contracts: v0.277.0- openvibe-sdk: v0.81.0- openvibe-shared: v4.105.0<!-- versions:end -->@@@ -5,11 +5,11 @@"runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step","port": 4630,"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",-- "contracts": "openvibe-contracts v0.261.0",++ "contracts": "openvibe-contracts v0.277.0","sdk": "openvibe-sdk v0.81.0","shared": "openvibe-shared v4.105.0","packages": [-- "openvibe-contracts v0.261.0",++ "openvibe-contracts v0.277.0","openvibe-sdk v0.81.0","openvibe-shared v4.105.0"],@@@ -13,7 +13,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.261.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.277.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.105.0","pg": "^8.23.0",@@@ -1120,9 +1120,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.261.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.261.0",-- "integrity": "sha512-SiXOcXRL9+WivMkKM1W6sGNJMD4uCBR9o7KYZvs99hT2xgYxDvP4rM7tLBudGu1boPH+coMdUXVQAAWbS/3JDw==",++ "version": "0.277.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.277.0",++ "integrity": "sha512-GMODJIu4HUC5qWU2Ju8wft3aquLDBBN2VXLC34RjWw4NOVf/c3XndmN5wrv9yite0zv51ESwwmUFrpPnpcVs+w==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -25,7 +25,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.261.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.277.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.105.0","pg": "^8.23.0",@@@ -77,7 +77,8 @@ function computerSummary(kind, value) {if (kind === 'desk') return { action: value.action, ...(typeof value.desk === 'string' ? { desk: value.desk } : {}), ...(typeof value.kind === 'string' ? { kind: value.kind } : {}), ...(typeof value.profile === 'string' ? { profile: value.profile } : {}), ...(Number.isInteger(value.width) && Number.isInteger(value.height) ? { width: value.width, height: value.height } : {}) };if (kind === 'exec') return { program: String(value.argv[0]).slice(0, 120), args: value.argv.length - 1, ...where, ...(value.detach === true ? { detach: true } : {}) };// A model's prompt is the person's own words: the audit keeps the model, how many messages and how long, never the text.-- if (kind === 'ai') return { model: String(value.model).slice(0, 120), messages: value.messages.length, chars: value.messages.reduce((n, m) => n + String(m.content || '').length, 0) };++ // With tools (contracts 0.277.0): how many were offered, never their names' arguments or the results sent back.++ if (kind === 'ai') return { model: String(value.model).slice(0, 120), messages: value.messages.length, chars: value.messages.reduce((n, m) => n + String(m.content || '').length, 0), ...(Array.isArray(value.tools) ? { tools: value.tools.length } : {}) };if (kind === 'fs') return { op: value.op, path: String(value.path).slice(0, 300), ...(typeof value.content_b64 === 'string' ? { bytes: Math.floor((value.content_b64.length * 3) / 4) } : {}) };if (kind === 'probe') { try { const u = new URL(value.target); return { scheme: u.protocol.replace(':', ''), host: u.host }; } catch { return { scheme: null }; } }// Typed text can be anything (a password the person asked for): the audit keeps its length, never the text.@@@ -432,6 +432,20 @@ const { boot, check, done } = require('./helpers/app');const rows = await t.db.many('SELECT value FROM command_audit WHERE robot_id = $1 AND kind = $2 AND result = $3', [gpu.deviceId, 'ai', 'ack']);assert.deepStrictEqual(rows[0].value, { model: 'llama3.1:8b', messages: 1, chars: 29 });assert.ok(!JSON.stringify(rows).includes('secret words'), 'never the text');++ // Tools (contracts 0.277.0): an agent's loop on this machine's model. The command carries the tools and the++ // calls already made; the audit counts the tools and keeps no names, arguments or results.++ const withTools = { kind: 'ai', value: { model: 'llama3.1:8b', tools: [{ type: 'function', function: { name: 'web_search', parameters: { type: 'object' } } }], tool_choice: 'auto', messages: [++ { role: 'user', content: 'Weather in Lisbon?' },++ { role: 'assistant', content: '', tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'web_search', arguments: '{"q":"secret query"}' } }] },++ { role: 'tool', tool_call_id: 'call_1', content: 'secret result' },++ ] } };++ const toolAsk = await cmd(gpu.deviceId, withTools, asAi(alex.subject));++ assert.strictEqual(toolAsk.json.result, 'ack', toolAsk.text);++ const toolRow = (await t.db.many('SELECT value FROM command_audit WHERE robot_id = $1 AND kind = $2 ORDER BY id DESC LIMIT 1', [gpu.deviceId, 'ai']))[0];++ assert.deepStrictEqual(toolRow.value, { model: 'llama3.1:8b', messages: 3, chars: 31, tools: 1 });++ assert.ok(!/secret (query|result)|web_search/.test(JSON.stringify(toolRow)), 'no tool names, arguments or results in the audit');++ const orphan = await cmd(gpu.deviceId, { kind: 'ai', value: { model: 'llama3.1:8b', messages: [{ role: 'tool', content: 'x' }] } }, asAi(alex.subject));++ assert.strictEqual(orphan.status, 422, 'a tool message needs the call it answers');// A real Node reports the ai plugin under its own name, and a machine may run it with no computer plugin.const own = await machine(alex, null);own.c.send(frame('status', { capabilities: { ai: { ai: { models: ['qwen2.5-1.5b-instruct'], ready: true, max_tokens: 256 } } }, drivers: [], faults: [] }));
@@ -235,7 +235,7 @@ Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras Bot
@@ -235,7 +235,7 @@ Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras Bot
OpenVibe.Sites no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json marks the vhost as owned by OpenVibe.Bot), and Bot serves nothing from a Sites checkout: the legal pages, `robots.txt`, `sitemap.xml`, `manifest.webmanifest` and the 404 page all come from the app. A Sites deploy leaves an installed vhost it no longer generates in place, so the current Sites vhost keeps serving openvibe.bot until Bot's first deploy installs this file over it; later Sites deploys never overwrite Bot's `/device` and `/control` routes.
OpenVibe.Sites no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json marks the vhost as owned by OpenVibe.Bot), and Bot serves nothing from a Sites checkout: the legal pages, `robots.txt`, `sitemap.xml`, `manifest.webmanifest` and the 404 page all come from the app. A Sites deploy leaves an installed vhost it no longer generates in place, so the current Sites vhost keeps serving openvibe.bot until Bot's first deploy installs this file over it; later Sites deploys never overwrite Bot's `/device` and `/control` routes.
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.261.0
+- openvibe-contracts: v0.277.0
- openvibe-sdk: v0.81.0
- openvibe-sdk: v0.81.0
- openvibe-shared: v4.105.0
- openvibe-shared: v4.105.0
<!-- versions:end -->
<!-- versions:end -->
@@ -5,11 +5,11 @@
@@ -5,11 +5,11 @@
"runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step",
"runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step",
"port": 4630,
"port": 4630,
"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",
"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",
- "contracts": "openvibe-contracts v0.261.0",
+ "contracts": "openvibe-contracts v0.277.0",
"sdk": "openvibe-sdk v0.81.0",
"sdk": "openvibe-sdk v0.81.0",
"shared": "openvibe-shared v4.105.0",
"shared": "openvibe-shared v4.105.0",
"packages": [
"packages": [
- "openvibe-contracts v0.261.0",
+ "openvibe-contracts v0.277.0",
"openvibe-sdk v0.81.0",
"openvibe-sdk v0.81.0",
"openvibe-shared v4.105.0"
"openvibe-shared v4.105.0"
],
],
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.261.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.277.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.105.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.105.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -1120,9 +1120,9 @@
@@ -1120,9 +1120,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.261.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.261.0",
- "integrity": "sha512-SiXOcXRL9+WivMkKM1W6sGNJMD4uCBR9o7KYZvs99hT2xgYxDvP4rM7tLBudGu1boPH+coMdUXVQAAWbS/3JDw==",
+ "version": "0.277.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.277.0",
+ "integrity": "sha512-GMODJIu4HUC5qWU2Ju8wft3aquLDBBN2VXLC34RjWw4NOVf/c3XndmN5wrv9yite0zv51ESwwmUFrpPnpcVs+w==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.261.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.277.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.105.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.105.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -77,7 +77,8 @@ function computerSummary(kind, value) {
@@ -77,7 +77,8 @@ function computerSummary(kind, value) {
if (kind === 'desk') return { action: value.action, ...(typeof value.desk === 'string' ? { desk: value.desk } : {}), ...(typeof value.kind === 'string' ? { kind: value.kind } : {}), ...(typeof value.profile === 'string' ? { profile: value.profile } : {}), ...(Number.isInteger(value.width) && Number.isInteger(value.height) ? { width: value.width, height: value.height } : {}) };
if (kind === 'desk') return { action: value.action, ...(typeof value.desk === 'string' ? { desk: value.desk } : {}), ...(typeof value.kind === 'string' ? { kind: value.kind } : {}), ...(typeof value.profile === 'string' ? { profile: value.profile } : {}), ...(Number.isInteger(value.width) && Number.isInteger(value.height) ? { width: value.width, height: value.height } : {}) };
if (kind === 'exec') return { program: String(value.argv[0]).slice(0, 120), args: value.argv.length - 1, ...where, ...(value.detach === true ? { detach: true } : {}) };
if (kind === 'exec') return { program: String(value.argv[0]).slice(0, 120), args: value.argv.length - 1, ...where, ...(value.detach === true ? { detach: true } : {}) };
// A model's prompt is the person's own words: the audit keeps the model, how many messages and how long, never the text.
// A model's prompt is the person's own words: the audit keeps the model, how many messages and how long, never the text.
- if (kind === 'ai') return { model: String(value.model).slice(0, 120), messages: value.messages.length, chars: value.messages.reduce((n, m) => n + String(m.content || '').length, 0) };
+ // With tools (contracts 0.277.0): how many were offered, never their names' arguments or the results sent back.
+ if (kind === 'ai') return { model: String(value.model).slice(0, 120), messages: value.messages.length, chars: value.messages.reduce((n, m) => n + String(m.content || '').length, 0), ...(Array.isArray(value.tools) ? { tools: value.tools.length } : {}) };
if (kind === 'fs') return { op: value.op, path: String(value.path).slice(0, 300), ...(typeof value.content_b64 === 'string' ? { bytes: Math.floor((value.content_b64.length * 3) / 4) } : {}) };
if (kind === 'fs') return { op: value.op, path: String(value.path).slice(0, 300), ...(typeof value.content_b64 === 'string' ? { bytes: Math.floor((value.content_b64.length * 3) / 4) } : {}) };
if (kind === 'probe') { try { const u = new URL(value.target); return { scheme: u.protocol.replace(':', ''), host: u.host }; } catch { return { scheme: null }; } }
if (kind === 'probe') { try { const u = new URL(value.target); return { scheme: u.protocol.replace(':', ''), host: u.host }; } catch { return { scheme: null }; } }
// Typed text can be anything (a password the person asked for): the audit keeps its length, never the text.
// Typed text can be anything (a password the person asked for): the audit keeps its length, never the text.
@@ -432,6 +432,20 @@ const { boot, check, done } = require('./helpers/app');
@@ -432,6 +432,20 @@ const { boot, check, done } = require('./helpers/app');
const rows = await t.db.many('SELECT value FROM command_audit WHERE robot_id = $1 AND kind = $2 AND result = $3', [gpu.deviceId, 'ai', 'ack']);
const rows = await t.db.many('SELECT value FROM command_audit WHERE robot_id = $1 AND kind = $2 AND result = $3', [gpu.deviceId, 'ai', 'ack']);
assert.deepStrictEqual(rows[0].value, { model: 'llama3.1:8b', messages: 1, chars: 29 });
assert.deepStrictEqual(rows[0].value, { model: 'llama3.1:8b', messages: 1, chars: 29 });
assert.ok(!JSON.stringify(rows).includes('secret words'), 'never the text');
assert.ok(!JSON.stringify(rows).includes('secret words'), 'never the text');
+ // Tools (contracts 0.277.0): an agent's loop on this machine's model. The command carries the tools and the
+ // calls already made; the audit counts the tools and keeps no names, arguments or results.
+ const withTools = { kind: 'ai', value: { model: 'llama3.1:8b', tools: [{ type: 'function', function: { name: 'web_search', parameters: { type: 'object' } } }], tool_choice: 'auto', messages: [
+ { role: 'user', content: 'Weather in Lisbon?' },
+ { role: 'assistant', content: '', tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'web_search', arguments: '{"q":"secret query"}' } }] },
+ { role: 'tool', tool_call_id: 'call_1', content: 'secret result' },
+ ] } };
+ const toolAsk = await cmd(gpu.deviceId, withTools, asAi(alex.subject));
+ assert.strictEqual(toolAsk.json.result, 'ack', toolAsk.text);
+ const toolRow = (await t.db.many('SELECT value FROM command_audit WHERE robot_id = $1 AND kind = $2 ORDER BY id DESC LIMIT 1', [gpu.deviceId, 'ai']))[0];
+ assert.deepStrictEqual(toolRow.value, { model: 'llama3.1:8b', messages: 3, chars: 31, tools: 1 });
+ assert.ok(!/secret (query|result)|web_search/.test(JSON.stringify(toolRow)), 'no tool names, arguments or results in the audit');
+ const orphan = await cmd(gpu.deviceId, { kind: 'ai', value: { model: 'llama3.1:8b', messages: [{ role: 'tool', content: 'x' }] } }, asAi(alex.subject));
+ assert.strictEqual(orphan.status, 422, 'a tool message needs the call it answers');
// A real Node reports the ai plugin under its own name, and a machine may run it with no computer plugin.
// A real Node reports the ai plugin under its own name, and a machine may run it with no computer plugin.
const own = await machine(alex, null);
const own = await machine(alex, null);
own.c.send(frame('status', { capabilities: { ai: { ai: { models: ['qwen2.5-1.5b-instruct'], ready: true, max_tokens: 256 } } }, drivers: [], faults: [] }));
own.c.send(frame('status', { capabilities: { ai: { ai: { models: ['qwen2.5-1.5b-instruct'], ready: true, max_tokens: 256 } } }, drivers: [], faults: [] }));