openvibers/openvibe.codes Commits

Raise the ask as a resumable event; the chain waits on the person (#406)

OP OpenVibers committed 2 days ago · 4ea6c06fe3 · 4 files, +231 −1

* Raise the ask as a resumable event; the chain waits on the person

* Merge origin/main (item 4) into ask branch

Changes

ddiff --git a/engine/attention.js b/engine/attention.js
nnew file mode 100644
iindex 0000000..2569f34
---- /dev/null
++++ b/engine/attention.js
@@@ -0,0 +1,113 @@
++'use strict';
++
++/**
++ * One structured question awaiting a person: `agent.attention@1`, raised, answered and resumed.
++ *
++ * A worker that cannot proceed without a person raises an attention instead of failing: one question, the evidence
++ * it holds, the options it offers, and the session the answer must resume. Authorised independent work may continue
++ * meanwhile; the answer resumes the same session (engine/chain.js reads the open record and says `attention`,
++ * never `failed`). The person's answer arrives from the phone path; Codes records it here and hands the session
++ * back. The contract shape is OpenVibe.Contracts' `agent/attention.v1.json`; the evidence, options and session ride
++ * alongside it and are projected off by toContract.
++ *
++ * raise({ ticket_id, question, evidence, options, session }) the open record
++ * answer(record, { answer, by }) the answered record
++ * dismiss(record, { note }) the dismissed record
++ * isOpen(record) an unanswered, undismissed record
++ * resumeSession(record) the session the answer resumes, or null
++ * toContract(record) the attention.v1 shape
++ * isValidContract(doc) { valid, errors } for the attention.v1 shape
++ * STATES open, answered, dismissed
++ */
++
++const STATES = ['open', 'answered', 'dismissed'];
++const MAX_TEXT = 4000;
++
++let counter = 0;
++
++const isText = (v, max = MAX_TEXT) => typeof v === 'string' && v.trim().length > 0 && (!max || v.length <= max);
++const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
++const stamp = (v) => (v === undefined ? new Date().toISOString() : String(v));
++
++/** The open record: one question, what backs it, what the person can pick, and the session it resumes. */
++function raise({ id = null, ticket_id = null, question = null, evidence = [], options = [], session = null, created_at } = {}) {
++ if (!isText(ticket_id, 200)) throw new Error('an attention needs the ticket it blocks');
++ if (!isText(question)) throw new Error('an attention needs the question');
++ if (evidence !== undefined && !Array.isArray(evidence)) throw new Error('attention evidence is a list');
++ if (options !== undefined && !Array.isArray(options)) throw new Error('attention options is a list');
++ if (session !== undefined && session !== null && !isText(session, 500)) throw new Error('an attention session is a short string');
++ return {
++ id: isText(id, 200) ? String(id) : `att_${Date.now().toString(36)}_${(counter++).toString(36)}`,
++ ticket_id: String(ticket_id),
++ question: String(question),
++ evidence: (Array.isArray(evidence) ? evidence : []).filter(isObject).slice(0, 10),
++ options: (Array.isArray(options) ? options : []).filter((o) => typeof o === 'string' && o.trim()).map((o) => String(o)).slice(0, 10),
++ ...(session ? { session: String(session) } : {}),
++ state: 'open',
++ created_at: stamp(created_at),
++ };
++}
++
++/** The answer, recorded on the open record. Answering anything but an open record is a mistake, said out loud. */
++function answer(record, { answer: text = null, by = null, answered_at } = {}) {
++ if (!isOpen(record)) throw new Error('only an open attention can be answered');
++ if (!isText(text)) throw new Error('an attention answer is non-empty text');
++ return {
++ ...record,
++ state: 'answered',
++ answer: String(text),
++ ...(by !== undefined && by !== null ? { answered_by: String(by) } : {}),
++ answered_at: stamp(answered_at),
++ };
++}
++
++/** Dismissed: the question is withdrawn, and the session is free. */
++function dismiss(record, { note = null } = {}) {
++ if (!isOpen(record)) throw new Error('only an open attention can be dismissed');
++ return { ...record, state: 'dismissed', ...(note ? { dismiss_note: String(note).slice(0, 1000) } : {}) };
++}
++
++/** An unanswered, undismissed record. */
++function isOpen(record) {
++ return !!record && record.state === 'open' && isText(record.question) && isText(record.ticket_id);
++}
++
++/** The session the answer must resume: the record's own, the same session the worker waited in. */
++function resumeSession(record) {
++ if (!isObject(record)) return null;
++ return record.session && String(record.session).trim() ? String(record.session) : null;
++}
++
++/** The contract shape: what attention.v1.json carries, nothing Codes-only. */
++function toContract(record) {
++ if (!isObject(record)) throw new Error('an attention record is an object');
++ const doc = {
++ id: String(record.id || ''),
++ ticket_id: String(record.ticket_id || ''),
++ question: String(record.question || ''),
++ state: record.state,
++ created_at: String(record.created_at || ''),
++ };
++ if (record.answer !== undefined) doc.answer = String(record.answer);
++ return doc;
++}
++
++/** The attention.v1 shape, structurally: the required fields, the closed state list, the optional answer. */
++function isValidContract(doc) {
++ const errors = [];
++ if (!isObject(doc)) return { valid: false, errors: ['an attention must be an object'] };
++ for (const k of ['id', 'ticket_id', 'question', 'state', 'created_at']) {
++ if (!isText(doc[k], k === 'question' ? MAX_TEXT : 500)) errors.push(`attention.${k} must be non-empty text`);
++ }
++ if (!STATES.includes(doc.state)) errors.push(`attention.state must be one of ${STATES.join(', ')}`);
++ if (doc.answer !== undefined && !isText(doc.answer)) errors.push('attention.answer must be non-empty text when present');
++ if (Number.isNaN(Date.parse(doc.created_at))) errors.push('attention.created_at must be a date-time');
++ const allowed = ['id', 'ticket_id', 'question', 'state', 'created_at', 'answer'];
++ for (const k of Object.keys(doc)) if (!allowed.includes(k)) errors.push(`attention.${k} is not an attention field`);
++ return { valid: errors.length === 0, errors };
++}
++
++module.exports = {
++ STATES,
++ raise, answer, dismiss, isOpen, resumeSession, toContract, isValidContract,
++};
ddiff --git a/engine/chain.js b/engine/chain.js
iindex be61764..4acb438 100644
---- a/engine/chain.js
++++ b/engine/chain.js
@@@ -398,7 +398,7 @@ function detect(attempt = {}) {
*
* @param {object[]} attempts the attempts (annotated or not)
* @param {object} [opts] what the caller knows: { cfg, health, counts, models, runners, tier }
-- * @returns {object} { action, why, … } — action is one of wait, done, switch, continue, restart, wrap-up, stop, failed
++ * @returns {object} { action, why, … } — action is one of wait, done, switch, continue, restart, wrap-up, stop, attention, failed
*/
function nextStep(attempts, opts = {}) {
const runs = annotate(attempts);
@@@ -432,6 +432,18 @@ function settledParent(runs, end) {
/** The next step, given the chain's end. */
function step(runs, end, opts = {}) {
if (!end) return { action: 'none', why: 'there are no attempts' };
++ // An open question waits for the person, never fails the task: the answer resumes the same session, and
++ // authorised independent work may continue meanwhile. It comes first: a worker blocked on a person is waiting.
++ if (end.attention && end.attention.state === 'open') {
++ const session = (end.attention.session && String(end.attention.session)) || facts(end).session || null;
++ return {
++ action: 'attention',
++ ...(end.attention.id ? { attention: end.attention.id } : {}),
++ ...(end.attention.question ? { question: String(end.attention.question).slice(0, 500) } : {}),
++ ...(session ? { session } : {}),
++ why: 'the worker asked a question and waits for the answer; the answer resumes the same session',
++ };
++ }
if (end.status === 'running' || end.status === 'queued') return { action: 'wait', why: 'the task is still running' };
// AL1: a worker's "done" is a proposal, not an outcome. Nothing is done until an independent verifier accepted the
// goal's evidence; a rejection (or a verdict that could not be made) goes back to the same session.
ddiff --git a/engine/index.js b/engine/index.js
iindex 4e03111..ac52d18 100644
---- a/engine/index.js
++++ b/engine/index.js
@@@ -22,6 +22,7 @@
* engine.finding one finding in the agent.finding@1 shape: confirmed, a lead that needs validation, or rejected
* engine.findings a review's findings across revisions: stable ids, ACTIVE/RESOLVED and the lifecycle line
* engine.verifier a fresh verifier per confirmed record, with a refute prompt
++ * engine.attention one question awaiting a person: raised, answered and resumed (agent.attention@1)
*
* The modules do not depend on each other in a cycle, and none of them reads the portal's log folder.
*/
@@@ -45,6 +46,7 @@ const review = require('./review');
const finding = require('./finding');
const findings = require('./findings');
const verifier = require('./verifier');
++const attention = require('./attention');
const goal = require('./goal');
module.exports = {
@@@ -67,6 +69,7 @@ module.exports = {
finding,
findings,
verifier,
++ attention,
goal,
// The names a caller usually wants, in one place.
classify: router.classify,
ddiff --git a/test/engine-attention.test.js b/test/engine-attention.test.js
nnew file mode 100644
iindex 0000000..9e6f109
---- /dev/null
++++ b/test/engine-attention.test.js
@@@ -0,0 +1,102 @@
++'use strict';
++
++/**
++ * Release 2 item 5: the ask as a resumable event (agent.attention@1).
++ *
++ * A worker that needs a person raises one structured question instead of failing. The chain answers `attention`
++ * with the session the answer resumes; the answered or dismissed record falls back to the ordinary flow. The record
++ * projects to the attention.v1 contract shape with nothing Codes-only in it.
++ */
++const assert = require('assert');
++const attention = require('../engine/attention');
++const chain = require('../engine/chain');
++const { check, done } = require('./helpers/boot');
++
++const now = Date.now();
++const job = (runner, name, status, at, extra = {}) => ({
++ key: `${runner}:${name}`, runner, name, runnerName: runner.toUpperCase(),
++ status, updated: at, error: status === 'done' ? '' : 'it broke', ...extra,
++});
++
++(async () => {
++ try {
++ await check('an attention is raised open, with its evidence, options and session', () => {
++ const rec = attention.raise({
++ ticket_id: 't-1', question: 'Ship it or fix the test first?',
++ evidence: [{ kind: 'command', summary: 'the red test', command: 'ov test red.test.js' }],
++ options: ['ship it', 'fix first'], session: 'sess-9',
++ });
++ assert.ok(rec.id.startsWith('att_'));
++ assert.strictEqual(rec.state, 'open');
++ assert.strictEqual(rec.session, 'sess-9');
++ assert.deepStrictEqual(rec.options, ['ship it', 'fix first']);
++ assert.strictEqual(rec.evidence.length, 1);
++ assert.ok(!Number.isNaN(Date.parse(rec.created_at)));
++ assert.strictEqual(attention.isOpen(rec), true);
++ assert.strictEqual(attention.resumeSession(rec), 'sess-9');
++ });
++
++ await check('raising needs the ticket and the question', () => {
++ assert.throws(() => attention.raise({ question: 'q?' }), /ticket/);
++ assert.throws(() => attention.raise({ ticket_id: 't' }), /question/);
++ assert.throws(() => attention.raise({ ticket_id: 't', question: ' ' }), /question/);
++ });
++
++ await check('the answer and the dismissal close the record, and only an open one moves', () => {
++ const rec = attention.raise({ ticket_id: 't', question: 'Which region?' });
++ const answered = attention.answer(rec, { answer: 'eu', by: 'owner' });
++ assert.strictEqual(answered.state, 'answered');
++ assert.strictEqual(answered.answer, 'eu');
++ assert.strictEqual(attention.isOpen(answered), false);
++ assert.throws(() => attention.answer(answered, { answer: 'us' }), /only an open attention/);
++ const dismissed = attention.dismiss(attention.raise({ ticket_id: 't', question: 'Still want this?' }), { note: 'asked twice' });
++ assert.strictEqual(dismissed.state, 'dismissed');
++ assert.throws(() => attention.dismiss(dismissed), /only an open attention/);
++ assert.throws(() => attention.answer(rec, { answer: ' ' }), /non-empty/);
++ });
++
++ await check('the record projects to the attention.v1 contract shape', () => {
++ const rec = attention.raise({ id: 'att_1', ticket_id: 't', question: 'Go?', session: 's' });
++ const doc = attention.toContract(rec);
++ assert.deepStrictEqual(Object.keys(doc).sort(), ['created_at', 'id', 'question', 'state', 'ticket_id']);
++ assert.deepStrictEqual(attention.isValidContract(doc), { valid: true, errors: [] });
++ assert.deepStrictEqual(attention.isValidContract(attention.toContract(attention.answer(rec, { answer: 'yes' }))).valid, true);
++ assert.strictEqual(attention.isValidContract({ ...doc, state: 'waiting' }).valid, false);
++ assert.strictEqual(attention.isValidContract({ ...doc, question: '' }).valid, false);
++ assert.strictEqual(attention.isValidContract({ ...doc, session: 's' }).valid, false, 'nothing Codes-only crosses into the contract');
++ });
++
++ await check('an open question is attention with its session, never a failure', () => {
++ const rec = attention.raise({ id: 'att_7', ticket_id: 't', question: 'Ship it?', session: 'sess-9' });
++ const attempts = [job('cx', 'ask', 'failed', now - 1000, { attention: rec, facts: { session: 'sess-9' } })];
++ const s = chain.nextStep(attempts);
++ assert.strictEqual(s.action, 'attention');
++ assert.strictEqual(s.session, 'sess-9');
++ assert.strictEqual(s.attention, 'att_7');
++ assert.ok(s.question.includes('Ship it'));
++ });
++
++ await check('the answer resumes the same session: answered and dismissed fall back to the ordinary flow', () => {
++ const cfg = { subagents: { cx: { enabled: true, max: 2 } }, fallback: { order: ['cx'] } };
++ const ctx = { cfg, health: { cx: { state: 'ok' } }, counts: {}, models: {}, runners: ['cx'] };
++ const rec = attention.raise({ id: 'att_8', ticket_id: 't', question: 'Ship it?' });
++ const answered = [job('cx', 'ask', 'failed', now - 1000, { attention: attention.answer(rec, { answer: 'yes' }) })];
++ assert.notStrictEqual(chain.nextStep(answered, ctx).action, 'attention', 'an answered question is not waited on');
++ const dismissed = [job('cx', 'ask', 'failed', now - 1000, { attention: attention.dismiss(attention.raise({ ticket_id: 't', question: 'q?' })) })];
++ assert.notStrictEqual(chain.nextStep(dismissed, ctx).action, 'attention', 'a dismissed question is not waited on');
++ });
++
++ await check('the open question wins over the failure handling, and carries the run session when its own is missing', () => {
++ const rec = attention.raise({ ticket_id: 't', question: 'Which model?' });
++ const s = chain.nextStep([job('cx', 'ask', 'failed', now - 1000, { attention: rec, facts: { session: 'sess-3' } })]);
++ assert.strictEqual(s.action, 'attention');
++ assert.strictEqual(s.session, 'sess-3', 'the run session carries the resume when the record names none');
++ });
++
++ console.log('attention: one structured question, answered in the same session, contracted');
++ } catch (e) {
++ console.error(e);
++ process.exit(1);
++ }
++ done();
++})();