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)
Changes
@@@ -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; } }@@@ -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 });}@@@ -17,7 +17,8 @@ const CHECKER_FOR = { deepseek: ['openai', 'deepseek'], openai: ['deepseek', 'opconst 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 }) {@@@ -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) });@@@ -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>`;}@@@ -76,6 +76,9 @@ function createEngine({ config, s, adapters, stream, now = () => Date.now(), logtimer.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(), logawait 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(), logreturn 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(), logif (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();@@@ -20,8 +20,8 @@ const BUDGETS = {jsRawKB: 264, // 239.8jsBrotliKB: 62.5, // 56.5cssFiles: 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.4externalFiles: 0, // 0};nnew file mode 100644@@@ -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); });@@@ -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';
@@ -81,25 +81,60 @@ button.sc-btn.sc-primary { border: 0; }
@@ -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 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; }
.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; } }
@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 { margin-top: 1.4rem; border: 1px solid var(--border, #1f2d47); border-radius: 12px; padding: 10px 14px; }
.why summary { cursor: pointer; font-weight: 700; }
.why summary { cursor: pointer; font-weight: 700; }
@media (max-width: 640px) {
@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; } }
@@ -1,48 +1,73 @@
@@ -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 () {
(function () {
'use strict';
'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() {
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 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);
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) {
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 () {});
}).catch(function () {});
});
});
src.addEventListener('end', function () { src.close(); window.location.reload(); });
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); };
src.onerror = function () { if (src.readyState === 2) setTimeout(function () { window.location.reload(); }, 4000); };
window.addEventListener('pagehide', function () { src.close(); }, { once: true });
window.addEventListener('pagehide', function () { src.close(); }, { once: true });
}
}
@@ -17,7 +17,8 @@ const CHECKER_FOR = { deepseek: ['openai', 'deepseek'], openai: ['deepseek', 'op
@@ -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>"}.
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 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.
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. */
/** Which checkers may read this agent's answer, best first: another family, then the same family. */
function checkersFor(agentId, checkers, { mode }) {
function checkersFor(agentId, checkers, { mode }) {
@@ -9,6 +9,10 @@
@@ -9,6 +9,10 @@
* best → balanced among agents that offer quality:high; when none can take the task, balanced among the rest,
* 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)
* 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 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.
* 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');
@@ -20,13 +24,13 @@ const { requirementFor } = require('./classify');
const OBJECTIVE = { cheapest: 'cheapest', balanced: 'balanced', fastest: 'lowest-latency', private: 'private', best: 'balanced' };
const OBJECTIVE = { cheapest: 'cheapest', balanced: 'balanced', fastest: 'lowest-latency', private: 'private', best: 'balanced' };
const MODES = Object.keys(OBJECTIVE);
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 = {
const req = {
kind: `actor.${cls}`,
kind: `actor.${cls}`,
mobility: 'job',
mobility: 'job',
latency_class: 'interactive',
latency_class: 'interactive',
objective: OBJECTIVE[mode],
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 (mode === 'private') req.trust = ['first-party'];
if (budgetUsd != null) req.max_cost_usd = budgetUsd;
if (budgetUsd != null) req.max_cost_usd = budgetUsd;
@@ -47,11 +51,11 @@ function planOnce({ cls, mode, pinned, budgetUsd, config, tried, now, measured,
@@ -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
* 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).
* (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());
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) {
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 || [])] };
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) });
for (const [id, why] of tried) r.candidates.push({ id, eligible: false, excluded_because: `tried first: ${why}`.slice(0, 200) });
@@ -188,10 +188,10 @@ ${a.available ? '' : html`<p class="muted small">${a.reason}</p>`}
@@ -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 pageRows = await store.listTasks(s, requesterOf(req), { limit: 30, before });
const today = await store.spendToday(s, requesterOf(req));
const today = await store.spendToday(s, requesterOf(req));
page(req, res, {
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>
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 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) => {
${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;
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)];
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</
@@ -201,6 +201,12 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</
});
});
// ── One task ─────────────────────────────────────────────
// ── 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) => {
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;
const row = TASK_ID.test(req.params.id) && signedIn(req) ? await store.getTask(s, req.params.id) : null;
if (!row || row.requester !== requesterOf(req)) {
if (!row || row.requester !== requesterOf(req)) {
@@ -209,46 +215,112 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</
@@ -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 t = store.toWire(row, { baseUrl: config.baseUrl });
const events = await store.eventsAfter(s, row.id, 0);
const events = await store.eventsAfter(s, row.id, 0);
const open = store.OPEN.includes(row.state);
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, {
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'] : [],
scripts: open ? ['js/task.js'] : [],
+ styles: [showcase.STYLESHEET],
noReferrer: true,
noReferrer: true,
body: html`${open ? raw('<noscript><meta http-equiv="refresh" content="4"></noscript>') : ''}
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>
<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) : ''}
${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>`,
</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);
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>
<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>`;
</section>`;
}
}
function stepItem(e) {
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) {
function explanationBlock(explanation) {
return html`<details class="why"><summary>Why this agent</summary>
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' : '')]))}`)}
${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>`;
</details>`;
}
}
@@ -76,6 +76,9 @@ function createEngine({ config, s, adapters, stream, now = () => Date.now(), log
@@ -76,6 +76,9 @@ function createEngine({ config, s, adapters, stream, now = () => Date.now(), log
timer.unref();
timer.unref();
const explanation = [];
const explanation = [];
const tried = new Map();
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 {
try {
await setState(id, 'running', { cost_usd: 0, free_usd: 0 });
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.
// 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
@@ -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.` });
await emit(id, { kind: 'output', step: 'plan', text: `This is a "${cls}" task.` });
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
- const placement = route({ cls, mode: row.mode, agent: row.agent, budgetUsd: Math.max(0, budgetLeft()), config, tried, now: now() });
+ 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);
explanation.push(placement);
await store.updateTask(s, id, { explanation });
await store.updateTask(s, id, { explanation });
if (!placement.selected) {
if (!placement.selected) {
@@ -112,6 +115,7 @@ function createEngine({ config, s, adapters, stream, now = () => Date.now(), log
@@ -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() });
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);
tried.set(agentId, why);
+ raise(agent);
continue;
continue;
}
}
await setState(id, 'verifying', { agentForEvent: agentId });
await setState(id, 'verifying', { agentForEvent: agentId });
@@ -125,8 +129,10 @@ function createEngine({ config, s, adapters, stream, now = () => Date.now(), log
@@ -125,8 +129,10 @@ function createEngine({ config, s, adapters, stream, now = () => Date.now(), log
if (verdict.unavailable) {
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() });
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}`);
tried.set(agentId, `its answer failed the check: ${verdict.reason}`);
+ raise(agent);
await setState(id, 'running');
await setState(id, 'running');
}
}
const last = [...tried.entries()].pop();
const last = [...tried.entries()].pop();
@@ -20,8 +20,8 @@ const BUDGETS = {
@@ -20,8 +20,8 @@ const BUDGETS = {
jsRawKB: 264, // 239.8
jsRawKB: 264, // 239.8
jsBrotliKB: 62.5, // 56.5
jsBrotliKB: 62.5, // 56.5
cssFiles: 2, // 2 (actor.css + the cached /shared/showcase.css)
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
externalFiles: 0, // 0
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,47 @@
@@ -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); });
@@ -110,7 +110,11 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -110,7 +110,11 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
assert.ok(!/Runtime answer/.test(task.result.answer));
assert.ok(!/Runtime answer/.test(task.result.answer));
const events = await t.get(`/api/v1/tasks/${id}/events`, { as: kim });
const events = await t.get(`/api/v1/tasks/${id}/events`, { as: kim });
assert.match(events.text, /Check failed/);
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 () => {
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' };
@@ -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');
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 () => {
await check('a budget below the cheapest capable agent fails before anything is paid', async () => {
t.providers.reset();
t.providers.reset();
t.providers.state.cls = 'web';
t.providers.state.cls = 'web';