openvibers/openvibe.actor Commits
Runs on: my computer (plan L55 C3) (#272)
Changes
nnew file mode 100644@@@ -0,0 +1,8 @@++-- phase: expand++-- Plan L55 C3, "Runs on: my computer": where a person's tasks run. 'openvibe' (the default, today's behaviour) or++-- 'computer' (every turn goes to OpenVibe.Codes on the person's own paired machine; robot and computer-control tasks++-- keep their own agents). A new column with a default: every row stays valid, and the code before this one never reads++-- or writes it.++ALTER TABLE actor_model_prefs ADD COLUMN IF NOT EXISTS runs_on text NOT NULL DEFAULT 'openvibe';++ALTER TABLE actor_model_prefs DROP CONSTRAINT IF EXISTS actor_model_prefs_runs_on_check;++ALTER TABLE actor_model_prefs ADD CONSTRAINT actor_model_prefs_runs_on_check CHECK (runs_on IN ('openvibe', 'computer'));@@@ -112,6 +112,7 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app.cm { max-width: 640px; margin: 0 auto; padding: 24px 16px 96px; }.cm-back { display: inline-flex; align-items: center; gap: 6px; color: var(--text-muted, #7386a3); text-decoration: none; font-size: 14px; }.cm h1 { margin: 14px 0 8px; font-size: clamp(24px, 4vw, 32px); line-height: 1.15; text-wrap: balance; }++.cm-h2 { margin: 40px 0 8px; padding-top: 24px; border-top: 1px solid var(--line); font-size: clamp(19px, 3vw, 22px); line-height: 1.2; scroll-margin-top: 72px; }.cm-lede { margin: 0 0 18px; color: var(--text-secondary, #a8b3c7); line-height: 1.55; }.cm-ok, .cm-err { padding: 10px 14px; border-radius: 12px; font-size: 14px; }.cm-ok { background: color-mix(in srgb, #22c55e 14%, transparent); }@@@ -124,6 +125,7 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app.cm-opt > span { flex: 1; min-width: 180px; display: grid; gap: 2px; }.cm-opt small { color: var(--text-muted, #7386a3); font-size: 12.5px; }.cm-setup { font-size: 13px; font-weight: 700; color: var(--accent, #4f7cff); }++.cm-setups { display: flex; flex-wrap: wrap; gap: 6px 16px; }.cm-modelid { display: grid; gap: 6px; margin-top: 12px; font-size: 14px; }.cm-modelid small { color: var(--text-muted, #7386a3); }.cm-modelid input { min-height: 42px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--line); background: transparent; color: inherit; font: inherit; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }@@@ -217,7 +217,14 @@ function createApi(ctx) {});r.put('/me/model', principal.requireCapability('actor.model.manage'), personOnly, limits.budget('actor.model.manage'), async (req, res) => {-- const out = await modelPrefs.put(s, req.principal.requester, req.body || {});++ const body = req.body || {};++ // Where it runs (plan L55 C3) may come alone or with the model choice.++ if (body.runs_on !== undefined) {++ const r = await modelPrefs.setRunsOn(s, req.principal.requester, body.runs_on);++ if (r.error) return problem(req, res, 422, 'actor.model.invalid', r.error);++ if (body.choice === undefined && body.model === undefined) return res.json({ ...modelPrefs.wire(await modelPrefs.get(s, req.principal.requester)), keys: null });++ }++ const out = await modelPrefs.put(s, req.principal.requester, body);if (out.error) return problem(req, res, 422, 'actor.model.invalid', out.error);res.json({ ...modelPrefs.wire(out.row), keys: null });});@@@ -390,6 +390,7 @@ ${modelChip(model)}const model = modelPrefs.wire(await modelPrefs.get(s, requesterOf(req)).catch(() => null));const current = model.provider ? `own:${model.provider}` : 'auto';const saved = req.query.saved === '1';++ const runsSaved = req.query.saved === 'runs';const error = typeof req.query.error === 'string' ? req.query.error.slice(0, 200) : '';const radio = (choice, title, sub, extra = '') => html`<label class="cm-opt"><input type="radio" name="choice" value="${choice}"${choice === current ? raw(' checked') : ''}><span><b>${title}</b><small>${sub}</small></span>${raw(extra)}</label>`;return send(res, 200, {@@@ -407,6 +408,14 @@ ${modelPrefs.KEY_PROVIDERS.map((p) => radio(`own:${p}`, PROVIDER_NAMES[p] || p,${radio('own:node', 'Your computer', 'A model you run at home (Ollama, LM Studio or llama.cpp) through OpenVibe Node v0.36.0 or newer: nothing is billed, and it answers only while that computer is on. Usually slower than a cloud model.', `<a class="cm-setup" href="${allowUrl('node').replace(/&/g, '&')}" target="_blank" rel="noopener">Add your computer's model and allow Actor</a>`)}<label class="cm-modelid"><span>A model id, if you want one <small>(leave empty and OpenVibe.AI picks the provider's best fit)</small></span><input name="model" value="${model.model || ''}" maxlength="120" pattern="[A-Za-z0-9][A-Za-z0-9._:/\-]{0,119}" placeholder="for example gpt-5.1, claude-sonnet-4-5, deepseek-chat" spellcheck="false" autocomplete="off"></label><button class="cm-save" type="submit">Save</button>++</form>++<h2 class="cm-h2" id="runs-on">Where it runs</h2>++<p class="cm-lede">On your computer, the model you run at home does all of Actor's thinking, whatever you picked above, and a coding task runs entirely on your machine through OpenVibe.Codes. Searching and reading the web still happen on OpenVibe. Private mode always stays on OpenVibe's own open model.</p>++${runsSaved ? html`<p class="cm-ok" role="status">Saved. Your next task runs on ${model.runs_on === 'computer' ? 'your computer' : 'OpenVibe'}.</p>` : ''}++<form class="cm-form" method="post" action="/chat/runs-on">++<label class="cm-opt"><input type="radio" name="runs_on" value="openvibe"${model.runs_on !== 'computer' ? raw(' checked') : ''}><span><b>OpenVibe</b><small>Quick, from anywhere, on the model you chose above.</small></span></label>++<label class="cm-opt"><input type="radio" name="runs_on" value="computer"${model.runs_on === 'computer' ? raw(' checked') : ''}><span><b>My computer</b><small>Nothing is billed for the model, and it answers only while your computer is on. Needs OpenVibe Node v0.36.0 or newer with a local model (Ollama, LM Studio or llama.cpp). Usually slower.</small></span><span class="cm-setups"><a class="cm-setup" href="${allowUrl('node')}" target="_blank" rel="noopener">Add your computer's model</a><a class="cm-setup" href="https://openvibe.bot/machines" target="_blank" rel="noopener">Add your machine for coding</a></span></label>++<button class="cm-save" type="submit">Save</button></form></main>`,});});@@@ -421,6 +430,15 @@ ${radio('own:node', 'Your computer', 'A model you run at home (Ollama, LM Studioreturn res.redirect(303, '/chat/model?saved=1');});++ r.post('/chat/runs-on', express.urlencoded({ extended: false, limit: '1kb' }), async (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ if (!signedIn(req)) return res.redirect(303, loginTo(req));++ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Change this on openvibe.actor itself.');++ const out = await modelPrefs.setRunsOn(s, requesterOf(req), (req.body || {}).runs_on);++ if (out.error) return res.redirect(303, `/chat/model?error=${encodeURIComponent('That did not save: ' + out.error)}#runs-on`);++ return res.redirect(303, '/chat/model?saved=runs#runs-on');++ });++r.get('/chat', async (req, res) => {res.set('Cache-Control', cache.htmlHeaders({ private: true }));if (!signedIn(req)) return res.redirect(303, loginTo(req));@@@ -66,7 +66,7 @@ const TABLES = [// they picked. Never a key: the key itself is sealed in OpenVibe.AI's vault, and this row only names the// provider. It goes with the person.{ table: 'actor_model_prefs', subject: 'requester', value: (usr) => `user:${usr}`, file: 'model-prefs.json',-- columns: ['requester', 'choice', 'model', 'updated_at'] },++ columns: ['requester', 'choice', 'model', 'runs_on', 'updated_at'] },// What they asked Actor in chat (chat/asks.js): the message it answered, the room, the task it became.{ table: 'actor_chat_asks', subject: 'requester', value: (usr) => `user:${usr}`, file: 'chat-asks.json' },// The connections they added (stage V2-11): the servers and agents, and which of their tools are on. The token@@@ -18,6 +18,9 @@* (server/identity/account-data.js).*/const CHOICES = ['auto', 'openvibe'];++// Where the tasks run (plan L55 C3, migration 0027): OpenVibe's cloud, or the person's own paired machine through++// OpenVibe.Codes (the codes agent), for every turn but robot and computer-control tasks.++const RUNS_ON = ['openvibe', 'computer'];const KEY_PROVIDERS = ['openai', 'anthropic', 'deepseek', 'google', 'openrouter', 'mistral'];// Plus their own computer (plan L55 C3): node is OpenVibe.AI's name for it.const OWN_PROVIDERS = [...KEY_PROVIDERS, 'node'];@@@ -45,7 +48,7 @@ function parse(body) {/** The row for a person, or null (no row: the default is 'auto'). */async function get(s, requester) {if (!requester) return null;-- const row = await s.db.maybe('SELECT requester, choice, model, updated_at FROM actor_model_prefs WHERE requester = $1', [requester]);++ const row = await s.db.maybe('SELECT requester, choice, model, runs_on, updated_at FROM actor_model_prefs WHERE requester = $1', [requester]);return row || null;}@@@ -75,15 +78,41 @@ async function choiceFor(s, requester) {return { subject, provider: row.choice.slice(4), model: row.model || null };}++/** Where the person's tasks run: 'openvibe' (no row, or the default) or 'computer'. */++async function runsOnFor(s, requester) {++ const row = await get(s, requester);++ return row && RUNS_ON.includes(row.runs_on) ? row.runs_on : 'openvibe';++}++++/** The model choice "Runs on: my computer" means: the person's own computer, through OpenVibe.AI's node credential. */++function computerChoice(requester) {++ const subject = String(requester || '').startsWith('user:') ? requester.slice(5) : null;++ return subject ? { subject, provider: 'node', model: null } : null;++}++++/** Set where the person's tasks run. Returns { runs_on } or { error }; the model choice is left as it is. */++async function setRunsOn(s, requester, value, now = () => new Date().toISOString()) {++ const v = String(value == null ? '' : value).trim();++ if (!RUNS_ON.includes(v)) return { error: 'runs_on: one of ' + RUNS_ON.join(', ') };++ if (!requester) return { error: 'no person' };++ await s.db.query(++ "INSERT INTO actor_model_prefs (requester, choice, model, runs_on, updated_at) VALUES ($1, 'auto', NULL, $2, $3)"++ + ' ON CONFLICT (requester) DO UPDATE SET runs_on = excluded.runs_on, updated_at = excluded.updated_at',++ [requester, v, now()],++ );++ return { runs_on: v };++}++/** The choice as the API answers it. */function wire(row) {-- if (!row) return { choice: 'auto', provider: null, model: null, updated_at: null };++ if (!row) return { choice: 'auto', provider: null, model: null, runs_on: 'openvibe', updated_at: null };return {choice: row.choice,provider: String(row.choice || '').startsWith('own:') ? row.choice.slice(4) : null,model: row.model || null,++ runs_on: RUNS_ON.includes(row.runs_on) ? row.runs_on : 'openvibe',updated_at: row.updated_at || null,};}--module.exports = { CHOICES, OWN_PROVIDERS, KEY_PROVIDERS, parse, get, put, choiceFor, wire };++module.exports = { CHOICES, RUNS_ON, OWN_PROVIDERS, KEY_PROVIDERS, parse, get, put, choiceFor, runsOnFor, setRunsOn, computerChoice, wire };@@@ -316,6 +316,11 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =if (fromChat && !PUBLIC_CLASSES.includes(cls)) {return await setState(id, 'failed', { error_code: 'actor.task.not_public', error_detail: 'Asked in a public chat, Actor uses public tools only: ask on openvibe.actor to use your own computers, robots or code.', finished_at: iso() });}++ // Where the person's tasks run (plan L55 C3, /chat/model): 'computer' sends every model call of the loop to++ // the model on their own computer, and a coding task to Codes on their machine. Never in private mode (the++ // open model on OpenVibe's server is the point) nor for a public chat's ask.++ const runsOn = row.mode === 'private' || fromChat || !String(row.requester || '').startsWith('user:')++ ? 'openvibe' : await modelPrefs.runsOnFor(s, row.requester).catch(() => 'openvibe');// Actor v2: every non-private task that is not code-only runs through the one loop. Private mode keeps// the local model with no network tools, and a coding task keeps the Codes agent (which the loop can also@@@ -330,7 +335,8 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =const notifyTask = (kind, detail) => tellPerson(row, kind, detail || {});const why = resume ? `the one agent loop resumed after a restart (step ${resume.step})`: fork ? `the one agent loop forked from step ${fork.step}`-- : `the one agent loop (${row.mode === 'best' ? 'best' : 'smart'} tier)`;++ : runsOn === 'computer' ? 'the one agent loop, thinking on the model on your computer (Runs on: my computer)'++ : `the one agent loop (${row.mode === 'best' ? 'best' : 'smart'} tier)`;explanation.push({ selected: agentId, objective: row.mode === 'best' ? 'correctness' : 'balanced', reasons: [why], candidates: [], decided_at: iso() });await store.updateTask(s, id, { explanation });if (!resume && !fork) await emit(id, { kind: 'output', step: 'plan', agent: agentId, text: 'The agent starts with a plan, then works through it.' });@@@ -352,7 +358,10 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =const attachments = await inputs.forRun(s, id, { models, meter, signal, log }).catch((err) => { log.warn('[Actor] attachments:', err && err.message); return []; });// The person's own model choice for this run (plan L55 C3): their own key, through OpenVibe.AI.// Every model call of the run — the loop's, the plan's, the check's — reads it, and no other run's does.-- const choice = await modelPrefs.choiceFor(s, row.requester).catch(() => null);++ let choice = await modelPrefs.choiceFor(s, row.requester).catch(() => null);++ // "Runs on: my computer": the model on the person's computer, whatever else they chose above it (a++ // model id they gave for their computer is kept).++ if (runsOn === 'computer' && !(choice && choice.provider === 'node')) choice = modelPrefs.computerChoice(row.requester);try {out = await withChoice(models, choice, async () => loop.run({task: row.task, requester: fromChat ? null : row.requester, taskId: id, mode: row.mode, signal,@@@ -530,13 +539,18 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =}}++ // "Runs on: my computer" (plan L55 C3): a coding task goes to the codes agent, OpenVibe.Codes on the person's++ // own paired machine, unless the task already names its agent.++ const pinnedAgent = row.agent || (runsOn === 'computer' && cls === 'code' ? 'codes' : null);for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {-- const placement = route({ cls, mode: row.mode, agent: row.agent, budgetUsd: Math.max(0, budgetLeft()), config, tried, now: now(), floor: row.mode === 'private' ? null : floor });++ const placement = route({ cls, mode: row.mode, agent: pinnedAgent, budgetUsd: Math.max(0, budgetLeft()), config, tried, now: now(), floor: row.mode === 'private' ? null : floor });explanation.push(placement);await store.updateTask(s, id, { explanation });if (!placement.selected) {if (attempt > 1) break;-- const why = whyNone(placement, { cls, mode: row.mode, budgetUsd: budgetLeft() });++ const why = pinnedAgent === 'codes' && !row.agent++ ? { code: 'actor.no_agent', detail: 'Your coding tasks run on your own machine (Runs on: my computer) through OpenVibe.Codes, and it is not connected here. Add your machine on https://openvibe.bot/machines, or switch Runs on back to OpenVibe at https://openvibe.actor/chat/model.' }++ : whyNone(placement, { cls, mode: row.mode, budgetUsd: budgetLeft() });return await setState(id, 'failed', { error_code: why.code, error_detail: why.detail, finished_at: iso() });}const agentId = placement.selected;@@@ -43,7 +43,7 @@ const owner = 'user:usr_01JZ0000000000000000000AAA';// 0017 on top of the running schema.const second = await db.migrate({ dir: MIGRATIONS });// 0025 is the person's own model choice (plan L55 C3): it is a migration of its own, like every one before it.-- assert.deepStrictEqual(second.applied.map((m) => m.id), ['0017', '0018', '0019', '0020', '0021', '0022', '0023', '0024', '0025', '0026']);++ assert.deepStrictEqual(second.applied.map((m) => m.id), ['0017', '0018', '0019', '0020', '0021', '0022', '0023', '0024', '0025', '0026', '0027']);await check('an old-style INSERT still works after 0017, and a 900-character row is intact', async () => {const old = await db.maybe('SELECT id, text, origin, status, kind, uses FROM actor_agent_memory WHERE id = $1', [agentMemoryId]);nnew file mode 100644@@@ -0,0 +1,126 @@++'use strict';++// "Runs on: my computer" (plan L55 C3; the owner, 11-01: "a remote session, or a local agent session"): a person++// chooses where their tasks run, beside the model they chose. On their computer, every model call of the agent loop++// goes to the model on their own computer (OpenVibe.AI's node credential, whatever key they chose above it), and a++// coding task is pinned to Codes on their machine. Private mode and a public chat's ask stay on OpenVibe.++// node test/runs-on.test.js++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { createModels } = require('../server/runtime/models');++const prefs = require('../server/runtime/model-prefs');++++const SAME = { 'sec-fetch-site': 'same-origin' };++const quiet = { log() {}, warn() {}, error() {}, info() {} };++const reply = (status, obj) => ({ ok: status >= 200 && status < 300, status, json: async () => obj, text: async () => JSON.stringify(obj) });++++/** Models that reach a fake OpenVibe.AI, and record every call. */++function aiModels(calls) {++ const config = {++ models: { tiers: {}, providerOrder: ['openai'], listTimeoutMs: 200 },++ providers: { openai: { apiKey: 'sk-fixture-operator-key', baseUrl: 'https://api.openai.test/v1', model: 'gpt-5-mini' } },++ ai: { url: 'https://ai.openvibe.services', audience: 'openvibe.ai', scope: 'ai.run.create', clientId: 'actor', clientSecret: 'actor-secret', timeoutMs: 5000 },++ networkInternalUrl: 'https://network.test', networkUrl: 'https://openvibe.network',++ oauth: { clientId: 'actor', clientSecret: 'actor-secret' },++ };++ const fetchImpl = async (url, opts = {}) => {++ const u = String(url);++ if (u.includes('/oauth/token')) return reply(200, { access_token: 'tok_service_token', token_type: 'Bearer', expires_in: 300, scope: 'ai.run.create' });++ calls.push({ url: u, headers: opts.headers || {} });++ if (u.endsWith('/models')) return reply(200, { data: [{ id: 'gpt-5-mini' }] });++ // The answer's check asks for a verdict as JSON; the run itself gets the answer.++ const checking = String(opts.body || '').includes('\\"claims\\"');++ const content = checking ? JSON.stringify({ claims: [{ claim: 'Two plus two is four.', kind: 'general', evidence: [], supported: true }], ok: true, reason: 'general knowledge' }) : 'Four.';++ return reply(200, { choices: [{ message: { role: 'assistant', content }, finish_reason: 'stop' }], model: 'llama3', usage: { prompt_tokens: 4, completion_tokens: 1 }, x_openvibe: { cost_usd: 0 } });++ };++ return createModels({ config, fetchImpl, log: quiet });++}++++(async () => {++ const calls = [];++ const models = aiModels(calls);++ // The loop, reduced to one model call: what matters here is which model the run's calls go to.++ const loop = {++ run: async ({ task }) => { const r = await models.chat({ tier: 'smart', messages: [{ role: 'user', content: task }] }); return { text: r.text, state: {} }; },++ close: async () => {}, planText: () => '', escalate: async () => null, revise: async () => null,++ };++ const t = await boot({ models, loop });++ const kim = t.network.addUser('runs-kim');++ try {++ await check('the default is OpenVibe, and the API sets it alone without touching the model choice', async () => {++ const before = await t.get('/api/v1/me/model', { as: kim });++ assert.strictEqual(before.json().runs_on, 'openvibe');++ await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { choice: 'own:openai' }, headers: SAME });++ const put = await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { runs_on: 'computer' }, headers: SAME });++ assert.strictEqual(put.status, 200, put.text);++ assert.deepStrictEqual([put.json().runs_on, put.json().choice], ['computer', 'own:openai'], 'the model choice is kept');++ const bad = await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { runs_on: 'the-moon' }, headers: SAME });++ assert.strictEqual(bad.status, 422);++ assert.strictEqual((await t.get('/api/v1/me/model', { as: kim })).json().runs_on, 'computer', 'the bad one never landed');++ // A choice change keeps where it runs.++ await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { choice: 'auto' }, headers: SAME });++ assert.strictEqual((await t.get('/api/v1/me/model', { as: kim })).json().runs_on, 'computer');++ });++++ await check('on their computer, the run\'s model calls go to the model on their own computer', async () => {++ calls.length = 0;++ const made = await t.get('/api/v1/tasks', { as: kim, json: { task: 'What is two plus two?' }, headers: SAME });++ assert.strictEqual(made.status, 201, made.text);++ const row = await t.waitFor(made.json().id);++ assert.strictEqual(row.state, 'succeeded', JSON.stringify([row.error_code, row.error_detail]));++ const loopCalls = calls.filter((c) => c.url.endsWith('/chat/completions'));++ assert.ok(loopCalls.length >= 1, 'the loop called a model');++ assert.ok(loopCalls.every((c) => c.headers['x-openvibe-credential'] === `node:${kim.subject || kim.id}`), JSON.stringify(loopCalls.map((c) => c.headers['x-openvibe-credential'])));++ const why = JSON.stringify(row.explanation);++ assert.ok(why.includes('on the model on your computer'), 'the explanation says where it ran');++ });++++ await check('back on OpenVibe, the same person\'s run uses the model they chose (auto: no call to AI)', async () => {++ await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { runs_on: 'openvibe' }, headers: SAME });++ calls.length = 0;++ const made = await t.get('/api/v1/tasks', { as: kim, json: { task: 'What is two plus two?' }, headers: SAME });++ const row = await t.waitFor(made.json().id);++ assert.strictEqual(row.state, 'succeeded', JSON.stringify([row.error_code, row.error_detail]));++ assert.ok(calls.every((c) => !c.headers['x-openvibe-credential']), 'no person credential');++ });++++ await check('private mode stays on OpenVibe\'s own open model even when it is set to the computer', async () => {++ await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { runs_on: 'computer' }, headers: SAME });++ calls.length = 0;++ const made = await t.get('/api/v1/tasks', { as: kim, json: { task: 'What is two plus two?', mode: 'private' }, headers: SAME });++ await t.waitFor(made.json().id);++ assert.ok(calls.every((c) => !String(c.headers['x-openvibe-credential'] || '').startsWith('node:')), 'never their computer');++ await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { runs_on: 'openvibe' }, headers: SAME });++ });++++ await check('/chat/model: the "Where it runs" form saves from openvibe.actor only', async () => {++ const page = await t.get('/chat/model', { as: kim });++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('id="runs-on"'));++ assert.ok(page.text.includes('name="runs_on" value="openvibe" checked'));++ assert.ok(page.text.includes('https://ai.openvibe.services/you?allow=actor&provider=node'), 'set up the computer\'s model at OpenVibe.AI');++ assert.ok(page.text.includes('https://openvibe.bot/machines'), 'add the machine for coding');++ const saved = await t.get('/chat/runs-on', { as: kim, form: { runs_on: 'computer' }, headers: SAME });++ assert.strictEqual(saved.status, 303);++ assert.strictEqual(saved.headers.get('location'), '/chat/model?saved=runs#runs-on');++ const after = await t.get('/chat/model?saved=runs', { as: kim });++ assert.ok(after.text.includes('name="runs_on" value="computer" checked'));++ assert.ok(after.text.includes('Your next task runs on your computer.'));++ const cross = await t.get('/chat/runs-on', { as: kim, form: { runs_on: 'openvibe' }, headers: { 'sec-fetch-site': 'cross-site' } });++ assert.strictEqual(cross.status, 403);++ const bad = await t.get('/chat/runs-on', { as: kim, form: { runs_on: 'nope' }, headers: SAME });++ assert.match(bad.headers.get('location'), /^\/chat\/model\?error=/);++ assert.strictEqual((await t.get('/api/v1/me/model', { as: kim })).json().runs_on, 'computer');++ const anon = await t.get('/chat/runs-on', { form: { runs_on: 'computer' }, headers: SAME });++ assert.strictEqual(anon.status, 303);++ assert.match(anon.headers.get('location'), /^\/auth\/login/);++ });++++ await check('the choice "Runs on: my computer" means: AI\'s node credential, for a person only', async () => {++ assert.deepStrictEqual(prefs.computerChoice('user:usr_1'), { subject: 'usr_1', provider: 'node', model: null });++ assert.strictEqual(prefs.computerChoice('ip:1.2.3.4'), null);++ assert.deepStrictEqual(prefs.RUNS_ON, ['openvibe', 'computer']);++ assert.deepStrictEqual(prefs.wire(null).runs_on, 'openvibe');++ });++ } finally { await t.close(); }++ done();++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+-- phase: expand
+-- Plan L55 C3, "Runs on: my computer": where a person's tasks run. 'openvibe' (the default, today's behaviour) or
+-- 'computer' (every turn goes to OpenVibe.Codes on the person's own paired machine; robot and computer-control tasks
+-- keep their own agents). A new column with a default: every row stays valid, and the code before this one never reads
+-- or writes it.
+ALTER TABLE actor_model_prefs ADD COLUMN IF NOT EXISTS runs_on text NOT NULL DEFAULT 'openvibe';
+ALTER TABLE actor_model_prefs DROP CONSTRAINT IF EXISTS actor_model_prefs_runs_on_check;
+ALTER TABLE actor_model_prefs ADD CONSTRAINT actor_model_prefs_runs_on_check CHECK (runs_on IN ('openvibe', 'computer'));
@@ -112,6 +112,7 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app
@@ -112,6 +112,7 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app
.cm { max-width: 640px; margin: 0 auto; padding: 24px 16px 96px; }
.cm { max-width: 640px; margin: 0 auto; padding: 24px 16px 96px; }
.cm-back { display: inline-flex; align-items: center; gap: 6px; color: var(--text-muted, #7386a3); text-decoration: none; font-size: 14px; }
.cm-back { display: inline-flex; align-items: center; gap: 6px; color: var(--text-muted, #7386a3); text-decoration: none; font-size: 14px; }
.cm h1 { margin: 14px 0 8px; font-size: clamp(24px, 4vw, 32px); line-height: 1.15; text-wrap: balance; }
.cm h1 { margin: 14px 0 8px; font-size: clamp(24px, 4vw, 32px); line-height: 1.15; text-wrap: balance; }
+.cm-h2 { margin: 40px 0 8px; padding-top: 24px; border-top: 1px solid var(--line); font-size: clamp(19px, 3vw, 22px); line-height: 1.2; scroll-margin-top: 72px; }
.cm-lede { margin: 0 0 18px; color: var(--text-secondary, #a8b3c7); line-height: 1.55; }
.cm-lede { margin: 0 0 18px; color: var(--text-secondary, #a8b3c7); line-height: 1.55; }
.cm-ok, .cm-err { padding: 10px 14px; border-radius: 12px; font-size: 14px; }
.cm-ok, .cm-err { padding: 10px 14px; border-radius: 12px; font-size: 14px; }
.cm-ok { background: color-mix(in srgb, #22c55e 14%, transparent); }
.cm-ok { background: color-mix(in srgb, #22c55e 14%, transparent); }
@@ -124,6 +125,7 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app
@@ -124,6 +125,7 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app
.cm-opt > span { flex: 1; min-width: 180px; display: grid; gap: 2px; }
.cm-opt > span { flex: 1; min-width: 180px; display: grid; gap: 2px; }
.cm-opt small { color: var(--text-muted, #7386a3); font-size: 12.5px; }
.cm-opt small { color: var(--text-muted, #7386a3); font-size: 12.5px; }
.cm-setup { font-size: 13px; font-weight: 700; color: var(--accent, #4f7cff); }
.cm-setup { font-size: 13px; font-weight: 700; color: var(--accent, #4f7cff); }
+.cm-setups { display: flex; flex-wrap: wrap; gap: 6px 16px; }
.cm-modelid { display: grid; gap: 6px; margin-top: 12px; font-size: 14px; }
.cm-modelid { display: grid; gap: 6px; margin-top: 12px; font-size: 14px; }
.cm-modelid small { color: var(--text-muted, #7386a3); }
.cm-modelid small { color: var(--text-muted, #7386a3); }
.cm-modelid input { min-height: 42px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--line); background: transparent; color: inherit; font: inherit; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.cm-modelid input { min-height: 42px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--line); background: transparent; color: inherit; font: inherit; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
@@ -217,7 +217,14 @@ function createApi(ctx) {
@@ -217,7 +217,14 @@ function createApi(ctx) {
});
});
r.put('/me/model', principal.requireCapability('actor.model.manage'), personOnly, limits.budget('actor.model.manage'), async (req, res) => {
r.put('/me/model', principal.requireCapability('actor.model.manage'), personOnly, limits.budget('actor.model.manage'), async (req, res) => {
- const out = await modelPrefs.put(s, req.principal.requester, req.body || {});
+ const body = req.body || {};
+ // Where it runs (plan L55 C3) may come alone or with the model choice.
+ if (body.runs_on !== undefined) {
+ const r = await modelPrefs.setRunsOn(s, req.principal.requester, body.runs_on);
+ if (r.error) return problem(req, res, 422, 'actor.model.invalid', r.error);
+ if (body.choice === undefined && body.model === undefined) return res.json({ ...modelPrefs.wire(await modelPrefs.get(s, req.principal.requester)), keys: null });
+ }
+ const out = await modelPrefs.put(s, req.principal.requester, body);
if (out.error) return problem(req, res, 422, 'actor.model.invalid', out.error);
if (out.error) return problem(req, res, 422, 'actor.model.invalid', out.error);
res.json({ ...modelPrefs.wire(out.row), keys: null });
res.json({ ...modelPrefs.wire(out.row), keys: null });
});
});
@@ -390,6 +390,7 @@ ${modelChip(model)}
@@ -390,6 +390,7 @@ ${modelChip(model)}
const model = modelPrefs.wire(await modelPrefs.get(s, requesterOf(req)).catch(() => null));
const model = modelPrefs.wire(await modelPrefs.get(s, requesterOf(req)).catch(() => null));
const current = model.provider ? `own:${model.provider}` : 'auto';
const current = model.provider ? `own:${model.provider}` : 'auto';
const saved = req.query.saved === '1';
const saved = req.query.saved === '1';
+ const runsSaved = req.query.saved === 'runs';
const error = typeof req.query.error === 'string' ? req.query.error.slice(0, 200) : '';
const error = typeof req.query.error === 'string' ? req.query.error.slice(0, 200) : '';
const radio = (choice, title, sub, extra = '') => html`<label class="cm-opt"><input type="radio" name="choice" value="${choice}"${choice === current ? raw(' checked') : ''}><span><b>${title}</b><small>${sub}</small></span>${raw(extra)}</label>`;
const radio = (choice, title, sub, extra = '') => html`<label class="cm-opt"><input type="radio" name="choice" value="${choice}"${choice === current ? raw(' checked') : ''}><span><b>${title}</b><small>${sub}</small></span>${raw(extra)}</label>`;
return send(res, 200, {
return send(res, 200, {
@@ -407,6 +408,14 @@ ${modelPrefs.KEY_PROVIDERS.map((p) => radio(`own:${p}`, PROVIDER_NAMES[p] || p,
@@ -407,6 +408,14 @@ ${modelPrefs.KEY_PROVIDERS.map((p) => radio(`own:${p}`, PROVIDER_NAMES[p] || p,
${radio('own:node', 'Your computer', 'A model you run at home (Ollama, LM Studio or llama.cpp) through OpenVibe Node v0.36.0 or newer: nothing is billed, and it answers only while that computer is on. Usually slower than a cloud model.', `<a class="cm-setup" href="${allowUrl('node').replace(/&/g, '&')}" target="_blank" rel="noopener">Add your computer's model and allow Actor</a>`)}
${radio('own:node', 'Your computer', 'A model you run at home (Ollama, LM Studio or llama.cpp) through OpenVibe Node v0.36.0 or newer: nothing is billed, and it answers only while that computer is on. Usually slower than a cloud model.', `<a class="cm-setup" href="${allowUrl('node').replace(/&/g, '&')}" target="_blank" rel="noopener">Add your computer's model and allow Actor</a>`)}
<label class="cm-modelid"><span>A model id, if you want one <small>(leave empty and OpenVibe.AI picks the provider's best fit)</small></span><input name="model" value="${model.model || ''}" maxlength="120" pattern="[A-Za-z0-9][A-Za-z0-9._:/\-]{0,119}" placeholder="for example gpt-5.1, claude-sonnet-4-5, deepseek-chat" spellcheck="false" autocomplete="off"></label>
<label class="cm-modelid"><span>A model id, if you want one <small>(leave empty and OpenVibe.AI picks the provider's best fit)</small></span><input name="model" value="${model.model || ''}" maxlength="120" pattern="[A-Za-z0-9][A-Za-z0-9._:/\-]{0,119}" placeholder="for example gpt-5.1, claude-sonnet-4-5, deepseek-chat" spellcheck="false" autocomplete="off"></label>
<button class="cm-save" type="submit">Save</button>
<button class="cm-save" type="submit">Save</button>
+</form>
+<h2 class="cm-h2" id="runs-on">Where it runs</h2>
+<p class="cm-lede">On your computer, the model you run at home does all of Actor's thinking, whatever you picked above, and a coding task runs entirely on your machine through OpenVibe.Codes. Searching and reading the web still happen on OpenVibe. Private mode always stays on OpenVibe's own open model.</p>
+${runsSaved ? html`<p class="cm-ok" role="status">Saved. Your next task runs on ${model.runs_on === 'computer' ? 'your computer' : 'OpenVibe'}.</p>` : ''}
+<form class="cm-form" method="post" action="/chat/runs-on">
+<label class="cm-opt"><input type="radio" name="runs_on" value="openvibe"${model.runs_on !== 'computer' ? raw(' checked') : ''}><span><b>OpenVibe</b><small>Quick, from anywhere, on the model you chose above.</small></span></label>
+<label class="cm-opt"><input type="radio" name="runs_on" value="computer"${model.runs_on === 'computer' ? raw(' checked') : ''}><span><b>My computer</b><small>Nothing is billed for the model, and it answers only while your computer is on. Needs OpenVibe Node v0.36.0 or newer with a local model (Ollama, LM Studio or llama.cpp). Usually slower.</small></span><span class="cm-setups"><a class="cm-setup" href="${allowUrl('node')}" target="_blank" rel="noopener">Add your computer's model</a><a class="cm-setup" href="https://openvibe.bot/machines" target="_blank" rel="noopener">Add your machine for coding</a></span></label>
+<button class="cm-save" type="submit">Save</button>
</form></main>`,
</form></main>`,
});
});
});
});
@@ -421,6 +430,15 @@ ${radio('own:node', 'Your computer', 'A model you run at home (Ollama, LM Studio
@@ -421,6 +430,15 @@ ${radio('own:node', 'Your computer', 'A model you run at home (Ollama, LM Studio
return res.redirect(303, '/chat/model?saved=1');
return res.redirect(303, '/chat/model?saved=1');
});
});
+ r.post('/chat/runs-on', express.urlencoded({ extended: false, limit: '1kb' }), async (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ if (!signedIn(req)) return res.redirect(303, loginTo(req));
+ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Change this on openvibe.actor itself.');
+ const out = await modelPrefs.setRunsOn(s, requesterOf(req), (req.body || {}).runs_on);
+ if (out.error) return res.redirect(303, `/chat/model?error=${encodeURIComponent('That did not save: ' + out.error)}#runs-on`);
+ return res.redirect(303, '/chat/model?saved=runs#runs-on');
+ });
+
r.get('/chat', async (req, res) => {
r.get('/chat', async (req, res) => {
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
if (!signedIn(req)) return res.redirect(303, loginTo(req));
if (!signedIn(req)) return res.redirect(303, loginTo(req));
@@ -66,7 +66,7 @@ const TABLES = [
@@ -66,7 +66,7 @@ const TABLES = [
// they picked. Never a key: the key itself is sealed in OpenVibe.AI's vault, and this row only names the
// they picked. Never a key: the key itself is sealed in OpenVibe.AI's vault, and this row only names the
// provider. It goes with the person.
// provider. It goes with the person.
{ table: 'actor_model_prefs', subject: 'requester', value: (usr) => `user:${usr}`, file: 'model-prefs.json',
{ table: 'actor_model_prefs', subject: 'requester', value: (usr) => `user:${usr}`, file: 'model-prefs.json',
- columns: ['requester', 'choice', 'model', 'updated_at'] },
+ columns: ['requester', 'choice', 'model', 'runs_on', 'updated_at'] },
// What they asked Actor in chat (chat/asks.js): the message it answered, the room, the task it became.
// What they asked Actor in chat (chat/asks.js): the message it answered, the room, the task it became.
{ table: 'actor_chat_asks', subject: 'requester', value: (usr) => `user:${usr}`, file: 'chat-asks.json' },
{ table: 'actor_chat_asks', subject: 'requester', value: (usr) => `user:${usr}`, file: 'chat-asks.json' },
// The connections they added (stage V2-11): the servers and agents, and which of their tools are on. The token
// The connections they added (stage V2-11): the servers and agents, and which of their tools are on. The token
@@ -18,6 +18,9 @@
@@ -18,6 +18,9 @@
* (server/identity/account-data.js).
* (server/identity/account-data.js).
*/
*/
const CHOICES = ['auto', 'openvibe'];
const CHOICES = ['auto', 'openvibe'];
+// Where the tasks run (plan L55 C3, migration 0027): OpenVibe's cloud, or the person's own paired machine through
+// OpenVibe.Codes (the codes agent), for every turn but robot and computer-control tasks.
+const RUNS_ON = ['openvibe', 'computer'];
const KEY_PROVIDERS = ['openai', 'anthropic', 'deepseek', 'google', 'openrouter', 'mistral'];
const KEY_PROVIDERS = ['openai', 'anthropic', 'deepseek', 'google', 'openrouter', 'mistral'];
// Plus their own computer (plan L55 C3): node is OpenVibe.AI's name for it.
// Plus their own computer (plan L55 C3): node is OpenVibe.AI's name for it.
const OWN_PROVIDERS = [...KEY_PROVIDERS, 'node'];
const OWN_PROVIDERS = [...KEY_PROVIDERS, 'node'];
@@ -45,7 +48,7 @@ function parse(body) {
@@ -45,7 +48,7 @@ function parse(body) {
/** The row for a person, or null (no row: the default is 'auto'). */
/** The row for a person, or null (no row: the default is 'auto'). */
async function get(s, requester) {
async function get(s, requester) {
if (!requester) return null;
if (!requester) return null;
- const row = await s.db.maybe('SELECT requester, choice, model, updated_at FROM actor_model_prefs WHERE requester = $1', [requester]);
+ const row = await s.db.maybe('SELECT requester, choice, model, runs_on, updated_at FROM actor_model_prefs WHERE requester = $1', [requester]);
return row || null;
return row || null;
}
}
@@ -75,15 +78,41 @@ async function choiceFor(s, requester) {
@@ -75,15 +78,41 @@ async function choiceFor(s, requester) {
return { subject, provider: row.choice.slice(4), model: row.model || null };
return { subject, provider: row.choice.slice(4), model: row.model || null };
}
}
+/** Where the person's tasks run: 'openvibe' (no row, or the default) or 'computer'. */
+async function runsOnFor(s, requester) {
+ const row = await get(s, requester);
+ return row && RUNS_ON.includes(row.runs_on) ? row.runs_on : 'openvibe';
+}
+
+/** The model choice "Runs on: my computer" means: the person's own computer, through OpenVibe.AI's node credential. */
+function computerChoice(requester) {
+ const subject = String(requester || '').startsWith('user:') ? requester.slice(5) : null;
+ return subject ? { subject, provider: 'node', model: null } : null;
+}
+
+/** Set where the person's tasks run. Returns { runs_on } or { error }; the model choice is left as it is. */
+async function setRunsOn(s, requester, value, now = () => new Date().toISOString()) {
+ const v = String(value == null ? '' : value).trim();
+ if (!RUNS_ON.includes(v)) return { error: 'runs_on: one of ' + RUNS_ON.join(', ') };
+ if (!requester) return { error: 'no person' };
+ await s.db.query(
+ "INSERT INTO actor_model_prefs (requester, choice, model, runs_on, updated_at) VALUES ($1, 'auto', NULL, $2, $3)"
+ + ' ON CONFLICT (requester) DO UPDATE SET runs_on = excluded.runs_on, updated_at = excluded.updated_at',
+ [requester, v, now()],
+ );
+ return { runs_on: v };
+}
+
/** The choice as the API answers it. */
/** The choice as the API answers it. */
function wire(row) {
function wire(row) {
- if (!row) return { choice: 'auto', provider: null, model: null, updated_at: null };
+ if (!row) return { choice: 'auto', provider: null, model: null, runs_on: 'openvibe', updated_at: null };
return {
return {
choice: row.choice,
choice: row.choice,
provider: String(row.choice || '').startsWith('own:') ? row.choice.slice(4) : null,
provider: String(row.choice || '').startsWith('own:') ? row.choice.slice(4) : null,
model: row.model || null,
model: row.model || null,
+ runs_on: RUNS_ON.includes(row.runs_on) ? row.runs_on : 'openvibe',
updated_at: row.updated_at || null,
updated_at: row.updated_at || null,
};
};
}
}
-module.exports = { CHOICES, OWN_PROVIDERS, KEY_PROVIDERS, parse, get, put, choiceFor, wire };
+module.exports = { CHOICES, RUNS_ON, OWN_PROVIDERS, KEY_PROVIDERS, parse, get, put, choiceFor, runsOnFor, setRunsOn, computerChoice, wire };
@@ -316,6 +316,11 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -316,6 +316,11 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
if (fromChat && !PUBLIC_CLASSES.includes(cls)) {
if (fromChat && !PUBLIC_CLASSES.includes(cls)) {
return await setState(id, 'failed', { error_code: 'actor.task.not_public', error_detail: 'Asked in a public chat, Actor uses public tools only: ask on openvibe.actor to use your own computers, robots or code.', finished_at: iso() });
return await setState(id, 'failed', { error_code: 'actor.task.not_public', error_detail: 'Asked in a public chat, Actor uses public tools only: ask on openvibe.actor to use your own computers, robots or code.', finished_at: iso() });
}
}
+ // Where the person's tasks run (plan L55 C3, /chat/model): 'computer' sends every model call of the loop to
+ // the model on their own computer, and a coding task to Codes on their machine. Never in private mode (the
+ // open model on OpenVibe's server is the point) nor for a public chat's ask.
+ const runsOn = row.mode === 'private' || fromChat || !String(row.requester || '').startsWith('user:')
+ ? 'openvibe' : await modelPrefs.runsOnFor(s, row.requester).catch(() => 'openvibe');
// Actor v2: every non-private task that is not code-only runs through the one loop. Private mode keeps
// Actor v2: every non-private task that is not code-only runs through the one loop. Private mode keeps
// the local model with no network tools, and a coding task keeps the Codes agent (which the loop can also
// the local model with no network tools, and a coding task keeps the Codes agent (which the loop can also
@@ -330,7 +335,8 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -330,7 +335,8 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
const notifyTask = (kind, detail) => tellPerson(row, kind, detail || {});
const notifyTask = (kind, detail) => tellPerson(row, kind, detail || {});
const why = resume ? `the one agent loop resumed after a restart (step ${resume.step})`
const why = resume ? `the one agent loop resumed after a restart (step ${resume.step})`
: fork ? `the one agent loop forked from step ${fork.step}`
: fork ? `the one agent loop forked from step ${fork.step}`
- : `the one agent loop (${row.mode === 'best' ? 'best' : 'smart'} tier)`;
+ : runsOn === 'computer' ? 'the one agent loop, thinking on the model on your computer (Runs on: my computer)'
+ : `the one agent loop (${row.mode === 'best' ? 'best' : 'smart'} tier)`;
explanation.push({ selected: agentId, objective: row.mode === 'best' ? 'correctness' : 'balanced', reasons: [why], candidates: [], decided_at: iso() });
explanation.push({ selected: agentId, objective: row.mode === 'best' ? 'correctness' : 'balanced', reasons: [why], candidates: [], decided_at: iso() });
await store.updateTask(s, id, { explanation });
await store.updateTask(s, id, { explanation });
if (!resume && !fork) await emit(id, { kind: 'output', step: 'plan', agent: agentId, text: 'The agent starts with a plan, then works through it.' });
if (!resume && !fork) await emit(id, { kind: 'output', step: 'plan', agent: agentId, text: 'The agent starts with a plan, then works through it.' });
@@ -352,7 +358,10 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -352,7 +358,10 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
const attachments = await inputs.forRun(s, id, { models, meter, signal, log }).catch((err) => { log.warn('[Actor] attachments:', err && err.message); return []; });
const attachments = await inputs.forRun(s, id, { models, meter, signal, log }).catch((err) => { log.warn('[Actor] attachments:', err && err.message); return []; });
// The person's own model choice for this run (plan L55 C3): their own key, through OpenVibe.AI.
// The person's own model choice for this run (plan L55 C3): their own key, through OpenVibe.AI.
// Every model call of the run — the loop's, the plan's, the check's — reads it, and no other run's does.
// Every model call of the run — the loop's, the plan's, the check's — reads it, and no other run's does.
- const choice = await modelPrefs.choiceFor(s, row.requester).catch(() => null);
+ let choice = await modelPrefs.choiceFor(s, row.requester).catch(() => null);
+ // "Runs on: my computer": the model on the person's computer, whatever else they chose above it (a
+ // model id they gave for their computer is kept).
+ if (runsOn === 'computer' && !(choice && choice.provider === 'node')) choice = modelPrefs.computerChoice(row.requester);
try {
try {
out = await withChoice(models, choice, async () => loop.run({
out = await withChoice(models, choice, async () => loop.run({
task: row.task, requester: fromChat ? null : row.requester, taskId: id, mode: row.mode, signal,
task: row.task, requester: fromChat ? null : row.requester, taskId: id, mode: row.mode, signal,
@@ -530,13 +539,18 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -530,13 +539,18 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
}
}
}
}
+ // "Runs on: my computer" (plan L55 C3): a coding task goes to the codes agent, OpenVibe.Codes on the person's
+ // own paired machine, unless the task already names its agent.
+ const pinnedAgent = row.agent || (runsOn === 'computer' && cls === 'code' ? 'codes' : null);
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
- const placement = route({ cls, mode: row.mode, agent: row.agent, budgetUsd: Math.max(0, budgetLeft()), config, tried, now: now(), floor: row.mode === 'private' ? null : floor });
+ const placement = route({ cls, mode: row.mode, agent: pinnedAgent, budgetUsd: Math.max(0, budgetLeft()), config, tried, now: now(), floor: row.mode === 'private' ? null : floor });
explanation.push(placement);
explanation.push(placement);
await store.updateTask(s, id, { explanation });
await store.updateTask(s, id, { explanation });
if (!placement.selected) {
if (!placement.selected) {
if (attempt > 1) break;
if (attempt > 1) break;
- const why = whyNone(placement, { cls, mode: row.mode, budgetUsd: budgetLeft() });
+ const why = pinnedAgent === 'codes' && !row.agent
+ ? { code: 'actor.no_agent', detail: 'Your coding tasks run on your own machine (Runs on: my computer) through OpenVibe.Codes, and it is not connected here. Add your machine on https://openvibe.bot/machines, or switch Runs on back to OpenVibe at https://openvibe.actor/chat/model.' }
+ : whyNone(placement, { cls, mode: row.mode, budgetUsd: budgetLeft() });
return await setState(id, 'failed', { error_code: why.code, error_detail: why.detail, finished_at: iso() });
return await setState(id, 'failed', { error_code: why.code, error_detail: why.detail, finished_at: iso() });
}
}
const agentId = placement.selected;
const agentId = placement.selected;
@@ -43,7 +43,7 @@ const owner = 'user:usr_01JZ0000000000000000000AAA';
@@ -43,7 +43,7 @@ const owner = 'user:usr_01JZ0000000000000000000AAA';
// 0017 on top of the running schema.
// 0017 on top of the running schema.
const second = await db.migrate({ dir: MIGRATIONS });
const second = await db.migrate({ dir: MIGRATIONS });
// 0025 is the person's own model choice (plan L55 C3): it is a migration of its own, like every one before it.
// 0025 is the person's own model choice (plan L55 C3): it is a migration of its own, like every one before it.
- assert.deepStrictEqual(second.applied.map((m) => m.id), ['0017', '0018', '0019', '0020', '0021', '0022', '0023', '0024', '0025', '0026']);
+ assert.deepStrictEqual(second.applied.map((m) => m.id), ['0017', '0018', '0019', '0020', '0021', '0022', '0023', '0024', '0025', '0026', '0027']);
await check('an old-style INSERT still works after 0017, and a 900-character row is intact', async () => {
await check('an old-style INSERT still works after 0017, and a 900-character row is intact', async () => {
const old = await db.maybe('SELECT id, text, origin, status, kind, uses FROM actor_agent_memory WHERE id = $1', [agentMemoryId]);
const old = await db.maybe('SELECT id, text, origin, status, kind, uses FROM actor_agent_memory WHERE id = $1', [agentMemoryId]);
new file mode 100644
new file mode 100644
@@ -0,0 +1,126 @@
@@ -0,0 +1,126 @@
+'use strict';
+// "Runs on: my computer" (plan L55 C3; the owner, 11-01: "a remote session, or a local agent session"): a person
+// chooses where their tasks run, beside the model they chose. On their computer, every model call of the agent loop
+// goes to the model on their own computer (OpenVibe.AI's node credential, whatever key they chose above it), and a
+// coding task is pinned to Codes on their machine. Private mode and a public chat's ask stay on OpenVibe.
+// node test/runs-on.test.js
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { createModels } = require('../server/runtime/models');
+const prefs = require('../server/runtime/model-prefs');
+
+const SAME = { 'sec-fetch-site': 'same-origin' };
+const quiet = { log() {}, warn() {}, error() {}, info() {} };
+const reply = (status, obj) => ({ ok: status >= 200 && status < 300, status, json: async () => obj, text: async () => JSON.stringify(obj) });
+
+/** Models that reach a fake OpenVibe.AI, and record every call. */
+function aiModels(calls) {
+ const config = {
+ models: { tiers: {}, providerOrder: ['openai'], listTimeoutMs: 200 },
+ providers: { openai: { apiKey: 'sk-fixture-operator-key', baseUrl: 'https://api.openai.test/v1', model: 'gpt-5-mini' } },
+ ai: { url: 'https://ai.openvibe.services', audience: 'openvibe.ai', scope: 'ai.run.create', clientId: 'actor', clientSecret: 'actor-secret', timeoutMs: 5000 },
+ networkInternalUrl: 'https://network.test', networkUrl: 'https://openvibe.network',
+ oauth: { clientId: 'actor', clientSecret: 'actor-secret' },
+ };
+ const fetchImpl = async (url, opts = {}) => {
+ const u = String(url);
+ if (u.includes('/oauth/token')) return reply(200, { access_token: 'tok_service_token', token_type: 'Bearer', expires_in: 300, scope: 'ai.run.create' });
+ calls.push({ url: u, headers: opts.headers || {} });
+ if (u.endsWith('/models')) return reply(200, { data: [{ id: 'gpt-5-mini' }] });
+ // The answer's check asks for a verdict as JSON; the run itself gets the answer.
+ const checking = String(opts.body || '').includes('\\"claims\\"');
+ const content = checking ? JSON.stringify({ claims: [{ claim: 'Two plus two is four.', kind: 'general', evidence: [], supported: true }], ok: true, reason: 'general knowledge' }) : 'Four.';
+ return reply(200, { choices: [{ message: { role: 'assistant', content }, finish_reason: 'stop' }], model: 'llama3', usage: { prompt_tokens: 4, completion_tokens: 1 }, x_openvibe: { cost_usd: 0 } });
+ };
+ return createModels({ config, fetchImpl, log: quiet });
+}
+
+(async () => {
+ const calls = [];
+ const models = aiModels(calls);
+ // The loop, reduced to one model call: what matters here is which model the run's calls go to.
+ const loop = {
+ run: async ({ task }) => { const r = await models.chat({ tier: 'smart', messages: [{ role: 'user', content: task }] }); return { text: r.text, state: {} }; },
+ close: async () => {}, planText: () => '', escalate: async () => null, revise: async () => null,
+ };
+ const t = await boot({ models, loop });
+ const kim = t.network.addUser('runs-kim');
+ try {
+ await check('the default is OpenVibe, and the API sets it alone without touching the model choice', async () => {
+ const before = await t.get('/api/v1/me/model', { as: kim });
+ assert.strictEqual(before.json().runs_on, 'openvibe');
+ await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { choice: 'own:openai' }, headers: SAME });
+ const put = await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { runs_on: 'computer' }, headers: SAME });
+ assert.strictEqual(put.status, 200, put.text);
+ assert.deepStrictEqual([put.json().runs_on, put.json().choice], ['computer', 'own:openai'], 'the model choice is kept');
+ const bad = await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { runs_on: 'the-moon' }, headers: SAME });
+ assert.strictEqual(bad.status, 422);
+ assert.strictEqual((await t.get('/api/v1/me/model', { as: kim })).json().runs_on, 'computer', 'the bad one never landed');
+ // A choice change keeps where it runs.
+ await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { choice: 'auto' }, headers: SAME });
+ assert.strictEqual((await t.get('/api/v1/me/model', { as: kim })).json().runs_on, 'computer');
+ });
+
+ await check('on their computer, the run\'s model calls go to the model on their own computer', async () => {
+ calls.length = 0;
+ const made = await t.get('/api/v1/tasks', { as: kim, json: { task: 'What is two plus two?' }, headers: SAME });
+ assert.strictEqual(made.status, 201, made.text);
+ const row = await t.waitFor(made.json().id);
+ assert.strictEqual(row.state, 'succeeded', JSON.stringify([row.error_code, row.error_detail]));
+ const loopCalls = calls.filter((c) => c.url.endsWith('/chat/completions'));
+ assert.ok(loopCalls.length >= 1, 'the loop called a model');
+ assert.ok(loopCalls.every((c) => c.headers['x-openvibe-credential'] === `node:${kim.subject || kim.id}`), JSON.stringify(loopCalls.map((c) => c.headers['x-openvibe-credential'])));
+ const why = JSON.stringify(row.explanation);
+ assert.ok(why.includes('on the model on your computer'), 'the explanation says where it ran');
+ });
+
+ await check('back on OpenVibe, the same person\'s run uses the model they chose (auto: no call to AI)', async () => {
+ await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { runs_on: 'openvibe' }, headers: SAME });
+ calls.length = 0;
+ const made = await t.get('/api/v1/tasks', { as: kim, json: { task: 'What is two plus two?' }, headers: SAME });
+ const row = await t.waitFor(made.json().id);
+ assert.strictEqual(row.state, 'succeeded', JSON.stringify([row.error_code, row.error_detail]));
+ assert.ok(calls.every((c) => !c.headers['x-openvibe-credential']), 'no person credential');
+ });
+
+ await check('private mode stays on OpenVibe\'s own open model even when it is set to the computer', async () => {
+ await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { runs_on: 'computer' }, headers: SAME });
+ calls.length = 0;
+ const made = await t.get('/api/v1/tasks', { as: kim, json: { task: 'What is two plus two?', mode: 'private' }, headers: SAME });
+ await t.waitFor(made.json().id);
+ assert.ok(calls.every((c) => !String(c.headers['x-openvibe-credential'] || '').startsWith('node:')), 'never their computer');
+ await t.get('/api/v1/me/model', { as: kim, method: 'PUT', json: { runs_on: 'openvibe' }, headers: SAME });
+ });
+
+ await check('/chat/model: the "Where it runs" form saves from openvibe.actor only', async () => {
+ const page = await t.get('/chat/model', { as: kim });
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('id="runs-on"'));
+ assert.ok(page.text.includes('name="runs_on" value="openvibe" checked'));
+ assert.ok(page.text.includes('https://ai.openvibe.services/you?allow=actor&provider=node'), 'set up the computer\'s model at OpenVibe.AI');
+ assert.ok(page.text.includes('https://openvibe.bot/machines'), 'add the machine for coding');
+ const saved = await t.get('/chat/runs-on', { as: kim, form: { runs_on: 'computer' }, headers: SAME });
+ assert.strictEqual(saved.status, 303);
+ assert.strictEqual(saved.headers.get('location'), '/chat/model?saved=runs#runs-on');
+ const after = await t.get('/chat/model?saved=runs', { as: kim });
+ assert.ok(after.text.includes('name="runs_on" value="computer" checked'));
+ assert.ok(after.text.includes('Your next task runs on your computer.'));
+ const cross = await t.get('/chat/runs-on', { as: kim, form: { runs_on: 'openvibe' }, headers: { 'sec-fetch-site': 'cross-site' } });
+ assert.strictEqual(cross.status, 403);
+ const bad = await t.get('/chat/runs-on', { as: kim, form: { runs_on: 'nope' }, headers: SAME });
+ assert.match(bad.headers.get('location'), /^\/chat\/model\?error=/);
+ assert.strictEqual((await t.get('/api/v1/me/model', { as: kim })).json().runs_on, 'computer');
+ const anon = await t.get('/chat/runs-on', { form: { runs_on: 'computer' }, headers: SAME });
+ assert.strictEqual(anon.status, 303);
+ assert.match(anon.headers.get('location'), /^\/auth\/login/);
+ });
+
+ await check('the choice "Runs on: my computer" means: AI\'s node credential, for a person only', async () => {
+ assert.deepStrictEqual(prefs.computerChoice('user:usr_1'), { subject: 'usr_1', provider: 'node', model: null });
+ assert.strictEqual(prefs.computerChoice('ip:1.2.3.4'), null);
+ assert.deepStrictEqual(prefs.RUNS_ON, ['openvibe', 'computer']);
+ assert.deepStrictEqual(prefs.wire(null).runs_on, 'openvibe');
+ });
+ } finally { await t.close(); }
+ done();
+})();