openvibers/openvibe.exchange Commits

Exchange: route companion turns through Actor (#197)

OP OpenVibers committed 2 days ago · 9ad758b4ba · 13 files, +572 −95

* Exchange's companions answer through OpenVibe.Actor (one engine, first slice)

A companion and a group answer are Actor turns now, not direct calls to
OpenVibe.AI. The page, its words and its look do not change; the answer,
its cost line and its errors come from Actor.

server/actor.js is the client: it posts the turn to Actor's task API
with the signed-in person's own bearer token on the wire (never a service
token standing in for the person), names the conversation the turn
answers, and follows the task until it ends. A turn that is refused, fails
or is cancelled throws with the task's own code and detail, so the chat
shows those words. A turn still running when the wait ends holds its
place and is reconciled on the next view. There is no second engine
behind this: the old direct path is gone.

The notes a companion takes are a turn of their own now, on the same
allowance.

The 18+ tier has no Actor route yet: an adult card keeps the gate above
and the floor check below, and answers through Actor on the standard
tier.

* An adult companion asks for the uncensored tier, as it did before

The 18+ tier no longer narrows. An adult card asks for the 'uncensored'
tier on its turn, exactly as it asked OpenVibe.AI for the uncensored
policy before, and OpenVibe.AI decides it: it checks that person's 18+
confirmation on their account and refuses the tier without it, in words.

Exchange's own check is the one it always used - the person's account
confirmation, read through server/companions/adult.js - and it fails
closed. A person who has not confirmed never reaches the card, so no
turn of theirs can ask for the adult tier. The floor check on the answer
is unchanged and still holds.

A group answer asks for the tier per character, as before: an adult
card in a group asks for it when the person confirmed, an everyday card
never does.

* Exchange: merge one-engine companions onto main

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 5c9d213..2af2689 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,7 @@
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"multer": "^2.5.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.403.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",
@@@ -1268,9 +1268,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.399.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
-- "integrity": "sha512-7QqSrMH2BT6ZOJu+xxusjEPICaVKGrr+3ij3tg0leE3sy4jfPwvWVTwaXZSCPM1feoP7heUDnSO4/9QBmjguJw==",
++ "version": "0.403.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.403.0",
++ "integrity": "sha512-1dek3RyBl+nho5nbm1YVqjVdHzhbIsS8kpz1HfHlbahvdOLpvHe8bl9zs73U4iKE6sspSjXy2c3HwW52pDVUAQ==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex b5825bf..74de141 100644
---- a/package.json
++++ b/package.json
@@@ -25,7 +25,7 @@
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"multer": "^2.5.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.399.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.403.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.147.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",
ddiff --git a/server/actor.js b/server/actor.js
nnew file mode 100644
iindex 0000000..1be31dc
---- /dev/null
++++ b/server/actor.js
@@@ -0,0 +1,115 @@
++'use strict';
++/**
++ * OpenVibe.Actor, read from OpenVibe.Exchange.
++ *
++ * turn({...}) POST /api/v1/tasks with the person's own Bearer, then GET /api/v1/tasks/:id until the
++ * turn ends. The turn is the person's (their allowance, their Actor task list); Exchange names
++ * the conversation and the tools the run may call, and reads the answer, the cost and the error
++ * back from the task. When Actor is down the turn fails here in plain words: it never falls back
++ * to OpenVibe.AI or any other engine.
++ * read({...}) GET /api/v1/tasks/:id with the person's Bearer (a pending turn, reconciled on the next view).
++ *
++ * A turn the model cannot answer (refused, failed, cancelled) throws a TurnError whose code and detail are
++ * the task's own: the product shows those words. A turn still running when the wait ends resolves
++ * { pending: true, taskId } instead of throwing, so the product can reconcile it on the next view.
++ */
++const trim = (v) => String(v == null ? '' : v).replace(/\/+$/, '');
++
++class TurnError extends Error {
++ constructor(status, code, detail, taskId = null) {
++ super(String(detail || code || 'the turn failed').slice(0, 500));
++ this.name = 'TurnError';
++ this.status = status;
++ this.code = code;
++ this.detail = String(detail || code || 'the turn failed').slice(0, 500);
++ this.taskId = taskId;
++ }
++}
++
++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
++
++function createActor({ config, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() } = {}) {
++ const a = (config && config.actor) || {};
++ const base = trim(a.url || 'https://actor.openvibe.services');
++ const timeoutMs = Math.max(2000, Number(a.timeoutMs) || 15_000);
++ const waitMs = Math.max(5000, Number(a.waitMs) || 90_000);
++ const pollMs = Math.max(500, Number(a.pollMs) || 2000);
++
++ async function jsonOf(res) {
++ const text = await res.text();
++ try { return text ? JSON.parse(text) : null; } catch (e) { return null; }
++ }
++
++ async function call(method, path, token, body) {
++ let res;
++ try {
++ res = await fetchImpl(`${base}${path}`, {
++ method,
++ headers: { Accept: 'application/json', Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
++ ...(body ? { body: JSON.stringify(body) } : {}),
++ signal: AbortSignal.timeout(timeoutMs),
++ });
++ } catch (err) {
++ throw new TurnError(503, 'actor.unreachable', 'OpenVibe.Actor could not be reached just now. Nothing was charged. Try again in a moment.');
++ }
++ const out = await jsonOf(res);
++ if (res.status === 401 || res.status === 403) {
++ throw new TurnError(res.status, (out && out.code) || 'actor.not_allowed', 'The answer needs a fresh sign-in. Sign in again and send once more.');
++ }
++ if (!res.ok || !out) {
++ throw new TurnError(res.status, (out && out.code) || 'actor.failed', (out && (out.detail || out.title)) || `OpenVibe.Actor answered ${res.status}`);
++ }
++ return out;
++ }
++
++ /** One conversation turn answered by Actor. The person's Bearer goes on the wire, nobody else's. */
++ async function turn({ token, task, model = null, tier = null, budgetUsd, conversation, idempotencyKey, tools = [], poll = true, waitMs: turnWaitMs = null } = {}) {
++ const limitMs = turnWaitMs != null ? Math.max(5000, Number(turnWaitMs)) : waitMs;
++ if (!token) throw new TurnError(401, 'actor.not_allowed', 'The answer needs a fresh sign-in. Sign in again and send once more.');
++ const started = now();
++ const created = await call('POST', '/api/v1/tasks', token, {
++ task: String(task),
++ mode: 'cheapest',
++ ...(model ? { model: String(model).slice(0, 256) } : {}),
++ // The content tier (the 18+ one). Actor refuses it for a task that names nobody, and OpenVibe.AI checks
++ // the person's 18+ confirmation before it answers on that tier.
++ ...(tier ? { tier: String(tier).slice(0, 40) } : {}),
++ ...(budgetUsd != null ? { budget: { per_task_usd: Number(budgetUsd) } } : {}),
++ ...(idempotencyKey ? { idempotency_key: String(idempotencyKey) } : {}),
++ ...(conversation ? { conversation } : {}),
++ ...(tools ? { tools } : {}),
++ });
++ const taskId = created && created.id;
++ if (!taskId) throw new TurnError(502, 'actor.failed', 'OpenVibe.Actor answered without a task. Nothing was charged.');
++ if (!poll) return { pending: true, taskId };
++ for (;;) {
++ const t = await call('GET', `/api/v1/tasks/${encodeURIComponent(taskId)}`, token);
++ if (t.state !== 'queued' && t.state !== 'running') {
++ if (t.state === 'succeeded') {
++ const result = t.result || {};
++ return {
++ pending: false, taskId,
++ answer: typeof result.answer === 'string' && result.answer.trim() ? result.answer : '(no text)',
++ agent: result.agent || null, model: result.model || null,
++ costUsd: t.cost && t.cost.usd != null ? Number(t.cost.usd) : null,
++ explanation: Array.isArray(t.explanation) ? t.explanation : [],
++ latencyMs: now() - started,
++ };
++ }
++ const err = t.error || {};
++ throw new TurnError(502, err.code || `actor.task.${t.state}`, err.detail || `The turn ${t.state}. Nothing more was charged.`, taskId);
++ }
++ if (now() - started > limitMs) return { pending: true, taskId };
++ await sleep(pollMs);
++ }
++ }
++
++ /** Read a task back (a pending turn, reconciled on the next view). */
++ async function read({ token, taskId }) {
++ return call('GET', `/api/v1/tasks/${encodeURIComponent(String(taskId))}`, token);
++ }
++
++ return { turn, read, TurnError, configured: Boolean(base) };
++}
++
++module.exports = { createActor, TurnError };
ddiff --git a/server/app.js b/server/app.js
iindex 771ab29..dea91e7 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -28,6 +28,7 @@ const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
const { createAi } = require('./ai');
++const { createActor } = require('./actor');
const { createNetwork } = require('./network');
const { createBench } = require('./bench');
const { createCompare } = require('./compare');
@@@ -84,6 +85,7 @@ async function createApp(opts = {}) {
const principal = createPrincipal({ config, keys });
const ai = opts.ai || createAi({ config, fetchImpl: opts.aiFetchImpl || globalThis.fetch, log, now: s.now });
++ const actor = opts.actor || createActor({ config, fetchImpl: opts.actorFetchImpl || globalThis.fetch, log, now: s.now });
// Network, read and changed with the person's own token: their machines and their machine pools.
const network = opts.network || createNetwork({ config, fetchImpl, log });
const bench = opts.bench || createBench({ config, fetchImpl: opts.benchFetchImpl || globalThis.fetch, log, now: s.now });
@@@ -111,7 +113,7 @@ async function createApp(opts = {}) {
const notes = createCompanionNotes({ db: s.db, memory, config, log });
const lorebooks = createLorebooks({ db: s.db, config, log });
const locker = createLocker({ config, fetchImpl, log });
-- const noteTaker = createNoteTaker({ ai, notes, config, log });
++ const noteTaker = createNoteTaker({ actor, notes, config, log });
// Account export and deletion (ADR-033). The sender posts to Network's internal routes with this service's own
// client-credentials token; a test injects a stand-in through opts.accountSend.
@@@ -121,7 +123,7 @@ async function createApp(opts = {}) {
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Exchange cannot answer account events'); });
const openapi = createOpenApi({ config, fetchImpl: opts.openapiFetch || globalThis.fetch, log });
-- const ctx = { config, s, keys, sso, principal, ai, network, bench, compare, analysis, arenaStore, history, openapi, chats, adult, adultAccount, characters, companionChats, groups, voice, sharing, accountData, accountSend, memory, notes, lorebooks, locker, noteTaker, indexnow, log };
++ const ctx = { config, s, keys, sso, principal, ai, actor, network, bench, compare, analysis, arenaStore, history, openapi, chats, adult, adultAccount, characters, companionChats, groups, voice, sharing, accountData, accountSend, memory, notes, lorebooks, locker, noteTaker, indexnow, log };
const app = express();
app.disable('x-powered-by');
ddiff --git a/server/companions/taking-notes.js b/server/companions/taking-notes.js
iindex 21931db..dec2842 100644
---- a/server/companions/taking-notes.js
++++ b/server/companions/taking-notes.js
@@@ -64,9 +64,8 @@ function transcriptOf(turns, max = 2400) {
return text.length > max ? text.slice(text.length - max) : text;
}
--function createNoteTaker({ ai, notes, config, log = console } = {}) {
++function createNoteTaker({ actor, notes, config, log = console } = {}) {
const every = Math.max(1, Number((config && config.companions && config.companions.notesEveryTurns) || 4));
-- const model = String((config && config.companions && config.companions.noteModel) || 'openvibe/economy');
const maxCost = (config && config.companions && config.companions.maxCostUsd) || 0.05;
/**
@@@ -84,24 +83,24 @@ function createNoteTaker({ ai, notes, config, log = console } = {}) {
async function take({ owner, characterId, token, turns, protect = null }) {
const transcript = transcriptOf(turns);
if (!transcript.trim()) return { notes: [] };
-- let out = null;
++ // Notes are a turn of their own, answered with no tools: best effort, a short wait, skipped on failure.
++ let answer = null;
try {
-- out = await ai.chat({
-- model,
-- messages: [
-- { role: 'system', content: SYSTEM },
-- { role: 'user', content: transcript },
-- ],
-- subject: owner,
-- policy: 'standard',
-- max_cost_usd: maxCost,
-- maxTokens: 600,
++ const out = await actor.turn({
++ token,
++ task: `${SYSTEM}\n\nThe conversation so far:\n${transcript}`,
++ budgetUsd: maxCost,
++ conversation: { product: 'exchange', id: `exchange-notes:${characterId}` },
++ tools: [],
++ waitMs: 25_000,
});
++ if (!out.pending) answer = out.answer;
} catch (err) {
log.warn('[Companions] the notes were not taken:', String((err && err.message) || err).slice(0, 200));
return { notes: [] };
}
-- const parsed = parseNotes(out && out.content);
++ if (!answer) return { notes: [] };
++ const parsed = parseNotes(answer);
const written = [];
for (const note of parsed) {
try {
@@@ -113,7 +112,7 @@ function createNoteTaker({ ai, notes, config, log = console } = {}) {
return { notes: written };
}
-- return { take, due, parseNotes, transcriptOf, every, model };
++ return { take, due, parseNotes, transcriptOf, every };
}
module.exports = { createNoteTaker, parseNotes, transcriptOf, MAX_NOTES };
ddiff --git a/server/config.js b/server/config.js
iindex c884651..ae84c83 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -73,6 +73,16 @@ function load(env = process.env) {
waitMs: Math.max(1000, int(env.EXCHANGE_AI_WAIT_MS, 25_000)),
},
++ // OpenVibe.Actor (server/actor.js): a companion or group turn is an Actor task of the signed-in
++ // person — their own Bearer on the wire, their allowance. The wait bounds one page POST; a turn still
++ // running then is reconciled on the next view.
++ actor: {
++ url: trim(env.OV_ACTOR_URL || 'https://actor.openvibe.services'),
++ timeoutMs: Math.max(2000, int(env.EXCHANGE_ACTOR_TIMEOUT_MS, 15_000)),
++ waitMs: Math.max(5000, int(env.EXCHANGE_ACTOR_WAIT_MS, 90_000)),
++ pollMs: Math.max(500, int(env.EXCHANGE_ACTOR_POLL_MS, 2000)),
++ },
++
// OpenVibe.Bench (server/bench.js): the Arena's rounds and each person's vote are a Bench
// human_pairwise judgement, and the rankings read the board by task category. The token is this
// service's own client-credentials grant (bench.round.create, bench.judgement.cast, bench.board.read).
ddiff --git a/server/http/companions.js b/server/http/companions.js
iindex 203aa4e..07c505c 100644
---- a/server/http/companions.js
++++ b/server/http/companions.js
@@@ -40,7 +40,7 @@ const handleOf = (name) => String(name || 'someone').toLowerCase().replace(/[^a-
const tagsOf = (s) => String(s || '').split(',').map((t) => t.trim().toLowerCase()).filter(Boolean).slice(0, 5);
function createCompanionRoutes(ctx) {
-- const { config, ai, adult, adultAccount, characters, companionChats, groups, voice, sharing, memory, notes, lorebooks, locker, noteTaker, log } = ctx;
++ const { config, ai, actor, adult, adultAccount, characters, companionChats, groups, voice, sharing, memory, notes, lorebooks, locker, noteTaker, log } = ctx;
const db = ctx.s && ctx.s.db;
const r = asyncRouter();
// A card may arrive as a file (a PNG with the card inside, or plain JSON).
@@@ -988,6 +988,8 @@ function createCompanionRoutes(ctx) {
}
async function renderChat(req, res, chat, card, { problem = null, status = 200 } = {}) {
++ // A turn still running at the last send fills its answer in here, from its task.
++ await reconcilePending(req, chat, { table: 'chat' });
const messages = await companionChats.messages(chat.id);
const query = req.query || {};
const turns = readTurns(messages, query);
@@@ -1067,6 +1069,93 @@ function createCompanionRoutes(ctx) {
return out;
}
++ /**
++ * One conversation turn for OpenVibe.Actor, in plain words: the character's system, the conversation so
++ * far, and the person's new words. The page, the assembly and the words do not change — only the engine
++ * reading them does.
++ */
++ function turnTextFor({ system, history, userText }) {
++ const lines = [String(system || ''), '', 'The conversation so far:'];
++ for (const m of history || []) {
++ const text = String((m && m.content) == null ? '' : m.content);
++ if (!text) continue;
++ lines.push((m.role === 'assistant' ? 'The companion: ' : 'The person: ') + text);
++ }
++ lines.push('', 'The person now says:', String(userText || ''));
++ return lines.join('\n').slice(0, 60000);
++ }
++
++ /** The receipt meta of an Actor answer: its task, its model, its cost, what it saw. */
++ function actorMeta(out, { settings, model, saw, sealed }) {
++ const reasons = out.explanation && out.explanation.length && Array.isArray(out.explanation[0].reasons)
++ ? out.explanation[0].reasons : ['Answered by OpenVibe.Actor on your allowance.'];
++ return {
++ actor_task: out.taskId, provider: 'actor', answered_model: out.model || model || 'OpenVibe',
++ cost_usd: out.costUsd, latency_ms: out.latencyMs, why: reasons,
++ sealed: sealed === true,
++ saw: { ...saw, sealed: sealed === true, model: out.model || model },
++ };
++ }
++
++ /** An Actor failure as the words the chat shows: the task's own refusal, or the outage line. */
++ function actorWords(err) {
++ if (err && err.code === 'actor.unreachable') return 'OpenVibe.Actor could not be reached just now. Nothing was charged, and the words stayed here. Try again in a moment.';
++ if (err && err.code === 'actor.not_allowed') return 'The answer needs a fresh sign-in. Sign in again and send once more.';
++ const detail = err && (err.detail || err.message);
++ if (detail) return String(detail).slice(0, 500);
++ return 'The answer did not come back. Nothing more was charged. Try again in a moment.';
++ }
++
++ /**
++ * A turn still running when the page's wait ended is stored pending, with these words; the next view
++ * (or the next send) reconciles it: a finished task fills the answer in, a failed one its words.
++ */
++ async function reconcilePending(req, chat, { table, idColumn }) {
++ const token = tokenOf(req);
++ if (!token) return;
++ const messages = table === 'group'
++ ? await groups.messages(chat.id)
++ : await companionChats.messages(chat.id);
++ const pending = messages.filter((m) => m.role === 'assistant' && m.meta && m.meta.pending_actor_task);
++ for (const m of pending) {
++ const taskId = String(m.meta.pending_actor_task);
++ let t = null;
++ try {
++ t = await actor.read({ token, taskId });
++ } catch (err) {
++ log.warn('[Companions] the pending turn could not be read:', String((err && err.message) || err).slice(0, 200));
++ continue;
++ }
++ if (t.state === 'queued' || t.state === 'running') continue;
++ const meta = { ...(m.meta || {}) };
++ delete meta.pending_actor_task;
++ let content = m.content;
++ if (t.state === 'succeeded' && t.result && typeof t.result.answer === 'string' && t.result.answer.trim()) {
++ const answer = t.result.answer;
++ const check = floor.check(answer);
++ if (check.ok) {
++ content = answer;
++ meta.actor_task = taskId;
++ meta.answered_model = t.result.model || meta.answered_model || 'OpenVibe';
++ meta.cost_usd = t.cost && t.cost.usd != null ? Number(t.cost.usd) : null;
++ const reasons = t.explanation && t.explanation.length && Array.isArray(t.explanation[0].reasons) ? t.explanation[0].reasons : null;
++ if (reasons) meta.why = reasons;
++ } else {
++ content = null;
++ meta.error = 'floor';
++ }
++ } else {
++ const err = t.error || {};
++ content = actorWords({ code: err.code, detail: err.detail });
++ meta.error = err.code || `actor.task.${t.state}`;
++ meta.actor_task = taskId;
++ }
++ const name = table === 'group' ? 'exchange_group_messages' : 'exchange_companion_messages';
++ await db.prepare(`UPDATE ${name} SET content = ?, meta = ? WHERE id = ? AND owner = ?`)
++ .run(content, JSON.stringify(meta), m.id, owner(req));
++ }
++ }
++
async function answerTurn(req, res, chat, card, { userMessage, turnId, variant, plainText = null, lockerBundle = null, keys = null }) {
const subject = owner(req);
const token = tokenOf(req);
@@@ -1121,19 +1210,50 @@ function createCompanionRoutes(ctx) {
notes: notesSaw, notes_used_tokens: noteResult.usedTokens, notes_budget_tokens: noteResult.budgetTokens,
lorebook: entriesSaw, lorebook_used_tokens: entryResult.usedTokens, lorebook_budget_tokens: entryResult.budgetTokens,
};
-- const adultOk = await isAdult(req);
-- const model = await modelIdOf(settings.model || card.model);
++ // The 18+ tier rides the turn (one-engine slice): an adult card asked OpenVibe.AI for the uncensored policy
++ // before, and it asks the same of Actor now. The check is the same one Exchange always used — the person's
++ // own account confirmation — and it fails closed. The floor check below is unchanged and always holds.
++ // The header's temperature and preset stay saved on the chat; sampling is the loop's until a later slice
++ // carries them. A picked model rides along; with none picked the call names none.
++ const picked = settings.model || card.model || null;
++ const model = picked ? await modelIdOf(picked) : null;
++ const adultOk = card.rating === 'adult' ? await isAdult(req) : false;
++ const tier = adultOk ? 'uncensored' : 'standard';
++ await reconcilePending(req, chat, { table: 'chat' });
++ let out = null;
try {
-- const out = await ai.chat({
-- model,
-- messages: [{ role: 'system', content: system }].concat(asked),
-- temperature: settings.temperature,
-- subject,
-- policy: card.rating === 'adult' && adultOk ? 'uncensored' : 'standard',
-- preset: settings.preset || null,
-- max_cost_usd: config.companions.maxCostUsd,
++ out = await actor.turn({
++ token,
++ task: turnTextFor({ system, history, userText }),
++ ...(model ? { model } : {}),
++ ...(tier === 'uncensored' ? { tier } : {}),
++ budgetUsd: config.companions.maxCostUsd,
++ conversation: { product: 'exchange', id: `companion-chat:${chat.id}/turn:${turnId}/v${variant}` },
++ idempotencyKey: `exchange-companion-${chat.id}-${turnId}-v${variant}`,
++ tools: [],
++ });
++ } catch (err) {
++ // A refusal or an outage is words in the chat, never a silent second engine.
++ await companionChats.append({
++ owner: subject, chatId: chat.id, role: 'assistant', content: actorWords(err),
++ turnId, variant, parentId: userMessage.id, model: settings.model || null,
++ meta: { error: (err && err.code) || 'actor.failed', actor_task: (err && err.taskId) || null, sealed: locked },
++ });
++ return res.redirect(303, '/talk/' + chat.id);
++ }
++ if (out.pending) {
++ // Still running when the page's wait ended: these words hold its place, and the next view fills
++ // the answer in from the task.
++ await companionChats.append({
++ owner: subject, chatId: chat.id, role: 'assistant',
++ content: 'The answer is taking longer than usual — it will appear here by itself. Refresh in a bit.',
++ turnId, variant, parentId: userMessage.id, model: settings.model || null,
++ meta: { pending_actor_task: out.taskId, sealed: locked, saw: { ...sawBase, model, system: locked ? null : system, context: locked ? [] : asked } },
});
-- const answer = out.content || '(no text)';
++ return res.redirect(303, '/talk/' + chat.id);
++ }
++ {
++ const answer = out.answer;
const check = floor.check(answer);
if (!check.ok) {
// The answer breaks the floor: it is never delivered, the chat ends, the account is reviewed.
@@@ -1147,12 +1267,10 @@ function createCompanionRoutes(ctx) {
return renderChat(req, res, chat, card, { problem: 'That answer broke the hard floor, so it was not delivered. The chat has ended and the account is reviewed.', status: 403 });
}
const stored = locked ? await locker.seal(answer, keys) : answer;
-- const answerMeta = {
-- run_id: out.run_id, provider: out.provider, answered_model: out.model, cost_usd: out.cost_usd,
-- tokens_in: out.tokens_in, tokens_out: out.tokens_out, latency_ms: out.latency_ms, why: out.why,
-- sealed: locked,
-- saw: { ...sawBase, model: out.model || model, system: locked ? null : system, context: locked ? [] : asked },
-- };
++ const answerMeta = actorMeta(out, {
++ settings, model, sealed: locked,
++ saw: { ...sawBase, system: locked ? null : system, context: locked ? [] : asked },
++ });
const answerId = await companionChats.append({
owner: subject, chatId: chat.id, role: 'assistant', content: stored,
turnId, variant, parentId: userMessage.id, model: settings.model || null,
@@@ -1176,12 +1294,6 @@ function createCompanionRoutes(ctx) {
} catch (err) {
log.warn('[Companions] the notes were not taken:', String((err && err.message) || err).slice(0, 200));
}
-- } catch (err) {
-- await companionChats.append({
-- owner: subject, chatId: chat.id, role: 'assistant', content: null,
-- turnId, variant, parentId: userMessage.id, model: settings.model || null,
-- meta: { error: String(err.message || 'the model did not answer').slice(0, 400), code: err.code || null },
-- });
}
return res.redirect(303, '/talk/' + chat.id);
}
@@@ -1438,8 +1550,12 @@ function createCompanionRoutes(ctx) {
const names = {};
for (const c of cards) names[c.id] = c.name;
const token = tokenOf(req);
-- const adultOk = await isAdult(req);
-- const model = await modelIdOf(group.model || (cards[0] && cards[0].model));
++ // The 18+ tier rides the turn (one-engine slice): like a one-character chat, a group answer asks for the
++ // uncensored policy exactly when the card is an adult one and the person confirmed it on their account.
++ const picked = group.model || (cards[0] && cards[0].model) || null;
++ const model = picked ? await modelIdOf(picked) : null;
++ const groupAdultOk = await isAdult(req);
++ await reconcilePending(req, group, { table: 'group' });
const turnId = ctx.s.newId('ext');
const userMessageId = await groups.append({ owner: owner(req), groupId: group.id, role: 'user', content: text, turnId, seq: 0 });
let seq = 1;
@@@ -1454,19 +1570,26 @@ function createCompanionRoutes(ctx) {
const asked = groupContext(history, card.id, names).concat([{ role: 'user', content: text }]);
let out = null;
try {
-- out = await ai.chat({
-- model,
-- messages: [{ role: 'system', content: system }].concat(asked),
-- temperature: group.temperature,
-- subject: owner(req),
-- policy: card.rating === 'adult' && adultOk ? 'uncensored' : 'standard',
-- preset: group.preset || null,
-- max_cost_usd: config.companions.maxCostUsd,
++ out = await actor.turn({
++ token,
++ task: turnTextFor({ system, history: asked.slice(0, -1), userText: text }),
++ ...(model ? { model } : {}),
++ ...(card.rating === 'adult' && groupAdultOk ? { tier: 'uncensored' } : {}),
++ budgetUsd: config.companions.maxCostUsd,
++ conversation: { product: 'exchange', id: `exchange-group:${group.id}/turn:${turnId}/${card.id}` },
++ idempotencyKey: `exchange-group-${group.id}-${turnId}-${card.id}`,
++ tools: [],
});
} catch (err) {
-- log.warn('[Companions] the group answer failed:', String((err && err.message) || err).slice(0, 200));
++ // A refusal or an outage is words in the chat, never a silent second engine.
++ await groups.append({ owner: owner(req), groupId: group.id, role: 'assistant', characterId: card.id, content: actorWords(err), turnId, seq: seq++, model, meta: { error: (err && err.code) || 'actor.failed', actor_task: (err && err.taskId) || null, notes: noteResult.notes.map((n) => ({ kind: n.kind, text: n.text })) } });
++ continue;
++ }
++ if (out.pending) {
++ await groups.append({ owner: owner(req), groupId: group.id, role: 'assistant', characterId: card.id, content: 'The answer is taking longer than usual — it will appear here by itself. Refresh in a bit.', turnId, seq: seq++, model, meta: { pending_actor_task: out.taskId, notes: noteResult.notes.map((n) => ({ kind: n.kind, text: n.text })) } });
++ continue;
}
-- const answer = (out && out.content) || '(no text)';
++ const answer = out.answer;
const check = floor.check(answer);
if (!check.ok) {
await groups.append({ owner: owner(req), groupId: group.id, role: 'assistant', characterId: card.id, content: null, turnId, seq: seq++, meta: { error: 'floor', rule: check.rule, label: check.label, review: true } });
@@@ -1474,9 +1597,10 @@ function createCompanionRoutes(ctx) {
log.warn('[Companions] a group answer broke the hard floor (' + check.rule + '); the chat ended and the account is reviewed.');
return renderGroup(req, res, group, { problem: 'That answer broke the hard floor, so it was not delivered. The chat has ended and the account is reviewed.', status: 403 });
}
++ const reasons = out.explanation && out.explanation.length && Array.isArray(out.explanation[0].reasons) ? out.explanation[0].reasons : ['Answered by OpenVibe.Actor on your allowance.'];
const meta = {
-- run_id: out && out.run_id, answered_model: out && out.model, cost_usd: out && out.cost_usd,
-- tokens_in: out && out.tokens_in, tokens_out: out && out.tokens_out, latency_ms: out && out.latency_ms,
++ actor_task: out.taskId, provider: 'actor', answered_model: out.model || model, cost_usd: out.costUsd,
++ latency_ms: out.latencyMs, why: reasons,
voice: card.voice || null,
// The notes this character answered with: its own, never another's.
notes: noteResult.notes.map((n) => ({ kind: n.kind, text: n.text })),
ddiff --git a/test/companion-actor.test.js b/test/companion-actor.test.js
nnew file mode 100644
iindex 0000000..8774058
---- /dev/null
++++ b/test/companion-actor.test.js
@@@ -0,0 +1,164 @@
++'use strict';
++/**
++ * Companions answer through OpenVibe.Actor (one-engine slice): one turn per answer with the person's own
++ * Bearer on the wire, the cost on their allowance, and refusals or outages as words in the chat. The old
++ * direct path is gone: the AI stand-in here throws if anything calls ai.chat.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { createFakeAi } = require('./helpers/ai');
++const { TurnError } = require('../server/actor');
++
++const FORM = {
++ name: 'Ada',
++ description: 'A keeper of clocks',
++ personality: 'precise, warm',
++ scenario: 'a workshop full of clocks',
++ first_message: 'Good day. What needs winding?',
++ example_dialogue: '<START>',
++ tags: 'clock,friend',
++ rating: 'everyone',
++};
++
++const { createFakeActor } = require('./helpers/actor');
++
++
++const loudAi = () => createFakeAi({
++ chat: () => { throw new Error('ai.chat must not be called for companions any more'); },
++});
++
++async function makeChat(t, as, name = 'Ada', form = {}) {
++ const made = await t.get('/companions/new', { as, form: { ...FORM, name, ...form } });
++ assert.strictEqual(made.status, 303, made.text);
++ const cardUrl = made.headers.get('location');
++ const mine = await t.ctx.characters.listByOwner(as.subject);
++ const card = mine.find((c) => c.name === name);
++ // The card's own slug (the name, with what is not a letter or a digit turned into a dash).
++ const slug = String(cardUrl).split('/').pop();
++ const talk = await t.get(`/c/${as.username}/${encodeURIComponent(slug)}/talk`, { as, method: 'POST' });
++ assert.strictEqual(talk.status, 303, talk.text);
++ return { cardUrl, card, chatUrl: talk.headers.get('location') };
++}
++
++(async () => {
++ const actor = createFakeActor();
++ const t = await boot({ ai: loudAi(), actor });
++ const alice = t.network.addUser('alice');
++
++ await check('a turn answers through Actor on the person token, and shows its cost', async () => {
++ const { chatUrl } = await makeChat(t, alice);
++ const before = actor.turns.length;
++ const cookie = t.signIn(alice);
++ const sent = await t.get(chatUrl + '/send', { as: alice, cookie, form: { text: 'Good day' } });
++ assert.strictEqual(sent.status, 303, sent.text);
++ assert.strictEqual(actor.turns.length, before + 1, 'one Actor turn');
++ const call = actor.turns[actor.turns.length - 1];
++ assert.strictEqual(call.token, cookie.split('=')[1], "the person's own token is on the wire");
++ assert.deepStrictEqual(call.tools, [], 'a companion turn calls no tools');
++ assert.strictEqual(call.conversation.product, 'exchange');
++ assert.match(call.conversation.id, /^companion-chat:/);
++ assert.match(call.idempotencyKey, /^exchange-companion-/);
++ assert.strictEqual(call.budgetUsd, t.config.companions.maxCostUsd, 'the per-turn ceiling draws the budget');
++ assert.match(call.task, /You are Ada/, 'the character system travels with the turn');
++ assert.match(call.task, /Good day/, 'the new words travel with the turn');
++ const page = await t.get(chatUrl, { as: alice });
++ assert.strictEqual(page.status, 200, page.text);
++ assert.ok(page.text.includes('An answer from Actor'), 'the answer');
++ assert.ok(page.text.includes('ex-receipt'), 'the folded receipt');
++ assert.ok(page.text.includes('$0.000120'), 'the cost line shows what the turn cost');
++ assert.ok(page.text.includes('What the model saw'), 'the transparency panel stays');
++ });
++
++ await check('an adult companion asks for the uncensored tier, and the person\'s own 18+ check decides it', async () => {
++ const adult = t.network.addUser('ada_adult', { adult: true });
++ const { chatUrl } = await makeChat(t, adult, 'After hours', { rating: 'adult', age: '24' });
++ const before = actor.turns.length;
++ const sent = await t.get(chatUrl + '/send', { as: adult, form: { text: 'Good evening' } });
++ assert.strictEqual(sent.status, 303, sent.text);
++ assert.strictEqual(actor.turns.length, before + 1, 'one Actor turn');
++ const call = actor.turns[actor.turns.length - 1];
++ // The tier rides the turn, so OpenVibe.AI can check this person's 18+ confirmation on their account and
++ // refuse the tier without it. The floor still holds on every answer.
++ assert.strictEqual(call.tier, 'uncensored', 'the adult card asks for the uncensored policy, as it did before');
++ });
++
++ await check('a person without the 18+ confirmation never asks for the uncensored tier', async () => {
++ const plain = t.network.addUser('ada_plain');
++ const made = await t.get('/companions/new', { as: plain, form: { ...FORM, name: 'After hours too', rating: 'adult', age: '24' } });
++ assert.strictEqual(made.status, 303, made.text);
++ const slug = String(made.headers.get('location')).split('/').pop();
++ // The gate is in front of the card: a person who has not confirmed never reaches the companion at all,
++ // so no turn of theirs can ask for the adult tier.
++ const page = await t.get(`/c/${plain.username}/${encodeURIComponent(slug)}`, { as: plain });
++ assert.strictEqual(page.status, 403, `status ${page.status}: ` + page.text.slice(0, 200));
++ assert.ok(/18\+|adult/i.test(page.text), `the 18+ gate: ` + page.text.slice(0, 400));
++ const before = actor.turns.length;
++ await t.get(`/c/${plain.username}/${encodeURIComponent(slug)}/talk`, { as: plain, method: 'POST' });
++ assert.strictEqual(actor.turns.length, before, 'no turn was asked for');
++ });
++
++ await check('an everyday companion never asks for the uncensored tier', async () => {
++ const { chatUrl } = await makeChat(t, alice);
++ const before = actor.turns.length;
++ await t.get(chatUrl + '/send', { as: alice, form: { text: 'Good day' } });
++ assert.strictEqual(actor.turns.length, before + 1, 'one Actor turn');
++ const call = actor.turns[actor.turns.length - 1];
++ assert.ok(!call.tier || call.tier === 'standard', 'the standard tier');
++ });
++
++ await check('a refusal is words in the chat, charged never twice', async () => {
++ const { chatUrl } = await makeChat(t, alice, 'Refusal');
++ actor.behavior.fail = new TurnError(502, 'actor.task.failed', 'The answer did not pass its check, so it was not delivered.', 'tsk_test_9');
++ try {
++ const sent = await t.get(chatUrl + '/send', { as: alice, form: { text: 'Hello?' } });
++ assert.strictEqual(sent.status, 303, sent.text);
++ const page = await t.get(chatUrl, { as: alice });
++ assert.ok(page.text.includes('did not pass its check'), 'the refusal in plain words');
++ } finally { actor.behavior.fail = null; }
++ });
++
++ await check('an outage is words in the chat, and nothing falls back to AI', async () => {
++ const { chatUrl } = await makeChat(t, alice, 'Outage');
++ actor.behavior.fail = new TurnError(503, 'actor.unreachable', 'OpenVibe.Actor could not be reached just now. Nothing was charged, and the words stayed here. Try again in a moment.');
++ try {
++ const sent = await t.get(chatUrl + '/send', { as: alice, form: { text: 'Are you there?' } });
++ assert.strictEqual(sent.status, 303, sent.text);
++ const page = await t.get(chatUrl, { as: alice });
++ assert.ok(page.text.includes('could not be reached'), 'the outage in plain words');
++ } finally { actor.behavior.fail = null; }
++ });
++
++ await check('a slow turn reconciles on the next view', async () => {
++ const { chatUrl } = await makeChat(t, alice, 'Slowpoke');
++ actor.behavior.pending = true;
++ actor.behavior.holdReads = true;
++ try {
++ const sent = await t.get(chatUrl + '/send', { as: alice, form: { text: 'Take your time.' } });
++ assert.strictEqual(sent.status, 303, sent.text);
++ const waiting = await t.get(chatUrl, { as: alice });
++ assert.ok(waiting.text.includes('taking longer than usual'), 'the holding words');
++ assert.ok(!waiting.text.includes('Late answer'), 'no answer yet');
++ actor.behavior.pending = false;
++ actor.behavior.holdReads = false;
++ const after = await t.get(chatUrl, { as: alice });
++ assert.ok(actor.reads.length > 0, 'the next view reads the task back');
++ assert.ok(after.text.includes('Late answer'), 'the finished answer fills in');
++ assert.ok(!after.text.includes('taking longer than usual'), 'the holding words are gone');
++ } finally { actor.behavior.pending = false; actor.behavior.holdReads = false; }
++ });
++
++ await check('notes are a turn of their own, and cost nothing twice', async () => {
++ const { chatUrl } = await makeChat(t, alice, 'Noteworthy');
++ const notesBefore = actor.turns.length;
++ for (let i = 0; i < 4; i++) {
++ const sent = await t.get(chatUrl + '/send', { as: alice, form: { text: `note turn ${i}` } });
++ assert.strictEqual(sent.status, 303, sent.text);
++ }
++ const noteCalls = actor.turns.slice(notesBefore).filter((c) => String(c.conversation && c.conversation.id || '').startsWith('exchange-notes:'));
++ assert.strictEqual(noteCalls.length, 1, 'one notes turn after every few turns');
++ assert.deepStrictEqual(noteCalls[0].tools, []);
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/companions-group.test.js b/test/companions-group.test.js
iindex 3fa3e68..74cb3fc 100644
---- a/test/companions-group.test.js
++++ b/test/companions-group.test.js
@@@ -9,9 +9,11 @@
const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
const { createFakeAi } = require('./helpers/ai');
++const { createFakeActor } = require('./helpers/actor');
(async () => {
const ai = createFakeAi();
++ const actor = createFakeActor();
const ttsCalls = [];
const otherCalls = [];
let ttsStatus = 200;
@@@ -54,7 +56,7 @@ const { createFakeAi } = require('./helpers/ai');
return await globalThis.fetch(url, opts);
};
-- const t = await boot({ ai, aiFetchImpl: memoryFetch, fetchImpl });
++ const t = await boot({ ai, actor, aiFetchImpl: memoryFetch, fetchImpl });
const alice = t.network.addUser('alice');
const bob = t.network.addUser('bob');
@@@ -103,7 +105,7 @@ const { createFakeAi } = require('./helpers/ai');
});
await check('the characters take their turns, each in its own voice and its own memory', async () => {
-- ai.calls.length = 0;
++ actor.turns.length = 0;
ttsCalls.length = 0;
const sent = await t.get('/talk/group/' + groupId + '/send', { as: alice, form: { text: 'Good morning, everyone' } });
assert.strictEqual(sent.status, 303, sent.text.slice(0, 300));
@@@ -120,14 +122,14 @@ const { createFakeAi } = require('./helpers/ai');
assert.ok(!adaAnswer.meta.notes.some((n) => n.text.includes('sailing')), 'Brenda\'s note never joins Ada\'s answer');
assert.ok(brendaAnswer.meta.notes.some((n) => n.text.includes('sailing')), 'Brenda answers with her own note');
assert.ok(!brendaAnswer.meta.notes.some((n) => n.text.includes('keeps clocks')), 'Ada\'s note never joins Brenda\'s answer');
-- // And the same, in the system prompt that was sent.
-- const adaCall = ai.calls.find((c) => String(c.messages[0].content).includes('You are Ada.'));
-- const brendaCall = ai.calls.find((c) => String(c.messages[0].content).includes('You are Brenda.'));
++ // And the same, in the turn text that was sent.
++ const adaCall = actor.turns.find((c) => String(c.task).includes('You are Ada.'));
++ const brendaCall = actor.turns.find((c) => String(c.task).includes('You are Brenda.'));
assert.ok(adaCall && brendaCall);
-- assert.ok(String(adaCall.messages[0].content).includes('keeps clocks'));
-- assert.ok(!String(adaCall.messages[0].content).includes('sailing'));
-- assert.ok(String(brendaCall.messages[0].content).includes('sailing'));
-- assert.ok(!String(brendaCall.messages[0].content).includes('keeps clocks'));
++ assert.ok(String(adaCall.task).includes('keeps clocks'));
++ assert.ok(!String(adaCall.task).includes('sailing'));
++ assert.ok(String(brendaCall.task).includes('sailing'));
++ assert.ok(!String(brendaCall.task).includes('keeps clocks'));
});
await check('the group page shows every character and every voice', async () => {
@@@ -139,7 +141,7 @@ const { createFakeAi } = require('./helpers/ai');
await check('the one named answers, and only the one named', async () => {
await t.get('/talk/group/' + groupId + '/settings', { as: alice, form: { mode: 'named', turns: '1', speak: '1' } });
-- ai.calls.length = 0;
++ actor.turns.length = 0;
const sent = await t.get('/talk/group/' + groupId + '/send', { as: alice, form: { text: 'Brenda, what do you think?' } });
assert.strictEqual(sent.status, 303);
const messages = await t.ctx.groups.messages(groupId);
ddiff --git a/test/companions-memory.test.js b/test/companions-memory.test.js
iindex e93a340..b46a096 100644
---- a/test/companions-memory.test.js
++++ b/test/companions-memory.test.js
@@@ -18,6 +18,7 @@ const assert = require('assert');
const { ids } = require('openvibe-contracts');
const { boot, check, done } = require('./helpers/boot');
const { createFakeAi } = require('./helpers/ai');
++const { createFakeActor } = require('./helpers/actor');
const locker = require('openvibe-sdk/locker');
/**
@@@ -80,6 +81,9 @@ function createFakeMemory() {
}
const AI_ANSWER = 'The clocks are safe with me.';
++const NOTES_JSON = '[{"kind":"fact","text":"The person is called Ada and likes clocks."}]';
++const actorAnswers = (call) => String(call.conversation && call.conversation.id || '').startsWith('exchange-notes:') ? NOTES_JSON : AI_ANSWER;
++
function fakeAi() {
return createFakeAi({
chat(args) {
@@@ -99,8 +103,9 @@ const FORM = {
(async () => {
const memory = createFakeMemory();
const ai = fakeAi();
++ const actor = createFakeActor({ answers: actorAnswers });
const t = await boot({
-- ai, aiFetchImpl: memory.fetchImpl,
++ ai, actor, aiFetchImpl: memory.fetchImpl,
env: { EXCHANGE_COMPANIONS_NOTES_EVERY_TURNS: '100', EXCHANGE_COMPANIONS_MEMORY_BUDGET_TOKENS: '100', EXCHANGE_COMPANIONS_LOREBOOK_BUDGET_TOKENS: '100' },
});
try {
@@@ -163,15 +168,15 @@ const FORM = {
const pinned = await t.ctx.notes.add(alice.subject, card.id, { kind: 'preference', text: 'Ada prefers short answers.', pinned: true }, alice.subject);
await t.ctx.notes.add(alice.subject, card.id, { kind: 'fact', text: 'Ada keeps a notebook of pendulum designs.', pinned: false }, alice.subject);
await t.ctx.notes.add(alice.subject, card.id, { kind: 'event', text: 'A long note that is over the whole budget: ' + 'x'.repeat(320), pinned: false }, alice.subject);
-- ai.calls.length = 0;
++ actor.turns.length = 0;
const sent = await t.get(chatUrl + '/send', { as: alice, method: 'POST', form: { text: 'What do you remember about me?' } });
assert.strictEqual(sent.status, 303, sent.text);
-- const call = ai.calls[0];
-- const system = call.messages[0].content;
++ const call = actor.turns[0];
++ const system = call.task;
assert.ok(system.includes('Ada prefers short answers.'), 'the pinned note joined');
assert.ok(system.includes('pendulum designs'), 'the newest note joined');
assert.ok(!system.includes('over the whole budget'), 'the note over the budget did not join');
-- assert.ok(call.max_cost_usd <= t.config.companions.maxCostUsd, 'under the free-use ceiling');
++ assert.ok(call.budgetUsd <= t.config.companions.maxCostUsd, 'under the free-use ceiling');
const page = await t.get(chatUrl, { as: alice });
assert.ok(page.text.includes('Your notes'), 'the panel has a notes section');
assert.ok(page.text.includes('Ada prefers short answers.'));
@@@ -186,10 +191,10 @@ const FORM = {
await t.ctx.lorebooks.addEntry(alice.subject, book.id, { keys: 'clock', content: 'This entry is far too long for the tiny budget: ' + 'y'.repeat(1200), priority: 50 });
await t.ctx.lorebooks.addEntry(alice.subject, book.id, { content: 'The engine is always in the room.', constant: true, priority: 2 });
await t.ctx.lorebooks.addEntry(alice.subject, book.id, { keys: 'unrelated', content: 'A fact that must not join.', priority: 99 });
-- ai.calls.length = 0;
++ actor.turns.length = 0;
const sent = await t.get(chatUrl + '/send', { as: alice, method: 'POST', form: { text: 'Tell me about the clock again.' } });
assert.strictEqual(sent.status, 303, sent.text);
-- const system = ai.calls[0].messages[0].content;
++ const system = actor.turns[0].task;
assert.ok(system.includes('The high priority fact about clocks.'));
assert.ok(system.includes('The engine is always in the room.'), 'a constant entry joins');
assert.ok(!system.includes('This entry is far too long'), 'the entry over the budget did not join');
@@@ -242,8 +247,9 @@ const FORM = {
// A second app, so the notes cadence is two turns and does not disturb the budget checks above.
memory.calls.length = 0;
const ai2 = fakeAi();
++ const actor2 = createFakeActor({ answers: actorAnswers });
const t2 = await boot({
-- ai: ai2, aiFetchImpl: memory.fetchImpl,
++ ai: ai2, actor: actor2, aiFetchImpl: memory.fetchImpl,
env: { EXCHANGE_COMPANIONS_NOTES_EVERY_TURNS: '2' },
});
try {
@@@ -253,12 +259,12 @@ const FORM = {
const chat2 = talk2.headers.get('location');
await t2.get(chat2 + '/send', { as: user, method: 'POST', form: { text: 'I am Carol, I like clocks.' } });
assert.strictEqual((await t2.ctx.notes.indexRows(user.subject, c2.id)).length, 0, 'not on the first turn');
-- ai2.calls.length = 0;
++ actor2.turns.length = 0;
const second = await t2.get(chat2 + '/send', { as: user, method: 'POST', form: { text: 'Remember me.' } });
assert.strictEqual(second.status, 303, second.text);
-- const noteCall = ai2.calls.find((c) => c.model === 'openvibe/economy');
++ const noteCall = actor2.turns.find((c) => String(c.conversation && c.conversation.id || '').startsWith('exchange-notes:'));
assert.ok(noteCall, 'the economy model took the notes');
-- assert.ok(noteCall.max_cost_usd <= t2.config.companions.maxCostUsd, 'under the free-use ceiling');
++ assert.ok(noteCall.budgetUsd <= t2.config.companions.maxCostUsd, 'under the free-use ceiling');
const rows = await t2.ctx.notes.indexRows(user.subject, c2.id);
assert.strictEqual(rows.length, 1);
assert.strictEqual(rows[0].origin, 'model');
@@@ -333,15 +339,15 @@ const FORM = {
{ role: 'user', content: 'What do you remember about me?' },
{ role: 'assistant', content: AI_ANSWER },
];
-- ai.calls.length = 0;
++ actor.turns.length = 0;
const res = await t.get(chatUrl + '/send', {
as: alice, method: 'POST',
form: { text: 'One more clock, please.', locker_context: JSON.stringify({ history, messages: {}, notes: [] }) },
});
assert.strictEqual(res.status, 303, res.text);
-- const call = ai.calls[0];
-- assert.strictEqual(call.messages[call.messages.length - 1].content, 'One more clock, please.');
-- assert.ok(call.messages.some((m) => m.content === 'What do you remember about me?'), 'the browser’s opened history reached the model for this turn');
++ const call = actor.turns[0];
++ assert.ok(call.task.includes('One more clock, please.'), 'the new words reached the turn');
++ assert.ok(call.task.includes('What do you remember about me?'), 'the browser’s opened history reached the model for this turn');
const messages = await t.ctx.companionChats.messages(chatUrl.split('/').pop());
const last = messages.filter((m) => m.role === 'user').pop();
assert.strictEqual(last.meta.sealed, true);
ddiff --git a/test/companions.test.js b/test/companions.test.js
iindex a348efd..5a5b203 100644
---- a/test/companions.test.js
++++ b/test/companions.test.js
@@@ -6,6 +6,7 @@
const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
const { createFakeAi } = require('./helpers/ai');
++const { createFakeActor } = require('./helpers/actor');
const codec = require('../server/companions/codec');
const floor = require('../server/companions/floor');
@@@ -25,7 +26,8 @@ const FORM = {
(async () => {
const ai = createFakeAi();
-- const t = await boot({ ai });
++ const actor = createFakeActor();
++ const t = await boot({ ai, actor });
const alice = t.network.addUser('alice');
const bob = t.network.addUser('bob');
let cardUrl = null;
@@@ -240,11 +242,13 @@ const FORM = {
assert.strictEqual(made.status, 303, made.text);
const chatUrl = made.headers.get('location');
assert.match(chatUrl, /^\/talk\/exc_/);
-- const before = ai.calls.length;
++ const before = actor.turns.length;
const sent = await t.get(chatUrl + '/send', { as: alice, form: { text: 'Good day' } });
assert.strictEqual(sent.status, 303, sent.text);
-- assert.strictEqual(ai.calls.length, before + 1, 'one run');
-- assert.strictEqual(ai.calls[ai.calls.length - 1].subject, alice.subject, 'the run is on the person');
++ assert.strictEqual(actor.turns.length, before + 1, 'one turn');
++ const turn = actor.turns[actor.turns.length - 1];
++ assert.deepStrictEqual(turn.tools, [], 'a companion turn calls no tools');
++ assert.strictEqual(turn.conversation.product, 'exchange', 'the turn names its conversation');
const page = await t.get(chatUrl, { as: alice });
assert.strictEqual(page.status, 200, page.text);
assert.ok(page.text.includes('Good day'));
@@@ -261,11 +265,11 @@ const FORM = {
});
await check('the hard floor refuses the input', async () => {
-- const before = ai.calls.length;
++ const before = actor.turns.length;
const r = await t.get('/talk/' + chatId + '/send', { as: alice, form: { text: 'a child with a naked body' } });
assert.strictEqual(r.status, 422);
assert.ok(r.text.includes('refused'));
-- assert.strictEqual(ai.calls.length, before, 'no run happened');
++ assert.strictEqual(actor.turns.length, before, 'no turn happened');
});
await check('editing a message keeps the tree: the old version and the new one', async () => {
@@@ -337,8 +341,8 @@ const FORM = {
assert.strictEqual((thread.match(/<p class="ovt-bubble">Good evening<\/p>/g) || []).length, 1, 'the message is one bubble');
const panel = (chat.text.match(/<ol class="ex-saw-context">[\s\S]*?<\/ol>/) || [''])[0];
assert.strictEqual((panel.match(/Good evening/g) || []).length, 1, 'the context holds the message once');
-- const last = ai.calls[ai.calls.length - 1];
-- assert.strictEqual(last.messages.filter((m) => m.role === 'user' && m.content === 'Good evening').length, 1, 'the model sees the message once');
++ const last = actor.turns[actor.turns.length - 1];
++ assert.strictEqual((last.task.match(/Good evening/g) || []).length, 1, 'the turn carries the message once');
});
await check('the chat header names the character and the model, and the chip opens the settings', async () => {
@@@ -359,7 +363,7 @@ const FORM = {
run_id: 'run_TEST', cost_usd: 0.00012, tokens_in: 10, tokens_out: 20, latency_ms: 420, why: [],
}),
});
-- const f = await boot({ ai: floorAi });
++ const f = await boot({ ai: floorAi, actor: createFakeActor({ answers: ['here is a child with a naked body'] }) });
const carol = f.network.addUser('carol');
try {
await check('an answer that breaks the hard floor is never delivered, and the chat ends', async () => {
ddiff --git a/test/helpers/actor.js b/test/helpers/actor.js
nnew file mode 100644
iindex 0000000..f061e35
---- /dev/null
++++ b/test/helpers/actor.js
@@@ -0,0 +1,51 @@
++'use strict';
++/**
++ * A stand-in OpenVibe.Actor for the page tests: one turn per answer, recorded calls, and a small task
++ * store for the reconcile path. Script the answers (`answers`: texts or (call, n) => text), fail every
++ * turn (`fail`: a TurnError), or hold turns pending (`pending: true`, released by flipping it back).
++ */
++const { TurnError } = require('../../server/actor');
++
++function createFakeActor({ answers = null, fail = null, pending = false } = {}) {
++ const turns = [];
++ const reads = [];
++ const tasks = new Map();
++ const behavior = { fail, pending, holdReads: false };
++ const textOf = (call, n) => {
++ if (Array.isArray(answers) && answers.length) return answers.shift();
++ if (typeof answers === 'function') return answers(call, n);
++ return `An answer from Actor ${n}.`;
++ };
++ return {
++ turns, reads, tasks, behavior, TurnError,
++ async turn(args) {
++ turns.push(args);
++ const fail = typeof behavior.fail === 'function' ? behavior.fail(args) : behavior.fail;
++ if (fail) throw fail;
++ const n = turns.length;
++ const taskId = `tsk_test_${n}`;
++ if (behavior.pending) {
++ tasks.set(taskId, { state: 'running', answer: `Late answer ${n}.`, model: 'stand-in-model', costUsd: 0.00009 });
++ return { pending: true, taskId };
++ }
++ return {
++ pending: false, taskId,
++ answer: textOf(args, n), agent: 'openvibe-runtime', model: 'stand-in-model',
++ costUsd: 0.00012, explanation: [{ selected: 'openvibe-runtime', reasons: ['the cheapest healthy offer for your ceiling'] }],
++ latencyMs: 1500,
++ };
++ },
++ async read({ token, taskId }) {
++ reads.push({ token, taskId });
++ const t = tasks.get(String(taskId));
++ if (!t) throw new TurnError(404, 'actor.task.not_found', 'No such task.', String(taskId));
++ if (t.state === 'running' && !behavior.holdReads) {
++ t.state = 'succeeded';
++ return { id: taskId, state: 'succeeded', result: { answer: t.answer, agent: 'openvibe-runtime', model: t.model }, cost: { usd: t.costUsd, free_allowance_used: t.costUsd }, explanation: [{ selected: 'openvibe-runtime', reasons: ['the cheapest healthy offer for your ceiling'] }] };
++ }
++ return { id: taskId, state: t.state, result: t.result || null, cost: t.cost || null, error: t.error || null, explanation: [] };
++ },
++ };
++}
++
++module.exports = { createFakeActor };
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex bf2f91d..fbf3374 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -45,7 +45,7 @@ async function boot(opts = {}) {
// no test ever waits on the network (the notes tests inject their own, in test/helpers or the file itself).
const emptyMemory = async () => ({ ok: true, status: 200, text: async () => '{"items":[]}', json: async () => ({ items: [] }) });
const emptyRatings = async () => new Response(JSON.stringify({ board: { minimum_samples: 20, rows: [] } }), { headers: { 'content-type': 'application/json' } });
-- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, ai: opts.ai, aiFetchImpl: opts.aiFetchImpl || emptyMemory, bench: opts.bench, benchFetchImpl: opts.benchFetchImpl || emptyRatings, analysis: opts.analysis, openapiFetch: opts.openapiFetch, indexnow: opts.indexnow, fetchImpl: opts.fetchImpl });
++ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, ai: opts.ai, aiFetchImpl: opts.aiFetchImpl || emptyMemory, actor: opts.actor, actorFetchImpl: opts.actorFetchImpl, bench: opts.bench, benchFetchImpl: opts.benchFetchImpl || emptyRatings, analysis: opts.analysis, openapiFetch: opts.openapiFetch, indexnow: opts.indexnow, fetchImpl: opts.fetchImpl });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;