@adonis-agora/agent
Version:
Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.
215 lines • 8.68 kB
JavaScript
/**
* Running the processor chains, and the stream buffering an output gate requires. See
* `./spi/processors.ts` for the seams themselves and the boundary against `HistoryWindow`.
*/
import { DEFAULT_INCREMENTAL_LOOKBACK_CHARS, ProcessorFailedError, } from './spi/processors.js';
export function createFrameBuffer() {
const frames = [];
return {
writer: {
write: (frame) => {
frames.push(frame);
},
end: () => { },
},
frames: () => frames,
};
}
/**
* The frames a passed gate releases to the live stream: the held ones, with every text frame
* collapsed into ONE carrying `text` — the answer as the chain left it, which is the only version
* anything downstream is allowed to see. The substitution happens in place, so the answer keeps its
* position relative to any non-text frame the provider interleaved with it.
*/
export function releaseGatedFrames(args) {
const { frames, text } = args;
const released = [];
let emitted = false;
for (const frame of frames) {
if (frame.t === 'text') {
if (!emitted && text.length > 0) {
released.push({ t: 'text', v: text });
emitted = true;
}
continue;
}
released.push(frame);
}
if (!emitted && text.length > 0) {
released.push({ t: 'text', v: text });
}
return released;
}
/**
* Fold the prompt through each processor in order, every one seeing what the previous one produced.
* A throw is wrapped so it cannot read as the model call failing — see {@link ProcessorFailedError}.
*/
export async function runInputProcessors(args) {
let current = args.prompt;
for (const processor of args.processors) {
try {
current = await processor.process(current, args.ctx);
}
catch (error) {
throw new ProcessorFailedError('input', processor.name, error);
}
}
return current;
}
/**
* Fold the answer through each processor in order. The FIRST rejection ends the chain: a later
* processor has nothing to add about text that is never going anywhere, and running it would bill a
* moderation call for a turn already refused.
*/
export async function runOutputProcessors(args) {
const { processors, answer, ctx } = args;
let text = answer.text;
for (const processor of processors) {
let verdict;
try {
verdict = await processor.process({ text, toolCalls: answer.toolCalls }, ctx);
}
catch (error) {
throw new ProcessorFailedError('output', processor.name, error);
}
if (verdict.action === 'reject') {
return { text, rejection: { processor: processor.name, reason: verdict.reason } };
}
if (verdict.action === 'replace') {
text = verdict.text;
}
}
return { text };
}
/**
* ALL or nothing: one undeclared processor puts the whole chain on the whole-answer path. Its author
* wrote `process` against the complete text, and a chain that ran it on a prefix because a neighbour
* opted in would be handing it an input it never agreed to read.
*/
export function resolveOutputGateMode(processors) {
if (processors.length === 0) {
return 'off';
}
return processors.every((processor) => processor.incremental !== undefined)
? 'incremental'
: 'whole';
}
/**
* The chain's window is the WIDEST any member asked for: a release safe for the shortest-sighted
* processor is not safe for the one that matches longer patterns, and the gate makes one release
* decision for all of them.
*
* An undeclared processor contributes nothing rather than the default — it has no window, because a
* chain containing one never reaches the incremental path at all.
*/
export function resolveGateLookback(processors) {
let lookback = 0;
for (const processor of processors) {
if (processor.incremental !== undefined) {
lookback = Math.max(lookback, processor.incremental.lookbackChars ?? DEFAULT_INCREMENTAL_LOOKBACK_CHARS);
}
}
return lookback;
}
/**
* Releases a model call's answer to `writer` as it arrives, holding back the last `lookbackChars`
* characters of what the chain produces so a processor can still change them.
*
* The chain runs over the whole PREFIX accumulated so far rather than over each new frame, so a
* processor always sees well-formed text and never has to reassemble a pattern split across frames —
* which is what makes {@link import('./spi/processors.js').IncrementalGating}'s promise a claim
* about prefixes. It runs once per streamed text frame.
*
* Only an EXTENSION of what the reader already has is ever written: a chain whose output stops
* agreeing with its earlier output has broken its promise, and the loop's whole-answer pass reports
* that rather than this gate papering over it by re-sending a different answer.
*
* Frames that are not text are forwarded live, in arrival order — they are not the answer, so the
* gate does not own them.
*/
export function createIncrementalGate(args) {
const { processors, ctx, lookbackChars, writer } = args;
let raw = '';
let released = '';
let rejection;
/** A prefix the chain could not rule on. Text stops flowing; the whole-answer pass decides. */
let stalled = false;
// The model is free to write without awaiting, and a verdict is asynchronous — serialize, or two
// prefixes race and the reader gets the answer out of order.
let queue = Promise.resolve();
async function advance() {
if (raw.length === 0) {
return;
}
let settled;
try {
settled = await runOutputProcessors({
processors,
answer: { text: raw, toolCalls: [] },
ctx,
});
}
catch {
// Swallowed HERE only: a prefix pass is an early release, not the verdict. The whole-answer
// pass runs the same chain and surfaces the failure with the processor's name on it.
stalled = true;
return;
}
if (settled.rejection !== undefined) {
rejection = settled.rejection;
return;
}
// The window is trimmed off the chain's OUTPUT, not off the text fed to it: a processor that
// only saw `raw` minus a tail could not match a pattern straddling that cut, and would release
// the very text it exists to rewrite.
const candidate = settled.text.slice(0, Math.max(0, settled.text.length - lookbackChars));
if (candidate.length <= released.length || !candidate.startsWith(released)) {
return;
}
const delta = candidate.slice(released.length);
released = candidate;
await writer.write({ t: 'text', v: delta });
}
async function handle(frame) {
if (rejection !== undefined) {
return;
}
if (frame.t === 'text') {
raw += frame.v;
if (!stalled) {
await advance();
}
return;
}
await writer.write(frame);
}
return {
writer: {
write: (frame) => {
queue = queue.then(() => handle(frame));
return queue;
},
end: () => { },
},
settled: () => queue,
released: () => released,
rejection: () => rejection,
};
}
/**
* The tail an incremental gate still owes the reader once the whole-answer pass has settled: the
* authoritative text minus the prefix already released.
*
* Throws when the settled answer is not an extension of that prefix. That check is the reason the
* final pass stays authoritative for both the stream and the store rather than the stream being
* stitched together from per-prefix results: agreement between what was streamed and what was stored
* becomes structural instead of assumed.
*/
export function gateTail(args) {
const { processors, released, text } = args;
if (!text.startsWith(released)) {
throw new ProcessorFailedError('output', processors.map((processor) => processor.name).join(' → '), new Error(`the gated answer is not an extension of the ${released.length} characters this chain already released — a processor that declares "incremental" promises its result on a prefix stays a prefix of its result on the whole answer, outside the last lookbackChars`));
}
return text.slice(released.length);
}
//# sourceMappingURL=processors.js.map