openvibers/openvibe.actor Commits

Route direct coding tasks through Actor runtime (#436)

OP OpenVibers committed 2 days ago · 231268fe92 · 10 files, +76 −9

* Route direct coding tasks through Actor runtime behind owner switch

* Let Chat read only the personal agent runs it starts

* Include delegated task reader in owner export

Changes

ddiff --git a/.env.example b/.env.example
iindex 2a77823..ad4b31b 100644
---- a/.env.example
++++ b/.env.example
@@@ -91,6 +91,8 @@ ACTOR_RUNTIME_OUTPUT_USD_PER_MTOK=0
# ACTOR_SESSION_SECRET=
# DATA_DIR=
# The OpenVibe services the runtime acts through.
++# Owner switch: route direct coding tasks through Actor's shared loop and its Codes tool (default off).
++# ACTOR_UNIFIED_CODE_RUNS=0
# ACTOR_SEARCH_URL=https://search.openvibe.network
# ACTOR_TOOLS_URL=https://openvibe.tools
# OpenVibe.Space: the person's own spaces, which their agent builds (plan 2026-10-22 §Z stage Z1). With this
ddiff --git a/docs/one-engine.md b/docs/one-engine.md
iindex 02a1705..8993417 100644
---- a/docs/one-engine.md
++++ b/docs/one-engine.md
@@@ -92,5 +92,7 @@ answer.
## 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.
++2. Direct coding tasks can use Actor's shared loop and `codes_task` when the owner sets `ACTOR_UNIFIED_CODE_RUNS=1`. Codes still executes the file changes on the person's machine. Scheduled personal agents already use the shared loop. The switch defaults off while the owner checks the new path in production.
3. Let the inbox's actions ride a turn, so a visitor's order question is answered by the owner's own system.
++
++When Chat starts one of its manager's personal agents as a channel member, the run keeps `via=agent:<id>` for the agent's memory and daily cap. Actor records `delegated_via=service:chat` on that task only, so Chat can follow its result and bring it back to the room. Other services and Chat's own unrelated tasks stay outside that read grant.
ddiff --git a/migrations/0069_chat_agent_run_reader.sql b/migrations/0069_chat_agent_run_reader.sql
nnew file mode 100644
iindex 0000000..75b5c14
---- /dev/null
++++ b/migrations/0069_chat_agent_run_reader.sql
@@@ -0,0 +1,4 @@
++-- phase: expand
++-- A personal agent run keeps via=agent:<id> for its memory and daily cap. When Chat starts one as that person's
++-- channel member, it alone may follow the task to bring the answer back to the channel.
++ALTER TABLE tasks ADD COLUMN IF NOT EXISTS delegated_via text COLLATE "C";
ddiff --git a/server/config.js b/server/config.js
iindex 0478665..3225730 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -30,6 +30,7 @@ function load(env = process.env) {
service: 'actor',
agentUi: bool(env.ACTOR_AGENT_UI),
codesSessionBridge: env.ACTOR_CODES_SESSION_BRIDGE === '1' || env.ACTOR_CODES_SESSION_BRIDGE === 'true',
++ unifiedCodeRuns: bool(env.ACTOR_UNIFIED_CODE_RUNS),
a4ModelSwitch: env.ACTOR_A4_MODEL_SWITCH === '1' || env.ACTOR_A4_MODEL_SWITCH === 'true',
fabric: { enabled: bool(env.ACTOR_WORK_FABRIC) },
port,
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 40e39c5..f3c339e 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -505,6 +505,8 @@ function createApi(ctx) {
level: trigger ? trigger.level : null,
triggerId: trigger ? trigger.id : null,
goal: trigger ? trigger.goal : null,
++ afterInsert: req.principal && req.principal.kind === 'app' && req.principal.via === 'service:chat'
++ ? async (task) => s.db.query('UPDATE tasks SET delegated_via = $1 WHERE id = $2', ['service:chat', task.id]) : null,
});
if (out.code) return problem(req, res, out.status, out.code, out.detail);
res.set('Location', `/api/v1/tasks/${out.task.id}`);
@@@ -611,7 +613,7 @@ function createApi(ctx) {
// ── Tasks ────────────────────────────────────────────────
// A delegated caller (a service acting for the person) owns only the person's tasks it started itself.
-- const owns = (p, row) => row && ((row.requester === p.requester && (!p.via || row.via === p.via)) || (p.project && row.project_id === p.project));
++ const owns = (p, row) => row && ((row.requester === p.requester && (!p.via || row.via === p.via || row.delegated_via === p.via)) || (p.project && row.project_id === p.project));
async function load(req, res, { delegated = false } = {}) {
if (!TASK_ID.test(req.params.id)) { problem(req, res, 404, 'actor.task.not_found', 'No such task.'); return null; }
const row = await store.getTask(s, req.params.id);
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex cc85b4f..e5e5c0d 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -85,7 +85,9 @@ const TABLES = [
'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'],
++ 'tier',
++ // The first-party service permitted to read a personal agent run on the person's behalf.
++ 'delegated_via'],
},
// 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 550e5e9..0afe708 100644
---- a/server/runtime/loop.js
++++ b/server/runtime/loop.js
@@@ -1159,7 +1159,8 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
content: SYSTEM({
date: today(now), plan: planText(state.plan, state.brain), maxCalls,
note: ordered.note, ledger: ledgerText(state.brain.ledger, state.brain.progress),
-- }),
++ }) + (allowedTools && allowedTools.length === 1 && allowedTools[0] === 'codes_task'
++ ? '\n\nThis task changes code on the person\'s machine. Use codes_task with the repository URL and the requested change. If the tool is unavailable, say what setup is missing; do not claim a code change happened.' : ''),
});
// A follow-up (stage V2-5b): the conversation it continues, as it went, then the new question.
for (const c of state.context) state.messages.push({ role: 'user', content: c.task }, { role: 'assistant', content: c.answer });
ddiff --git a/server/tasks/engine.js b/server/tasks/engine.js
iindex 871ab8c..4b46887 100644
---- a/server/tasks/engine.js
++++ b/server/tasks/engine.js
@@@ -514,10 +514,11 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
// 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 || turnAllowed)) {
++ // The owner can route direct coding tasks through the same loop that runs scheduled agents. The loop
++ // delegates the machine work through codes_task; it retains one model choice, task, budget and timeline.
++ // Until the owner enables this, direct coding tasks keep their existing Codes adapter path.
++ const unifiedCode = cls === 'code' && config.unifiedCodeRuns === true && !fromChat;
++ if (loop && models && row.mode !== 'private' && (cls !== 'code' || chatPolicy || dayRun || turnAllowed || unifiedCode)) {
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.
@@@ -604,7 +605,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
// 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,
++ allowedTools: unifiedCode && !turnAllowed ? ['codes_task'] : 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/test/agents-api.test.js b/test/agents-api.test.js
iindex 2141e52..349f906 100644
---- a/test/agents-api.test.js
++++ b/test/agents-api.test.js
@@@ -52,6 +52,29 @@ const data = (name) => ({ name, instructions: `Help with ${name}.`, mode: 'balan
const run = await t.get('/api/v1/me/agents/agt_01JZ0000000000000000000AAA/run', { bearer: mint(['actor.agent.manage']), headers, json: {} });
assert.strictEqual(run.status, 403);
});
++ await check('Chat can follow only the personal agent run it started for its owner', async () => {
++ const person = t.network.addUser('channel-agent-owner');
++ const created = await post('/api/v1/me/agents', data('Researcher'), person);
++ assert.strictEqual(created.status, 201, created.text);
++ const id = created.json().id;
++ const now = Math.floor(Date.now() / 1000);
++ const mint = (sub, cap) => serviceAuth.signServiceToken({ iss: t.network.url, sub, actor_type: 'service', aud: ['openvibe.actor'], cap,
++ iat: now, exp: now + 300, jti: crypto.randomUUID() }, t.network.privatePem);
++ const chat = mint('svc:chat', ['actor.agent.run', 'actor.task.read']);
++ const other = mint('svc:watch', ['actor.task.read']);
++ const headers = { 'X-OV-Subject': person.subject };
++ const started = await t.get(`/api/v1/me/agents/${id}/run`, { bearer: chat, headers, json: { input: 'Say hello to the channel.' } });
++ assert.strictEqual(started.status, 201, started.text);
++ const taskId = started.json().id;
++ const row = await require('../server/tasks/store').getTask(t.ctx.s, taskId);
++ assert.deepStrictEqual([row.via, row.delegated_via], [`agent:${id}`, 'service:chat']);
++ assert.strictEqual((await t.get(`/api/v1/tasks/${taskId}`, { bearer: chat, headers })).status, 200);
++ assert.strictEqual((await t.get(`/api/v1/tasks/${taskId}`, { bearer: other, headers })).status, 404);
++ assert.strictEqual((await t.get(`/api/v1/tasks/${taskId}`, { as: person })).status, 200);
++ const ownRun = await post(`/api/v1/me/agents/${id}/run`, { input: 'Private work.' }, person);
++ assert.strictEqual(ownRun.status, 201, ownRun.text);
++ assert.strictEqual((await t.get(`/api/v1/tasks/${ownRun.json().id}`, { bearer: chat, headers })).status, 404);
++ });
} finally { await t.close(); }
done();
})();
ddiff --git a/test/unified-code-runtime.test.js b/test/unified-code-runtime.test.js
nnew file mode 100644
iindex 0000000..fbe375d
---- /dev/null
++++ b/test/unified-code-runtime.test.js
@@@ -0,0 +1,29 @@
++'use strict';
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const calls = [];
++ const loop = {
++ async run(args) {
++ calls.push(args);
++ return { text: 'The coding request is ready for Codes on your machine.', evidence: [], sources: [], family: 'deepseek' };
++ },
++ async close() {},
++ };
++ const t = await boot({ env: { ACTOR_UNIFIED_CODE_RUNS: '1' }, loop });
++ try {
++ const person = t.network.addUser('code-owner');
++ t.providers.state.cls = 'code';
++ await check('a direct coding task uses the shared loop with only the Codes tool', async () => {
++ const r = await t.get('/api/v1/tasks', { as: person, headers: { 'sec-fetch-site': 'same-origin' }, json: { task: 'Edit the README in https://github.com/octocat/Hello-World' } });
++ assert.strictEqual(r.status, 201, r.text);
++ const row = await t.waitFor(r.json().id);
++ assert.strictEqual(calls.length, 1, JSON.stringify(row.error));
++ assert.deepStrictEqual(calls[0].allowedTools, ['codes_task']);
++ assert.match(calls[0].requester, /^user:usr_[0-9A-HJKMNP-TV-Z]{26}$/);
++ assert.strictEqual(row.explanation[0].selected, 'openvibe-runtime');
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });