agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
150 lines • 8.98 kB
TypeScript
/**
* artifacts/s3Artifacts — the claim-check store in an S3 bucket.
*
* The third rung of the same ladder: `inMemoryArtifacts` for a test,
* `fileArtifacts`/`sqliteArtifacts` for one machine, this for a fleet. Nothing
* about the five verbs changes — a tool that stored a dataset against the
* in-memory store stores it here by swapping one constructor, because the port
* is the contract and this file is only a translation of it.
*
* Read `objectStore.ts` first: it holds the five laws both object adapters
* obey (payload as canonical bytes in the body, ONE metadata entry carrying
* the ticket, a checked metadata budget, 404-means-null-everything-else-means-
* error, service-time ordering). What is genuinely S3's, and therefore lives
* here:
*
* ── The object key ──────────────────────────────────────────────────────────
* `[<prefix>/]<tenant>/<principal>/<conversation>/<ref>` — the scope
* partitioned into key segments by `scopePath.ts`, the same percent-encoding
* law the directory adapter uses, so a tenant of literally `'..'` is a NAME in
* both. A ref alone opens nothing: a wrong scope computes a different key,
* S3 answers 404, and the caller reads `null` — one indistinguishable miss,
* never a cross-tenant read.
*
* ── The ticket rides as object metadata, not a sidecar ──────────────────────
* `x-amz-meta-af-artifact`, one entry, ASCII JSON. Two reasons this beats a
* second `<ref>.meta.json` object: `head()` becomes ONE HeadObject (the port
* promises the render-by-ref decision without paying for the payload, and a
* sidecar would make it a second GET), and `get()` returns the ticket AND the
* bytes in a single GetObject — a sidecar would make every read two round
* trips and open a window where one exists and the other does not.
*
* The cost is S3's cap: **2 KB of user metadata per object**, checked at put
* and refused by name. A ticket is small (a ref, a kind, a media type, sizes,
* a label); it only reaches 2 KB if the label is prose or `parentRefs` is a
* bibliography, and the refusal says which.
*
* ── What a 404 is allowed to mean here ──────────────────────────────────────
* S3 answers 404 for two different sentences: "no such key" and "no such
* bucket". Only the first is the port's "no data", so this adapter reads the
* CODE and not the status, and only the calls that named one object —
* Head/Get/Delete — may read a 404 as `null` at all. A 404 from PutObject or
* ListObjectsV2 means the bucket is not there; it goes through the sanitizer
* like every other failure, because nothing downstream is waiting to turn it
* into `null` and the SDK's text for it contains the object key.
*
* ── What a listing costs, said plainly ──────────────────────────────────────
* `ListObjectsV2` returns keys, sizes and `LastModified` — never user
* metadata. So `list()` pages the keys (cheap, 1000 per call), sorts by
* `LastModified` (newest first, as the port promises), and issues one
* `HeadObject` for each row IT RETURNS — not for the whole scope. An expired
* object inside a page is dropped from that page rather than backfilled, so a
* page can come back short while more pages remain. That is the honest trade;
* an "index object" listing every ticket in a scope would be cheaper and
* would be a lost-update race between two writers, which is worse than a
* round trip.
*
* ── Retention, and the operator's bulk tool ─────────────────────────────────
* The dials work exactly as everywhere else: `ttlMs` stamps `expiresAt` AT
* MINT (stated, never sprung), and budgets evict oldest-first (S3 has no cheap
* read-recency, the same statement `fileArtifacts` makes). Two S3-specific
* facts:
*
* • **Expiry is enforced on READ.** An expired object answers `null` from
* `head`/`get` and is deleted on the way past (a sweep on access), so an
* expired ticket can never be redeemed even if the object is still there.
* • **A put only SCANS the scope when a budget is configured.** With no
* `maxBytesPerScope`/`maxCountPerScope` there is nothing to plan, so a put
* is exactly one `PutObject` — and paying for a full scope listing on
* every write would be a real bill for no answer.
*
* Which leaves reclamation of storage for artifacts nobody reads again — and
* that is what **S3 Lifecycle rules** are for. They are the operator's bulk
* tool and this adapter does not create them (a lifecycle rule is a cost and
* compliance decision that belongs to your infrastructure). Align them like
* this:
*
* ```jsonc
* // Expire objects 7 days after creation, under the store's prefix.
* // Keep the rule LONGER than the store's ttlMs, never shorter:
* // the store's expiresAt is the promise consumers read on the ticket, and a
* // lifecycle rule that deletes first makes a live ticket resolve to null
* // BEFORE the time it printed. Longer, and lifecycle is what it should be:
* // the backstop that reclaims what the store's own sweep never revisited.
* { "Rules": [{ "ID": "agentfootprint-artifacts", "Status": "Enabled",
* "Filter": { "Prefix": "artifacts/" },
* "Expiration": { "Days": 7 } }] }
* ```
*
* ── Lazy peer dependency ────────────────────────────────────────────────────
* `@aws-sdk/client-s3` is an OPTIONAL peer dependency, required at
* CONSTRUCTION (the sqliteSessions law): importing the barrel costs a browser
* bundle nothing, and a missing install refuses where the config was written
* rather than at the first put of the first run. Pass `client` to share the
* SDK configuration your app already has.
*
* ── Status ──────────────────────────────────────────────────────────────────
* Contract-shaped and tested; awaiting field use. The five commands it
* dispatches are pinned by `test/adapters/aws/aws-command-pin.test.ts`; the
* behavior is proved against an emulation double that speaks the same five.
* No live call has been made from this repository.
*/
import { type ArtifactRetention } from './retention.js';
import { type ArtifactStore } from './types.js';
/** A `*Client` as this adapter uses it: `send`, and nothing else. */
export interface S3ArtifactsClientLike {
send(command: unknown): Promise<unknown>;
}
/** The five command constructors this adapter dispatches. */
export interface S3ArtifactsSdkModule {
readonly S3Client?: new (config: {
region?: string;
}) => S3ArtifactsClientLike;
readonly PutObjectCommand?: new (input: unknown) => unknown;
readonly GetObjectCommand?: new (input: unknown) => unknown;
readonly HeadObjectCommand?: new (input: unknown) => unknown;
readonly DeleteObjectCommand?: new (input: unknown) => unknown;
readonly ListObjectsV2Command?: new (input: unknown) => unknown;
}
/** Options for {@link s3Artifacts}. */
export interface S3ArtifactsOptions {
/** The bucket. It must already exist — this library never creates one. */
readonly bucket: string;
/** Key prefix inside the bucket, so a bucket can be shared. The scope
* layout starts under it. Absent = at the root of the bucket. */
readonly prefix?: string;
/** Region for the client this factory builds. Ignored when `client` is
* passed — that client's configuration is yours. */
readonly region?: string;
/** Your own pre-built client; configuration and credentials stay yours. */
readonly client?: S3ArtifactsClientLike;
/** Retention dials. Budgets evict OLDEST-first here: S3 has no cheap
* read-recency, the same statement `fileArtifacts` makes about a
* directory. */
readonly retention?: ArtifactRetention;
/** @internal Test seam — the SDK module, injected. */
readonly _sdk?: S3ArtifactsSdkModule;
/** @internal Test seam — a client injected past the SDK entirely. */
readonly _client?: S3ArtifactsClientLike;
/** @internal Test seam — the clock. Defaults to `Date.now`. */
readonly _now?: () => number;
}
/**
* An artifact store in an S3 bucket.
*
* @example
* const store = s3Artifacts({ bucket: 'my-agent-artifacts', prefix: 'artifacts' });
* const agent = Agent.create({ provider, artifacts: store });
*/
export declare function s3Artifacts(options: S3ArtifactsOptions): ArtifactStore;
//# sourceMappingURL=s3Artifacts.d.ts.map