agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
357 lines • 17.5 kB
JavaScript
;
/**
* artifacts/fileArtifacts — the claim-check store in a directory.
*
* One machine, one directory, nothing to install: each artifact is one JSON
* envelope at `<dir>/<tenant>/<principal>/<conversation>/<ref>.json`, so an
* incident can be inspected with `ls` and `cat` — a store you cannot read
* with the tools already on the box is a store you debug by guessing.
*
* ── Scope-partitioned paths, structurally traversal-proof ──────────────────
* The path is built from two kinds of segment and both are closed:
* • the ref — MINTED (`art_` + base62), and `isArtifactRef` is asserted
* before any ref touches a path anyway, because "cannot happen" is a
* claim, not a defence;
* • the scope tuple — caller strings, so each segment is percent-encoded
* before it becomes a directory name: `..`, `/` and `\` arrive as data
* and land as literals, never as navigation. That encoding is
* `scopePath.ts`'s to own (9.25.0) — the object-store adapters partition
* their keys with the identical law, and one traversal defence that lives
* in two files is one that eventually differs in two files.
*
* ── Streaming (9.25.0) ─────────────────────────────────────────────────────
* This adapter implements the port's OPTIONAL `putStream`/`getStream`,
* because a directory can honestly do it: the bytes go to a sibling
* `<ref>.bin` as they arrive and the envelope records `payload: { shape:
* 'external' }`. The envelope is written LAST, so a crash mid-upload leaves an
* orphan `.bin` nobody can see rather than a ticket pointing at a payload that
* never finished. A streamed artifact reads back as a `Uint8Array` through
* `get()`, and `delete`/retention remove both files.
*
* ── What this adapter does NOT know ────────────────────────────────────────
* A directory cannot cheaply know which artifact was READ last, so budget
* evictions here are oldest-first (by mint time) rather than the in-memory
* adapter's least-recently-used — stated here so nobody discovers it as a
* surprise under pressure. TTL expiry behaves identically everywhere.
*
* Node-only (`node:fs` via lazy require, the sqliteSessions law) — importing
* the barrel costs a browser bundle nothing; constructing one where there is
* no filesystem refuses by name.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.fileArtifacts = exports.UnreadableArtifactFileError = void 0;
const lazyRequire_js_1 = require("../lib/lazyRequire.js");
const minting_js_1 = require("./minting.js");
const naming_js_1 = require("./naming.js");
const payload_js_1 = require("./payload.js");
const retention_js_1 = require("./retention.js");
const scopePath_js_1 = require("./scopePath.js");
const streaming_js_1 = require("./streaming.js");
const types_js_1 = require("./types.js");
/** What one artifact file holds. `format` is bumped only for a change an
* older reader could not survive; a newer number is refused, never half-read. */
const FILE_FORMAT = 1;
/**
* Raised when an artifact file EXISTS but this runtime cannot read it — a
* different fact from "no artifact", and only one of them is safe to answer
* with `null`. (The sqliteSessions law, per file.)
*/
class UnreadableArtifactFileError extends Error {
code = 'ERR_UNREADABLE_ARTIFACT_FILE';
file;
constructor(file, detail) {
super(`[artifacts] the artifact file at '${file}' cannot be read: ${detail} ` +
`An unreadable artifact and a missing one are different facts — this refuses rather ` +
`than reporting "no data" over bytes that exist. Move the file aside to release the ref.`);
this.name = 'UnreadableArtifactFileError';
this.file = file;
}
}
exports.UnreadableArtifactFileError = UnreadableArtifactFileError;
/**
* A directory-backed artifact store — durable across restarts, legible to a
* human, one file per artifact.
*
* @example
* const store = fileArtifacts({ directory: './artifacts' });
* const agent = Agent.create({ provider, artifacts: store });
*/
function fileArtifacts(options) {
const { directory, retention } = options;
if (typeof directory !== 'string' || directory.trim() === '') {
throw new TypeError(`[artifacts] fileArtifacts({ directory: ${JSON.stringify(directory)} }) names no ` +
`directory. Give it a path — it will be created if it does not exist.`);
}
(0, retention_js_1.assertRetention)('fileArtifacts', retention);
const now = options._now ?? Date.now;
const fs = (0, lazyRequire_js_1.lazyRequire)('node:fs');
const path = (0, lazyRequire_js_1.lazyRequire)('node:path');
fs.mkdirSync(directory, { recursive: true });
// One RAW tuple field → one inert directory name. The law (encode the
// FIELD not the joined namespace; encode dots too, so a literal '..' is a
// NAME) is `scopePath.ts`'s — shared with the object-store adapters rather
// than restated here.
const scopeDir = (scope) => path.join(directory, (0, scopePath_js_1.scopeSegment)(scope.tenant), (0, scopePath_js_1.scopeSegment)(scope.principal), (0, scopePath_js_1.scopeSegment)(scope.conversationId));
const fileFor = (scope, ref) => path.join(scopeDir(scope), `${ref}.json`);
/** Where a STREAMED payload's bytes live: beside the envelope. */
const payloadFileFor = (scope, ref) => path.join(scopeDir(scope), `${ref}.bin`);
/** Remove an artifact's files — the envelope and, when it was streamed,
* the sibling payload. One remover, so no caller can forget half. */
const removeArtifactFiles = (scope, ref) => {
fs.rmSync(fileFor(scope, ref), { force: true });
fs.rmSync(payloadFileFor(scope, ref), { force: true });
};
const readEnvelope = (file) => {
let raw;
try {
raw = fs.readFileSync(file, 'utf8');
}
catch {
return undefined; // never written (or already swept) — the null case.
}
let parsed;
try {
parsed = JSON.parse(raw);
}
catch {
throw new UnreadableArtifactFileError(file, 'it is not JSON.');
}
const candidate = parsed;
if (candidate === null ||
typeof candidate !== 'object' ||
typeof candidate.format !== 'number' ||
candidate.meta === undefined ||
candidate.payload === undefined) {
throw new UnreadableArtifactFileError(file, 'it is not an artifact envelope.');
}
if (candidate.format > FILE_FORMAT) {
throw new UnreadableArtifactFileError(file, `it was written with format ${candidate.format} and this runtime reads format ` +
`${FILE_FORMAT}; roll agentfootprint forward rather than half-read it.`);
}
return candidate;
};
/** Read a LIVE envelope; sweep the file when the calendar says it is gone. */
const liveEnvelope = (scope, ref) => {
if (!(0, naming_js_1.isArtifactRef)(ref))
return undefined;
const file = fileFor(scope, ref);
const envelope = readEnvelope(file);
if (envelope === undefined)
return undefined;
if (envelope.meta.expiresAt !== undefined && envelope.meta.expiresAt <= now()) {
removeArtifactFiles(scope, ref);
return undefined;
}
return envelope;
};
/** Every live meta in a scope (expired files swept as they are found). */
const scopeMetas = (scope) => {
const dir = scopeDir(scope);
let names;
try {
names = fs.readdirSync(dir);
}
catch {
return []; // scope never wrote anything.
}
const at = now();
const metas = [];
for (const name of names) {
if (!name.endsWith('.json'))
continue;
const ref = name.slice(0, -'.json'.length);
if (!(0, naming_js_1.isArtifactRef)(ref))
continue; // not ours; leave foreign files alone.
const envelope = readEnvelope(path.join(dir, name));
if (envelope === undefined)
continue;
if (envelope.meta.expiresAt !== undefined && envelope.meta.expiresAt <= at) {
removeArtifactFiles(scope, ref);
continue;
}
metas.push(envelope.meta);
}
return metas;
};
return {
async put(scope, input) {
const at = now();
const { meta } = await (0, minting_js_1.prepareArtifact)(input, (parent) => liveEnvelope(scope, parent) !== undefined, retention, at);
const rows = scopeMetas(scope).map((held) => ({
ref: held.ref,
kind: held.kind,
bytes: held.bytes,
...(held.expiresAt !== undefined && { expiresAt: held.expiresAt }),
// A directory has no cheap read-recency; oldest-first is the stated law.
lastAccessedAt: held.createdAt,
}));
const plan = (0, retention_js_1.planRetention)(rows, meta.bytes, retention, at);
if (plan.refusal !== undefined)
throw new types_js_1.InvalidArtifactError(plan.refusal);
for (const swept of plan.swept)
removeArtifactFiles(scope, swept.ref);
const envelope = {
format: FILE_FORMAT,
meta,
payload: (0, payload_js_1.encodeArtifactData)(input.data),
};
fs.mkdirSync(scopeDir(scope), { recursive: true });
fs.writeFileSync(fileFor(scope, meta.ref), JSON.stringify(envelope));
return { meta, swept: plan.swept };
},
// eslint-disable-next-line @typescript-eslint/require-await
async head(scope, ref) {
return liveEnvelope(scope, ref)?.meta ?? null;
},
async get(scope, ref) {
const envelope = liveEnvelope(scope, ref);
if (envelope === undefined)
return null;
// A STREAMED payload lives beside the envelope: this adapter is the one
// that knows where, so it reads the bytes rather than asking the shared
// decoder to reach a file it cannot see.
const data = envelope.payload.shape === 'external'
? readPayloadFile(scope, envelope.meta.ref)
: (0, payload_js_1.decodeArtifactData)(envelope.payload);
if (data === undefined)
return null; // the sibling bytes are gone
if (envelope.meta.digest !== undefined) {
const actual = await (0, payload_js_1.computeArtifactDigest)(data);
if (actual !== envelope.meta.digest) {
throw new types_js_1.ArtifactIntegrityError(envelope.meta.ref, envelope.meta.digest, actual);
}
}
return { meta: envelope.meta, data };
},
// eslint-disable-next-line @typescript-eslint/require-await
async delete(scope, ref) {
if (!(0, naming_js_1.isArtifactRef)(ref))
return;
removeArtifactFiles(scope, ref);
},
// eslint-disable-next-line @typescript-eslint/require-await
async list(scope, listOptions) {
const all = scopeMetas(scope).sort((a, b) => b.createdAt - a.createdAt || (a.ref < b.ref ? -1 : 1));
const offset = decodeCursor(listOptions?.cursor);
const limit = Math.max(1, Math.floor(listOptions?.limit ?? 50));
const page = all.slice(offset, offset + limit);
const nextOffset = offset + page.length;
return {
artifacts: page,
...(nextOffset < all.length && { cursor: String(nextOffset) }),
};
},
/**
* The streamed put: bytes to `<ref>.bin` as they arrive, envelope LAST.
*
* The declared `bytes` is what retention plans against, and it is
* VERIFIED as the stream ends — a producer that said 10 MB and sent 11 is
* refused by name and leaves nothing behind, because a ticket that
* misdescribes its own payload is the accepted-and-silently-wrong failure
* this whole folder exists to prevent.
*/
async putStream(scope, input, body) {
const at = now();
const declared = (0, streaming_js_1.assertStreamBytes)('fileArtifacts', input.bytes);
const { meta } = await (0, minting_js_1.prepareArtifact)({ ...input, data: new Uint8Array(0) }, (parent) => liveEnvelope(scope, parent) !== undefined, retention, at);
const stated = { ...meta, bytes: declared };
const rows = scopeMetas(scope).map((held) => ({
ref: held.ref,
kind: held.kind,
bytes: held.bytes,
...(held.expiresAt !== undefined && { expiresAt: held.expiresAt }),
lastAccessedAt: held.createdAt,
}));
const plan = (0, retention_js_1.planRetention)(rows, declared, retention, at);
if (plan.refusal !== undefined)
throw new types_js_1.InvalidArtifactError(plan.refusal);
for (const swept of plan.swept)
removeArtifactFiles(scope, swept.ref);
fs.mkdirSync(scopeDir(scope), { recursive: true });
const payloadFile = payloadFileFor(scope, stated.ref);
const handle = fs.openSync(payloadFile, 'w');
let written = 0;
try {
const reader = body.getReader();
try {
for (;;) {
const { done, value } = await reader.read();
if (done)
break;
if (value === undefined)
continue;
written += value.byteLength;
if (written > declared) {
await reader.cancel();
break;
}
fs.writeSync(handle, value);
}
}
finally {
reader.releaseLock();
}
}
finally {
fs.closeSync(handle);
}
if (written !== declared) {
fs.rmSync(payloadFile, { force: true });
throw new types_js_1.InvalidArtifactError(`fileArtifacts: the streamed payload is ${written} bytes but declared ${declared}. ` +
`Nothing was stored: a ticket whose \`bytes\` misdescribes its own payload would ` +
`be believed by every later consumer. State the exact byte length.`);
}
// The envelope LAST: a crash before this line leaves an orphan .bin that
// no ref names, never a ticket pointing at an unfinished payload.
const envelope = {
format: FILE_FORMAT,
meta: stated,
payload: { shape: 'external', value: `${stated.ref}.bin` },
};
fs.writeFileSync(fileFor(scope, stated.ref), JSON.stringify(envelope));
return { meta: stated, swept: plan.swept };
},
/** The streamed read: the payload's canonical bytes, straight off disk
* for a streamed artifact, and as one chunk for an inline one (this
* store held it whole — it says so rather than pretending to be lazy). */
// eslint-disable-next-line @typescript-eslint/require-await
async getStream(scope, ref) {
const envelope = liveEnvelope(scope, ref);
if (envelope === undefined)
return null;
if (envelope.payload.shape === 'external') {
const file = payloadFileFor(scope, envelope.meta.ref);
if (!fs.existsSync(file))
return null;
const stream = (0, lazyRequire_js_1.lazyRequire)('node:stream');
return {
meta: envelope.meta,
body: stream.Readable.toWeb(fs.createReadStream(file)),
};
}
return {
meta: envelope.meta,
body: (0, streaming_js_1.bytesAsStream)((0, payload_js_1.canonicalPayloadBytes)((0, payload_js_1.decodeArtifactData)(envelope.payload))),
};
},
};
/** The bytes of a streamed payload, or undefined when the sibling file is
* gone (an interrupted write, an external deletion) — which is "no data",
* the same answer a missing envelope gives. */
function readPayloadFile(scope, ref) {
try {
return new Uint8Array(fs.readFileSync(payloadFileFor(scope, ref)));
}
catch {
return undefined;
}
}
}
exports.fileArtifacts = fileArtifacts;
function decodeCursor(cursor) {
if (cursor === undefined)
return 0;
const parsed = Number(cursor);
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 0;
}
//# sourceMappingURL=fileArtifacts.js.map