openvibers/openvibe.actor Commits

Actor: a task page that reads top to bottom, a check that passes a sensible reply, and no hand-off to a weaker agent. (#3)

OP OpenVibers committed 8 Oct 2026 · 00ac84db1c · 9 files, +301 −82

The owner's "test" task failed: the checker refused a fine reply ("I'm up and running, what would you like me to do?")
and the hand-off went to the small open model ("I am not sure"). Now:
- the check passes a greeting, a test message or a clarifying reply;
- a hand-off requires the quality tier of the agent that failed (quality:standard after the runtime), so it never
  goes to a weaker agent (private mode excepted: it has only the open model);
- the task page puts the outcome first (the answer with its sources and check; or why it did not finish, in plain
  words, with Try again in Best mode; or live progress with Cancel), then How it went grouped by agent with each
  check, the refused answer folded away, and Why this agent folded; Your tasks starts with the task box.


Claude-Session: https://claude.ai/code/session_01K8AYn3FF3HE7CPfM262qdh

Co-authored-by: Claude Opus 5.5 <[email protected]>

Changes

ddiff --git a/public/css/actor.css b/public/css/actor.css
iindex 1ae243c..052fd13 100644
---- a/public/css/actor.css
++++ b/public/css/actor.css
@@@ -81,25 +81,60 @@ button.sc-btn.sc-primary { border: 0; }
.agent-card header { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; }
.agent-card p { margin: .5rem 0 0; font-size: 14px; line-height: 1.5; }
--/* ── One task ── */
--.task-text { font-size: 1.45rem; font-weight: 700; white-space: pre-wrap; }
--.task-meta { color: var(--text-secondary, #96a7c2); font-size: .92rem; display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; }
--.answer { margin-top: 1.4rem; padding: 18px; border-radius: 16px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #101828); }
--.answer > h2 { margin-top: 0; }
--.sources { padding-left: 18px; font-size: .92rem; overflow-wrap: anywhere; }
--.steps-log { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
--.step { display: grid; grid-template-columns: 76px 1fr; gap: 10px; padding: 8px 10px; border-radius: 10px; background: var(--bg-card, #131c2e); font-size: .92rem; animation: step-in .25s ease-out; }
--.step-k { font-size: 12px; font-weight: 700; letter-spacing: .4px; text-transform: uppercase; color: var(--text-muted, #7386a3); padding-top: 2px; }
--.step-v { overflow-wrap: anywhere; white-space: pre-wrap; }
--.step-check .step-k { color: var(--success, #22c55e); }
--.step.err .step-k { color: var(--warning, #f59e0b); }
--.step-plan .step-k, .step-handoff .step-k { color: var(--accent-light, #60a5fa); }
++/* ── One task: the status line and the task, the outcome, how it went, why ── */
++.task-head { margin-top: .4rem; }
++.task-status { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; margin: 0 0 .4rem; color: var(--text-muted, #7386a3); font-size: .88rem; }
++.task-text { font-size: 1.55rem; line-height: 1.3; font-weight: 700; margin: .2rem 0 1rem; white-space: pre-wrap; overflow-wrap: anywhere; }
++.pill { display: inline-flex; align-items: center; gap: 6px; padding: 2px 10px; border-radius: 999px; font-weight: 700; font-size: .8rem; border: 1px solid var(--border-light, #2c3d5c); color: var(--text-secondary, #96a7c2); }
++.pill::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
++.pill-succeeded { color: var(--success, #22c55e); border-color: color-mix(in srgb, var(--success, #22c55e) 45%, transparent); }
++.pill-failed { color: var(--error, #ef4444); border-color: color-mix(in srgb, var(--error, #ef4444) 45%, transparent); }
++.pill-running, .pill-verifying, .pill-queued { color: var(--accent-light, #60a5fa); border-color: color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent); }
++.chip { padding: 2px 9px; border-radius: 999px; background: var(--bg-card, #131c2e); border: 1px solid var(--border, #1f2d47); font-size: .8rem; color: var(--text-secondary, #96a7c2); }
++.outcome { margin: 0 0 1.6rem; padding: 18px 20px; border-radius: 16px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #101828); }
++.outcome > h2 { margin: 0 0 .4rem; font-size: 1.15rem; display: flex; align-items: center; gap: 10px; }
++.outcome > p { margin: .3rem 0; color: var(--text-secondary, #96a7c2); }
++.outcome-ok { border-color: color-mix(in srgb, var(--success, #22c55e) 35%, var(--border-light, #2c3d5c)); }
++.outcome-bad { border-color: color-mix(in srgb, var(--error, #ef4444) 40%, var(--border-light, #2c3d5c)); background: color-mix(in srgb, var(--error, #ef4444) 5%, var(--bg-secondary, #101828)); }
++.outcome-live { border-color: color-mix(in srgb, var(--accent, #3b82f6) 40%, var(--border-light, #2c3d5c)); }
++.outcome-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
++.outcome .sc-btn { padding: 9px 16px; font-size: 14px; }
++.answer .prose { font-size: 1.02rem; line-height: 1.65; }
++.answer .prose > :first-child { margin-top: 0; }
++.sources h3 { font-size: .8rem; letter-spacing: .5px; text-transform: uppercase; color: var(--text-muted, #7386a3); margin: 1rem 0 .3rem; }
++.sources ul { margin: 0; padding-left: 18px; font-size: .9rem; overflow-wrap: anywhere; }
++.answer-foot { display: flex; flex-wrap: wrap; gap: 6px 16px; margin: 1rem 0 0; padding-top: 12px; border-top: 1px solid var(--border, #1f2d47); font-size: .85rem; color: var(--text-muted, #7386a3); }
++.answer-foot span:nth-child(2) { color: var(--success, #22c55e); }
++.how > h2 { font-size: 1.05rem; color: var(--text-secondary, #96a7c2); }
++.attempts { display: grid; gap: 12px; }
++.attempt { border: 1px solid var(--border, #1f2d47); border-radius: 14px; padding: 12px 14px; background: var(--bg-card, #131c2e); }
++.attempt-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
++.attempt-n { width: 24px; height: 24px; border-radius: 50%; display: grid; place-items: center; font-size: 12px; font-weight: 800; color: var(--accent-light, #60a5fa); border: 1px solid var(--border-light, #2c3d5c); }
++.tl { list-style: none; margin: 10px 0 0; padding: 0 0 0 12px; border-left: 2px solid var(--border, #1f2d47); display: grid; gap: 6px; }
++.tl:empty { display: none; }
++.tl-item { display: grid; grid-template-columns: 52px 1fr; gap: 10px; font-size: .9rem; position: relative; animation: step-in .25s ease-out; }
++.tl-item::before { content: ""; position: absolute; left: -18px; top: .55em; width: 10px; height: 10px; border-radius: 50%; background: var(--bg-card, #131c2e); border: 2px solid var(--border-light, #2c3d5c); }
++.tl-k { font-size: 11.5px; font-weight: 700; letter-spacing: .4px; text-transform: uppercase; color: var(--text-muted, #7386a3); padding-top: 2px; }
++.tl-v { overflow-wrap: anywhere; white-space: pre-wrap; }
++.tl-ok::before { border-color: var(--success, #22c55e); background: var(--success, #22c55e); }
++.tl-ok .tl-k { color: var(--success, #22c55e); }
++.tl-err::before { border-color: var(--warning, #f59e0b); }
++.tl-err .tl-k { color: var(--warning, #f59e0b); }
++.refused { margin: 10px 0 0; }
++.refused summary { cursor: pointer; font-size: .88rem; color: var(--text-muted, #7386a3); }
++.refused .prose { margin-top: 8px; padding: 10px 12px; border-radius: 10px; border: 1px dashed var(--border-light, #2c3d5c); font-size: .92rem; opacity: .85; }
++.dot-pulse { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: var(--accent, #3b82f6); animation: pulse 1.2s ease-in-out infinite; }
++.live-note { display: flex; align-items: center; gap: 8px; }
++.api-hint { margin-top: 1.4rem; }
++.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
++@keyframes pulse { 0%, 100% { opacity: .35; transform: scale(.85); } 50% { opacity: 1; transform: scale(1); } }
@keyframes step-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
.why { margin-top: 1.4rem; border: 1px solid var(--border, #1f2d47); border-radius: 12px; padding: 10px 14px; }
.why summary { cursor: pointer; font-weight: 700; }
@media (max-width: 640px) {
-- .step { grid-template-columns: 1fr; gap: 2px; }
-- .task-text { font-size: 1.2rem; }
++ .tl-item { grid-template-columns: 1fr; gap: 2px; }
++ .task-text { font-size: 1.25rem; }
++ .outcome { padding: 14px; }
}
--@media (prefers-reduced-motion: reduce) { .step { animation: none; } .mode span { transition: none; } }
++@media (prefers-reduced-motion: reduce) { .tl-item, .dot-pulse { animation: none; } .mode span { transition: none; } }
ddiff --git a/public/js/task.js b/public/js/task.js
iindex 92f07c9..4af38f9 100644
---- a/public/js/task.js
++++ b/public/js/task.js
@@@ -1,48 +1,73 @@
--/* A running task's page: follows its live stream (GET /api/v1/tasks/:id/events, actor.task-event@1) and adds each step
-- * as it happens; when the task ends the page reloads to show the answer as the server renders it. Without this script
-- * the page refreshes itself every few seconds (a <noscript> refresh), so nothing depends on it. */
++/* A running task's page: follows its live stream (GET /api/v1/tasks/:id/events, actor.task-event@1) and draws each step
++ * the way the server renders a finished task (one block per agent that tries, its steps, its check); when the task ends
++ * the page reloads to show the outcome. Without this script the page refreshes itself every few seconds (<noscript>). */
(function () {
'use strict';
++ var LABEL = { tool_call: 'Used', tool_result: 'Got', text: 'Note' };
++ var STATE = { queued: 'Waiting to start', running: 'Working', verifying: 'Checking the answer' };
++
++ function el(tag, cls, text) {
++ var n = document.createElement(tag);
++ if (cls) n.className = cls;
++ if (text != null) n.textContent = text;
++ return n;
++ }
++
function start() {
-- var el = document.querySelector('article.task[data-open="1"]');
-- if (!el || !window.EventSource || el.dataset.live) return;
-- el.dataset.live = '1';
-- var id = el.getAttribute('data-task');
-- var after = el.getAttribute('data-last') || '0';
-- var log = document.getElementById('steps-log');
++ var art = document.querySelector('article.task[data-open="1"]');
++ if (!art || !window.EventSource || art.dataset.live) return;
++ art.dataset.live = '1';
++ var id = art.getAttribute('data-task');
++ var box = document.getElementById('attempts');
++ var liveState = document.getElementById('live-state');
var cost = document.getElementById('task-cost');
-- var LABEL = { plan: 'Plan', text: 'Says', tool_call: 'Uses', tool_result: 'Got', check: 'Check', handoff: 'Hands on' };
++ var current = box.lastElementChild ? box.lastElementChild.querySelector('.tl') : null;
++ var lastCheckFailed = false;
-- function step(e) {
-- var li = document.createElement('li');
-- li.className = 'step step-' + e.step + (e.is_error ? ' err' : '');
-- var k = document.createElement('span');
-- k.className = 'step-k';
-- k.textContent = LABEL[e.step] || e.step;
-- var v = document.createElement('span');
-- v.className = 'step-v';
-- if (e.tool) {
-- var c = document.createElement('code');
-- c.textContent = e.tool;
-- v.appendChild(c);
-- v.appendChild(document.createTextNode(' ' + (e.step === 'tool_result' ? String(e.text || '').slice(0, 240) : (e.input || ''))));
-- } else {
-- v.textContent = e.text || '';
-- }
-- li.appendChild(k);
++ function attempt(name) {
++ var sec = el('section', 'attempt');
++ var head = el('header', 'attempt-head');
++ head.appendChild(el('span', 'attempt-n', String(box.children.length + 1)));
++ head.appendChild(el('b', null, name));
++ sec.appendChild(head);
++ current = el('ol', 'tl');
++ sec.appendChild(current);
++ box.appendChild(sec);
++ lastCheckFailed = false;
++ }
++ function item(kind, cls, value, code) {
++ if (!current) attempt('Agent');
++ var li = el('li', 'tl-item ' + cls);
++ li.appendChild(el('span', 'tl-k', kind));
++ var v = el('span', 'tl-v');
++ if (code) { v.appendChild(el('code', null, code)); v.appendChild(document.createTextNode(' ')); }
++ v.appendChild(document.createTextNode(value || ''));
li.appendChild(v);
-- log.appendChild(li);
++ current.appendChild(li);
++ }
++ function onOutput(e) {
++ if ((e.step === 'plan' || e.step === 'handoff') && e.agent) return attempt((e.text || e.agent).replace(/ takes it.*$/, ''));
++ if (e.step === 'plan') return undefined;
++ if (e.step === 'check') {
++ lastCheckFailed = !!e.is_error;
++ return item('Check', e.is_error ? 'tl-err' : 'tl-ok', String(e.text || '').replace(/^(Checked|Check failed)( by \w+( \(same family\))?)?: ?/, ''));
++ }
++ if (e.step === 'text' && e.is_error && lastCheckFailed) return item('Refused', 'tl-err', 'The answer did not pass its check; trying a stronger agent.');
++ if (e.step === 'text' && e.is_error) return item('Stopped', 'tl-err', e.text);
++ if (e.step === 'tool_call') return item(LABEL.tool_call, 'tl-tool', e.input, e.tool);
++ if (e.step === 'tool_result') return item(LABEL.tool_result, 'tl-result' + (e.is_error ? ' tl-err' : ''), String(e.text || '').slice(0, 220), e.tool);
++ return item(LABEL.text, 'tl-say', e.text);
}
-- var src = new EventSource('/api/v1/tasks/' + encodeURIComponent(id) + '/events?after=' + encodeURIComponent(after));
-- src.addEventListener('output', function (m) { try { step(JSON.parse(m.data)); } catch (err) { /* a malformed event is skipped */ } });
-- src.addEventListener('state', function () {
++ var src = new EventSource('/api/v1/tasks/' + encodeURIComponent(id) + '/events?after=' + encodeURIComponent(art.getAttribute('data-last') || '0'));
++ src.addEventListener('output', function (m) { try { onOutput(JSON.parse(m.data)); } catch (err) { /* a malformed event is skipped */ } });
++ src.addEventListener('state', function (m) {
++ try { var s = JSON.parse(m.data).state; if (liveState && STATE[s]) liveState.textContent = STATE[s] + '…'; } catch (err) { /* ignore */ }
fetch('/api/v1/tasks/' + encodeURIComponent(id), { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (t) {
-- if (t && t.cost && cost) cost.textContent = '$' + (t.cost.usd < 0.01 ? t.cost.usd.toFixed(4) : t.cost.usd.toFixed(2));
++ if (t && t.cost && cost) cost.textContent = cost.textContent.replace(/^\$[0-9.]+/, '$' + (t.cost.usd < 0.01 ? t.cost.usd.toFixed(4) : t.cost.usd.toFixed(2)));
}).catch(function () {});
});
src.addEventListener('end', function () { src.close(); window.location.reload(); });
-- // The page's own refresh is the fallback when the stream cannot stay open.
src.onerror = function () { if (src.readyState === 2) setTimeout(function () { window.location.reload(); }, 4000); };
window.addEventListener('pagehide', function () { src.close(); }, { once: true });
}
ddiff --git a/server/agents/check.js b/server/agents/check.js
iindex 13846da..1a4b54b 100644
---- a/server/agents/check.js
++++ b/server/agents/check.js
@@@ -17,7 +17,8 @@ const CHECKER_FOR = { deepseek: ['openai', 'deepseek'], openai: ['deepseek', 'op
const SYSTEM = `You check an AI agent's answer before a person sees it. Reply with JSON only: {"ok": true|false, "reason": "<one short sentence>"}.
ok is true when the answer addresses the task and nothing in it is clearly invented, contradictory or unsafe. It does not have to be perfect.
ok is false when the answer is empty, refuses without a good reason, answers a different question, contradicts itself, or states something that is plainly impossible.
--The agent may have searched the web or run tools: do not fail an answer only because it states recent facts, versions, prices or dates you cannot verify yourself, especially when it cites a source for them.`;
++The agent may have searched the web or run tools: do not fail an answer only because it states recent facts, versions, prices or dates you cannot verify yourself, especially when it cites a source for them.
++When the task is a greeting, small talk, a test message or too vague to act on, a short friendly reply, a summary of what the agent can do or a clarifying question is a good answer: ok is true.`;
/** Which checkers may read this agent's answer, best first: another family, then the same family. */
function checkersFor(agentId, checkers, { mode }) {
ddiff --git a/server/agents/router.js b/server/agents/router.js
iindex 9ef201a..a145a0b 100644
---- a/server/agents/router.js
++++ b/server/agents/router.js
@@@ -9,6 +9,10 @@
* best → balanced among agents that offer quality:high; when none can take the task, balanced among the rest,
* and the explanation says so (ADR-044 leaves what `best` maps to open; this is the first answer)
*
++ * A hand-off (escalate: the agent before it failed, or its answer failed the check) never goes to a weaker agent: it
++ * requires the quality tier of the agent that failed (quality:standard after the runtime), so a task is never handed
++ * from a capable model to a smaller one. Private mode has only the open model, so it never escalates.
++ *
* The answer is a platform.placement-result@1, stored on the task as its explanation. An agent that is down carries
* the catalog's reason instead of the planner's "health down"; agents already tried on this task are listed with why.
*/
@@@ -20,13 +24,13 @@ const { requirementFor } = require('./classify');
const OBJECTIVE = { cheapest: 'cheapest', balanced: 'balanced', fastest: 'lowest-latency', private: 'private', best: 'balanced' };
const MODES = Object.keys(OBJECTIVE);
--function planOnce({ cls, mode, pinned, budgetUsd, config, tried, now, measured, quality }) {
++function planOnce({ cls, mode, pinned, budgetUsd, config, tried, now, measured, quality, floor = null }) {
const req = {
kind: `actor.${cls}`,
mobility: 'job',
latency_class: 'interactive',
objective: OBJECTIVE[mode],
-- capabilities: [requirementFor(cls), ...(quality ? ['quality:high'] : [])],
++ capabilities: [requirementFor(cls), ...(quality ? ['quality:high'] : floor ? [`quality:${floor}`] : [])],
};
if (mode === 'private') req.trust = ['first-party'];
if (budgetUsd != null) req.max_cost_usd = budgetUsd;
@@@ -47,11 +51,11 @@ function planOnce({ cls, mode, pinned, budgetUsd, config, tried, now, measured,
* route({ cls, mode, agent, budgetUsd, config, tried: Map<id, why>, now, measured }) → platform.placement-result@1
* (selected is null when nothing can take the task; the reasons say why).
*/
--function route({ cls, mode = 'balanced', agent = null, budgetUsd = null, config, tried = new Map(), now = Date.now(), measured = {} }) {
++function route({ cls, mode = 'balanced', agent = null, budgetUsd = null, config, tried = new Map(), now = Date.now(), measured = {}, floor = null }) {
const triedIds = new Set(tried.keys());
-- let r = planOnce({ cls, mode, pinned: agent, budgetUsd, config, tried: triedIds, now, measured, quality: mode === 'best' });
++ let r = planOnce({ cls, mode, pinned: agent, budgetUsd, config, tried: triedIds, now, measured, quality: mode === 'best', floor });
if (mode === 'best' && !r.selected) {
-- const fallback = planOnce({ cls, mode, pinned: agent, budgetUsd, config, tried: triedIds, now, measured, quality: false });
++ const fallback = planOnce({ cls, mode, pinned: agent, budgetUsd, config, tried: triedIds, now, measured, quality: false, floor });
if (fallback.selected) r = { ...fallback, reasons: ['best mode: no high-quality agent can take this task, so the best of the rest', ...(fallback.reasons || [])] };
}
for (const [id, why] of tried) r.candidates.push({ id, eligible: false, excluded_because: `tried first: ${why}`.slice(0, 200) });
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 5acf6a0..7a3e724 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -188,10 +188,10 @@ ${a.available ? '' : html`<p class="muted small">${a.reason}</p>`}
const pageRows = await store.listTasks(s, requesterOf(req), { limit: 30, before });
const today = await store.spendToday(s, requesterOf(req));
page(req, res, {
-- title: 'Your tasks', crumbs: [{ label: 'Your tasks' }],
++ title: 'Your tasks', crumbs: [{ label: 'Your tasks' }], styles: [showcase.STYLESHEET],
body: html`<h1>Your tasks</h1>
<p class="muted">Today: ${today.tasks} of ${tier.tasksPerDay} free tasks, ${usd(today.usd)} of ${usd(tier.perDayUsd)}. Tasks are kept ${store.RETENTION_DAYS} days.</p>
--<p><a class="sc-btn sc-primary" href="/#task">New task</a></p>
++${taskForm(req)}
${table(['Task', 'State', 'Agent', 'Cost', 'Started'], pageRows.rows.map((t) => {
const result = t.result ? (typeof t.result === 'string' ? JSON.parse(t.result) : t.result) : null;
return [html`<a href="/tasks/${t.id}">${t.task.length > 90 ? `${t.task.slice(0, 89)}…` : t.task}</a>`, badge(t.state, STATE_KIND[t.state]), result ? (catalog.byId(result.agent) || {}).name || result.agent : '—', usd(t.cost_usd == null ? null : Number(t.cost_usd)), time(t.created_at)];
@@@ -201,6 +201,12 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</
});
// ── One task ─────────────────────────────────────────────
++ // Read top to bottom: what was asked and where it stands, the outcome (the answer, why it did not finish, or the
++ // live progress), then how it went — one block per agent that tried, its steps, its check, and a refused answer
++ // folded away — and why that agent, folded.
++ const STATE_TEXT = { queued: 'Waiting to start', running: 'Working', verifying: 'Checking the answer', succeeded: 'Done', failed: 'Didn\'t finish', cancelled: 'Cancelled' };
++ const CLASS_TEXT = { answer: 'Answer', lookup: 'Lookup', research: 'Research', web: 'Web search', code: 'Code' };
++
r.get('/tasks/:id', async (req, res) => {
const row = TASK_ID.test(req.params.id) && signedIn(req) ? await store.getTask(s, req.params.id) : null;
if (!row || row.requester !== requesterOf(req)) {
@@@ -209,46 +215,112 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</
const t = store.toWire(row, { baseUrl: config.baseUrl });
const events = await store.eventsAfter(s, row.id, 0);
const open = store.OPEN.includes(row.state);
++ const cls = (events.find((e) => e.step === 'plan' && !e.agent && /"(\w+)" task/.test(e.text || '')) || { text: '' }).text.match(/"(\w+)"/);
++ const kind = cls ? CLASS_TEXT[cls[1]] || cls[1] : null;
page(req, res, {
-- title: 'Task', crumbs: [{ label: 'Your tasks', href: '/tasks' }, { label: row.id }],
++ title: row.task.length > 60 ? `${row.task.slice(0, 59)}…` : row.task,
++ crumbs: [{ label: 'Your tasks', href: '/tasks' }, { label: 'Task' }],
scripts: open ? ['js/task.js'] : [],
++ styles: [showcase.STYLESHEET],
noReferrer: true,
body: html`${open ? raw('<noscript><meta http-equiv="refresh" content="4"></noscript>') : ''}
--<article class="task" data-task="${row.id}" data-last="${row.last_seq}" data-open="${open ? '1' : ''}">
++<article class="task state-${t.state}" data-task="${row.id}" data-last="${row.last_seq}" data-open="${open ? '1' : ''}">
++<header class="task-head">
++<p class="task-status"><span class="pill pill-${t.state}">${STATE_TEXT[t.state]}</span>${kind ? html`<span class="chip">${kind}</span>` : ''}<span class="chip">${MODE_LABEL[t.mode]}</span><span class="chip" id="task-cost">${t.cost ? usd(t.cost.usd) : '$0'} of ${usd(t.budget.per_task_usd)}</span>${time(t.created_at)}</p>
<h1 class="task-text">${row.task}</h1>
--<p class="task-meta">${badge(t.state, STATE_KIND[t.state])} · ${MODE_LABEL[t.mode]} · budget ${usd(t.budget.per_task_usd)} · cost <span id="task-cost">${usd(t.cost ? t.cost.usd : null)}</span> · ${time(t.created_at)}</p>
--${open ? html`<form method="post" action="/tasks/${row.id}/cancel" class="inline"><button class="sc-btn" type="submit">Cancel</button></form>` : ''}
--${t.result ? resultBlock(t.result) : ''}
--${t.error ? notice(html`<strong>${t.state === 'failed' ? 'It did not finish' : 'Stopped'}.</strong> ${t.error.detail}`, 'warn') : ''}
--${t.state === 'cancelled' && !t.error ? notice('Cancelled. What was spent before the cancel stays spent.') : ''}
--<section class="progress" aria-labelledby="h-steps"><h2 id="h-steps">Steps</h2>
--<ol class="steps-log" id="steps-log">${events.filter((e) => e.kind === 'output').map(stepItem)}</ol>
--${open ? html`<p class="muted small" id="live-note">Working… this page updates as the agent works.</p>` : ''}</section>
++</header>
++${outcome(t, row)}
++<section class="how" aria-labelledby="h-how"><h2 id="h-how">How it went</h2>
++<div class="attempts" id="attempts">${attemptsOf(events).map(attemptBlock)}</div>
++${open ? html`<p class="muted small live-note" id="live-note"><span class="dot-pulse" aria-hidden="true"></span> Updating as the agent works.</p>` : ''}
++</section>
${t.explanation ? explanationBlock(t.explanation) : ''}
--<p class="muted small">Need it in code? <code>GET /api/v1/tasks/${row.id}</code> (<a href="/docs">API</a>).</p>
++<p class="muted small api-hint">In code: <code>GET /api/v1/tasks/${row.id}</code> · <a href="/docs">API</a></p>
</article>`,
});
});
-- function resultBlock(result) {
++ /** The outcome card: the first thing under the task. */
++ function outcome(t, row) {
++ if (t.state === 'succeeded') return resultBlock(t.result, t);
++ const again = html`<form method="post" action="/tasks" class="inline"><input type="hidden" name="task" value="${row.task}"><input type="hidden" name="mode" value="best"><button class="sc-btn sc-primary" type="submit">Try again in Best mode</button></form> <a class="sc-btn" href="/#task">New task</a>`;
++ if (t.state === 'failed') {
++ return html`<section class="outcome outcome-bad" role="status"><h2>Actor couldn't finish this</h2><p>${friendlyError(t.error)}</p><div class="outcome-actions">${again}</div>${t.error ? html`<p class="muted small">Code <code>${t.error.code}</code></p>` : ''}</section>`;
++ }
++ if (t.state === 'cancelled') return html`<section class="outcome" role="status"><h2>Cancelled</h2><p>What was spent before the cancel stays spent.</p><div class="outcome-actions">${again}</div></section>`;
++ return html`<section class="outcome outcome-live" role="status" aria-live="polite"><h2><span class="dot-pulse" aria-hidden="true"></span> <span id="live-state">${STATE_TEXT[t.state]}…</span></h2><p class="muted">This page fills in as the agent works. You can leave and come back: the task keeps running.</p><form method="post" action="/tasks/${row.id}/cancel" class="inline"><button class="sc-btn" type="submit">Cancel</button></form></section>`;
++ }
++
++ /** What went wrong, for a person; the code stays visible underneath for anyone who needs it. */
++ function friendlyError(err) {
++ if (!err) return 'It stopped without saying why.';
++ const plain = {
++ 'actor.agents.exhausted': 'No agent gave an answer that passed its check, so nothing unchecked was shown to you. Trying again in Best mode starts with the strongest agent.',
++ 'actor.check.unavailable': 'The answer could not be checked, so it was not delivered. Try again in a minute.',
++ 'actor.task.timeout': 'It ran too long and was stopped.',
++ 'actor.task.interrupted': 'Actor restarted while this was running. Nothing more was charged; ask again.',
++ };
++ return plain[err.code] || err.detail;
++ }
++
++ function resultBlock(result, t) {
const agent = catalog.byId(result.agent);
-- return html`<section class="answer" aria-labelledby="h-answer"><h2 id="h-answer">Answer</h2>
++ const checked = result.checked || {};
++ return html`<section class="outcome outcome-ok answer" aria-labelledby="h-answer"><h2 id="h-answer" class="sr-only">Answer</h2>
<div class="prose">${raw(markdown(result.answer, { headingOffset: 2 }))}</div>
--${result.sources && result.sources.length ? html`<h3>Sources</h3><ul class="sources">${result.sources.map((u) => html`<li><a href="${u}" rel="nofollow ugc noopener" target="_blank">${u}</a></li>`)}</ul>` : ''}
--<p class="muted small">By ${agent ? agent.name : result.agent}${result.model ? html` (<code>${result.model}</code>)` : ''} · checked by ${result.checked && result.checked.by}${result.checked && !result.checked.cross_family ? ' (same family)' : ''}${result.checked && result.checked.reason ? `: ${result.checked.reason}` : ''}</p>
++${result.sources && result.sources.length ? html`<div class="sources"><h3>Sources</h3><ul>${result.sources.map((u) => html`<li><a href="${u}" rel="nofollow ugc noopener" target="_blank">${u.replace(/^https?:\/\//, '').slice(0, 90)}</a></li>`)}</ul></div>` : ''}
++<p class="answer-foot"><span>${agent ? agent.name : result.agent}${result.model ? html` · <code>${result.model}</code>` : ''}</span><span>✓ checked by ${checked.by}${checked.cross_family ? ' (another model family)' : ' (same family)'}</span><span>${t.cost ? usd(t.cost.usd) : '$0'}</span></p>
++</section>`;
++ }
++
++ /**
++ * The events grouped by agent: an attempt starts with the plan or hand-off event that names its agent. A text event
++ * marked as an error right after a failed check is the answer that check refused; any other error text is the agent
++ * stopping.
++ */
++ function attemptsOf(events) {
++ const out = [];
++ let cur = null;
++ let lastCheckFailed = false;
++ for (const e of events) {
++ if (e.kind !== 'output') continue;
++ if ((e.step === 'plan' || e.step === 'handoff') && e.agent) {
++ cur = { agent: e.agent, why: e.text, steps: [], refused: null, check: null, stopped: null };
++ out.push(cur);
++ lastCheckFailed = false;
++ continue;
++ }
++ if (!cur) continue;
++ if (e.step === 'check') { cur.check = e; lastCheckFailed = !!e.is_error; continue; }
++ if (e.step === 'text' && e.is_error && lastCheckFailed) { cur.refused = e.text; continue; }
++ if (e.step === 'text' && e.is_error) { cur.stopped = e.text; continue; }
++ cur.steps.push(e);
++ }
++ return out;
++ }
++
++ function attemptBlock(a, i) {
++ const agent = catalog.byId(a.agent);
++ const outcomeBadge = a.check ? (a.check.is_error ? badge('answer refused', 'bad') : badge('passed', 'ok')) : a.stopped ? badge('stopped', 'warn') : '';
++ return html`<section class="attempt" data-agent="${a.agent}">
++<header class="attempt-head"><span class="attempt-n">${i + 1}</span><b>${agent ? agent.name : a.agent}</b>${outcomeBadge}</header>
++<ol class="tl">${a.steps.map(stepItem)}
++${a.stopped ? html`<li class="tl-item tl-err"><span class="tl-k">Stopped</span><span class="tl-v">${a.stopped}</span></li>` : ''}
++${a.check ? html`<li class="tl-item ${a.check.is_error ? 'tl-err' : 'tl-ok'}"><span class="tl-k">Check</span><span class="tl-v">${String(a.check.text || '').replace(/^(Checked|Check failed)( by \w+( \(same family\))?)?: ?/, '')}</span></li>` : ''}
++</ol>
++${a.refused ? html`<details class="refused"><summary>See the answer that didn't pass</summary><div class="prose">${raw(markdown(a.refused, { headingOffset: 3 }))}</div></details>` : ''}
</section>`;
}
function stepItem(e) {
-- const label = { plan: 'Plan', text: 'Says', tool_call: 'Uses', tool_result: 'Got', check: 'Check', handoff: 'Hands on' }[e.step] || e.step;
-- const body = e.step === 'tool_call' ? html`<code>${e.tool}</code> ${e.input || ''}` : e.step === 'tool_result' ? html`<code>${e.tool}</code> <span class="muted">${(e.text || '').slice(0, 240)}</span>` : (e.text || '');
-- return html`<li class="step step-${e.step}${e.is_error ? ' err' : ''}"><span class="step-k">${label}</span><span class="step-v">${body}</span></li>`;
++ if (e.step === 'tool_call') return html`<li class="tl-item tl-tool"><span class="tl-k">Used</span><span class="tl-v"><code>${e.tool}</code> ${e.input || ''}</span></li>`;
++ if (e.step === 'tool_result') return html`<li class="tl-item tl-result${e.is_error ? ' tl-err' : ''}"><span class="tl-k">Got</span><span class="tl-v"><span class="muted">${(e.text || '').slice(0, 220)}</span></span></li>`;
++ return html`<li class="tl-item tl-say"><span class="tl-k">Note</span><span class="tl-v">${e.text || ''}</span></li>`;
}
function explanationBlock(explanation) {
return html`<details class="why"><summary>Why this agent</summary>
--${explanation.map((p, i) => html`<h3>${i ? `Attempt ${i + 1}` : 'First choice'}: ${p.selected ? (catalog.byId(p.selected) || {}).name || p.selected : 'no agent'} <span class="muted small">(${p.objective})</span></h3>
--${(p.reasons || []).length ? html`<p class="muted">${p.reasons.join('; ')}</p>` : ''}
++${explanation.map((p, i) => html`<h3>${i ? `Hand-off ${i}` : 'First choice'}: ${p.selected ? (catalog.byId(p.selected) || {}).name || p.selected : 'no agent'} <span class="muted small">(${p.objective})</span></h3>
${table(['Agent', 'Could take it', 'Estimated cost', 'Why not'], (p.candidates || []).map((c) => [(catalog.byId(c.id) || {}).name || c.id, c.eligible ? 'yes' : 'no', c.estimated_cost_usd != null ? `~${usd(c.estimated_cost_usd)}` : '—', c.excluded_because || (c.id === p.selected ? 'picked' : '')]))}`)}
</details>`;
}
ddiff --git a/server/tasks/engine.js b/server/tasks/engine.js
iindex 03264bb..aca2875 100644
---- a/server/tasks/engine.js
++++ b/server/tasks/engine.js
@@@ -76,6 +76,9 @@ function createEngine({ config, s, adapters, stream, now = () => Date.now(), log
timer.unref();
const explanation = [];
const tried = new Map();
++ // The quality tier a hand-off may not go below: the tier of the best agent tried so far (catalog quality).
++ let floor = null;
++ const raise = (agent) => { if (agent && agent.quality === 'high') floor = 'high'; else if (agent && agent.quality === 'standard' && floor !== 'high') floor = 'standard'; };
try {
await setState(id, 'running', { cost_usd: 0, free_usd: 0 });
// What kind of task this is. Private mode never sends the text anywhere to find out.
@@@ -90,7 +93,7 @@ function createEngine({ config, s, adapters, stream, now = () => Date.now(), log
await emit(id, { kind: 'output', step: 'plan', text: `This is a "${cls}" task.` });
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
-- const placement = route({ cls, mode: row.mode, agent: row.agent, budgetUsd: Math.max(0, budgetLeft()), config, tried, now: now() });
++ const placement = route({ cls, mode: row.mode, agent: row.agent, budgetUsd: Math.max(0, budgetLeft()), config, tried, now: now(), floor: row.mode === 'private' ? null : floor });
explanation.push(placement);
await store.updateTask(s, id, { explanation });
if (!placement.selected) {
@@@ -112,6 +115,7 @@ function createEngine({ config, s, adapters, stream, now = () => Date.now(), log
return await setState(id, 'failed', { error_code: 'actor.budget.exceeded', error_detail: `The task used its whole $${budget.toFixed(3)} budget before an agent finished.`, finished_at: iso() });
}
tried.set(agentId, why);
++ raise(agent);
continue;
}
await setState(id, 'verifying', { agentForEvent: agentId });
@@@ -125,8 +129,10 @@ function createEngine({ config, s, adapters, stream, now = () => Date.now(), log
if (verdict.unavailable) {
return await setState(id, 'failed', { error_code: 'actor.check.unavailable', error_detail: `The answer could not be checked (${verdict.reason}), so it was not delivered.`, finished_at: iso() });
}
-- await emit(id, { kind: 'output', step: 'text', agent: agentId, text: `Unchecked answer, not delivered:\n\n${out.text}`.slice(0, 8000) });
++ // The refused answer, right after its failed check: the task page shows it folded away, never as the answer.
++ await emit(id, { kind: 'output', step: 'text', agent: agentId, text: out.text.slice(0, 8000), is_error: true });
tried.set(agentId, `its answer failed the check: ${verdict.reason}`);
++ raise(agent);
await setState(id, 'running');
}
const last = [...tried.entries()].pop();
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 8be62b8..0aaa830 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -20,8 +20,8 @@ const BUDGETS = {
jsRawKB: 264, // 239.8
jsBrotliKB: 62.5, // 56.5
cssFiles: 2, // 2 (actor.css + the cached /shared/showcase.css)
-- cssRawKB: 22.5, // 20.2
-- cssBrotliKB: 5.2, // 4.7
++ cssRawKB: 26.5, // 24.2 (the task page's outcome card and timeline)
++ cssBrotliKB: 6, // 5.4
externalFiles: 0, // 0
};
ddiff --git a/test/shots.js b/test/shots.js
nnew file mode 100644
iindex 0000000..7f16b11
---- /dev/null
++++ b/test/shots.js
@@@ -0,0 +1,47 @@
++'use strict';
++// Screenshots of a finished and a failed task page, signed in (not run by test/run.js: only *.test.js are).
++// Usage: node test/shots.js <outdir>
++const path = require('path');
++const http = require('http');
++const { spawn } = require('child_process');
++const { boot } = require('./helpers/boot');
++
++(async () => {
++ const out = process.argv[2];
++ const t = await boot();
++ const kim = t.network.addUser('kim');
++ const SAME = { 'sec-fetch-site': 'same-origin' };
++ t.providers.state.cls = 'lookup';
++ const ok = (await t.get('/api/v1/tasks', { as: kim, json: { task: 'Who handles the mail for openvibe.network?' }, headers: SAME })).json().id;
++ await t.waitFor(ok);
++ t.providers.reset();
++ t.providers.state.cls = 'research';
++ t.providers.state.checkOk = false;
++ const bad = (await t.get('/api/v1/tasks', { as: kim, json: { task: 'test' }, headers: SAME })).json().id;
++ await t.waitFor(bad);
++
++ // The pages as kim sees them, served next to the app's own assets (a proxy for everything else).
++ const pages = {};
++ pages['/task-ok'] = (await t.get(`/tasks/${ok}`, { as: kim })).text;
++ pages['/task-failed'] = (await t.get(`/tasks/${bad}`, { as: kim })).text;
++ const proxy = http.createServer(async (req, res) => {
++ if (pages[req.url]) { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(pages[req.url]); }
++ const r = await fetch(t.base + req.url).catch(() => null);
++ if (!r) { res.writeHead(502); return res.end(); }
++ res.writeHead(r.status, { 'content-type': r.headers.get('content-type') || 'application/octet-stream' });
++ return res.end(Buffer.from(await r.arrayBuffer()));
++ });
++ await new Promise((r) => proxy.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${proxy.address().port}`;
++ const jobs = [];
++ Object.keys(pages).forEach((name) => [1280, 390].forEach((w) => jobs.push([name, w])));
++ await jobs.reduce((p, [name, w]) => p.then(() => new Promise((resolve) => {
++ const shot = path.join(out, `${name.slice(1)}-${w}.png`);
++ const c = spawn('google-chrome', ['--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars', `--window-size=${w},1500`, '--virtual-time-budget=3000', `--screenshot=${shot}`, base + name], { stdio: 'ignore' });
++ const kill = setTimeout(() => c.kill('SIGKILL'), 45000);
++ c.on('exit', () => { clearTimeout(kill); resolve(); });
++ })), Promise.resolve());
++ proxy.close();
++ await t.close();
++ process.exit(0);
++})().catch((e) => { process.stderr.write(String(e && e.stack)); process.exit(1); });
ddiff --git a/test/tasks.test.js b/test/tasks.test.js
iindex a327794..5e847a8 100644
---- a/test/tasks.test.js
++++ b/test/tasks.test.js
@@@ -110,7 +110,11 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
assert.ok(!/Runtime answer/.test(task.result.answer));
const events = await t.get(`/api/v1/tasks/${id}/events`, { as: kim });
assert.match(events.text, /Check failed/);
-- assert.match(events.text, /Unchecked answer, not delivered/);
++ const refused = [...events.text.matchAll(/^data: (.*)$/gm)].map((m) => JSON.parse(m[1])).filter((e) => e.step === 'text' && e.is_error);
++ assert.ok(refused.some((e) => /Runtime answer/.test(e.text)), 'the refused answer is kept as an error event after its check');
++ const pageHtml = (await t.get(`/tasks/${id}`, { as: kim })).text;
++ assert.match(pageHtml, /See the answer that didn't pass/, 'the task page folds the refused answer away');
++ assert.match(pageHtml, /class="outcome outcome-ok answer"/);
});
await check('when every agent fails its check the task fails, with no result', async () => {
@@@ -139,6 +143,31 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
assert.ok(deepseekChecks.length === 1 && deepseekChecks[0].body.thinking && deepseekChecks[0].body.thinking.type === 'disabled', 'short calls run with DeepSeek reasoning off');
});
++ await check('a hand-off never goes to a weaker agent: after the runtime, the open model is out (lacks quality:standard)', async () => {
++ t.providers.reset();
++ t.providers.state.cls = 'answer';
++ t.providers.state.checkOk = (body) => !/Runtime answer/.test(JSON.stringify(body));
++ const id = (await create({ task: 'test', mode: 'balanced' })).json().id;
++ await t.waitFor(id);
++ const task = (await t.get(`/api/v1/tasks/${id}`, { as: kim })).json();
++ assert.strictEqual(task.state, 'succeeded', JSON.stringify(task.error));
++ assert.strictEqual(task.result.agent, 'openai-agent');
++ assert.ok(task.explanation[1].candidates.some((c) => c.id === 'open-model' && /lacks quality:standard/.test(c.excluded_because)), JSON.stringify(task.explanation[1].candidates));
++ });
++
++ await check('a task that did not finish shows why in plain words and offers a retry in Best mode', async () => {
++ t.providers.reset();
++ t.providers.state.cls = 'research';
++ t.providers.state.checkOk = false;
++ const id = (await create({ task: 'Read about OpenVibe and summarize it' })).json().id;
++ await t.waitFor(id);
++ const pageHtml = (await t.get(`/tasks/${id}`, { as: kim })).text;
++ assert.match(pageHtml, /Actor couldn't finish this/);
++ assert.match(pageHtml, /name="mode" value="best"/);
++ assert.match(pageHtml, /Try again in Best mode/);
++ assert.match(pageHtml, /class="attempt"/);
++ });
++
await check('a budget below the cheapest capable agent fails before anything is paid', async () => {
t.providers.reset();
t.providers.state.cls = 'web';