UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

358 lines • 16.1 kB
/** * abort — the ONE owner of how the library honours a cancellation. * * Pattern: Small pure helpers over `AbortSignal`. No state between calls. * Role: Leaf. Every provider adapter, the provider decorators and the * agent's own provider calls stop through these, so "the caller * cancelled" means the same thing at every layer. Two adapters * (Ollama, Foundry Local) each kept a private copy of the first * three helpers and the classifier kept a third; most adapters kept * none, and an aborted run left their remote calls running — and * billing. `test/architecture/abortOwner.test.ts` refuses a fourth. * Emits: N/A. * * ── THE LAW ────────────────────────────────────────────────────────────── * Once a request's signal has fired: * 1. nothing more is SENT — `throwIfAborted` before every dispatch, because * an already-aborted signal never dispatches another 'abort' event, so a * listener alone cannot see it; * 2. nothing more is WAITED FOR — `untilAborted` races every wait (the * response, a body read, a stream's next chunk) against the signal, so a * transport that ignores its signal still cannot hold the call open; * 3. nothing more is READ — `abortableIterable` closes the source (its * iterator's `return()`, plus the caller's own `close`, e.g. an SDK * stream's `abort()`), so the socket is released and the server stops * generating for nobody; * 4. nothing that arrives LATE counts — a response or chunk that lands after * the abort is refused with the abort, never returned as a success, so it * cannot be recorded as an answer or billed into a run's usage. "After" * means after the abort was SEEN: a read that settled a moment before the * abort, but whose reader had not yet run, is refused too — the signal is * checked again after every wait, not only raced during it. * * And a CLOSE is a wait like any other: a consumer that stops early waits for * the transport to finish closing while the signal is live; once it fires, * that wait ends with the abort and the caller's own close runs, so the * transport is shut either way. The abort wins whenever it is seen before the * close's result is handed back — even if the source finished closing a moment * earlier. * * ── WHAT AN ABORT REACHES THE CALLER AS ────────────────────────────────── * The signal's own `reason` when it is an `Error` — what every runtime * supplies (a `DOMException` named 'AbortError', or 'TimeoutError' for * `AbortSignal.timeout`), and what a caller who passed their own reason * expects back. Anything else becomes an `Error` named 'AbortError' that keeps * the reason as `cause`. An abort is the caller's word: it is never dressed up * as a provider failure, never retried, never fallen back from. */ /** * The error an aborted wait rejects with. See the module notes. * * Never throws. A reason that cannot even be classified (a revoked `Proxy`, * one whose `getPrototypeOf` throws) becomes a plain `AbortError` WITHOUT it: * this runs inside abort listeners, where a throw would escape as an uncaught * exception and leave the wait it should end unsettled. */ export function abortErrorOf(signal) { let reason; try { reason = signal.reason; if (reason instanceof Error) return reason; } catch { return Object.assign(new Error('This operation was aborted'), { name: 'AbortError' }); } return Object.assign(new Error('This operation was aborted'), { name: 'AbortError', ...(reason !== undefined && { cause: reason }), }); } /** Stop right here when `signal` has already fired. */ export function throwIfAborted(signal) { if (signal?.aborted) throw abortErrorOf(signal); } /** * `pending`, except that an abort ends the wait at once — with the abort, * whatever `pending` later does. The abandoned promise's own rejection is * swallowed, so the work given up on never surfaces as an unhandled rejection. * With no signal, `pending` itself. */ export function untilAborted(pending, signal) { if (signal === undefined) return Promise.resolve(pending); if (signal.aborted) { Promise.resolve(pending).catch(() => undefined); return Promise.reject(abortErrorOf(signal)); } return new Promise((resolve, reject) => { const onAbort = () => reject(abortErrorOf(signal)); signal.addEventListener('abort', onAbort, { once: true }); Promise.resolve(pending).then((value) => { signal.removeEventListener('abort', onAbort); resolve(value); }, (err) => { signal.removeEventListener('abort', onAbort); reject(err); }); }); } /** * A signal that fires when ANY of `signals` does, with that signal's reason. * `undefined` when none is given, the one signal itself when only one is. * * `AbortSignal.any` where the runtime has it (Node 20.3+, every current * browser): it holds its sources weakly, so a long-lived signal combined into * every call does not collect a listener per call. The fallback (an older * browser) listens until the first source fires, so the library's hot paths do * not lean on it: a run's signal and a call's deadline are combined with * {@link linkedController}, whose links end with the run or the call. */ export function eitherSignal(...signals) { const sources = [...new Set(signals.filter((s) => s !== undefined))]; if (sources.length === 0) return undefined; if (sources.length === 1) return sources[0]; const any = AbortSignal.any; if (typeof any === 'function') return any.call(AbortSignal, sources); const controller = new AbortController(); const fired = sources.find((s) => s.aborted); if (fired !== undefined) { controller.abort(fired.reason); return controller.signal; } const onAbort = (event) => { for (const s of sources) s.removeEventListener('abort', onAbort); controller.abort(event.target.reason); }; for (const s of sources) s.addEventListener('abort', onAbort, { once: true }); return controller.signal; } /** The longest delay a platform timer holds; Node turns a longer one into 1 ms. */ const MAX_TIMER_MS = 2 ** 31 - 1; /** * A controller of the caller's own, following `sources` until `unlink()` — * for work that must be cancellable by the caller's signals AND by itself (a * deadline), for exactly that work's lifetime. Aborted at once when a source * already has; aborted with a source's reason when one fires; `unlink()` takes * every listener off the sources, so nothing outlives the work on a * long-lived signal. Idempotent. */ export function linkedController(...sources) { const controller = new AbortController(); const linked = [...new Set(sources.filter((s) => s !== undefined))]; const fired = linked.find((s) => s.aborted); if (fired !== undefined) { controller.abort(fired.reason); return { controller, unlink: () => undefined }; } const unlink = () => { for (const s of linked) s.removeEventListener('abort', onAbort); }; const onAbort = (event) => { unlink(); controller.abort(event.target.reason); }; for (const s of linked) s.addEventListener('abort', onAbort, { once: true }); return { controller, unlink }; } /** * Abort `controller` with `reason()` once `ms` have passed — a DEADLINE, not a * wait (`sleep.ts` owns waits). Returns the disarm: a deadline is released * when the work it bounds settles, as the engine releases its own, so it never * fires on work that already finished. A delay past what a timer holds * (2^31 − 1 ms, about 24.8 days) is held at that maximum rather than firing at * once. * * The timer is NOT `unref`'d, exactly like the engine's: while the work it * bounds is pending it keeps the process alive, so a stage that hangs on work * holding no handle of its own still gets its timeout instead of a process * that exits with the run never settled. The disarm is what lets it go. */ export function armDeadline(controller, ms, reason) { const timer = setTimeout(() => controller.abort(reason()), Math.min(Math.max(ms, 0), MAX_TIMER_MS)); return () => clearTimeout(timer); } /** * `request` carrying `signal` as well as any signal it already carried — the * SAME object when there is nothing to add. Every place the library hands a * request to a provider stamps the run's signal through here. */ export function withRequestSignal(request, signal) { if (signal === undefined || request.signal === signal) return request; return { ...request, signal: eitherSignal(request.signal, signal) }; } /** * One non-streaming call under `signal` — the four laws in one place, for the * library's own provider calls (the agent, `LLMCall`, the summarizers, the * extractors, the gates): refused before it is sent when the signal has * already fired, handed the request WITH the signal so the provider can cancel * its own request, raced so an adapter that ignores it cannot hold the caller, * and refused when its answer lands after the abort — so a late answer is * never recorded or billed as if the call had completed. */ export function callUnder(request, signal, call) { return awaitUnder(signal, () => call(withRequestSignal(request, signal))); } /** * Laws 1, 2 and 4 for one piece of work that cannot be handed the signal (a * callback with no signal in its contract): not started when the signal has * already fired, not waited for once it fires, and refused when it lands after. */ export async function awaitUnder(signal, start) { throwIfAborted(signal); const answer = await untilAborted(start(), signal); throwIfAborted(signal); return answer; } /** * Iterate `source` under `signal` — laws 2, 3 and 4 for a stream. With no * signal, `source` itself. * * Each `next()` is raced against the signal; on abort the source is CLOSED — * `close()` first (an SDK stream's `abort()`, a body reader's `cancel()`), * then its iterator's `return()` — and the iteration rejects with the abort. A * step that lands after the abort is refused, never handed on. A consumer that * stops early (`break`, a throw) on a live signal closes the source as * `for await` does — its `return()` waited for, its errors propagated; an * abort during that wait ends it with the abort and runs `close()`, so the * transport is closed even when its `return()` never finishes. * * The source's `return()` is NOT awaited: a source suspended inside its own * await (a read the transport will never answer) cannot finish returning, and * waiting for it would re-create the hang this exists to end. */ export function abortableIterable(source, signal, close) { if (signal === undefined) return source; const watched = signal; return { [Symbol.asyncIterator]() { const it = source[Symbol.asyncIterator](); let shut = false; const closeSource = () => { if (shut) return; shut = true; settleQuietly(() => close?.()); settleQuietly(() => it.return?.()); }; const refuse = () => { closeSource(); throw abortErrorOf(watched); }; return { async next() { if (watched.aborted) refuse(); let step; try { step = await untilAborted(it.next(), watched); } catch (err) { if (watched.aborted) refuse(); throw err; } if (watched.aborted) refuse(); if (step.done) shut = true; return step; }, async return(value) { // A consumer that stops early on a live signal closes the source // the way `for await` always did: waited for, its errors its own. // Once the signal fires — before or DURING that wait — the close is // not waited for: the caller's `close()` shuts the transport (its // `return()` was already asked) and the abort is what comes back. if (!shut && !watched.aborted) { shut = true; try { await untilAborted(it.return?.(value), watched); } catch (err) { if (!watched.aborted) throw err; } if (watched.aborted) { settleQuietly(() => close?.()); throw abortErrorOf(watched); } return { done: true, value: value }; } closeSource(); return { done: true, value: value }; }, }; }, }; } /** * The chunks of a fetch body, read under `signal` — the raw-`fetch` adapters' * one reader. Each read is raced against the signal and the signal checked * again once it settles, so a chunk read a moment before the abort is never * handed on. The body is CANCELLED on every way out (the end, a consumer that * stops early, an error, an abort): an open body is a socket held and, on a * local model, a generation that keeps running for nobody. A cancel waited on * by a consumer that stopped early ends with the abort if one fires during it. */ export async function* bodyChunks(body, signal) { const reader = body.getReader(); try { for (;;) { const { value, done } = await untilAborted(reader.read(), signal); throwIfAborted(signal); if (done) return; if (value !== undefined) yield value; } } finally { // Not waited for once aborted — before the cancel or during it: a reader // whose transport ignores the abort may never settle its cancel, and the // caller is owed the abort now. const cancelled = reader.cancel().catch(() => undefined); try { // Only an abort that lands DURING the wait is thrown here: one that // fired before it is already the error on its way out (or a consumer // that broke after aborting, which exits cleanly, as abortableIterable). // An abort during the wait replaces any error already leaving: the // caller asked to stop, and that is what they are owed. if (!signal?.aborted) { await untilAborted(cancelled, signal); throwIfAborted(signal); } } finally { try { reader.releaseLock(); } catch { /* a lock the runtime already dropped, or a read still pending */ } } } } /** Run `fn`, swallowing a throw AND a rejected promise it returns. */ function settleQuietly(fn) { try { const out = fn(); if (out !== null && typeof out === 'object' && 'then' in out) { Promise.resolve(out).catch(() => undefined); } } catch { /* closing is best-effort: the abort is what the caller is owed */ } } //# sourceMappingURL=abort.js.map