openvibers/openvibe.sdk Commits

openvibe-sdk 0.159.0: the agent engine's client (#243)

OP OpenVibers committed 24 h ago · 910cc04513 · 12 files, +857 −84

runs.list/get/steps/start/say/stop/events/follow, approvals.answer, createFrameAgentsClient (every
product's runs through the Frame by ref), refOf/parseRef/isEnd/needsPerson; only a step advances the
stream's resume cursor. Squashed from #239 onto main (its 0.158.0 went to openvibe-sdk/views).

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 4c6eac0..26f489d 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,25 @@
# Changelog
++## 0.159.0 — 2026-11-12
++
++The agent engine's client (Contracts 0.414.0: everything a person asks an AI to do is a run with a thread of steps).
++
++- **`openvibe-sdk/agents` runs:** `runs.list({ kind, status, q, limit, cursor, before, agent, parentRun })`,
++ `runs.get(run)` (null on 404), `runs.steps(run, { after, limit })`, `runs.start(text | agents.run-start@1,
++ { idempotencyKey })`, `runs.say(run, text | { text, attachments })` (continue a conversation or steer work),
++ `runs.stop(run, { reason })`, `runs.events(run, { after })` and `runs.follow(run, { onRun, onStep, onDelta,
++ onApproval, onEvent })`, which resolves with the end status.
++- **`approvals.answer(run, approval, answer)`** by the approval's id, its object, or the step seq that asked.
++- **`createFrameAgentsClient(client)`:** every product's runs through the Frame (`/api/frame/runs…`), where a run is
++ named by its ref (`<product>:<id>`); `createAgentsClient(client, { prefix })` for any product's Run API.
++- **Helpers:** `refOf`, `parseRef`, `isEnd`, `needsPerson` (a run waiting for a message is a conversation between
++ turns, not something that needs its person), `KINDS`, `STATUSES`, `END_STATUSES`.
++- **The stream:** the engine's events (`run`, `step`, `delta`, `approval`, `end`) are read beside AG-UI's; only a
++ step advances the resume cursor, so a snapshot or more text that shares a step's id is never skipped.
++- **The plugin interfaces** (model adapter, tool, sandbox, memory, planner, reviewer) stay as JSDoc types; the loop
++ that calls them is the next release.
++- `run`, `post`, `events`, `desks`, `takeOver`, `handBack` and `replay` are unchanged.
++
## 0.158.0 — 2026-11-12
- **`openvibe-sdk/views`: one view API for every content kind** (Contracts `media.view-request@1`, the `media.viewed`
ddiff --git a/README.md b/README.md
iindex 5c54e67..199102a 100644
---- a/README.md
++++ b/README.md
@@@ -8,12 +8,12 @@
**Install and test:** `npm ci && npm test` (Node 20 or newer)
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.158.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.159.0
```
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.158.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
++**Status:** alpha, v0.159.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
@@@ -22,7 +22,7 @@ npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags
After v0.97.1 is tagged:
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.158.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.159.0
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against).
ddiff --git a/STATUS.json b/STATUS.json
iindex ed8f71a..8ba11c8 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.158.0",
++ "version": "0.159.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex c954d51..42ebbf6 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.158.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.159.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, locker, registry, trust, levels, modules, realtime, media, composer, cam, mediaUpload, ui, uiLayout, community, homeFeed, feed, views, bot, search, bench, meme, vision, themes, elements, design, redact, chat, ai, credentials, jobs, agents, frame, tools, projects, geo, history, userCard, player, agentSheet). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -68,7 +68,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.158.0';
++const SDK_VERSION = '0.159.0';
module.exports = {
createClient,
@@@ -6770,50 +6770,153 @@ module.exports = { isBlob, isUpload, appendFiles };
// ── 53: src/agents.js ───────────────────────────────────────────────
function (module, exports) {
'use strict';
--/** Agent run snapshots, AG-UI event streams and approval responses. */
++/**
++ * openvibe-sdk/agents — the client for the agent engine (OpenVibe.Contracts docs/agents-runs.md).
++ *
++ * Everything a person asks an AI to do on OpenVibe is a run, and a run is a thread of steps: a chat with Vibe, a
++ * question in a composer, a task for an agent, a coding task, a job on a machine. One shape (`agents.run@1`,
++ * `agents.step@1`, `agents.approval@1`), one set of routes (the Run API), on every product that hosts runs and, for
++ * every product at once, on the Frame.
++ *
++ * const agents = createAgentsClient(client); // one product (Network by default)
++ * const all = createFrameAgentsClient(client); // every product, through the Frame
++ *
++ * const { runs, next } = await all.runs.list({ kind: 'chat', limit: 20 });
++ * const run = await all.runs.start('A goal bar for my sub goal'); // or the whole agents.run-start@1 object
++ * await all.runs.say(run, 'Make it purple'); // continue or steer it
++ * const { steps } = await all.runs.steps(run, { after: 10 });
++ * await all.runs.follow(run, { onStep, onDelta, onRun }); // the live stream, until it ends
++ * await all.approvals.answer(run, 'apr_…', 'approve');
++ * await all.runs.stop(run, { reason: 'not now' });
++ *
++ * The Run API, relative to the client's prefix (`/api/v1` on a product, `/api/frame` on the Frame, where a run's id
++ * is its ref, `<product>:<id>`):
++ * GET /runs?kind=&status=&q=&limit=&cursor= POST /runs (agents.run-start@1)
++ * GET /runs/:id GET /runs/:id/steps?after=&limit=
++ * GET /runs/:id/events POST /runs/:id/messages { text, attachments }
++ * POST /runs/:id/stop { reason } POST /runs/:id/approvals/:id { answer }
++ * GET /runs/:id/desks · POST …/desks/:desk/take-over · …/hand-back · GET /runs/:id/replay
++ *
++ * A product other than Network: { service: 'actor', audience: 'openvibe.actor' } (or { baseUrl }).
++ * The plugin interfaces of the engine's loop (model adapter, tool, sandbox, memory, planner, reviewer) are described
++ * at the end of this file; `openvibe-sdk/agents/loop` is where they are called.
++ */
const { OpenVibeError, isOpenVibeError } = __load(3);
const { parseSSE } = __load(25);
++
const enc = encodeURIComponent;
const pause = (ms, signal) => new Promise((resolve) => {
const t = setTimeout(resolve, ms);
if (signal) signal.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true });
});
--function createAgentsClient(client, { baseUrl, service = 'network', audience = 'openvibe.network' } = {}) {
++
++/** The kinds a run may be; a product may add its own, and a reader treats an unknown kind as `task`. */
++const KINDS = Object.freeze(['chat', 'ask', 'task', 'code', 'machine', 'goal']);
++/** A run's statuses; the last three are ends, and an end never changes. */
++const STATUSES = Object.freeze(['planning', 'running', 'waiting', 'done', 'failed', 'stopped']);
++const END_STATUSES = Object.freeze(['done', 'failed', 'stopped']);
++const REF = /^([a-z][a-z0-9-]{0,30}):([A-Za-z0-9._~-]{1,160})$/;
++
++/** Has this run (or status) ended? */
++function isEnd(runOrStatus) {
++ const s = typeof runOrStatus === 'string' ? runOrStatus : runOrStatus && runOrStatus.status;
++ return END_STATUSES.includes(s);
++}
++/** A run's address on the Frame: `<product>:<id>`. */
++function refOf(product, id) {
++ const ref = `${String(product || '')}:${String(id || '')}`;
++ if (!REF.test(ref)) throw new TypeError('agents.refOf: a product (a-z, 0-9, -) and an id are required');
++ return ref;
++}
++/** `{ product, id }` from a ref, or null when it is not one. */
++function parseRef(ref) {
++ const m = REF.exec(String(ref || ''));
++ return m ? { product: m[1], id: m[2] } : null;
++}
++/** Does this run need its person now (an approval, a takeover, a payment), as opposed to a conversation between turns? */
++function needsPerson(run) {
++ return !!run && run.status === 'waiting' && !!run.waiting && run.waiting.for !== 'message';
++}
++
++const runIdOf = (run) => {
++ if (typeof run === 'string' && run) return run;
++ if (run && typeof run === 'object') return typeof run.ref === 'string' && run.ref ? run.ref : (typeof run.id === 'string' ? run.id : null);
++ return null;
++};
++
++function createAgentsClient(client, { baseUrl, service = 'network', audience = 'openvibe.network', prefix = '/api/v1' } = {}) {
++ if (typeof prefix !== 'string' || !/^\/[a-z0-9/_-]+$/i.test(prefix)) throw new TypeError('agents: prefix is a path such as /api/v1');
++ // On the Frame a run is named by its ref; on a product by its own id.
++ const byRef = prefix === '/api/frame';
const call = (opts) => client.request({ service, baseUrl, audience, ...opts });
-- const runId = (run) => {
-- const id = typeof run === 'string' ? run : run && run.id;
-- if (!id) throw new TypeError('agents: a run (or its id) is required');
-- return enc(id);
++ const at = (what, run) => {
++ const id = byRef ? runIdOf(run) : (typeof run === 'string' ? run : run && run.id);
++ if (!id) throw new TypeError(`${what}: a run (or its id) is required`);
++ return `${prefix}/runs/${enc(id)}`;
};
const deskId = (desk) => {
if (typeof desk !== 'string' || !desk) throw new TypeError('agents: a desk id is required');
return enc(desk);
};
-- async function run(id, { signal } = {}) {
-- if (!id) throw new TypeError('agents.run: id is required');
-- try { return (await call({ path: `/api/v1/runs/${enc(id)}`, signal })).data; }
++
++ // ── runs ──
++ async function list({ kind, status, q, limit, cursor, before, agent, parentRun, signal } = {}) {
++ return (await call({ path: `${prefix}/runs`, query: { kind, status, q, limit, cursor, before, agent, parent_run: parentRun }, signal })).data;
++ }
++ async function get(run, { signal } = {}) {
++ try { return (await call({ path: at('agents.runs.get', run), headers: { Accept: 'application/vnd.openvibe.agents.run+json, application/json' }, signal })).data; }
catch (err) { if (isOpenVibeError(err) && err.status === 404) return null; throw err; }
}
-- async function post(id, approval, { signal } = {}) {
-- if (!id) throw new TypeError('agents.post: id is required');
-- if (!approval || typeof approval !== 'object' || Array.isArray(approval)) throw new TypeError('agents.post: approval must be an object');
-- return (await call({ method: 'POST', path: `/api/v1/runs/${enc(id)}/approvals`, json: approval, signal })).data;
++ async function steps(run, { after, limit, signal } = {}) {
++ if (after != null && !Number.isInteger(Number(after))) throw new TypeError('agents.runs.steps: after is a step seq');
++ return (await call({ path: `${at('agents.runs.steps', run)}/steps`, query: { after, limit }, signal })).data;
}
-- async function* events(id, { lastEventId = 0, signal, maxReconnects = 20, reconnectDelayMs = 1000 } = {}) {
-- if (!id) throw new TypeError('agents.events: id is required');
-- let last = String(lastEventId || '0'), failures = 0, serverRetryMs = 0;
++ /** Start a run. `request` is the words, or the whole agents.run-start@1 object; only `text` is required. */
++ async function start(request, { signal, idempotencyKey } = {}) {
++ const body = typeof request === 'string' ? { text: request } : request;
++ if (!body || typeof body !== 'object' || Array.isArray(body) || typeof body.text !== 'string' || !body.text.trim()) {
++ throw new TypeError('agents.runs.start: the words to start with are required ({ text })');
++ }
++ return (await call({ method: 'POST', path: `${prefix}/runs`, json: body, ...(idempotencyKey ? { idempotencyKey } : {}), signal })).data;
++ }
++ /** Say something to a run: continue a conversation, answer a question in words, or steer work under way. */
++ async function say(run, message, { signal, idempotencyKey } = {}) {
++ const body = typeof message === 'string' ? { text: message } : message;
++ if (!body || typeof body !== 'object' || Array.isArray(body) || ((typeof body.text !== 'string' || !body.text.trim()) && !(Array.isArray(body.attachments) && body.attachments.length))) {
++ throw new TypeError('agents.runs.say: words or attachments are required');
++ }
++ return (await call({ method: 'POST', path: `${at('agents.runs.say', run)}/messages`, json: body, ...(idempotencyKey ? { idempotencyKey } : {}), signal })).data;
++ }
++ async function stop(run, { reason, signal } = {}) {
++ return (await call({ method: 'POST', path: `${at('agents.runs.stop', run)}/stop`, ...(reason !== undefined ? { json: { reason } } : {}), signal })).data;
++ }
++ /**
++ * A run's stream, as an async iterator of `{ id, event, data }`. It reconnects with Last-Event-ID (a step's seq on
++ * the engine's stream) and ends at the stream's `end` event (or AG-UI's RUN_FINISHED / RUN_ERROR on an Ask run).
++ */
++ async function* events(run, { after, lastEventId = 0, signal, maxReconnects = 20, reconnectDelayMs = 1000 } = {}) {
++ const path = `${at('agents.events', run)}/events`;
++ let last = String(after != null ? after : (lastEventId || '0')), failures = 0, serverRetryMs = 0;
while (!(signal && signal.aborted)) {
let delivered = false;
try {
-- const res = await call({ path: `/api/v1/runs/${enc(id)}/events`, responseType: 'response', retries: 0, signal,
++ const res = await call({ path, responseType: 'response', retries: 0, signal,
headers: { Accept: 'text/event-stream', 'Cache-Control': 'no-cache', 'Last-Event-ID': last } });
if (res.status === 204) return;
for await (const msg of parseSSE(res.data.body, { onRetry: (ms) => { serverRetryMs = ms; } })) {
-- if (msg.id !== undefined && msg.id !== '') { if (/^\d+$/.test(msg.id) && Number(msg.id) <= Number(last)) continue; last = msg.id; }
++ // Only a step advances the cursor and is skipped when already seen: a run snapshot, more text for a
++ // step, an approval and the end carry the newest step's seq as their id.
++ const soft = msg.event === 'run' || msg.event === 'delta' || msg.event === 'approval' || msg.event === 'end';
++ if (msg.id !== undefined && msg.id !== '') {
++ const numeric = /^\d+$/.test(msg.id);
++ if (!soft && numeric && Number(msg.id) <= Number(last)) continue;
++ if (!numeric || Number(msg.id) > Number(last)) last = msg.id;
++ }
let data; try { data = JSON.parse(msg.data); } catch { data = null; }
delivered = true; failures = 0;
-- yield { id: msg.id ?? null, event: msg.event || (data && data.type) || null, data };
-- if (data && ['RUN_FINISHED', 'RUN_ERROR'].includes(data.type)) return;
++ const event = msg.event || (data && data.type) || null;
++ yield { id: msg.id ?? null, event, data };
++ if (event === 'end' || (data && ['RUN_FINISHED', 'RUN_ERROR'].includes(data.type))) return;
}
} catch (err) {
if (signal && signal.aborted) return;
@@@ -6821,34 +6924,134 @@ function createAgentsClient(client, { baseUrl, service = 'network', audience = '
if (!isOpenVibeError(err) && !['TypeError', 'AbortError'].includes(err && err.name)) throw err;
}
failures = delivered ? 0 : failures + 1;
-- if (failures > maxReconnects) throw new OpenVibeError({ code: 'sdk.network_error', retryable: true, message: `agents: event stream ${id} failed ${failures} times in a row` });
++ if (failures > maxReconnects) throw new OpenVibeError({ code: 'sdk.network_error', retryable: true, message: `agents: the stream of ${runIdOf(run)} failed ${failures} times in a row` });
const backoffMs = Math.min(reconnectDelayMs * (2 ** Math.max(0, failures - 1)), 30000);
-- const waitMs = Math.min(Math.max(backoffMs, serverRetryMs), 30000);
-- await pause(waitMs, signal);
++ await pause(Math.min(Math.max(backoffMs, serverRetryMs), 30000), signal);
}
}
/**
-- * See it work (W1): the desks a run may show, the person's control of one, and the finished run's replay. The
-- * shapes match Actor's own until these routes are released here: desks → [{ desk, view, still }], replay → the
-- * steps with their stills. A run is an id or an object with an id.
++ * Follow a run until it ends: `onRun(run)` for a new snapshot, `onStep(step)` for an appended step,
++ * `onDelta({ seq, text })` for more words of a step being written, `onApproval(approval)`, `onEvent(event)` for
++ * everything. Resolves with `{ status }` (the end status, or null when the stream closed without one).
*/
++ async function follow(run, { onRun, onStep, onDelta, onApproval, onEvent, ...options } = {}) {
++ for await (const e of events(run, options)) {
++ if (onEvent) await onEvent(e);
++ const d = e.data || {};
++ if (e.event === 'run' && d.run && onRun) await onRun(d.run);
++ else if (e.event === 'step' && d.step && onStep) await onStep(d.step);
++ else if (e.event === 'delta' && onDelta) await onDelta({ run_id: d.run_id, seq: d.seq, text: d.text });
++ else if (e.event === 'approval' && d.approval && onApproval) await onApproval(d.approval);
++ else if (e.event === 'end') return { status: d.status || null };
++ else if (d.type === 'RUN_FINISHED') return { status: 'done' };
++ else if (d.type === 'RUN_ERROR') return { status: 'failed' };
++ }
++ return { status: null };
++ }
++
++ // ── approvals ──
++ /** Answer an approval: by its id (agents.approval@1's `id`), or by the seq of the step that asked. */
++ async function answer(run, approval, reply, { signal } = {}) {
++ const id = typeof approval === 'number' ? String(approval) : (approval && typeof approval === 'object' ? (approval.id || String(approval.step_seq || '')) : String(approval || ''));
++ if (!id || !/^[A-Za-z0-9._~-]{1,160}$/.test(id)) throw new TypeError('agents.approvals.answer: an approval (its id or its step seq) is required');
++ if (reply === undefined || reply === null || reply === '') throw new TypeError('agents.approvals.answer: an answer is required');
++ return (await call({ method: 'POST', path: `${at('agents.approvals.answer', run)}/approvals/${enc(id)}`, json: { answer: reply }, signal })).data;
++ }
++
++ // ── see it work (0.157.0): the desks a run may show, the person's control of one, the finished run's replay ──
async function desks(run, { signal } = {}) {
-- return (await call({ path: `/api/v1/runs/${runId(run)}/desks`, signal })).data;
++ return (await call({ path: `${at('agents.desks', run)}/desks`, signal })).data;
}
async function takeOver(run, desk, { signal } = {}) {
-- return (await call({ method: 'POST', path: `/api/v1/runs/${runId(run)}/desks/${deskId(desk)}/take-over`, signal })).data;
++ return (await call({ method: 'POST', path: `${at('agents.takeOver', run)}/desks/${deskId(desk)}/take-over`, signal })).data;
}
async function handBack(run, desk, note = '', { signal } = {}) {
if (note != null && typeof note !== 'string') throw new TypeError('agents.handBack: note is a string');
-- return (await call({ method: 'POST', path: `/api/v1/runs/${runId(run)}/desks/${deskId(desk)}/hand-back`, json: { note: String(note || '').slice(0, 1000) }, signal })).data;
++ return (await call({ method: 'POST', path: `${at('agents.handBack', run)}/desks/${deskId(desk)}/hand-back`, json: { note: String(note || '').slice(0, 1000) }, signal })).data;
}
async function replay(run, { signal } = {}) {
-- return (await call({ path: `/api/v1/runs/${runId(run)}/replay`, signal })).data;
++ return (await call({ path: `${at('agents.replay', run)}/replay`, signal })).data;
}
-- return { run, events, post, desks, takeOver, handBack, replay };
++ // ── the first shapes (an Ask run's snapshot and approval post on Network), kept as they were released ──
++ async function run(id, { signal } = {}) {
++ if (!id) throw new TypeError('agents.run: id is required');
++ try { return (await call({ path: `${prefix}/runs/${enc(id)}`, signal })).data; }
++ catch (err) { if (isOpenVibeError(err) && err.status === 404) return null; throw err; }
++ }
++ async function post(id, approval, { signal } = {}) {
++ if (!id) throw new TypeError('agents.post: id is required');
++ if (!approval || typeof approval !== 'object' || Array.isArray(approval)) throw new TypeError('agents.post: approval must be an object');
++ return (await call({ method: 'POST', path: `${prefix}/runs/${enc(id)}/approvals`, json: approval, signal })).data;
++ }
++
++ return {
++ runs: { list, get, steps, start, say, stop, events, follow },
++ approvals: { answer },
++ desks, takeOver, handBack, replay,
++ run, post, events,
++ };
++}
++
++/** Every product's runs through the Frame (`/api/frame/runs…` on Network): a run is named by its ref. */
++function createFrameAgentsClient(client, options = {}) {
++ return createAgentsClient(client, { ...options, service: 'network', audience: 'openvibe.network', prefix: '/api/frame' });
}
--module.exports = { createAgentsClient };
++
++/**
++ * A model adapter is the only way the loop reaches a model: Exchange's routes, a person's own key, a machine's local
++ * model or a signed-in plan. It returns the model's next turn and what it cost.
++ * @typedef {object} AgentModelAdapter
++ * @property {string} id A stable id for the model (the picker's row id, e.g. "openvibe:deepseek-v4").
++ * @property {string} [family] The model family, for a reviewer of another family.
++ * @property {(request: { messages: object[], tools?: AgentTool[], model?: string, budget?: object, signal?: AbortSignal }) => Promise<{ text?: string, tool_calls?: object[], usage?: { tokens?: number }, cost?: { usd?: number }, raw?: object }>} complete
++ */
++
++/**
++ * A tool the loop may call. OpenVibe products are tools (Search, Media, Live, Bot, a machine through Link); an MCP
++ * server is a bag of them. The registry decides which a run may use; a tool never sees a credential it does not need.
++ * @typedef {object} AgentTool
++ * @property {string} name
++ * @property {string} description
++ * @property {object} [inputSchema] JSON Schema for `input`.
++ * @property {(input: object, context: { run: object, toolCall: object, signal?: AbortSignal }) => Promise<{ result: any, cost?: { usd?: number }, artifacts?: object[] }>} run
++ */
++
++/**
++ * A sandbox is where code runs: a Codes runner, a machine through Link or an OpenVibe computer. The loop asks; the
++ * sandbox owns isolation, egress and the redaction of what it returns.
++ * @typedef {object} AgentSandbox
++ * @property {string} id
++ * @property {string} [where] agents.run@1's `where.kind`: "openvibe" | "machine" | "runner" | "this-computer"
++ * @property {(command: string | string[], options?: { cwd?: string, env?: object, timeoutMs?: number, signal?: AbortSignal }) => Promise<{ ok: boolean, output: string, exitCode?: number, artifacts?: object[] }>} exec
++ * @property {(path: string, options?: { encoding?: string }) => Promise<string | Uint8Array>} [readFile]
++ * @property {(path: string, data: string | Uint8Array) => Promise<{ path: string, bytes?: number }>} [writeFile]
++ */
++
++/**
++ * Memory is what a run may remember between steps and runs. It is scoped to the owner; a run never reads another
++ * owner's memory, and what it remembers says where it came from.
++ * @typedef {object} AgentMemory
++ * @property {(query: { text?: string, kinds?: string[], limit?: number }) => Promise<object[]>} read
++ * @property {(entry: { text: string, kind?: string, ref?: string, data?: object }) => Promise<object>} write
++ * @property {(id: string) => Promise<boolean>} [forget]
++ */
++
++/**
++ * The planner turns the person's words into a plan before the loop runs; a strong model plans once and small ones
++ * execute (the Dispatcher's "plan big, execute small").
++ * @typedef {object} AgentPlanner
++ * @property {(input: { run: object, text: string, context?: object, signal?: AbortSignal }) => Promise<{ steps: Array<{ title: string, kind?: string, tool?: string }>, text?: string }>} plan
++ */
++
++/**
++ * The reviewer checks a run: a model of another family, a test suite, a written bar. A run is never delivered as
++ * checked unless the reviewer passed it.
++ * @typedef {object} AgentReviewer
++ * @property {(input: { run: object, result: any, steps?: object[], signal?: AbortSignal }) => Promise<{ ok: boolean, reason?: string, by?: string, cross?: boolean }>} review
++ */
++
++module.exports = { createAgentsClient, createFrameAgentsClient, KINDS, STATUSES, END_STATUSES, isEnd, refOf, parseRef, needsPerson };
},
// ── 54: src/frame.js ────────────────────────────────────────────────
function (module, exports) {
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex b21c8ac..a2c9bee 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -16,7 +16,15 @@
},
"agents": {
"object": [
-- "createAgentsClient"
++ "END_STATUSES",
++ "KINDS",
++ "STATUSES",
++ "createAgentsClient",
++ "createFrameAgentsClient",
++ "isEnd",
++ "needsPerson",
++ "parseRef",
++ "refOf"
]
},
"ai": {
@@@ -600,7 +608,15 @@
"createAgentSheetClient": "function"
},
"openvibe-sdk/agents": {
-- "createAgentsClient": "function"
++ "END_STATUSES": "array",
++ "KINDS": "array",
++ "STATUSES": "array",
++ "createAgentsClient": "function",
++ "createFrameAgentsClient": "function",
++ "isEnd": "function",
++ "needsPerson": "function",
++ "parseRef": "function",
++ "refOf": "function"
},
"openvibe-sdk/ai": {
"CLASSES": "array",
ddiff --git a/esm/agents.mjs b/esm/agents.mjs
iindex 5e87c42..472b2b3 100644
---- a/esm/agents.mjs
++++ b/esm/agents.mjs
@@@ -2,6 +2,7 @@
import cjs from '../src/agents.js';
export const {
-- createAgentsClient,
++ createAgentsClient, createFrameAgentsClient, KINDS, STATUSES, END_STATUSES, isEnd,
++ refOf, parseRef, needsPerson,
} = cjs;
export default cjs;
ddiff --git a/package-lock.json b/package-lock.json
iindex 235201a..e83975f 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.158.0",
++ "version": "0.159.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.158.0",
++ "version": "0.159.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex 9a4d7f6..20f2cf1 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.158.0",
++ "version": "0.159.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
ddiff --git a/src/agents.js b/src/agents.js
iindex c3905db..646c1c3 100644
---- a/src/agents.js
++++ b/src/agents.js
@@@ -1,48 +1,151 @@
'use strict';
--/** Agent run snapshots, AG-UI event streams and approval responses. */
++/**
++ * openvibe-sdk/agents — the client for the agent engine (OpenVibe.Contracts docs/agents-runs.md).
++ *
++ * Everything a person asks an AI to do on OpenVibe is a run, and a run is a thread of steps: a chat with Vibe, a
++ * question in a composer, a task for an agent, a coding task, a job on a machine. One shape (`agents.run@1`,
++ * `agents.step@1`, `agents.approval@1`), one set of routes (the Run API), on every product that hosts runs and, for
++ * every product at once, on the Frame.
++ *
++ * const agents = createAgentsClient(client); // one product (Network by default)
++ * const all = createFrameAgentsClient(client); // every product, through the Frame
++ *
++ * const { runs, next } = await all.runs.list({ kind: 'chat', limit: 20 });
++ * const run = await all.runs.start('A goal bar for my sub goal'); // or the whole agents.run-start@1 object
++ * await all.runs.say(run, 'Make it purple'); // continue or steer it
++ * const { steps } = await all.runs.steps(run, { after: 10 });
++ * await all.runs.follow(run, { onStep, onDelta, onRun }); // the live stream, until it ends
++ * await all.approvals.answer(run, 'apr_…', 'approve');
++ * await all.runs.stop(run, { reason: 'not now' });
++ *
++ * The Run API, relative to the client's prefix (`/api/v1` on a product, `/api/frame` on the Frame, where a run's id
++ * is its ref, `<product>:<id>`):
++ * GET /runs?kind=&status=&q=&limit=&cursor= POST /runs (agents.run-start@1)
++ * GET /runs/:id GET /runs/:id/steps?after=&limit=
++ * GET /runs/:id/events POST /runs/:id/messages { text, attachments }
++ * POST /runs/:id/stop { reason } POST /runs/:id/approvals/:id { answer }
++ * GET /runs/:id/desks · POST …/desks/:desk/take-over · …/hand-back · GET /runs/:id/replay
++ *
++ * A product other than Network: { service: 'actor', audience: 'openvibe.actor' } (or { baseUrl }).
++ * The plugin interfaces of the engine's loop (model adapter, tool, sandbox, memory, planner, reviewer) are described
++ * at the end of this file; `openvibe-sdk/agents/loop` is where they are called.
++ */
const { OpenVibeError, isOpenVibeError } = require('./core/errors');
const { parseSSE } = require('./realtime');
++
const enc = encodeURIComponent;
const pause = (ms, signal) => new Promise((resolve) => {
const t = setTimeout(resolve, ms);
if (signal) signal.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true });
});
--function createAgentsClient(client, { baseUrl, service = 'network', audience = 'openvibe.network' } = {}) {
++
++/** The kinds a run may be; a product may add its own, and a reader treats an unknown kind as `task`. */
++const KINDS = Object.freeze(['chat', 'ask', 'task', 'code', 'machine', 'goal']);
++/** A run's statuses; the last three are ends, and an end never changes. */
++const STATUSES = Object.freeze(['planning', 'running', 'waiting', 'done', 'failed', 'stopped']);
++const END_STATUSES = Object.freeze(['done', 'failed', 'stopped']);
++const REF = /^([a-z][a-z0-9-]{0,30}):([A-Za-z0-9._~-]{1,160})$/;
++
++/** Has this run (or status) ended? */
++function isEnd(runOrStatus) {
++ const s = typeof runOrStatus === 'string' ? runOrStatus : runOrStatus && runOrStatus.status;
++ return END_STATUSES.includes(s);
++}
++/** A run's address on the Frame: `<product>:<id>`. */
++function refOf(product, id) {
++ const ref = `${String(product || '')}:${String(id || '')}`;
++ if (!REF.test(ref)) throw new TypeError('agents.refOf: a product (a-z, 0-9, -) and an id are required');
++ return ref;
++}
++/** `{ product, id }` from a ref, or null when it is not one. */
++function parseRef(ref) {
++ const m = REF.exec(String(ref || ''));
++ return m ? { product: m[1], id: m[2] } : null;
++}
++/** Does this run need its person now (an approval, a takeover, a payment), as opposed to a conversation between turns? */
++function needsPerson(run) {
++ return !!run && run.status === 'waiting' && !!run.waiting && run.waiting.for !== 'message';
++}
++
++const runIdOf = (run) => {
++ if (typeof run === 'string' && run) return run;
++ if (run && typeof run === 'object') return typeof run.ref === 'string' && run.ref ? run.ref : (typeof run.id === 'string' ? run.id : null);
++ return null;
++};
++
++function createAgentsClient(client, { baseUrl, service = 'network', audience = 'openvibe.network', prefix = '/api/v1' } = {}) {
++ if (typeof prefix !== 'string' || !/^\/[a-z0-9/_-]+$/i.test(prefix)) throw new TypeError('agents: prefix is a path such as /api/v1');
++ // On the Frame a run is named by its ref; on a product by its own id.
++ const byRef = prefix === '/api/frame';
const call = (opts) => client.request({ service, baseUrl, audience, ...opts });
-- const runId = (run) => {
-- const id = typeof run === 'string' ? run : run && run.id;
-- if (!id) throw new TypeError('agents: a run (or its id) is required');
-- return enc(id);
++ const at = (what, run) => {
++ const id = byRef ? runIdOf(run) : (typeof run === 'string' ? run : run && run.id);
++ if (!id) throw new TypeError(`${what}: a run (or its id) is required`);
++ return `${prefix}/runs/${enc(id)}`;
};
const deskId = (desk) => {
if (typeof desk !== 'string' || !desk) throw new TypeError('agents: a desk id is required');
return enc(desk);
};
-- async function run(id, { signal } = {}) {
-- if (!id) throw new TypeError('agents.run: id is required');
-- try { return (await call({ path: `/api/v1/runs/${enc(id)}`, signal })).data; }
++
++ // ── runs ──
++ async function list({ kind, status, q, limit, cursor, before, agent, parentRun, signal } = {}) {
++ return (await call({ path: `${prefix}/runs`, query: { kind, status, q, limit, cursor, before, agent, parent_run: parentRun }, signal })).data;
++ }
++ async function get(run, { signal } = {}) {
++ try { return (await call({ path: at('agents.runs.get', run), headers: { Accept: 'application/vnd.openvibe.agents.run+json, application/json' }, signal })).data; }
catch (err) { if (isOpenVibeError(err) && err.status === 404) return null; throw err; }
}
-- async function post(id, approval, { signal } = {}) {
-- if (!id) throw new TypeError('agents.post: id is required');
-- if (!approval || typeof approval !== 'object' || Array.isArray(approval)) throw new TypeError('agents.post: approval must be an object');
-- return (await call({ method: 'POST', path: `/api/v1/runs/${enc(id)}/approvals`, json: approval, signal })).data;
++ async function steps(run, { after, limit, signal } = {}) {
++ if (after != null && !Number.isInteger(Number(after))) throw new TypeError('agents.runs.steps: after is a step seq');
++ return (await call({ path: `${at('agents.runs.steps', run)}/steps`, query: { after, limit }, signal })).data;
++ }
++ /** Start a run. `request` is the words, or the whole agents.run-start@1 object; only `text` is required. */
++ async function start(request, { signal, idempotencyKey } = {}) {
++ const body = typeof request === 'string' ? { text: request } : request;
++ if (!body || typeof body !== 'object' || Array.isArray(body) || typeof body.text !== 'string' || !body.text.trim()) {
++ throw new TypeError('agents.runs.start: the words to start with are required ({ text })');
++ }
++ return (await call({ method: 'POST', path: `${prefix}/runs`, json: body, ...(idempotencyKey ? { idempotencyKey } : {}), signal })).data;
}
-- async function* events(id, { lastEventId = 0, signal, maxReconnects = 20, reconnectDelayMs = 1000 } = {}) {
-- if (!id) throw new TypeError('agents.events: id is required');
-- let last = String(lastEventId || '0'), failures = 0, serverRetryMs = 0;
++ /** Say something to a run: continue a conversation, answer a question in words, or steer work under way. */
++ async function say(run, message, { signal, idempotencyKey } = {}) {
++ const body = typeof message === 'string' ? { text: message } : message;
++ if (!body || typeof body !== 'object' || Array.isArray(body) || ((typeof body.text !== 'string' || !body.text.trim()) && !(Array.isArray(body.attachments) && body.attachments.length))) {
++ throw new TypeError('agents.runs.say: words or attachments are required');
++ }
++ return (await call({ method: 'POST', path: `${at('agents.runs.say', run)}/messages`, json: body, ...(idempotencyKey ? { idempotencyKey } : {}), signal })).data;
++ }
++ async function stop(run, { reason, signal } = {}) {
++ return (await call({ method: 'POST', path: `${at('agents.runs.stop', run)}/stop`, ...(reason !== undefined ? { json: { reason } } : {}), signal })).data;
++ }
++ /**
++ * A run's stream, as an async iterator of `{ id, event, data }`. It reconnects with Last-Event-ID (a step's seq on
++ * the engine's stream) and ends at the stream's `end` event (or AG-UI's RUN_FINISHED / RUN_ERROR on an Ask run).
++ */
++ async function* events(run, { after, lastEventId = 0, signal, maxReconnects = 20, reconnectDelayMs = 1000 } = {}) {
++ const path = `${at('agents.events', run)}/events`;
++ let last = String(after != null ? after : (lastEventId || '0')), failures = 0, serverRetryMs = 0;
while (!(signal && signal.aborted)) {
let delivered = false;
try {
-- const res = await call({ path: `/api/v1/runs/${enc(id)}/events`, responseType: 'response', retries: 0, signal,
++ const res = await call({ path, responseType: 'response', retries: 0, signal,
headers: { Accept: 'text/event-stream', 'Cache-Control': 'no-cache', 'Last-Event-ID': last } });
if (res.status === 204) return;
for await (const msg of parseSSE(res.data.body, { onRetry: (ms) => { serverRetryMs = ms; } })) {
-- if (msg.id !== undefined && msg.id !== '') { if (/^\d+$/.test(msg.id) && Number(msg.id) <= Number(last)) continue; last = msg.id; }
++ // Only a step advances the cursor and is skipped when already seen: a run snapshot, more text for a
++ // step, an approval and the end carry the newest step's seq as their id.
++ const soft = msg.event === 'run' || msg.event === 'delta' || msg.event === 'approval' || msg.event === 'end';
++ if (msg.id !== undefined && msg.id !== '') {
++ const numeric = /^\d+$/.test(msg.id);
++ if (!soft && numeric && Number(msg.id) <= Number(last)) continue;
++ if (!numeric || Number(msg.id) > Number(last)) last = msg.id;
++ }
let data; try { data = JSON.parse(msg.data); } catch { data = null; }
delivered = true; failures = 0;
-- yield { id: msg.id ?? null, event: msg.event || (data && data.type) || null, data };
-- if (data && ['RUN_FINISHED', 'RUN_ERROR'].includes(data.type)) return;
++ const event = msg.event || (data && data.type) || null;
++ yield { id: msg.id ?? null, event, data };
++ if (event === 'end' || (data && ['RUN_FINISHED', 'RUN_ERROR'].includes(data.type))) return;
}
} catch (err) {
if (signal && signal.aborted) return;
@@@ -50,31 +153,131 @@ function createAgentsClient(client, { baseUrl, service = 'network', audience = '
if (!isOpenVibeError(err) && !['TypeError', 'AbortError'].includes(err && err.name)) throw err;
}
failures = delivered ? 0 : failures + 1;
-- if (failures > maxReconnects) throw new OpenVibeError({ code: 'sdk.network_error', retryable: true, message: `agents: event stream ${id} failed ${failures} times in a row` });
++ if (failures > maxReconnects) throw new OpenVibeError({ code: 'sdk.network_error', retryable: true, message: `agents: the stream of ${runIdOf(run)} failed ${failures} times in a row` });
const backoffMs = Math.min(reconnectDelayMs * (2 ** Math.max(0, failures - 1)), 30000);
-- const waitMs = Math.min(Math.max(backoffMs, serverRetryMs), 30000);
-- await pause(waitMs, signal);
++ await pause(Math.min(Math.max(backoffMs, serverRetryMs), 30000), signal);
}
}
/**
-- * See it work (W1): the desks a run may show, the person's control of one, and the finished run's replay. The
-- * shapes match Actor's own until these routes are released here: desks → [{ desk, view, still }], replay → the
-- * steps with their stills. A run is an id or an object with an id.
++ * Follow a run until it ends: `onRun(run)` for a new snapshot, `onStep(step)` for an appended step,
++ * `onDelta({ seq, text })` for more words of a step being written, `onApproval(approval)`, `onEvent(event)` for
++ * everything. Resolves with `{ status }` (the end status, or null when the stream closed without one).
*/
++ async function follow(run, { onRun, onStep, onDelta, onApproval, onEvent, ...options } = {}) {
++ for await (const e of events(run, options)) {
++ if (onEvent) await onEvent(e);
++ const d = e.data || {};
++ if (e.event === 'run' && d.run && onRun) await onRun(d.run);
++ else if (e.event === 'step' && d.step && onStep) await onStep(d.step);
++ else if (e.event === 'delta' && onDelta) await onDelta({ run_id: d.run_id, seq: d.seq, text: d.text });
++ else if (e.event === 'approval' && d.approval && onApproval) await onApproval(d.approval);
++ else if (e.event === 'end') return { status: d.status || null };
++ else if (d.type === 'RUN_FINISHED') return { status: 'done' };
++ else if (d.type === 'RUN_ERROR') return { status: 'failed' };
++ }
++ return { status: null };
++ }
++
++ // ── approvals ──
++ /** Answer an approval: by its id (agents.approval@1's `id`), or by the seq of the step that asked. */
++ async function answer(run, approval, reply, { signal } = {}) {
++ const id = typeof approval === 'number' ? String(approval) : (approval && typeof approval === 'object' ? (approval.id || String(approval.step_seq || '')) : String(approval || ''));
++ if (!id || !/^[A-Za-z0-9._~-]{1,160}$/.test(id)) throw new TypeError('agents.approvals.answer: an approval (its id or its step seq) is required');
++ if (reply === undefined || reply === null || reply === '') throw new TypeError('agents.approvals.answer: an answer is required');
++ return (await call({ method: 'POST', path: `${at('agents.approvals.answer', run)}/approvals/${enc(id)}`, json: { answer: reply }, signal })).data;
++ }
++
++ // ── see it work (0.157.0): the desks a run may show, the person's control of one, the finished run's replay ──
async function desks(run, { signal } = {}) {
-- return (await call({ path: `/api/v1/runs/${runId(run)}/desks`, signal })).data;
++ return (await call({ path: `${at('agents.desks', run)}/desks`, signal })).data;
}
async function takeOver(run, desk, { signal } = {}) {
-- return (await call({ method: 'POST', path: `/api/v1/runs/${runId(run)}/desks/${deskId(desk)}/take-over`, signal })).data;
++ return (await call({ method: 'POST', path: `${at('agents.takeOver', run)}/desks/${deskId(desk)}/take-over`, signal })).data;
}
async function handBack(run, desk, note = '', { signal } = {}) {
if (note != null && typeof note !== 'string') throw new TypeError('agents.handBack: note is a string');
-- return (await call({ method: 'POST', path: `/api/v1/runs/${runId(run)}/desks/${deskId(desk)}/hand-back`, json: { note: String(note || '').slice(0, 1000) }, signal })).data;
++ return (await call({ method: 'POST', path: `${at('agents.handBack', run)}/desks/${deskId(desk)}/hand-back`, json: { note: String(note || '').slice(0, 1000) }, signal })).data;
}
async function replay(run, { signal } = {}) {
-- return (await call({ path: `/api/v1/runs/${runId(run)}/replay`, signal })).data;
++ return (await call({ path: `${at('agents.replay', run)}/replay`, signal })).data;
}
-- return { run, events, post, desks, takeOver, handBack, replay };
++ // ── the first shapes (an Ask run's snapshot and approval post on Network), kept as they were released ──
++ async function run(id, { signal } = {}) {
++ if (!id) throw new TypeError('agents.run: id is required');
++ try { return (await call({ path: `${prefix}/runs/${enc(id)}`, signal })).data; }
++ catch (err) { if (isOpenVibeError(err) && err.status === 404) return null; throw err; }
++ }
++ async function post(id, approval, { signal } = {}) {
++ if (!id) throw new TypeError('agents.post: id is required');
++ if (!approval || typeof approval !== 'object' || Array.isArray(approval)) throw new TypeError('agents.post: approval must be an object');
++ return (await call({ method: 'POST', path: `${prefix}/runs/${enc(id)}/approvals`, json: approval, signal })).data;
++ }
++
++ return {
++ runs: { list, get, steps, start, say, stop, events, follow },
++ approvals: { answer },
++ desks, takeOver, handBack, replay,
++ run, post, events,
++ };
++}
++
++/** Every product's runs through the Frame (`/api/frame/runs…` on Network): a run is named by its ref. */
++function createFrameAgentsClient(client, options = {}) {
++ return createAgentsClient(client, { ...options, service: 'network', audience: 'openvibe.network', prefix: '/api/frame' });
}
--module.exports = { createAgentsClient };
++
++/**
++ * A model adapter is the only way the loop reaches a model: Exchange's routes, a person's own key, a machine's local
++ * model or a signed-in plan. It returns the model's next turn and what it cost.
++ * @typedef {object} AgentModelAdapter
++ * @property {string} id A stable id for the model (the picker's row id, e.g. "openvibe:deepseek-v4").
++ * @property {string} [family] The model family, for a reviewer of another family.
++ * @property {(request: { messages: object[], tools?: AgentTool[], model?: string, budget?: object, signal?: AbortSignal }) => Promise<{ text?: string, tool_calls?: object[], usage?: { tokens?: number }, cost?: { usd?: number }, raw?: object }>} complete
++ */
++
++/**
++ * A tool the loop may call. OpenVibe products are tools (Search, Media, Live, Bot, a machine through Link); an MCP
++ * server is a bag of them. The registry decides which a run may use; a tool never sees a credential it does not need.
++ * @typedef {object} AgentTool
++ * @property {string} name
++ * @property {string} description
++ * @property {object} [inputSchema] JSON Schema for `input`.
++ * @property {(input: object, context: { run: object, toolCall: object, signal?: AbortSignal }) => Promise<{ result: any, cost?: { usd?: number }, artifacts?: object[] }>} run
++ */
++
++/**
++ * A sandbox is where code runs: a Codes runner, a machine through Link or an OpenVibe computer. The loop asks; the
++ * sandbox owns isolation, egress and the redaction of what it returns.
++ * @typedef {object} AgentSandbox
++ * @property {string} id
++ * @property {string} [where] agents.run@1's `where.kind`: "openvibe" | "machine" | "runner" | "this-computer"
++ * @property {(command: string | string[], options?: { cwd?: string, env?: object, timeoutMs?: number, signal?: AbortSignal }) => Promise<{ ok: boolean, output: string, exitCode?: number, artifacts?: object[] }>} exec
++ * @property {(path: string, options?: { encoding?: string }) => Promise<string | Uint8Array>} [readFile]
++ * @property {(path: string, data: string | Uint8Array) => Promise<{ path: string, bytes?: number }>} [writeFile]
++ */
++
++/**
++ * Memory is what a run may remember between steps and runs. It is scoped to the owner; a run never reads another
++ * owner's memory, and what it remembers says where it came from.
++ * @typedef {object} AgentMemory
++ * @property {(query: { text?: string, kinds?: string[], limit?: number }) => Promise<object[]>} read
++ * @property {(entry: { text: string, kind?: string, ref?: string, data?: object }) => Promise<object>} write
++ * @property {(id: string) => Promise<boolean>} [forget]
++ */
++
++/**
++ * The planner turns the person's words into a plan before the loop runs; a strong model plans once and small ones
++ * execute (the Dispatcher's "plan big, execute small").
++ * @typedef {object} AgentPlanner
++ * @property {(input: { run: object, text: string, context?: object, signal?: AbortSignal }) => Promise<{ steps: Array<{ title: string, kind?: string, tool?: string }>, text?: string }>} plan
++ */
++
++/**
++ * The reviewer checks a run: a model of another family, a test suite, a written bar. A run is never delivered as
++ * checked unless the reviewer passed it.
++ * @typedef {object} AgentReviewer
++ * @property {(input: { run: object, result: any, steps?: object[], signal?: AbortSignal }) => Promise<{ ok: boolean, reason?: string, by?: string, cross?: boolean }>} review
++ */
++
++module.exports = { createAgentsClient, createFrameAgentsClient, KINDS, STATUSES, END_STATUSES, isEnd, refOf, parseRef, needsPerson };
ddiff --git a/src/core/index.js b/src/core/index.js
iindex ebd9fc0..ddaf7d6 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.158.0';
++const SDK_VERSION = '0.159.0';
module.exports = {
createClient,
ddiff --git a/test/agents.test.js b/test/agents.test.js
iindex 46ef361..a0949b9 100644
---- a/test/agents.test.js
++++ b/test/agents.test.js
@@@ -2,7 +2,7 @@
const assert = require('node:assert/strict');
const { run, stubServer, send } = require('./helpers');
const { createClient } = require('../src/core');
--const { createAgentsClient } = require('../src/agents');
++const { createAgentsClient, createFrameAgentsClient, refOf, parseRef, isEnd, needsPerson, KINDS, STATUSES } = require('../src/agents');
run([
['run snapshot, post approval, and resumable AG-UI SSE', async () => {
let calls = 0; const srv = await stubServer((req,res) => {
@@@ -48,4 +48,96 @@ run([
try { const agents=createAgentsClient(createClient({baseUrls:{network:srv.url},token:'t'})); const start=Date.now(); const got=[]; for await(const e of agents.events('run_a',{reconnectDelayMs:10,maxReconnects:5})) got.push(e.id); assert.deepEqual(got,['1']); assert.ok(Date.now()-start>=65,`backoff elapsed ${Date.now()-start}ms`); assert.equal(calls,4); }
finally { await srv.close(); }
}],
++ ['runs: list, get, steps, start, say, stop, and an approval answer', async () => {
++ const run = { id:'run_a', owner:{type:'user',id:'usr_a'}, agent:{id:'actor',product:'actor'}, status:'running', created_at:'2026-11-11T00:00:00Z', updated_at:'2026-11-11T00:00:10Z' };
++ const step = { run_id:'run_a', seq:11, kind:'tool', text:'Ran the tests', started_at:'2026-11-11T00:00:10Z', ended_at:'2026-11-11T00:00:10Z' };
++ const srv = await stubServer((req,res) => {
++ const u = new URL(req.url,'http://x');
++ if (u.pathname === '/api/v1/runs' && req.method === 'GET') return send(res,200,{runs:[run],next:'run_z'});
++ if (u.pathname === '/api/v1/runs' && req.method === 'POST') return send(res,201,{...run,id:'run_new'});
++ if (u.pathname === '/api/v1/runs/run_a/steps') return send(res,200,{steps:[step],next:11});
++ if (u.pathname === '/api/v1/runs/run_a/stop') return send(res,200,{...run,status:'stopped'});
++ if (u.pathname === '/api/v1/runs/run_a/approvals/7' || u.pathname === '/api/v1/runs/run_a/approvals/apr_1') return send(res,200,{run_id:'run_a',step_seq:7,what:'push',options:[{id:'approve',label:'Approve'}],answer:'approve'});
++ if (u.pathname === '/api/v1/runs/run_a/messages') return send(res,201,{run_id:'run_a',seq:12,kind:'message',from:'person',text:'make it purple',started_at:'2026-11-11T00:00:11Z'});
++ if (u.pathname === '/api/v1/runs/run_a') return send(res,200,run);
++ return send(res,404,{code:'agents.run.not_found'});
++ });
++ try {
++ const agents = createAgentsClient(createClient({baseUrls:{network:srv.url},token:'t'}));
++ assert.deepEqual(await agents.runs.list({limit:2,parentRun:'run_p'}), {runs:[run],next:'run_z'});
++ assert.match(srv.requests.at(-1).url, /limit=2/); assert.match(srv.requests.at(-1).url, /parent_run=run_p/);
++ assert.deepEqual(await agents.runs.get('run_a'), run);
++ assert.equal(await agents.runs.get('missing'), null);
++ assert.deepEqual(await agents.runs.steps('run_a',{after:10,limit:5}), {steps:[step],next:11});
++ assert.match(srv.requests.at(-1).url, /after=10/);
++ // Start: the words alone, or the whole agents.run-start@1 object (Contracts 0.414.0).
++ assert.equal((await agents.runs.start('find it')).id,'run_new');
++ assert.deepEqual(JSON.parse(srv.requests.at(-1).body.toString()), {text:'find it'});
++ assert.equal((await agents.runs.start({text:'a goal bar',kind:'code',target:'openvibe',budget:{usd:1}},{idempotencyKey:'k-1'})).id,'run_new');
++ assert.deepEqual(JSON.parse(srv.requests.at(-1).body.toString()), {text:'a goal bar',kind:'code',target:'openvibe',budget:{usd:1}});
++ assert.equal(srv.requests.at(-1).headers['idempotency-key'], 'k-1');
++ await assert.rejects(agents.runs.start({kind:'chat'}), /words to start with/);
++ await assert.rejects(agents.runs.start(' '), /words to start with/);
++ // Say: continue or steer a run.
++ assert.equal((await agents.runs.say('run_a','make it purple')).seq, 12);
++ assert.deepEqual(JSON.parse(srv.requests.at(-1).body.toString()), {text:'make it purple'});
++ assert.equal((await agents.runs.say({id:'run_a'},{attachments:[{kind:'image',media_id:'med_1'}]})).seq, 12);
++ await assert.rejects(agents.runs.say('run_a',''), /words or attachments/);
++ assert.equal((await agents.runs.stop('run_a',{reason:'not now'})).status,'stopped');
++ assert.deepEqual(JSON.parse(srv.requests.at(-1).body.toString()), {reason:'not now'});
++ assert.equal((await agents.approvals.answer({id:'run_a'},7,'approve')).answer,'approve');
++ assert.deepEqual(JSON.parse(srv.requests.at(-1).body.toString()), {answer:'approve'});
++ // By the approval's own id, or the approval object.
++ assert.equal((await agents.approvals.answer('run_a','apr_1','approve')).answer,'approve');
++ assert.match(srv.requests.at(-1).url, /\/approvals\/apr_1$/);
++ assert.equal((await agents.approvals.answer('run_a',{id:'apr_1'},'approve')).answer,'approve');
++ await assert.rejects(agents.approvals.answer('run_a','','approve'), /an approval/);
++ await assert.rejects(agents.approvals.answer('run_a','apr_1'), /an answer/);
++ } finally { await srv.close(); }
++ }],
++ ['follow: the engine\'s stream calls a handler per event and ends at end', async () => {
++ const run = { id:'c_1', ref:'chat:c_1', kind:'chat', owner:{type:'user',id:'usr_a'}, agent:{id:'vibe',product:'chat'}, status:'running', created_at:'2026-11-12T00:00:00Z', updated_at:'2026-11-12T00:00:01Z' };
++ const step = { run_id:'c_1', seq:4, kind:'message', from:'agent', status:'running', text:'Three', started_at:'2026-11-12T00:00:01Z' };
++ const srv = await stubServer((req,res) => {
++ assert.equal(req.url, '/api/v1/runs/c_1/events'); assert.equal(req.headers['last-event-id'], '3');
++ res.writeHead(200,{'Content-Type':'text/event-stream'});
++ res.write(`id: 3\nevent: run\ndata: ${JSON.stringify({type:'run',run})}\n\n`);
++ res.write(`id: 4\nevent: step\ndata: ${JSON.stringify({type:'step',step})}\n\n`);
++ res.write(`id: 4\nevent: delta\ndata: ${JSON.stringify({type:'delta',run_id:'c_1',seq:4,text:' picks'})}\n\n`);
++ res.write(`id: 4\nevent: delta\ndata: ${JSON.stringify({type:'delta',run_id:'c_1',seq:4,text:' under $90'})}\n\n`);
++ res.end(`id: 5\nevent: end\ndata: ${JSON.stringify({type:'end',run_id:'c_1',status:'done'})}\n\n`);
++ });
++ try {
++ const agents = createAgentsClient(createClient({baseUrls:{network:srv.url},token:'t'}));
++ const seen = { runs:[], steps:[], text:'', events:0 };
++ const end = await agents.runs.follow('c_1', { after: 3, reconnectDelayMs: 1,
++ onRun: (r) => seen.runs.push(r.status), onStep: (st) => { seen.steps.push(st.seq); seen.text = st.text; }, onDelta: (d) => { seen.text += d.text; }, onEvent: () => { seen.events++; } });
++ assert.deepEqual(end, { status: 'done' });
++ assert.deepEqual(seen, { runs: ['running'], steps: [4], text: 'Three picks under $90', events: 5 }, 'a delta with its step\'s id is delivered, not skipped as already seen');
++ } finally { await srv.close(); }
++ }],
++ ['the Frame client names a run by its ref', async () => {
++ const seen = [];
++ const srv = await stubServer((req,res) => { seen.push(`${req.method} ${req.url}`); return send(res, 200, req.url.includes('/steps') ? { steps: [], next: null } : { id: 'tsk_1', ref: 'codes:tsk_1', status: 'running' }); });
++ try {
++ const all = createFrameAgentsClient(createClient({baseUrls:{network:srv.url},token:'t'}));
++ await all.runs.list({ kind: 'code', limit: 5 });
++ await all.runs.get({ id: 'tsk_1', ref: 'codes:tsk_1' });
++ await all.runs.steps('codes:tsk_1', { after: 2 });
++ await all.runs.say('codes:tsk_1', 'also add a test');
++ await all.runs.stop({ id: 'tsk_1', ref: 'codes:tsk_1' });
++ assert.deepEqual(seen, ['GET /api/frame/runs?kind=code&limit=5', 'GET /api/frame/runs/codes%3Atsk_1', 'GET /api/frame/runs/codes%3Atsk_1/steps?after=2', 'POST /api/frame/runs/codes%3Atsk_1/messages', 'POST /api/frame/runs/codes%3Atsk_1/stop']);
++ assert.throws(() => createAgentsClient(createClient({baseUrls:{network:srv.url},token:'t'}), { prefix: 'runs' }), /prefix/);
++ } finally { await srv.close(); }
++ }],
++ ['shape helpers: refs, ends and who a run waits on', async () => {
++ assert.equal(refOf('chat', 'c_8f3k2m'), 'chat:c_8f3k2m');
++ assert.deepEqual(parseRef('codes:tsk_01K9R2V6M8Q4T0B7C3D5E9F2GH'), { product: 'codes', id: 'tsk_01K9R2V6M8Q4T0B7C3D5E9F2GH' });
++ assert.equal(parseRef('tsk_1'), null); assert.throws(() => refOf('Chat', 'x'), /product/); assert.throws(() => refOf('chat', ''), /product/);
++ assert.equal(isEnd('done'), true); assert.equal(isEnd({ status: 'waiting' }), false); assert.equal(isEnd(null), false);
++ assert.equal(needsPerson({ status: 'waiting', waiting: { for: 'message' } }), false, 'a conversation between turns does not need its person');
++ assert.equal(needsPerson({ status: 'waiting', waiting: { for: 'approval', approval: 'apr_1' } }), true);
++ assert.equal(needsPerson({ status: 'running' }), false);
++ assert.ok(KINDS.includes('chat') && KINDS.includes('code') && STATUSES.length === 6);
++ }],
]);
ddiff --git a/types/agents.d.ts b/types/agents.d.ts
iindex cf3bc3c..200f7bb 100644
---- a/types/agents.d.ts
++++ b/types/agents.d.ts
@@@ -1,8 +1,246 @@
--/** AG-UI 1.0 event stream and agent.run@1 snapshot. */
++/** One agent system: agents.run@1, agents.step@1, agents.approval@1, the AG-UI stream and the plugin interfaces. */
++
++// ── The run shape (openvibe-contracts agents.run@1) ─────────────────────────
++export type AgentRunStatus = 'planning' | 'running' | 'waiting' | 'done' | 'failed' | 'stopped';
++export type AgentsStepKind = 'think' | 'tool' | 'message' | 'approval' | 'child' | 'file';
++export interface AgentsOwner { type: string; id: string; revision?: number }
++/** The agent that runs it, with its face: a glyph from the registry, a hue, a picture, and the loop (harness) that runs it. */
++export interface AgentsAgentRef { id: string; name?: string; product?: string; glyph?: string; hue?: number; avatar_url?: string; harness?: string }
++export type AgentsRunKind = 'chat' | 'ask' | 'task' | 'code' | 'machine' | 'goal' | (string & {});
++export interface AgentsModel { id: string; source?: 'openvibe' | 'keys' | 'machines' | 'plans'; machine?: string; name?: string; effort?: string }
++export interface AgentsWhere { kind: 'openvibe' | 'machine' | 'runner' | 'this-computer'; id?: string; name?: string }
++export type AgentsTarget = 'new' | 'openvibe' | { repo: string; branch?: string };
++export interface AgentsContext { site?: string; url?: string; title?: string; kind?: string; id?: string }
++export interface AgentsAttachment { kind: 'image' | 'file' | 'audio' | 'video'; media_id?: string; url?: string; name?: string; mime?: string; bytes?: number; width?: number; height?: number; alt?: string }
++export interface AgentsOutput { kind: 'link' | 'file' | 'image' | 'video' | 'audio' | 'pr' | 'repo' | 'page' | 'post' | 'run'; title: string; url?: string; thumb?: string; media_id?: string; state?: string }
++export interface AgentsBudget { usd?: number; tokens?: number; seconds?: number; steps?: number }
++export interface AgentsCost { usd?: number; tokens?: number; seconds?: number; steps?: number }
++export interface AgentsRun {
++ id: string;
++ owner: AgentsOwner;
++ agent: AgentsAgentRef;
++ parent_run?: string | null;
++ budget?: AgentsBudget;
++ status: AgentRunStatus;
++ created_at: string;
++ updated_at: string;
++ result_summary?: string | null;
++ cost?: AgentsCost | null;
++ // 1.1.0 (Contracts 0.414.0), all optional:
++ title?: string;
++ kind?: AgentsRunKind;
++ /** Its address on the Frame: `<product>:<id>`. */
++ ref?: string;
++ url?: string;
++ model?: AgentsModel;
++ where?: AgentsWhere;
++ target?: AgentsTarget;
++ doing?: { text: string; glyph?: string } | null;
++ /** Why a waiting run waits; `message` is a conversation between turns. */
++ waiting?: { for: 'message' | 'approval' | 'takeover' | 'payment'; what?: string; approval?: string; since?: string } | null;
++ preview?: { text: string; from?: 'person' | 'agent' | 'system' | 'tool'; at?: string } | null;
++ context?: AgentsContext;
++ outputs?: AgentsOutput[];
++ counts?: { steps?: number; children?: number; files?: number; approvals?: number; unread?: number };
++ visibility?: 'private' | 'shared' | 'public';
++ ended_at?: string | null;
++}
++export interface AgentsRunList { runs: AgentsRun[]; next?: string | null }
++
++// ── The step shape (agents.step@1) ─────────────────────────────────────────
++export interface AgentsToolCall { name: string; input?: unknown }
++export interface AgentsStep {
++ run_id: string;
++ seq: number;
++ kind: AgentsStepKind;
++ text?: string | null;
++ tool_call?: AgentsToolCall | null;
++ result?: unknown;
++ cost?: { usd?: number; tokens?: number; seconds?: number } | null;
++ started_at: string;
++ ended_at?: string | null;
++ child_run?: string | null;
++ // 1.1.0 (Contracts 0.414.0), all optional:
++ from?: 'person' | 'agent' | 'system' | 'tool';
++ author?: { id?: string; name: string; avatar_url?: string };
++ status?: 'running' | 'done' | 'failed';
++ title?: string;
++ glyph?: string;
++ attachments?: AgentsAttachment[];
++ sources?: Array<{ n: number; title?: string; url: string; site?: string }>;
++ /** A rich card; a reader that does not know the type shows `text`. */
++ ui?: { type: string; data?: Record<string, unknown> };
++ desk?: { id: string; still_url?: string; action?: { type: string; x?: number; y?: number; text?: string } };
++}
++export interface AgentsStepPage { steps: AgentsStep[]; next?: number | null }
++
++// ── The approval shape (agents.approval@1) ─────────────────────────────────
++export interface AgentsApprovalOption { id: string; label: string }
++export interface AgentsApproval {
++ id?: string;
++ run_id: string;
++ step_seq: number;
++ what: string;
++ options: AgentsApprovalOption[];
++ decided_by?: AgentsOwner | null;
++ decided_at?: string | null;
++ answer?: string | null;
++}
++
++// ── The legacy AG-UI run (Network's composer Ask) ──────────────────────────
export interface AgentSource { url: string; title: string; site: string }
--export interface AgentUiEvent { type: string; timestamp: string; threadId?: string; runId?: string; metadata?: { ov?: { title?: string; kind?: string; sources?: AgentSource[]; attachments?: unknown[]; cost_usd?: number | null; needs_you?: object | null; product?: string; ref?: string | null } }; [field: string]: unknown }
--export interface AgentRun { id: string; product: string; title: string; status: 'queued'|'running'|'waiting'|'needs_you'|'succeeded'|'failed'|'stopped'|'cancelled'; plan: Array<{title:string;status:string}>; steps: Array<Record<string, unknown>>; sources: AgentSource[]; result: Record<string, unknown>|null; started_at: string; ended_at: string|null; cost_usd: number|null }
++export interface AgentUiEvent {
++ type: string;
++ timestamp: string;
++ threadId?: string;
++ runId?: string;
++ metadata?: { ov?: { title?: string; kind?: string; sources?: AgentSource[]; attachments?: unknown[]; cost_usd?: number | null; needs_you?: object | null; product?: string; ref?: string | null } };
++ [field: string]: unknown;
++}
++/** Network's AG-UI ask run (agent.run@1, the composer's Ask). Different from AgentsRun. */
++export interface AgentRun {
++ id: string;
++ product: string;
++ title: string;
++ status: 'queued' | 'running' | 'waiting' | 'needs_you' | 'succeeded' | 'failed' | 'stopped' | 'cancelled';
++ plan: Array<{ title: string; status: string }>;
++ steps: Array<Record<string, unknown>>;
++ sources: AgentSource[];
++ result: Record<string, unknown> | null;
++ started_at: string;
++ ended_at: string | null;
++ cost_usd: number | null;
++}
export interface AgentApproval { id?: string; action?: string; [field: string]: unknown }
--export interface AgentEvent { id: string|null; event: string|null; data: AgentUiEvent|null }
--export interface AgentsClient { run(id: string, options?: {signal?: AbortSignal}): Promise<AgentRun|null>; events(id: string, options?: {lastEventId?: string|number; signal?: AbortSignal; maxReconnects?: number; reconnectDelayMs?: number}): AsyncIterable<AgentEvent>; post(id: string, approval: AgentApproval, options?: {signal?: AbortSignal}): Promise<unknown> }
--export function createAgentsClient(client: unknown, options?: {baseUrl?: string; service?: string; audience?: string}): AgentsClient;
++export interface AgentEvent { id: string | null; event: string | null; data: AgentUiEvent | null }
++
++// ── The client ──────────────────────────────────────────────────────────────
++/** agents.run-start@1: only `text` is required. */
++export interface AgentsRunStart {
++ text: string;
++ title?: string;
++ kind?: AgentsRunKind;
++ agent?: string;
++ model?: AgentsModel;
++ where?: AgentsWhere;
++ target?: AgentsTarget;
++ context?: AgentsContext;
++ attachments?: AgentsAttachment[];
++ sources?: Array<'web' | 'openvibe' | 'mine' | 'page'>;
++ depth?: 'quick' | 'balanced' | 'deep';
++ parent_run?: string;
++ budget?: AgentsBudget;
++}
++/** agents.event@1: one event of a run's stream. */
++export interface AgentsStreamEvent {
++ type: 'run' | 'step' | 'delta' | 'approval' | 'end';
++ run?: AgentsRun; step?: AgentsStep; approval?: AgentsApproval;
++ run_id?: string; seq?: number; text?: string; status?: 'done' | 'failed' | 'stopped';
++}
++export type AgentsRunLike = string | Pick<AgentsRun, 'id'> & { ref?: string };
++export interface AgentsListOptions { kind?: AgentsRunKind; status?: AgentRunStatus; q?: string; limit?: number; cursor?: string; before?: string; agent?: string; parentRun?: string; signal?: AbortSignal }
++export interface AgentsStepsOptions { after?: number; limit?: number; signal?: AbortSignal }
++export interface AgentsEventsOptions { after?: number; lastEventId?: string | number; signal?: AbortSignal; maxReconnects?: number; reconnectDelayMs?: number }
++export interface AgentsFollowHandlers extends AgentsEventsOptions {
++ onRun?(run: AgentsRun): unknown;
++ onStep?(step: AgentsStep): unknown;
++ onDelta?(delta: { run_id?: string; seq?: number; text?: string }): unknown;
++ onApproval?(approval: AgentsApproval): unknown;
++ onEvent?(event: AgentEvent): unknown;
++}
++
++export interface AgentsRuns {
++ list(options?: AgentsListOptions): Promise<AgentsRunList>;
++ /** One run; null on 404. */
++ get(run: AgentsRunLike, options?: { signal?: AbortSignal }): Promise<AgentsRun | null>;
++ steps(run: AgentsRunLike, options?: AgentsStepsOptions): Promise<AgentsStepPage>;
++ /** Start a run: the words, or the whole agents.run-start@1 object. */
++ start(request: string | AgentsRunStart, options?: { signal?: AbortSignal; idempotencyKey?: string }): Promise<AgentsRun>;
++ /** Say something to a run: continue a conversation or steer work under way. */
++ say(run: AgentsRunLike, message: string | { text?: string; attachments?: AgentsAttachment[] }, options?: { signal?: AbortSignal; idempotencyKey?: string }): Promise<AgentsStep>;
++ stop(run: AgentsRunLike, options?: { reason?: string; signal?: AbortSignal }): Promise<AgentsRun>;
++ events(run: AgentsRunLike, options?: AgentsEventsOptions): AsyncIterable<AgentEvent>;
++ /** Follow a run until it ends; resolves with its end status (null when the stream closed without one). */
++ follow(run: AgentsRunLike, handlers?: AgentsFollowHandlers): Promise<{ status: 'done' | 'failed' | 'stopped' | null }>;
++}
++export interface AgentsApprovals {
++ /** Answer an approval by its id, its object, or the seq of the step that asked. */
++ answer(run: AgentsRunLike, approval: string | number | Pick<AgentsApproval, 'id' | 'step_seq'>, answer: string | Record<string, unknown>, options?: { signal?: AbortSignal }): Promise<AgentsApproval>;
++}
++export interface AgentsDeskView { desk: { id: string; [field: string]: unknown }; view: unknown; still: string | null }
++export interface AgentsClient {
++ runs: AgentsRuns;
++ approvals: AgentsApprovals;
++ /** See it work: the desks a run may show, the person's control of one, the finished run's replay. */
++ desks(run: AgentsRunLike, options?: { signal?: AbortSignal }): Promise<{ desks: AgentsDeskView[] }>;
++ takeOver(run: AgentsRunLike, desk: string, options?: { signal?: AbortSignal }): Promise<unknown>;
++ handBack(run: AgentsRunLike, desk: string, note?: string, options?: { signal?: AbortSignal }): Promise<unknown>;
++ replay(run: AgentsRunLike, options?: { signal?: AbortSignal }): Promise<{ steps: object[] }>;
++ /** The first shapes, as released: an Ask run's snapshot, its approval post and its stream. */
++ run(id: string, options?: { signal?: AbortSignal }): Promise<AgentsRun | null>;
++ post(id: string, approval: AgentApproval, options?: { signal?: AbortSignal }): Promise<unknown>;
++ events(run: AgentsRunLike, options?: AgentsEventsOptions): AsyncIterable<AgentEvent>;
++}
++export interface AgentsClientOptions { baseUrl?: string; service?: string; audience?: string; /** `/api/v1` on a product (default), `/api/frame` on the Frame. */ prefix?: string }
++export function createAgentsClient(client: unknown, options?: AgentsClientOptions): AgentsClient;
++/** Every product's runs through the Frame (`/api/frame/runs…`): a run is named by its ref. */
++export function createFrameAgentsClient(client: unknown, options?: Omit<AgentsClientOptions, 'prefix' | 'service' | 'audience'>): AgentsClient;
++export const KINDS: readonly ['chat', 'ask', 'task', 'code', 'machine', 'goal'];
++export const STATUSES: readonly AgentRunStatus[];
++export const END_STATUSES: readonly ['done', 'failed', 'stopped'];
++export function isEnd(runOrStatus: AgentsRun | AgentRunStatus | null | undefined): boolean;
++export function refOf(product: string, id: string): string;
++export function parseRef(ref: string): { product: string; id: string } | null;
++/** Does this run need its person now (an approval, a takeover, a payment)? A conversation between turns does not. */
++export function needsPerson(run: AgentsRun | null | undefined): boolean;
++
++// ── The plugin interfaces (types only; the loop that calls them is the next release) ─────────
++export interface AgentsModelRequest { messages: object[]; tools?: AgentTool[]; model?: string; budget?: AgentsBudget; signal?: AbortSignal }
++export interface AgentsModelTurn { text?: string; tool_calls?: object[]; usage?: { tokens?: number }; cost?: { usd?: number }; raw?: object }
++/** The only way the loop reaches a model: AI routes, a person's key, a Link machine's local model or a plan. */
++export interface AgentModelAdapter {
++ id: string;
++ family?: string;
++ complete(request: AgentsModelRequest): Promise<AgentsModelTurn>;
++}
++
++export interface AgentToolContext { run: AgentsRun; toolCall: AgentsToolCall; signal?: AbortSignal }
++export interface AgentToolResult { result: unknown; cost?: { usd?: number }; artifacts?: object[] }
++/** A tool the loop may call: an OpenVibe product, an MCP server or one of a machine's own tools. */
++export interface AgentTool {
++ name: string;
++ description: string;
++ inputSchema?: object;
++ run(input: object, context: AgentToolContext): Promise<AgentToolResult>;
++}
++
++export interface AgentSandboxOptions { cwd?: string; env?: object; timeoutMs?: number; signal?: AbortSignal }
++/** Where code runs: a Codes worker, a Link machine or an OpenVibe computer. */
++export interface AgentSandbox {
++ id: string;
++ where?: 'codes-worker' | 'link-machine' | 'run-computer' | 'this-computer';
++ exec(command: string | string[], options?: AgentSandboxOptions): Promise<{ ok: boolean; output: string; exitCode?: number; artifacts?: object[] }>;
++ readFile?(path: string, options?: { encoding?: string }): Promise<string | Uint8Array>;
++ writeFile?(path: string, data: string | Uint8Array): Promise<{ path: string; bytes?: number }>;
++}
++
++export interface AgentMemoryQuery { text?: string; kinds?: string[]; limit?: number }
++export interface AgentMemoryEntry { text: string; kind?: string; ref?: string; data?: object }
++/** What a run may remember, scoped to its owner (Actor's memories, Codes' knowledge, a person's notes). */
++export interface AgentMemory {
++ read(query: AgentMemoryQuery): Promise<object[]>;
++ write(entry: AgentMemoryEntry): Promise<object>;
++ forget?(id: string): Promise<boolean>;
++}
++
++export interface AgentsPlanStep { title: string; kind?: string; tool?: string }
++/** Turns the input into steps before the loop runs; replaceable, including with a no-plan adapter. */
++export interface AgentPlanner {
++ plan(input: { run: AgentsRun; text: string; context?: object; signal?: AbortSignal }): Promise<{ steps: AgentsPlanStep[]; text?: string }>;
++}
++
++export interface AgentsReview { ok: boolean; reason?: string; by?: string; cross?: boolean }
++/** The second look: a model of another family, a test suite or a rubric. */
++export interface AgentReviewer {
++ review(input: { run: AgentsRun; result: unknown; steps?: AgentsStep[]; signal?: AbortSignal }): Promise<AgentsReview>;
++}