openvibers/openvibe.codes Commits
The API agent asks the advisor mid-run, capped at 3 (R1 item 4) (#380)
Changes
@@@ -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 };@@@ -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' }),};}@@@ -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 });
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
* write_file, edit_file edit and full
* write_file, edit_file edit and full
* run_command full only (bash -lc in the working directory, two minutes, output clipped;
* 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)
* 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
* 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`
* `.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 = {
@@ -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'] } },
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 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']);
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. */
/** What one read_file answer may hold: 40 KB, enforced here, not in the prompt. */
@@ -75,6 +76,76 @@ function refusedCommand(command) {
@@ -75,6 +76,76 @@ function refusedCommand(command) {
return null;
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
* 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.
* 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) {
@@ -166,6 +237,33 @@ function runTool(name, args, cwd) {
return [`unknown tool ${name}`, true];
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. */
/** 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) {
function runCommand(command, cwd, signal, onChunk) {
return new Promise((resolve) => {
return new Promise((resolve) => {
@@ -245,7 +343,11 @@ function apiAgent(spec) {
@@ -245,7 +343,11 @@ function apiAgent(spec) {
const level = LEVELS[t.permission] || 1;
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
// 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.
// 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))
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, d]) => ({ type: 'function', function: { name, description: d.description, parameters: d.parameters } }));
.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)}`;
const sessionId = t.resume || `api_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
@@ -268,6 +370,9 @@ function apiAgent(spec) {
@@ -268,6 +370,9 @@ function apiAgent(spec) {
// The model AI says ran, and its route disclosure (x_openvibe), recorded on the run's result.
// The model AI says ran, and its route disclosure (x_openvibe), recorded on the run's result.
let resolvedModel = null;
let resolvedModel = null;
let disclosure = 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 {
try {
while (turns < maxTurns && !cancelled) {
while (turns < maxTurns && !cancelled) {
turns++;
turns++;
@@ -286,8 +391,10 @@ function apiAgent(spec) {
@@ -286,8 +391,10 @@ function apiAgent(spec) {
const x = body && body.x_openvibe && typeof body.x_openvibe === 'object' ? body.x_openvibe : null;
const x = body && body.x_openvibe && typeof body.x_openvibe === 'object' ? body.x_openvibe : null;
if (x) {
if (x) {
if (x.resolved_model) resolvedModel = String(x.resolved_model);
if (x.resolved_model) resolvedModel = String(x.resolved_model);
+ if (x.run_id) adviceRunId = String(x.run_id);
disclosure = { ...(disclosure || {}), ...x };
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]) {
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:
// 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.
// it is reported as it is, and this loop never asks for a different model instead.
@@ -310,9 +417,27 @@ function apiAgent(spec) {
@@ -310,9 +417,27 @@ function apiAgent(spec) {
try { args = JSON.parse((call.function && call.function.arguments) || '{}'); } catch { /* answered as an error below */ }
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);
yield ev.toolUse(call.id, SHOWN_AS[name] || name, shownInput(name, args), resolvedModel || model);
let out;
let out;
- const def = TOOL_DEFS[name];
+ const def = defs[name];
if (!def) out = [`unknown tool ${name}`, true];
if (!def) out = [`unknown tool ${name}`, true];
else if (LEVELS[def.level] > level) out = [`${name} needs the ${def.level} permission level`, 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') {
else if (name === 'run_command') {
// The hard rules run before the shell does: a refusal is the tool's answer, and
// The hard rules run before the shell does: a refusal is the tool's answer, and
// nothing spawns.
// nothing spawns.
@@ -357,4 +482,4 @@ function apiAgent(spec) {
@@ -357,4 +482,4 @@ function apiAgent(spec) {
return adapter;
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 };
@@ -69,7 +69,7 @@ function adaptersFor(env) {
@@ -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'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
// 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.
// 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' }),
'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' }),
};
};
}
}
@@ -3,7 +3,9 @@
@@ -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 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
* 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
* 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 assert = require('assert');
const fs = require('fs');
const fs = require('fs');
@@ -12,18 +14,29 @@ const path = require('path');
@@ -12,18 +14,29 @@ const path = require('path');
const http = require('http');
const http = require('http');
const { createHarness } = require('../harness');
const { createHarness } = require('../harness');
const { createStore } = require('../harness/sessions');
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');
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() {
async function mockApi() {
const requests = [];
const requests = [];
+ const advice = [];
let script = [];
let script = [];
+ let adviceScript = [];
const server = http.createServer((req, res) => {
const server = http.createServer((req, res) => {
let body = '';
let body = '';
req.on('data', (d) => { body += d; });
req.on('data', (d) => { body += d; });
req.on('end', () => {
req.on('end', () => {
const parsed = JSON.parse(body || '{}');
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 });
requests.push({ auth: req.headers.authorization || null, body: parsed });
const step = script.shift();
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; }
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() {
@@ -33,7 +46,7 @@ async function mockApi() {
});
});
});
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
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; };
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(
@@ -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'));
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 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 }) });
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 () => {
await check('the model\'s tool calls run in the working directory and the loop ends on its answer', async () => {
api.set([
api.set([
@@ -282,6 +299,83 @@ const toolResults = (events) => events.filter((e) => e.type === 'user').flatMap(
@@ -282,6 +299,83 @@ const toolResults = (events) => events.filter((e) => e.type === 'user').flatMap(
assert.match(r.result, /HTTP 429: rate limited/);
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 () => {
await check('resume continues the saved conversation', async () => {
api.set([{ content: 'first answer' }]);
api.set([{ content: 'first answer' }]);
const run1 = h.run({ prompt: 'remember the word kiwi', agent: 'openai-compatible', cwd: work, handoff: false });
const run1 = h.run({ prompt: 'remember the word kiwi', agent: 'openai-compatible', cwd: work, handoff: false });