/** * Shared attachment-ingestion policy — the SINGLE source of truth all three * harnesses (Claude Agent SDK, Codex app-server, PI) consult when turning an * AgentAttachment into provider content. Centralizing it here is what makes the * three harnesses behave consistently instead of drifting (different text * allowlists, different size caps, different "saved to disk" wording, etc.). * * Routing rule (per the canonical attachment contract): * • image/* → native image content block (vision) * • application/pdf → native document block WHERE the provider supports it * (Claude always; PI-anthropic; PI-gemini). Otherwise the * file is on disk and the model reads it via its file tools. * • text-like → decode + cap + inline as a text note * • everything else → a disk-pointer note (the agent opens it with Read/Bash) * * The constraint on what is ingested is the MODEL's capability, never an arbitrary * blocklist of ours — anything that cannot be inlined is still saved to disk and * surfaced to the agent's file tools, so nothing is silently dropped. */ import type { SavedFile } from '../file-saver.js'; /** Per-file / cross-file budgets for INLINING text-like documents as model-visible text. * Both expressed in characters of decoded text so all harnesses agree on the unit * (Codex previously capped on bytes, PI on chars — they diverged). The file is also * on disk, so truncating the inline copy is lossless for a tool-capable agent. */ export const INLINE_TEXT_PER_FILE_CHARS = 48_000; export const INLINE_TEXT_TOTAL_CHARS = 96_000; /** Hard ceiling on a single inlined base64 image (decoded bytes). Over this we drop the * inline image and fall back to the saved-files disk pointer rather than bloat every * stateless resend with a multi-MB payload. */ export const MAX_INLINE_IMAGE_BYTES = 5 * 1024 * 1024; /** Media types whose bytes are safe/useful to decode and inline as plain text. * Anchored, union of the lists Codex and PI carried separately. */ const INLINE_TEXT_RE = /^(?:text\/[\w.+-]+|application\/(?:json|xml|x-ndjson|ld\+json|yaml|x-yaml|toml|x-sh|javascript|ecmascript|x-www-form-urlencoded|csv))$/i; /** Image media types we will hand to a provider as a native image block. */ const INLINE_IMAGE_RE = /^image\/(?:png|jpe?g|gif|webp|avif|bmp|heic|heif)$/i; export function isInlineTextMediaType(mt?: string): boolean { if (!mt) return false; const base = mt.split(';')[0].trim().toLowerCase(); return INLINE_TEXT_RE.test(base); } export function isInlinePdf(mt?: string): boolean { if (!mt) return false; return mt.split(';')[0].trim().toLowerCase() === 'application/pdf'; } export function isImageMediaType(mt?: string): boolean { if (!mt) return false; return INLINE_IMAGE_RE.test(mt.split(';')[0].trim().toLowerCase()); } /** Coerce an image media type to one every provider accepts. Unknown/garbage * (undefined, "", "application/octet-stream", "img/png", …) → image/jpeg so we * never emit `data:undefined;base64,` or a provider-rejected document type. */ export function normalizeImageMediaType(mt?: string): string { if (!mt) return 'image/jpeg'; const base = mt.split(';')[0].trim().toLowerCase(); if (base === 'image/jpg') return 'image/jpeg'; return INLINE_IMAGE_RE.test(base) ? base : 'image/jpeg'; } /** Approximate decoded byte length of a base64 string without allocating a Buffer. */ export function approxBase64Bytes(data: string): number { if (!data) return 0; const len = data.length; let padding = 0; if (data.endsWith('==')) padding = 2; else if (data.endsWith('=')) padding = 1; return Math.max(0, Math.floor((len * 3) / 4) - padding); } export type AttachmentRoute = 'image' | 'native-document' | 'inline-text' | 'reference-only'; /** * Decide how a non-image / document attachment should reach the model, given the * active provider's native-document capability. * - canNativeDocument: true for Claude, PI-anthropic, PI-gemini (PDF via document block). */ export function routeAttachment( att: { type: 'image' | 'file'; mediaType: string; data?: string }, opts: { canNativeDocument: boolean }, ): AttachmentRoute { // An empty/undefined payload must never become an inline provider block: a // `data:''`/empty base64 image or document source 400s the entire turn on // Anthropic/Gemini. Degrade to reference-only (there's no SavedFile for it // either, so it's simply skipped). Mirrors Codex's `if (!att.data) break;`. if (!att.data) return 'reference-only'; if (att.type === 'image' || isImageMediaType(att.mediaType)) return 'image'; if (isInlinePdf(att.mediaType) && opts.canNativeDocument) return 'native-document'; if (isInlineTextMediaType(att.mediaType)) return 'inline-text'; return 'reference-only'; } /** * The ONE canonical "files are on disk, read them with your tools" note. Every * harness emits byte-identical wording so the agent's behavior doesn't depend on * which provider is active. Cites the ABSOLUTE path because the persisted relPath * (`/`) omits the `files/` segment and isn't openable as-is. */ export function buildSavedFilesNote(savedFiles: SavedFile[]): string { if (!savedFiles?.length) return ''; const lines = savedFiles.map((f) => `- ${f.name || f.relPath} (${f.mediaType}) → ${f.absPath}`); return `[attached files saved to disk — open them with your file tools (Read/Bash) if you need their contents]\n${lines.join('\n')}`; }