openvibers/openvibe.actor Commits
Notices name the step and the task, not the words Actor was given (#379)
Changes
@@@ -6,8 +6,14 @@* (POST /internal/notifications/push, contracts network.notification-push-request@1, capability* network.notifications.push). Each with a token of its own, audience openvibe.network.*-- * needsYou({ subject, what, title, url }) a step only the person can do (the desk hand-off)-- * taskEnded({ subject, state, taskId, title, url, detail }) done or not finished: what it was and where to read it++ * needsYou({ subject, what, kind, title, url }) a step only the person can do (the desk hand-off, an approval,++ * a question): the message is the step alone; rich_content carries++ * step, kind (approve | answer | choose) and the task's title++ * taskEnded({ subject, state, taskId, title, url, detail }) done or not finished: the message is the task's title++ * (and why it did not finish); rich_content carries task_title++ *++ * The words are for a person: no "Done:"/"approve:" prefixes, no task text in brackets after a step (Shared 4.172++ * reads rich_content; older clients show the message, which already reads right).** Best effort: off without a client secret, never retried (Network keeps no idempotency record for a push, and a person* told twice is worse than one told once), and a failure never stops a task. → { sent, reason? }@@@ -17,6 +23,7 @@ const { serviceAuth } = require('openvibe-contracts');const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;++const ASK_KINDS = new Set(['approve', 'answer', 'choose']);const clip = (s, n) => (String(s == null ? '' : s).length > n ? `${String(s).slice(0, n - 1)}…` : String(s == null ? '' : s));function createNotifier({ config, fetchImpl = globalThis.fetch, tokens: tokensOverride = null, log = console }) {@@@ -61,12 +68,13 @@ function createNotifier({ config, fetchImpl = globalThis.fetch, tokens: tokensOv}}-- /** The agent needs its person for one step on its desk: `what`, the task's `title` and the desk's `url`. */-- async function needsYou({ subject, what, title = null, url }) {-- const message = `${clip(what || 'One step only you can do.', 300)}${title ? ` (${clip(title, 120)})` : ''}`;++ /** The agent needs its person for one step: `what` (the step), its `kind`, the task's `title` and where to do it. */++ async function needsYou({ subject, what, kind = null, title = null, url }) {++ const message = clip(String(what || '').trim() || 'One step only you can do.', 300);return send(subject, {type: 'AGENT_NEEDS_YOU', category: 'service', priority: 'high', icon: '🙋',title: 'Your agent needs you', message, url, service: 'actor',++ rich_content: { step: message, ...(ASK_KINDS.has(kind) ? { kind } : {}), ...(title ? { task_title: clip(title, 160) } : {}) },});}@@@ -78,14 +86,12 @@ function createNotifier({ config, fetchImpl = globalThis.fetch, tokens: tokensOvif (!TASK_ID.test(String(taskId || ''))) return { sent: false, reason: 'bad_input' };const done = state === 'succeeded';const what = clip(title || 'Your task', 160);-- const message = done-- ? `Done: ${what}`-- : `Not finished: ${what}${detail ? ` — ${clip(detail, 140)}` : ''}`;++ const message = done ? what : `${what}${detail ? ` — ${clip(detail, 140)}` : ''}`;return send(subject, {type: done ? 'AGENT_TASK_DONE' : 'AGENT_TASK_FAILED',category: 'service', priority: done ? 'normal' : 'high', icon: done ? '✅' : '⚠️',title: done ? 'Your task is done' : 'Your task did not finish',-- message, url, service: 'actor', rich_content: { task_id: taskId, state },++ message, url, service: 'actor', rich_content: { task_id: taskId, state, task_title: what, ...(!done && detail ? { detail: clip(detail, 140) } : {}) },});}@@@ -108,7 +108,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =try {if (!(await taskNotices.once(s, row.id, kind))) return;if (kind === 'needs_you') {-- await notifier.needsYou({ subject, what: detail.what, title: await taskTitle(row), url: detail.url || `${config.baseUrl}/tasks/${row.id}` });++ await notifier.needsYou({ subject, what: detail.what, kind: detail.kind || null, title: await taskTitle(row), url: detail.url || `${config.baseUrl}/tasks/${row.id}` });} else {await notifier.taskEnded({subject, state: kind, taskId: row.id, title: await taskTitle(row),@@@ -372,7 +372,10 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =// The person is told before the task says it needs them: a reader that sees the task waiting// for an answer must find the notification already sent, the same way the run's own end is// published last (see setState).-- await tellPerson(row, 'needs_you', { what: `${questionKind === 'acting' ? 'choose for an interrupted action' : 'answer'}: ${clean}`, url: `${config.baseUrl}/tasks/${id}` });++ await tellPerson(row, 'needs_you', {++ what: questionKind === 'acting' ? `An action stopped halfway: ${clean}` : clean,++ kind: questionKind === 'acting' ? 'choose' : 'answer', url: `${config.baseUrl}/tasks/${id}`,++ });await setState(id, 'needs_you');waitForPerson(15 * 60_000);const answer = await waiting;@@@ -427,7 +430,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =const url = `${config.baseUrl}/me/approvals/${ask.id}`;const shown = money ? money.summary : summary;await emit(id, { kind: 'output', step: 'text', agent, text: `Your agent wants to ${String(shown).toLowerCase()} Nothing runs until you approve it: ${url}` });-- await tellPerson(row, 'needs_you', { what: `approve: ${shown}`, url });++ await tellPerson(row, 'needs_you', { what: String(shown), kind: 'approve', url });return { asked: true, decision, ask, url };};// A child hand-off (a sub-agent's consult, a Codes task, a Space action) is one node below this task's.@@@ -22,9 +22,18 @@ const reply = (status, body) => new Response(JSON.stringify(body), { status, heaconst url = 'https://openvibe.bot/machines/dev_01M4W5JBFDG2TJ8C4FRDMCWBZ9/screen?desk=desk-0123456789';assert.deepStrictEqual(await n.needsYou({ subject: ME, what: 'Sign in to the airline.', url }), { sent: true });assert.strictEqual(calls[0].url, `http://network.test/internal/identity/resolve?subject_id=${ME}`);-- assert.deepStrictEqual(calls[1].body, { user_id: 42, type: 'AGENT_NEEDS_YOU', category: 'service', priority: 'high', icon: '🙋', title: 'Your agent needs you', message: 'Sign in to the airline.', url, service: 'actor' });++ assert.deepStrictEqual(calls[1].body, { user_id: 42, type: 'AGENT_NEEDS_YOU', category: 'service', priority: 'high', icon: '🙋', title: 'Your agent needs you', message: 'Sign in to the airline.', url, service: 'actor', rich_content: { step: 'Sign in to the airline.' } });++ calls.length = 0;++ await n.needsYou({ subject: ME, what: 'Run "ls" on your computer', kind: 'approve', title: 'Call the computer_run tool', url });++ const asked = calls.find((c) => c.method === 'POST').body;++ assert.strictEqual(asked.message, 'Run "ls" on your computer', 'the step alone: no prefix, no task text in brackets');++ assert.deepStrictEqual(asked.rich_content, { step: 'Run "ls" on your computer', kind: 'approve', task_title: 'Call the computer_run tool' });++ calls.length = 0;++ await n.needsYou({ subject: ME, what: 'x', kind: 'shout', url });++ assert.deepStrictEqual(calls.find((c) => c.method === 'POST').body.rich_content, { step: 'x' }, 'an unknown kind is left out');++ calls.length = 0;assert.deepStrictEqual(await n.needsYou({ subject: 'usr_01M4W5JBFEG3QG9ER8W1A2K0RY', what: 'x', url }), { sent: false, reason: 'unknown_subject' });-- assert.strictEqual(calls.filter((c) => c.method === 'POST').length, 1, 'only the known person was told');++ assert.strictEqual(calls.filter((c) => c.method === 'POST').length, 0, 'only the known person was told');assert.deepStrictEqual(await n.needsYou({ subject: ME, what: 'x', url: 'http://evil.example/' }), { sent: false, reason: 'bad_input' }, 'only an https link');});@@@ -41,14 +50,15 @@ const reply = (status, body) => new Response(JSON.stringify(body), { status, heaassert.deepStrictEqual(await n.taskEnded({ subject: ME, state: 'succeeded', taskId, title: 'Say hello.', url }), { sent: true });const done = calls.find((c) => c.method === 'POST').body;assert.strictEqual(done.type, 'AGENT_TASK_DONE');-- assert.match(done.message, /Done: Say hello\./);++ assert.strictEqual(done.message, 'Say hello.', 'the task itself, no "Done:" prefix');assert.strictEqual(done.url, url);-- assert.deepStrictEqual(done.rich_content, { task_id: taskId, state: 'succeeded' });++ assert.deepStrictEqual(done.rich_content, { task_id: taskId, state: 'succeeded', task_title: 'Say hello.' });calls.length = 0;await n.taskEnded({ subject: ME, state: 'failed', taskId, title: 'Plan the trip', url, detail: 'It ran too long.' });const failed = calls.find((c) => c.method === 'POST').body;assert.strictEqual(failed.type, 'AGENT_TASK_FAILED');-- assert.match(failed.message, /Not finished: Plan the trip — It ran too long\./);++ assert.strictEqual(failed.message, 'Plan the trip — It ran too long.');++ assert.deepStrictEqual(failed.rich_content, { task_id: taskId, state: 'failed', task_title: 'Plan the trip', detail: 'It ran too long.' });assert.deepStrictEqual(await n.taskEnded({ subject: ME, state: 'failed', taskId: 'nope', title: 'x', url }), { sent: false, reason: 'bad_input' });});
@@ -6,8 +6,14 @@
@@ -6,8 +6,14 @@
* (POST /internal/notifications/push, contracts network.notification-push-request@1, capability
* (POST /internal/notifications/push, contracts network.notification-push-request@1, capability
* network.notifications.push). Each with a token of its own, audience openvibe.network.
* network.notifications.push). Each with a token of its own, audience openvibe.network.
*
*
- * needsYou({ subject, what, title, url }) a step only the person can do (the desk hand-off)
- * taskEnded({ subject, state, taskId, title, url, detail }) done or not finished: what it was and where to read it
+ * needsYou({ subject, what, kind, title, url }) a step only the person can do (the desk hand-off, an approval,
+ * a question): the message is the step alone; rich_content carries
+ * step, kind (approve | answer | choose) and the task's title
+ * taskEnded({ subject, state, taskId, title, url, detail }) done or not finished: the message is the task's title
+ * (and why it did not finish); rich_content carries task_title
+ *
+ * The words are for a person: no "Done:"/"approve:" prefixes, no task text in brackets after a step (Shared 4.172
+ * reads rich_content; older clients show the message, which already reads right).
*
*
* Best effort: off without a client secret, never retried (Network keeps no idempotency record for a push, and a person
* Best effort: off without a client secret, never retried (Network keeps no idempotency record for a push, and a person
* told twice is worse than one told once), and a failure never stops a task. → { sent, reason? }
* told twice is worse than one told once), and a failure never stops a task. → { sent, reason? }
@@ -17,6 +23,7 @@ const { serviceAuth } = require('openvibe-contracts');
@@ -17,6 +23,7 @@ const { serviceAuth } = require('openvibe-contracts');
const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
+const ASK_KINDS = new Set(['approve', 'answer', 'choose']);
const clip = (s, n) => (String(s == null ? '' : s).length > n ? `${String(s).slice(0, n - 1)}…` : String(s == null ? '' : s));
const clip = (s, n) => (String(s == null ? '' : s).length > n ? `${String(s).slice(0, n - 1)}…` : String(s == null ? '' : s));
function createNotifier({ config, fetchImpl = globalThis.fetch, tokens: tokensOverride = null, log = console }) {
function createNotifier({ config, fetchImpl = globalThis.fetch, tokens: tokensOverride = null, log = console }) {
@@ -61,12 +68,13 @@ function createNotifier({ config, fetchImpl = globalThis.fetch, tokens: tokensOv
@@ -61,12 +68,13 @@ function createNotifier({ config, fetchImpl = globalThis.fetch, tokens: tokensOv
}
}
}
}
- /** The agent needs its person for one step on its desk: `what`, the task's `title` and the desk's `url`. */
- async function needsYou({ subject, what, title = null, url }) {
- const message = `${clip(what || 'One step only you can do.', 300)}${title ? ` (${clip(title, 120)})` : ''}`;
+ /** The agent needs its person for one step: `what` (the step), its `kind`, the task's `title` and where to do it. */
+ async function needsYou({ subject, what, kind = null, title = null, url }) {
+ const message = clip(String(what || '').trim() || 'One step only you can do.', 300);
return send(subject, {
return send(subject, {
type: 'AGENT_NEEDS_YOU', category: 'service', priority: 'high', icon: '🙋',
type: 'AGENT_NEEDS_YOU', category: 'service', priority: 'high', icon: '🙋',
title: 'Your agent needs you', message, url, service: 'actor',
title: 'Your agent needs you', message, url, service: 'actor',
+ rich_content: { step: message, ...(ASK_KINDS.has(kind) ? { kind } : {}), ...(title ? { task_title: clip(title, 160) } : {}) },
});
});
}
}
@@ -78,14 +86,12 @@ function createNotifier({ config, fetchImpl = globalThis.fetch, tokens: tokensOv
@@ -78,14 +86,12 @@ function createNotifier({ config, fetchImpl = globalThis.fetch, tokens: tokensOv
if (!TASK_ID.test(String(taskId || ''))) return { sent: false, reason: 'bad_input' };
if (!TASK_ID.test(String(taskId || ''))) return { sent: false, reason: 'bad_input' };
const done = state === 'succeeded';
const done = state === 'succeeded';
const what = clip(title || 'Your task', 160);
const what = clip(title || 'Your task', 160);
- const message = done
- ? `Done: ${what}`
- : `Not finished: ${what}${detail ? ` — ${clip(detail, 140)}` : ''}`;
+ const message = done ? what : `${what}${detail ? ` — ${clip(detail, 140)}` : ''}`;
return send(subject, {
return send(subject, {
type: done ? 'AGENT_TASK_DONE' : 'AGENT_TASK_FAILED',
type: done ? 'AGENT_TASK_DONE' : 'AGENT_TASK_FAILED',
category: 'service', priority: done ? 'normal' : 'high', icon: done ? '✅' : '⚠️',
category: 'service', priority: done ? 'normal' : 'high', icon: done ? '✅' : '⚠️',
title: done ? 'Your task is done' : 'Your task did not finish',
title: done ? 'Your task is done' : 'Your task did not finish',
- message, url, service: 'actor', rich_content: { task_id: taskId, state },
+ message, url, service: 'actor', rich_content: { task_id: taskId, state, task_title: what, ...(!done && detail ? { detail: clip(detail, 140) } : {}) },
});
});
}
}
@@ -108,7 +108,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -108,7 +108,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
try {
try {
if (!(await taskNotices.once(s, row.id, kind))) return;
if (!(await taskNotices.once(s, row.id, kind))) return;
if (kind === 'needs_you') {
if (kind === 'needs_you') {
- await notifier.needsYou({ subject, what: detail.what, title: await taskTitle(row), url: detail.url || `${config.baseUrl}/tasks/${row.id}` });
+ await notifier.needsYou({ subject, what: detail.what, kind: detail.kind || null, title: await taskTitle(row), url: detail.url || `${config.baseUrl}/tasks/${row.id}` });
} else {
} else {
await notifier.taskEnded({
await notifier.taskEnded({
subject, state: kind, taskId: row.id, title: await taskTitle(row),
subject, state: kind, taskId: row.id, title: await taskTitle(row),
@@ -372,7 +372,10 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -372,7 +372,10 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
// The person is told before the task says it needs them: a reader that sees the task waiting
// The person is told before the task says it needs them: a reader that sees the task waiting
// for an answer must find the notification already sent, the same way the run's own end is
// for an answer must find the notification already sent, the same way the run's own end is
// published last (see setState).
// published last (see setState).
- await tellPerson(row, 'needs_you', { what: `${questionKind === 'acting' ? 'choose for an interrupted action' : 'answer'}: ${clean}`, url: `${config.baseUrl}/tasks/${id}` });
+ await tellPerson(row, 'needs_you', {
+ what: questionKind === 'acting' ? `An action stopped halfway: ${clean}` : clean,
+ kind: questionKind === 'acting' ? 'choose' : 'answer', url: `${config.baseUrl}/tasks/${id}`,
+ });
await setState(id, 'needs_you');
await setState(id, 'needs_you');
waitForPerson(15 * 60_000);
waitForPerson(15 * 60_000);
const answer = await waiting;
const answer = await waiting;
@@ -427,7 +430,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -427,7 +430,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
const url = `${config.baseUrl}/me/approvals/${ask.id}`;
const url = `${config.baseUrl}/me/approvals/${ask.id}`;
const shown = money ? money.summary : summary;
const shown = money ? money.summary : summary;
await emit(id, { kind: 'output', step: 'text', agent, text: `Your agent wants to ${String(shown).toLowerCase()} Nothing runs until you approve it: ${url}` });
await emit(id, { kind: 'output', step: 'text', agent, text: `Your agent wants to ${String(shown).toLowerCase()} Nothing runs until you approve it: ${url}` });
- await tellPerson(row, 'needs_you', { what: `approve: ${shown}`, url });
+ await tellPerson(row, 'needs_you', { what: String(shown), kind: 'approve', url });
return { asked: true, decision, ask, url };
return { asked: true, decision, ask, url };
};
};
// 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.
@@ -22,9 +22,18 @@ const reply = (status, body) => new Response(JSON.stringify(body), { status, hea
@@ -22,9 +22,18 @@ const reply = (status, body) => new Response(JSON.stringify(body), { status, hea
const url = 'https://openvibe.bot/machines/dev_01M4W5JBFDG2TJ8C4FRDMCWBZ9/screen?desk=desk-0123456789';
const url = 'https://openvibe.bot/machines/dev_01M4W5JBFDG2TJ8C4FRDMCWBZ9/screen?desk=desk-0123456789';
assert.deepStrictEqual(await n.needsYou({ subject: ME, what: 'Sign in to the airline.', url }), { sent: true });
assert.deepStrictEqual(await n.needsYou({ subject: ME, what: 'Sign in to the airline.', url }), { sent: true });
assert.strictEqual(calls[0].url, `http://network.test/internal/identity/resolve?subject_id=${ME}`);
assert.strictEqual(calls[0].url, `http://network.test/internal/identity/resolve?subject_id=${ME}`);
- assert.deepStrictEqual(calls[1].body, { user_id: 42, type: 'AGENT_NEEDS_YOU', category: 'service', priority: 'high', icon: '🙋', title: 'Your agent needs you', message: 'Sign in to the airline.', url, service: 'actor' });
+ assert.deepStrictEqual(calls[1].body, { user_id: 42, type: 'AGENT_NEEDS_YOU', category: 'service', priority: 'high', icon: '🙋', title: 'Your agent needs you', message: 'Sign in to the airline.', url, service: 'actor', rich_content: { step: 'Sign in to the airline.' } });
+ calls.length = 0;
+ await n.needsYou({ subject: ME, what: 'Run "ls" on your computer', kind: 'approve', title: 'Call the computer_run tool', url });
+ const asked = calls.find((c) => c.method === 'POST').body;
+ assert.strictEqual(asked.message, 'Run "ls" on your computer', 'the step alone: no prefix, no task text in brackets');
+ assert.deepStrictEqual(asked.rich_content, { step: 'Run "ls" on your computer', kind: 'approve', task_title: 'Call the computer_run tool' });
+ calls.length = 0;
+ await n.needsYou({ subject: ME, what: 'x', kind: 'shout', url });
+ assert.deepStrictEqual(calls.find((c) => c.method === 'POST').body.rich_content, { step: 'x' }, 'an unknown kind is left out');
+ calls.length = 0;
assert.deepStrictEqual(await n.needsYou({ subject: 'usr_01M4W5JBFEG3QG9ER8W1A2K0RY', what: 'x', url }), { sent: false, reason: 'unknown_subject' });
assert.deepStrictEqual(await n.needsYou({ subject: 'usr_01M4W5JBFEG3QG9ER8W1A2K0RY', what: 'x', url }), { sent: false, reason: 'unknown_subject' });
- assert.strictEqual(calls.filter((c) => c.method === 'POST').length, 1, 'only the known person was told');
+ assert.strictEqual(calls.filter((c) => c.method === 'POST').length, 0, 'only the known person was told');
assert.deepStrictEqual(await n.needsYou({ subject: ME, what: 'x', url: 'http://evil.example/' }), { sent: false, reason: 'bad_input' }, 'only an https link');
assert.deepStrictEqual(await n.needsYou({ subject: ME, what: 'x', url: 'http://evil.example/' }), { sent: false, reason: 'bad_input' }, 'only an https link');
});
});
@@ -41,14 +50,15 @@ const reply = (status, body) => new Response(JSON.stringify(body), { status, hea
@@ -41,14 +50,15 @@ const reply = (status, body) => new Response(JSON.stringify(body), { status, hea
assert.deepStrictEqual(await n.taskEnded({ subject: ME, state: 'succeeded', taskId, title: 'Say hello.', url }), { sent: true });
assert.deepStrictEqual(await n.taskEnded({ subject: ME, state: 'succeeded', taskId, title: 'Say hello.', url }), { sent: true });
const done = calls.find((c) => c.method === 'POST').body;
const done = calls.find((c) => c.method === 'POST').body;
assert.strictEqual(done.type, 'AGENT_TASK_DONE');
assert.strictEqual(done.type, 'AGENT_TASK_DONE');
- assert.match(done.message, /Done: Say hello\./);
+ assert.strictEqual(done.message, 'Say hello.', 'the task itself, no "Done:" prefix');
assert.strictEqual(done.url, url);
assert.strictEqual(done.url, url);
- assert.deepStrictEqual(done.rich_content, { task_id: taskId, state: 'succeeded' });
+ assert.deepStrictEqual(done.rich_content, { task_id: taskId, state: 'succeeded', task_title: 'Say hello.' });
calls.length = 0;
calls.length = 0;
await n.taskEnded({ subject: ME, state: 'failed', taskId, title: 'Plan the trip', url, detail: 'It ran too long.' });
await n.taskEnded({ subject: ME, state: 'failed', taskId, title: 'Plan the trip', url, detail: 'It ran too long.' });
const failed = calls.find((c) => c.method === 'POST').body;
const failed = calls.find((c) => c.method === 'POST').body;
assert.strictEqual(failed.type, 'AGENT_TASK_FAILED');
assert.strictEqual(failed.type, 'AGENT_TASK_FAILED');
- assert.match(failed.message, /Not finished: Plan the trip — It ran too long\./);
+ assert.strictEqual(failed.message, 'Plan the trip — It ran too long.');
+ assert.deepStrictEqual(failed.rich_content, { task_id: taskId, state: 'failed', task_title: 'Plan the trip', detail: 'It ran too long.' });
assert.deepStrictEqual(await n.taskEnded({ subject: ME, state: 'failed', taskId: 'nope', title: 'x', url }), { sent: false, reason: 'bad_input' });
assert.deepStrictEqual(await n.taskEnded({ subject: ME, state: 'failed', taskId: 'nope', title: 'x', url }), { sent: false, reason: 'bad_input' });
});
});