openvibers/openvibe.actor Commits
Actor: coding tasks go to OpenVibe.Codes and run on the person's own machine (the codes agent) (#50)
Changes
@@@ -10,9 +10,11 @@* signed-in person's task also gets their own robots (agents/robots.js)* openai-agent OpenAI's Responses agent with its hosted web search* open-model the open model on OpenVibe's own server, one call, no tools++ * codes OpenVibe.Codes: the person's coding task on their own machine (agents/codes.js)*/const { chatClient, responsesAgent, ProviderError } = require('./providers');const { createOpenVibeTools } = require('./tools');++const { createCodes } = require('./codes');const MAX_TURNS = 10;@@@ -34,7 +36,7 @@ const summarize = (args) => {return String(v).slice(0, 300);};--function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = console, tools: toolsOverride }) {++function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = console, tools: toolsOverride, codes: codesOverride }) {const p = config.providers;const ovTools = toolsOverride || createOpenVibeTools({ config, fetchImpl, log });@@@ -94,7 +96,8 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = conreturn { text: r.text.trim(), sources: [], model: r.model };}-- const run = { 'openvibe-runtime': runtime, 'openai-agent': openai, 'open-model': openModel };++ const codes = codesOverride || createCodes({ config, fetchImpl });++ const run = { 'openvibe-runtime': runtime, 'openai-agent': openai, 'open-model': openModel, codes: codes.adapter };/** A cheap model of another family to classify and to check results (null when none is configured). */const checkers = {@@@ -76,8 +76,9 @@ const AGENTS = [trust: 'first-party',quality: 'high',can: ['task:code'],-- rate_card: { input_usd_per_mtok: 0.30, output_usd_per_mtok: 1.20, source: 'https://openvibe.codes/harnesses', verified_at: VERIFIED },-- blurb: 'Coding work goes to OpenVibe.Codes, the coding-agent harness (Claude Code, Codex, OpenCode, DeepSeek and more). Hosted coding runs come with OpenVibe.Run; today, run openvibe-codes on your own machine.',++ // The model use is on the person's own machine and keys: nothing for Actor to meter.++ rate_card: { input_usd_per_mtok: 0, output_usd_per_mtok: 0, source: 'https://openvibe.codes/run', verified_at: VERIFIED },++ blurb: 'Coding work goes to OpenVibe.Codes and runs on your own machine (add it on openvibe.bot/machines, then start one task on openvibe.codes/run). Name the repository; you get the summary, the changed files and the patch. The model use is on your machine\'s own keys, so Actor charges nothing for it.',},];@@@ -87,7 +88,10 @@ function availability(agent, config) {if (agent.providerKey === 'deepseek') return p.deepseek && p.deepseek.apiKey ? { available: true } : { available: false, reason: 'no DeepSeek key is configured on this server' };if (agent.providerKey === 'openai') return p.openai && p.openai.apiKey ? { available: true } : { available: false, reason: 'no OpenAI key is configured on this server' };if (agent.providerKey === 'local') return p.local && p.local.url && p.local.model ? { available: true } : { available: false, reason: 'no open model is running on this server' };-- if (agent.providerKey === 'codes') return { available: false, reason: 'hosted coding runs need OpenVibe.Run sandboxes, which are not open yet; run openvibe-codes on your own machine (openvibe.codes/start)' };++ if (agent.providerKey === 'codes') {++ const s = config.services || {};++ return s.codesUrl && config.oauth && config.oauth.clientSecret ? { available: true } : { available: false, reason: 'OpenVibe.Codes is not connected to this server' };++ }return { available: false, reason: 'no adapter' };}@@@ -11,14 +11,16 @@* empty answer or one about something else fails.*/--const FAMILY = { 'openvibe-runtime': 'deepseek', 'openai-agent': 'openai', 'open-model': 'local' };--const CHECKER_FOR = { deepseek: ['openai', 'deepseek'], openai: ['deepseek', 'openai'], local: ['local'] };++// codes: the person's own machine wrote it (any of Codes' agents), so either family reads it.++const FAMILY = { 'openvibe-runtime': 'deepseek', 'openai-agent': 'openai', 'open-model': 'local', codes: 'codes' };++const CHECKER_FOR = { deepseek: ['openai', 'deepseek'], openai: ['deepseek', 'openai'], local: ['local'], codes: ['deepseek', 'openai'] };const SYSTEM = `You check an AI agent's answer before a person sees it. Reply with JSON only: {"ok": true|false, "reason": "<one short sentence>"}.ok is true when the answer addresses the task and nothing in it is clearly invented, contradictory or unsafe. It does not have to be perfect.ok is false when the answer is empty, refuses without a good reason, answers a different question, contradicts itself, or states something that is plainly impossible.The agent may have searched the web or run tools: do not fail an answer only because it states recent facts, versions, prices or dates you cannot verify yourself, especially when it cites a source for them.The agent may have made the person's own robot act through OpenVibe.Bot: an answer that reports what the robot did, or why it stopped or was refused, addresses the task.++The agent may have run a coding task on the person's own machine through OpenVibe.Codes: an answer that reports what changed (a summary, the files, a patch), or that the machine is still working on it with a link to follow it, addresses the task.When the task is a greeting, small talk, a test message or too vague to act on, a short friendly reply, a summary of what the agent can do or a clarifying question is a good answer: ok is true.`;/** Which checkers may read this agent's answer, best first: another family, then the same family. */nnew file mode 100644@@@ -0,0 +1,134 @@++'use strict';++++/**++ * The `codes` agent (plan T16/T17): a person's coding task goes to OpenVibe.Codes, which runs it on that person's own++ * machine (openvibe.codes/run: an OpenVibe.Run job placed only on a machine of theirs, added on openvibe.bot/machines).++ * Actor calls Codes' task API (Contracts 0.162 codes.task.*) with its own service token (audience openvibe.codes) and++ * X-OV-Subject naming the person; Codes shows Actor only the tasks Actor started for that person. The model use is on++ * the machine's own keys, so Actor meters nothing for it.++ *++ * createCodes({ config, fetchImpl, tokens?, sleep?, pollMs?, now? }) → { enabled, adapter }++ * adapter({ task, requester, signal, emit, meter, budgetLeft, taskId }) → { text, sources, model }++ *++ * The adapter starts the task (the Actor task id is its idempotency key, so a retry never starts a second one),++ * reports each state change, and waits for the end. When the machine is still working as Actor's own task time runs++ * out, the answer says so and links the task's live page; when the task is cancelled, the Codes task is cancelled too.++ * Off (enabled false) without ACTOR_CODES_URL or without Actor's client secret.++ */++const { serviceAuth } = require('openvibe-contracts');++const { ProviderError } = require('./providers');++++const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;++const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'expired']);++const LABEL = {++ queued: 'Waiting for your machine', placed: 'Sent to your machine', running: 'Your machine is working on it',++ succeeded: 'Finished', failed: 'Failed', cancelled: 'Cancelled', expired: 'No machine of yours took it in time',++};++// A repository the task names: an https URL with an owner and a name (GitHub, GitLab, Codeberg, a Forgejo …).++const REPO = /https:\/\/[a-z0-9.-]+\.[a-z]{2,}\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+/;++++const personOf = (requester) => (typeof requester === 'string' && requester.startsWith('user:') ? requester.slice(5) : null);++++/** The first repository URL in the task text, without a trailing .git or punctuation; null when there is none. */++function repoIn(text) {++ const m = REPO.exec(String(text || ''));++ if (!m) return null;++ return m[0].replace(/[.,;:!?)]+$/, '').replace(/\.git$/, '');++}++++/** The ended task's answer for the person, in Markdown. */++function answerOf(t) {++ const r = t.result || {};++ const lines = [];++ const took = typeof r.duration_ms === 'number' ? `, ${Math.max(1, Math.round(r.duration_ms / 1000))} s` : '';++ lines.push(`**Done on your machine**${r.agents && r.agents.length ? ` (${r.agents.join(' → ')}${took})` : took ? ` (${took.slice(2)})` : ''}.`);++ if (r.summary) lines.push('', r.summary.trim());++ const files = Array.isArray(r.files) ? r.files : [];++ if (files.length) {++ const shown = files.slice(0, 20).map((f) => `\`${f.path}\` (+${f.added == null ? '·' : f.added} −${f.removed == null ? '·' : f.removed})`);++ lines.push('', `**Changed files:** ${shown.join(', ')}${files.length > 20 ? ` and ${files.length - 20} more` : ''}`);++ } else {++ lines.push('', 'No files changed.');++ }++ if (r.patch) {++ const cut = r.patch.length > 3000;++ lines.push('', '```diff', r.patch.slice(0, 3000).replace(/```/g, '```'), '```', ...(cut || r.patch_truncated ? ['(The whole patch is on the task page.)'] : []));++ }++ if (r.push && r.push.pushed) lines.push('', `Pushed to the branch [${r.push.branch}](${r.push.branch_url})${r.push.pull_request ? ` · [pull request](${r.push.pull_request})` : ''}.`);++ lines.push('', `[The task on OpenVibe.Codes](${t.url})`);++ return lines.join('\n');++}++++function createCodes({ config, fetchImpl = globalThis.fetch, tokens: tokensOverride = null, sleep = (ms, signal) => new Promise((ok) => { const h = setTimeout(ok, ms); if (signal) signal.addEventListener('abort', () => { clearTimeout(h); ok(); }, { once: true }); }), pollMs = 3000, now = () => Date.now() }) {++ const base = config.services.codesUrl;++ const pages = config.services.codesPublicUrl || 'https://openvibe.codes';++ const enabled = !!(base && (tokensOverride || config.oauth.clientSecret));++ const tokens = !enabled ? null : tokensOverride || serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.codes', scope: 'codes.task.start codes.task.read codes.task.cancel', fetchImpl,++ });++++ async function call(method, path, subject, { body, signal } = {}) {++ const sig = signal ? AbortSignal.any([signal, AbortSignal.timeout(20_000)]) : AbortSignal.timeout(20_000);++ const res = await fetchImpl(`${base}/api/v1${path}`, {++ method,++ headers: { Accept: 'application/json', 'X-OV-Subject': subject, ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokens.authHeaders()) },++ body: body ? JSON.stringify(body) : undefined, signal: sig,++ });++ const data = await res.json().catch(() => null);++ return { status: res.status, ok: res.ok, data };++ }++ const why = (r) => (r.data && (r.data.detail || r.data.title)) || `OpenVibe.Codes answered ${r.status}`;++++ async function adapter({ task, requester, signal, emit, taskId = null }) {++ if (!enabled) throw new ProviderError('OpenVibe.Codes is not connected to this server', { code: 'actor.agent.unavailable' });++ const subject = personOf(requester);++ if (!subject || !USER_SUBJECT.test(subject)) {++ throw new ProviderError(`Coding tasks run on your own machine through OpenVibe.Codes, so they need you signed in on openvibe.actor. You can also start one yourself at ${pages}/run`, { code: 'actor.agent.unavailable' });++ }++ const repo = repoIn(task);++ if (!repo) throw new ProviderError('Name the repository to work on, as its https address (for example https://github.com/you/project), and say what to change.', { code: 'actor.task.no_repo' });++ await emit({ step: 'tool_call', tool: 'codes.task.start', input: repo });++ const started = await call('POST', '/tasks', subject, { body: { repo, prompt: String(task).slice(0, 8000), ...(taskId ? { idempotency_key: String(taskId).slice(0, 128) } : {}) }, signal });++ if (started.status === 409 && started.data && started.data.code === 'codes.no_project') {++ throw new ProviderError(`Before Actor can send coding work to your machine: add the machine at https://openvibe.bot/machines, then start one task yourself at ${pages}/run (it picks the project your tasks run in).`, { code: 'actor.agent.unavailable' });++ }++ if (!started.ok || !started.data || !started.data.task) throw new ProviderError(why(started), { code: 'actor.agent.failed' });++ if (!TASK_ID.test(String(started.data.task.id))) throw new ProviderError('OpenVibe.Codes answered with no task id', { code: 'actor.agent.failed' });++ let t = started.data.task;++ await emit({ step: 'tool_result', tool: 'codes.task.start', text: `${LABEL[t.state] || t.state}: ${t.url}` });++ // Wait for the end, inside Actor's own task time: past it, the answer is the task's live page.++ const deadline = now() + Math.max(5_000, (config.taskTimeoutMs || 180_000) - 25_000);++ let shown = t.state;++ try {++ while (!TERMINAL.has(t.state) && now() < deadline) {++ await sleep(pollMs, signal);++ if (signal && signal.aborted) break;++ const r = await call('GET', `/tasks/${t.id}`, subject, { signal });++ if (r.ok && r.data) t = r.data;++ if (t.state !== shown) {++ shown = t.state;++ await emit({ step: 'text', text: `${LABEL[t.state] || t.state}.` });++ }++ }++ } finally {++ if (signal && signal.aborted && !TERMINAL.has(t.state)) {++ await call('POST', `/tasks/${t.id}/cancel`, subject).catch(() => null);++ }++ }++ if (signal && signal.aborted) throw new ProviderError('cancelled', { code: 'actor.task.cancelled' });++ const sources = [t.url];++ if (!TERMINAL.has(t.state)) {++ return { text: `${LABEL[t.state] || t.state}, and it is not done yet. Follow it live, and see the change when it ends: [the task on OpenVibe.Codes](${t.url})`, sources, model: 'openvibe-codes' };++ }++ if (t.state === 'expired') throw new ProviderError(`No machine of yours took the task in time. Is it online at https://openvibe.bot/machines, with the Codes job runner (install it with --worker)? The task: ${t.url}`, { code: 'actor.agent.failed' });++ if (t.state !== 'succeeded') throw new ProviderError(`The task ${t.state === 'cancelled' ? 'was cancelled' : 'failed'}${t.error ? `: ${t.error}` : ''}. ${t.url}`, { code: 'actor.agent.failed' });++ if (!t.result || !t.result.ok) throw new ProviderError(`The agents on your machine did not finish${t.result && t.result.error ? `: ${t.result.error}` : ''}. ${t.url}`, { code: 'actor.agent.failed' });++ return { text: answerOf(t), sources, model: (t.result.agents || [])[0] || 'openvibe-codes' };++ }++++ return { enabled, adapter };++}++++module.exports = { createCodes, repoIn, answerOf, personOf };@@@ -78,7 +78,7 @@ function whyNone(result, { cls, mode, budgetUsd }) {}if (mode === 'private') return { code: 'actor.no_agent', detail: `Private mode keeps the task on OpenVibe's own hardware, and no agent there can take a "${cls}" task yet (the open model answers simple questions only).` };if (cls === 'robot') return { code: 'actor.no_agent', detail: 'Robot tasks go to the OpenVibe runtime, which acts on your own robots on OpenVibe.Bot. It is not available right now; try again later.' };-- if (cls === 'code') return { code: 'actor.no_agent', detail: 'Coding tasks go to OpenVibe.Codes, and hosted coding runs are not open yet. Run openvibe-codes on your own machine: https://openvibe.codes/start' };++ if (cls === 'code') return { code: 'actor.no_agent', detail: 'Coding tasks go to OpenVibe.Codes, which runs them on your own machine, and it is not connected right now. You can start one yourself at https://openvibe.codes/run' };const down = cands.filter((c) => !c.eligible).map((c) => `${c.id}: ${c.excluded_because}`).join('; ');return { code: 'actor.no_agent', detail: `No agent can take this task right now (${down || 'none configured'}).`.slice(0, 2000) };}@@@ -68,6 +68,9 @@ function load(env = process.env) {// OpenVibe.Bot: the person's own robots (list_my_robots, robot_steps). Empty turns the robot tools off.botUrl: trim(env.ACTOR_BOT_URL != null ? env.ACTOR_BOT_URL : 'http://127.0.0.1:4630'),botPublicUrl: trim(env.ACTOR_BOT_PUBLIC_URL || 'https://openvibe.bot'),++ // OpenVibe.Codes: the `codes` agent runs a person's coding task on their own machine. Empty turns it off.++ codesUrl: trim(env.ACTOR_CODES_URL != null ? env.ACTOR_CODES_URL : 'http://127.0.0.1:4900'),++ codesPublicUrl: trim(env.ACTOR_CODES_PUBLIC_URL || 'https://openvibe.codes'),},// What one person may spend without paying (plan T17: "free tier from the bounded allowance"), and the@@@ -142,7 +142,7 @@ ${table(['Piece', 'State'], [[html`Tasks a watch hands over when its condition fires (<a href="https://openvibe.watch">OpenVibe.Watch</a>)`, 'Live: the task is yours, with your allowance; Watch sees only the tasks it started.'],[html`Your own agents (<a href="/me/agents">Your agents</a>): instructions, memory you can read and edit, a schedule (hourly, daily or weekly), a budget per run and a daily cap`, 'Live.'],[html`Your own robots on <a href="https://openvibe.bot">OpenVibe.Bot</a>: see them and drive the ones you opened to your agent, through Bot's limits and e-stop`, 'Live: turn on "Let my OpenVibe.Actor agent drive this robot" on the robot\'s panel.'],-- ['Coding tasks', html`They go to OpenVibe.Codes: <a href="https://openvibe.codes/start">run openvibe-codes</a> on your machine.`],++ [html`Coding tasks on your own machine, through <a href="https://openvibe.codes/run">OpenVibe.Codes</a>: name the repository and what to change, and get the summary, the files and the patch`, html`Live: add the machine on <a href="https://openvibe.bot/machines">openvibe.bot/machines</a> and start one task on openvibe.codes/run first. The model use is on your machine's own keys.`],])}</section>${raw(showcase.cta({ title: 'Build on it', text: 'Every task, its live stream, its webhooks and the router are an API. Apps get actor.task.* grants on OpenVibe.Services.', actions: [{ label: 'Read the API', href: '/docs', primary: true }, { label: 'See the agents', href: '/agents' }] }))}`,}, status);@@@ -113,7 +113,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =await emit(id, { kind: 'output', step: attempt > 1 ? 'handoff' : 'plan', agent: agentId, text: `${agent.name} takes it${placement.reasons && placement.reasons[0] ? `: ${placement.reasons[0]}` : ''}.` });let out;try {-- out = await adapters.run[agentId]({ task: row.task, requester: row.requester, cls, mode: row.mode, signal, meter, budgetLeft, emit: (e) => emit(id, { kind: 'output', agent: agentId, ...e }) });++ out = await adapters.run[agentId]({ task: row.task, requester: row.requester, taskId: id, cls, mode: row.mode, signal, meter, budgetLeft, emit: (e) => emit(id, { kind: 'output', agent: agentId, ...e }) });} catch (err) {if (signal.aborted) throw err;const why = (err && err.message) || String(err);nnew file mode 100644@@@ -0,0 +1,104 @@++'use strict';++// The `codes` agent (plan T16/T17, agents/codes.js): a signed-in person's coding task goes to OpenVibe.Codes as that++// person (X-OV-Subject, Actor's own service token), with the Actor task id as its idempotency key; Actor follows it++// to the end and answers with the summary, the files, the patch and the task's page. A person who never started a++// task on openvibe.codes/run is told how; past Actor's own time the answer links the live page; a cancelled Actor task++// cancels the Codes task; an expired one says the machine never took it. The catalog lists the agent as available++// once Codes is connected, at no cost to Actor, and its answers are checked like any other.++const assert = require('assert');++const { createCodes, repoIn } = require('../server/agents/codes');++const catalog = require('../server/agents/catalog');++const { checkersFor } = require('../server/agents/check');++const { check, done } = require('./helpers/boot');++++const ME = 'usr_01M4W5JBFEG3QG9ER8W1A2K0RX';++const TASK = 'tsk_01M4XENCR31NXDJQYDCCW2J9R9';++const config = {++ networkInternalUrl: 'http://network.test', oauth: { clientId: 'actor', clientSecret: 'actor-secret' }, taskTimeoutMs: 180_000,++ services: { codesUrl: 'http://codes.test', codesPublicUrl: 'https://openvibe.codes' },++};++const tokens = { authHeaders: async () => ({ Authorization: 'Bearer svc-token' }) };++++/** A fake OpenVibe.Codes: `states` is what each read answers, in turn (the last one repeats). */++function fakeCodes({ start = 201, startBody = null, states = ['running', 'succeeded'] } = {}) {++ const calls = [];++ let reads = 0;++ const page = `https://openvibe.codes/run/tasks/${TASK}`;++ const task = (state) => ({++ id: TASK, state, repo: 'https://github.com/octocat/Hello-World', url: page, created_at: '2026-10-14T15:00:00.000Z', updated_at: '2026-10-14T15:00:00.000Z',++ ...(state === 'succeeded' ? { result: { ok: true, agents: ['deepseek'], summary: 'Appended the line.', files: [{ path: 'README', added: 1, removed: 0 }], patch: 'diff --git a/README b/README\n+Edited.\n', duration_ms: 4200 } } : {}),++ });++ const reply = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });++ const fetchImpl = async (url, init = {}) => {++ const u = new URL(url);++ const headers = init.headers || {};++ calls.push({ method: init.method || 'GET', path: u.pathname, subject: headers['X-OV-Subject'], auth: headers.Authorization, body: init.body ? JSON.parse(init.body) : null });++ if (u.pathname === '/api/v1/tasks' && init.method === 'POST') return reply(start, startBody || { task: task('queued'), created: true });++ if (u.pathname === `/api/v1/tasks/${TASK}` && (init.method || 'GET') === 'GET') return reply(200, task(states[Math.min(reads++, states.length - 1)]));++ if (u.pathname === `/api/v1/tasks/${TASK}/cancel`) return reply(200, task('cancelled'));++ return reply(404, { code: 'not_found' });++ };++ return { calls, fetchImpl };++}++const run = (codes, extra = {}) => {++ const events = [];++ return codes.adapter({ task: 'In https://github.com/octocat/Hello-World, add a line to the README.', requester: `user:${ME}`, taskId: 'act_task_01', emit: async (e) => events.push(e), ...extra })++ .then((out) => ({ out, events }));++};++++(async () => {++ await check('the repository comes from the task text', async () => {++ assert.strictEqual(repoIn('fix https://github.com/a/b.git, please'), 'https://github.com/a/b');++ assert.strictEqual(repoIn('see https://codeberg.org/me/tool).'), 'https://codeberg.org/me/tool');++ assert.strictEqual(repoIn('fix my code'), null);++ assert.strictEqual(repoIn('http://github.com/a/b'), null);++ });++++ await check('a coding task starts on Codes as the person, keyed by the Actor task, and answers with the change', async () => {++ const codes = fakeCodes();++ const { out, events } = await run(createCodes({ config, fetchImpl: codes.fetchImpl, tokens, sleep: async () => {}, pollMs: 0 }));++ const start = codes.calls[0];++ assert.deepStrictEqual([start.method, start.path, start.subject, start.auth], ['POST', '/api/v1/tasks', ME, 'Bearer svc-token']);++ assert.strictEqual(start.body.repo, 'https://github.com/octocat/Hello-World');++ assert.strictEqual(start.body.idempotency_key, 'act_task_01');++ assert.ok(out.text.includes('Done on your machine') && out.text.includes('Appended the line.') && out.text.includes('`README` (+1 −0)') && out.text.includes('```diff'), out.text);++ assert.deepStrictEqual(out.sources, [`https://openvibe.codes/run/tasks/${TASK}`]);++ assert.ok(events.some((e) => e.text && e.text.startsWith('Your machine is working on it')));++ });++++ await check('signed out, no repository, or no /run task yet: a refusal that says what to do', async () => {++ const codes = createCodes({ config, fetchImpl: fakeCodes().fetchImpl, tokens, sleep: async () => {}, pollMs: 0 });++ await assert.rejects(codes.adapter({ task: 'https://github.com/a/b fix', requester: 'ip:1.2.3.4', emit: async () => {} }), /signed in/);++ await assert.rejects(codes.adapter({ task: 'fix my code', requester: `user:${ME}`, emit: async () => {} }), (e) => e.code === 'actor.task.no_repo');++ const none = fakeCodes({ start: 409, startBody: { code: 'codes.no_project', detail: 'no /run task yet' } });++ await assert.rejects(run(createCodes({ config, fetchImpl: none.fetchImpl, tokens, sleep: async () => {}, pollMs: 0 })), /openvibe\.bot\/machines.*openvibe\.codes\/run/);++ });++++ await check('past Actor\'s own time the answer links the live page; an expired task says the machine never took it', async () => {++ let clock = 0;++ const slow = fakeCodes({ states: ['running'] });++ const { out } = await run(createCodes({ config, fetchImpl: slow.fetchImpl, tokens, sleep: async () => { clock += 10_000; }, pollMs: 0, now: () => clock }));++ assert.ok(out.text.includes('not done yet') && out.text.includes(`/run/tasks/${TASK}`), out.text);++ assert.ok(!slow.calls.some((c) => c.path.endsWith('/cancel')), 'a slow task is left running');++ const expired = fakeCodes({ states: ['expired'] });++ await assert.rejects(run(createCodes({ config, fetchImpl: expired.fetchImpl, tokens, sleep: async () => {}, pollMs: 0 })), /No machine of yours took the task/);++ });++++ await check('cancelling the Actor task cancels the Codes task', async () => {++ const codes = fakeCodes({ states: ['running'] });++ const ac = new AbortController();++ const sleep = async () => { ac.abort(); };++ await assert.rejects(run(createCodes({ config, fetchImpl: codes.fetchImpl, tokens, sleep, pollMs: 0 }), { signal: ac.signal }), (e) => e.code === 'actor.task.cancelled');++ assert.ok(codes.calls.some((c) => c.method === 'POST' && c.path === `/api/v1/tasks/${TASK}/cancel` && c.subject === ME));++ });++++ await check('the catalog lists the agent once Codes is connected, at no cost to Actor; either family checks its answers', async () => {++ const agent = catalog.byId('codes');++ assert.deepStrictEqual(catalog.availability(agent, config), { available: true });++ assert.strictEqual(catalog.availability(agent, { ...config, services: { codesUrl: '' } }).available, false);++ assert.strictEqual(catalog.typicalCost('codes'), 0);++ const families = checkersFor('codes', { deepseek: async () => ({}), openai: async () => ({}) }, { mode: 'balanced' }).map((c) => c.family);++ assert.deepStrictEqual(families, ['deepseek', 'openai']);++ });++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -106,6 +106,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };'server/auth/keys.js': 'no request itself; openvibe-sdk/auth fetches the JWKS from the configured Network','server/auth/sso.js': 'Network OAuth token and revoke (configured networkInternalUrl)','server/agents/providers.js': 'the configured provider base URLs (DeepSeek, OpenAI, the open model) with the operator\'s keys',++ 'server/agents/codes.js': 'the configured OpenVibe.Codes base (ACTOR_CODES_URL) with Actor\'s own service token; the subject must be usr_<ULID> and every task id tsk_<ULID> before it goes in a path; the repository named in the task is data for the person\'s own machine, never fetched here (test/codes.test.js)','server/agents/robots.js': 'the configured OpenVibe.Bot base (ACTOR_BOT_URL) with Actor\'s own service token; the robot id must be rob_<ULID> and the subject usr_<ULID> before anything is sent (test/robots.test.js)','server/agents/tools.js': 'the configured OpenVibe.Tools and OpenVibe.Search bases; ids come from Tools\' catalog, a page URL goes to Tools as input','server/events-consumer.js': 'OpenVibe.Events: the configured events.url (ACTOR_EVENTS_URL), to create the two ADR-033 subscriptions at boot',
@@ -10,9 +10,11 @@
@@ -10,9 +10,11 @@
* signed-in person's task also gets their own robots (agents/robots.js)
* signed-in person's task also gets their own robots (agents/robots.js)
* openai-agent OpenAI's Responses agent with its hosted web search
* openai-agent OpenAI's Responses agent with its hosted web search
* open-model the open model on OpenVibe's own server, one call, no tools
* open-model the open model on OpenVibe's own server, one call, no tools
+ * codes OpenVibe.Codes: the person's coding task on their own machine (agents/codes.js)
*/
*/
const { chatClient, responsesAgent, ProviderError } = require('./providers');
const { chatClient, responsesAgent, ProviderError } = require('./providers');
const { createOpenVibeTools } = require('./tools');
const { createOpenVibeTools } = require('./tools');
+const { createCodes } = require('./codes');
const MAX_TURNS = 10;
const MAX_TURNS = 10;
@@ -34,7 +36,7 @@ const summarize = (args) => {
@@ -34,7 +36,7 @@ const summarize = (args) => {
return String(v).slice(0, 300);
return String(v).slice(0, 300);
};
};
-function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = console, tools: toolsOverride }) {
+function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = console, tools: toolsOverride, codes: codesOverride }) {
const p = config.providers;
const p = config.providers;
const ovTools = toolsOverride || createOpenVibeTools({ config, fetchImpl, log });
const ovTools = toolsOverride || createOpenVibeTools({ config, fetchImpl, log });
@@ -94,7 +96,8 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
@@ -94,7 +96,8 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
return { text: r.text.trim(), sources: [], model: r.model };
return { text: r.text.trim(), sources: [], model: r.model };
}
}
- const run = { 'openvibe-runtime': runtime, 'openai-agent': openai, 'open-model': openModel };
+ const codes = codesOverride || createCodes({ config, fetchImpl });
+ const run = { 'openvibe-runtime': runtime, 'openai-agent': openai, 'open-model': openModel, codes: codes.adapter };
/** A cheap model of another family to classify and to check results (null when none is configured). */
/** A cheap model of another family to classify and to check results (null when none is configured). */
const checkers = {
const checkers = {
@@ -76,8 +76,9 @@ const AGENTS = [
@@ -76,8 +76,9 @@ const AGENTS = [
trust: 'first-party',
trust: 'first-party',
quality: 'high',
quality: 'high',
can: ['task:code'],
can: ['task:code'],
- rate_card: { input_usd_per_mtok: 0.30, output_usd_per_mtok: 1.20, source: 'https://openvibe.codes/harnesses', verified_at: VERIFIED },
- blurb: 'Coding work goes to OpenVibe.Codes, the coding-agent harness (Claude Code, Codex, OpenCode, DeepSeek and more). Hosted coding runs come with OpenVibe.Run; today, run openvibe-codes on your own machine.',
+ // The model use is on the person's own machine and keys: nothing for Actor to meter.
+ rate_card: { input_usd_per_mtok: 0, output_usd_per_mtok: 0, source: 'https://openvibe.codes/run', verified_at: VERIFIED },
+ blurb: 'Coding work goes to OpenVibe.Codes and runs on your own machine (add it on openvibe.bot/machines, then start one task on openvibe.codes/run). Name the repository; you get the summary, the changed files and the patch. The model use is on your machine\'s own keys, so Actor charges nothing for it.',
},
},
];
];
@@ -87,7 +88,10 @@ function availability(agent, config) {
@@ -87,7 +88,10 @@ function availability(agent, config) {
if (agent.providerKey === 'deepseek') return p.deepseek && p.deepseek.apiKey ? { available: true } : { available: false, reason: 'no DeepSeek key is configured on this server' };
if (agent.providerKey === 'deepseek') return p.deepseek && p.deepseek.apiKey ? { available: true } : { available: false, reason: 'no DeepSeek key is configured on this server' };
if (agent.providerKey === 'openai') return p.openai && p.openai.apiKey ? { available: true } : { available: false, reason: 'no OpenAI key is configured on this server' };
if (agent.providerKey === 'openai') return p.openai && p.openai.apiKey ? { available: true } : { available: false, reason: 'no OpenAI key is configured on this server' };
if (agent.providerKey === 'local') return p.local && p.local.url && p.local.model ? { available: true } : { available: false, reason: 'no open model is running on this server' };
if (agent.providerKey === 'local') return p.local && p.local.url && p.local.model ? { available: true } : { available: false, reason: 'no open model is running on this server' };
- if (agent.providerKey === 'codes') return { available: false, reason: 'hosted coding runs need OpenVibe.Run sandboxes, which are not open yet; run openvibe-codes on your own machine (openvibe.codes/start)' };
+ if (agent.providerKey === 'codes') {
+ const s = config.services || {};
+ return s.codesUrl && config.oauth && config.oauth.clientSecret ? { available: true } : { available: false, reason: 'OpenVibe.Codes is not connected to this server' };
+ }
return { available: false, reason: 'no adapter' };
return { available: false, reason: 'no adapter' };
}
}
@@ -11,14 +11,16 @@
@@ -11,14 +11,16 @@
* empty answer or one about something else fails.
* empty answer or one about something else fails.
*/
*/
-const FAMILY = { 'openvibe-runtime': 'deepseek', 'openai-agent': 'openai', 'open-model': 'local' };
-const CHECKER_FOR = { deepseek: ['openai', 'deepseek'], openai: ['deepseek', 'openai'], local: ['local'] };
+// codes: the person's own machine wrote it (any of Codes' agents), so either family reads it.
+const FAMILY = { 'openvibe-runtime': 'deepseek', 'openai-agent': 'openai', 'open-model': 'local', codes: 'codes' };
+const CHECKER_FOR = { deepseek: ['openai', 'deepseek'], openai: ['deepseek', 'openai'], local: ['local'], codes: ['deepseek', 'openai'] };
const SYSTEM = `You check an AI agent's answer before a person sees it. Reply with JSON only: {"ok": true|false, "reason": "<one short sentence>"}.
const SYSTEM = `You check an AI agent's answer before a person sees it. Reply with JSON only: {"ok": true|false, "reason": "<one short sentence>"}.
ok is true when the answer addresses the task and nothing in it is clearly invented, contradictory or unsafe. It does not have to be perfect.
ok is true when the answer addresses the task and nothing in it is clearly invented, contradictory or unsafe. It does not have to be perfect.
ok is false when the answer is empty, refuses without a good reason, answers a different question, contradicts itself, or states something that is plainly impossible.
ok is false when the answer is empty, refuses without a good reason, answers a different question, contradicts itself, or states something that is plainly impossible.
The agent may have searched the web or run tools: do not fail an answer only because it states recent facts, versions, prices or dates you cannot verify yourself, especially when it cites a source for them.
The agent may have searched the web or run tools: do not fail an answer only because it states recent facts, versions, prices or dates you cannot verify yourself, especially when it cites a source for them.
The agent may have made the person's own robot act through OpenVibe.Bot: an answer that reports what the robot did, or why it stopped or was refused, addresses the task.
The agent may have made the person's own robot act through OpenVibe.Bot: an answer that reports what the robot did, or why it stopped or was refused, addresses the task.
+The agent may have run a coding task on the person's own machine through OpenVibe.Codes: an answer that reports what changed (a summary, the files, a patch), or that the machine is still working on it with a link to follow it, addresses the task.
When the task is a greeting, small talk, a test message or too vague to act on, a short friendly reply, a summary of what the agent can do or a clarifying question is a good answer: ok is true.`;
When the task is a greeting, small talk, a test message or too vague to act on, a short friendly reply, a summary of what the agent can do or a clarifying question is a good answer: ok is true.`;
/** Which checkers may read this agent's answer, best first: another family, then the same family. */
/** Which checkers may read this agent's answer, best first: another family, then the same family. */
new file mode 100644
new file mode 100644
@@ -0,0 +1,134 @@
@@ -0,0 +1,134 @@
+'use strict';
+
+/**
+ * The `codes` agent (plan T16/T17): a person's coding task goes to OpenVibe.Codes, which runs it on that person's own
+ * machine (openvibe.codes/run: an OpenVibe.Run job placed only on a machine of theirs, added on openvibe.bot/machines).
+ * Actor calls Codes' task API (Contracts 0.162 codes.task.*) with its own service token (audience openvibe.codes) and
+ * X-OV-Subject naming the person; Codes shows Actor only the tasks Actor started for that person. The model use is on
+ * the machine's own keys, so Actor meters nothing for it.
+ *
+ * createCodes({ config, fetchImpl, tokens?, sleep?, pollMs?, now? }) → { enabled, adapter }
+ * adapter({ task, requester, signal, emit, meter, budgetLeft, taskId }) → { text, sources, model }
+ *
+ * The adapter starts the task (the Actor task id is its idempotency key, so a retry never starts a second one),
+ * reports each state change, and waits for the end. When the machine is still working as Actor's own task time runs
+ * out, the answer says so and links the task's live page; when the task is cancelled, the Codes task is cancelled too.
+ * Off (enabled false) without ACTOR_CODES_URL or without Actor's client secret.
+ */
+const { serviceAuth } = require('openvibe-contracts');
+const { ProviderError } = require('./providers');
+
+const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
+const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'expired']);
+const LABEL = {
+ queued: 'Waiting for your machine', placed: 'Sent to your machine', running: 'Your machine is working on it',
+ succeeded: 'Finished', failed: 'Failed', cancelled: 'Cancelled', expired: 'No machine of yours took it in time',
+};
+// A repository the task names: an https URL with an owner and a name (GitHub, GitLab, Codeberg, a Forgejo …).
+const REPO = /https:\/\/[a-z0-9.-]+\.[a-z]{2,}\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+/;
+
+const personOf = (requester) => (typeof requester === 'string' && requester.startsWith('user:') ? requester.slice(5) : null);
+
+/** The first repository URL in the task text, without a trailing .git or punctuation; null when there is none. */
+function repoIn(text) {
+ const m = REPO.exec(String(text || ''));
+ if (!m) return null;
+ return m[0].replace(/[.,;:!?)]+$/, '').replace(/\.git$/, '');
+}
+
+/** The ended task's answer for the person, in Markdown. */
+function answerOf(t) {
+ const r = t.result || {};
+ const lines = [];
+ const took = typeof r.duration_ms === 'number' ? `, ${Math.max(1, Math.round(r.duration_ms / 1000))} s` : '';
+ lines.push(`**Done on your machine**${r.agents && r.agents.length ? ` (${r.agents.join(' → ')}${took})` : took ? ` (${took.slice(2)})` : ''}.`);
+ if (r.summary) lines.push('', r.summary.trim());
+ const files = Array.isArray(r.files) ? r.files : [];
+ if (files.length) {
+ const shown = files.slice(0, 20).map((f) => `\`${f.path}\` (+${f.added == null ? '·' : f.added} −${f.removed == null ? '·' : f.removed})`);
+ lines.push('', `**Changed files:** ${shown.join(', ')}${files.length > 20 ? ` and ${files.length - 20} more` : ''}`);
+ } else {
+ lines.push('', 'No files changed.');
+ }
+ if (r.patch) {
+ const cut = r.patch.length > 3000;
+ lines.push('', '```diff', r.patch.slice(0, 3000).replace(/```/g, '```'), '```', ...(cut || r.patch_truncated ? ['(The whole patch is on the task page.)'] : []));
+ }
+ if (r.push && r.push.pushed) lines.push('', `Pushed to the branch [${r.push.branch}](${r.push.branch_url})${r.push.pull_request ? ` · [pull request](${r.push.pull_request})` : ''}.`);
+ lines.push('', `[The task on OpenVibe.Codes](${t.url})`);
+ return lines.join('\n');
+}
+
+function createCodes({ config, fetchImpl = globalThis.fetch, tokens: tokensOverride = null, sleep = (ms, signal) => new Promise((ok) => { const h = setTimeout(ok, ms); if (signal) signal.addEventListener('abort', () => { clearTimeout(h); ok(); }, { once: true }); }), pollMs = 3000, now = () => Date.now() }) {
+ const base = config.services.codesUrl;
+ const pages = config.services.codesPublicUrl || 'https://openvibe.codes';
+ const enabled = !!(base && (tokensOverride || config.oauth.clientSecret));
+ const tokens = !enabled ? null : tokensOverride || serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.codes', scope: 'codes.task.start codes.task.read codes.task.cancel', fetchImpl,
+ });
+
+ async function call(method, path, subject, { body, signal } = {}) {
+ const sig = signal ? AbortSignal.any([signal, AbortSignal.timeout(20_000)]) : AbortSignal.timeout(20_000);
+ const res = await fetchImpl(`${base}/api/v1${path}`, {
+ method,
+ headers: { Accept: 'application/json', 'X-OV-Subject': subject, ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokens.authHeaders()) },
+ body: body ? JSON.stringify(body) : undefined, signal: sig,
+ });
+ const data = await res.json().catch(() => null);
+ return { status: res.status, ok: res.ok, data };
+ }
+ const why = (r) => (r.data && (r.data.detail || r.data.title)) || `OpenVibe.Codes answered ${r.status}`;
+
+ async function adapter({ task, requester, signal, emit, taskId = null }) {
+ if (!enabled) throw new ProviderError('OpenVibe.Codes is not connected to this server', { code: 'actor.agent.unavailable' });
+ const subject = personOf(requester);
+ if (!subject || !USER_SUBJECT.test(subject)) {
+ throw new ProviderError(`Coding tasks run on your own machine through OpenVibe.Codes, so they need you signed in on openvibe.actor. You can also start one yourself at ${pages}/run`, { code: 'actor.agent.unavailable' });
+ }
+ const repo = repoIn(task);
+ if (!repo) throw new ProviderError('Name the repository to work on, as its https address (for example https://github.com/you/project), and say what to change.', { code: 'actor.task.no_repo' });
+ await emit({ step: 'tool_call', tool: 'codes.task.start', input: repo });
+ const started = await call('POST', '/tasks', subject, { body: { repo, prompt: String(task).slice(0, 8000), ...(taskId ? { idempotency_key: String(taskId).slice(0, 128) } : {}) }, signal });
+ if (started.status === 409 && started.data && started.data.code === 'codes.no_project') {
+ throw new ProviderError(`Before Actor can send coding work to your machine: add the machine at https://openvibe.bot/machines, then start one task yourself at ${pages}/run (it picks the project your tasks run in).`, { code: 'actor.agent.unavailable' });
+ }
+ if (!started.ok || !started.data || !started.data.task) throw new ProviderError(why(started), { code: 'actor.agent.failed' });
+ if (!TASK_ID.test(String(started.data.task.id))) throw new ProviderError('OpenVibe.Codes answered with no task id', { code: 'actor.agent.failed' });
+ let t = started.data.task;
+ await emit({ step: 'tool_result', tool: 'codes.task.start', text: `${LABEL[t.state] || t.state}: ${t.url}` });
+ // Wait for the end, inside Actor's own task time: past it, the answer is the task's live page.
+ const deadline = now() + Math.max(5_000, (config.taskTimeoutMs || 180_000) - 25_000);
+ let shown = t.state;
+ try {
+ while (!TERMINAL.has(t.state) && now() < deadline) {
+ await sleep(pollMs, signal);
+ if (signal && signal.aborted) break;
+ const r = await call('GET', `/tasks/${t.id}`, subject, { signal });
+ if (r.ok && r.data) t = r.data;
+ if (t.state !== shown) {
+ shown = t.state;
+ await emit({ step: 'text', text: `${LABEL[t.state] || t.state}.` });
+ }
+ }
+ } finally {
+ if (signal && signal.aborted && !TERMINAL.has(t.state)) {
+ await call('POST', `/tasks/${t.id}/cancel`, subject).catch(() => null);
+ }
+ }
+ if (signal && signal.aborted) throw new ProviderError('cancelled', { code: 'actor.task.cancelled' });
+ const sources = [t.url];
+ if (!TERMINAL.has(t.state)) {
+ return { text: `${LABEL[t.state] || t.state}, and it is not done yet. Follow it live, and see the change when it ends: [the task on OpenVibe.Codes](${t.url})`, sources, model: 'openvibe-codes' };
+ }
+ if (t.state === 'expired') throw new ProviderError(`No machine of yours took the task in time. Is it online at https://openvibe.bot/machines, with the Codes job runner (install it with --worker)? The task: ${t.url}`, { code: 'actor.agent.failed' });
+ if (t.state !== 'succeeded') throw new ProviderError(`The task ${t.state === 'cancelled' ? 'was cancelled' : 'failed'}${t.error ? `: ${t.error}` : ''}. ${t.url}`, { code: 'actor.agent.failed' });
+ if (!t.result || !t.result.ok) throw new ProviderError(`The agents on your machine did not finish${t.result && t.result.error ? `: ${t.result.error}` : ''}. ${t.url}`, { code: 'actor.agent.failed' });
+ return { text: answerOf(t), sources, model: (t.result.agents || [])[0] || 'openvibe-codes' };
+ }
+
+ return { enabled, adapter };
+}
+
+module.exports = { createCodes, repoIn, answerOf, personOf };
@@ -78,7 +78,7 @@ function whyNone(result, { cls, mode, budgetUsd }) {
@@ -78,7 +78,7 @@ function whyNone(result, { cls, mode, budgetUsd }) {
}
}
if (mode === 'private') return { code: 'actor.no_agent', detail: `Private mode keeps the task on OpenVibe's own hardware, and no agent there can take a "${cls}" task yet (the open model answers simple questions only).` };
if (mode === 'private') return { code: 'actor.no_agent', detail: `Private mode keeps the task on OpenVibe's own hardware, and no agent there can take a "${cls}" task yet (the open model answers simple questions only).` };
if (cls === 'robot') return { code: 'actor.no_agent', detail: 'Robot tasks go to the OpenVibe runtime, which acts on your own robots on OpenVibe.Bot. It is not available right now; try again later.' };
if (cls === 'robot') return { code: 'actor.no_agent', detail: 'Robot tasks go to the OpenVibe runtime, which acts on your own robots on OpenVibe.Bot. It is not available right now; try again later.' };
- if (cls === 'code') return { code: 'actor.no_agent', detail: 'Coding tasks go to OpenVibe.Codes, and hosted coding runs are not open yet. Run openvibe-codes on your own machine: https://openvibe.codes/start' };
+ if (cls === 'code') return { code: 'actor.no_agent', detail: 'Coding tasks go to OpenVibe.Codes, which runs them on your own machine, and it is not connected right now. You can start one yourself at https://openvibe.codes/run' };
const down = cands.filter((c) => !c.eligible).map((c) => `${c.id}: ${c.excluded_because}`).join('; ');
const down = cands.filter((c) => !c.eligible).map((c) => `${c.id}: ${c.excluded_because}`).join('; ');
return { code: 'actor.no_agent', detail: `No agent can take this task right now (${down || 'none configured'}).`.slice(0, 2000) };
return { code: 'actor.no_agent', detail: `No agent can take this task right now (${down || 'none configured'}).`.slice(0, 2000) };
}
}
@@ -68,6 +68,9 @@ function load(env = process.env) {
@@ -68,6 +68,9 @@ function load(env = process.env) {
// OpenVibe.Bot: the person's own robots (list_my_robots, robot_steps). Empty turns the robot tools off.
// OpenVibe.Bot: the person's own robots (list_my_robots, robot_steps). Empty turns the robot tools off.
botUrl: trim(env.ACTOR_BOT_URL != null ? env.ACTOR_BOT_URL : 'http://127.0.0.1:4630'),
botUrl: trim(env.ACTOR_BOT_URL != null ? env.ACTOR_BOT_URL : 'http://127.0.0.1:4630'),
botPublicUrl: trim(env.ACTOR_BOT_PUBLIC_URL || 'https://openvibe.bot'),
botPublicUrl: trim(env.ACTOR_BOT_PUBLIC_URL || 'https://openvibe.bot'),
+ // OpenVibe.Codes: the `codes` agent runs a person's coding task on their own machine. Empty turns it off.
+ codesUrl: trim(env.ACTOR_CODES_URL != null ? env.ACTOR_CODES_URL : 'http://127.0.0.1:4900'),
+ codesPublicUrl: trim(env.ACTOR_CODES_PUBLIC_URL || 'https://openvibe.codes'),
},
},
// What one person may spend without paying (plan T17: "free tier from the bounded allowance"), and the
// What one person may spend without paying (plan T17: "free tier from the bounded allowance"), and the
@@ -142,7 +142,7 @@ ${table(['Piece', 'State'], [
@@ -142,7 +142,7 @@ ${table(['Piece', 'State'], [
[html`Tasks a watch hands over when its condition fires (<a href="https://openvibe.watch">OpenVibe.Watch</a>)`, 'Live: the task is yours, with your allowance; Watch sees only the tasks it started.'],
[html`Tasks a watch hands over when its condition fires (<a href="https://openvibe.watch">OpenVibe.Watch</a>)`, 'Live: the task is yours, with your allowance; Watch sees only the tasks it started.'],
[html`Your own agents (<a href="/me/agents">Your agents</a>): instructions, memory you can read and edit, a schedule (hourly, daily or weekly), a budget per run and a daily cap`, 'Live.'],
[html`Your own agents (<a href="/me/agents">Your agents</a>): instructions, memory you can read and edit, a schedule (hourly, daily or weekly), a budget per run and a daily cap`, 'Live.'],
[html`Your own robots on <a href="https://openvibe.bot">OpenVibe.Bot</a>: see them and drive the ones you opened to your agent, through Bot's limits and e-stop`, 'Live: turn on "Let my OpenVibe.Actor agent drive this robot" on the robot\'s panel.'],
[html`Your own robots on <a href="https://openvibe.bot">OpenVibe.Bot</a>: see them and drive the ones you opened to your agent, through Bot's limits and e-stop`, 'Live: turn on "Let my OpenVibe.Actor agent drive this robot" on the robot\'s panel.'],
- ['Coding tasks', html`They go to OpenVibe.Codes: <a href="https://openvibe.codes/start">run openvibe-codes</a> on your machine.`],
+ [html`Coding tasks on your own machine, through <a href="https://openvibe.codes/run">OpenVibe.Codes</a>: name the repository and what to change, and get the summary, the files and the patch`, html`Live: add the machine on <a href="https://openvibe.bot/machines">openvibe.bot/machines</a> and start one task on openvibe.codes/run first. The model use is on your machine's own keys.`],
])}</section>
])}</section>
${raw(showcase.cta({ title: 'Build on it', text: 'Every task, its live stream, its webhooks and the router are an API. Apps get actor.task.* grants on OpenVibe.Services.', actions: [{ label: 'Read the API', href: '/docs', primary: true }, { label: 'See the agents', href: '/agents' }] }))}`,
${raw(showcase.cta({ title: 'Build on it', text: 'Every task, its live stream, its webhooks and the router are an API. Apps get actor.task.* grants on OpenVibe.Services.', actions: [{ label: 'Read the API', href: '/docs', primary: true }, { label: 'See the agents', href: '/agents' }] }))}`,
}, status);
}, status);
@@ -113,7 +113,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -113,7 +113,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
await emit(id, { kind: 'output', step: attempt > 1 ? 'handoff' : 'plan', agent: agentId, text: `${agent.name} takes it${placement.reasons && placement.reasons[0] ? `: ${placement.reasons[0]}` : ''}.` });
await emit(id, { kind: 'output', step: attempt > 1 ? 'handoff' : 'plan', agent: agentId, text: `${agent.name} takes it${placement.reasons && placement.reasons[0] ? `: ${placement.reasons[0]}` : ''}.` });
let out;
let out;
try {
try {
- out = await adapters.run[agentId]({ task: row.task, requester: row.requester, cls, mode: row.mode, signal, meter, budgetLeft, emit: (e) => emit(id, { kind: 'output', agent: agentId, ...e }) });
+ out = await adapters.run[agentId]({ task: row.task, requester: row.requester, taskId: id, cls, mode: row.mode, signal, meter, budgetLeft, emit: (e) => emit(id, { kind: 'output', agent: agentId, ...e }) });
} catch (err) {
} catch (err) {
if (signal.aborted) throw err;
if (signal.aborted) throw err;
const why = (err && err.message) || String(err);
const why = (err && err.message) || String(err);
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+'use strict';
+// The `codes` agent (plan T16/T17, agents/codes.js): a signed-in person's coding task goes to OpenVibe.Codes as that
+// person (X-OV-Subject, Actor's own service token), with the Actor task id as its idempotency key; Actor follows it
+// to the end and answers with the summary, the files, the patch and the task's page. A person who never started a
+// task on openvibe.codes/run is told how; past Actor's own time the answer links the live page; a cancelled Actor task
+// cancels the Codes task; an expired one says the machine never took it. The catalog lists the agent as available
+// once Codes is connected, at no cost to Actor, and its answers are checked like any other.
+const assert = require('assert');
+const { createCodes, repoIn } = require('../server/agents/codes');
+const catalog = require('../server/agents/catalog');
+const { checkersFor } = require('../server/agents/check');
+const { check, done } = require('./helpers/boot');
+
+const ME = 'usr_01M4W5JBFEG3QG9ER8W1A2K0RX';
+const TASK = 'tsk_01M4XENCR31NXDJQYDCCW2J9R9';
+const config = {
+ networkInternalUrl: 'http://network.test', oauth: { clientId: 'actor', clientSecret: 'actor-secret' }, taskTimeoutMs: 180_000,
+ services: { codesUrl: 'http://codes.test', codesPublicUrl: 'https://openvibe.codes' },
+};
+const tokens = { authHeaders: async () => ({ Authorization: 'Bearer svc-token' }) };
+
+/** A fake OpenVibe.Codes: `states` is what each read answers, in turn (the last one repeats). */
+function fakeCodes({ start = 201, startBody = null, states = ['running', 'succeeded'] } = {}) {
+ const calls = [];
+ let reads = 0;
+ const page = `https://openvibe.codes/run/tasks/${TASK}`;
+ const task = (state) => ({
+ id: TASK, state, repo: 'https://github.com/octocat/Hello-World', url: page, created_at: '2026-10-14T15:00:00.000Z', updated_at: '2026-10-14T15:00:00.000Z',
+ ...(state === 'succeeded' ? { result: { ok: true, agents: ['deepseek'], summary: 'Appended the line.', files: [{ path: 'README', added: 1, removed: 0 }], patch: 'diff --git a/README b/README\n+Edited.\n', duration_ms: 4200 } } : {}),
+ });
+ const reply = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+ const fetchImpl = async (url, init = {}) => {
+ const u = new URL(url);
+ const headers = init.headers || {};
+ calls.push({ method: init.method || 'GET', path: u.pathname, subject: headers['X-OV-Subject'], auth: headers.Authorization, body: init.body ? JSON.parse(init.body) : null });
+ if (u.pathname === '/api/v1/tasks' && init.method === 'POST') return reply(start, startBody || { task: task('queued'), created: true });
+ if (u.pathname === `/api/v1/tasks/${TASK}` && (init.method || 'GET') === 'GET') return reply(200, task(states[Math.min(reads++, states.length - 1)]));
+ if (u.pathname === `/api/v1/tasks/${TASK}/cancel`) return reply(200, task('cancelled'));
+ return reply(404, { code: 'not_found' });
+ };
+ return { calls, fetchImpl };
+}
+const run = (codes, extra = {}) => {
+ const events = [];
+ return codes.adapter({ task: 'In https://github.com/octocat/Hello-World, add a line to the README.', requester: `user:${ME}`, taskId: 'act_task_01', emit: async (e) => events.push(e), ...extra })
+ .then((out) => ({ out, events }));
+};
+
+(async () => {
+ await check('the repository comes from the task text', async () => {
+ assert.strictEqual(repoIn('fix https://github.com/a/b.git, please'), 'https://github.com/a/b');
+ assert.strictEqual(repoIn('see https://codeberg.org/me/tool).'), 'https://codeberg.org/me/tool');
+ assert.strictEqual(repoIn('fix my code'), null);
+ assert.strictEqual(repoIn('http://github.com/a/b'), null);
+ });
+
+ await check('a coding task starts on Codes as the person, keyed by the Actor task, and answers with the change', async () => {
+ const codes = fakeCodes();
+ const { out, events } = await run(createCodes({ config, fetchImpl: codes.fetchImpl, tokens, sleep: async () => {}, pollMs: 0 }));
+ const start = codes.calls[0];
+ assert.deepStrictEqual([start.method, start.path, start.subject, start.auth], ['POST', '/api/v1/tasks', ME, 'Bearer svc-token']);
+ assert.strictEqual(start.body.repo, 'https://github.com/octocat/Hello-World');
+ assert.strictEqual(start.body.idempotency_key, 'act_task_01');
+ assert.ok(out.text.includes('Done on your machine') && out.text.includes('Appended the line.') && out.text.includes('`README` (+1 −0)') && out.text.includes('```diff'), out.text);
+ assert.deepStrictEqual(out.sources, [`https://openvibe.codes/run/tasks/${TASK}`]);
+ assert.ok(events.some((e) => e.text && e.text.startsWith('Your machine is working on it')));
+ });
+
+ await check('signed out, no repository, or no /run task yet: a refusal that says what to do', async () => {
+ const codes = createCodes({ config, fetchImpl: fakeCodes().fetchImpl, tokens, sleep: async () => {}, pollMs: 0 });
+ await assert.rejects(codes.adapter({ task: 'https://github.com/a/b fix', requester: 'ip:1.2.3.4', emit: async () => {} }), /signed in/);
+ await assert.rejects(codes.adapter({ task: 'fix my code', requester: `user:${ME}`, emit: async () => {} }), (e) => e.code === 'actor.task.no_repo');
+ const none = fakeCodes({ start: 409, startBody: { code: 'codes.no_project', detail: 'no /run task yet' } });
+ await assert.rejects(run(createCodes({ config, fetchImpl: none.fetchImpl, tokens, sleep: async () => {}, pollMs: 0 })), /openvibe\.bot\/machines.*openvibe\.codes\/run/);
+ });
+
+ await check('past Actor\'s own time the answer links the live page; an expired task says the machine never took it', async () => {
+ let clock = 0;
+ const slow = fakeCodes({ states: ['running'] });
+ const { out } = await run(createCodes({ config, fetchImpl: slow.fetchImpl, tokens, sleep: async () => { clock += 10_000; }, pollMs: 0, now: () => clock }));
+ assert.ok(out.text.includes('not done yet') && out.text.includes(`/run/tasks/${TASK}`), out.text);
+ assert.ok(!slow.calls.some((c) => c.path.endsWith('/cancel')), 'a slow task is left running');
+ const expired = fakeCodes({ states: ['expired'] });
+ await assert.rejects(run(createCodes({ config, fetchImpl: expired.fetchImpl, tokens, sleep: async () => {}, pollMs: 0 })), /No machine of yours took the task/);
+ });
+
+ await check('cancelling the Actor task cancels the Codes task', async () => {
+ const codes = fakeCodes({ states: ['running'] });
+ const ac = new AbortController();
+ const sleep = async () => { ac.abort(); };
+ await assert.rejects(run(createCodes({ config, fetchImpl: codes.fetchImpl, tokens, sleep, pollMs: 0 }), { signal: ac.signal }), (e) => e.code === 'actor.task.cancelled');
+ assert.ok(codes.calls.some((c) => c.method === 'POST' && c.path === `/api/v1/tasks/${TASK}/cancel` && c.subject === ME));
+ });
+
+ await check('the catalog lists the agent once Codes is connected, at no cost to Actor; either family checks its answers', async () => {
+ const agent = catalog.byId('codes');
+ assert.deepStrictEqual(catalog.availability(agent, config), { available: true });
+ assert.strictEqual(catalog.availability(agent, { ...config, services: { codesUrl: '' } }).available, false);
+ assert.strictEqual(catalog.typicalCost('codes'), 0);
+ const families = checkersFor('codes', { deepseek: async () => ({}), openai: async () => ({}) }, { mode: 'balanced' }).map((c) => c.family);
+ assert.deepStrictEqual(families, ['deepseek', 'openai']);
+ });
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -106,6 +106,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -106,6 +106,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
'server/auth/keys.js': 'no request itself; openvibe-sdk/auth fetches the JWKS from the configured Network',
'server/auth/keys.js': 'no request itself; openvibe-sdk/auth fetches the JWKS from the configured Network',
'server/auth/sso.js': 'Network OAuth token and revoke (configured networkInternalUrl)',
'server/auth/sso.js': 'Network OAuth token and revoke (configured networkInternalUrl)',
'server/agents/providers.js': 'the configured provider base URLs (DeepSeek, OpenAI, the open model) with the operator\'s keys',
'server/agents/providers.js': 'the configured provider base URLs (DeepSeek, OpenAI, the open model) with the operator\'s keys',
+ 'server/agents/codes.js': 'the configured OpenVibe.Codes base (ACTOR_CODES_URL) with Actor\'s own service token; the subject must be usr_<ULID> and every task id tsk_<ULID> before it goes in a path; the repository named in the task is data for the person\'s own machine, never fetched here (test/codes.test.js)',
'server/agents/robots.js': 'the configured OpenVibe.Bot base (ACTOR_BOT_URL) with Actor\'s own service token; the robot id must be rob_<ULID> and the subject usr_<ULID> before anything is sent (test/robots.test.js)',
'server/agents/robots.js': 'the configured OpenVibe.Bot base (ACTOR_BOT_URL) with Actor\'s own service token; the robot id must be rob_<ULID> and the subject usr_<ULID> before anything is sent (test/robots.test.js)',
'server/agents/tools.js': 'the configured OpenVibe.Tools and OpenVibe.Search bases; ids come from Tools\' catalog, a page URL goes to Tools as input',
'server/agents/tools.js': 'the configured OpenVibe.Tools and OpenVibe.Search bases; ids come from Tools\' catalog, a page URL goes to Tools as input',
'server/events-consumer.js': 'OpenVibe.Events: the configured events.url (ACTOR_EVENTS_URL), to create the two ADR-033 subscriptions at boot',
'server/events-consumer.js': 'OpenVibe.Events: the configured events.url (ACTOR_EVENTS_URL), to create the two ADR-033 subscriptions at boot',