From e56d57184cce6e79c448d4fa8bf4aec1ea3998d4 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Thu, 9 Jul 2026 19:25:11 +0800 Subject: [PATCH 1/3] fix(agent-core): scope [image] config limits to the owning core --- .changeset/scoped-image-limits.md | 5 + packages/agent-core/src/agent/index.ts | 5 + packages/agent-core/src/agent/tool/index.ts | 3 + packages/agent-core/src/index.ts | 4 + packages/agent-core/src/mcp/output.ts | 3 + packages/agent-core/src/rpc/core-impl.ts | 12 ++- .../src/services/coreProcess/coreProcess.ts | 7 ++ .../coreProcess/coreProcessService.ts | 6 ++ packages/agent-core/src/session/index.ts | 6 ++ .../src/tools/builtin/file/read-media.ts | 9 +- .../src/tools/support/image-compress.ts | 69 +++++++------- .../src/tools/support/image-limits.ts | 50 ++++++++++ .../agent-core/test/rpc/config-rpc.test.ts | 63 +++++++++++++ .../test/tools/image-compress.test.ts | 92 +++++++++++++------ .../agent-core/test/tools/read-media.test.ts | 54 ++++++++--- packages/server/src/routes/prompts.ts | 6 ++ 16 files changed, 310 insertions(+), 84 deletions(-) create mode 100644 .changeset/scoped-image-limits.md create mode 100644 packages/agent-core/src/tools/support/image-limits.ts diff --git a/.changeset/scoped-image-limits.md b/.changeset/scoped-image-limits.md new file mode 100644 index 0000000000..47fa886cfd --- /dev/null +++ b/.changeset/scoped-image-limits.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Scope `[image]` config limits to the owning core instead of a process-global value, so multiple cores in one process (the SDK's multi-client pattern) each compress images with their own `max_edge_px` / `read_byte_budget`, and a config reload of one core never retunes another; the env vars `KIMI_IMAGE_MAX_EDGE_PX` / `KIMI_IMAGE_READ_BYTE_BUDGET` still override process-wide. diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 6b9d7cf19f..f33f0ab59f 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -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, @@ -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) | undefined; @@ -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; @@ -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; diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index 8803227e00..0d056253d8 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -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(), }); }, }; @@ -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), diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index b347ac5d67..b4ce829e27 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -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, diff --git a/packages/agent-core/src/mcp/output.ts b/packages/agent-core/src/mcp/output.ts index 0f77683f95..84e246a9d9 100644 --- a/packages/agent-core/src/mcp/output.ts +++ b/packages/agent-core/src/mcp/output.ts @@ -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 @@ -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 diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 5cd3b03af9..0435a12bfa 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -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'; @@ -169,6 +169,8 @@ export class KimiCore implements PromisableMethods { 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, @@ -206,8 +208,7 @@ export class KimiCore implements PromisableMethods { 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 @@ -301,6 +302,7 @@ export class KimiCore implements PromisableMethods { skills: this.resolveSessionSkillConfig(config), mcpConfig, experimentalFlags: this.experimentalFlags, + imageLimits: this.imageLimits, telemetry: sessionTelemetry, pluginSessionStarts, pluginCommands, @@ -436,6 +438,7 @@ export class KimiCore implements PromisableMethods { skills: this.resolveSessionSkillConfig(config), mcpConfig, experimentalFlags: this.experimentalFlags, + imageLimits: this.imageLimits, telemetry: withTelemetryContext(this.telemetry, { sessionId: summary.id }), initializeMainAgent: false, pluginSessionStarts, @@ -1082,8 +1085,7 @@ export class KimiCore implements PromisableMethods { 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; } diff --git a/packages/agent-core/src/services/coreProcess/coreProcess.ts b/packages/agent-core/src/services/coreProcess/coreProcess.ts index 73b8b27c78..ce3b75958a 100644 --- a/packages/agent-core/src/services/coreProcess/coreProcess.ts +++ b/packages/agent-core/src/services/coreProcess/coreProcess.ts @@ -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 { /** @@ -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. diff --git a/packages/agent-core/src/services/coreProcess/coreProcessService.ts b/packages/agent-core/src/services/coreProcess/coreProcessService.ts index c66422bff6..fa7fd5bd06 100644 --- a/packages/agent-core/src/services/coreProcess/coreProcessService.ts +++ b/packages/agent-core/src/services/coreProcess/coreProcessService.ts @@ -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'; @@ -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. diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 75f1a302ed..a5086e76ea 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -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 { @@ -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 @@ -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[]; @@ -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, @@ -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( diff --git a/packages/agent-core/src/tools/builtin/file/read-media.ts b/packages/agent-core/src/tools/builtin/file/read-media.ts index b406a5f41c..4543c4e33c 100644 --- a/packages/agent-core/src/tools/builtin/file/read-media.ts +++ b/packages/agent-core/src/tools/builtin/file/read-media.ts @@ -45,10 +45,10 @@ import { compressImageForModel, cropImageForModel, formatByteSize, - resolveReadImageByteBudget, type ImageCompressionTelemetry, type ImageCropRegion, } from '../../support/image-compress'; +import { ImageLimits } from '../../support/image-limits'; import { toInputJsonSchema } from '../../support/input-schema'; import { literalRulePattern, matchesPathRuleSubject } from '../../support/rule-match'; import type { WorkspaceConfig } from '../../support/workspace'; @@ -258,12 +258,14 @@ export class ReadMediaFileTool implements BuiltinTool { readonly description: string; readonly parameters: Record = toInputJsonSchema(ReadMediaFileInputSchema); private readonly compressTelemetry: ImageCompressionTelemetry | undefined; + private readonly imageLimits: ImageLimits; constructor( private readonly kaos: Kaos, private readonly workspace: WorkspaceConfig, private readonly capabilities: ModelCapability, private readonly videoUploader?: VideoUploader | undefined, telemetry?: TelemetryClient, + imageLimits?: ImageLimits, ) { if (!capabilities.image_in && !capabilities.video_in) { const skip = new Error('ReadMediaFile requires image_in or video_in capability'); @@ -273,6 +275,7 @@ export class ReadMediaFileTool implements BuiltinTool { this.description = buildDescription(capabilities); this.compressTelemetry = telemetry === undefined ? undefined : { client: telemetry, source: 'read_media' }; + this.imageLimits = imageLimits ?? new ImageLimits(); } resolveExecution(args: ReadMediaFileInput): ToolExecution { @@ -387,6 +390,7 @@ export class ReadMediaFileTool implements BuiltinTool { // full fidelity, so a prior downsampled view can be zoomed into. const outcome = await cropImageForModel(data, fileType.mimeType, args.region, { skipResize: args.full_resolution === true, + maxEdge: this.imageLimits.maxEdgePx(), telemetry: this.compressTelemetry, }); if (!outcome.ok) { @@ -446,7 +450,8 @@ export class ReadMediaFileTool implements BuiltinTool { // failure compressImageForModel returns the original bytes, so the // read still succeeds with the uncompressed image. const compressed = await compressImageForModel(data, fileType.mimeType, { - byteBudget: resolveReadImageByteBudget(), + maxEdge: this.imageLimits.maxEdgePx(), + byteBudget: this.imageLimits.readByteBudget(), telemetry: this.compressTelemetry, }); const base64 = Buffer.from(compressed.data).toString('base64'); diff --git a/packages/agent-core/src/tools/support/image-compress.ts b/packages/agent-core/src/tools/support/image-compress.ts index 6655735c98..75f9d46bdd 100644 --- a/packages/agent-core/src/tools/support/image-compress.ts +++ b/packages/agent-core/src/tools/support/image-compress.ts @@ -49,36 +49,34 @@ export const MAX_IMAGE_EDGE_PX = 2000; */ 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; +/** The env override for the longest-edge ceiling, or undefined when unset/invalid. */ +export function maxImageEdgeFromEnv( + env: Readonly> = process.env, +): number | undefined { + return positiveIntFromEnv(env, MAX_IMAGE_EDGE_ENV); } /** - * 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}. + * Default longest-edge ceiling (px) for calls that pass no explicit + * `maxEdge` and have no config owner: env var > built-in + * {@link MAX_IMAGE_EDGE_PX}. Owned call sites (tools under an Agent, server + * ingestion under a core) resolve through their `ImageLimits` instance + * instead, which adds the owner's `[image]` config between the two. */ export function resolveMaxImageEdgePx( env: Readonly> = 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; + return maxImageEdgeFromEnv(env) ?? MAX_IMAGE_EDGE_PX; } -function isPositiveInt(value: number): boolean { - return Number.isInteger(value) && value > 0; +function positiveIntFromEnv( + env: Readonly>, + name: string, +): number | undefined { + const raw = env[name]?.trim(); + if (raw === undefined || raw.length === 0 || !/^\d+$/.test(raw)) return undefined; + const parsed = Number(raw); + return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; } /** @@ -107,28 +105,21 @@ export const READ_IMAGE_BYTE_BUDGET = 256 * 1024; */ 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; +/** The env override for the read-image byte budget, or undefined when unset/invalid. */ +export function readImageByteBudgetFromEnv( + env: Readonly> = process.env, +): number | undefined { + return positiveIntFromEnv(env, READ_IMAGE_BYTE_BUDGET_ENV); } /** - * Effective read-image byte budget. Precedence mirrors - * {@link resolveMaxImageEdgePx}: env var > config.toml > built-in - * {@link READ_IMAGE_BYTE_BUDGET}. + * Read-image byte budget for callers with no config owner; see + * {@link resolveMaxImageEdgePx} for the ownership model. */ export function resolveReadImageByteBudget( env: Readonly> = 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; + return readImageByteBudgetFromEnv(env) ?? READ_IMAGE_BYTE_BUDGET; } /** Progressively lower JPEG quality until the payload fits the byte budget. */ @@ -182,7 +173,11 @@ const MAX_DECODE_BYTES = 64 * 1024 * 1024; const RECODABLE_MIME = new Set(['image/png', 'image/jpeg', 'image/webp']); export interface CompressImageOptions { - /** Override the longest-edge ceiling (px); defaults to {@link resolveMaxImageEdgePx}. */ + /** + * Override the longest-edge ceiling (px). When omitted, owned call sites + * pass their {@link ImageLimits.maxEdgePx}; ownerless ones fall back to + * {@link resolveMaxImageEdgePx} (env var, then built-in). + */ readonly maxEdge?: number; /** Override the raw-byte budget. */ readonly byteBudget?: number; diff --git a/packages/agent-core/src/tools/support/image-limits.ts b/packages/agent-core/src/tools/support/image-limits.ts new file mode 100644 index 0000000000..b91aac80b0 --- /dev/null +++ b/packages/agent-core/src/tools/support/image-limits.ts @@ -0,0 +1,50 @@ +/** + * Owner-scoped resolution of the `[image]` config limits. + * + * One instance per owner (KimiCore in production; a fresh default for a + * standalone Agent), mirroring the FlagResolver lifecycle: the owner pushes + * its config on load and reload via {@link ImageLimits.setConfig}, and every + * consumer resolves through the instance it was handed. Nothing is stored in + * module state, so two cores in one process (the SDK's multi-client pattern) + * each compress with their own `[image]` settings and a reload of one never + * restamps the other. + * + * Resolution precedence per value: env var > owning config > built-in + * default. Env stays process-level on purpose — it is the operator's + * override for everything in the process, exactly like the experimental-flag + * env switches. + */ + +import type { ImageConfig } from '#/config/schema'; + +import { + MAX_IMAGE_EDGE_PX, + maxImageEdgeFromEnv, + READ_IMAGE_BYTE_BUDGET, + readImageByteBudgetFromEnv, +} from './image-compress'; + +export class ImageLimits { + constructor( + private readonly env: Readonly> = process.env, + private config: ImageConfig | undefined = undefined, + ) {} + + /** Push (or clear, with `undefined`) the owning config. Called by the + * config owner on load and reload, so limits hot-reload per owner. */ + setConfig(config: ImageConfig | undefined): void { + this.config = config; + } + + /** Longest-edge ceiling (px) for compressing images for the model. */ + maxEdgePx(): number { + return maxImageEdgeFromEnv(this.env) ?? this.config?.maxEdgePx ?? MAX_IMAGE_EDGE_PX; + } + + /** Raw-byte budget for model-initiated image reads (ReadMediaFile default path). */ + readByteBudget(): number { + return ( + readImageByteBudgetFromEnv(this.env) ?? this.config?.readByteBudget ?? READ_IMAGE_BYTE_BUDGET + ); + } +} diff --git a/packages/agent-core/test/rpc/config-rpc.test.ts b/packages/agent-core/test/rpc/config-rpc.test.ts index 384cb91a29..542c4783e1 100644 --- a/packages/agent-core/test/rpc/config-rpc.test.ts +++ b/packages/agent-core/test/rpc/config-rpc.test.ts @@ -108,3 +108,66 @@ max_steps_per_turn = "nope" await expect(core.getConfigDiagnostics({})).resolves.toEqual({ warnings: [] }); }); }); + +describe('KimiCore imageLimits scoping', () => { + it('two cores keep independent [image] limits and only follow their own reloads', async () => { + const homeA = await makeHome(`${VALID_TOML} +[image] +max_edge_px = 800 +read_byte_budget = 65536 +`); + const homeB = await makeHome(`${VALID_TOML} +[image] +max_edge_px = 1600 +`); + const coreA = makeCore(homeA); + const coreB = makeCore(homeB); + + // Baseline: each core resolves its own [image] section. + expect(coreA.imageLimits.maxEdgePx()).toBe(800); + expect(coreA.imageLimits.readByteBudget()).toBe(65536); + expect(coreB.imageLimits.maxEdgePx()).toBe(1600); + expect(coreB.imageLimits.readByteBudget()).toBe(256 * 1024); + + // Reloading B must not restamp A (the module-global regression). + await writeFile( + path.join(homeB, 'config.toml'), + `${VALID_TOML} +[image] +max_edge_px = 1000 +read_byte_budget = 32768 +`, + 'utf-8', + ); + await coreB.getKimiConfig({ reload: true }); + expect(coreB.imageLimits.maxEdgePx()).toBe(1000); + expect(coreB.imageLimits.readByteBudget()).toBe(32768); + expect(coreA.imageLimits.maxEdgePx()).toBe(800); + expect(coreA.imageLimits.readByteBudget()).toBe(65536); + }); + + it('reloading [image] takes effect on the core instance immediately', async () => { + const home = await makeHome(VALID_TOML); + const core = makeCore(home); + expect(core.imageLimits.maxEdgePx()).toBe(2000); + + await writeFile( + path.join(home, 'config.toml'), + `${VALID_TOML} +[image] +max_edge_px = 1400 +read_byte_budget = 131072 +`, + 'utf-8', + ); + await core.getKimiConfig({ reload: true }); + expect(core.imageLimits.maxEdgePx()).toBe(1400); + expect(core.imageLimits.readByteBudget()).toBe(131072); + + // Removing the section clears back to built-ins. + await writeFile(path.join(home, 'config.toml'), VALID_TOML, 'utf-8'); + await core.getKimiConfig({ reload: true }); + expect(core.imageLimits.maxEdgePx()).toBe(2000); + expect(core.imageLimits.readByteBudget()).toBe(256 * 1024); + }); +}); diff --git a/packages/agent-core/test/tools/image-compress.test.ts b/packages/agent-core/test/tools/image-compress.test.ts index dafcd0e427..d60f3820a7 100644 --- a/packages/agent-core/test/tools/image-compress.test.ts +++ b/packages/agent-core/test/tools/image-compress.test.ts @@ -54,10 +54,10 @@ import { READ_IMAGE_BYTE_BUDGET_ENV, resolveMaxImageEdgePx, resolveReadImageByteBudget, - setConfiguredMaxImageEdgePx, - setConfiguredReadImageByteBudget, } from '../../src/tools/support/image-compress'; // eslint-disable-next-line import/no-unresolved +import { ImageLimits } from '../../src/tools/support/image-limits'; +// eslint-disable-next-line import/no-unresolved import { sniffImageDimensions } from '../../src/tools/support/file-type'; // eslint-disable-next-line import/no-unresolved import type { TelemetryClient, TelemetryProperties } from '../../src/telemetry'; @@ -653,27 +653,18 @@ describe('compressImageForModel — performance', () => { }); }); -// ── default edge resolution (env + config) ────────────────────────── +// ── default edge resolution (env-only fallback) ───────────────────── 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); + it('lets the env var override the built-in ceiling', () => { vi.stubEnv(MAX_IMAGE_EDGE_ENV, '900'); expect(resolveMaxImageEdgePx()).toBe(900); }); @@ -684,7 +675,7 @@ describe('resolveMaxImageEdgePx', () => { }); it('drives compressImageForModel when no explicit maxEdge is passed', async () => { - setConfiguredMaxImageEdgePx(1200); + vi.stubEnv(MAX_IMAGE_EDGE_ENV, '1200'); const png = await solidPng(1600, 800); const result = await compressImageForModel(png, 'image/png'); expect(result.changed).toBe(true); @@ -692,8 +683,7 @@ describe('resolveMaxImageEdgePx', () => { expect(result.height).toBe(600); }); - it('an explicit maxEdge option still wins over env and config', async () => { - setConfiguredMaxImageEdgePx(1200); + it('an explicit maxEdge option still wins over the env var', async () => { vi.stubEnv(MAX_IMAGE_EDGE_ENV, '900'); const png = await solidPng(1600, 800); const result = await compressImageForModel(png, 'image/png', { maxEdge: 800 }); @@ -703,7 +693,7 @@ describe('resolveMaxImageEdgePx', () => { }); it('drives cropImageForModel region fitting', async () => { - setConfiguredMaxImageEdgePx(400); + vi.stubEnv(MAX_IMAGE_EDGE_ENV, '400'); const png = await solidPng(1600, 800); const result = await cropImageForModel(png, 'image/png', { x: 0, @@ -720,7 +710,6 @@ describe('resolveMaxImageEdgePx', () => { describe('resolveReadImageByteBudget', () => { afterEach(() => { vi.unstubAllEnvs(); - setConfiguredReadImageByteBudget(undefined); }); it('defaults to the built-in read budget', () => { @@ -728,15 +717,7 @@ describe('resolveReadImageByteBudget', () => { 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); + it('lets the env var override the built-in budget', () => { vi.stubEnv(READ_IMAGE_BYTE_BUDGET_ENV, '100000'); expect(resolveReadImageByteBudget()).toBe(100000); }); @@ -747,6 +728,63 @@ describe('resolveReadImageByteBudget', () => { }); }); +// ── ImageLimits (owner-scoped [image] config) ─────────────────────── + +describe('ImageLimits', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('resolves built-in defaults when no config is set', () => { + const limits = new ImageLimits(); + expect(limits.maxEdgePx()).toBe(MAX_IMAGE_EDGE_PX); + expect(limits.readByteBudget()).toBe(READ_IMAGE_BYTE_BUDGET); + }); + + it('uses the owning config when set', () => { + const limits = new ImageLimits(process.env, { maxEdgePx: 1200, readByteBudget: 512 * 1024 }); + expect(limits.maxEdgePx()).toBe(1200); + expect(limits.readByteBudget()).toBe(512 * 1024); + }); + + it('lets the env vars override the config', () => { + vi.stubEnv(MAX_IMAGE_EDGE_ENV, '900'); + vi.stubEnv(READ_IMAGE_BYTE_BUDGET_ENV, '100000'); + const limits = new ImageLimits(process.env, { maxEdgePx: 1200, readByteBudget: 512 * 1024 }); + expect(limits.maxEdgePx()).toBe(900); + expect(limits.readByteBudget()).toBe(100000); + }); + + it('falls back to the config when the env value is invalid', () => { + vi.stubEnv(MAX_IMAGE_EDGE_ENV, 'abc'); + const limits = new ImageLimits(process.env, { maxEdgePx: 1200 }); + expect(limits.maxEdgePx()).toBe(1200); + }); + + it('setConfig replaces and clears the config (reload semantics)', () => { + const limits = new ImageLimits(process.env, { maxEdgePx: 1200 }); + limits.setConfig({ maxEdgePx: 1500 }); + expect(limits.maxEdgePx()).toBe(1500); + limits.setConfig(undefined); + expect(limits.maxEdgePx()).toBe(MAX_IMAGE_EDGE_PX); + }); + + it('instances are isolated — one owner cannot leak limits into another', () => { + // The regression this class exists for: two cores in one process (the + // SDK's multi-client pattern) must each compress with their own [image] + // settings, and a reload of one must not restamp the other. + const first = new ImageLimits(process.env, { maxEdgePx: 800, readByteBudget: 64 * 1024 }); + const second = new ImageLimits(process.env, { maxEdgePx: 1600 }); + expect(first.maxEdgePx()).toBe(800); + expect(second.maxEdgePx()).toBe(1600); + expect(second.readByteBudget()).toBe(READ_IMAGE_BYTE_BUDGET); + + second.setConfig({ maxEdgePx: 1000 }); + expect(first.maxEdgePx()).toBe(800); + expect(first.readByteBudget()).toBe(64 * 1024); + }); +}); + // ── content-part helper ────────────────────────────────────────────── describe('compressImageContentParts', () => { diff --git a/packages/agent-core/test/tools/read-media.test.ts b/packages/agent-core/test/tools/read-media.test.ts index d23f0526a1..37825ce0c6 100644 --- a/packages/agent-core/test/tools/read-media.test.ts +++ b/packages/agent-core/test/tools/read-media.test.ts @@ -13,7 +13,7 @@ import { ReadMediaFileInputSchema, ReadMediaFileTool, } from '../../src/tools/builtin/file/read-media'; -import { setConfiguredReadImageByteBudget } from '../../src/tools/support/image-compress'; +import { ImageLimits } from '../../src/tools/support/image-limits'; import { MEDIA_SNIFF_BYTES, sniffImageDimensions } from '../../src/tools/support/file-type'; import type { TelemetryClient } from '../../src/telemetry'; import { createFakeKaos, FAKE_OS_ENV, PERMISSIVE_WORKSPACE } from './fixtures/fake-kaos'; @@ -90,6 +90,7 @@ function makeReadMediaTool( readonly readBytes?: Kaos['readBytes'] | undefined; readonly modelCapabilities?: ModelCapability | undefined; readonly telemetry?: TelemetryClient | undefined; + readonly imageLimits?: ImageLimits | undefined; } = {}, ): ReadMediaFileTool { const kaos = createFakeKaos({ @@ -102,6 +103,7 @@ function makeReadMediaTool( input.modelCapabilities ?? capabilities(), undefined, input.telemetry, + input.imageLimits, ); } @@ -849,10 +851,11 @@ describe('ReadMediaFileTool', () => { ); } - function toolFor(data: Buffer): ReadMediaFileTool { + function toolFor(data: Buffer, imageLimits?: ImageLimits): ReadMediaFileTool { return makeReadMediaTool({ stat: vi.fn().mockResolvedValue({ ...DEFAULT_STAT, stSize: data.length }), readBytes: vi.fn().mockResolvedValue(data), + imageLimits, }); } @@ -1095,10 +1098,6 @@ describe('ReadMediaFileTool', () => { }); describe('read byte budget', () => { - afterEach(() => { - setConfiguredReadImageByteBudget(undefined); - }); - /** High-entropy PNG whose bytes stay large after downscaling. */ async function noisePng(width: number, height: number): Promise { const image = new Jimp({ width, height, color: 0x000000ff }); @@ -1117,10 +1116,11 @@ describe('ReadMediaFileTool', () => { return Buffer.from(await image.getBuffer('image/png')); } - function toolFor(data: Buffer): ReadMediaFileTool { + function toolFor(data: Buffer, imageLimits?: ImageLimits): ReadMediaFileTool { return makeReadMediaTool({ stat: vi.fn().mockResolvedValue({ ...DEFAULT_STAT, stSize: data.length }), readBytes: vi.fn().mockResolvedValue(data), + imageLimits, }); } @@ -1136,11 +1136,11 @@ describe('ReadMediaFileTool', () => { 'compresses a default read to fit the configured read budget', async () => { const budget = 64 * 1024; - setConfiguredReadImageByteBudget(budget); + const limits = new ImageLimits(process.env, { readByteBudget: budget }); const data = await noisePng(1200, 1200); expect(data.length).toBeGreaterThan(budget); - const result = await executeTool(toolFor(data), { + const result = await executeTool(toolFor(data, limits), { turnId: 't1', toolCallId: 'c_budget', args: { path: '/workspace/noisy.png' }, @@ -1155,11 +1155,11 @@ describe('ReadMediaFileTool', () => { ); it('full_resolution ignores the read budget (per-image provider limit applies)', async () => { - setConfiguredReadImageByteBudget(64 * 1024); + const limits = new ImageLimits(process.env, { readByteBudget: 64 * 1024 }); const data = await noisePng(600, 600); expect(data.length).toBeGreaterThan(64 * 1024); - const result = await executeTool(toolFor(data), { + const result = await executeTool(toolFor(data, limits), { turnId: 't1', toolCallId: 'c_fullres_budget', args: { path: '/workspace/noisy.png', full_resolution: true }, @@ -1175,10 +1175,10 @@ describe('ReadMediaFileTool', () => { it( 'region reads ignore the read budget so detail readback stays full-fidelity', async () => { - setConfiguredReadImageByteBudget(16 * 1024); + const limits = new ImageLimits(process.env, { readByteBudget: 16 * 1024 }); const data = await noisePng(800, 800); - const result = await executeTool(toolFor(data), { + const result = await executeTool(toolFor(data, limits), { turnId: 't1', toolCallId: 'c_region_budget', args: { path: '/workspace/noisy.png', region: { x: 0, y: 0, width: 400, height: 400 } }, @@ -1192,5 +1192,33 @@ describe('ReadMediaFileTool', () => { }, 15_000, ); + + it( + 'two tools honor their own limits independently (no shared process state)', + async () => { + const data = await noisePng(1200, 1200); + const tight = toolFor(data, new ImageLimits(process.env, { readByteBudget: 48 * 1024 })); + const roomy = toolFor(data, new ImageLimits(process.env, { maxEdgePx: 1200 })); + + const tightResult = await executeTool(tight, { + turnId: 't1', + toolCallId: 'c_iso_tight', + args: { path: '/workspace/noisy.png' }, + signal, + }); + const roomyResult = await executeTool(roomy, { + turnId: 't1', + toolCallId: 'c_iso_roomy', + args: { path: '/workspace/noisy.png' }, + signal, + }); + + expect(sentBytes(tightResult).length).toBeLessThanOrEqual(48 * 1024); + // The roomy tool keeps the default 256 KB budget and its own edge cap: + // its output is far larger than the tight tool's budget. + expect(sentBytes(roomyResult).length).toBeGreaterThan(48 * 1024); + }, + 15_000, + ); }); }); diff --git a/packages/server/src/routes/prompts.ts b/packages/server/src/routes/prompts.ts index 50062f6958..ac0d1cd738 100644 --- a/packages/server/src/routes/prompts.ts +++ b/packages/server/src/routes/prompts.ts @@ -140,6 +140,8 @@ export function registerPromptsRoutes( : withTelemetryContext(core.telemetry, { sessionId: session_id }), // Resolved lazily — only when an inline base64 image actually // got compressed — so image-free prompts never pay the lookup. + // Resolved per request so a config reload applies immediately. + maxImageEdgePx: core.imageLimits?.maxEdgePx(), resolveOriginalsDir: async () => { const summaries = await core.rpc.listSessions({ sessionId: session_id }); const sessionDir = summaries[0]?.sessionDir; @@ -278,6 +280,8 @@ interface ResolvePromptMediaOptions { readonly resolveOriginalsDir?: () => Promise; /** Report an `image_compress` event per compressed prompt image. */ readonly telemetry?: TelemetryClient; + /** Owner-resolved longest-edge ceiling (px) from the core's [image] config. */ + readonly maxImageEdgePx?: number; } async function resolvePromptMediaFiles( @@ -305,6 +309,7 @@ async function resolvePromptMediaFiles( // image as `{ source: { kind: 'base64' } }` instead of uploading a file. if (part.type === 'image' && part.source.kind === 'base64') { const compressed = await compressBase64ForModel(part.source.data, part.source.media_type, { + maxEdge: options.maxImageEdgePx, telemetry: telemetryFor('prompt_inline'), }); if (compressed.changed) { @@ -370,6 +375,7 @@ async function resolvePromptMediaFiles( let bytes: Uint8Array = data; if (part.type === 'image') { const compressed = await compressImageForModel(data, mediaType, { + maxEdge: options.maxImageEdgePx, telemetry: telemetryFor('prompt_file'), }); if (compressed.changed) { From 4d6458f14ab25267857e7240e962c65141aa6545 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Thu, 9 Jul 2026 20:23:37 +0800 Subject: [PATCH 2/3] fix(agent-core): thread harness [image] max_edge_px to TUI paste and ACP ingestion --- .changeset/scoped-image-limits.md | 2 +- .../src/tui/controllers/editor-keyboard.ts | 12 ++- .../editor-keyboard-image-paste.test.ts | 27 +++++- packages/acp-adapter/src/convert.ts | 7 ++ packages/acp-adapter/src/session.ts | 1 + packages/acp-adapter/test/convert.test.ts | 33 +++++++ packages/node-sdk/src/index.ts | 1 + packages/node-sdk/src/kimi-harness.ts | 15 ++++ packages/node-sdk/src/sdk-rpc-client.ts | 1 + packages/node-sdk/test/kimi-harness.test.ts | 86 +++++++++++++++++++ 10 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 packages/node-sdk/test/kimi-harness.test.ts diff --git a/.changeset/scoped-image-limits.md b/.changeset/scoped-image-limits.md index 47fa886cfd..8a30fed516 100644 --- a/.changeset/scoped-image-limits.md +++ b/.changeset/scoped-image-limits.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Scope `[image]` config limits to the owning core instead of a process-global value, so multiple cores in one process (the SDK's multi-client pattern) each compress images with their own `max_edge_px` / `read_byte_budget`, and a config reload of one core never retunes another; the env vars `KIMI_IMAGE_MAX_EDGE_PX` / `KIMI_IMAGE_READ_BYTE_BUDGET` still override process-wide. +Scope `[image]` config limits to the owning core instead of a process-global value, so multiple cores in one process (the SDK's multi-client pattern) each compress images with their own `max_edge_px` / `read_byte_budget`, and a config reload of one core never retunes another; pasted and ACP prompt images now also use the harness's `[image] max_edge_px`, and the env vars `KIMI_IMAGE_MAX_EDGE_PX` / `KIMI_IMAGE_READ_BYTE_BUDGET` still override process-wide. diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index e76492f7b9..383694c073 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -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'; @@ -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; @@ -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) => diff --git a/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts b/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts index 73e05b7ef1..d565481ca0 100644 --- a/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts +++ b/apps/kimi-code/test/tui/controllers/editor-keyboard-image-paste.test.ts @@ -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. @@ -41,7 +42,7 @@ interface PasteHarness { pasteImage(): Promise; } -function createPasteHarness(options: { sessionDir?: string } = {}): PasteHarness { +function createPasteHarness(options: { sessionDir?: string; imageLimits?: ImageLimits } = {}): PasteHarness { const editor: Record unknown) | undefined> = { setHistoryFilter: vi.fn() as unknown as (...args: never[]) => unknown, }; @@ -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(); @@ -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' }); diff --git a/packages/acp-adapter/src/convert.ts b/packages/acp-adapter/src/convert.ts index bf8cdb197c..4e28a1c62c 100644 --- a/packages/acp-adapter/src/convert.ts +++ b/packages/acp-adapter/src/convert.ts @@ -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 { const out: PromptPart[] = []; @@ -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 diff --git a/packages/acp-adapter/src/session.ts b/packages/acp-adapter/src/session.ts index 7fc01f444d..52c621e2b3 100644 --- a/packages/acp-adapter/src/session.ts +++ b/packages/acp-adapter/src/session.ts @@ -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 diff --git a/packages/acp-adapter/test/convert.test.ts b/packages/acp-adapter/test/convert.test.ts index cb4117f485..0a280ab6b4 100644 --- a/packages/acp-adapter/test/convert.test.ts +++ b/packages/acp-adapter/test/convert.test.ts @@ -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 }[] = []; diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index d84e395f9b..c6e1dc6d2c 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -82,6 +82,7 @@ export { IMAGE_BYTE_BUDGET, MAX_IMAGE_EDGE_PX, } from '@moonshot-ai/agent-core'; +export { ImageLimits } from '@moonshot-ai/agent-core'; export type { CompressImageOptions, CompressImageResult, diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index d503143eac..7e9a22a7b1 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -2,6 +2,7 @@ import type { Kaos } from '@moonshot-ai/kaos'; import { ErrorCodes, KimiError, + ImageLimits, withTelemetryContext, type ExperimentalFeatureState, } from '@moonshot-ai/agent-core'; @@ -39,6 +40,13 @@ export interface KimiHarnessRuntimeOptions { readonly ensureConfigFile: () => Promise; readonly onClose: () => void | Promise; readonly sessionStartedProperties?: TelemetryProperties; + /** + * Owner-scoped [image] limits for prompt-ingestion compression in the + * client process (paste-time, ACP prompt conversion). In-process cores + * (SDKRpcClient) hand over their core's instance; daemon-client hosts + * leave it undefined and ingestion falls back to env/built-in defaults. + */ + readonly imageLimits?: ImageLimits | undefined; } export class KimiHarness { @@ -54,6 +62,12 @@ export class KimiHarness { private readonly closeImpl: () => void | Promise; private readonly sessionStartedProperties: TelemetryProperties; + /** + * Ingestion-side [image] limits owned by this harness's core; undefined for + * daemon-client hosts, where the env var / built-in defaults apply. + */ + readonly imageLimits: ImageLimits | undefined; + constructor( private readonly rpc: SDKRpcClientBase, options: KimiHarnessRuntimeOptions, @@ -67,6 +81,7 @@ export class KimiHarness { this.ensureConfigFileImpl = options.ensureConfigFile; this.closeImpl = options.onClose; this.sessionStartedProperties = options.sessionStartedProperties ?? {}; + this.imageLimits = options.imageLimits; } get sessions(): ReadonlyMap { diff --git a/packages/node-sdk/src/sdk-rpc-client.ts b/packages/node-sdk/src/sdk-rpc-client.ts index 923d0765a3..16b73fb703 100644 --- a/packages/node-sdk/src/sdk-rpc-client.ts +++ b/packages/node-sdk/src/sdk-rpc-client.ts @@ -139,6 +139,7 @@ export function createKimiHarness(options: KimiHarnessOptions): KimiHarness { telemetry: rpc.telemetry, ensureConfigFile: () => rpc.ensureConfigFile(), onClose: () => rpc.close(), + imageLimits: rpc.core.imageLimits, sessionStartedProperties: options.sessionStartedProperties, }); } diff --git a/packages/node-sdk/test/kimi-harness.test.ts b/packages/node-sdk/test/kimi-harness.test.ts new file mode 100644 index 0000000000..8e8e21eb7c --- /dev/null +++ b/packages/node-sdk/test/kimi-harness.test.ts @@ -0,0 +1,86 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { createKimiHarness, ImageLimits, KimiHarness, SDKRpcClientBase } from '#/index'; + +import { recordingTelemetry } from './telemetry'; +import { TEST_IDENTITY } from './test-identity'; + +const tempDirs: string[] = []; + +afterEach(async () => { + for (const dir of tempDirs.splice(0)) { + await rm(dir, { recursive: true, force: true }); + } +}); + +/** + * The recursive RPC surface KimiHarness touches for the tests below: kept + * minimal like the StubRpc in create-session-transport.test.ts. + */ +class StubRpc extends SDKRpcClientBase { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + protected async getRpc(): Promise { + throw new Error('no core calls expected'); + } +} + +describe('KimiHarness imageLimits', () => { + it('exposes the in-process core [image] limits loaded from config.toml', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-harness-')); + tempDirs.push(homeDir); + await writeFile( + join(homeDir, 'config.toml'), + ` +[image] +max_edge_px = 1200 +read_byte_budget = 65536 +`, + 'utf-8', + ); + + const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); + try { + // The core was constructed in-process; its owner-scoped [image] limits + // must be readable on the harness for prompt-ingestion paths. + expect(harness.imageLimits).toBeInstanceOf(ImageLimits); + expect(harness.imageLimits?.maxEdgePx()).toBe(1200); + expect(harness.imageLimits?.readByteBudget()).toBe(65536); + } finally { + await harness.close(); + } + }); + + it('falls back to built-in defaults when no [image] section is configured', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-harness-')); + tempDirs.push(homeDir); + + const harness = createKimiHarness({ identity: TEST_IDENTITY, homeDir }); + try { + expect(harness.imageLimits).toBeInstanceOf(ImageLimits); + expect(harness.imageLimits?.maxEdgePx()).toBe(2000); + expect(harness.imageLimits?.readByteBudget()).toBe(256 * 1024); + } finally { + await harness.close(); + } + }); + + it('a hand-built harness returns the injected ImageLimits as-is', () => { + const limits = new ImageLimits(process.env, { maxEdgePx: 900 }); + const harness = new KimiHarness(new StubRpc(), { + homeDir: '/tmp/home', + configPath: '/tmp/config.toml', + auth: { status: async () => ({ providers: [] }) } as never, + telemetry: recordingTelemetry([]), + ensureConfigFile: async () => undefined, + onClose: () => undefined, + imageLimits: limits, + }); + + expect(harness.imageLimits).toBe(limits); + expect(harness.imageLimits?.maxEdgePx()).toBe(900); + }); +}); From 1020eb01b17d14e19a21b0015c94ca2635c542d9 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Thu, 9 Jul 2026 20:33:43 +0800 Subject: [PATCH 3/3] chore(changeset): simplify entry to user-facing wording --- .changeset/scoped-image-limits.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/scoped-image-limits.md b/.changeset/scoped-image-limits.md index 8a30fed516..091483420b 100644 --- a/.changeset/scoped-image-limits.md +++ b/.changeset/scoped-image-limits.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Scope `[image]` config limits to the owning core instead of a process-global value, so multiple cores in one process (the SDK's multi-client pattern) each compress images with their own `max_edge_px` / `read_byte_budget`, and a config reload of one core never retunes another; pasted and ACP prompt images now also use the harness's `[image] max_edge_px`, and the env vars `KIMI_IMAGE_MAX_EDGE_PX` / `KIMI_IMAGE_READ_BYTE_BUDGET` still override process-wide. +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.