openvibers/openvibe.codes Commits

The API agent asks the advisor mid-run, capped at 3 (R1 item 4) (#380)

OP OpenVibers committed 3 days ago · 27b0cfeae0 · 3 files, +228 −9

* The API agent asks the advisor mid-run, capped at 3 (R1 item 4)

On OpenVibe.AI, a run that asked for an advisor gets the advise tool: the
model asks with its question, the loop sends the bounded transcript (text
only, 50 messages / 60 KB, the task always first) to the latest turn's
advice route, and the stronger model's guidance feeds the next turn. The
fourth ask is refused in the loop without another call, and AI puts each
use on the run's receipt. Off unless asked; the other API paths have no
advice endpoint, so the tool is never offered there.

* Merge remote-tracking branch 'origin/main' into codes-r1-advisor

# Conflicts:
#	harness/adapters/api.js
#	test/harness-api.test.js

Changes

ddiff --git a/harness/adapters/api.js b/harness/adapters/api.js
iindex d9abc37..cf42128 100644
---- a/harness/adapters/api.js
++++ b/harness/adapters/api.js
@@@ -9,6 +9,7 @@
* write_file, edit_file edit and full
* run_command full only (bash -lc in the working directory, two minutes, output clipped;
* push, reset, recursive rm and .env paths refused before anything spawns)
++ * advise OpenVibe.AI only, when the run asked for an advisor (at most 3 asks)
*
* Every path is resolved inside the working directory: `..` out of it, an absolute path elsewhere, `.git/` writes and
* `.env` files are refused. One read answers at most 40 KB. The conversation is kept in the session store so `resume`
@@@ -33,7 +34,7 @@ const TOOL_DEFS = {
run_command: { level: 'full', description: 'Run a shell command in the working directory (two-minute limit).', parameters: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] } },
};
const LEVELS = { read: 1, edit: 2, full: 3 };
--const SHOWN_AS = { list_dir: 'LS', read_file: 'Read', grep: 'Grep', write_file: 'Write', edit_file: 'Edit', run_command: 'Bash' };
++const SHOWN_AS = { list_dir: 'LS', read_file: 'Read', grep: 'Grep', write_file: 'Write', edit_file: 'Edit', run_command: 'Bash', advise: 'Advise' };
const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist', 'build', '.next', 'coverage', '.cache']);
/** What one read_file answer may hold: 40 KB, enforced here, not in the prompt. */
@@@ -75,6 +76,76 @@ function refusedCommand(command) {
return null;
}
++/**
++ * Ask the run's advisor (a stronger model on OpenVibe.AI) for guidance mid-run. Offered only when the run
++ * asked for an advisor and only on the OpenVibe.AI adapter: the other API paths have no advice endpoint.
++ */
++const ADVISE = { level: 'read', description: 'Ask the stronger advisor model for guidance with the transcript so far (at most 3 asks per run). Say what you are stuck on or about to do.', parameters: { type: 'object', properties: { question: { type: 'string' } }, required: [] } };
++/** The loop's own advice budget: AI caps uses per run, and every turn is its own run, so this is the real one. */
++const MAX_ADVICE = 3;
++/** What one advice call may carry of the transcript: 50 messages, 60 KB, inside AI's 200/300 KB. */
++const ADVICE_MAX_MESSAGES = 50;
++const ADVICE_MAX_CHARS = 60_000;
++
++/**
++ * The transcript an advice call sends: text only, roles AI accepts, the newest window inside the budget with
++ * the system message and the task (the first user turn) always first. Tool calls become `called: …` lines —
++ * the advisor sees what ran, not the calls' shapes.
++ */
++function adviceTranscript(messages, { maxMessages = ADVICE_MAX_MESSAGES, maxChars = ADVICE_MAX_CHARS } = {}) {
++ const flat = [];
++ for (const m of messages || []) {
++ if (!m || typeof m !== 'object') continue;
++ if (m.role === 'system') {
++ const c = String(m.content == null ? '' : m.content).trim();
++ if (c) flat.push({ role: 'system', content: c.slice(0, 20000) });
++ continue;
++ }
++ if (m.role === 'tool') {
++ const c = String(m.content == null ? '' : m.content).trim();
++ if (c) flat.push({ role: 'tool', content: c.slice(0, 20000) });
++ continue;
++ }
++ if (m.role === 'assistant') {
++ const parts = [];
++ const c = String(m.content || '').trim();
++ if (c) parts.push(c.slice(0, 20000));
++ const calls = Array.isArray(m.tool_calls) ? m.tool_calls : [];
++ if (calls.length) {
++ parts.push(`called: ${calls.map((t) => {
++ const fn = (t && t.function) || {};
++ const a = String(fn.arguments || '');
++ return `${SHOWN_AS[fn.name] || fn.name || 'tool'}(${a.length > 300 ? `${a.slice(0, 300)}…` : a})`;
++ }).join(', ')}`);
++ }
++ if (parts.length) flat.push({ role: 'assistant', content: parts.join('\n\n').slice(0, 20000) });
++ continue;
++ }
++ if (m.role === 'user') {
++ const c = String(m.content == null ? '' : m.content).trim();
++ if (c) flat.push({ role: 'user', content: c.slice(0, 20000) });
++ }
++ }
++ const system = flat.filter((m) => m.role === 'system');
++ const rest = flat.filter((m) => m.role !== 'system');
++ const firstUser = rest.find((m) => m.role === 'user');
++ const head = [...system];
++ if (firstUser) head.push(firstUser);
++ const room = Math.max(0, maxChars - head.reduce((n, m) => n + m.content.length, 0));
++ const tail = [];
++ let chars = 0;
++ for (let i = rest.length - 1; i >= 0 && tail.length < Math.max(0, maxMessages - head.length) && chars < room; i--) {
++ if (rest[i] === firstUser) continue;
++ chars += rest[i].content.length;
++ tail.unshift(rest[i]);
++ }
++ const out = [...head, ...tail].slice(0, Math.max(1, maxMessages));
++ return out.length ? out : [{ role: 'user', content: 'the executor asked for advice but has no transcript yet' }];
++}
++
++/** The API root the advice route hangs off: the chat base without its trailing /v1. */
++function adviceRoot(base) { return String(base || '').replace(/\/v1$/, ''); }
++
/**
* The real path of abs, with any part that does not exist yet kept as written; null when a component is
* not a directory. Every existing component is realpath'd, so a symlink cannot hide where the path goes.
@@@ -166,6 +237,33 @@ function runTool(name, args, cwd) {
return [`unknown tool ${name}`, true];
}
++/**
++ * One advice call against the latest turn's run: the bounded transcript and the model's question go to
++ * POST /api/v1/runs/:id/advice, and the stronger model's guidance (at most 100 words) comes back. AI records
++ * the use on the run's receipt with its cost; this loop's own count is the per-task budget.
++ *
++ * @returns {Promise<[string, boolean, boolean]>} the answer, whether it failed, and whether to stop asking
++ */
++async function askAdvice({ doFetch, base, env, bearer, runId, transcript, question, model }) {
++ let res;
++ try {
++ res = await doFetch(`${adviceRoot(base)}/api/v1/runs/${encodeURIComponent(runId)}/advice`, {
++ method: 'POST',
++ headers: { 'content-type': 'application/json', ...(bearer ? { authorization: `Bearer ${bearer}` } : {}), ...extraHeaders(env) },
++ body: JSON.stringify({ transcript, ...(question ? { question } : {}), ...(model ? { model } : {}) }),
++ });
++ } catch (err) {
++ return [`the advisor could not be reached: ${err.message}`, true, false];
++ }
++ const body = await res.json().catch(() => null);
++ if (res.status === 201 && body && typeof body.advice === 'string' && body.advice.trim()) return [body.advice.trim(), false, false];
++ const code = String((body && ((body.error && body.error.code) || body.code)) || '');
++ const detail = String((body && ((body.error && (body.error.message || body.error.detail)) || body.detail)) || '').slice(0, 300);
++ if (res.status === 409 || code === 'advisor.exhausted') return ['the advisor has no calls left on this run: work with what it said', true, true];
++ if (res.status === 403 || code === 'advisor.not_enabled') return ['this run did not ask for an advisor', true, true];
++ return [`the advisor answered HTTP ${res.status}${code ? ` (${code})` : ''}${detail ? `: ${detail}` : ''}`, true, false];
++}
++
/** run_command, asynchronously (the only tool that waits on something else); onChunk(text) hears its output as it comes. */
function runCommand(command, cwd, signal, onChunk) {
return new Promise((resolve) => {
@@@ -245,7 +343,11 @@ function apiAgent(spec) {
const level = LEVELS[t.permission] || 1;
// Tools in a fixed name order, and the system message above never carries the task: the request prefix a
// provider caches (system + tools) is byte-identical from one task to the next, and only the prompt differs.
-- const tools = Object.entries(TOOL_DEFS).filter(([, d]) => LEVELS[d.level] <= level)
++ // advise joins the list only when the run asked for an advisor (off unless asked) and this adapter is
++ // OpenVibe.AI's: the other API paths have no advice endpoint. It stays for every turn, asked out or not,
++ // so the prefix stays stable; past its budget it answers a refusal instead of calling.
++ const defs = spec.advisorEndpoint === true && t.advisor ? { ...TOOL_DEFS, advise: ADVISE } : TOOL_DEFS;
++ const tools = Object.entries(defs).filter(([, d]) => LEVELS[d.level] <= level)
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, d]) => ({ type: 'function', function: { name, description: d.description, parameters: d.parameters } }));
const sessionId = t.resume || `api_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
@@@ -268,6 +370,9 @@ function apiAgent(spec) {
// The model AI says ran, and its route disclosure (x_openvibe), recorded on the run's result.
let resolvedModel = null;
let disclosure = null;
++ // The latest turn's AI run, advise asks against; and the advice spent on this task.
++ let adviceRunId = null;
++ let adviceUsed = 0;
try {
while (turns < maxTurns && !cancelled) {
turns++;
@@@ -286,8 +391,10 @@ function apiAgent(spec) {
const x = body && body.x_openvibe && typeof body.x_openvibe === 'object' ? body.x_openvibe : null;
if (x) {
if (x.resolved_model) resolvedModel = String(x.resolved_model);
++ if (x.run_id) adviceRunId = String(x.run_id);
disclosure = { ...(disclosure || {}), ...x };
}
++ if (!adviceRunId && body && typeof body.id === 'string' && body.id.startsWith('chatcmpl-')) adviceRunId = body.id.slice('chatcmpl-'.length);
if (!res.ok || !body || !Array.isArray(body.choices) || !body.choices[0]) {
// An exact id AI refused (ai.model_not_found / ai.model_unavailable) is its own answer:
// it is reported as it is, and this loop never asks for a different model instead.
@@@ -310,9 +417,27 @@ function apiAgent(spec) {
try { args = JSON.parse((call.function && call.function.arguments) || '{}'); } catch { /* answered as an error below */ }
yield ev.toolUse(call.id, SHOWN_AS[name] || name, shownInput(name, args), resolvedModel || model);
let out;
-- const def = TOOL_DEFS[name];
++ const def = defs[name];
if (!def) out = [`unknown tool ${name}`, true];
else if (LEVELS[def.level] > level) out = [`${name} needs the ${def.level} permission level`, true];
++ else if (name === 'advise') {
++ // Capped at 3 asks, against the latest turn's run: the transcript goes bounded,
++ // the guidance feeds this loop, and AI puts each use on the run's receipt.
++ if (adviceUsed >= MAX_ADVICE) out = ['the advisor has been asked 3 times on this run: work with what it said', true];
++ else if (!adviceRunId) out = ['the advisor has no run to ask against yet: carry on, and ask again after the next answer', true];
++ else {
++ const question = typeof args.question === 'string' ? args.question.trim().slice(0, 4000) : '';
++ const advisorModel = t.advisor && typeof t.advisor === 'object' && typeof t.advisor.model === 'string' ? t.advisor.model : null;
++ try {
++ const b = spec.token ? await spec.token(env, doFetch) : key;
++ const [text, failed, exhausted] = await askAdvice({ doFetch, base, env, bearer: b, runId: adviceRunId, transcript: adviceTranscript(messages), question, model: advisorModel });
++ if (failed) { if (exhausted) adviceUsed = MAX_ADVICE; out = [text, true]; }
++ else { adviceUsed++; out = [`${text}\n\n(advice ${adviceUsed} of ${MAX_ADVICE})`, false]; }
++ } catch (err) {
++ out = [`the advisor could not be reached: ${err.message}`, true];
++ }
++ }
++ }
else if (name === 'run_command') {
// The hard rules run before the shell does: a refusal is the tool's answer, and
// nothing spawns.
@@@ -357,4 +482,4 @@ function apiAgent(spec) {
return adapter;
}
--module.exports = { apiAgent, refusalOf, extraHeaders, runTool, inside, refusedCommand, TOOL_DEFS, SYSTEM };
++module.exports = { apiAgent, refusalOf, extraHeaders, runTool, inside, refusedCommand, adviceTranscript, TOOL_DEFS, SYSTEM };
ddiff --git a/harness/index.js b/harness/index.js
iindex bb331f2..5570ab3 100644
---- a/harness/index.js
++++ b/harness/index.js
@@@ -69,7 +69,7 @@ function adaptersFor(env) {
// OpenVibe.AI's OpenAI-compatible endpoint: its router picks the model, each answer reports its cost.
// OPENVIBE_AI_SECRET names a project secret granted to ai (ADR-056): AI sends each request to the project's own
// provider (OPENVIBE_AI_PROVIDER_URL, OPENVIBE_AI_PROVIDER_MODEL) with that key, revealed per request by Network.
-- 'openvibe-ai': apiAgent({ id: 'openvibe-ai', name: 'OpenVibe.AI', baseUrl: env.OPENVIBE_AI_URL || 'https://ai.openvibe.services/v1', token: networkToken(), model: 'openvibe', extra: projectProvider }),
++ 'openvibe-ai': apiAgent({ id: 'openvibe-ai', name: 'OpenVibe.AI', baseUrl: env.OPENVIBE_AI_URL || 'https://ai.openvibe.services/v1', token: networkToken(), model: 'openvibe', extra: projectProvider, advisorEndpoint: true }),
'openai-compatible': apiAgent({ id: 'openai-compatible', name: 'Your own model', baseUrl: env.OPENVIBE_CODES_BASE_URL || 'http://127.0.0.1:8080/v1', keyEnv: 'OPENVIBE_CODES_API_KEY', model: env.OPENVIBE_CODES_MODEL || 'local' }),
};
}
ddiff --git a/test/harness-api.test.js b/test/harness-api.test.js
iindex 955c291..88a79f6 100644
---- a/test/harness-api.test.js
++++ b/test/harness-api.test.js
@@@ -3,7 +3,9 @@
* The API agent (harness/adapters/api.js) against a stand-in OpenAI-compatible server: the model's tool calls run inside
* the working directory and the loop ends on an answer; usage and cost come from the API's own counts; the permission
* level decides which tools exist; a path out of the working directory, a .env file or a .git write is refused; an HTTP
-- * error ends the run with the server's message; resume continues the saved conversation.
++ * error ends the run with the server's message; resume continues the saved conversation. On OpenVibe.AI a run that
++ * asked for an advisor gets the advise tool: a bounded transcript and a question go to the run's advice route, the
++ * guidance feeds the loop, and the fourth ask is refused without another call.
*/
const assert = require('assert');
const fs = require('fs');
@@@ -12,18 +14,29 @@ const path = require('path');
const http = require('http');
const { createHarness } = require('../harness');
const { createStore } = require('../harness/sessions');
--const { refusedCommand, runTool } = require('../harness/adapters/api');
++const { refusedCommand, runTool, adviceTranscript } = require('../harness/adapters/api');
const { check, done } = require('./helpers/boot');
--/** A chat-completions server that answers from a script: each request gets the next scripted message. */
++/** A chat-completions server that answers from a script: each request gets the next scripted message. Advice calls
++ * (POST /api/v1/runs/:id/advice) are recorded apart and answered from their own script. */
async function mockApi() {
const requests = [];
++ const advice = [];
let script = [];
++ let adviceScript = [];
const server = http.createServer((req, res) => {
let body = '';
req.on('data', (d) => { body += d; });
req.on('end', () => {
const parsed = JSON.parse(body || '{}');
++ if (req.method === 'POST' && /\/api\/v1\/runs\/.+\/advice$/.test(req.url || '')) {
++ advice.push({ url: req.url, auth: req.headers.authorization || null, body: parsed });
++ const step = adviceScript.length ? adviceScript.shift() : { advice: 'keep going' };
++ if (step.status) { res.writeHead(step.status, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: { message: step.error, ...(step.code ? { code: step.code } : {}) } })); return; }
++ res.writeHead(201, { 'content-type': 'application/json' });
++ res.end(JSON.stringify({ run_id: 'run_9', use: advice.length, max_uses: 3, advice: step.advice }));
++ return;
++ }
requests.push({ auth: req.headers.authorization || null, body: parsed });
const step = script.shift();
if (!step) { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'done' } }], usage: { prompt_tokens: 1, completion_tokens: 1 } })); return; }
@@@ -33,7 +46,7 @@ async function mockApi() {
});
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
-- return { url: `http://127.0.0.1:${server.address().port}`, requests, set: (s) => { script = s; requests.length = 0; }, close: () => new Promise((r) => server.close(r)) };
++ return { url: `http://127.0.0.1:${server.address().port}`, requests, advice, set: (s) => { script = s; requests.length = 0; }, adviceSet: (s) => { adviceScript = s; advice.length = 0; }, close: () => new Promise((r) => server.close(r)) };
}
const collect = async (run) => { const out = []; for await (const e of run) out.push(e); return out; };
@@@ -55,6 +68,10 @@ const toolResults = (events) => events.filter((e) => e.type === 'user').flatMap(
fs.symlinkSync(path.join(dir, 'outside-dir'), path.join(work, 'dir-link'));
const env = { PATH: '/nonexistent', OPENVIBE_CODES_HOME: path.join(dir, 'home'), OPENVIBE_CODES_BASE_URL: api.url, OPENVIBE_CODES_MODEL: 'tiny', OPENVIBE_CODES_API_KEY: 'k-123' };
const h = createHarness({ env, store: createStore({ env }) });
++ // The same stand-in as OpenVibe.AI: the advice route hangs off the chat base without its /v1.
++ const aiEnv = { ...env, OPENVIBE_AI_URL: api.url, OPENVIBE_AI_TOKEN: 'ov-task-token' };
++ const ai = createHarness({ env: aiEnv, store: createStore({ env: aiEnv }) });
++ const toolNames = (req) => req.body.tools.map((t) => t.function.name);
await check('the model\'s tool calls run in the working directory and the loop ends on its answer', async () => {
api.set([
@@@ -282,6 +299,83 @@ const toolResults = (events) => events.filter((e) => e.type === 'user').flatMap(
assert.match(r.result, /HTTP 429: rate limited/);
});
++ await check('advise is offered only on OpenVibe.AI when the run asked for an advisor', async () => {
++ api.set([{ content: 'done' }]);
++ await collect(ai.run({ prompt: 'x', agent: 'openvibe-ai', cwd: work, permission: 'read', handoff: false, advisor: true }));
++ assert.ok(toolNames(api.requests[0]).includes('advise'), 'asked on OpenVibe.AI');
++ api.set([{ content: 'done' }]);
++ await collect(ai.run({ prompt: 'x', agent: 'openvibe-ai', cwd: work, permission: 'read', handoff: false }));
++ assert.ok(!toolNames(api.requests[0]).includes('advise'), 'off unless asked');
++ api.set([{ content: 'done' }]);
++ await collect(h.run({ prompt: 'x', agent: 'openai-compatible', cwd: work, permission: 'read', handoff: false, advisor: true }));
++ assert.ok(!toolNames(api.requests[0]).includes('advise'), 'another API path has no advice endpoint');
++ });
++
++ await check('advise sends the bounded transcript and the question; the guidance feeds the loop', async () => {
++ api.set([
++ { calls: [['read_file', { path: 'src/a.js' }], ['advise', { question: 'is the parser done?' }]], x: { run_id: 'run_9' } },
++ { content: 'done' },
++ ]);
++ api.adviceSet([{ advice: 'check the parser first' }]);
++ const events = await collect(ai.run({ prompt: 'fix the parser', agent: 'openvibe-ai', cwd: work, permission: 'read', handoff: false, advisor: true }));
++ assert.strictEqual(api.advice.length, 1);
++ const call = api.advice[0];
++ assert.ok(call.url.endsWith('/api/v1/runs/run_9/advice'), call.url);
++ assert.strictEqual(call.auth, 'Bearer ov-task-token', 'the advice call carries the run token');
++ assert.strictEqual(call.body.question, 'is the parser done?');
++ assert.ok(!('model' in call.body), 'no model named, none sent');
++ const t = call.body.transcript;
++ assert.ok(Array.isArray(t) && t.length >= 3, 'system, task and the turn so far');
++ assert.ok(t.every((m) => ['system', 'user', 'assistant', 'tool'].includes(m.role) && typeof m.content === 'string' && m.content.trim()), 'text only, roles AI accepts');
++ assert.ok(t.reduce((n, m) => n + m.content.length, 0) <= 80_000, 'bounded');
++ assert.strictEqual(t[0].role, 'system');
++ assert.ok(t.some((m) => m.role === 'user' && m.content === 'fix the parser'), 'the task rides along');
++ assert.ok(t.some((m) => m.role === 'assistant' && /called: Read/.test(m.content)), 'the tool call reads as a line');
++ const res = toolResults(events);
++ assert.ok(res.some((c) => !c.is_error && /check the parser first/.test(c.content) && /\(advice 1 of 3\)/.test(c.content)), JSON.stringify(res.map((c) => c.content)));
++ const fed = api.requests[1].body.messages.find((m) => m.role === 'tool' && /check the parser first/.test(m.content));
++ assert.ok(fed, 'the guidance feeds the next turn');
++ });
++
++ await check('the fourth ask is refused in the loop, without another HTTP call', async () => {
++ api.set([{ calls: [['advise', { question: 'one' }], ['advise', { question: 'two' }], ['advise', { question: 'three' }], ['advise', { question: 'four' }]], x: { run_id: 'run_9' } }, { content: 'done' }]);
++ api.adviceSet([{ advice: 'a' }, { advice: 'b' }, { advice: 'c' }]);
++ const events = await collect(ai.run({ prompt: 'x', agent: 'openvibe-ai', cwd: work, permission: 'read', handoff: false, advisor: true }));
++ assert.strictEqual(api.advice.length, 3);
++ const res = toolResults(events);
++ assert.ok(res.slice(0, 3).every((c) => !c.is_error));
++ assert.ok(res[3].is_error && /asked 3 times/.test(res[3].content), res[3].content);
++ });
++
++ await check('an exhausted advisor and a missing run id end in words, not a crash', async () => {
++ api.set([{ calls: [['advise', { question: 'any?' }]], x: { run_id: 'run_9' } }, { content: 'done' }]);
++ api.adviceSet([{ status: 409, error: 'used up', code: 'advisor.exhausted' }]);
++ const spent = await collect(ai.run({ prompt: 'x', agent: 'openvibe-ai', cwd: work, permission: 'read', handoff: false, advisor: true }));
++ assert.ok(toolResults(spent).some((c) => c.is_error && /no calls left/.test(c.content)));
++ api.set([{ calls: [['advise', { question: 'any?' }]] }, { content: 'done' }]);
++ api.adviceSet([]);
++ const norun = await collect(ai.run({ prompt: 'x', agent: 'openvibe-ai', cwd: work, permission: 'read', handoff: false, advisor: true }));
++ assert.ok(toolResults(norun).some((c) => c.is_error && /ask again after the next answer/.test(c.content)));
++ assert.strictEqual(api.advice.length, 0, 'no run id, no call');
++ });
++
++ await check('the advice transcript: text only, bounded, the task always first', () => {
++ const messages = [{ role: 'system', content: 'be careful' }, { role: 'user', content: 'the task' }];
++ for (let i = 0; i < 100; i++) {
++ messages.push({ role: 'assistant', content: '', tool_calls: [{ id: `c${i}`, function: { name: 'read_file', arguments: JSON.stringify({ path: `f${i}.js`, pad: 'y'.repeat(5000) }) } }] });
++ messages.push({ role: 'tool', tool_call_id: `c${i}`, content: `file ${i}\n${'z'.repeat(5000)}` });
++ }
++ messages.push({ role: 'assistant', content: ' ' }, { role: 'user', content: '' }, { role: 'bogus', content: 'x' });
++ const t = adviceTranscript(messages);
++ assert.ok(t.length <= 50, `${t.length} messages`);
++ assert.ok(t.reduce((n, m) => n + m.content.length, 0) <= 80_000, 'bounded');
++ assert.ok(t.every((m) => ['system', 'user', 'assistant', 'tool'].includes(m.role) && m.content.trim()), 'text only');
++ assert.strictEqual(t[0].content, 'be careful');
++ assert.strictEqual(t[1].content, 'the task', 'the task rides first even past the budget');
++ assert.ok(t.some((m) => /called: Read/.test(m.content)), 'tool calls read as lines');
++ assert.deepStrictEqual(adviceTranscript([]), [{ role: 'user', content: 'the executor asked for advice but has no transcript yet' }]);
++ });
++
await check('resume continues the saved conversation', async () => {
api.set([{ content: 'first answer' }]);
const run1 = h.run({ prompt: 'remember the word kiwi', agent: 'openai-compatible', cwd: work, handoff: false });