agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
152 lines • 7.59 kB
JavaScript
/**
* artifacts/streaming — the OPTIONAL leg of the port, and how to ask for it.
*
* A claim check exists so a 6 MB result never rides the conversation. It does
* not follow that the 6 MB should ride the PROCESS: a store that can accept
* bytes as they arrive, and hand them back the same way, lets a report be
* written to a bucket without ever existing whole in memory. That is what
* `putStream` / `getStream` are for.
*
* ── Why OPTIONAL members and not two more verbs ─────────────────────────────
* Not every backend can honor the promise, and the ones that cannot must say
* so by ABSENCE rather than by faking it. A `Map`-backed store "streaming"
* bytes it already holds whole would be theatre: the memory was already spent,
* and a consumer that chose it to bound memory would have been told a
* falsehood in the shape of a capability. So:
*
* • `fileArtifacts` — YES. The bytes go to a sibling file as they arrive.
* • object stores — YES, through the SDK's own streaming upload/download.
* • `sqliteArtifacts`— NO. `node:sqlite` reads and writes a BLOB whole;
* there is no incremental blob I/O to build on, so a
* stream here would buffer everything and call it a
* stream.
* • `inMemoryArtifacts` — NO, by design. It holds payloads whole, under a
* byte budget; streaming into it would defeat both.
*
* ── Web streams, one shape ──────────────────────────────────────────────────
* `ReadableStream<Uint8Array>` — the shape that exists in every supported Node
* AND in a browser, so the port stays the same object everywhere the rest of
* this folder already works (the Buffer-free base64 law in `payload.ts`, for
* the same reason). Node-native adapters bridge at their own edge with
* `Readable.toWeb` / `Readable.fromWeb`; that bridge is an ADAPTER detail and
* never leaks into the port.
*
* ── What streaming COSTS, named so it cannot be assumed away ────────────────
* `get` is the VERIFYING read: when the meta carries a `digest`, the store
* re-hashes what it holds and throws `ArtifactIntegrityError` rather than hand
* back bytes that no longer match their ticket. **`getStream` does not, and
* cannot**: the digest is computed over the WHOLE canonical payload with the
* one primitive every adapter shares (`crypto.subtle.digest`, one-shot by
* design and identical in Node and a browser), so verifying a stream would mean
* buffering the payload — the exact cost the member exists to avoid. Faking it
* with a second, incremental hashing implementation would be a different
* promise wearing the same field name.
*
* So the trade is STATED rather than silently made, in all three places a
* caller meets it (the port's `getStream`, the guard below, and this header):
*
* • `get(scope, ref)` — integrity verified, payload held whole.
* • `getStream(scope, ref)` — memory bounded, integrity NOT verified. The
* `digest` still rides `meta`, so a caller who needs the guarantee on this
* path can hash what it collected and compare — deliberately the caller's
* move, because only the caller knows whether it can afford to hold the
* bytes it just streamed.
*
* ── What `ctx.artifacts` does NOT get, and why (a decision, not an omission) ─
* The tool-facing capability keeps FIVE verbs this phase. A tool receives its
* artifacts pre-scoped and answers a model; the model cannot hold a stream,
* and a tool that genuinely moves gigabytes wants the store itself, with its
* own lifecycle and its own error handling. So a tool that needs streaming
* holds the store (it already constructed it) and calls it directly — while
* `ctx.artifacts` stays the small, safe, scope-bound surface a model-driven
* tool can be trusted with.
*/
import { InvalidArtifactError } from './types.js';
/**
* Can this store take a streamed put? Narrowing type guard — the answer is
* the type, so a consumer branches once and the compiler carries it.
*
* @example
* if (canPutArtifactStream(store)) await store.putStream(scope, input, body);
* else await store.put(scope, { ...input, data: await collect(body) });
*/
export function canPutArtifactStream(store) {
return typeof store.putStream === 'function';
}
/**
* Can this store hand back a stream? Narrowing type guard.
*
* Note what the streamed read does NOT carry: `get` re-verifies a `digest`
* before returning, `getStream` cannot (see the header) — it bounds memory,
* not integrity. Branching to `getStream` for a digested artifact is a
* deliberate trade, and the ticket keeps the digest so a caller who needs the
* guarantee can make it themselves.
*/
export function canGetArtifactStream(store) {
return typeof store.getStream === 'function';
}
/** Both halves at once — for a consumer that needs the round trip. */
export function canStreamArtifacts(store) {
return canPutArtifactStream(store) && canGetArtifactStream(store);
}
/**
* Read a stream to its bytes, refusing at a ceiling instead of growing until
* the process dies.
*
* Adapters that must hold the payload to store it (an object `put` of a
* declared length) use this, and so does any consumer bridging a stream into
* `put`. The ceiling is the caller's declared `bytes`: a producer that says
* 10 MB and sends 11 is refused rather than trusted, because the meta it
* would otherwise mint describes a payload that does not exist.
*/
export async function collectStream(body, ceiling) {
const reader = body.getReader();
const chunks = [];
let total = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done)
break;
if (value === undefined)
continue;
total += value.byteLength;
if (total > ceiling) {
await reader.cancel();
return { bytes: new Uint8Array(0), overflowed: true };
}
chunks.push(value);
}
}
finally {
reader.releaseLock();
}
const out = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
out.set(chunk, offset);
offset += chunk.byteLength;
}
return { bytes: out, overflowed: false };
}
/** One chunk of bytes as a web stream — how a store that HOLDS the payload
* answers `getStream` honestly (it never claimed to be lazy; it is handing
* over what it has, in the port's shape). */
export function bytesAsStream(bytes) {
return new ReadableStream({
start(controller) {
controller.enqueue(bytes);
controller.close();
},
});
}
/** The stated `bytes` on a streamed put, checked before anything is stored. */
export function assertStreamBytes(adapter, bytes) {
if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes < 0) {
throw new InvalidArtifactError(`${adapter}: putStream needs a stated \`bytes\` (got ${JSON.stringify(bytes)}). A stream ` +
`has no length until it ends, and both the retention plan and the upload need the size ` +
`BEFORE the first chunk. State the exact byte length, or use put() with the payload.`);
}
return Math.floor(bytes);
}
//# sourceMappingURL=streaming.js.map