UNPKG

agentfootprint

Version:

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

352 lines • 17 kB
/** * 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. */ import { lazyRequire } from '../lib/lazyRequire.js'; import { prepareArtifact } from './minting.js'; import { isArtifactRef } from './naming.js'; import { canonicalPayloadBytes, computeArtifactDigest, decodeArtifactData, encodeArtifactData, } from './payload.js'; import { assertRetention, planRetention, } from './retention.js'; import { scopeSegment } from './scopePath.js'; import { assertStreamBytes, bytesAsStream } from './streaming.js'; import { ArtifactIntegrityError, InvalidArtifactError, } from './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.) */ export 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; } } /** * 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 }); */ export 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.`); } assertRetention('fileArtifacts', retention); const now = options._now ?? Date.now; const fs = lazyRequire('node:fs'); const path = 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, scopeSegment(scope.tenant), scopeSegment(scope.principal), 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 (!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 (!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 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 = planRetention(rows, meta.bytes, retention, at); if (plan.refusal !== undefined) throw new InvalidArtifactError(plan.refusal); for (const swept of plan.swept) removeArtifactFiles(scope, swept.ref); const envelope = { format: FILE_FORMAT, meta, payload: 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) : decodeArtifactData(envelope.payload); if (data === undefined) return null; // the sibling bytes are gone if (envelope.meta.digest !== undefined) { const actual = await computeArtifactDigest(data); if (actual !== envelope.meta.digest) { throw new 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 (!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 = assertStreamBytes('fileArtifacts', input.bytes); const { meta } = await 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 = planRetention(rows, declared, retention, at); if (plan.refusal !== undefined) throw new 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 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 = lazyRequire('node:stream'); return { meta: envelope.meta, body: stream.Readable.toWeb(fs.createReadStream(file)), }; } return { meta: envelope.meta, body: bytesAsStream(canonicalPayloadBytes(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; } } } 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