UNPKG

@adonis-agora/agent

Version:

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

1,017 lines • 49 kB
import { createHash, randomUUID } from 'node:crypto'; import { EventEmitter } from 'node:events'; import { scopeRole } from '../a2a/gate.js'; import { PERSONAL_AGENT_ROLE } from '../a2a/permission-tool.js'; import { runA2aTurn, } from '../a2a/turn.js'; import { derivedPoppyId, messageFingerprint, newPoppyId, PoppyError, } from './protocol.js'; import { eventIdSeq, } from './store.js'; export const DEFAULT_POPPY_TEXTS = { handoffUnavailable: 'No one is available to take this conversation right now.', failed: 'Sorry, something went wrong on our side. Please try again.', signIn: 'Please sign in to your account so I can continue with this request.', actionSucceeded: 'Done: {title}', actionFailed: 'This could not be done: {title}', actionRejected: 'This was not done: {title}', }; /** * Default wording: the status line from `texts` with the confirmation title (a question like * "Log this dose?" loses its `?`), then the confirmation detail. */ export function formatPoppyAction(action, texts) { const title = action.summary.title.trim().replace(/\?+$/, ''); const template = action.status === 'succeeded' ? texts.actionSucceeded : action.status === 'failed' ? texts.actionFailed : texts.actionRejected; const line = template.replaceAll('{title}', title).replaceAll('{error}', action.error ?? ''); const detail = action.summary.detail?.trim(); return detail ? `${line}\n${detail}` : line; } const PAGE_SIZE = 100; const LEASE_MS = 30_000; const RUN_ACTIVE_RETRY_MS = 250; const PRUNE_EVERY_MS = 60_000; /** The actor of a principal the resolver gave none: stable per (Personal Agent, its User). */ export function poppyActorId(clientId, userId) { return `poppy:${createHash('sha256').update(`${clientId}\n${userId}`).digest('hex').slice(0, 40)}`; } /** The idempotency owner of §7.3: message ids are unique per User of a Personal Agent. */ function ownerKey(principal) { return createHash('sha256').update(`${principal.clientId}\n${principal.userId}`).digest('hex'); } const hasContent = (message) => (message.text !== undefined && message.text.trim() !== '') || message.data !== undefined; /** A user message as the model reads it: its text, and its data as JSON. */ function turnText(messages) { return messages .map((message) => [ message.text?.trim() ?? '', message.data !== undefined ? `\`\`\`json\n${JSON.stringify(message.data, null, 2)}\n\`\`\`` : '', ] .filter((part) => part !== '') .join('\n\n')) .filter((text) => text !== '') .join('\n\n'); } const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); /** * Poppy conversations (§7) over the agent runtime: the state machine, the event log and the turns. * HTTP-free — `createPoppyHandler` puts the protocol's requests on it, and the app drives the * Company side through it (a person joining, their messages, `user_requested`). * * A conversation is an agent thread owned by the principal's actor. A user message is appended to * the event log, and the log is the turn queue: a pump per conversation (one replica at a time, * by lease) answers the messages after `turnSeq` in one turn, streams its text as `text-delta`, * then appends the reply as a `message` event. A replica that finds a conversation `working` with * no pump picks it up, so a reply survives the process that started it. * * @experimental Tracks Personal Agent Protocol Draft 0.1 (https://personalagentprotocol.org/docs/spec), * a spec still in development: this API WILL change as the spec evolves — possibly in breaking * ways, outside semver majors while it is a draft. */ export class PoppyConversations { options; #bus = new EventEmitter(); #pumps = new Map(); #rekick = new Set(); #locks = new Map(); #pruned = new Map(); #holder = `poppy:${randomUUID()}`; #texts; #closed = false; constructor(options) { this.options = options; this.#texts = { ...DEFAULT_POPPY_TEXTS, ...options.texts }; this.#bus.setMaxListeners(0); } // ── identity and ownership ────────────────────────────────────────────────── /** * The actor a principal's turns run as: the resolver's (`toActor`), else the app's `actorFor`, * else — signed in — the ACCOUNT, `{ id: accountId }`, the way a PACT delegation runs as the * account; signed out, `poppy:<hash>` (stable per Personal Agent and User, never an account). * It ALWAYS carries `personal_agent` (what puts it behind `personalAgentGate`) and one * `scope:<id>` role per account scope of a signed-in token — the roles a PACT delegation gives, * so a tool written for personal agents gates both surfaces alike. */ async actorOf(principal) { const defaults = principal.actor ?? { id: principal.signedIn && principal.accountId ? principal.accountId : poppyActorId(principal.clientId, principal.userId), }; const actor = principal.actor === undefined && this.options.actorFor ? await this.options.actorFor(principal, defaults) : defaults; const scopes = principal.signedIn ? principal.scopes : []; return { ...actor, roles: [...new Set([...(actor.roles ?? []), PERSONAL_AGENT_ROLE, ...scopes.map(scopeRole)])], }; } /** The account a signed-in principal is — what a conversation binds to once used (§7.2). */ async #accountOf(principal) { if (!principal.signedIn) return null; return principal.accountId ?? (await this.actorOf(principal)).id; } #grant(principal, actor) { return { actor, scopes: principal.signedIn ? [...principal.scopes] : [], signedIn: principal.signedIn, }; } /** * The conversation, when `principal` may use it (§7.2): the same Personal Agent and User — and * once it used an account, a token signed in to that account. Anything else is indistinguishable * from no conversation, except a signed-out token of its own User: `sign_in_required` (§7.11). */ async #owned(principal, id) { const conversation = await this.options.store.getConversation(id); if (!conversation || conversation.clientId !== principal.clientId) { throw new PoppyError('conversation_not_found'); } if (conversation.accountRef !== null) { if (principal.signedIn) { if ((await this.#accountOf(principal)) !== conversation.accountRef) { throw new PoppyError('conversation_not_found'); } return conversation; } if (conversation.userId === principal.userId) { throw new PoppyError('sign_in_required', 'This conversation used your account: sign in'); } throw new PoppyError('conversation_not_found'); } if (conversation.userId !== principal.userId) throw new PoppyError('conversation_not_found'); return conversation; } /** Run `fn` alone among this process's mutations of conversation `id`. */ async #locked(id, fn) { const previous = this.#locks.get(id) ?? Promise.resolve(); const next = previous.catch(() => { }).then(fn); this.#locks.set(id, next); try { return await next; } finally { if (this.#locks.get(id) === next) this.#locks.delete(id); } } // ── the event log ────────────────────────────────────────────────────────── async #append(conversationId, body, key) { const { event, created } = await this.options.store.appendEvent(conversationId, body, key !== undefined ? { key } : {}); if (created) this.#bus.emit(`event:${conversationId}`, event); return event; } /** Change status and/or responder; every change is a `state` event (§7.7). */ async #setState(conversation, next, extra = {}) { const status = next.status ?? conversation.status; const responder = next.responder ?? conversation.responder; const changed = status !== conversation.status || responder !== conversation.responder; await this.options.store.updateConversation(conversation.id, { status, responder, ...extra }); const updated = { ...conversation, ...extra, status, responder }; if (changed) await this.#append(conversation.id, { type: 'state', status, responder }); return updated; } #companyMessage(text, data, sender, id = newPoppyId('msg')) { return { id, role: 'company', sender, ...(text !== undefined && text !== '' ? { text } : {}), ...(data !== undefined ? { data } : {}), }; } // ── Personal Agent requests ───────────────────────────────────────────────── /** `POST {endpoint}` — start a conversation (or a Direct Conversation) with its first message. */ async start(principal, body) { const actor = await this.actorOf(principal); const owner = ownerKey(principal); const fingerprint = messageFingerprint('new', body); let parent = null; if (body.parentConversationId !== undefined) { parent = await this.#owned(principal, body.parentConversationId); // A Direct Conversation is not a parent (§7.10): as a parent, it does not exist. if (parent.parentId !== null) throw new PoppyError('conversation_not_found'); } for (;;) { const id = newPoppyId('cnv'); const content = hasContent(body.message); const response = { status: 201, body: { conversation_id: id, status: (content ? 'working' : 'idle'), responder: 'agent', }, }; const claim = await this.options.store.claimMessage(owner, body.message.id, { fingerprint, conversationId: id, response, }); if (claim.status === 'existing') { if (claim.fingerprint !== fingerprint) throw new PoppyError('message_id_conflict'); const existing = await this.options.store.getConversation(claim.conversationId); if (existing) { // A retried first message: the conversation it already started (§7.3). return { ...claim.response, conversationId: claim.conversationId }; } // Claimed by a delivery that died before creating it: take it over. await this.options.store.releaseMessage(owner, body.message.id); continue; } try { if (parent !== null) { const parentId = parent.id; await this.#locked(parentId, async () => { const current = await this.#owned(principal, parentId); await this.#assertNoDirect(current); if (current.status === 'closed') throw new PoppyError('conversation_closed'); await this.#create(id, principal, actor, current); await this.options.store.updateConversation(current.id, { openDirectId: id }); await this.#append(current.id, { type: 'direct_opened', conversation_id: id }); }); } else { await this.#create(id, principal, actor, null); } } catch (error) { await this.options.store.releaseMessage(owner, body.message.id); throw error; } const created = (await this.options.store.getConversation(id)); await this.#locked(id, () => this.#acceptMessage(created, body.message, response.body.status, {})); if (parent !== null) { const parentId = parent.id; await this.#hook('direct.opened', () => this.options.direct?.opened?.({ conversationId: id, conversation: created, actor, parentId, })); } return { ...response, conversationId: id }; } } async #create(id, principal, actor, parent) { const now = Date.now(); await this.options.store.createConversation({ id, clientId: principal.clientId, userId: principal.userId, accountRef: await this.#accountOf(principal), agentName: parent?.agentName ?? this.options.agentName ?? '', threadId: parent?.threadId ?? null, parentId: parent?.id ?? null, openDirectId: null, status: 'idle', responder: 'agent', context: {}, activeRunId: null, turnSeq: 0, grant: this.#grant(principal, actor), prunedSeq: 0, createdAt: now, updatedAt: now, }); } async #assertNoDirect(conversation) { if (conversation.openDirectId === null) return; const direct = await this.options.store.getConversation(conversation.openDirectId); if (direct && direct.status !== 'closed') { throw new PoppyError('direct_conversation_open', undefined, { conversation_id: direct.id, }); } // A Direct Conversation that closed without telling its parent: the parent is free. await this.options.store.updateConversation(conversation.id, { openDirectId: null }); } /** `POST {endpoint}/{id}/messages` — another message (§7.3). */ async send(principal, conversationId, body) { const owned = await this.#owned(principal, conversationId); const actor = await this.actorOf(principal); const owner = ownerKey(principal); const fingerprint = messageFingerprint(conversationId, body); // A retry is answered as before, whatever happened since (§7.3). return this.#locked(conversationId, async () => { const conversation = (await this.options.store.getConversation(owned.id)) ?? owned; const content = hasContent(body.message); const toAgent = conversation.responder === 'agent' && conversation.status !== 'queued'; const status = content && toAgent && conversation.status !== 'closed' ? 'working' : conversation.status; const response = { status: 202, body: { conversation_id: conversationId, status, responder: conversation.responder }, }; const claim = await this.options.store.claimMessage(owner, body.message.id, { fingerprint, conversationId, response, }); if (claim.status === 'existing') { if (claim.fingerprint !== fingerprint) throw new PoppyError('message_id_conflict'); return claim.response; } try { if (conversation.status === 'closed') throw new PoppyError('conversation_closed'); await this.#assertNoDirect(conversation); } catch (error) { await this.options.store.releaseMessage(owner, body.message.id); throw error; } const patch = { grant: this.#grant(principal, actor) }; // Once it is used signed in, the conversation belongs to that account (§7.2). if (principal.signedIn && conversation.accountRef === null) { patch.accountRef = await this.#accountOf(principal); } await this.#acceptMessage(conversation, body.message, status, patch); return response; }); } /** * Record a user message, and hand it to whoever answers: the agent's pump, or — while a person * handles the conversation or is being found — the app's handoff hook. */ async #acceptMessage(conversation, message, status, patch) { const context = message.context !== undefined ? { ...conversation.context, ...message.context } : conversation.context; if (message.context !== undefined) patch = { ...patch, context }; const eventMessage = { role: 'user', ...message }; const stored = await this.#append(conversation.id, { type: 'message', message: eventMessage }); const toAgent = conversation.responder === 'agent' && conversation.status !== 'queued'; if (!toAgent) patch = { ...patch, turnSeq: stored.seq }; await this.#setState(conversation, { status }, patch); if (!toAgent) { await this.#hook('handoff.message', () => this.options.handoff?.message?.({ conversationId: conversation.id, conversation: { ...conversation, context }, actor: conversation.grant?.actor ?? null, message: eventMessage, })); return; } if (hasContent(message)) this.#kick(conversation.id); } /** `GET {endpoint}/{id}/events` without streaming — read (and wait for) events (§7.5). */ async read(principal, conversationId, options) { const conversation = await this.#owned(principal, conversationId); return this.readOwned(conversation, options); } /** A read of a conversation already checked — also what a POST with `wait` answers (§7.3). */ async readOwned(conversation, options) { await this.#maintain(conversation); let afterSeq = await this.#cursorSeq(conversation, options.cursor); const deadline = Date.now() + (options.waitMs ?? 0); let page = await this.options.store.listEvents(conversation.id, afterSeq, PAGE_SIZE + 1); while (page.length === 0 && Date.now() < deadline && !this.#closed) { await this.#waitForEvent(conversation.id, deadline - Date.now()); page = await this.options.store.listEvents(conversation.id, afterSeq, PAGE_SIZE + 1); } const events = page.slice(0, PAGE_SIZE); if (events.length > 0) afterSeq = events[events.length - 1]?.seq ?? afterSeq; const current = (await this.options.store.getConversation(conversation.id)) ?? conversation; return { conversation_id: conversation.id, events: events.map((entry) => entry.event), cursor: events.at(-1)?.event.id ?? options.cursor ?? null, has_more: page.length > PAGE_SIZE, status: current.status, responder: current.responder, }; } /** Check a conversation is the principal's, for a caller that reads it afterwards. */ owned(principal, conversationId) { return this.#owned(principal, conversationId); } /** * `GET {endpoint}/{id}/events` as SSE (§7.6): the events after `cursor`, then new ones as they * come, with `text-delta` pieces of a reply being written in this process. Ends when `signal` * aborts, or once the conversation is closed and every event was sent. */ async *stream(conversation, cursor, signal) { await this.#maintain(conversation); let afterSeq = await this.#cursorSeq(conversation, cursor); const pending = []; let wake = null; const notify = () => { wake?.(); wake = null; }; const onDelta = (delta) => { pending.push({ kind: 'delta', ...delta }); notify(); }; let dirty = true; const onEvent = () => { dirty = true; notify(); }; this.#bus.on(`event:${conversation.id}`, onEvent); this.#bus.on(`delta:${conversation.id}`, onDelta); signal.addEventListener('abort', notify, { once: true }); try { while (!signal.aborted && !this.#closed) { // Deltas that arrived before the events now in the log were written before them. while (pending.length > 0) yield pending.shift(); if (dirty) { dirty = false; const page = await this.options.store.listEvents(conversation.id, afterSeq, PAGE_SIZE); while (pending.length > 0) yield pending.shift(); for (const entry of page) { afterSeq = entry.seq; yield { kind: 'event', event: entry.event }; } if (page.length === PAGE_SIZE) { dirty = true; continue; } const current = await this.options.store.getConversation(conversation.id); if (!current || current.status === 'closed') { yield { kind: 'state', status: 'closed' }; return; } if (current.status === 'working' && !this.#pumps.has(current.id)) { // A turn nobody here is writing (a replica that died): take it up. this.#kick(current.id); } } await new Promise((resolve) => { wake = resolve; const timer = setTimeout(() => { // Another replica appends without telling this one: look again now and then. dirty = true; resolve(); }, this.options.pollMs ?? 1000); timer.unref?.(); if (pending.length > 0 || dirty || signal.aborted) resolve(); }); wake = null; } } finally { this.#bus.off(`event:${conversation.id}`, onEvent); this.#bus.off(`delta:${conversation.id}`, onDelta); signal.removeEventListener('abort', notify); } } async #waitForEvent(conversationId, ms) { await new Promise((resolve) => { const done = () => { clearTimeout(timer); this.#bus.off(`event:${conversationId}`, done); resolve(); }; const timer = setTimeout(done, Math.max(0, Math.min(ms, this.options.pollMs ?? 1000))); this.#bus.on(`event:${conversationId}`, done); }); } /** The position after which a read starts (§7.5): unknown → `invalid_cursor`, pruned → expired. */ async #cursorSeq(conversation, cursor) { if (cursor === undefined) return 0; const seq = await this.options.store.findEventSeq(conversation.id, cursor); if (seq !== null) return seq; const minted = eventIdSeq(cursor); const current = (await this.options.store.getConversation(conversation.id)) ?? conversation; if (minted !== null && minted <= current.prunedSeq) throw new PoppyError('cursor_expired'); throw new PoppyError('invalid_cursor'); } /** Housekeeping on read: prune past the retention, and take up a turn nobody is pumping. */ async #maintain(conversation) { const retention = this.options.retentionMs; if (retention !== undefined) { const last = this.#pruned.get(conversation.id) ?? 0; if (Date.now() - last >= PRUNE_EVERY_MS) { this.#pruned.set(conversation.id, Date.now()); await this.options.store.pruneEvents(conversation.id, Date.now() - retention); } } if (conversation.status === 'working' && !this.#pumps.has(conversation.id)) { this.#kick(conversation.id); } } /** `POST {endpoint}/{id}/handoff` — ask for a person at the Company (§7.9). */ async requestHandoff(principal, conversationId) { const owned = await this.#owned(principal, conversationId); const conversation = await this.#locked(owned.id, async () => { const current = (await this.options.store.getConversation(owned.id)) ?? owned; if (current.status === 'closed') throw new PoppyError('conversation_closed'); await this.#assertNoDirect(current); return current; }); return this.#handoff(conversation, 'requested'); } async #handoff(conversation, by) { // Already with a person, or being found one: nothing more to ask for. if (conversation.status === 'queued' || conversation.responder === 'human') { return { status: conversation.status, responder: conversation.responder }; } const hooks = this.options.handoff; if (!hooks) { // No people to hand off to: the Company Agent says so, and keeps the conversation (§7.9). await this.#append(conversation.id, { type: 'message', message: this.#companyMessage(this.#texts.handoffUnavailable, undefined, 'agent'), }); return { status: conversation.status, responder: conversation.responder }; } const queued = await this.#locked(conversation.id, async () => { const current = (await this.options.store.getConversation(conversation.id)) ?? conversation; return this.#setState(current, { status: 'queued' }); }); let decision; try { decision = await hooks.request({ conversationId: conversation.id, conversation: queued, actor: queued.grant?.actor ?? null, by, }); } catch (error) { this.options.onError?.(error, 'poppy: handoff.request failed'); decision = { unavailable: this.#texts.handoffUnavailable }; } if (decision === 'joined') return this.humanJoined(conversation.id); if (typeof decision === 'object') { return this.handoffUnavailable(conversation.id, decision.unavailable); } return { status: queued.status, responder: queued.responder }; } /** `POST {endpoint}/{id}/close` — close the conversation (§7.12). */ async closeByAgent(principal, conversationId) { const conversation = await this.#owned(principal, conversationId); return this.close(conversation.id); } // ── the Company side (app API) ───────────────────────────────────────────── /** Look a conversation up — for the app's own routing (a support tool, an admin page). */ get(conversationId) { return this.options.store.getConversation(conversationId); } async #require(conversationId) { const conversation = await this.options.store.getConversation(conversationId); if (!conversation) throw new PoppyError('conversation_not_found'); return conversation; } /** * Close a conversation (§7.12): its open Direct Conversation first, a pending handoff is * cancelled, and a `closed` state event added. Nothing it started is undone. Idempotent. */ async close(conversationId) { const conversation = await this.#require(conversationId); if (conversation.status === 'closed') { return { status: 'closed', responder: conversation.responder }; } if (conversation.openDirectId !== null) { const direct = await this.options.store.getConversation(conversation.openDirectId); if (direct && direct.status !== 'closed') await this.close(direct.id); } const before = (await this.options.store.getConversation(conversationId)) ?? conversation; const closed = await this.#locked(conversationId, async () => { const current = (await this.options.store.getConversation(conversationId)) ?? before; if (current.status === 'closed') return current; return this.#setState(current, { status: 'closed' }); }); if (before.status === 'queued' || before.responder === 'human') { await this.#hook('handoff.cancel', () => this.options.handoff?.cancel?.({ conversationId, conversation: closed, actor: closed.grant?.actor ?? null, })); } if (closed.parentId !== null) { const parentId = closed.parentId; await this.#locked(parentId, async () => { const parent = await this.options.store.getConversation(parentId); if (parent?.openDirectId === conversationId) { await this.options.store.updateConversation(parentId, { openDirectId: null }); await this.#append(parentId, { type: 'direct_closed', conversation_id: conversationId }); } }); await this.#hook('direct.closed', () => this.options.direct?.closed?.({ conversationId, conversation: closed, actor: closed.grant?.actor ?? null, parentId, })); } return { status: 'closed', responder: closed.responder }; } /** The Company Agent hands off to a person on its own (§7.9) — e.g. from one of your tools. */ async handoff(conversationId) { const conversation = await this.#require(conversationId); if (conversation.status === 'closed') throw new PoppyError('conversation_closed'); return this.#handoff(conversation, 'company'); } /** A person at the Company joined: `responder` becomes `human` (§7.7). */ async humanJoined(conversationId) { return this.#locked(conversationId, async () => { const conversation = await this.#require(conversationId); if (conversation.status === 'closed') throw new PoppyError('conversation_closed'); const status = conversation.status === 'queued' ? 'idle' : conversation.status; const updated = await this.#setState(conversation, { status, responder: 'human' }); return { status: updated.status, responder: updated.responder }; }); } /** The person left: the Company Agent answers again. */ async humanLeft(conversationId) { return this.#locked(conversationId, async () => { const conversation = await this.#require(conversationId); if (conversation.status === 'closed') { return { status: conversation.status, responder: conversation.responder }; } const status = conversation.status === 'queued' ? 'idle' : conversation.status; const updated = await this.#setState(conversation, { status, responder: 'agent' }); return { status: updated.status, responder: updated.responder }; }); } /** * No one could take a queued handoff: the Company Agent says why, and the conversation goes back * to `idle` with the agent (§7.9). */ async handoffUnavailable(conversationId, reason = this.#texts.handoffUnavailable) { return this.#locked(conversationId, async () => { const conversation = await this.#require(conversationId); if (conversation.status !== 'queued') { return { status: conversation.status, responder: conversation.responder }; } await this.#append(conversationId, { type: 'message', message: this.#companyMessage(reason, undefined, 'agent'), }); const updated = await this.#setState(conversation, { status: 'idle', responder: 'agent' }); return { status: updated.status, responder: updated.responder }; }); } /** * Add a Company message — a person's (`sender: 'human'`, while they handle the conversation) or * an automated one. Answers the message's id. */ async postMessage(conversationId, message) { const conversation = await this.#require(conversationId); if (conversation.status === 'closed') throw new PoppyError('conversation_closed'); const event = this.#companyMessage(message.text, message.data, message.sender); await this.#append(conversationId, { type: 'message', message: event }); return event.id; } /** * Ask to talk with the User directly (§7.10): a `user_requested` event. Only the Personal Agent * opens the Direct Conversation, once the User agrees. */ async requestUser(conversationId, reason) { const conversation = await this.#require(conversationId); if (conversation.status === 'closed') throw new PoppyError('conversation_closed'); if (conversation.parentId !== null) { throw new PoppyError('invalid_request', 'A Direct Conversation already has the User'); } await this.#append(conversationId, { type: 'user_requested', reason }); } /** Stop pumping and waiting in this process (shutdown). */ shutdown() { this.#closed = true; this.#bus.emit('shutdown'); } // ── turns ────────────────────────────────────────────────────────────────── /** Make sure a pump runs for the conversation in this process (if no replica holds it). */ #kick(conversationId) { if (this.#closed) return; if (this.#pumps.has(conversationId)) { this.#rekick.add(conversationId); return; } const pump = this.#pump(conversationId) .catch((error) => this.options.onError?.(error, 'poppy: turn failed')) .finally(() => { this.#pumps.delete(conversationId); if (this.#rekick.delete(conversationId)) this.#kick(conversationId); }); this.#pumps.set(conversationId, pump); } /** * Answer what waits in the conversation's log, one turn at a time, while this process holds its * lease. Messages that arrived during a turn are answered together by the next one. */ async #pump(conversationId) { const store = this.options.store; if (!(await store.leaseReader(conversationId, this.#holder, LEASE_MS))) return; const renew = setInterval(() => { void store.leaseReader(conversationId, this.#holder, LEASE_MS).catch(() => { }); }, LEASE_MS / 3); renew.unref?.(); try { for (;;) { this.#rekick.delete(conversationId); const conversation = await store.getConversation(conversationId); if (!conversation || conversation.status === 'closed') return; if (conversation.activeRunId !== null) { // A turn whose writer died: write its reply from the run's own stream. await this.#recover(conversation, conversation.activeRunId); continue; } // Decided under the lock a send takes, so a message accepted meanwhile is never left // behind an `idle` conversation. const waiting = await this.#locked(conversationId, async () => { const current = await store.getConversation(conversationId); if (!current || current.status === 'closed') return null; const toAgent = current.responder === 'agent' && current.status !== 'queued'; const found = toAgent ? (await this.#unanswered(current)).filter((entry) => entry.message.text !== undefined || entry.message.data !== undefined) : []; if (found.length === 0) { if (current.status === 'working') await this.#setState(current, { status: 'idle' }); return null; } if (current.status !== 'working') await this.#setState(current, { status: 'working' }); return { current, found }; }); if (waiting === null) { if (!this.#rekick.has(conversationId)) return; continue; } await this.#turn(waiting.current, waiting.found.map((entry) => entry.message), waiting.found.at(-1)?.seq ?? 0); } } finally { clearInterval(renew); await store.releaseReader(conversationId, this.#holder).catch(() => { }); } } /** User messages after `turnSeq`. */ async #unanswered(conversation) { const out = []; let after = conversation.turnSeq; for (;;) { const page = await this.options.store.listEvents(conversation.id, after, PAGE_SIZE); for (const entry of page) { after = entry.seq; const message = entry.event.message; if (entry.event.type === 'message' && message?.role === 'user') { out.push({ seq: entry.seq, message }); } } if (page.length < PAGE_SIZE) return out; } } async #turn(conversation, messages, upToSeq) { const grant = conversation.grant; if (!grant) { await this.options.store.updateConversation(conversation.id, { turnSeq: upToSeq }); return; } const deadline = Date.now() + (this.options.timeoutMs ?? 120_000); const components = new Map(); let replyId = null; let runId = null; const last = messages.at(-1); const service = this.options.service; // A Direct Conversation answers in its parent's thread — which a later parent turn may create. let threadId = conversation.threadId; if (threadId === null && conversation.parentId !== null) { threadId = (await this.options.store.getConversation(conversation.parentId))?.threadId ?? null; } for (;;) { try { const result = await runA2aTurn(service, this.options.toolRoles, { actor: grant.actor, text: turnText(messages), ...(conversation.agentName !== '' ? { agentName: conversation.agentName } : {}), ...(threadId !== null ? { threadId } : {}), delegatedScopes: grant.signedIn ? grant.scopes : null, actions: this.options.actions ?? 'approve-delegated', timeoutMs: Math.max(1, deadline - Date.now()), via: 'poppy', pageContext: { channel: 'poppy', poppy: { conversationId: conversation.id, direct: conversation.parentId !== null, sender: last?.sender ?? 'agent', signedIn: grant.signedIn, context: conversation.context, }, }, ...(this.options.drive !== false && service.drive ? { drive: (id) => service.drive?.(id) ?? Promise.resolve() } : {}), onStarted: async (startedThreadId, startedRunId) => { runId = startedRunId; replyId = derivedPoppyId('msg', `reply:${startedRunId}`); await this.options.store.updateConversation(conversation.id, { threadId: startedThreadId, activeRunId: startedRunId, turnSeq: upToSeq, }); // A Direct Conversation's first turn may create the thread its parent shares. if (conversation.parentId !== null && threadId === null) { const parent = await this.options.store.getConversation(conversation.parentId); if (parent && parent.threadId === null) { await this.options.store.updateConversation(parent.id, { threadId: startedThreadId, }); } } }, onText: (delta) => { if (replyId !== null) { this.#bus.emit(`delta:${conversation.id}`, { messageId: replyId, text: delta }); } }, onComponent: (frame) => { if (frame.partial === true) return; components.set(frame.id ?? `ui:${components.size}`, frame); }, }); await this.#reply(conversation, grant, runId ?? randomUUID(), { text: result.text, components: [...components.values()], outcomes: result.outcomes, permission: result.permission, failed: result.error !== null, }); return; } catch (error) { // The thread is busy — its other conversation (parent / Direct) or a run another replica // started: wait for it, within the turn's time. if (error.code === 'run_active' && Date.now() < deadline) { await sleep(RUN_ACTIVE_RETRY_MS); continue; } if (runId === null) { await this.options.store.updateConversation(conversation.id, { turnSeq: upToSeq }); } await this.#reply(conversation, grant, runId ?? randomUUID(), { text: '', components: [], outcomes: [], permission: null, failed: true, }); throw error; } } } /** Read a run whose writer died from its stream, and write its reply (once: keyed by run). */ async #recover(conversation, runId) { const grant = conversation.grant; let text = ''; const components = new Map(); let failed = false; const iterator = this.options.service.subscribe(runId)[Symbol.asyncIterator](); const deadline = Date.now() + (this.options.timeoutMs ?? 120_000); try { for (;;) { let timer; const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve('timeout'), Math.max(0, deadline - Date.now())); }); const next = await Promise.race([iterator.next(), timeout]).finally(() => clearTimeout(timer)); if (next === 'timeout') { failed = text === ''; await this.options.service.cancel(runId).catch(() => { }); break; } if (next.done) break; const frame = next.value; if (frame.t === 'text') text += frame.v; else if (frame.t === 'component' && frame.partial !== true) { components.set(frame.id ?? `ui:${components.size}`, frame); } else if (frame.t === 'error') failed = true; } } finally { void iterator.return?.(); } await this.#reply(conversation, grant ?? { actor: { id: '' }, scopes: [], signedIn: false }, runId, { text, components: [...components.values()], outcomes: [], permission: null, failed, }); } /** * Write a turn's reply: a `message` event (text, and `data` for components and executed actions), * then an `authorization` event when the turn needed a scope the token does not carry (§7.11). */ async #reply(conversation, grant, runId, turn) { const store = this.options.store; const current = await store.getConversation(conversation.id); await store.updateConversation(conversation.id, { activeRunId: null }); // A closed conversation takes no more events from its agent (§7.12). if (!current || current.status === 'closed') return; const components = turn.components.map((frame) => ({ name: frame.name, props: frame.data, ...(frame.fallbackText !== undefined ? { fallback_text: frame.fallbackText } : {}), })); let text = turn.text.trim(); if (text === '') { // A component's fallback is all a reader that does not render it can show. text = turn.components .map((frame) => frame.fallbackText?.trim() ?? '') .filter((part) => part !== '') .join('\n\n'); } // Every action is told in words too: a reader that ignores `data` still learns what was done. // The model wrote its text before an action's result — so when one did not go through, the // reply is the actions' own account (the streamed deltas are provisional and get replaced). if (turn.outcomes.length > 0) { const format = this.options.formatAction ?? formatPoppyAction; const told = turn.outcomes .map((action) => format(action, this.#texts)?.trim() ?? '') .filter((line) => line !== '') .join('\n\n'); const allDone = turn.outcomes.every((action) => action.status === 'succeeded'); if (!allDone || text === '') text = told; } if (text === '' && turn.failed && components.length === 0 && turn.outcomes.length === 0) { text = this.#texts.failed; } const data = { ...(components.length > 0 ? { components } : {}), ...(turn.outcomes.length > 0 ? { actions: turn.outcomes } : {}), }; if (text !== '' || Object.keys(data).length > 0) { await this.#append(conversation.id, { type: 'message', message: this.#companyMessage(text, Object.keys(data).length > 0 ? data : undefined, 'agent', derivedPoppyId('msg', `reply:${runId}`)), }, `reply:${runId}`); } const missing = (turn.permission ?? []).filter((scope) => !grant.signedIn || !grant.scopes.includes(scope)); if (missing.length > 0) { await this.#append(conversation.id, { type: 'authorization', error: grant.signedIn ? 'insufficient_scope' : 'sign_in_required', scope: missing.join(' '), }, `authorization:${runId}`); // In a Direct Conversation the User reads the messages, not the events: ask there too. if (conversation.parentId !== null) { await this.#append(conversation.id, { type: 'message', message: this.#companyMessage(this.#texts.signIn, undefined, 'agent', derivedPoppyId('msg', `sign-in:${runId}`)), }, `sign-in:${runId}`); } } } async #hook(name, fn) { try { await fn(); } catch (error) { this.options.onError?.(error, `poppy: ${name} hook failed`); } } } //# sourceMappingURL=conversations.js.map