Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/image-request-size-limits.md
Original file line number Diff line number Diff line change
@@ -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.

Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
14 changes: 13 additions & 1 deletion docs/en/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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`

Expand Down
2 changes: 2 additions & 0 deletions docs/en/configuration/env-vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
14 changes: 13 additions & 1 deletion docs/zh/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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`

Expand Down
2 changes: 2 additions & 0 deletions docs/zh/configuration/env-vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@
inherit (finalAttrs) pname version src pnpmWorkspaces;
inherit pnpm;
fetcherVersion = 3;
hash = "sha256-hUn5Srn3HnEEzU5DLxgjIzFjI0ukM3iSP4QagftEXdE=";
hash = "sha256-i3T30dxNQ7DTWOmUX47kmqpIVtDYby0buPo58+Jhhmw=";
};

nativeBuildInputs = [
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:^",
Expand Down
36 changes: 36 additions & 0 deletions packages/agent-core/scripts/generate-webp-dec-wasm.mjs
Original file line number Diff line number Diff line change
@@ -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)`);
59 changes: 58 additions & 1 deletion packages/agent-core/src/agent/compaction/full.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import {
APIEmptyResponseError,
inputTotal,
isRetryableGenerateError,
type ContentPart,
type GenerateResult,
type Message,
type TokenUsage,
APIContextOverflowError,
APIRequestTooLargeError,
APIStatusError,
createUserMessage,
} from '@moonshot-ai/kosong';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -465,14 +468,34 @@ 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,
);
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;
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions packages/agent-core/src/agent/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
type CompactionResult,
} from '../compaction';
import {
degradeOlderMediaParts,
MEDIA_DEGRADE_KEEP_RECENT,
project,
type ProjectionAnomaly,
type ProjectOptions,
Expand Down Expand Up @@ -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)));
Expand Down
55 changes: 55 additions & 0 deletions packages/agent-core/src/agent/context/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Comment on lines +510 to +511

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Degrade media even when one recent item is too large

When the 413 is caused by one or two large media parts, this returns the original messages unchanged because mediaCount <= keepRecent. That can happen with default ReadMediaFile reads despite the new read budget, since the compressor still passes through guarded/unsupported inputs (for example a >64 MB image under the 100 MB media limit, or an inline video), so the single retry in executeLoopStep resends the same oversized body and the session remains stuck. The fallback needs a way to drop or shrink recent media when those parts alone exceed the provider request limit.

Useful? React with 👍 / 👎.


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 };
});
}
5 changes: 3 additions & 2 deletions packages/agent-core/src/agent/records/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
Loading
Loading