openvibers/openvibe.sdk Commits
openvibe-sdk 0.160.0: the agent engine's loop, Dispatcher and Run API kit (#244)
Changes
@@@ -1,5 +1,61 @@# Changelog++## 0.160.0 — 2026-11-12++++**The agent engine's loop, Dispatcher and Run API kit: `openvibe-sdk/agents/loop`, `/dispatch` and `/api`** (agent-engine.md §§3, 9). A model, tools and the person's words become++a run's thread. It runs chat and ask turns on a server with no workspace (Vibe answering, an agent using OpenVibe's own++products as tools).++++- **`runLoop(options)`** resolves `{ messages, stop, error, usage, cost, turns, toolCalls }`. Stop is `done`,++ `terminated`, `budget`, `turns`, `aborted` or `error`.++ - **The model adapter:** `complete({ messages, tools, system, signal, budgetLeft, onDelta })` returns the next turn:++ text, thinking, tool calls, why it stopped, usage and cost. `onDelta` streams the turn as it is written.++ - **Tools** run in parallel, or in sequence when the loop or a tool asks. Parallel runs are prepared in order, run++ together, and their results kept in the model's order.++ - **`beforeToolCall`** is the approval gate (block, with a reason, or end the run); **`afterToolCall`** may rewrite a++ result.++ - **`steering()`:** words the person sent while it works, taken before the next turn. **`followUp()`:** words after++ it would stop. **`finishTurn()`** may end or continue the loop. **`transformContext()`** compacts or filters the++ thread before each turn, and entries marked `ui_only` never reach the model.++ - **The budget** (usd, tokens, turns, steps) stops it between turns, never inside one; the model is told what is left.++- **What it keeps, whatever the hooks say:**++ - A call cut off by the output limit never runs; the model is asked to send it again.++ - An unknown tool, input its schema refuses, a throw or a timeout comes back to the model as an error, never as a++ crash.++ - The same call repeated past `repeatLimit` gets a reminder instead of a run, and an idempotent tool answers the same++ call from the run's memo.++ - A long output is clipped before the model sees it; the whole result stays in the thread.++- **`createStepSink({ append, update, delta, startSeq, startTurn, runId })`** turns the loop's events into++ `agents.step@1` steps. In order: the person's words, the thinking (folded), one tool step updated when it ends, then++ the agent's words streamed into their step. A person's message opens the next `turn` (Contracts 0.417.0).++- **`checkInput(schema, value)`:** the JSON Schema subset tools use.++- **New `openvibe-sdk/agents/dispatch`: the Dispatcher** (agent-engine.md §3), as pure functions over a person's++ choices:++ - `ladder(choices, { need, pin, avoid, budgetLeft })` orders them: their own machine, a signed-in plan, their own++ key, the free allowance, then credits, cheapest first inside a rung, and a smaller model before a stronger one++ unless the step needs strength. What cannot do the step (a capability, the context, the speed, the budget) is++ listed with why. A person's pick wins when it can, and is otherwise passed over with the reason.++ - `escalate(current, evidence, choices)` moves to a stronger model only on checkable evidence: a failed test,++ schema, tool or grader, or the model's own "not sure". A grader's failure also changes family.++ - `harnessFor(task, harnesses, { plans })` chooses by capability: the loop for turns with tools, the person's++ signed-in plan, else dsh.++ - `placeFor(run, { machines, cloud })`: the person's computer first (free), a Codes runner for a repository++ elsewhere, an OpenVibe computer when allowed, Bot for a robot.++ - `estimate(choice, tokens)` and `slice(parentBudget, share)`.++- **New `openvibe-sdk/agents/api`: the Run API from a product's own store** (server).++ - `createRunApi({ product, subject, store, prefix })` returns `handle(req, res, next)` for Express or any Node http++ server: the list, start, get, steps, events, messages, stop and approvals routes of Contracts' `docs/agents-runs.md`.++ - The store has `list, get, steps, start, say, stop, answer` and, optionally, `follow` to push its own events++ (otherwise the stream reads new steps). A method a store lacks answers 409.++ - No subject is 401, another person's run 404, and a start without words or with an unknown kind 422.++ - Every run carries its ref. A store's own `{ status, code }` errors go out as they are; anything else is a 500 that++ says nothing of the store. A mandate from a caller is dropped (intake's hook sets it).++ - Read back with the SDK's own client in its test.++- Ported from pi's agent loop (earendil-works/pi, `packages/agent/src/agent-loop.ts`, MIT, Copyright (c) 2025 Mario++ Zechner): the inner loop over tools and steering, the outer loop over follow-ups, the turn hook, parallel and++ sequential tools, and the two tool hooks. The model API, the message shape, the budget, the memo, the repeat guard,++ the timeouts and the step sink are OpenVibe's.++## 0.159.0 — 2026-11-12The agent engine's client (Contracts 0.414.0: everything a person asks an AI to do is a run with a thread of steps).@@@ -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.159.0++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.160.0```> Supported browser and server clients for the OpenVibe platform.--**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.++**Status:** alpha, v0.160.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/tagsAfter v0.97.1 is tagged:```bash--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.159.0++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.160.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).@@@ -220,6 +220,9 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client| `openvibe-sdk/chat` | both | `createChatClient(client)`: `global.send/history/search`, `rooms.*` (list, create, get, update, messages, send, deleteMessage, join, leave, read, members, setMember, react), `dms.*` (list, create, get, messages, send, deleteMessage, read, unread, blocks, block, unblock), `activity.*` (list, unread, readOne, read: what happened to the person in Chat), `embeds(message)` (the typed `chat.embed@1` address previews on a message), `exportMine()`, `vibe.*` (list, start, rename, remove: chats with Vibe), `inboxes.*` (list, create, get, update, remove, addMember, removeMember, conversations, messages, reply, setStatus, read, identity, replies, saveReply, deleteReply, webhooks, addWebhook, testWebhook, removeWebhook: a support inbox's team), `support.*` (list, messages, send: their own conversations with sites' chats), `feeds.*` (mine, create, update, rotateKey, remove, view, onLive). With no account: `createInboxVisitor(client, { inbox, token, user, account })` (visit, send, messages, handoff, rate: your own panel for an inbox, from any site) and `createFeedPublisher(client, { feed, key })` (hello, publish, claudeCode: vibe coding events with the feed's key) || `openvibe-sdk/ai` | both | `createAiClient(client)`: `runs.create(workflow, input, {wait, idempotencyKey, latency, deadlineHours, …})`, `runs.get/list/cancel/retry/citations/addCitations/waitFor`, `batches.create/get/list/cancel`, `creations()` (the public catalogue of what AI can make, no token sent), `chat/generate/summarize/classify/extract/enrich/embed`. A run may ask for the class it may wait in (`latency`: `interactive`, `background`, `batch`; `deadlineHours`: 1–72 for a batch run); `waitFor` gives a batch run back pending rather than throwing when the wait is over — the answer comes with the run's completion event or webhook. || `openvibe-sdk/jobs` | both | `createJobsClient(client, {baseUrl?})`: `submit` (Idempotency-Key), `get`, `cancel`, `retry`, `reference`, `unreference`, `events` (SSE, reattaches), `wait`, `file` (raw Response); `isTerminal()`. Default origin: the Tools gateway's jobs facade |++| `openvibe-sdk/agents/loop` | both | The light agent loop (a port of pi's, MIT): `runLoop({ model, tools, system, messages, prompts, budget, beforeToolCall, afterToolCall, transformContext, steering, followUp, finishTurn, onEvent, signal })` resolves `{ messages, stop, usage, cost, turns }` (stop: done, terminated, budget, turns, aborted, error). Tools in parallel (results in the model's order) or in sequence; an unknown tool, refused input, a throw or a timeout comes back to the model as an error; a call cut off by the output limit never runs; repeats get a reminder; idempotent tools answer from a memo; long outputs are clipped. `createStepSink({ append, update, delta, startSeq, startTurn })` turns its events into `agents.step@1` steps |++| `openvibe-sdk/agents/dispatch` | both | The Dispatcher, as pure functions over a person's choices: `ladder(choices, { need, pin, avoid, budgetLeft })` (their own machine, a signed-in plan, their key, the free allowance, then credits, cheapest first; what cannot do the step is listed in `.skipped` with why; a person's pick wins when it can), `pick`, `escalate(current, evidence, choices)` (only on a failed test, schema, tool or grader, or the model's own "not sure"), `harnessFor(task, harnesses, { plans })`, `placeFor(run, { machines, cloud })`, `estimate(choice, tokens)`, `slice(parentBudget, share)` |++| `openvibe-sdk/agents/api` | server | The Run API from a product's own store: `createRunApi({ product, subject, store, prefix })` returns `handle(req, res, next)` for Express or any Node http server, serving `GET/POST /runs`, `GET /runs/:id`, `…/steps`, `…/events` (server-sent events, from the store's `follow` or by reading new steps), `POST …/messages`, `…/stop`, `…/approvals/:approval`. The store has `list, get, steps, start, say, stop, answer, follow?`; a method it lacks answers 409. No subject is 401, another person's run 404, a start without words 422; every run carries its ref; a store's own `{ status, code }` errors go out as they are and anything else is a 500 that says nothing of the store; a mandate from a caller is dropped (intake only) || `openvibe-sdk/agents` | both | `createAgentsClient(client, {baseUrl?, service?, audience?})`: `run(id)` fetches an agent run snapshot (null when missing), `events(id, {lastEventId, signal})` follows the resumable AG-UI event stream, `post(id, approval)` answers an approval request || `openvibe-sdk/tools` | both | `createToolsClient(client, {baseUrl?, anonymousReads?})`: `list({family, q, execution, api, status})`, `get(id)`, `schema(id)`, `run(id, input, {files, waitMs, idempotencyKey, signal, timeoutMs, prefer})` (+ `.wait()`), `jobs`; `ToolRunError`, `isToolRunError()`; `prefer` picks the worker placement (`auto`, `home`, `host`) ([details](docs/tools-routing.md)) || `openvibe-sdk/projects` | both (user token) | `createProjectsClient(client)`: `catalog`, `list`, `create`, `get`, `update`, `archive`, `setAllowance`, `setEnvironmentPolicy`, `members.*`, `apps.*`, `credentials.list/rotate/revoke`, `grants.list/request/approve/deny/revoke`, `quotas.*`, `audit`, `iterateAudit` |@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.159.0",++ "version": "0.160.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",@@@ -1,4 +1,4 @@--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.159.0, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.160.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.159.0';++const SDK_VERSION = '0.160.0';module.exports = {createClient,@@@ -27,6 +27,38 @@"refOf"]},++ "agentsApi": {++ "object": [++ "KINDS",++ "STATUSES",++ "createRunApi",++ "startInput"++ ]++ },++ "agentsDispatch": {++ "object": [++ "EVIDENCE",++ "RUNGS",++ "TIERS",++ "cannot",++ "escalate",++ "estimate",++ "harnessFor",++ "ladder",++ "pick",++ "placeFor",++ "slice"++ ]++ },++ "agentsLoop": {++ "object": [++ "DEFAULTS",++ "STOPS",++ "checkInput",++ "createStepSink",++ "runLoop"++ ]++ },"ai": {"object": ["CLASSES",@@@ -618,6 +650,39 @@"parseRef": "function","refOf": "function"},++ "openvibe-sdk/agents/api": {++ "KINDS": "array",++ "STATUSES": "array",++ "createRunApi": "function",++ "startInput": "function"++ },++ "openvibe-sdk/agents/dispatch": {++ "EVIDENCE": "array",++ "RUNGS": "array",++ "TIERS": "array",++ "cannot": "function",++ "escalate": "function",++ "estimate": "function",++ "harnessFor": "function",++ "ladder": "function",++ "pick": "function",++ "placeFor": "function",++ "slice": "function"++ },++ "openvibe-sdk/agents/loop": {++ "DEFAULTS": {++ "object": [++ "maxTurns",++ "repeatLimit",++ "toolOutputLimit",++ "toolTimeoutMs"++ ]++ },++ "STOPS": "array",++ "checkInput": "function",++ "createStepSink": "function",++ "runLoop": "function"++ },"openvibe-sdk/ai": {"CLASSES": "array","OPS": "array",nnew file mode 100644@@@ -0,0 +1,7 @@++// Generated by scripts/esm.js from src/agents/api.js. Do not edit.++import cjs from '../src/agents/api.js';++++export const {++ createRunApi, startInput, KINDS, STATUSES,++} = cjs;++export default cjs;nnew file mode 100644@@@ -0,0 +1,8 @@++// Generated by scripts/esm.js from src/agents/dispatch.js. Do not edit.++import cjs from '../src/agents/dispatch.js';++++export const {++ ladder, pick, escalate, harnessFor, placeFor, estimate,++ slice, cannot, RUNGS, TIERS, EVIDENCE,++} = cjs;++export default cjs;nnew file mode 100644@@@ -0,0 +1,7 @@++// Generated by scripts/esm.js from src/agents/loop.js. Do not edit.++import cjs from '../src/agents/loop.js';++++export const {++ runLoop, createStepSink, checkInput, STOPS, DEFAULTS,++} = cjs;++export default cjs;@@@ -11,12 +11,13 @@ export const {community, homeFeed, feed, bot, search, bench,meme, vision, themes, elements, design, redact,chat, chatApps, ai, credentials, jobs, agents,-- frame, tools, projects, resources, vip, quest,-- live, inventory, tips, blog, reviews, wiki,-- coupons, deals, shoes, trade, space, host,-- news, food, actor, codes, run, video,-- pics, work, zone, git, play, download,-- openre, geo, history, usage, commerce, moneyReasons,-- telemetry, userCard, player, agentSheet,++ agentsLoop, agentsDispatch, agentsApi, frame, tools, projects,++ resources, vip, quest, live, inventory, tips,++ blog, reviews, wiki, coupons, deals, shoes,++ trade, space, host, news, food, actor,++ codes, run, video, pics, work, zone,++ git, play, download, openre, geo, history,++ usage, commerce, moneyReasons, telemetry, userCard, player,++ agentSheet,} = cjs;export default cjs;@@@ -45,6 +45,9 @@ module.exports = {credentials: require('./src/credentials'),jobs: require('./src/jobs'),agents: require('./src/agents'),++ agentsLoop: require('./src/agents/loop'),++ agentsDispatch: require('./src/agents/dispatch'),++ agentsApi: require('./src/agents/api'),frame: require('./src/frame'),tools: require('./src/tools'),projects: require('./src/projects'),@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.159.0",++ "version": "0.160.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.159.0",++ "version": "0.160.0","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.159.0",++ "version": "0.160.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",@@@ -314,6 +314,21 @@"import": "./esm/agents.mjs","default": "./src/agents.js"},++ "./agents/loop": {++ "types": "./types/agents-loop.d.ts",++ "import": "./esm/agents-loop.mjs",++ "default": "./src/agents/loop.js"++ },++ "./agents/dispatch": {++ "types": "./types/agents-dispatch.d.ts",++ "import": "./esm/agents-dispatch.mjs",++ "default": "./src/agents/dispatch.js"++ },++ "./agents/api": {++ "types": "./types/agents-api.d.ts",++ "import": "./esm/agents-api.mjs",++ "default": "./src/agents/api.js"++ },"./tools": {"types": "./types/tools.d.ts","import": "./esm/tools.mjs",@@@ -77,6 +77,9 @@ const ENTRIES = {'ai.mjs': '../src/ai.js','jobs.mjs': '../src/jobs.js','agents.mjs': '../src/agents.js',++ 'agents-loop.mjs': '../src/agents/loop.js',++ 'agents-dispatch.mjs': '../src/agents/dispatch.js',++ 'agents-api.mjs': '../src/agents/api.js','tools.mjs': '../src/tools.js','projects.mjs': '../src/projects.js','geo.mjs': '../src/geo.js',nnew file mode 100644@@@ -0,0 +1,220 @@++'use strict';++/**++ * openvibe-sdk/agents/api — the Run API from a store (agent-engine.md §9; Contracts docs/agents-runs.md). A product++ * writes its store once (its own tables, its own runs) and serves the same routes as every other product and the Frame:++ *++ * GET /runs?kind=&status=&q=&limit=&cursor= { runs, next }++ * POST /runs agents.run-start@1 → 201 the run (Idempotency-Key: the first run again)++ * GET /runs/:id the run++ * GET /runs/:id/steps?after=&limit= { steps, next }++ * GET /runs/:id/events agents.event@1 as server-sent events (Last-Event-ID: a step's seq)++ * POST /runs/:id/messages { text, attachments } → 201 the step++ * POST /runs/:id/stop { reason } → the run++ * POST /runs/:id/approvals/:approval { answer } → what the store answers++ *++ * const api = createRunApi({ product: 'chat', subject: async (req) => personOf(req), store });++ * app.use('/api/v1', (req, res, next) => api.handle(req, res, next)); // Express, or any Node http server++ *++ * The store (every method gets `owner`, the caller as { type, id }; a method it does not have answers 409):++ * list({ owner, kind, status, q, limit, cursor }) → { runs, next }++ * get({ owner, id }) → run | null++ * steps({ owner, id, after, limit }) → { steps, next }++ * start({ owner, input, idempotencyKey }) → run++ * say({ owner, id, text, attachments }) → step++ * stop({ owner, id, reason }) → run++ * answer({ owner, id, approval, answer }) → anything++ * follow({ owner, id, after, signal, emit }) → optional: push events (emit(event, data, id)) until the signal++ * aborts; without it the stream reads new steps every pollMs++ *++ * What it keeps: a caller without a subject gets 401; a run id or an approval id that is not one gets 404; a start++ * without words, or with a kind or a body that is not one, gets 422; every run it returns carries its ref++ * (`<product>:<id>`); the stream closes after streamMs so no proxy holds it forever, and the client resumes after the++ * last step it has. Errors a store throws with { status, code, message } go out as they are; anything else is a 500++ * that says nothing of the store.++ */++++const KINDS = ['chat', 'ask', 'task', 'code', 'machine', 'goal'];++const STATUSES = ['planning', 'running', 'waiting', 'done', 'failed', 'stopped'];++const ID = /^[A-Za-z0-9._~:-]{1,200}$/;++const APPROVAL = /^[A-Za-z0-9._~-]{1,160}$/;++const KEY = /^[A-Za-z0-9._~-]{1,200}$/;++const BODY_MAX = 64 * 1024;++++function problem(status, code, message) { const e = new Error(message); e.status = status; e.code = code; return e; }++const clip = (v, n) => String(v == null ? '' : v).slice(0, n);++++async function readJson(req) {++ if (req.body !== undefined && req.body !== null && typeof req.body === 'object' && !Buffer.isBuffer(req.body)) return req.body;++ if (typeof req.body === 'string') { try { return JSON.parse(req.body || '{}'); } catch { throw problem(400, 'runs.bad_json', 'The body is not JSON'); } }++ if (req.method === 'GET' || req.method === 'HEAD') return {};++ const chunks = []; let size = 0;++ for await (const c of req) { size += c.length; if (size > BODY_MAX) throw problem(413, 'runs.too_large', 'The body is larger than 64 KB'); chunks.push(c); }++ const raw = Buffer.concat(chunks).toString('utf8').trim();++ if (!raw) return {};++ try { const v = JSON.parse(raw); return v && typeof v === 'object' && !Array.isArray(v) ? v : {}; } catch { throw problem(400, 'runs.bad_json', 'The body is not JSON'); }++}++++function send(res, status, body, headers = {}) {++ if (res.headersSent) return;++ res.statusCode = status;++ res.setHeader('Content-Type', 'application/json; charset=utf-8');++ res.setHeader('Cache-Control', 'private, no-store');++ for (const [k, v] of Object.entries(headers)) res.setHeader(k, v);++ res.end(JSON.stringify(body));++}++++/** A start body as agents.run-start@1 says it, or a 422 naming what is wrong. */++function startInput(b) {++ const text = clip(b && b.text, 16000).trim();++ if (!text) throw problem(422, 'runs.start_needs_words', 'Say what the run is for ({ text })');++ if (b.kind != null && !KINDS.includes(b.kind)) throw problem(422, 'runs.bad_kind', `kind is one of ${KINDS.join(', ')}`);++ const out = { ...b, text };++ if (b.attachments != null && !Array.isArray(b.attachments)) throw problem(422, 'runs.bad_attachments', 'attachments is a list');++ if (Array.isArray(b.attachments)) out.attachments = b.attachments.slice(0, 20);++ // A mandate comes from intake only (agents.run-start@1 1.1.0): a product's own caller never sets one.++ delete out.mandate;++ return out;++}++++function createRunApi({ store, subject, product, prefix = '', pollMs = 1500, streamMs = 55000, intake = null } = {}) {++ if (!store || typeof store !== 'object') throw new TypeError('agents/api: a store is required');++ if (typeof subject !== 'function') throw new TypeError('agents/api: subject(req) is required (who is calling)');++ if (!/^[a-z][a-z0-9-]{0,40}$/.test(String(product || ''))) throw new TypeError('agents/api: product is a short lowercase name (chat, actor, codes…)');++ const base = String(prefix || '').replace(/\/+$/, '');++ const withRef = (run) => (run && typeof run === 'object' && run.id != null && !run.ref ? { ...run, ref: `${product}:${run.id}` } : run);++ const need = (name) => { if (typeof store[name] !== 'function') throw problem(409, `runs.cannot_${name}`, `A ${product} run cannot ${name === 'say' ? 'take messages' : name === 'answer' ? 'be answered' : name} here yet`); };++++ async function who(req) {++ let s = null;++ try { s = await subject(req); } catch { s = null; }++ if (!s || !s.id) throw problem(401, 'auth.required', 'Sign in to see your runs');++ return { type: s.type || 'user', id: String(s.id) };++ }++++ async function list(req, res, owner, url) {++ const q = url.searchParams;++ const kind = KINDS.includes(q.get('kind')) ? q.get('kind') : null;++ const status = STATUSES.includes(q.get('status')) ? q.get('status') : null;++ const limit = Math.min(Math.max(parseInt(q.get('limit'), 10) || 30, 1), 100);++ need('list');++ const out = await store.list({ owner, kind, status, q: clip(q.get('q'), 200) || null, limit, cursor: clip(q.get('cursor'), 400) || null });++ send(res, 200, { runs: (out && Array.isArray(out.runs) ? out.runs : []).slice(0, limit).map(withRef), next: (out && out.next) || null });++ }++++ async function events(req, res, owner, id, url) {++ const last = String(req.headers['last-event-id'] || url.searchParams.get('after') || '0');++ if (!/^\d{1,16}$/.test(last)) return send(res, 400, { error: 'Last-Event-ID is a step seq', code: 'runs.bad_event_id' });++ need('get');++ const run = await store.get({ owner, id });++ if (!run) throw problem(404, 'runs.not_found', 'No such run');++ let after = Number(last);++ const ctl = new AbortController();++ req.on('close', () => ctl.abort());++ res.statusCode = 200;++ res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');++ res.setHeader('Cache-Control', 'private, no-store');++ res.setHeader('X-Accel-Buffering', 'no');++ if (typeof res.flushHeaders === 'function') res.flushHeaders();++ const emit = (event, data, eventId) => { if (!ctl.signal.aborted) res.write(`${eventId != null ? `id: ${eventId}\n` : ''}event: ${event}\ndata: ${JSON.stringify({ type: event, ...data })}\n\n`); };++ const timer = setTimeout(() => ctl.abort(), streamMs);++ try {++ if (typeof store.follow === 'function') {++ await store.follow({ owner, id, after, signal: ctl.signal, emit: (event, data, eventId) => emit(event, event === 'run' && data && data.run ? { ...data, run: withRef(data.run) } : data, eventId) });++ } else {++ need('steps');++ while (!ctl.signal.aborted) {++ const page = await store.steps({ owner, id, after, limit: 100 });++ const fresh = (page && Array.isArray(page.steps) ? page.steps : []).filter((s) => s && Number(s.seq) > after);++ fresh.forEach((s) => { emit('step', { step: s }, s.seq); after = Math.max(after, Number(s.seq)); });++ if (fresh.length) {++ const now = await store.get({ owner, id });++ if (now) {++ emit('run', { run: withRef(now) }, after);++ if (['done', 'failed', 'stopped'].includes(now.status)) { emit('end', { run_id: withRef(now).ref, status: now.status }, after); break; }++ }++ }++ await new Promise((resolve) => { const t = setTimeout(resolve, pollMs); ctl.signal.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true }); });++ }++ }++ } catch (err) {++ emit('error', { error: err && err.status ? err.message : 'The run could not be read', code: (err && err.code) || 'runs.error' });++ } finally {++ clearTimeout(timer);++ if (!res.writableEnded) res.end();++ }++ }++++ /** Answer one request; `next()` (when given) for a path that is not the Run API's. */++ async function handle(req, res, next) {++ let url;++ try { url = new URL(req.originalUrl || req.url, 'http://local'); } catch { return next ? next() : send(res, 404, { error: 'Not found', code: 'runs.not_found' }); }++ let path = url.pathname;++ if (base) { if (!path.startsWith(base)) return next ? next() : send(res, 404, { error: 'Not found', code: 'runs.not_found' }); path = path.slice(base.length) || '/'; }++ const m = /^\/runs(?:\/([^/]+)(?:\/(steps|events|messages|stop|approvals)(?:\/([^/]+))?)?)?\/?$/.exec(path);++ if (!m) return next ? next() : send(res, 404, { error: 'Not found', code: 'runs.not_found' });++ const [, rawId, part, rawApproval] = m;++ const method = String(req.method || 'GET').toUpperCase();++ try {++ const owner = await who(req);++ const id = rawId === undefined ? null : decodeURIComponent(rawId);++ if (id !== null && !ID.test(id)) throw problem(404, 'runs.not_found', 'No such run');++ const localId = id && id.startsWith(`${product}:`) ? id.slice(product.length + 1) : id;++ if (!id) {++ if (method === 'GET') return await list(req, res, owner, url);++ if (method === 'POST') {++ need('start');++ const input = startInput(await readJson(req));++ if (intake && typeof intake === 'function') { const mandate = await intake(req, input); if (mandate) input.mandate = mandate; }++ const key = String(req.headers['idempotency-key'] || '');++ if (key && !KEY.test(key)) throw problem(422, 'runs.bad_key', 'Idempotency-Key is 1 to 200 letters, digits or ._~-');++ const run = await store.start({ owner, input, idempotencyKey: key || null });++ return send(res, 201, withRef(run));++ }++ throw problem(405, 'runs.method', 'GET or POST');++ }++ if (!part) {++ if (method !== 'GET') throw problem(405, 'runs.method', 'GET');++ need('get');++ const run = await store.get({ owner, id: localId });++ if (!run) throw problem(404, 'runs.not_found', 'No such run');++ return send(res, 200, withRef(run));++ }++ if (part === 'steps') {++ if (method !== 'GET') throw problem(405, 'runs.method', 'GET');++ need('steps');++ const after = Math.max(0, parseInt(url.searchParams.get('after'), 10) || 0);++ const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit'), 10) || 100, 1), 500);++ const out = await store.steps({ owner, id: localId, after, limit });++ if (!out) throw problem(404, 'runs.not_found', 'No such run');++ return send(res, 200, { steps: (Array.isArray(out.steps) ? out.steps : []).slice(0, limit), next: out.next || null });++ }++ if (part === 'events') { if (method !== 'GET') throw problem(405, 'runs.method', 'GET'); return await events(req, res, owner, localId, url); }++ if (method !== 'POST') throw problem(405, 'runs.method', 'POST');++ const b = await readJson(req);++ if (part === 'messages') {++ need('say');++ const text = clip(b.text, 16000);++ const attachments = Array.isArray(b.attachments) ? b.attachments.slice(0, 20) : [];++ if (!text.trim() && !attachments.length) throw problem(422, 'runs.say_needs_words', 'Say something ({ text })');++ return send(res, 201, await store.say({ owner, id: localId, text, attachments }));++ }++ if (part === 'stop') { need('stop'); return send(res, 200, withRef(await store.stop({ owner, id: localId, reason: clip(b.reason, 500) || null }))); }++ if (part === 'approvals') {++ const approval = rawApproval ? decodeURIComponent(rawApproval) : '';++ if (!APPROVAL.test(approval)) throw problem(404, 'runs.not_found', 'No such approval');++ if (b.answer === undefined || b.answer === null || b.answer === '') throw problem(422, 'runs.answer_needed', 'An answer is required ({ answer })');++ need('answer');++ return send(res, 200, await store.answer({ owner, id: localId, approval, answer: b.answer }));++ }++ throw problem(404, 'runs.not_found', 'Not found');++ } catch (err) {++ const status = err && Number.isInteger(err.status) && err.status >= 400 && err.status < 600 ? err.status : 500;++ if (res.headersSent) { if (!res.writableEnded) res.end(); return; }++ return send(res, status, { error: status === 500 && !(err && err.status) ? 'Runs are unavailable right now' : String((err && err.message) || 'Error'), code: (err && err.code) || 'runs.error' });++ }++ }++++ return { handle, KINDS, STATUSES };++}++++module.exports = { createRunApi, startInput, KINDS, STATUSES };nnew file mode 100644@@@ -0,0 +1,166 @@++'use strict';++/**++ * openvibe-sdk/agents/dispatch — the Dispatcher (agent-engine.md §3): for each step, not just each run, which model,++ * which harness and which place. Pure functions over the choices a person has (Exchange's offers merged with their++ * own machines, plans and keys); nothing here calls a network.++ *++ * ladder(choices, { need, pin, avoid, budgetLeft }) → the choices that can do the step, cheapest rung first++ * pick(choices, options) → the first of them (or null), with why++ * escalate(current, evidence, choices, options) → a stronger choice, only on checkable evidence++ * harnessFor(task, harnesses, { plans }) → the harness by capability, never by name++ * placeFor(run, { machines, cloud }) → where the work lives++ * estimate(choice, { tokensIn, tokensOut }) → the price of a turn in USD++ * slice(parentBudget, share) → a child's budget, a slice of its parent's++ *++ * A choice: { id, name, rung: 'machine' | 'plan' | 'key' | 'allowance' | 'credits', family, tier: 'small' | 'medium' |++ * 'strong', caps: ['tools', 'vision', 'json', 'thinking', 'long-context', …], context (tokens), speed (tokens a++ * second), price: { in, out } (USD per million tokens; a rung that is not paid per token has none), available }.++ *++ * The rules, in the order they apply: a person's own pick always wins when it can do the step (and is only passed over,++ * with a reason, when it cannot); a choice that lacks a needed capability, a large enough context or the speed asked++ * for is never offered; then the ladder: the person's own machine (free), a signed-in plan through its harness (flat),++ * their own key (no fee), the free allowance, then Exchange's credits, cheapest first inside a rung; a smaller tier is++ * tried before a stronger one inside a rung unless the step says it needs strength. A step is redone on a stronger model++ * only when something checkable failed: a test, a schema, a tool error, a grader (of another family), or the model's++ * own structured "not sure"; never on a hunch.++ */++++const RUNGS = ['machine', 'plan', 'key', 'allowance', 'credits'];++const TIERS = ['small', 'medium', 'strong'];++const EVIDENCE = ['test', 'schema', 'tool', 'grader', 'unsure'];++++const rungOf = (c) => (RUNGS.includes(c && c.rung) ? RUNGS.indexOf(c.rung) : RUNGS.length);++const tierOf = (c) => (TIERS.includes(c && c.tier) ? TIERS.indexOf(c.tier) : 1);++const caps = (c) => new Set(Array.isArray(c && c.caps) ? c.caps : []);++++/** The price of one turn of `choice`, in USD (0 on a rung that is not paid per token). */++function estimate(choice, { tokensIn = 0, tokensOut = 0 } = {}) {++ const p = choice && choice.price;++ if (!p || (p.in == null && p.out == null)) return 0;++ return Math.round((((Number(p.in) || 0) * tokensIn + (Number(p.out) || 0) * tokensOut) / 1e6) * 1e6) / 1e6;++}++++/** Why a choice cannot do the step, or null when it can. */++function cannot(c, need = {}) {++ if (!c || c.available === false) return 'not available now';++ const have = caps(c);++ for (const cap of Array.isArray(need.caps) ? need.caps : []) if (!have.has(cap)) return `cannot ${cap}`;++ if (need.context && (Number(c.context) || 0) < need.context) return `holds ${c.context || 0} tokens, the step needs ${need.context}`;++ if (need.speed && (Number(c.speed) || 0) < need.speed) return `writes ${c.speed || 0} tokens a second, the step needs ${need.speed}`;++ if (need.tier && tierOf(c) < TIERS.indexOf(need.tier)) return `a ${c.tier || 'medium'} model, the step needs ${need.tier}`;++ return null;++}++++/**++ * The choices that can do the step, in the order the Dispatcher tries them, each with `why` (the rung, or the person's++ * pick). Choices that cannot are listed in `.skipped` with the reason (the picker shows them greyed, with it).++ */++function ladder(choices, { need = {}, pin = null, avoid = [], budgetLeft = null, tokens = { tokensIn: 2000, tokensOut: 800 } } = {}) {++ const list = Array.isArray(choices) ? choices.filter(Boolean) : [];++ const skipped = [];++ const ok = [];++ const avoidSet = new Set(Array.isArray(avoid) ? avoid : []);++ for (const c of list) {++ const why = cannot(c, need) || (avoidSet.has(c.family) ? `a ${c.family} model, and this step asks for another family` : null)++ || (budgetLeft != null && estimate(c, tokens) > budgetLeft ? 'costs more than the budget has left' : null);++ if (why) skipped.push({ choice: c, why }); else ok.push(c);++ }++ const strongFirst = need.tier === 'strong';++ ok.sort((a, b) => rungOf(a) - rungOf(b)++ || (strongFirst ? tierOf(b) - tierOf(a) : tierOf(a) - tierOf(b))++ || estimate(a, tokens) - estimate(b, tokens)++ || String(a.id).localeCompare(String(b.id)));++ let out = ok.map((c) => ({ ...c, why: c.rung || 'credits' }));++ if (pin) {++ const pinned = out.find((c) => c.id === pin);++ if (pinned) out = [{ ...pinned, why: 'your pick' }, ...out.filter((c) => c.id !== pin)];++ else { const s = skipped.find((x) => x.choice.id === pin); if (s) s.pinned = true; }++ }++ out.skipped = skipped;++ return out;++}++++/** The first choice the ladder offers, or null with the reasons nothing could. */++function pick(choices, options = {}) {++ const l = ladder(choices, options);++ const pinSkipped = l.skipped.find((s) => s.pinned);++ return { choice: l[0] || null, why: l[0] ? l[0].why : 'no model can do this step', passedOver: pinSkipped ? { id: pinSkipped.choice.id, why: pinSkipped.why } : null, skipped: l.skipped };++}++++/**++ * A stronger choice after a checkable failure. `evidence` is { kind: 'test' | 'schema' | 'tool' | 'grader' | 'unsure',++ * detail }; anything else (a hunch, a long answer, a slow one) returns null. The next choice is the cheapest that can do++ * the step at a higher tier than `current`; a grader's failure also avoids the current family. `tried` lists the ids++ * already used on this step.++ */++function escalate(current, evidence, choices, { need = {}, tried = [], budgetLeft = null } = {}) {++ if (!evidence || !EVIDENCE.includes(evidence.kind)) return null;++ const higher = TIERS[Math.min(TIERS.length - 1, tierOf(current) + 1)];++ if (tierOf(current) >= TIERS.length - 1 && evidence.kind !== 'grader') return null;++ const avoid = evidence.kind === 'grader' && current && current.family ? [current.family] : [];++ const used = new Set([current && current.id, ...(Array.isArray(tried) ? tried : [])].filter(Boolean));++ // At the top tier only a grader's failure moves, and then to another family at the same strength.++ const l = ladder((choices || []).filter((c) => !used.has(c.id)), { need: { ...need, tier: higher }, avoid, budgetLeft });++ return l[0] ? { choice: l[0], why: `${evidence.kind === 'unsure' ? 'it was not sure' : `a ${evidence.kind} failed`}${evidence.detail ? `: ${String(evidence.detail).slice(0, 200)}` : ''}` } : null;++}++++/**++ * The harness for a task by what it must do: chat and ask turns with product tools run on the light loop on a server;++ * work with files and commands on dsh by default, or on a harness whose plan the person signed in (their Codex, their++ * Claude Code) when it can do the work. A harness: { id, caps: [...], plan?: string }.++ */++function harnessFor(task = {}, harnesses = [], { plans = [] } = {}) {++ const needs = Array.isArray(task.needs) ? task.needs : [];++ const list = Array.isArray(harnesses) ? harnesses : [];++ const can = (h) => needs.every((n) => (h.caps || []).includes(n));++ if (!needs.some((n) => ['files', 'shell', 'desks', 'browser'].includes(n))) {++ const loop = list.find((h) => h.id === 'loop' && can(h));++ if (loop) return { harness: loop, why: 'a turn with tools runs on the light loop' };++ }++ const signed = list.find((h) => h.plan && plans.includes(h.plan) && can(h));++ if (signed) return { harness: signed, why: `your ${signed.plan} plan` };++ const dsh = list.find((h) => h.id === 'dsh' && can(h));++ if (dsh) return { harness: dsh, why: 'the default for work with files and commands' };++ const any = list.find(can);++ return any ? { harness: any, why: 'it can do what the task needs' } : null;++}++++/**++ * Where the work lives. A repository on the person's computer → that computer (Link's worker); a site to click through++ * → a private desk on their machine, else an OpenVibe computer; heavy or untrusted code → a sandbox; a robot → Bot;++ * a conversation → OpenVibe's servers. `machines`: the person's online machines with what their grants allow++ * ({ id, name, online, allows: ['worker', 'desks', 'models'], repos: [...] }). `cloud`: whether OpenVibe's computers++ * may be used (and paid for).++ */++function placeFor(run = {}, { machines = [], cloud = true } = {}) {++ const online = (Array.isArray(machines) ? machines : []).filter((m) => m && m.online);++ const repo = run.target && typeof run.target === 'object' ? run.target.repo : null;++ if (run.robot) return { where: { kind: 'machine', id: run.robot }, why: 'a robot is reached through Bot' };++ if (repo) {++ const m = online.find((x) => (x.allows || []).includes('worker') && (x.repos || []).includes(repo));++ if (m) return { where: { kind: 'machine', id: m.id, name: m.name }, why: 'the repository is on your computer' };++ return { where: { kind: 'runner' }, why: 'a Codes runner checks it out' };++ }++ if (run.needs && run.needs.includes('browser')) {++ const m = online.find((x) => (x.allows || []).includes('desks'));++ if (m) return { where: { kind: 'machine', id: m.id, name: m.name }, why: 'a private desk on your computer (free)' };++ if (cloud) return { where: { kind: 'openvibe' }, why: 'an OpenVibe computer' };++ return null;++ }++ if (run.untrusted || run.heavy) return cloud ? { where: { kind: 'openvibe' }, why: 'heavy or untrusted work runs in a sandbox' } : null;++ return { where: { kind: 'openvibe' }, why: 'a conversation runs on OpenVibe' };++}++++/** A child's budget: `share` (0..1) of what the parent has left, field by field; absent fields stay unlimited. */++function slice(parent, share) {++ const s = Math.max(0, Math.min(1, Number(share) || 0));++ const out = {};++ if (!parent || typeof parent !== 'object') return out;++ if (parent.usd != null) out.usd = Math.round(parent.usd * s * 1e6) / 1e6;++ if (parent.tokens != null) out.tokens = Math.floor(parent.tokens * s);++ if (parent.seconds != null) out.seconds = Math.floor(parent.seconds * s);++ if (parent.steps != null) out.steps = Math.max(1, Math.floor(parent.steps * s));++ return out;++}++++module.exports = { ladder, pick, escalate, harnessFor, placeFor, estimate, slice, cannot, RUNGS, TIERS, EVIDENCE };nnew file mode 100644@@@ -0,0 +1,372 @@++'use strict';++/**++ * openvibe-sdk/agents/loop — the light agent loop (agent-engine.md §9): a model, tools, and the person's words, turned++ * into a run's thread. It runs chat and ask turns on a server with no workspace (Vibe answering, an agent using++ * OpenVibe's own products as tools); work with files and commands goes to a harness through agents/acp.++ *++ * // import { runLoop, createStepSink } from the openvibe-sdk/agents/loop entry++ * const out = await runLoop({++ * model, // AgentModelAdapter (below): one turn per call++ * tools: [search, remind], // AgentLoopTool (below)++ * system: 'You are Vibe, on OpenVibe.',++ * messages: history, // the thread so far (not emitted again)++ * prompts: [{ role: 'user', text: 'Remind me to stretch every 2 hours' }], // what is new: emitted, then answered++ * budget: { usd: 0.05, tokens: 40000, turns: 12 },++ * beforeToolCall: async ({ toolCall, input }) => (needsYes(toolCall) ? await askThePerson(toolCall) : undefined),++ * steering: () => queue.splice(0), // words the person sent while it works: taken before the next turn++ * onEvent: createStepSink({ append, update, delta }), // the run's steps (agents.step@1), as it goes++ * signal,++ * });++ * // → { messages, stop: 'done' | 'terminated' | 'budget' | 'turns' | 'aborted' | 'error', usage, cost, turns }++ *++ * The model adapter (agents.js's AgentModelAdapter):++ * model.complete({ messages, tools, system, signal, onDelta }) → { text, thinking, tool_calls: [{ id, name, input }],++ * stop: 'stop' | 'tool_use' | 'length' | 'error' | 'aborted', usage: { tokens }, cost: { usd }, error }++ * onDelta({ text } | { thinking }) streams the turn as it is written. Messages are { role: 'system' | 'user' |++ * 'assistant' | 'tool', text, thinking, tool_calls, tool_call_id, name, is_error }; one marked ui_only stays in the++ * thread and never reaches the model.++ *++ * A tool (AgentTool, plus what the loop reads):++ * { name, description, inputSchema, run(input, { toolCall, signal, onUpdate, run }) → { result, cost, artifacts,++ * terminate, is_error }, sequential?, idempotent?, timeoutMs?, title?, glyph?, prepareInput?(input) }++ *++ * What it keeps, whatever the hooks say: a tool call cut off by the token limit is never run (the model is told to++ * send it again); an unknown tool, input its schema refuses, a blocked call, a throw or a timeout comes back to the++ * model as an error result, never as a crash; the same call repeated past `repeatLimit` gets a reminder instead of a++ * run; an idempotent tool's same call is answered from the run's memo; a tool's output is clipped before the model sees++ * it (`toolOutputLimit`); the budget stops the loop between turns, never in the middle of one.++ *++ * Ported from pi's agent loop (earendil-works/pi, packages/agent/src/agent-loop.ts), MIT License, Copyright (c) 2025++ * Mario Zechner. The structure is pi's: an inner loop over tool calls and steering, an outer loop over follow-ups, a++ * turn hook that may end or continue, tools in parallel (prepared in order, run together, results kept in the model's++ * order) or in sequence, beforeToolCall and afterToolCall. The model API, the message shape, the budget, the memo, the++ * repeat guard, the timeouts and the step sink are OpenVibe's.++ */++++const DEFAULTS = { maxTurns: 50, repeatLimit: 3, toolTimeoutMs: 120000, toolOutputLimit: 20000 };++const STOPS = ['done', 'terminated', 'budget', 'turns', 'aborted', 'error'];++++function stableJson(v) {++ if (v === null || typeof v !== 'object') return JSON.stringify(v === undefined ? null : v);++ if (Array.isArray(v)) return `[${v.map(stableJson).join(',')}]`;++ return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stableJson(v[k])}`).join(',')}}`;++}++++/** A small JSON Schema check for tool input (type, required, properties, enum, items; the subset tools use). */++function checkInput(schema, value, at = 'input') {++ if (!schema || typeof schema !== 'object') return null;++ const types = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : null;++ if (types) {++ const kind = value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value;++ const ok = types.some((t) => t === kind || (t === 'integer' && Number.isInteger(value)) || (t === 'number' && kind === 'number'));++ if (!ok) return `${at} should be ${types.join(' or ')}`;++ }++ if (Array.isArray(schema.enum) && !schema.enum.some((e) => stableJson(e) === stableJson(value))) return `${at} should be one of ${schema.enum.map((e) => JSON.stringify(e)).join(', ')}`;++ if (value && typeof value === 'object' && !Array.isArray(value)) {++ for (const k of Array.isArray(schema.required) ? schema.required : []) if (!(k in value)) return `${at}.${k} is required`;++ const props = schema.properties || {};++ for (const [k, sub] of Object.entries(props)) if (k in value) { const e = checkInput(sub, value[k], `${at}.${k}`); if (e) return e; }++ if (schema.additionalProperties === false) { const extra = Object.keys(value).find((k) => !(k in props)); if (extra) return `${at}.${extra} is not an input of this tool`; }++ }++ if (Array.isArray(value) && schema.items) for (let i = 0; i < value.length; i++) { const e = checkInput(schema.items, value[i], `${at}[${i}]`); if (e) return e; }++ return null;++}++++const errorResult = (message, extra = {}) => ({ result: { error: String(message) }, isError: true, ...extra });++const now = () => Date.now();++++/**++ * Run the loop until the model stops asking for tools and nobody has said anything more, a tool ends it, the budget or++ * the turn limit is reached, or the signal aborts. Resolves with the new messages and why it stopped; never rejects for++ * a model or tool failure (stop: 'error' carries it), only for a call it cannot start (no model).++ */++async function runLoop(o = {}) {++ if (!o.model || typeof o.model.complete !== 'function') throw new TypeError('agents/loop: a model adapter with complete() is required');++ const cfg = { ...DEFAULTS, ...o };++ const tools = Array.isArray(o.tools) ? o.tools.filter((t) => t && typeof t.name === 'string' && typeof t.run === 'function') : [];++ const signal = o.signal;++ const emit = async (event) => { if (typeof o.onEvent === 'function') await o.onEvent(event); };++ const thread = Array.isArray(o.messages) ? o.messages.slice() : [];++ const added = [];++ const totals = { tokens: 0, usd: 0, turns: 0, toolCalls: 0 };++ const memo = new Map();++ const repeats = new Map();++ const budget = o.budget || {};++ const overBudget = () => (budget.usd != null && totals.usd >= budget.usd) || (budget.tokens != null && totals.tokens >= budget.tokens)++ || (budget.turns != null && totals.turns >= budget.turns) || (budget.steps != null && totals.turns + totals.toolCalls >= budget.steps);++ const take = async (fn) => { try { const v = typeof fn === 'function' ? await fn() : null; return Array.isArray(v) ? v.filter(Boolean) : []; } catch { return []; } };++ const push = async (m) => { thread.push(m); added.push(m); await emit({ type: 'message_start', message: m }); await emit({ type: 'message_end', message: m }); };++++ let stop = 'done', error = null, terminated = false, explicit = false;++ await emit({ type: 'agent_start' });++ let pending = (Array.isArray(o.prompts) ? o.prompts.filter(Boolean) : []).concat(await take(o.steering));++++ outer: for (;;) {++ let more = true;++ while (more || pending.length) {++ await emit({ type: 'turn_start' });++ for (const m of pending) await push({ at: now(), ...m });++ pending = [];++ if (signal && signal.aborted) { stop = 'aborted'; break outer; }++ if (overBudget()) { stop = 'budget'; break outer; }++ if (totals.turns >= cfg.maxTurns) { stop = 'turns'; break outer; }++++ let visible = thread;++ if (typeof o.transformContext === 'function') { try { visible = (await o.transformContext(thread.slice(), signal)) || thread; } catch { visible = thread; } }++ const forModel = visible.filter((m) => m && !m.ui_only);++ const assistant = await callModel(o.model, { messages: forModel, tools, system: o.system, signal, budgetLeft: left(budget, totals) }, emit);++ thread.push(assistant); added.push(assistant);++ totals.turns += 1;++ totals.tokens += (assistant.usage && assistant.usage.tokens) || 0;++ totals.usd += (assistant.cost && assistant.cost.usd) || 0;++++ if (assistant.stop === 'error' || assistant.stop === 'aborted') {++ await emit({ type: 'turn_end', message: assistant, toolResults: [] });++ stop = assistant.stop; error = assistant.error || null;++ break outer;++ }++++ const calls = Array.isArray(assistant.tool_calls) ? assistant.tool_calls : [];++ const results = [];++ more = false;++ if (calls.length) {++ const batch = assistant.stop === 'length' ? await failTruncated(calls) : await runTools(calls, assistant);++ for (const r of batch.messages) { thread.push(r); added.push(r); results.push(r); }++ terminated = batch.terminate;++ more = !batch.terminate;++ }++ let decision = null;++ if (typeof o.finishTurn === 'function') { try { decision = await o.finishTurn({ assistant, results, messages: thread, added, totals: { ...totals } }); } catch { decision = null; } }++ await emit({ type: 'turn_end', message: assistant, toolResults: results });++ if (decision && decision.action === 'end') { stop = 'done'; break outer; }++ explicit = !!(decision && decision.action === 'continue');++ pending = await take(o.steering);++ if (more || pending.length) explicit = false;++ }++ const follow = await take(o.followUp);++ if (follow.length) { explicit = false; terminated = false; pending = follow; continue; }++ if (explicit) { explicit = false; continue; }++ if (terminated) stop = 'terminated';++ break;++ }++++ await emit({ type: 'agent_end', messages: added, stop, error });++ return { messages: added, stop, error, usage: { tokens: totals.tokens }, cost: { usd: Math.round(totals.usd * 1e6) / 1e6 }, turns: totals.turns, toolCalls: totals.toolCalls };++++ // ── tools ──++ async function failTruncated(calls) {++ const messages = [];++ for (const call of calls) {++ await emit({ type: 'tool_start', toolCall: call });++ const done = { toolCall: call, ...errorResult(`The tool call "${call.name}" was not run: the answer hit the output limit, so its input may be cut off. Send the call again with its whole input.`) };++ messages.push(await finish(done));++ }++ return { messages, terminate: false };++ }++++ async function runTools(calls, assistant) {++ const sequential = cfg.toolExecution === 'sequential' || calls.some((c) => { const t = tools.find((x) => x.name === c.name); return t && t.sequential; });++ const entries = [];++ for (const call of calls) {++ await emit({ type: 'tool_start', toolCall: call });++ totals.toolCalls += 1;++ const prep = await prepare(call, assistant);++ if (prep.immediate) {++ const done = { toolCall: call, ...prep.immediate };++ await emit({ type: 'tool_end', toolCall: call, result: done.result, isError: done.isError });++ entries.push(done);++ } else if (sequential) {++ entries.push(await execute(prep, assistant));++ } else {++ entries.push(() => execute(prep, assistant));++ }++ if (signal && signal.aborted) break;++ }++ const done = await Promise.all(entries.map((e) => (typeof e === 'function' ? e() : e)));++ const messages = [];++ for (const d of done) messages.push(await finish(d, true));++ return { messages, terminate: done.length > 0 && done.every((d) => d.terminate === true) };++ }++++ async function prepare(call, assistant) {++ const tool = tools.find((t) => t.name === call.name);++ if (!tool) return { immediate: errorResult(`There is no tool named ${call.name}.`) };++ let input = call.input && typeof call.input === 'object' ? call.input : {};++ try {++ if (typeof tool.prepareInput === 'function') input = tool.prepareInput(input) || input;++ const bad = checkInput(tool.inputSchema, input);++ if (bad) return { immediate: errorResult(`The input does not fit ${tool.name}: ${bad}.`) };++ const key = `${tool.name} ${stableJson(input)}`;++ const n = (repeats.get(key) || 0) + 1;++ repeats.set(key, n);++ if (n > cfg.repeatLimit) return { immediate: errorResult(`You already called ${tool.name} with this same input ${n - 1} times. Use what it returned, or try something else.`) };++ if (tool.idempotent && memo.has(key)) return { immediate: { ...memo.get(key), memo: true } };++ if (typeof o.beforeToolCall === 'function') {++ const gate = await o.beforeToolCall({ toolCall: call, input, tool, assistant, messages: thread }, signal);++ if (signal && signal.aborted) return { immediate: errorResult('Stopped.') };++ if (gate && gate.block) return { immediate: errorResult(gate.reason || 'This call was not allowed.', gate.terminate === true ? { terminate: true } : {}) };++ }++ if (signal && signal.aborted) return { immediate: errorResult('Stopped.') };++ return { tool, call, input, key };++ } catch (err) {++ return { immediate: errorResult((err && err.message) || err) };++ }++ }++++ async function execute(prep, assistant) {++ const { tool, call, input } = prep;++ const started = now();++ const ctl = new AbortController();++ const onAbort = () => ctl.abort();++ if (signal) { if (signal.aborted) ctl.abort(); else signal.addEventListener('abort', onAbort, { once: true }); }++ const limit = Number(tool.timeoutMs || cfg.toolTimeoutMs) || DEFAULTS.toolTimeoutMs;++ let timer = null;++ let out;++ try {++ const ran = Promise.resolve(tool.run(input, { toolCall: call, signal: ctl.signal, run: o.run || null, onUpdate: (partial) => emit({ type: 'tool_update', toolCall: call, partial }) }));++ // The timeout is settled before the tool is told to stop, so a tool that answers its abort never wins the race.++ const timeout = new Promise((resolve) => { timer = setTimeout(() => { resolve({ timedOut: true }); ctl.abort(); }, limit); });++ const got = await Promise.race([ran, timeout]);++ if (got && got.timedOut) out = errorResult(`${tool.name} took longer than ${limit < 1000 ? `${limit} ms` : `${Math.round(limit / 1000)} s`} and was stopped.`);++ else {++ const r = got && typeof got === 'object' && ('result' in got || 'is_error' in got || 'terminate' in got) ? got : { result: got };++ out = { result: r.result, isError: r.is_error === true, terminate: r.terminate === true, cost: r.cost || null, artifacts: Array.isArray(r.artifacts) ? r.artifacts : [] };++ }++ } catch (err) {++ out = errorResult((err && err.message) || err);++ } finally {++ clearTimeout(timer);++ if (signal) signal.removeEventListener('abort', onAbort);++ }++ if (typeof o.afterToolCall === 'function') {++ try {++ const after = await o.afterToolCall({ toolCall: call, input, tool, assistant, result: out.result, isError: out.isError, messages: thread }, signal);++ if (after) out = { ...out, ...('result' in after ? { result: after.result } : {}), ...('isError' in after ? { isError: !!after.isError } : {}), ...('terminate' in after ? { terminate: !!after.terminate } : {}) };++ } catch (err) { out = errorResult((err && err.message) || err); }++ }++ if (out.cost && typeof out.cost.usd === 'number') totals.usd += out.cost.usd;++ if (tool.idempotent && !out.isError) memo.set(prep.key, { result: out.result, isError: false });++ const done = { toolCall: call, ...out, durationMs: now() - started };++ await emit({ type: 'tool_end', toolCall: call, result: done.result, isError: done.isError, durationMs: done.durationMs });++ return done;++ }++++ async function finish(done, ended) {++ if (!ended) await emit({ type: 'tool_end', toolCall: done.toolCall, result: done.result, isError: done.isError });++ let text = typeof done.result === 'string' ? done.result : JSON.stringify(done.result === undefined ? null : done.result);++ if (text.length > cfg.toolOutputLimit) text = `${text.slice(0, cfg.toolOutputLimit)}\n[… ${text.length - cfg.toolOutputLimit} more characters left out]`;++ const m = { role: 'tool', tool_call_id: done.toolCall.id, name: done.toolCall.name, text, result: done.result, is_error: !!done.isError, at: now() };++ if (done.durationMs != null) m.duration_ms = done.durationMs;++ if (done.memo) m.memo = true;++ if (done.artifacts && done.artifacts.length) m.artifacts = done.artifacts;++ await emit({ type: 'message_start', message: m });++ await emit({ type: 'message_end', message: m });++ return m;++ }++}++++function left(budget, totals) {++ const out = {};++ if (budget.usd != null) out.usd = Math.max(0, budget.usd - totals.usd);++ if (budget.tokens != null) out.tokens = Math.max(0, budget.tokens - totals.tokens);++ if (budget.turns != null) out.turns = Math.max(0, budget.turns - totals.turns);++ return Object.keys(out).length ? out : null;++}++++/** One turn of the model, streamed into message_update events; a throw becomes stop: 'error' (or 'aborted'). */++async function callModel(model, request, emit) {++ const message = { role: 'assistant', text: '', thinking: '', tool_calls: [], at: now() };++ await emit({ type: 'message_start', message: { ...message } });++ let turn;++ try {++ turn = await model.complete({++ ...request,++ onDelta: (d) => {++ if (!d) return;++ if (typeof d.text === 'string') message.text += d.text;++ if (typeof d.thinking === 'string') message.thinking += d.thinking;++ return emit({ type: 'message_update', message: { ...message }, delta: d });++ },++ });++ } catch (err) {++ const aborted = request.signal && request.signal.aborted;++ const out = { ...message, stop: aborted ? 'aborted' : 'error', error: aborted ? 'Stopped.' : String((err && err.message) || err) };++ await emit({ type: 'message_end', message: out });++ return out;++ }++ const t = turn || {};++ const calls = (Array.isArray(t.tool_calls) ? t.tool_calls : []).filter((c) => c && typeof c.name === 'string')++ .map((c, i) => ({ id: String(c.id || `call_${now().toString(36)}_${i}`), name: c.name, input: c.input && typeof c.input === 'object' ? c.input : {} }));++ const out = {++ role: 'assistant', at: message.at,++ text: typeof t.text === 'string' ? t.text : message.text,++ thinking: typeof t.thinking === 'string' ? t.thinking : message.thinking,++ tool_calls: calls,++ stop: ['stop', 'tool_use', 'length', 'error', 'aborted'].includes(t.stop) ? t.stop : calls.length ? 'tool_use' : 'stop',++ usage: t.usage || null, cost: t.cost || null,++ };++ if (t.error) out.error = String(t.error);++ if (!out.thinking) delete out.thinking;++ if (!out.tool_calls.length) delete out.tool_calls;++ await emit({ type: 'message_end', message: out });++ return out;++}++++/**++ * The loop's events as a run's steps (agents.step@1): the person's words, the agent's thinking (folded) and words, and++ * one step per tool call, updated when it ends. `append(step)` stores a step and may return it; `update(seq, patch)`++ * changes one (a tool that ended, words that finished); `delta(seq, text)` is more text for a step being written.++ * Seqs count from `startSeq` (the run's last seq + 1); `turn` starts at `startTurn` and a person's message opens the++ * next one.++ */++function createStepSink({ append, update, delta, runId = null, startSeq = 1, startTurn = 0, toolTitle } = {}) {++ if (typeof append !== 'function') throw new TypeError('agents/loop: createStepSink needs append(step)');++ let seq = startSeq, turn = startTurn, writing = null, thinking = null;++ const tools = new Map();++ const iso = () => new Date().toISOString();++ const add = async (step) => { const st = { run_id: runId, seq: seq++, turn, started_at: iso(), ...step }; await append(st); return st; };++ const patch = async (n, p) => { if (typeof update === 'function') await update(n, p); };++ const more = async (n, t) => { if (typeof delta === 'function') await delta(n, t); };++ return async function onEvent(e) {++ if (!e || !e.type) return;++ const m = e.message;++ if (e.type === 'message_update' && m && m.role === 'assistant' && e.delta) {++ if (typeof e.delta.thinking === 'string' && e.delta.thinking) {++ if (!thinking) thinking = await add({ kind: 'think', from: 'agent', status: 'running', title: 'Thinking', text: '' });++ await more(thinking.seq, e.delta.thinking);++ }++ if (typeof e.delta.text === 'string' && e.delta.text) {++ if (!writing) writing = await add({ kind: 'message', from: 'agent', status: 'running', text: '' });++ await more(writing.seq, e.delta.text);++ }++ return;++ }++ if (e.type === 'tool_start' && e.toolCall) {++ const c = e.toolCall;++ const st = await add({ kind: 'tool', from: 'tool', status: 'running', title: (typeof toolTitle === 'function' && toolTitle(c)) || c.name, tool_call: { name: c.name, input: c.input || {} } });++ tools.set(c.id, st.seq);++ return;++ }++ if (e.type === 'tool_end' && e.toolCall && tools.has(e.toolCall.id)) {++ await patch(tools.get(e.toolCall.id), { status: e.isError ? 'failed' : 'done', result: e.result === undefined ? null : e.result, ended_at: iso() });++ return;++ }++ if (e.type !== 'message_end' || !m) return;++ if (m.role === 'user') {++ // A person's message opens a turn (the run's first words are turn 0).++ if (seq > startSeq || turn > startTurn) turn += 1;++ await add({ kind: 'message', from: 'person', status: 'done', text: String(m.text || ''), ended_at: iso() });++ return;++ }++ if (m.role !== 'assistant' || !m.stop) return;++ const failed = m.stop === 'error';++ if (thinking) await patch(thinking.seq, { status: 'done', text: m.thinking || '', ended_at: iso() });++ else if (m.thinking) await add({ kind: 'think', from: 'agent', status: 'done', title: 'Thinking', text: m.thinking, ended_at: iso() });++ if (writing) await patch(writing.seq, { status: failed ? 'failed' : 'done', text: m.text || '', ended_at: iso() });++ else if (m.text) await add({ kind: 'message', from: 'agent', status: failed ? 'failed' : 'done', text: m.text, ended_at: iso() });++ else if (failed) await add({ kind: 'message', from: 'system', status: 'failed', text: m.error || 'The model did not answer.', ended_at: iso() });++ writing = null; thinking = null;++ };++}++++module.exports = { runLoop, createStepSink, checkInput, STOPS, DEFAULTS };@@@ -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.159.0';++const SDK_VERSION = '0.160.0';module.exports = {createClient,nnew file mode 100644@@@ -0,0 +1,137 @@++'use strict';++// openvibe-sdk/agents/api: the Run API from a store, on a real Node http server, read back with the SDK's own client.++const assert = require('node:assert/strict');++const http = require('node:http');++const { run } = require('./helpers');++const { createRunApi } = require('../src/agents/api');++const { createClient } = require('../src/core');++const { createAgentsClient } = require('../src/agents');++++/** A memory store: Ada's runs; a chat that answers a moment after each message. */++function memoryStore() {++ const runs = new Map(); const steps = new Map(); const seen = { starts: [], answers: [] };++ let n = 0;++ const mine = (owner, id) => { const r = runs.get(id); return r && r.owner.id === owner.id ? r : null; };++ return {++ seen, runs, steps,++ async list({ owner, kind, limit }) { return { runs: [...runs.values()].filter((r) => r.owner.id === owner.id && (!kind || r.kind === kind)).slice(0, limit), next: null }; },++ async get({ owner, id }) { return mine(owner, id); },++ async steps({ owner, id, after, limit }) { if (!mine(owner, id)) return null; return { steps: (steps.get(id) || []).filter((s) => s.seq > after).slice(0, limit), next: null }; },++ async start({ owner, input, idempotencyKey }) {++ seen.starts.push({ input, idempotencyKey });++ const id = `c_${++n}`;++ const r = { id, kind: input.kind || 'chat', title: input.text.slice(0, 40), owner, status: 'waiting', waiting: { for: 'message' }, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };++ runs.set(id, r); steps.set(id, [{ run_id: id, seq: 1, kind: 'message', from: 'person', text: input.text }]);++ return r;++ },++ async say({ owner, id, text }) {++ const r = mine(owner, id); if (!r) { const e = new Error('No such run'); e.status = 404; e.code = 'runs.not_found'; throw e; }++ const list = steps.get(id); const step = { run_id: id, seq: list.length + 1, kind: 'message', from: 'person', text };++ list.push(step);++ setTimeout(() => { list.push({ run_id: id, seq: list.length + 1, kind: 'message', from: 'agent', text: `You said: ${text}` }); if (text === 'bye') r.status = 'done'; }, 80);++ return step;++ },++ async answer({ approval, answer }) { seen.answers.push([approval, answer]); return { ok: true }; },++ };++}++++async function serve(api) {++ const server = http.createServer((req, res) => api.handle(req, res, () => { res.statusCode = 418; res.end('not mine'); }));++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ return { url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((ok) => server.close(ok)) };++}++const ADA = { type: 'user', id: 'usr_ada' };++const subject = async (req) => (req.headers.authorization === 'Bearer ada' ? ADA : req.headers.authorization === 'Bearer bo' ? { type: 'user', id: 'usr_bo' } : null);++++run([++ ['the routes, read back with the SDK client: start, list, get, say, steps, the stream to its end, approvals', async () => {++ const store = memoryStore();++ const srv = await serve(createRunApi({ store, subject, product: 'chat', prefix: '/api/v1', pollMs: 30 }));++ try {++ const agents = createAgentsClient(createClient({ baseUrls: { network: srv.url }, token: 'ada' }));++ const started = await agents.runs.start('A goal bar for my sub goal', { idempotencyKey: 'k-1' });++ assert.equal(started.ref, 'chat:c_1', 'every run carries its ref');++ assert.deepEqual(store.seen.starts[0], { input: { text: 'A goal bar for my sub goal' }, idempotencyKey: 'k-1' });++ const listed = await agents.runs.list({ kind: 'chat' });++ assert.deepEqual(listed.runs.map((r) => r.ref), ['chat:c_1']);++ assert.equal((await agents.runs.get('chat:c_1')).title, 'A goal bar for my sub goal', 'a ref works as the id');++ assert.equal(await agents.runs.get('c_9'), null, 'a run that is not there is null (404)');++ const said = await agents.runs.say('c_1', 'Make it purple');++ assert.deepEqual([said.seq, said.from], [2, 'person']);++ const got = [];++ const end = await agents.runs.follow('c_1', { onStep: (s) => { got.push(s.text); if (s.text === 'You said: Make it purple') agents.runs.say('c_1', 'bye'); } });++ assert.equal(end.status, 'done', 'follow resolves with the end status');++ assert.deepEqual(got.filter((t) => t.startsWith('You said')), ['You said: Make it purple', 'You said: bye']);++ assert.deepEqual(await agents.approvals.answer('c_1', 'apr_1', 'approve'), { ok: true });++ assert.deepEqual(store.seen.answers, [['apr_1', 'approve']]);++ } finally { await srv.close(); }++ }],++ ['who may: no subject is 401; someone else\'s run is 404; a path that is not the Run API\'s goes to next()', async () => {++ const store = memoryStore();++ const srv = await serve(createRunApi({ store, subject, product: 'chat', prefix: '/api/v1' }));++ try {++ await store.start({ owner: ADA, input: { text: 'mine' } });++ const call = (path, o = {}) => fetch(srv.url + path, { ...o, headers: { 'content-type': 'application/json', ...(o.token ? { authorization: `Bearer ${o.token}` } : {}) } });++ let r = await call('/api/v1/runs');++ assert.equal(r.status, 401); assert.equal((await r.json()).code, 'auth.required');++ r = await call('/api/v1/runs/c_1', { token: 'bo' });++ assert.equal(r.status, 404, 'Bo cannot read Ada\'s run');++ r = await call('/api/v1/runs/c_1/messages', { method: 'POST', token: 'bo', body: JSON.stringify({ text: 'hi' }) });++ assert.equal(r.status, 404, 'nor speak in it');++ r = await call('/api/v1/other', { token: 'ada' });++ assert.equal(r.status, 418, 'not the Run API\'s: next()');++ r = await call('/api/v1/runs/a%20b', { token: 'ada' });++ assert.equal(r.status, 404, 'an id that is not one');++ } finally { await srv.close(); }++ }],++ ['what is refused: no words, a kind that is not one, a bad body, an answer without one; a mandate from a caller is dropped', async () => {++ const store = memoryStore();++ const srv = await serve(createRunApi({ store, subject, product: 'actor' }));++ try {++ const post = (path, body, raw) => fetch(srv.url + path, { method: 'POST', headers: { 'content-type': 'application/json', authorization: 'Bearer ada' }, body: raw || JSON.stringify(body) });++ assert.equal((await (await post('/runs', { text: ' ' })).json()).code, 'runs.start_needs_words');++ assert.equal((await (await post('/runs', { text: 'x', kind: 'wish' })).json()).code, 'runs.bad_kind');++ assert.equal((await post('/runs', null, '{nope')).status, 400);++ const r = await post('/runs', { text: 'Post the clip', kind: 'task', mandate: { source: 'feed', asked_by: { type: 'user', id: 'usr_someone' }, strength: 'explicit' } });++ assert.equal(r.status, 201);++ assert.equal('mandate' in store.seen.starts[0].input, false, 'only intake sets a mandate');++ assert.equal((await post('/runs/c_1/approvals/apr_1', {})).status, 422);++ assert.equal((await post('/runs/c_1/approvals/bad%2Fid', { answer: 'yes' })).status, 404);++ } finally { await srv.close(); }++ }],++ ['a store without a method says so (409); a store\'s own error goes out as it is; anything else says nothing of the store', async () => {++ const failing = { async list() { throw new Error('the database password is hunter2'); }, async get() { const e = new Error('Paused by the owner'); e.status = 423; e.code = 'runs.paused'; throw e; } };++ const srv = await serve(createRunApi({ store: failing, subject, product: 'codes' }));++ try {++ const get = (p) => fetch(srv.url + p, { headers: { authorization: 'Bearer ada' } });++ let r = await get('/runs');++ assert.equal(r.status, 500);++ const body = await r.json();++ assert.equal(body.error, 'Runs are unavailable right now');++ assert.ok(!JSON.stringify(body).includes('hunter2'), 'no store detail leaks');++ r = await get('/runs/tsk_1');++ assert.deepEqual([r.status, (await r.json()).code], [423, 'runs.paused']);++ r = await fetch(srv.url + '/runs/tsk_1/messages', { method: 'POST', headers: { authorization: 'Bearer ada', 'content-type': 'application/json' }, body: '{"text":"hi"}' });++ assert.deepEqual([r.status, (await r.json()).code], [409, 'runs.cannot_say']);++ } finally { await srv.close(); }++ }],++ ['a store that pushes its own events is used for the stream, with refs on runs', async () => {++ const store = { ...memoryStore(), async follow({ emit }) { emit('step', { step: { seq: 2, kind: 'message', text: 'hi' } }, 2); emit('run', { run: { id: 'c_1', status: 'done' } }, 2); emit('end', { status: 'done' }, 2); } };++ await store.start({ owner: ADA, input: { text: 'x' } });++ const srv = await serve(createRunApi({ store, subject, product: 'chat' }));++ try {++ const r = await fetch(srv.url + '/runs/c_1/events', { headers: { authorization: 'Bearer ada', 'last-event-id': '1' } });++ const text = await r.text();++ assert.match(text, /id: 2\nevent: step\ndata: \{"type":"step","step":\{"seq":2/);++ assert.match(text, /"run":\{"id":"c_1","status":"done","ref":"chat:c_1"\}/);++ assert.match(text, /event: end/);++ const bad = await fetch(srv.url + '/runs/c_1/events', { headers: { authorization: 'Bearer ada', 'last-event-id': 'x' } });++ assert.equal(bad.status, 400);++ } finally { await srv.close(); }++ }],++ ['a store and a subject are required', () => {++ assert.throws(() => createRunApi({ subject, product: 'chat' }), /a store is required/);++ assert.throws(() => createRunApi({ store: {}, product: 'chat' }), /subject\(req\) is required/);++ assert.throws(() => createRunApi({ store: {}, subject, product: 'Chat!' }), /product is a short lowercase name/);++ }],++]);nnew file mode 100644@@@ -0,0 +1,78 @@++'use strict';++// openvibe-sdk/agents/dispatch: the Dispatcher's choices, as pure functions (agent-engine.md §3).++const assert = require('node:assert/strict');++const { run } = require('./helpers');++const { ladder, pick, escalate, harnessFor, placeFor, estimate, slice } = require('../src/agents/dispatch');++++const C = [++ { id: 'credits:big', rung: 'credits', family: 'claude', tier: 'strong', caps: ['tools', 'vision', 'json', 'thinking'], context: 200000, speed: 60, price: { in: 3, out: 15 } },++ { id: 'credits:cheap', rung: 'credits', family: 'deepseek', tier: 'medium', caps: ['tools', 'json'], context: 128000, speed: 50, price: { in: 0.28, out: 0.42 } },++ { id: 'allowance:flash', rung: 'allowance', family: 'deepseek', tier: 'small', caps: ['tools', 'json'], context: 64000, speed: 90, price: { in: 0, out: 0 } },++ { id: 'machine:qwen', rung: 'machine', family: 'qwen', tier: 'small', caps: ['tools'], context: 32000, speed: 12 },++ { id: 'key:gpt', rung: 'key', family: 'openai', tier: 'strong', caps: ['tools', 'vision', 'json'], context: 400000, speed: 70, price: { in: 1.25, out: 10 } },++ { id: 'plan:codex', rung: 'plan', family: 'openai', tier: 'strong', caps: ['tools', 'files', 'shell'], context: 400000, speed: 40, available: false },++];++const ids = (l) => l.map((c) => c.id);++++run([++ ['the ladder: own machine, plan, key, allowance, credits, cheapest first; what cannot is skipped with why', () => {++ assert.deepEqual(ids(ladder(C)), ['machine:qwen', 'key:gpt', 'allowance:flash', 'credits:cheap', 'credits:big']);++ const l = ladder(C, { need: { caps: ['vision'] } });++ assert.deepEqual(ids(l), ['key:gpt', 'credits:big']);++ assert.deepEqual(l.skipped.map((s) => [s.choice.id, s.why]), [++ ['credits:cheap', 'cannot vision'], ['allowance:flash', 'cannot vision'], ['machine:qwen', 'cannot vision'], ['plan:codex', 'not available now'],++ ]);++ assert.deepEqual(ids(ladder(C, { need: { context: 100000, speed: 30 } })), ['key:gpt', 'credits:cheap', 'credits:big']);++ assert.equal(ladder(C)[0].why, 'machine');++ }],++ ['a person\'s pick wins when it can; when it cannot, it is passed over with the reason', () => {++ assert.deepEqual(ids(ladder(C, { pin: 'credits:big' })).slice(0, 2), ['credits:big', 'machine:qwen']);++ assert.equal(ladder(C, { pin: 'credits:big' })[0].why, 'your pick');++ const p = pick(C, { pin: 'machine:qwen', need: { caps: ['json'] } });++ assert.equal(p.choice.id, 'key:gpt');++ assert.deepEqual(p.passedOver, { id: 'machine:qwen', why: 'cannot json' });++ assert.equal(pick([], {}).choice, null);++ }],++ ['a step that needs strength tries strong first inside a rung; the budget leaves out what it cannot pay', () => {++ assert.deepEqual(ids(ladder(C, { need: { tier: 'strong' } })), ['key:gpt', 'credits:big']);++ assert.deepEqual(ids(ladder(C, { budgetLeft: 0.002 })), ['machine:qwen', 'allowance:flash', 'credits:cheap']);++ }],++ ['escalate only on checkable evidence, to a higher tier; a grader\'s failure changes family', () => {++ const cheap = C[1];++ assert.equal(escalate(cheap, { kind: 'hunch' }, C), null, 'never on a hunch');++ assert.equal(escalate(cheap, null, C), null);++ const up = escalate(cheap, { kind: 'test', detail: '3 failing' }, C);++ assert.equal(up.choice.id, 'key:gpt');++ assert.equal(up.why, 'a test failed: 3 failing');++ assert.equal(escalate(C[4], { kind: 'schema' }, C), null, 'nothing above strong');++ const other = escalate(C[4], { kind: 'grader' }, C);++ assert.equal(other.choice.id, 'credits:big', 'a strong model of another family');++ assert.equal(escalate(cheap, { kind: 'unsure' }, C, { tried: ['key:gpt'] }).choice.id, 'credits:big');++ }],++ ['the harness by capability: the loop for turns with tools, a signed-in plan, else dsh', () => {++ const H = [{ id: 'loop', caps: ['tools'] }, { id: 'dsh', caps: ['tools', 'files', 'shell'] }, { id: 'codex', plan: 'codex', caps: ['tools', 'files', 'shell'] }, { id: 'browser', caps: ['tools', 'browser', 'desks'] }];++ assert.equal(harnessFor({ needs: ['tools'] }, H).harness.id, 'loop');++ assert.equal(harnessFor({ needs: ['files', 'shell'] }, H).harness.id, 'dsh');++ const own = harnessFor({ needs: ['files', 'shell'] }, H, { plans: ['codex'] });++ assert.deepEqual([own.harness.id, own.why], ['codex', 'your codex plan']);++ assert.equal(harnessFor({ needs: ['browser'] }, H).harness.id, 'browser');++ assert.equal(harnessFor({ needs: ['voice'] }, H), null);++ }],++ ['the place: your computer first (free), a runner for a repository elsewhere, an OpenVibe computer when allowed', () => {++ const M = [{ id: 'mch_1', name: 'Desk PC', online: true, allows: ['worker', 'desks'], repos: ['ada/site'] }, { id: 'mch_2', online: false, allows: ['worker'], repos: ['ada/game'] }];++ assert.deepEqual(placeFor({ target: { repo: 'ada/site' } }, { machines: M }).where, { kind: 'machine', id: 'mch_1', name: 'Desk PC' });++ assert.deepEqual(placeFor({ target: { repo: 'ada/game' } }, { machines: M }).where, { kind: 'runner' });++ assert.equal(placeFor({ needs: ['browser'] }, { machines: M }).where.kind, 'machine');++ assert.equal(placeFor({ needs: ['browser'] }, { machines: [] }).where.kind, 'openvibe');++ assert.equal(placeFor({ needs: ['browser'] }, { machines: [], cloud: false }), null, 'nowhere it may go');++ assert.deepEqual(placeFor({ robot: 'rbt_1' }).where, { kind: 'machine', id: 'rbt_1' });++ assert.equal(placeFor({}).where.kind, 'openvibe');++ }],++ ['a price says itself; a child\'s budget is a slice of its parent\'s', () => {++ assert.equal(estimate(C[0], { tokensIn: 1e6, tokensOut: 1e6 }), 18);++ assert.equal(estimate(C[3], { tokensIn: 1e6 }), 0, 'your own machine is free');++ assert.deepEqual(slice({ usd: 1, tokens: 10000, steps: 40 }, 0.25), { usd: 0.25, tokens: 2500, steps: 10 });++ assert.deepEqual(slice({ usd: 1 }, 7), { usd: 1 }, 'never more than the whole');++ assert.deepEqual(slice(null, 0.5), {});++ }],++]);nnew file mode 100644@@@ -0,0 +1,165 @@++'use strict';++// openvibe-sdk/agents/loop: the light agent loop (a port of pi's, MIT) and its step sink, driven by a scripted model.++const assert = require('node:assert/strict');++const { run } = require('./helpers');++const { runLoop, createStepSink, checkInput, STOPS } = require('../src/agents/loop');++++/** A model that answers from a script, one turn per call, and remembers what it was shown. */++function scripted(turns) {++ const seen = [];++ return {++ id: 'test:scripted',++ seen,++ async complete(req) {++ seen.push(req.messages.map((m) => `${m.role}:${m.text || (m.tool_calls || []).map((c) => c.name).join('+') || ''}`));++ const t = turns.shift();++ if (!t) return { text: 'Done.', stop: 'stop' };++ if (t.throws) throw new Error(t.throws);++ if (t.stream) for (const d of t.stream) await req.onDelta(d);++ return t;++ },++ };++}++const tool = (name, run, extra = {}) => ({ name, description: name, run, ...extra });++++run([++ ['a question answered with a tool, then words; the steps follow it in order', async () => {++ const model = scripted([++ { thinking: 'They want a reminder.', tool_calls: [{ id: 'c1', name: 'remind', input: { what: 'stretch', every: 120 } }], usage: { tokens: 100 }, cost: { usd: 0.001 } },++ { stream: [{ text: 'Reminder ' }, { text: 'set.' }], usage: { tokens: 50 }, cost: { usd: 0.0005 } },++ ]);++ const steps = new Map(); const deltas = [];++ const sink = createStepSink({ runId: 'r1', startSeq: 2, append: (s) => steps.set(s.seq, { ...s }), update: (n, p) => Object.assign(steps.get(n), p), delta: (n, t) => deltas.push([n, t]) });++ const remind = tool('remind', async (input) => ({ result: { id: 'rem_1', ...input }, cost: { usd: 0.0001 } }), { inputSchema: { type: 'object', required: ['what'], properties: { what: { type: 'string' }, every: { type: 'integer' } } } });++ const out = await runLoop({ model, tools: [remind], prompts: [{ role: 'user', text: 'Remind me to stretch every 2 hours' }], onEvent: sink });++ assert.equal(out.stop, 'done');++ assert.equal(out.turns, 2);++ assert.deepEqual(out.usage, { tokens: 150 });++ assert.equal(out.cost.usd, 0.0016, 'model turns and the tool\'s own cost');++ assert.deepEqual(out.messages.map((m) => m.role), ['user', 'assistant', 'tool', 'assistant']);++ assert.deepEqual(model.seen[1], ['user:Remind me to stretch every 2 hours', 'assistant:remind', 'tool:{"id":"rem_1","what":"stretch","every":120}'], 'the second turn sees the tool\'s result');++ const list = [...steps.values()];++ assert.deepEqual(list.map((s) => [s.seq, s.kind, s.from, s.status]), [++ [2, 'message', 'person', 'done'], [3, 'think', 'agent', 'done'], [4, 'tool', 'tool', 'done'], [5, 'message', 'agent', 'done'],++ ]);++ assert.equal(list[2].tool_call.name, 'remind');++ assert.deepEqual(list[2].result, { id: 'rem_1', what: 'stretch', every: 120 });++ assert.equal(list[3].text, 'Reminder set.');++ assert.deepEqual(deltas, [[5, 'Reminder '], [5, 'set.']], 'the words stream into their step');++ assert.ok(list.every((s) => s.run_id === 'r1' && s.turn === 0));++ }],++ ['parallel tools keep the model\'s order; a sequential tool runs them one by one', async () => {++ const order = [];++ const slow = tool('slow', async () => { await new Promise((r) => setTimeout(r, 30)); order.push('slow'); return { result: 'slow' }; });++ const fast = tool('fast', async () => { order.push('fast'); return { result: 'fast' }; });++ let out = await runLoop({ model: scripted([{ tool_calls: [{ id: 'a', name: 'slow', input: {} }, { id: 'b', name: 'fast', input: {} }] }]), tools: [slow, fast], prompts: [{ role: 'user', text: 'go' }] });++ assert.deepEqual(order, ['fast', 'slow'], 'run together');++ assert.deepEqual(out.messages.filter((m) => m.role === 'tool').map((m) => m.name), ['slow', 'fast'], 'results in the model\'s order');++ order.length = 0;++ out = await runLoop({ model: scripted([{ tool_calls: [{ id: 'a', name: 'slow', input: {} }, { id: 'b', name: 'fast', input: {} }] }]), tools: [{ ...slow, sequential: true }, fast], prompts: [{ role: 'user', text: 'go' }] });++ assert.deepEqual(order, ['slow', 'fast'], 'one by one');++ }],++ ['an unknown tool, input its schema refuses, a throw and a timeout come back as errors, never a crash', async () => {++ const boom = tool('boom', async () => { throw new Error('the disk is full'); });++ const hang = tool('hang', (input, ctx) => new Promise((resolve) => ctx.signal.addEventListener('abort', () => resolve({ result: 'late' }))), { timeoutMs: 40 });++ const strict = tool('strict', async () => ({ result: 'ok' }), { inputSchema: { type: 'object', required: ['n'], properties: { n: { type: 'integer' } }, additionalProperties: false } });++ const out = await runLoop({++ model: scripted([{ tool_calls: [{ id: '1', name: 'nope', input: {} }, { id: '2', name: 'boom', input: {} }, { id: '3', name: 'hang', input: {} }, { id: '4', name: 'strict', input: { n: 1.5 } }, { id: '5', name: 'strict', input: { n: 1, x: 2 } }] }]),++ tools: [boom, hang, strict], prompts: [{ role: 'user', text: 'go' }],++ });++ const errs = out.messages.filter((m) => m.role === 'tool').map((m) => [m.name, m.is_error, m.result.error]);++ assert.deepEqual(errs, [++ ['nope', true, 'There is no tool named nope.'],++ ['boom', true, 'the disk is full'],++ ['hang', true, 'hang took longer than 40 ms and was stopped.'],++ ['strict', true, 'The input does not fit strict: input.n should be integer.'],++ ['strict', true, 'The input does not fit strict: input.x is not an input of this tool.'],++ ]);++ assert.equal(out.stop, 'done', 'the model was told and finished');++ }],++ ['a call cut off by the output limit is never run', async () => {++ let ran = 0;++ const out = await runLoop({ model: scripted([{ stop: 'length', tool_calls: [{ id: '1', name: 'send', input: { to: 'every' } }] }]), tools: [tool('send', async () => { ran++; return { result: 'sent' }; })], prompts: [{ role: 'user', text: 'go' }] });++ assert.equal(ran, 0);++ assert.match(out.messages.find((m) => m.role === 'tool').result.error, /hit the output limit/);++ }],++ ['the approval gate blocks a call; terminate ends the run', async () => {++ const asked = [];++ const pay = tool('pay', async () => ({ result: 'paid' }));++ let out = await runLoop({ model: scripted([{ tool_calls: [{ id: '1', name: 'pay', input: { usd: 5 } }] }]), tools: [pay], prompts: [{ role: 'user', text: 'pay' }],++ beforeToolCall: async ({ toolCall, input }) => { asked.push([toolCall.name, input.usd]); return { block: true, reason: 'The person said not now.' }; } });++ assert.deepEqual(asked, [['pay', 5]]);++ assert.equal(out.messages.find((m) => m.role === 'tool').result.error, 'The person said not now.');++ assert.equal(out.stop, 'done');++ out = await runLoop({ model: scripted([{ tool_calls: [{ id: '1', name: 'finish', input: {} }] }, { text: 'never asked' }]), tools: [tool('finish', async () => ({ result: 'over', terminate: true }))], prompts: [{ role: 'user', text: 'go' }] });++ assert.equal(out.stop, 'terminated');++ assert.equal(out.turns, 1, 'no turn after a tool that ends the run');++ }],++ ['the same call repeated gets a reminder; an idempotent tool answers from the memo', async () => {++ let looks = 0, reads = 0;++ const look = tool('look', async () => { looks++; return { result: 'same' }; });++ const read = tool('read', async () => { reads++; return { result: 'page' }; }, { idempotent: true });++ const call = (id, name) => ({ tool_calls: [{ id, name, input: { q: 'x' } }] });++ const out = await runLoop({ model: scripted([call('1', 'look'), call('2', 'look'), call('3', 'look'), call('4', 'look'), call('5', 'read'), call('6', 'read')]), tools: [look, read], prompts: [{ role: 'user', text: 'go' }], repeatLimit: 3 });++ assert.equal(looks, 3, 'the fourth was not run');++ assert.match(out.messages.filter((m) => m.name === 'look').pop().result.error, /already called look with this same input 3 times/);++ assert.equal(reads, 1, 'read once');++ assert.equal(out.messages.filter((m) => m.name === 'read').pop().memo, true);++ }],++ ['the budget stops it between turns; the model is told what is left', async () => {++ const model = scripted([{ tool_calls: [{ id: '1', name: 'x', input: {} }], cost: { usd: 0.03 } }, { tool_calls: [{ id: '2', name: 'x', input: { a: 1 } }], cost: { usd: 0.03 } }, { text: 'never' }]);++ const lefts = [];++ const wrapped = { ...model, complete: (req) => { lefts.push(req.budgetLeft); return model.complete(req); } };++ const out = await runLoop({ model: wrapped, tools: [tool('x', async () => ({ result: 1 }))], prompts: [{ role: 'user', text: 'go' }], budget: { usd: 0.05 } });++ assert.equal(out.stop, 'budget');++ assert.equal(out.turns, 2);++ assert.deepEqual(lefts.map((l) => Math.round(l.usd * 100) / 100), [0.05, 0.02]);++ const capped = await runLoop({ model: scripted([{ tool_calls: [{ id: '1', name: 'x', input: {} }] }, { tool_calls: [{ id: '2', name: 'x', input: { b: 1 } }] }, { text: 'no' }]), tools: [tool('x', async () => ({ result: 1 }))], prompts: [{ role: 'user', text: 'go' }], maxTurns: 2 });++ assert.equal(capped.stop, 'turns');++ }],++ ['steering is taken before the next turn; a follow-up wakes it after it would stop; a person\'s word opens a turn', async () => {++ const queue = []; // the person writes while the tool runs++ let follow = [{ role: 'user', text: 'and a sound' }];++ const model = scripted([{ tool_calls: [{ id: '1', name: 'draw', input: {} }] }, { text: 'Purple it is.' }, { text: 'Sound added.' }]);++ const steps = [];++ const out = await runLoop({ model, tools: [tool('draw', async () => { queue.push({ role: 'user', text: 'make it purple' }); return { result: 'bar' }; })], prompts: [{ role: 'user', text: 'a goal bar' }],++ steering: () => queue.splice(0), followUp: () => { const f = follow; follow = []; return f; },++ onEvent: createStepSink({ append: (s) => steps.push(s), startSeq: 1 }) });++ assert.equal(out.stop, 'done');++ assert.deepEqual(out.messages.filter((m) => m.role === 'user').map((m) => m.text), ['a goal bar', 'make it purple', 'and a sound']);++ assert.deepEqual(model.seen[1].slice(-1), ['user:make it purple'], 'the steering words reached the next turn');++ assert.deepEqual(steps.filter((s) => s.from === 'person').map((s) => [s.text, s.turn]), [['a goal bar', 0], ['make it purple', 1], ['and a sound', 2]]);++ }],++ ['a model that throws ends the run with stop: error; an abort stops it; ui-only entries never reach the model', async () => {++ const steps = [];++ let out = await runLoop({ model: scripted([{ throws: 'provider down' }]), prompts: [{ role: 'user', text: 'hi' }], onEvent: createStepSink({ append: (s) => steps.push(s) }) });++ assert.equal(out.stop, 'error');++ assert.equal(out.error, 'provider down');++ assert.deepEqual(steps.map((s) => [s.kind, s.from, s.status, s.text]), [['message', 'person', 'done', 'hi'], ['message', 'system', 'failed', 'provider down']]);++ const ctl = new AbortController(); ctl.abort();++ out = await runLoop({ model: scripted([{ text: 'no' }]), prompts: [{ role: 'user', text: 'hi' }], signal: ctl.signal });++ assert.equal(out.stop, 'aborted');++ const model = scripted([{ text: 'ok' }]);++ await runLoop({ model, messages: [{ role: 'user', text: 'old' }, { role: 'assistant', text: 'a card', ui_only: true }], prompts: [{ role: 'user', text: 'new' }] });++ assert.deepEqual(model.seen[0], ['user:old', 'user:new']);++ assert.ok(STOPS.includes('budget'));++ }],++ ['a long tool output is clipped before the model sees it', async () => {++ const model = scripted([{ tool_calls: [{ id: '1', name: 'dump', input: {} }] }, { text: 'ok' }]);++ const out = await runLoop({ model, tools: [tool('dump', async () => ({ result: 'x'.repeat(500) }))], prompts: [{ role: 'user', text: 'go' }], toolOutputLimit: 100 });++ const m = out.messages.find((x) => x.role === 'tool');++ assert.ok(m.text.length < 160 && /more characters left out/.test(m.text));++ assert.equal(m.result.length, 500, 'the whole result is kept in the thread');++ }],++ ['checkInput: the subset tools use', () => {++ const s = { type: 'object', required: ['a'], properties: { a: { type: 'string', enum: ['x', 'y'] }, b: { type: 'array', items: { type: 'number' } } } };++ assert.equal(checkInput(s, { a: 'x', b: [1, 2] }), null);++ assert.equal(checkInput(s, { b: [] }), 'input.a is required');++ assert.equal(checkInput(s, { a: 'z' }), 'input.a should be one of "x", "y"');++ assert.equal(checkInput(s, { a: 'x', b: [1, 'two'] }), 'input.b[1] should be number');++ }],++ ['no model, no loop', async () => {++ await assert.rejects(() => runLoop({}), /a model adapter with complete\(\) is required/);++ assert.throws(() => createStepSink({}), /needs append/);++ }],++]);@@@ -72,7 +72,7 @@ run([})().catch((err) => { console.error(err); process.exit(1); });`;const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));-- assert.equal(subpaths.length, 112); // 0.152.0 train: elements (#227) and ui-layout (#226) add the 110th and 111th; // composer, feed and agents add the 104th-106th after user-card; the 0.148.0 train's codes, player and the rest bring 109; elements brings 110.++ assert.equal(subpaths.length, 115); // 0.160.0: agents/loop, agents/dispatch and agents/api are the 113th to 115th. // 0.152.0 train: elements (#227) and ui-layout (#226) add the 110th and 111th; // composer, feed and agents add the 104th-106th after user-card; the 0.148.0 train's codes, player and the rest bring 109; elements brings 110.assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');} finally {fs.rmSync(tmp, { recursive: true, force: true });nnew file mode 100644@@@ -0,0 +1,28 @@++// openvibe-sdk/agents/api: the Run API from a product's own store (Contracts docs/agents-runs.md).++import type { IncomingMessage, ServerResponse } from 'node:http';++++export interface RunOwner { type: string; id: string; }++export interface RunStore {++ list?(q: { owner: RunOwner; kind: string | null; status: string | null; q: string | null; limit: number; cursor: string | null }): Promise<{ runs: object[]; next?: string | null }>;++ get?(q: { owner: RunOwner; id: string }): Promise<object | null>;++ steps?(q: { owner: RunOwner; id: string; after: number; limit: number }): Promise<{ steps: object[]; next?: string | null } | null>;++ start?(q: { owner: RunOwner; input: { text: string; [key: string]: unknown }; idempotencyKey: string | null }): Promise<object>;++ say?(q: { owner: RunOwner; id: string; text: string; attachments: unknown[] }): Promise<object>;++ stop?(q: { owner: RunOwner; id: string; reason: string | null }): Promise<object>;++ answer?(q: { owner: RunOwner; id: string; approval: string; answer: unknown }): Promise<unknown>;++ follow?(q: { owner: RunOwner; id: string; after: number; signal: AbortSignal; emit(event: string, data: object, id?: number): void }): Promise<void>;++}++export interface RunApiOptions {++ store: RunStore;++ subject(req: IncomingMessage): Promise<{ type?: string; id: string } | null> | { type?: string; id: string } | null;++ product: string;++ prefix?: string;++ pollMs?: number;++ streamMs?: number;++ /** Intake's own hook: a mandate for a start it took in (agents.run-start@1's MANDATE); callers never set one. */++ intake?(req: IncomingMessage, input: object): Promise<object | null> | object | null;++}++export function createRunApi(options: RunApiOptions): { handle(req: IncomingMessage, res: ServerResponse, next?: () => void): Promise<void>; KINDS: string[]; STATUSES: string[] };++export function startInput(body: object): { text: string; [key: string]: unknown };++export const KINDS: string[];++export const STATUSES: string[];nnew file mode 100644@@@ -0,0 +1,34 @@++// openvibe-sdk/agents/dispatch: the Dispatcher (agent-engine.md §3), pure functions over a person's choices.++++export type Rung = 'machine' | 'plan' | 'key' | 'allowance' | 'credits';++export type Tier = 'small' | 'medium' | 'strong';++export interface Choice {++ id: string;++ name?: string;++ rung?: Rung;++ family?: string;++ tier?: Tier;++ caps?: string[];++ context?: number;++ speed?: number;++ price?: { in?: number; out?: number };++ available?: boolean;++}++export interface Need { caps?: string[]; context?: number; speed?: number; tier?: Tier; }++export interface LadderOptions { need?: Need; pin?: string | null; avoid?: string[]; budgetLeft?: number | null; tokens?: { tokensIn?: number; tokensOut?: number }; }++export type Ladder = Array<Choice & { why: string }> & { skipped: Array<{ choice: Choice; why: string; pinned?: boolean }> };++export function ladder(choices: Choice[], options?: LadderOptions): Ladder;++export function pick(choices: Choice[], options?: LadderOptions): { choice: (Choice & { why: string }) | null; why: string; passedOver: { id: string; why: string } | null; skipped: Ladder['skipped'] };++export interface Evidence { kind: 'test' | 'schema' | 'tool' | 'grader' | 'unsure'; detail?: string; }++export function escalate(current: Choice, evidence: Evidence | null, choices: Choice[], options?: { need?: Need; tried?: string[]; budgetLeft?: number | null }): { choice: Choice & { why: string }; why: string } | null;++export interface Harness { id: string; caps?: string[]; plan?: string; }++export function harnessFor(task: { needs?: string[] }, harnesses: Harness[], options?: { plans?: string[] }): { harness: Harness; why: string } | null;++export interface Machine { id: string; name?: string; online?: boolean; allows?: string[]; repos?: string[]; }++export interface Where { kind: 'openvibe' | 'machine' | 'runner' | 'this-computer'; id?: string; name?: string; }++export function placeFor(run: { target?: unknown; needs?: string[]; robot?: string; untrusted?: boolean; heavy?: boolean }, options?: { machines?: Machine[]; cloud?: boolean }): { where: Where; why: string } | null;++export function estimate(choice: Choice, tokens?: { tokensIn?: number; tokensOut?: number }): number;++export function slice(parent: { usd?: number; tokens?: number; seconds?: number; steps?: number } | null, share: number): { usd?: number; tokens?: number; seconds?: number; steps?: number };++export function cannot(choice: Choice, need?: Need): string | null;++export const RUNGS: Rung[];++export const TIERS: Tier[];++export const EVIDENCE: Array<Evidence['kind']>;nnew file mode 100644@@@ -0,0 +1,106 @@++// openvibe-sdk/agents/loop: the light agent loop (a port of pi's agent loop, MIT, (c) 2025 Mario Zechner).++++export type LoopRole = 'system' | 'user' | 'assistant' | 'tool';++export interface LoopToolCall { id: string; name: string; input: Record<string, unknown>; }++export interface LoopMessage {++ role: LoopRole;++ text?: string;++ thinking?: string;++ tool_calls?: LoopToolCall[];++ tool_call_id?: string;++ name?: string;++ result?: unknown;++ is_error?: boolean;++ memo?: boolean;++ duration_ms?: number;++ artifacts?: unknown[];++ stop?: 'stop' | 'tool_use' | 'length' | 'error' | 'aborted';++ usage?: { tokens?: number } | null;++ cost?: { usd?: number } | null;++ error?: string;++ /** Kept in the thread, never sent to the model (a card, a note for the UI). */++ ui_only?: boolean;++ at?: number;++}++export interface LoopModelTurn {++ text?: string;++ thinking?: string;++ tool_calls?: Array<{ id?: string; name: string; input?: Record<string, unknown> }>;++ stop?: 'stop' | 'tool_use' | 'length' | 'error' | 'aborted';++ usage?: { tokens?: number };++ cost?: { usd?: number };++ error?: string;++}++export interface LoopModelAdapter {++ id?: string;++ family?: string;++ complete(request: {++ messages: LoopMessage[];++ tools: LoopTool[];++ system?: string;++ signal?: AbortSignal;++ budgetLeft: { usd?: number; tokens?: number; turns?: number } | null;++ onDelta(delta: { text?: string; thinking?: string }): void | Promise<void>;++ }): Promise<LoopModelTurn>;++}++export interface LoopToolResult { result?: unknown; is_error?: boolean; terminate?: boolean; cost?: { usd?: number }; artifacts?: unknown[]; }++export interface LoopTool {++ name: string;++ description?: string;++ inputSchema?: Record<string, unknown>;++ run(input: Record<string, unknown>, context: { toolCall: LoopToolCall; signal: AbortSignal; run: unknown; onUpdate(partial: unknown): void }): Promise<LoopToolResult | unknown> | LoopToolResult | unknown;++ sequential?: boolean;++ idempotent?: boolean;++ timeoutMs?: number;++ title?: string;++ glyph?: string;++ prepareInput?(input: Record<string, unknown>): Record<string, unknown>;++}++export type LoopEvent =++ | { type: 'agent_start' }++ | { type: 'turn_start' }++ | { type: 'message_start' | 'message_end'; message: LoopMessage }++ | { type: 'message_update'; message: LoopMessage; delta: { text?: string; thinking?: string } }++ | { type: 'tool_start'; toolCall: LoopToolCall }++ | { type: 'tool_update'; toolCall: LoopToolCall; partial: unknown }++ | { type: 'tool_end'; toolCall: LoopToolCall; result: unknown; isError: boolean; durationMs?: number }++ | { type: 'turn_end'; message: LoopMessage; toolResults: LoopMessage[] }++ | { type: 'agent_end'; messages: LoopMessage[]; stop: LoopStop; error: string | null };++export type LoopStop = 'done' | 'terminated' | 'budget' | 'turns' | 'aborted' | 'error';++export interface LoopOptions {++ model: LoopModelAdapter;++ tools?: LoopTool[];++ system?: string;++ messages?: LoopMessage[];++ prompts?: LoopMessage[];++ budget?: { usd?: number; tokens?: number; turns?: number; steps?: number };++ beforeToolCall?(call: { toolCall: LoopToolCall; input: Record<string, unknown>; tool: LoopTool; assistant: LoopMessage; messages: LoopMessage[] }, signal?: AbortSignal): Promise<{ block?: boolean; reason?: string; terminate?: boolean } | void> | { block?: boolean; reason?: string; terminate?: boolean } | void;++ afterToolCall?(call: { toolCall: LoopToolCall; input: Record<string, unknown>; tool: LoopTool; assistant: LoopMessage; result: unknown; isError: boolean; messages: LoopMessage[] }, signal?: AbortSignal): Promise<{ result?: unknown; isError?: boolean; terminate?: boolean } | void> | { result?: unknown; isError?: boolean; terminate?: boolean } | void;++ transformContext?(messages: LoopMessage[], signal?: AbortSignal): Promise<LoopMessage[]> | LoopMessage[];++ steering?(): Promise<LoopMessage[]> | LoopMessage[];++ followUp?(): Promise<LoopMessage[]> | LoopMessage[];++ finishTurn?(turn: { assistant: LoopMessage; results: LoopMessage[]; messages: LoopMessage[]; added: LoopMessage[]; totals: { tokens: number; usd: number; turns: number; toolCalls: number } }): Promise<{ action?: 'end' | 'continue' } | void> | { action?: 'end' | 'continue' } | void;++ onEvent?(event: LoopEvent): void | Promise<void>;++ signal?: AbortSignal;++ toolExecution?: 'parallel' | 'sequential';++ maxTurns?: number;++ repeatLimit?: number;++ toolTimeoutMs?: number;++ toolOutputLimit?: number;++ run?: unknown;++}++export interface LoopResult { messages: LoopMessage[]; stop: LoopStop; error: string | null; usage: { tokens: number }; cost: { usd: number }; turns: number; toolCalls: number; }++export function runLoop(options: LoopOptions): Promise<LoopResult>;++export interface StepSinkOptions {++ append(step: Record<string, unknown>): unknown;++ update?(seq: number, patch: Record<string, unknown>): unknown;++ delta?(seq: number, text: string): unknown;++ runId?: string | null;++ startSeq?: number;++ startTurn?: number;++ toolTitle?(call: LoopToolCall): string | undefined;++}++export function createStepSink(options: StepSinkOptions): (event: LoopEvent) => Promise<void>;++export function checkInput(schema: Record<string, unknown> | undefined, value: unknown, at?: string): string | null;++export const STOPS: LoopStop[];++export const DEFAULTS: { maxTurns: number; repeatLimit: number; toolTimeoutMs: number; toolOutputLimit: number };@@@ -30,6 +30,9 @@ import * as ai from './ai';import * as credentials from './credentials';import * as jobs from './jobs';import * as agents from './agents';++import * as agentsLoop from './agents-loop';++import * as agentsDispatch from './agents-dispatch';++import * as agentsApi from './agents-api';import * as frame from './frame';import * as tools from './tools';import * as projects from './projects';@@@ -71,6 +74,9 @@ export * as ai from './ai';export * as credentials from './credentials';export * as jobs from './jobs';export * as agents from './agents';++export * as agentsLoop from './agents-loop';++export * as agentsDispatch from './agents-dispatch';++export * as agentsApi from './agents-api';export * as frame from './frame';export * as tools from './tools';export * as projects from './projects';@@@ -88,6 +94,6 @@ declare const sdk: typeof core & {userCard: typeof userCard;player: typeof player;auth: typeof auth; registry: typeof registry; modules: typeof modules; realtime: typeof realtime;-- media: typeof media; composer: typeof composer; cam: typeof cam; mediaUpload: typeof mediaUpload; ui: typeof ui; uiLayout: typeof uiLayout; community: typeof community; homeFeed: typeof homeFeed; feed: typeof feed; views: typeof views; bot: typeof bot; search: typeof search; bench: typeof bench; meme: typeof meme; vision: typeof vision; themes: typeof themes; design: typeof design; redact: typeof redact; chat: typeof chat; ai: typeof ai; credentials: typeof credentials; jobs: typeof jobs; agents: typeof agents; tools: typeof tools; projects: typeof projects; geo: typeof geo; history: typeof history; locker: typeof locker; trust: typeof trust; levels: typeof levels;++ media: typeof media; composer: typeof composer; cam: typeof cam; mediaUpload: typeof mediaUpload; ui: typeof ui; uiLayout: typeof uiLayout; community: typeof community; homeFeed: typeof homeFeed; feed: typeof feed; views: typeof views; bot: typeof bot; search: typeof search; bench: typeof bench; meme: typeof meme; vision: typeof vision; themes: typeof themes; design: typeof design; redact: typeof redact; chat: typeof chat; ai: typeof ai; credentials: typeof credentials; jobs: typeof jobs; agents: typeof agents; agentsLoop: typeof agentsLoop; agentsDispatch: typeof agentsDispatch; agentsApi: typeof agentsApi; tools: typeof tools; projects: typeof projects; geo: typeof geo; history: typeof history; locker: typeof locker; trust: typeof trust; levels: typeof levels;};export default sdk;@@@ -31,6 +31,9 @@ export * as ai from './ai';export * as credentials from './credentials';export * as jobs from './jobs';export * as agents from './agents';++export * as agentsLoop from './agents-loop';++export * as agentsDispatch from './agents-dispatch';++export * as agentsApi from './agents-api';export * as frame from './frame';export * as tools from './tools';export * as projects from './projects';
@@ -1,5 +1,61 @@
@@ -1,5 +1,61 @@
# Changelog
# Changelog
+## 0.160.0 — 2026-11-12
+
+**The agent engine's loop, Dispatcher and Run API kit: `openvibe-sdk/agents/loop`, `/dispatch` and `/api`** (agent-engine.md §§3, 9). A model, tools and the person's words become
+a run's thread. It runs chat and ask turns on a server with no workspace (Vibe answering, an agent using OpenVibe's own
+products as tools).
+
+- **`runLoop(options)`** resolves `{ messages, stop, error, usage, cost, turns, toolCalls }`. Stop is `done`,
+ `terminated`, `budget`, `turns`, `aborted` or `error`.
+ - **The model adapter:** `complete({ messages, tools, system, signal, budgetLeft, onDelta })` returns the next turn:
+ text, thinking, tool calls, why it stopped, usage and cost. `onDelta` streams the turn as it is written.
+ - **Tools** run in parallel, or in sequence when the loop or a tool asks. Parallel runs are prepared in order, run
+ together, and their results kept in the model's order.
+ - **`beforeToolCall`** is the approval gate (block, with a reason, or end the run); **`afterToolCall`** may rewrite a
+ result.
+ - **`steering()`:** words the person sent while it works, taken before the next turn. **`followUp()`:** words after
+ it would stop. **`finishTurn()`** may end or continue the loop. **`transformContext()`** compacts or filters the
+ thread before each turn, and entries marked `ui_only` never reach the model.
+ - **The budget** (usd, tokens, turns, steps) stops it between turns, never inside one; the model is told what is left.
+- **What it keeps, whatever the hooks say:**
+ - A call cut off by the output limit never runs; the model is asked to send it again.
+ - An unknown tool, input its schema refuses, a throw or a timeout comes back to the model as an error, never as a
+ crash.
+ - The same call repeated past `repeatLimit` gets a reminder instead of a run, and an idempotent tool answers the same
+ call from the run's memo.
+ - A long output is clipped before the model sees it; the whole result stays in the thread.
+- **`createStepSink({ append, update, delta, startSeq, startTurn, runId })`** turns the loop's events into
+ `agents.step@1` steps. In order: the person's words, the thinking (folded), one tool step updated when it ends, then
+ the agent's words streamed into their step. A person's message opens the next `turn` (Contracts 0.417.0).
+- **`checkInput(schema, value)`:** the JSON Schema subset tools use.
+- **New `openvibe-sdk/agents/dispatch`: the Dispatcher** (agent-engine.md §3), as pure functions over a person's
+ choices:
+ - `ladder(choices, { need, pin, avoid, budgetLeft })` orders them: their own machine, a signed-in plan, their own
+ key, the free allowance, then credits, cheapest first inside a rung, and a smaller model before a stronger one
+ unless the step needs strength. What cannot do the step (a capability, the context, the speed, the budget) is
+ listed with why. A person's pick wins when it can, and is otherwise passed over with the reason.
+ - `escalate(current, evidence, choices)` moves to a stronger model only on checkable evidence: a failed test,
+ schema, tool or grader, or the model's own "not sure". A grader's failure also changes family.
+ - `harnessFor(task, harnesses, { plans })` chooses by capability: the loop for turns with tools, the person's
+ signed-in plan, else dsh.
+ - `placeFor(run, { machines, cloud })`: the person's computer first (free), a Codes runner for a repository
+ elsewhere, an OpenVibe computer when allowed, Bot for a robot.
+ - `estimate(choice, tokens)` and `slice(parentBudget, share)`.
+- **New `openvibe-sdk/agents/api`: the Run API from a product's own store** (server).
+ - `createRunApi({ product, subject, store, prefix })` returns `handle(req, res, next)` for Express or any Node http
+ server: the list, start, get, steps, events, messages, stop and approvals routes of Contracts' `docs/agents-runs.md`.
+ - The store has `list, get, steps, start, say, stop, answer` and, optionally, `follow` to push its own events
+ (otherwise the stream reads new steps). A method a store lacks answers 409.
+ - No subject is 401, another person's run 404, and a start without words or with an unknown kind 422.
+ - Every run carries its ref. A store's own `{ status, code }` errors go out as they are; anything else is a 500 that
+ says nothing of the store. A mandate from a caller is dropped (intake's hook sets it).
+ - Read back with the SDK's own client in its test.
+- Ported from pi's agent loop (earendil-works/pi, `packages/agent/src/agent-loop.ts`, MIT, Copyright (c) 2025 Mario
+ Zechner): the inner loop over tools and steering, the outer loop over follow-ups, the turn hook, parallel and
+ sequential tools, and the two tool hooks. The model API, the message shape, the budget, the memo, the repeat guard,
+ the timeouts and the step sink are OpenVibe's.
+
## 0.159.0 — 2026-11-12
## 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).
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).
@@ -8,12 +8,12 @@
@@ -8,12 +8,12 @@
**Install and test:** `npm ci && npm test` (Node 20 or newer)
**Install and test:** `npm ci && npm test` (Node 20 or newer)
```bash
```bash
-npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.159.0
+npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.160.0
```
```
> Supported browser and server clients for the OpenVibe platform.
> Supported browser and server clients for the OpenVibe platform.
-**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.
+**Status:** alpha, v0.160.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.
**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.
**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
@@ -22,7 +22,7 @@ npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags
After v0.97.1 is tagged:
After v0.97.1 is tagged:
```bash
```bash
-npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.159.0
+npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.160.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).
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).
@@ -220,6 +220,9 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
@@ -220,6 +220,9 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/chat` | both | `createChatClient(client)`: `global.send/history/search`, `rooms.*` (list, create, get, update, messages, send, deleteMessage, join, leave, read, members, setMember, react), `dms.*` (list, create, get, messages, send, deleteMessage, read, unread, blocks, block, unblock), `activity.*` (list, unread, readOne, read: what happened to the person in Chat), `embeds(message)` (the typed `chat.embed@1` address previews on a message), `exportMine()`, `vibe.*` (list, start, rename, remove: chats with Vibe), `inboxes.*` (list, create, get, update, remove, addMember, removeMember, conversations, messages, reply, setStatus, read, identity, replies, saveReply, deleteReply, webhooks, addWebhook, testWebhook, removeWebhook: a support inbox's team), `support.*` (list, messages, send: their own conversations with sites' chats), `feeds.*` (mine, create, update, rotateKey, remove, view, onLive). With no account: `createInboxVisitor(client, { inbox, token, user, account })` (visit, send, messages, handoff, rate: your own panel for an inbox, from any site) and `createFeedPublisher(client, { feed, key })` (hello, publish, claudeCode: vibe coding events with the feed's key) |
| `openvibe-sdk/chat` | both | `createChatClient(client)`: `global.send/history/search`, `rooms.*` (list, create, get, update, messages, send, deleteMessage, join, leave, read, members, setMember, react), `dms.*` (list, create, get, messages, send, deleteMessage, read, unread, blocks, block, unblock), `activity.*` (list, unread, readOne, read: what happened to the person in Chat), `embeds(message)` (the typed `chat.embed@1` address previews on a message), `exportMine()`, `vibe.*` (list, start, rename, remove: chats with Vibe), `inboxes.*` (list, create, get, update, remove, addMember, removeMember, conversations, messages, reply, setStatus, read, identity, replies, saveReply, deleteReply, webhooks, addWebhook, testWebhook, removeWebhook: a support inbox's team), `support.*` (list, messages, send: their own conversations with sites' chats), `feeds.*` (mine, create, update, rotateKey, remove, view, onLive). With no account: `createInboxVisitor(client, { inbox, token, user, account })` (visit, send, messages, handoff, rate: your own panel for an inbox, from any site) and `createFeedPublisher(client, { feed, key })` (hello, publish, claudeCode: vibe coding events with the feed's key) |
| `openvibe-sdk/ai` | both | `createAiClient(client)`: `runs.create(workflow, input, {wait, idempotencyKey, latency, deadlineHours, …})`, `runs.get/list/cancel/retry/citations/addCitations/waitFor`, `batches.create/get/list/cancel`, `creations()` (the public catalogue of what AI can make, no token sent), `chat/generate/summarize/classify/extract/enrich/embed`. A run may ask for the class it may wait in (`latency`: `interactive`, `background`, `batch`; `deadlineHours`: 1–72 for a batch run); `waitFor` gives a batch run back pending rather than throwing when the wait is over — the answer comes with the run's completion event or webhook. |
| `openvibe-sdk/ai` | both | `createAiClient(client)`: `runs.create(workflow, input, {wait, idempotencyKey, latency, deadlineHours, …})`, `runs.get/list/cancel/retry/citations/addCitations/waitFor`, `batches.create/get/list/cancel`, `creations()` (the public catalogue of what AI can make, no token sent), `chat/generate/summarize/classify/extract/enrich/embed`. A run may ask for the class it may wait in (`latency`: `interactive`, `background`, `batch`; `deadlineHours`: 1–72 for a batch run); `waitFor` gives a batch run back pending rather than throwing when the wait is over — the answer comes with the run's completion event or webhook. |
| `openvibe-sdk/jobs` | both | `createJobsClient(client, {baseUrl?})`: `submit` (Idempotency-Key), `get`, `cancel`, `retry`, `reference`, `unreference`, `events` (SSE, reattaches), `wait`, `file` (raw Response); `isTerminal()`. Default origin: the Tools gateway's jobs facade |
| `openvibe-sdk/jobs` | both | `createJobsClient(client, {baseUrl?})`: `submit` (Idempotency-Key), `get`, `cancel`, `retry`, `reference`, `unreference`, `events` (SSE, reattaches), `wait`, `file` (raw Response); `isTerminal()`. Default origin: the Tools gateway's jobs facade |
+| `openvibe-sdk/agents/loop` | both | The light agent loop (a port of pi's, MIT): `runLoop({ model, tools, system, messages, prompts, budget, beforeToolCall, afterToolCall, transformContext, steering, followUp, finishTurn, onEvent, signal })` resolves `{ messages, stop, usage, cost, turns }` (stop: done, terminated, budget, turns, aborted, error). Tools in parallel (results in the model's order) or in sequence; an unknown tool, refused input, a throw or a timeout comes back to the model as an error; a call cut off by the output limit never runs; repeats get a reminder; idempotent tools answer from a memo; long outputs are clipped. `createStepSink({ append, update, delta, startSeq, startTurn })` turns its events into `agents.step@1` steps |
+| `openvibe-sdk/agents/dispatch` | both | The Dispatcher, as pure functions over a person's choices: `ladder(choices, { need, pin, avoid, budgetLeft })` (their own machine, a signed-in plan, their key, the free allowance, then credits, cheapest first; what cannot do the step is listed in `.skipped` with why; a person's pick wins when it can), `pick`, `escalate(current, evidence, choices)` (only on a failed test, schema, tool or grader, or the model's own "not sure"), `harnessFor(task, harnesses, { plans })`, `placeFor(run, { machines, cloud })`, `estimate(choice, tokens)`, `slice(parentBudget, share)` |
+| `openvibe-sdk/agents/api` | server | The Run API from a product's own store: `createRunApi({ product, subject, store, prefix })` returns `handle(req, res, next)` for Express or any Node http server, serving `GET/POST /runs`, `GET /runs/:id`, `…/steps`, `…/events` (server-sent events, from the store's `follow` or by reading new steps), `POST …/messages`, `…/stop`, `…/approvals/:approval`. The store has `list, get, steps, start, say, stop, answer, follow?`; a method it lacks answers 409. No subject is 401, another person's run 404, a start without words 422; every run carries its ref; a store's own `{ status, code }` errors go out as they are and anything else is a 500 that says nothing of the store; a mandate from a caller is dropped (intake only) |
| `openvibe-sdk/agents` | both | `createAgentsClient(client, {baseUrl?, service?, audience?})`: `run(id)` fetches an agent run snapshot (null when missing), `events(id, {lastEventId, signal})` follows the resumable AG-UI event stream, `post(id, approval)` answers an approval request |
| `openvibe-sdk/agents` | both | `createAgentsClient(client, {baseUrl?, service?, audience?})`: `run(id)` fetches an agent run snapshot (null when missing), `events(id, {lastEventId, signal})` follows the resumable AG-UI event stream, `post(id, approval)` answers an approval request |
| `openvibe-sdk/tools` | both | `createToolsClient(client, {baseUrl?, anonymousReads?})`: `list({family, q, execution, api, status})`, `get(id)`, `schema(id)`, `run(id, input, {files, waitMs, idempotencyKey, signal, timeoutMs, prefer})` (+ `.wait()`), `jobs`; `ToolRunError`, `isToolRunError()`; `prefer` picks the worker placement (`auto`, `home`, `host`) ([details](docs/tools-routing.md)) |
| `openvibe-sdk/tools` | both | `createToolsClient(client, {baseUrl?, anonymousReads?})`: `list({family, q, execution, api, status})`, `get(id)`, `schema(id)`, `run(id, input, {files, waitMs, idempotencyKey, signal, timeoutMs, prefer})` (+ `.wait()`), `jobs`; `ToolRunError`, `isToolRunError()`; `prefer` picks the worker placement (`auto`, `home`, `host`) ([details](docs/tools-routing.md)) |
| `openvibe-sdk/projects` | both (user token) | `createProjectsClient(client)`: `catalog`, `list`, `create`, `get`, `update`, `archive`, `setAllowance`, `setEnvironmentPolicy`, `members.*`, `apps.*`, `credentials.list/rotate/revoke`, `grants.list/request/approve/deny/revoke`, `quotas.*`, `audit`, `iterateAudit` |
| `openvibe-sdk/projects` | both (user token) | `createProjectsClient(client)`: `catalog`, `list`, `create`, `get`, `update`, `archive`, `setAllowance`, `setEnvironmentPolicy`, `members.*`, `apps.*`, `credentials.list/rotate/revoke`, `grants.list/request/approve/deny/revoke`, `quotas.*`, `audit`, `iterateAudit` |
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.159.0",
+ "version": "0.160.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.159.0, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.160.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// 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.
// (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 */
/* eslint-disable */
@@ -68,7 +68,7 @@ const { paginate, offsetPager } = __load(7);
@@ -68,7 +68,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.159.0';
+const SDK_VERSION = '0.160.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -27,6 +27,38 @@
@@ -27,6 +27,38 @@
"refOf"
"refOf"
]
]
},
},
+ "agentsApi": {
+ "object": [
+ "KINDS",
+ "STATUSES",
+ "createRunApi",
+ "startInput"
+ ]
+ },
+ "agentsDispatch": {
+ "object": [
+ "EVIDENCE",
+ "RUNGS",
+ "TIERS",
+ "cannot",
+ "escalate",
+ "estimate",
+ "harnessFor",
+ "ladder",
+ "pick",
+ "placeFor",
+ "slice"
+ ]
+ },
+ "agentsLoop": {
+ "object": [
+ "DEFAULTS",
+ "STOPS",
+ "checkInput",
+ "createStepSink",
+ "runLoop"
+ ]
+ },
"ai": {
"ai": {
"object": [
"object": [
"CLASSES",
"CLASSES",
@@ -618,6 +650,39 @@
@@ -618,6 +650,39 @@
"parseRef": "function",
"parseRef": "function",
"refOf": "function"
"refOf": "function"
},
},
+ "openvibe-sdk/agents/api": {
+ "KINDS": "array",
+ "STATUSES": "array",
+ "createRunApi": "function",
+ "startInput": "function"
+ },
+ "openvibe-sdk/agents/dispatch": {
+ "EVIDENCE": "array",
+ "RUNGS": "array",
+ "TIERS": "array",
+ "cannot": "function",
+ "escalate": "function",
+ "estimate": "function",
+ "harnessFor": "function",
+ "ladder": "function",
+ "pick": "function",
+ "placeFor": "function",
+ "slice": "function"
+ },
+ "openvibe-sdk/agents/loop": {
+ "DEFAULTS": {
+ "object": [
+ "maxTurns",
+ "repeatLimit",
+ "toolOutputLimit",
+ "toolTimeoutMs"
+ ]
+ },
+ "STOPS": "array",
+ "checkInput": "function",
+ "createStepSink": "function",
+ "runLoop": "function"
+ },
"openvibe-sdk/ai": {
"openvibe-sdk/ai": {
"CLASSES": "array",
"CLASSES": "array",
"OPS": "array",
"OPS": "array",
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+// Generated by scripts/esm.js from src/agents/api.js. Do not edit.
+import cjs from '../src/agents/api.js';
+
+export const {
+ createRunApi, startInput, KINDS, STATUSES,
+} = cjs;
+export default cjs;
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+// Generated by scripts/esm.js from src/agents/dispatch.js. Do not edit.
+import cjs from '../src/agents/dispatch.js';
+
+export const {
+ ladder, pick, escalate, harnessFor, placeFor, estimate,
+ slice, cannot, RUNGS, TIERS, EVIDENCE,
+} = cjs;
+export default cjs;
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+// Generated by scripts/esm.js from src/agents/loop.js. Do not edit.
+import cjs from '../src/agents/loop.js';
+
+export const {
+ runLoop, createStepSink, checkInput, STOPS, DEFAULTS,
+} = cjs;
+export default cjs;
@@ -11,12 +11,13 @@ export const {
@@ -11,12 +11,13 @@ export const {
community, homeFeed, feed, bot, search, bench,
community, homeFeed, feed, bot, search, bench,
meme, vision, themes, elements, design, redact,
meme, vision, themes, elements, design, redact,
chat, chatApps, ai, credentials, jobs, agents,
chat, chatApps, ai, credentials, jobs, agents,
- frame, tools, projects, resources, vip, quest,
- live, inventory, tips, blog, reviews, wiki,
- coupons, deals, shoes, trade, space, host,
- news, food, actor, codes, run, video,
- pics, work, zone, git, play, download,
- openre, geo, history, usage, commerce, moneyReasons,
- telemetry, userCard, player, agentSheet,
+ agentsLoop, agentsDispatch, agentsApi, frame, tools, projects,
+ resources, vip, quest, live, inventory, tips,
+ blog, reviews, wiki, coupons, deals, shoes,
+ trade, space, host, news, food, actor,
+ codes, run, video, pics, work, zone,
+ git, play, download, openre, geo, history,
+ usage, commerce, moneyReasons, telemetry, userCard, player,
+ agentSheet,
} = cjs;
} = cjs;
export default cjs;
export default cjs;
@@ -45,6 +45,9 @@ module.exports = {
@@ -45,6 +45,9 @@ module.exports = {
credentials: require('./src/credentials'),
credentials: require('./src/credentials'),
jobs: require('./src/jobs'),
jobs: require('./src/jobs'),
agents: require('./src/agents'),
agents: require('./src/agents'),
+ agentsLoop: require('./src/agents/loop'),
+ agentsDispatch: require('./src/agents/dispatch'),
+ agentsApi: require('./src/agents/api'),
frame: require('./src/frame'),
frame: require('./src/frame'),
tools: require('./src/tools'),
tools: require('./src/tools'),
projects: require('./src/projects'),
projects: require('./src/projects'),
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.159.0",
+ "version": "0.160.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.159.0",
+ "version": "0.160.0",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.159.0",
+ "version": "0.160.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.",
"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",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
@@ -314,6 +314,21 @@
@@ -314,6 +314,21 @@
"import": "./esm/agents.mjs",
"import": "./esm/agents.mjs",
"default": "./src/agents.js"
"default": "./src/agents.js"
},
},
+ "./agents/loop": {
+ "types": "./types/agents-loop.d.ts",
+ "import": "./esm/agents-loop.mjs",
+ "default": "./src/agents/loop.js"
+ },
+ "./agents/dispatch": {
+ "types": "./types/agents-dispatch.d.ts",
+ "import": "./esm/agents-dispatch.mjs",
+ "default": "./src/agents/dispatch.js"
+ },
+ "./agents/api": {
+ "types": "./types/agents-api.d.ts",
+ "import": "./esm/agents-api.mjs",
+ "default": "./src/agents/api.js"
+ },
"./tools": {
"./tools": {
"types": "./types/tools.d.ts",
"types": "./types/tools.d.ts",
"import": "./esm/tools.mjs",
"import": "./esm/tools.mjs",
@@ -77,6 +77,9 @@ const ENTRIES = {
@@ -77,6 +77,9 @@ const ENTRIES = {
'ai.mjs': '../src/ai.js',
'ai.mjs': '../src/ai.js',
'jobs.mjs': '../src/jobs.js',
'jobs.mjs': '../src/jobs.js',
'agents.mjs': '../src/agents.js',
'agents.mjs': '../src/agents.js',
+ 'agents-loop.mjs': '../src/agents/loop.js',
+ 'agents-dispatch.mjs': '../src/agents/dispatch.js',
+ 'agents-api.mjs': '../src/agents/api.js',
'tools.mjs': '../src/tools.js',
'tools.mjs': '../src/tools.js',
'projects.mjs': '../src/projects.js',
'projects.mjs': '../src/projects.js',
'geo.mjs': '../src/geo.js',
'geo.mjs': '../src/geo.js',
new file mode 100644
new file mode 100644
@@ -0,0 +1,220 @@
@@ -0,0 +1,220 @@
+'use strict';
+/**
+ * openvibe-sdk/agents/api — the Run API from a store (agent-engine.md §9; Contracts docs/agents-runs.md). A product
+ * writes its store once (its own tables, its own runs) and serves the same routes as every other product and the Frame:
+ *
+ * GET /runs?kind=&status=&q=&limit=&cursor= { runs, next }
+ * POST /runs agents.run-start@1 → 201 the run (Idempotency-Key: the first run again)
+ * GET /runs/:id the run
+ * GET /runs/:id/steps?after=&limit= { steps, next }
+ * GET /runs/:id/events agents.event@1 as server-sent events (Last-Event-ID: a step's seq)
+ * POST /runs/:id/messages { text, attachments } → 201 the step
+ * POST /runs/:id/stop { reason } → the run
+ * POST /runs/:id/approvals/:approval { answer } → what the store answers
+ *
+ * const api = createRunApi({ product: 'chat', subject: async (req) => personOf(req), store });
+ * app.use('/api/v1', (req, res, next) => api.handle(req, res, next)); // Express, or any Node http server
+ *
+ * The store (every method gets `owner`, the caller as { type, id }; a method it does not have answers 409):
+ * list({ owner, kind, status, q, limit, cursor }) → { runs, next }
+ * get({ owner, id }) → run | null
+ * steps({ owner, id, after, limit }) → { steps, next }
+ * start({ owner, input, idempotencyKey }) → run
+ * say({ owner, id, text, attachments }) → step
+ * stop({ owner, id, reason }) → run
+ * answer({ owner, id, approval, answer }) → anything
+ * follow({ owner, id, after, signal, emit }) → optional: push events (emit(event, data, id)) until the signal
+ * aborts; without it the stream reads new steps every pollMs
+ *
+ * What it keeps: a caller without a subject gets 401; a run id or an approval id that is not one gets 404; a start
+ * without words, or with a kind or a body that is not one, gets 422; every run it returns carries its ref
+ * (`<product>:<id>`); the stream closes after streamMs so no proxy holds it forever, and the client resumes after the
+ * last step it has. Errors a store throws with { status, code, message } go out as they are; anything else is a 500
+ * that says nothing of the store.
+ */
+
+const KINDS = ['chat', 'ask', 'task', 'code', 'machine', 'goal'];
+const STATUSES = ['planning', 'running', 'waiting', 'done', 'failed', 'stopped'];
+const ID = /^[A-Za-z0-9._~:-]{1,200}$/;
+const APPROVAL = /^[A-Za-z0-9._~-]{1,160}$/;
+const KEY = /^[A-Za-z0-9._~-]{1,200}$/;
+const BODY_MAX = 64 * 1024;
+
+function problem(status, code, message) { const e = new Error(message); e.status = status; e.code = code; return e; }
+const clip = (v, n) => String(v == null ? '' : v).slice(0, n);
+
+async function readJson(req) {
+ if (req.body !== undefined && req.body !== null && typeof req.body === 'object' && !Buffer.isBuffer(req.body)) return req.body;
+ if (typeof req.body === 'string') { try { return JSON.parse(req.body || '{}'); } catch { throw problem(400, 'runs.bad_json', 'The body is not JSON'); } }
+ if (req.method === 'GET' || req.method === 'HEAD') return {};
+ const chunks = []; let size = 0;
+ for await (const c of req) { size += c.length; if (size > BODY_MAX) throw problem(413, 'runs.too_large', 'The body is larger than 64 KB'); chunks.push(c); }
+ const raw = Buffer.concat(chunks).toString('utf8').trim();
+ if (!raw) return {};
+ try { const v = JSON.parse(raw); return v && typeof v === 'object' && !Array.isArray(v) ? v : {}; } catch { throw problem(400, 'runs.bad_json', 'The body is not JSON'); }
+}
+
+function send(res, status, body, headers = {}) {
+ if (res.headersSent) return;
+ res.statusCode = status;
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
+ res.setHeader('Cache-Control', 'private, no-store');
+ for (const [k, v] of Object.entries(headers)) res.setHeader(k, v);
+ res.end(JSON.stringify(body));
+}
+
+/** A start body as agents.run-start@1 says it, or a 422 naming what is wrong. */
+function startInput(b) {
+ const text = clip(b && b.text, 16000).trim();
+ if (!text) throw problem(422, 'runs.start_needs_words', 'Say what the run is for ({ text })');
+ if (b.kind != null && !KINDS.includes(b.kind)) throw problem(422, 'runs.bad_kind', `kind is one of ${KINDS.join(', ')}`);
+ const out = { ...b, text };
+ if (b.attachments != null && !Array.isArray(b.attachments)) throw problem(422, 'runs.bad_attachments', 'attachments is a list');
+ if (Array.isArray(b.attachments)) out.attachments = b.attachments.slice(0, 20);
+ // A mandate comes from intake only (agents.run-start@1 1.1.0): a product's own caller never sets one.
+ delete out.mandate;
+ return out;
+}
+
+function createRunApi({ store, subject, product, prefix = '', pollMs = 1500, streamMs = 55000, intake = null } = {}) {
+ if (!store || typeof store !== 'object') throw new TypeError('agents/api: a store is required');
+ if (typeof subject !== 'function') throw new TypeError('agents/api: subject(req) is required (who is calling)');
+ if (!/^[a-z][a-z0-9-]{0,40}$/.test(String(product || ''))) throw new TypeError('agents/api: product is a short lowercase name (chat, actor, codes…)');
+ const base = String(prefix || '').replace(/\/+$/, '');
+ const withRef = (run) => (run && typeof run === 'object' && run.id != null && !run.ref ? { ...run, ref: `${product}:${run.id}` } : run);
+ const need = (name) => { if (typeof store[name] !== 'function') throw problem(409, `runs.cannot_${name}`, `A ${product} run cannot ${name === 'say' ? 'take messages' : name === 'answer' ? 'be answered' : name} here yet`); };
+
+ async function who(req) {
+ let s = null;
+ try { s = await subject(req); } catch { s = null; }
+ if (!s || !s.id) throw problem(401, 'auth.required', 'Sign in to see your runs');
+ return { type: s.type || 'user', id: String(s.id) };
+ }
+
+ async function list(req, res, owner, url) {
+ const q = url.searchParams;
+ const kind = KINDS.includes(q.get('kind')) ? q.get('kind') : null;
+ const status = STATUSES.includes(q.get('status')) ? q.get('status') : null;
+ const limit = Math.min(Math.max(parseInt(q.get('limit'), 10) || 30, 1), 100);
+ need('list');
+ const out = await store.list({ owner, kind, status, q: clip(q.get('q'), 200) || null, limit, cursor: clip(q.get('cursor'), 400) || null });
+ send(res, 200, { runs: (out && Array.isArray(out.runs) ? out.runs : []).slice(0, limit).map(withRef), next: (out && out.next) || null });
+ }
+
+ async function events(req, res, owner, id, url) {
+ const last = String(req.headers['last-event-id'] || url.searchParams.get('after') || '0');
+ if (!/^\d{1,16}$/.test(last)) return send(res, 400, { error: 'Last-Event-ID is a step seq', code: 'runs.bad_event_id' });
+ need('get');
+ const run = await store.get({ owner, id });
+ if (!run) throw problem(404, 'runs.not_found', 'No such run');
+ let after = Number(last);
+ const ctl = new AbortController();
+ req.on('close', () => ctl.abort());
+ res.statusCode = 200;
+ res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
+ res.setHeader('Cache-Control', 'private, no-store');
+ res.setHeader('X-Accel-Buffering', 'no');
+ if (typeof res.flushHeaders === 'function') res.flushHeaders();
+ const emit = (event, data, eventId) => { if (!ctl.signal.aborted) res.write(`${eventId != null ? `id: ${eventId}\n` : ''}event: ${event}\ndata: ${JSON.stringify({ type: event, ...data })}\n\n`); };
+ const timer = setTimeout(() => ctl.abort(), streamMs);
+ try {
+ if (typeof store.follow === 'function') {
+ await store.follow({ owner, id, after, signal: ctl.signal, emit: (event, data, eventId) => emit(event, event === 'run' && data && data.run ? { ...data, run: withRef(data.run) } : data, eventId) });
+ } else {
+ need('steps');
+ while (!ctl.signal.aborted) {
+ const page = await store.steps({ owner, id, after, limit: 100 });
+ const fresh = (page && Array.isArray(page.steps) ? page.steps : []).filter((s) => s && Number(s.seq) > after);
+ fresh.forEach((s) => { emit('step', { step: s }, s.seq); after = Math.max(after, Number(s.seq)); });
+ if (fresh.length) {
+ const now = await store.get({ owner, id });
+ if (now) {
+ emit('run', { run: withRef(now) }, after);
+ if (['done', 'failed', 'stopped'].includes(now.status)) { emit('end', { run_id: withRef(now).ref, status: now.status }, after); break; }
+ }
+ }
+ await new Promise((resolve) => { const t = setTimeout(resolve, pollMs); ctl.signal.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true }); });
+ }
+ }
+ } catch (err) {
+ emit('error', { error: err && err.status ? err.message : 'The run could not be read', code: (err && err.code) || 'runs.error' });
+ } finally {
+ clearTimeout(timer);
+ if (!res.writableEnded) res.end();
+ }
+ }
+
+ /** Answer one request; `next()` (when given) for a path that is not the Run API's. */
+ async function handle(req, res, next) {
+ let url;
+ try { url = new URL(req.originalUrl || req.url, 'http://local'); } catch { return next ? next() : send(res, 404, { error: 'Not found', code: 'runs.not_found' }); }
+ let path = url.pathname;
+ if (base) { if (!path.startsWith(base)) return next ? next() : send(res, 404, { error: 'Not found', code: 'runs.not_found' }); path = path.slice(base.length) || '/'; }
+ const m = /^\/runs(?:\/([^/]+)(?:\/(steps|events|messages|stop|approvals)(?:\/([^/]+))?)?)?\/?$/.exec(path);
+ if (!m) return next ? next() : send(res, 404, { error: 'Not found', code: 'runs.not_found' });
+ const [, rawId, part, rawApproval] = m;
+ const method = String(req.method || 'GET').toUpperCase();
+ try {
+ const owner = await who(req);
+ const id = rawId === undefined ? null : decodeURIComponent(rawId);
+ if (id !== null && !ID.test(id)) throw problem(404, 'runs.not_found', 'No such run');
+ const localId = id && id.startsWith(`${product}:`) ? id.slice(product.length + 1) : id;
+ if (!id) {
+ if (method === 'GET') return await list(req, res, owner, url);
+ if (method === 'POST') {
+ need('start');
+ const input = startInput(await readJson(req));
+ if (intake && typeof intake === 'function') { const mandate = await intake(req, input); if (mandate) input.mandate = mandate; }
+ const key = String(req.headers['idempotency-key'] || '');
+ if (key && !KEY.test(key)) throw problem(422, 'runs.bad_key', 'Idempotency-Key is 1 to 200 letters, digits or ._~-');
+ const run = await store.start({ owner, input, idempotencyKey: key || null });
+ return send(res, 201, withRef(run));
+ }
+ throw problem(405, 'runs.method', 'GET or POST');
+ }
+ if (!part) {
+ if (method !== 'GET') throw problem(405, 'runs.method', 'GET');
+ need('get');
+ const run = await store.get({ owner, id: localId });
+ if (!run) throw problem(404, 'runs.not_found', 'No such run');
+ return send(res, 200, withRef(run));
+ }
+ if (part === 'steps') {
+ if (method !== 'GET') throw problem(405, 'runs.method', 'GET');
+ need('steps');
+ const after = Math.max(0, parseInt(url.searchParams.get('after'), 10) || 0);
+ const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit'), 10) || 100, 1), 500);
+ const out = await store.steps({ owner, id: localId, after, limit });
+ if (!out) throw problem(404, 'runs.not_found', 'No such run');
+ return send(res, 200, { steps: (Array.isArray(out.steps) ? out.steps : []).slice(0, limit), next: out.next || null });
+ }
+ if (part === 'events') { if (method !== 'GET') throw problem(405, 'runs.method', 'GET'); return await events(req, res, owner, localId, url); }
+ if (method !== 'POST') throw problem(405, 'runs.method', 'POST');
+ const b = await readJson(req);
+ if (part === 'messages') {
+ need('say');
+ const text = clip(b.text, 16000);
+ const attachments = Array.isArray(b.attachments) ? b.attachments.slice(0, 20) : [];
+ if (!text.trim() && !attachments.length) throw problem(422, 'runs.say_needs_words', 'Say something ({ text })');
+ return send(res, 201, await store.say({ owner, id: localId, text, attachments }));
+ }
+ if (part === 'stop') { need('stop'); return send(res, 200, withRef(await store.stop({ owner, id: localId, reason: clip(b.reason, 500) || null }))); }
+ if (part === 'approvals') {
+ const approval = rawApproval ? decodeURIComponent(rawApproval) : '';
+ if (!APPROVAL.test(approval)) throw problem(404, 'runs.not_found', 'No such approval');
+ if (b.answer === undefined || b.answer === null || b.answer === '') throw problem(422, 'runs.answer_needed', 'An answer is required ({ answer })');
+ need('answer');
+ return send(res, 200, await store.answer({ owner, id: localId, approval, answer: b.answer }));
+ }
+ throw problem(404, 'runs.not_found', 'Not found');
+ } catch (err) {
+ const status = err && Number.isInteger(err.status) && err.status >= 400 && err.status < 600 ? err.status : 500;
+ if (res.headersSent) { if (!res.writableEnded) res.end(); return; }
+ return send(res, status, { error: status === 500 && !(err && err.status) ? 'Runs are unavailable right now' : String((err && err.message) || 'Error'), code: (err && err.code) || 'runs.error' });
+ }
+ }
+
+ return { handle, KINDS, STATUSES };
+}
+
+module.exports = { createRunApi, startInput, KINDS, STATUSES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,166 @@
@@ -0,0 +1,166 @@
+'use strict';
+/**
+ * openvibe-sdk/agents/dispatch — the Dispatcher (agent-engine.md §3): for each step, not just each run, which model,
+ * which harness and which place. Pure functions over the choices a person has (Exchange's offers merged with their
+ * own machines, plans and keys); nothing here calls a network.
+ *
+ * ladder(choices, { need, pin, avoid, budgetLeft }) → the choices that can do the step, cheapest rung first
+ * pick(choices, options) → the first of them (or null), with why
+ * escalate(current, evidence, choices, options) → a stronger choice, only on checkable evidence
+ * harnessFor(task, harnesses, { plans }) → the harness by capability, never by name
+ * placeFor(run, { machines, cloud }) → where the work lives
+ * estimate(choice, { tokensIn, tokensOut }) → the price of a turn in USD
+ * slice(parentBudget, share) → a child's budget, a slice of its parent's
+ *
+ * A choice: { id, name, rung: 'machine' | 'plan' | 'key' | 'allowance' | 'credits', family, tier: 'small' | 'medium' |
+ * 'strong', caps: ['tools', 'vision', 'json', 'thinking', 'long-context', …], context (tokens), speed (tokens a
+ * second), price: { in, out } (USD per million tokens; a rung that is not paid per token has none), available }.
+ *
+ * The rules, in the order they apply: a person's own pick always wins when it can do the step (and is only passed over,
+ * with a reason, when it cannot); a choice that lacks a needed capability, a large enough context or the speed asked
+ * for is never offered; then the ladder: the person's own machine (free), a signed-in plan through its harness (flat),
+ * their own key (no fee), the free allowance, then Exchange's credits, cheapest first inside a rung; a smaller tier is
+ * tried before a stronger one inside a rung unless the step says it needs strength. A step is redone on a stronger model
+ * only when something checkable failed: a test, a schema, a tool error, a grader (of another family), or the model's
+ * own structured "not sure"; never on a hunch.
+ */
+
+const RUNGS = ['machine', 'plan', 'key', 'allowance', 'credits'];
+const TIERS = ['small', 'medium', 'strong'];
+const EVIDENCE = ['test', 'schema', 'tool', 'grader', 'unsure'];
+
+const rungOf = (c) => (RUNGS.includes(c && c.rung) ? RUNGS.indexOf(c.rung) : RUNGS.length);
+const tierOf = (c) => (TIERS.includes(c && c.tier) ? TIERS.indexOf(c.tier) : 1);
+const caps = (c) => new Set(Array.isArray(c && c.caps) ? c.caps : []);
+
+/** The price of one turn of `choice`, in USD (0 on a rung that is not paid per token). */
+function estimate(choice, { tokensIn = 0, tokensOut = 0 } = {}) {
+ const p = choice && choice.price;
+ if (!p || (p.in == null && p.out == null)) return 0;
+ return Math.round((((Number(p.in) || 0) * tokensIn + (Number(p.out) || 0) * tokensOut) / 1e6) * 1e6) / 1e6;
+}
+
+/** Why a choice cannot do the step, or null when it can. */
+function cannot(c, need = {}) {
+ if (!c || c.available === false) return 'not available now';
+ const have = caps(c);
+ for (const cap of Array.isArray(need.caps) ? need.caps : []) if (!have.has(cap)) return `cannot ${cap}`;
+ if (need.context && (Number(c.context) || 0) < need.context) return `holds ${c.context || 0} tokens, the step needs ${need.context}`;
+ if (need.speed && (Number(c.speed) || 0) < need.speed) return `writes ${c.speed || 0} tokens a second, the step needs ${need.speed}`;
+ if (need.tier && tierOf(c) < TIERS.indexOf(need.tier)) return `a ${c.tier || 'medium'} model, the step needs ${need.tier}`;
+ return null;
+}
+
+/**
+ * The choices that can do the step, in the order the Dispatcher tries them, each with `why` (the rung, or the person's
+ * pick). Choices that cannot are listed in `.skipped` with the reason (the picker shows them greyed, with it).
+ */
+function ladder(choices, { need = {}, pin = null, avoid = [], budgetLeft = null, tokens = { tokensIn: 2000, tokensOut: 800 } } = {}) {
+ const list = Array.isArray(choices) ? choices.filter(Boolean) : [];
+ const skipped = [];
+ const ok = [];
+ const avoidSet = new Set(Array.isArray(avoid) ? avoid : []);
+ for (const c of list) {
+ const why = cannot(c, need) || (avoidSet.has(c.family) ? `a ${c.family} model, and this step asks for another family` : null)
+ || (budgetLeft != null && estimate(c, tokens) > budgetLeft ? 'costs more than the budget has left' : null);
+ if (why) skipped.push({ choice: c, why }); else ok.push(c);
+ }
+ const strongFirst = need.tier === 'strong';
+ ok.sort((a, b) => rungOf(a) - rungOf(b)
+ || (strongFirst ? tierOf(b) - tierOf(a) : tierOf(a) - tierOf(b))
+ || estimate(a, tokens) - estimate(b, tokens)
+ || String(a.id).localeCompare(String(b.id)));
+ let out = ok.map((c) => ({ ...c, why: c.rung || 'credits' }));
+ if (pin) {
+ const pinned = out.find((c) => c.id === pin);
+ if (pinned) out = [{ ...pinned, why: 'your pick' }, ...out.filter((c) => c.id !== pin)];
+ else { const s = skipped.find((x) => x.choice.id === pin); if (s) s.pinned = true; }
+ }
+ out.skipped = skipped;
+ return out;
+}
+
+/** The first choice the ladder offers, or null with the reasons nothing could. */
+function pick(choices, options = {}) {
+ const l = ladder(choices, options);
+ const pinSkipped = l.skipped.find((s) => s.pinned);
+ return { choice: l[0] || null, why: l[0] ? l[0].why : 'no model can do this step', passedOver: pinSkipped ? { id: pinSkipped.choice.id, why: pinSkipped.why } : null, skipped: l.skipped };
+}
+
+/**
+ * A stronger choice after a checkable failure. `evidence` is { kind: 'test' | 'schema' | 'tool' | 'grader' | 'unsure',
+ * detail }; anything else (a hunch, a long answer, a slow one) returns null. The next choice is the cheapest that can do
+ * the step at a higher tier than `current`; a grader's failure also avoids the current family. `tried` lists the ids
+ * already used on this step.
+ */
+function escalate(current, evidence, choices, { need = {}, tried = [], budgetLeft = null } = {}) {
+ if (!evidence || !EVIDENCE.includes(evidence.kind)) return null;
+ const higher = TIERS[Math.min(TIERS.length - 1, tierOf(current) + 1)];
+ if (tierOf(current) >= TIERS.length - 1 && evidence.kind !== 'grader') return null;
+ const avoid = evidence.kind === 'grader' && current && current.family ? [current.family] : [];
+ const used = new Set([current && current.id, ...(Array.isArray(tried) ? tried : [])].filter(Boolean));
+ // At the top tier only a grader's failure moves, and then to another family at the same strength.
+ const l = ladder((choices || []).filter((c) => !used.has(c.id)), { need: { ...need, tier: higher }, avoid, budgetLeft });
+ return l[0] ? { choice: l[0], why: `${evidence.kind === 'unsure' ? 'it was not sure' : `a ${evidence.kind} failed`}${evidence.detail ? `: ${String(evidence.detail).slice(0, 200)}` : ''}` } : null;
+}
+
+/**
+ * The harness for a task by what it must do: chat and ask turns with product tools run on the light loop on a server;
+ * work with files and commands on dsh by default, or on a harness whose plan the person signed in (their Codex, their
+ * Claude Code) when it can do the work. A harness: { id, caps: [...], plan?: string }.
+ */
+function harnessFor(task = {}, harnesses = [], { plans = [] } = {}) {
+ const needs = Array.isArray(task.needs) ? task.needs : [];
+ const list = Array.isArray(harnesses) ? harnesses : [];
+ const can = (h) => needs.every((n) => (h.caps || []).includes(n));
+ if (!needs.some((n) => ['files', 'shell', 'desks', 'browser'].includes(n))) {
+ const loop = list.find((h) => h.id === 'loop' && can(h));
+ if (loop) return { harness: loop, why: 'a turn with tools runs on the light loop' };
+ }
+ const signed = list.find((h) => h.plan && plans.includes(h.plan) && can(h));
+ if (signed) return { harness: signed, why: `your ${signed.plan} plan` };
+ const dsh = list.find((h) => h.id === 'dsh' && can(h));
+ if (dsh) return { harness: dsh, why: 'the default for work with files and commands' };
+ const any = list.find(can);
+ return any ? { harness: any, why: 'it can do what the task needs' } : null;
+}
+
+/**
+ * Where the work lives. A repository on the person's computer → that computer (Link's worker); a site to click through
+ * → a private desk on their machine, else an OpenVibe computer; heavy or untrusted code → a sandbox; a robot → Bot;
+ * a conversation → OpenVibe's servers. `machines`: the person's online machines with what their grants allow
+ * ({ id, name, online, allows: ['worker', 'desks', 'models'], repos: [...] }). `cloud`: whether OpenVibe's computers
+ * may be used (and paid for).
+ */
+function placeFor(run = {}, { machines = [], cloud = true } = {}) {
+ const online = (Array.isArray(machines) ? machines : []).filter((m) => m && m.online);
+ const repo = run.target && typeof run.target === 'object' ? run.target.repo : null;
+ if (run.robot) return { where: { kind: 'machine', id: run.robot }, why: 'a robot is reached through Bot' };
+ if (repo) {
+ const m = online.find((x) => (x.allows || []).includes('worker') && (x.repos || []).includes(repo));
+ if (m) return { where: { kind: 'machine', id: m.id, name: m.name }, why: 'the repository is on your computer' };
+ return { where: { kind: 'runner' }, why: 'a Codes runner checks it out' };
+ }
+ if (run.needs && run.needs.includes('browser')) {
+ const m = online.find((x) => (x.allows || []).includes('desks'));
+ if (m) return { where: { kind: 'machine', id: m.id, name: m.name }, why: 'a private desk on your computer (free)' };
+ if (cloud) return { where: { kind: 'openvibe' }, why: 'an OpenVibe computer' };
+ return null;
+ }
+ if (run.untrusted || run.heavy) return cloud ? { where: { kind: 'openvibe' }, why: 'heavy or untrusted work runs in a sandbox' } : null;
+ return { where: { kind: 'openvibe' }, why: 'a conversation runs on OpenVibe' };
+}
+
+/** A child's budget: `share` (0..1) of what the parent has left, field by field; absent fields stay unlimited. */
+function slice(parent, share) {
+ const s = Math.max(0, Math.min(1, Number(share) || 0));
+ const out = {};
+ if (!parent || typeof parent !== 'object') return out;
+ if (parent.usd != null) out.usd = Math.round(parent.usd * s * 1e6) / 1e6;
+ if (parent.tokens != null) out.tokens = Math.floor(parent.tokens * s);
+ if (parent.seconds != null) out.seconds = Math.floor(parent.seconds * s);
+ if (parent.steps != null) out.steps = Math.max(1, Math.floor(parent.steps * s));
+ return out;
+}
+
+module.exports = { ladder, pick, escalate, harnessFor, placeFor, estimate, slice, cannot, RUNGS, TIERS, EVIDENCE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,372 @@
@@ -0,0 +1,372 @@
+'use strict';
+/**
+ * openvibe-sdk/agents/loop — the light agent loop (agent-engine.md §9): a model, tools, and the person's words, turned
+ * into a run's thread. It runs chat and ask turns on a server with no workspace (Vibe answering, an agent using
+ * OpenVibe's own products as tools); work with files and commands goes to a harness through agents/acp.
+ *
+ * // import { runLoop, createStepSink } from the openvibe-sdk/agents/loop entry
+ * const out = await runLoop({
+ * model, // AgentModelAdapter (below): one turn per call
+ * tools: [search, remind], // AgentLoopTool (below)
+ * system: 'You are Vibe, on OpenVibe.',
+ * messages: history, // the thread so far (not emitted again)
+ * prompts: [{ role: 'user', text: 'Remind me to stretch every 2 hours' }], // what is new: emitted, then answered
+ * budget: { usd: 0.05, tokens: 40000, turns: 12 },
+ * beforeToolCall: async ({ toolCall, input }) => (needsYes(toolCall) ? await askThePerson(toolCall) : undefined),
+ * steering: () => queue.splice(0), // words the person sent while it works: taken before the next turn
+ * onEvent: createStepSink({ append, update, delta }), // the run's steps (agents.step@1), as it goes
+ * signal,
+ * });
+ * // → { messages, stop: 'done' | 'terminated' | 'budget' | 'turns' | 'aborted' | 'error', usage, cost, turns }
+ *
+ * The model adapter (agents.js's AgentModelAdapter):
+ * model.complete({ messages, tools, system, signal, onDelta }) → { text, thinking, tool_calls: [{ id, name, input }],
+ * stop: 'stop' | 'tool_use' | 'length' | 'error' | 'aborted', usage: { tokens }, cost: { usd }, error }
+ * onDelta({ text } | { thinking }) streams the turn as it is written. Messages are { role: 'system' | 'user' |
+ * 'assistant' | 'tool', text, thinking, tool_calls, tool_call_id, name, is_error }; one marked ui_only stays in the
+ * thread and never reaches the model.
+ *
+ * A tool (AgentTool, plus what the loop reads):
+ * { name, description, inputSchema, run(input, { toolCall, signal, onUpdate, run }) → { result, cost, artifacts,
+ * terminate, is_error }, sequential?, idempotent?, timeoutMs?, title?, glyph?, prepareInput?(input) }
+ *
+ * What it keeps, whatever the hooks say: a tool call cut off by the token limit is never run (the model is told to
+ * send it again); an unknown tool, input its schema refuses, a blocked call, a throw or a timeout comes back to the
+ * model as an error result, never as a crash; the same call repeated past `repeatLimit` gets a reminder instead of a
+ * run; an idempotent tool's same call is answered from the run's memo; a tool's output is clipped before the model sees
+ * it (`toolOutputLimit`); the budget stops the loop between turns, never in the middle of one.
+ *
+ * Ported from pi's agent loop (earendil-works/pi, packages/agent/src/agent-loop.ts), MIT License, Copyright (c) 2025
+ * Mario Zechner. The structure is pi's: an inner loop over tool calls and steering, an outer loop over follow-ups, a
+ * turn hook that may end or continue, tools in parallel (prepared in order, run together, results kept in the model's
+ * order) or in sequence, beforeToolCall and afterToolCall. The model API, the message shape, the budget, the memo, the
+ * repeat guard, the timeouts and the step sink are OpenVibe's.
+ */
+
+const DEFAULTS = { maxTurns: 50, repeatLimit: 3, toolTimeoutMs: 120000, toolOutputLimit: 20000 };
+const STOPS = ['done', 'terminated', 'budget', 'turns', 'aborted', 'error'];
+
+function stableJson(v) {
+ if (v === null || typeof v !== 'object') return JSON.stringify(v === undefined ? null : v);
+ if (Array.isArray(v)) return `[${v.map(stableJson).join(',')}]`;
+ return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${stableJson(v[k])}`).join(',')}}`;
+}
+
+/** A small JSON Schema check for tool input (type, required, properties, enum, items; the subset tools use). */
+function checkInput(schema, value, at = 'input') {
+ if (!schema || typeof schema !== 'object') return null;
+ const types = Array.isArray(schema.type) ? schema.type : schema.type ? [schema.type] : null;
+ if (types) {
+ const kind = value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value;
+ const ok = types.some((t) => t === kind || (t === 'integer' && Number.isInteger(value)) || (t === 'number' && kind === 'number'));
+ if (!ok) return `${at} should be ${types.join(' or ')}`;
+ }
+ if (Array.isArray(schema.enum) && !schema.enum.some((e) => stableJson(e) === stableJson(value))) return `${at} should be one of ${schema.enum.map((e) => JSON.stringify(e)).join(', ')}`;
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
+ for (const k of Array.isArray(schema.required) ? schema.required : []) if (!(k in value)) return `${at}.${k} is required`;
+ const props = schema.properties || {};
+ for (const [k, sub] of Object.entries(props)) if (k in value) { const e = checkInput(sub, value[k], `${at}.${k}`); if (e) return e; }
+ if (schema.additionalProperties === false) { const extra = Object.keys(value).find((k) => !(k in props)); if (extra) return `${at}.${extra} is not an input of this tool`; }
+ }
+ if (Array.isArray(value) && schema.items) for (let i = 0; i < value.length; i++) { const e = checkInput(schema.items, value[i], `${at}[${i}]`); if (e) return e; }
+ return null;
+}
+
+const errorResult = (message, extra = {}) => ({ result: { error: String(message) }, isError: true, ...extra });
+const now = () => Date.now();
+
+/**
+ * Run the loop until the model stops asking for tools and nobody has said anything more, a tool ends it, the budget or
+ * the turn limit is reached, or the signal aborts. Resolves with the new messages and why it stopped; never rejects for
+ * a model or tool failure (stop: 'error' carries it), only for a call it cannot start (no model).
+ */
+async function runLoop(o = {}) {
+ if (!o.model || typeof o.model.complete !== 'function') throw new TypeError('agents/loop: a model adapter with complete() is required');
+ const cfg = { ...DEFAULTS, ...o };
+ const tools = Array.isArray(o.tools) ? o.tools.filter((t) => t && typeof t.name === 'string' && typeof t.run === 'function') : [];
+ const signal = o.signal;
+ const emit = async (event) => { if (typeof o.onEvent === 'function') await o.onEvent(event); };
+ const thread = Array.isArray(o.messages) ? o.messages.slice() : [];
+ const added = [];
+ const totals = { tokens: 0, usd: 0, turns: 0, toolCalls: 0 };
+ const memo = new Map();
+ const repeats = new Map();
+ const budget = o.budget || {};
+ const overBudget = () => (budget.usd != null && totals.usd >= budget.usd) || (budget.tokens != null && totals.tokens >= budget.tokens)
+ || (budget.turns != null && totals.turns >= budget.turns) || (budget.steps != null && totals.turns + totals.toolCalls >= budget.steps);
+ const take = async (fn) => { try { const v = typeof fn === 'function' ? await fn() : null; return Array.isArray(v) ? v.filter(Boolean) : []; } catch { return []; } };
+ const push = async (m) => { thread.push(m); added.push(m); await emit({ type: 'message_start', message: m }); await emit({ type: 'message_end', message: m }); };
+
+ let stop = 'done', error = null, terminated = false, explicit = false;
+ await emit({ type: 'agent_start' });
+ let pending = (Array.isArray(o.prompts) ? o.prompts.filter(Boolean) : []).concat(await take(o.steering));
+
+ outer: for (;;) {
+ let more = true;
+ while (more || pending.length) {
+ await emit({ type: 'turn_start' });
+ for (const m of pending) await push({ at: now(), ...m });
+ pending = [];
+ if (signal && signal.aborted) { stop = 'aborted'; break outer; }
+ if (overBudget()) { stop = 'budget'; break outer; }
+ if (totals.turns >= cfg.maxTurns) { stop = 'turns'; break outer; }
+
+ let visible = thread;
+ if (typeof o.transformContext === 'function') { try { visible = (await o.transformContext(thread.slice(), signal)) || thread; } catch { visible = thread; } }
+ const forModel = visible.filter((m) => m && !m.ui_only);
+ const assistant = await callModel(o.model, { messages: forModel, tools, system: o.system, signal, budgetLeft: left(budget, totals) }, emit);
+ thread.push(assistant); added.push(assistant);
+ totals.turns += 1;
+ totals.tokens += (assistant.usage && assistant.usage.tokens) || 0;
+ totals.usd += (assistant.cost && assistant.cost.usd) || 0;
+
+ if (assistant.stop === 'error' || assistant.stop === 'aborted') {
+ await emit({ type: 'turn_end', message: assistant, toolResults: [] });
+ stop = assistant.stop; error = assistant.error || null;
+ break outer;
+ }
+
+ const calls = Array.isArray(assistant.tool_calls) ? assistant.tool_calls : [];
+ const results = [];
+ more = false;
+ if (calls.length) {
+ const batch = assistant.stop === 'length' ? await failTruncated(calls) : await runTools(calls, assistant);
+ for (const r of batch.messages) { thread.push(r); added.push(r); results.push(r); }
+ terminated = batch.terminate;
+ more = !batch.terminate;
+ }
+ let decision = null;
+ if (typeof o.finishTurn === 'function') { try { decision = await o.finishTurn({ assistant, results, messages: thread, added, totals: { ...totals } }); } catch { decision = null; } }
+ await emit({ type: 'turn_end', message: assistant, toolResults: results });
+ if (decision && decision.action === 'end') { stop = 'done'; break outer; }
+ explicit = !!(decision && decision.action === 'continue');
+ pending = await take(o.steering);
+ if (more || pending.length) explicit = false;
+ }
+ const follow = await take(o.followUp);
+ if (follow.length) { explicit = false; terminated = false; pending = follow; continue; }
+ if (explicit) { explicit = false; continue; }
+ if (terminated) stop = 'terminated';
+ break;
+ }
+
+ await emit({ type: 'agent_end', messages: added, stop, error });
+ return { messages: added, stop, error, usage: { tokens: totals.tokens }, cost: { usd: Math.round(totals.usd * 1e6) / 1e6 }, turns: totals.turns, toolCalls: totals.toolCalls };
+
+ // ── tools ──
+ async function failTruncated(calls) {
+ const messages = [];
+ for (const call of calls) {
+ await emit({ type: 'tool_start', toolCall: call });
+ const done = { toolCall: call, ...errorResult(`The tool call "${call.name}" was not run: the answer hit the output limit, so its input may be cut off. Send the call again with its whole input.`) };
+ messages.push(await finish(done));
+ }
+ return { messages, terminate: false };
+ }
+
+ async function runTools(calls, assistant) {
+ const sequential = cfg.toolExecution === 'sequential' || calls.some((c) => { const t = tools.find((x) => x.name === c.name); return t && t.sequential; });
+ const entries = [];
+ for (const call of calls) {
+ await emit({ type: 'tool_start', toolCall: call });
+ totals.toolCalls += 1;
+ const prep = await prepare(call, assistant);
+ if (prep.immediate) {
+ const done = { toolCall: call, ...prep.immediate };
+ await emit({ type: 'tool_end', toolCall: call, result: done.result, isError: done.isError });
+ entries.push(done);
+ } else if (sequential) {
+ entries.push(await execute(prep, assistant));
+ } else {
+ entries.push(() => execute(prep, assistant));
+ }
+ if (signal && signal.aborted) break;
+ }
+ const done = await Promise.all(entries.map((e) => (typeof e === 'function' ? e() : e)));
+ const messages = [];
+ for (const d of done) messages.push(await finish(d, true));
+ return { messages, terminate: done.length > 0 && done.every((d) => d.terminate === true) };
+ }
+
+ async function prepare(call, assistant) {
+ const tool = tools.find((t) => t.name === call.name);
+ if (!tool) return { immediate: errorResult(`There is no tool named ${call.name}.`) };
+ let input = call.input && typeof call.input === 'object' ? call.input : {};
+ try {
+ if (typeof tool.prepareInput === 'function') input = tool.prepareInput(input) || input;
+ const bad = checkInput(tool.inputSchema, input);
+ if (bad) return { immediate: errorResult(`The input does not fit ${tool.name}: ${bad}.`) };
+ const key = `${tool.name} ${stableJson(input)}`;
+ const n = (repeats.get(key) || 0) + 1;
+ repeats.set(key, n);
+ if (n > cfg.repeatLimit) return { immediate: errorResult(`You already called ${tool.name} with this same input ${n - 1} times. Use what it returned, or try something else.`) };
+ if (tool.idempotent && memo.has(key)) return { immediate: { ...memo.get(key), memo: true } };
+ if (typeof o.beforeToolCall === 'function') {
+ const gate = await o.beforeToolCall({ toolCall: call, input, tool, assistant, messages: thread }, signal);
+ if (signal && signal.aborted) return { immediate: errorResult('Stopped.') };
+ if (gate && gate.block) return { immediate: errorResult(gate.reason || 'This call was not allowed.', gate.terminate === true ? { terminate: true } : {}) };
+ }
+ if (signal && signal.aborted) return { immediate: errorResult('Stopped.') };
+ return { tool, call, input, key };
+ } catch (err) {
+ return { immediate: errorResult((err && err.message) || err) };
+ }
+ }
+
+ async function execute(prep, assistant) {
+ const { tool, call, input } = prep;
+ const started = now();
+ const ctl = new AbortController();
+ const onAbort = () => ctl.abort();
+ if (signal) { if (signal.aborted) ctl.abort(); else signal.addEventListener('abort', onAbort, { once: true }); }
+ const limit = Number(tool.timeoutMs || cfg.toolTimeoutMs) || DEFAULTS.toolTimeoutMs;
+ let timer = null;
+ let out;
+ try {
+ const ran = Promise.resolve(tool.run(input, { toolCall: call, signal: ctl.signal, run: o.run || null, onUpdate: (partial) => emit({ type: 'tool_update', toolCall: call, partial }) }));
+ // The timeout is settled before the tool is told to stop, so a tool that answers its abort never wins the race.
+ const timeout = new Promise((resolve) => { timer = setTimeout(() => { resolve({ timedOut: true }); ctl.abort(); }, limit); });
+ const got = await Promise.race([ran, timeout]);
+ if (got && got.timedOut) out = errorResult(`${tool.name} took longer than ${limit < 1000 ? `${limit} ms` : `${Math.round(limit / 1000)} s`} and was stopped.`);
+ else {
+ const r = got && typeof got === 'object' && ('result' in got || 'is_error' in got || 'terminate' in got) ? got : { result: got };
+ out = { result: r.result, isError: r.is_error === true, terminate: r.terminate === true, cost: r.cost || null, artifacts: Array.isArray(r.artifacts) ? r.artifacts : [] };
+ }
+ } catch (err) {
+ out = errorResult((err && err.message) || err);
+ } finally {
+ clearTimeout(timer);
+ if (signal) signal.removeEventListener('abort', onAbort);
+ }
+ if (typeof o.afterToolCall === 'function') {
+ try {
+ const after = await o.afterToolCall({ toolCall: call, input, tool, assistant, result: out.result, isError: out.isError, messages: thread }, signal);
+ if (after) out = { ...out, ...('result' in after ? { result: after.result } : {}), ...('isError' in after ? { isError: !!after.isError } : {}), ...('terminate' in after ? { terminate: !!after.terminate } : {}) };
+ } catch (err) { out = errorResult((err && err.message) || err); }
+ }
+ if (out.cost && typeof out.cost.usd === 'number') totals.usd += out.cost.usd;
+ if (tool.idempotent && !out.isError) memo.set(prep.key, { result: out.result, isError: false });
+ const done = { toolCall: call, ...out, durationMs: now() - started };
+ await emit({ type: 'tool_end', toolCall: call, result: done.result, isError: done.isError, durationMs: done.durationMs });
+ return done;
+ }
+
+ async function finish(done, ended) {
+ if (!ended) await emit({ type: 'tool_end', toolCall: done.toolCall, result: done.result, isError: done.isError });
+ let text = typeof done.result === 'string' ? done.result : JSON.stringify(done.result === undefined ? null : done.result);
+ if (text.length > cfg.toolOutputLimit) text = `${text.slice(0, cfg.toolOutputLimit)}\n[… ${text.length - cfg.toolOutputLimit} more characters left out]`;
+ const m = { role: 'tool', tool_call_id: done.toolCall.id, name: done.toolCall.name, text, result: done.result, is_error: !!done.isError, at: now() };
+ if (done.durationMs != null) m.duration_ms = done.durationMs;
+ if (done.memo) m.memo = true;
+ if (done.artifacts && done.artifacts.length) m.artifacts = done.artifacts;
+ await emit({ type: 'message_start', message: m });
+ await emit({ type: 'message_end', message: m });
+ return m;
+ }
+}
+
+function left(budget, totals) {
+ const out = {};
+ if (budget.usd != null) out.usd = Math.max(0, budget.usd - totals.usd);
+ if (budget.tokens != null) out.tokens = Math.max(0, budget.tokens - totals.tokens);
+ if (budget.turns != null) out.turns = Math.max(0, budget.turns - totals.turns);
+ return Object.keys(out).length ? out : null;
+}
+
+/** One turn of the model, streamed into message_update events; a throw becomes stop: 'error' (or 'aborted'). */
+async function callModel(model, request, emit) {
+ const message = { role: 'assistant', text: '', thinking: '', tool_calls: [], at: now() };
+ await emit({ type: 'message_start', message: { ...message } });
+ let turn;
+ try {
+ turn = await model.complete({
+ ...request,
+ onDelta: (d) => {
+ if (!d) return;
+ if (typeof d.text === 'string') message.text += d.text;
+ if (typeof d.thinking === 'string') message.thinking += d.thinking;
+ return emit({ type: 'message_update', message: { ...message }, delta: d });
+ },
+ });
+ } catch (err) {
+ const aborted = request.signal && request.signal.aborted;
+ const out = { ...message, stop: aborted ? 'aborted' : 'error', error: aborted ? 'Stopped.' : String((err && err.message) || err) };
+ await emit({ type: 'message_end', message: out });
+ return out;
+ }
+ const t = turn || {};
+ const calls = (Array.isArray(t.tool_calls) ? t.tool_calls : []).filter((c) => c && typeof c.name === 'string')
+ .map((c, i) => ({ id: String(c.id || `call_${now().toString(36)}_${i}`), name: c.name, input: c.input && typeof c.input === 'object' ? c.input : {} }));
+ const out = {
+ role: 'assistant', at: message.at,
+ text: typeof t.text === 'string' ? t.text : message.text,
+ thinking: typeof t.thinking === 'string' ? t.thinking : message.thinking,
+ tool_calls: calls,
+ stop: ['stop', 'tool_use', 'length', 'error', 'aborted'].includes(t.stop) ? t.stop : calls.length ? 'tool_use' : 'stop',
+ usage: t.usage || null, cost: t.cost || null,
+ };
+ if (t.error) out.error = String(t.error);
+ if (!out.thinking) delete out.thinking;
+ if (!out.tool_calls.length) delete out.tool_calls;
+ await emit({ type: 'message_end', message: out });
+ return out;
+}
+
+/**
+ * The loop's events as a run's steps (agents.step@1): the person's words, the agent's thinking (folded) and words, and
+ * one step per tool call, updated when it ends. `append(step)` stores a step and may return it; `update(seq, patch)`
+ * changes one (a tool that ended, words that finished); `delta(seq, text)` is more text for a step being written.
+ * Seqs count from `startSeq` (the run's last seq + 1); `turn` starts at `startTurn` and a person's message opens the
+ * next one.
+ */
+function createStepSink({ append, update, delta, runId = null, startSeq = 1, startTurn = 0, toolTitle } = {}) {
+ if (typeof append !== 'function') throw new TypeError('agents/loop: createStepSink needs append(step)');
+ let seq = startSeq, turn = startTurn, writing = null, thinking = null;
+ const tools = new Map();
+ const iso = () => new Date().toISOString();
+ const add = async (step) => { const st = { run_id: runId, seq: seq++, turn, started_at: iso(), ...step }; await append(st); return st; };
+ const patch = async (n, p) => { if (typeof update === 'function') await update(n, p); };
+ const more = async (n, t) => { if (typeof delta === 'function') await delta(n, t); };
+ return async function onEvent(e) {
+ if (!e || !e.type) return;
+ const m = e.message;
+ if (e.type === 'message_update' && m && m.role === 'assistant' && e.delta) {
+ if (typeof e.delta.thinking === 'string' && e.delta.thinking) {
+ if (!thinking) thinking = await add({ kind: 'think', from: 'agent', status: 'running', title: 'Thinking', text: '' });
+ await more(thinking.seq, e.delta.thinking);
+ }
+ if (typeof e.delta.text === 'string' && e.delta.text) {
+ if (!writing) writing = await add({ kind: 'message', from: 'agent', status: 'running', text: '' });
+ await more(writing.seq, e.delta.text);
+ }
+ return;
+ }
+ if (e.type === 'tool_start' && e.toolCall) {
+ const c = e.toolCall;
+ const st = await add({ kind: 'tool', from: 'tool', status: 'running', title: (typeof toolTitle === 'function' && toolTitle(c)) || c.name, tool_call: { name: c.name, input: c.input || {} } });
+ tools.set(c.id, st.seq);
+ return;
+ }
+ if (e.type === 'tool_end' && e.toolCall && tools.has(e.toolCall.id)) {
+ await patch(tools.get(e.toolCall.id), { status: e.isError ? 'failed' : 'done', result: e.result === undefined ? null : e.result, ended_at: iso() });
+ return;
+ }
+ if (e.type !== 'message_end' || !m) return;
+ if (m.role === 'user') {
+ // A person's message opens a turn (the run's first words are turn 0).
+ if (seq > startSeq || turn > startTurn) turn += 1;
+ await add({ kind: 'message', from: 'person', status: 'done', text: String(m.text || ''), ended_at: iso() });
+ return;
+ }
+ if (m.role !== 'assistant' || !m.stop) return;
+ const failed = m.stop === 'error';
+ if (thinking) await patch(thinking.seq, { status: 'done', text: m.thinking || '', ended_at: iso() });
+ else if (m.thinking) await add({ kind: 'think', from: 'agent', status: 'done', title: 'Thinking', text: m.thinking, ended_at: iso() });
+ if (writing) await patch(writing.seq, { status: failed ? 'failed' : 'done', text: m.text || '', ended_at: iso() });
+ else if (m.text) await add({ kind: 'message', from: 'agent', status: failed ? 'failed' : 'done', text: m.text, ended_at: iso() });
+ else if (failed) await add({ kind: 'message', from: 'system', status: 'failed', text: m.error || 'The model did not answer.', ended_at: iso() });
+ writing = null; thinking = null;
+ };
+}
+
+module.exports = { runLoop, createStepSink, checkInput, STOPS, DEFAULTS };
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.159.0';
+const SDK_VERSION = '0.160.0';
module.exports = {
module.exports = {
createClient,
createClient,
new file mode 100644
new file mode 100644
@@ -0,0 +1,137 @@
@@ -0,0 +1,137 @@
+'use strict';
+// openvibe-sdk/agents/api: the Run API from a store, on a real Node http server, read back with the SDK's own client.
+const assert = require('node:assert/strict');
+const http = require('node:http');
+const { run } = require('./helpers');
+const { createRunApi } = require('../src/agents/api');
+const { createClient } = require('../src/core');
+const { createAgentsClient } = require('../src/agents');
+
+/** A memory store: Ada's runs; a chat that answers a moment after each message. */
+function memoryStore() {
+ const runs = new Map(); const steps = new Map(); const seen = { starts: [], answers: [] };
+ let n = 0;
+ const mine = (owner, id) => { const r = runs.get(id); return r && r.owner.id === owner.id ? r : null; };
+ return {
+ seen, runs, steps,
+ async list({ owner, kind, limit }) { return { runs: [...runs.values()].filter((r) => r.owner.id === owner.id && (!kind || r.kind === kind)).slice(0, limit), next: null }; },
+ async get({ owner, id }) { return mine(owner, id); },
+ async steps({ owner, id, after, limit }) { if (!mine(owner, id)) return null; return { steps: (steps.get(id) || []).filter((s) => s.seq > after).slice(0, limit), next: null }; },
+ async start({ owner, input, idempotencyKey }) {
+ seen.starts.push({ input, idempotencyKey });
+ const id = `c_${++n}`;
+ const r = { id, kind: input.kind || 'chat', title: input.text.slice(0, 40), owner, status: 'waiting', waiting: { for: 'message' }, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
+ runs.set(id, r); steps.set(id, [{ run_id: id, seq: 1, kind: 'message', from: 'person', text: input.text }]);
+ return r;
+ },
+ async say({ owner, id, text }) {
+ const r = mine(owner, id); if (!r) { const e = new Error('No such run'); e.status = 404; e.code = 'runs.not_found'; throw e; }
+ const list = steps.get(id); const step = { run_id: id, seq: list.length + 1, kind: 'message', from: 'person', text };
+ list.push(step);
+ setTimeout(() => { list.push({ run_id: id, seq: list.length + 1, kind: 'message', from: 'agent', text: `You said: ${text}` }); if (text === 'bye') r.status = 'done'; }, 80);
+ return step;
+ },
+ async answer({ approval, answer }) { seen.answers.push([approval, answer]); return { ok: true }; },
+ };
+}
+
+async function serve(api) {
+ const server = http.createServer((req, res) => api.handle(req, res, () => { res.statusCode = 418; res.end('not mine'); }));
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ return { url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((ok) => server.close(ok)) };
+}
+const ADA = { type: 'user', id: 'usr_ada' };
+const subject = async (req) => (req.headers.authorization === 'Bearer ada' ? ADA : req.headers.authorization === 'Bearer bo' ? { type: 'user', id: 'usr_bo' } : null);
+
+run([
+ ['the routes, read back with the SDK client: start, list, get, say, steps, the stream to its end, approvals', async () => {
+ const store = memoryStore();
+ const srv = await serve(createRunApi({ store, subject, product: 'chat', prefix: '/api/v1', pollMs: 30 }));
+ try {
+ const agents = createAgentsClient(createClient({ baseUrls: { network: srv.url }, token: 'ada' }));
+ const started = await agents.runs.start('A goal bar for my sub goal', { idempotencyKey: 'k-1' });
+ assert.equal(started.ref, 'chat:c_1', 'every run carries its ref');
+ assert.deepEqual(store.seen.starts[0], { input: { text: 'A goal bar for my sub goal' }, idempotencyKey: 'k-1' });
+ const listed = await agents.runs.list({ kind: 'chat' });
+ assert.deepEqual(listed.runs.map((r) => r.ref), ['chat:c_1']);
+ assert.equal((await agents.runs.get('chat:c_1')).title, 'A goal bar for my sub goal', 'a ref works as the id');
+ assert.equal(await agents.runs.get('c_9'), null, 'a run that is not there is null (404)');
+ const said = await agents.runs.say('c_1', 'Make it purple');
+ assert.deepEqual([said.seq, said.from], [2, 'person']);
+ const got = [];
+ const end = await agents.runs.follow('c_1', { onStep: (s) => { got.push(s.text); if (s.text === 'You said: Make it purple') agents.runs.say('c_1', 'bye'); } });
+ assert.equal(end.status, 'done', 'follow resolves with the end status');
+ assert.deepEqual(got.filter((t) => t.startsWith('You said')), ['You said: Make it purple', 'You said: bye']);
+ assert.deepEqual(await agents.approvals.answer('c_1', 'apr_1', 'approve'), { ok: true });
+ assert.deepEqual(store.seen.answers, [['apr_1', 'approve']]);
+ } finally { await srv.close(); }
+ }],
+ ['who may: no subject is 401; someone else\'s run is 404; a path that is not the Run API\'s goes to next()', async () => {
+ const store = memoryStore();
+ const srv = await serve(createRunApi({ store, subject, product: 'chat', prefix: '/api/v1' }));
+ try {
+ await store.start({ owner: ADA, input: { text: 'mine' } });
+ const call = (path, o = {}) => fetch(srv.url + path, { ...o, headers: { 'content-type': 'application/json', ...(o.token ? { authorization: `Bearer ${o.token}` } : {}) } });
+ let r = await call('/api/v1/runs');
+ assert.equal(r.status, 401); assert.equal((await r.json()).code, 'auth.required');
+ r = await call('/api/v1/runs/c_1', { token: 'bo' });
+ assert.equal(r.status, 404, 'Bo cannot read Ada\'s run');
+ r = await call('/api/v1/runs/c_1/messages', { method: 'POST', token: 'bo', body: JSON.stringify({ text: 'hi' }) });
+ assert.equal(r.status, 404, 'nor speak in it');
+ r = await call('/api/v1/other', { token: 'ada' });
+ assert.equal(r.status, 418, 'not the Run API\'s: next()');
+ r = await call('/api/v1/runs/a%20b', { token: 'ada' });
+ assert.equal(r.status, 404, 'an id that is not one');
+ } finally { await srv.close(); }
+ }],
+ ['what is refused: no words, a kind that is not one, a bad body, an answer without one; a mandate from a caller is dropped', async () => {
+ const store = memoryStore();
+ const srv = await serve(createRunApi({ store, subject, product: 'actor' }));
+ try {
+ const post = (path, body, raw) => fetch(srv.url + path, { method: 'POST', headers: { 'content-type': 'application/json', authorization: 'Bearer ada' }, body: raw || JSON.stringify(body) });
+ assert.equal((await (await post('/runs', { text: ' ' })).json()).code, 'runs.start_needs_words');
+ assert.equal((await (await post('/runs', { text: 'x', kind: 'wish' })).json()).code, 'runs.bad_kind');
+ assert.equal((await post('/runs', null, '{nope')).status, 400);
+ const r = await post('/runs', { text: 'Post the clip', kind: 'task', mandate: { source: 'feed', asked_by: { type: 'user', id: 'usr_someone' }, strength: 'explicit' } });
+ assert.equal(r.status, 201);
+ assert.equal('mandate' in store.seen.starts[0].input, false, 'only intake sets a mandate');
+ assert.equal((await post('/runs/c_1/approvals/apr_1', {})).status, 422);
+ assert.equal((await post('/runs/c_1/approvals/bad%2Fid', { answer: 'yes' })).status, 404);
+ } finally { await srv.close(); }
+ }],
+ ['a store without a method says so (409); a store\'s own error goes out as it is; anything else says nothing of the store', async () => {
+ const failing = { async list() { throw new Error('the database password is hunter2'); }, async get() { const e = new Error('Paused by the owner'); e.status = 423; e.code = 'runs.paused'; throw e; } };
+ const srv = await serve(createRunApi({ store: failing, subject, product: 'codes' }));
+ try {
+ const get = (p) => fetch(srv.url + p, { headers: { authorization: 'Bearer ada' } });
+ let r = await get('/runs');
+ assert.equal(r.status, 500);
+ const body = await r.json();
+ assert.equal(body.error, 'Runs are unavailable right now');
+ assert.ok(!JSON.stringify(body).includes('hunter2'), 'no store detail leaks');
+ r = await get('/runs/tsk_1');
+ assert.deepEqual([r.status, (await r.json()).code], [423, 'runs.paused']);
+ r = await fetch(srv.url + '/runs/tsk_1/messages', { method: 'POST', headers: { authorization: 'Bearer ada', 'content-type': 'application/json' }, body: '{"text":"hi"}' });
+ assert.deepEqual([r.status, (await r.json()).code], [409, 'runs.cannot_say']);
+ } finally { await srv.close(); }
+ }],
+ ['a store that pushes its own events is used for the stream, with refs on runs', async () => {
+ const store = { ...memoryStore(), async follow({ emit }) { emit('step', { step: { seq: 2, kind: 'message', text: 'hi' } }, 2); emit('run', { run: { id: 'c_1', status: 'done' } }, 2); emit('end', { status: 'done' }, 2); } };
+ await store.start({ owner: ADA, input: { text: 'x' } });
+ const srv = await serve(createRunApi({ store, subject, product: 'chat' }));
+ try {
+ const r = await fetch(srv.url + '/runs/c_1/events', { headers: { authorization: 'Bearer ada', 'last-event-id': '1' } });
+ const text = await r.text();
+ assert.match(text, /id: 2\nevent: step\ndata: \{"type":"step","step":\{"seq":2/);
+ assert.match(text, /"run":\{"id":"c_1","status":"done","ref":"chat:c_1"\}/);
+ assert.match(text, /event: end/);
+ const bad = await fetch(srv.url + '/runs/c_1/events', { headers: { authorization: 'Bearer ada', 'last-event-id': 'x' } });
+ assert.equal(bad.status, 400);
+ } finally { await srv.close(); }
+ }],
+ ['a store and a subject are required', () => {
+ assert.throws(() => createRunApi({ subject, product: 'chat' }), /a store is required/);
+ assert.throws(() => createRunApi({ store: {}, product: 'chat' }), /subject\(req\) is required/);
+ assert.throws(() => createRunApi({ store: {}, subject, product: 'Chat!' }), /product is a short lowercase name/);
+ }],
+]);
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+'use strict';
+// openvibe-sdk/agents/dispatch: the Dispatcher's choices, as pure functions (agent-engine.md §3).
+const assert = require('node:assert/strict');
+const { run } = require('./helpers');
+const { ladder, pick, escalate, harnessFor, placeFor, estimate, slice } = require('../src/agents/dispatch');
+
+const C = [
+ { id: 'credits:big', rung: 'credits', family: 'claude', tier: 'strong', caps: ['tools', 'vision', 'json', 'thinking'], context: 200000, speed: 60, price: { in: 3, out: 15 } },
+ { id: 'credits:cheap', rung: 'credits', family: 'deepseek', tier: 'medium', caps: ['tools', 'json'], context: 128000, speed: 50, price: { in: 0.28, out: 0.42 } },
+ { id: 'allowance:flash', rung: 'allowance', family: 'deepseek', tier: 'small', caps: ['tools', 'json'], context: 64000, speed: 90, price: { in: 0, out: 0 } },
+ { id: 'machine:qwen', rung: 'machine', family: 'qwen', tier: 'small', caps: ['tools'], context: 32000, speed: 12 },
+ { id: 'key:gpt', rung: 'key', family: 'openai', tier: 'strong', caps: ['tools', 'vision', 'json'], context: 400000, speed: 70, price: { in: 1.25, out: 10 } },
+ { id: 'plan:codex', rung: 'plan', family: 'openai', tier: 'strong', caps: ['tools', 'files', 'shell'], context: 400000, speed: 40, available: false },
+];
+const ids = (l) => l.map((c) => c.id);
+
+run([
+ ['the ladder: own machine, plan, key, allowance, credits, cheapest first; what cannot is skipped with why', () => {
+ assert.deepEqual(ids(ladder(C)), ['machine:qwen', 'key:gpt', 'allowance:flash', 'credits:cheap', 'credits:big']);
+ const l = ladder(C, { need: { caps: ['vision'] } });
+ assert.deepEqual(ids(l), ['key:gpt', 'credits:big']);
+ assert.deepEqual(l.skipped.map((s) => [s.choice.id, s.why]), [
+ ['credits:cheap', 'cannot vision'], ['allowance:flash', 'cannot vision'], ['machine:qwen', 'cannot vision'], ['plan:codex', 'not available now'],
+ ]);
+ assert.deepEqual(ids(ladder(C, { need: { context: 100000, speed: 30 } })), ['key:gpt', 'credits:cheap', 'credits:big']);
+ assert.equal(ladder(C)[0].why, 'machine');
+ }],
+ ['a person\'s pick wins when it can; when it cannot, it is passed over with the reason', () => {
+ assert.deepEqual(ids(ladder(C, { pin: 'credits:big' })).slice(0, 2), ['credits:big', 'machine:qwen']);
+ assert.equal(ladder(C, { pin: 'credits:big' })[0].why, 'your pick');
+ const p = pick(C, { pin: 'machine:qwen', need: { caps: ['json'] } });
+ assert.equal(p.choice.id, 'key:gpt');
+ assert.deepEqual(p.passedOver, { id: 'machine:qwen', why: 'cannot json' });
+ assert.equal(pick([], {}).choice, null);
+ }],
+ ['a step that needs strength tries strong first inside a rung; the budget leaves out what it cannot pay', () => {
+ assert.deepEqual(ids(ladder(C, { need: { tier: 'strong' } })), ['key:gpt', 'credits:big']);
+ assert.deepEqual(ids(ladder(C, { budgetLeft: 0.002 })), ['machine:qwen', 'allowance:flash', 'credits:cheap']);
+ }],
+ ['escalate only on checkable evidence, to a higher tier; a grader\'s failure changes family', () => {
+ const cheap = C[1];
+ assert.equal(escalate(cheap, { kind: 'hunch' }, C), null, 'never on a hunch');
+ assert.equal(escalate(cheap, null, C), null);
+ const up = escalate(cheap, { kind: 'test', detail: '3 failing' }, C);
+ assert.equal(up.choice.id, 'key:gpt');
+ assert.equal(up.why, 'a test failed: 3 failing');
+ assert.equal(escalate(C[4], { kind: 'schema' }, C), null, 'nothing above strong');
+ const other = escalate(C[4], { kind: 'grader' }, C);
+ assert.equal(other.choice.id, 'credits:big', 'a strong model of another family');
+ assert.equal(escalate(cheap, { kind: 'unsure' }, C, { tried: ['key:gpt'] }).choice.id, 'credits:big');
+ }],
+ ['the harness by capability: the loop for turns with tools, a signed-in plan, else dsh', () => {
+ const H = [{ id: 'loop', caps: ['tools'] }, { id: 'dsh', caps: ['tools', 'files', 'shell'] }, { id: 'codex', plan: 'codex', caps: ['tools', 'files', 'shell'] }, { id: 'browser', caps: ['tools', 'browser', 'desks'] }];
+ assert.equal(harnessFor({ needs: ['tools'] }, H).harness.id, 'loop');
+ assert.equal(harnessFor({ needs: ['files', 'shell'] }, H).harness.id, 'dsh');
+ const own = harnessFor({ needs: ['files', 'shell'] }, H, { plans: ['codex'] });
+ assert.deepEqual([own.harness.id, own.why], ['codex', 'your codex plan']);
+ assert.equal(harnessFor({ needs: ['browser'] }, H).harness.id, 'browser');
+ assert.equal(harnessFor({ needs: ['voice'] }, H), null);
+ }],
+ ['the place: your computer first (free), a runner for a repository elsewhere, an OpenVibe computer when allowed', () => {
+ const M = [{ id: 'mch_1', name: 'Desk PC', online: true, allows: ['worker', 'desks'], repos: ['ada/site'] }, { id: 'mch_2', online: false, allows: ['worker'], repos: ['ada/game'] }];
+ assert.deepEqual(placeFor({ target: { repo: 'ada/site' } }, { machines: M }).where, { kind: 'machine', id: 'mch_1', name: 'Desk PC' });
+ assert.deepEqual(placeFor({ target: { repo: 'ada/game' } }, { machines: M }).where, { kind: 'runner' });
+ assert.equal(placeFor({ needs: ['browser'] }, { machines: M }).where.kind, 'machine');
+ assert.equal(placeFor({ needs: ['browser'] }, { machines: [] }).where.kind, 'openvibe');
+ assert.equal(placeFor({ needs: ['browser'] }, { machines: [], cloud: false }), null, 'nowhere it may go');
+ assert.deepEqual(placeFor({ robot: 'rbt_1' }).where, { kind: 'machine', id: 'rbt_1' });
+ assert.equal(placeFor({}).where.kind, 'openvibe');
+ }],
+ ['a price says itself; a child\'s budget is a slice of its parent\'s', () => {
+ assert.equal(estimate(C[0], { tokensIn: 1e6, tokensOut: 1e6 }), 18);
+ assert.equal(estimate(C[3], { tokensIn: 1e6 }), 0, 'your own machine is free');
+ assert.deepEqual(slice({ usd: 1, tokens: 10000, steps: 40 }, 0.25), { usd: 0.25, tokens: 2500, steps: 10 });
+ assert.deepEqual(slice({ usd: 1 }, 7), { usd: 1 }, 'never more than the whole');
+ assert.deepEqual(slice(null, 0.5), {});
+ }],
+]);
new file mode 100644
new file mode 100644
@@ -0,0 +1,165 @@
@@ -0,0 +1,165 @@
+'use strict';
+// openvibe-sdk/agents/loop: the light agent loop (a port of pi's, MIT) and its step sink, driven by a scripted model.
+const assert = require('node:assert/strict');
+const { run } = require('./helpers');
+const { runLoop, createStepSink, checkInput, STOPS } = require('../src/agents/loop');
+
+/** A model that answers from a script, one turn per call, and remembers what it was shown. */
+function scripted(turns) {
+ const seen = [];
+ return {
+ id: 'test:scripted',
+ seen,
+ async complete(req) {
+ seen.push(req.messages.map((m) => `${m.role}:${m.text || (m.tool_calls || []).map((c) => c.name).join('+') || ''}`));
+ const t = turns.shift();
+ if (!t) return { text: 'Done.', stop: 'stop' };
+ if (t.throws) throw new Error(t.throws);
+ if (t.stream) for (const d of t.stream) await req.onDelta(d);
+ return t;
+ },
+ };
+}
+const tool = (name, run, extra = {}) => ({ name, description: name, run, ...extra });
+
+run([
+ ['a question answered with a tool, then words; the steps follow it in order', async () => {
+ const model = scripted([
+ { thinking: 'They want a reminder.', tool_calls: [{ id: 'c1', name: 'remind', input: { what: 'stretch', every: 120 } }], usage: { tokens: 100 }, cost: { usd: 0.001 } },
+ { stream: [{ text: 'Reminder ' }, { text: 'set.' }], usage: { tokens: 50 }, cost: { usd: 0.0005 } },
+ ]);
+ const steps = new Map(); const deltas = [];
+ const sink = createStepSink({ runId: 'r1', startSeq: 2, append: (s) => steps.set(s.seq, { ...s }), update: (n, p) => Object.assign(steps.get(n), p), delta: (n, t) => deltas.push([n, t]) });
+ const remind = tool('remind', async (input) => ({ result: { id: 'rem_1', ...input }, cost: { usd: 0.0001 } }), { inputSchema: { type: 'object', required: ['what'], properties: { what: { type: 'string' }, every: { type: 'integer' } } } });
+ const out = await runLoop({ model, tools: [remind], prompts: [{ role: 'user', text: 'Remind me to stretch every 2 hours' }], onEvent: sink });
+ assert.equal(out.stop, 'done');
+ assert.equal(out.turns, 2);
+ assert.deepEqual(out.usage, { tokens: 150 });
+ assert.equal(out.cost.usd, 0.0016, 'model turns and the tool\'s own cost');
+ assert.deepEqual(out.messages.map((m) => m.role), ['user', 'assistant', 'tool', 'assistant']);
+ assert.deepEqual(model.seen[1], ['user:Remind me to stretch every 2 hours', 'assistant:remind', 'tool:{"id":"rem_1","what":"stretch","every":120}'], 'the second turn sees the tool\'s result');
+ const list = [...steps.values()];
+ assert.deepEqual(list.map((s) => [s.seq, s.kind, s.from, s.status]), [
+ [2, 'message', 'person', 'done'], [3, 'think', 'agent', 'done'], [4, 'tool', 'tool', 'done'], [5, 'message', 'agent', 'done'],
+ ]);
+ assert.equal(list[2].tool_call.name, 'remind');
+ assert.deepEqual(list[2].result, { id: 'rem_1', what: 'stretch', every: 120 });
+ assert.equal(list[3].text, 'Reminder set.');
+ assert.deepEqual(deltas, [[5, 'Reminder '], [5, 'set.']], 'the words stream into their step');
+ assert.ok(list.every((s) => s.run_id === 'r1' && s.turn === 0));
+ }],
+ ['parallel tools keep the model\'s order; a sequential tool runs them one by one', async () => {
+ const order = [];
+ const slow = tool('slow', async () => { await new Promise((r) => setTimeout(r, 30)); order.push('slow'); return { result: 'slow' }; });
+ const fast = tool('fast', async () => { order.push('fast'); return { result: 'fast' }; });
+ let out = await runLoop({ model: scripted([{ tool_calls: [{ id: 'a', name: 'slow', input: {} }, { id: 'b', name: 'fast', input: {} }] }]), tools: [slow, fast], prompts: [{ role: 'user', text: 'go' }] });
+ assert.deepEqual(order, ['fast', 'slow'], 'run together');
+ assert.deepEqual(out.messages.filter((m) => m.role === 'tool').map((m) => m.name), ['slow', 'fast'], 'results in the model\'s order');
+ order.length = 0;
+ out = await runLoop({ model: scripted([{ tool_calls: [{ id: 'a', name: 'slow', input: {} }, { id: 'b', name: 'fast', input: {} }] }]), tools: [{ ...slow, sequential: true }, fast], prompts: [{ role: 'user', text: 'go' }] });
+ assert.deepEqual(order, ['slow', 'fast'], 'one by one');
+ }],
+ ['an unknown tool, input its schema refuses, a throw and a timeout come back as errors, never a crash', async () => {
+ const boom = tool('boom', async () => { throw new Error('the disk is full'); });
+ const hang = tool('hang', (input, ctx) => new Promise((resolve) => ctx.signal.addEventListener('abort', () => resolve({ result: 'late' }))), { timeoutMs: 40 });
+ const strict = tool('strict', async () => ({ result: 'ok' }), { inputSchema: { type: 'object', required: ['n'], properties: { n: { type: 'integer' } }, additionalProperties: false } });
+ const out = await runLoop({
+ model: scripted([{ tool_calls: [{ id: '1', name: 'nope', input: {} }, { id: '2', name: 'boom', input: {} }, { id: '3', name: 'hang', input: {} }, { id: '4', name: 'strict', input: { n: 1.5 } }, { id: '5', name: 'strict', input: { n: 1, x: 2 } }] }]),
+ tools: [boom, hang, strict], prompts: [{ role: 'user', text: 'go' }],
+ });
+ const errs = out.messages.filter((m) => m.role === 'tool').map((m) => [m.name, m.is_error, m.result.error]);
+ assert.deepEqual(errs, [
+ ['nope', true, 'There is no tool named nope.'],
+ ['boom', true, 'the disk is full'],
+ ['hang', true, 'hang took longer than 40 ms and was stopped.'],
+ ['strict', true, 'The input does not fit strict: input.n should be integer.'],
+ ['strict', true, 'The input does not fit strict: input.x is not an input of this tool.'],
+ ]);
+ assert.equal(out.stop, 'done', 'the model was told and finished');
+ }],
+ ['a call cut off by the output limit is never run', async () => {
+ let ran = 0;
+ const out = await runLoop({ model: scripted([{ stop: 'length', tool_calls: [{ id: '1', name: 'send', input: { to: 'every' } }] }]), tools: [tool('send', async () => { ran++; return { result: 'sent' }; })], prompts: [{ role: 'user', text: 'go' }] });
+ assert.equal(ran, 0);
+ assert.match(out.messages.find((m) => m.role === 'tool').result.error, /hit the output limit/);
+ }],
+ ['the approval gate blocks a call; terminate ends the run', async () => {
+ const asked = [];
+ const pay = tool('pay', async () => ({ result: 'paid' }));
+ let out = await runLoop({ model: scripted([{ tool_calls: [{ id: '1', name: 'pay', input: { usd: 5 } }] }]), tools: [pay], prompts: [{ role: 'user', text: 'pay' }],
+ beforeToolCall: async ({ toolCall, input }) => { asked.push([toolCall.name, input.usd]); return { block: true, reason: 'The person said not now.' }; } });
+ assert.deepEqual(asked, [['pay', 5]]);
+ assert.equal(out.messages.find((m) => m.role === 'tool').result.error, 'The person said not now.');
+ assert.equal(out.stop, 'done');
+ out = await runLoop({ model: scripted([{ tool_calls: [{ id: '1', name: 'finish', input: {} }] }, { text: 'never asked' }]), tools: [tool('finish', async () => ({ result: 'over', terminate: true }))], prompts: [{ role: 'user', text: 'go' }] });
+ assert.equal(out.stop, 'terminated');
+ assert.equal(out.turns, 1, 'no turn after a tool that ends the run');
+ }],
+ ['the same call repeated gets a reminder; an idempotent tool answers from the memo', async () => {
+ let looks = 0, reads = 0;
+ const look = tool('look', async () => { looks++; return { result: 'same' }; });
+ const read = tool('read', async () => { reads++; return { result: 'page' }; }, { idempotent: true });
+ const call = (id, name) => ({ tool_calls: [{ id, name, input: { q: 'x' } }] });
+ const out = await runLoop({ model: scripted([call('1', 'look'), call('2', 'look'), call('3', 'look'), call('4', 'look'), call('5', 'read'), call('6', 'read')]), tools: [look, read], prompts: [{ role: 'user', text: 'go' }], repeatLimit: 3 });
+ assert.equal(looks, 3, 'the fourth was not run');
+ assert.match(out.messages.filter((m) => m.name === 'look').pop().result.error, /already called look with this same input 3 times/);
+ assert.equal(reads, 1, 'read once');
+ assert.equal(out.messages.filter((m) => m.name === 'read').pop().memo, true);
+ }],
+ ['the budget stops it between turns; the model is told what is left', async () => {
+ const model = scripted([{ tool_calls: [{ id: '1', name: 'x', input: {} }], cost: { usd: 0.03 } }, { tool_calls: [{ id: '2', name: 'x', input: { a: 1 } }], cost: { usd: 0.03 } }, { text: 'never' }]);
+ const lefts = [];
+ const wrapped = { ...model, complete: (req) => { lefts.push(req.budgetLeft); return model.complete(req); } };
+ const out = await runLoop({ model: wrapped, tools: [tool('x', async () => ({ result: 1 }))], prompts: [{ role: 'user', text: 'go' }], budget: { usd: 0.05 } });
+ assert.equal(out.stop, 'budget');
+ assert.equal(out.turns, 2);
+ assert.deepEqual(lefts.map((l) => Math.round(l.usd * 100) / 100), [0.05, 0.02]);
+ const capped = await runLoop({ model: scripted([{ tool_calls: [{ id: '1', name: 'x', input: {} }] }, { tool_calls: [{ id: '2', name: 'x', input: { b: 1 } }] }, { text: 'no' }]), tools: [tool('x', async () => ({ result: 1 }))], prompts: [{ role: 'user', text: 'go' }], maxTurns: 2 });
+ assert.equal(capped.stop, 'turns');
+ }],
+ ['steering is taken before the next turn; a follow-up wakes it after it would stop; a person\'s word opens a turn', async () => {
+ const queue = []; // the person writes while the tool runs
+ let follow = [{ role: 'user', text: 'and a sound' }];
+ const model = scripted([{ tool_calls: [{ id: '1', name: 'draw', input: {} }] }, { text: 'Purple it is.' }, { text: 'Sound added.' }]);
+ const steps = [];
+ const out = await runLoop({ model, tools: [tool('draw', async () => { queue.push({ role: 'user', text: 'make it purple' }); return { result: 'bar' }; })], prompts: [{ role: 'user', text: 'a goal bar' }],
+ steering: () => queue.splice(0), followUp: () => { const f = follow; follow = []; return f; },
+ onEvent: createStepSink({ append: (s) => steps.push(s), startSeq: 1 }) });
+ assert.equal(out.stop, 'done');
+ assert.deepEqual(out.messages.filter((m) => m.role === 'user').map((m) => m.text), ['a goal bar', 'make it purple', 'and a sound']);
+ assert.deepEqual(model.seen[1].slice(-1), ['user:make it purple'], 'the steering words reached the next turn');
+ assert.deepEqual(steps.filter((s) => s.from === 'person').map((s) => [s.text, s.turn]), [['a goal bar', 0], ['make it purple', 1], ['and a sound', 2]]);
+ }],
+ ['a model that throws ends the run with stop: error; an abort stops it; ui-only entries never reach the model', async () => {
+ const steps = [];
+ let out = await runLoop({ model: scripted([{ throws: 'provider down' }]), prompts: [{ role: 'user', text: 'hi' }], onEvent: createStepSink({ append: (s) => steps.push(s) }) });
+ assert.equal(out.stop, 'error');
+ assert.equal(out.error, 'provider down');
+ assert.deepEqual(steps.map((s) => [s.kind, s.from, s.status, s.text]), [['message', 'person', 'done', 'hi'], ['message', 'system', 'failed', 'provider down']]);
+ const ctl = new AbortController(); ctl.abort();
+ out = await runLoop({ model: scripted([{ text: 'no' }]), prompts: [{ role: 'user', text: 'hi' }], signal: ctl.signal });
+ assert.equal(out.stop, 'aborted');
+ const model = scripted([{ text: 'ok' }]);
+ await runLoop({ model, messages: [{ role: 'user', text: 'old' }, { role: 'assistant', text: 'a card', ui_only: true }], prompts: [{ role: 'user', text: 'new' }] });
+ assert.deepEqual(model.seen[0], ['user:old', 'user:new']);
+ assert.ok(STOPS.includes('budget'));
+ }],
+ ['a long tool output is clipped before the model sees it', async () => {
+ const model = scripted([{ tool_calls: [{ id: '1', name: 'dump', input: {} }] }, { text: 'ok' }]);
+ const out = await runLoop({ model, tools: [tool('dump', async () => ({ result: 'x'.repeat(500) }))], prompts: [{ role: 'user', text: 'go' }], toolOutputLimit: 100 });
+ const m = out.messages.find((x) => x.role === 'tool');
+ assert.ok(m.text.length < 160 && /more characters left out/.test(m.text));
+ assert.equal(m.result.length, 500, 'the whole result is kept in the thread');
+ }],
+ ['checkInput: the subset tools use', () => {
+ const s = { type: 'object', required: ['a'], properties: { a: { type: 'string', enum: ['x', 'y'] }, b: { type: 'array', items: { type: 'number' } } } };
+ assert.equal(checkInput(s, { a: 'x', b: [1, 2] }), null);
+ assert.equal(checkInput(s, { b: [] }), 'input.a is required');
+ assert.equal(checkInput(s, { a: 'z' }), 'input.a should be one of "x", "y"');
+ assert.equal(checkInput(s, { a: 'x', b: [1, 'two'] }), 'input.b[1] should be number');
+ }],
+ ['no model, no loop', async () => {
+ await assert.rejects(() => runLoop({}), /a model adapter with complete\(\) is required/);
+ assert.throws(() => createStepSink({}), /needs append/);
+ }],
+]);
@@ -72,7 +72,7 @@ run([
@@ -72,7 +72,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
- assert.equal(subpaths.length, 112); // 0.152.0 train: elements (#227) and ui-layout (#226) add the 110th and 111th; // composer, feed and agents add the 104th-106th after user-card; the 0.148.0 train's codes, player and the rest bring 109; elements brings 110.
+ assert.equal(subpaths.length, 115); // 0.160.0: agents/loop, agents/dispatch and agents/api are the 113th to 115th. // 0.152.0 train: elements (#227) and ui-layout (#226) add the 110th and 111th; // composer, feed and agents add the 104th-106th after user-card; the 0.148.0 train's codes, player and the rest bring 109; elements brings 110.
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
+// openvibe-sdk/agents/api: the Run API from a product's own store (Contracts docs/agents-runs.md).
+import type { IncomingMessage, ServerResponse } from 'node:http';
+
+export interface RunOwner { type: string; id: string; }
+export interface RunStore {
+ list?(q: { owner: RunOwner; kind: string | null; status: string | null; q: string | null; limit: number; cursor: string | null }): Promise<{ runs: object[]; next?: string | null }>;
+ get?(q: { owner: RunOwner; id: string }): Promise<object | null>;
+ steps?(q: { owner: RunOwner; id: string; after: number; limit: number }): Promise<{ steps: object[]; next?: string | null } | null>;
+ start?(q: { owner: RunOwner; input: { text: string; [key: string]: unknown }; idempotencyKey: string | null }): Promise<object>;
+ say?(q: { owner: RunOwner; id: string; text: string; attachments: unknown[] }): Promise<object>;
+ stop?(q: { owner: RunOwner; id: string; reason: string | null }): Promise<object>;
+ answer?(q: { owner: RunOwner; id: string; approval: string; answer: unknown }): Promise<unknown>;
+ follow?(q: { owner: RunOwner; id: string; after: number; signal: AbortSignal; emit(event: string, data: object, id?: number): void }): Promise<void>;
+}
+export interface RunApiOptions {
+ store: RunStore;
+ subject(req: IncomingMessage): Promise<{ type?: string; id: string } | null> | { type?: string; id: string } | null;
+ product: string;
+ prefix?: string;
+ pollMs?: number;
+ streamMs?: number;
+ /** Intake's own hook: a mandate for a start it took in (agents.run-start@1's MANDATE); callers never set one. */
+ intake?(req: IncomingMessage, input: object): Promise<object | null> | object | null;
+}
+export function createRunApi(options: RunApiOptions): { handle(req: IncomingMessage, res: ServerResponse, next?: () => void): Promise<void>; KINDS: string[]; STATUSES: string[] };
+export function startInput(body: object): { text: string; [key: string]: unknown };
+export const KINDS: string[];
+export const STATUSES: string[];
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+// openvibe-sdk/agents/dispatch: the Dispatcher (agent-engine.md §3), pure functions over a person's choices.
+
+export type Rung = 'machine' | 'plan' | 'key' | 'allowance' | 'credits';
+export type Tier = 'small' | 'medium' | 'strong';
+export interface Choice {
+ id: string;
+ name?: string;
+ rung?: Rung;
+ family?: string;
+ tier?: Tier;
+ caps?: string[];
+ context?: number;
+ speed?: number;
+ price?: { in?: number; out?: number };
+ available?: boolean;
+}
+export interface Need { caps?: string[]; context?: number; speed?: number; tier?: Tier; }
+export interface LadderOptions { need?: Need; pin?: string | null; avoid?: string[]; budgetLeft?: number | null; tokens?: { tokensIn?: number; tokensOut?: number }; }
+export type Ladder = Array<Choice & { why: string }> & { skipped: Array<{ choice: Choice; why: string; pinned?: boolean }> };
+export function ladder(choices: Choice[], options?: LadderOptions): Ladder;
+export function pick(choices: Choice[], options?: LadderOptions): { choice: (Choice & { why: string }) | null; why: string; passedOver: { id: string; why: string } | null; skipped: Ladder['skipped'] };
+export interface Evidence { kind: 'test' | 'schema' | 'tool' | 'grader' | 'unsure'; detail?: string; }
+export function escalate(current: Choice, evidence: Evidence | null, choices: Choice[], options?: { need?: Need; tried?: string[]; budgetLeft?: number | null }): { choice: Choice & { why: string }; why: string } | null;
+export interface Harness { id: string; caps?: string[]; plan?: string; }
+export function harnessFor(task: { needs?: string[] }, harnesses: Harness[], options?: { plans?: string[] }): { harness: Harness; why: string } | null;
+export interface Machine { id: string; name?: string; online?: boolean; allows?: string[]; repos?: string[]; }
+export interface Where { kind: 'openvibe' | 'machine' | 'runner' | 'this-computer'; id?: string; name?: string; }
+export function placeFor(run: { target?: unknown; needs?: string[]; robot?: string; untrusted?: boolean; heavy?: boolean }, options?: { machines?: Machine[]; cloud?: boolean }): { where: Where; why: string } | null;
+export function estimate(choice: Choice, tokens?: { tokensIn?: number; tokensOut?: number }): number;
+export function slice(parent: { usd?: number; tokens?: number; seconds?: number; steps?: number } | null, share: number): { usd?: number; tokens?: number; seconds?: number; steps?: number };
+export function cannot(choice: Choice, need?: Need): string | null;
+export const RUNGS: Rung[];
+export const TIERS: Tier[];
+export const EVIDENCE: Array<Evidence['kind']>;
new file mode 100644
new file mode 100644
@@ -0,0 +1,106 @@
@@ -0,0 +1,106 @@
+// openvibe-sdk/agents/loop: the light agent loop (a port of pi's agent loop, MIT, (c) 2025 Mario Zechner).
+
+export type LoopRole = 'system' | 'user' | 'assistant' | 'tool';
+export interface LoopToolCall { id: string; name: string; input: Record<string, unknown>; }
+export interface LoopMessage {
+ role: LoopRole;
+ text?: string;
+ thinking?: string;
+ tool_calls?: LoopToolCall[];
+ tool_call_id?: string;
+ name?: string;
+ result?: unknown;
+ is_error?: boolean;
+ memo?: boolean;
+ duration_ms?: number;
+ artifacts?: unknown[];
+ stop?: 'stop' | 'tool_use' | 'length' | 'error' | 'aborted';
+ usage?: { tokens?: number } | null;
+ cost?: { usd?: number } | null;
+ error?: string;
+ /** Kept in the thread, never sent to the model (a card, a note for the UI). */
+ ui_only?: boolean;
+ at?: number;
+}
+export interface LoopModelTurn {
+ text?: string;
+ thinking?: string;
+ tool_calls?: Array<{ id?: string; name: string; input?: Record<string, unknown> }>;
+ stop?: 'stop' | 'tool_use' | 'length' | 'error' | 'aborted';
+ usage?: { tokens?: number };
+ cost?: { usd?: number };
+ error?: string;
+}
+export interface LoopModelAdapter {
+ id?: string;
+ family?: string;
+ complete(request: {
+ messages: LoopMessage[];
+ tools: LoopTool[];
+ system?: string;
+ signal?: AbortSignal;
+ budgetLeft: { usd?: number; tokens?: number; turns?: number } | null;
+ onDelta(delta: { text?: string; thinking?: string }): void | Promise<void>;
+ }): Promise<LoopModelTurn>;
+}
+export interface LoopToolResult { result?: unknown; is_error?: boolean; terminate?: boolean; cost?: { usd?: number }; artifacts?: unknown[]; }
+export interface LoopTool {
+ name: string;
+ description?: string;
+ inputSchema?: Record<string, unknown>;
+ run(input: Record<string, unknown>, context: { toolCall: LoopToolCall; signal: AbortSignal; run: unknown; onUpdate(partial: unknown): void }): Promise<LoopToolResult | unknown> | LoopToolResult | unknown;
+ sequential?: boolean;
+ idempotent?: boolean;
+ timeoutMs?: number;
+ title?: string;
+ glyph?: string;
+ prepareInput?(input: Record<string, unknown>): Record<string, unknown>;
+}
+export type LoopEvent =
+ | { type: 'agent_start' }
+ | { type: 'turn_start' }
+ | { type: 'message_start' | 'message_end'; message: LoopMessage }
+ | { type: 'message_update'; message: LoopMessage; delta: { text?: string; thinking?: string } }
+ | { type: 'tool_start'; toolCall: LoopToolCall }
+ | { type: 'tool_update'; toolCall: LoopToolCall; partial: unknown }
+ | { type: 'tool_end'; toolCall: LoopToolCall; result: unknown; isError: boolean; durationMs?: number }
+ | { type: 'turn_end'; message: LoopMessage; toolResults: LoopMessage[] }
+ | { type: 'agent_end'; messages: LoopMessage[]; stop: LoopStop; error: string | null };
+export type LoopStop = 'done' | 'terminated' | 'budget' | 'turns' | 'aborted' | 'error';
+export interface LoopOptions {
+ model: LoopModelAdapter;
+ tools?: LoopTool[];
+ system?: string;
+ messages?: LoopMessage[];
+ prompts?: LoopMessage[];
+ budget?: { usd?: number; tokens?: number; turns?: number; steps?: number };
+ beforeToolCall?(call: { toolCall: LoopToolCall; input: Record<string, unknown>; tool: LoopTool; assistant: LoopMessage; messages: LoopMessage[] }, signal?: AbortSignal): Promise<{ block?: boolean; reason?: string; terminate?: boolean } | void> | { block?: boolean; reason?: string; terminate?: boolean } | void;
+ afterToolCall?(call: { toolCall: LoopToolCall; input: Record<string, unknown>; tool: LoopTool; assistant: LoopMessage; result: unknown; isError: boolean; messages: LoopMessage[] }, signal?: AbortSignal): Promise<{ result?: unknown; isError?: boolean; terminate?: boolean } | void> | { result?: unknown; isError?: boolean; terminate?: boolean } | void;
+ transformContext?(messages: LoopMessage[], signal?: AbortSignal): Promise<LoopMessage[]> | LoopMessage[];
+ steering?(): Promise<LoopMessage[]> | LoopMessage[];
+ followUp?(): Promise<LoopMessage[]> | LoopMessage[];
+ finishTurn?(turn: { assistant: LoopMessage; results: LoopMessage[]; messages: LoopMessage[]; added: LoopMessage[]; totals: { tokens: number; usd: number; turns: number; toolCalls: number } }): Promise<{ action?: 'end' | 'continue' } | void> | { action?: 'end' | 'continue' } | void;
+ onEvent?(event: LoopEvent): void | Promise<void>;
+ signal?: AbortSignal;
+ toolExecution?: 'parallel' | 'sequential';
+ maxTurns?: number;
+ repeatLimit?: number;
+ toolTimeoutMs?: number;
+ toolOutputLimit?: number;
+ run?: unknown;
+}
+export interface LoopResult { messages: LoopMessage[]; stop: LoopStop; error: string | null; usage: { tokens: number }; cost: { usd: number }; turns: number; toolCalls: number; }
+export function runLoop(options: LoopOptions): Promise<LoopResult>;
+export interface StepSinkOptions {
+ append(step: Record<string, unknown>): unknown;
+ update?(seq: number, patch: Record<string, unknown>): unknown;
+ delta?(seq: number, text: string): unknown;
+ runId?: string | null;
+ startSeq?: number;
+ startTurn?: number;
+ toolTitle?(call: LoopToolCall): string | undefined;
+}
+export function createStepSink(options: StepSinkOptions): (event: LoopEvent) => Promise<void>;
+export function checkInput(schema: Record<string, unknown> | undefined, value: unknown, at?: string): string | null;
+export const STOPS: LoopStop[];
+export const DEFAULTS: { maxTurns: number; repeatLimit: number; toolTimeoutMs: number; toolOutputLimit: number };
@@ -30,6 +30,9 @@ import * as ai from './ai';
@@ -30,6 +30,9 @@ import * as ai from './ai';
import * as credentials from './credentials';
import * as credentials from './credentials';
import * as jobs from './jobs';
import * as jobs from './jobs';
import * as agents from './agents';
import * as agents from './agents';
+import * as agentsLoop from './agents-loop';
+import * as agentsDispatch from './agents-dispatch';
+import * as agentsApi from './agents-api';
import * as frame from './frame';
import * as frame from './frame';
import * as tools from './tools';
import * as tools from './tools';
import * as projects from './projects';
import * as projects from './projects';
@@ -71,6 +74,9 @@ export * as ai from './ai';
@@ -71,6 +74,9 @@ export * as ai from './ai';
export * as credentials from './credentials';
export * as credentials from './credentials';
export * as jobs from './jobs';
export * as jobs from './jobs';
export * as agents from './agents';
export * as agents from './agents';
+export * as agentsLoop from './agents-loop';
+export * as agentsDispatch from './agents-dispatch';
+export * as agentsApi from './agents-api';
export * as frame from './frame';
export * as frame from './frame';
export * as tools from './tools';
export * as tools from './tools';
export * as projects from './projects';
export * as projects from './projects';
@@ -88,6 +94,6 @@ declare const sdk: typeof core & {
@@ -88,6 +94,6 @@ declare const sdk: typeof core & {
userCard: typeof userCard;
userCard: typeof userCard;
player: typeof player;
player: typeof player;
auth: typeof auth; registry: typeof registry; modules: typeof modules; realtime: typeof realtime;
auth: typeof auth; registry: typeof registry; modules: typeof modules; realtime: typeof realtime;
- media: typeof media; composer: typeof composer; cam: typeof cam; mediaUpload: typeof mediaUpload; ui: typeof ui; uiLayout: typeof uiLayout; community: typeof community; homeFeed: typeof homeFeed; feed: typeof feed; views: typeof views; bot: typeof bot; search: typeof search; bench: typeof bench; meme: typeof meme; vision: typeof vision; themes: typeof themes; design: typeof design; redact: typeof redact; chat: typeof chat; ai: typeof ai; credentials: typeof credentials; jobs: typeof jobs; agents: typeof agents; tools: typeof tools; projects: typeof projects; geo: typeof geo; history: typeof history; locker: typeof locker; trust: typeof trust; levels: typeof levels;
+ media: typeof media; composer: typeof composer; cam: typeof cam; mediaUpload: typeof mediaUpload; ui: typeof ui; uiLayout: typeof uiLayout; community: typeof community; homeFeed: typeof homeFeed; feed: typeof feed; views: typeof views; bot: typeof bot; search: typeof search; bench: typeof bench; meme: typeof meme; vision: typeof vision; themes: typeof themes; design: typeof design; redact: typeof redact; chat: typeof chat; ai: typeof ai; credentials: typeof credentials; jobs: typeof jobs; agents: typeof agents; agentsLoop: typeof agentsLoop; agentsDispatch: typeof agentsDispatch; agentsApi: typeof agentsApi; tools: typeof tools; projects: typeof projects; geo: typeof geo; history: typeof history; locker: typeof locker; trust: typeof trust; levels: typeof levels;
};
};
export default sdk;
export default sdk;
@@ -31,6 +31,9 @@ export * as ai from './ai';
@@ -31,6 +31,9 @@ export * as ai from './ai';
export * as credentials from './credentials';
export * as credentials from './credentials';
export * as jobs from './jobs';
export * as jobs from './jobs';
export * as agents from './agents';
export * as agents from './agents';
+export * as agentsLoop from './agents-loop';
+export * as agentsDispatch from './agents-dispatch';
+export * as agentsApi from './agents-api';
export * as frame from './frame';
export * as frame from './frame';
export * as tools from './tools';
export * as tools from './tools';
export * as projects from './projects';
export * as projects from './projects';