Skip to content

Commit 046b6c4

Browse files
authored
fix(agent-core): scope [image] config limits to the owning core (#1521)
* fix(agent-core): scope [image] config limits to the owning core * fix(agent-core): thread harness [image] max_edge_px to TUI paste and ACP ingestion * chore(changeset): simplify entry to user-facing wording
1 parent a354803 commit 046b6c4

25 files changed

Lines changed: 491 additions & 86 deletions

File tree

.changeset/scoped-image-limits.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": patch
3+
---
4+
5+
The `[image]` limits in config.toml now also apply to pasted images (CLI paste and ACP prompts), and each core now uses its own settings, so reloading one client's config no longer changes another client's image compression.

apps/kimi-code/src/tui/controllers/editor-keyboard.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Session } from '@moonshot-ai/kimi-code-sdk';
1+
import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk';
22
import { compressImageForModel, persistOriginalImage, sessionMediaOriginalsDir } from '@moonshot-ai/kimi-code-sdk';
33

44
import { ClipboardMediaError, readClipboardMedia } from '#/utils/clipboard/clipboard-image';
@@ -23,6 +23,12 @@ export interface EditorKeyboardHost {
2323
state: TUIState;
2424
session: Session | undefined;
2525
cancelInFlight: (() => void) | undefined;
26+
/**
27+
* The host's harness (KimiTUI always has one). Its `imageLimits` drives
28+
* paste-time image compression; hosts without one fall back to the
29+
* env/built-in default.
30+
*/
31+
harness?: KimiHarness | undefined;
2632

2733
handleUserInput(text: string): void;
2834
readonly btwPanelController: BtwPanelController;
@@ -407,7 +413,11 @@ export class EditorKeyboardController {
407413
// session's media-originals dir when known, else the temp-dir fallback)
408414
// and recorded on the attachment, so submit-time expansion can announce
409415
// the compression and point the model at the full-fidelity copy.
416+
// The edge cap comes from the host harness's [image] config (resolved per
417+
// paste so a config reload applies immediately); hosts without a harness
418+
// use the env/built-in default.
410419
const compressed = await compressImageForModel(media.bytes, meta.mime, {
420+
maxEdge: this.host.harness?.imageLimits?.maxEdgePx(),
411421
telemetry: {
412422
client: {
413423
track: (event, properties) =>

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

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
} from '#/tui/controllers/editor-keyboard';
2626
import { ImageAttachmentStore } from '#/tui/utils/image-attachment-store';
2727
import { parseImageMeta } from '#/utils/image/image-mime';
28+
import { ImageLimits, type KimiHarness } from '@moonshot-ai/kimi-code-sdk';
2829

2930
// vitest hoists vi.mock/vi.hoisted above the imports above, so the mock still
3031
// applies to the editor-keyboard module that pulls in readClipboardMedia.
@@ -41,7 +42,7 @@ interface PasteHarness {
4142
pasteImage(): Promise<void>;
4243
}
4344

44-
function createPasteHarness(options: { sessionDir?: string } = {}): PasteHarness {
45+
function createPasteHarness(options: { sessionDir?: string; imageLimits?: ImageLimits } = {}): PasteHarness {
4546
const editor: Record<string, ((...args: never[]) => unknown) | undefined> = {
4647
setHistoryFilter: vi.fn() as unknown as (...args: never[]) => unknown,
4748
};
@@ -65,6 +66,11 @@ function createPasteHarness(options: { sessionDir?: string } = {}): PasteHarness
6566
openUndoSelector: vi.fn(),
6667
cancelRunningShellCommand: vi.fn(),
6768
} as unknown as EditorKeyboardHost;
69+
if (options.imageLimits !== undefined) {
70+
(host as unknown as { harness: KimiHarness }).harness = {
71+
imageLimits: options.imageLimits,
72+
} as unknown as KimiHarness;
73+
}
6874

6975
const controller = new EditorKeyboardController(host, store);
7076
controller.install();
@@ -151,6 +157,25 @@ describe('clipboard image paste compression', () => {
151157
expect(Math.max(dims!.width, dims!.height)).toBeLessThanOrEqual(2000);
152158
});
153159

160+
it('honors the harness [image] max_edge_px when pasting', async () => {
161+
const big = await solidPng(3600, 1800);
162+
readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: big, mimeType: 'image/png' });
163+
164+
const { store, pasteImage } = createPasteHarness({
165+
imageLimits: new ImageLimits(process.env, { maxEdgePx: 800 }),
166+
});
167+
await pasteImage();
168+
169+
const att = store.get(1);
170+
if (att?.kind !== 'image') throw new Error('expected image attachment');
171+
// The harness [image] config — not the built-in 2000px — drives ingestion.
172+
expect(Math.max(att.width, att.height)).toBe(800);
173+
expect(att.placeholder).toContain('800×400');
174+
const dims = parseImageMeta(att.bytes);
175+
expect(dims).not.toBeNull();
176+
expect(Math.max(dims!.width, dims!.height)).toBe(800);
177+
});
178+
154179
it('records and persists the pre-compression original for an oversized paste', async () => {
155180
const big = await solidPng(3600, 1800);
156181
readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: big, mimeType: 'image/png' });

packages/acp-adapter/src/convert.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ export async function compressPromptImageParts(
9393
readonly originalsDir?: string | undefined;
9494
/** Report an `image_compress` event per prompt image (source `acp_prompt`). */
9595
readonly telemetry?: TelemetryClient | undefined;
96+
/**
97+
* Longest-edge ceiling (px) from the harness's [image] config, resolved
98+
* per prompt so a config reload applies immediately. Absent → the
99+
* env/built-in default cap applies.
100+
*/
101+
readonly maxImageEdgePx?: number | undefined;
96102
} = {},
97103
): Promise<PromptPart[]> {
98104
const out: PromptPart[] = [];
@@ -101,6 +107,7 @@ export async function compressPromptImageParts(
101107
const parsed = parseImageDataUrl(part.imageUrl.url);
102108
if (parsed !== null) {
103109
const result = await compressBase64ForModel(parsed.base64, parsed.mimeType, {
110+
maxEdge: options.maxImageEdgePx,
104111
telemetry:
105112
options.telemetry === undefined
106113
? undefined

packages/acp-adapter/src/session.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,6 +741,7 @@ export class AcpSession {
741741
parts = await compressPromptImageParts(acpBlocksToPromptParts(blocks), {
742742
originalsDir:
743743
sessionDir === undefined ? undefined : sessionMediaOriginalsDir(sessionDir),
744+
maxImageEdgePx: this.harness?.imageLimits?.maxEdgePx(),
744745
telemetry:
745746
track === undefined
746747
? undefined

packages/acp-adapter/test/convert.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,39 @@ describe('compressPromptImageParts', () => {
367367
await rm(originalsDir, { recursive: true, force: true });
368368
});
369369

370+
it('downsamples to the caller-provided max edge instead of the built-in cap', async () => {
371+
const originalsDir = await mkdtemp(join(tmpdir(), 'acp-originals-'));
372+
const parts = acpBlocksToPromptParts([imageBlock(await pngBase64(3600, 1800), 'image/png')]);
373+
const compressed = await compressPromptImageParts(parts, {
374+
originalsDir,
375+
maxImageEdgePx: 800,
376+
});
377+
378+
const part = compressed[1];
379+
if (part?.type !== 'image_url') throw new Error('expected an image_url part');
380+
const match = /^data:(image\/[a-z]+);base64,(.+)$/.exec(part.imageUrl.url);
381+
expect(match).not.toBeNull();
382+
const decoded = await Jimp.fromBuffer(Buffer.from(match![2]!, 'base64'));
383+
expect(decoded.width).toBe(800);
384+
expect(decoded.height).toBe(400);
385+
await rm(originalsDir, { recursive: true, force: true });
386+
});
387+
388+
it('uses the built-in 2000px cap when no max edge is provided', async () => {
389+
const originalsDir = await mkdtemp(join(tmpdir(), 'acp-originals-'));
390+
const parts = acpBlocksToPromptParts([imageBlock(await pngBase64(3600, 1800), 'image/png')]);
391+
const compressed = await compressPromptImageParts(parts, { originalsDir });
392+
393+
const part = compressed[1];
394+
if (part?.type !== 'image_url') throw new Error('expected an image_url part');
395+
const match = /^data:(image\/[a-z]+);base64,(.+)$/.exec(part.imageUrl.url);
396+
expect(match).not.toBeNull();
397+
const decoded = await Jimp.fromBuffer(Buffer.from(match![2]!, 'base64'));
398+
expect(decoded.width).toBe(2000);
399+
expect(decoded.height).toBe(1000);
400+
await rm(originalsDir, { recursive: true, force: true });
401+
});
402+
370403
it('emits image_compress telemetry tagged acp_prompt', async () => {
371404
const originalsDir = await mkdtemp(join(tmpdir(), 'acp-originals-'));
372405
const events: { event: string; props: Record<string, unknown> }[] = [];

packages/agent-core/src/agent/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { PluginCommandOrigin } from './context';
1414

1515
import type { McpConnectionManager } from '../mcp';
1616
import { FlagResolver, type ExperimentalFlagResolver } from '../flags';
17+
import { ImageLimits } from '../tools/support/image-limits';
1718
import {
1819
prepareSystemPromptContext,
1920
type PreparedSystemPromptContext,
@@ -96,6 +97,8 @@ export interface AgentOptions {
9697
readonly pluginSessionStarts?: readonly EnabledPluginSessionStart[];
9798
readonly pluginCommands?: readonly PluginCommandDef[];
9899
readonly experimentalFlags?: ExperimentalFlagResolver;
100+
/** Owner-scoped [image] limits; a standalone Agent gets env/built-in defaults. */
101+
readonly imageLimits?: ImageLimits;
99102
readonly replay?: ReplayBuilderOptions;
100103
readonly additionalDirs?: readonly string[];
101104
readonly systemPromptContextProvider?: (() => Promise<PreparedSystemPromptContext>) | undefined;
@@ -124,6 +127,7 @@ export class Agent {
124127
readonly log: Logger;
125128
readonly telemetry: TelemetryClient;
126129
readonly experimentalFlags: ExperimentalFlagResolver;
130+
readonly imageLimits: ImageLimits;
127131

128132
readonly llmRequestLogger: LlmRequestLogger;
129133
readonly llmRequestRecorder: LlmRequestRecorder;
@@ -178,6 +182,7 @@ export class Agent {
178182
this.log = options.log ?? log;
179183
this.telemetry = options.telemetry ?? noopTelemetryClient;
180184
this.experimentalFlags = options.experimentalFlags ?? new FlagResolver();
185+
this.imageLimits = options.imageLimits ?? new ImageLimits();
181186
this.additionalDirs = normalizeAdditionalDirs(options.additionalDirs ?? []);
182187
this.systemPromptContextProvider = options.systemPromptContextProvider;
183188

packages/agent-core/src/agent/tool/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,8 @@ export class ToolManager {
317317
return mcpResultToExecutableOutput(result, qualified, {
318318
originalsDir: this.agent.mediaOriginalsDir,
319319
telemetry: this.agent.telemetry,
320+
// Resolved per call so a config reload applies immediately.
321+
maxImageEdgePx: this.agent.imageLimits?.maxEdgePx(),
320322
});
321323
},
322324
};
@@ -706,6 +708,7 @@ export class ToolManager {
706708
modelCapabilities,
707709
videoUploader,
708710
this.agent.telemetry,
711+
this.agent.imageLimits,
709712
),
710713
new b.EnterPlanModeTool(this.agent),
711714
new b.ExitPlanModeTool(this.agent),

packages/agent-core/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,13 @@ export {
6464
compressImageContentParts,
6565
cropImageForModel,
6666
formatByteSize,
67+
resolveMaxImageEdgePx,
68+
resolveReadImageByteBudget,
6769
IMAGE_BYTE_BUDGET,
6870
MAX_IMAGE_EDGE_PX,
71+
READ_IMAGE_BYTE_BUDGET,
6972
} from './tools/support/image-compress';
73+
export { ImageLimits } from './tools/support/image-limits';
7074
export type {
7175
CompressAnnotateOptions,
7276
CompressedContentParts,

packages/agent-core/src/mcp/output.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export interface McpOutputOptions {
4444
readonly originalsDir?: string | undefined;
4545
/** Report an `image_compress` event per compressed tool-result image. */
4646
readonly telemetry?: TelemetryClient | undefined;
47+
/** Owner-resolved longest-edge ceiling (px) for tool-result images. */
48+
readonly maxImageEdgePx?: number | undefined;
4749
}
4850

4951
// MCP servers can produce arbitrarily large outputs; cap what we feed back to
@@ -188,6 +190,7 @@ export async function mcpResultToExecutableOutput(
188190
// DATA (never inserted into the parts), so tool output that merely quotes
189191
// a caption can never be mistaken for a generated one.
190192
const compressed = await compressImageContentParts(budgeted.parts, {
193+
maxEdge: options.maxImageEdgePx,
191194
telemetry:
192195
options.telemetry === undefined
193196
? undefined

0 commit comments

Comments
 (0)