openvibers/openvibe.actor Commits
Run direct coding tasks on Actor loop by default (#439)
Changes
@@@ -91,8 +91,11 @@ 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++# Direct coding tasks use Actor's shared loop and Codes tool by default; set 0 to use the old Codes adapter.++# ACTOR_UNIFIED_CODE_RUNS=1++# Mid-run model switching and the Codes session bridge are on by default; set either to 0 to turn it off.++# ACTOR_A4_MODEL_SWITCH=1++# ACTOR_CODES_SESSION_BRIDGE=1# 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@@@ -89,10 +89,15 @@ answer.- **Sampling.** A chat's temperature and preset stay saved on the chat; the loop's own sampling applies until alater slice carries them.++## Coding tasks++++A person's direct coding task uses Actor's shared loop and `codes_task` by default. Codes still executes file changes on the person's machine. The task fails with `actor.codes.not_run` unless that tool reports a successful run, so a model cannot claim code was changed without a Codes handoff. Scheduled personal agents already use the shared loop; service turns and explicitly chosen legacy agents keep their own routing. The owner can set `ACTOR_UNIFIED_CODE_RUNS=0` to use the old direct Codes adapter.++++A running personal Actor can switch models mid-run. The Codes session bridge also shows the coding job's tool steps and final result in Actor's timeline when Codes provides them. `ACTOR_A4_MODEL_SWITCH=0` and `ACTOR_CODES_SESSION_BRIDGE=0` turn these paths off separately.++## Next slices1. Move Vibe room conversations onto Actor tasks, retaining the room UI and its authorization rules.--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.++2. 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.@@@ -29,9 +29,10 @@ function load(env = process.env) {return {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',++ codesSessionBridge: env.ACTOR_CODES_SESSION_BRIDGE == null ? !selfHosted : bool(env.ACTOR_CODES_SESSION_BRIDGE),++ // Hosted direct coding tasks use the shared runtime; the self-hosted build has no Codes service.++ unifiedCodeRuns: env.ACTOR_UNIFIED_CODE_RUNS == null ? !selfHosted : bool(env.ACTOR_UNIFIED_CODE_RUNS),++ a4ModelSwitch: env.ACTOR_A4_MODEL_SWITCH == null ? !selfHosted : bool(env.ACTOR_A4_MODEL_SWITCH),fabric: { enabled: bool(env.ACTOR_WORK_FABRIC) },port,host: env.HOST || '127.0.0.1',@@@ -448,6 +448,23 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =await tellPerson(row, 'needs_you', { what: String(shown), kind: 'approve', url });return { asked: true, decision, ask, url };};++ const askBeforeCodesTask = async (agentId, cls) => {++ const repo = codesRepoIn(row.task);++ if (!taskAgent || !repo) return null;++ const asked = await askForApproval({++ kind: 'codes.task', summary: `Start a coding task on your machine in ${repo}`,++ payload: { task: row.task }, act: 'edit', agent: agentId,++ target: require('../agents/actions').repoTarget(repo),++ });++ if (!asked.asked) return null;++ const result = {++ answer: `Your agent asked to start a coding task on your own machine (OpenVibe.Codes) in ${repo}. Nothing has run: approve or decline it at ${asked.url}. The request expires in ${approvals.EXPIRES_DAYS} days.`,++ sources: [asked.url], agent: agentId, model: null, class: cls,++ approval: { id: asked.ask.id, url: asked.url },++ checked: { by: 'none', cross_family: false, reason: 'Nothing was run: its owner decides first.' },++ };++ return await setState(id, 'succeeded', { result, finished_at: iso() });++ };// A child hand-off (a sub-agent's consult, a Codes task, a Space action) is one node below this task's.// The root's budget decides whether it may start at all: when it is spent, nothing below it starts.assign = assignment ? async (spec = {}) => {@@@ -514,12 +531,17 @@ 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);-- // 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;++ // A person's direct coding task uses the loop that already runs scheduled agents. The machine work goes++ // through codes_task, on the same task, budget and timeline. An approved or explicitly chosen Codes++ // task follows that path too; service and personal agent runs keep their own tool policy.++ const unifiedCode = cls === 'code' && config.unifiedCodeRuns === true && !fromChat && !dayRun++ && !row.via && (!row.agent || row.agent === 'codes') && String(row.requester || '').startsWith('user:');if (loop && models && row.mode !== 'private' && (cls !== 'code' || chatPolicy || dayRun || turnAllowed || unifiedCode)) {const agentId = LOOP_AGENT;++ if (unifiedCode) {++ const paused = await askBeforeCodesTask('codes', cls);++ if (paused) return paused;++ }// 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.const ownAgent = chatContext && chatPolicy === 'dm' ? chatContext.agent_id : /^agent:agt_/.test(String(row.via || '')) ? row.via.slice(6) : null;@@@ -620,6 +642,17 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =if (wait) await waits.setRemaining(wait.id, left);return undefined;}++ if (unifiedCode) {++ const steps = out && out.state && out.state.brain && out.state.brain.steps++ || out && out.brain && out.brain.steps || [];++ if (!steps.some((step) => step.tool === 'codes_task' && step.ok === true)) {++ const detail = 'OpenVibe.Codes did not run this task. Connect your machine on openvibe.link/machines, or retry after Codes is available.';++ await emit(id, { kind: 'output', step: 'text', agent: agentId, text: detail, is_error: true });++ controls.delete(id);++ await loop.close(out && out.state).catch(() => {});++ return await setState(id, 'failed', { error_code: 'actor.codes.not_run', error_detail: detail, finished_at: iso() });++ }++ }} catch (err) {if (signal && signal.aborted) throw err;// The person chose "Stop the task" on an interrupted action (stage V2-9): end it cancelled, not failed.@@@ -792,19 +825,9 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =// The approval gate (levels.js + agents/approvals.js): a coding task on the person's machine asks at ask// and for the always-ask floor; auto-edits lets the edit through, full access skips the gate, and// approve-for-me asks a reviewer of another family first.-- if (agentId === 'codes' && taskAgent && codesRepoIn(row.task)) {-- const asked = await askForApproval({-- kind: 'codes.task', summary: `Start a coding task on your machine in ${codesRepoIn(row.task)}`,-- payload: { task: row.task }, act: 'edit', agent: agentId, target: require('../agents/actions').repoTarget(codesRepoIn(row.task)),-- });-- if (asked.asked) {-- const result = {-- answer: `Your agent asked to start a coding task on your own machine (OpenVibe.Codes) in ${codesRepoIn(row.task)}. Nothing has run: approve or decline it at ${asked.url}. The request expires in ${approvals.EXPIRES_DAYS} days.`,-- sources: [asked.url], agent: agentId, model: null, class: cls, approval: { id: asked.ask.id, url: asked.url },-- checked: { by: 'none', cross_family: false, reason: 'Nothing was run: its owner decides first.' },-- };-- return await setState(id, 'succeeded', { result, finished_at: iso() });-- }++ if (agentId === 'codes') {++ const paused = await askBeforeCodesTask(agentId, cls);++ if (paused) return paused;}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;@@@ -5,7 +5,7 @@ const { boot, done } = require('./helpers/boot');const { render } = require('../server/day/page');const SAME = { 'sec-fetch-site':'same-origin' };(async()=>{-- const t = await boot({env:{ACTOR_CHECKPOINT_KEY:crypto.randomBytes(32).toString('hex')}});++ const t = await boot({env:{ACTOR_CHECKPOINT_KEY:crypto.randomBytes(32).toString('hex'),ACTOR_A4_MODEL_SWITCH:'0'}});try {const user=t.network.addUser('you'), other=t.network.addUser('other');const api=(path='',json,method)=>t.get('/api/v1/me/actor'+path,{as:user,json,method,headers:SAME});@@@ -16,7 +16,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };(async () => {const codesCalls = [];const t = await boot({-- env: { ACTOR_CODES_URL: 'http://127.0.0.1:9' },++ env: { ACTOR_CODES_URL: 'http://127.0.0.1:9', ACTOR_UNIFIED_CODE_RUNS: '0' },adapters: {// No deepseek checker: classification is the heuristic, which reads "repository … bug" as code.checkers: { openai: async () => ({ text: '{"ok":true,"reason":"reports the change"}', cost_usd: 0 }) },@@@ -4,26 +4,74 @@ const { boot, check, done } = require('./helpers/boot');(async () => {const calls = [];++ let ranCodes = false;const loop = {async run(args) {calls.push(args);-- return { text: 'The coding request is ready for Codes on your machine.', evidence: [], sources: [], family: 'deepseek' };++ return {++ text: ranCodes ? 'Codes started the requested change. [E1]' : 'I changed the code.',++ evidence: ranCodes ? [{ id: 'E1', tool: 'codes_task', input: { instruction: 'Edit the README' }, text: 'Codes started the task.', sources: [], artifacts: [], refs: [] }] : [],++ sources: [], family: 'deepseek', brain: { steps: ranCodes ? [{ tool: 'codes_task', ok: true }] : [] },++ };},async close() {},};-- const t = await boot({ env: { ACTOR_UNIFIED_CODE_RUNS: '1' }, loop });++ const t = await boot({ 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 () => {++ let approvalId;++ await check('a direct coding task at ask level waits for approval before Codes runs', 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.strictEqual(row.state, 'succeeded');++ assert.ok(row.result.approval && row.result.approval.id);++ approvalId = row.result.approval.id;++ assert.strictEqual(calls.length, 0, 'the loop must wait for approval too');++ });++ await check('an approved coding task restarts in the same runtime', async () => {++ ranCodes = true;++ const approved = await t.get(`/me/approvals/${approvalId}`, { as: person, form: { decision: 'approve' }, headers: { 'sec-fetch-site': 'same-origin' } });++ assert.strictEqual(approved.status, 303, approved.text);++ const taskId = /\/tasks\/(tsk_[0-9A-Z]{26})/.exec(approved.headers.get('location'))[1];++ const row = await t.waitFor(taskId);++ assert.strictEqual(row.state, 'succeeded', row.error_detail || row.error_code);++ assert.strictEqual(row.result.agent, 'openvibe-runtime');assert.deepStrictEqual(calls[0].allowedTools, ['codes_task']);-- assert.match(calls[0].requester, /^user:usr_[0-9A-HJKMNP-TV-Z]{26}$/);++ ranCodes = false;++ });++ await check('a direct coding task cannot claim success without a Codes handoff', 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', level: 'full' } });++ assert.strictEqual(r.status, 201, r.text);++ const row = await t.waitFor(r.json().id);++ assert.deepStrictEqual([row.state, row.error_code], ['failed', 'actor.codes.not_run']);++ assert.strictEqual(calls.length, 2, JSON.stringify(row.error));++ assert.deepStrictEqual(calls[1].allowedTools, ['codes_task']);++ assert.match(calls[1].requester, /^user:usr_[0-9A-HJKMNP-TV-Z]{26}$/);assert.strictEqual(row.explanation[0].selected, 'openvibe-runtime');});++ await check('the same runtime accepts a successful Codes handoff', async () => {++ ranCodes = true;++ const r = await t.get('/api/v1/tasks', { as: person, headers: { 'sec-fetch-site': 'same-origin' }, json: { task: 'Edit the docs in https://github.com/octocat/Hello-World', level: 'full' } });++ assert.strictEqual(r.status, 201, r.text);++ const row = await t.waitFor(r.json().id);++ assert.strictEqual(row.state, 'succeeded', row.error_detail || row.error_code);++ assert.strictEqual(row.result.agent, 'openvibe-runtime');++ });++ await check('an explicitly selected Codes agent uses the shared loop', async () => {++ const r = await t.get('/api/v1/tasks', { as: person, headers: { 'sec-fetch-site': 'same-origin' }, json: { task: 'Edit the docs in https://github.com/octocat/Hello-World', agent: 'codes', level: 'full' } });++ assert.strictEqual(r.status, 201, r.text);++ const row = await t.waitFor(r.json().id);++ assert.strictEqual(row.state, 'succeeded', row.error_detail || row.error_code);++ assert.strictEqual(row.result.agent, 'openvibe-runtime');++ assert.deepStrictEqual(calls.at(-1).allowedTools, ['codes_task']);++ });++ const load = require('../server/config').load;++ const defaults = load({});++ assert.deepStrictEqual([defaults.unifiedCodeRuns, defaults.a4ModelSwitch, defaults.codesSessionBridge], [true, true, true]);++ const rollback = load({ ACTOR_UNIFIED_CODE_RUNS: '0', ACTOR_A4_MODEL_SWITCH: '0', ACTOR_CODES_SESSION_BRIDGE: '0' });++ assert.deepStrictEqual([rollback.unifiedCodeRuns, rollback.a4ModelSwitch, rollback.codesSessionBridge], [false, false, false]);} finally { await t.close(); }done();})().catch((err) => { console.error(err); process.exit(1); });
@@ -91,8 +91,11 @@ ACTOR_RUNTIME_OUTPUT_USD_PER_MTOK=0
@@ -91,8 +91,11 @@ ACTOR_RUNTIME_OUTPUT_USD_PER_MTOK=0
# ACTOR_SESSION_SECRET=
# ACTOR_SESSION_SECRET=
# DATA_DIR=
# DATA_DIR=
# The OpenVibe services the runtime acts through.
# 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
+# Direct coding tasks use Actor's shared loop and Codes tool by default; set 0 to use the old Codes adapter.
+# ACTOR_UNIFIED_CODE_RUNS=1
+# Mid-run model switching and the Codes session bridge are on by default; set either to 0 to turn it off.
+# ACTOR_A4_MODEL_SWITCH=1
+# ACTOR_CODES_SESSION_BRIDGE=1
# ACTOR_SEARCH_URL=https://search.openvibe.network
# ACTOR_SEARCH_URL=https://search.openvibe.network
# ACTOR_TOOLS_URL=https://openvibe.tools
# 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
# OpenVibe.Space: the person's own spaces, which their agent builds (plan 2026-10-22 §Z stage Z1). With this
@@ -89,10 +89,15 @@ answer.
@@ -89,10 +89,15 @@ answer.
- **Sampling.** A chat's temperature and preset stay saved on the chat; the loop's own sampling applies until a
- **Sampling.** A chat's temperature and preset stay saved on the chat; the loop's own sampling applies until a
later slice carries them.
later slice carries them.
+## Coding tasks
+
+A person's direct coding task uses Actor's shared loop and `codes_task` by default. Codes still executes file changes on the person's machine. The task fails with `actor.codes.not_run` unless that tool reports a successful run, so a model cannot claim code was changed without a Codes handoff. Scheduled personal agents already use the shared loop; service turns and explicitly chosen legacy agents keep their own routing. The owner can set `ACTOR_UNIFIED_CODE_RUNS=0` to use the old direct Codes adapter.
+
+A running personal Actor can switch models mid-run. The Codes session bridge also shows the coding job's tool steps and final result in Actor's timeline when Codes provides them. `ACTOR_A4_MODEL_SWITCH=0` and `ACTOR_CODES_SESSION_BRIDGE=0` turn these paths off separately.
+
## Next slices
## Next slices
1. Move Vibe room conversations onto Actor tasks, retaining the room UI and its authorization rules.
1. Move Vibe room conversations onto Actor tasks, retaining the room UI and its authorization rules.
-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.
+2. 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.
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.
@@ -29,9 +29,10 @@ function load(env = process.env) {
@@ -29,9 +29,10 @@ function load(env = process.env) {
return {
return {
service: 'actor',
service: 'actor',
agentUi: bool(env.ACTOR_AGENT_UI),
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',
+ codesSessionBridge: env.ACTOR_CODES_SESSION_BRIDGE == null ? !selfHosted : bool(env.ACTOR_CODES_SESSION_BRIDGE),
+ // Hosted direct coding tasks use the shared runtime; the self-hosted build has no Codes service.
+ unifiedCodeRuns: env.ACTOR_UNIFIED_CODE_RUNS == null ? !selfHosted : bool(env.ACTOR_UNIFIED_CODE_RUNS),
+ a4ModelSwitch: env.ACTOR_A4_MODEL_SWITCH == null ? !selfHosted : bool(env.ACTOR_A4_MODEL_SWITCH),
fabric: { enabled: bool(env.ACTOR_WORK_FABRIC) },
fabric: { enabled: bool(env.ACTOR_WORK_FABRIC) },
port,
port,
host: env.HOST || '127.0.0.1',
host: env.HOST || '127.0.0.1',
@@ -448,6 +448,23 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -448,6 +448,23 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
await tellPerson(row, 'needs_you', { what: String(shown), kind: 'approve', url });
await tellPerson(row, 'needs_you', { what: String(shown), kind: 'approve', url });
return { asked: true, decision, ask, url };
return { asked: true, decision, ask, url };
};
};
+ const askBeforeCodesTask = async (agentId, cls) => {
+ const repo = codesRepoIn(row.task);
+ if (!taskAgent || !repo) return null;
+ const asked = await askForApproval({
+ kind: 'codes.task', summary: `Start a coding task on your machine in ${repo}`,
+ payload: { task: row.task }, act: 'edit', agent: agentId,
+ target: require('../agents/actions').repoTarget(repo),
+ });
+ if (!asked.asked) return null;
+ const result = {
+ answer: `Your agent asked to start a coding task on your own machine (OpenVibe.Codes) in ${repo}. Nothing has run: approve or decline it at ${asked.url}. The request expires in ${approvals.EXPIRES_DAYS} days.`,
+ sources: [asked.url], agent: agentId, model: null, class: cls,
+ approval: { id: asked.ask.id, url: asked.url },
+ checked: { by: 'none', cross_family: false, reason: 'Nothing was run: its owner decides first.' },
+ };
+ return await setState(id, 'succeeded', { result, finished_at: iso() });
+ };
// A child hand-off (a sub-agent's consult, a Codes task, a Space action) is one node below this task's.
// A child hand-off (a sub-agent's consult, a Codes task, a Space action) is one node below this task's.
// The root's budget decides whether it may start at all: when it is spent, nothing below it starts.
// The root's budget decides whether it may start at all: when it is spent, nothing below it starts.
assign = assignment ? async (spec = {}) => {
assign = assignment ? async (spec = {}) => {
@@ -514,12 +531,17 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -514,12 +531,17 @@ 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
// 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.
// turn must not escape to the Codes agent.
const turnAllowed = parseTurnTools(row.tools);
const turnAllowed = parseTurnTools(row.tools);
- // 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;
+ // A person's direct coding task uses the loop that already runs scheduled agents. The machine work goes
+ // through codes_task, on the same task, budget and timeline. An approved or explicitly chosen Codes
+ // task follows that path too; service and personal agent runs keep their own tool policy.
+ const unifiedCode = cls === 'code' && config.unifiedCodeRuns === true && !fromChat && !dayRun
+ && !row.via && (!row.agent || row.agent === 'codes') && String(row.requester || '').startsWith('user:');
if (loop && models && row.mode !== 'private' && (cls !== 'code' || chatPolicy || dayRun || turnAllowed || unifiedCode)) {
if (loop && models && row.mode !== 'private' && (cls !== 'code' || chatPolicy || dayRun || turnAllowed || unifiedCode)) {
const agentId = LOOP_AGENT;
const agentId = LOOP_AGENT;
+ if (unifiedCode) {
+ const paused = await askBeforeCodesTask('codes', cls);
+ if (paused) return paused;
+ }
// The person-authored part of the task (never a run input that may come from a watch or a webhook),
// The person-authored part of the task (never a run input that may come from a watch or a webhook),
// and the personal agent this run is for, so the memory tools scope their writes.
// and the personal agent this run is for, so the memory tools scope their writes.
const ownAgent = chatContext && chatPolicy === 'dm' ? chatContext.agent_id : /^agent:agt_/.test(String(row.via || '')) ? row.via.slice(6) : null;
const ownAgent = chatContext && chatPolicy === 'dm' ? chatContext.agent_id : /^agent:agt_/.test(String(row.via || '')) ? row.via.slice(6) : null;
@@ -620,6 +642,17 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -620,6 +642,17 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
if (wait) await waits.setRemaining(wait.id, left);
if (wait) await waits.setRemaining(wait.id, left);
return undefined;
return undefined;
}
}
+ if (unifiedCode) {
+ const steps = out && out.state && out.state.brain && out.state.brain.steps
+ || out && out.brain && out.brain.steps || [];
+ if (!steps.some((step) => step.tool === 'codes_task' && step.ok === true)) {
+ const detail = 'OpenVibe.Codes did not run this task. Connect your machine on openvibe.link/machines, or retry after Codes is available.';
+ await emit(id, { kind: 'output', step: 'text', agent: agentId, text: detail, is_error: true });
+ controls.delete(id);
+ await loop.close(out && out.state).catch(() => {});
+ return await setState(id, 'failed', { error_code: 'actor.codes.not_run', error_detail: detail, finished_at: iso() });
+ }
+ }
} catch (err) {
} catch (err) {
if (signal && signal.aborted) throw err;
if (signal && signal.aborted) throw err;
// The person chose "Stop the task" on an interrupted action (stage V2-9): end it cancelled, not failed.
// The person chose "Stop the task" on an interrupted action (stage V2-9): end it cancelled, not failed.
@@ -792,19 +825,9 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -792,19 +825,9 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
// The approval gate (levels.js + agents/approvals.js): a coding task on the person's machine asks at ask
// The approval gate (levels.js + agents/approvals.js): a coding task on the person's machine asks at ask
// and for the always-ask floor; auto-edits lets the edit through, full access skips the gate, and
// and for the always-ask floor; auto-edits lets the edit through, full access skips the gate, and
// approve-for-me asks a reviewer of another family first.
// approve-for-me asks a reviewer of another family first.
- if (agentId === 'codes' && taskAgent && codesRepoIn(row.task)) {
- const asked = await askForApproval({
- kind: 'codes.task', summary: `Start a coding task on your machine in ${codesRepoIn(row.task)}`,
- payload: { task: row.task }, act: 'edit', agent: agentId, target: require('../agents/actions').repoTarget(codesRepoIn(row.task)),
- });
- if (asked.asked) {
- const result = {
- answer: `Your agent asked to start a coding task on your own machine (OpenVibe.Codes) in ${codesRepoIn(row.task)}. Nothing has run: approve or decline it at ${asked.url}. The request expires in ${approvals.EXPIRES_DAYS} days.`,
- sources: [asked.url], agent: agentId, model: null, class: cls, approval: { id: asked.ask.id, url: asked.url },
- checked: { by: 'none', cross_family: false, reason: 'Nothing was run: its owner decides first.' },
- };
- return await setState(id, 'succeeded', { result, finished_at: iso() });
- }
+ if (agentId === 'codes') {
+ const paused = await askBeforeCodesTask(agentId, cls);
+ if (paused) return paused;
}
}
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;
@@ -5,7 +5,7 @@ const { boot, done } = require('./helpers/boot');
@@ -5,7 +5,7 @@ const { boot, done } = require('./helpers/boot');
const { render } = require('../server/day/page');
const { render } = require('../server/day/page');
const SAME = { 'sec-fetch-site':'same-origin' };
const SAME = { 'sec-fetch-site':'same-origin' };
(async()=>{
(async()=>{
- const t = await boot({env:{ACTOR_CHECKPOINT_KEY:crypto.randomBytes(32).toString('hex')}});
+ const t = await boot({env:{ACTOR_CHECKPOINT_KEY:crypto.randomBytes(32).toString('hex'),ACTOR_A4_MODEL_SWITCH:'0'}});
try {
try {
const user=t.network.addUser('you'), other=t.network.addUser('other');
const user=t.network.addUser('you'), other=t.network.addUser('other');
const api=(path='',json,method)=>t.get('/api/v1/me/actor'+path,{as:user,json,method,headers:SAME});
const api=(path='',json,method)=>t.get('/api/v1/me/actor'+path,{as:user,json,method,headers:SAME});
@@ -16,7 +16,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -16,7 +16,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
(async () => {
(async () => {
const codesCalls = [];
const codesCalls = [];
const t = await boot({
const t = await boot({
- env: { ACTOR_CODES_URL: 'http://127.0.0.1:9' },
+ env: { ACTOR_CODES_URL: 'http://127.0.0.1:9', ACTOR_UNIFIED_CODE_RUNS: '0' },
adapters: {
adapters: {
// No deepseek checker: classification is the heuristic, which reads "repository … bug" as code.
// No deepseek checker: classification is the heuristic, which reads "repository … bug" as code.
checkers: { openai: async () => ({ text: '{"ok":true,"reason":"reports the change"}', cost_usd: 0 }) },
checkers: { openai: async () => ({ text: '{"ok":true,"reason":"reports the change"}', cost_usd: 0 }) },
@@ -4,26 +4,74 @@ const { boot, check, done } = require('./helpers/boot');
@@ -4,26 +4,74 @@ const { boot, check, done } = require('./helpers/boot');
(async () => {
(async () => {
const calls = [];
const calls = [];
+ let ranCodes = false;
const loop = {
const loop = {
async run(args) {
async run(args) {
calls.push(args);
calls.push(args);
- return { text: 'The coding request is ready for Codes on your machine.', evidence: [], sources: [], family: 'deepseek' };
+ return {
+ text: ranCodes ? 'Codes started the requested change. [E1]' : 'I changed the code.',
+ evidence: ranCodes ? [{ id: 'E1', tool: 'codes_task', input: { instruction: 'Edit the README' }, text: 'Codes started the task.', sources: [], artifacts: [], refs: [] }] : [],
+ sources: [], family: 'deepseek', brain: { steps: ranCodes ? [{ tool: 'codes_task', ok: true }] : [] },
+ };
},
},
async close() {},
async close() {},
};
};
- const t = await boot({ env: { ACTOR_UNIFIED_CODE_RUNS: '1' }, loop });
+ const t = await boot({ loop });
try {
try {
const person = t.network.addUser('code-owner');
const person = t.network.addUser('code-owner');
t.providers.state.cls = 'code';
t.providers.state.cls = 'code';
- await check('a direct coding task uses the shared loop with only the Codes tool', async () => {
+ let approvalId;
+ await check('a direct coding task at ask level waits for approval before Codes runs', 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' } });
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);
assert.strictEqual(r.status, 201, r.text);
const row = await t.waitFor(r.json().id);
const row = await t.waitFor(r.json().id);
- assert.strictEqual(calls.length, 1, JSON.stringify(row.error));
+ assert.strictEqual(row.state, 'succeeded');
+ assert.ok(row.result.approval && row.result.approval.id);
+ approvalId = row.result.approval.id;
+ assert.strictEqual(calls.length, 0, 'the loop must wait for approval too');
+ });
+ await check('an approved coding task restarts in the same runtime', async () => {
+ ranCodes = true;
+ const approved = await t.get(`/me/approvals/${approvalId}`, { as: person, form: { decision: 'approve' }, headers: { 'sec-fetch-site': 'same-origin' } });
+ assert.strictEqual(approved.status, 303, approved.text);
+ const taskId = /\/tasks\/(tsk_[0-9A-Z]{26})/.exec(approved.headers.get('location'))[1];
+ const row = await t.waitFor(taskId);
+ assert.strictEqual(row.state, 'succeeded', row.error_detail || row.error_code);
+ assert.strictEqual(row.result.agent, 'openvibe-runtime');
assert.deepStrictEqual(calls[0].allowedTools, ['codes_task']);
assert.deepStrictEqual(calls[0].allowedTools, ['codes_task']);
- assert.match(calls[0].requester, /^user:usr_[0-9A-HJKMNP-TV-Z]{26}$/);
+ ranCodes = false;
+ });
+ await check('a direct coding task cannot claim success without a Codes handoff', 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', level: 'full' } });
+ assert.strictEqual(r.status, 201, r.text);
+ const row = await t.waitFor(r.json().id);
+ assert.deepStrictEqual([row.state, row.error_code], ['failed', 'actor.codes.not_run']);
+ assert.strictEqual(calls.length, 2, JSON.stringify(row.error));
+ assert.deepStrictEqual(calls[1].allowedTools, ['codes_task']);
+ assert.match(calls[1].requester, /^user:usr_[0-9A-HJKMNP-TV-Z]{26}$/);
assert.strictEqual(row.explanation[0].selected, 'openvibe-runtime');
assert.strictEqual(row.explanation[0].selected, 'openvibe-runtime');
});
});
+ await check('the same runtime accepts a successful Codes handoff', async () => {
+ ranCodes = true;
+ const r = await t.get('/api/v1/tasks', { as: person, headers: { 'sec-fetch-site': 'same-origin' }, json: { task: 'Edit the docs in https://github.com/octocat/Hello-World', level: 'full' } });
+ assert.strictEqual(r.status, 201, r.text);
+ const row = await t.waitFor(r.json().id);
+ assert.strictEqual(row.state, 'succeeded', row.error_detail || row.error_code);
+ assert.strictEqual(row.result.agent, 'openvibe-runtime');
+ });
+ await check('an explicitly selected Codes agent uses the shared loop', async () => {
+ const r = await t.get('/api/v1/tasks', { as: person, headers: { 'sec-fetch-site': 'same-origin' }, json: { task: 'Edit the docs in https://github.com/octocat/Hello-World', agent: 'codes', level: 'full' } });
+ assert.strictEqual(r.status, 201, r.text);
+ const row = await t.waitFor(r.json().id);
+ assert.strictEqual(row.state, 'succeeded', row.error_detail || row.error_code);
+ assert.strictEqual(row.result.agent, 'openvibe-runtime');
+ assert.deepStrictEqual(calls.at(-1).allowedTools, ['codes_task']);
+ });
+ const load = require('../server/config').load;
+ const defaults = load({});
+ assert.deepStrictEqual([defaults.unifiedCodeRuns, defaults.a4ModelSwitch, defaults.codesSessionBridge], [true, true, true]);
+ const rollback = load({ ACTOR_UNIFIED_CODE_RUNS: '0', ACTOR_A4_MODEL_SWITCH: '0', ACTOR_CODES_SESSION_BRIDGE: '0' });
+ assert.deepStrictEqual([rollback.unifiedCodeRuns, rollback.a4ModelSwitch, rollback.codesSessionBridge], [false, false, false]);
} finally { await t.close(); }
} finally { await t.close(); }
done();
done();
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });