diff --git a/.changeset/scoped-image-limits.md b/.changeset/scoped-image-limits.md new file mode 100644 index 000000000..091483420 --- /dev/null +++ b/.changeset/scoped-image-limits.md @@ -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. diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index e76492f7b..383694c07 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 73e05b7ef..d565481ca 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 bf8cdb197..4e28a1c62 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 7fc01f444..52c621e2b 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 cb4117f48..0a280ab6b 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/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 6b9d7cf19..f33f0ab59 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 8803227e0..0d056253d 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 b347ac5d6..b4ce829e2 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 0f77683f9..84e246a9d 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 5cd3b03af..0435a12bf 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 73b8b27c7..ce3b75958 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 c66422bff..fa7fd5bd0 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 75f1a302e..a5086e76e 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 b406a5f41..4543c4e33 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 6655735c9..75f9d46bd 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 000000000..b91aac80b --- /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 384cb91a2..542c4783e 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 dafcd0e42..d60f3820a 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 d23f0526a..37825ce0c 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/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index d84e395f9..c6e1dc6d2 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 d503143ea..7e9a22a7b 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 923d0765a..16b73fb70 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 000000000..8e8e21eb7 --- /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); + }); +}); diff --git a/packages/server/src/routes/prompts.ts b/packages/server/src/routes/prompts.ts index 50062f695..ac0d1cd73 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) {