openvibers/openvibe.codes Commits

Who works on it: an agent and its model in one control, in plain words (L31 CH5) (#206)

OP OpenVibers committed 13 days ago · 69b55f2746 · 4 files, +94 −11

The composer's agent picker becomes one control, grouped by agent: Auto, Claude Code · Opus / Sonnet / Haiku, DeepSeek
· Chat / Reasoner, and each other agent at its own default (its CLI keeps that current), each with a line on what it
is good at. The choice rides on the attempt (agent and model); the same agent with another model carries on, another
agent takes over. GET /api/v1/harnesses/models publishes the catalogue with each agent's effort levels.

Changes

ddiff --git a/server/data/models.js b/server/data/models.js
nnew file mode 100644
iindex 0000000..c98869c
---- /dev/null
++++ b/server/data/models.js
@@@ -0,0 +1,56 @@
++'use strict';
++
++/**
++ * The models a person can pick for a turn (Codes L31 CH5), in words a beginner understands. One entry per agent: the
++ * models whose names stay put (Claude Code's own aliases follow each new release; DeepSeek's two names are its API's),
++ * and otherwise the agent's own default, which its CLI keeps current. The composer offers them as one control, "who
++ * works on it": an agent and its model together. The value is "agent|model" (model empty for the agent's default).
++ *
++ * list(harnesses) → [{ agent, name, models: [{ id, name, note }], efforts }] for the agents Codes knows
++ * choices(harnesses) → [{ value, label, agent, model, group }] for a picker, Auto first
++ * parse(value, harnesses) → { agent, model } or null when it names nothing Codes offers
++ */
++const { EFFORT_LEVELS } = require('../../engine/adapters/base');
++
++const MODELS = {
++ 'claude-code': [
++ { id: 'opus', name: 'Opus', note: 'the strongest: hard changes, design, long work' },
++ { id: 'sonnet', name: 'Sonnet', note: 'fast and capable: most tasks' },
++ { id: 'haiku', name: 'Haiku', note: 'the quickest: small fixes and questions' },
++ ],
++ deepseek: [
++ { id: 'deepseek-chat', name: 'Chat', note: 'quick and very cheap' },
++ { id: 'deepseek-reasoner', name: 'Reasoner', note: 'thinks first: harder problems' },
++ ],
++};
++const DEFAULT = { id: '', name: 'its default', note: 'the model the agent picks for itself' };
++
++function list(harnesses) {
++ return (harnesses?.list?.() || []).map((a) => ({
++ agent: a.id, name: a.name,
++ models: MODELS[a.id] ? MODELS[a.id] : [DEFAULT],
++ efforts: EFFORT_LEVELS[a.id] || [],
++ }));
++}
++
++function choices(harnesses) {
++ const out = [{ value: '', label: 'Auto: Codes picks', agent: null, model: null, group: null }];
++ for (const a of list(harnesses)) {
++ for (const m of a.models) {
++ out.push({ value: `${a.agent}|${m.id}`, label: m.id ? `${a.name} · ${m.name}` : a.name, note: m.note, agent: a.agent, model: m.id || null, group: a.name });
++ }
++ }
++ return out;
++}
++
++function parse(value, harnesses) {
++ const v = String(value || '');
++ if (!v) return { agent: null, model: null };
++ const [agent, model = ''] = v.split('|');
++ const known = list(harnesses).find((a) => a.agent === agent);
++ if (!known) return null;
++ if (model && !known.models.some((m) => m.id === model)) return null;
++ return { agent, model: model || null };
++}
++
++module.exports = { MODELS, list, choices, parse };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex b4ccd0e..a4a8a32 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -26,6 +26,9 @@ function createApi(ctx) {
const limiter = rateLimit({ windowMs: 60_000, limit: 60, standardHeaders: true, legacyHeaders: false });
const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
++ // The models each agent offers, in words a beginner understands, with the effort levels it takes (Codes L31 CH5).
++ r.get('/harnesses/models', reads, (req, res) => res.set('Cache-Control', 'public, max-age=300').json({ agents: require('../data/models').list(harnesses) }));
++
r.get('/harnesses', reads, (req, res) => {
res.json({ harnesses: harnesses.list().map((h) => ({ ...h, agents: harnesses.agents(h.id) })) });
});
ddiff --git a/server/http/chat.js b/server/http/chat.js
iindex 514750c..c924bcc 100644
---- a/server/http/chat.js
++++ b/server/http/chat.js
@@@ -20,6 +20,7 @@ const { raw, esc } = require('../render/html');
const { markdown } = require('../render/markdown');
const { asyncRouter } = require('./router');
const { sessionLogOf } = require('../domain/run-tasks');
++const models = require('../data/models');
const LIVE = ['queued', 'placed', 'running'];
const safeId = (s) => /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(s || ''));
@@@ -292,9 +293,13 @@ ${rows.length ? '' : '<p class="ch-empty-rail">Your conversations appear here.</
}
// ── The composer ─────────────────────────────────────────────────────────────────────────────────────────────
-- function agentPicker(selected, { label = 'Agent', auto = 'Auto' } = {}) {
-- const list = harnesses?.list?.() || [];
-- return `<label class="ch-pick" title="${esc(label)}"><span class="ch-sr">${esc(label)}</span><select name="agent" data-ch-agent><option value="">${esc(auto)}</option>${list.map((a) => `<option value="${esc(a.id)}"${selected === a.id ? ' selected' : ''}>${esc(a.name)}</option>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
++ function whoPicker(selected, { label = 'Who works on it', auto = null } = {}) {
++ const all = models.choices(harnesses);
++ const groups = new Map();
++ for (const c of all.slice(1)) { if (!groups.has(c.group)) groups.set(c.group, []); groups.get(c.group).push(c); }
++ const opt = (c) => `<option value="${esc(c.value)}"${selected === c.value ? ' selected' : ''}${c.note ? ` title="${esc(c.note)}"` : ''}>${esc(c.label)}</option>`;
++ const first = auto ? `<option value="">${esc(auto)}</option>` : opt(all[0]);
++ return `<label class="ch-pick" title="${esc(label)}"><span class="ch-sr">${esc(label)}</span>${ico('agents')}<select name="who" data-ch-who>${first}${[...groups].map(([g, list]) => list.length > 1 ? `<optgroup label="${esc(g)}">${list.map(opt).join('')}</optgroup>` : opt(list[0])).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
}
// The effort in plain words (the shared ladder underneath; each agent gets the nearest level it accepts).
const EFFORT = [['', 'Effort: auto'], ['low', 'Quick'], ['medium', 'Balanced'], ['high', 'Thorough'], ['max', 'Deepest']];
@@@ -360,7 +365,7 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
const error = String(req.query.error || '').slice(0, 200);
const name = String(req.viewer.displayName || req.viewer.username || '').split(' ')[0];
const ideas = ['Fix the failing tests', 'Explain how this project is laid out', 'Make the home page faster on phones', 'Find and fix one bug'];
-- const pickers = `<label class="ch-pick ch-pick-repo" title="Repository"><span class="ch-sr">Repository</span>${ico('repo')}<input name="repo" type="url" value="${esc(repo)}" placeholder="Repository URL" list="ch-repos" autocomplete="url" required data-ch-repo data-ch-repo-url="/api/v1/me/repos"><datalist id="ch-repos">${repos.map((x) => `<option value="${esc(x.url)}">${esc(x.full_name)}</option>`).join('')}</datalist></label>${agentPicker(settings.default_agent || '')}${effortPicker('')}`;
++ const pickers = `<label class="ch-pick ch-pick-repo" title="Repository"><span class="ch-sr">Repository</span>${ico('repo')}<input name="repo" type="url" value="${esc(repo)}" placeholder="Repository URL" list="ch-repos" autocomplete="url" required data-ch-repo data-ch-repo-url="/api/v1/me/repos"><datalist id="ch-repos">${repos.map((x) => `<option value="${esc(x.url)}">${esc(x.full_name)}</option>`).join('')}</datalist></label>${whoPicker(settings.default_agent ? `${settings.default_agent}|` : '')}${effortPicker('')}`;
const thread = `<div class="ch-hello"><span class="ov-mark" data-size="56" aria-hidden="true"></span><h1>${name ? `What should we build, ${esc(name)}?` : 'What should we build?'}</h1><p>Say it the way you would to a colleague. Codes picks the agent, runs it on your machine, and shows you what changed.</p>${error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : ''}<div class="ch-ideas" aria-label="Ideas">${ideas.map((t) => `<button class="ch-chip" type="button" data-ch-idea="${esc(t)}">${esc(t)}</button>`).join('')}</div>${repos.length > 1 ? `<div class="ch-ideas ch-recent" aria-label="Your recent repositories">${repos.map((x) => `<button class="ch-chip" type="button" data-ch-saved-repo="${esc(x.url)}">${ico('repo')}${esc(x.full_name)}</button>`).join('')}</div>` : ''}</div>`;
return frame(req, res, {
title: 'New chat', empty: true, head: '<h1 class="ch-title">New chat</h1>', thread,
@@@ -376,12 +381,13 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
const back = (msg) => res.redirect(303, `/?error=${encodeURIComponent(msg)}${b.repo ? `&repo=${encodeURIComponent(String(b.repo).slice(0, 300))}` : ''}`);
const text = String(b.text || '').trim().slice(0, 16000);
if (!text) return back('Write what you want done first.');
-- const agent = String(b.agent || '').trim();
-- if (agent && !(harnesses?.list?.() || []).some((a) => a.id === agent)) return back('Choose an agent from the list.');
++ const who = models.parse(b.who !== undefined ? b.who : (b.agent ? `${String(b.agent).trim()}|` : ''), harnesses);
++ if (!who) return back('Choose who works on it from the list.');
++ const agent = who.agent || '';
let args;
try {
const { parseArgs } = require('../../harness/job');
-- args = parseArgs(JSON.stringify({ repo: String(b.repo || '').trim(), prompt: text, agent: agent || null, permission: 'edit', ...(b.effort ? { effort: String(b.effort) } : {}) }));
++ args = parseArgs(JSON.stringify({ repo: String(b.repo || '').trim(), prompt: text, agent: agent || null, ...(who.model ? { model: who.model } : {}), permission: 'edit', ...(b.effort ? { effort: String(b.effort) } : {}) }));
} catch (e) { return back(`Check the repository: ${e.message}.`); }
let project = null;
try { project = (await tasks.latestProject?.(req.viewer.subject)) || await conductor.projectFor(req.viewer.token || null, req.viewer.subject); } catch { project = null; }
@@@ -416,7 +422,7 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
// The stream starts after the last line this page already shows.
const seqRow = live ? await db.maybe('SELECT max(seq) AS seq FROM codes_session_lines WHERE task_id = $1', [row.id]) : null;
const lastSeq = seqRow && seqRow.seq != null ? Number(seqRow.seq) : 0;
-- const pickers = agentPicker('', { label: 'Who carries on', auto: `Same agent (${agentOf(row.agent, agentNames()).name})` }) + effortPicker('');
++ const pickers = whoPicker('', { label: 'Who carries on', auto: `Same agent (${agentOf(row.agent, agentNames()).name})` }) + effortPicker('');
return frame(req, res, {
currentId: row.id, title: titleOf(row), head, side: await sideHtml(row),
thread: (error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : '') + await taskThread(row),
@@@ -437,10 +443,12 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
const row = await db.maybe("SELECT * FROM run_tasks WHERE id = $1 AND subject = $2 AND kind <> 'conductor'", [req.params.id, req.viewer.subject]);
if (!row) return res.status(404).send('Not found');
const b = req.body || {};
-- const agent = String(b.agent || '').trim() || null;
-- if (agent && !(harnesses?.list?.() || []).some((a) => a.id === agent)) return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}?error=${encodeURIComponent('Choose an agent from the list.')}`);
++ const who = models.parse(b.who !== undefined ? b.who : (b.agent ? `${String(b.agent).trim()}|` : ''), harnesses);
++ if (!who) return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}?error=${encodeURIComponent('Choose who carries on from the list.')}`);
++ // The same agent with another model carries on; another agent takes over.
++ const agent = who.agent && who.agent !== row.agent ? who.agent : null;
const effort = ['low', 'medium', 'high', 'xhigh', 'max'].includes(b.effort) ? b.effort : null;
-- const out = await tasks.continueTask(row, { note: String(b.text || '').slice(0, 4000), agent, effort });
++ const out = await tasks.continueTask(row, { note: String(b.text || '').slice(0, 4000), agent, model: who.model, effort });
return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}${out.error ? `?error=${encodeURIComponent(out.error)}` : ''}`);
});
ddiff --git a/test/chat.test.js b/test/chat.test.js
iindex 6a4f1a9..5120467 100644
---- a/test/chat.test.js
++++ b/test/chat.test.js
@@@ -78,6 +78,22 @@ const { stepKind } = require('../server/http/chat');
assert.ok(home.text.includes('name="effort"') && home.text.includes('>Thorough</option>'), 'the composer offers it in plain words');
});
++ await check('who works on it: an agent and its model in one control, kept on the first attempt (L31 CH5)', async () => {
++ const home = await t.get('/', { as: ada });
++ assert.ok(home.text.includes('name="who"') && home.text.includes('value="claude-code|sonnet"') && home.text.includes('Claude Code · Sonnet'), 'the choices in words');
++ const made = await t.get('/chat', { as: ada, form: { text: 'A quick fix', repo: 'https://github.com/OpenVibers/OpenVibe.Live', who: 'claude-code|sonnet' } });
++ assert.strictEqual(made.status, 303);
++ const id = made.headers.get('location').split('/').pop();
++ const att = await db.maybe('SELECT agent, model FROM codes_attempts WHERE task_id = $1 ORDER BY n LIMIT 1', [id]);
++ assert.ok(att && att.model === 'sonnet', `the model rides on the attempt (${JSON.stringify(att)})`);
++ const bad = await t.get('/chat', { as: ada, form: { text: 'x', repo: 'https://github.com/OpenVibers/OpenVibe.Live', who: 'claude-code|gpt-9' } });
++ assert.match(bad.headers.get('location'), /Choose%20who%20works%20on%20it/, 'a model Codes does not offer is refused in words');
++ const cat = await t.get('/api/v1/harnesses/models');
++ assert.strictEqual(cat.status, 200);
++ const claude = cat.json().agents.find((a) => a.agent === 'claude-code');
++ assert.ok(claude && claude.models.some((m) => m.id === 'opus' && /strongest/.test(m.note)) && claude.efforts.includes('max'), 'the public catalogue says what each offers');
++ });
++
await check('a visitor who is not signed in gets the product&rsquo;s home, not the app', async () => {
const page = await t.get('/');
assert.strictEqual(page.status, 200);