feat: keep image-heavy sessions within provider request-size limits (#1508)

* feat(kosong): classify HTTP 413 request-body-too-large as a dedicated error type

* feat(agent-core): lower default image downscale cap to 2000px and make it configurable

* feat(agent-core): strip media to text markers and retry when the compaction request is too large

* feat(agent-core): cap model-initiated image reads with a configurable byte budget

* feat(agent-core): resend with degraded media when the provider rejects the request body as too large

* test(agent-core): add explicit timeouts to encode-heavy image budget tests

* feat: add WebP decoding support with wasm integration

- Introduced a new WebP decoding module using @jsquash/webp's wasm decoder.
- Implemented functions to decode WebP images and check for animated WebP formats.
- Updated image compression tests to include scenarios for WebP handling, including encoding and decoding.
- Enhanced error handling for API request size limits to accommodate various error messages.
- Updated pnpm lockfile to include new dependencies for WebP encoding and decoding.

* chore(changeset): consolidate this PR's entries into one

* fix(nix): update pnpmDeps hash for merged lockfile

* feat(agent-core): refuse HEIC/HEIF reads with platform-matched conversion guidance
This commit is contained in:
Kai 2026-07-09 18:05:14 +08:00 committed by GitHub
parent b91099ed7a
commit 1bf2c9afee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1839 additions and 215 deletions

View file

@ -0,0 +1,6 @@
---
"@moonshot-ai/kimi-code": patch
---
Keep image-heavy sessions within provider request-size limits: model-read images now honor a 256 KB per-image budget and a 2000px downscale cap (configurable via `[image]` in config.toml or `KIMI_IMAGE_*` env vars), oversized WebP is compressed as well, HEIC/HEIF reads are refused with a platform-matched conversion command instead of poisoning the session, and a request-too-large rejection (HTTP 413) now recovers automatically — the request and /compact both retry with older media replaced by text markers instead of failing the session.

View file

@ -141,14 +141,14 @@ describe('clipboard image paste compression', () => {
if (att?.kind !== 'image') throw new Error('expected image attachment');
// Stored metadata reflects the compressed size.
expect(Math.max(att.width, att.height)).toBeLessThanOrEqual(3000);
expect(att.placeholder).toContain('3000×1500');
expect(Math.max(att.width, att.height)).toBeLessThanOrEqual(2000);
expect(att.placeholder).toContain('2000×1000');
// The stored bytes decode to the compressed dimensions — the thumbnail and
// the submitted image both read from these bytes, so they cannot diverge.
const dims = parseImageMeta(att.bytes);
expect(dims).not.toBeNull();
expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(3000);
expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(2000);
});
it('records and persists the pre-compression original for an oversized paste', async () => {

View file

@ -87,11 +87,12 @@ Fields in the config file fall into two categories: **top-level scalars** that d
| `thinking` | `table` | — | Default parameters for Thinking mode → [`thinking`](#thinking) |
| `loop_control` | `table` | — | Agent loop control parameters → [`loop_control`](#loop_control) |
| `background` | `table` | — | Background task runtime parameters → [`background`](#background) |
| `image` | `table` | — | Image compression parameters → [`image`](#image) |
| `services` | `table` | — | Built-in external service configuration → [`services`](#services) |
| `permission` | `table` | — | Initial permission rules → [`permission`](#permission) |
| `hooks` | `array<table>` | — | Lifecycle hooks; see [Hooks](../customization/hooks.md) |
The following sections cover each of the nested tables in turn: `providers`, `models`, `thinking`, `loop_control`, `background`, `services`, and `permission`.
The following sections cover each of the nested tables in turn: `providers`, `models`, `thinking`, `loop_control`, `background`, `image`, `services`, and `permission`.
## `providers`
@ -202,6 +203,17 @@ You can also switch models temporarily without touching the config file — by s
In print mode (`kimi -p "<prompt>"`), Kimi Code runs a single non-interactive turn and exits as soon as the main agent finishes. If you launch background tasks (for example, concurrent subagents via `Agent(run_in_background=true)`) and need them to run to completion, set `keep_alive_on_exit = true`: the process then waits for every background task to reach a terminal state before exiting, bounded by `print_wait_ceiling_s`. Without it, the single turn ending tears background tasks down with the process.
## `image`
`image` controls how images are compressed before being sent to the model, across every ingestion point (pasted images, `ReadMediaFile` reads, images in MCP tool results, and so on).
| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `max_edge_px` | `integer` | `2000` | Longest-edge ceiling in pixels. Larger images are scaled down proportionally to fit; raising it preserves more detail at the cost of larger request bodies |
| `read_byte_budget` | `integer` | `262144` (256 KB) | Per-image byte budget for images the model reads for itself (`ReadMediaFile` default reads). It bounds the accumulated request-body size when the model keeps screenshotting and reading images; fine detail stays reachable through the `region` parameter, which reads a crop back at full fidelity (`region` and `full_resolution` are not subject to this budget) |
`max_edge_px` can be overridden by the `KIMI_IMAGE_MAX_EDGE_PX` environment variable and `read_byte_budget` by `KIMI_IMAGE_READ_BYTE_BUDGET`; both take higher priority than `config.toml`.
<!--
## `experimental`

View file

@ -122,6 +122,8 @@ Switches that control the behavior of subsystems such as telemetry, background t
| --- | --- | --- |
| `KIMI_DISABLE_TELEMETRY` | Disable anonymous telemetry reporting | `1`, `true`, `yes`, `y` (case-insensitive) |
| `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | Whether to keep background tasks when the session closes; takes higher priority than `config.toml`. The default is to stop them on exit | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` |
| `KIMI_IMAGE_MAX_EDGE_PX` | Longest-edge ceiling (px) for image compression; takes higher priority than `[image] max_edge_px` in `config.toml` (default `2000`) | Positive integer; invalid values are ignored |
| `KIMI_IMAGE_READ_BYTE_BUDGET` | Per-image byte budget for model-initiated image reads (`ReadMediaFile` default reads); takes higher priority than `[image] read_byte_budget` in `config.toml` (default `262144`, i.e. 256 KB) | Positive integer; invalid values are ignored |
| `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | Override the plugin marketplace JSON loaded by `/plugins`; useful for dev loopback servers, staging CDN files, or alternate marketplace directories | `https://code.kimi.com/kimi-code/plugins/marketplace.json`; also accepts `http://`, `file://` URLs, and local paths |
| `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | Cap how many AgentSwarm subagents run concurrently during the initial ramp; leave unset for no cap | Positive integer; invalid values fail fast |
| `KIMI_CODE_EXPERIMENTAL_FLAG` | Enable all registered experimental features for this process | `1`, `true`, `yes`, `on` |

View file

@ -87,11 +87,12 @@ timeout = 5
| `thinking` | `table` | — | Thinking 模式默认参数 → [`thinking`](#thinking) |
| `loop_control` | `table` | — | Agent 循环控制参数 → [`loop_control`](#loop_control) |
| `background` | `table` | — | 后台任务运行参数 → [`background`](#background) |
| `image` | `table` | — | 图片压缩参数 → [`image`](#image) |
| `services` | `table` | — | 内置外部服务配置 → [`services`](#services) |
| `permission` | `table` | — | 初始权限规则 → [`permission`](#permission) |
| `hooks` | `array<table>` | — | 生命周期 hook详见 [Hooks](../customization/hooks.md) |
以下各节对 `providers``models``thinking``loop_control``background``services`、`permission` 等嵌套表逐一展开。
以下各节对 `providers``models``thinking``loop_control``background``image`、`services`、`permission` 等嵌套表逐一展开。
## `providers`
@ -202,6 +203,17 @@ display_name = "Kimi for Coding (custom)"
在 print 模式(`kimi -p "<prompt>"`Kimi Code 只跑一个非交互的单轮 turn主 agent 一结束就退出。如果你启动了后台任务(例如通过 `Agent(run_in_background=true)` 并发子代理)并希望它们跑完,请设置 `keep_alive_on_exit = true`:进程会在退出前等待所有后台任务进入终态,最长不超过 `print_wait_ceiling_s`。否则,单轮 turn 结束时后台任务会随进程一起被清理。
## `image`
`image` 控制图片发送给模型前的压缩行为,对所有图片入口生效(粘贴图片、`ReadMediaFile` 读图、MCP 工具结果里的图片等)。
| 字段 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `max_edge_px` | `integer` | `2000` | 图片最长边上限(像素)。超过时按比例缩小到该值以内;调大可保留更多细节,代价是更大的请求体积 |
| `read_byte_budget` | `integer` | `262144`256 KB | 模型自行读取的图片(`ReadMediaFile` 默认读取)的单图字节预算。会话中模型反复截图、读图时,累计请求体大小由它控制;细节可通过 `region` 参数按原图坐标全保真回读(`region``full_resolution` 不受此预算限制) |
`max_edge_px` 可被环境变量 `KIMI_IMAGE_MAX_EDGE_PX` 覆盖,`read_byte_budget` 可被 `KIMI_IMAGE_READ_BYTE_BUDGET` 覆盖,优先级均高于配置文件。
<!--
## `experimental`

View file

@ -122,6 +122,8 @@ kimi
| --- | --- | --- |
| `KIMI_DISABLE_TELEMETRY` | 关闭匿名遥测上报 | `1``true``yes``y`(不区分大小写) |
| `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | 会话关闭时是否保留后台任务,优先级高于 `config.toml`。默认会在退出时停止后台任务 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` |
| `KIMI_IMAGE_MAX_EDGE_PX` | 图片压缩的最长边上限(像素),优先级高于 `config.toml``[image] max_edge_px`(默认 `2000` | 正整数;非法值被忽略 |
| `KIMI_IMAGE_READ_BYTE_BUDGET` | 模型自行读图(`ReadMediaFile` 默认读取)的单图字节预算,优先级高于 `config.toml``[image] read_byte_budget`(默认 `262144`,即 256 KB | 正整数;非法值被忽略 |
| `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | 覆盖 `/plugins` 加载的 plugin marketplace JSON适合 dev loopback server、测试 CDN 文件或替换 marketplace 目录 | `https://code.kimi.com/kimi-code/plugins/marketplace.json`;也接受 `http://``file://` URL 和本地路径 |
| `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | 限制 AgentSwarm 初始提升并发阶段可同时运行的子 Agent 数量;不设置表示不限制 | 正整数;非法值会立即失败 |
| `KIMI_CODE_EXPERIMENTAL_FLAG` | 在当前进程启用所有已注册的实验功能 | `1``true``yes``on` |

View file

@ -152,7 +152,7 @@
inherit (finalAttrs) pname version src pnpmWorkspaces;
inherit pnpm;
fetcherVersion = 3;
hash = "sha256-hUn5Srn3HnEEzU5DLxgjIzFjI0ukM3iSP4QagftEXdE=";
hash = "sha256-i3T30dxNQ7DTWOmUX47kmqpIVtDYby0buPo58+Jhhmw=";
};
nativeBuildInputs = [

View file

@ -59,6 +59,7 @@
},
"dependencies": {
"@antfu/utils": "^9.3.0",
"@jsquash/webp": "^1.5.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"@moonshot-ai/kaos": "workspace:^",
"@moonshot-ai/kimi-code-oauth": "workspace:^",

View file

@ -0,0 +1,36 @@
/**
* Regenerate `src/tools/support/webp-dec-wasm.ts` from the installed
* `@jsquash/webp` package.
*
* The WebP decoder wasm is committed as a base64 string module because the
* published CLI bundles every dependency into a single file with no runtime
* node_modules a file-path lookup for the .wasm would break there, while a
* string constant survives every packaging (vitest on sources, tsdown
* bundling, nix builds) unchanged. Run this after bumping @jsquash/webp:
*
* node scripts/generate-webp-dec-wasm.mjs
*/
import { createRequire } from 'node:module';
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
const packageRoot = resolve(import.meta.dirname, '..');
const require = createRequire(resolve(packageRoot, 'package.json'));
const wasmPath = require.resolve('@jsquash/webp/codec/dec/webp_dec.wasm');
const version = require('@jsquash/webp/package.json').version;
const wasm = readFileSync(wasmPath);
const target = resolve(packageRoot, 'src/tools/support/webp-dec-wasm.ts');
writeFileSync(
target,
`// GENERATED FILE — do not edit by hand.
// WebP decoder wasm from @jsquash/webp@${version} (codec/dec/webp_dec.wasm),
// base64-encoded so the bundled CLI needs no on-disk wasm asset.
// Regenerate with: node scripts/generate-webp-dec-wasm.mjs
export const WEBP_DECODER_WASM_BASE64 =
'${wasm.toString('base64')}';
`,
);
console.log(`Wrote ${target} (${wasm.length} bytes of wasm)`);

View file

@ -8,10 +8,12 @@ import {
APIEmptyResponseError,
inputTotal,
isRetryableGenerateError,
type ContentPart,
type GenerateResult,
type Message,
type TokenUsage,
APIContextOverflowError,
APIRequestTooLargeError,
APIStatusError,
createUserMessage,
} from '@moonshot-ai/kosong';
@ -430,6 +432,7 @@ export class FullCompaction {
// prefix-race check and `compactedCount`.
let historyForModel: readonly ContextMessage[] = stripDynamicToolContext(originalHistory);
let droppedCount = 0;
let mediaStripAttempted = false;
let overflowShrinkCount = 0;
let emptyOrTruncatedShrinkCount = 0;
while (true) {
@ -465,6 +468,24 @@ export class FullCompaction {
summary = extractCompactionSummary(response);
break;
} catch (error) {
// A request-body-size rejection (HTTP 413) is first retried with
// media parts replaced by text markers: accumulated base64 payloads
// are the usual culprit, and a text summary does not need them —
// the conversation already narrates what was seen, and the
// ReadMediaFile `<image path="...">` text wrapper survives. Only
// the summarizer input copy is rewritten; the real history keeps
// its media. A 413 after the strip (or with no media to strip)
// falls through to the overflow shrink below — dropping oldest
// messages shrinks the body too.
if (error instanceof APIRequestTooLargeError && !mediaStripAttempted) {
mediaStripAttempted = true;
const stripped = replaceMediaPartsWithMarkers(historyForModel);
if (stripped !== historyForModel) {
historyForModel = stripped;
retryCount = 0;
continue;
}
}
const isContextOverflow = this.shouldRecoverFromContextOverflow(
error,
estimatedCompactionRequestTokens,
@ -472,7 +493,9 @@ export class FullCompaction {
if (isContextOverflow) {
this.observeContextOverflow(estimatedCompactionRequestTokens);
}
if (isContextOverflow && historyForModel.length > 1) {
const shouldShrinkAfterOverflow =
isContextOverflow || error instanceof APIRequestTooLargeError;
if (shouldShrinkAfterOverflow && historyForModel.length > 1) {
overflowShrinkCount += 1;
if (overflowShrinkCount > MAX_COMPACTION_OVERFLOW_SHRINK_ATTEMPTS) {
throw error;
@ -639,6 +662,40 @@ export class FullCompaction {
const MAX_COMPACTION_OVERFLOW_SHRINK_ATTEMPTS = 3;
const COMPACTION_OVERFLOW_SHRINK_RATIOS = [0.7, 0.5, 0.35] as const;
const MEDIA_PART_MARKERS = {
image_url: '[image]',
audio_url: '[audio]',
video_url: '[video]',
} as const;
function isMediaPart(part: ContentPart): part is ContentPart & { type: keyof typeof MEDIA_PART_MARKERS } {
return part.type in MEDIA_PART_MARKERS;
}
/**
* Replace media parts (image/audio/video) with text markers in the summarizer
* input, for the 413 strip-and-retry above. Messages without media are
* returned by reference (keeping the per-message token-estimate cache warm),
* and when nothing changed the input array itself is returned so the caller
* can tell there was no media to strip.
*/
function replaceMediaPartsWithMarkers(
messages: readonly ContextMessage[],
): readonly ContextMessage[] {
let changed = false;
const out = messages.map((message) => {
if (!message.content.some(isMediaPart)) return message;
changed = true;
return {
...message,
content: message.content.map((part): ContentPart =>
isMediaPart(part) ? { type: 'text', text: MEDIA_PART_MARKERS[part.type] } : part,
),
};
});
return changed ? out : messages;
}
function shrinkCompactionHistoryAfterOverflow<T extends Message>(
messages: readonly T[],
attempt: number,

View file

@ -18,6 +18,8 @@ import {
type CompactionResult,
} from '../compaction';
import {
degradeOlderMediaParts,
MEDIA_DEGRADE_KEEP_RECENT,
project,
type ProjectionAnomaly,
type ProjectOptions,
@ -488,6 +490,17 @@ export class ContextMemory {
});
}
// Fallback projection for the post-413 media-degraded resend: the normal
// wire projection with all but the most recent media parts replaced by text
// markers, so a request body bloated by accumulated base64 media fits the
// provider's size limit. Purely read-side — the history keeps its media —
// and only used when the provider has already rejected the normal
// projection as too large; see the request-too-large fallback in
// `turn-step`.
get mediaDegradedMessages(): Message[] {
return degradeOlderMediaParts(this.messages, MEDIA_DEGRADE_KEEP_RECENT);
}
useProjectedHistoryFrom(source: ContextMemory): void {
this.clear();
this.pushHistory(...trimTrailingOpenToolExchange(source.project(source.history)));

View file

@ -465,3 +465,58 @@ export function trimTrailingOpenToolExchange(history: readonly Message[]): Messa
const closed = assistant.toolCalls.every((toolCall) => trailingToolCallIds.has(toolCall.id));
return closed ? [...history] : history.slice(0, lastNonToolIndex);
}
/**
* How many of the most recent media parts survive the media-degraded
* projection. The tail images are what the model is actively working from
* (the screenshot it just took); everything older is replaced by a marker.
*/
export const MEDIA_DEGRADE_KEEP_RECENT = 2;
const MEDIA_DEGRADED_PLACEHOLDERS = {
image_url:
'[image omitted: dropped to fit the provider request size limit; re-read the file to view it]',
audio_url:
'[audio omitted: dropped to fit the provider request size limit; re-read the file to hear it]',
video_url:
'[video omitted: dropped to fit the provider request size limit; re-read the file to view it]',
} as const;
function isDegradableMediaPart(
part: ContentPart,
): part is ContentPart & { type: keyof typeof MEDIA_DEGRADED_PLACEHOLDERS } {
return part.type in MEDIA_DEGRADED_PLACEHOLDERS;
}
/**
* Replace all but the `keepRecent` most recent media parts with deterministic
* text markers. This is the media-degraded projection used to resend a request
* the provider rejected as too large (HTTP 413 on accumulated base64 media):
* a purely read-side transform the underlying history is left untouched
* that trades old pixels for bytes while the surrounding text (including
* ReadMediaFile's `<image path="...">` wrapper) survives, so the model can
* re-read any file it still needs. Untouched messages are returned by
* reference, and when nothing needs degrading the input array itself is
* returned.
*/
export function degradeOlderMediaParts(
messages: readonly Message[],
keepRecent: number,
): Message[] {
const mediaCount = messages.reduce(
(count, message) => count + message.content.filter(isDegradableMediaPart).length,
0,
);
let toDegrade = Math.max(0, mediaCount - keepRecent);
if (toDegrade === 0) return messages as Message[];
return messages.map((message) => {
if (toDegrade === 0 || !message.content.some(isDegradableMediaPart)) return message;
const content = message.content.map((part): ContentPart => {
if (toDegrade === 0 || !isDegradableMediaPart(part)) return part;
toDegrade -= 1;
return { type: 'text', text: MEDIA_DEGRADED_PLACEHOLDERS[part.type] };
});
return { ...message, content };
});
}

View file

@ -177,8 +177,9 @@ export interface AgentRecordEvents {
messageCount: number;
turnStep?: string;
attempt?: string;
/** Set when this request is the strict wire-compliant rebuild resend. */
projection?: 'strict';
/** Set when this request is a fallback resend (strict rebuild or
* media-degraded rebuild). */
projection?: 'strict' | 'media-degraded';
/** Compaction only: messages dropped so far by overflow/empty shrinking. */
droppedCount?: number;
};

View file

@ -724,6 +724,7 @@ export class TurnFlow {
llm: this.agent.llm,
buildMessages: () => this.agent.context.messages,
buildMessagesStrict: () => this.agent.context.strictMessages,
buildMessagesMediaDegraded: () => this.agent.context.mediaDegradedMessages,
dispatchEvent: this.buildDispatchEvent(turnId),
// Re-read per step (not snapshotted per turn) so a select_tools load
// is dispatchable on the very next step of the same turn.

View file

@ -134,6 +134,25 @@ export const BackgroundConfigSchema = z.object({
export type BackgroundConfig = z.infer<typeof BackgroundConfigSchema>;
export const ImageConfigSchema = z.object({
/**
* Longest-edge ceiling (px) applied when compressing images for the model.
* Overrides the built-in default; the KIMI_IMAGE_MAX_EDGE_PX env var wins
* over this value.
*/
maxEdgePx: z.number().int().min(1).optional(),
/**
* Raw-byte budget for images the model reads for itself (ReadMediaFile's
* default path). Overrides the built-in default; the
* KIMI_IMAGE_READ_BYTE_BUDGET env var wins over this value. Explicit
* region / full_resolution reads use the provider-scale per-image limit
* instead.
*/
readByteBudget: z.number().int().min(1).optional(),
});
export type ImageConfig = z.infer<typeof ImageConfigSchema>;
export const ModelCatalogConfigSchema = z.object({
/** Interval (ms) between automatic provider-model refreshes. `0` disables. */
refreshIntervalMs: z.number().int().min(0).optional(),
@ -256,6 +275,7 @@ export const KimiConfigSchema = z.object({
extraSkillDirs: z.array(z.string()).optional(),
loopControl: LoopControlSchema.optional(),
background: BackgroundConfigSchema.optional(),
image: ImageConfigSchema.optional(),
modelCatalog: ModelCatalogConfigSchema.optional(),
experimental: ExperimentalConfigSchema.optional(),
telemetry: z.boolean().optional(),
@ -270,6 +290,7 @@ const ThinkingConfigPatchSchema = ThinkingConfigSchema.partial();
const PermissionConfigPatchSchema = PermissionConfigSchema.partial();
const LoopControlPatchSchema = LoopControlSchema.partial();
const BackgroundConfigPatchSchema = BackgroundConfigSchema.partial();
const ImageConfigPatchSchema = ImageConfigSchema.partial();
const ModelCatalogConfigPatchSchema = ModelCatalogConfigSchema.partial();
const ExperimentalConfigPatchSchema = ExperimentalConfigSchema;
const MoonshotServiceConfigPatchSchema = MoonshotServiceConfigSchema.partial();
@ -296,6 +317,7 @@ export const KimiConfigPatchSchema = z
extraSkillDirs: z.array(z.string()).optional(),
loopControl: LoopControlPatchSchema.optional(),
background: BackgroundConfigPatchSchema.optional(),
image: ImageConfigPatchSchema.optional(),
modelCatalog: ModelCatalogConfigPatchSchema.optional(),
experimental: ExperimentalConfigPatchSchema.optional(),
telemetry: z.boolean().optional(),

View file

@ -11,6 +11,7 @@ import {
type BackgroundConfig,
type ExperimentalConfig,
type HookDefConfig,
type ImageConfig,
type KimiConfig,
type LoopControl,
type ModelAlias,
@ -312,6 +313,8 @@ export function transformTomlData(data: Record<string, unknown>): Record<string,
result[targetKey] = transformLoopControlData(value);
} else if (targetKey === 'background' && isPlainObject(value)) {
result[targetKey] = transformPlainObject(value);
} else if (targetKey === 'image' && isPlainObject(value)) {
result[targetKey] = transformPlainObject(value);
} else if (targetKey === 'experimental' && isPlainObject(value)) {
result[targetKey] = cloneRecord(value);
} else if (!isPlainObject(value)) {
@ -489,6 +492,7 @@ export function configToTomlData(config: KimiConfig): Record<string, unknown> {
setSection(out, 'services', config.services, servicesToToml);
setSection(out, 'loop_control', config.loopControl, loopControlToToml);
setSection(out, 'background', config.background, backgroundToToml);
setSection(out, 'image', config.image, imageToToml);
setSection(out, 'experimental', config.experimental, experimentalToToml);
setSection(out, 'permission', config.permission, permissionToToml);
setHooks(out, config.hooks);
@ -670,6 +674,14 @@ function backgroundToToml(
return out;
}
function imageToToml(image: ImageConfig, rawImage: unknown): Record<string, unknown> {
const out = cloneRecord(rawImage);
for (const [key, value] of Object.entries(image)) {
setDefined(out, camelToSnake(key), value);
}
return out;
}
function experimentalToToml(
experimental: ExperimentalConfig,
_rawExperimental: unknown,

View file

@ -33,8 +33,10 @@ export interface LLMRequestLogFields {
readonly attempt?: string;
/** Request purpose; absent means a regular loop step. */
readonly kind?: 'loop' | 'compaction';
/** Set when the messages are the strict wire-compliant rebuild resend. */
readonly projection?: 'strict';
/** Set when the messages are a fallback resend projection: the strict
* wire-compliant rebuild, or the media-degraded rebuild after a
* request-too-large rejection. */
readonly projection?: 'strict' | 'media-degraded';
/** Compaction only: messages dropped so far by overflow/empty shrinking. */
readonly droppedCount?: number;
}

View file

@ -41,6 +41,15 @@ export interface RunTurnInput {
* a tool_use/tool_result adjacency 400 (see `executeLoopStep`).
*/
readonly buildMessagesStrict?: LoopMessageBuilder | undefined;
/**
* Optional media-degraded rebuild of the request messages: old media parts
* replaced by text markers, the most recent kept. Used to resend once after
* the provider rejects the request body as too large (HTTP 413 on
* accumulated media, see `executeLoopStep`); after a successful degraded
* resend, later steps of the same turn build from this projection directly
* so each step does not pay a fresh rejection.
*/
readonly buildMessagesMediaDegraded?: LoopMessageBuilder | undefined;
readonly dispatchEvent: LoopEventDispatcher;
readonly tools?: readonly ExecutableTool[] | undefined;
/**
@ -74,6 +83,7 @@ export async function runTurn(input: RunTurnInput): Promise<TurnResult> {
llm,
buildMessages,
buildMessagesStrict,
buildMessagesMediaDegraded,
dispatchEvent,
tools,
buildTools,
@ -89,6 +99,11 @@ export async function runTurn(input: RunTurnInput): Promise<TurnResult> {
// Normal exits overwrite this with the completed step's stop reason.
let stopReason: LoopTurnStopReason = 'end_turn';
let activeStep: number | undefined;
// Once a step only succeeded via the media-degraded resend, later steps of
// this turn build from the degraded projection directly: the full-media
// history is deterministically over the provider's body-size limit, so
// rebuilding it would pay a fresh rejection on every step.
let mediaDegradedActive = false;
const recordStepUsage = async (
stepUsage: TokenUsage,
): Promise<RecordStepUsageResult | void> => {
@ -109,8 +124,12 @@ export async function runTurn(input: RunTurnInput): Promise<TurnResult> {
const stepResult = await executeLoopStep({
turnId,
signal,
buildMessages,
buildMessages:
mediaDegradedActive && buildMessagesMediaDegraded !== undefined
? buildMessagesMediaDegraded
: buildMessages,
buildMessagesStrict,
buildMessagesMediaDegraded,
dispatchEvent,
llm,
tools,
@ -127,6 +146,7 @@ export async function runTurn(input: RunTurnInput): Promise<TurnResult> {
recordUsage: recordStepUsage,
});
activeStep = undefined;
mediaDegradedActive = mediaDegradedActive || stepResult.mediaDegradedResendUsed === true;
if (stepResult.stopReason === 'tool_use') {
continue;

View file

@ -9,7 +9,11 @@
import { randomUUID } from 'node:crypto';
import { isRecoverableRequestStructureError, type TokenUsage } from '@moonshot-ai/kosong';
import {
APIRequestTooLargeError,
isRecoverableRequestStructureError,
type TokenUsage,
} from '@moonshot-ai/kosong';
import type { Logger } from '#/logging/types';
import type { LoopEventDispatcher } from './events';
@ -35,6 +39,8 @@ export interface ExecuteLoopStepDeps {
readonly signal: AbortSignal;
readonly buildMessages: LoopMessageBuilder;
readonly buildMessagesStrict?: LoopMessageBuilder | undefined;
/** See RunTurnInput.buildMessagesMediaDegraded. */
readonly buildMessagesMediaDegraded?: LoopMessageBuilder | undefined;
readonly dispatchEvent: LoopEventDispatcher;
readonly llm: LLM;
readonly tools?: readonly ExecutableTool[] | undefined;
@ -57,12 +63,20 @@ export interface ExecuteLoopStepDeps {
export async function executeLoopStep(deps: ExecuteLoopStepDeps): Promise<{
readonly usage: TokenUsage;
readonly stopReason: LoopStepStopReason;
/**
* True when this step only succeeded after resending with the
* media-degraded projection. The turn loop uses it to keep later steps on
* that projection re-sending the full-media history would pay a fresh
* rejection on every step of the turn.
*/
readonly mediaDegradedResendUsed?: boolean;
}> {
const {
turnId,
signal,
buildMessages,
buildMessagesStrict,
buildMessagesMediaDegraded,
dispatchEvent,
llm,
tools,
@ -140,49 +154,90 @@ export async function executeLoopStep(deps: ExecuteLoopStepDeps): Promise<{
log,
} as const;
let response: LLMChatResponse;
let mediaDegradedResendUsed = false;
try {
response = await chatWithRetry({ ...retryInput, params: chatParams });
} catch (error) {
// A structural request rejection (tool_use/tool_result pairing, empty or
// whitespace-only text, non-user first message, non-alternating roles) means
// the projected history is not wire-compliant for a strict provider — and
// since the same history is re-sent every turn, the session would stay stuck
// on this error forever. Resend ONCE with a strict, guaranteed-compliant
// rebuild (every open call closed, stray results dropped, leading non-user
// trimmed, consecutive assistants merged) as a last resort. Any other error,
// or a host that supplied no strict builder, propagates unchanged.
if (buildMessagesStrict === undefined || !isRecoverableRequestStructureError(error)) throw error;
signal.throwIfAborted();
log?.warn('provider rejected request structure; resending with strict projection', {
turnStep: `${turnId}.${String(currentStep)}`,
model: llm.modelName,
});
const strictMessages = await buildMessagesStrict();
signal.throwIfAborted();
try {
response = await chatWithRetry({
...retryInput,
params: {
...chatParams,
messages: strictMessages,
requestLogFields: { projection: 'strict' },
},
});
} catch (strictError) {
// The strictly-sanitized rebuild was still rejected — our wire-compliance
// repair did not cover this case. Surface it loudly: the session is stuck
// and this is the signal we need to diagnose the gap.
log?.error('strict resend still rejected by provider; request remains wire-invalid', {
if (buildMessagesMediaDegraded !== undefined && error instanceof APIRequestTooLargeError) {
// The provider rejected the request BODY as too large (HTTP 413) —
// accumulated base64 media, not tokens, so compaction's token-driven
// recovery never fires (media is estimated at a small flat cost). The
// same media is re-sent on every request, so without intervention the
// session stays stuck. Resend ONCE with the media-degraded projection
// (old media replaced by text markers, the most recent kept); a
// rejection of that rebuild propagates unchanged.
signal.throwIfAborted();
log?.warn('provider rejected request as too large; resending with degraded media', {
turnStep: `${turnId}.${String(currentStep)}`,
model: llm.modelName,
originalError: errorMessage(error),
strictError: errorMessage(strictError),
});
throw strictError;
const degradedMessages = await buildMessagesMediaDegraded();
signal.throwIfAborted();
try {
response = await chatWithRetry({
...retryInput,
params: {
...chatParams,
messages: degradedMessages,
requestLogFields: { projection: 'media-degraded' },
},
});
} catch (degradedError) {
log?.error('media-degraded resend still rejected by provider', {
turnStep: `${turnId}.${String(currentStep)}`,
model: llm.modelName,
originalError: errorMessage(error),
degradedError: errorMessage(degradedError),
});
throw degradedError;
}
mediaDegradedResendUsed = true;
log?.info('recovered after media-degraded resend', {
turnStep: `${turnId}.${String(currentStep)}`,
});
} else if (buildMessagesStrict !== undefined && isRecoverableRequestStructureError(error)) {
// A structural request rejection (tool_use/tool_result pairing, empty or
// whitespace-only text, non-user first message, non-alternating roles) means
// the projected history is not wire-compliant for a strict provider — and
// since the same history is re-sent every turn, the session would stay stuck
// on this error forever. Resend ONCE with a strict, guaranteed-compliant
// rebuild (every open call closed, stray results dropped, leading non-user
// trimmed, consecutive assistants merged) as a last resort. Any other error,
// or a host that supplied no strict builder, propagates unchanged.
signal.throwIfAborted();
log?.warn('provider rejected request structure; resending with strict projection', {
turnStep: `${turnId}.${String(currentStep)}`,
model: llm.modelName,
});
const strictMessages = await buildMessagesStrict();
signal.throwIfAborted();
try {
response = await chatWithRetry({
...retryInput,
params: {
...chatParams,
messages: strictMessages,
requestLogFields: { projection: 'strict' },
},
});
} catch (strictError) {
// The strictly-sanitized rebuild was still rejected — our wire-compliance
// repair did not cover this case. Surface it loudly: the session is stuck
// and this is the signal we need to diagnose the gap.
log?.error('strict resend still rejected by provider; request remains wire-invalid', {
turnStep: `${turnId}.${String(currentStep)}`,
model: llm.modelName,
originalError: errorMessage(error),
strictError: errorMessage(strictError),
});
throw strictError;
}
log?.info('recovered after strict resend', {
turnStep: `${turnId}.${String(currentStep)}`,
});
} else {
throw error;
}
log?.info('recovered after strict resend', {
turnStep: `${turnId}.${String(currentStep)}`,
});
}
const usage = response.usage;
const usageResult = await recordUsage(usage);
@ -244,6 +299,7 @@ export async function executeLoopStep(deps: ExecuteLoopStepDeps): Promise<{
usage,
stopReason:
stopTurnAfterStep && effectiveStopReason === 'tool_use' ? 'end_turn' : effectiveStopReason,
mediaDegradedResendUsed,
};
}

View file

@ -7,6 +7,7 @@ import { PluginManager } from '#/plugin';
import { LocalFetchURLProvider } from '#/tools/providers/local-fetch-url';
import { MoonshotFetchURLProvider } from '#/tools/providers/moonshot-fetch-url';
import { MoonshotWebSearchProvider } from '#/tools/providers/moonshot-web-search';
import { setConfiguredMaxImageEdgePx, setConfiguredReadImageByteBudget } from '#/tools/support/image-compress';
import type { PromisableMethods } from '#/utils/types';
import { getCoreVersion } from '#/version';
import { resolveThinkingEffort } from '../agent/config/thinking';
@ -205,6 +206,8 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
FLAG_DEFINITIONS,
this.config.experimental,
);
setConfiguredMaxImageEdgePx(this.config.image?.maxEdgePx);
setConfiguredReadImageByteBudget(this.config.image?.readByteBudget);
this.sessionStore = new SessionStore(this.homeDir);
this.plugins = new PluginManager({ kimiHomeDir: this.homeDir });
// Capture the error rather than swallow it: mutators and explicit /plugins
@ -1079,6 +1082,8 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
private setRuntimeConfig(config: KimiConfig): KimiConfig {
this.config = config;
this.experimentalFlags.setConfigOverrides(config.experimental);
setConfiguredMaxImageEdgePx(config.image?.maxEdgePx);
setConfiguredReadImageByteBudget(config.image?.readByteBudget);
return this.config;
}

View file

@ -45,6 +45,7 @@ import {
compressImageForModel,
cropImageForModel,
formatByteSize,
resolveReadImageByteBudget,
type ImageCompressionTelemetry,
type ImageCropRegion,
} from '../../support/image-compress';
@ -213,6 +214,45 @@ function buildMediaNote(input: {
// ── Implementation ───────────────────────────────────────────────────
/**
* Refusal message for HEIC/HEIF with a conversion command matching the
* execution environment (`kaos.osEnv.osKind` where Bash actually runs, so
* SSH/container sessions get the right command too). macOS converts with the
* built-in `sips`; Linux and Windows have no built-in HEIC decoder, so the
* guidance names the common tools and how to get them.
*/
function buildHeicConversionGuidance(path: string, mimeType: string, osKind: string): string {
const converted = path.replace(/\.[^./\\]+$/, '') + '.jpg';
return (
`"${path}" is a ${mimeType} image, which the provider does not accept. ` +
'Convert it to JPEG first, then read the converted file. ' +
heicConversionCommand(path, converted, osKind)
);
}
function heicConversionCommand(path: string, converted: string, osKind: string): string {
switch (osKind) {
case 'macOS':
return `On macOS: sips -s format jpeg "${path}" --out "${converted}"`;
case 'Linux':
return (
`On Linux: heif-convert "${path}" "${converted}" (package libheif-examples), ` +
`or with ImageMagick: magick "${path}" "${converted}"`
);
case 'Windows':
return (
`On Windows, with ImageMagick: magick "${path}" "${converted}" ` +
'(install it first if missing: winget install ImageMagick.ImageMagick)'
);
default:
return (
`Options: sips -s format jpeg "${path}" --out "${converted}" (macOS), ` +
`heif-convert "${path}" "${converted}" (Linux, package libheif-examples), ` +
`or magick "${path}" "${converted}" (ImageMagick)`
);
}
}
export class ReadMediaFileTool implements BuiltinTool<ReadMediaFileInput> {
readonly name = 'ReadMediaFile' as const;
readonly description: string;
@ -293,6 +333,17 @@ export class ReadMediaFileTool implements BuiltinTool<ReadMediaFileInput> {
'Tell the user to use a model with image input capability.',
};
}
// HEIC/HEIF must never reach the provider: no provider accepts them,
// and once the image_url lands in the history every subsequent request
// in the session is rejected. Refuse with a conversion command for the
// execution environment instead — the model can run it through Bash
// (under the normal permission flow) and read the converted file.
if (fileType.mimeType === 'image/heic' || fileType.mimeType === 'image/heif') {
return {
isError: true,
output: buildHeicConversionGuidance(args.path, fileType.mimeType, this.kaos.osEnv.osKind),
};
}
if (fileType.kind === 'video' && !this.capabilities.video_in) {
return {
isError: true,
@ -388,10 +439,14 @@ export class ReadMediaFileTool implements BuiltinTool<ReadMediaFileInput> {
};
} else {
// Shrink oversized images so a large screenshot neither wastes context
// tokens nor trips the provider's per-image byte ceiling. Best effort:
// on any failure compressImageForModel returns the original bytes, so
// the read still succeeds with the uncompressed image.
// tokens nor trips the provider's per-image byte ceiling. Model-read
// images get the much tighter read budget: they accumulate in the
// request body on every turn, and detail stays reachable through the
// region readback (which ignores the budget). Best effort: on any
// failure compressImageForModel returns the original bytes, so the
// read still succeeds with the uncompressed image.
const compressed = await compressImageForModel(data, fileType.mimeType, {
byteBudget: resolveReadImageByteBudget(),
telemetry: this.compressTelemetry,
});
const base64 = Buffer.from(compressed.data).toString('base64');

View file

@ -8,14 +8,16 @@
* untouched the common case is a fast, codec-free pass-through.
*
* Design notes:
* - Pure JS (jimp), imported lazily so the codec is only paid for when an
* image actually needs work; startup and the fast path stay cheap.
* - Pure JS (jimp + a wasm WebP decoder), imported lazily so the codecs are
* only paid for when an image actually needs work; startup and the fast
* path stay cheap.
* - Best effort: any decode/encode failure returns the original bytes
* unchanged (`changed: false`), so a compression problem never blocks a
* prompt. Callers simply send the original instead.
* - Only PNG and JPEG are re-encoded. GIF is passed through to preserve
* animation; WebP is passed through because the default jimp build ships no
* WebP codec. Unknown formats are passed through.
* - PNG, JPEG, and (non-animated) WebP are re-encoded; WebP re-encodes
* through the PNG/JPEG ladder, so only its decoder wasm ships. GIF and
* animated WebP are passed through to preserve animation. Unknown formats
* are passed through.
* - Compression must never be silent to the model: results carry the
* original dimensions, {@link buildImageCompressionCaption} renders the
* shared "what was compressed, where is the original" note every ingestion
@ -31,9 +33,53 @@ import type { ContentPart } from '@moonshot-ai/kosong';
import type { TelemetryClient } from '#/telemetry';
import { sniffImageDimensions } from './file-type';
import { decodeWebp, isAnimatedWebp } from './webp-decode';
/** Longest-edge ceiling (px). Larger images are scaled down to fit. */
export const MAX_IMAGE_EDGE_PX = 3000;
/**
* Built-in longest-edge ceiling (px). Larger images are scaled down to fit.
* This is the default only: the effective ceiling is resolved per call by
* {@link resolveMaxImageEdgePx} (explicit option > env > config > this).
*/
export const MAX_IMAGE_EDGE_PX = 2000;
/**
* Env var overriding the longest-edge ceiling (px). Read live on every
* resolution so it applies in any process without wiring; a value that is
* not a positive integer is ignored.
*/
export const MAX_IMAGE_EDGE_ENV = 'KIMI_IMAGE_MAX_EDGE_PX';
/**
* The `[image] max_edge_px` value from config.toml, pushed by the config
* owner (KimiCore) on load and reload. Processes that never load config
* (TUI paste, ACP adapter) leave this unset and get env/built-in behavior.
*/
let configuredMaxImageEdgePx: number | undefined;
/** Push (or clear, with `undefined`) the config.toml longest-edge ceiling. */
export function setConfiguredMaxImageEdgePx(value: number | undefined): void {
configuredMaxImageEdgePx = value !== undefined && isPositiveInt(value) ? value : undefined;
}
/**
* Effective default longest-edge ceiling (px), for calls that pass no
* explicit `maxEdge`. Precedence mirrors the experimental-flag resolver:
* env var > config.toml > built-in {@link MAX_IMAGE_EDGE_PX}.
*/
export function resolveMaxImageEdgePx(
env: Readonly<Record<string, string | undefined>> = process.env,
): number {
const raw = env[MAX_IMAGE_EDGE_ENV]?.trim();
if (raw !== undefined && raw.length > 0 && /^\d+$/.test(raw)) {
const parsed = Number(raw);
if (isPositiveInt(parsed)) return parsed;
}
return configuredMaxImageEdgePx ?? MAX_IMAGE_EDGE_PX;
}
function isPositiveInt(value: number): boolean {
return Number.isInteger(value) && value > 0;
}
/**
* Raw-byte budget for a single image. base64 inflates bytes by ~4/3, so a
@ -42,16 +88,70 @@ export const MAX_IMAGE_EDGE_PX = 3000;
*/
export const IMAGE_BYTE_BUDGET = 3.75 * 1024 * 1024;
/**
* Built-in raw-byte budget for images the model reads for itself
* (ReadMediaFile's default path). Far below {@link IMAGE_BYTE_BUDGET}: a
* session that keeps screenshotting and reading images accumulates every one
* of them in the request body on every turn, so per-image size not the
* provider's per-image ceiling is what keeps the total under the
* provider's request-size limit. 256 KB keeps a clean 2000px UI screenshot
* on the lossless fast path while capping dense content at a readable
* q80/1000px JPEG; fine detail stays reachable through the `region`
* readback, which deliberately ignores this budget.
*/
export const READ_IMAGE_BYTE_BUDGET = 256 * 1024;
/**
* Env var overriding the read-image byte budget. Read live on every
* resolution; a value that is not a positive integer is ignored.
*/
export const READ_IMAGE_BYTE_BUDGET_ENV = 'KIMI_IMAGE_READ_BYTE_BUDGET';
/** The `[image] read_byte_budget` value from config.toml; see {@link setConfiguredMaxImageEdgePx}. */
let configuredReadImageByteBudget: number | undefined;
/** Push (or clear, with `undefined`) the config.toml read-image byte budget. */
export function setConfiguredReadImageByteBudget(value: number | undefined): void {
configuredReadImageByteBudget = value !== undefined && isPositiveInt(value) ? value : undefined;
}
/**
* Effective read-image byte budget. Precedence mirrors
* {@link resolveMaxImageEdgePx}: env var > config.toml > built-in
* {@link READ_IMAGE_BYTE_BUDGET}.
*/
export function resolveReadImageByteBudget(
env: Readonly<Record<string, string | undefined>> = process.env,
): number {
const raw = env[READ_IMAGE_BYTE_BUDGET_ENV]?.trim();
if (raw !== undefined && raw.length > 0 && /^\d+$/.test(raw)) {
const parsed = Number(raw);
if (isPositiveInt(parsed)) return parsed;
}
return configuredReadImageByteBudget ?? READ_IMAGE_BYTE_BUDGET;
}
/** Progressively lower JPEG quality until the payload fits the byte budget. */
const JPEG_QUALITY_STEPS = [80, 60, 40, 20] as const;
/**
* Longest-edge step-downs tried when the budget cannot be met at the fitted
* size. The 2000px step preserves the behavior of the previous 2000px cap:
* an image whose 2000px encode fits the budget keeps that resolution
* instead of dropping straight to the 1000px last resort.
* size. With the built-in 2000px ceiling the first step is a no-op; it
* matters when a larger ceiling is configured (config/env/option). The
* sub-1000px tail exists for small (read-scale) budgets: JPEG bytes shrink
* roughly linearly with pixel count, so stepping down to 256px lets even
* entropy-upper-bound content (noise, photos) land within any budget of a
* few tens of KB instead of stalling at the q20@1000px floor.
*/
const FALLBACK_EDGES_PX = [2000, 1000] as const;
const FALLBACK_EDGES_PX = [2000, 1000, 768, 512, 384, 256] as const;
/**
* PNG rescales stop at this edge; below it the ladder goes lossy instead.
* For text-bearing screenshots a q80 JPEG at 1000px reads better than a
* lossless PNG at 512px resolution beats losslessness once both are
* degraded so sub-floor edges are only ever tried with the JPEG ladder.
*/
const PNG_RESCALE_FLOOR_PX = 1000;
/**
* Pixel-count ceiling above which we skip compression entirely. A tiny-byte,
@ -76,11 +176,13 @@ const MAX_DECODE_PIXELS = 100_000_000;
*/
const MAX_DECODE_BYTES = 64 * 1024 * 1024;
/** Formats we can both decode and re-encode with the default jimp build. */
const RECODABLE_MIME = new Set(['image/png', 'image/jpeg']);
/** Formats we can decode and re-encode. WebP decodes via the bundled wasm
* codec and re-encodes through the PNG/JPEG ladder (animated WebP is gated
* to a passthrough before decoding). */
const RECODABLE_MIME = new Set(['image/png', 'image/jpeg', 'image/webp']);
export interface CompressImageOptions {
/** Override the longest-edge ceiling (px). */
/** Override the longest-edge ceiling (px); defaults to {@link resolveMaxImageEdgePx}. */
readonly maxEdge?: number;
/** Override the raw-byte budget. */
readonly byteBudget?: number;
@ -152,7 +254,7 @@ export async function compressImageForModel(
options: CompressImageOptions = {},
): Promise<CompressImageResult> {
const startedAt = Date.now();
const maxEdge = options.maxEdge ?? MAX_IMAGE_EDGE_PX;
const maxEdge = options.maxEdge ?? resolveMaxImageEdgePx();
const byteBudget = options.byteBudget ?? IMAGE_BYTE_BUDGET;
const maxDecodeBytes = options.maxDecodeBytes ?? MAX_DECODE_BYTES;
const normalizedMime = normalizeMime(mimeType);
@ -183,6 +285,11 @@ export async function compressImageForModel(
if (bytes.length === 0) return finish('passthrough_unsupported', passthrough());
// Only re-encode formats the codec handles; everything else passes through.
if (!RECODABLE_MIME.has(normalizedMime)) return finish('passthrough_unsupported', passthrough());
// Animated WebP would be flattened to one frame by decoding — pass it
// through whole, the same reason GIF is never re-encoded.
if (normalizedMime === 'image/webp' && isAnimatedWebp(bytes)) {
return finish('passthrough_unsupported', passthrough());
}
// Fast path: already within both budgets — no codec load, no allocation.
const longestEdge = dims ? Math.max(dims.width, dims.height) : 0;
@ -202,9 +309,10 @@ export async function compressImageForModel(
if (bytes.length > maxDecodeBytes) return finish('passthrough_guard', passthrough());
try {
const { Jimp } = await import('jimp');
const image = await Jimp.fromBuffer(Buffer.from(bytes));
const sourceIsPng = normalizedMime === 'image/png';
const image = await decodeToJimp(bytes, normalizedMime);
// WebP joins PNG on the lossless-first ladder: both carry alpha and
// screenshot-grade detail that the PNG rungs preserve.
const preferLossless = normalizedMime !== 'image/jpeg';
// The decoded bitmap is authoritative for the original size: jimp
// applies EXIF orientation while decoding, and this is the coordinate
// space the encoded result and any later crop region (see
@ -218,7 +326,7 @@ export async function compressImageForModel(
fitWithinEdge(image, maxEdge);
const encoded = await encodeWithinBudget(image, {
sourceIsPng,
preferLossless,
byteBudget,
fallbackEdges: FALLBACK_EDGES_PX,
});
@ -518,7 +626,7 @@ export async function cropImageForModel(
options: CropImageOptions = {},
): Promise<CropImageOutcome> {
const startedAt = Date.now();
const maxEdge = options.maxEdge ?? MAX_IMAGE_EDGE_PX;
const maxEdge = options.maxEdge ?? resolveMaxImageEdgePx();
const byteBudget = options.byteBudget ?? IMAGE_BYTE_BUDGET;
const maxDecodeBytes = options.maxDecodeBytes ?? MAX_DECODE_BYTES;
const normalizedMime = normalizeMime(mimeType);
@ -538,9 +646,14 @@ export async function cropImageForModel(
if (!RECODABLE_MIME.has(normalizedMime)) {
return fail(
'unsupported_format',
`Cropping is only supported for PNG and JPEG images; got ${mimeType}.`,
`Cropping is only supported for PNG, JPEG, and WebP images; got ${mimeType}.`,
);
}
// A crop is a still image by definition; decoding an animated WebP would
// silently crop a single frame, so refuse explicitly.
if (normalizedMime === 'image/webp' && isAnimatedWebp(bytes)) {
return fail('unsupported_format', 'Cropping is not supported for animated WebP images.');
}
// NaN slips past every </>= comparison in the bounds guard below, so gate
// on finiteness explicitly rather than surfacing a codec-internal error.
if (
@ -564,8 +677,7 @@ export async function cropImageForModel(
}
try {
const { Jimp } = await import('jimp');
const image = await Jimp.fromBuffer(Buffer.from(bytes));
const image = await decodeToJimp(bytes, normalizedMime);
const originalWidth = image.width;
const originalHeight = image.height;
@ -582,13 +694,15 @@ export async function cropImageForModel(
const h = Math.min(Math.floor(region.height), originalHeight - y);
const applied: ImageCropRegion = { x, y, width: w, height: h };
image.crop({ x, y, w, h });
const sourceIsPng = normalizedMime === 'image/png';
// WebP joins PNG on the lossless side: both carry alpha and
// screenshot-grade detail that PNG output preserves.
const preferLossless = normalizedMime !== 'image/jpeg';
if (options.skipResize === true) {
// Native resolution requested: encode once, favoring fidelity (lossless
// PNG, or high-quality JPEG), and refuse rather than degrade when the
// result cannot fit the byte budget.
const buffer = sourceIsPng
const buffer = preferLossless
? await image.getBuffer('image/png', { deflateLevel: 9 })
: await image.getBuffer('image/jpeg', { quality: 90 });
if (buffer.length > byteBudget) {
@ -603,7 +717,7 @@ export async function cropImageForModel(
return succeed({
ok: true,
data: new Uint8Array(buffer),
mimeType: sourceIsPng ? 'image/png' : 'image/jpeg',
mimeType: preferLossless ? 'image/png' : 'image/jpeg',
width: image.width,
height: image.height,
originalWidth,
@ -617,7 +731,7 @@ export async function cropImageForModel(
fitWithinEdge(image, maxEdge);
const encoded = await encodeWithinBudget(image, {
sourceIsPng,
preferLossless,
byteBudget,
fallbackEdges: FALLBACK_EDGES_PX,
});
@ -767,28 +881,53 @@ interface EncodedImage {
}
interface EncodeOptions {
readonly sourceIsPng: boolean;
/** Lossless-first (PNG rungs before JPEG): PNG and WebP sources, which
* carry alpha and screenshot-grade detail. JPEG sources skip straight to
* the quality ladder their detail is already lossy. */
readonly preferLossless: boolean;
readonly byteBudget: number;
readonly fallbackEdges: readonly number[];
}
/**
* Decode `bytes` into a jimp image. PNG/JPEG decode through jimp itself
* (which applies EXIF orientation); WebP decodes through the bundled wasm
* codec and enters jimp as a raw RGBA bitmap (WebP carries no EXIF-style
* orientation, so the decoded pixels are already display space).
*/
async function decodeToJimp(bytes: Uint8Array, normalizedMime: string): Promise<JimpImage> {
const { Jimp } = await import('jimp');
if (normalizedMime === 'image/webp') {
const decoded = await decodeWebp(bytes);
return Jimp.fromBitmap({
data: Buffer.from(decoded.data.buffer, decoded.data.byteOffset, decoded.data.byteLength),
width: decoded.width,
height: decoded.height,
});
}
return Jimp.fromBuffer(Buffer.from(bytes));
}
/**
* Encode `image` (already fitted to the edge ceiling) under the byte budget.
*
* Strategy prefer the source format so a downscaled screenshot stays lossless
* PNG (preserving text and transparency), and only fall back to lossy JPEG when
* PNG cannot meet the byte budget:
* - PNG source: PNG at the fitted size smaller PNG rescales, stepping down
* the fallback edges JPEG ladder.
* - PNG source: PNG at the fitted size smaller PNG rescales down to the
* {@link PNG_RESCALE_FLOOR_PX} floor JPEG ladder at that size JPEG
* ladder again at each sub-floor edge.
* - JPEG source: the full quality ladder at the fitted size, then again at
* each fallback edge a smaller rescale must not skip the high-quality
* rungs its extra pixels just paid for.
*
* Always returns the smallest buffer it produced, even if no attempt met the
* budget the caller still gates on whether it actually helped.
* The sub-floor edges make the ladder converge for small (read-scale)
* budgets: any budget of a few tens of KB is met by q20 at 256px even for
* entropy-upper-bound content. Below that, the smallest buffer produced is
* still returned the caller gates on whether it actually helped.
*/
async function encodeWithinBudget(image: JimpImage, opts: EncodeOptions): Promise<EncodedImage> {
const { sourceIsPng, byteBudget, fallbackEdges } = opts;
const { preferLossless, byteBudget, fallbackEdges } = opts;
let smallest: EncodedImage | null = null;
const consider = (data: Buffer, mimeType: string): EncodedImage => {
@ -799,43 +938,52 @@ async function encodeWithinBudget(image: JimpImage, opts: EncodeOptions): Promis
return candidate;
};
if (sourceIsPng) {
const jpegLadder = async (): Promise<EncodedImage | null> => {
for (const quality of JPEG_QUALITY_STEPS) {
const jpeg = await image.getBuffer('image/jpeg', { quality });
if (jpeg.length <= byteBudget) return consider(jpeg, 'image/jpeg');
consider(jpeg, 'image/jpeg');
}
return null;
};
if (preferLossless) {
// Lossless PNG first: best for screenshots/UI (sharp text) and keeps alpha.
const png = await image.getBuffer('image/png', { deflateLevel: 9 });
if (png.length <= byteBudget) return consider(png, 'image/png');
consider(png, 'image/png');
// Over budget: progressively smaller PNGs before going lossy.
// Over budget: progressively smaller PNGs (down to the floor) before
// going lossy.
for (const edge of fallbackEdges) {
if (edge < PNG_RESCALE_FLOOR_PX) break;
if (!fitWithinEdge(image, edge)) continue;
const smallerPng = await image.getBuffer('image/png', { deflateLevel: 9 });
if (smallerPng.length <= byteBudget) return consider(smallerPng, 'image/png');
consider(smallerPng, 'image/png');
}
// Last resort: lossy JPEG ladder (drops transparency) to meet the budget.
for (const quality of JPEG_QUALITY_STEPS) {
const jpeg = await image.getBuffer('image/jpeg', { quality });
if (jpeg.length <= byteBudget) return consider(jpeg, 'image/jpeg');
consider(jpeg, 'image/jpeg');
// Lossy JPEG ladder (drops transparency) at the floored size, then at
// each sub-floor edge until the budget is met.
const atFloor = await jpegLadder();
if (atFloor !== null) return atFloor;
for (const edge of fallbackEdges) {
if (edge >= PNG_RESCALE_FLOOR_PX) continue;
if (!fitWithinEdge(image, edge)) continue;
const atEdge = await jpegLadder();
if (atEdge !== null) return atEdge;
}
return smallest!;
}
// JPEG source: quality ladder at the fitted size, then the full ladder
// again at each fallback rescale.
for (const quality of JPEG_QUALITY_STEPS) {
const jpeg = await image.getBuffer('image/jpeg', { quality });
if (jpeg.length <= byteBudget) return consider(jpeg, 'image/jpeg');
consider(jpeg, 'image/jpeg');
}
const atFitted = await jpegLadder();
if (atFitted !== null) return atFitted;
for (const edge of fallbackEdges) {
if (!fitWithinEdge(image, edge)) continue;
for (const quality of JPEG_QUALITY_STEPS) {
const jpeg = await image.getBuffer('image/jpeg', { quality });
if (jpeg.length <= byteBudget) return consider(jpeg, 'image/jpeg');
consider(jpeg, 'image/jpeg');
}
const atEdge = await jpegLadder();
if (atEdge !== null) return atEdge;
}
return smallest!;

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,86 @@
/**
* WebP decoding for the image-compression pipeline.
*
* The default jimp build ships no WebP codec, so WebP is decoded with
* `@jsquash/webp`'s wasm decoder instead. The decoder wasm is compiled from a
* base64 string committed to the repo (see `webp-dec-wasm.ts`): the published
* CLI bundles every dependency into a single file with no runtime
* node_modules, so a file-path or fetch lookup for the .wasm (what the
* emscripten glue would do on its own) cannot work there the module is
* compiled and injected manually via the codec's `init()` hook. Only the
* decoder is bundled: re-encoding runs through the existing PNG/JPEG ladder,
* so the (larger) WebP encoder wasm is never needed.
*
* The repo's tsconfig carries no DOM lib, so the global `WebAssembly` and
* `ImageData` names are unavailable at the type level the wasm namespace is
* reached through a structurally-typed `globalThis` and the decoder's RGBA
* output is described by the local {@link DecodedWebp} shape.
*/
/** Decoded RGBA bitmap in the shape `Jimp.fromBitmap` accepts. */
export interface DecodedWebp {
readonly data: Uint8ClampedArray;
readonly width: number;
readonly height: number;
}
type WebpDecodeFn = (bytes: Uint8Array) => Promise<DecodedWebp>;
interface WasmGlobal {
readonly WebAssembly: {
compile(bytes: Uint8Array): Promise<object>;
};
}
let decoderReady: Promise<WebpDecodeFn> | null = null;
async function loadDecoder(): Promise<WebpDecodeFn> {
decoderReady ??= (async () => {
const [decodeModule, { WEBP_DECODER_WASM_BASE64 }] = await Promise.all([
import('@jsquash/webp/decode.js'),
import('./webp-dec-wasm'),
]);
const wasm = await (globalThis as unknown as WasmGlobal).WebAssembly.compile(
Buffer.from(WEBP_DECODER_WASM_BASE64, 'base64'),
);
await decodeModule.init(wasm as never);
const decode = decodeModule.default;
return async (bytes: Uint8Array) => {
const copy = new Uint8Array(bytes); // detach from any shared buffer
return (await decode(copy.buffer as ArrayBuffer)) as unknown as DecodedWebp;
};
})();
return decoderReady;
}
/**
* Decode a (non-animated) WebP payload to RGBA. Throws on undecodable input
* callers keep their existing best-effort catch semantics.
*/
export async function decodeWebp(bytes: Uint8Array): Promise<DecodedWebp> {
const decode = await loadDecoder();
return decode(bytes);
}
/**
* True when the payload is a WebP whose VP8X container header carries the
* ANIM flag. Animated WebP must be passed through, not re-encoded: decoding
* yields a single frame and would silently destroy the animation (the same
* reason GIF is passed through).
*/
export function isAnimatedWebp(bytes: Uint8Array): boolean {
if (bytes.length < 21) return false;
return (
hasAscii(bytes, 'RIFF', 0) &&
hasAscii(bytes, 'WEBP', 8) &&
hasAscii(bytes, 'VP8X', 12) &&
(bytes[20]! & 0x02) !== 0
);
}
function hasAscii(bytes: Uint8Array, text: string, at: number): boolean {
for (let i = 0; i < text.length; i++) {
if (bytes[at + i] !== text.codePointAt(i)) return false;
}
return true;
}

View file

@ -439,6 +439,33 @@ describe('compaction — probe tests (high-risk scenarios)', () => {
});
});
describe('compaction — summarizer request media handling', () => {
// GUARD — the first summarizer attempt sends media as-is: a multimodal
// summarizer can still read an image nobody narrated. Media is only
// replaced with text markers when the provider rejects the request body
// as too large (see full.test.ts "request too large" cases).
it('keeps media parts in the summarizer request when it is not rejected', async () => {
const ctx = testAgent();
ctx.configure({ provider: PROVIDER, modelCapabilities: CAPS });
ctx.agent.context.appendUserMessage(
[
{ type: 'text', text: '<image path="/workspace/shot.png">' },
{ type: 'image_url', imageUrl: { url: 'data:image/png;base64,AAAA' } },
{ type: 'text', text: '</image>' },
],
{ kind: 'user' },
);
ctx.mockNextResponse({ type: 'text', text: 'Summary.' });
await ctx.rpc.beginCompaction({});
await ctx.once('compaction.completed');
const request = ctx.llmCalls.at(-1)!;
const parts = request.history.flatMap((message) => message.content);
expect(parts.some((part) => part.type === 'image_url')).toBe(true);
});
});
describe('compaction — head/tail user-message retention', () => {
const FIRST = `FIRST ${'a'.repeat(4_000)}`; // ~1k tokens
const MIDDLE = 'b'.repeat(88_000); // ~22k tokens, over the 20k budget on its own

View file

@ -5,6 +5,7 @@ import { join } from 'pathe';
import {
APIConnectionError,
APIContextOverflowError,
APIRequestTooLargeError,
APIStatusError,
generate as runKosongGenerate,
UNKNOWN_CAPABILITY,
@ -560,6 +561,142 @@ describe('FullCompaction', () => {
await ctx.expectResumeMatches();
});
it('strips media to text markers and retries when the summarizer request is rejected as too large', async () => {
let attempts = 0;
const histories: Message[][] = [];
const generate: GenerateFn = async (_provider, _system, _tools, history) => {
attempts += 1;
histories.push(structuredClone(history));
if (attempts === 1) {
throw new APIRequestTooLargeError(413, 'Request exceeds the maximum size', 'req-413');
}
return textResult('Compacted without media.');
};
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
// A ReadMediaFile-shaped result: path wrapper text around inline image data.
ctx.agent.context.appendUserMessage(
[
{ type: 'text', text: '<image path="/workspace/shot.png">' },
{ type: 'image_url', imageUrl: { url: 'data:image/png;base64,AAAA' } },
{ type: 'text', text: '</image>' },
],
{ kind: 'user' },
);
ctx.agent.context.appendUserMessage(
[
{ type: 'video_url', videoUrl: { url: 'data:video/mp4;base64,BBBB' } },
{ type: 'audio_url', audioUrl: { url: 'data:audio/mp3;base64,CCCC' } },
],
{ kind: 'user' },
);
const compacted = ctx.once('context.apply_compaction');
const completed = ctx.once('compaction.completed');
await ctx.rpc.beginCompaction({});
await compacted;
await completed;
expect(attempts).toBe(2);
// The first attempt goes out with the media as-is.
const firstParts = histories[0]!.flatMap((message) => message.content);
expect(firstParts.some((part) => part.type === 'image_url')).toBe(true);
// The 413 retry replaces every media part with a text marker; the
// ReadMediaFile path wrapper survives so the summary can still reference
// the file, and no base64 payload leaks into the retried request.
// (Projection may merge adjacent text parts, so assert on the joined text.)
const retryParts = histories[1]!.flatMap((message) => message.content);
expect(retryParts.some((part) => part.type !== 'text' && part.type !== 'think')).toBe(false);
const retryText = retryParts
.filter((part) => part.type === 'text')
.map((part) => part.text)
.join('\n');
expect(retryText).toContain('[image]');
expect(retryText).toContain('[video]');
expect(retryText).toContain('[audio]');
expect(retryText).toContain('<image path="/workspace/shot.png">');
expect(JSON.stringify(histories[1])).not.toContain('base64');
// Stripping is not an overflow shrink: the retry drops no messages.
expect(histories[1]!.length).toBe(histories[0]!.length);
// The real history is untouched: recent kept user messages retain media.
const keptParts = ctx.agent.context.history.flatMap((message) => message.content);
expect(keptParts.some((part) => part.type === 'image_url')).toBe(true);
await ctx.expectResumeMatches();
});
it('shrinks the history when the summarizer request stays too large after media stripping', async () => {
let attempts = 0;
const histories: Message[][] = [];
const generate: GenerateFn = async (_provider, _system, _tools, history) => {
attempts += 1;
histories.push(structuredClone(history));
if (attempts <= 2) {
throw new APIRequestTooLargeError(413, 'Request exceeds the maximum size');
}
return textResult('Recovered after shrink.');
};
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'old user two', 'old assistant two', 40);
ctx.appendExchange(3, 'recent user three', 'recent assistant three', 120);
ctx.agent.context.appendUserMessage(
[{ type: 'image_url', imageUrl: { url: 'data:image/png;base64,AAAA' } }],
{ kind: 'user' },
);
const completed = ctx.once('compaction.completed');
await ctx.rpc.beginCompaction({});
await completed;
expect(attempts).toBe(3);
// Attempt 2 is the media strip: same message count, no media parts.
expect(histories[1]!.length).toBe(histories[0]!.length);
expect(
histories[1]!.flatMap((m) => m.content).some((part) => part.type === 'image_url'),
).toBe(false);
// Attempt 3 falls through to the overflow shrink and drops old messages.
expect(histories[2]!.length).toBeLessThan(histories[1]!.length);
await ctx.expectResumeMatches();
});
it('shrinks immediately on a too-large rejection when the history has no media', async () => {
let attempts = 0;
const histories: Message[][] = [];
const generate: GenerateFn = async (_provider, _system, _tools, history) => {
attempts += 1;
histories.push(structuredClone(history));
if (attempts === 1) {
throw new APIRequestTooLargeError(413, 'Request exceeds the maximum size');
}
return textResult('Recovered after shrink.');
};
const ctx = testAgent({ generate });
ctx.configure({
provider: CATALOGUED_PROVIDER,
modelCapabilities: CATALOGUED_MODEL_CAPABILITIES,
});
ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
ctx.appendExchange(2, 'old user two', 'old assistant two', 40);
ctx.appendExchange(3, 'recent user three', 'recent assistant three', 120);
const completed = ctx.once('compaction.completed');
await ctx.rpc.beginCompaction({});
await completed;
// With no media to strip, the too-large rejection goes straight to the
// overflow shrink instead of wasting a retry on an identical request.
expect(attempts).toBe(2);
expect(histories[1]!.length).toBeLessThan(histories[0]!.length);
await ctx.expectResumeMatches();
});
it('retries compaction responses with empty summaries before applying context', async () => {
vi.useFakeTimers();
const firstEmptySummary = deferred<void>();

View file

@ -1,7 +1,11 @@
import type { ContentPart, Message, ToolCall } from '@moonshot-ai/kosong';
import { describe, expect, it } from 'vitest';
import { project, type ProjectionAnomaly } from '../../../src/agent/context/projector';
import {
degradeOlderMediaParts,
project,
type ProjectionAnomaly,
} from '../../../src/agent/context/projector';
import type { ContextMessage } from '../../../src/agent/context/types';
// ---------------------------------------------------------------------------
@ -768,3 +772,63 @@ function shuffle<T>(items: T[], rng: Rng): void {
[items[i], items[j]] = [items[j]!, items[i]!];
}
}
describe('degradeOlderMediaParts', () => {
function imageMessage(name: string): Message {
return {
role: 'user',
content: [
{ type: 'text', text: `<image path="/ws/${name}.png">` },
{ type: 'image_url', imageUrl: { url: `data:image/png;base64,${name}` } },
{ type: 'text', text: '</image>' },
],
toolCalls: [],
};
}
it('replaces all but the most recent N media parts with placeholder text', () => {
const messages: Message[] = [
imageMessage('old'),
{
role: 'user',
content: [
{ type: 'video_url', videoUrl: { url: 'data:video/mp4;base64,VVVV' } },
{ type: 'audio_url', audioUrl: { url: 'data:audio/mp3;base64,SSSS' } },
],
toolCalls: [],
},
imageMessage('recent'),
];
const degraded = degradeOlderMediaParts(messages, 2);
const parts = degraded.flatMap((message) => message.content);
// The two most recent media parts survive; older ones become text.
expect(parts.filter((part) => part.type === 'audio_url')).toHaveLength(1);
expect(parts.filter((part) => part.type === 'image_url')).toHaveLength(1);
expect(parts.filter((part) => part.type === 'video_url')).toHaveLength(0);
const texts = parts.filter((part) => part.type === 'text').map((part) => part.text);
expect(texts.some((text) => text.startsWith('[image omitted:'))).toBe(true);
expect(texts.some((text) => text.startsWith('[video omitted:'))).toBe(true);
// The path wrapper of the degraded image survives for readback.
expect(texts).toContain('<image path="/ws/old.png">');
// The surviving image is the most recent one.
const survivor = parts.find((part) => part.type === 'image_url');
expect(survivor?.type === 'image_url' && survivor.imageUrl.url).toContain('recent');
});
it('returns the input array by reference when nothing needs degrading', () => {
const messages: Message[] = [imageMessage('a'), imageMessage('b')];
expect(degradeOlderMediaParts(messages, 2)).toBe(messages);
});
it('keeps untouched messages by reference and does not mutate degraded ones', () => {
const messages: Message[] = [imageMessage('old'), imageMessage('mid'), imageMessage('new')];
const degraded = degradeOlderMediaParts(messages, 2);
expect(degraded).not.toBe(messages);
expect(degraded[1]).toBe(messages[1]);
expect(degraded[2]).toBe(messages[2]);
// The input's first message still carries its image part.
expect(messages[0]!.content.some((part) => part.type === 'image_url')).toBe(true);
});
});

View file

@ -9,9 +9,11 @@ import { createControlledPromise } from '@antfu/utils';
import {
APIConnectionError,
APIEmptyResponseError,
APIRequestTooLargeError,
APIStatusError,
APITimeoutError,
type ChatProvider,
type Message,
type ModelCapability,
type ToolCall,
} from '@moonshot-ai/kosong';
@ -60,6 +62,76 @@ function captureLogs(): { logger: Logger; entries: CapturedLogEntry[] } {
}
describe('Agent turn flow', () => {
it('degrades older history media and retries when the provider rejects the request body as too large', async () => {
let attempts = 0;
const histories: Message[][] = [];
const generate: GenerateFn = async (_provider, _system, _tools, history) => {
attempts += 1;
histories.push(structuredClone(history));
if (attempts === 1) {
throw new APIRequestTooLargeError(413, 'Request exceeds the maximum size');
}
return {
id: 'mock-degraded-recovery',
message: { role: 'assistant', content: [{ type: 'text', text: 'done' }], toolCalls: [] },
usage: { inputOther: 1, output: 1, inputCacheRead: 0, inputCacheCreation: 0 },
finishReason: 'completed',
rawFinishReason: 'stop',
};
};
const ctx = testAgent({ generate });
ctx.configure({
provider: { type: 'kimi', apiKey: 'test-key', model: 'kimi-code' },
modelCapabilities: {
image_in: true,
video_in: false,
audio_in: false,
thinking: false,
tool_use: true,
max_context_tokens: 256_000,
},
});
// Three ReadMediaFile-shaped image results in the history.
for (const name of ['a', 'b', 'c']) {
ctx.agent.context.appendUserMessage(
[
{ type: 'text', text: `<image path="/workspace/${name}.png">` },
{ type: 'image_url', imageUrl: { url: `data:image/png;base64,${name}AAA` } },
{ type: 'text', text: '</image>' },
],
{ kind: 'user' },
);
}
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'inspect the screenshots' }] });
await ctx.untilTurnEnd();
expect(attempts).toBe(2);
// The first request carried all three images.
const firstParts = histories[0]!.flatMap((message) => message.content);
expect(firstParts.filter((part) => part.type === 'image_url')).toHaveLength(3);
// The retry keeps only the two most recent images; the oldest becomes a
// placeholder while its path wrapper survives for readback.
const retryParts = histories[1]!.flatMap((message) => message.content);
const retryImages = retryParts.filter((part) => part.type === 'image_url');
expect(retryImages).toHaveLength(2);
expect(
retryImages.map((part) => (part.type === 'image_url' ? part.imageUrl.url : '')),
).toEqual(['data:image/png;base64,bAAA', 'data:image/png;base64,cAAA']);
const retryText = retryParts
.filter((part) => part.type === 'text')
.map((part) => part.text)
.join('\n');
expect(retryText).toContain('[image omitted:');
expect(retryText).toContain('<image path="/workspace/a.png">');
// The real history is untouched.
expect(
ctx.agent.context.history
.flatMap((message) => message.content)
.filter((part) => part.type === 'image_url'),
).toHaveLength(3);
});
it('tracks turn_started and turn_interrupted telemetry', async () => {
const records: TelemetryRecord[] = [];
const ctx = testAgent({ telemetry: recordingTelemetry(records) });

View file

@ -104,6 +104,10 @@ keep_alive_on_exit = false
kill_grace_period_ms = 2000
print_wait_ceiling_s = 3600
[image]
max_edge_px = 1500
read_byte_budget = 131072
[[hooks]]
event = "PreToolUse"
matcher = "Shell"
@ -181,6 +185,7 @@ describe('harness config TOML loader', () => {
killGracePeriodMs: 2000,
printWaitCeilingS: 3600,
});
expect(config.image).toEqual({ maxEdgePx: 1500, readByteBudget: 131072 });
expect(config.hooks).toEqual([
{
event: 'PreToolUse',
@ -201,6 +206,23 @@ describe('harness config TOML loader', () => {
expect(config.raw?.['notifications']).toEqual({ claim_stale_after_ms: 15000 });
});
it('round-trips the [image] section', async () => {
const dir = makeTempDir();
const configPath = join(dir, 'image-round-trip.toml');
const toml = `
[image]
max_edge_px = 2500
read_byte_budget = 524288
`;
const config = parseConfigString(toml, configPath);
expect(config.image).toEqual({ maxEdgePx: 2500, readByteBudget: 524288 });
await writeConfigFile(configPath, config);
const text = await readFile(configPath, 'utf-8');
const roundTripped = parseConfigString(text, configPath);
expect(roundTripped.image).toEqual({ maxEdgePx: 2500, readByteBudget: 524288 });
});
it('round-trips a custom registry source field on a provider', async () => {
const dir = makeTempDir();
const configPath = join(dir, 'round-trip.toml');

View file

@ -1,14 +1,22 @@
/**
* Post-400 strict-resend fallback.
* Post-rejection resend fallbacks in `executeLoopStep`.
*
* When a strict provider rejects a step with a tool_use/tool_result adjacency
* 400, the same history would be re-sent every turn and the session would stay
* stuck forever. `executeLoopStep` resends ONCE with a strict, guaranteed
* wire-compliant rebuild (`buildMessagesStrict`). Any other error propagates
* unchanged and the strict builder is never consulted.
* Strict resend: when a strict provider rejects a step with a
* tool_use/tool_result adjacency 400, the same history would be re-sent every
* turn and the session would stay stuck forever. `executeLoopStep` resends
* ONCE with a strict, guaranteed wire-compliant rebuild
* (`buildMessagesStrict`).
*
* Media-degraded resend: when the provider rejects the request BODY as too
* large (HTTP 413, `APIRequestTooLargeError` accumulated base64 media, not
* tokens), the step resends ONCE with the media-degraded projection
* (`buildMessagesMediaDegraded`), and later steps of the same turn keep using
* it so each step does not pay a fresh 413.
*
* Any other error propagates unchanged and the builders are never consulted.
*/
import { APIStatusError, type Message } from '@moonshot-ai/kosong';
import { APIRequestTooLargeError, APIStatusError, type Message } from '@moonshot-ai/kosong';
import { describe, expect, it } from 'vitest';
import {
@ -18,8 +26,9 @@ import {
type RunTurnInput,
} from '../../src/loop/index';
import { CollectingSink } from './fixtures/collecting-sink';
import { FakeLLM, makeEndTurnResponse } from './fixtures/fake-llm';
import { FakeLLM, makeEndTurnResponse, makeToolCall, makeToolUseResponse } from './fixtures/fake-llm';
import { RecordingContext } from './fixtures/recording-context';
import { EchoTool } from './fixtures/tools';
const ADJACENCY_400 = new APIStatusError(
400,
@ -160,3 +169,154 @@ describe('executeLoopStep — tool exchange adjacency fallback', () => {
expect(strictCount).toBe(1);
});
});
describe('executeLoopStep — request-too-large media-degraded fallback', () => {
const REQUEST_TOO_LARGE = new APIRequestTooLargeError(413, 'Request exceeds the maximum size');
interface MediaHarness {
readonly input: RunTurnInput;
readonly llm: FakeLLM;
readonly degradedCalls: { count: number };
readonly degradedMessages: Message[];
readonly strictCalls: { count: number };
readonly normalCalls: { count: number };
}
function makeMediaHarness(
error: unknown,
extra: Partial<Pick<RunTurnInput, 'tools'>> & { responses?: number } = {},
): MediaHarness {
const responseCount = extra.responses ?? 2;
const llm = new FakeLLM({
responses: Array.from({ length: responseCount }, (_, index) =>
makeEndTurnResponse(index === 0 ? 'unused' : 'recovered'),
),
throwOnIndex: { index: 0, error },
});
const sink = new CollectingSink({});
const normalCalls = { count: 0 };
const normalMessages: Message[] = [userMessage('normal projection')];
const context = new RecordingContext({ messages: normalMessages });
const buildMessages: LoopMessageBuilder = () => {
normalCalls.count += 1;
return normalMessages;
};
const degradedMessages: Message[] = [userMessage('media-degraded projection')];
const degradedCalls = { count: 0 };
const buildMessagesMediaDegraded: LoopMessageBuilder = () => {
degradedCalls.count += 1;
return degradedMessages;
};
const strictCalls = { count: 0 };
const buildMessagesStrict: LoopMessageBuilder = () => {
strictCalls.count += 1;
return [userMessage('strict projection')];
};
const input: RunTurnInput = {
turnId: 'turn-1',
signal: new AbortController().signal,
llm,
buildMessages,
buildMessagesStrict,
buildMessagesMediaDegraded,
tools: extra.tools,
dispatchEvent: createLoopEventDispatcher({
appendTranscriptRecord: context.appendTranscriptRecord,
emitLiveEvent: sink.emit,
}),
};
return { input, llm, degradedCalls, degradedMessages, strictCalls, normalCalls };
}
it('resends once with the media-degraded projection after a request-too-large 413 and recovers', async () => {
const { input, llm, degradedCalls, degradedMessages, strictCalls } =
makeMediaHarness(REQUEST_TOO_LARGE);
const result = await runTurn(input);
expect(result.stopReason).toBe('end_turn');
// Exactly two provider calls: the rejected one and the degraded resend —
// and the strict builder is never consulted for a body-size rejection.
expect(llm.callCount).toBe(2);
expect(degradedCalls.count).toBe(1);
expect(strictCalls.count).toBe(0);
expect(llm.calls[0]?.messages).toEqual([userMessage('normal projection')]);
expect(llm.calls[1]?.messages).toBe(degradedMessages);
});
it('does not degrade for an unclassified 413 — the error propagates', async () => {
const { input, llm, degradedCalls } = makeMediaHarness(
new APIStatusError(413, 'Request failed'),
);
await expect(runTurn(input)).rejects.toThrow('Request failed');
expect(llm.callCount).toBe(1);
expect(degradedCalls.count).toBe(0);
});
it('resends only once: a degraded rebuild that is also rejected gives up (no loop)', async () => {
const llm = new FakeLLM({ responses: [] });
let calls = 0;
llm.chat = async () => {
calls += 1;
throw REQUEST_TOO_LARGE;
};
const sink = new CollectingSink({});
const context = new RecordingContext({ messages: [userMessage('normal')] });
let degradedCount = 0;
const input: RunTurnInput = {
turnId: 'turn-1',
signal: new AbortController().signal,
llm,
buildMessages: context.buildMessages,
buildMessagesMediaDegraded: () => {
degradedCount += 1;
return [userMessage('degraded')];
},
dispatchEvent: createLoopEventDispatcher({
appendTranscriptRecord: context.appendTranscriptRecord,
emitLiveEvent: sink.emit,
}),
};
await expect(runTurn(input)).rejects.toBe(REQUEST_TOO_LARGE);
expect(calls).toBe(2); // first attempt + one degraded resend, then give up
expect(degradedCount).toBe(1);
});
it('keeps using the degraded projection for later steps of the same turn', async () => {
// Step 1 is rejected with a 413 and recovers via the degraded projection,
// then issues a tool call; step 2 must build from the degraded projection
// directly — re-sending the full-media history would deterministically
// pay a fresh 413 on every step.
const echo = new EchoTool();
const llm = new FakeLLM({
responses: [
makeEndTurnResponse('unused'),
makeToolUseResponse([makeToolCall('echo', { text: 'hi' }, 'tc-1')]),
makeEndTurnResponse('done'),
],
throwOnIndex: { index: 0, error: REQUEST_TOO_LARGE },
});
const harness = makeMediaHarness(REQUEST_TOO_LARGE);
const input: RunTurnInput = {
...harness.input,
llm,
tools: [echo],
};
const result = await runTurn(input);
expect(result.stopReason).toBe('end_turn');
expect(llm.callCount).toBe(3);
// Step 1: normal projection rejected, degraded resend recovers.
expect(llm.calls[0]?.messages).toEqual([userMessage('normal projection')]);
expect(llm.calls[1]?.messages).toBe(harness.degradedMessages);
// Step 2: built straight from the degraded projection, not the normal one.
expect(llm.calls[2]?.messages).toBe(harness.degradedMessages);
expect(harness.normalCalls.count).toBe(1);
expect(harness.degradedCalls.count).toBe(2);
expect(echo.calls).toHaveLength(1);
});
});

View file

@ -10,8 +10,10 @@
* comes back as JPEG, strictly smaller than the input
* - alpha: a translucent PNG stays PNG when the budget allows, and only
* drops to JPEG as a last resort to meet a tiny budget
* - fallback: corrupt/empty bytes and non-recodable formats (GIF/WebP)
* return the original unchanged never throws
* - fallback: corrupt/empty bytes and non-recodable formats (GIF,
* animated WebP) return the original unchanged never throws
* - webp: still WebP decodes through the bundled wasm codec and re-encodes
* on the lossless-first ladder; animated WebP passes through whole
* - invariant: `changed` implies the result is strictly smaller
* - base64 wrapper round-trips
* - performance: the fast path is codec-free; a large image compresses
@ -32,8 +34,10 @@
* result is a no-op; extreme aspect ratios never collapse to zero
*/
import { createRequire } from 'node:module';
import { Jimp, ResizeStrategy } from 'jimp';
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
// eslint-disable-next-line import/no-unresolved
import {
@ -44,7 +48,14 @@ import {
cropImageForModel,
extractImageCompressionCaptions,
IMAGE_BYTE_BUDGET,
MAX_IMAGE_EDGE_ENV,
MAX_IMAGE_EDGE_PX,
READ_IMAGE_BYTE_BUDGET,
READ_IMAGE_BYTE_BUDGET_ENV,
resolveMaxImageEdgePx,
resolveReadImageByteBudget,
setConfiguredMaxImageEdgePx,
setConfiguredReadImageByteBudget,
} from '../../src/tools/support/image-compress';
// eslint-disable-next-line import/no-unresolved
import { sniffImageDimensions } from '../../src/tools/support/file-type';
@ -184,11 +195,11 @@ describe('compressImageForModel — dimension cap', () => {
const result = await compressImageForModel(png, 'image/png');
expect(result.changed).toBe(true);
expect(Math.max(result.width, result.height)).toBe(MAX_IMAGE_EDGE_PX);
// 4500x2250 → 3000x1500 (aspect 2:1 preserved).
expect(result.width).toBe(3000);
expect(result.height).toBe(1500);
// 4500x2250 → 2000x1000 (aspect 2:1 preserved).
expect(result.width).toBe(2000);
expect(result.height).toBe(1000);
const dims = sniffImageDimensions(result.data);
expect(dims).toEqual({ width: 3000, height: 1500 });
expect(dims).toEqual({ width: 2000, height: 1000 });
});
it('respects a custom maxEdge', async () => {
@ -239,9 +250,10 @@ describe('compressImageForModel — byte budget', () => {
});
it('steps down through the 2000px edge before the 1000px fallback', async () => {
// Regression guard for the 3000px cap raise: a PNG whose fitted encode
// is over budget but whose 2000px encode fits must come back at 2000px
// (as it did under the old cap), not skip straight to 1000px.
// Fallback-ladder guard, pinned with an explicit 3000px ceiling (the
// built-in default is 2000px, where the first fallback edge is a no-op):
// a PNG whose fitted encode is over budget but whose 2000px encode fits
// must come back at 2000px, not skip straight to 1000px.
// The budget is anchored to the actual 2000px encode size (probed with
// an unlimited budget) so the test does not depend on exact deflate
// output sizes.
@ -258,6 +270,7 @@ describe('compressImageForModel — byte budget', () => {
expect(probe.finalByteLength + 1024).toBeLessThan(png.length);
const result = await compressImageForModel(png, 'image/png', {
maxEdge: 3000,
byteBudget: probe.finalByteLength + 1024,
});
expect(result.changed).toBe(true);
@ -299,6 +312,195 @@ describe('compressImageForModel — byte budget', () => {
);
});
// ── webp fixtures (encoder wasm loaded manually from node_modules) ──
/**
* Encode RGBA pixels to WebP using the encoder wasm from node_modules
* test-fixture only; production never encodes WebP. The emscripten glue
* cannot auto-locate its wasm under Node (it tries fetch on a file URL), so
* the module is compiled and injected manually, mirroring how production
* initializes the decoder from the bundled base64.
*/
async function encodeWebp(
image: { bitmap: { data: Buffer | Uint8Array; width: number; height: number } },
quality = 90,
): Promise<Uint8Array> {
const requireLocal = createRequire(import.meta.url);
const encMod = (await import(
requireLocal.resolve('@jsquash/webp/encode.js')
)) as typeof import('@jsquash/webp/encode.js');
const { readFileSync } = await import('node:fs');
// The repo tsconfig has no DOM lib, so the global WebAssembly name is
// reached structurally (same approach as the production decoder).
const wasmNamespace = (
globalThis as unknown as { WebAssembly: { compile(bytes: Uint8Array): Promise<object> } }
).WebAssembly;
const wasm = await wasmNamespace.compile(
readFileSync(requireLocal.resolve('@jsquash/webp/codec/enc/webp_enc.wasm')),
);
await encMod.init(wasm as never);
const { bitmap } = image;
const encoded = await encMod.default(
{
data: new Uint8ClampedArray(
bitmap.data.buffer,
bitmap.data.byteOffset,
bitmap.data.byteLength,
),
width: bitmap.width,
height: bitmap.height,
} as never,
{ quality },
);
return new Uint8Array(encoded);
}
/** Minimal VP8X container header with the ANIM flag set. */
function animatedWebpHeader(): Uint8Array {
const bytes = new Uint8Array(30);
const ascii = (s: string, at: number) => {
for (let i = 0; i < s.length; i++) bytes[at + i] = s.charCodeAt(i);
};
ascii('RIFF', 0);
new DataView(bytes.buffer).setUint32(4, 22, true);
ascii('WEBP', 8);
ascii('VP8X', 12);
new DataView(bytes.buffer).setUint32(16, 10, true);
bytes[20] = 0x02; // ANIM flag
return bytes;
}
describe('compressImageForModel — webp', () => {
it(
'downscales an oversized WebP to the edge cap',
async () => {
const source = new Jimp({ width: 2600, height: 1300, color: 0x3366ccff });
const webp = await encodeWebp(source);
const result = await compressImageForModel(webp, 'image/webp');
expect(result.changed).toBe(true);
expect(Math.max(result.width, result.height)).toBe(2000);
expect(result.originalWidth).toBe(2600);
expect(result.originalHeight).toBe(1300);
expect(sniffImageDimensions(result.data)).toEqual({ width: 2000, height: 1000 });
},
15_000,
);
it(
're-encodes an over-budget WebP within the byte budget',
async () => {
const budget = 128 * 1024;
const noisy = new Jimp({ width: 1200, height: 1200, color: 0x000000ff });
fillXorshiftNoise(noisy.bitmap.data);
const webp = await encodeWebp(noisy, 100);
expect(webp.length).toBeGreaterThan(budget);
const result = await compressImageForModel(webp, 'image/webp', { byteBudget: budget });
expect(result.changed).toBe(true);
expect(result.finalByteLength).toBeLessThanOrEqual(budget);
},
15_000,
);
it(
'keeps alpha when re-encoding a translucent WebP',
async () => {
const translucent = new Jimp({ width: 2600, height: 1300, color: 0x33_66_cc_80 });
const webp = await encodeWebp(translucent);
const result = await compressImageForModel(webp, 'image/webp');
expect(result.changed).toBe(true);
expect(result.mimeType).toBe('image/png');
expect(await decodeAlpha(result.data)).toBe(true);
},
15_000,
);
it('passes an animated WebP through to preserve animation', async () => {
const animated = animatedWebpHeader();
const result = await compressImageForModel(animated, 'image/webp');
expect(result.changed).toBe(false);
expect(result.data).toBe(animated);
});
it(
'crops a region out of a WebP',
async () => {
const source = new Jimp({ width: 800, height: 400, color: 0x3366ccff });
const webp = await encodeWebp(source);
const result = await cropImageForModel(webp, 'image/webp', {
x: 10,
y: 20,
width: 300,
height: 200,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.width).toBe(300);
expect(result.height).toBe(200);
expect(result.originalWidth).toBe(800);
expect(result.originalHeight).toBe(400);
},
15_000,
);
});
// ── small byte budgets (per-image read budget scale) ────────────────
// These walk many encode rungs over noise fixtures, which is slow on CI
// runners — each carries an explicit timeout like the quality-ladder test
// above.
describe('compressImageForModel — small byte budgets', () => {
it(
'converges under a 128KB budget for high-entropy PNG content',
async () => {
// Statistically random noise is the entropy upper bound (photos, dense
// charts): the old [2000, 1000] fallback floor left q20@1000px at ~200KB,
// over a read-scale budget. The extended ladder must land within it.
const budget = 128 * 1024;
const png = await randomNoisePng(1200, 1200);
expect(png.length).toBeGreaterThan(budget);
const result = await compressImageForModel(png, 'image/png', { byteBudget: budget });
expect(result.changed).toBe(true);
expect(result.finalByteLength).toBeLessThanOrEqual(budget);
expect(sniffImageDimensions(result.data)).not.toBeNull();
},
15_000,
);
it(
'converges under a 128KB budget for a JPEG source',
async () => {
const budget = 128 * 1024;
const jpeg = await randomNoiseJpeg(1200, 1200);
expect(jpeg.length).toBeGreaterThan(budget);
const result = await compressImageForModel(jpeg, 'image/jpeg', { byteBudget: budget });
expect(result.changed).toBe(true);
expect(result.mimeType).toBe('image/jpeg');
expect(result.finalByteLength).toBeLessThanOrEqual(budget);
},
15_000,
);
it(
'shrinks pixels instead of passing through an already-optimized JPEG over budget',
async () => {
// A JPEG already at the encoder's quality floor for its size: re-encoding
// at the same size cannot shrink it, so without sub-size fallbacks the
// "unhelpful" guard used to return the original — silently over budget.
const image = new Jimp({ width: 900, height: 900, color: 0x000000ff });
fillXorshiftNoise(image.bitmap.data);
const optimized = new Uint8Array(await image.getBuffer('image/jpeg', { quality: 20 }));
const budget = optimized.length - 10 * 1024;
expect(budget).toBeGreaterThan(0);
const result = await compressImageForModel(optimized, 'image/jpeg', { byteBudget: budget });
expect(result.changed).toBe(true);
expect(result.finalByteLength).toBeLessThanOrEqual(budget);
expect(Math.max(result.width, result.height)).toBeLessThan(900);
},
15_000,
);
});
// ── fallback / robustness ────────────────────────────────────────────
describe('compressImageForModel — fallback', () => {
@ -325,7 +527,10 @@ describe('compressImageForModel — fallback', () => {
expect(result.data).toBe(gif);
});
it('passes WebP through (no codec in the default build)', async () => {
it('passes undecodable WebP bytes through (never throws)', async () => {
// A bare RIFF/WEBP container header with no image payload: the sniffer
// reports no dimensions and the wasm decoder cannot decode it, so it
// passes through unchanged like any other undecodable input.
const webp = new Uint8Array([
0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50,
]);
@ -444,7 +649,101 @@ describe('compressImageForModel — performance', () => {
it('exposes a sane default budget', () => {
expect(IMAGE_BYTE_BUDGET).toBeGreaterThan(0);
expect(MAX_IMAGE_EDGE_PX).toBe(3000);
expect(MAX_IMAGE_EDGE_PX).toBe(2000);
});
});
// ── default edge resolution (env + config) ──────────────────────────
describe('resolveMaxImageEdgePx', () => {
afterEach(() => {
vi.unstubAllEnvs();
setConfiguredMaxImageEdgePx(undefined);
});
it('defaults to the built-in ceiling', () => {
expect(resolveMaxImageEdgePx()).toBe(MAX_IMAGE_EDGE_PX);
});
it('uses the configured value when set, and clears with undefined', () => {
setConfiguredMaxImageEdgePx(1200);
expect(resolveMaxImageEdgePx()).toBe(1200);
setConfiguredMaxImageEdgePx(undefined);
expect(resolveMaxImageEdgePx()).toBe(MAX_IMAGE_EDGE_PX);
});
it('lets the env var override the configured value', () => {
setConfiguredMaxImageEdgePx(1200);
vi.stubEnv(MAX_IMAGE_EDGE_ENV, '900');
expect(resolveMaxImageEdgePx()).toBe(900);
});
it.each(['abc', '-100', '0', '1.5', ' '])('ignores the invalid env value "%s"', (raw) => {
vi.stubEnv(MAX_IMAGE_EDGE_ENV, raw);
expect(resolveMaxImageEdgePx()).toBe(MAX_IMAGE_EDGE_PX);
});
it('drives compressImageForModel when no explicit maxEdge is passed', async () => {
setConfiguredMaxImageEdgePx(1200);
const png = await solidPng(1600, 800);
const result = await compressImageForModel(png, 'image/png');
expect(result.changed).toBe(true);
expect(result.width).toBe(1200);
expect(result.height).toBe(600);
});
it('an explicit maxEdge option still wins over env and config', async () => {
setConfiguredMaxImageEdgePx(1200);
vi.stubEnv(MAX_IMAGE_EDGE_ENV, '900');
const png = await solidPng(1600, 800);
const result = await compressImageForModel(png, 'image/png', { maxEdge: 800 });
expect(result.changed).toBe(true);
expect(result.width).toBe(800);
expect(result.height).toBe(400);
});
it('drives cropImageForModel region fitting', async () => {
setConfiguredMaxImageEdgePx(400);
const png = await solidPng(1600, 800);
const result = await cropImageForModel(png, 'image/png', {
x: 0,
y: 0,
width: 800,
height: 800,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(Math.max(result.width, result.height)).toBe(400);
});
});
describe('resolveReadImageByteBudget', () => {
afterEach(() => {
vi.unstubAllEnvs();
setConfiguredReadImageByteBudget(undefined);
});
it('defaults to the built-in read budget', () => {
expect(READ_IMAGE_BYTE_BUDGET).toBe(256 * 1024);
expect(resolveReadImageByteBudget()).toBe(READ_IMAGE_BYTE_BUDGET);
});
it('uses the configured value when set, and clears with undefined', () => {
setConfiguredReadImageByteBudget(512 * 1024);
expect(resolveReadImageByteBudget()).toBe(512 * 1024);
setConfiguredReadImageByteBudget(undefined);
expect(resolveReadImageByteBudget()).toBe(READ_IMAGE_BYTE_BUDGET);
});
it('lets the env var override the configured value', () => {
setConfiguredReadImageByteBudget(512 * 1024);
vi.stubEnv(READ_IMAGE_BYTE_BUDGET_ENV, '100000');
expect(resolveReadImageByteBudget()).toBe(100000);
});
it.each(['abc', '-1', '0', '1.5', ' '])('ignores the invalid env value "%s"', (raw) => {
vi.stubEnv(READ_IMAGE_BYTE_BUDGET_ENV, raw);
expect(resolveReadImageByteBudget()).toBe(READ_IMAGE_BYTE_BUDGET);
});
});
@ -546,7 +845,7 @@ describe('compressImageForModel — original dimensions metadata', () => {
expect(shrunk.changed).toBe(true);
expect(shrunk.originalWidth).toBe(4500);
expect(shrunk.originalHeight).toBe(2250);
expect(shrunk.width).toBe(3000);
expect(shrunk.width).toBe(2000);
});
it('reports original dimensions through the base64 wrapper', async () => {
@ -556,8 +855,8 @@ describe('compressImageForModel — original dimensions metadata', () => {
expect(result.changed).toBe(true);
expect(result.originalWidth).toBe(3900);
expect(result.originalHeight).toBe(1950);
expect(result.width).toBe(3000);
expect(result.height).toBe(1500);
expect(result.width).toBe(2000);
expect(result.height).toBe(1000);
});
});
@ -680,7 +979,7 @@ describe('cropImageForModel', () => {
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toMatch(/PNG and JPEG/);
expect(result.error).toMatch(/PNG, JPEG, and WebP/);
});
it('rejects corrupt bytes without throwing', async () => {
@ -1021,14 +1320,14 @@ describe('compressImageForModel — downscale quality guards', () => {
});
it('keeps a degenerate aspect ratio at least 1px tall (no zero-size collapse)', async () => {
// 9000×2 scaled to a 3000px edge would round the short side to 0.67px;
// the resizer must clamp to 1, not produce an undecodable 3000×0 image.
// 9000×2 scaled to a 2000px edge would round the short side to 0.44px;
// the resizer must clamp to 1, not produce an undecodable 2000×0 image.
const png = await solidPng(9000, 2);
const result = await compressImageForModel(png, 'image/png');
expect(result.changed).toBe(true);
expect(result.width).toBe(3000);
expect(result.width).toBe(2000);
expect(result.height).toBe(1);
expect(sniffImageDimensions(result.data)).toEqual({ width: 3000, height: 1 });
expect(sniffImageDimensions(result.data)).toEqual({ width: 2000, height: 1 });
});
});
@ -1067,8 +1366,8 @@ describe('compressImageForModel — telemetry', () => {
expect(props['final_bytes']).toBe(result.finalByteLength);
expect(props['original_width']).toBe(4500);
expect(props['original_height']).toBe(2250);
expect(props['final_width']).toBe(3000);
expect(props['final_height']).toBe(1500);
expect(props['final_width']).toBe(2000);
expect(props['final_height']).toBe(1000);
expect(props['exif_transposed']).toBe(false);
expect(typeof props['duration_ms']).toBe('number');
});

View file

@ -5,7 +5,7 @@
import type { Kaos } from '@moonshot-ai/kaos';
import type { ContentPart, ModelCapability } from '@moonshot-ai/kosong';
import { Jimp } from 'jimp';
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ToolAccesses } from '../../src/loop';
import type { ExecutableToolResult } from '../../src/loop';
@ -13,9 +13,10 @@ import {
ReadMediaFileInputSchema,
ReadMediaFileTool,
} from '../../src/tools/builtin/file/read-media';
import { setConfiguredReadImageByteBudget } from '../../src/tools/support/image-compress';
import { MEDIA_SNIFF_BYTES, sniffImageDimensions } from '../../src/tools/support/file-type';
import type { TelemetryClient } from '../../src/telemetry';
import { createFakeKaos, PERMISSIVE_WORKSPACE } from './fixtures/fake-kaos';
import { createFakeKaos, FAKE_OS_ENV, PERMISSIVE_WORKSPACE } from './fixtures/fake-kaos';
import { executeTool } from './fixtures/execute-tool';
const signal = new AbortController().signal;
@ -711,7 +712,7 @@ describe('ReadMediaFileTool', () => {
// The image actually sent to the model is downsampled to the edge cap.
const sentBytes = Buffer.from(match![2]!, 'base64');
const sentDims = sniffImageDimensions(sentBytes);
expect(Math.max(sentDims!.width, sentDims!.height)).toBeLessThanOrEqual(3000);
expect(Math.max(sentDims!.width, sentDims!.height)).toBeLessThanOrEqual(2000);
// The <system> note keeps the ORIGINAL size so coordinate mapping holds.
const systemText = noteText(result);
@ -746,7 +747,7 @@ describe('ReadMediaFileTool', () => {
const systemText = noteText(result);
expect(systemText).toContain('Original dimensions: 1800x3600');
expect(systemText).toMatch(/downsampled to 1500x3000/);
expect(systemText).toMatch(/downsampled to 1000x2000/);
});
it('reports the decoded size for a region read of an EXIF-rotated image', async () => {
@ -893,7 +894,7 @@ describe('ReadMediaFileTool', () => {
// Wording must not depend on serialization order: some providers keep
// the note inline after the media, others flatten tool text and
// re-attach the image after it — so no "above"/"below".
expect(systemText).toMatch(/The attached image was downsampled to 3000x3000/);
expect(systemText).toMatch(/The attached image was downsampled to 2000x2000/);
expect(systemText).toMatch(/fine detail/i);
expect(systemText).toContain('region');
});
@ -1014,4 +1015,182 @@ describe('ReadMediaFileTool', () => {
expect(withFullRes.isError).toBe(true);
});
});
describe('provider-unsupported formats (HEIC/HEIF)', () => {
/** Minimal ISO-BMFF header: size + 'ftyp' + the given brand. */
function ftypHeader(brand: string): Buffer {
const bytes = Buffer.alloc(16);
bytes.writeUInt32BE(16, 0);
bytes.write('ftyp', 4, 'latin1');
bytes.write(brand, 8, 'latin1');
return bytes;
}
function heicTool(osKind: string, brand = 'heic'): ReadMediaFileTool {
const data = ftypHeader(brand);
const kaos = createFakeKaos({
stat: vi.fn<Kaos['stat']>().mockResolvedValue({ ...DEFAULT_STAT, stSize: data.length }),
readBytes: vi.fn<Kaos['readBytes']>().mockResolvedValue(data),
osEnv: { ...FAKE_OS_ENV, osKind },
});
return new ReadMediaFileTool(kaos, PERMISSIVE_WORKSPACE, capabilities());
}
it('refuses HEIC with sips guidance on macOS instead of sending it to the provider', async () => {
const result = await executeTool(heicTool('macOS'), {
turnId: 't1',
toolCallId: 'c_heic_mac',
args: { path: '/workspace/photo.HEIC' },
signal,
});
expect(result.isError).toBe(true);
expect(result.output).toContain('image/heic');
expect(result.output).toContain('sips -s format jpeg');
expect(result.output).toContain('/workspace/photo.jpg');
});
it('refuses HEIC with heif-convert / ImageMagick guidance on Linux', async () => {
const result = await executeTool(heicTool('Linux'), {
turnId: 't1',
toolCallId: 'c_heic_linux',
args: { path: '/workspace/photo.HEIC' },
signal,
});
expect(result.isError).toBe(true);
expect(result.output).toContain('heif-convert');
expect(result.output).toContain('magick');
});
it('refuses HEIC with ImageMagick guidance on Windows', async () => {
const result = await executeTool(heicTool('Windows'), {
turnId: 't1',
toolCallId: 'c_heic_win',
args: { path: '/workspace/photo.HEIC' },
signal,
});
expect(result.isError).toBe(true);
expect(result.output).toContain('magick');
expect(result.output).toContain('winget');
});
it('refuses HEIF brands and region reads the same way', async () => {
const heif = await executeTool(heicTool('macOS', 'mif1'), {
turnId: 't1',
toolCallId: 'c_heif',
args: { path: '/workspace/photo.heif' },
signal,
});
expect(heif.isError).toBe(true);
expect(heif.output).toContain('image/heif');
const region = await executeTool(heicTool('macOS'), {
turnId: 't1',
toolCallId: 'c_heic_region',
args: { path: '/workspace/photo.HEIC', region: { x: 0, y: 0, width: 10, height: 10 } },
signal,
});
expect(region.isError).toBe(true);
expect(region.output).toContain('sips');
});
});
describe('read byte budget', () => {
afterEach(() => {
setConfiguredReadImageByteBudget(undefined);
});
/** High-entropy PNG whose bytes stay large after downscaling. */
async function noisePng(width: number, height: number): Promise<Buffer> {
const image = new Jimp({ width, height, color: 0x000000ff });
const data = image.bitmap.data;
let state = 0x9e3779b9;
for (let i = 0; i < data.length; i += 4) {
state ^= (state << 13) >>> 0;
state ^= state >>> 17;
state ^= (state << 5) >>> 0;
state >>>= 0;
data[i] = state & 0xff;
data[i + 1] = (state >>> 8) & 0xff;
data[i + 2] = (state >>> 16) & 0xff;
data[i + 3] = 0xff;
}
return Buffer.from(await image.getBuffer('image/png'));
}
function toolFor(data: Buffer): ReadMediaFileTool {
return makeReadMediaTool({
stat: vi.fn<Kaos['stat']>().mockResolvedValue({ ...DEFAULT_STAT, stSize: data.length }),
readBytes: vi.fn<Kaos['readBytes']>().mockResolvedValue(data),
});
}
function sentBytes(result: Awaited<ReturnType<typeof executeTool>>): Buffer {
const parts = outputParts(result);
const url = (parts[1] as { imageUrl: { url: string } }).imageUrl.url;
const match = /^data:image\/[a-z]+;base64,(.+)$/.exec(url);
expect(match).not.toBeNull();
return Buffer.from(match![1]!, 'base64');
}
it(
'compresses a default read to fit the configured read budget',
async () => {
const budget = 64 * 1024;
setConfiguredReadImageByteBudget(budget);
const data = await noisePng(1200, 1200);
expect(data.length).toBeGreaterThan(budget);
const result = await executeTool(toolFor(data), {
turnId: 't1',
toolCallId: 'c_budget',
args: { path: '/workspace/noisy.png' },
signal,
});
expect(result.isError).toBe(false);
expect(sentBytes(result).length).toBeLessThanOrEqual(budget);
expect(noteText(result)).toMatch(/downsampled/);
},
15_000,
);
it('full_resolution ignores the read budget (per-image provider limit applies)', async () => {
setConfiguredReadImageByteBudget(64 * 1024);
const data = await noisePng(600, 600);
expect(data.length).toBeGreaterThan(64 * 1024);
const result = await executeTool(toolFor(data), {
turnId: 't1',
toolCallId: 'c_fullres_budget',
args: { path: '/workspace/noisy.png', full_resolution: true },
signal,
});
expect(result.isError).toBe(false);
// Delivered byte-for-byte: the read budget must not shrink the
// full-fidelity escape hatch the downsample note promises.
expect(sentBytes(result).equals(data)).toBe(true);
});
it(
'region reads ignore the read budget so detail readback stays full-fidelity',
async () => {
setConfiguredReadImageByteBudget(16 * 1024);
const data = await noisePng(800, 800);
const result = await executeTool(toolFor(data), {
turnId: 't1',
toolCallId: 'c_region_budget',
args: { path: '/workspace/noisy.png', region: { x: 0, y: 0, width: 400, height: 400 } },
signal,
});
expect(result.isError).toBe(false);
// A native-resolution noise crop is far larger than the read budget;
// it must still be delivered under the provider-scale budget.
expect(sentBytes(result).length).toBeGreaterThan(16 * 1024);
},
15_000,
);
});
});

View file

@ -56,6 +56,19 @@ export class APIContextOverflowError extends APIStatusError {
}
}
/**
* HTTP 413 that specifically means the serialized request body exceeded the
* provider's byte ceiling (e.g. accumulated base64 images), as opposed to a
* token-count overflow. Token overflow is recoverable by compaction; a body
* size rejection is not it needs media to be dropped or shrunk.
*/
export class APIRequestTooLargeError extends APIStatusError {
constructor(statusCode: number, message: string, requestId?: string | null) {
super(statusCode, message, requestId);
this.name = 'APIRequestTooLargeError';
}
}
/**
* HTTP status error that specifically means the provider rate-limited the
* request.
@ -146,6 +159,29 @@ const PROVIDER_RATE_LIMIT_MESSAGE_PATTERNS = [
/rate-limited/,
] as const;
// Wordings that mean the serialized request BODY was too big, matched against
// the lowercased message of a 413. Kept separate from the context-overflow
// patterns above: those describe token counts, these describe bytes. A 413
// whose message matches neither family stays a plain `APIStatusError` —
// Vertex phrases prompt-too-long as a 413, so the status alone is not proof
// of a body-size rejection.
const REQUEST_TOO_LARGE_MESSAGE_PATTERNS = [
// Moonshot / Kimi: "Request exceeds the maximum size".
/request exceeds the maximum size/,
// Reverse proxies (nginx-style HTML body): "413 Request Entity Too Large".
/request entity too large/,
// Anthropic: error type `request_too_large`, message "Request exceeds the
// maximum allowed number of bytes".
/request_too_large/,
/exceeds? the maximum allowed number of bytes/,
// RFC 9110 reason phrase (both the pre-2022 and current names).
/payload too large/,
/content too large/,
// Plain wordings: generic gateways say "request too large"; Go's
// http.MaxBytesReader (common in Go proxies) says "request body too large".
/request (?:body )?too large/,
] as const;
export function isContextOverflowErrorCode(code: string | null | undefined): boolean {
return code === 'context_length_exceeded';
}
@ -158,9 +194,14 @@ export function normalizeAPIStatusError(
if (statusCode === 429) {
return new APIProviderRateLimitError(message, requestId);
}
// Context overflow first: Vertex returns prompt-too-long as a 413, and a
// token overflow must keep routing to compaction even on that status.
if (isContextOverflowStatusError(statusCode, message)) {
return new APIContextOverflowError(statusCode, message, requestId);
}
if (isRequestTooLargeStatusError(statusCode, message)) {
return new APIRequestTooLargeError(statusCode, message, requestId);
}
return new APIStatusError(statusCode, message, requestId);
}
@ -170,6 +211,12 @@ export function isContextOverflowStatusError(statusCode: number, message: string
return CONTEXT_OVERFLOW_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage));
}
export function isRequestTooLargeStatusError(statusCode: number, message: string): boolean {
if (statusCode !== 413) return false;
const lowerMessage = message.toLowerCase();
return REQUEST_TOO_LARGE_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage));
}
// Strict providers reject a request whose assistant `tool_use`/`tool_calls` and
// `tool_result`/`tool` blocks are not correctly paired and adjacent — a missing
// result, a stray result with no matching call, or a result that does not

View file

@ -63,12 +63,14 @@ export {
APIContextOverflowError,
APIEmptyResponseError,
APIProviderRateLimitError,
APIRequestTooLargeError,
APIStatusError,
APITimeoutError,
ChatProviderError,
isContextOverflowStatusError,
isProviderRateLimitError,
isRecoverableRequestStructureError,
isRequestTooLargeStatusError,
isRetryableGenerateError,
isToolExchangeAdjacencyError,
} from './errors';

View file

@ -3,6 +3,7 @@ import {
APIContextOverflowError,
APIEmptyResponseError,
APIProviderRateLimitError,
APIRequestTooLargeError,
APIStatusError,
APITimeoutError,
ChatProviderError,
@ -112,6 +113,23 @@ describe('APIProviderRateLimitError', () => {
});
});
describe('APIRequestTooLargeError', () => {
it('extends APIStatusError and preserves HTTP details', () => {
const err = new APIRequestTooLargeError(413, 'Request exceeds the maximum size.', 'req-large');
expect(err).toBeInstanceOf(APIStatusError);
expect(err).toBeInstanceOf(ChatProviderError);
expect(err.name).toBe('APIRequestTooLargeError');
expect(err.statusCode).toBe(413);
expect(err.requestId).toBe('req-large');
});
it('is not retryable', () => {
expect(
isRetryableGenerateError(new APIRequestTooLargeError(413, 'Request exceeds the maximum size.')),
).toBe(false);
});
});
describe('isRetryableGenerateError', () => {
it('matches transient provider errors and empty generate responses', () => {
expect(isRetryableGenerateError(new APIConnectionError('conn'))).toBe(true);
@ -209,6 +227,52 @@ describe('normalizeAPIStatusError', () => {
expect(error).toBeInstanceOf(APIStatusError);
expect(error).not.toBeInstanceOf(APIContextOverflowError);
});
it.each([
// Moonshot / Kimi 413 observed in the field when accumulated media pushed
// the request body over the provider's byte ceiling.
[413, 'Request exceeds the maximum size'],
// Reverse-proxy (nginx-style) 413 with an HTML body.
[413, '413 <html><head><title>413 Request Entity Too Large</title></head></html>'],
// Anthropic request_too_large: body over the 32 MB API ceiling.
[413, 'request_too_large: Request exceeds the maximum allowed number of bytes'],
// RFC 9110 reason phrase / Node-style wording.
[413, 'Payload Too Large'],
[413, 'Content Too Large'],
// Plain wordings without "entity": generic gateways say "Request too
// large"; Go's http.MaxBytesReader says "http: request body too large".
[413, 'Request too large'],
[413, 'Request body too large'],
[413, 'http: request body too large'],
])('normalizes %i "%s" to APIRequestTooLargeError', (statusCode, message) => {
const error = normalizeAPIStatusError(statusCode, message, 'req-large');
expect(error).toBeInstanceOf(APIRequestTooLargeError);
expect(error.statusCode).toBe(statusCode);
expect(error.requestId).toBe('req-large');
});
it('keeps a 413 with token-overflow wording as APIContextOverflowError', () => {
// Vertex phrases prompt-too-long as a 413; that is a token problem
// (recoverable by compaction), not a request-body-size problem.
const error = normalizeAPIStatusError(413, 'prompt is too long: 210000 tokens > 200000 maximum');
expect(error).toBeInstanceOf(APIContextOverflowError);
expect(error).not.toBeInstanceOf(APIRequestTooLargeError);
});
it.each([
// A bare 413 with unrecognized wording stays unclassified: Vertex abuses
// 413 for prompt-too-long, so the status alone is not proof of a
// body-size rejection.
[413, 'Request failed'],
// Size wording without the 413 status is not classified either.
[400, 'Payload too large'],
[422, 'Request entity too large'],
])('keeps %i "%s" as plain APIStatusError', (statusCode, message) => {
const error = normalizeAPIStatusError(statusCode, message);
expect(error).toBeInstanceOf(APIStatusError);
expect(error).not.toBeInstanceOf(APIRequestTooLargeError);
expect(error).not.toBeInstanceOf(APIContextOverflowError);
});
});
describe('isToolExchangeAdjacencyError', () => {

94
pnpm-lock.yaml generated
View file

@ -59,7 +59,7 @@ importers:
version: 2.13.1
tsdown:
specifier: 0.22.0
version: 0.22.0(@arethetypeswrong/core@0.18.2)(publint@0.3.18)(tsx@4.21.0)(typescript@6.0.2)(unrun@0.2.34)(vue-tsc@3.2.9(typescript@6.0.2))
version: 0.22.0(@arethetypeswrong/core@0.18.2)(publint@0.3.18)(tsx@4.21.0)(typescript@6.0.2)(unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0))(vue-tsc@3.2.9(typescript@6.0.2))
tsx:
specifier: ^4.21.0
version: 4.21.0
@ -354,6 +354,9 @@ importers:
'@antfu/utils':
specifier: ^9.3.0
version: 9.3.0
'@jsquash/webp':
specifier: ^1.5.0
version: 1.5.0
'@modelcontextprotocol/sdk':
specifier: ^1.29.0
version: 1.29.0(zod@4.3.6)
@ -1791,6 +1794,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@jsquash/webp@1.5.0':
resolution: {integrity: sha512-KggLoj2MnRSfIqTeKe1EmbljTX2vuV7mh79k89PCL1pyqiDULcPM1L47twxXt0hkb68F70bXiL31MxsuoZtKFw==}
'@loaderkit/resolve@1.0.4':
resolution: {integrity: sha512-rJzYKVcV4dxJv+vW6jlvagF8zvGxHJ2+HTr1e2qOejfmGhAApgJHl8Aog4mMszxceTRiKTTbnpgmTO1bEZHV/A==}
@ -7411,6 +7417,9 @@ packages:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
wasm-feature-detect@1.8.0:
resolution: {integrity: sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==}
wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
@ -8842,6 +8851,10 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@jsquash/webp@1.5.0':
dependencies:
wasm-feature-detect: 1.8.0
'@loaderkit/resolve@1.0.4':
dependencies:
'@braidai/lang': 1.1.2
@ -8993,11 +9006,6 @@ snapshots:
'@mozilla/readability@0.6.0': {}
'@napi-rs/wasm-runtime@1.1.4':
dependencies:
'@tybys/wasm-util': 0.10.1
optional: true
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0
@ -9258,14 +9266,6 @@ snapshots:
'@rolldown/binding-openharmony-arm64@1.0.1':
optional: true
'@rolldown/binding-wasm32-wasi@1.0.0-rc.12':
dependencies:
'@napi-rs/wasm-runtime': 1.1.4
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
optional: true
'@rolldown/binding-wasm32-wasi@1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
@ -9963,7 +9963,7 @@ snapshots:
obug: 2.1.1
std-env: 4.0.0
tinyrainbow: 3.1.0
vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))
vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/expect@4.1.4':
dependencies:
@ -13777,31 +13777,6 @@ snapshots:
transitivePeerDependencies:
- oxc-resolver
rolldown@1.0.0-rc.12:
dependencies:
'@oxc-project/types': 0.122.0
'@rolldown/pluginutils': 1.0.0-rc.12
optionalDependencies:
'@rolldown/binding-android-arm64': 1.0.0-rc.12
'@rolldown/binding-darwin-arm64': 1.0.0-rc.12
'@rolldown/binding-darwin-x64': 1.0.0-rc.12
'@rolldown/binding-freebsd-x64': 1.0.0-rc.12
'@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.12
'@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.12
'@rolldown/binding-linux-arm64-musl': 1.0.0-rc.12
'@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.12
'@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.12
'@rolldown/binding-linux-x64-gnu': 1.0.0-rc.12
'@rolldown/binding-linux-x64-musl': 1.0.0-rc.12
'@rolldown/binding-openharmony-arm64': 1.0.0-rc.12
'@rolldown/binding-wasm32-wasi': 1.0.0-rc.12
'@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.12
'@rolldown/binding-win32-x64-msvc': 1.0.0-rc.12
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
optional: true
rolldown@1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0):
dependencies:
'@oxc-project/types': 0.122.0
@ -14553,35 +14528,6 @@ snapshots:
- oxc-resolver
- vue-tsc
tsdown@0.22.0(@arethetypeswrong/core@0.18.2)(publint@0.3.18)(tsx@4.21.0)(typescript@6.0.2)(unrun@0.2.34)(vue-tsc@3.2.9(typescript@6.0.2)):
dependencies:
ansis: 4.2.0
cac: 7.0.0
defu: 6.1.7
empathic: 2.0.0
hookable: 6.1.1
import-without-cache: 0.4.0
obug: 2.1.1
picomatch: 4.0.4
rolldown: 1.0.1
rolldown-plugin-dts: 0.25.1(rolldown@1.0.1)(typescript@6.0.2)(vue-tsc@3.2.9(typescript@6.0.2))
semver: 7.7.4
tinyexec: 1.1.2
tinyglobby: 0.2.16
tree-kill: 1.2.2
unconfig-core: 7.5.0
optionalDependencies:
'@arethetypeswrong/core': 0.18.2
publint: 0.3.18
tsx: 4.21.0
typescript: 6.0.2
unrun: 0.2.34
transitivePeerDependencies:
- '@ts-macro/tsc'
- '@typescript/native-preview'
- oxc-resolver
- vue-tsc
tslib@2.8.1: {}
tsx@4.21.0:
@ -14745,14 +14691,6 @@ snapshots:
picomatch: 4.0.4
webpack-virtual-modules: 0.6.2
unrun@0.2.34:
dependencies:
rolldown: 1.0.0-rc.12
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
optional: true
unrun@0.2.34(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0):
dependencies:
rolldown: 1.0.0-rc.12(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
@ -15014,6 +14952,8 @@ snapshots:
xml-name-validator: 5.0.0
optional: true
wasm-feature-detect@1.8.0: {}
wcwidth@1.0.1:
dependencies:
defaults: 1.0.4