openvibers/openvibe.actor Commits
Add mid-run Actor model switching (#397)
Changes
nnew file mode 100644@@@ -0,0 +1,4 @@++-- phase: expand++-- A4: a model selected for the next step, and the switches that happened during an Actor run.++ALTER TABLE actor_day_runs ADD COLUMN model_override text;++ALTER TABLE actor_day_runs ADD COLUMN model_switches jsonb NOT NULL DEFAULT '[]'::jsonb;@@@ -95,7 +95,8 @@} else if (f.getAttribute('id') === 'ay-goal-form') {const id = fields.get('id'), body = { text:fields.get('text'),lane:fields.get('lane'),every_minutes:Math.round(Number(fields.get('hours'))*60),budget:{usd:Number(fields.get('usd')),minutes:Number(fields.get('minutes'))},asks_before:String(fields.get('asks_before')).split('\n').map(x=>x.trim()).filter(Boolean),schedule:{days:fields.getAll('day').map(Number),start:Number(fields.get('start')),end:Number(fields.get('end')),zone:fields.get('zone')} };await request('/goals' + (id ? '/' + id : ''),body,id?'PATCH':'POST'); $('#ay-goal-editor').close(); await refresh();-- } else if (f.getAttribute('id') === 'ay-earn') { await request('/earnings',{text:fields.get('text'),usd:Number(fields.get('usd')),confirmed:fields.get('confirmed')==='on'}); f.reset(); notice('Earnings added.'); location.reload(); }++ } else if (f.getAttribute('id') === 'ay-run-model') { await request('/runs/' + encodeURIComponent(f.dataset.task) + '/model',{model:fields.get('model')}); notice('The next step will use that model.'); await refresh(); }++ else if (f.getAttribute('id') === 'ay-earn') { await request('/earnings',{text:fields.get('text'),usd:Number(fields.get('usd')),confirmed:fields.get('confirmed')==='on'}); f.reset(); notice('Earnings added.'); location.reload(); }else if (f.getAttribute('id') === 'ay-computer-form') { const kind=fields.get('kind'); await request('',{computer:{kind,...(kind==='link'?{machine_id:fields.get('machine_id')}:{})}},'PATCH'); notice('Computer saved.'); await refresh(); }else if (f.getAttribute('id') === 'ay-type' && controlling) { const text=fields.get('text'); f.reset(); await input({action:'type',text}); }else if (f.dataset.answerForm) { await request('/asks/' + f.dataset.answerForm + '/answer',{text:fields.get('text')}); await refresh(); }@@@ -28,6 +28,7 @@ function load(env = process.env) {return {service: 'actor',++ a4ModelSwitch: env.ACTOR_A4_MODEL_SWITCH === '1' || env.ACTOR_A4_MODEL_SWITCH === 'true',fabric: { enabled: bool(env.ACTOR_WORK_FABRIC) },port,host: env.HOST || '127.0.0.1',@@@ -96,6 +96,34 @@ function createDay(ctx) {return s.db.maybe('UPDATE actor_day_goals SET schedule=$2::jsonb,brain_override=$3 WHERE id=$1 RETURNING *', [saved.id, JSON.stringify(v.schedule || null), v.brain_override || null]);});}++ async function switchRunModel(owner, taskId, model) {++ if (!config.a4ModelSwitch) fail('Model switching during a run is not enabled yet.', 404);++ const value = String(model == null ? '' : model).trim();++ if (value.length > 256 || !/^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,255}$/.test(value)) fail('Choose an available model.');++ const runRow = await s.db.maybe(`SELECT r.*,a.adult_opt_in FROM actor_day_runs r JOIN actors a ON a.id=r.actor_id++ JOIN tasks t ON t.id=r.task_id WHERE r.task_id=$1 AND r.owner=$2 AND t.state IN ('queued','running')`, [taskId, owner]);++ if (!runRow) fail('That run is no longer active.', 409);++ let selected = value;++ let unavailable = false;++ if (selected === 'own:node') selected = 'own:link';++ if (selected !== 'auto' && selected !== 'openvibe' && !/^own:(openai|anthropic|deepseek|google|openrouter|mistral|link)$/.test(selected)) {++ const entries = models.aiCatalog ? await models.aiCatalog() || [] : [];++ const entry = entries.find(m => m.id === selected && m.availability !== 'unavailable' && (runRow.adult_opt_in || !/uncensored|18\\+/i.test(JSON.stringify(m))));++ if (!entry) unavailable = true;++ if (entry && (entry.tool_calls === false || entry.capabilities?.tools === false)) fail('That model cannot use the tools this task needs. Choose a model that supports tools.');++ }++ if (unavailable) fail('Choose an available model from your account’s list.');++ return s.tx(async () => {++ const latest = await s.db.maybe(`SELECT r.model_override,t.state FROM actor_day_runs r JOIN tasks t ON t.id=r.task_id++ WHERE r.task_id=$1 AND r.owner=$2 FOR UPDATE OF r,t`, [taskId, owner]);++ if (!latest || !['queued','running'].includes(latest.state)) fail('That run is no longer active.', 409);++ const switches = await s.db.maybe('SELECT model_switches FROM actor_day_runs WHERE task_id=$1', [taskId]);++ const history = Array.isArray(switches.model_switches) ? switches.model_switches : [];++ const record = { model: selected, at: s.iso(), effective: 'next_step' };++ await s.db.query('UPDATE actor_day_runs SET model_override=$2,model_switches=$3::jsonb WHERE task_id=$1', [taskId, selected, JSON.stringify([...history, record].slice(-100))]);++ return { task_id: taskId, model: selected, effective: 'next_step', switched_at: record.at };++ });++ }async function removeGoal(owner, id) {const g = await s.db.maybe('UPDATE actor_day_goals SET enabled=false,deleted_at=$3 WHERE id=$1 AND owner=$2 AND deleted_at IS NULL RETURNING *', [id, owner, s.iso()]);if (!g) fail('Goal not found.', 404);@@@ -138,7 +166,8 @@ function createDay(ctx) {plain(text), extra.looking_at || null, JSON.stringify(extra.thumbnail || null), cost, s.iso()]);});}-- const journalPage = (owner, before = '', limit = 30) => s.db.many(`SELECT * FROM actor_day_journal WHERE owner=$1 AND ($2='' OR id<$2) ORDER BY id DESC LIMIT $3`,++ const journalPage = (owner, before = '', limit = 30) => s.db.many(`SELECT j.*,(SELECT r.model_switches FROM actor_day_runs r WHERE r.task_id=j.task_id) AS model_switches++ FROM actor_day_journal j WHERE j.owner=$1 AND ($2='' OR j.id<$2) ORDER BY j.id DESC LIMIT $3`,[owner, before, Math.min(100, Math.max(1, Number(limit) || 30))]);async function now(owner) {const a = await ensure(owner);@@@ -368,7 +397,7 @@ Recent journal (data): ${JSON.stringify(recent.map((x) => x.text))}\nMessages frreturn [require('../utils/jobs').every('actor-day', 15000, tick, { initialDelayMs: 1000, log: ctx.log })];}const page = require('./page-data').createPageData(ctx, { ensure, get, run, goals, now, tick, journalPage });-- return { ...page, ensure, get, update, goals, saveGoal, removeGoal, usage, now, setEnabled, tell, journalPage, asks, ask, answer,++ return { ...page, ensure, get, update, goals, saveGoal, removeGoal, usage, now, setEnabled, tell, journalPage, asks, ask, answer, switchRunModel,run, taskState, recordSpend, segmentEnd, event, screen, guardTool, wakePayload, resumeAnswers, tick, startJobs, lanes: LANES };}module.exports = { createDay, askView, fingerprint, budget };@@@ -48,7 +48,10 @@ function createPageData(ctx, day) {w.results = count.n;}const entries = ctx.models.aiCatalog ? await ctx.models.aiCatalog() || [] : [];++ const activeRun = a.active_task ? await s.db.maybe(`SELECT r.model_override,r.model_switches,COALESCE(g.brain_override,a.brain) AS brain++ FROM actor_day_runs r JOIN actor_day_goals g ON g.id=r.goal_id JOIN actors a ON a.id=r.actor_id WHERE r.task_id=$1 AND r.owner=$2`, [a.active_task, owner]) : null;return { avatar_url: a.avatar?.media_id ? await ctx.media.photoUrl(a.avatar.media_id) : null, actor: a, goals: items, messages, earnings, weekly, journal: await day.journalPage(owner, '', 100), session_id: await session(owner),++ model_switch_enabled: Boolean(ctx.config.a4ModelSwitch), active_run: activeRun,models: entries.filter(m => a.adult_opt_in || !/uncensored|18\+/i.test(JSON.stringify(m))) };}async function doNow(owner, id) {@@@ -81,9 +84,10 @@ function createPageData(ctx, day) {const r = await day.run(taskId);if (!r) return null;const subject = r.owner.slice(5);-- if (r.brain === 'auto') return require('../runtime/model-prefs').choiceFor(s, r.owner);-- if (r.brain.startsWith('own:')) return { subject, provider: r.brain.slice(4), model: null };-- return { subject, model: r.brain === 'openvibe' ? null : r.brain };++ const brain = r.model_override || r.brain;++ if (brain === 'auto') return require('../runtime/model-prefs').choiceFor(s, r.owner);++ if (brain.startsWith('own:')) return { subject, provider: brain.slice(4), model: null };++ return { subject, model: brain === 'openvibe' ? null : brain };}const retainDesk = async taskId => Boolean(await s.db.maybe('SELECT id FROM actor_day_asks WHERE task_id=$1 AND session_id IS NOT NULL AND answered IS NULL', [taskId]));return { session, computerCall, computerLog, sites, pageData, doNow, earn, retainDesk, modelChoice, stepMessages };@@@ -10,7 +10,7 @@ const time = v => v ? '<time datetime="' + e(v) + '">' + e(new Date(v).toISOStriconst card = (id, title, body) => '<section id="' + id + '" class="ay-card ovk-answer"><header class="ay-section-head"><h2>' + title + '</h2></header>' + body + '</section>';const input = (name, label, attrs = '') => '<label>' + e(label) + '<input name="' + name + '" ' + attrs + '></label>';function render(data, viewer = {}) {-- const { actor: a, goals = [], journal = [], messages = [], earnings = [], weekly = [], models = [], session_id: session } = data;++ const { actor: a, goals = [], journal = [], messages = [], earnings = [], weekly = [], models = [], session_id: session, model_switch_enabled: modelSwitch = false, active_run: activeRun = null } = data;const asks = a.asks || [], now = a.now || {};const state = asks.length ? 'Waiting for you' : !a.enabled ? 'Paused' : a.status === 'working' ? 'Working' : 'Resting';const today = data.today || new Date().toISOString().slice(0, 10);@@@ -23,7 +23,11 @@ function render(data, viewer = {}) {const screen = session ? '<div class="ay-screen" data-session="' + e(session) + '"><img id="ay-screen" src="/api/v1/me/actor/computer/still" alt="Your Actor’s live computer screen" tabindex="0"><div class="ay-screen-note" role="status"></div></div><div class="ay-actions">' + button('Take over', 'data-take') + button('Watch full screen', 'data-fullscreen') + '<button type="button" class="ovk-btn" data-back hidden>Hand back</button></div><form id="ay-type" hidden>' + input('text','Type on its screen','autocomplete="off" type="password" maxlength="8000"') + '<button class="ovk-btn">Type</button>' + button('Enter','data-key="Return"') + button('Tab','data-key="Tab"') + button('Backspace','data-key="BackSpace"') + '</form>' : '';const next = now.next ? '<p class="ay-next">Next: ' + e(now.next.text) + ' · ' + time(now.next.at) + '</p>' : '<p class="ay-next">Add a goal to give your Actor something to do.</p>';const spend = '<div class="ay-spend"><span>Today <strong>' + money(now.cost_today) + '</strong> of ' + money(a.caps.usd) + '</span><progress value="' + Math.min(Number(now.cost_today || 0), a.caps.usd) + '" max="' + (a.caps.usd || 1) + '" aria-label="Today’s spending"></progress></div>';-- const nowHtml = card('ay-now','Now', screen + '<p class="ay-current">' + (state === 'Working' ? pulse : '') + e(now.current_step || state) + '</p>' + (now.looking_at ? '<p class="ay-looking">' + thread.icon('read') + e(now.looking_at) + '</p>' : '') + '<ol class="ay-steps">' + (now.last_three_steps || []).map(j => '<li>' + time(j.created_at) + '<span>' + e(j.text) + '</span></li>').join('') + '</ol>' + next + (state === 'Resting' && now.next ? button('Do it now','data-action="do-now"',true) : '') + spend);++ const rawRunBrain = activeRun?.model_override || activeRun?.brain || a.brain;++ const runBrain = rawRunBrain === 'own:node' ? 'own:link' : rawRunBrain;++ const runOption = (id,label) => '<option value="'+e(id)+'" '+(runBrain===id?'selected':'')+'>'+e(label)+'</option>';++ const runModel = modelSwitch && a.active_task ? '<form id="ay-run-model" data-task="'+e(a.active_task)+'"><label>Model for the next step<select name="model">' + runOption('auto','Auto') + runOption('openvibe','OpenVibe') + models.map(m=>runOption(m.id,m.name||m.id)).join('') + ['openai','anthropic','deepseek','google','openrouter','mistral','link'].map(p=>runOption('own:'+p,'Your '+p)).join('') + '</select></label><button class="ovk-btn ovk-btn--primary">Use for next step</button><p class="ay-muted">The run record keeps its model steps and costs. Tools this model cannot use stay unavailable.</p></form>' : '';++ const nowHtml = card('ay-now','Now', screen + '<p class="ay-current">' + (state === 'Working' ? pulse : '') + e(now.current_step || state) + '</p>' + (now.looking_at ? '<p class="ay-looking">' + thread.icon('read') + e(now.looking_at) + '</p>' : '') + runModel + '<ol class="ay-steps">' + (now.last_three_steps || []).map(j => '<li>' + time(j.created_at) + '<span>' + e(j.text) + '</span></li>').join('') + '</ol>' + next + (state === 'Resting' && now.next ? button('Do it now','data-action="do-now"',true) : '') + spend);const asksHtml = asks.length ? card('ay-asks','Asks <span class="ovk-badge">' + asks.length + '</span>', asks.map(q => '<article class="ay-ask"><h3>' + e(q.text) + '</h3>' + (q.evidence?.text ? '<blockquote>' + e(q.evidence.text) + '</blockquote>' : '') + '<div class="ay-actions">' + q.options.map(o => q.kind === 'sign_in' && o !== 'Done' ? button('Sign in on its screen','data-sign-in',true) : button(o,'data-answer="' + e(q.id) + '" data-text="' + e(o) + '"',true)).join('') + button('Not now','data-decline="' + e(q.id) + '"') + '</div>' + (q.kind === 'answer_question' ? '<form data-answer-form="' + e(q.id) + '">' + input('text','Your answer','required maxlength="4000"') + '<button class="ovk-btn">Answer</button></form>' : '') + '</article>').join('')) : '<div id="ay-asks" hidden></div>';const tell = card('ay-tell','Tell it','<div class="ovt ay-thread" data-ov-thread>' + thread.composer({ id:'ay-tell-form',action:'/api/v1/me/actor/tell',placeholder:'Tell your Actor something…',maxlength:4000,attach:{name:'files',accept:'.txt,.md,.csv,.json'},note:'Small text files welcome. Your Actor reads this on its next step.' }) + '<div id="ay-replies">' + messages.slice().reverse().map(m => thread.turn({ id:m.id,you:{text:m.text},reply:m.reply ? thread.answer({html:'<p>' + e(m.reply.answer || m.reply.text || '') + '</p>'}) : '<p class="ay-muted">Kept for the next step.</p>' })).join('') + '</div></div>');function timeline(list) {@@@ -26,6 +26,7 @@ function router(ctx) {r.get('/', route((owner) => day.now(owner)));r.patch('/', route((owner, req) => day.update(owner, req.body)));r.get('/page', route(owner => day.pageData(owner)));++ r.post('/runs/:id/model', route((owner, req) => day.switchRunModel(owner, req.params.id, req.body && req.body.model)));r.post('/do-now', route((owner, req) => day.doNow(owner, req.body.goal)));r.post('/earnings', route((owner, req) => day.earn(owner, req.body), 201));r.get('/computer-log', route(owner => day.computerLog(owner)));@@@ -65,6 +65,9 @@ function parse(body) {name: String(m.display_name || m.name || id),description: typeof m.description === 'string' ? m.description : null,policy: typeof m.policy === 'string' ? m.policy : null,++ // The router's capability is the guardrail when a live task has tools. Unknown stays unknown; a model++ // that explicitly refuses tool calls cannot be switched onto a tool-using Actor run.++ tool_calls: m.capabilities && typeof m.capabilities.tools === 'boolean' ? m.capabilities.tools : undefined,output_nano_usd_per_m: typeof output === 'string' && /^\d+$/.test(output) ? output : null,availability,state: STATE[availability] || (dynamic ? 'ok' : 'off'),@@@ -537,7 +537,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =if (dayRun) choice = await day.modelChoice(id);// "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 === 'link')) choice = modelPrefs.computerChoice(row.requester);++ if (runsOn === 'computer' && !(choice && choice.provider === 'link') && !(dayRun && dayRun.model_override)) choice = modelPrefs.computerChoice(row.requester);// The person the task belongs to rides on every router call (on_behalf_of): their allowance pays,// even when they chose no key. A task with no person (a watch, a webhook) names nobody, and AI// serves it from free or untolled capacity only.@@@ -12,7 +12,7 @@ async function until(fn) {(async () => {let clock = Date.now();const notices = [];-- const t = await boot({ now: () => clock, env: { ACTOR_CHECKPOINT_KEY: crypto.randomBytes(32).toString('hex') },++ const t = await boot({ now: () => clock, env: { ACTOR_CHECKPOINT_KEY: crypto.randomBytes(32).toString('hex'), ACTOR_A4_MODEL_SWITCH: '1' },notifier: { needsYou: async (q) => { notices.push(q); }, taskEnded: async () => {} } });const api = (user, path = '', json, method) => t.get('/api/v1/me/actor' + path, { as: user, json, method, headers: SAME });try {@@@ -46,6 +46,15 @@ async function until(fn) {const q = await t.ctx.s.db.maybe('SELECT * FROM actor_day_asks WHERE id=$1', [ask.id]);await until(async () => { const w = await t.ctx.waits.get(q.wait_id); return w.remaining_ms != null; });const runBeforeAnswer = await t.ctx.day.run(id);++ const switched = await api(bea, `/runs/${id}/model`, { model: 'openvibe' });++ assert.equal(switched.status, 200, switched.text);++ assert.equal(switched.json().effective, 'next_step');++ assert.deepEqual((await t.ctx.s.db.maybe('SELECT model_override,model_switches FROM actor_day_runs WHERE task_id=$1', [id])).model_switches.map(x=>x.model), ['openvibe']);++ assert.deepEqual(await t.ctx.day.modelChoice(id), { subject: bea.subject, model: null });++ t.ctx.models.aiCatalog = async () => [{ id:'no-tools/model@rev1', availability:'available', tool_calls:false }];++ const refusedModel = await api(bea, `/runs/${id}/model`, { model:'no-tools/model@rev1' });++ assert.equal(refusedModel.status, 422);++ assert.match(refusedModel.json().detail,/cannot use the tools this task needs/);const calls = t.providers.loopCalls();await new Promise((r) => setTimeout(r, 100));assert.equal(t.providers.loopCalls(), calls, 'a parked goal makes no model calls');@@@ -61,6 +70,7 @@ async function until(fn) {assert.equal((await t.ctx.day.asks(owner)).length, 0);const journal = (await api(bea, '/journal')).json();assert.ok(journal.items.length > 0);++ assert.ok(journal.items.some(x => x.task_id === id && x.model_switches?.some(s => s.model === 'openvibe')), 'the Actor run record keeps model-switch history');assert.ok(journal.items.every((x) => x.goal_id === goal.id));assert.ok(Math.abs(journal.items.reduce((sum, x) => sum + Number(x.cost_usd), 0) - Number(end.cost_usd)) < 1e-8, 'journal step costs sum to the measured run cost');assert.ok((await tasks.getTask(t.ctx.s, id)).task.includes('Focus on the cost of living.'));@@@ -19,6 +19,8 @@ const SAME = { 'sec-fetch-site':'same-origin' };else {assert.doesNotMatch(render({...base,actor}),/id="ay-screen"/);assert.match(render({...base,actor,session_id:'ses_01J8Z4M2Q0R7T9YV3K6N8P1W2A'}),/id="ay-screen"/);}}assert.doesNotMatch(render(base),/Uncensored/);++ assert.doesNotMatch(render({...base, actor:{...a, active_task:'tsk_01J8Z4M2Q0R7T9YV3K6N8P1W2A'}, model_switch_enabled:false}),/ay-run-model/);++ assert.match(render({...base, actor:{...a, active_task:'tsk_01J8Z4M2Q0R7T9YV3K6N8P1W2A'}, active_run:{brain:'openvibe'}, model_switch_enabled:true}),/option value="openvibe" selected/);assert.match(render({...base,actor:{...a,adult_opt_in:true}}),/Uncensored/);assert.match(render({...base,actor:{...a,asks:[{id:'ask',kind:'sign_in',text:'Sign in to the site',options:['Sign in on its screen','Done']} ]}}),/Waiting for you/);const page=await t.get('/you',{as:user});assert.equal(page.status,200,page.text);assert.match(page.text,/ovt-composer/);@@@ -31,6 +33,7 @@ const SAME = { 'sec-fetch-site':'same-origin' };assert.equal((await api('/earnings',{text:'Paid project',usd:40,confirmed:false})).status,422);assert.equal((await api('/earnings',{text:'Paid project',usd:40,confirmed:true})).status,201);const data=(await api('/page')).json();assert.equal(data.messages[0].attachments[0].name,'notes.txt');assert.equal(data.earnings[0].usd,40);++ assert.equal((await api('/runs/tsk_01J8Z4M2Q0R7T9YV3K6N8P1W2A/model',{model:'openvibe'})).status,404,'model switching stays off unless the owner enables its switch');assert.equal((await t.get('/api/v1/me/actor/page',{as:other})).json().earnings.length,0);const schedule=require('../server/day/schedule');assert.equal(schedule.open(g.schedule,Date.parse('2026-11-09T18:00:00Z')),true);assert.equal(schedule.open(g.schedule,Date.parse('2026-11-08T18:00:00Z')),false);const calls=[];@@@ -93,6 +93,7 @@ function answer({ status = 200, resolved = EXACT, cost = 0.0012, body = null } =assert.strictEqual(aiCatalog.priceDescription('1000000000000'), '$1000.00 per million output tokens');assert.strictEqual(aiCatalog.priceDescription(null), 'Output price not listed');assert.deepStrictEqual(entries.map((e) => e.state), ['ok', 'ok', 'ok', 'ok', 'off'], 'availability as the select state');++ assert.strictEqual(entries.find(e => e.id === 'acme/m1:q4_k_m@rev1').tool_calls, true, 'tool support is retained for live Actor model switching');assert.strictEqual(entries[4].available, false, 'an unavailable exact id is not a choice');// The flag-off AI answer (one legacy model), an empty answer and rubbish are today's behaviour.
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
+-- phase: expand
+-- A4: a model selected for the next step, and the switches that happened during an Actor run.
+ALTER TABLE actor_day_runs ADD COLUMN model_override text;
+ALTER TABLE actor_day_runs ADD COLUMN model_switches jsonb NOT NULL DEFAULT '[]'::jsonb;
@@ -95,7 +95,8 @@
@@ -95,7 +95,8 @@
} else if (f.getAttribute('id') === 'ay-goal-form') {
} else if (f.getAttribute('id') === 'ay-goal-form') {
const id = fields.get('id'), body = { text:fields.get('text'),lane:fields.get('lane'),every_minutes:Math.round(Number(fields.get('hours'))*60),budget:{usd:Number(fields.get('usd')),minutes:Number(fields.get('minutes'))},asks_before:String(fields.get('asks_before')).split('\n').map(x=>x.trim()).filter(Boolean),schedule:{days:fields.getAll('day').map(Number),start:Number(fields.get('start')),end:Number(fields.get('end')),zone:fields.get('zone')} };
const id = fields.get('id'), body = { text:fields.get('text'),lane:fields.get('lane'),every_minutes:Math.round(Number(fields.get('hours'))*60),budget:{usd:Number(fields.get('usd')),minutes:Number(fields.get('minutes'))},asks_before:String(fields.get('asks_before')).split('\n').map(x=>x.trim()).filter(Boolean),schedule:{days:fields.getAll('day').map(Number),start:Number(fields.get('start')),end:Number(fields.get('end')),zone:fields.get('zone')} };
await request('/goals' + (id ? '/' + id : ''),body,id?'PATCH':'POST'); $('#ay-goal-editor').close(); await refresh();
await request('/goals' + (id ? '/' + id : ''),body,id?'PATCH':'POST'); $('#ay-goal-editor').close(); await refresh();
- } else if (f.getAttribute('id') === 'ay-earn') { await request('/earnings',{text:fields.get('text'),usd:Number(fields.get('usd')),confirmed:fields.get('confirmed')==='on'}); f.reset(); notice('Earnings added.'); location.reload(); }
+ } else if (f.getAttribute('id') === 'ay-run-model') { await request('/runs/' + encodeURIComponent(f.dataset.task) + '/model',{model:fields.get('model')}); notice('The next step will use that model.'); await refresh(); }
+ else if (f.getAttribute('id') === 'ay-earn') { await request('/earnings',{text:fields.get('text'),usd:Number(fields.get('usd')),confirmed:fields.get('confirmed')==='on'}); f.reset(); notice('Earnings added.'); location.reload(); }
else if (f.getAttribute('id') === 'ay-computer-form') { const kind=fields.get('kind'); await request('',{computer:{kind,...(kind==='link'?{machine_id:fields.get('machine_id')}:{})}},'PATCH'); notice('Computer saved.'); await refresh(); }
else if (f.getAttribute('id') === 'ay-computer-form') { const kind=fields.get('kind'); await request('',{computer:{kind,...(kind==='link'?{machine_id:fields.get('machine_id')}:{})}},'PATCH'); notice('Computer saved.'); await refresh(); }
else if (f.getAttribute('id') === 'ay-type' && controlling) { const text=fields.get('text'); f.reset(); await input({action:'type',text}); }
else if (f.getAttribute('id') === 'ay-type' && controlling) { const text=fields.get('text'); f.reset(); await input({action:'type',text}); }
else if (f.dataset.answerForm) { await request('/asks/' + f.dataset.answerForm + '/answer',{text:fields.get('text')}); await refresh(); }
else if (f.dataset.answerForm) { await request('/asks/' + f.dataset.answerForm + '/answer',{text:fields.get('text')}); await refresh(); }
@@ -28,6 +28,7 @@ function load(env = process.env) {
@@ -28,6 +28,7 @@ function load(env = process.env) {
return {
return {
service: 'actor',
service: 'actor',
+ a4ModelSwitch: env.ACTOR_A4_MODEL_SWITCH === '1' || env.ACTOR_A4_MODEL_SWITCH === 'true',
fabric: { enabled: bool(env.ACTOR_WORK_FABRIC) },
fabric: { enabled: bool(env.ACTOR_WORK_FABRIC) },
port,
port,
host: env.HOST || '127.0.0.1',
host: env.HOST || '127.0.0.1',
@@ -96,6 +96,34 @@ function createDay(ctx) {
@@ -96,6 +96,34 @@ function createDay(ctx) {
return s.db.maybe('UPDATE actor_day_goals SET schedule=$2::jsonb,brain_override=$3 WHERE id=$1 RETURNING *', [saved.id, JSON.stringify(v.schedule || null), v.brain_override || null]);
return s.db.maybe('UPDATE actor_day_goals SET schedule=$2::jsonb,brain_override=$3 WHERE id=$1 RETURNING *', [saved.id, JSON.stringify(v.schedule || null), v.brain_override || null]);
});
});
}
}
+ async function switchRunModel(owner, taskId, model) {
+ if (!config.a4ModelSwitch) fail('Model switching during a run is not enabled yet.', 404);
+ const value = String(model == null ? '' : model).trim();
+ if (value.length > 256 || !/^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,255}$/.test(value)) fail('Choose an available model.');
+ const runRow = await s.db.maybe(`SELECT r.*,a.adult_opt_in FROM actor_day_runs r JOIN actors a ON a.id=r.actor_id
+ JOIN tasks t ON t.id=r.task_id WHERE r.task_id=$1 AND r.owner=$2 AND t.state IN ('queued','running')`, [taskId, owner]);
+ if (!runRow) fail('That run is no longer active.', 409);
+ let selected = value;
+ let unavailable = false;
+ if (selected === 'own:node') selected = 'own:link';
+ if (selected !== 'auto' && selected !== 'openvibe' && !/^own:(openai|anthropic|deepseek|google|openrouter|mistral|link)$/.test(selected)) {
+ const entries = models.aiCatalog ? await models.aiCatalog() || [] : [];
+ const entry = entries.find(m => m.id === selected && m.availability !== 'unavailable' && (runRow.adult_opt_in || !/uncensored|18\\+/i.test(JSON.stringify(m))));
+ if (!entry) unavailable = true;
+ if (entry && (entry.tool_calls === false || entry.capabilities?.tools === false)) fail('That model cannot use the tools this task needs. Choose a model that supports tools.');
+ }
+ if (unavailable) fail('Choose an available model from your account’s list.');
+ return s.tx(async () => {
+ const latest = await s.db.maybe(`SELECT r.model_override,t.state FROM actor_day_runs r JOIN tasks t ON t.id=r.task_id
+ WHERE r.task_id=$1 AND r.owner=$2 FOR UPDATE OF r,t`, [taskId, owner]);
+ if (!latest || !['queued','running'].includes(latest.state)) fail('That run is no longer active.', 409);
+ const switches = await s.db.maybe('SELECT model_switches FROM actor_day_runs WHERE task_id=$1', [taskId]);
+ const history = Array.isArray(switches.model_switches) ? switches.model_switches : [];
+ const record = { model: selected, at: s.iso(), effective: 'next_step' };
+ await s.db.query('UPDATE actor_day_runs SET model_override=$2,model_switches=$3::jsonb WHERE task_id=$1', [taskId, selected, JSON.stringify([...history, record].slice(-100))]);
+ return { task_id: taskId, model: selected, effective: 'next_step', switched_at: record.at };
+ });
+ }
async function removeGoal(owner, id) {
async function removeGoal(owner, id) {
const g = await s.db.maybe('UPDATE actor_day_goals SET enabled=false,deleted_at=$3 WHERE id=$1 AND owner=$2 AND deleted_at IS NULL RETURNING *', [id, owner, s.iso()]);
const g = await s.db.maybe('UPDATE actor_day_goals SET enabled=false,deleted_at=$3 WHERE id=$1 AND owner=$2 AND deleted_at IS NULL RETURNING *', [id, owner, s.iso()]);
if (!g) fail('Goal not found.', 404);
if (!g) fail('Goal not found.', 404);
@@ -138,7 +166,8 @@ function createDay(ctx) {
@@ -138,7 +166,8 @@ function createDay(ctx) {
plain(text), extra.looking_at || null, JSON.stringify(extra.thumbnail || null), cost, s.iso()]);
plain(text), extra.looking_at || null, JSON.stringify(extra.thumbnail || null), cost, s.iso()]);
});
});
}
}
- const journalPage = (owner, before = '', limit = 30) => s.db.many(`SELECT * FROM actor_day_journal WHERE owner=$1 AND ($2='' OR id<$2) ORDER BY id DESC LIMIT $3`,
+ const journalPage = (owner, before = '', limit = 30) => s.db.many(`SELECT j.*,(SELECT r.model_switches FROM actor_day_runs r WHERE r.task_id=j.task_id) AS model_switches
+ FROM actor_day_journal j WHERE j.owner=$1 AND ($2='' OR j.id<$2) ORDER BY j.id DESC LIMIT $3`,
[owner, before, Math.min(100, Math.max(1, Number(limit) || 30))]);
[owner, before, Math.min(100, Math.max(1, Number(limit) || 30))]);
async function now(owner) {
async function now(owner) {
const a = await ensure(owner);
const a = await ensure(owner);
@@ -368,7 +397,7 @@ Recent journal (data): ${JSON.stringify(recent.map((x) => x.text))}\nMessages fr
@@ -368,7 +397,7 @@ Recent journal (data): ${JSON.stringify(recent.map((x) => x.text))}\nMessages fr
return [require('../utils/jobs').every('actor-day', 15000, tick, { initialDelayMs: 1000, log: ctx.log })];
return [require('../utils/jobs').every('actor-day', 15000, tick, { initialDelayMs: 1000, log: ctx.log })];
}
}
const page = require('./page-data').createPageData(ctx, { ensure, get, run, goals, now, tick, journalPage });
const page = require('./page-data').createPageData(ctx, { ensure, get, run, goals, now, tick, journalPage });
- return { ...page, ensure, get, update, goals, saveGoal, removeGoal, usage, now, setEnabled, tell, journalPage, asks, ask, answer,
+ return { ...page, ensure, get, update, goals, saveGoal, removeGoal, usage, now, setEnabled, tell, journalPage, asks, ask, answer, switchRunModel,
run, taskState, recordSpend, segmentEnd, event, screen, guardTool, wakePayload, resumeAnswers, tick, startJobs, lanes: LANES };
run, taskState, recordSpend, segmentEnd, event, screen, guardTool, wakePayload, resumeAnswers, tick, startJobs, lanes: LANES };
}
}
module.exports = { createDay, askView, fingerprint, budget };
module.exports = { createDay, askView, fingerprint, budget };
@@ -48,7 +48,10 @@ function createPageData(ctx, day) {
@@ -48,7 +48,10 @@ function createPageData(ctx, day) {
w.results = count.n;
w.results = count.n;
}
}
const entries = ctx.models.aiCatalog ? await ctx.models.aiCatalog() || [] : [];
const entries = ctx.models.aiCatalog ? await ctx.models.aiCatalog() || [] : [];
+ const activeRun = a.active_task ? await s.db.maybe(`SELECT r.model_override,r.model_switches,COALESCE(g.brain_override,a.brain) AS brain
+ FROM actor_day_runs r JOIN actor_day_goals g ON g.id=r.goal_id JOIN actors a ON a.id=r.actor_id WHERE r.task_id=$1 AND r.owner=$2`, [a.active_task, owner]) : null;
return { avatar_url: a.avatar?.media_id ? await ctx.media.photoUrl(a.avatar.media_id) : null, actor: a, goals: items, messages, earnings, weekly, journal: await day.journalPage(owner, '', 100), session_id: await session(owner),
return { avatar_url: a.avatar?.media_id ? await ctx.media.photoUrl(a.avatar.media_id) : null, actor: a, goals: items, messages, earnings, weekly, journal: await day.journalPage(owner, '', 100), session_id: await session(owner),
+ model_switch_enabled: Boolean(ctx.config.a4ModelSwitch), active_run: activeRun,
models: entries.filter(m => a.adult_opt_in || !/uncensored|18\+/i.test(JSON.stringify(m))) };
models: entries.filter(m => a.adult_opt_in || !/uncensored|18\+/i.test(JSON.stringify(m))) };
}
}
async function doNow(owner, id) {
async function doNow(owner, id) {
@@ -81,9 +84,10 @@ function createPageData(ctx, day) {
@@ -81,9 +84,10 @@ function createPageData(ctx, day) {
const r = await day.run(taskId);
const r = await day.run(taskId);
if (!r) return null;
if (!r) return null;
const subject = r.owner.slice(5);
const subject = r.owner.slice(5);
- if (r.brain === 'auto') return require('../runtime/model-prefs').choiceFor(s, r.owner);
- if (r.brain.startsWith('own:')) return { subject, provider: r.brain.slice(4), model: null };
- return { subject, model: r.brain === 'openvibe' ? null : r.brain };
+ const brain = r.model_override || r.brain;
+ if (brain === 'auto') return require('../runtime/model-prefs').choiceFor(s, r.owner);
+ if (brain.startsWith('own:')) return { subject, provider: brain.slice(4), model: null };
+ return { subject, model: brain === 'openvibe' ? null : brain };
}
}
const retainDesk = async taskId => Boolean(await s.db.maybe('SELECT id FROM actor_day_asks WHERE task_id=$1 AND session_id IS NOT NULL AND answered IS NULL', [taskId]));
const retainDesk = async taskId => Boolean(await s.db.maybe('SELECT id FROM actor_day_asks WHERE task_id=$1 AND session_id IS NOT NULL AND answered IS NULL', [taskId]));
return { session, computerCall, computerLog, sites, pageData, doNow, earn, retainDesk, modelChoice, stepMessages };
return { session, computerCall, computerLog, sites, pageData, doNow, earn, retainDesk, modelChoice, stepMessages };
@@ -10,7 +10,7 @@ const time = v => v ? '<time datetime="' + e(v) + '">' + e(new Date(v).toISOStri
@@ -10,7 +10,7 @@ const time = v => v ? '<time datetime="' + e(v) + '">' + e(new Date(v).toISOStri
const card = (id, title, body) => '<section id="' + id + '" class="ay-card ovk-answer"><header class="ay-section-head"><h2>' + title + '</h2></header>' + body + '</section>';
const card = (id, title, body) => '<section id="' + id + '" class="ay-card ovk-answer"><header class="ay-section-head"><h2>' + title + '</h2></header>' + body + '</section>';
const input = (name, label, attrs = '') => '<label>' + e(label) + '<input name="' + name + '" ' + attrs + '></label>';
const input = (name, label, attrs = '') => '<label>' + e(label) + '<input name="' + name + '" ' + attrs + '></label>';
function render(data, viewer = {}) {
function render(data, viewer = {}) {
- const { actor: a, goals = [], journal = [], messages = [], earnings = [], weekly = [], models = [], session_id: session } = data;
+ const { actor: a, goals = [], journal = [], messages = [], earnings = [], weekly = [], models = [], session_id: session, model_switch_enabled: modelSwitch = false, active_run: activeRun = null } = data;
const asks = a.asks || [], now = a.now || {};
const asks = a.asks || [], now = a.now || {};
const state = asks.length ? 'Waiting for you' : !a.enabled ? 'Paused' : a.status === 'working' ? 'Working' : 'Resting';
const state = asks.length ? 'Waiting for you' : !a.enabled ? 'Paused' : a.status === 'working' ? 'Working' : 'Resting';
const today = data.today || new Date().toISOString().slice(0, 10);
const today = data.today || new Date().toISOString().slice(0, 10);
@@ -23,7 +23,11 @@ function render(data, viewer = {}) {
@@ -23,7 +23,11 @@ function render(data, viewer = {}) {
const screen = session ? '<div class="ay-screen" data-session="' + e(session) + '"><img id="ay-screen" src="/api/v1/me/actor/computer/still" alt="Your Actor’s live computer screen" tabindex="0"><div class="ay-screen-note" role="status"></div></div><div class="ay-actions">' + button('Take over', 'data-take') + button('Watch full screen', 'data-fullscreen') + '<button type="button" class="ovk-btn" data-back hidden>Hand back</button></div><form id="ay-type" hidden>' + input('text','Type on its screen','autocomplete="off" type="password" maxlength="8000"') + '<button class="ovk-btn">Type</button>' + button('Enter','data-key="Return"') + button('Tab','data-key="Tab"') + button('Backspace','data-key="BackSpace"') + '</form>' : '';
const screen = session ? '<div class="ay-screen" data-session="' + e(session) + '"><img id="ay-screen" src="/api/v1/me/actor/computer/still" alt="Your Actor’s live computer screen" tabindex="0"><div class="ay-screen-note" role="status"></div></div><div class="ay-actions">' + button('Take over', 'data-take') + button('Watch full screen', 'data-fullscreen') + '<button type="button" class="ovk-btn" data-back hidden>Hand back</button></div><form id="ay-type" hidden>' + input('text','Type on its screen','autocomplete="off" type="password" maxlength="8000"') + '<button class="ovk-btn">Type</button>' + button('Enter','data-key="Return"') + button('Tab','data-key="Tab"') + button('Backspace','data-key="BackSpace"') + '</form>' : '';
const next = now.next ? '<p class="ay-next">Next: ' + e(now.next.text) + ' · ' + time(now.next.at) + '</p>' : '<p class="ay-next">Add a goal to give your Actor something to do.</p>';
const next = now.next ? '<p class="ay-next">Next: ' + e(now.next.text) + ' · ' + time(now.next.at) + '</p>' : '<p class="ay-next">Add a goal to give your Actor something to do.</p>';
const spend = '<div class="ay-spend"><span>Today <strong>' + money(now.cost_today) + '</strong> of ' + money(a.caps.usd) + '</span><progress value="' + Math.min(Number(now.cost_today || 0), a.caps.usd) + '" max="' + (a.caps.usd || 1) + '" aria-label="Today’s spending"></progress></div>';
const spend = '<div class="ay-spend"><span>Today <strong>' + money(now.cost_today) + '</strong> of ' + money(a.caps.usd) + '</span><progress value="' + Math.min(Number(now.cost_today || 0), a.caps.usd) + '" max="' + (a.caps.usd || 1) + '" aria-label="Today’s spending"></progress></div>';
- const nowHtml = card('ay-now','Now', screen + '<p class="ay-current">' + (state === 'Working' ? pulse : '') + e(now.current_step || state) + '</p>' + (now.looking_at ? '<p class="ay-looking">' + thread.icon('read') + e(now.looking_at) + '</p>' : '') + '<ol class="ay-steps">' + (now.last_three_steps || []).map(j => '<li>' + time(j.created_at) + '<span>' + e(j.text) + '</span></li>').join('') + '</ol>' + next + (state === 'Resting' && now.next ? button('Do it now','data-action="do-now"',true) : '') + spend);
+ const rawRunBrain = activeRun?.model_override || activeRun?.brain || a.brain;
+ const runBrain = rawRunBrain === 'own:node' ? 'own:link' : rawRunBrain;
+ const runOption = (id,label) => '<option value="'+e(id)+'" '+(runBrain===id?'selected':'')+'>'+e(label)+'</option>';
+ const runModel = modelSwitch && a.active_task ? '<form id="ay-run-model" data-task="'+e(a.active_task)+'"><label>Model for the next step<select name="model">' + runOption('auto','Auto') + runOption('openvibe','OpenVibe') + models.map(m=>runOption(m.id,m.name||m.id)).join('') + ['openai','anthropic','deepseek','google','openrouter','mistral','link'].map(p=>runOption('own:'+p,'Your '+p)).join('') + '</select></label><button class="ovk-btn ovk-btn--primary">Use for next step</button><p class="ay-muted">The run record keeps its model steps and costs. Tools this model cannot use stay unavailable.</p></form>' : '';
+ const nowHtml = card('ay-now','Now', screen + '<p class="ay-current">' + (state === 'Working' ? pulse : '') + e(now.current_step || state) + '</p>' + (now.looking_at ? '<p class="ay-looking">' + thread.icon('read') + e(now.looking_at) + '</p>' : '') + runModel + '<ol class="ay-steps">' + (now.last_three_steps || []).map(j => '<li>' + time(j.created_at) + '<span>' + e(j.text) + '</span></li>').join('') + '</ol>' + next + (state === 'Resting' && now.next ? button('Do it now','data-action="do-now"',true) : '') + spend);
const asksHtml = asks.length ? card('ay-asks','Asks <span class="ovk-badge">' + asks.length + '</span>', asks.map(q => '<article class="ay-ask"><h3>' + e(q.text) + '</h3>' + (q.evidence?.text ? '<blockquote>' + e(q.evidence.text) + '</blockquote>' : '') + '<div class="ay-actions">' + q.options.map(o => q.kind === 'sign_in' && o !== 'Done' ? button('Sign in on its screen','data-sign-in',true) : button(o,'data-answer="' + e(q.id) + '" data-text="' + e(o) + '"',true)).join('') + button('Not now','data-decline="' + e(q.id) + '"') + '</div>' + (q.kind === 'answer_question' ? '<form data-answer-form="' + e(q.id) + '">' + input('text','Your answer','required maxlength="4000"') + '<button class="ovk-btn">Answer</button></form>' : '') + '</article>').join('')) : '<div id="ay-asks" hidden></div>';
const asksHtml = asks.length ? card('ay-asks','Asks <span class="ovk-badge">' + asks.length + '</span>', asks.map(q => '<article class="ay-ask"><h3>' + e(q.text) + '</h3>' + (q.evidence?.text ? '<blockquote>' + e(q.evidence.text) + '</blockquote>' : '') + '<div class="ay-actions">' + q.options.map(o => q.kind === 'sign_in' && o !== 'Done' ? button('Sign in on its screen','data-sign-in',true) : button(o,'data-answer="' + e(q.id) + '" data-text="' + e(o) + '"',true)).join('') + button('Not now','data-decline="' + e(q.id) + '"') + '</div>' + (q.kind === 'answer_question' ? '<form data-answer-form="' + e(q.id) + '">' + input('text','Your answer','required maxlength="4000"') + '<button class="ovk-btn">Answer</button></form>' : '') + '</article>').join('')) : '<div id="ay-asks" hidden></div>';
const tell = card('ay-tell','Tell it','<div class="ovt ay-thread" data-ov-thread>' + thread.composer({ id:'ay-tell-form',action:'/api/v1/me/actor/tell',placeholder:'Tell your Actor something…',maxlength:4000,attach:{name:'files',accept:'.txt,.md,.csv,.json'},note:'Small text files welcome. Your Actor reads this on its next step.' }) + '<div id="ay-replies">' + messages.slice().reverse().map(m => thread.turn({ id:m.id,you:{text:m.text},reply:m.reply ? thread.answer({html:'<p>' + e(m.reply.answer || m.reply.text || '') + '</p>'}) : '<p class="ay-muted">Kept for the next step.</p>' })).join('') + '</div></div>');
const tell = card('ay-tell','Tell it','<div class="ovt ay-thread" data-ov-thread>' + thread.composer({ id:'ay-tell-form',action:'/api/v1/me/actor/tell',placeholder:'Tell your Actor something…',maxlength:4000,attach:{name:'files',accept:'.txt,.md,.csv,.json'},note:'Small text files welcome. Your Actor reads this on its next step.' }) + '<div id="ay-replies">' + messages.slice().reverse().map(m => thread.turn({ id:m.id,you:{text:m.text},reply:m.reply ? thread.answer({html:'<p>' + e(m.reply.answer || m.reply.text || '') + '</p>'}) : '<p class="ay-muted">Kept for the next step.</p>' })).join('') + '</div></div>');
function timeline(list) {
function timeline(list) {
@@ -26,6 +26,7 @@ function router(ctx) {
@@ -26,6 +26,7 @@ function router(ctx) {
r.get('/', route((owner) => day.now(owner)));
r.get('/', route((owner) => day.now(owner)));
r.patch('/', route((owner, req) => day.update(owner, req.body)));
r.patch('/', route((owner, req) => day.update(owner, req.body)));
r.get('/page', route(owner => day.pageData(owner)));
r.get('/page', route(owner => day.pageData(owner)));
+ r.post('/runs/:id/model', route((owner, req) => day.switchRunModel(owner, req.params.id, req.body && req.body.model)));
r.post('/do-now', route((owner, req) => day.doNow(owner, req.body.goal)));
r.post('/do-now', route((owner, req) => day.doNow(owner, req.body.goal)));
r.post('/earnings', route((owner, req) => day.earn(owner, req.body), 201));
r.post('/earnings', route((owner, req) => day.earn(owner, req.body), 201));
r.get('/computer-log', route(owner => day.computerLog(owner)));
r.get('/computer-log', route(owner => day.computerLog(owner)));
@@ -65,6 +65,9 @@ function parse(body) {
@@ -65,6 +65,9 @@ function parse(body) {
name: String(m.display_name || m.name || id),
name: String(m.display_name || m.name || id),
description: typeof m.description === 'string' ? m.description : null,
description: typeof m.description === 'string' ? m.description : null,
policy: typeof m.policy === 'string' ? m.policy : null,
policy: typeof m.policy === 'string' ? m.policy : null,
+ // The router's capability is the guardrail when a live task has tools. Unknown stays unknown; a model
+ // that explicitly refuses tool calls cannot be switched onto a tool-using Actor run.
+ tool_calls: m.capabilities && typeof m.capabilities.tools === 'boolean' ? m.capabilities.tools : undefined,
output_nano_usd_per_m: typeof output === 'string' && /^\d+$/.test(output) ? output : null,
output_nano_usd_per_m: typeof output === 'string' && /^\d+$/.test(output) ? output : null,
availability,
availability,
state: STATE[availability] || (dynamic ? 'ok' : 'off'),
state: STATE[availability] || (dynamic ? 'ok' : 'off'),
@@ -537,7 +537,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -537,7 +537,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
if (dayRun) choice = await day.modelChoice(id);
if (dayRun) choice = await day.modelChoice(id);
// "Runs on: my computer": the model on the person's computer, whatever else they chose above it (a
// "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).
// model id they gave for their computer is kept).
- if (runsOn === 'computer' && !(choice && choice.provider === 'link')) choice = modelPrefs.computerChoice(row.requester);
+ if (runsOn === 'computer' && !(choice && choice.provider === 'link') && !(dayRun && dayRun.model_override)) choice = modelPrefs.computerChoice(row.requester);
// The person the task belongs to rides on every router call (on_behalf_of): their allowance pays,
// The person the task belongs to rides on every router call (on_behalf_of): their allowance pays,
// even when they chose no key. A task with no person (a watch, a webhook) names nobody, and AI
// even when they chose no key. A task with no person (a watch, a webhook) names nobody, and AI
// serves it from free or untolled capacity only.
// serves it from free or untolled capacity only.
@@ -12,7 +12,7 @@ async function until(fn) {
@@ -12,7 +12,7 @@ async function until(fn) {
(async () => {
(async () => {
let clock = Date.now();
let clock = Date.now();
const notices = [];
const notices = [];
- const t = await boot({ now: () => clock, env: { ACTOR_CHECKPOINT_KEY: crypto.randomBytes(32).toString('hex') },
+ const t = await boot({ now: () => clock, env: { ACTOR_CHECKPOINT_KEY: crypto.randomBytes(32).toString('hex'), ACTOR_A4_MODEL_SWITCH: '1' },
notifier: { needsYou: async (q) => { notices.push(q); }, taskEnded: async () => {} } });
notifier: { needsYou: async (q) => { notices.push(q); }, taskEnded: async () => {} } });
const api = (user, path = '', json, method) => t.get('/api/v1/me/actor' + path, { as: user, json, method, headers: SAME });
const api = (user, path = '', json, method) => t.get('/api/v1/me/actor' + path, { as: user, json, method, headers: SAME });
try {
try {
@@ -46,6 +46,15 @@ async function until(fn) {
@@ -46,6 +46,15 @@ async function until(fn) {
const q = await t.ctx.s.db.maybe('SELECT * FROM actor_day_asks WHERE id=$1', [ask.id]);
const q = await t.ctx.s.db.maybe('SELECT * FROM actor_day_asks WHERE id=$1', [ask.id]);
await until(async () => { const w = await t.ctx.waits.get(q.wait_id); return w.remaining_ms != null; });
await until(async () => { const w = await t.ctx.waits.get(q.wait_id); return w.remaining_ms != null; });
const runBeforeAnswer = await t.ctx.day.run(id);
const runBeforeAnswer = await t.ctx.day.run(id);
+ const switched = await api(bea, `/runs/${id}/model`, { model: 'openvibe' });
+ assert.equal(switched.status, 200, switched.text);
+ assert.equal(switched.json().effective, 'next_step');
+ assert.deepEqual((await t.ctx.s.db.maybe('SELECT model_override,model_switches FROM actor_day_runs WHERE task_id=$1', [id])).model_switches.map(x=>x.model), ['openvibe']);
+ assert.deepEqual(await t.ctx.day.modelChoice(id), { subject: bea.subject, model: null });
+ t.ctx.models.aiCatalog = async () => [{ id:'no-tools/model@rev1', availability:'available', tool_calls:false }];
+ const refusedModel = await api(bea, `/runs/${id}/model`, { model:'no-tools/model@rev1' });
+ assert.equal(refusedModel.status, 422);
+ assert.match(refusedModel.json().detail,/cannot use the tools this task needs/);
const calls = t.providers.loopCalls();
const calls = t.providers.loopCalls();
await new Promise((r) => setTimeout(r, 100));
await new Promise((r) => setTimeout(r, 100));
assert.equal(t.providers.loopCalls(), calls, 'a parked goal makes no model calls');
assert.equal(t.providers.loopCalls(), calls, 'a parked goal makes no model calls');
@@ -61,6 +70,7 @@ async function until(fn) {
@@ -61,6 +70,7 @@ async function until(fn) {
assert.equal((await t.ctx.day.asks(owner)).length, 0);
assert.equal((await t.ctx.day.asks(owner)).length, 0);
const journal = (await api(bea, '/journal')).json();
const journal = (await api(bea, '/journal')).json();
assert.ok(journal.items.length > 0);
assert.ok(journal.items.length > 0);
+ assert.ok(journal.items.some(x => x.task_id === id && x.model_switches?.some(s => s.model === 'openvibe')), 'the Actor run record keeps model-switch history');
assert.ok(journal.items.every((x) => x.goal_id === goal.id));
assert.ok(journal.items.every((x) => x.goal_id === goal.id));
assert.ok(Math.abs(journal.items.reduce((sum, x) => sum + Number(x.cost_usd), 0) - Number(end.cost_usd)) < 1e-8, 'journal step costs sum to the measured run cost');
assert.ok(Math.abs(journal.items.reduce((sum, x) => sum + Number(x.cost_usd), 0) - Number(end.cost_usd)) < 1e-8, 'journal step costs sum to the measured run cost');
assert.ok((await tasks.getTask(t.ctx.s, id)).task.includes('Focus on the cost of living.'));
assert.ok((await tasks.getTask(t.ctx.s, id)).task.includes('Focus on the cost of living.'));
@@ -19,6 +19,8 @@ const SAME = { 'sec-fetch-site':'same-origin' };
@@ -19,6 +19,8 @@ const SAME = { 'sec-fetch-site':'same-origin' };
else {assert.doesNotMatch(render({...base,actor}),/id="ay-screen"/);assert.match(render({...base,actor,session_id:'ses_01J8Z4M2Q0R7T9YV3K6N8P1W2A'}),/id="ay-screen"/);}
else {assert.doesNotMatch(render({...base,actor}),/id="ay-screen"/);assert.match(render({...base,actor,session_id:'ses_01J8Z4M2Q0R7T9YV3K6N8P1W2A'}),/id="ay-screen"/);}
}
}
assert.doesNotMatch(render(base),/Uncensored/);
assert.doesNotMatch(render(base),/Uncensored/);
+ assert.doesNotMatch(render({...base, actor:{...a, active_task:'tsk_01J8Z4M2Q0R7T9YV3K6N8P1W2A'}, model_switch_enabled:false}),/ay-run-model/);
+ assert.match(render({...base, actor:{...a, active_task:'tsk_01J8Z4M2Q0R7T9YV3K6N8P1W2A'}, active_run:{brain:'openvibe'}, model_switch_enabled:true}),/option value="openvibe" selected/);
assert.match(render({...base,actor:{...a,adult_opt_in:true}}),/Uncensored/);
assert.match(render({...base,actor:{...a,adult_opt_in:true}}),/Uncensored/);
assert.match(render({...base,actor:{...a,asks:[{id:'ask',kind:'sign_in',text:'Sign in to the site',options:['Sign in on its screen','Done']} ]}}),/Waiting for you/);
assert.match(render({...base,actor:{...a,asks:[{id:'ask',kind:'sign_in',text:'Sign in to the site',options:['Sign in on its screen','Done']} ]}}),/Waiting for you/);
const page=await t.get('/you',{as:user});assert.equal(page.status,200,page.text);assert.match(page.text,/ovt-composer/);
const page=await t.get('/you',{as:user});assert.equal(page.status,200,page.text);assert.match(page.text,/ovt-composer/);
@@ -31,6 +33,7 @@ const SAME = { 'sec-fetch-site':'same-origin' };
@@ -31,6 +33,7 @@ const SAME = { 'sec-fetch-site':'same-origin' };
assert.equal((await api('/earnings',{text:'Paid project',usd:40,confirmed:false})).status,422);
assert.equal((await api('/earnings',{text:'Paid project',usd:40,confirmed:false})).status,422);
assert.equal((await api('/earnings',{text:'Paid project',usd:40,confirmed:true})).status,201);
assert.equal((await api('/earnings',{text:'Paid project',usd:40,confirmed:true})).status,201);
const data=(await api('/page')).json();assert.equal(data.messages[0].attachments[0].name,'notes.txt');assert.equal(data.earnings[0].usd,40);
const data=(await api('/page')).json();assert.equal(data.messages[0].attachments[0].name,'notes.txt');assert.equal(data.earnings[0].usd,40);
+ assert.equal((await api('/runs/tsk_01J8Z4M2Q0R7T9YV3K6N8P1W2A/model',{model:'openvibe'})).status,404,'model switching stays off unless the owner enables its switch');
assert.equal((await t.get('/api/v1/me/actor/page',{as:other})).json().earnings.length,0);
assert.equal((await t.get('/api/v1/me/actor/page',{as:other})).json().earnings.length,0);
const schedule=require('../server/day/schedule');assert.equal(schedule.open(g.schedule,Date.parse('2026-11-09T18:00:00Z')),true);assert.equal(schedule.open(g.schedule,Date.parse('2026-11-08T18:00:00Z')),false);
const schedule=require('../server/day/schedule');assert.equal(schedule.open(g.schedule,Date.parse('2026-11-09T18:00:00Z')),true);assert.equal(schedule.open(g.schedule,Date.parse('2026-11-08T18:00:00Z')),false);
const calls=[];
const calls=[];
@@ -93,6 +93,7 @@ function answer({ status = 200, resolved = EXACT, cost = 0.0012, body = null } =
@@ -93,6 +93,7 @@ function answer({ status = 200, resolved = EXACT, cost = 0.0012, body = null } =
assert.strictEqual(aiCatalog.priceDescription('1000000000000'), '$1000.00 per million output tokens');
assert.strictEqual(aiCatalog.priceDescription('1000000000000'), '$1000.00 per million output tokens');
assert.strictEqual(aiCatalog.priceDescription(null), 'Output price not listed');
assert.strictEqual(aiCatalog.priceDescription(null), 'Output price not listed');
assert.deepStrictEqual(entries.map((e) => e.state), ['ok', 'ok', 'ok', 'ok', 'off'], 'availability as the select state');
assert.deepStrictEqual(entries.map((e) => e.state), ['ok', 'ok', 'ok', 'ok', 'off'], 'availability as the select state');
+ assert.strictEqual(entries.find(e => e.id === 'acme/m1:q4_k_m@rev1').tool_calls, true, 'tool support is retained for live Actor model switching');
assert.strictEqual(entries[4].available, false, 'an unavailable exact id is not a choice');
assert.strictEqual(entries[4].available, false, 'an unavailable exact id is not a choice');
// The flag-off AI answer (one legacy model), an empty answer and rubbish are today's behaviour.
// The flag-off AI answer (one legacy model), an empty answer and rubbish are today's behaviour.