openvibers/openvibe.actor Commits
Actor v2 stage V2-5b: follow-ups that keep the question and answer in mind (#195)
Changes
nnew file mode 100644@@@ -0,0 +1,8 @@++-- phase: expand++-- Actor v2, stage V2-5b: a follow-up is a task that continues another, with that task's question and answer in mind.++--++-- parent_id is the task it follows up on, always the same person's (server/http/pages.js checks it before the++-- insert); NULL for a task that starts fresh. When a parent goes (retention, the person's deletion), its follow-ups++-- stay as tasks of their own.++ALTER TABLE tasks ADD COLUMN IF NOT EXISTS parent_id text COLLATE "C" REFERENCES tasks (id) ON DELETE SET NULL;++CREATE INDEX IF NOT EXISTS tasks_parent ON tasks (parent_id) WHERE parent_id IS NOT NULL;@@@ -66,4 +66,20 @@.answer .prose table { display: block; max-width: 100%; overflow-x: auto; }.answer .prose th, .answer .prose td { overflow-wrap: normal; word-break: normal; white-space: nowrap; }++/* Follow-ups (stage V2-5b): the thread above, one composer under the answer. */++.follows-on { margin: 0 0 .3rem; font-size: .9rem; color: var(--text-muted, #7386a3); overflow-wrap: anywhere; }++.follows-on a { font-weight: 650; }++.follow { margin: -0.6rem 0 1.6rem; }++.follow > h2 { margin: 0 0 .5rem; font-size: .78rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }++.follow-list { list-style: none; margin: 0 0 .7rem; padding: 0; display: grid; gap: 6px; }++.follow-list li { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 6px 10px; padding: 9px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }++.follow-list a { min-width: 0; flex: 1 1 14rem; font-weight: 650; overflow-wrap: anywhere; text-decoration: none; }++.follow-list a:hover { text-decoration: underline; }++.follow-form { display: flex; align-items: flex-end; gap: 8px; padding: 6px 6px 6px 14px; border-radius: 16px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #101828); transition: border-color .15s, box-shadow .15s; }++.follow-form:focus-within { border-color: var(--accent, #3b82f6); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); }++.follow-form textarea { flex: 1; min-width: 0; min-height: 40px; max-height: 40vh; padding: 9px 0; resize: none; border: 0; outline: none; background: transparent; color: var(--text-primary, #e6edf7); font: inherit; line-height: 1.45; }++.follow-form textarea::placeholder { color: var(--text-muted, #7386a3); }++.follow-form .sc-btn { flex: none; min-height: 40px; padding: 0 18px; border-radius: 12px; }++.follow-hint { margin: .45rem 0 0 4px; font-size: .82rem; color: var(--text-muted, #7386a3); }++@media (prefers-reduced-motion: reduce) { .ps, .ps.is-active .ps-mark { animation: none; } .ev, .ev-item { transition: none; } }@@@ -90,8 +90,22 @@if (at && document.getElementById('evidence')) { document.getElementById('evidence').open = true; at.classList.add('is-open'); }}++ // The follow-up box grows with what is typed (up to 40% of the screen), and Ctrl/Cmd+Enter sends it.++ function followBox() {++ var box = document.getElementById('follow-text');++ if (!box || box.dataset.bound) return;++ box.dataset.bound = '1';++ var fit = function () { box.style.height = 'auto'; box.style.height = Math.min(box.scrollHeight, Math.round(window.innerHeight * 0.4)) + 'px'; };++ box.addEventListener('input', fit);++ box.addEventListener('keydown', function (e) {++ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && box.value.trim() && box.form) { e.preventDefault(); if (box.form.requestSubmit) box.form.requestSubmit(); else box.form.submit(); }++ });++ if (location.hash === '#follow-up') { try { box.focus({ preventScroll: true }); } catch (err) { box.focus(); } }++ }++function start() {evidence();++ followBox();var art0 = document.querySelector('article.task');var view = art0 ? session(art0) : null;var art = document.querySelector('article.task[data-open="1"]');@@@ -334,6 +334,28 @@ ${raw(showcase.faq({ id: 'faq', items: faq }))}return res.redirect(303, `/tasks/${out.task.id}`);});++ // A follow-up (Actor v2, stage V2-5b): a new task of the same person's that continues an answered one, with its++ // question and answer in mind (store.followContext). Only from the person's own page on Actor, in the parent's mode,++ // inside the same allowance as any task.++ const FOLLOW_ERROR = {++ 'actor.allowance.exhausted': 'You have used today\'s free tasks. They come back at midnight UTC.',++ 'actor.budget.day_spent': 'Today\'s budget is spent. It resets at midnight UTC.',++ 'actor.capacity.spent': 'Actor has used today\'s free capacity across everyone. It resets at midnight UTC.',++ };++ r.post('/tasks/:id/follow-up', require('express').urlencoded({ extended: false, limit: '64kb' }), async (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ if (!signedIn(req) || !TASK_ID.test(req.params.id)) return res.redirect(303, '/tasks');++ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('A follow-up must be sent from openvibe.actor itself.');++ const parent = await store.getTask(s, req.params.id);++ if (!parent || parent.requester !== requesterOf(req)) return res.status(404).type('text/plain').send('No such task');++ if (parent.state !== 'succeeded') return res.redirect(303, `/tasks/${parent.id}`);++ const text = String((req.body && req.body.task) || '').trim().slice(0, 20000);++ if (!text) return res.redirect(303, `/tasks/${parent.id}#follow-up`);++ const out = await createTask({ s, config, engine, principal: { requester: requesterOf(req), project: null }, body: { task: text, mode: parent.mode }, parentId: parent.id });++ if (out.code) return res.redirect(303, `/tasks/${parent.id}?follow=${encodeURIComponent(out.code)}#follow-up`);++ return res.redirect(303, `/tasks/${out.task.id}`);++ });++// ── Your tasks ───────────────────────────────────────────r.get('/tasks', async (req, res) => {if (!signedIn(req)) {@@@ -398,6 +420,10 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</const kind = cls ? CLASS_TEXT[cls[1]] || cls[1] : null;const desk = open ? store.deskFrom(events) : null;const look = looks.get(row.id);++ const parentRow = row.parent_id ? await store.getTask(s, row.parent_id) : null;++ const parentTask = parentRow && parentRow.requester === row.requester ? parentRow : null;++ const follows = t.state === 'succeeded' ? await store.followUps(s, row) : [];++ const followProblem = typeof req.query.follow === 'string' ? (FOLLOW_ERROR[req.query.follow] || 'That follow-up could not start. Try again in a moment.') : null;const planEvent = [...events].reverse().find((e) => e.kind === 'output' && e.step === 'plan' && taskEvents.plan(e.text));const plan = (t.result && t.result.plan && Array.isArray(t.result.plan.steps) && t.result.plan.steps.length ? t.result.plan : null)|| (planEvent ? taskEvents.plan(planEvent.text) : null);@@@ -415,9 +441,11 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</<article class="task state-${t.state}" data-task="${row.id}" data-last="${row.last_seq}" data-open="${open ? '1' : ''}" data-bot="${botPages}"><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>++${parentTask ? html`<p class="follows-on">Following up on <a href="/tasks/${parentTask.id}">${parentTask.task.length > 90 ? `${parentTask.task.slice(0, 89)}…` : parentTask.task}</a></p>` : ''}<h1 class="task-text">${row.task}</h1></header>${outcome(t, row)}++${t.state === 'succeeded' ? followBlock(row, follows, followProblem) : ''}${open ? planCard(plan) : ''}${open ? html`<section class="desk-card" id="desk-card" aria-live="polite"${desk ? '' : raw(' hidden')}><p><strong>Your agent is working on a desk of its own</strong><span id="desk-kind">${desk && desk.kind === 'browser' ? ' (a browser)' : ''}</span>. Watch it live, and take it over whenever you like: while you use it, the agent waits; when you stop, it goes on.</p>@@@ -464,6 +492,25 @@ ${table(['State', 'Endpoint', 'Delivery', 'Attempts'], rows.map((d) => [</section>`;}++ /**++ * Ask a follow-up: one box under the answer, the follow-ups already asked above it. The new task keeps this question++ * and answer in mind and is checked the same way. It is a plain form: no script needed.++ */++ function followBlock(row, follows, problem) {++ const STATE = { queued: 'Waiting', running: 'Working', verifying: 'Checking', succeeded: 'Done', failed: 'Not finished', cancelled: 'Cancelled' };++ return html`<section class="follow" id="follow-up" aria-labelledby="h-follow">++<h2 id="h-follow"${follows.length ? '' : raw(' class="sr-only"')}>${follows.length ? 'Follow-ups' : 'Ask a follow-up'}</h2>++${follows.length ? html`<ol class="follow-list">${follows.map((f) => html`<li><a href="/tasks/${f.id}">${f.task.length > 120 ? `${f.task.slice(0, 119)}…` : f.task}</a> <span class="pill pill-${f.state}">${STATE[f.state] || f.state}</span></li>`)}</ol>` : ''}++${problem ? html`<p class="notice warn" role="alert">${problem}</p>` : ''}++<form method="post" action="/tasks/${row.id}/follow-up" class="follow-form">++<label for="follow-text" class="sr-only">Ask a follow-up</label>++<textarea id="follow-text" name="task" rows="1" maxlength="20000" required placeholder="Ask a follow-up…"></textarea>++<button class="sc-btn sc-primary" type="submit">Ask</button>++</form>++<p class="follow-hint">It keeps this question and answer in mind, and checks its answer the same way.</p>++</section>`;++ }++/** The outcome card: the first thing under the task. */function outcome(t, row) {if (t.state === 'succeeded') return resultBlock(t.result, t);@@@ -35,7 +35,7 @@ const TABLES = [{table: 'tasks', subject: 'requester', value: (usr) => `user:${usr}`, file: 'tasks.json',columns: ['id', 'requester', 'project_id', 'task', 'mode', 'agent', 'budget_task', 'budget_day', 'state', 'result', 'error_code', 'error_detail',-- 'cost_usd', 'free_usd', 'explanation', 'cancel_at', 'cancel_by', 'last_seq', 'idem_key', 'idem_hash', 'created_at', 'finished_at', 'timeout_ms', 'via', 'webhooks'],++ 'cost_usd', 'free_usd', 'explanation', 'cancel_at', 'cancel_by', 'last_seq', 'idem_key', 'idem_hash', 'created_at', 'finished_at', 'timeout_ms', 'via', 'webhooks', 'parent_id'],},// The person's own per-UTC-day spend accounting. Deleted: nothing in Actor keeps a person's spend record for a// reason that outlives them. The `*` row (everyone's daily ceiling) does not match `user:usr_…` and stays — it is@@@ -116,6 +116,13 @@ function createLoop({ config, models, registry, log = console, now = () => Date.const selfReflections = loopCfg.selfReflections == null ? 2 : loopCfg.selfReflections;/** The plan: a fast-tier call when one is available, else one honest step from the task itself. */++ // A follow-up is planned with what it follows: "and for 8 GB?" means nothing on its own.++ const FOLLOW_NOTE = 'This follows up on the conversation above. Citations in the earlier answers belong to earlier runs: fetch again any live fact you cite.';++ function planInput(state) {++ if (!state.context.length) return state.task;++ return `Earlier in this conversation:\n${state.context.map((c) => `Asked: ${clip(c.task, 600)}\nAnswered: ${clip(c.answer, 1200)}`).join('\n\n')}\n\nNow, a follow-up: ${state.task}`;++ }++async function makePlan(state, ctx) {const fallback = () => ({ assumptions: [], steps: [{ goal: clip(state.task.replace(/\s+/g, ' ').trim(), 200) || 'Do the task', state: 'pending' }] });let plan = fallback();@@@ -125,7 +132,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.tier: 'fast',messages: [{ role: 'system', content: `You plan one task for an agent that can call tools. Reply with JSON only: {"assumptions":["..."],"steps":[{"goal":"..."}]}. Give 2 to 5 short sub-goals in the order you will do them. An assumption is only what you must suppose and the task does not say; keep the list empty when there is none. Today is ${today(now)}.` },-- { role: 'user', content: state.task.slice(0, 6000) },++ { role: 'user', content: planInput(state).slice(0, 6000) },],json: true, maxTokens: 400, signal: state.signal,});@@@ -247,7 +254,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.};}-- async function run({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null }) {++ async function run({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null, context = [] }) {const tier = mode === 'best' && models.resolve('best').available ? 'best' : 'smart';if (!models.resolve(tier).available) throw Object.assign(new Error(models.resolve(tier).reason || 'no model is available for the loop'), { code: 'actor.model.unavailable' });// Files and charts made in this run are stored privately for the person the task is for, in Actor's own@@@ -262,13 +269,15 @@ function createLoop({ config, models, registry, log = console, now = () => Date.const state = {task: String(task), requester, taskId, mode, tier, signal, messages: [], lastText: '', model: null, family: null, provider: null,calls: 0, ledger: createLedger({ now }), plan: { assumptions: [], steps: [] }, counts: new Map(), stopped: null, selfReflections: 0,-- ctx,++ ctx, context: Array.isArray(context) ? context.filter((c) => c && c.task && c.answer).slice(-3) : [],};try {state.plan = await makePlan(state, ctx);await emitPlan(state, ctx);state.messages.push({ role: 'system', content: SYSTEM({ date: today(now), plan: planText(state.plan), maxCalls }) });-- state.messages.push({ role: 'user', content: state.task });++ // A follow-up (stage V2-5b): the conversation it continues, as it went, then the new question.++ for (const c of state.context) state.messages.push({ role: 'user', content: c.task }, { role: 'assistant', content: c.answer });++ state.messages.push({ role: 'user', content: state.context.length ? `${FOLLOW_NOTE}\n\n${state.task}` : state.task });const tools = registry.definitions({ requester, mode, config, models, signal, artifacts });// Read from config each run so a test (or an operator's change) takes effect without rebuilding the loop.const maxSelf = config.loop && config.loop.selfReflections != null ? config.loop.selfReflections : selfReflections;@@@ -18,7 +18,7 @@ function untilMidnight(nowMs) {return Math.ceil((Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), n.getUTCDate() + 1) - n.getTime()) / 1000);}--async function createTask({ s, config, engine, principal, body, webhooks: webhookQueue = null, beforeInsert = null }) {++async function createTask({ s, config, engine, principal, body, webhooks: webhookQueue = null, beforeInsert = null, parentId = null }) {const v = contracts.validate('actor.task-create-request@1', body);if (!v.valid) return { status: 422, code: 'actor.task.invalid', detail: (v.errors || []).map((e) => `${e.path || e.instancePath || ''} ${e.message}`.trim()).join('; ').slice(0, 500) || 'invalid request' };const tier = config.allowance;@@@ -69,7 +69,7 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhooconst row = await store.insertTask(s, {id: s.newId('tsk'), requester: principal.requester, via: principal.via || null, project_id: principal.project || null, task: body.task, mode: body.mode || 'balanced', agent: body.agent || null,budget_task: perTask, budget_day: perDay, idem_key: body.idempotency_key || null, idem_hash: body.idempotency_key ? hash : null, created_at: s.iso(),-- webhooks, webhook_secret: ownSecret ? mintSecret() : null,++ webhooks, webhook_secret: ownSecret ? mintSecret() : null, parent_id: parentId,// A task's own clock (Actor v2): 10 minutes by default; mode best may run up to an hour.timeout_ms: (body.mode || 'balanced') === 'best' ? config.bestTimeoutMs : config.taskTimeoutMs,});@@@ -156,6 +156,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =try {out = await loop.run({task: row.task, requester: fromChat ? null : row.requester, taskId: id, mode: row.mode, signal,++ context: row.parent_id ? await store.followContext(s, row) : [],emit: (e) => emit(id, { kind: 'output', agent: agentId, ...e }),meter, budgetLeft, waitForPerson, requestApproval,});@@@ -22,15 +22,38 @@ function subjectRef(requester) {async function insertTask(s, t) {await s.db.query(-- `INSERT INTO tasks (id, requester, project_id, task, mode, agent, budget_task, budget_day, state, idem_key, idem_hash, created_at, via, webhooks, webhook_secret, timeout_ms)-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'queued', $9, $10, $11, $12, $13::jsonb, $14, $15)`,++ `INSERT INTO tasks (id, requester, project_id, task, mode, agent, budget_task, budget_day, state, idem_key, idem_hash, created_at, via, webhooks, webhook_secret, timeout_ms, parent_id)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'queued', $9, $10, $11, $12, $13::jsonb, $14, $15, $16)`,[t.id, t.requester, t.project_id || null, t.task, t.mode, t.agent || null, t.budget_task, t.budget_day, t.idem_key || null, t.idem_hash || null, t.created_at, t.via || null,-- t.webhooks && t.webhooks.length ? JSON.stringify(t.webhooks) : null, t.webhook_secret || null, t.timeout_ms || null]);++ t.webhooks && t.webhooks.length ? JSON.stringify(t.webhooks) : null, t.webhook_secret || null, t.timeout_ms || null, t.parent_id || null]);await addSpend(s, t.requester, 0, { tasks: 1 });return getTask(s, t.id);}const getTask = (s, id) => s.db.maybe('SELECT * FROM tasks WHERE id = $1', [id]);++++/**++ * The conversation a follow-up continues (Actor v2, stage V2-5b): up to `depth` tasks before it, oldest first, each its++ * question and its answer, only the same person's, only answered ones. An answer's [E#] citations belonged to its own++ * run, so they are taken out: the new run cites only what it fetches itself. Each answer is clipped to `clip` chars.++ */++async function followContext(s, row, { depth = 3, clip = 3000 } = {}) {++ const out = [];++ let at = row && row.parent_id;++ for (let i = 0; at && i < depth; i++) {++ const p = await getTask(s, at);++ if (!p || p.requester !== row.requester) break;++ const result = p.result ? (typeof p.result === 'string' ? JSON.parse(p.result) : p.result) : null;++ if (p.state === 'succeeded' && result && result.answer) {++ const answer = String(result.answer).replace(/\s*\[E\d{1,3}(?:\s*,\s*E\d{1,3})*\]/g, '');++ out.unshift({ task: String(p.task), answer: answer.length > clip ? `${answer.slice(0, clip - 1)}…` : answer });++ }++ at = p.parent_id;++ }++ return out;++}++/** The follow-ups of a task, oldest first: { id, task, state, created_at }. */++const followUps = (s, row) => s.db.many('SELECT id, task, state, created_at FROM tasks WHERE parent_id = $1 AND requester = $2 ORDER BY created_at, id LIMIT 20', [row.id, row.requester]);const byIdempotency = (s, requester, key) => s.db.maybe('SELECT * FROM tasks WHERE requester = $1 AND idem_key = $2', [requester, key]);/** A requester's tasks, newest first; `via` narrows to the ones that service started for them. */@@@ -205,4 +228,4 @@ function toWire(row, { baseUrl }) {return t;}--module.exports = { insertTask, getTask, byIdempotency, listTasks, updateTask, appendEvent, eventsAfter, lastEvent, needsYou, deskFrom, taskDesk, addSpend, spendToday, spendDays, lockSpendToday, failInterrupted, prune, toWire, subjectRef, dayOf, parseJson: parse, OPEN, RETENTION_DAYS };++module.exports = { insertTask, getTask, byIdempotency, listTasks, updateTask, appendEvent, eventsAfter, lastEvent, needsYou, deskFrom, taskDesk, addSpend, spendToday, spendDays, lockSpendToday, failInterrupted, prune, toWire, subjectRef, dayOf, parseJson: parse, OPEN, RETENTION_DAYS, followContext, followUps };nnew file mode 100644@@@ -0,0 +1,97 @@++'use strict';++// Follow-ups (Actor v2, stage V2-5b): a person asks again under an answer, and the new task runs with that question and++// answer in mind (its plan too), without the old run's citations; only the task's own person may, only from Actor's++// own page, only after an answer; the two pages link to each other, and the column goes with the person's data.++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const SAME = { 'sec-fetch-site': 'same-origin' };++const ID = /\/tasks\/(tsk_[0-9A-HJKMNP-TV-Z]{26})$/;++++(async () => {++ const t = await boot();++ const kim = t.network.addUser('kim');++ const bo = t.network.addUser('bo');++ t.config.loop.selfReflections = 0;++ const s = t.ctx.s;++ const answered = async (task, answer) => {++ t.providers.state.script = [{ content: answer }];++ const created = (await t.get('/api/v1/tasks', { as: kim, json: { task }, headers: SAME })).json();++ await t.waitFor(created.id);++ const row = await s.db.maybe('SELECT state FROM tasks WHERE id = $1', [created.id]);++ assert.strictEqual(row.state, 'succeeded');++ return created.id;++ };++ const follow = (id, text, o = {}) => t.get(`/tasks/${id}/follow-up`, { as: o.as || kim, form: { task: text }, headers: o.headers || SAME });++++ try {++ const first = await answered('Which VPS is cheapest for 2 vCPU and 4 GB?', 'Hetzner CX22 at €4.51 a month [E1], then Netcup [E2, E3].');++++ await check('a follow-up is a new task that runs with the question and answer before it, citations taken out', async () => {++ t.providers.reset();++ t.config.loop.selfReflections = 0;++ t.providers.state.script = [{ content: 'With 8 GB, Hetzner CX32 at €7.59.' }];++ const r = await follow(first, 'And for 8 GB?');++ assert.strictEqual(r.status, 303);++ const next = ID.exec(r.headers.get('location'))[1];++ await t.waitFor(next);++ const row = await s.db.maybe('SELECT parent_id, requester, mode, state FROM tasks WHERE id = $1', [next]);++ assert.deepStrictEqual([row.parent_id, row.state, row.mode], [first, 'succeeded', 'balanced'], 'the same person\'s task, in the parent\'s mode');++ const sent = t.providers.requests.map((q) => JSON.stringify(q.body || {}));++ const loopCall = sent.find((b) => b.includes('This follows up on the conversation above'));++ assert.ok(loopCall, 'the loop is told it is a follow-up, and to fetch live facts again');++ assert.ok(loopCall.includes('And for 8 GB?') && loopCall.includes('Hetzner CX22 at €4.51 a month'), 'with the earlier answer and the new question');++ assert.ok(loopCall.includes('{"role":"assistant","content":"Hetzner CX22 at €4.51 a month, then Netcup."}'), 'the earlier answer as its turn, without the earlier run\'s citations');++ assert.ok(sent.some((b) => b.includes('Earlier in this conversation') && b.includes('Now, a follow-up: And for 8 GB?')), 'the plan is made with what it follows');++ });++++ await check('the two pages link each other: the follow-ups under the answer, the thread above the follow-up', async () => {++ const kids = await s.db.many('SELECT id FROM tasks WHERE parent_id = $1', [first]);++ const parentPage = (await t.get(`/tasks/${first}`, { as: kim })).text;++ assert.ok(parentPage.includes('<section class="follow" id="follow-up"'), 'the follow-up box');++ assert.ok(parentPage.includes(`action="/tasks/${first}/follow-up"`) && parentPage.includes('name="task"'), 'a plain form, no script needed');++ assert.ok(parentPage.includes(`<a href="/tasks/${kids[0].id}">And for 8 GB?</a>`), 'the follow-up asked, listed above the box');++ const childPage = (await t.get(`/tasks/${kids[0].id}`, { as: kim })).text;++ assert.ok(childPage.includes(`Following up on <a href="/tasks/${first}">Which VPS is cheapest`), 'and it links back');++ });++++ await check('only the task\'s own person, only from Actor, only after an answer, never empty', async () => {++ const before = Number(await s.db.value('SELECT count(*) FROM tasks'));++ assert.strictEqual((await follow(first, 'mine now', { as: bo })).status, 404, 'another person: the 404 of a task that does not exist');++ assert.strictEqual((await follow(first, 'from elsewhere', { headers: { 'sec-fetch-site': 'cross-site' } })).status, 403, 'another site cannot post one');++ const empty = await follow(first, ' ');++ assert.ok(empty.status === 303 && /#follow-up$/.test(empty.headers.get('location')), 'nothing to ask: back to the box');++ await s.db.query("UPDATE tasks SET state = 'failed' WHERE id = $1", [first]);++ const unanswered = await follow(first, 'and then?');++ assert.ok(unanswered.status === 303 && unanswered.headers.get('location').endsWith(`/tasks/${first}`), 'no answer yet, nothing to follow');++ await s.db.query("UPDATE tasks SET state = 'succeeded' WHERE id = $1", [first]);++ assert.strictEqual(Number(await s.db.value('SELECT count(*) FROM tasks')), before, 'none of those made a task');++ });++++ await check('the conversation reaches back three answers at most, and never into another person\'s task', async () => {++ const { followContext } = require('../server/tasks/store');++ const ids = [first];++ for (let i = 0; i < 4; i++) {++ t.providers.state.script = [{ content: `Answer ${i + 2}.` }];++ const r = await follow(ids[ids.length - 1], `Question ${i + 2}?`);++ const id = ID.exec(r.headers.get('location'))[1];++ await t.waitFor(id);++ ids.push(id);++ }++ const last = await s.db.maybe('SELECT * FROM tasks WHERE id = $1', [ids[ids.length - 1]]);++ const ctx = await followContext(s, last);++ assert.deepStrictEqual(ctx.map((c) => c.task), ['Question 2?', 'Question 3?', 'Question 4?'], 'the three before it, oldest first');++ await s.db.query('UPDATE tasks SET requester = $2 WHERE id = $1', [ids[3], `user:${bo.subject}`]);++ assert.deepStrictEqual((await followContext(s, last)).map((c) => c.task), [], 'a parent that is not the same person\'s ends the thread');++ });++++ await check('the parent column goes with the person\'s data', async () => {++ const ad = require('../server/identity/account-data');++ const spec = JSON.stringify(ad.TABLES || ad.tables || ad);++ assert.ok(spec.includes('parent_id'), 'exported and erased with the rest of the task');++ });++ } finally {++ await t.close();++ }++ done();++})().catch((e) => { console.error(e && e.stack || e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+-- phase: expand
+-- Actor v2, stage V2-5b: a follow-up is a task that continues another, with that task's question and answer in mind.
+--
+-- parent_id is the task it follows up on, always the same person's (server/http/pages.js checks it before the
+-- insert); NULL for a task that starts fresh. When a parent goes (retention, the person's deletion), its follow-ups
+-- stay as tasks of their own.
+ALTER TABLE tasks ADD COLUMN IF NOT EXISTS parent_id text COLLATE "C" REFERENCES tasks (id) ON DELETE SET NULL;
+CREATE INDEX IF NOT EXISTS tasks_parent ON tasks (parent_id) WHERE parent_id IS NOT NULL;
@@ -66,4 +66,20 @@
@@ -66,4 +66,20 @@
.answer .prose table { display: block; max-width: 100%; overflow-x: auto; }
.answer .prose table { display: block; max-width: 100%; overflow-x: auto; }
.answer .prose th, .answer .prose td { overflow-wrap: normal; word-break: normal; white-space: nowrap; }
.answer .prose th, .answer .prose td { overflow-wrap: normal; word-break: normal; white-space: nowrap; }
+/* Follow-ups (stage V2-5b): the thread above, one composer under the answer. */
+.follows-on { margin: 0 0 .3rem; font-size: .9rem; color: var(--text-muted, #7386a3); overflow-wrap: anywhere; }
+.follows-on a { font-weight: 650; }
+.follow { margin: -0.6rem 0 1.6rem; }
+.follow > h2 { margin: 0 0 .5rem; font-size: .78rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
+.follow-list { list-style: none; margin: 0 0 .7rem; padding: 0; display: grid; gap: 6px; }
+.follow-list li { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 6px 10px; padding: 9px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); }
+.follow-list a { min-width: 0; flex: 1 1 14rem; font-weight: 650; overflow-wrap: anywhere; text-decoration: none; }
+.follow-list a:hover { text-decoration: underline; }
+.follow-form { display: flex; align-items: flex-end; gap: 8px; padding: 6px 6px 6px 14px; border-radius: 16px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #101828); transition: border-color .15s, box-shadow .15s; }
+.follow-form:focus-within { border-color: var(--accent, #3b82f6); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); }
+.follow-form textarea { flex: 1; min-width: 0; min-height: 40px; max-height: 40vh; padding: 9px 0; resize: none; border: 0; outline: none; background: transparent; color: var(--text-primary, #e6edf7); font: inherit; line-height: 1.45; }
+.follow-form textarea::placeholder { color: var(--text-muted, #7386a3); }
+.follow-form .sc-btn { flex: none; min-height: 40px; padding: 0 18px; border-radius: 12px; }
+.follow-hint { margin: .45rem 0 0 4px; font-size: .82rem; color: var(--text-muted, #7386a3); }
+
@media (prefers-reduced-motion: reduce) { .ps, .ps.is-active .ps-mark { animation: none; } .ev, .ev-item { transition: none; } }
@media (prefers-reduced-motion: reduce) { .ps, .ps.is-active .ps-mark { animation: none; } .ev, .ev-item { transition: none; } }
@@ -90,8 +90,22 @@
@@ -90,8 +90,22 @@
if (at && document.getElementById('evidence')) { document.getElementById('evidence').open = true; at.classList.add('is-open'); }
if (at && document.getElementById('evidence')) { document.getElementById('evidence').open = true; at.classList.add('is-open'); }
}
}
+ // The follow-up box grows with what is typed (up to 40% of the screen), and Ctrl/Cmd+Enter sends it.
+ function followBox() {
+ var box = document.getElementById('follow-text');
+ if (!box || box.dataset.bound) return;
+ box.dataset.bound = '1';
+ var fit = function () { box.style.height = 'auto'; box.style.height = Math.min(box.scrollHeight, Math.round(window.innerHeight * 0.4)) + 'px'; };
+ box.addEventListener('input', fit);
+ box.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && box.value.trim() && box.form) { e.preventDefault(); if (box.form.requestSubmit) box.form.requestSubmit(); else box.form.submit(); }
+ });
+ if (location.hash === '#follow-up') { try { box.focus({ preventScroll: true }); } catch (err) { box.focus(); } }
+ }
+
function start() {
function start() {
evidence();
evidence();
+ followBox();
var art0 = document.querySelector('article.task');
var art0 = document.querySelector('article.task');
var view = art0 ? session(art0) : null;
var view = art0 ? session(art0) : null;
var art = document.querySelector('article.task[data-open="1"]');
var art = document.querySelector('article.task[data-open="1"]');
@@ -334,6 +334,28 @@ ${raw(showcase.faq({ id: 'faq', items: faq }))}
@@ -334,6 +334,28 @@ ${raw(showcase.faq({ id: 'faq', items: faq }))}
return res.redirect(303, `/tasks/${out.task.id}`);
return res.redirect(303, `/tasks/${out.task.id}`);
});
});
+ // A follow-up (Actor v2, stage V2-5b): a new task of the same person's that continues an answered one, with its
+ // question and answer in mind (store.followContext). Only from the person's own page on Actor, in the parent's mode,
+ // inside the same allowance as any task.
+ const FOLLOW_ERROR = {
+ 'actor.allowance.exhausted': 'You have used today\'s free tasks. They come back at midnight UTC.',
+ 'actor.budget.day_spent': 'Today\'s budget is spent. It resets at midnight UTC.',
+ 'actor.capacity.spent': 'Actor has used today\'s free capacity across everyone. It resets at midnight UTC.',
+ };
+ r.post('/tasks/:id/follow-up', require('express').urlencoded({ extended: false, limit: '64kb' }), async (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ if (!signedIn(req) || !TASK_ID.test(req.params.id)) return res.redirect(303, '/tasks');
+ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('A follow-up must be sent from openvibe.actor itself.');
+ const parent = await store.getTask(s, req.params.id);
+ if (!parent || parent.requester !== requesterOf(req)) return res.status(404).type('text/plain').send('No such task');
+ if (parent.state !== 'succeeded') return res.redirect(303, `/tasks/${parent.id}`);
+ const text = String((req.body && req.body.task) || '').trim().slice(0, 20000);
+ if (!text) return res.redirect(303, `/tasks/${parent.id}#follow-up`);
+ const out = await createTask({ s, config, engine, principal: { requester: requesterOf(req), project: null }, body: { task: text, mode: parent.mode }, parentId: parent.id });
+ if (out.code) return res.redirect(303, `/tasks/${parent.id}?follow=${encodeURIComponent(out.code)}#follow-up`);
+ return res.redirect(303, `/tasks/${out.task.id}`);
+ });
+
// ── Your tasks ───────────────────────────────────────────
// ── Your tasks ───────────────────────────────────────────
r.get('/tasks', async (req, res) => {
r.get('/tasks', async (req, res) => {
if (!signedIn(req)) {
if (!signedIn(req)) {
@@ -398,6 +420,10 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</
@@ -398,6 +420,10 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</
const kind = cls ? CLASS_TEXT[cls[1]] || cls[1] : null;
const kind = cls ? CLASS_TEXT[cls[1]] || cls[1] : null;
const desk = open ? store.deskFrom(events) : null;
const desk = open ? store.deskFrom(events) : null;
const look = looks.get(row.id);
const look = looks.get(row.id);
+ const parentRow = row.parent_id ? await store.getTask(s, row.parent_id) : null;
+ const parentTask = parentRow && parentRow.requester === row.requester ? parentRow : null;
+ const follows = t.state === 'succeeded' ? await store.followUps(s, row) : [];
+ const followProblem = typeof req.query.follow === 'string' ? (FOLLOW_ERROR[req.query.follow] || 'That follow-up could not start. Try again in a moment.') : null;
const planEvent = [...events].reverse().find((e) => e.kind === 'output' && e.step === 'plan' && taskEvents.plan(e.text));
const planEvent = [...events].reverse().find((e) => e.kind === 'output' && e.step === 'plan' && taskEvents.plan(e.text));
const plan = (t.result && t.result.plan && Array.isArray(t.result.plan.steps) && t.result.plan.steps.length ? t.result.plan : null)
const plan = (t.result && t.result.plan && Array.isArray(t.result.plan.steps) && t.result.plan.steps.length ? t.result.plan : null)
|| (planEvent ? taskEvents.plan(planEvent.text) : null);
|| (planEvent ? taskEvents.plan(planEvent.text) : null);
@@ -415,9 +441,11 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</
@@ -415,9 +441,11 @@ ${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</
<article class="task state-${t.state}" data-task="${row.id}" data-last="${row.last_seq}" data-open="${open ? '1' : ''}" data-bot="${botPages}">
<article class="task state-${t.state}" data-task="${row.id}" data-last="${row.last_seq}" data-open="${open ? '1' : ''}" data-bot="${botPages}">
<header class="task-head">
<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>
<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>
+${parentTask ? html`<p class="follows-on">Following up on <a href="/tasks/${parentTask.id}">${parentTask.task.length > 90 ? `${parentTask.task.slice(0, 89)}…` : parentTask.task}</a></p>` : ''}
<h1 class="task-text">${row.task}</h1>
<h1 class="task-text">${row.task}</h1>
</header>
</header>
${outcome(t, row)}
${outcome(t, row)}
+${t.state === 'succeeded' ? followBlock(row, follows, followProblem) : ''}
${open ? planCard(plan) : ''}
${open ? planCard(plan) : ''}
${open ? html`<section class="desk-card" id="desk-card" aria-live="polite"${desk ? '' : raw(' hidden')}>
${open ? html`<section class="desk-card" id="desk-card" aria-live="polite"${desk ? '' : raw(' hidden')}>
<p><strong>Your agent is working on a desk of its own</strong><span id="desk-kind">${desk && desk.kind === 'browser' ? ' (a browser)' : ''}</span>. Watch it live, and take it over whenever you like: while you use it, the agent waits; when you stop, it goes on.</p>
<p><strong>Your agent is working on a desk of its own</strong><span id="desk-kind">${desk && desk.kind === 'browser' ? ' (a browser)' : ''}</span>. Watch it live, and take it over whenever you like: while you use it, the agent waits; when you stop, it goes on.</p>
@@ -464,6 +492,25 @@ ${table(['State', 'Endpoint', 'Delivery', 'Attempts'], rows.map((d) => [
@@ -464,6 +492,25 @@ ${table(['State', 'Endpoint', 'Delivery', 'Attempts'], rows.map((d) => [
</section>`;
</section>`;
}
}
+ /**
+ * Ask a follow-up: one box under the answer, the follow-ups already asked above it. The new task keeps this question
+ * and answer in mind and is checked the same way. It is a plain form: no script needed.
+ */
+ function followBlock(row, follows, problem) {
+ const STATE = { queued: 'Waiting', running: 'Working', verifying: 'Checking', succeeded: 'Done', failed: 'Not finished', cancelled: 'Cancelled' };
+ return html`<section class="follow" id="follow-up" aria-labelledby="h-follow">
+<h2 id="h-follow"${follows.length ? '' : raw(' class="sr-only"')}>${follows.length ? 'Follow-ups' : 'Ask a follow-up'}</h2>
+${follows.length ? html`<ol class="follow-list">${follows.map((f) => html`<li><a href="/tasks/${f.id}">${f.task.length > 120 ? `${f.task.slice(0, 119)}…` : f.task}</a> <span class="pill pill-${f.state}">${STATE[f.state] || f.state}</span></li>`)}</ol>` : ''}
+${problem ? html`<p class="notice warn" role="alert">${problem}</p>` : ''}
+<form method="post" action="/tasks/${row.id}/follow-up" class="follow-form">
+<label for="follow-text" class="sr-only">Ask a follow-up</label>
+<textarea id="follow-text" name="task" rows="1" maxlength="20000" required placeholder="Ask a follow-up…"></textarea>
+<button class="sc-btn sc-primary" type="submit">Ask</button>
+</form>
+<p class="follow-hint">It keeps this question and answer in mind, and checks its answer the same way.</p>
+</section>`;
+ }
+
/** The outcome card: the first thing under the task. */
/** The outcome card: the first thing under the task. */
function outcome(t, row) {
function outcome(t, row) {
if (t.state === 'succeeded') return resultBlock(t.result, t);
if (t.state === 'succeeded') return resultBlock(t.result, t);
@@ -35,7 +35,7 @@ const TABLES = [
@@ -35,7 +35,7 @@ const TABLES = [
{
{
table: 'tasks', subject: 'requester', value: (usr) => `user:${usr}`, file: 'tasks.json',
table: 'tasks', subject: 'requester', value: (usr) => `user:${usr}`, file: 'tasks.json',
columns: ['id', 'requester', 'project_id', 'task', 'mode', 'agent', 'budget_task', 'budget_day', 'state', 'result', 'error_code', 'error_detail',
columns: ['id', 'requester', 'project_id', 'task', 'mode', 'agent', 'budget_task', 'budget_day', 'state', 'result', 'error_code', 'error_detail',
- 'cost_usd', 'free_usd', 'explanation', 'cancel_at', 'cancel_by', 'last_seq', 'idem_key', 'idem_hash', 'created_at', 'finished_at', 'timeout_ms', 'via', 'webhooks'],
+ 'cost_usd', 'free_usd', 'explanation', 'cancel_at', 'cancel_by', 'last_seq', 'idem_key', 'idem_hash', 'created_at', 'finished_at', 'timeout_ms', 'via', 'webhooks', 'parent_id'],
},
},
// The person's own per-UTC-day spend accounting. Deleted: nothing in Actor keeps a person's spend record for a
// The person's own per-UTC-day spend accounting. Deleted: nothing in Actor keeps a person's spend record for a
// reason that outlives them. The `*` row (everyone's daily ceiling) does not match `user:usr_…` and stays — it is
// reason that outlives them. The `*` row (everyone's daily ceiling) does not match `user:usr_…` and stays — it is
@@ -116,6 +116,13 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -116,6 +116,13 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
const selfReflections = loopCfg.selfReflections == null ? 2 : loopCfg.selfReflections;
const selfReflections = loopCfg.selfReflections == null ? 2 : loopCfg.selfReflections;
/** The plan: a fast-tier call when one is available, else one honest step from the task itself. */
/** The plan: a fast-tier call when one is available, else one honest step from the task itself. */
+ // A follow-up is planned with what it follows: "and for 8 GB?" means nothing on its own.
+ const FOLLOW_NOTE = 'This follows up on the conversation above. Citations in the earlier answers belong to earlier runs: fetch again any live fact you cite.';
+ function planInput(state) {
+ if (!state.context.length) return state.task;
+ return `Earlier in this conversation:\n${state.context.map((c) => `Asked: ${clip(c.task, 600)}\nAnswered: ${clip(c.answer, 1200)}`).join('\n\n')}\n\nNow, a follow-up: ${state.task}`;
+ }
+
async function makePlan(state, ctx) {
async function makePlan(state, ctx) {
const fallback = () => ({ assumptions: [], steps: [{ goal: clip(state.task.replace(/\s+/g, ' ').trim(), 200) || 'Do the task', state: 'pending' }] });
const fallback = () => ({ assumptions: [], steps: [{ goal: clip(state.task.replace(/\s+/g, ' ').trim(), 200) || 'Do the task', state: 'pending' }] });
let plan = fallback();
let plan = fallback();
@@ -125,7 +132,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -125,7 +132,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
tier: 'fast',
tier: 'fast',
messages: [
messages: [
{ role: 'system', content: `You plan one task for an agent that can call tools. Reply with JSON only: {"assumptions":["..."],"steps":[{"goal":"..."}]}. Give 2 to 5 short sub-goals in the order you will do them. An assumption is only what you must suppose and the task does not say; keep the list empty when there is none. Today is ${today(now)}.` },
{ role: 'system', content: `You plan one task for an agent that can call tools. Reply with JSON only: {"assumptions":["..."],"steps":[{"goal":"..."}]}. Give 2 to 5 short sub-goals in the order you will do them. An assumption is only what you must suppose and the task does not say; keep the list empty when there is none. Today is ${today(now)}.` },
- { role: 'user', content: state.task.slice(0, 6000) },
+ { role: 'user', content: planInput(state).slice(0, 6000) },
],
],
json: true, maxTokens: 400, signal: state.signal,
json: true, maxTokens: 400, signal: state.signal,
});
});
@@ -247,7 +254,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -247,7 +254,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
};
};
}
}
- async function run({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null }) {
+ async function run({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null, context = [] }) {
const tier = mode === 'best' && models.resolve('best').available ? 'best' : 'smart';
const tier = mode === 'best' && models.resolve('best').available ? 'best' : 'smart';
if (!models.resolve(tier).available) throw Object.assign(new Error(models.resolve(tier).reason || 'no model is available for the loop'), { code: 'actor.model.unavailable' });
if (!models.resolve(tier).available) throw Object.assign(new Error(models.resolve(tier).reason || 'no model is available for the loop'), { code: 'actor.model.unavailable' });
// Files and charts made in this run are stored privately for the person the task is for, in Actor's own
// Files and charts made in this run are stored privately for the person the task is for, in Actor's own
@@ -262,13 +269,15 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -262,13 +269,15 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
const state = {
const state = {
task: String(task), requester, taskId, mode, tier, signal, messages: [], lastText: '', model: null, family: null, provider: null,
task: String(task), requester, taskId, mode, tier, signal, messages: [], lastText: '', model: null, family: null, provider: null,
calls: 0, ledger: createLedger({ now }), plan: { assumptions: [], steps: [] }, counts: new Map(), stopped: null, selfReflections: 0,
calls: 0, ledger: createLedger({ now }), plan: { assumptions: [], steps: [] }, counts: new Map(), stopped: null, selfReflections: 0,
- ctx,
+ ctx, context: Array.isArray(context) ? context.filter((c) => c && c.task && c.answer).slice(-3) : [],
};
};
try {
try {
state.plan = await makePlan(state, ctx);
state.plan = await makePlan(state, ctx);
await emitPlan(state, ctx);
await emitPlan(state, ctx);
state.messages.push({ role: 'system', content: SYSTEM({ date: today(now), plan: planText(state.plan), maxCalls }) });
state.messages.push({ role: 'system', content: SYSTEM({ date: today(now), plan: planText(state.plan), maxCalls }) });
- state.messages.push({ role: 'user', content: state.task });
+ // A follow-up (stage V2-5b): the conversation it continues, as it went, then the new question.
+ for (const c of state.context) state.messages.push({ role: 'user', content: c.task }, { role: 'assistant', content: c.answer });
+ state.messages.push({ role: 'user', content: state.context.length ? `${FOLLOW_NOTE}\n\n${state.task}` : state.task });
const tools = registry.definitions({ requester, mode, config, models, signal, artifacts });
const tools = registry.definitions({ requester, mode, config, models, signal, artifacts });
// Read from config each run so a test (or an operator's change) takes effect without rebuilding the loop.
// Read from config each run so a test (or an operator's change) takes effect without rebuilding the loop.
const maxSelf = config.loop && config.loop.selfReflections != null ? config.loop.selfReflections : selfReflections;
const maxSelf = config.loop && config.loop.selfReflections != null ? config.loop.selfReflections : selfReflections;
@@ -18,7 +18,7 @@ function untilMidnight(nowMs) {
@@ -18,7 +18,7 @@ function untilMidnight(nowMs) {
return Math.ceil((Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), n.getUTCDate() + 1) - n.getTime()) / 1000);
return Math.ceil((Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), n.getUTCDate() + 1) - n.getTime()) / 1000);
}
}
-async function createTask({ s, config, engine, principal, body, webhooks: webhookQueue = null, beforeInsert = null }) {
+async function createTask({ s, config, engine, principal, body, webhooks: webhookQueue = null, beforeInsert = null, parentId = null }) {
const v = contracts.validate('actor.task-create-request@1', body);
const v = contracts.validate('actor.task-create-request@1', body);
if (!v.valid) return { status: 422, code: 'actor.task.invalid', detail: (v.errors || []).map((e) => `${e.path || e.instancePath || ''} ${e.message}`.trim()).join('; ').slice(0, 500) || 'invalid request' };
if (!v.valid) return { status: 422, code: 'actor.task.invalid', detail: (v.errors || []).map((e) => `${e.path || e.instancePath || ''} ${e.message}`.trim()).join('; ').slice(0, 500) || 'invalid request' };
const tier = config.allowance;
const tier = config.allowance;
@@ -69,7 +69,7 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
@@ -69,7 +69,7 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
const row = await store.insertTask(s, {
const row = await store.insertTask(s, {
id: s.newId('tsk'), requester: principal.requester, via: principal.via || null, project_id: principal.project || null, task: body.task, mode: body.mode || 'balanced', agent: body.agent || null,
id: s.newId('tsk'), requester: principal.requester, via: principal.via || null, project_id: principal.project || null, task: body.task, mode: body.mode || 'balanced', agent: body.agent || null,
budget_task: perTask, budget_day: perDay, idem_key: body.idempotency_key || null, idem_hash: body.idempotency_key ? hash : null, created_at: s.iso(),
budget_task: perTask, budget_day: perDay, idem_key: body.idempotency_key || null, idem_hash: body.idempotency_key ? hash : null, created_at: s.iso(),
- webhooks, webhook_secret: ownSecret ? mintSecret() : null,
+ webhooks, webhook_secret: ownSecret ? mintSecret() : null, parent_id: parentId,
// A task's own clock (Actor v2): 10 minutes by default; mode best may run up to an hour.
// A task's own clock (Actor v2): 10 minutes by default; mode best may run up to an hour.
timeout_ms: (body.mode || 'balanced') === 'best' ? config.bestTimeoutMs : config.taskTimeoutMs,
timeout_ms: (body.mode || 'balanced') === 'best' ? config.bestTimeoutMs : config.taskTimeoutMs,
});
});
@@ -156,6 +156,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -156,6 +156,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
try {
try {
out = await loop.run({
out = await loop.run({
task: row.task, requester: fromChat ? null : row.requester, taskId: id, mode: row.mode, signal,
task: row.task, requester: fromChat ? null : row.requester, taskId: id, mode: row.mode, signal,
+ context: row.parent_id ? await store.followContext(s, row) : [],
emit: (e) => emit(id, { kind: 'output', agent: agentId, ...e }),
emit: (e) => emit(id, { kind: 'output', agent: agentId, ...e }),
meter, budgetLeft, waitForPerson, requestApproval,
meter, budgetLeft, waitForPerson, requestApproval,
});
});
@@ -22,15 +22,38 @@ function subjectRef(requester) {
@@ -22,15 +22,38 @@ function subjectRef(requester) {
async function insertTask(s, t) {
async function insertTask(s, t) {
await s.db.query(
await s.db.query(
- `INSERT INTO tasks (id, requester, project_id, task, mode, agent, budget_task, budget_day, state, idem_key, idem_hash, created_at, via, webhooks, webhook_secret, timeout_ms)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'queued', $9, $10, $11, $12, $13::jsonb, $14, $15)`,
+ `INSERT INTO tasks (id, requester, project_id, task, mode, agent, budget_task, budget_day, state, idem_key, idem_hash, created_at, via, webhooks, webhook_secret, timeout_ms, parent_id)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'queued', $9, $10, $11, $12, $13::jsonb, $14, $15, $16)`,
[t.id, t.requester, t.project_id || null, t.task, t.mode, t.agent || null, t.budget_task, t.budget_day, t.idem_key || null, t.idem_hash || null, t.created_at, t.via || null,
[t.id, t.requester, t.project_id || null, t.task, t.mode, t.agent || null, t.budget_task, t.budget_day, t.idem_key || null, t.idem_hash || null, t.created_at, t.via || null,
- t.webhooks && t.webhooks.length ? JSON.stringify(t.webhooks) : null, t.webhook_secret || null, t.timeout_ms || null]);
+ t.webhooks && t.webhooks.length ? JSON.stringify(t.webhooks) : null, t.webhook_secret || null, t.timeout_ms || null, t.parent_id || null]);
await addSpend(s, t.requester, 0, { tasks: 1 });
await addSpend(s, t.requester, 0, { tasks: 1 });
return getTask(s, t.id);
return getTask(s, t.id);
}
}
const getTask = (s, id) => s.db.maybe('SELECT * FROM tasks WHERE id = $1', [id]);
const getTask = (s, id) => s.db.maybe('SELECT * FROM tasks WHERE id = $1', [id]);
+
+/**
+ * The conversation a follow-up continues (Actor v2, stage V2-5b): up to `depth` tasks before it, oldest first, each its
+ * question and its answer, only the same person's, only answered ones. An answer's [E#] citations belonged to its own
+ * run, so they are taken out: the new run cites only what it fetches itself. Each answer is clipped to `clip` chars.
+ */
+async function followContext(s, row, { depth = 3, clip = 3000 } = {}) {
+ const out = [];
+ let at = row && row.parent_id;
+ for (let i = 0; at && i < depth; i++) {
+ const p = await getTask(s, at);
+ if (!p || p.requester !== row.requester) break;
+ const result = p.result ? (typeof p.result === 'string' ? JSON.parse(p.result) : p.result) : null;
+ if (p.state === 'succeeded' && result && result.answer) {
+ const answer = String(result.answer).replace(/\s*\[E\d{1,3}(?:\s*,\s*E\d{1,3})*\]/g, '');
+ out.unshift({ task: String(p.task), answer: answer.length > clip ? `${answer.slice(0, clip - 1)}…` : answer });
+ }
+ at = p.parent_id;
+ }
+ return out;
+}
+/** The follow-ups of a task, oldest first: { id, task, state, created_at }. */
+const followUps = (s, row) => s.db.many('SELECT id, task, state, created_at FROM tasks WHERE parent_id = $1 AND requester = $2 ORDER BY created_at, id LIMIT 20', [row.id, row.requester]);
const byIdempotency = (s, requester, key) => s.db.maybe('SELECT * FROM tasks WHERE requester = $1 AND idem_key = $2', [requester, key]);
const byIdempotency = (s, requester, key) => s.db.maybe('SELECT * FROM tasks WHERE requester = $1 AND idem_key = $2', [requester, key]);
/** A requester's tasks, newest first; `via` narrows to the ones that service started for them. */
/** A requester's tasks, newest first; `via` narrows to the ones that service started for them. */
@@ -205,4 +228,4 @@ function toWire(row, { baseUrl }) {
@@ -205,4 +228,4 @@ function toWire(row, { baseUrl }) {
return t;
return t;
}
}
-module.exports = { insertTask, getTask, byIdempotency, listTasks, updateTask, appendEvent, eventsAfter, lastEvent, needsYou, deskFrom, taskDesk, addSpend, spendToday, spendDays, lockSpendToday, failInterrupted, prune, toWire, subjectRef, dayOf, parseJson: parse, OPEN, RETENTION_DAYS };
+module.exports = { insertTask, getTask, byIdempotency, listTasks, updateTask, appendEvent, eventsAfter, lastEvent, needsYou, deskFrom, taskDesk, addSpend, spendToday, spendDays, lockSpendToday, failInterrupted, prune, toWire, subjectRef, dayOf, parseJson: parse, OPEN, RETENTION_DAYS, followContext, followUps };
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
+'use strict';
+// Follow-ups (Actor v2, stage V2-5b): a person asks again under an answer, and the new task runs with that question and
+// answer in mind (its plan too), without the old run's citations; only the task's own person may, only from Actor's
+// own page, only after an answer; the two pages link to each other, and the column goes with the person's data.
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const SAME = { 'sec-fetch-site': 'same-origin' };
+const ID = /\/tasks\/(tsk_[0-9A-HJKMNP-TV-Z]{26})$/;
+
+(async () => {
+ const t = await boot();
+ const kim = t.network.addUser('kim');
+ const bo = t.network.addUser('bo');
+ t.config.loop.selfReflections = 0;
+ const s = t.ctx.s;
+ const answered = async (task, answer) => {
+ t.providers.state.script = [{ content: answer }];
+ const created = (await t.get('/api/v1/tasks', { as: kim, json: { task }, headers: SAME })).json();
+ await t.waitFor(created.id);
+ const row = await s.db.maybe('SELECT state FROM tasks WHERE id = $1', [created.id]);
+ assert.strictEqual(row.state, 'succeeded');
+ return created.id;
+ };
+ const follow = (id, text, o = {}) => t.get(`/tasks/${id}/follow-up`, { as: o.as || kim, form: { task: text }, headers: o.headers || SAME });
+
+ try {
+ const first = await answered('Which VPS is cheapest for 2 vCPU and 4 GB?', 'Hetzner CX22 at €4.51 a month [E1], then Netcup [E2, E3].');
+
+ await check('a follow-up is a new task that runs with the question and answer before it, citations taken out', async () => {
+ t.providers.reset();
+ t.config.loop.selfReflections = 0;
+ t.providers.state.script = [{ content: 'With 8 GB, Hetzner CX32 at €7.59.' }];
+ const r = await follow(first, 'And for 8 GB?');
+ assert.strictEqual(r.status, 303);
+ const next = ID.exec(r.headers.get('location'))[1];
+ await t.waitFor(next);
+ const row = await s.db.maybe('SELECT parent_id, requester, mode, state FROM tasks WHERE id = $1', [next]);
+ assert.deepStrictEqual([row.parent_id, row.state, row.mode], [first, 'succeeded', 'balanced'], 'the same person\'s task, in the parent\'s mode');
+ const sent = t.providers.requests.map((q) => JSON.stringify(q.body || {}));
+ const loopCall = sent.find((b) => b.includes('This follows up on the conversation above'));
+ assert.ok(loopCall, 'the loop is told it is a follow-up, and to fetch live facts again');
+ assert.ok(loopCall.includes('And for 8 GB?') && loopCall.includes('Hetzner CX22 at €4.51 a month'), 'with the earlier answer and the new question');
+ assert.ok(loopCall.includes('{"role":"assistant","content":"Hetzner CX22 at €4.51 a month, then Netcup."}'), 'the earlier answer as its turn, without the earlier run\'s citations');
+ assert.ok(sent.some((b) => b.includes('Earlier in this conversation') && b.includes('Now, a follow-up: And for 8 GB?')), 'the plan is made with what it follows');
+ });
+
+ await check('the two pages link each other: the follow-ups under the answer, the thread above the follow-up', async () => {
+ const kids = await s.db.many('SELECT id FROM tasks WHERE parent_id = $1', [first]);
+ const parentPage = (await t.get(`/tasks/${first}`, { as: kim })).text;
+ assert.ok(parentPage.includes('<section class="follow" id="follow-up"'), 'the follow-up box');
+ assert.ok(parentPage.includes(`action="/tasks/${first}/follow-up"`) && parentPage.includes('name="task"'), 'a plain form, no script needed');
+ assert.ok(parentPage.includes(`<a href="/tasks/${kids[0].id}">And for 8 GB?</a>`), 'the follow-up asked, listed above the box');
+ const childPage = (await t.get(`/tasks/${kids[0].id}`, { as: kim })).text;
+ assert.ok(childPage.includes(`Following up on <a href="/tasks/${first}">Which VPS is cheapest`), 'and it links back');
+ });
+
+ await check('only the task\'s own person, only from Actor, only after an answer, never empty', async () => {
+ const before = Number(await s.db.value('SELECT count(*) FROM tasks'));
+ assert.strictEqual((await follow(first, 'mine now', { as: bo })).status, 404, 'another person: the 404 of a task that does not exist');
+ assert.strictEqual((await follow(first, 'from elsewhere', { headers: { 'sec-fetch-site': 'cross-site' } })).status, 403, 'another site cannot post one');
+ const empty = await follow(first, ' ');
+ assert.ok(empty.status === 303 && /#follow-up$/.test(empty.headers.get('location')), 'nothing to ask: back to the box');
+ await s.db.query("UPDATE tasks SET state = 'failed' WHERE id = $1", [first]);
+ const unanswered = await follow(first, 'and then?');
+ assert.ok(unanswered.status === 303 && unanswered.headers.get('location').endsWith(`/tasks/${first}`), 'no answer yet, nothing to follow');
+ await s.db.query("UPDATE tasks SET state = 'succeeded' WHERE id = $1", [first]);
+ assert.strictEqual(Number(await s.db.value('SELECT count(*) FROM tasks')), before, 'none of those made a task');
+ });
+
+ await check('the conversation reaches back three answers at most, and never into another person\'s task', async () => {
+ const { followContext } = require('../server/tasks/store');
+ const ids = [first];
+ for (let i = 0; i < 4; i++) {
+ t.providers.state.script = [{ content: `Answer ${i + 2}.` }];
+ const r = await follow(ids[ids.length - 1], `Question ${i + 2}?`);
+ const id = ID.exec(r.headers.get('location'))[1];
+ await t.waitFor(id);
+ ids.push(id);
+ }
+ const last = await s.db.maybe('SELECT * FROM tasks WHERE id = $1', [ids[ids.length - 1]]);
+ const ctx = await followContext(s, last);
+ assert.deepStrictEqual(ctx.map((c) => c.task), ['Question 2?', 'Question 3?', 'Question 4?'], 'the three before it, oldest first');
+ await s.db.query('UPDATE tasks SET requester = $2 WHERE id = $1', [ids[3], `user:${bo.subject}`]);
+ assert.deepStrictEqual((await followContext(s, last)).map((c) => c.task), [], 'a parent that is not the same person\'s ends the thread');
+ });
+
+ await check('the parent column goes with the person\'s data', async () => {
+ const ad = require('../server/identity/account-data');
+ const spec = JSON.stringify(ad.TABLES || ad.tables || ad);
+ assert.ok(spec.includes('parent_id'), 'exported and erased with the rest of the task');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((e) => { console.error(e && e.stack || e); process.exit(1); });