openvibers/openvibe.exchange Commits
Exchange: route companion turns through Actor (#197)
Changes
@@@ -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",@@@ -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",nnew file mode 100644@@@ -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 };@@@ -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');@@@ -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 };@@@ -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).@@@ -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 })),nnew file mode 100644@@@ -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); });@@@ -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);@@@ -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);@@@ -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 () => {nnew file mode 100644@@@ -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 };@@@ -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}`;
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.5.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-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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",
@@ -1268,9 +1268,9 @@
@@ -1268,9 +1268,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.5.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-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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -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 };
@@ -28,6 +28,7 @@ const { createKeyStore } = require('./auth/keys');
@@ -28,6 +28,7 @@ const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
const { createPrincipal } = require('./http/principal');
const { createAi } = require('./ai');
const { createAi } = require('./ai');
+const { createActor } = require('./actor');
const { createNetwork } = require('./network');
const { createNetwork } = require('./network');
const { createBench } = require('./bench');
const { createBench } = require('./bench');
const { createCompare } = require('./compare');
const { createCompare } = require('./compare');
@@ -84,6 +85,7 @@ async function createApp(opts = {}) {
@@ -84,6 +85,7 @@ async function createApp(opts = {}) {
const principal = createPrincipal({ config, keys });
const principal = createPrincipal({ config, keys });
const ai = opts.ai || createAi({ config, fetchImpl: opts.aiFetchImpl || globalThis.fetch, log, now: s.now });
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.
// 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 network = opts.network || createNetwork({ config, fetchImpl, log });
const bench = opts.bench || createBench({ config, fetchImpl: opts.benchFetchImpl || globalThis.fetch, log, now: s.now });
const bench = opts.bench || createBench({ config, fetchImpl: opts.benchFetchImpl || globalThis.fetch, log, now: s.now });
@@ -111,7 +113,7 @@ async function createApp(opts = {}) {
@@ -111,7 +113,7 @@ async function createApp(opts = {}) {
const notes = createCompanionNotes({ db: s.db, memory, config, log });
const notes = createCompanionNotes({ db: s.db, memory, config, log });
const lorebooks = createLorebooks({ db: s.db, config, log });
const lorebooks = createLorebooks({ db: s.db, config, log });
const locker = createLocker({ config, fetchImpl, 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
// 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.
// client-credentials token; a test injects a stand-in through opts.accountSend.
@@ -121,7 +123,7 @@ async function createApp(opts = {}) {
@@ -121,7 +123,7 @@ async function createApp(opts = {}) {
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Exchange cannot answer account events'); });
: 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 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();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -64,9 +64,8 @@ function transcriptOf(turns, max = 2400) {
@@ -64,9 +64,8 @@ function transcriptOf(turns, max = 2400) {
return text.length > max ? text.slice(text.length - max) : text;
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 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;
const maxCost = (config && config.companions && config.companions.maxCostUsd) || 0.05;
/**
/**
@@ -84,24 +83,24 @@ function createNoteTaker({ ai, notes, config, log = console } = {}) {
@@ -84,24 +83,24 @@ function createNoteTaker({ ai, notes, config, log = console } = {}) {
async function take({ owner, characterId, token, turns, protect = null }) {
async function take({ owner, characterId, token, turns, protect = null }) {
const transcript = transcriptOf(turns);
const transcript = transcriptOf(turns);
if (!transcript.trim()) return { notes: [] };
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 {
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) {
} catch (err) {
log.warn('[Companions] the notes were not taken:', String((err && err.message) || err).slice(0, 200));
log.warn('[Companions] the notes were not taken:', String((err && err.message) || err).slice(0, 200));
return { notes: [] };
return { notes: [] };
}
}
- const parsed = parseNotes(out && out.content);
+ if (!answer) return { notes: [] };
+ const parsed = parseNotes(answer);
const written = [];
const written = [];
for (const note of parsed) {
for (const note of parsed) {
try {
try {
@@ -113,7 +112,7 @@ function createNoteTaker({ ai, notes, config, log = console } = {}) {
@@ -113,7 +112,7 @@ function createNoteTaker({ ai, notes, config, log = console } = {}) {
return { notes: written };
return { notes: written };
}
}
- return { take, due, parseNotes, transcriptOf, every, model };
+ return { take, due, parseNotes, transcriptOf, every };
}
}
module.exports = { createNoteTaker, parseNotes, transcriptOf, MAX_NOTES };
module.exports = { createNoteTaker, parseNotes, transcriptOf, MAX_NOTES };
@@ -73,6 +73,16 @@ function load(env = process.env) {
@@ -73,6 +73,16 @@ function load(env = process.env) {
waitMs: Math.max(1000, int(env.EXCHANGE_AI_WAIT_MS, 25_000)),
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
// 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
// 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).
// service's own client-credentials grant (bench.round.create, bench.judgement.cast, bench.board.read).
@@ -40,7 +40,7 @@ const handleOf = (name) => String(name || 'someone').toLowerCase().replace(/[^a-
@@ -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);
const tagsOf = (s) => String(s || '').split(',').map((t) => t.trim().toLowerCase()).filter(Boolean).slice(0, 5);
function createCompanionRoutes(ctx) {
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 db = ctx.s && ctx.s.db;
const r = asyncRouter();
const r = asyncRouter();
// A card may arrive as a file (a PNG with the card inside, or plain JSON).
// A card may arrive as a file (a PNG with the card inside, or plain JSON).
@@ -988,6 +988,8 @@ function createCompanionRoutes(ctx) {
@@ -988,6 +988,8 @@ function createCompanionRoutes(ctx) {
}
}
async function renderChat(req, res, chat, card, { problem = null, status = 200 } = {}) {
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 messages = await companionChats.messages(chat.id);
const query = req.query || {};
const query = req.query || {};
const turns = readTurns(messages, query);
const turns = readTurns(messages, query);
@@ -1067,6 +1069,93 @@ function createCompanionRoutes(ctx) {
@@ -1067,6 +1069,93 @@ function createCompanionRoutes(ctx) {
return out;
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 }) {
async function answerTurn(req, res, chat, card, { userMessage, turnId, variant, plainText = null, lockerBundle = null, keys = null }) {
const subject = owner(req);
const subject = owner(req);
const token = tokenOf(req);
const token = tokenOf(req);
@@ -1121,19 +1210,50 @@ function createCompanionRoutes(ctx) {
@@ -1121,19 +1210,50 @@ function createCompanionRoutes(ctx) {
notes: notesSaw, notes_used_tokens: noteResult.usedTokens, notes_budget_tokens: noteResult.budgetTokens,
notes: notesSaw, notes_used_tokens: noteResult.usedTokens, notes_budget_tokens: noteResult.budgetTokens,
lorebook: entriesSaw, lorebook_used_tokens: entryResult.usedTokens, lorebook_budget_tokens: entryResult.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 {
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);
const check = floor.check(answer);
if (!check.ok) {
if (!check.ok) {
// The answer breaks the floor: it is never delivered, the chat ends, the account is reviewed.
// The answer breaks the floor: it is never delivered, the chat ends, the account is reviewed.
@@ -1147,12 +1267,10 @@ function createCompanionRoutes(ctx) {
@@ -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 });
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 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({
const answerId = await companionChats.append({
owner: subject, chatId: chat.id, role: 'assistant', content: stored,
owner: subject, chatId: chat.id, role: 'assistant', content: stored,
turnId, variant, parentId: userMessage.id, model: settings.model || null,
turnId, variant, parentId: userMessage.id, model: settings.model || null,
@@ -1176,12 +1294,6 @@ function createCompanionRoutes(ctx) {
@@ -1176,12 +1294,6 @@ function createCompanionRoutes(ctx) {
} catch (err) {
} catch (err) {
log.warn('[Companions] the notes were not taken:', String((err && err.message) || err).slice(0, 200));
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);
return res.redirect(303, '/talk/' + chat.id);
}
}
@@ -1438,8 +1550,12 @@ function createCompanionRoutes(ctx) {
@@ -1438,8 +1550,12 @@ function createCompanionRoutes(ctx) {
const names = {};
const names = {};
for (const c of cards) names[c.id] = c.name;
for (const c of cards) names[c.id] = c.name;
const token = tokenOf(req);
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 turnId = ctx.s.newId('ext');
const userMessageId = await groups.append({ owner: owner(req), groupId: group.id, role: 'user', content: text, turnId, seq: 0 });
const userMessageId = await groups.append({ owner: owner(req), groupId: group.id, role: 'user', content: text, turnId, seq: 0 });
let seq = 1;
let seq = 1;
@@ -1454,19 +1570,26 @@ function createCompanionRoutes(ctx) {
@@ -1454,19 +1570,26 @@ function createCompanionRoutes(ctx) {
const asked = groupContext(history, card.id, names).concat([{ role: 'user', content: text }]);
const asked = groupContext(history, card.id, names).concat([{ role: 'user', content: text }]);
let out = null;
let out = null;
try {
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) {
} 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);
const check = floor.check(answer);
if (!check.ok) {
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 } });
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) {
@@ -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.');
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 });
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 = {
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,
voice: card.voice || null,
// The notes this character answered with: its own, never another's.
// The notes this character answered with: its own, never another's.
notes: noteResult.notes.map((n) => ({ kind: n.kind, text: n.text })),
notes: noteResult.notes.map((n) => ({ kind: n.kind, text: n.text })),
new file mode 100644
new file mode 100644
@@ -0,0 +1,164 @@
@@ -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); });
@@ -9,9 +9,11 @@
@@ -9,9 +9,11 @@
const assert = require('assert');
const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
const { createFakeAi } = require('./helpers/ai');
const { createFakeAi } = require('./helpers/ai');
+const { createFakeActor } = require('./helpers/actor');
(async () => {
(async () => {
const ai = createFakeAi();
const ai = createFakeAi();
+ const actor = createFakeActor();
const ttsCalls = [];
const ttsCalls = [];
const otherCalls = [];
const otherCalls = [];
let ttsStatus = 200;
let ttsStatus = 200;
@@ -54,7 +56,7 @@ const { createFakeAi } = require('./helpers/ai');
@@ -54,7 +56,7 @@ const { createFakeAi } = require('./helpers/ai');
return await globalThis.fetch(url, opts);
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 alice = t.network.addUser('alice');
const bob = t.network.addUser('bob');
const bob = t.network.addUser('bob');
@@ -103,7 +105,7 @@ const { createFakeAi } = require('./helpers/ai');
@@ -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 () => {
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;
ttsCalls.length = 0;
const sent = await t.get('/talk/group/' + groupId + '/send', { as: alice, form: { text: 'Good morning, everyone' } });
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));
assert.strictEqual(sent.status, 303, sent.text.slice(0, 300));
@@ -120,14 +122,14 @@ const { createFakeAi } = require('./helpers/ai');
@@ -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(!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('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');
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(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 () => {
await check('the group page shows every character and every voice', async () => {
@@ -139,7 +141,7 @@ const { createFakeAi } = require('./helpers/ai');
@@ -139,7 +141,7 @@ const { createFakeAi } = require('./helpers/ai');
await check('the one named answers, and only the one named', async () => {
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' } });
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?' } });
const sent = await t.get('/talk/group/' + groupId + '/send', { as: alice, form: { text: 'Brenda, what do you think?' } });
assert.strictEqual(sent.status, 303);
assert.strictEqual(sent.status, 303);
const messages = await t.ctx.groups.messages(groupId);
const messages = await t.ctx.groups.messages(groupId);
@@ -18,6 +18,7 @@ const assert = require('assert');
@@ -18,6 +18,7 @@ const assert = require('assert');
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
const { createFakeAi } = require('./helpers/ai');
const { createFakeAi } = require('./helpers/ai');
+const { createFakeActor } = require('./helpers/actor');
const locker = require('openvibe-sdk/locker');
const locker = require('openvibe-sdk/locker');
/**
/**
@@ -80,6 +81,9 @@ function createFakeMemory() {
@@ -80,6 +81,9 @@ function createFakeMemory() {
}
}
const AI_ANSWER = 'The clocks are safe with me.';
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() {
function fakeAi() {
return createFakeAi({
return createFakeAi({
chat(args) {
chat(args) {
@@ -99,8 +103,9 @@ const FORM = {
@@ -99,8 +103,9 @@ const FORM = {
(async () => {
(async () => {
const memory = createFakeMemory();
const memory = createFakeMemory();
const ai = fakeAi();
const ai = fakeAi();
+ const actor = createFakeActor({ answers: actorAnswers });
const t = await boot({
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' },
env: { EXCHANGE_COMPANIONS_NOTES_EVERY_TURNS: '100', EXCHANGE_COMPANIONS_MEMORY_BUDGET_TOKENS: '100', EXCHANGE_COMPANIONS_LOREBOOK_BUDGET_TOKENS: '100' },
});
});
try {
try {
@@ -163,15 +168,15 @@ const FORM = {
@@ -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);
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: '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);
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?' } });
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);
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('Ada prefers short answers.'), 'the pinned note joined');
assert.ok(system.includes('pendulum designs'), 'the newest 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(!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 });
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('Your notes'), 'the panel has a notes section');
assert.ok(page.text.includes('Ada prefers short answers.'));
assert.ok(page.text.includes('Ada prefers short answers.'));
@@ -186,10 +191,10 @@ const FORM = {
@@ -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, { 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, { 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 });
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.' } });
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);
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 high priority fact about clocks.'));
assert.ok(system.includes('The engine is always in the room.'), 'a constant entry joins');
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');
assert.ok(!system.includes('This entry is far too long'), 'the entry over the budget did not join');
@@ -242,8 +247,9 @@ const FORM = {
@@ -242,8 +247,9 @@ const FORM = {
// A second app, so the notes cadence is two turns and does not disturb the budget checks above.
// A second app, so the notes cadence is two turns and does not disturb the budget checks above.
memory.calls.length = 0;
memory.calls.length = 0;
const ai2 = fakeAi();
const ai2 = fakeAi();
+ const actor2 = createFakeActor({ answers: actorAnswers });
const t2 = await boot({
const t2 = await boot({
- ai: ai2, aiFetchImpl: memory.fetchImpl,
+ ai: ai2, actor: actor2, aiFetchImpl: memory.fetchImpl,
env: { EXCHANGE_COMPANIONS_NOTES_EVERY_TURNS: '2' },
env: { EXCHANGE_COMPANIONS_NOTES_EVERY_TURNS: '2' },
});
});
try {
try {
@@ -253,12 +259,12 @@ const FORM = {
@@ -253,12 +259,12 @@ const FORM = {
const chat2 = talk2.headers.get('location');
const chat2 = talk2.headers.get('location');
await t2.get(chat2 + '/send', { as: user, method: 'POST', form: { text: 'I am Carol, I like clocks.' } });
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');
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.' } });
const second = await t2.get(chat2 + '/send', { as: user, method: 'POST', form: { text: 'Remember me.' } });
assert.strictEqual(second.status, 303, second.text);
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, '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);
const rows = await t2.ctx.notes.indexRows(user.subject, c2.id);
assert.strictEqual(rows.length, 1);
assert.strictEqual(rows.length, 1);
assert.strictEqual(rows[0].origin, 'model');
assert.strictEqual(rows[0].origin, 'model');
@@ -333,15 +339,15 @@ const FORM = {
@@ -333,15 +339,15 @@ const FORM = {
{ role: 'user', content: 'What do you remember about me?' },
{ role: 'user', content: 'What do you remember about me?' },
{ role: 'assistant', content: AI_ANSWER },
{ role: 'assistant', content: AI_ANSWER },
];
];
- ai.calls.length = 0;
+ actor.turns.length = 0;
const res = await t.get(chatUrl + '/send', {
const res = await t.get(chatUrl + '/send', {
as: alice, method: 'POST',
as: alice, method: 'POST',
form: { text: 'One more clock, please.', locker_context: JSON.stringify({ history, messages: {}, notes: [] }) },
form: { text: 'One more clock, please.', locker_context: JSON.stringify({ history, messages: {}, notes: [] }) },
});
});
assert.strictEqual(res.status, 303, res.text);
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 messages = await t.ctx.companionChats.messages(chatUrl.split('/').pop());
const last = messages.filter((m) => m.role === 'user').pop();
const last = messages.filter((m) => m.role === 'user').pop();
assert.strictEqual(last.meta.sealed, true);
assert.strictEqual(last.meta.sealed, true);
@@ -6,6 +6,7 @@
@@ -6,6 +6,7 @@
const assert = require('assert');
const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
const { createFakeAi } = require('./helpers/ai');
const { createFakeAi } = require('./helpers/ai');
+const { createFakeActor } = require('./helpers/actor');
const codec = require('../server/companions/codec');
const codec = require('../server/companions/codec');
const floor = require('../server/companions/floor');
const floor = require('../server/companions/floor');
@@ -25,7 +26,8 @@ const FORM = {
@@ -25,7 +26,8 @@ const FORM = {
(async () => {
(async () => {
const ai = createFakeAi();
const ai = createFakeAi();
- const t = await boot({ ai });
+ const actor = createFakeActor();
+ const t = await boot({ ai, actor });
const alice = t.network.addUser('alice');
const alice = t.network.addUser('alice');
const bob = t.network.addUser('bob');
const bob = t.network.addUser('bob');
let cardUrl = null;
let cardUrl = null;
@@ -240,11 +242,13 @@ const FORM = {
@@ -240,11 +242,13 @@ const FORM = {
assert.strictEqual(made.status, 303, made.text);
assert.strictEqual(made.status, 303, made.text);
const chatUrl = made.headers.get('location');
const chatUrl = made.headers.get('location');
assert.match(chatUrl, /^\/talk\/exc_/);
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' } });
const sent = await t.get(chatUrl + '/send', { as: alice, form: { text: 'Good day' } });
assert.strictEqual(sent.status, 303, sent.text);
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 });
const page = await t.get(chatUrl, { as: alice });
assert.strictEqual(page.status, 200, page.text);
assert.strictEqual(page.status, 200, page.text);
assert.ok(page.text.includes('Good day'));
assert.ok(page.text.includes('Good day'));
@@ -261,11 +265,11 @@ const FORM = {
@@ -261,11 +265,11 @@ const FORM = {
});
});
await check('the hard floor refuses the input', async () => {
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' } });
const r = await t.get('/talk/' + chatId + '/send', { as: alice, form: { text: 'a child with a naked body' } });
assert.strictEqual(r.status, 422);
assert.strictEqual(r.status, 422);
assert.ok(r.text.includes('refused'));
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 () => {
await check('editing a message keeps the tree: the old version and the new one', async () => {
@@ -337,8 +341,8 @@ const FORM = {
@@ -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');
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];
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');
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 () => {
await check('the chat header names the character and the model, and the chip opens the settings', async () => {
@@ -359,7 +363,7 @@ const FORM = {
@@ -359,7 +363,7 @@ const FORM = {
run_id: 'run_TEST', cost_usd: 0.00012, tokens_in: 10, tokens_out: 20, latency_ms: 420, why: [],
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');
const carol = f.network.addUser('carol');
try {
try {
await check('an answer that breaks the hard floor is never delivered, and the chat ends', async () => {
await check('an answer that breaks the hard floor is never delivered, and the chat ends', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -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 };
@@ -45,7 +45,7 @@ async function boot(opts = {}) {
@@ -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).
// 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 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 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();
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 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}`;
const base = `http://127.0.0.1:${server.address().port}`;