openvibers/openvibe.actor Commits

Add mid-run Actor model switching (#397)

OP OpenVibers committed 3 days ago · bca9347deb · 12 files, +71 −10

* Add mid-run Actor model switching

* Show the active Actor model in the switcher

Changes

ddiff --git a/migrations/0061_actor_run_model_switch.sql b/migrations/0061_actor_run_model_switch.sql
nnew file mode 100644
iindex 0000000..f8aa0b7
---- /dev/null
++++ b/migrations/0061_actor_run_model_switch.sql
@@@ -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;
ddiff --git a/public/js/you.js b/public/js/you.js
iindex 7faddd6..64255e8 100644
---- a/public/js/you.js
++++ b/public/js/you.js
@@@ -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(); }
ddiff --git a/server/config.js b/server/config.js
iindex c401089..32846b2 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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',
ddiff --git a/server/day/index.js b/server/day/index.js
iindex a430412..31a394d 100644
---- a/server/day/index.js
++++ b/server/day/index.js
@@@ -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 fr
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 });
-- 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 };
ddiff --git a/server/day/page-data.js b/server/day/page-data.js
iindex 2d0caf5..023ef67 100644
---- a/server/day/page-data.js
++++ b/server/day/page-data.js
@@@ -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 };
ddiff --git a/server/day/page.js b/server/day/page.js
iindex 4ee8130..10793c9 100644
---- a/server/day/page.js
++++ b/server/day/page.js
@@@ -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 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) {
ddiff --git a/server/day/routes.js b/server/day/routes.js
iindex da37905..3630faf 100644
---- a/server/day/routes.js
++++ b/server/day/routes.js
@@@ -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)));
ddiff --git a/server/runtime/ai-catalog.js b/server/runtime/ai-catalog.js
iindex d752f70..07d3bce 100644
---- a/server/runtime/ai-catalog.js
++++ b/server/runtime/ai-catalog.js
@@@ -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'),
ddiff --git a/server/tasks/engine.js b/server/tasks/engine.js
iindex 67852fc..449b42f 100644
---- a/server/tasks/engine.js
++++ b/server/tasks/engine.js
@@@ -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.
ddiff --git a/test/actor-day.test.js b/test/actor-day.test.js
iindex 7f548f2..5ea6a46 100644
---- a/test/actor-day.test.js
++++ b/test/actor-day.test.js
@@@ -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.'));
ddiff --git a/test/actor-page.test.js b/test/actor-page.test.js
iindex 42dba32..7a93928 100644
---- a/test/actor-page.test.js
++++ b/test/actor-page.test.js
@@@ -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=[];
ddiff --git a/test/ai-catalog.test.js b/test/ai-catalog.test.js
iindex c612b74..13907d9 100644
---- a/test/ai-catalog.test.js
++++ b/test/ai-catalog.test.js
@@@ -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.