@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
459 lines • 22 kB
JavaScript
import { A2UI_LEGACY_BASIC_CATALOG_ID, negotiateA2uiCatalog, readAgUiA2uiCatalogIds, } from '../a2ui/core.js';
import { uiActionText } from '../genui/actions.js';
import { AG_UI_CUSTOM, actionProposalDecisionEvents, parseRunInput, planResume, readAnswersPayload, readApprovalPayload, readContext, readForwardedProps, readUserTurn, } from './core/index.js';
import { agUiEvents, agUiSse } from './stream.js';
/** The surface an AG-UI resume decision is recorded as having come through. */
const AG_UI_VIA = 'ag-ui';
/** Input material the route could not use, as the events that say so. */
function warningEvents(warnings) {
return warnings.map((message) => ({
type: 'CUSTOM',
name: AG_UI_CUSTOM.warning,
value: { message },
}));
}
/**
* Serve the agent over AG-UI 1.0 (https://docs.ag-ui.com/spec/1.0): the body of `POST <path>/ag-ui`
* is a `RunAgentInput`, the answer the run as AG-UI events. A run that stops to ask ends with the
* interrupt outcome and is continued by a later request carrying `resume`.
*
* ```ts
* import { agUiAdapter } from '@adonis-agora/agent/ag-ui'
*
* export default defineConfig({ adapters: [agUiAdapter()] })
* ```
*
* Authenticated and owner-scoped exactly as `chat`, `chat/:runId/stream` and the decision routes
* are: it goes through the gates the provider hands every adapter.
*/
export function agUiAdapter(options = {}) {
const path = (options.path ?? 'ag-ui').replace(/^\/+|\/+$/g, '');
const quietMs = options.quietMs;
const a2ui = options.a2ui === undefined || options.a2ui === false
? undefined
: options.a2ui === true
? {}
: options.a2ui;
return {
name: 'ag-ui',
mount(host) {
const { service } = host;
const staging = host.attachments;
host.post(path, async (ctx) => {
const input = parseRunInput(ctx.request.body());
if (typeof input === 'string') {
return ctx.response.badRequest({ message: input, code: 'invalid_input' });
}
const forwarded = readForwardedProps(input.forwardedProps);
const agentName = forwarded.agent ?? host.defaultAgentName;
const actor = await host.resolveActor(ctx, agentName);
if (actor === null)
return;
const warnings = [];
if (Array.isArray(input.tools) && input.tools.length > 0) {
warnings.push('Frontend tools are not supported by this agent: the tools list was ignored.');
}
if (input.resume !== undefined && input.resume.length > 0) {
const plan = planResume(input.resume);
if (typeof plan === 'string') {
return ctx.response.badRequest({ message: plan, code: 'invalid_resume' });
}
for (const id of plan.unrecognised) {
warnings.push(`The resume entry ${id} answers an interrupt this agent did not raise.`);
}
const first = plan.decisions[0];
// Independent proposals do not park their run: deciding them is the whole answer.
if (first !== undefined &&
plan.decisions.every((decision) => decision.address.kind === 'proposal')) {
const decided = await decideProposals(host, ctx, actor, plan.decisions);
if (decided === null)
return;
writeEvents(ctx, [
...warningEvents(warnings),
...decided.flatMap((outcome, index) => actionProposalDecisionEvents({
threadId: input.threadId,
runId: index === 0 ? input.runId : `${input.runId}:${index}`,
text: outcome.text,
proposalDecision: outcome.proposalDecision,
})),
]);
return;
}
// A list that answers nothing this agent asked continues nothing: an ordinary run.
if (first !== undefined) {
const streamRunId = first.address.stream;
const owner = await service.runOwner(streamRunId);
if (!(await host.assertOwner(ctx, actor, owner, 'run')))
return;
if (!(await service.hasStream(streamRunId))) {
return ctx.response.conflict({
code: 'run_not_active',
message: 'The interrupted run is no longer waiting.',
});
}
if (!(await settleResume(host, ctx, actor, plan.decisions))) {
return;
}
await pipe(ctx, service, streamRunId, {
threadId: input.threadId,
runId: input.runId,
streamRunId,
skip: first.address.position,
answered: plan.decisions.map((decision) => decision.address.toolCallId),
...(quietMs !== undefined ? { quietMs } : {}),
...(a2ui !== undefined ? { a2ui: forClient(withCatalog(a2ui, host), input) } : {}),
preamble: warningEvents(warnings),
});
return;
}
}
// A UI action (a sandbox's `agent.send`, an A2UI button) IS the turn: the messages only
// restate the conversation so far.
const action = forwarded.uiAction;
if (typeof action === 'string') {
return ctx.response.badRequest({ message: action, code: 'invalid_ui_action' });
}
const turn = action !== undefined
? { text: uiActionText(action), media: [], staged: [], dropped: [] }
: readUserTurn(input.messages);
if (turn === null) {
return ctx.response.badRequest({
message: 'messages carries no user message to answer',
code: 'no_user_message',
});
}
warnings.push(...turn.dropped);
// The consumer names the conversation. A thread it already has is continued (its owner
// checked, as on `chat`); one nobody has is created under that name.
const owner = await service.threadOwner(input.threadId);
if (owner !== null) {
if (!(await host.assertOwner(ctx, actor, owner, 'thread')))
return;
}
// A regenerate re-answers the thread's last user message: the message restated here (and
// anything attached to it) is not a new turn.
const regenerate = forwarded.regenerate === true;
if (regenerate && owner === null) {
return ctx.response.badRequest({
message: 'regenerate requires an existing thread',
code: 'thread_required',
});
}
if (owner !== null &&
!regenerate &&
action === undefined &&
turn.media.length === 0 &&
turn.staged.length === 0) {
try {
const decision = await service.handleTextDecision(actor, input.threadId, turn.text);
if ('proposalDecision' in decision) {
writeEvents(ctx, [
...warningEvents(warnings),
...actionProposalDecisionEvents({
threadId: input.threadId,
runId: input.runId,
text: decision.text,
proposalDecision: decision.proposalDecision,
}),
]);
return;
}
}
catch (error) {
if (host.refuseSend(ctx, error))
return;
throw error;
}
}
const refs = [];
// Uploads this agent already staged: resolved for this actor by the send itself, which
// refuses one that is not theirs (as `chat` refuses its `attachments` refs).
for (const ref of regenerate ? [] : turn.staged) {
if (staging === undefined) {
warnings.push(`The upload ${ref.mediaId} was not used: attachments are not enabled on this agent.`);
}
else if (refs.length >= staging.maxPerMessage) {
warnings.push(`The upload ${ref.mediaId} was not used: a message carries at most ${staging.maxPerMessage} attachments.`);
}
else
refs.push(ref);
}
for (const media of regenerate ? [] : turn.media) {
const label = `The ${media.kind} "${media.filename}" was not used`;
if (staging === undefined) {
warnings.push(`${label}: attachments are not enabled on this agent.`);
}
else if (refs.length >= staging.maxPerMessage) {
warnings.push(`${label}: a message carries at most ${staging.maxPerMessage} attachments.`);
}
else if (!staging.allowedContentTypes.includes(media.contentType)) {
warnings.push(`${label}: ${media.contentType} is not an accepted type.`);
}
else if (media.data.length > staging.maxBytes) {
warnings.push(`${label}: it is larger than ${staging.maxBytes} bytes.`);
}
else {
try {
const staged = await staging.store.stage({
data: media.data,
filename: media.filename,
contentType: media.contentType,
sizeBytes: media.data.length,
actor,
});
refs.push({ mediaId: staged.mediaId });
}
catch {
warnings.push(`${label}: it could not be stored.`);
}
}
}
if (!regenerate && turn.text.trim().length === 0 && refs.length === 0) {
return ctx.response.badRequest({
message: 'the user message to answer is empty',
code: 'no_user_message',
});
}
const context = readContext(input.context);
// An AG-UI client is a web page: no channel stamped (`turnChannel` reads none as `web`),
// unless its page context names one.
const pageContext = forwarded.pageContext !== undefined || context !== undefined
? {
...forwarded.pageContext,
...(context !== undefined ? { agUiContext: context } : {}),
}
: undefined;
let started;
try {
started = await service.chat({
actor,
// A regenerate answers the stored user message; whatever this one says is ignored.
message: regenerate ? '' : turn.text,
...(regenerate ? { regenerate: true } : {}),
...(owner !== null ? { threadId: input.threadId } : { newThreadId: input.threadId }),
...(forwarded.agent !== undefined ? { agentName: forwarded.agent } : {}),
...(forwarded.model !== undefined ? { model: forwarded.model } : {}),
...(forwarded.uiCapabilities !== undefined
? { uiCapabilities: forwarded.uiCapabilities }
: {}),
...(forwarded.persona !== undefined ? { personaId: forwarded.persona } : {}),
...(pageContext !== undefined ? { pageContext } : {}),
...(refs.length > 0 ? { attachments: refs } : {}),
});
}
catch (error) {
if (host.refuseSend(ctx, error))
return;
throw error;
}
await pipe(ctx, service, started.runId, {
threadId: input.threadId,
runId: input.runId,
streamRunId: started.runId,
streamThreadId: started.threadId,
...(quietMs !== undefined ? { quietMs } : {}),
...(a2ui !== undefined ? { a2ui: forClient(withCatalog(a2ui, host), input) } : {}),
preamble: warningEvents(warnings),
});
});
},
};
}
/**
* Deliver what a resume list decided, through the same calls the native decision routes make.
* Everything that can refuse — who may decide, an answer a question's rules reject, a payload that
* says nothing — is checked for EVERY entry before the first one is delivered, so a list with one
* bad entry settles nothing. Answers `false` after writing the refusal.
*/
async function settleResume(host, ctx, actor, decisions) {
const { service } = host;
const deliveries = [];
const proposals = decisions.filter((decision) => decision.address.kind === 'proposal');
if (proposals.length > 0) {
// Settled before the parked calls, like any other delivery: one refusal settles nothing more.
if ((await decideProposals(host, ctx, actor, proposals)) === null)
return false;
}
for (const { address, entry } of decisions) {
const { parked, toolCallId } = address;
const abandoned = entry.status === 'cancelled';
if (address.kind === 'proposal')
continue;
if (address.kind === 'approval') {
if (!(await host.mayDecide(ctx, actor, parked, toolCallId))) {
return false;
}
const decision = abandoned ? { approved: false } : readApprovalPayload(entry.payload);
if (decision === null) {
ctx.response.badRequest({
code: 'invalid_resume',
message: 'an approval is answered with { approved: boolean, reason?, remember? }',
});
return false;
}
deliveries.push(() => decision.approved
? service.approve(parked, toolCallId, {
executedByRef: actor.id,
via: AG_UI_VIA,
...(decision.remember !== undefined ? { remember: decision.remember } : {}),
})
: service.reject(parked, toolCallId, decision.reason ?? (abandoned ? 'abandoned' : undefined), {
executedByRef: actor.id,
via: AG_UI_VIA,
}));
continue;
}
const owner = await service.runOwner(parked);
if (!(await host.assertOwner(ctx, actor, owner, 'run')))
return false;
if (abandoned) {
deliveries.push(() => service.skip({
runId: parked,
toolCallId,
answeredByRef: actor.id,
answeredVia: AG_UI_VIA,
}));
continue;
}
const answers = readAnswersPayload(entry.payload);
if (answers === null) {
ctx.response.badRequest({
code: 'invalid_resume',
message: 'a question set is answered with { answers: { <questionId>: string[] } }',
});
return false;
}
const problem = await service.answerProblem(toolCallId, answers, parked);
if (problem !== null) {
ctx.response.badRequest({ code: 'invalid_resume', message: problem });
return false;
}
deliveries.push(() => service.answer({
runId: parked,
toolCallId,
answers,
answeredByRef: actor.id,
answeredVia: AG_UI_VIA,
}));
}
try {
for (const deliver of deliveries)
await deliver();
}
catch (error) {
host.conflictOnMismatch(ctx, error);
return false;
}
return true;
}
/**
* Decide independent proposals through the proposal service — the call the native
* `action-proposals/:id/approve|reject` routes make — never by signalling the origin run, which is
* not waiting. Every payload is checked before the first decision. `null` after writing a refusal.
*/
async function decideProposals(host, ctx, actor, decisions) {
const { service } = host;
const planned = [];
for (const { address, entry } of decisions) {
const decision = entry.status === 'cancelled' ? { approved: false } : readApprovalPayload(entry.payload);
if (decision === null || address.proposalId === undefined || address.threadId === undefined) {
ctx.response.badRequest({
code: 'invalid_resume',
message: 'an approval is answered with { approved: boolean, reason?, remember? }',
});
return null;
}
planned.push({
threadId: address.threadId,
proposalId: address.proposalId,
decision: decision.approved ? 'approved' : 'rejected',
body: {
...(decision.remember !== undefined ? { remember: decision.remember } : {}),
...(decision.reason !== undefined ? { reason: decision.reason } : {}),
},
});
}
const outcomes = [];
try {
for (const item of planned) {
const result = await service.decideActionProposal(actor, item.threadId, item.proposalId, item.decision, item.body, AG_UI_VIA);
outcomes.push({
proposalDecision: result,
text: service.actionProposalReply(result, item.decision),
});
}
}
catch (error) {
if (host.refuseSend(ctx, error))
return null;
throw error;
}
return outcomes;
}
/** Answer with a complete list of events, no run behind them. */
function writeEvents(ctx, events) {
const raw = ctx.response.response;
raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
});
for (const event of events)
raw.write(agUiSse(event));
raw.end();
}
/**
* Pipe a run to the client as AG-UI 1.0 over SSE: one event per `data:` line, from `RUN_STARTED` to
* the run's terminal event. It ends when the run stops to ask — the library run stays parked, and
* the request that answers re-attaches to it.
*/
async function pipe(ctx, service, runId, options) {
const raw = ctx.response.response;
const headers = {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
'X-Agent-Run-Id': runId,
};
const pending = ctx.response.getHeaders();
for (const [name, value] of Object.entries(pending)) {
if (value !== undefined && !(name in headers))
raw.setHeader(name, value);
}
raw.writeHead(200, headers);
let terminal = false;
// Every event goes out with the run's own sequence number as its SSE `id:`, so a consumer can
// follow the rest of the run on `chat/:runId/stream?after=<id>` once this AG-UI run ends.
const cursor = { seq: 0 };
try {
for await (const event of agUiEvents(service.subscribe(runId), { ...options, cursor })) {
if (event.type === 'RUN_FINISHED' || event.type === 'RUN_ERROR')
terminal = true;
raw.write(agUiSse(event, cursor.seq));
}
}
catch {
// The stream under the run broke. The status line is long gone, so the failure travels
// in-stream — and says nothing of what broke, which is the server's to know.
if (!terminal) {
raw.write(agUiSse({
type: 'RUN_ERROR',
message: 'The run could not be followed.',
code: 'run_failed',
}));
}
}
raw.end();
}
/** The A2UI options for this request's client: the basic catalog under the id it advertises. */
function forClient(options, input) {
return negotiateA2uiCatalog(options, readAgUiA2uiCatalogIds(input), A2UI_LEGACY_BASIC_CATALOG_ID);
}
/** The app's genui catalog under the A2UI options, for the text of components nothing maps. */
function withCatalog(options, host) {
if (options.catalog !== undefined)
return options;
const catalog = host.genuiCatalog;
return catalog !== undefined ? { ...options, catalog } : options;
}
//# sourceMappingURL=adapter.js.map