@sema-agent/core
Version:
Stateless, task-oriented AI agent core
123 lines (122 loc) • 7.52 kB
TypeScript
/**
* The ONE admission an image's bytes pass before they reach the model as an `ImageContent` block —
* whether a file supplied them (the Read tool's image lane), a caller did (`TaskSpec.images`' inline
* arm, a user attachment) or a tool returned them (the tool-result seat below). Four steps, in this order:
*
* ① the claimed type is one the content API accepts (png / jpeg / gif / webp — the four
* {@link imageMagicMatches} can verify);
* ② the raw bytes are within the read cap (5 MiB without a downsampler, 64 MiB with one);
* ③ the bytes carry the claimed type's signature — an empty / text / mis-named "png" would
* otherwise become a malformed block that 400s the NEXT whole provider request;
* ④ optional downsample (when a downsampler is supplied; a processing failure keeps the original
* bytes, CC's catch → createImageResponse(original)), then the provider's hard limit, which is on the
* BASE64 length, not raw bytes.
*
* The answer is structured; each carrier words its own refusal (the Read tool speaks to the model, the
* task door to the caller). The block it admits is built from the bytes it checked — never from a
* caller's string it did not decode. A document's bytes pass the same shape of admission
* ({@link admitDocumentBytes}: `application/pdf`, the whole-send cap, the `%PDF-` signature).
*
* THE TOOL-RESULT SEAT ({@link admitToolResultMedia}): every executed call's result passes the loop's one
* post-execution half (`finalizeToolOutcome`) — an engine tool's, an MCP tool's converted content, a host
* tool's own return, a resumed approved call's, and whatever a `tool_result` hook put in their place — so
* the media blocks of every supply are admitted there, once, after the last hand that can change them and
* before the frame and the transcript read them. A block whose bytes the admission refuses becomes a text
* block that names the tool, the refusal and the size — never the bytes, and never silently nothing.
*/
import type { DocumentContent, ImageContent } from "../internal/llm.js";
import { type ImageDimensions, type ImageDownsampler } from "./image-downsample.js";
import { type MediaBlock } from "./media-byte-cap.js";
/** Byte cap on a Read-image WITHOUT a downsampler: an image file is returned AS a visual
* `ImageContent` block (base64) rather than refused. Bounded so a huge image cannot blow the request.
* This stat-time cap only bounds the READ; the accept/refuse decision is the base64 gate below (the
* provider limit is on base64 LENGTH, so the effective no-sharp accept bound is raw ≤ 3.75MB). */
export declare const MAX_IMAGE_READ_BYTES: number;
/** Byte cap on a Read-image WITH a downsampler: sharp can shrink a big photo to CC's 2000×2000/3.75MB
* target, so the smaller hard refusal does not apply (CC readImageWithTokenBudget reads the file
* unbounded; we keep an OOM sanity bound — deliberate delta, camera photos fit comfortably). */
export declare const MAX_IMAGE_DOWNSAMPLE_INPUT_BYTES: number;
/** Max raw size sent whole as a document block (CC `PDF_TARGET_RAW_SIZE`). */
export declare const PDF_TARGET_RAW_SIZE: number;
/** Verify the raw bytes actually ARE the image format the extension claims (magic-number sniff). Extension alone
* is forgeable: a `.png` holding text / an empty file / a truncated blob would otherwise become a malformed
* `ImageContent` that makes the NEXT provider request fail (400). Pure — no I/O. */
export declare function imageMagicMatches(bytes: Uint8Array, mimeType: string): boolean;
/** True when the bytes start with the `%PDF-` magic. Magic outranks extension both ways: a mis-named
* `.pdf` without it is refused, and a non-`.pdf` file carrying it is read AS a PDF. */
export declare function pdfMagicMatches(bytes: Uint8Array): boolean;
/** The image types the content API accepts — the only ones a signature check can verify. */
export declare const ADMITTED_IMAGE_MIME_TYPES: readonly ["image/png", "image/jpeg", "image/gif", "image/webp"];
/** The raw-byte read cap for an image, with or without a downsampler. */
export declare function imageReadCap(downsampler: ImageDownsampler | undefined): number;
export type ImageAdmission = {
ok: true;
block: ImageContent;
/** The raw byte count of the bytes checked (before any downsample). */
originalSize: number;
/** Measured by the downsampler; absent without one (never fabricated). */
dimensions?: ImageDimensions;
/** True when the downsampler actually changed the pixel size. */
resized: boolean;
} | {
ok: false;
reason: "mime_unsupported" | "magic_mismatch";
rawBytes: number;
} | {
ok: false;
reason: "raw_too_large";
rawBytes: number;
cap: number;
}
/** `base64` is the encoding that failed the limit (the caller words the refusal from its length). */
| {
ok: false;
reason: "base64_too_large";
rawBytes: number;
base64: string;
};
export declare function admitImageBytes(bytes: Uint8Array, claimedMime: string, downsampler: ImageDownsampler | undefined): Promise<ImageAdmission>;
/** The document types the content API accepts — the one a signature check can verify. */
export declare const ADMITTED_DOCUMENT_MIME_TYPES: readonly ["application/pdf"];
export type DocumentAdmission = {
ok: true;
block: DocumentContent;
originalSize: number;
} | {
ok: false;
reason: "mime_unsupported" | "magic_mismatch";
rawBytes: number;
} | {
ok: false;
reason: "raw_too_large";
rawBytes: number;
cap: number;
};
/** A document's bytes: the claimed type is `application/pdf`, the raw bytes are within the whole-send cap
* ({@link PDF_TARGET_RAW_SIZE}, the Read tool's), and they start with `%PDF-`. */
export declare function admitDocumentBytes(bytes: Uint8Array, claimedMime: string): DocumentAdmission;
/** Why the tool-result seat refused a media block: the admissions' own words (an image's four — a document's are among
* them), or `undecodable` — the block carried no base64 string to decode. */
export type MediaRefusalReason = "undecodable" | "mime_unsupported" | "raw_too_large" | "magic_mismatch" | "base64_too_large";
/** One media block the seat refused: its type, the refusal, its decoded size, the bound it exceeded (the raw-byte cap for
* `raw_too_large`, the base64 length limit for `base64_too_large`), and its media type when it can be one. */
export interface ToolResultMediaRefusal {
type: MediaBlock["type"];
reason: MediaRefusalReason;
bytes: number;
limit?: number;
mimeType?: string;
}
/** What the seat made of one result's content: every media block rebuilt from its checked bytes or replaced, in its slot,
* by its placeholder; and the refusals. */
export interface ToolResultMediaAdmission {
content: unknown[];
refused: ToolResultMediaRefusal[];
}
/**
* The tool-result seat's admission over one executed call's content: `undefined` when no block claims a media type (the
* content stands as it is, and nothing is awaited); otherwise every block is read once into a new array, each media
* block admitted — rebuilt from the bytes it checked, or replaced in its slot by a placeholder naming `toolName` and the
* refusal. Total: a content whose blocks cannot be read is not media this seat can admit or refuse, and is left as it is.
*/
export declare function admitToolResultMedia(content: unknown, toolName: string): Promise<ToolResultMediaAdmission> | undefined;