agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
122 lines • 5.99 kB
JavaScript
/**
* adapter — AG-UI as a protocol adapter: what `streamHandler` takes to serve
* runs to any AG-UI frontend — or to a gateway that speaks AG-UI in its own
* request body and its own envelope.
*
* Three strategies, each replaceable on its own, around one CORE that is not:
* - the READER — a request body → the turn it asks for (`reader`; default:
* the body is a `RunAgentInput`). Mapping only;
* - the TRANSLATION — one run's served events → AG-UI events (`translation`;
* default `agUiSerializer`, tuned by `custom` and `reasoning`), one per
* request;
* - the FRAMING — AG-UI events → bytes (`framing`; default `agUiSse`,
* `encoder` for bundles). The host numbers each stream's frames and hands
* the number to the framing, so a gateway envelope's sequence is per run.
* - the CORE (`request.ts`), whichever reader ran: the request's shape and
* ids checked as plain JSON, a system/developer message refused, frontend
* tools only by the app's allow-list, `state`/`context` only through the
* app's validators (`accept`), an answer only for the pause the server
* keeps. No strategy can reach past it.
*
* Holds no state between requests: its options are read once and frozen, and
* every request gets its own translation and its own frontend tools.
*/
import { agUiSse } from './framing.js';
import { frontendToolBuilders, isFrontendToolPause, } from './frontendTools.js';
import { planAgUiTurn, readAgUiRequest, readAgUiSuspension, runAgentInputReader, } from './request.js';
import { agUiSerializer } from './serializer.js';
/**
* The AG-UI protocol adapter. Hand it to a host:
*
* @example
* ```ts
* import { memorySessions, streamHandler } from 'agentfootprint/hosting';
* import { agUi } from 'agentfootprint/ag-ui';
*
* export const POST = streamHandler({
* agent: ({ tools }) => Agent.create({ provider, model }).tools([...mine, ...tools]).build(),
* adapter: agUi(),
* threads: { bind: ({ conversation, headers }) => bindToSignedInUser(conversation, headers) },
* sessions: memorySessions(),
* });
* ```
*
* @throws TypeError at construction for a frontend tool whose name or contract
* is not usable, and a validator that is not a function.
*/
export function agUi(options = {}) {
const frontendTools = frontendToolBuilders(options.frontendTools);
const accept = Object.freeze({ ...options.accept });
for (const [name, validator] of Object.entries(accept)) {
if (validator !== undefined && typeof validator !== 'function')
throw new TypeError(`agUi(): accept.${name} must be a function.`);
}
const rules = Object.freeze({ frontendTools, accept });
const reader = strategy(options.reader, 'reader') ?? runAgentInputReader;
const translate = translationOf(options);
const framing = framingOf(options);
return Object.freeze({
name: 'ag-ui',
readRequest: ({ body, headers }) => readAgUiRequest(reader, { body, headers }),
conversation: (input) => input.threadId,
plan: (input, held, facts) => planAgUiTurn(input, held, facts, rules),
awaitsCaller: isFrontendToolPause,
open: (input, resumed) => {
const resume = resumed === undefined ? undefined : readAgUiSuspension(resumed);
return translate({
threadId: input.threadId,
runId: input.runId,
...(resume !== undefined && { resume }),
});
},
framing,
});
}
/** A strategy option: absent, or a function — anything else refused by name. */
function strategy(value, name) {
if (value !== undefined && typeof value !== 'function')
throw new TypeError(`agUi(): ${name} must be a function.`);
return value;
}
/** The translation strategy — the app's, or `agUiSerializer` tuned by `custom` and `reasoning`. */
function translationOf(options) {
const { custom, reasoning } = options;
const translation = strategy(options.translation, 'translation');
if (translation !== undefined) {
if (custom !== undefined || reasoning !== undefined)
throw new TypeError('agUi(): `translation` was given with `custom` or `reasoning`, which tune the default ' +
'translation only — your translation decides both itself. Pass one or the other.');
// One translation, one run: a strategy that hands one back twice would
// write one run's open messages and ids into another run's stream.
const opened = new WeakSet();
return (run) => {
const opening = translation(run);
if (typeof opening !== 'object' || opening === null || opened.has(opening))
throw new TypeError('agUi(): `translation` must return a NEW translation for every run — one it returned ' +
"before would carry another run's state into this one.");
opened.add(opening);
return opening;
};
}
return (run) => agUiSerializer({
...run,
...(custom !== undefined && { custom }),
...(reasoning !== undefined && { reasoning }),
});
}
/** The framing strategy — the app's, or `agUiSse` (with the `encoder` the app imported). */
function framingOf(options) {
const { framing, encoder } = options;
if (framing === undefined)
return () => agUiSse(encoder === undefined ? {} : { encoder });
if (encoder !== undefined)
throw new TypeError('agUi(): `framing` was given with `encoder`, which feeds the default framing only — ' +
'your framing writes its own frames. Pass one or the other.');
if (typeof framing !== 'object' ||
framing === null ||
typeof framing.frame !== 'function' ||
typeof framing.contentType !== 'string')
throw new TypeError('agUi(): `framing` must be an SseFraming — { contentType, frame }.');
return () => framing;
}
//# sourceMappingURL=adapter.js.map