UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

123 lines (122 loc) • 7.52 kB
/** * 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;