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
5 changes: 5 additions & 0 deletions .changeset/scoped-image-limits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

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.
12 changes: 11 additions & 1 deletion apps/kimi-code/src/tui/controllers/editor-keyboard.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Session } from '@moonshot-ai/kimi-code-sdk';
import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk';
import { compressImageForModel, persistOriginalImage, sessionMediaOriginalsDir } from '@moonshot-ai/kimi-code-sdk';

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

handleUserInput(text: string): void;
readonly btwPanelController: BtwPanelController;
Expand Down Expand Up @@ -407,7 +413,11 @@ export class EditorKeyboardController {
// session's media-originals dir when known, else the temp-dir fallback)
// and recorded on the attachment, so submit-time expansion can announce
// the compression and point the model at the full-fidelity copy.
// The edge cap comes from the host harness's [image] config (resolved per
// paste so a config reload applies immediately); hosts without a harness
// use the env/built-in default.
const compressed = await compressImageForModel(media.bytes, meta.mime, {
maxEdge: this.host.harness?.imageLimits?.maxEdgePx(),
telemetry: {
client: {
track: (event, properties) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from '#/tui/controllers/editor-keyboard';
import { ImageAttachmentStore } from '#/tui/utils/image-attachment-store';
import { parseImageMeta } from '#/utils/image/image-mime';
import { ImageLimits, type KimiHarness } from '@moonshot-ai/kimi-code-sdk';

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

function createPasteHarness(options: { sessionDir?: string } = {}): PasteHarness {
function createPasteHarness(options: { sessionDir?: string; imageLimits?: ImageLimits } = {}): PasteHarness {
const editor: Record<string, ((...args: never[]) => unknown) | undefined> = {
setHistoryFilter: vi.fn() as unknown as (...args: never[]) => unknown,
};
Expand All @@ -65,6 +66,11 @@ function createPasteHarness(options: { sessionDir?: string } = {}): PasteHarness
openUndoSelector: vi.fn(),
cancelRunningShellCommand: vi.fn(),
} as unknown as EditorKeyboardHost;
if (options.imageLimits !== undefined) {
(host as unknown as { harness: KimiHarness }).harness = {
imageLimits: options.imageLimits,
} as unknown as KimiHarness;
}

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

it('honors the harness [image] max_edge_px when pasting', async () => {
const big = await solidPng(3600, 1800);
readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: big, mimeType: 'image/png' });

const { store, pasteImage } = createPasteHarness({
imageLimits: new ImageLimits(process.env, { maxEdgePx: 800 }),
});
await pasteImage();

const att = store.get(1);
if (att?.kind !== 'image') throw new Error('expected image attachment');
// The harness [image] config — not the built-in 2000px — drives ingestion.
expect(Math.max(att.width, att.height)).toBe(800);
expect(att.placeholder).toContain('800×400');
const dims = parseImageMeta(att.bytes);
expect(dims).not.toBeNull();
expect(Math.max(dims!.width, dims!.height)).toBe(800);
});

it('records and persists the pre-compression original for an oversized paste', async () => {
const big = await solidPng(3600, 1800);
readClipboardMedia.mockResolvedValue({ kind: 'image', bytes: big, mimeType: 'image/png' });
Expand Down
7 changes: 7 additions & 0 deletions packages/acp-adapter/src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,12 @@ export async function compressPromptImageParts(
readonly originalsDir?: string | undefined;
/** Report an `image_compress` event per prompt image (source `acp_prompt`). */
readonly telemetry?: TelemetryClient | undefined;
/**
* Longest-edge ceiling (px) from the harness's [image] config, resolved
* per prompt so a config reload applies immediately. Absent → the
* env/built-in default cap applies.
*/
readonly maxImageEdgePx?: number | undefined;
} = {},
): Promise<PromptPart[]> {
const out: PromptPart[] = [];
Expand All @@ -101,6 +107,7 @@ export async function compressPromptImageParts(
const parsed = parseImageDataUrl(part.imageUrl.url);
if (parsed !== null) {
const result = await compressBase64ForModel(parsed.base64, parsed.mimeType, {
maxEdge: options.maxImageEdgePx,
telemetry:
options.telemetry === undefined
? undefined
Expand Down
1 change: 1 addition & 0 deletions packages/acp-adapter/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,7 @@ export class AcpSession {
parts = await compressPromptImageParts(acpBlocksToPromptParts(blocks), {
originalsDir:
sessionDir === undefined ? undefined : sessionMediaOriginalsDir(sessionDir),
maxImageEdgePx: this.harness?.imageLimits?.maxEdgePx(),
telemetry:
track === undefined
? undefined
Expand Down
33 changes: 33 additions & 0 deletions packages/acp-adapter/test/convert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,39 @@ describe('compressPromptImageParts', () => {
await rm(originalsDir, { recursive: true, force: true });
});

it('downsamples to the caller-provided max edge instead of the built-in cap', async () => {
const originalsDir = await mkdtemp(join(tmpdir(), 'acp-originals-'));
const parts = acpBlocksToPromptParts([imageBlock(await pngBase64(3600, 1800), 'image/png')]);
const compressed = await compressPromptImageParts(parts, {
originalsDir,
maxImageEdgePx: 800,
});

const part = compressed[1];
if (part?.type !== 'image_url') throw new Error('expected an image_url part');
const match = /^data:(image\/[a-z]+);base64,(.+)$/.exec(part.imageUrl.url);
expect(match).not.toBeNull();
const decoded = await Jimp.fromBuffer(Buffer.from(match![2]!, 'base64'));
expect(decoded.width).toBe(800);
expect(decoded.height).toBe(400);
await rm(originalsDir, { recursive: true, force: true });
});

it('uses the built-in 2000px cap when no max edge is provided', async () => {
const originalsDir = await mkdtemp(join(tmpdir(), 'acp-originals-'));
const parts = acpBlocksToPromptParts([imageBlock(await pngBase64(3600, 1800), 'image/png')]);
const compressed = await compressPromptImageParts(parts, { originalsDir });

const part = compressed[1];
if (part?.type !== 'image_url') throw new Error('expected an image_url part');
const match = /^data:(image\/[a-z]+);base64,(.+)$/.exec(part.imageUrl.url);
expect(match).not.toBeNull();
const decoded = await Jimp.fromBuffer(Buffer.from(match![2]!, 'base64'));
expect(decoded.width).toBe(2000);
expect(decoded.height).toBe(1000);
await rm(originalsDir, { recursive: true, force: true });
});

it('emits image_compress telemetry tagged acp_prompt', async () => {
const originalsDir = await mkdtemp(join(tmpdir(), 'acp-originals-'));
const events: { event: string; props: Record<string, unknown> }[] = [];
Expand Down
5 changes: 5 additions & 0 deletions packages/agent-core/src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { PluginCommandOrigin } from './context';

import type { McpConnectionManager } from '../mcp';
import { FlagResolver, type ExperimentalFlagResolver } from '../flags';
import { ImageLimits } from '../tools/support/image-limits';
import {
prepareSystemPromptContext,
type PreparedSystemPromptContext,
Expand Down Expand Up @@ -96,6 +97,8 @@ export interface AgentOptions {
readonly pluginSessionStarts?: readonly EnabledPluginSessionStart[];
readonly pluginCommands?: readonly PluginCommandDef[];
readonly experimentalFlags?: ExperimentalFlagResolver;
/** Owner-scoped [image] limits; a standalone Agent gets env/built-in defaults. */
readonly imageLimits?: ImageLimits;
readonly replay?: ReplayBuilderOptions;
readonly additionalDirs?: readonly string[];
readonly systemPromptContextProvider?: (() => Promise<PreparedSystemPromptContext>) | undefined;
Expand Down Expand Up @@ -124,6 +127,7 @@ export class Agent {
readonly log: Logger;
readonly telemetry: TelemetryClient;
readonly experimentalFlags: ExperimentalFlagResolver;
readonly imageLimits: ImageLimits;

readonly llmRequestLogger: LlmRequestLogger;
readonly llmRequestRecorder: LlmRequestRecorder;
Expand Down Expand Up @@ -178,6 +182,7 @@ export class Agent {
this.log = options.log ?? log;
this.telemetry = options.telemetry ?? noopTelemetryClient;
this.experimentalFlags = options.experimentalFlags ?? new FlagResolver();
this.imageLimits = options.imageLimits ?? new ImageLimits();
this.additionalDirs = normalizeAdditionalDirs(options.additionalDirs ?? []);
this.systemPromptContextProvider = options.systemPromptContextProvider;

Expand Down
3 changes: 3 additions & 0 deletions packages/agent-core/src/agent/tool/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,8 @@ export class ToolManager {
return mcpResultToExecutableOutput(result, qualified, {
originalsDir: this.agent.mediaOriginalsDir,
telemetry: this.agent.telemetry,
// Resolved per call so a config reload applies immediately.
maxImageEdgePx: this.agent.imageLimits?.maxEdgePx(),
});
},
};
Expand Down Expand Up @@ -706,6 +708,7 @@ export class ToolManager {
modelCapabilities,
videoUploader,
this.agent.telemetry,
this.agent.imageLimits,
),
new b.EnterPlanModeTool(this.agent),
new b.ExitPlanModeTool(this.agent),
Expand Down
4 changes: 4 additions & 0 deletions packages/agent-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,13 @@ export {
compressImageContentParts,
cropImageForModel,
formatByteSize,
resolveMaxImageEdgePx,
resolveReadImageByteBudget,
IMAGE_BYTE_BUDGET,
MAX_IMAGE_EDGE_PX,
READ_IMAGE_BYTE_BUDGET,
} from './tools/support/image-compress';
export { ImageLimits } from './tools/support/image-limits';
export type {
CompressAnnotateOptions,
CompressedContentParts,
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-core/src/mcp/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export interface McpOutputOptions {
readonly originalsDir?: string | undefined;
/** Report an `image_compress` event per compressed tool-result image. */
readonly telemetry?: TelemetryClient | undefined;
/** Owner-resolved longest-edge ceiling (px) for tool-result images. */
readonly maxImageEdgePx?: number | undefined;
}

// MCP servers can produce arbitrarily large outputs; cap what we feed back to
Expand Down Expand Up @@ -188,6 +190,7 @@ export async function mcpResultToExecutableOutput(
// DATA (never inserted into the parts), so tool output that merely quotes
// a caption can never be mistaken for a generated one.
const compressed = await compressImageContentParts(budgeted.parts, {
maxEdge: options.maxImageEdgePx,
telemetry:
options.telemetry === undefined
? undefined
Expand Down
12 changes: 7 additions & 5 deletions packages/agent-core/src/rpc/core-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +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 { ImageLimits } from '#/tools/support/image-limits';
import type { PromisableMethods } from '#/utils/types';
import { getCoreVersion } from '#/version';
import { resolveThinkingEffort } from '../agent/config/thinking';
Expand Down Expand Up @@ -169,6 +169,8 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
private pluginsLoadError: Error | undefined;
private readonly appVersion: string | undefined;
private readonly experimentalFlags: FlagResolver;
/** Owner-scoped [image] limits; reload pushes the new config via setConfig. */
readonly imageLimits: ImageLimits;

constructor(
protected readonly rpcClient: CoreRPCClient,
Expand Down Expand Up @@ -206,8 +208,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
FLAG_DEFINITIONS,
this.config.experimental,
);
setConfiguredMaxImageEdgePx(this.config.image?.maxEdgePx);
setConfiguredReadImageByteBudget(this.config.image?.readByteBudget);
this.imageLimits = new ImageLimits(process.env, this.config.image);
this.sessionStore = new SessionStore(this.homeDir);
this.plugins = new PluginManager({ kimiHomeDir: this.homeDir });
// Capture the error rather than swallow it: mutators and explicit /plugins
Expand Down Expand Up @@ -301,6 +302,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
skills: this.resolveSessionSkillConfig(config),
mcpConfig,
experimentalFlags: this.experimentalFlags,
imageLimits: this.imageLimits,
telemetry: sessionTelemetry,
pluginSessionStarts,
pluginCommands,
Expand Down Expand Up @@ -436,6 +438,7 @@ export class KimiCore implements PromisableMethods<CoreAPI> {
skills: this.resolveSessionSkillConfig(config),
mcpConfig,
experimentalFlags: this.experimentalFlags,
imageLimits: this.imageLimits,
telemetry: withTelemetryContext(this.telemetry, { sessionId: summary.id }),
initializeMainAgent: false,
pluginSessionStarts,
Expand Down Expand Up @@ -1082,8 +1085,7 @@ 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);
this.imageLimits.setConfig(config.image);
return this.config;
}

Expand Down
7 changes: 7 additions & 0 deletions packages/agent-core/src/services/coreProcess/coreProcess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { createDecorator } from '../../di';
import type { CoreRPC, KimiCoreOptions } from '../../rpc';
import type { TelemetryClient } from '../../telemetry';
import { type KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth';
import type { ImageLimits } from '#/tools/support/image-limits';

export interface CoreProcessServiceOptions extends KimiCoreOptions {
/**
Expand Down Expand Up @@ -68,6 +69,12 @@ export interface ICoreProcessService {
*/
readonly telemetry?: TelemetryClient | undefined;

/**
* The core's owner-scoped [image] limits, so daemon-side prompt-ingestion
* compression uses the same settings (and reloads) as the core's own tools.
*/
readonly imageLimits?: ImageLimits | undefined;

/**
* Resolves once `KimiCore` is fully constructed and the SDK side of the
* in-process RPC has been bound. Repeated calls return the cached promise.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import { createRPC, KimiCore } from '../../rpc';
import type { ImageLimits } from '../../tools/support/image-limits';
import { Disposable, registerSingleton, SyncDescriptor } from '../../di';
import type { CoreAPI, CoreRPC, SDKAPI } from '../../rpc';
import type { OAuthTokenProviderResolver } from '../../session/provider-manager';
Expand Down Expand Up @@ -36,6 +37,11 @@ export class CoreProcessService extends Disposable implements ICoreProcessServic

public readonly telemetry: TelemetryClient;

/** The core's owner-scoped [image] limits; see ICoreProcessService. */
public get imageLimits(): ImageLimits {
return this._core.imageLimits;
}

/**
* The in-process `KimiCore` instance. Kept private so daemon-side code can't
* grab it and bypass the peer-service indirection.
Expand Down
6 changes: 6 additions & 0 deletions packages/agent-core/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { SessionSubagentHost } from './subagent-host';
import { sessionMediaOriginalsDir } from '../tools/support/image-originals';
import type { ToolServices } from '../tools/support/services';
import { FlagResolver, type ExperimentalFlagResolver } from '../flags';
import { ImageLimits } from '../tools/support/image-limits';
import { abortError } from '../utils/abort';

export interface SessionOptions {
Expand All @@ -75,6 +76,8 @@ export interface SessionOptions {
readonly pluginCommands?: readonly PluginCommandDef[];
readonly appVersion?: string;
readonly experimentalFlags?: ExperimentalFlagResolver;
/** Owner-scoped [image] limits, threaded from the owning core into every agent. */
readonly imageLimits?: ImageLimits;
readonly additionalDirs?: readonly string[];
/**
* Print-mode (`kimi -p`) only: hold the main turn open while background
Expand Down Expand Up @@ -169,6 +172,7 @@ export class Session {
private readonly logHandle: SessionLogHandle | undefined;
readonly hookEngine: HookEngine;
readonly experimentalFlags: ExperimentalFlagResolver;
readonly imageLimits: ImageLimits;
private toolKaos: Kaos;
private persistenceKaos: Kaos;
private additionalDirs: readonly string[];
Expand Down Expand Up @@ -202,6 +206,7 @@ export class Session {
(options.id === undefined ? log : log.createChild({ sessionId: options.id }));
this.rpc = options.rpc;
this.experimentalFlags = options.experimentalFlags ?? new FlagResolver();
this.imageLimits = options.imageLimits ?? new ImageLimits();
this.hookEngine = new HookEngine(options.hooks, {
cwd: options.kaos.getcwd(),
sessionId: options.id,
Expand Down Expand Up @@ -837,6 +842,7 @@ export class Session {
pluginSessionStarts: type === 'main' ? this.options.pluginSessionStarts : undefined,
pluginCommands: type === 'main' ? this.options.pluginCommands : undefined,
experimentalFlags: this.experimentalFlags,
imageLimits: this.imageLimits,
additionalDirs: parentAgent?.getAdditionalDirs() ?? this.additionalDirs,
systemPromptContextProvider: () =>
prepareSystemPromptContext(
Expand Down
Loading
Loading