openvibers/openvibe.ai Commits

The advisor: a cheap run asks a stronger model mid-run, each use on its receipt (#427)

OP OpenVibers committed 3 days ago · 45f83944e9 · 12 files, +532 −23

* The advisor: a cheap run asks a stronger model mid-run, each use on its receipt

* Advice on an app's own run, with the app's ai.app.run token

* Choose an app's advisor from the offers its own run may use

* Fix the advisor tests to run, and let the advisor's ai.chat run pin its offer for an app

* Sequence the advisor migration after quota files

* Merge remote-tracking branch 'origin/main' into al3-effort-advisor

* Keep the chat route pin internal to the advisor's own call

Changes

ddiff --git a/README.md b/README.md
iindex 2d00493..126e8a9 100644
---- a/README.md
++++ b/README.md
@@@ -128,7 +128,7 @@ idempotent replay):
| Route | Capability |
|---|---|
--| `POST /api/v1/runs` (`?wait=ms`, `Idempotency-Key`), `POST /runs/:id/cancel`, `POST /runs/:id/retry`, `POST /runs/:id/citations` | `ai.run.create` |
++| `POST /api/v1/runs` (`?wait=ms`, `Idempotency-Key`), `POST /runs/:id/cancel`, `POST /runs/:id/retry`, `POST /runs/:id/advice` (an advisor call on a run that asked for one), `POST /runs/:id/citations` | `ai.run.create` |
| `POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed,image}` — each a run of workflow `ai.<op>` | `ai.run.create` |
| `POST /api/v1/research` — a run of `research.gather` ([MegaSource](#megasource-research)) | `ai.run.create` (namespace `research.*`) or `ai.app.run` (free sources only) |
| `POST /api/v1/{chat,generate,summarize,classify,extract,embed,image}` (no `enrich`), `GET /runs/:id` (the app's own runs) | `ai.app.run` for a developer-app token ([Developer apps](#developer-apps)) |
ddiff --git a/docs/agent-effort.md b/docs/agent-effort.md
iindex 1ca0178..61a5942 100644
---- a/docs/agent-effort.md
++++ b/docs/agent-effort.md
@@@ -76,3 +76,26 @@ samples yields null. This is provider token-cache usage, distinct from whole-run
Migration 0059 is expand-only, above main's 0057 and the 0058 reserved by Exchange routing PR #403.
It adds only role policy storage and a receipt time index. Tests use fixture responses
and temporary databases; no paid provider calls, production writes or deployment.
++
++## The advisor
++
++A run on a cheap executor may ask a stronger model for advice while it works (plan AL3,
++`ai.advisor@1`). The executor sends its transcript and a question to
++`POST /api/v1/runs/:id/advice` (owner, `ai.run.create`); the advisor sees the transcript, runs without tools
++and answers in at most 100 words. The executor stays in charge: the advice is guidance it feeds back into its
++own loop, never an answer to the task. The endpoint is available on `ai.complete`, `ai.chat` and `ai.messages`
++runs.
++
++The advisor is **off unless the run asks**: `advisor: true` on the run request, or an object
++`{model?, effort?, max_uses?, max_words?}`. `advisor: true` takes at most 3 uses, at most 100 words, high
++effort and no named model. A named model is `provider/model` from `GET /api/v1/routes?operation=chat` and must
++be offered to that run; without one the owner's reviewer role default is used, then the dearest chat offer on
++the run's route (never the executor's own model when another exists). Abortion, an empty transcript and a
++fourth use are refused before a provider is called; the 18+ floor travels with an uncensored run's advice.
++
++Each use is its own run of `ai.chat` on the pinned offer, so it carries the ordinary receipt, quota and usage
++record. The use is written to `run_advisor_uses` and appears in the run's `explain.advisor` and on its receipt
++(`ai.advice@1`) as `{max_uses, max_words, count, cost_usd, uses: [{run_id, use, advice_run_id, model, effort,
++tokens_in, tokens_out, cost_usd, words, created_at}]}`. The advice text is answered to the caller and is not
++kept. Migration 0066 is expand-only. Tests use the stub provider and a temporary database; no paid call or
++production write is made.
ddiff --git a/migrations/0067_run_advisor.sql b/migrations/0067_run_advisor.sql
nnew file mode 100644
iindex 0000000..420bbc6
---- /dev/null
++++ b/migrations/0067_run_advisor.sql
@@@ -0,0 +1,21 @@
++-- phase: expand
++-- The advisor (ai.advisor@1, plan AL3): one row per advice call on a run. The row is on the run's receipt
++-- and explain; the advice itself is answered to the caller and never stored. advice_run_id is the ai.chat
++-- run that made the call, so its own receipt, quota and usage record hold the price. seq is the 1-based use
++-- number and is unique per run (at most 3).
++CREATE TABLE IF NOT EXISTS run_advisor_uses (
++ id text PRIMARY KEY,
++ run_id text NOT NULL,
++ seq integer NOT NULL,
++ advice_run_id text,
++ model text,
++ effort text,
++ tokens_in integer NOT NULL DEFAULT 0,
++ tokens_out integer NOT NULL DEFAULT 0,
++ cost_usd numeric NOT NULL DEFAULT 0,
++ words integer,
++ created_at text NOT NULL,
++ UNIQUE (run_id, seq)
++);
++CREATE INDEX IF NOT EXISTS run_advisor_uses_run ON run_advisor_uses (run_id);
++CREATE INDEX IF NOT EXISTS run_advisor_uses_advice_run ON run_advisor_uses (advice_run_id);
ddiff --git a/package-lock.json b/package-lock.json
iindex 223155a..086955b 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.387.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.389.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.183.0",
"pg": "^8.23.0"
@@@ -1543,9 +1543,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.387.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.387.0",
-- "integrity": "sha512-EYx2yHYTGAqi2sEHAOb/fGT1u5Yj1/rcxmp6xOkqDOCnWtm4848Gr5akd5DP47JsTkbZ/5GHsjCt3d9hTkCR+w==",
++ "version": "0.389.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.389.0",
++ "integrity": "sha512-kA5uAQIDy/4a0MomMBl0VsgLKgLvLBFB5afRIm8DCx/l/OfM5wX5JJEaPot08WZfi+HSNpeXwIpEsYz5/w/VRA==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 992a7ac..b9b6bfd 100644
---- a/package.json
++++ b/package.json
@@@ -29,7 +29,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.387.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.389.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.125.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.183.0",
"pg": "^8.23.0"
ddiff --git a/server/advisor.js b/server/advisor.js
nnew file mode 100644
iindex 0000000..171580a
---- /dev/null
++++ b/server/advisor.js
@@@ -0,0 +1,118 @@
++'use strict';
++/**
++ * The advisor (ai.advisor@1, plan AL3): a run on a cheap executor model may ask a stronger model for advice
++ * mid-run. The executor sends its transcript and a question; the advisor sees the transcript, runs without
++ * tools and answers in at most 100 words. At most 3 uses per run, and each use is recorded on the run's
++ * receipt with the advice run, the model, the tokens and the cost.
++ *
++ * Off unless the run request asks: `advisor: true` takes the defaults, an object may name a model, an effort
++ * and smaller caps. No environment switch enables it. The advice is a run of workflow ai.chat on a pinned
++ * offer, so it has the ordinary receipt, quota and usage record. The executor stays in charge: the answer is
++ * guidance the caller feeds back into its own loop, never an answer to its task.
++ */
++const { AiError } = require('./util');
++
++const MAX_USES = 3;
++const MAX_WORDS = 100;
++const MIN_WORDS = 10;
++const EFFORTS = ['low', 'medium', 'high', 'max'];
++// The workflows whose executor can ask: the OpenAI-compatible completion, the chat passthrough and the
++// Anthropic-compatible messages run. A product workflow with its own output schema is not an executor.
++const EXECUTORS = new Set(['ai.complete', 'ai.chat', 'ai.messages']);
++const MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]*\/[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,199}$/;
++
++const DEFAULT_PROMPT = 'You are the advisor to the executor that is doing this work. You see its transcript. Give concrete guidance it can act on now: what to do next, what to avoid, and what to check before it declares the work done. Do not restate the task, do not write the answer for it, and do not use tools.';
++
++/** The advisor value on a run request: top-level `advisor`, or `options.advisor` (the compat endpoints pass it there). */
++function rawOf(body) {
++ if (!body || typeof body !== 'object') return undefined;
++ if (body.advisor !== undefined) return body.advisor;
++ if (body.options && typeof body.options === 'object' && body.options.advisor !== undefined) return body.options.advisor;
++ return undefined;
++}
++
++/**
++ * Parse and validate the request's advisor control. Returns { enabled: false } when it was not asked for.
++ * A refusal is 422 before the run exists, so a bad cap never spends.
++ */
++function policy(body, wf) {
++ const raw = rawOf(body);
++ if (raw === undefined || raw === null || raw === false) return { enabled: false };
++ if (raw !== true && (typeof raw !== 'object' || Array.isArray(raw))) throw new AiError(422, 'input.invalid', 'advisor must be true or an object with model, effort, max_uses and max_words');
++ if (raw === true && (!wf || !EXECUTORS.has(wf.key))) throw new AiError(422, 'input.invalid', 'advisor is available on ai.complete, ai.chat and ai.messages runs');
++ if (raw !== true && (!wf || !EXECUTORS.has(wf.key))) throw new AiError(422, 'input.invalid', 'advisor is available on ai.complete, ai.chat and ai.messages runs');
++ const out = { enabled: true, max_uses: MAX_USES, max_words: MAX_WORDS, effort: 'high' };
++ if (raw === true) return out;
++ for (const key of Object.keys(raw)) if (!['model', 'effort', 'max_uses', 'max_words'].includes(key)) throw new AiError(422, 'input.invalid', `advisor has no field ${key}`);
++ if (raw.model !== undefined) {
++ if (typeof raw.model !== 'string' || !MODEL_RE.test(raw.model.trim())) throw new AiError(422, 'input.invalid', 'advisor.model must be provider/model from GET /api/v1/routes');
++ out.model = raw.model.trim();
++ }
++ if (raw.effort !== undefined) {
++ if (!EFFORTS.includes(raw.effort)) throw new AiError(422, 'input.invalid', 'advisor.effort must be low|medium|high|max');
++ out.effort = raw.effort;
++ }
++ if (raw.max_uses !== undefined) {
++ if (!Number.isInteger(raw.max_uses) || raw.max_uses < 1 || raw.max_uses > MAX_USES) throw new AiError(422, 'input.invalid', `advisor.max_uses must be an integer from 1 to ${MAX_USES}`);
++ out.max_uses = raw.max_uses;
++ }
++ if (raw.max_words !== undefined) {
++ if (!Number.isInteger(raw.max_words) || raw.max_words < MIN_WORDS || raw.max_words > MAX_WORDS) throw new AiError(422, 'input.invalid', `advisor.max_words must be an integer from ${MIN_WORDS} to ${MAX_WORDS}`);
++ out.max_words = raw.max_words;
++ }
++ return out;
++}
++
++/** The transcript a caller sends: 1 to 200 messages, text only, bounded so one advice call cannot carry a whole repo. */
++const ROLES = new Set(['system', 'user', 'assistant', 'tool']);
++function transcript(value) {
++ if (!Array.isArray(value) || value.length < 1 || value.length > 200) throw new AiError(422, 'input.invalid', 'transcript must be 1 to 200 messages');
++ let total = 0;
++ return value.map((m) => {
++ if (!m || typeof m !== 'object' || !ROLES.has(m.role)) throw new AiError(422, 'input.invalid', 'each transcript message needs a role (system, user, assistant, tool)');
++ if (typeof m.content !== 'string' || !m.content.trim()) throw new AiError(422, 'input.invalid', 'each transcript message needs text content');
++ total += m.content.length;
++ if (total > 300000) throw new AiError(413, 'input.too_large', 'the transcript is larger than 300000 characters');
++ return { role: m.role, content: m.content.slice(0, 200000) };
++ });
++}
++
++/** Cut to at most n words; never cut mid-word. */
++function words(text, n) {
++ const list = String(text || '').trim().split(/\s+/).filter(Boolean);
++ return { text: list.slice(0, n).join(' '), count: Math.min(list.length, n) };
++}
++
++/** The messages of an ai.chat run: the advisor prompt and the transcript flattened to user/assistant turns. */
++function messagesFor(transcriptList, question, maxWords) {
++ const system = [];
++ const messages = [];
++ const push = (role, content) => {
++ if (!content) return;
++ const last = messages[messages.length - 1];
++ if (last && last.role === role) last.content += `\n\n${content}`;
++ else messages.push({ role, content });
++ };
++ for (const m of transcriptList) {
++ if (m.role === 'system') { system.push(m.content); continue; }
++ if (m.role === 'tool') { push('user', `Tool result:\n${m.content}`); continue; }
++ push(m.role === 'assistant' ? 'assistant' : 'user', m.content);
++ }
++ const ask = question && String(question).trim() ? String(question).trim() : 'Given the transcript, what should the executor do next, and what should it check before declaring the work done?';
++ push('user', `${ask}\n\nAnswer with at most ${maxWords} words of concrete guidance only.`);
++ return { system: [DEFAULT_PROMPT, ...system].join('\n\n'), messages };
++}
++
++/** The receipt/explain block for a set of uses (the run's receipt and explain carry the same object). */
++function summary(uses, policy) {
++ const list = uses || [];
++ return {
++ max_uses: policy.max_uses,
++ max_words: policy.max_words,
++ count: list.length,
++ cost_usd: Math.round(list.reduce((n, u) => n + Number(u.cost_usd || 0), 0) * 1e8) / 1e8,
++ uses: list,
++ };
++}
++
++module.exports = { policy, transcript, words, messagesFor, summary, MAX_USES, MAX_WORDS, MIN_WORDS, EFFORTS, EXECUTORS };
ddiff --git a/server/api/runs.js b/server/api/runs.js
iindex c4143f1..47d160b 100644
---- a/server/api/runs.js
++++ b/server/api/runs.js
@@@ -12,6 +12,8 @@
* run.explain.usage_readings (on every run response): each reading's idempotency key, delivery state (queued/sent/failed), last error, free_allowance_used
* POST /api/v1/runs/:id/cancel (ai.run.create, owner)
* POST /api/v1/runs/:id/retry (ai.run.create, owner) -> a new run with retry_of
++ * POST /api/v1/runs/:id/advice (ai.run.create, owner) -> one advisor call for a run that
++ * asked for advisor: true; at most its max_uses, at most 100 words
* GET /api/v1/runs/:id/citations (ai.run.read)
* POST /api/v1/runs/:id/citations attach sources/citations to a finished run (ai.run.create, owner)
* POST /api/v1/{chat,generate,summarize,classify,extract,enrich,embed}
@@@ -156,6 +158,15 @@ function runsRouter({ choices = null, runs, registry, auth, config, log = consol
} catch (err) { sendError(res, err, req.ov, log); }
});
++ // AL3: one advisor call for a run that asked for it. The executor sends its transcript; a stronger model
++ // answers with at most 100 words, every use is on the run's receipt with its cost, and the run's own cap
++ // (at most 3) is enforced here. The advice is guidance the executor feeds back into its own loop.
++ r.post('/api/v1/runs/:id/advice', createApp, async (req, res) => {
++ try {
++ res.status(201).json(await runs.advise(req.params.id, req.body || {}, req.principal, { trace: req.ov.traceId, principalHas: has(req) }));
++ } catch (err) { sendError(res, err, req.ov, log); }
++ });
++
r.get('/api/v1/runs/:id/citations', read, async (req, res) => {
try { const run = await owned(req); res.json({ citations: await runs.citations(run.id) }); } catch (err) { sendError(res, err, req.ov, log); }
});
ddiff --git a/server/effort/defaults.js b/server/effort/defaults.js
iindex d19661b..b2878e2 100644
---- a/server/effort/defaults.js
++++ b/server/effort/defaults.js
@@@ -57,7 +57,7 @@ function createDefaults({ db, registry, clock = { now: () => Date.now() } }) {
}
function controls(body) {
const out = {};
-- for (const k of ['effort', 'agent_role', 'ladder_id', 'rung', 'kind']) if (body[k] !== undefined) out[k] = body[k];
++ for (const k of ['effort', 'agent_role', 'ladder_id', 'rung', 'kind', 'advisor']) if (body[k] !== undefined) out[k] = body[k];
return out;
}
async function resolve(body, workflow, defaults) {
ddiff --git a/server/exchange/receipts.js b/server/exchange/receipts.js
iindex aa2999b..13bb1e7 100644
---- a/server/exchange/receipts.js
++++ b/server/exchange/receipts.js
@@@ -81,6 +81,7 @@ function buildReceipt({ run, explain = null, usage = {}, price = null, privacy =
return {
...require('../effort/receipt').receiptEffort(run, explain),
...(parseJson(run.options, {}).effort_policy?.kind ? { kind: parseJson(run.options, {}).effort_policy.kind } : {}),
++ ...(x.advisor ? { advisor: x.advisor } : {}),
id: receiptId(run.id),
run_id: run.id,
created_at: createdAt || new Date().toISOString(),
@@@ -142,6 +143,7 @@ function compact(receipt) {
fallbacks: (receipt.fallbacks || []).map((f) => f.offer),
privacy: receipt.privacy ? receipt.privacy.label : null,
preset: receipt.preset ? receipt.preset.name : null,
++ advisor: receipt.advisor ? { count: receipt.advisor.count, cost_usd: receipt.advisor.cost_usd } : null,
};
}
ddiff --git a/server/openapi.js b/server/openapi.js
iindex f04d170..5698d9f 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -64,13 +64,26 @@ const effortFields = {
agent_role: str({ enum: ['router', 'gate', 'assessor', 'worker', 'subagent', 'reviewer'] }),
ladder_id: str({ maxLength: 128 }), rung: integer({ minimum: 0 }), kind: str({ maxLength: 100 }),
};
++// AL3: a run may ask a stronger model for advice (ai.advisor@1). Off when omitted; true takes the defaults.
++const advisorField = {
++ description: 'Ask a stronger model for advice during this run (ai.advisor@1): true, or { model, effort, max_uses (1-3), max_words (10-100) }. Off when omitted; each use is on the run receipt with its cost.',
++ oneOf: [
++ { const: true },
++ { type: 'object', additionalProperties: false, properties: {
++ model: str({ description: 'provider/model from GET /api/v1/routes' }),
++ effort: str({ enum: ['low', 'medium', 'high', 'max'] }),
++ max_uses: integer({ minimum: 1, maximum: 3, default: 3 }),
++ max_words: integer({ minimum: 10, maximum: 100, default: 100 }),
++ } },
++ ],
++};
const runBody = (required = []) => ({
type: 'object', ...(required.length ? { required } : {}),
properties: {
workflow: str({ description: 'The workflow key, e.g. ai.generate' }), version: integer({ minimum: 1 }),
task: str({ description: 'A registered task (GET /api/v1/tasks); its workflow runs with the task’s resolved choice. Required instead of workflow when no workflow is named.' }),
choice: ref('Choice'),
-- ...effortFields, input: { type: 'object' }, idempotency_key: str({ minLength: 8, maxLength: 128 }),
++ ...effortFields, advisor: advisorField, input: { type: 'object' }, idempotency_key: str({ minLength: 8, maxLength: 128 }),
target: { type: 'object' }, attribution: { type: 'object' }, on_behalf_of: ref('SubjectRef'),
options: { type: 'object', description: 'cache, debug, and the latency class: latency (interactive | background | batch); a batch-class run also takes deadline_hours (1-72, the default is 24)' }, route: { type: 'object', description: '{ provider, model } — one offer from GET /api/v1/routes?operation=…' },
},
@@@ -79,6 +92,7 @@ const directBody = {
type: 'object',
properties: {
...effortFields,
++ advisor: advisorField,
idempotency_key: str({ minLength: 8, maxLength: 128 }), target: { type: 'object' }, attribution: { type: 'object' },
on_behalf_of: ref('SubjectRef'), options: { type: 'object' },
},
@@@ -359,6 +373,21 @@ route('post', '/runs/{id}/retry', {
responses: { 201: json('The new run', { type: 'object', required: ['run'], properties: { run: ref('Run') } }),
202: json('Still running', { type: 'object', required: ['run'], properties: { run: ref('Run') } }), ...errors([401, 403, 404, 409, 429]) },
});
++route('post', '/runs/{id}/advice', {
++ operationId: 'adviseRun', summary: 'One advisor call for a run that asked for it (owner)', tags: ['runs'], security: SERVICE,
++ description: 'ai.run.create. The run must have been created with advisor (ai.advisor@1). At most 3 uses per run and at most 100 words back; the advice is guidance the executor feeds into its own loop, and each use is on the run receipt (ai.advice@1) with the advice run, model, tokens and cost.',
++ parameters: [path('id', str())],
++ requestBody: body({ type: 'object', required: ['transcript'], properties: {
++ transcript: { type: 'array', minItems: 1, maxItems: 200, items: { type: 'object', required: ['role', 'content'], properties: { role: { type: 'string', enum: ['system', 'user', 'assistant', 'tool'] }, content: str({ maxLength: 200000 }) } } },
++ question: str({ maxLength: 4000 }), model: str({ description: 'provider/model; omitted uses the reviewer role default, then the strongest offer' }),
++ effort: str({ enum: ['low', 'medium', 'high', 'max'] }), max_words: integer({ minimum: 10, maximum: 100 }),
++ } }),
++ responses: { 201: json('The advice', { type: 'object', required: ['run_id', 'use', 'advice', 'max_uses', 'advice_run_id'], properties: {
++ run_id: str(), use: integer({ minimum: 1, maximum: 3 }), max_uses: integer({ minimum: 1, maximum: 3 }), max_words: integer({ minimum: 10, maximum: 100 }),
++ advice: str(), model: str(), effort: str(), usage: { type: 'object', properties: { tokens_in: integer(), tokens_out: integer(), cost_usd: { type: 'number' } } },
++ advice_run_id: str(), receipt_id: str(),
++ } }), ...errors([401, 403, 404, 409, 413, 422, 429, 502, 503]) },
++});
route('get', '/runs/{id}/citations', {
operationId: 'listRunCitations', summary: 'A run\'s citations', tags: ['runs'], security: SERVICE,
description: 'ai.run.read.',
ddiff --git a/server/runs.js b/server/runs.js
iindex d48646c..6fb99bd 100644
---- a/server/runs.js
++++ b/server/runs.js
@@@ -30,6 +30,7 @@ const usageRecord = require('./usage-record');
const usageSamples = require('./usage-samples');
const effortDefaults = require('./effort/defaults');
const { receiptEffort } = require('./effort/receipt');
++const advisor = require('./advisor');
const { buildReceipt } = require('./exchange/receipts');
const privacy = require('./exchange/privacy');
@@@ -97,15 +98,18 @@ function projectProviderOf(spec) {
/**
* A generate run's `route: { provider, model }`: one offer from GET /api/v1/routes. Only a first-party
-- * service may ask for one, and only on the generate operation; the offer itself is checked against the
-- * routing's own list later, once the run's route is resolved.
++ * service may ask for one, on the generate operation and on the chat passthrough the advisor's own call
++ * uses; the offer itself is checked against the routing's own list later, once the run's route is resolved.
*/
const ROUTE_PIN_KEYS = new Set(['provider', 'model']);
--function routePinRequest(value, principal, wf) {
++function routePinRequest(value, principal, wf, internal = false) {
if (value == null) return null;
-- if (principal.kind === 'app') throw new AiError(403, 'capability.denied', 'a developer app may not pin a route; use the routing pool');
-- if (!principal.subject || principal.subject.type !== 'service') throw new AiError(403, 'capability.denied', 'only a first-party service may pin a route');
-- if (!wf || wf.key !== 'ai.generate') throw new AiError(422, 'input.invalid', 'route pinning is available on the generate operation (ai.generate) only');
++ // A first-party service pins a route on the generate operation. The advisor's own ai.chat child run is
++ // created with the app's (or service's) principal and passes `internal`; no caller's request reaches the
++ // chat pin, so a caller still cannot pin anything but generate.
++ if (principal.kind === 'app' && !(internal && wf && wf.key === 'ai.chat')) throw new AiError(403, 'capability.denied', 'a developer app may not pin a route; use the routing pool');
++ if (principal.kind !== 'app' && (!principal.subject || principal.subject.type !== 'service')) throw new AiError(403, 'capability.denied', 'only a first-party service may pin a route');
++ if (!wf || !(wf.key === 'ai.generate' || (internal && wf.key === 'ai.chat'))) throw new AiError(422, 'input.invalid', 'route pinning is available on the generate operation (ai.generate) only');
if (typeof value !== 'object' || Array.isArray(value)) throw new AiError(422, 'input.invalid', 'route must be { provider, model }');
for (const k of Object.keys(value)) if (!ROUTE_PIN_KEYS.has(k)) throw new AiError(422, 'input.invalid', `route has no field ${k}; send { provider, model }`);
const provider = typeof value.provider === 'string' ? value.provider.trim() : '';
@@@ -172,6 +176,18 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
const getRow = async (id) => await db.prepare('SELECT * FROM runs WHERE id = ?').get(id);
const get = async (id) => decode(await getRow(id));
++ /** Every advisor use on a run, oldest first, in ai.advice@1 shape (the receipt and explain carry them). */
++ async function advisorUsesOf(runId) {
++ const rows = await db.prepare(`SELECT run_id, seq AS use, advice_run_id, model, effort, tokens_in, tokens_out, cost_usd, words, created_at
++ FROM run_advisor_uses WHERE run_id = ? ORDER BY seq`).all(String(runId || ''));
++ return rows.map((r) => ({ ...r, tokens_in: Number(r.tokens_in) || 0, tokens_out: Number(r.tokens_out) || 0, cost_usd: Number(r.cost_usd) || 0, use: Number(r.use) || 1, words: r.words == null ? undefined : Number(r.words) }));
++ }
++ const advisorPolicyOf = (options) => parseJson(options, {}).advisor_policy || { max_uses: 3, max_words: 100 };
++ const advisorSummaryOf = async (row) => {
++ const uses = await advisorUsesOf(row.id);
++ return uses.length ? advisor.summary(uses, advisorPolicyOf(row.options)) : null;
++ };
++
async function citations(runId) {
return (await db.prepare('SELECT * FROM citations WHERE run_id = ? ORDER BY ordinal, id').all(runId))
.map(c => ({ ...c, trust: parseJson(c.trust, {}), provenance: parseJson(c.provenance, {}) }));
@@@ -214,7 +230,7 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
* principal: { sub, subject: {type, id} }
* Returns { run, created, promise? }.
*/
-- async function create(body, principal, { trace = null, requestId = null, retryOf = null, onDelta = null, onRoute = null, abuse: requestAbuse = null, discount = null, exact = null, costCheck = null, communityOfferId = null, callerWaitMs = 0 } = {}) {
++ async function create(body, principal, { trace = null, requestId = null, retryOf = null, onDelta = null, onRoute = null, abuse: requestAbuse = null, discount = null, exact = null, costCheck = null, communityOfferId = null, callerWaitMs = 0, advisorInternal = false } = {}) {
if (!body || typeof body !== 'object') throw new AiError(400, 'input.invalid', 'JSON body required');
const namedTask = choices && body.task ? await choices.task(String(body.task)) : null;
if (body.task && !namedTask) throw new AiError(404, 'task.not_found', 'unknown task');
@@@ -222,7 +238,7 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
const wf = body.version ? await registry.getWorkflow(wfKey, Number(body.version)) : await registry.activeWorkflow(wfKey);
if (!wf) throw new AiError(404, 'workflow.not_found', `no active workflow ${wfKey}${body.version ? ` v${body.version}` : ''}`);
if (body.version && !['active', 'deprecated'].includes(wf.status)) throw new AiError(409, 'workflow.inactive', `${wfKey} v${wf.version} is ${wf.status}`);
-- const pinned = routePinRequest(body.route, principal, wf);
++ const pinned = routePinRequest(body.route, principal, wf, advisorInternal);
if (pinned && body.credential != null) throw new AiError(422, 'input.invalid', 'a pinned route cannot be combined with a person\'s credential; the pin is the provider');
// A run token names the person it acts for (Codes C3, `on_behalf_of` in the claims). The run records that
// person exactly as a body field would, so they pay, the run is theirs and caches never cross persons. The
@@@ -243,11 +259,14 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
const input = body.input === undefined ? {} : body.input;
const taskDefaults = choices ? (await choices.defaults())[body.task || wf.key] : null;
const effortPolicy = await effortDefaults.resolve(body, { ...wf, metadata: { ...wf.metadata, ...(taskDefaults?.effort ? { effort: taskDefaults.effort } : {}) } }, agentDefaults);
++ // AL3: the advisor is off unless this run asked for it. Parsed before the input hash, so a different
++ // advisor policy is a different run and never replays another policy's answer.
++ const advisorPolicy = advisor.policy(body, wf);
const inputJson = stableStringify(input);
if (Buffer.byteLength(inputJson) > config.runs.maxInputBytes) throw new AiError(413, 'input.too_large', `input is larger than ${config.runs.maxInputBytes} bytes`);
const v = schemas.validate(wf.input_schema, input);
if (!v.valid) throw new AiError(422, 'input.invalid', `input does not match ${wf.key} v${wf.version}`, { errors: v.errors });
-- const inputHash = sha256({ input, effortPolicy, choice: body.choice || null, task: body.task || null });
++ const inputHash = sha256({ input, effortPolicy, advisorPolicy, choice: body.choice || null, task: body.task || null });
const requester = principal.subject;
// A developer app (ADR-014): the requester IS its project, the run is metered with the project's
// id on every Billing reading, and a sandbox run may use free/local capacity only (server/providers).
@@@ -273,8 +292,8 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
if (pinned) {
if (!route || route.disabled) throw new AiError(503, 'route.unavailable', `route ${routeKey || '(none)'} is not available, so its offers cannot be listed`);
if (!pool) throw new AiError(503, 'route.unavailable', 'routing is not available on this service');
-- const step = wf.steps.find((x) => x.kind === 'llm');
-- const operation = (step && step.operation) || 'generate';
++ const step = wf.steps.find((x) => x.kind === 'llm') || wf.steps[0];
++ const operation = (step && (step.operation || (step.kind === 'passthrough' ? 'chat' : step.kind === 'complete' ? 'complete' : step.kind === 'messages' ? 'messages' : null))) || 'generate';
const offers = await pool.offers(route, operation, { requesterType: requester.type, requesterId: requester.id });
const offered = offers.some((o) => o.provider === pinned.provider && (pinned.model == null || o.model === pinned.model));
if (!offered) {
@@@ -446,7 +465,7 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
target: body.target ? JSON.stringify(body.target) : null, target_key: ctx.targetKey,
input: null, input_hash: inputHash, idempotency_key: idem, trace_id: trace, request_id: requestId, retry_of: retryOf,
latency_class: latencyClass,
-- options: JSON.stringify({ effort_policy: effortPolicy, ...(taskChoice ? { task_choice: taskChoice } : {}), cache: options.cache !== false && credentialSubject === null && !projectProvider && !pinned && !selfKeys && !communityOptIn, debug: Boolean(options.debug), input_retained: kept.full,
++ options: JSON.stringify({ effort_policy: effortPolicy, ...(advisorPolicy.enabled ? { advisor_policy: advisorPolicy } : {}), ...(taskChoice ? { task_choice: taskChoice } : {}), cache: options.cache !== false && credentialSubject === null && !projectProvider && !pinned && !selfKeys && !communityOptIn && !advisorPolicy.enabled, debug: Boolean(options.debug), input_retained: kept.full,
...(options.latency_clamped ? { latency_clamped: options.latency_clamped } : {}),
...(credentialSubject !== null ? { credential_subject: credentialSubject } : {}), ...(credentialProvider !== null ? { credential_provider: credentialProvider } : {}),
...((selfKeys || taskChoice && runPerson) ? { self_subject: runPerson } : {}),
@@@ -463,7 +482,7 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
// and another model's answer under the same prompt would silently break the pin. A run that may use the
// person's own key is never cached either: their key must be used, and the answer is theirs, not the scope's.
// A community or adult-tier answer cannot be reused by a request without the same policy.
-- const scope = taskChoice || options.cache === false || credentialSubject !== null || projectProvider || pinned || selfKeys || communityOptIn || contentPolicy !== 'standard' ? null : cache.scopeFor(wf.cache_mode, ctx);
++ const scope = taskChoice || options.cache === false || credentialSubject !== null || projectProvider || pinned || selfKeys || communityOptIn || contentPolicy !== 'standard' || advisorPolicy.enabled ? null : cache.scopeFor(wf.cache_mode, ctx);
let cacheKey = null;
if (scope) {
// Some workflows key the cache on less than their whole input (hooks.js CACHE_INPUT: a signed URL's signature).
@@@ -795,6 +814,10 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
const record = await registry.getProvider(answerProvider);
if (record) explain = { ...(explain || {}), effort_applied: require('./effort/providers').control({ ...record, model: answerModel }, opts.effort_policy.effort_resolved).applied };
}
++ // AL3: every advisor use this run made is on the receipt with its cost, whether it happened
++ // while the run was open or after it finished.
++ const advisorBlock = await advisorSummaryOf(row);
++ if (advisorBlock) explain = { ...(explain || {}), advisor: advisorBlock };
const receipt = buildReceipt({
run: {
id: row.id, workflow_key: row.workflow_key, route_key: row.route_key, route_version: row.route_version,
@@@ -1175,6 +1198,134 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
}, principal, { trace, requestId, retryOf: id });
}
++ /**
++ * AL3: one advisor call for a run that asked for it. The executor sends its transcript and a question;
++ * the advice is a run of workflow ai.chat on a stronger pinned offer, so it carries the ordinary receipt,
++ * quota and usage. At most policy.max_uses calls per run; each use lands in run_advisor_uses, the run's
++ * explain and the run's receipt with the advice run, the model, the tokens and the cost. The advice text
++ * is answered to the caller and never kept: the transcript is the executor's own context, not ours to store.
++ */
++ async function advise(id, body = {}, principal, { trace = null, principalHas } = {}) {
++ const row = await getRow(id);
++ if (!row) throw new AiError(404, 'run.not_found', 'no such run');
++ assertOwner(row, principal, 'ai.workflow.manage', principalHas);
++ const opts = parseJson(row.options, {});
++ const policy = opts.advisor_policy;
++ if (!policy || !policy.enabled) throw new AiError(403, 'advisor.not_enabled', 'this run did not ask for an advisor; send advisor: true when it is created');
++ const transcript = advisor.transcript(body.transcript);
++ const question = body.question == null ? '' : String(body.question).trim();
++ if (question.length > 4000) throw new AiError(422, 'input.invalid', 'question is at most 4000 characters');
++ const maxWords = body.max_words == null ? policy.max_words : body.max_words;
++ if (!Number.isInteger(maxWords) || maxWords < advisor.MIN_WORDS || maxWords > policy.max_words) throw new AiError(422, 'input.invalid', `max_words must be an integer from ${advisor.MIN_WORDS} to ${policy.max_words} for this run`);
++ const effort = body.effort == null ? policy.effort : body.effort;
++ if (!advisor.EFFORTS.includes(effort)) throw new AiError(422, 'input.invalid', 'effort must be low|medium|high|max');
++ const used = await advisorUsesOf(id);
++ if (used.length >= policy.max_uses) throw new AiError(409, 'advisor.exhausted', `this run has used its ${policy.max_uses} advisor calls`);
++ if (!pool) throw new AiError(503, 'route.unavailable', 'routing is not available on this service');
++ const routeKey = row.route_key || 'default.chat';
++ const route = await registry.resolveRoute(routeKey);
++ if (!route || route.disabled) throw new AiError(503, 'route.unavailable', `route ${routeKey} is not available, so the advisor cannot be selected`);
++ const person = subjectOf(row);
++ // A developer app's run may only use what its own run could use: a sandbox app (or any app run no tier
++ // budget governs) is limited to free and local capacity, so the advisor is chosen from those offers too.
++ const appRun = principal.kind === 'app';
++ const freeOnly = appRun && (principal.env === 'sandbox' || !(tiers && tiers.enabled));
++ const ctx = { requesterType: row.requester_type, requesterId: row.requester_id, ...(person ? { onBehalfOf: person } : {}), ...(opts.policy ? { contentPolicy: opts.policy } : {}), ...(appRun ? { appRun: true, freeOnly } : {}) };
++ const offers = await pool.offers(route, 'chat', ctx);
++ const asked = body.model == null ? null : String(body.model).trim();
++ if (asked != null && !/^[A-Za-z0-9][A-Za-z0-9._:-]*\/[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,199}$/.test(asked)) throw new AiError(422, 'input.invalid', 'model must be provider/model from GET /api/v1/routes');
++ let chosen = null;
++ let why = null;
++ if (asked) {
++ chosen = offers.find((o) => `${o.provider}/${o.model}` === asked) || null;
++ if (!chosen) throw new AiError(422, 'advisor.model_unavailable', `${asked} is not offered for chat on route ${routeKey}; GET /api/v1/routes?operation=chat lists the offers`);
++ why = 'the model the caller named';
++ } else {
++ // The owner's reviewer role default is the stronger model by policy. When the route cannot serve it,
++ // the dearest offer the route can serve is the fallback, never the executor's own model if another exists.
++ const defaults = await agentDefaults.get();
++ const candidates = defaults.reviewer ? (defaults.reviewer.model ? [defaults.reviewer.model] : (defaults.reviewer.choice_order || [])) : [];
++ chosen = candidates.map((m) => offers.find((o) => `${o.provider}/${o.model}` === m)).find(Boolean) || null;
++ if (chosen) why = 'the reviewer role default';
++ else {
++ const scored = [];
++ for (const o of offers) {
++ const cards = await pool.rateCardsFor(o.provider, o.model);
++ scored.push({ offer: o, price: cards && cards.out ? Number(cards.out.unit_price_usd) || 0 : 0 });
++ }
++ const executorKey = row.provider_key && row.model_key ? `${row.provider_key}/${row.model_key}` : null;
++ const notSame = scored.filter((s) => `${s.offer.provider}/${s.offer.model}` !== executorKey);
++ const list = notSame.length ? notSame : scored;
++ list.sort((a, b) => b.price - a.price);
++ chosen = list[0] && list[0].offer;
++ why = 'the strongest offer on the run\'s route';
++ }
++ }
++ if (!chosen) throw new AiError(503, 'advisor.model_unavailable', `no chat offer on route ${routeKey} can advise this run`);
++ const built = advisor.messagesFor(transcript, question, maxWords);
++ const created = await create({
++ workflow: 'ai.chat',
++ input: { system: built.system, messages: built.messages },
++ route: { provider: chosen.provider, model: chosen.model },
++ effort,
++ // The adult tier's floor travels with the advice: an uncensored run's transcript is only sent
++ // to an uncensored offer, and the child run passes the same 18+ admission.
++ ...(opts.policy ? { policy: opts.policy } : {}),
++ ...(row.on_behalf_of ? { on_behalf_of: parseJson(row.on_behalf_of, null) } : {}),
++ ...(row.attribution ? { attribution: parseJson(row.attribution, null) } : {}),
++ options: { cache: false },
++ }, principal, { trace, advisorInternal: true });
++ const done = await wait(created, config.runs.maxWaitMs);
++ if (!done || ['failed', 'cancelled'].includes(done.status)) throw new AiError(502, 'advisor.failed', (done && done.error && done.error.detail) || 'the advisor run did not answer');
++ const cut = advisor.words(done.output && done.output.text, maxWords);
++ if (!cut.text) throw new AiError(502, 'advisor.empty', 'the advisor answered with no advice');
++ const cost = Number(done.usage && done.usage.cost_usd) || 0;
++ const tokensIn = Number(done.usage && done.usage.tokens_in) || 0;
++ const tokensOut = Number(done.usage && done.usage.tokens_out) || 0;
++ // The offer may name only its provider (a stub or the host's own model leaves model null): the run's
++ // provenance names the artifact that actually answered.
++ const answeredModel = chosen.model || (done.provenance && done.provenance.model) || 'default';
++ const entry = {
++ run_id: id, use: used.length + 1, advice_run_id: done.id, model: `${chosen.provider}/${answeredModel}`, effort,
++ tokens_in: tokensIn, tokens_out: tokensOut, cost_usd: cost, words: cut.count, created_at: iso(clock.now()),
++ };
++ await db.tx(async () => {
++ // One advisor counter per run, so two concurrent calls cannot make a fourth use.
++ await db.prepare('SELECT pg_advisory_xact_lock(hashtext(?))').get(`advisor:${id}`);
++ const count = Number((await db.prepare('SELECT COUNT(*)::int n FROM run_advisor_uses WHERE run_id = ?').get(id)).n) || 0;
++ if (count >= policy.max_uses) throw new AiError(409, 'advisor.exhausted', `this run has used its ${policy.max_uses} advisor calls`);
++ entry.use = count + 1;
++ await db.prepare(`INSERT INTO run_advisor_uses (id, run_id, seq, advice_run_id, model, effort, tokens_in, tokens_out, cost_usd, words, created_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
++ .run(`adv_${ids.ulid()}`, id, entry.use, entry.advice_run_id, entry.model, entry.effort, entry.tokens_in, entry.tokens_out, entry.cost_usd, entry.words, entry.created_at);
++ const latest = await getRow(id);
++ const uses = await advisorUsesOf(id);
++ const explain = { ...(parseJson(latest.explain, {}) || {}), advisor: advisor.summary(uses, advisorPolicyOf(latest.options)) };
++ await db.prepare('UPDATE runs SET explain = ? WHERE id = ?').run(JSON.stringify(explain), id);
++ });
++ // The run may have finished before this call: its receipt is already written, so add the same block to it.
++ // A run that finishes after this call picks the uses up in writeReceipt.
++ try {
++ if (receipts) {
++ const existing = await receipts.forRun(id);
++ if (existing) {
++ const uses = await advisorUsesOf(id);
++ await receipts.record({ ...existing, advisor: advisor.summary(uses, policy) }, {
++ requester: { type: row.requester_type, id: row.requester_id },
++ owner: person ? { type: 'user', id: person } : null,
++ });
++ }
++ }
++ } catch (err) {
++ log.warn(`[runs] ${id}: the receipt did not take the advisor use: ${err && err.message}`);
++ }
++ return {
++ run_id: id, use: entry.use, max_uses: policy.max_uses, max_words: maxWords, advice: cut.text, why,
++ model: entry.model, effort, usage: { tokens_in: entry.tokens_in, tokens_out: entry.tokens_out, cost_usd: entry.cost_usd },
++ advice_run_id: entry.advice_run_id, receipt_id: `rcpt_${String(id).replace(/^run_/, '')}`,
++ };
++ }
++
async function list(principal, { status, workflow, target, limit = 50, before, all = false } = {}) {
const where = [];
const args = [];
@@@ -1217,7 +1368,15 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
async function explained(run) {
const ids = run && run.usage ? run.usage.usage_sample_ids : null;
const readings = ids && ids.length ? await usageSamples.readingsFor(db, ids) : [];
-- return readings.length ? { ...run, explain: { ...(run.explain || {}), usage_readings: readings } } : run;
++ // AL3: a run's advisor uses are part of its explain, so GET /runs/:id answers them beside the receipt.
++ const row = run && run.id ? await getRow(run.id) : null;
++ const advisorBlock = row ? await advisorSummaryOf(row) : null;
++ const explain = {
++ ...(run.explain || {}),
++ ...(readings.length ? { usage_readings: readings } : {}),
++ ...(advisorBlock ? { advisor: advisorBlock } : {}),
++ };
++ return readings.length || advisorBlock ? { ...run, explain } : run;
}
async function requestsFor(runId) {
@@@ -1254,6 +1413,7 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
await db.tx(async () => {
for (const id of old) {
await db.prepare('DELETE FROM requests WHERE run_id = ?').run(id);
++ await db.prepare('DELETE FROM run_advisor_uses WHERE run_id = ? OR advice_run_id = ?').run(id, id);
await db.prepare('DELETE FROM citations WHERE run_id = ?').run(id);
await db.prepare('DELETE FROM runs WHERE id = ?').run(id);
}
@@@ -1377,7 +1537,7 @@ function createRuns({ choices = null, db, registry, engine, cache, quotas, confi
/** Queue depth for /metrics and /api/ready: runs waiting for a slot, and runs holding one. */
function stats() { return { queued: queue.length, running: active, inflight: inflight.size, max_concurrent: config.runs.maxConcurrent, max_queued: config.runs.maxQueued }; }
-- return { create, wait, get, getReceipt, list, listOnBehalfOf, cancel, retry, citations, addCitations, requestsFor, explained, recoverInterrupted, prune, drain, stats, inflight, TERMINAL, decode, completeBatch, deferred };
++ return { create, wait, get, getReceipt, list, listOnBehalfOf, cancel, retry, advise, citations, addCitations, requestsFor, explained, recoverInterrupted, prune, drain, stats, inflight, TERMINAL, decode, completeBatch, deferred };
}
module.exports = { createRuns, entityKey, subjectKey };
ddiff --git a/test/advisor.test.js b/test/advisor.test.js
nnew file mode 100644
iindex 0000000..cfd1b31
---- /dev/null
++++ b/test/advisor.test.js
@@@ -0,0 +1,145 @@
++'use strict';
++const assert = require('assert');
++const { suite, boot, request, token, ALL } = require('./helpers');
++const advisor = require('../server/advisor');
++
++const ULID = '01JQ8Z3K4M5N6P7Q8R9S0T1V2W';
++const appToken = (project, app, cap = ['ai.app.run']) => token('app', cap, { sub: `app:app_${app}`, actorType: 'app', ns: [project, `app.${project}.*`], extra: { project_id: project, env: 'production' } });
++
++const t = suite('advisor');
++const messages = [{ role: 'user', content: 'Change the failing test and run it.' }];
++
++async function startRun(h, tok, body) {
++ const res = await request(h.base, 'POST', '/api/v1/runs?wait=5000', { tok, body: { workflow: 'ai.complete', input: { messages }, ...body } });
++ assert.ok([200, 201].includes(res.status), `${res.status} ${res.text}`);
++ return res.body.run;
++}
++
++t.test('the advisor is off unless the run asks; caps and workflow are refused before a run exists', async () => {
++ const h = await boot();
++ const tok = token('live', ALL);
++ try {
++ const plain = await startRun(h, tok);
++ assert.strictEqual((await request(h.base, 'POST', `/api/v1/runs/${plain.id}/advice`, { tok, body: { transcript: messages } })).status, 403);
++ for (const advisor of [false, null, undefined]) {
++ const run = await startRun(h, tok, { advisor });
++ assert.strictEqual((await request(h.base, 'POST', `/api/v1/runs/${run.id}/advice`, { tok, body: { transcript: messages } })).status, 403);
++ }
++ assert.strictEqual((await request(h.base, 'POST', '/api/v1/runs', { tok, body: { workflow: 'ai.complete', input: { messages }, advisor: { max_uses: 4 } } })).status, 422);
++ assert.strictEqual((await request(h.base, 'POST', '/api/v1/runs', { tok, body: { workflow: 'ai.complete', input: { messages }, advisor: { max_words: 101 } } })).status, 422);
++ assert.strictEqual((await request(h.base, 'POST', '/api/v1/runs', { tok, body: { workflow: 'ai.complete', input: { messages }, advisor: { effort: 'auto' } } })).status, 422);
++ assert.strictEqual((await request(h.base, 'POST', '/api/v1/runs', { tok, body: { workflow: 'ai.generate', input: { prompt: 'x' }, advisor: true } })).status, 422);
++ const top = await startRun(h, tok, { advisor: true, options: { advisor: false } });
++ assert.match(top.id, /^run_/, 'top-level advisor wins over options');
++ } finally { await h.stop(); }
++});
++
++t.test('one advice call makes a run, cuts the advice to the cap, and lands on the run explain and receipt', async () => {
++ const h = await boot();
++ const tok = token('live', ALL);
++ try {
++ const run = await startRun(h, tok, { advisor: { max_uses: 3, max_words: 40, model: 'stub/stub-1', effort: 'low' }, effort: 'low' });
++ const row = await h.db.prepare('SELECT options FROM runs WHERE id = ?').get(run.id);
++ const policy = JSON.parse(row.options).advisor_policy;
++ assert.deepStrictEqual({ enabled: policy.enabled, max_uses: policy.max_uses, max_words: policy.max_words, model: policy.model }, { enabled: true, max_uses: 3, max_words: 40, model: 'stub/stub-1' });
++ const res = await request(h.base, 'POST', `/api/v1/runs/${run.id}/advice`, { tok, body: { transcript: [{ role: 'system', content: 'You are a coding agent.' }, ...messages, { role: 'tool', content: 'exit 1: expected 2' }], question: 'What now?' } });
++ assert.strictEqual(res.status, 201, res.text);
++ assert.ok(res.body.advice.length > 0);
++ assert.ok(res.body.advice.trim().split(/\s+/).length <= 40, `advice is cut to max_words: ${res.body.advice}`);
++ assert.strictEqual(res.body.use, 1);
++ assert.strictEqual(res.body.max_uses, 3);
++ assert.strictEqual(res.body.model, 'stub/stub-1');
++ assert.strictEqual(res.body.effort, 'low');
++ assert.match(res.body.advice_run_id, /^run_/);
++ const adviceRun = await h.db.prepare('SELECT workflow_key, status, options FROM runs WHERE id = ?').get(res.body.advice_run_id);
++ assert.strictEqual(adviceRun.workflow_key, 'ai.chat');
++ assert.strictEqual(adviceRun.status, 'succeeded');
++ assert.strictEqual(JSON.parse(adviceRun.options).effort_policy.effort_requested, 'low');
++ const read = await request(h.base, 'GET', `/api/v1/runs/${run.id}`, { tok });
++ assert.strictEqual(read.status, 200, read.text);
++ assert.strictEqual(read.body.run.explain.advisor.count, 1);
++ assert.strictEqual(read.body.run.explain.advisor.max_words, 40);
++ assert.strictEqual(read.body.run.explain.advisor.cost_usd, res.body.usage.cost_usd);
++ assert.strictEqual(read.body.run.explain.advisor.uses[0].advice_run_id, res.body.advice_run_id);
++ const receipt = await request(h.base, 'GET', `/api/v1/receipts/${res.body.receipt_id}`, { tok });
++ assert.strictEqual(receipt.status, 200, receipt.text);
++ assert.strictEqual(receipt.body.receipt.advisor.count, 1);
++ assert.strictEqual(receipt.body.receipt.advisor.uses[0].model, 'stub/stub-1');
++ assert.strictEqual(receipt.body.receipt.advisor.cost_usd, res.body.usage.cost_usd);
++ } finally { await h.stop(); }
++});
++
++t.test('at most max_uses per run; a fourth call is refused and the owner check holds', async () => {
++ const h = await boot();
++ const tok = token('live', ALL);
++ const other = token('other', ALL);
++ try {
++ const run = await startRun(h, tok, { advisor: { max_uses: 3 } });
++ const plain = token('other', ['ai.run.create', 'ai.run.read']);
++ assert.strictEqual((await request(h.base, 'POST', `/api/v1/runs/${run.id}/advice`, { tok: plain, body: { transcript: messages } })).status, 404);
++ for (let i = 1; i <= 3; i++) {
++ const res = await request(h.base, 'POST', `/api/v1/runs/${run.id}/advice`, { tok, body: { transcript: messages } });
++ assert.strictEqual(res.status, 201, res.text);
++ assert.strictEqual(res.body.use, i);
++ }
++ const fourth = await request(h.base, 'POST', `/api/v1/runs/${run.id}/advice`, { tok, body: { transcript: messages } });
++ assert.strictEqual(fourth.status, 409, fourth.text);
++ assert.strictEqual(fourth.body.code, 'advisor.exhausted');
++ const uses = await h.db.prepare('SELECT COUNT(*)::int n, SUM(cost_usd) c FROM run_advisor_uses WHERE run_id = ?').get(run.id);
++ assert.strictEqual(uses.n, 3);
++ const read = await request(h.base, 'GET', `/api/v1/runs/${run.id}`, { tok });
++ assert.strictEqual(read.body.run.explain.advisor.count, 3);
++ assert.strictEqual(read.body.run.explain.advisor.cost_usd, Math.round(Number(uses.c) * 1e8) / 1e8);
++ } finally { await h.stop(); }
++});
++
++t.test('a named model the route does not serve and a transcript that is not messages are 422', async () => {
++ const h = await boot();
++ const tok = token('live', ALL);
++ try {
++ const run = await startRun(h, tok, { advisor: true });
++ assert.strictEqual((await request(h.base, 'POST', `/api/v1/runs/${run.id}/advice`, { tok, body: { transcript: messages, model: 'nobody/nothing' } })).status, 422);
++ assert.strictEqual((await request(h.base, 'POST', `/api/v1/runs/${run.id}/advice`, { tok, body: { transcript: [] } })).status, 422);
++ assert.strictEqual((await request(h.base, 'POST', `/api/v1/runs/${run.id}/advice`, { tok, body: { transcript: [{ role: 'wizard', content: 'x' }] } })).status, 422);
++ assert.strictEqual((await request(h.base, 'POST', `/api/v1/runs/${run.id}/advice`, { tok, body: { transcript: messages, max_words: 101 } })).status, 422);
++ assert.strictEqual((await request(h.base, 'POST', `/api/v1/runs/${run.id}/advice`, { tok, body: { transcript: messages, max_words: 5 } })).status, 422);
++ assert.strictEqual((await request(h.base, 'POST', `/api/v1/runs/${run.id}/advice`, { tok, body: { transcript: messages, effort: 'auto' } })).status, 422);
++ assert.strictEqual((await request(h.base, 'POST', `/api/v1/runs/${run.id}/advice`, { tok, body: { transcript: messages } })).status, 201);
++ } finally { await h.stop(); }
++});
++
++t.test('a developer app may ask advice on its own run, never on another project\'s', async () => {
++ const h = await boot();
++ const prj = `prj_${ULID}`;
++ const app = appToken(prj, ULID);
++ const other = appToken(`prj_${'01JQ8Z3K4M5N6P7Q8R9S0T1V2X'}`, '01JQ8Z3K4M5N6P7Q8R9S0T1V2X');
++ try {
++ const res = await request(h.base, 'POST', '/v1/chat/completions?wait=5000', { tok: app, body: { model: 'openvibe', messages, advisor: true, effort: 'low' } });
++ assert.strictEqual(res.status, 200, res.text);
++ const runId = res.headers.get('x-openvibe-run-id');
++ assert.match(runId, /^run_/);
++ assert.strictEqual((await request(h.base, 'POST', `/api/v1/runs/${runId}/advice`, { tok: other, body: { transcript: messages } })).status, 404);
++ const advice = await request(h.base, 'POST', `/api/v1/runs/${runId}/advice`, { tok: app, body: { transcript: messages } });
++ assert.strictEqual(advice.status, 201, advice.text);
++ assert.strictEqual(advice.body.use, 1);
++ const read = await request(h.base, 'GET', `/api/v1/runs/${runId}`, { tok: app });
++ assert.strictEqual(read.status, 200, read.text);
++ assert.strictEqual(read.body.run.explain.advisor.count, 1);
++ } finally { await h.stop(); }
++});
++
++t.test('policy parsing: defaults, compat option, and the 3/100 ceilings', async () => {
++ const wf = { key: 'ai.complete' };
++ assert.deepStrictEqual(advisor.policy({}, wf), { enabled: false });
++ assert.deepStrictEqual(advisor.policy({ advisor: true }, wf), { enabled: true, max_uses: 3, max_words: 100, effort: 'high' });
++ assert.deepStrictEqual(advisor.policy({ options: { advisor: { model: 'deepseek/deepseek-v4-pro', effort: 'max', max_uses: 1, max_words: 20 } } }, wf), { enabled: true, max_uses: 1, max_words: 20, effort: 'max', model: 'deepseek/deepseek-v4-pro' });
++ assert.throws(() => advisor.policy({ advisor: { max_uses: 0 } }, wf), /1 to 3/);
++ assert.throws(() => advisor.policy({ advisor: { max_words: 9 } }, wf), /10 to 100/);
++ assert.throws(() => advisor.policy({ advisor: { effort: 'auto' } }, wf), /low\|medium\|high\|max/);
++ assert.throws(() => advisor.policy({ advisor: { extra: true } }, wf), /no field extra/);
++ assert.throws(() => advisor.policy({ advisor: true }, { key: 'live.chat_reply' }), /available on/);
++ assert.deepStrictEqual(advisor.words('one two three four', 3), { text: 'one two three', count: 3 });
++ assert.deepStrictEqual(advisor.words('one two', 3), { text: 'one two', count: 2 });
++});
++
++t.run();