@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
267 lines • 12 kB
JavaScript
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