UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

332 lines • 13.7 kB
import { throwForResponse } from './company.js'; import { ConversationError, PoppyError } from './errors.js'; import { readJson } from './http.js'; import { parseSse } from './sse.js'; import { randomId, requireSecureUrl, sleep } from './util.js'; const ID_RE = /^[A-Za-z0-9_-]{1,256}$/; /** A fresh message ID: URL-safe base64, prefixed, unique for the User (7.2, 7.3). */ export function newMessageId() { return randomId('msg_', 12); } /** Picks the `poppy` conversation protocol entry (7.1); unknown types are skipped. */ export function poppyProtocol(company) { return company.document.agent?.protocols?.find((p) => p?.type === 'poppy'); } /** * Client for one Company's Poppy conversation endpoint (section 7). Every request carries the * Session Token with a DPoP proof (7.2), through `CompanyClient.fetch`. */ export class ConversationClient { company; endpoint; resource; /** Narrower scope for every request of this client (4.3 "Narrower tokens"), when set. */ scope; constructor(company, entry = poppyProtocol(company), options = {}) { this.company = company; if (!entry) throw new PoppyError('no_conversation_protocol', 'Company lists no `poppy` protocol'); this.endpoint = requireSecureUrl(entry.endpoint, 'agent endpoint', company.security) .toString() .replace(/\/+$/, ''); this.resource = entry.resource; this.scope = options.scope; } url(id, suffix, query) { if (id !== undefined && !ID_RE.test(id)) throw new PoppyError('invalid_id', `bad conversation id ${id}`); const u = new URL(id === undefined ? this.endpoint : `${this.endpoint}/${id}${suffix ?? ''}`); for (const [k, v] of Object.entries(query ?? {})) if (v !== undefined) u.searchParams.set(k, String(v)); return u.toString(); } /** * POST with retries on network errors / 5xx. Safe because the message ID makes the request * idempotent: the Company returns the original response for a repeat (7.3). */ async post(url, body, retries = 2) { let lastErr; for (let attempt = 0; attempt <= retries; attempt++) { try { const res = await this.company.fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(body), resource: this.resource, scope: this.scope, }); if (res.status >= 500 && attempt < retries) { lastErr = new PoppyError(`http_${res.status}`); await sleep(300 * (attempt + 1)); continue; } if (!res.ok) await throwForResponse(res); return { status: res.status, body: (await readJson(res)) }; } catch (e) { if (e instanceof PoppyError) throw e; lastErr = e; if (attempt < retries) await sleep(300 * (attempt + 1)); } } throw lastErr; } remember(id, patch) { const convs = this.company.state.conversations; convs[id] ??= { endpoint: this.endpoint, createdAt: Date.now() }; const rec = convs[id]; if (patch.cursor !== undefined) rec.cursor = patch.cursor; if (patch.closed !== undefined) rec.closed = patch.closed; if (patch.parent !== undefined) rec.parentConversationId = patch.parent; } /** Starts a conversation (7.3), or a Direct Conversation with `parentConversationId` (7.10). */ async start(message, opts = {}) { checkMessage(message); const body = { message }; if (opts.parentConversationId) body.parent_conversation_id = opts.parentConversationId; const r = await this.post(this.url(undefined, undefined, { wait: opts.wait, cursor: opts.cursor }), body); if (!r.body?.conversation_id) throw new PoppyError('invalid_response', 'no conversation_id'); this.remember(r.body.conversation_id, { parent: opts.parentConversationId }); await this.company.save(); return r.body; } /** Sends another message (7.3). Returns 202 with `status`/`responder` (+ events with `wait`). */ async send(id, message, opts = {}) { checkMessage(message); const r = await this.post(this.url(id, '/messages', { wait: opts.wait, cursor: opts.cursor }), { message }); return r.body; } /** One read of events after `cursor` (7.5). */ async read(id, opts = {}) { const { body } = await this.company.json(this.url(id, '/events', opts), { headers: { accept: 'application/json' }, resource: this.resource, scope: this.scope, }); if (!Array.isArray(body?.events)) throw new PoppyError('invalid_response', 'events must be a list'); return body; } /** Asks for a person at the Company (7.9). */ async handoff(id) { return (await this.post(this.url(id, '/handoff'), {})).body; } /** Closes the conversation (7.12). */ async close(id) { const r = (await this.post(this.url(id, '/close'), {})).body; this.remember(id, { closed: true }); await this.company.save(); return r; } /** * Reads (long-polling, JSON) until the Company is no longer `working` and `has_more` is * false, returning the new events. Used by the autonomous `ask` mode. */ async collect(id, opts = {}) { const deadline = Date.now() + (opts.timeoutMs ?? 120_000); const events = []; const seen = new Set(); let cursor = this.company.state.conversations[id]?.cursor || undefined; for (;;) { let page; try { page = await this.read(id, { cursor, wait: opts.wait ?? 20 }); } catch (e) { if (e instanceof ConversationError && e.code === 'cursor_expired') { cursor = undefined; continue; } throw e; } for (const ev of page.events) { if (seen.has(ev.id)) continue; seen.add(ev.id); events.push(ev); } if (page.cursor) cursor = page.cursor; this.remember(id, { cursor }); await this.company.save(); if (page.has_more) continue; if (page.status !== 'working' || Date.now() > deadline) { return { events, state: { status: page.status, responder: page.responder } }; } } } /** * Follows a conversation's events in order, from the saved cursor: SSE when the Company * streams (reconnecting with `Last-Event-ID`), long-polling otherwise. Skips event IDs it * already handled, restarts from the beginning on `cursor_expired`, and persists the cursor. */ async *follow(id, opts = {}) { const seen = new Set(); let cursor = this.company.state.conversations[id]?.cursor; let useStream = opts.stream !== false; let firstConnect = true; let status; const handle = async (ev) => { if (!ev || typeof ev.id !== 'string' || seen.has(ev.id)) return false; seen.add(ev.id); cursor = ev.id; this.remember(id, { cursor }); if (ev.type === 'state' && ev.status) status = ev.status; return true; }; const settled = () => opts.untilSettled && (status === 'idle' || status === 'queued' || status === 'closed'); // `untilSettled` ends on a `state` event — but a request that changes nothing (a handoff with // no one available, a context-only message) emits none, and a stream would then wait forever. // So read what is there first: if the conversation is already settled, that is the answer. if (opts.untilSettled) { for (;;) { let page; try { page = await this.read(id, cursor ? { cursor } : {}); } catch (e) { if (e instanceof ConversationError && e.code === 'cursor_expired') { cursor = undefined; this.remember(id, { cursor: '' }); yield { kind: 'history_gap' }; continue; } throw e; } for (const ev of page.events) { if (await handle(ev)) yield { kind: 'event', event: ev }; } status = page.status ?? status; if (page.cursor) { cursor = page.cursor; this.remember(id, { cursor }); } if (!page.has_more) break; } await this.company.save(); if (settled()) return; } while (!opts.signal?.aborted) { if (!firstConnect) yield { kind: 'reconnected' }; firstConnect = false; let res; try { const headers = { accept: useStream ? 'text/event-stream' : 'application/json', }; // Reconnects carry Last-Event-ID in place of `cursor` (7.6). if (useStream && cursor) headers['last-event-id'] = cursor; res = await this.company.fetch(this.url(id, '/events', useStream ? {} : { cursor, wait: opts.wait ?? 25 }), { headers, signal: opts.signal, resource: this.resource, scope: this.scope }); } catch (e) { if (opts.signal?.aborted) return; throw e; } if (!res.ok) { const body = await readJson(res.clone()); if (body?.error === 'cursor_expired') { cursor = undefined; this.remember(id, { cursor: '' }); yield { kind: 'history_gap' }; continue; } await throwForResponse(res); } const type = res.headers.get('content-type') ?? ''; if (type.includes('text/event-stream') && res.body) { try { for await (const sse of parseSse(res.body)) { if (sse.event === 'text-delta') { const delta = safeJson(sse.data); if (delta?.message_id) yield { kind: 'delta', delta }; continue; } if (sse.event !== 'message') continue; // unknown SSE event names are skipped const ev = safeJson(sse.data); if (ev && (await handle(ev))) { yield { kind: 'event', event: ev }; if (settled()) { await this.company.save(); return; } } } } catch (e) { if (opts.signal?.aborted) return; this.company.agent.log(`stream interrupted: ${e.message}`); } await this.company.save(); if (status === 'closed') return; await sleep(250, opts.signal).catch(() => { }); continue; } // The Company answered with JSON: it doesn't stream, so long-poll from now on (7.6). useStream = false; const page = (await readJson(res)); status = page?.status ?? status; for (const ev of page?.events ?? []) { if (await handle(ev)) yield { kind: 'event', event: ev }; } if (page?.cursor) { cursor = page.cursor; this.remember(id, { cursor }); } await this.company.save(); if (page?.has_more) { firstConnect = true; // not a reconnect, just the next page continue; } if (settled() || status === 'closed') return; firstConnect = true; } } } function safeJson(s) { try { return JSON.parse(s); } catch { return undefined; } } /** 7.4: `id` + `sender` required, and at least one of `text`, `data`, `context`. */ function checkMessage(m) { if (!ID_RE.test(m.id)) throw new ConversationError('invalid_message', { description: 'bad message id' }); if (m.sender !== 'agent' && m.sender !== 'human') { throw new ConversationError('invalid_message', { description: 'sender must be agent or human', }); } if (m.text === undefined && m.data === undefined && m.context === undefined) { throw new ConversationError('invalid_message', { description: 'a message needs text, data or context', }); } } //# sourceMappingURL=conversations.js.map