Skip to content

Commit 1bf2c9a

Browse files
authored
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
1 parent b91099e commit 1bf2c9a

36 files changed

Lines changed: 1838 additions & 214 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@moonshot-ai/kimi-code": patch
3+
---
4+
5+
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.
6+

apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,14 +141,14 @@ describe('clipboard image paste compression', () => {
141141
if (att?.kind !== 'image') throw new Error('expected image attachment');
142142

143143
// Stored metadata reflects the compressed size.
144-
expect(Math.max(att.width, att.height)).toBeLessThanOrEqual(3000);
145-
expect(att.placeholder).toContain('3000×1500');
144+
expect(Math.max(att.width, att.height)).toBeLessThanOrEqual(2000);
145+
expect(att.placeholder).toContain('2000×1000');
146146

147147
// The stored bytes decode to the compressed dimensions — the thumbnail and
148148
// the submitted image both read from these bytes, so they cannot diverge.
149149
const dims = parseImageMeta(att.bytes);
150150
expect(dims).not.toBeNull();
151-
expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(3000);
151+
expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(2000);
152152
});
153153

154154
it('records and persists the pre-compression original for an oversized paste', async () => {

docs/en/configuration/config-files.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,12 @@ Fields in the config file fall into two categories: **top-level scalars** that d
8787
| `thinking` | `table` || Default parameters for Thinking mode → [`thinking`](#thinking) |
8888
| `loop_control` | `table` || Agent loop control parameters → [`loop_control`](#loop_control) |
8989
| `background` | `table` || Background task runtime parameters → [`background`](#background) |
90+
| `image` | `table` || Image compression parameters → [`image`](#image) |
9091
| `services` | `table` || Built-in external service configuration → [`services`](#services) |
9192
| `permission` | `table` || Initial permission rules → [`permission`](#permission) |
9293
| `hooks` | `array<table>` || Lifecycle hooks; see [Hooks](../customization/hooks.md) |
9394

94-
The following sections cover each of the nested tables in turn: `providers`, `models`, `thinking`, `loop_control`, `background`, `services`, and `permission`.
95+
The following sections cover each of the nested tables in turn: `providers`, `models`, `thinking`, `loop_control`, `background`, `image`, `services`, and `permission`.
9596

9697
## `providers`
9798

@@ -202,6 +203,17 @@ You can also switch models temporarily without touching the config file — by s
202203

203204
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.
204205

206+
## `image`
207+
208+
`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).
209+
210+
| Field | Type | Default | Description |
211+
| --- | --- | --- | --- |
212+
| `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 |
213+
| `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) |
214+
215+
`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`.
216+
205217
<!--
206218
## `experimental`
207219

docs/en/configuration/env-vars.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ Switches that control the behavior of subsystems such as telemetry, background t
122122
| --- | --- | --- |
123123
| `KIMI_DISABLE_TELEMETRY` | Disable anonymous telemetry reporting | `1`, `true`, `yes`, `y` (case-insensitive) |
124124
| `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` |
125+
| `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 |
126+
| `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 |
125127
| `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 |
126128
| `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 |
127129
| `KIMI_CODE_EXPERIMENTAL_FLAG` | Enable all registered experimental features for this process | `1`, `true`, `yes`, `on` |

docs/zh/configuration/config-files.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,12 @@ timeout = 5
8787
| `thinking` | `table` || Thinking 模式默认参数 → [`thinking`](#thinking) |
8888
| `loop_control` | `table` || Agent 循环控制参数 → [`loop_control`](#loop_control) |
8989
| `background` | `table` || 后台任务运行参数 → [`background`](#background) |
90+
| `image` | `table` || 图片压缩参数 → [`image`](#image) |
9091
| `services` | `table` || 内置外部服务配置 → [`services`](#services) |
9192
| `permission` | `table` || 初始权限规则 → [`permission`](#permission) |
9293
| `hooks` | `array<table>` || 生命周期 hook,详见 [Hooks](../customization/hooks.md) |
9394

94-
以下各节对 `providers``models``thinking``loop_control``background``services``permission` 等嵌套表逐一展开。
95+
以下各节对 `providers``models``thinking``loop_control``background``image``services``permission` 等嵌套表逐一展开。
9596

9697
## `providers`
9798

@@ -202,6 +203,17 @@ display_name = "Kimi for Coding (custom)"
202203

203204
在 print 模式(`kimi -p "<prompt>"`)下,Kimi Code 只跑一个非交互的单轮 turn,主 agent 一结束就退出。如果你启动了后台任务(例如通过 `Agent(run_in_background=true)` 并发子代理)并希望它们跑完,请设置 `keep_alive_on_exit = true`:进程会在退出前等待所有后台任务进入终态,最长不超过 `print_wait_ceiling_s`。否则,单轮 turn 结束时后台任务会随进程一起被清理。
204205

206+
## `image`
207+
208+
`image` 控制图片发送给模型前的压缩行为,对所有图片入口生效(粘贴图片、`ReadMediaFile` 读图、MCP 工具结果里的图片等)。
209+
210+
| 字段 | 类型 | 默认值 | 说明 |
211+
| --- | --- | --- | --- |
212+
| `max_edge_px` | `integer` | `2000` | 图片最长边上限(像素)。超过时按比例缩小到该值以内;调大可保留更多细节,代价是更大的请求体积 |
213+
| `read_byte_budget` | `integer` | `262144`(256 KB) | 模型自行读取的图片(`ReadMediaFile` 默认读取)的单图字节预算。会话中模型反复截图、读图时,累计请求体大小由它控制;细节可通过 `region` 参数按原图坐标全保真回读(`region``full_resolution` 不受此预算限制) |
214+
215+
`max_edge_px` 可被环境变量 `KIMI_IMAGE_MAX_EDGE_PX` 覆盖,`read_byte_budget` 可被 `KIMI_IMAGE_READ_BYTE_BUDGET` 覆盖,优先级均高于配置文件。
216+
205217
<!--
206218
## `experimental`
207219

docs/zh/configuration/env-vars.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ kimi
122122
| --- | --- | --- |
123123
| `KIMI_DISABLE_TELEMETRY` | 关闭匿名遥测上报 | `1``true``yes``y`(不区分大小写) |
124124
| `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT` | 会话关闭时是否保留后台任务,优先级高于 `config.toml`。默认会在退出时停止后台任务 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` |
125+
| `KIMI_IMAGE_MAX_EDGE_PX` | 图片压缩的最长边上限(像素),优先级高于 `config.toml``[image] max_edge_px`(默认 `2000`| 正整数;非法值被忽略 |
126+
| `KIMI_IMAGE_READ_BYTE_BUDGET` | 模型自行读图(`ReadMediaFile` 默认读取)的单图字节预算,优先级高于 `config.toml``[image] read_byte_budget`(默认 `262144`,即 256 KB) | 正整数;非法值被忽略 |
125127
| `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 和本地路径 |
126128
| `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | 限制 AgentSwarm 初始提升并发阶段可同时运行的子 Agent 数量;不设置表示不限制 | 正整数;非法值会立即失败 |
127129
| `KIMI_CODE_EXPERIMENTAL_FLAG` | 在当前进程启用所有已注册的实验功能 | `1``true``yes``on` |

flake.nix

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@
152152
inherit (finalAttrs) pname version src pnpmWorkspaces;
153153
inherit pnpm;
154154
fetcherVersion = 3;
155-
hash = "sha256-hUn5Srn3HnEEzU5DLxgjIzFjI0ukM3iSP4QagftEXdE=";
155+
hash = "sha256-i3T30dxNQ7DTWOmUX47kmqpIVtDYby0buPo58+Jhhmw=";
156156
};
157157

158158
nativeBuildInputs = [

packages/agent-core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
},
6060
"dependencies": {
6161
"@antfu/utils": "^9.3.0",
62+
"@jsquash/webp": "^1.5.0",
6263
"@modelcontextprotocol/sdk": "^1.29.0",
6364
"@moonshot-ai/kaos": "workspace:^",
6465
"@moonshot-ai/kimi-code-oauth": "workspace:^",
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* Regenerate `src/tools/support/webp-dec-wasm.ts` from the installed
3+
* `@jsquash/webp` package.
4+
*
5+
* The WebP decoder wasm is committed as a base64 string module because the
6+
* published CLI bundles every dependency into a single file with no runtime
7+
* node_modules — a file-path lookup for the .wasm would break there, while a
8+
* string constant survives every packaging (vitest on sources, tsdown
9+
* bundling, nix builds) unchanged. Run this after bumping @jsquash/webp:
10+
*
11+
* node scripts/generate-webp-dec-wasm.mjs
12+
*/
13+
import { createRequire } from 'node:module';
14+
import { readFileSync, writeFileSync } from 'node:fs';
15+
import { resolve } from 'node:path';
16+
17+
const packageRoot = resolve(import.meta.dirname, '..');
18+
const require = createRequire(resolve(packageRoot, 'package.json'));
19+
20+
const wasmPath = require.resolve('@jsquash/webp/codec/dec/webp_dec.wasm');
21+
const version = require('@jsquash/webp/package.json').version;
22+
const wasm = readFileSync(wasmPath);
23+
24+
const target = resolve(packageRoot, 'src/tools/support/webp-dec-wasm.ts');
25+
writeFileSync(
26+
target,
27+
`// GENERATED FILE — do not edit by hand.
28+
// WebP decoder wasm from @jsquash/webp@${version} (codec/dec/webp_dec.wasm),
29+
// base64-encoded so the bundled CLI needs no on-disk wasm asset.
30+
// Regenerate with: node scripts/generate-webp-dec-wasm.mjs
31+
32+
export const WEBP_DECODER_WASM_BASE64 =
33+
'${wasm.toString('base64')}';
34+
`,
35+
);
36+
console.log(`Wrote ${target} (${wasm.length} bytes of wasm)`);

packages/agent-core/src/agent/compaction/full.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@ import {
88
APIEmptyResponseError,
99
inputTotal,
1010
isRetryableGenerateError,
11+
type ContentPart,
1112
type GenerateResult,
1213
type Message,
1314
type TokenUsage,
1415
APIContextOverflowError,
16+
APIRequestTooLargeError,
1517
APIStatusError,
1618
createUserMessage,
1719
} from '@moonshot-ai/kosong';
@@ -430,6 +432,7 @@ export class FullCompaction {
430432
// prefix-race check and `compactedCount`.
431433
let historyForModel: readonly ContextMessage[] = stripDynamicToolContext(originalHistory);
432434
let droppedCount = 0;
435+
let mediaStripAttempted = false;
433436
let overflowShrinkCount = 0;
434437
let emptyOrTruncatedShrinkCount = 0;
435438
while (true) {
@@ -465,14 +468,34 @@ export class FullCompaction {
465468
summary = extractCompactionSummary(response);
466469
break;
467470
} catch (error) {
471+
// A request-body-size rejection (HTTP 413) is first retried with
472+
// media parts replaced by text markers: accumulated base64 payloads
473+
// are the usual culprit, and a text summary does not need them —
474+
// the conversation already narrates what was seen, and the
475+
// ReadMediaFile `<image path="...">` text wrapper survives. Only
476+
// the summarizer input copy is rewritten; the real history keeps
477+
// its media. A 413 after the strip (or with no media to strip)
478+
// falls through to the overflow shrink below — dropping oldest
479+
// messages shrinks the body too.
480+
if (error instanceof APIRequestTooLargeError && !mediaStripAttempted) {
481+
mediaStripAttempted = true;
482+
const stripped = replaceMediaPartsWithMarkers(historyForModel);
483+
if (stripped !== historyForModel) {
484+
historyForModel = stripped;
485+
retryCount = 0;
486+
continue;
487+
}
488+
}
468489
const isContextOverflow = this.shouldRecoverFromContextOverflow(
469490
error,
470491
estimatedCompactionRequestTokens,
471492
);
472493
if (isContextOverflow) {
473494
this.observeContextOverflow(estimatedCompactionRequestTokens);
474495
}
475-
if (isContextOverflow && historyForModel.length > 1) {
496+
const shouldShrinkAfterOverflow =
497+
isContextOverflow || error instanceof APIRequestTooLargeError;
498+
if (shouldShrinkAfterOverflow && historyForModel.length > 1) {
476499
overflowShrinkCount += 1;
477500
if (overflowShrinkCount > MAX_COMPACTION_OVERFLOW_SHRINK_ATTEMPTS) {
478501
throw error;
@@ -639,6 +662,40 @@ export class FullCompaction {
639662
const MAX_COMPACTION_OVERFLOW_SHRINK_ATTEMPTS = 3;
640663
const COMPACTION_OVERFLOW_SHRINK_RATIOS = [0.7, 0.5, 0.35] as const;
641664

665+
const MEDIA_PART_MARKERS = {
666+
image_url: '[image]',
667+
audio_url: '[audio]',
668+
video_url: '[video]',
669+
} as const;
670+
671+
function isMediaPart(part: ContentPart): part is ContentPart & { type: keyof typeof MEDIA_PART_MARKERS } {
672+
return part.type in MEDIA_PART_MARKERS;
673+
}
674+
675+
/**
676+
* Replace media parts (image/audio/video) with text markers in the summarizer
677+
* input, for the 413 strip-and-retry above. Messages without media are
678+
* returned by reference (keeping the per-message token-estimate cache warm),
679+
* and when nothing changed the input array itself is returned so the caller
680+
* can tell there was no media to strip.
681+
*/
682+
function replaceMediaPartsWithMarkers(
683+
messages: readonly ContextMessage[],
684+
): readonly ContextMessage[] {
685+
let changed = false;
686+
const out = messages.map((message) => {
687+
if (!message.content.some(isMediaPart)) return message;
688+
changed = true;
689+
return {
690+
...message,
691+
content: message.content.map((part): ContentPart =>
692+
isMediaPart(part) ? { type: 'text', text: MEDIA_PART_MARKERS[part.type] } : part,
693+
),
694+
};
695+
});
696+
return changed ? out : messages;
697+
}
698+
642699
function shrinkCompactionHistoryAfterOverflow<T extends Message>(
643700
messages: readonly T[],
644701
attempt: number,

0 commit comments

Comments
 (0)