agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
329 lines • 16.4 kB
JavaScript
;
/**
* hosting/envelope — pack a session for storage, and refuse to unpack one you
* cannot read.
*
* One rule, and everything here is a consequence of it: **an envelope this
* runtime cannot read is refused BY NAME, never guessed at.** A store outlives
* the code that wrote to it. Somebody will deploy a newer runtime, it will write
* a newer format, and an older instance still running will read it. The only
* honest thing that older instance can do is say which format it found, which
* ones it knows, and stop — because "restore what I can and hope" means an agent
* answering from a session that is missing whatever the older reader did not
* understand.
*
* ── Two formats, two readers, and why they refuse each other ─────────────────
* `readEnvelope` unpacks a CONVERSATION; `readPausedRun` unpacks a PAUSED RUN.
* Each refuses the other's format by name and points at its sibling. That looks
* fussy until you notice the alternative: one reader that quietly returned the
* conversation inside a paused run would hand back a session that LOOKS finished
* while a person is still waiting on a question nobody mentioned. That is a
* half-restore wearing a happy path, which is the exact failure the format field
* exists to prevent.
*
* ── The same rule one step earlier: unreadable ≠ absent ──────────────────────
* A format nobody knows is one way a store can hand back something unusable.
* The other is bytes that are not an envelope at all — a store that kept its own
* encoding and gave back its host language's `toString()` of the object rather
* than the object, say. **An unreadable stored conversation and an absent one
* are different facts, and only one of them is safe to answer with a fresh
* start.** Absent is ordinary. Unreadable means a conversation EXISTS and this
* runtime cannot see it, and answering that with a fresh start is invisible from
* the outside — it looks exactly like a new user — until a deployment boundary
* hands somebody a blank slate where their conversation was.
*
* So {@link readFormat} — the ONE place a stored value is inspected, and
* therefore the place every reader and every store adapter inherits from —
* raises {@link UnreadableEnvelopeError} by name, with the session and a short
* prefix of what it found. Decoding is an adapter's business (only the adapter
* knows its transport's encoding); "these bytes are nobody's readable session"
* is everybody's, so it is refused here, once.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.envelopeTranscript = exports.envelopeOwner = exports.checkEnvelope = exports.readPausedRun = exports.readEnvelope = exports.toPausedEnvelope = exports.toEnvelope = void 0;
const errors_js_1 = require("./errors.js");
const runCheckpoint_js_1 = require("../core/runCheckpoint.js");
/** Every format this runtime can read. Add, never redefine. */
const KNOWN_FORMATS = ['conversation-v1', 'flowchart-v1'];
/**
* Pack a conversation checkpoint for storage.
*
* @example
* const conversation = agent.checkpoint();
* if (conversation) await sessions.persist(sessionId, toEnvelope(conversation));
*/
function toEnvelope(checkpoint) {
return { format: 'conversation-v1', data: checkpoint, savedAt: Date.now() };
}
exports.toEnvelope = toEnvelope;
/**
* Pack a paused run for storage — the engine checkpoint, the conversation as of
* the pause, and the question it is waiting on.
*
* Store it anywhere that speaks JSON. Note what JSON does and does not preserve
* here: `agent.resume()` reads `checkpoint.sharedState`, which round-trips
* unchanged; the engine's diagnostic halves lose their explicitly-`undefined`
* properties, because that is what `JSON.stringify` does to them. See
* {@link PausedRun}.
*
* @example
* const outcome = await agent.run({ message });
* if (isPaused(outcome)) {
* await sessions.persist(sessionId, toPausedEnvelope({
* checkpoint: outcome.checkpoint,
* conversation: agent.checkpoint()!,
* pending: { pauseData: outcome.pauseData },
* }));
* }
*/
function toPausedEnvelope(paused) {
return { format: 'flowchart-v1', data: paused, savedAt: Date.now() };
}
exports.toPausedEnvelope = toPausedEnvelope;
/**
* Unpack a stored envelope back into a conversation checkpoint.
*
* Takes `unknown` on purpose: what comes back from a store is bytes somebody
* else wrote, in a format this runtime may not know, and typing the parameter
* as the happy shape would be assuming the very thing that needs checking.
*
* @throws UnreadableEnvelopeError when what came back is present but is not an
* envelope at all — bytes a store kept in its own encoding, say. Never
* `undefined`: a conversation that exists and cannot be read is not the same
* fact as one that was never there.
* @throws TypeError naming the format when it is one this runtime cannot read;
* naming the missing field when the conversation inside is malformed; and
* pointing at {@link readPausedRun} when the envelope holds a paused run,
* which is a session with a question outstanding rather than a conversation.
*/
function readEnvelope(envelope) {
if (readFormat(envelope) === 'flowchart-v1') {
throw new TypeError(`[hosting] this envelope holds a PAUSED RUN ('flowchart-v1'), not a conversation. ` +
`Read it with readPausedRun(envelope) — it is waiting on a person's decision, and ` +
`handing back only the conversation inside it would restore a session that looks ` +
`finished while somebody is still waiting to be asked.`);
}
return (0, runCheckpoint_js_1.validateCheckpoint)(envelope.data);
}
exports.readEnvelope = readEnvelope;
/**
* Unpack a stored envelope back into a paused run.
*
* It hands back the record as stored, MARK INCLUDED: a pause whose resume
* failed and may have used its decision carries `answered`
* (`PausedRun.answered`), and code that continues sessions must not resume one
* — continue its `conversation` with the next message instead. The library's own doors read stored sessions
* through one reader that does exactly that (`answeredPause.ts`).
*
* @throws TypeError naming the format when it is one this runtime cannot read;
* pointing at {@link readEnvelope} when the envelope holds a plain
* conversation; and naming the missing field when the paused run inside is
* malformed.
*/
function readPausedRun(envelope) {
if (readFormat(envelope) === 'conversation-v1') {
throw new TypeError(`[hosting] this envelope holds a conversation ('conversation-v1'), not a paused run. ` +
`Read it with readEnvelope(envelope). Nothing is waiting on a decision here.`);
}
return validatePausedRun(envelope.data);
}
exports.readPausedRun = readPausedRun;
/**
* Check that an envelope is one this runtime can read, and hand it back
* unchanged — without committing to which half you wanted.
*
* This is what a STORE wants. A store's job is to notice that the bytes it is
* about to hand over are unreadable, so the refusal names the store that
* produced them rather than whoever read them next; it has no business caring
* whether the session inside is mid-conversation or mid-question.
*
* Pass `sessionId` and the refusal names the conversation, which is what turns
* a log line into an incident somebody can act on. Pass it whenever you have it
* — a store always does.
*
* @throws UnreadableEnvelopeError when the stored value is present but is not an
* envelope at all. That is the case a store must NOT answer with `undefined`:
* see the module header.
* @throws TypeError naming the format when this runtime cannot read it, or the
* missing field when the payload is malformed.
*
* @example
* async hydrate(sessionId) {
* const stored = await myStore.get(sessionId);
* return stored === undefined ? undefined : checkEnvelope(stored, sessionId);
* }
*/
function checkEnvelope(envelope, sessionId) {
if (readFormat(envelope, sessionId) === 'flowchart-v1') {
validatePausedRun(envelope.data);
}
else {
(0, runCheckpoint_js_1.validateCheckpoint)(envelope.data);
}
return envelope;
}
exports.checkEnvelope = checkEnvelope;
/**
* WHO a stored session belongs to — the `principal` on the conversation the
* envelope carries, or `undefined` when it names nobody (9.26.0).
*
* The ONE derivation, so a store's owner index and the composer's ownership
* check can never disagree about what "belongs to" means. It is read from the
* conversation's own identity — the tuple `standingAgent` composed from the
* request's verified user — and NOT from anything a caller supplied at persist
* time: a store that indexed on a claimed owner would be a store where owning
* a session is a matter of asking.
*
* A conversation that ran anonymously, or under a derived session-namespace
* rung with no principal, has no owner. That is a FACT, not a gap to fill: a
* session nobody signed for is a session nobody can list, and inventing an
* owner would be inventing an entitlement.
*
* **Status: the CONTRACT is field-validated — an independent field trial,
* 2026-08-13.** A live multi-user run against a real Firestore: an owner
* written once and never moved, a cross-user transcript answering the same
* not-found a missing session gets, one caller's own two sessions listed over
* two pages, `listByUser` / `ownerOf` feature detection, and `mayOpenSession`
* refusing an ordinary turn on somebody else's conversation. The store
* underneath was **the trial's own Firestore `SessionLifecycle`**, not one this
* package ships — so the rung belongs to this derivation and the rules above
* it, and no shipped Google adapter may borrow the credit.
*
* Reads both formats, so a session that is mid-question is owned exactly as
* firmly as one that is finished. Returns `undefined` rather than throwing on
* a shape it cannot read — an index is a convenience, and refusing to WRITE a
* conversation because its owner could not be derived would be the tail
* wagging the dog. (Every reader of the index still checks ownership itself.)
*/
function envelopeOwner(envelope) {
const conversation = conversationOf(envelope);
const principal = conversation?.identity?.principal;
return typeof principal === 'string' && principal.length > 0 ? principal : undefined;
}
exports.envelopeOwner = envelopeOwner;
/**
* The stored conversation's messages, projected to what a person may read back
* (9.26.0): user and assistant TEXT, in stored order.
*
* Everything else is deliberately dropped — see {@link TranscriptMessage} for
* the full list and the reason. Non-string content (a provider's structured
* block) is dropped rather than stringified: a transcript is what was said, and
* `[object Object]` is not something anybody said.
*/
function envelopeTranscript(envelope) {
const history = conversationOf(envelope)?.history;
if (!Array.isArray(history))
return [];
const messages = [];
for (const message of history) {
const role = message.role;
if (role !== 'user' && role !== 'assistant')
continue;
const content = message.content;
if (typeof content !== 'string' || content.length === 0)
continue;
messages.push({ role, content });
}
return messages;
}
exports.envelopeTranscript = envelopeTranscript;
/**
* The conversation inside either envelope format, WITHOUT the strict validation
* the two readers apply.
*
* Lenient on purpose, and only for the two projections above: an index entry
* and a read-back transcript are both best-effort views of something that is
* already stored, and raising here would turn "I could not summarize your old
* session" into "your request failed". The authoritative readers
* ({@link readEnvelope} / {@link readPausedRun}) keep their refusals for the
* path that actually RESTORES a session, which is the one where a half-read
* conversation does damage.
*/
function conversationOf(envelope) {
if (!envelope || typeof envelope !== 'object')
return undefined;
const { format, data } = envelope;
if (!data || typeof data !== 'object')
return undefined;
if (format === 'flowchart-v1') {
const conversation = data.conversation;
return conversation && typeof conversation === 'object'
? conversation
: undefined;
}
if (format === 'conversation-v1') {
return data;
}
return undefined;
}
/**
* The one place a stored value is inspected, so every refusal in this file says
* the same thing in the same words — and so the "unreadable is not absent" law
* holds for every reader here and every store adapter that calls one, including
* ones nobody has written yet.
*/
function readFormat(envelope, sessionId) {
if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) {
// Present but not an envelope. NOT `undefined`, NOT a fresh start: see the
// module header. Still a `TypeError` by ancestry, so an existing catch is
// unaffected — it just has a name and a session now.
throw new errors_js_1.UnreadableEnvelopeError(envelope, sessionId);
}
const found = envelope.format;
if (typeof found !== 'string' || !KNOWN_FORMATS.includes(found)) {
throw new TypeError(`[hosting] unknown checkpoint format '${String(found)}'. ` +
`This runtime reads: ${KNOWN_FORMATS.join(', ')}. ` +
`Refusing rather than restoring a session it cannot read — a newer envelope ` +
`needs a runtime that knows the format that wrote it.`);
}
return found;
}
/**
* Validate a paused run at deserialization time, naming the missing piece.
*
* Only the fields resume actually consumes are required. `executionTree` and
* `subflowResults` are the engine's diagnostic halves — a checkpoint that lost
* them in transit still resumes, so demanding them would refuse a session that
* would have worked.
*/
function validatePausedRun(value) {
if (!value || typeof value !== 'object') {
throw new TypeError('[hosting] paused run is not an object.');
}
const run = value;
const cp = run.checkpoint;
if (!cp || typeof cp !== 'object') {
throw new TypeError(`[hosting] paused run is missing required field: checkpoint. ` +
`It is the engine checkpoint agent.resume() continues from; without it the run ` +
`cannot be continued at all.`);
}
if (typeof cp.pausedStageId !== 'string' ||
!cp.sharedState ||
typeof cp.sharedState !== 'object') {
throw new TypeError(`[hosting] paused run's checkpoint is missing required fields (pausedStageId, ` +
`sharedState) — the two agent.resume() rebuilds the cursor and the run's state from.`);
}
if (!Array.isArray(cp.subflowPath)) {
throw new TypeError(`[hosting] paused run's checkpoint is missing required field: subflowPath.`);
}
if (!run.pending || typeof run.pending !== 'object') {
throw new TypeError(`[hosting] paused run is missing required field: pending — the question it is ` +
`waiting on. A stored pause nobody can describe is a session that hangs.`);
}
(0, runCheckpoint_js_1.validateCheckpoint)(run.conversation);
// A mark a reader cannot read is refused, never taken as "still waiting":
// that would offer an answered request for an answer again.
const answered = run.answered;
if (answered !== undefined &&
(answered === null || typeof answered !== 'object' || typeof answered.at !== 'number')) {
throw new TypeError(`[hosting] paused run's \`answered\` must be { at: <epoch ms> } when present — the ` +
`mark that a resume used its decision and did not finish.`);
}
const wire = run.wire;
if (wire !== undefined &&
(wire === null || typeof wire !== 'object' || typeof wire.protocol !== 'string')) {
throw new TypeError(`[hosting] paused run's \`wire\` must name the protocol that recorded it when present.`);
}
return run;
}
//# sourceMappingURL=envelope.js.map