openvibers/openvibe.actor Commits

Actor: first slice of one engine for turns (#427)

OP OpenVibers committed 2 days ago · 480d69d0e3 · 15 files, +729 −19

* Actor answers a product's conversation turn (one engine, first slice)

A product has Actor answer one turn of its own conversation. The task
carries three new fields, stored on the task row:

- conversation: which product conversation the task answers, so a later
  query finds the turn's task.
- tools: the registry tool names the run may call. The loop narrows the
  run's own tool set to it, so the model is never offered - and never
  runs - anything wider. A name nothing offers 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.

A turn always runs in the loop, the path that honors the tool list: a
code-classified turn does not escape to the Codes agent. The turn's
idempotency hash covers the three fields, so the same turn with a
different tool list is refused rather than answered twice.

The installed contracts package (v0.384.0) predates 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.

docs/one-engine.md is the slice's design note: the turn call, who a turn
belongs to and who pays for it, how a turn is stored, and what the
products show when Actor refuses or is down.

* A person's data export carries the turn fields too

The tasks export lists every column of the table but the webhook secret.
The turn's conversation, tool list and model are the person's own data:
without them their export could not say which of their turns ran where.

* A turn may name the content tier it answers under

The uncensored tier is OpenVibe.AI's, and AI is the one that decides it:
the tier rides every model call of the run as a control field on AI's
run, beside on_behalf_of, and AI checks the named person's 18+
confirmation on the account before it answers on that tier.

- A turn that names nobody may not ask for it: 422 actor.tier.no_person,
  in plain words. An anonymous visitor's turn can never reach the adult
  tier.
- The tier never reaches a provider directly. A server without
  OpenVibe.AI configured refuses it (actor.tier.unavailable) rather than
  quietly answering on the standard tier.
- The hard floor holds on every tier, unchanged.

The tier rides on the model's choice, beside the subject, so every call
of the run answers on it.

* A person's data export carries the turn's tier too

The tier a turn answered under is the person's own data: without it their
export could not say which of their turns were on the adult tier.

* Merge remote-tracking branch 'origin/feat/one-engine-turns-v2' into lostwork/one-engine-actor-1111

* Actor: pin task turn contracts

Changes

ddiff --git a/docs/one-engine.md b/docs/one-engine.md
nnew file mode 100644
iindex 0000000..02a1705
---- /dev/null
++++ b/docs/one-engine.md
@@@ -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.
ddiff --git a/migrations/0065_task_turn_fields.sql b/migrations/0065_task_turn_fields.sql
nnew file mode 100644
iindex 0000000..7c92da8
---- /dev/null
++++ b/migrations/0065_task_turn_fields.sql
@@@ -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;
ddiff --git a/migrations/0066_task_turn_tier.sql b/migrations/0066_task_turn_tier.sql
nnew file mode 100644
iindex 0000000..49163da
---- /dev/null
++++ b/migrations/0066_task_turn_tier.sql
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex 9fdf432..a75cfcd 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex eda31fb..ccb5405 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 8c228fd..cc85b4f 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -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
ddiff --git a/server/runtime/loop.js b/server/runtime/loop.js
iindex 9521075..01d97a0 100644
---- a/server/runtime/loop.js
++++ b/server/runtime/loop.js
@@@ -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({
ddiff --git a/server/runtime/models.js b/server/runtime/models.js
iindex 11a03a8..81795cd 100644
---- a/server/runtime/models.js
++++ b/server/runtime/models.js
@@@ -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 itself
const 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' });
ddiff --git a/server/tasks/create.js b/server/tasks/create.js
iindex cf3402d..51acf1f 100644
---- a/server/tasks/create.js
++++ b/server/tasks/create.js
@@@ -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: webhoo
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';
++ // 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.
ddiff --git a/server/tasks/engine.js b/server/tasks/engine.js
iindex f130949..674605d 100644
---- a/server/tasks/engine.js
++++ b/server/tasks/engine.js
@@@ -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 → AbortController
const 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
ddiff --git a/server/tasks/store.js b/server/tasks/store.js
iindex 214266f..e257483 100644
---- a/server/tasks/store.js
++++ b/server/tasks/store.js
@@@ -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);
}
ddiff --git a/test/ai-router.test.js b/test/ai-router.test.js
iindex 77bf979..82d5468 100644
---- a/test/ai-router.test.js
++++ b/test/ai-router.test.js
@@@ -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');
})();
ddiff --git a/test/bench-first-word.js b/test/bench-first-word.js
nnew file mode 100644
iindex 0000000..b6d9ad5
---- /dev/null
++++ b/test/bench-first-word.js
@@@ -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); });
ddiff --git a/test/integration-inbox-turn.js b/test/integration-inbox-turn.js
nnew file mode 100644
iindex 0000000..37ed719
---- /dev/null
++++ b/test/integration-inbox-turn.js
@@@ -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); });
ddiff --git a/test/task-turns.test.js b/test/task-turns.test.js
nnew file mode 100644
iindex 0000000..14a229f
---- /dev/null
++++ b/test/task-turns.test.js
@@@ -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); });