UNPKG

@adonis-agora/agent

Version:

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

267 lines • 12 kB
import { createHash } from 'node:crypto'; import { humanizeToolName } from '../tool-presentation.js'; import { SCOPE_ROLE_PREFIX, scopeRole } from './gate.js'; import { PERMISSION_COMPONENT } from './permission-tool.js'; /** The app's confirmation of a proposal, as an outcome's `summary`. */ function summaryOf(proposal, toolName) { const confirmation = proposal?.confirmation; // Words, not the identifier: "Refund order", never `refund_order`. if (!confirmation) return { title: humanizeToolName(toolName) || toolName }; return { title: confirmation.title, ...(confirmation.detail ? { detail: confirmation.detail } : {}), }; } /** How often a turn looks at an approved proposal while it waits for it to execute. */ const PROPOSAL_POLL_MS = 250; /** How often a driven turn looks for work of its run left waiting for a worker. */ const DRIVE_POLL_MS = 100; /** * Keep executing, in this process, what of `runId` waits for a worker until stopped — the same * loop the channels run (`channels/handler.ts`): a run a persisting-only dispatcher left `pending` * (or a delegate it started) would otherwise wait for a worker that may be the caller itself. */ function keepDriving(drive, runId) { let stopped = false; let timer; const pass = async () => { await drive(runId).catch(() => { }); if (stopped) return; timer = setTimeout(() => void pass(), DRIVE_POLL_MS); timer.unref?.(); }; void pass(); return () => { stopped = true; clearTimeout(timer); }; } function argsHash(input) { return createHash('sha256') .update(JSON.stringify(input ?? null)) .digest('base64url'); } /** * Run one A2A turn to completion and read its outcome off the stream. A2A's `message:send` is * synchronous (PACT §4.2), so the run's frames are consumed here until the stream ends: text is the * reply, an approval is decided on the spot by {@link A2aActionPolicy}, a question set is skipped * (there is no human on this side to answer it — the agent proceeds on its own assumptions), and a * `request_permission` call becomes a step-up. */ export async function runA2aTurn(service, /** The roles a tool declares (see {@link A2aTurnService.toolRoles}). */ toolRoles, input) { const { runId, threadId } = await service.chat({ actor: input.actor, message: input.text, ...(input.agentName !== undefined ? { agentName: input.agentName } : {}), // A prompt can tell it is answering a personal agent, not the person in the app. pageContext: input.pageContext ?? { channel: 'a2a' }, ...(input.threadId !== undefined ? { threadId: input.threadId } : { transient: true }), }); const result = { threadId, text: '', permission: null, actions: [], outcomes: [], error: null, }; const delegated = input.delegatedScopes ?? []; // The delegated scopes a tool's roles name (as `scope:<id>` roles). const scopesOf = (tool) => { const roles = toolRoles(tool) ?? []; return delegated.filter((scope) => roles.includes(scopeRole(scope))); }; // Announced calls, by id, until their output says they ran. const calls = new Map(); // Independent proposals the run left behind, decided once it ends. const proposals = []; // Set once the turn answered (or gave up): a wait still polling stops there. let finished = false; const cancel = () => service.cancel(runId).catch(() => { }); const via = input.via ?? 'a2a'; /** * Decide the proposals by {@link A2aActionPolicy} and wait for the approved ones to execute. One * the caller may not decide (another approver) stays with the app, and is not reported as done. */ const settleProposals = async () => { const { decideActionProposal, listActionProposals } = service; if (!decideActionProposal || !listActionProposals) return; for (const proposal of proposals) { const scopes = scopesOf(proposal.toolName); const approve = input.actions === 'approve-delegated' && scopes.length > 0; try { await decideActionProposal.call(service, input.actor, threadId, proposal.id, approve ? 'approved' : 'rejected', approve ? {} : { reason: 'A personal agent may not run this action without a delegated permission for it.', }, via); } catch { continue; } if (!approve) { // Refused by the policy: say so, with the app's own summary of what was asked. const listed = await listActionProposals .call(service, input.actor, threadId) .catch(() => []); result.outcomes.push({ tool: proposal.toolName, status: 'rejected', summary: summaryOf(listed.find((p) => p.id === proposal.id), proposal.toolName), }); continue; } while (!finished) { const listed = await listActionProposals.call(service, input.actor, threadId); const current = listed.find((p) => p.id === proposal.id); const status = current?.execution?.status; if (current?.decision !== 'approved' || status === 'failed') { result.error = current?.execution?.error ?? `${proposal.toolName} did not run`; result.outcomes.push({ tool: proposal.toolName, status: 'failed', summary: summaryOf(current, proposal.toolName), error: result.error, }); return; } if (status === 'succeeded') { result.actions.push({ tool: proposal.toolName, argsHash: argsHash(calls.get(proposal.toolCallId)?.input ?? current.input), scopes, }); // The model never sees an independent proposal's result: the app's own summary and the // tool's output go back as data, whatever the model wrote before calling it. result.outcomes.push({ tool: proposal.toolName, status: 'succeeded', summary: summaryOf(current, proposal.toolName), ...(current.execution?.result !== undefined ? { result: current.execution.result } : {}), }); const said = current.outcome?.text?.trim(); if (said) result.text = result.text ? `${result.text}\n\n${said}` : said; break; } await new Promise((resolve) => setTimeout(resolve, PROPOSAL_POLL_MS)); } } }; let timer; // A HARD deadline: if cancelling does not end the stream, the request still answers. const deadline = new Promise((resolve) => { timer = setTimeout(() => resolve('timeout'), input.timeoutMs); }); const stream = service.subscribe(runId)[Symbol.asyncIterator](); const stopDriving = input.drive ? keepDriving(input.drive, runId) : undefined; try { await input.onStarted?.(threadId, runId); for (;;) { const next = await Promise.race([stream.next(), deadline]); if (next === 'timeout') { result.error = 'timeout'; await cancel(); break; } if (next.done) break; const frame = next.value; if (frame.t === 'text') { result.text += frame.v; if (frame.v !== '') await input.onText?.(frame.v); } else if (frame.t === 'component' && frame.name === PERMISSION_COMPONENT) { const scopes = frame.data?.scopes; if (Array.isArray(scopes)) { result.permission = [...new Set([...(result.permission ?? []), ...scopes.map(String)])]; } } else if (frame.t === 'component') { await input.onComponent?.(frame); } else if (frame.t === 'event') { const event = frame.event; if (event.kind === 'tool-input-available') { calls.set(event.id, { name: event.name, input: event.input }); } else if (event.kind === 'tool-output-error') { // A tool behind a scope the caller did not delegate is offered but not run // (`personalAgentGate`): its denied call IS the request for that scope. const call = calls.get(event.id); const missing = (call ? (toolRoles(call.name) ?? []) : []) .filter((role) => role.startsWith(SCOPE_ROLE_PREFIX)) .map((role) => role.slice(SCOPE_ROLE_PREFIX.length)) .filter((scope) => !delegated.includes(scope)); if (missing.length > 0) { result.permission = [...new Set([...(result.permission ?? []), ...missing])]; } } else if (event.kind === 'tool-output') { const call = calls.get(event.id); const scopes = call ? scopesOf(call.name) : []; if (call && scopes.length > 0) { result.actions.push({ tool: call.name, argsHash: argsHash(call.input), scopes }); } } } else if (frame.t === 'approval') { if (frame.target?.kind === 'proposal') { proposals.push({ id: frame.target.proposalId, toolName: frame.toolName, toolCallId: frame.id, }); continue; } if (input.actions === 'approve-delegated' && scopesOf(frame.toolName).length > 0) { await service.approve(frame.runId, frame.id, { executedByRef: input.actor.id, via, }); } else { await service.reject(frame.runId, frame.id, 'A personal agent may not run this action without a delegated permission for it.', { executedByRef: input.actor.id, via }); } } else if (frame.t === 'elicitation') { await service.skip({ runId: frame.runId, toolCallId: frame.id, answeredByRef: input.actor.id, answeredVia: via, }); } else if (frame.t === 'error') { result.error = frame.message; } } if (result.error === null && proposals.length > 0) { const settled = await Promise.race([settleProposals(), deadline]); if (settled === 'timeout') result.error = 'timeout'; } } catch (error) { // Nobody will read this run's stream any more: leaving it parked would hold the thread's // active stream, and every later message to this context would be refused as busy. await cancel(); throw error; } finally { finished = true; stopDriving?.(); clearTimeout(timer); void stream.return?.(); } return result; } //# sourceMappingURL=turn.js.map