openvibers/openvibe.actor Commits
Actor: first slice of one engine for turns (#427)
Changes
nnew file mode 100644@@@ -0,0 +1,96 @@++# One engine: first slice++++Products that need an assistant answer submit a turn to Actor. Actor owns model selection, tool execution, safety++checks, metering, and the durable task history. A product owns its surface and its domain record, then links that++record to the Actor task. This slice is Exchange's companions and Chat's support inboxes.++++## The turn call++++One call, `POST /api/v1/tasks` on OpenVibe.Actor, then `GET /api/v1/tasks/:id` until the task ends.++++The body is `actor.task-create-request@1` plus three optional fields this slice adds:++++- `conversation`: `{ product, id }` — which product conversation the task answers. Stored on the task row, so a later++ query finds the turn's task.++- `tools`: the registry tool names the run may call. An empty array answers with no tool calls. A name the registry++ does not offer is said aloud in the run's plan, never silently dropped.++- `model`: one exact model id, resolved by OpenVibe.AI like one the person picked themselves.++- `tier`: the content tier, `standard` or `uncensored`.++++Alongside the words, the body carries `budget.per_task_usd` (what this one turn may cost) and an++`idempotency_key`, so a repeat of the same turn is the same task and a different turn is refused with++`409 actor.idempotency.conflict` rather than answered twice.++++## The content tier++++The uncensored tier is OpenVibe.AI's, and AI is the one that decides it. A turn that asks for it must name++the person it is for; Actor refuses such a turn with `422 actor.tier.no_person` when it names nobody, so an++anonymous visitor's turn can never ask for the adult tier. The tier then rides every model call of the run as++a control field on AI's run, alongside `on_behalf_of`. AI reads it (`server/runs.js`), checks the named++person's 18+ confirmation on the account (`server/policy/adult-status.js`, a five-minute cache, failing++closed) and refuses the tier without it, in words. The hard floor — nothing sexual involving a minor — is++checked on the request, the answer and every streamed chunk, on every tier, and it never moves.++++Actor never sends the tier to a provider directly: the adult tier runs on AI, and a server without AI++configured refuses it (`actor.tier.unavailable`) rather than quietly answering on the standard tier.++++## Who the turn is for, and who pays++++Two callers exist in this slice, and they are not the same.++++**A signed-in person's own turn.** The caller sends that person's own Network bearer token, audience++`openvibe.actor`. Actor reads the person from the token itself. Nothing is exchanged and nobody is impersonated.++++**A first-party product acting for a person.** A product that holds the person's session (Exchange's companions)++sends the person's own bearer token. A product that does not hold one — Chat's inbox answers, where a visitor may++be anonymous — sends Chat's own service token with `X-OV-Subject: usr_…` naming the person. Actor accepts that++only for a first-party service token (`sub` begins `svc:` and `actor_type` is `service`); a developer app or an++agent may not name a person this way. The task is then the person's: their allowance pays for it, it appears in++their task list, and Actor records the service as `via`.++++**A turn with no person.** An anonymous visitor has no account, so the turn is the product's own: the service++token goes out with no `X-OV-Subject`, and the cost comes from the operator's cap. A service never borrows an++account for somebody who has none.++++Example: Exchange companion `cmp_chat_123`, turn `turn_456`, submits its assembled system context and current++history plus "Help me plan a quiet weekend." The allowed tools are empty for this plain companion answer, and its++per-turn ceiling is the companion's configured maximum. The task is owned and charged to the signed-in person's++Actor budget. Exchange stores the task id and renders Actor's answer, cost, refusal, or outage in its existing++receipt and error slots.++++Example: Chat's support inbox, conversation `cnv_…`, visitor signed in as `usr_…`, asks "Where is my order?" The++turn carries the inbox's instructions and pages, the conversation so far, and the visitor's words, with the tools++`read_page` (the inbox owner's own pages) and `time_in`. The visitor's subject id rides in `X-OV-Subject`, so the++turn is theirs and their allowance pays for it. An anonymous visitor's turn carries no subject: it is Chat's own++turn on Chat's cap.++++## Shared history++++Each product stores the Actor task id beside its conversation turn. The task is the canonical answer, event stream,++and cost record; another authorized site reads it through Actor's `actor.task.read` API using that person's own++token. Product-specific context and messages remain in their domain store until a later migration makes Actor the++conversation store. No copied model transcript becomes a second canonical history.++++A turn still running when the caller's wait ends is stored pending with the task id; the next view reads the task++back and fills the answer in. The words the visitor sees in the meantime say the answer is coming.++++## Failure behavior++++If Actor refuses the turn, the product shows Actor's refusal in plain words. If Actor is unavailable, the product++says the assistant could not answer right now and offers its existing human handoff or retry. It never retries++through OpenVibe.AI or another engine silently, and it never charges the visitor twice for a turn that did not++answer.++++## What this slice does not do++++- **The inbox's own actions.** A support inbox's `action_*` tools call the owner's own systems from Chat, signed++ with the action's secret. They stay a Chat-side tool until Actor can call them.++- **Pictures.** A turn is words, so a visitor's picture travels as its link and a note saying it is there.++- **Sampling.** A chat's temperature and preset stay saved on the chat; the loop's own sampling applies until a++ later slice carries them.++++## Next slices++++1. Move Vibe room conversations onto Actor tasks, retaining the room UI and its authorization rules.++2. Move Codes placement behind Actor while Codes continues to run work on the person's machine.++3. Let the inbox's actions ride a turn, so a visitor's order question is answered by the owner's own system.nnew file mode 100644@@@ -0,0 +1,9 @@++-- phase: expand++-- A product conversation turn answered by Actor (one-engine slice): which product conversation this task++-- answers, and the registry tool names the run may call (an empty list answers with no tool calls). Both++-- ride on the row so the run can honor them and a later query can find the turn's task. Nullable: older++-- tasks predate turns, and no read requires them.++ALTER TABLE tasks ADD COLUMN conversation jsonb;++ALTER TABLE tasks ADD COLUMN tools jsonb;++-- The model the product asked for (an exact model id, resolved by OpenVibe.AI like one the person picked).++ALTER TABLE tasks ADD COLUMN model text;nnew file mode 100644@@@ -0,0 +1,6 @@++-- phase: expand++-- A product conversation turn answered by Actor (one-engine slice): the content tier it answers under++-- (standard or uncensored). The uncensored tier is only for a task that names the person it is for, and++-- OpenVibe.AI checks that person's 18+ confirmation on the account before it answers on that tier. Nullable:++-- a task with no tier predates turns, and no read requires one.++ALTER TABLE tasks ADD COLUMN tier text;@@@ -15,7 +15,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.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-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.201.0","pg": "^8.23.0",@@@ -1712,9 +1712,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",@@@ -28,7 +28,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.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-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.201.0","pg": "^8.23.0",@@@ -79,7 +79,13 @@ const TABLES = [// A fork's parent step and the one change it was made with (stage V2-9), and how often it resumed.'fork_of_step', 'fork_change', 'resumes', 'agent_id', 'level', 'assignment_id',// The catalogue skill a task was started from (migration 0057): its slug, name and the subject's title.-- 'skill_slug', 'skill_name', 'skill_subject_title'],++ 'skill_slug', 'skill_name', 'skill_subject_title',++ // A product conversation turn (one-engine slice): which conversation it answers, the tools it may call and++ // the model it asked for. The person's own export says which of their turns ran where.++ 'conversation', 'tools', 'model',++ // The content tier the turn answered under: the person's own export says which of their turns were on++ // the adult tier.++ 'tier'],},// The person's own per-UTC-day spend accounting. Deleted: nothing in Actor keeps a person's spend record for a// reason that outlives them. The `*` row (everyone's daily ceiling) does not match `user:usr_…` and stays — it is@@@ -1047,7 +1047,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.}}-- async function run({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null, assign = null, closeAssignment = null, chatPolicy = null, approveChatTool = null, context = [], personText = null, agentId = null, notifyTask = null, askPerson = null, control = null, resume = null, fork = null, spent = null, waits = null, waitEvent = null, attachments = [], delta = null, personToken = null, timezone = null, triggers = null, level = null, latency = null, spend = null, day = null }) {++ async function run({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null, assign = null, closeAssignment = null, chatPolicy = null, approveChatTool = null, context = [], personText = null, agentId = null, notifyTask = null, askPerson = null, control = null, resume = null, fork = null, spent = null, waits = null, waitEvent = null, attachments = [], delta = null, personToken = null, timezone = null, triggers = null, level = null, latency = null, spend = null, day = null, allowedTools = null }) {if (resume || fork) {return await continueFrom({ task, requester, taskId, mode, signal, emit, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, chatPolicy, approveChatTool, context, personText, agentId, notifyTask, askPerson, control, resume, fork, spent, waits, waitEvent, delta, latency, spend, day });}@@@ -1143,6 +1143,17 @@ function createLoop({ config, models, registry, log = console, now = () => Date.const subAgents = mode === 'best' && ordered.tools.length > 0 && !(config.loop && config.loop.subAgents === false);state.tools = subAgents ? ordered.tools.concat([DELEGATE]) : ordered.tools;state.allow = new Set(state.tools.map((t) => t.function.name));++ // A product conversation turn names the registry tools the run may call (one-engine slice): intersect++ // here, so the model is never offered — and act() never runs — anything wider. A name nothing offers++ // is said aloud in the run's plan, never silently dropped. A resume or fork keeps the checkpoint's++ // allow-list, which this already narrowed.++ if (allowedTools) {++ const turnAllowed = new Set(allowedTools);++ const unknown = [...turnAllowed].filter((n) => !state.tools.some((t) => t.function.name === n));++ state.tools = state.tools.filter((t) => turnAllowed.has(t.function.name));++ state.allow = new Set(state.tools.map((t) => t.function.name));++ if (unknown.length) await emit({ step: 'plan', text: `The caller allowed ${unknown.length === 1 ? 'a tool' : 'tools'} this run does not offer (${unknown.slice(0, 8).join(', ')}): it runs without ${unknown.length === 1 ? 'it' : 'them'}.` });++ }state.messages.push({role: 'system',content: SYSTEM({@@@ -364,7 +364,7 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =* (x-openvibe-credential: <provider>:<subject>, the bare usr_… id AI expects — never "user:usr_…"). AI answers* with that key, or refuses: the person has no key for that provider yet, and the message says where to add it.*/-- async function chatViaAi({ choice, messages, tools, json, maxTokens, signal, onDelta = null }) {++ async function chatViaAi({ choice, messages, tools, json, maxTokens, signal, onDelta = null, policy = null }) {const tokens = aiTokenClient();if (!tokens) throw new ModelError('OpenVibe.AI is not configured on this server (no Network client secret)', { code: 'actor.model.unavailable' });const base = trimSlash(ai.url);@@@ -380,6 +380,12 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =temperature: 0.2,// L37: a wish, not a pin. The provider is the one the person's own key is for, so the wish holds.prefer: { provider, ...(choice.model ? { model: choice.model } : {}) },++ // The content tier (the Exchange remake's "uncensored"). AI checks the named person's 18+ confirmation++ // before it answers on that tier, and refuses the tier without one.++ ...(policy === 'uncensored' ? { policy: 'uncensored' } : {}),++ // The person the run is for (on_behalf_of): AI's content tier is decided about a person, never about a++ // token, so the name travels with the request.++ ...(choice.subject ? { on_behalf_of: { type: 'user', id: String(choice.subject) } } : {}),// The answer streams in (plan L55 C2): AI passes the upstream's pieces on as they arrive....(onDelta ? { stream: true, stream_options: { include_usage: true } } : {}),};@@@ -465,7 +471,7 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =* class than the route's own; AI clamps a faster one and says so. The classes live on AI's router, so a call that* goes through the person's own key asks for none.*/-- async function chatViaRouter({ tier, messages = [], tools = null, json = false, maxTokens = 4000, signal, onDelta = null, subject = null, target = null, latency = null }) {++ async function chatViaRouter({ tier, messages = [], tools = null, json = false, maxTokens = 4000, signal, onDelta = null, subject = null, target = null, latency = null, policy = null }) {const c = routerClient();if (!c) throw new ModelError('OpenVibe.AI is not configured on this server (no Network client secret)', { code: 'actor.model.unavailable' });const input = {@@@ -481,12 +487,18 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =workflow: 'ai.complete', input,...(subject ? { on_behalf_of: { type: 'user', id: String(subject) } } : {}),...(target ? { target } : {}),++ // The content tier (the Exchange remake's "uncensored"): a control field on the run, never part of the++ // workflow's input. AI checks the named person's 18+ confirmation before it answers on that tier, and++ // refuses the tier without one. A run with no person never asks for it.++ ...(policy === 'uncensored' ? { policy: 'uncensored' } : {}),};// A slow class may take minutes (background) or hours (batch): the wait and the deadline are longer, and the// answer is polled for until it is ready.const slow = latency === 'background' || latency === 'batch';const timeoutMs = slow ? Math.max(600_000, Number(ai.timeoutMs) || 180_000) : Math.max(5000, Number(ai.timeoutMs) || 180_000);-- const options = { cache: false, ...(latency ? { latency } : {}) };++ // The content tier rides in options.policy: AI reads it as a control field on the run (server/runs.js), the++ // same place the streamed path below puts it on the body it posts itself.++ const options = { cache: false, ...(latency ? { latency } : {}), ...(policy === 'uncensored' ? { policy: 'uncensored' } : {}) };body.options = options; // the streamed path posts this body itselfconst sig = typeof AbortSignal.any === 'function'? AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(timeoutMs)])@@@ -567,20 +579,29 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =}/** One model call. The caller checks the budget first and meters cost_usd after; this layer only prices it. */-- async function chat({ tier = 'smart', messages = null, tools = null, json = false, maxTokens = 4000, signal, avoidFamily = null, provider = null, model = null, onDelta = null, latency = null, subject = null } = {}) {++ async function chat({ tier = 'smart', messages = null, tools = null, json = false, maxTokens = 4000, signal, avoidFamily = null, provider = null, model = null, onDelta = null, latency = null, subject = null, policy = null } = {}) {// The person's own key (plan L55 C3): every model call of this run goes to OpenVibe.AI with it. That path has// no latency class: the classes live on AI's router.const choice = currentChoice();++ // The content tier of the run (one-engine slice): it rides on the choice, so every call of the run answers on++ // that tier. A caller may also name it directly.++ const policyOf = policy || (choice && choice.policy) || null;if (choice && choice.provider) {-- const r = await chatViaAi({ choice, messages, tools, json, maxTokens, signal, onDelta });++ const r = await chatViaAi({ choice, messages, tools, json, maxTokens, signal, onDelta, policy: policyOf });return { ...r, tier, provider: AI_PROVIDER, family: AI_PROVIDER, latency_class: null };}// The router (the AI layer): the tier names a policy on AI and the person's allowance pays. No provider key.if (routerOn() || (choice && choice.model && !choice.provider)) {-- const r = await chatViaRouter({ tier, messages, tools, json, maxTokens, signal, onDelta, subject: subject || (choice && choice.subject), target: choice && choice.target, latency });++ const r = await chatViaRouter({ tier, messages, tools, json, maxTokens, signal, onDelta, subject: subject || (choice && choice.subject), target: choice && choice.target, latency, policy: policyOf });return { ...r, tier, provider: AI_PROVIDER, family: AI_PROVIDER };}await warm();++ // The content tier never reaches a provider directly: the adult tier is OpenVibe.AI's, and AI is the one that++ // checks the person's 18+ confirmation and labels the answer. A run that asks for it and is not on AI is++ // refused, never quietly answered on the standard tier.++ if (policyOf === 'uncensored') {++ throw new ModelError('the uncensored tier runs on OpenVibe.AI, which is not configured on this server', { code: 'actor.tier.unavailable' });++ }if (provider && !PROVIDERS.includes(provider)) throw new ModelError(`unknown model provider "${provider}"`, { code: 'actor.model.provider' });const d = provider && model ? descriptor(tier, provider, model) : resolve(tier, { avoidFamily });if (!d.available) throw new ModelError(d.reason || `no model is available for the ${tier} tier`, { code: 'actor.model.unavailable' });@@@ -33,9 +33,60 @@ function untilMidnight(nowMs) {return Math.ceil((Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), n.getUTCDate() + 1) - n.getTime()) / 1000);}++const TURN_TOOL = /^[a-z][a-z0-9_]{1,63}$/;++// The content tiers a turn may answer under. The uncensored one is only for a task that names the person it is++// for, and OpenVibe.AI checks that person's 18+ confirmation on the account before it answers on that tier.++const TIERS = ['standard', 'uncensored'];++++/**++ * A product conversation turn (one-engine slice): which product conversation this task answers, the++ * registry tool names the run may call (an empty list answers with no tool calls) and the content tier it++ * answers under. The installed contracts package still forbids these fields, so they are lifted out before the++ * contracts check and validated here; the open OpenVibe.Contracts change owns the schema, and this shim dissolves++ * when the dependency passes it.++ * Shape only: a name the registry does not offer is reported in the run's plan, never silently dropped.++ */++function turnFields(turnTools, turnConversation, turnModel, turnTier) {++ let tools = null;++ if (turnTools != null) {++ if (!Array.isArray(turnTools) || turnTools.length > 64 || turnTools.some((n) => typeof n !== 'string' || !TURN_TOOL.test(n))) {++ return { error: { status: 422, code: 'actor.task.invalid', detail: 'tools: an array of at most 64 registry tool names (snake_case); an empty array answers with no tool calls' } };++ }++ tools = [...new Set(turnTools)];++ }++ let conversation = null;++ if (turnConversation != null) {++ const c = turnConversation;++ if (!c || typeof c !== 'object' || Array.isArray(c) || typeof c.product !== 'string' || !c.product.trim() || c.product.length > 64 ||++ typeof c.id !== 'string' || !c.id.trim() || c.id.length > 256 || Object.keys(c).some((k) => k !== 'product' && k !== 'id')) {++ return { error: { status: 422, code: 'actor.task.invalid', detail: 'conversation: { product, id }, the product conversation this task answers' } };++ }++ conversation = { product: c.product.trim().slice(0, 64), id: c.id.trim().slice(0, 256) };++ }++ let model = null;++ if (turnModel != null) {++ if (typeof turnModel !== 'string' || !turnModel.trim() || turnModel.length > 256) {++ return { error: { status: 422, code: 'actor.task.invalid', detail: 'model: an exact model id, at most 256 characters' } };++ }++ model = turnModel.trim().slice(0, 256);++ }++ let tier = null;++ if (turnTier != null) {++ const t = String(turnTier).trim().toLowerCase();++ if (!TIERS.includes(t)) {++ return { error: { status: 422, code: 'actor.task.invalid', detail: `tier: one of ${TIERS.join(', ')}` } };++ }++ tier = t;++ }++ return { tools, conversation, model, tier };++}++async function createTask({ s, config, engine, principal, body, webhooks: webhookQueue = null, beforeInsert = null, chatContext = null, afterInsert = null, parentId = null, forkOfStep = null, forkChange = null, assignmentOrigin = null, assignmentLevel = null, triggerId = null, personToken = null, timezone = null }) {-- const v = contracts.validate('actor.task-create-request@1', body);++ const { tools: turnTools, conversation: turnConversation, model: turnModel, tier: turnTier, ...contractBody } = body || {};++ const v = contracts.validate('actor.task-create-request@1', contractBody);if (!v.valid) return { status: 422, code: 'actor.task.invalid', detail: (v.errors || []).map((e) => `${e.path || e.instancePath || ''} ${e.message}`.trim()).join('; ').slice(0, 500) || 'invalid request' };++ const turn = turnFields(turnTools, turnConversation, turnModel, turnTier);++ if (turn.error) return turn.error;const tier = config.allowance;// A trigger's run is linked to its card. The trigger must belong to the same person the task is for; the// caller (Watch's delivery or the Run now route) already checked, and this check makes it true for every path.@@@ -57,6 +108,15 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhooreturn { status: 422, code: 'actor.agent.not_found', detail: 'agent_id names one of your own agents (GET /api/v1/agents).' };}const level = body.level || 'ask';++ // The content tier (one-engine slice): the uncensored tier is only for a task that names the person it is for.++ // A task with no person (a watch, a webhook, a service acting for an anonymous visitor) answers on the standard++ // tier. OpenVibe.AI checks that person's 18+ confirmation on the account before it answers on the adult tier,++ // so a person who has not confirmed is refused there, in words, and never silently served the adult tier.++ const person = String(principal.requester || '').startsWith('user:') ? principal.requester.slice(5) : null;++ const tier_ = turn.tier || 'standard';++ if (tier_ === 'uncensored' && !person) {++ return { status: 422, code: 'actor.tier.no_person', detail: 'the uncensored tier is for a person: this task names nobody, so it answers on the standard tier' };++ }// A budget the request named is the first word; without one, the task gets up to a tier task's worth and, below// that, what the day has left (so a cheap run leaves room for the next one).const requestedTaskUsd = body.budget && body.budget.per_task_usd != null ? body.budget.per_task_usd : null;@@@ -80,7 +140,7 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo}const ownSecret = webhooks.some((h) => !h.secret_ref);-- const hash = crypto.createHash('sha256').update(JSON.stringify({ task: body.task, mode: body.mode || 'balanced', budget: [perTask, perDay], agent: body.agent || null, ...(body.agent_id ? { agent_id: body.agent_id } : {}), ...(body.level ? { level: body.level } : {}), ...(webhooks.length ? { webhooks } : {}) })).digest('hex');++ const hash = crypto.createHash('sha256').update(JSON.stringify({ task: body.task, mode: body.mode || 'balanced', budget: [perTask, perDay], agent: body.agent || null, ...(body.agent_id ? { agent_id: body.agent_id } : {}), ...(body.level ? { level: body.level } : {}), ...(turn.tools != null ? { tools: turn.tools } : {}), ...(turn.conversation ? { conversation: turn.conversation } : {}), ...(turn.model ? { model: turn.model } : {}), ...(turn.tier ? { tier: turn.tier } : {}), ...(webhooks.length ? { webhooks } : {}) })).digest('hex');if (body.idempotency_key) {const prior = await store.byIdempotency(s, principal.requester, body.idempotency_key);if (prior) {@@@ -121,6 +181,9 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo// A task's own clock (Actor v2): 10 minutes by default; mode best may run up to an hour.timeout_ms: (body.mode || 'balanced') === 'best' ? config.bestTimeoutMs : config.taskTimeoutMs,agent_id: agentId, level,++ // A product conversation turn (one-engine slice): which conversation it answers, the tools it may++ // call, the model it asked for, and the content tier it answers under.++ conversation: turn.conversation, tools: turn.tools, model: turn.model, tier: turn.tier,});// What belongs with the task from its first moment (the chat's attached files), in the same transaction: the// engine starts it only after this commits, so the run never begins without them.@@@ -49,6 +49,16 @@ const LOOP_AGENT = 'openvibe-runtime';// What a task asked in a public chat may be (chat/asks.js): nothing that uses the person's own computers, robots or code.const PUBLIC_CLASSES = ['answer', 'lookup', 'research', 'web'];++/** A product conversation turn's allow-list as stored (one-engine slice): an array of registry tool names, or null. */++function parseTurnTools(value) {++ if (value == null) return null;++ try {++ const v = typeof value === 'string' ? JSON.parse(value) : value;++ if (!Array.isArray(v)) return null;++ return v.filter((n) => typeof n === 'string');++ } catch { return null; }++}++function createEngine({ config, s, adapters, stream, webhooks = null, now = () => Date.now(), log = console, models = null, loop = null, notifier = null, registry = null, checkpoints = null, waits: waitsArg = null, triggers: triggersArg = null }) {const running = new Map(); // id → AbortControllerconst waiting = [];@@@ -500,10 +510,14 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =const runsOn = row.mode === 'private' || fromChat || !String(row.requester || '').startsWith('user:')? 'openvibe' : await modelPrefs.runsOnFor(s, row.requester).catch(() => 'openvibe');++ // A product conversation turn (one-engine slice): the registry tool names the run may call, stored at++ // creation. A turn always runs in the loop below, the path that honors the list — a code-classified++ // turn must not escape to the Codes agent.++ const turnAllowed = parseTurnTools(row.tools);// Actor v2: every non-private task that is not code-only runs through the one loop. Private mode keeps// the local model with no network tools, and a coding task keeps the Codes agent (which the loop can also// reach as codes_task). Approval inbox, budgets, cancel, webhooks and the event stream are unchanged.-- if (loop && models && row.mode !== 'private' && (cls !== 'code' || chatPolicy || dayRun)) {++ if (loop && models && row.mode !== 'private' && (cls !== 'code' || chatPolicy || dayRun || turnAllowed)) {const agentId = LOOP_AGENT;// The person-authored part of the task (never a run input that may come from a watch or a webhook),// and the personal agent this run is for, so the memory tools scope their writes.@@@ -550,6 +564,15 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =const subject = String(row.requester || '').startsWith('user:') ? row.requester.slice(5) : null;if (subject) choice = { ...(choice || {}), subject };}++ // A product conversation turn may name its model (one-engine slice): it rides as the run's model++ // choice, resolved by OpenVibe.AI like an exact id the person picked themselves. A run the model++ // cannot serve fails with AI's own error, in words.++ if (typeof row.model === 'string' && row.model.trim()) choice = { ...(choice || {}), model: row.model.trim().slice(0, 256) };++ // The content tier of the turn (one-engine slice): it rides as the run's policy, so every model call++ // in the run answers on that tier. A turn that names nobody cannot ask for the uncensored tier++ // (create.js refuses it), and OpenVibe.AI checks the named person's 18+ confirmation before it++ // answers on the adult tier.++ if (row.tier === 'uncensored') choice = { ...(choice || {}), policy: 'uncensored' };try {// The conversation so far, and any canvas tick the person made since the last turn (HO9): a// tick starts no turn by itself, so the next run is told which item changed.@@@ -573,6 +596,8 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =spend: spendAllowance,// A routine's or a goal's run may wait (plan BA/HO): the class it asks AI's router for.latency: await latencyClass(id),++ // A product conversation turn (one-engine slice): the tools this run may call.++ allowedTools: turnAllowed,}));// The run parked (plan A2): the wait is open, so the task is not finished. It stays open// ('running' is the closest state there is today), its wall clock waits with it, and its@@@ -22,12 +22,13 @@ function subjectRef(requester) {async function insertTask(s, t) {await s.db.query(-- `INSERT INTO tasks (id, requester, project_id, task, mode, agent, budget_task, budget_day, state, idem_key, idem_hash, created_at, via, webhooks, webhook_secret, timeout_ms, parent_id, fork_of_step, fork_change, agent_id, level, assignment_id)-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'queued', $9, $10, $11, $12, $13::jsonb, $14, $15, $16, $17, $18::jsonb, $19, $20, $21)`,++ `INSERT INTO tasks (id, requester, project_id, task, mode, agent, budget_task, budget_day, state, idem_key, idem_hash, created_at, via, webhooks, webhook_secret, timeout_ms, parent_id, fork_of_step, fork_change, agent_id, level, assignment_id, conversation, tools, model, tier)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'queued', $9, $10, $11, $12, $13::jsonb, $14, $15, $16, $17, $18::jsonb, $19, $20, $21, $22::jsonb, $23::jsonb, $24, $25)`,[t.id, t.requester, t.project_id || null, t.task, t.mode, t.agent || null, t.budget_task, t.budget_day, t.idem_key || null, t.idem_hash || null, t.created_at, t.via || null,t.webhooks && t.webhooks.length ? JSON.stringify(t.webhooks) : null, t.webhook_secret || null, t.timeout_ms || null, t.parent_id || null,t.fork_of_step == null ? null : Number(t.fork_of_step), t.fork_change ? JSON.stringify(t.fork_change) : null,-- t.agent_id || null, t.level || 'ask', t.assignment_id || null]);++ t.agent_id || null, t.level || 'ask', t.assignment_id || null,++ t.conversation ? JSON.stringify(t.conversation) : null, t.tools != null ? JSON.stringify(t.tools) : null, t.model || null, t.tier || null]);await addSpend(s, t.requester, 0, { tasks: 1 });return getTask(s, t.id);}@@@ -106,6 +106,33 @@ const ok = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`);await assert.rejects(() => models.chat({ tier: 'smart', messages: [{ role: 'user', content: 'x' }] }), /model|provider/i);});++ await ok('the content tier rides the run to AI: uncensored reaches it with the person, standard never asks', async () => {++ const { fetchImpl, calls } = fakeAi();++ const models = createModels({ config: configLib.load(env), fetchImpl, log: quiet });++ await models.withChoice({ subject: USR, policy: 'uncensored' }, () => models.chat({ tier: 'smart', messages: [{ role: 'user', content: 'Say hello' }] }));++ const run = calls.find((c) => c.url.includes('/api/v1/runs'));++ assert.ok(run, 'a run was made');++ // AI reads the tier as a control field on the run, and decides about the person it names.++ assert.strictEqual(run.body.options && run.body.options.policy, 'uncensored', 'the tier reaches AI');++ assert.deepStrictEqual(run.body.on_behalf_of, { type: 'user', id: USR }, 'and the person it is for');++++ const plain = fakeAi();++ const models2 = createModels({ config: configLib.load(env), fetchImpl: plain.fetchImpl, log: quiet });++ await models2.withChoice({ subject: USR }, () => models2.chat({ tier: 'smart', messages: [{ role: 'user', content: 'Say hello' }] }));++ const run2 = plain.calls.find((c) => c.url.includes('/api/v1/runs'));++ assert.ok(!run2.body.options || run2.body.options.policy === undefined, 'a standard run never asks for the adult tier');++ });++++ await ok('the content tier never reaches a provider directly: without AI it is refused, not downgraded', async () => {++ const env2 = { ...env, ACTOR_AI_ROUTER: '0', ACTOR_DEEPSEEK_API_KEY: 'sk-test', ACTOR_LOCAL_LLM_URL: '' };++ const models = createModels({ config: configLib.load(env2), fetchImpl: fakeAi().fetchImpl, log: quiet });++ await assert.rejects(++ () => models.withChoice({ subject: USR, policy: 'uncensored' }, () => models.chat({ tier: 'smart', messages: [{ role: 'user', content: 'x' }] })),++ /uncensored|AI/i,++ 'the adult tier is OpenVibe.AI\'s, and it is said so'++ );++ });++if (results.length) { console.error(`${results.length} failed`); process.exit(1); }console.log('ai-router: all passed');})();nnew file mode 100644@@@ -0,0 +1,87 @@++'use strict';++/**++ * Time to first word, before and after (one-engine slice).++ *++ * Before: the product asked OpenVibe.AI directly for one answer (a chat.member.turn) and rendered the answer.++ * After: the product asks Actor for one turn, and Actor answers it (the same model behind its own loop).++ *++ * This measures both on the same machine, against the same stand-in provider, so the numbers compare:++ * - the old path: one model round trip (the provider's own latency),++ * - the new path: Actor's task from the moment the product asks, until the answer's first words arrive in the++ * task's live event stream (the moment a product would render them).++ *++ * Run: node test/bench-first-word.js++ */++const { boot } = require('./helpers/boot');++++/** The stand-in provider's own latency per call, so both paths pay the same price per model call. */++const MODEL_LATENCY_MS = Number(process.env.BENCH_MODEL_LATENCY_MS || 120);++++/** The old path: one direct model call, the way the products answered before. */++async function oldPath() {++ const started = Date.now();++ await new Promise((r) => setTimeout(r, MODEL_LATENCY_MS));++ return Date.now() - started;++}++++(async () => {++ const t = await boot();++ const kim = t.network.addUser('kim');++ const saved = t.config.loop.selfReflections;++ t.config.loop.selfReflections = 0; // the cheapest run: no reflection pass++ t.providers.reset();++ t.providers.state.script = Array.from({ length: 60 }, () => ({ content: 'A quiet weekend: the night train south, one good book, no plans before noon.' }));++++ /** The new path: an Actor turn, measured from the product's ask to the first words in the event stream. */++ const newPath = async (label) => {++ const started = Date.now();++ const r = await t.get('/api/v1/tasks', {++ as: kim, method: 'POST', json: {++ task: 'Companion Sable: "Help me plan a quiet weekend."',++ mode: 'cheapest', budget: { per_task_usd: 0.05 },++ conversation: { product: 'exchange', id: `bench-${label}` }, tools: [],++ }, headers: { 'sec-fetch-site': 'same-origin' },++ });++ if (r.status !== 201) throw new Error(`the task was refused: ${r.status} ${r.text}`);++ const id = r.json().id;++ // The live stream: the first text step is the answer's first words.++ let firstWord = null;++ const res = await fetch(`${t.base}/api/v1/tasks/${id}/events`, { headers: { cookie: t.signIn(kim), accept: 'text/event-stream' } });++ const reader = res.body.getReader();++ const decoder = new TextDecoder();++ let buf = '';++ for (;;) {++ const { value, done } = await reader.read();++ if (done) break;++ buf += decoder.decode(value, { stream: true });++ let idx;++ while ((idx = buf.indexOf('\n\n')) >= 0) {++ const chunk = buf.slice(0, idx);++ buf = buf.slice(idx + 2);++ for (const line of chunk.split('\n')) {++ if (!line.startsWith('data:')) continue;++ let e = null;++ try { e = JSON.parse(line.slice(5).trim()); } catch { continue; }++ if (e && e.step === 'text' && String(e.text || '').trim() && firstWord == null) firstWord = Date.now() - started;++ }++ }++ if (firstWord != null) { try { await reader.cancel(); } catch { /* */ } break; }++ }++ return { firstWord: firstWord == null ? Date.now() - started : firstWord };++ };++++ const oldMs = await oldPath();++ const runs = [];++ for (let i = 0; i < 5; i++) runs.push(await newPath(`r${i}`));++ t.config.loop.selfReflections = saved;++++ const avg = (a) => Math.round(a.reduce((s, x) => s + x, 0) / a.length);++ // The boot captures console.log, so the numbers go to stderr.++ process.stderr.write(JSON.stringify({++ modelLatencyMs: MODEL_LATENCY_MS,++ before: { directAiCallMs: oldMs },++ after: { firstWordMs: avg(runs.map((r) => r.firstWord)), firstWordRuns: runs.map((r) => r.firstWord) },++ }, null, 2) + '\n');++++ await t.close();++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,137 @@++'use strict';++/**++ * The two halves of the one-engine slice, together: Chat's inbox client asks a real OpenVibe.Actor for a turn, and++ * the turn reaches the loop with the visitor's subject, the conversation key and the tool list the product named.++ *++ * This is a local check across the two repositories (Actor's own test boot + Chat's client), run from the Actor++ * worktree with Chat's client on the path. It proves the shapes fit before either side ships.++ *++ * Run: node test/integration-inbox-turn.js (from the OpenVibe.Actor worktree; CHAT_DIR points at the Chat one)++ */++const path = require('path');++const { boot } = require('./helpers/boot');++++const CHAT_DIR = process.env.CHAT_DIR || path.join(__dirname, '..', '..', 'wt-one-engine-chat');++++(async () => {++ const t = await boot();++ const kim = t.network.addUser('kim');++ const subject = kim.subject || null;++++ // Chat's own client for Actor (server/inbox/actor.js), pointed at this Actor.++ const { createActor } = require(path.join(CHAT_DIR, 'server', 'inbox', 'actor'));++ const config = require(path.join(CHAT_DIR, 'server', 'config'));++ config.actor.internalUrl = t.base;++ config.oauth.clientSecret = 'actor-secret';++ config.oauth.clientId = 'chat';++ // Chat's service token for Actor: a first-party service token (sub svc:…, actor_type service), which is the++ // only kind Actor lets name a person in X-OV-Subject.++ const { serviceAuth } = require('openvibe-contracts');++ const crypto = require('crypto');++ const actorModule = require(path.join(CHAT_DIR, 'server', 'inbox', 'actor'));++ const now = Math.floor(Date.now() / 1000);++ const serviceToken = (subject) => serviceAuth.signServiceToken({++ iss: t.network.url, sub: 'svc:chat', actor_type: 'service', aud: ['openvibe.actor'],++ cap: ['actor.task.create', 'actor.task.read'], ns: [], iat: now, exp: now + 300,++ jti: crypto.randomUUID(), ...(subject ? { on_behalf_of: subject } : {}),++ }, t.network.privatePem);++ actorModule._setTokens({ getToken: async () => serviceToken(subject) });++++ const saved = t.config.loop.selfReflections;++ t.config.loop.selfReflections = 0;++ t.providers.reset();++ t.providers.state.script = [{ content: 'We ship in 3-5 days. https://example.com/faq' }];++++ const actor = createActor({ fetchImpl: globalThis.fetch });++ let out = null;++ try {++ out = await actor.turn({++ subject: null, // an anonymous visitor: the turn is Chat's own++ task: 'You are Vibe, the assistant answering for "Acme".\n\nThe conversation so far:\nThe visitor: When do you ship?\n\nThe visitor now says: When do you ship?',++ tools: ['read_page', 'time_in'],++ budgetUsd: 0.05,++ conversation: { product: 'chat', id: 'inbox:cnv_test/turn:1' },++ idempotencyKey: 'chat-inbox-cnv_test-1',++ });++ } finally {++ t.config.loop.selfReflections = saved;++ }++++ const ok = out && !out.pending && out.answer && /ship in 3-5 days/.test(out.answer);++ const anonTaskId = out && out.taskId;++ // The task row carries the conversation key and the tool list the product named.++ const row = await t.dbDump();++ const taskRow = row.split('\n').map((l) => { try { return JSON.parse(l); } catch { return null; } })++ .filter((r) => Array.isArray(r)).flat().find((r) => r && r.id === anonTaskId);++++ // A signed-in visitor: the turn is theirs. Their subject id rides in X-OV-Subject, which Actor checks against++ // the service token's grant. This is the identity path the slice had to open.++ const person = t.network.addUser('visitor');++ const personSubject = person.subject || null;++ const signed = serviceToken(personSubject);++ actorModule._setTokens({ getToken: async () => signed });++ t.providers.reset();++ t.providers.state.script = [{ content: 'Order 42 ships Tuesday.' }, { content: 'Order 42 ships Tuesday.' }, { content: 'Order 42 ships Tuesday.' }];++ let personOut = null;++ try {++ personOut = await actor.turn({++ subject: personSubject,++ task: 'You are Vibe, the assistant answering for "Acme".\n\nThe conversation so far:\nThe visitor: Where is my order?',++ tools: ['read_page'],++ budgetUsd: 0.05,++ conversation: { product: 'chat', id: 'inbox:cnv_person/turn:1' },++ idempotencyKey: 'chat-inbox-cnv_person-1',++ });++ } catch (err) {++ process.stderr.write(JSON.stringify({ ok: false, personError: String(err && err.message) }, null, 2) + '\n');++ await t.close();++ process.exit(1);++ }++ // Whose task is it, and whose allowance paid for it?++ const personTask = await t.dbDump();++ const personRows = personTask.split('\n').map((l) => { try { return JSON.parse(l); } catch { return null; } })++ .filter((r) => Array.isArray(r)).flat();++ const personRow = personRows.find((r) => r && r.id === personOut.taskId);++ const personSpend = personRows.filter((r) => r && r.requester === `user:${personSubject}` && typeof r.usd === 'number');++ const serviceSpend = personRows.filter((r) => r && r.requester && String(r.requester).startsWith('service:') && typeof r.usd === 'number');++++ // The content tier of a turn (one-engine slice): a turn that names nobody may not ask for the adult tier.++ // Chat's own client never asks for it (a support inbox's visitor is not a person who confirmed 18+), so this++ // goes to Actor's API the way a product that may ask for it would.++ let adultRefused = null;++ try {++ const r = await fetch(`${t.base}/api/v1/tasks`, {++ method: 'POST',++ headers: { 'content-type': 'application/json', authorization: `Bearer ${serviceToken(null)}` },++ body: JSON.stringify({++ task: 'An anonymous visitor asks for the adult tier.', tier: 'uncensored',++ conversation: { product: 'chat', id: 'inbox:cnv_anon/turn:adult' }, tools: [],++ }),++ });++ const body = await r.json().catch(() => null);++ adultRefused = `${r.status} ${(body && body.code) || ''} ${(body && body.detail) || ''}`;++ } catch (err) {++ adultRefused = `error ${String((err && err.message) || err)}`;++ }++++ process.stderr.write(JSON.stringify({++ ok: Boolean(ok),++ answer: out && out.answer,++ costUsd: out && out.costUsd,++ taskId: anonTaskId,++ storedConversation: taskRow && taskRow.conversation,++ storedTools: taskRow && taskRow.tools,++ anonymousAdultTier: adultRefused,++ signedIn: {++ ok: Boolean(personOut && !personOut.pending && personOut.answer),++ answer: personOut && personOut.answer,++ requester: personRow && personRow.requester,++ via: personRow && personRow.via,++ personSpendUsd: personSpend.reduce((s, r) => s + Number(r.usd), 0),++ serviceSpendUsd: serviceSpend.reduce((s, r) => s + Number(r.usd), 0),++ },++ }, null, 2) + '\n');++++ await t.close();++ const adultOk = /actor\.tier\.no_person/.test(String(adultRefused || '')); process.exit(ok && personOut && !personOut.pending && personOut.answer && adultOk ? 0 : 1);++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,221 @@++'use strict';++/**++ * A product conversation turn (one-engine slice): a product has Actor answer one turn of its own++ * conversation with the person's own allowance. The call carries which conversation it answers, the++ * model, and the tools the run may call; an empty tool list answers with no tool calls. The cost is++ * charged once, to the person the task is for; a refusal or an outage arrives as plain words, never a++ * silent second engine.++ */++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++++const valid = (task) => { const v = contracts.validate('platform.task@1', task); assert.ok(v.valid, JSON.stringify(v.errors)); };++const SAME = { 'sec-fetch-site': 'same-origin' };++/** Every dumped table: dbDump prints one JSON array per table. */++const tableRows = async (t) => {++ const out = [];++ for (const l of (await t.dbDump()).split('\n')) {++ try { const r = JSON.parse(l); if (Array.isArray(r) && r.length) out.push(r); } catch { /* not a row list */ }++ }++ return out;++};++const findTable = (tables, pred) => tables.find((rows) => rows.some(pred));++++(async () => {++ const t = await boot();++ const kim = t.network.addUser('kim');++ const create = (json, o = {}) => t.get('/api/v1/tasks', { as: o.as || kim, json, headers: { ...SAME, ...(o.headers || {}) } });++++ await check('a turn with no tools answers directly, charges once, and stores its conversation', async () => {++ t.providers.reset();++ const saved = t.config.loop.selfReflections;++ t.config.loop.selfReflections = 0;++ t.providers.state.script = [{ content: 'A quiet weekend: the night train south, one good book, no plans before noon.' }];++ try {++ const turn = {++ task: 'Companion Sable: "Help me plan a quiet weekend."',++ mode: 'cheapest',++ budget: { per_task_usd: 0.05 },++ idempotency_key: 'exchange-companion-chat1-turn1',++ conversation: { product: 'exchange', id: 'cmp_chat_1' },++ tools: [],++ };++ const r = await create(turn);++ assert.strictEqual(r.status, 201, r.text);++ const created = r.json();++ valid(created);++ await t.waitFor(created.id);++ const task = (await t.get(`/api/v1/tasks/${created.id}`, { as: kim })).json();++ valid(task);++ assert.strictEqual(task.state, 'succeeded', JSON.stringify(task.error));++ assert.match(task.result.answer, /quiet weekend/);++ assert.ok(task.cost.usd > 0, 'the turn spent something');++ assert.strictEqual(task.cost.free_allowance_used, task.cost.usd, 'the free allowance paid it');++ // No tool ran anywhere: the answer came from the model alone.++ assert.ok(!t.providers.requests.some((q) => q.path.startsWith('/tools/') || q.path.startsWith('/search/')), 'no tool call left the server');++ // The turn is stored on the row, beside the conversation the product keeps.++ const tables = await tableRows(t);++ const tasks = findTable(tables, (row) => row.id === created.id);++ assert.ok(tasks, 'the task row is in the dump');++ const row = tasks.find((row) => row.id === created.id);++ assert.deepStrictEqual(row.conversation, { product: 'exchange', id: 'cmp_chat_1' });++ assert.deepStrictEqual(row.tools, []);++ assert.strictEqual(row.model, null, 'no model picked: Actor answers on its default');++ assert.strictEqual(row.requester, task.requester.type === 'user' ? `user:${task.requester.id}` : row.requester);++ // Charged once: the day's spend for the person equals what the task cost.++ const spends = findTable(tables, (row) => typeof row.usd === 'number' && 'day' in row);++ assert.ok(spends, 'the spend table is in the dump');++ const spent = spends.filter((r) => String(r.requester) === String(row.requester)).reduce((n, r) => n + Number(r.usd), 0);++ assert.strictEqual(Math.round(spent * 1e6), Math.round(Number(task.cost.usd) * 1e6), 'one charge, not two');++ } finally { t.config.loop.selfReflections = saved; }++ });++++ await check('an idempotent turn repeats the first task; different tools are a different task', async () => {++ t.providers.reset();++ const saved = t.config.loop.selfReflections;++ t.config.loop.selfReflections = 0;++ t.providers.state.script = [{ content: 'First answer.' }, { content: 'Second answer.' }];++ try {++ const base = { task: 'Inbox turn one.', mode: 'cheapest', idempotency_key: 'chat-inbox-7-99', conversation: { product: 'chat', id: 'inbox-7' }, tools: [] };++ const first = (await create(base)).json();++ assert.strictEqual(first.state, 'queued');++ const repeat = await create(base);++ assert.strictEqual(repeat.status, 200);++ assert.strictEqual(repeat.json().id, first.id, 'the same turn answers the same task');++ const other = await create({ ...base, tools: ['read_page'] });++ assert.strictEqual(other.status, 409, other.text);++ assert.strictEqual(other.json().code, 'actor.idempotency.conflict');++ } finally { t.config.loop.selfReflections = saved; }++ });++++ await check('a tool name nothing offers is said aloud in the run, never silently dropped', async () => {++ t.providers.reset();++ const saved = t.config.loop.selfReflections;++ t.config.loop.selfReflections = 0;++ t.providers.state.script = [{ content: 'Answered without it.' }];++ try {++ const r = await create({ task: 'Inbox turn two.', mode: 'cheapest', idempotency_key: 'chat-inbox-7-100', conversation: { product: 'chat', id: 'inbox-7' }, tools: ['read_page', 'no_such_tool'] });++ assert.strictEqual(r.status, 201, r.text);++ await t.waitFor(r.json().id);++ assert.ok(!t.providers.requests.some((q) => q.path.startsWith('/tools/')), 'nothing ran anyway');++ const events = (await t.get(`/api/v1/tasks/${r.json().id}/events`, { as: kim })).text;++ assert.match(events, /no_such_tool/, 'the run says which allowed name it does not offer');++ } finally { t.config.loop.selfReflections = saved; }++ });++++ await check('a first-party service may answer a turn for a person: the turn is theirs, on their allowance', async () => {++ const { serviceAuth } = contracts;++ const crypto = require('crypto');++ const now = Math.floor(Date.now() / 1000);++ const mint = (sub, actorType, cap, extra = {}) => serviceAuth.signServiceToken({ iss: t.network.url, sub, actor_type: actorType, aud: ['openvibe.actor'], cap, ns: [], iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}`, ...extra }, t.network.privatePem);++ const chat = mint('svc:chat', 'service', ['actor.task.create', 'actor.task.read']);++ const visitor = t.network.addUser('visitor');++ t.providers.reset();++ const saved = t.config.loop.selfReflections;++ t.config.loop.selfReflections = 0;++ t.providers.state.script = [{ content: 'Order 42 ships Tuesday.' }];++ try {++ const r = await t.get('/api/v1/tasks', { bearer: chat, headers: { 'X-OV-Subject': visitor.subject }, json: {++ task: 'You are Vibe, the assistant answering for "Acme".\n\nThe conversation so far:\nThe visitor: Where is my order?',++ mode: 'cheapest', budget: { per_task_usd: 0.05 },++ conversation: { product: 'chat', id: 'inbox:cnv_1/turn:1' }, tools: ['read_page'],++ idempotency_key: 'chat-inbox-cnv_1-1',++ } });++ assert.strictEqual(r.status, 201, r.text);++ // The turn is the person's: their allowance pays for it, and it is their task.++ assert.deepStrictEqual(r.json().requester, { type: 'user', id: visitor.subject }, 'the person\'s turn');++ const row = await require('../server/tasks/store').getTask(t.ctx.s, r.json().id);++ assert.strictEqual(row.via, 'service:chat', 'and it records who asked for it');++ assert.deepStrictEqual(row.conversation, { product: 'chat', id: 'inbox:cnv_1/turn:1' });++ assert.deepStrictEqual(row.tools, ['read_page']);++ await t.waitFor(r.json().id);++ // Their day's spend is the turn's cost: charged once, to the person, not to the service.++ const store = require('../server/tasks/store');++ const days = await store.spendDays(t.ctx.s, `user:${visitor.subject}`, 30);++ const spent = days.reduce((n, d) => n + Number(d.usd || 0), 0);++ assert.ok(spent > 0, 'the person paid for their turn');++ const task = (await t.get(`/api/v1/tasks/${r.json().id}`, { as: visitor })).json();++ assert.ok(task.cost && Number(task.cost.usd) > 0, 'and the task says what it cost');++ assert.ok(Math.abs(spent - Number(task.cost.usd)) < 1e-6, 'charged once, to the person');++ } finally { t.config.loop.selfReflections = saved; }++ });++++ await check('a turn may name the content tier, and it is stored on the task', async () => {++ t.providers.reset();++ const saved = t.config.loop.selfReflections;++ t.config.loop.selfReflections = 0;++ t.providers.state.script = [{ content: 'An answer on the adult tier.' }];++ try {++ const r = await create({++ task: 'Companion Sable: "Say hello."', mode: 'cheapest', budget: { per_task_usd: 0.05 },++ idempotency_key: 'exchange-companion-adult-1',++ conversation: { product: 'exchange', id: 'cmp_adult_1' }, tools: [], tier: 'uncensored',++ });++ assert.strictEqual(r.status, 201, r.text);++ await t.waitFor(r.json().id);++ const tables = await tableRows(t);++ const tasks = findTable(tables, (row) => row.id === r.json().id);++ const row = tasks.find((row) => row.id === r.json().id);++ assert.strictEqual(row.tier, 'uncensored', 'the tier is on the task, beside the conversation');++ // The floor is unchanged: the tier only decides which offers may answer.++ assert.strictEqual(row.conversation.product, 'exchange');++ } finally { t.config.loop.selfReflections = saved; }++ });++++ await check('a turn that names nobody may not ask for the uncensored tier', async () => {++ const { serviceAuth } = contracts;++ const crypto = require('crypto');++ const now = Math.floor(Date.now() / 1000);++ // A service acting for nobody: no X-OV-Subject, so the task has no person.++ const svc = serviceAuth.signServiceToken({ iss: t.network.url, sub: 'svc:chat', actor_type: 'service', aud: ['openvibe.actor'], cap: ['actor.task.create'], ns: [], iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, t.network.privatePem);++ const r = await t.get('/api/v1/tasks', { bearer: svc, json: {++ task: 'An anonymous visitor asks for the adult tier.', tier: 'uncensored', conversation: { product: 'chat', id: 'inbox:cnv_anon/turn:1' }, tools: [],++ } });++ assert.strictEqual(r.status, 422, r.text);++ assert.strictEqual(r.json().code, 'actor.tier.no_person');++ assert.match(r.json().detail, /names nobody|standard tier/);++ });++++ await check('an unknown content tier is refused in plain words', async () => {++ const r = await create({ task: 'Hi.', tier: 'adult' });++ assert.strictEqual(r.status, 422);++ assert.strictEqual(r.json().code, 'actor.task.invalid');++ assert.match(r.json().detail, /tier/);++ });++++ await check('malformed turn fields are refused in plain words', async () => {++ const badTools = await create({ task: 'Hi.', tools: 'read_page' });++ assert.strictEqual(badTools.status, 422);++ assert.strictEqual(badTools.json().code, 'actor.task.invalid');++ assert.match(badTools.json().detail, /tools/);++ const badConv = await create({ task: 'Hi.', conversation: { product: 'exchange' } });++ assert.strictEqual(badConv.status, 422);++ assert.match(badConv.json().detail, /conversation/);++ const badModel = await create({ task: 'Hi.', model: '' });++ assert.strictEqual(badModel.status, 422);++ assert.match(badModel.json().detail, /model/);++ });++++ await check('a refusal arrives as words with its code', async () => {++ const r = await create({ task: 'Hi.', budget: { per_task_usd: 50 } });++ assert.strictEqual(r.status, 422);++ assert.strictEqual(r.json().code, 'actor.budget.over_tier');++ assert.match(r.json().detail, /free tier allows/);++ });++++ await check('a model this server cannot serve fails in words, never silently', async () => {++ t.providers.reset();++ const r = await create({ task: 'Answer this.', mode: 'cheapest', model: 'openvibe/auto', idempotency_key: 'chat-inbox-7-101', conversation: { product: 'chat', id: 'inbox-7' }, tools: [] });++ assert.strictEqual(r.status, 201, r.text);++ await t.waitFor(r.json().id);++ const task = (await t.get(`/api/v1/tasks/${r.json().id}`, { as: kim })).json();++ assert.strictEqual(task.state, 'failed', JSON.stringify(task.result));++ assert.strictEqual(task.error.code, 'actor.model.unavailable');++ assert.match(task.error.detail, /OpenVibe\.AI is not configured/);++ assert.ok(task.cost.usd < 0.01, 'only the classify-and-plan calls spent');++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -0,0 +1,96 @@
+# One engine: first slice
+
+Products that need an assistant answer submit a turn to Actor. Actor owns model selection, tool execution, safety
+checks, metering, and the durable task history. A product owns its surface and its domain record, then links that
+record to the Actor task. This slice is Exchange's companions and Chat's support inboxes.
+
+## The turn call
+
+One call, `POST /api/v1/tasks` on OpenVibe.Actor, then `GET /api/v1/tasks/:id` until the task ends.
+
+The body is `actor.task-create-request@1` plus three optional fields this slice adds:
+
+- `conversation`: `{ product, id }` — which product conversation the task answers. Stored on the task row, so a later
+ query finds the turn's task.
+- `tools`: the registry tool names the run may call. An empty array answers with no tool calls. A name the registry
+ does not offer is said aloud in the run's plan, never silently dropped.
+- `model`: one exact model id, resolved by OpenVibe.AI like one the person picked themselves.
+- `tier`: the content tier, `standard` or `uncensored`.
+
+Alongside the words, the body carries `budget.per_task_usd` (what this one turn may cost) and an
+`idempotency_key`, so a repeat of the same turn is the same task and a different turn is refused with
+`409 actor.idempotency.conflict` rather than answered twice.
+
+## The content tier
+
+The uncensored tier is OpenVibe.AI's, and AI is the one that decides it. A turn that asks for it must name
+the person it is for; Actor refuses such a turn with `422 actor.tier.no_person` when it names nobody, so an
+anonymous visitor's turn can never ask for the adult tier. The tier then rides every model call of the run as
+a control field on AI's run, alongside `on_behalf_of`. AI reads it (`server/runs.js`), checks the named
+person's 18+ confirmation on the account (`server/policy/adult-status.js`, a five-minute cache, failing
+closed) and refuses the tier without it, in words. The hard floor — nothing sexual involving a minor — is
+checked on the request, the answer and every streamed chunk, on every tier, and it never moves.
+
+Actor never sends the tier to a provider directly: the adult tier runs on AI, and a server without AI
+configured refuses it (`actor.tier.unavailable`) rather than quietly answering on the standard tier.
+
+## Who the turn is for, and who pays
+
+Two callers exist in this slice, and they are not the same.
+
+**A signed-in person's own turn.** The caller sends that person's own Network bearer token, audience
+`openvibe.actor`. Actor reads the person from the token itself. Nothing is exchanged and nobody is impersonated.
+
+**A first-party product acting for a person.** A product that holds the person's session (Exchange's companions)
+sends the person's own bearer token. A product that does not hold one — Chat's inbox answers, where a visitor may
+be anonymous — sends Chat's own service token with `X-OV-Subject: usr_…` naming the person. Actor accepts that
+only for a first-party service token (`sub` begins `svc:` and `actor_type` is `service`); a developer app or an
+agent may not name a person this way. The task is then the person's: their allowance pays for it, it appears in
+their task list, and Actor records the service as `via`.
+
+**A turn with no person.** An anonymous visitor has no account, so the turn is the product's own: the service
+token goes out with no `X-OV-Subject`, and the cost comes from the operator's cap. A service never borrows an
+account for somebody who has none.
+
+Example: Exchange companion `cmp_chat_123`, turn `turn_456`, submits its assembled system context and current
+history plus "Help me plan a quiet weekend." The allowed tools are empty for this plain companion answer, and its
+per-turn ceiling is the companion's configured maximum. The task is owned and charged to the signed-in person's
+Actor budget. Exchange stores the task id and renders Actor's answer, cost, refusal, or outage in its existing
+receipt and error slots.
+
+Example: Chat's support inbox, conversation `cnv_…`, visitor signed in as `usr_…`, asks "Where is my order?" The
+turn carries the inbox's instructions and pages, the conversation so far, and the visitor's words, with the tools
+`read_page` (the inbox owner's own pages) and `time_in`. The visitor's subject id rides in `X-OV-Subject`, so the
+turn is theirs and their allowance pays for it. An anonymous visitor's turn carries no subject: it is Chat's own
+turn on Chat's cap.
+
+## Shared history
+
+Each product stores the Actor task id beside its conversation turn. The task is the canonical answer, event stream,
+and cost record; another authorized site reads it through Actor's `actor.task.read` API using that person's own
+token. Product-specific context and messages remain in their domain store until a later migration makes Actor the
+conversation store. No copied model transcript becomes a second canonical history.
+
+A turn still running when the caller's wait ends is stored pending with the task id; the next view reads the task
+back and fills the answer in. The words the visitor sees in the meantime say the answer is coming.
+
+## Failure behavior
+
+If Actor refuses the turn, the product shows Actor's refusal in plain words. If Actor is unavailable, the product
+says the assistant could not answer right now and offers its existing human handoff or retry. It never retries
+through OpenVibe.AI or another engine silently, and it never charges the visitor twice for a turn that did not
+answer.
+
+## What this slice does not do
+
+- **The inbox's own actions.** A support inbox's `action_*` tools call the owner's own systems from Chat, signed
+ with the action's secret. They stay a Chat-side tool until Actor can call them.
+- **Pictures.** A turn is words, so a visitor's picture travels as its link and a note saying it is there.
+- **Sampling.** A chat's temperature and preset stay saved on the chat; the loop's own sampling applies until a
+ later slice carries them.
+
+## Next slices
+
+1. Move Vibe room conversations onto Actor tasks, retaining the room UI and its authorization rules.
+2. Move Codes placement behind Actor while Codes continues to run work on the person's machine.
+3. Let the inbox's actions ride a turn, so a visitor's order question is answered by the owner's own system.
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+-- phase: expand
+-- A product conversation turn answered by Actor (one-engine slice): which product conversation this task
+-- answers, and the registry tool names the run may call (an empty list answers with no tool calls). Both
+-- ride on the row so the run can honor them and a later query can find the turn's task. Nullable: older
+-- tasks predate turns, and no read requires them.
+ALTER TABLE tasks ADD COLUMN conversation jsonb;
+ALTER TABLE tasks ADD COLUMN tools jsonb;
+-- The model the product asked for (an exact model id, resolved by OpenVibe.AI like one the person picked).
+ALTER TABLE tasks ADD COLUMN model text;
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
+-- phase: expand
+-- A product conversation turn answered by Actor (one-engine slice): the content tier it answers under
+-- (standard or uncensored). The uncensored tier is only for a task that names the person it is for, and
+-- OpenVibe.AI checks that person's 18+ confirmation on the account before it answers on that tier. Nullable:
+-- a task with no tier predates turns, and no read requires one.
+ALTER TABLE tasks ADD COLUMN tier text;
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.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-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.201.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.201.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -1712,9 +1712,9 @@
@@ -1712,9 +1712,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",
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.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-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.201.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.201.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -79,7 +79,13 @@ const TABLES = [
@@ -79,7 +79,13 @@ const TABLES = [
// A fork's parent step and the one change it was made with (stage V2-9), and how often it resumed.
// A fork's parent step and the one change it was made with (stage V2-9), and how often it resumed.
'fork_of_step', 'fork_change', 'resumes', 'agent_id', 'level', 'assignment_id',
'fork_of_step', 'fork_change', 'resumes', 'agent_id', 'level', 'assignment_id',
// The catalogue skill a task was started from (migration 0057): its slug, name and the subject's title.
// The catalogue skill a task was started from (migration 0057): its slug, name and the subject's title.
- 'skill_slug', 'skill_name', 'skill_subject_title'],
+ 'skill_slug', 'skill_name', 'skill_subject_title',
+ // A product conversation turn (one-engine slice): which conversation it answers, the tools it may call and
+ // the model it asked for. The person's own export says which of their turns ran where.
+ 'conversation', 'tools', 'model',
+ // The content tier the turn answered under: the person's own export says which of their turns were on
+ // the adult tier.
+ 'tier'],
},
},
// The person's own per-UTC-day spend accounting. Deleted: nothing in Actor keeps a person's spend record for a
// The person's own per-UTC-day spend accounting. Deleted: nothing in Actor keeps a person's spend record for a
// reason that outlives them. The `*` row (everyone's daily ceiling) does not match `user:usr_…` and stays — it is
// reason that outlives them. The `*` row (everyone's daily ceiling) does not match `user:usr_…` and stays — it is
@@ -1047,7 +1047,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -1047,7 +1047,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
}
}
}
}
- async function run({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null, assign = null, closeAssignment = null, chatPolicy = null, approveChatTool = null, context = [], personText = null, agentId = null, notifyTask = null, askPerson = null, control = null, resume = null, fork = null, spent = null, waits = null, waitEvent = null, attachments = [], delta = null, personToken = null, timezone = null, triggers = null, level = null, latency = null, spend = null, day = null }) {
+ async function run({ task, requester = null, taskId = null, mode = 'balanced', signal, emit, meter, budgetLeft, waitForPerson = null, requestApproval = null, assign = null, closeAssignment = null, chatPolicy = null, approveChatTool = null, context = [], personText = null, agentId = null, notifyTask = null, askPerson = null, control = null, resume = null, fork = null, spent = null, waits = null, waitEvent = null, attachments = [], delta = null, personToken = null, timezone = null, triggers = null, level = null, latency = null, spend = null, day = null, allowedTools = null }) {
if (resume || fork) {
if (resume || fork) {
return await continueFrom({ task, requester, taskId, mode, signal, emit, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, chatPolicy, approveChatTool, context, personText, agentId, notifyTask, askPerson, control, resume, fork, spent, waits, waitEvent, delta, latency, spend, day });
return await continueFrom({ task, requester, taskId, mode, signal, emit, meter, budgetLeft, waitForPerson, requestApproval, assign, closeAssignment, chatPolicy, approveChatTool, context, personText, agentId, notifyTask, askPerson, control, resume, fork, spent, waits, waitEvent, delta, latency, spend, day });
}
}
@@ -1143,6 +1143,17 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -1143,6 +1143,17 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
const subAgents = mode === 'best' && ordered.tools.length > 0 && !(config.loop && config.loop.subAgents === false);
const subAgents = mode === 'best' && ordered.tools.length > 0 && !(config.loop && config.loop.subAgents === false);
state.tools = subAgents ? ordered.tools.concat([DELEGATE]) : ordered.tools;
state.tools = subAgents ? ordered.tools.concat([DELEGATE]) : ordered.tools;
state.allow = new Set(state.tools.map((t) => t.function.name));
state.allow = new Set(state.tools.map((t) => t.function.name));
+ // A product conversation turn names the registry tools the run may call (one-engine slice): intersect
+ // here, so the model is never offered — and act() never runs — anything wider. A name nothing offers
+ // is said aloud in the run's plan, never silently dropped. A resume or fork keeps the checkpoint's
+ // allow-list, which this already narrowed.
+ if (allowedTools) {
+ const turnAllowed = new Set(allowedTools);
+ const unknown = [...turnAllowed].filter((n) => !state.tools.some((t) => t.function.name === n));
+ state.tools = state.tools.filter((t) => turnAllowed.has(t.function.name));
+ state.allow = new Set(state.tools.map((t) => t.function.name));
+ if (unknown.length) await emit({ step: 'plan', text: `The caller allowed ${unknown.length === 1 ? 'a tool' : 'tools'} this run does not offer (${unknown.slice(0, 8).join(', ')}): it runs without ${unknown.length === 1 ? 'it' : 'them'}.` });
+ }
state.messages.push({
state.messages.push({
role: 'system',
role: 'system',
content: SYSTEM({
content: SYSTEM({
@@ -364,7 +364,7 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
@@ -364,7 +364,7 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
* (x-openvibe-credential: <provider>:<subject>, the bare usr_… id AI expects — never "user:usr_…"). AI answers
* (x-openvibe-credential: <provider>:<subject>, the bare usr_… id AI expects — never "user:usr_…"). AI answers
* with that key, or refuses: the person has no key for that provider yet, and the message says where to add it.
* with that key, or refuses: the person has no key for that provider yet, and the message says where to add it.
*/
*/
- async function chatViaAi({ choice, messages, tools, json, maxTokens, signal, onDelta = null }) {
+ async function chatViaAi({ choice, messages, tools, json, maxTokens, signal, onDelta = null, policy = null }) {
const tokens = aiTokenClient();
const tokens = aiTokenClient();
if (!tokens) throw new ModelError('OpenVibe.AI is not configured on this server (no Network client secret)', { code: 'actor.model.unavailable' });
if (!tokens) throw new ModelError('OpenVibe.AI is not configured on this server (no Network client secret)', { code: 'actor.model.unavailable' });
const base = trimSlash(ai.url);
const base = trimSlash(ai.url);
@@ -380,6 +380,12 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
@@ -380,6 +380,12 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
temperature: 0.2,
temperature: 0.2,
// L37: a wish, not a pin. The provider is the one the person's own key is for, so the wish holds.
// L37: a wish, not a pin. The provider is the one the person's own key is for, so the wish holds.
prefer: { provider, ...(choice.model ? { model: choice.model } : {}) },
prefer: { provider, ...(choice.model ? { model: choice.model } : {}) },
+ // The content tier (the Exchange remake's "uncensored"). AI checks the named person's 18+ confirmation
+ // before it answers on that tier, and refuses the tier without one.
+ ...(policy === 'uncensored' ? { policy: 'uncensored' } : {}),
+ // The person the run is for (on_behalf_of): AI's content tier is decided about a person, never about a
+ // token, so the name travels with the request.
+ ...(choice.subject ? { on_behalf_of: { type: 'user', id: String(choice.subject) } } : {}),
// The answer streams in (plan L55 C2): AI passes the upstream's pieces on as they arrive.
// The answer streams in (plan L55 C2): AI passes the upstream's pieces on as they arrive.
...(onDelta ? { stream: true, stream_options: { include_usage: true } } : {}),
...(onDelta ? { stream: true, stream_options: { include_usage: true } } : {}),
};
};
@@ -465,7 +471,7 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
@@ -465,7 +471,7 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
* class than the route's own; AI clamps a faster one and says so. The classes live on AI's router, so a call that
* class than the route's own; AI clamps a faster one and says so. The classes live on AI's router, so a call that
* goes through the person's own key asks for none.
* goes through the person's own key asks for none.
*/
*/
- async function chatViaRouter({ tier, messages = [], tools = null, json = false, maxTokens = 4000, signal, onDelta = null, subject = null, target = null, latency = null }) {
+ async function chatViaRouter({ tier, messages = [], tools = null, json = false, maxTokens = 4000, signal, onDelta = null, subject = null, target = null, latency = null, policy = null }) {
const c = routerClient();
const c = routerClient();
if (!c) throw new ModelError('OpenVibe.AI is not configured on this server (no Network client secret)', { code: 'actor.model.unavailable' });
if (!c) throw new ModelError('OpenVibe.AI is not configured on this server (no Network client secret)', { code: 'actor.model.unavailable' });
const input = {
const input = {
@@ -481,12 +487,18 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
@@ -481,12 +487,18 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
workflow: 'ai.complete', input,
workflow: 'ai.complete', input,
...(subject ? { on_behalf_of: { type: 'user', id: String(subject) } } : {}),
...(subject ? { on_behalf_of: { type: 'user', id: String(subject) } } : {}),
...(target ? { target } : {}),
...(target ? { target } : {}),
+ // The content tier (the Exchange remake's "uncensored"): a control field on the run, never part of the
+ // workflow's input. AI checks the named person's 18+ confirmation before it answers on that tier, and
+ // refuses the tier without one. A run with no person never asks for it.
+ ...(policy === 'uncensored' ? { policy: 'uncensored' } : {}),
};
};
// A slow class may take minutes (background) or hours (batch): the wait and the deadline are longer, and the
// A slow class may take minutes (background) or hours (batch): the wait and the deadline are longer, and the
// answer is polled for until it is ready.
// answer is polled for until it is ready.
const slow = latency === 'background' || latency === 'batch';
const slow = latency === 'background' || latency === 'batch';
const timeoutMs = slow ? Math.max(600_000, Number(ai.timeoutMs) || 180_000) : Math.max(5000, Number(ai.timeoutMs) || 180_000);
const timeoutMs = slow ? Math.max(600_000, Number(ai.timeoutMs) || 180_000) : Math.max(5000, Number(ai.timeoutMs) || 180_000);
- const options = { cache: false, ...(latency ? { latency } : {}) };
+ // The content tier rides in options.policy: AI reads it as a control field on the run (server/runs.js), the
+ // same place the streamed path below puts it on the body it posts itself.
+ const options = { cache: false, ...(latency ? { latency } : {}), ...(policy === 'uncensored' ? { policy: 'uncensored' } : {}) };
body.options = options; // the streamed path posts this body itself
body.options = options; // the streamed path posts this body itself
const sig = typeof AbortSignal.any === 'function'
const sig = typeof AbortSignal.any === 'function'
? AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(timeoutMs)])
? AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(timeoutMs)])
@@ -567,20 +579,29 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
@@ -567,20 +579,29 @@ function createModels({ config, rates = {}, fetchImpl = globalThis.fetch, log =
}
}
/** One model call. The caller checks the budget first and meters cost_usd after; this layer only prices it. */
/** One model call. The caller checks the budget first and meters cost_usd after; this layer only prices it. */
- async function chat({ tier = 'smart', messages = null, tools = null, json = false, maxTokens = 4000, signal, avoidFamily = null, provider = null, model = null, onDelta = null, latency = null, subject = null } = {}) {
+ async function chat({ tier = 'smart', messages = null, tools = null, json = false, maxTokens = 4000, signal, avoidFamily = null, provider = null, model = null, onDelta = null, latency = null, subject = null, policy = null } = {}) {
// The person's own key (plan L55 C3): every model call of this run goes to OpenVibe.AI with it. That path has
// The person's own key (plan L55 C3): every model call of this run goes to OpenVibe.AI with it. That path has
// no latency class: the classes live on AI's router.
// no latency class: the classes live on AI's router.
const choice = currentChoice();
const choice = currentChoice();
+ // The content tier of the run (one-engine slice): it rides on the choice, so every call of the run answers on
+ // that tier. A caller may also name it directly.
+ const policyOf = policy || (choice && choice.policy) || null;
if (choice && choice.provider) {
if (choice && choice.provider) {
- const r = await chatViaAi({ choice, messages, tools, json, maxTokens, signal, onDelta });
+ const r = await chatViaAi({ choice, messages, tools, json, maxTokens, signal, onDelta, policy: policyOf });
return { ...r, tier, provider: AI_PROVIDER, family: AI_PROVIDER, latency_class: null };
return { ...r, tier, provider: AI_PROVIDER, family: AI_PROVIDER, latency_class: null };
}
}
// The router (the AI layer): the tier names a policy on AI and the person's allowance pays. No provider key.
// The router (the AI layer): the tier names a policy on AI and the person's allowance pays. No provider key.
if (routerOn() || (choice && choice.model && !choice.provider)) {
if (routerOn() || (choice && choice.model && !choice.provider)) {
- const r = await chatViaRouter({ tier, messages, tools, json, maxTokens, signal, onDelta, subject: subject || (choice && choice.subject), target: choice && choice.target, latency });
+ const r = await chatViaRouter({ tier, messages, tools, json, maxTokens, signal, onDelta, subject: subject || (choice && choice.subject), target: choice && choice.target, latency, policy: policyOf });
return { ...r, tier, provider: AI_PROVIDER, family: AI_PROVIDER };
return { ...r, tier, provider: AI_PROVIDER, family: AI_PROVIDER };
}
}
await warm();
await warm();
+ // The content tier never reaches a provider directly: the adult tier is OpenVibe.AI's, and AI is the one that
+ // checks the person's 18+ confirmation and labels the answer. A run that asks for it and is not on AI is
+ // refused, never quietly answered on the standard tier.
+ if (policyOf === 'uncensored') {
+ throw new ModelError('the uncensored tier runs on OpenVibe.AI, which is not configured on this server', { code: 'actor.tier.unavailable' });
+ }
if (provider && !PROVIDERS.includes(provider)) throw new ModelError(`unknown model provider "${provider}"`, { code: 'actor.model.provider' });
if (provider && !PROVIDERS.includes(provider)) throw new ModelError(`unknown model provider "${provider}"`, { code: 'actor.model.provider' });
const d = provider && model ? descriptor(tier, provider, model) : resolve(tier, { avoidFamily });
const d = provider && model ? descriptor(tier, provider, model) : resolve(tier, { avoidFamily });
if (!d.available) throw new ModelError(d.reason || `no model is available for the ${tier} tier`, { code: 'actor.model.unavailable' });
if (!d.available) throw new ModelError(d.reason || `no model is available for the ${tier} tier`, { code: 'actor.model.unavailable' });
@@ -33,9 +33,60 @@ function untilMidnight(nowMs) {
@@ -33,9 +33,60 @@ function untilMidnight(nowMs) {
return Math.ceil((Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), n.getUTCDate() + 1) - n.getTime()) / 1000);
return Math.ceil((Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), n.getUTCDate() + 1) - n.getTime()) / 1000);
}
}
+const TURN_TOOL = /^[a-z][a-z0-9_]{1,63}$/;
+// The content tiers a turn may answer under. The uncensored one is only for a task that names the person it is
+// for, and OpenVibe.AI checks that person's 18+ confirmation on the account before it answers on that tier.
+const TIERS = ['standard', 'uncensored'];
+
+/**
+ * A product conversation turn (one-engine slice): which product conversation this task answers, the
+ * registry tool names the run may call (an empty list answers with no tool calls) and the content tier it
+ * answers under. The installed contracts package still forbids these fields, so they are lifted out before the
+ * contracts check and validated here; the open OpenVibe.Contracts change owns the schema, and this shim dissolves
+ * when the dependency passes it.
+ * Shape only: a name the registry does not offer is reported in the run's plan, never silently dropped.
+ */
+function turnFields(turnTools, turnConversation, turnModel, turnTier) {
+ let tools = null;
+ if (turnTools != null) {
+ if (!Array.isArray(turnTools) || turnTools.length > 64 || turnTools.some((n) => typeof n !== 'string' || !TURN_TOOL.test(n))) {
+ return { error: { status: 422, code: 'actor.task.invalid', detail: 'tools: an array of at most 64 registry tool names (snake_case); an empty array answers with no tool calls' } };
+ }
+ tools = [...new Set(turnTools)];
+ }
+ let conversation = null;
+ if (turnConversation != null) {
+ const c = turnConversation;
+ if (!c || typeof c !== 'object' || Array.isArray(c) || typeof c.product !== 'string' || !c.product.trim() || c.product.length > 64 ||
+ typeof c.id !== 'string' || !c.id.trim() || c.id.length > 256 || Object.keys(c).some((k) => k !== 'product' && k !== 'id')) {
+ return { error: { status: 422, code: 'actor.task.invalid', detail: 'conversation: { product, id }, the product conversation this task answers' } };
+ }
+ conversation = { product: c.product.trim().slice(0, 64), id: c.id.trim().slice(0, 256) };
+ }
+ let model = null;
+ if (turnModel != null) {
+ if (typeof turnModel !== 'string' || !turnModel.trim() || turnModel.length > 256) {
+ return { error: { status: 422, code: 'actor.task.invalid', detail: 'model: an exact model id, at most 256 characters' } };
+ }
+ model = turnModel.trim().slice(0, 256);
+ }
+ let tier = null;
+ if (turnTier != null) {
+ const t = String(turnTier).trim().toLowerCase();
+ if (!TIERS.includes(t)) {
+ return { error: { status: 422, code: 'actor.task.invalid', detail: `tier: one of ${TIERS.join(', ')}` } };
+ }
+ tier = t;
+ }
+ return { tools, conversation, model, tier };
+}
+
async function createTask({ s, config, engine, principal, body, webhooks: webhookQueue = null, beforeInsert = null, chatContext = null, afterInsert = null, parentId = null, forkOfStep = null, forkChange = null, assignmentOrigin = null, assignmentLevel = null, triggerId = null, personToken = null, timezone = null }) {
async function createTask({ s, config, engine, principal, body, webhooks: webhookQueue = null, beforeInsert = null, chatContext = null, afterInsert = null, parentId = null, forkOfStep = null, forkChange = null, assignmentOrigin = null, assignmentLevel = null, triggerId = null, personToken = null, timezone = null }) {
- const v = contracts.validate('actor.task-create-request@1', body);
+ const { tools: turnTools, conversation: turnConversation, model: turnModel, tier: turnTier, ...contractBody } = body || {};
+ const v = contracts.validate('actor.task-create-request@1', contractBody);
if (!v.valid) return { status: 422, code: 'actor.task.invalid', detail: (v.errors || []).map((e) => `${e.path || e.instancePath || ''} ${e.message}`.trim()).join('; ').slice(0, 500) || 'invalid request' };
if (!v.valid) return { status: 422, code: 'actor.task.invalid', detail: (v.errors || []).map((e) => `${e.path || e.instancePath || ''} ${e.message}`.trim()).join('; ').slice(0, 500) || 'invalid request' };
+ const turn = turnFields(turnTools, turnConversation, turnModel, turnTier);
+ if (turn.error) return turn.error;
const tier = config.allowance;
const tier = config.allowance;
// A trigger's run is linked to its card. The trigger must belong to the same person the task is for; the
// A trigger's run is linked to its card. The trigger must belong to the same person the task is for; the
// caller (Watch's delivery or the Run now route) already checked, and this check makes it true for every path.
// caller (Watch's delivery or the Run now route) already checked, and this check makes it true for every path.
@@ -57,6 +108,15 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
@@ -57,6 +108,15 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
return { status: 422, code: 'actor.agent.not_found', detail: 'agent_id names one of your own agents (GET /api/v1/agents).' };
return { status: 422, code: 'actor.agent.not_found', detail: 'agent_id names one of your own agents (GET /api/v1/agents).' };
}
}
const level = body.level || 'ask';
const level = body.level || 'ask';
+ // The content tier (one-engine slice): the uncensored tier is only for a task that names the person it is for.
+ // A task with no person (a watch, a webhook, a service acting for an anonymous visitor) answers on the standard
+ // tier. OpenVibe.AI checks that person's 18+ confirmation on the account before it answers on the adult tier,
+ // so a person who has not confirmed is refused there, in words, and never silently served the adult tier.
+ const person = String(principal.requester || '').startsWith('user:') ? principal.requester.slice(5) : null;
+ const tier_ = turn.tier || 'standard';
+ if (tier_ === 'uncensored' && !person) {
+ return { status: 422, code: 'actor.tier.no_person', detail: 'the uncensored tier is for a person: this task names nobody, so it answers on the standard tier' };
+ }
// A budget the request named is the first word; without one, the task gets up to a tier task's worth and, below
// A budget the request named is the first word; without one, the task gets up to a tier task's worth and, below
// that, what the day has left (so a cheap run leaves room for the next one).
// that, what the day has left (so a cheap run leaves room for the next one).
const requestedTaskUsd = body.budget && body.budget.per_task_usd != null ? body.budget.per_task_usd : null;
const requestedTaskUsd = body.budget && body.budget.per_task_usd != null ? body.budget.per_task_usd : null;
@@ -80,7 +140,7 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
@@ -80,7 +140,7 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
}
}
const ownSecret = webhooks.some((h) => !h.secret_ref);
const ownSecret = webhooks.some((h) => !h.secret_ref);
- const hash = crypto.createHash('sha256').update(JSON.stringify({ task: body.task, mode: body.mode || 'balanced', budget: [perTask, perDay], agent: body.agent || null, ...(body.agent_id ? { agent_id: body.agent_id } : {}), ...(body.level ? { level: body.level } : {}), ...(webhooks.length ? { webhooks } : {}) })).digest('hex');
+ const hash = crypto.createHash('sha256').update(JSON.stringify({ task: body.task, mode: body.mode || 'balanced', budget: [perTask, perDay], agent: body.agent || null, ...(body.agent_id ? { agent_id: body.agent_id } : {}), ...(body.level ? { level: body.level } : {}), ...(turn.tools != null ? { tools: turn.tools } : {}), ...(turn.conversation ? { conversation: turn.conversation } : {}), ...(turn.model ? { model: turn.model } : {}), ...(turn.tier ? { tier: turn.tier } : {}), ...(webhooks.length ? { webhooks } : {}) })).digest('hex');
if (body.idempotency_key) {
if (body.idempotency_key) {
const prior = await store.byIdempotency(s, principal.requester, body.idempotency_key);
const prior = await store.byIdempotency(s, principal.requester, body.idempotency_key);
if (prior) {
if (prior) {
@@ -121,6 +181,9 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
@@ -121,6 +181,9 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
// A task's own clock (Actor v2): 10 minutes by default; mode best may run up to an hour.
// A task's own clock (Actor v2): 10 minutes by default; mode best may run up to an hour.
timeout_ms: (body.mode || 'balanced') === 'best' ? config.bestTimeoutMs : config.taskTimeoutMs,
timeout_ms: (body.mode || 'balanced') === 'best' ? config.bestTimeoutMs : config.taskTimeoutMs,
agent_id: agentId, level,
agent_id: agentId, level,
+ // A product conversation turn (one-engine slice): which conversation it answers, the tools it may
+ // call, the model it asked for, and the content tier it answers under.
+ conversation: turn.conversation, tools: turn.tools, model: turn.model, tier: turn.tier,
});
});
// What belongs with the task from its first moment (the chat's attached files), in the same transaction: the
// What belongs with the task from its first moment (the chat's attached files), in the same transaction: the
// engine starts it only after this commits, so the run never begins without them.
// engine starts it only after this commits, so the run never begins without them.
@@ -49,6 +49,16 @@ const LOOP_AGENT = 'openvibe-runtime';
@@ -49,6 +49,16 @@ const LOOP_AGENT = 'openvibe-runtime';
// What a task asked in a public chat may be (chat/asks.js): nothing that uses the person's own computers, robots or code.
// What a task asked in a public chat may be (chat/asks.js): nothing that uses the person's own computers, robots or code.
const PUBLIC_CLASSES = ['answer', 'lookup', 'research', 'web'];
const PUBLIC_CLASSES = ['answer', 'lookup', 'research', 'web'];
+/** A product conversation turn's allow-list as stored (one-engine slice): an array of registry tool names, or null. */
+function parseTurnTools(value) {
+ if (value == null) return null;
+ try {
+ const v = typeof value === 'string' ? JSON.parse(value) : value;
+ if (!Array.isArray(v)) return null;
+ return v.filter((n) => typeof n === 'string');
+ } catch { return null; }
+}
+
function createEngine({ config, s, adapters, stream, webhooks = null, now = () => Date.now(), log = console, models = null, loop = null, notifier = null, registry = null, checkpoints = null, waits: waitsArg = null, triggers: triggersArg = null }) {
function createEngine({ config, s, adapters, stream, webhooks = null, now = () => Date.now(), log = console, models = null, loop = null, notifier = null, registry = null, checkpoints = null, waits: waitsArg = null, triggers: triggersArg = null }) {
const running = new Map(); // id → AbortController
const running = new Map(); // id → AbortController
const waiting = [];
const waiting = [];
@@ -500,10 +510,14 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -500,10 +510,14 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
const runsOn = row.mode === 'private' || fromChat || !String(row.requester || '').startsWith('user:')
const runsOn = row.mode === 'private' || fromChat || !String(row.requester || '').startsWith('user:')
? 'openvibe' : await modelPrefs.runsOnFor(s, row.requester).catch(() => 'openvibe');
? 'openvibe' : await modelPrefs.runsOnFor(s, row.requester).catch(() => 'openvibe');
+ // A product conversation turn (one-engine slice): the registry tool names the run may call, stored at
+ // creation. A turn always runs in the loop below, the path that honors the list — a code-classified
+ // turn must not escape to the Codes agent.
+ const turnAllowed = parseTurnTools(row.tools);
// Actor v2: every non-private task that is not code-only runs through the one loop. Private mode keeps
// Actor v2: every non-private task that is not code-only runs through the one loop. Private mode keeps
// the local model with no network tools, and a coding task keeps the Codes agent (which the loop can also
// the local model with no network tools, and a coding task keeps the Codes agent (which the loop can also
// reach as codes_task). Approval inbox, budgets, cancel, webhooks and the event stream are unchanged.
// reach as codes_task). Approval inbox, budgets, cancel, webhooks and the event stream are unchanged.
- if (loop && models && row.mode !== 'private' && (cls !== 'code' || chatPolicy || dayRun)) {
+ if (loop && models && row.mode !== 'private' && (cls !== 'code' || chatPolicy || dayRun || turnAllowed)) {
const agentId = LOOP_AGENT;
const agentId = LOOP_AGENT;
// The person-authored part of the task (never a run input that may come from a watch or a webhook),
// The person-authored part of the task (never a run input that may come from a watch or a webhook),
// and the personal agent this run is for, so the memory tools scope their writes.
// and the personal agent this run is for, so the memory tools scope their writes.
@@ -550,6 +564,15 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -550,6 +564,15 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
const subject = String(row.requester || '').startsWith('user:') ? row.requester.slice(5) : null;
const subject = String(row.requester || '').startsWith('user:') ? row.requester.slice(5) : null;
if (subject) choice = { ...(choice || {}), subject };
if (subject) choice = { ...(choice || {}), subject };
}
}
+ // A product conversation turn may name its model (one-engine slice): it rides as the run's model
+ // choice, resolved by OpenVibe.AI like an exact id the person picked themselves. A run the model
+ // cannot serve fails with AI's own error, in words.
+ if (typeof row.model === 'string' && row.model.trim()) choice = { ...(choice || {}), model: row.model.trim().slice(0, 256) };
+ // The content tier of the turn (one-engine slice): it rides as the run's policy, so every model call
+ // in the run answers on that tier. A turn that names nobody cannot ask for the uncensored tier
+ // (create.js refuses it), and OpenVibe.AI checks the named person's 18+ confirmation before it
+ // answers on the adult tier.
+ if (row.tier === 'uncensored') choice = { ...(choice || {}), policy: 'uncensored' };
try {
try {
// The conversation so far, and any canvas tick the person made since the last turn (HO9): a
// The conversation so far, and any canvas tick the person made since the last turn (HO9): a
// tick starts no turn by itself, so the next run is told which item changed.
// tick starts no turn by itself, so the next run is told which item changed.
@@ -573,6 +596,8 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -573,6 +596,8 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
spend: spendAllowance,
spend: spendAllowance,
// A routine's or a goal's run may wait (plan BA/HO): the class it asks AI's router for.
// A routine's or a goal's run may wait (plan BA/HO): the class it asks AI's router for.
latency: await latencyClass(id),
latency: await latencyClass(id),
+ // A product conversation turn (one-engine slice): the tools this run may call.
+ allowedTools: turnAllowed,
}));
}));
// The run parked (plan A2): the wait is open, so the task is not finished. It stays open
// The run parked (plan A2): the wait is open, so the task is not finished. It stays open
// ('running' is the closest state there is today), its wall clock waits with it, and its
// ('running' is the closest state there is today), its wall clock waits with it, and its
@@ -22,12 +22,13 @@ function subjectRef(requester) {
@@ -22,12 +22,13 @@ function subjectRef(requester) {
async function insertTask(s, t) {
async function insertTask(s, t) {
await s.db.query(
await s.db.query(
- `INSERT INTO tasks (id, requester, project_id, task, mode, agent, budget_task, budget_day, state, idem_key, idem_hash, created_at, via, webhooks, webhook_secret, timeout_ms, parent_id, fork_of_step, fork_change, agent_id, level, assignment_id)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'queued', $9, $10, $11, $12, $13::jsonb, $14, $15, $16, $17, $18::jsonb, $19, $20, $21)`,
+ `INSERT INTO tasks (id, requester, project_id, task, mode, agent, budget_task, budget_day, state, idem_key, idem_hash, created_at, via, webhooks, webhook_secret, timeout_ms, parent_id, fork_of_step, fork_change, agent_id, level, assignment_id, conversation, tools, model, tier)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'queued', $9, $10, $11, $12, $13::jsonb, $14, $15, $16, $17, $18::jsonb, $19, $20, $21, $22::jsonb, $23::jsonb, $24, $25)`,
[t.id, t.requester, t.project_id || null, t.task, t.mode, t.agent || null, t.budget_task, t.budget_day, t.idem_key || null, t.idem_hash || null, t.created_at, t.via || null,
[t.id, t.requester, t.project_id || null, t.task, t.mode, t.agent || null, t.budget_task, t.budget_day, t.idem_key || null, t.idem_hash || null, t.created_at, t.via || null,
t.webhooks && t.webhooks.length ? JSON.stringify(t.webhooks) : null, t.webhook_secret || null, t.timeout_ms || null, t.parent_id || null,
t.webhooks && t.webhooks.length ? JSON.stringify(t.webhooks) : null, t.webhook_secret || null, t.timeout_ms || null, t.parent_id || null,
t.fork_of_step == null ? null : Number(t.fork_of_step), t.fork_change ? JSON.stringify(t.fork_change) : null,
t.fork_of_step == null ? null : Number(t.fork_of_step), t.fork_change ? JSON.stringify(t.fork_change) : null,
- t.agent_id || null, t.level || 'ask', t.assignment_id || null]);
+ t.agent_id || null, t.level || 'ask', t.assignment_id || null,
+ t.conversation ? JSON.stringify(t.conversation) : null, t.tools != null ? JSON.stringify(t.tools) : null, t.model || null, t.tier || null]);
await addSpend(s, t.requester, 0, { tasks: 1 });
await addSpend(s, t.requester, 0, { tasks: 1 });
return getTask(s, t.id);
return getTask(s, t.id);
}
}
@@ -106,6 +106,33 @@ const ok = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`);
@@ -106,6 +106,33 @@ const ok = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`);
await assert.rejects(() => models.chat({ tier: 'smart', messages: [{ role: 'user', content: 'x' }] }), /model|provider/i);
await assert.rejects(() => models.chat({ tier: 'smart', messages: [{ role: 'user', content: 'x' }] }), /model|provider/i);
});
});
+ await ok('the content tier rides the run to AI: uncensored reaches it with the person, standard never asks', async () => {
+ const { fetchImpl, calls } = fakeAi();
+ const models = createModels({ config: configLib.load(env), fetchImpl, log: quiet });
+ await models.withChoice({ subject: USR, policy: 'uncensored' }, () => models.chat({ tier: 'smart', messages: [{ role: 'user', content: 'Say hello' }] }));
+ const run = calls.find((c) => c.url.includes('/api/v1/runs'));
+ assert.ok(run, 'a run was made');
+ // AI reads the tier as a control field on the run, and decides about the person it names.
+ assert.strictEqual(run.body.options && run.body.options.policy, 'uncensored', 'the tier reaches AI');
+ assert.deepStrictEqual(run.body.on_behalf_of, { type: 'user', id: USR }, 'and the person it is for');
+
+ const plain = fakeAi();
+ const models2 = createModels({ config: configLib.load(env), fetchImpl: plain.fetchImpl, log: quiet });
+ await models2.withChoice({ subject: USR }, () => models2.chat({ tier: 'smart', messages: [{ role: 'user', content: 'Say hello' }] }));
+ const run2 = plain.calls.find((c) => c.url.includes('/api/v1/runs'));
+ assert.ok(!run2.body.options || run2.body.options.policy === undefined, 'a standard run never asks for the adult tier');
+ });
+
+ await ok('the content tier never reaches a provider directly: without AI it is refused, not downgraded', async () => {
+ const env2 = { ...env, ACTOR_AI_ROUTER: '0', ACTOR_DEEPSEEK_API_KEY: 'sk-test', ACTOR_LOCAL_LLM_URL: '' };
+ const models = createModels({ config: configLib.load(env2), fetchImpl: fakeAi().fetchImpl, log: quiet });
+ await assert.rejects(
+ () => models.withChoice({ subject: USR, policy: 'uncensored' }, () => models.chat({ tier: 'smart', messages: [{ role: 'user', content: 'x' }] })),
+ /uncensored|AI/i,
+ 'the adult tier is OpenVibe.AI\'s, and it is said so'
+ );
+ });
+
if (results.length) { console.error(`${results.length} failed`); process.exit(1); }
if (results.length) { console.error(`${results.length} failed`); process.exit(1); }
console.log('ai-router: all passed');
console.log('ai-router: all passed');
})();
})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
+'use strict';
+/**
+ * Time to first word, before and after (one-engine slice).
+ *
+ * Before: the product asked OpenVibe.AI directly for one answer (a chat.member.turn) and rendered the answer.
+ * After: the product asks Actor for one turn, and Actor answers it (the same model behind its own loop).
+ *
+ * This measures both on the same machine, against the same stand-in provider, so the numbers compare:
+ * - the old path: one model round trip (the provider's own latency),
+ * - the new path: Actor's task from the moment the product asks, until the answer's first words arrive in the
+ * task's live event stream (the moment a product would render them).
+ *
+ * Run: node test/bench-first-word.js
+ */
+const { boot } = require('./helpers/boot');
+
+/** The stand-in provider's own latency per call, so both paths pay the same price per model call. */
+const MODEL_LATENCY_MS = Number(process.env.BENCH_MODEL_LATENCY_MS || 120);
+
+/** The old path: one direct model call, the way the products answered before. */
+async function oldPath() {
+ const started = Date.now();
+ await new Promise((r) => setTimeout(r, MODEL_LATENCY_MS));
+ return Date.now() - started;
+}
+
+(async () => {
+ const t = await boot();
+ const kim = t.network.addUser('kim');
+ const saved = t.config.loop.selfReflections;
+ t.config.loop.selfReflections = 0; // the cheapest run: no reflection pass
+ t.providers.reset();
+ t.providers.state.script = Array.from({ length: 60 }, () => ({ content: 'A quiet weekend: the night train south, one good book, no plans before noon.' }));
+
+ /** The new path: an Actor turn, measured from the product's ask to the first words in the event stream. */
+ const newPath = async (label) => {
+ const started = Date.now();
+ const r = await t.get('/api/v1/tasks', {
+ as: kim, method: 'POST', json: {
+ task: 'Companion Sable: "Help me plan a quiet weekend."',
+ mode: 'cheapest', budget: { per_task_usd: 0.05 },
+ conversation: { product: 'exchange', id: `bench-${label}` }, tools: [],
+ }, headers: { 'sec-fetch-site': 'same-origin' },
+ });
+ if (r.status !== 201) throw new Error(`the task was refused: ${r.status} ${r.text}`);
+ const id = r.json().id;
+ // The live stream: the first text step is the answer's first words.
+ let firstWord = null;
+ const res = await fetch(`${t.base}/api/v1/tasks/${id}/events`, { headers: { cookie: t.signIn(kim), accept: 'text/event-stream' } });
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+ let buf = '';
+ for (;;) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ buf += decoder.decode(value, { stream: true });
+ let idx;
+ while ((idx = buf.indexOf('\n\n')) >= 0) {
+ const chunk = buf.slice(0, idx);
+ buf = buf.slice(idx + 2);
+ for (const line of chunk.split('\n')) {
+ if (!line.startsWith('data:')) continue;
+ let e = null;
+ try { e = JSON.parse(line.slice(5).trim()); } catch { continue; }
+ if (e && e.step === 'text' && String(e.text || '').trim() && firstWord == null) firstWord = Date.now() - started;
+ }
+ }
+ if (firstWord != null) { try { await reader.cancel(); } catch { /* */ } break; }
+ }
+ return { firstWord: firstWord == null ? Date.now() - started : firstWord };
+ };
+
+ const oldMs = await oldPath();
+ const runs = [];
+ for (let i = 0; i < 5; i++) runs.push(await newPath(`r${i}`));
+ t.config.loop.selfReflections = saved;
+
+ const avg = (a) => Math.round(a.reduce((s, x) => s + x, 0) / a.length);
+ // The boot captures console.log, so the numbers go to stderr.
+ process.stderr.write(JSON.stringify({
+ modelLatencyMs: MODEL_LATENCY_MS,
+ before: { directAiCallMs: oldMs },
+ after: { firstWordMs: avg(runs.map((r) => r.firstWord)), firstWordRuns: runs.map((r) => r.firstWord) },
+ }, null, 2) + '\n');
+
+ await t.close();
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,137 @@
@@ -0,0 +1,137 @@
+'use strict';
+/**
+ * The two halves of the one-engine slice, together: Chat's inbox client asks a real OpenVibe.Actor for a turn, and
+ * the turn reaches the loop with the visitor's subject, the conversation key and the tool list the product named.
+ *
+ * This is a local check across the two repositories (Actor's own test boot + Chat's client), run from the Actor
+ * worktree with Chat's client on the path. It proves the shapes fit before either side ships.
+ *
+ * Run: node test/integration-inbox-turn.js (from the OpenVibe.Actor worktree; CHAT_DIR points at the Chat one)
+ */
+const path = require('path');
+const { boot } = require('./helpers/boot');
+
+const CHAT_DIR = process.env.CHAT_DIR || path.join(__dirname, '..', '..', 'wt-one-engine-chat');
+
+(async () => {
+ const t = await boot();
+ const kim = t.network.addUser('kim');
+ const subject = kim.subject || null;
+
+ // Chat's own client for Actor (server/inbox/actor.js), pointed at this Actor.
+ const { createActor } = require(path.join(CHAT_DIR, 'server', 'inbox', 'actor'));
+ const config = require(path.join(CHAT_DIR, 'server', 'config'));
+ config.actor.internalUrl = t.base;
+ config.oauth.clientSecret = 'actor-secret';
+ config.oauth.clientId = 'chat';
+ // Chat's service token for Actor: a first-party service token (sub svc:…, actor_type service), which is the
+ // only kind Actor lets name a person in X-OV-Subject.
+ const { serviceAuth } = require('openvibe-contracts');
+ const crypto = require('crypto');
+ const actorModule = require(path.join(CHAT_DIR, 'server', 'inbox', 'actor'));
+ const now = Math.floor(Date.now() / 1000);
+ const serviceToken = (subject) => serviceAuth.signServiceToken({
+ iss: t.network.url, sub: 'svc:chat', actor_type: 'service', aud: ['openvibe.actor'],
+ cap: ['actor.task.create', 'actor.task.read'], ns: [], iat: now, exp: now + 300,
+ jti: crypto.randomUUID(), ...(subject ? { on_behalf_of: subject } : {}),
+ }, t.network.privatePem);
+ actorModule._setTokens({ getToken: async () => serviceToken(subject) });
+
+ const saved = t.config.loop.selfReflections;
+ t.config.loop.selfReflections = 0;
+ t.providers.reset();
+ t.providers.state.script = [{ content: 'We ship in 3-5 days. https://example.com/faq' }];
+
+ const actor = createActor({ fetchImpl: globalThis.fetch });
+ let out = null;
+ try {
+ out = await actor.turn({
+ subject: null, // an anonymous visitor: the turn is Chat's own
+ task: 'You are Vibe, the assistant answering for "Acme".\n\nThe conversation so far:\nThe visitor: When do you ship?\n\nThe visitor now says: When do you ship?',
+ tools: ['read_page', 'time_in'],
+ budgetUsd: 0.05,
+ conversation: { product: 'chat', id: 'inbox:cnv_test/turn:1' },
+ idempotencyKey: 'chat-inbox-cnv_test-1',
+ });
+ } finally {
+ t.config.loop.selfReflections = saved;
+ }
+
+ const ok = out && !out.pending && out.answer && /ship in 3-5 days/.test(out.answer);
+ const anonTaskId = out && out.taskId;
+ // The task row carries the conversation key and the tool list the product named.
+ const row = await t.dbDump();
+ const taskRow = row.split('\n').map((l) => { try { return JSON.parse(l); } catch { return null; } })
+ .filter((r) => Array.isArray(r)).flat().find((r) => r && r.id === anonTaskId);
+
+ // A signed-in visitor: the turn is theirs. Their subject id rides in X-OV-Subject, which Actor checks against
+ // the service token's grant. This is the identity path the slice had to open.
+ const person = t.network.addUser('visitor');
+ const personSubject = person.subject || null;
+ const signed = serviceToken(personSubject);
+ actorModule._setTokens({ getToken: async () => signed });
+ t.providers.reset();
+ t.providers.state.script = [{ content: 'Order 42 ships Tuesday.' }, { content: 'Order 42 ships Tuesday.' }, { content: 'Order 42 ships Tuesday.' }];
+ let personOut = null;
+ try {
+ personOut = await actor.turn({
+ subject: personSubject,
+ task: 'You are Vibe, the assistant answering for "Acme".\n\nThe conversation so far:\nThe visitor: Where is my order?',
+ tools: ['read_page'],
+ budgetUsd: 0.05,
+ conversation: { product: 'chat', id: 'inbox:cnv_person/turn:1' },
+ idempotencyKey: 'chat-inbox-cnv_person-1',
+ });
+ } catch (err) {
+ process.stderr.write(JSON.stringify({ ok: false, personError: String(err && err.message) }, null, 2) + '\n');
+ await t.close();
+ process.exit(1);
+ }
+ // Whose task is it, and whose allowance paid for it?
+ const personTask = await t.dbDump();
+ const personRows = personTask.split('\n').map((l) => { try { return JSON.parse(l); } catch { return null; } })
+ .filter((r) => Array.isArray(r)).flat();
+ const personRow = personRows.find((r) => r && r.id === personOut.taskId);
+ const personSpend = personRows.filter((r) => r && r.requester === `user:${personSubject}` && typeof r.usd === 'number');
+ const serviceSpend = personRows.filter((r) => r && r.requester && String(r.requester).startsWith('service:') && typeof r.usd === 'number');
+
+ // The content tier of a turn (one-engine slice): a turn that names nobody may not ask for the adult tier.
+ // Chat's own client never asks for it (a support inbox's visitor is not a person who confirmed 18+), so this
+ // goes to Actor's API the way a product that may ask for it would.
+ let adultRefused = null;
+ try {
+ const r = await fetch(`${t.base}/api/v1/tasks`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${serviceToken(null)}` },
+ body: JSON.stringify({
+ task: 'An anonymous visitor asks for the adult tier.', tier: 'uncensored',
+ conversation: { product: 'chat', id: 'inbox:cnv_anon/turn:adult' }, tools: [],
+ }),
+ });
+ const body = await r.json().catch(() => null);
+ adultRefused = `${r.status} ${(body && body.code) || ''} ${(body && body.detail) || ''}`;
+ } catch (err) {
+ adultRefused = `error ${String((err && err.message) || err)}`;
+ }
+
+ process.stderr.write(JSON.stringify({
+ ok: Boolean(ok),
+ answer: out && out.answer,
+ costUsd: out && out.costUsd,
+ taskId: anonTaskId,
+ storedConversation: taskRow && taskRow.conversation,
+ storedTools: taskRow && taskRow.tools,
+ anonymousAdultTier: adultRefused,
+ signedIn: {
+ ok: Boolean(personOut && !personOut.pending && personOut.answer),
+ answer: personOut && personOut.answer,
+ requester: personRow && personRow.requester,
+ via: personRow && personRow.via,
+ personSpendUsd: personSpend.reduce((s, r) => s + Number(r.usd), 0),
+ serviceSpendUsd: serviceSpend.reduce((s, r) => s + Number(r.usd), 0),
+ },
+ }, null, 2) + '\n');
+
+ await t.close();
+ const adultOk = /actor\.tier\.no_person/.test(String(adultRefused || '')); process.exit(ok && personOut && !personOut.pending && personOut.answer && adultOk ? 0 : 1);
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,221 @@
@@ -0,0 +1,221 @@
+'use strict';
+/**
+ * A product conversation turn (one-engine slice): a product has Actor answer one turn of its own
+ * conversation with the person's own allowance. The call carries which conversation it answers, the
+ * model, and the tools the run may call; an empty tool list answers with no tool calls. The cost is
+ * charged once, to the person the task is for; a refusal or an outage arrives as plain words, never a
+ * silent second engine.
+ */
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+
+const valid = (task) => { const v = contracts.validate('platform.task@1', task); assert.ok(v.valid, JSON.stringify(v.errors)); };
+const SAME = { 'sec-fetch-site': 'same-origin' };
+/** Every dumped table: dbDump prints one JSON array per table. */
+const tableRows = async (t) => {
+ const out = [];
+ for (const l of (await t.dbDump()).split('\n')) {
+ try { const r = JSON.parse(l); if (Array.isArray(r) && r.length) out.push(r); } catch { /* not a row list */ }
+ }
+ return out;
+};
+const findTable = (tables, pred) => tables.find((rows) => rows.some(pred));
+
+(async () => {
+ const t = await boot();
+ const kim = t.network.addUser('kim');
+ const create = (json, o = {}) => t.get('/api/v1/tasks', { as: o.as || kim, json, headers: { ...SAME, ...(o.headers || {}) } });
+
+ await check('a turn with no tools answers directly, charges once, and stores its conversation', async () => {
+ t.providers.reset();
+ const saved = t.config.loop.selfReflections;
+ t.config.loop.selfReflections = 0;
+ t.providers.state.script = [{ content: 'A quiet weekend: the night train south, one good book, no plans before noon.' }];
+ try {
+ const turn = {
+ task: 'Companion Sable: "Help me plan a quiet weekend."',
+ mode: 'cheapest',
+ budget: { per_task_usd: 0.05 },
+ idempotency_key: 'exchange-companion-chat1-turn1',
+ conversation: { product: 'exchange', id: 'cmp_chat_1' },
+ tools: [],
+ };
+ const r = await create(turn);
+ assert.strictEqual(r.status, 201, r.text);
+ const created = r.json();
+ valid(created);
+ await t.waitFor(created.id);
+ const task = (await t.get(`/api/v1/tasks/${created.id}`, { as: kim })).json();
+ valid(task);
+ assert.strictEqual(task.state, 'succeeded', JSON.stringify(task.error));
+ assert.match(task.result.answer, /quiet weekend/);
+ assert.ok(task.cost.usd > 0, 'the turn spent something');
+ assert.strictEqual(task.cost.free_allowance_used, task.cost.usd, 'the free allowance paid it');
+ // No tool ran anywhere: the answer came from the model alone.
+ assert.ok(!t.providers.requests.some((q) => q.path.startsWith('/tools/') || q.path.startsWith('/search/')), 'no tool call left the server');
+ // The turn is stored on the row, beside the conversation the product keeps.
+ const tables = await tableRows(t);
+ const tasks = findTable(tables, (row) => row.id === created.id);
+ assert.ok(tasks, 'the task row is in the dump');
+ const row = tasks.find((row) => row.id === created.id);
+ assert.deepStrictEqual(row.conversation, { product: 'exchange', id: 'cmp_chat_1' });
+ assert.deepStrictEqual(row.tools, []);
+ assert.strictEqual(row.model, null, 'no model picked: Actor answers on its default');
+ assert.strictEqual(row.requester, task.requester.type === 'user' ? `user:${task.requester.id}` : row.requester);
+ // Charged once: the day's spend for the person equals what the task cost.
+ const spends = findTable(tables, (row) => typeof row.usd === 'number' && 'day' in row);
+ assert.ok(spends, 'the spend table is in the dump');
+ const spent = spends.filter((r) => String(r.requester) === String(row.requester)).reduce((n, r) => n + Number(r.usd), 0);
+ assert.strictEqual(Math.round(spent * 1e6), Math.round(Number(task.cost.usd) * 1e6), 'one charge, not two');
+ } finally { t.config.loop.selfReflections = saved; }
+ });
+
+ await check('an idempotent turn repeats the first task; different tools are a different task', async () => {
+ t.providers.reset();
+ const saved = t.config.loop.selfReflections;
+ t.config.loop.selfReflections = 0;
+ t.providers.state.script = [{ content: 'First answer.' }, { content: 'Second answer.' }];
+ try {
+ const base = { task: 'Inbox turn one.', mode: 'cheapest', idempotency_key: 'chat-inbox-7-99', conversation: { product: 'chat', id: 'inbox-7' }, tools: [] };
+ const first = (await create(base)).json();
+ assert.strictEqual(first.state, 'queued');
+ const repeat = await create(base);
+ assert.strictEqual(repeat.status, 200);
+ assert.strictEqual(repeat.json().id, first.id, 'the same turn answers the same task');
+ const other = await create({ ...base, tools: ['read_page'] });
+ assert.strictEqual(other.status, 409, other.text);
+ assert.strictEqual(other.json().code, 'actor.idempotency.conflict');
+ } finally { t.config.loop.selfReflections = saved; }
+ });
+
+ await check('a tool name nothing offers is said aloud in the run, never silently dropped', async () => {
+ t.providers.reset();
+ const saved = t.config.loop.selfReflections;
+ t.config.loop.selfReflections = 0;
+ t.providers.state.script = [{ content: 'Answered without it.' }];
+ try {
+ const r = await create({ task: 'Inbox turn two.', mode: 'cheapest', idempotency_key: 'chat-inbox-7-100', conversation: { product: 'chat', id: 'inbox-7' }, tools: ['read_page', 'no_such_tool'] });
+ assert.strictEqual(r.status, 201, r.text);
+ await t.waitFor(r.json().id);
+ assert.ok(!t.providers.requests.some((q) => q.path.startsWith('/tools/')), 'nothing ran anyway');
+ const events = (await t.get(`/api/v1/tasks/${r.json().id}/events`, { as: kim })).text;
+ assert.match(events, /no_such_tool/, 'the run says which allowed name it does not offer');
+ } finally { t.config.loop.selfReflections = saved; }
+ });
+
+ await check('a first-party service may answer a turn for a person: the turn is theirs, on their allowance', async () => {
+ const { serviceAuth } = contracts;
+ const crypto = require('crypto');
+ const now = Math.floor(Date.now() / 1000);
+ const mint = (sub, actorType, cap, extra = {}) => serviceAuth.signServiceToken({ iss: t.network.url, sub, actor_type: actorType, aud: ['openvibe.actor'], cap, ns: [], iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}`, ...extra }, t.network.privatePem);
+ const chat = mint('svc:chat', 'service', ['actor.task.create', 'actor.task.read']);
+ const visitor = t.network.addUser('visitor');
+ t.providers.reset();
+ const saved = t.config.loop.selfReflections;
+ t.config.loop.selfReflections = 0;
+ t.providers.state.script = [{ content: 'Order 42 ships Tuesday.' }];
+ try {
+ const r = await t.get('/api/v1/tasks', { bearer: chat, headers: { 'X-OV-Subject': visitor.subject }, json: {
+ task: 'You are Vibe, the assistant answering for "Acme".\n\nThe conversation so far:\nThe visitor: Where is my order?',
+ mode: 'cheapest', budget: { per_task_usd: 0.05 },
+ conversation: { product: 'chat', id: 'inbox:cnv_1/turn:1' }, tools: ['read_page'],
+ idempotency_key: 'chat-inbox-cnv_1-1',
+ } });
+ assert.strictEqual(r.status, 201, r.text);
+ // The turn is the person's: their allowance pays for it, and it is their task.
+ assert.deepStrictEqual(r.json().requester, { type: 'user', id: visitor.subject }, 'the person\'s turn');
+ const row = await require('../server/tasks/store').getTask(t.ctx.s, r.json().id);
+ assert.strictEqual(row.via, 'service:chat', 'and it records who asked for it');
+ assert.deepStrictEqual(row.conversation, { product: 'chat', id: 'inbox:cnv_1/turn:1' });
+ assert.deepStrictEqual(row.tools, ['read_page']);
+ await t.waitFor(r.json().id);
+ // Their day's spend is the turn's cost: charged once, to the person, not to the service.
+ const store = require('../server/tasks/store');
+ const days = await store.spendDays(t.ctx.s, `user:${visitor.subject}`, 30);
+ const spent = days.reduce((n, d) => n + Number(d.usd || 0), 0);
+ assert.ok(spent > 0, 'the person paid for their turn');
+ const task = (await t.get(`/api/v1/tasks/${r.json().id}`, { as: visitor })).json();
+ assert.ok(task.cost && Number(task.cost.usd) > 0, 'and the task says what it cost');
+ assert.ok(Math.abs(spent - Number(task.cost.usd)) < 1e-6, 'charged once, to the person');
+ } finally { t.config.loop.selfReflections = saved; }
+ });
+
+ await check('a turn may name the content tier, and it is stored on the task', async () => {
+ t.providers.reset();
+ const saved = t.config.loop.selfReflections;
+ t.config.loop.selfReflections = 0;
+ t.providers.state.script = [{ content: 'An answer on the adult tier.' }];
+ try {
+ const r = await create({
+ task: 'Companion Sable: "Say hello."', mode: 'cheapest', budget: { per_task_usd: 0.05 },
+ idempotency_key: 'exchange-companion-adult-1',
+ conversation: { product: 'exchange', id: 'cmp_adult_1' }, tools: [], tier: 'uncensored',
+ });
+ assert.strictEqual(r.status, 201, r.text);
+ await t.waitFor(r.json().id);
+ const tables = await tableRows(t);
+ const tasks = findTable(tables, (row) => row.id === r.json().id);
+ const row = tasks.find((row) => row.id === r.json().id);
+ assert.strictEqual(row.tier, 'uncensored', 'the tier is on the task, beside the conversation');
+ // The floor is unchanged: the tier only decides which offers may answer.
+ assert.strictEqual(row.conversation.product, 'exchange');
+ } finally { t.config.loop.selfReflections = saved; }
+ });
+
+ await check('a turn that names nobody may not ask for the uncensored tier', async () => {
+ const { serviceAuth } = contracts;
+ const crypto = require('crypto');
+ const now = Math.floor(Date.now() / 1000);
+ // A service acting for nobody: no X-OV-Subject, so the task has no person.
+ const svc = serviceAuth.signServiceToken({ iss: t.network.url, sub: 'svc:chat', actor_type: 'service', aud: ['openvibe.actor'], cap: ['actor.task.create'], ns: [], iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, t.network.privatePem);
+ const r = await t.get('/api/v1/tasks', { bearer: svc, json: {
+ task: 'An anonymous visitor asks for the adult tier.', tier: 'uncensored', conversation: { product: 'chat', id: 'inbox:cnv_anon/turn:1' }, tools: [],
+ } });
+ assert.strictEqual(r.status, 422, r.text);
+ assert.strictEqual(r.json().code, 'actor.tier.no_person');
+ assert.match(r.json().detail, /names nobody|standard tier/);
+ });
+
+ await check('an unknown content tier is refused in plain words', async () => {
+ const r = await create({ task: 'Hi.', tier: 'adult' });
+ assert.strictEqual(r.status, 422);
+ assert.strictEqual(r.json().code, 'actor.task.invalid');
+ assert.match(r.json().detail, /tier/);
+ });
+
+ await check('malformed turn fields are refused in plain words', async () => {
+ const badTools = await create({ task: 'Hi.', tools: 'read_page' });
+ assert.strictEqual(badTools.status, 422);
+ assert.strictEqual(badTools.json().code, 'actor.task.invalid');
+ assert.match(badTools.json().detail, /tools/);
+ const badConv = await create({ task: 'Hi.', conversation: { product: 'exchange' } });
+ assert.strictEqual(badConv.status, 422);
+ assert.match(badConv.json().detail, /conversation/);
+ const badModel = await create({ task: 'Hi.', model: '' });
+ assert.strictEqual(badModel.status, 422);
+ assert.match(badModel.json().detail, /model/);
+ });
+
+ await check('a refusal arrives as words with its code', async () => {
+ const r = await create({ task: 'Hi.', budget: { per_task_usd: 50 } });
+ assert.strictEqual(r.status, 422);
+ assert.strictEqual(r.json().code, 'actor.budget.over_tier');
+ assert.match(r.json().detail, /free tier allows/);
+ });
+
+ await check('a model this server cannot serve fails in words, never silently', async () => {
+ t.providers.reset();
+ const r = await create({ task: 'Answer this.', mode: 'cheapest', model: 'openvibe/auto', idempotency_key: 'chat-inbox-7-101', conversation: { product: 'chat', id: 'inbox-7' }, tools: [] });
+ assert.strictEqual(r.status, 201, r.text);
+ await t.waitFor(r.json().id);
+ const task = (await t.get(`/api/v1/tasks/${r.json().id}`, { as: kim })).json();
+ assert.strictEqual(task.state, 'failed', JSON.stringify(task.result));
+ assert.strictEqual(task.error.code, 'actor.model.unavailable');
+ assert.match(task.error.detail, /OpenVibe\.AI is not configured/);
+ assert.ok(task.cost.usd < 0.01, 'only the classify-and-plan calls spent');
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });