agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
273 lines • 12.7 kB
JavaScript
/**
* memberRun — WHO a composition member runs for: the run that mounted it.
*
* ── The defect this closes ───────────────────────────────────────────────────
* A composition (`Sequence`, `Parallel`, `Conditional`, `Loop`, `Workflow`,
* `Graph`) mounts each member's chart in ITS OWN executor; the member's
* `run()` never runs. So a member agent's per-run facts — the memory
* namespace, a tool's `ctx.identity`, `ctx.runId` — were read off the member
* INSTANCE, which every run of the composition shares: `'pending'` for an
* instance never run on its own (one memory for every user of the
* composition), or the last direct run's caller (another person's identity).
*
* ── The rule ─────────────────────────────────────────────────────────────────
* The run decides, and only the engine can name it. The OUTERMOST
* composition's `run()` OPENS one run — the identity its caller named, or
* none, and that run's own id — in this module's private registry, and hands
* its members only an unguessable LINK to it (`withParentRun`), nesting
* included. A member agent looks the run up by that link (`stages/seed.ts`):
* the caller's identity when one was named, else the run's own per-run
* conversation `{ conversationId: runId }` — never a value its instance
* holds, and never a run-shaped value it was handed: a `parentRun` that is not
* a live link is refused by name (`ParentRunRefusedError`). A member mounted
* where no run reaches it is refused too (`MemberIdentityError`).
*
* Per-run args are the carrier because they are the one thing two overlapping
* runs of one composition cannot share.
*
* Pattern: value object + pure helpers. Role: core, run identity.
*/
import { assertIdentityShape } from './agent/callerIdentity.js';
/** The state key a runner keeps the run it serves under. */
export const MEMBER_RUN_KEY = 'memberRun';
// ── The brand: a module-private registry of the runs a composition opened ──
//
// A member is handed a LINK, an unguessable token, never a run. The run it
// names lives here, where only `openParentRun` / `reopenParentRun` can put it
// — so nothing outside this module (an option, an input, a mapper, a state
// key, a model's or a tool's output) can make a member run for someone: a
// `parentRun` that is not a live link is refused by name. A token rather than
// an object brand, because a member's input is copied on its way in, and a
// copy keeps a string but not an object's identity.
const live = new Map();
/**
* A `parentRun` that is not a live link this module issued — forged, copied
* from another run, or a run already over. Refused, never used.
*/
export class ParentRunRefusedError extends Error {
code = 'ERR_PARENT_RUN_REFUSED';
constructor(door) {
super(`${door}: \`parentRun\` is not a run this process opened — it is refused. Only a ` +
'composition’s own run() hands its members their run; one built anywhere else (an ' +
'option, an input mapper, a model’s or a tool’s output) could name another person.');
this.name = 'ParentRunRefusedError';
}
}
/**
* Open the run a top-level composition run hands its members, and register it.
* Close it with {@link closeParentRun} when the run returns (finished or paused).
*
* @throws TypeError (from `assertIdentityShape`) when `identity` is not one.
*/
export function openParentRun(runId, identity, door, request) {
assertIdentityShape(identity, door);
const run = {
runId,
...(identity !== undefined && { identity: copyIdentity(identity) }),
};
const link = globalThis.crypto.randomUUID();
// The request stays HERE, beside the run, and never on the record: the
// record is written to the runner's state, and state is the record.
live.set(link, { run, holders: 1, ...(request !== undefined && { request }) });
return { link, ...run };
}
/**
* Re-open, for a resumed leg, the run a paused composition was running — read
* off the checkpoint's own state (the checkpoint is the record of that run; a
* host keeps it server-side or signs it, as for every resume). `undefined`
* when the paused run recorded none.
*/
export function reopenParentRun(sharedState, door, request) {
if (sharedState === null || typeof sharedState !== 'object')
return undefined;
const raw = sharedState[MEMBER_RUN_KEY];
if (raw === undefined)
return undefined;
const { link, runId, identity } = (raw ?? {});
if (typeof link !== 'string' || link === '' || typeof runId !== 'string' || runId === '') {
throw new ParentRunRefusedError(door);
}
assertIdentityShape(identity, door);
const run = {
runId,
...(identity !== undefined && { identity: copyIdentity(identity) }),
};
const held = live.get(link);
if (held !== undefined) {
// The run is still open elsewhere. Its members already serve ONE request;
// a resume naming another would hand them a second — refused, not merged.
if (held.request !== request)
throw new ResumeRequestConflictError(door);
held.holders++;
}
else
live.set(link, { run, holders: 1, ...(request !== undefined && { request }) });
return { link, ...(held?.run ?? run) };
}
/** Release a run this module opened (`openParentRun` / `reopenParentRun`). */
export function closeParentRun(record) {
if (record === undefined)
return;
const held = live.get(record.link);
if (held === undefined)
return;
if (--held.holders <= 0)
live.delete(record.link);
}
/**
* The request a live link's run serves, or `undefined` — for a dead link, a
* value that is not a link, or a run opened without one.
*/
export function requestOfLink(link) {
return typeof link === 'string' ? live.get(link)?.request : undefined;
}
/**
* The request a member's stage serves, read from the stage's own input: the
* request of the run its `parentRun` link names. A member with no link (a
* chart a caller built and runs itself) serves NO request — never the
* request of whatever run its instance is in elsewhere.
*/
export function memberRequestIn(scope) {
const getArgs = scope.$getArgs;
const args = (typeof getArgs === 'function' ? getArgs.call(scope) : undefined);
return requestOfLink(args?.parentRun);
}
/**
* A resume names a request while the run it resumes is still open under
* another — its members would serve two requests. Refused before anything runs.
*/
export class ResumeRequestConflictError extends Error {
code = 'ERR_RESUME_REQUEST_CONFLICT';
constructor(door) {
super(`${door}: the paused run is still open and serves another request — a resume cannot hand its ` +
'members a second one. Resume it once the open leg has returned.');
this.name = 'ResumeRequestConflictError';
}
}
/** The run a live link names, or `undefined`. */
export function runOfLink(link) {
return typeof link === 'string' ? live.get(link)?.run : undefined;
}
/**
* The run a runner was handed in its input, looked up by its link; `undefined`
* when the input carries none.
*
* @throws ParentRunRefusedError when a `parentRun` is present but is not a live
* link — a member never runs under a run it cannot prove.
*/
export function resolveParentRun(args, door) {
if (args === null || typeof args !== 'object')
return undefined;
const link = args.parentRun;
if (link === undefined)
return undefined;
const run = runOfLink(link);
if (run === undefined)
throw new ParentRunRefusedError(door);
return { link: link, ...run };
}
/** The runners that read `parentRun` — an Agent, and every composition (which hands it on). */
const readers = new WeakSet();
/**
* Register a runner that reads `parentRun` from its input — `Agent` and the
* compositions call this in their constructors. Every other runner is handed
* the input it always was, byte for byte.
*/
export function readsParentRun(runner) {
readers.add(runner);
}
/**
* A member's input with the LINK of the run it is mounted in — what every
* composition's input mapper returns — for a runner that reads it; the input
* unchanged for any other runner, or when there is no run to hand down.
*/
export function withParentRun(input, record, runner) {
if (!readers.has(runner))
return input;
if (record !== undefined)
return { ...input, parentRun: record.link };
// No run to hand down: a `parentRun` already in the input came from a mapper
// or an earlier step's data, not from this module — a link is a key to its
// run's identity and request, so one that arrived that way is dropped.
if (!Object.hasOwn(input, 'parentRun'))
return input;
const { parentRun: _dropped, ...rest } = input;
void _dropped;
return rest;
}
/**
* A composition's seed: resolve the link it was handed — its own `run()`
* opened one; a parent composition's mapper hands one down — and keep the run
* in its state, under {@link MEMBER_RUN_KEY}, for its input mappers to hand on
* (a run's input is not part of the state an input mapper reads). Written only
* when there is one.
*
* @throws ParentRunRefusedError for a link that is not live.
*/
export function adoptParentRun(scope, args, door) {
const record = resolveParentRun(args, door);
if (record !== undefined)
scope[MEMBER_RUN_KEY] = record;
}
/** The run a runner's state carries (`adoptParentRun`, or an agent's seed), for its input mappers. */
export function parentRunIn(state) {
if (state === null || typeof state !== 'object')
return undefined;
return state[MEMBER_RUN_KEY];
}
/**
* What an inner mount of a member agent is handed of its run: the link, or —
* for a chart a caller built — the identity it was given. Nothing on the
* agent's own runs.
*/
export function memberRunArg(record) {
if (record === null || typeof record !== 'object')
return {};
const { link, identity } = record;
if (typeof link === 'string' && link !== '')
return { parentRun: link };
return identity !== undefined ? { memberIdentity: identity } : {};
}
/**
* The identity a member's stage runs for, read from the stage's own input:
* the run its link names, else the identity a caller-built chart handed it.
*/
export function memberIdentityIn(scope) {
const getArgs = scope.$getArgs;
const args = (typeof getArgs === 'function' ? getArgs.call(scope) : undefined);
if (args?.parentRun !== undefined) {
// A link decides alone: a dead one names nobody — never the input's own
// `identity`, which on a workflow or graph step can be an earlier step's data.
return runOfLink(args.parentRun)?.identity;
}
return args?.memberIdentity ?? args?.identity;
}
/** The memory namespace a member runs under: the named identity, else the run's own conversation. */
export function memberIdentityOf(parentRun) {
return parentRun.identity ?? { conversationId: parentRun.runId };
}
function copyIdentity(identity) {
return {
...(identity.conversationId !== undefined && { conversationId: identity.conversationId }),
...(identity.tenant !== undefined && { tenant: identity.tenant }),
...(identity.principal !== undefined && { principal: identity.principal }),
};
}
/**
* A member mounted where no run reaches it — another runner's executor is
* running this agent's chart, and neither a `parentRun` nor an `identity`
* arrived in its input. Refused rather than run under its instance's last run.
*/
export class MemberIdentityError extends Error {
code = 'ERR_MEMBER_NO_IDENTITY';
constructor() {
super('Agent: this agent is running inside another runner’s chart and no identity reached it ' +
'— no `parentRun` from a composition and no `identity` in its input. It is refused ' +
'rather than run under its instance’s last run, which may be another user’s, or under ' +
'a placeholder every run would share. Mount it through a composition (Sequence, ' +
'Parallel, Conditional, Loop, Workflow, Graph), which hands it the run it serves, or ' +
'return `identity` from your inputMapper.');
this.name = 'MemberIdentityError';
}
}
//# sourceMappingURL=memberRun.js.map