mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
* feat(agent-core): progressive tool disclosure via select_tools Keep MCP tool schemas out of the immutable top-level tools[] and let the model load them on demand, preserving the provider prompt cache: - kosong: Message.tools (append-only load primitive, serialized as Kimi messages[].tools with type:function wrapping and no content), Tool.deferred (stripped once in generate() so loaded tools stay executable without re-entering the top level), select_tools capability bit (UNKNOWN/catalog default false). - select_tools builtin: load-by-exact-name, three-branch semantics settled per name (Loaded / Already available / Unknown), schemas read from the live registry, injection-origin schema messages survive undo. - ToolsDiffInjector: <tools_added>/<tools_removed> announcements at turn boundaries and post-compaction, folded from history (undo/compaction/ resume self-heal), appended only when the loadable set changes. - Loaded-tools ledger = history scan + defer-window pending set (cleared on /clear); loop re-reads the executable table per step so a selected tool dispatches on the next step of the same turn; preflight distinguishes not-loaded from loaded-but-disconnected. - Cross-cuts: projection strips protocol context for non-select_tools models (lossless mid-session model switch both ways), compaction filters it from the summarizer input and rebuilds loaded schemas keep-all after folding, token estimation counts message.tools, request logging reflects the post-strip wire tools. - Three-condition gate: capability.select_tools x capability.tool_use x tool-select experimental flag (KIMI_CODE_EXPERIMENTAL_TOOL_SELECT). Any gate closed reproduces the inline request byte-for-byte; all current models keep the capability off, so behavior is unchanged until a supporting model is catalogued. The SDK catalog-to-alias mapping forwards the capability so catalog-driven setups can enable it. * feat(kosong): skip tool-declaration-only messages in non-Kimi providers Message-level tool declarations (messages[].tools) are a Kimi wire feature. The other providers' explicit field construction already keeps the tools field off the wire, but the content-free leftover message would be rejected (OpenAI: system message without content) or serialize as a garbage <system></system> turn (Anthropic/Google system-to-user wrapping). Skip such messages entirely via a shared predicate; a message that also carries content only loses the tools field, as before. Unreachable in kimi-code (the projection gate strips dynamic-tool context for models without the select_tools capability before any provider sees it) — defense-in-depth for direct kosong consumers. * fix(agent-core): survive runtime flag flips and align tool table with post-compaction state Two fixes from PR review: - Register select_tools unconditionally and gate only its exposure in loopTools. The tool-select flag can flip at runtime (config reload calls setConfigOverrides on the live resolver) without initializeBuiltinTools re-running; previously the disclosure shape activated while the tool itself was unregistered, cutting the session off from MCP entirely until a model/cwd change rebuilt the builtins. A profile listing the name explicitly still never surfaces it in inline mode, and execution guards the flip race defensively. - Resolve the per-step tool table AFTER beforeStep, next to buildMessages. beforeStep can run full compaction, which trims loaded schemas and rewrites the ledger; a table captured before it could still dispatch a tool whose schema the model no longer has. The executable table and the request messages now always reflect the same state, so a trimmed tool is rejected with select guidance instead of executed. * fix(agent-core): drop unused Tool import in dynamic-tools * fix(agent-core): baseline compaction guard after post-compaction reinjection The reinjected reminders (loadable-tools manifest, goal) are re-appended after every compaction, but the nothing-new-since-compaction baseline was captured before injectAfterCompaction. With a large manifest the guard could re-trigger auto-compaction against a floor that cannot shrink. Raise the baseline to the true post-compaction floor once reinjection completes; the earlier capture stays as a fallback when reinjection throws. --------- Co-authored-by: fengchenchen <fengchenchen@moonshot.ai>
101 lines
3.8 KiB
TypeScript
101 lines
3.8 KiB
TypeScript
import type { ContentPart } from '@moonshot-ai/kosong';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
estimateTokens,
|
|
estimateTokensForContentPart,
|
|
estimateTokensForMessage,
|
|
estimateTokensForTools,
|
|
MEDIA_TOKEN_ESTIMATE,
|
|
} from '../../src/utils/tokens';
|
|
|
|
// Regression coverage for CMP-03: media content parts (image/audio/video) must
|
|
// NOT estimate to 0 tokens. When they did, compaction triggers, the
|
|
// overflow-shrink budget, the kept-user 20k budget, and the reported
|
|
// `tokensAfter` all went blind to the single largest context contributor (a
|
|
// base64 image data URL), so a vision-heavy session could overflow the provider
|
|
// while the estimator reported a near-empty context.
|
|
describe('estimateTokensForContentPart — media parts', () => {
|
|
const imagePart: ContentPart = {
|
|
type: 'image_url',
|
|
imageUrl: { url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB' },
|
|
};
|
|
const audioPart: ContentPart = {
|
|
type: 'audio_url',
|
|
audioUrl: { url: 'data:audio/mp3;base64,AAAA' },
|
|
};
|
|
const videoPart: ContentPart = {
|
|
type: 'video_url',
|
|
videoUrl: { url: 'data:video/mp4;base64,AAAA' },
|
|
};
|
|
|
|
it('estimates an image part as a substantial, non-zero token cost', () => {
|
|
expect(estimateTokensForContentPart(imagePart)).toBe(MEDIA_TOKEN_ESTIMATE);
|
|
expect(MEDIA_TOKEN_ESTIMATE).toBeGreaterThan(100);
|
|
});
|
|
|
|
it('estimates audio and video parts as non-zero', () => {
|
|
expect(estimateTokensForContentPart(audioPart)).toBeGreaterThan(0);
|
|
expect(estimateTokensForContentPart(videoPart)).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('uses a bounded fixed estimate, not the base64 payload length', () => {
|
|
// A ~4 MB base64 data URL must not be counted as text (which would yield
|
|
// ~1M "tokens"); the estimate must stay a small bounded value.
|
|
const huge = 'A'.repeat(4_000_000);
|
|
const bigImage: ContentPart = {
|
|
type: 'image_url',
|
|
imageUrl: { url: `data:image/png;base64,${huge}` },
|
|
};
|
|
const estimate = estimateTokensForContentPart(bigImage);
|
|
expect(estimate).toBeGreaterThan(0);
|
|
expect(estimate).toBeLessThan(50_000);
|
|
});
|
|
|
|
it('includes media when estimating a whole message', () => {
|
|
const message = {
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'see screenshot' }, imagePart] satisfies ContentPart[],
|
|
};
|
|
// The image must dominate the ~4-token text, not be free.
|
|
expect(estimateTokensForMessage(message)).toBeGreaterThan(100);
|
|
});
|
|
});
|
|
|
|
// Dynamic tool schema messages (select_tools progressive disclosure) carry
|
|
// full tool definitions in `message.tools`. If the estimator ignores them,
|
|
// injected schemas are invisible to every compaction budget and the context
|
|
// overflows before compaction triggers.
|
|
describe('estimateTokensForMessage — message.tools', () => {
|
|
const tool = {
|
|
name: 'mcp__grafana__query_range',
|
|
description: 'Query a Prometheus-compatible range endpoint.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: { query: { type: 'string' }, minutes: { type: 'number' } },
|
|
required: ['query'],
|
|
},
|
|
};
|
|
|
|
it('counts injected tool schemas', () => {
|
|
const bare = { role: 'system', content: [] } as const;
|
|
const withTools = { role: 'system', content: [], tools: [tool] } as const;
|
|
expect(estimateTokensForMessage(withTools)).toBe(
|
|
estimateTokensForMessage(bare) + estimateTokensForTools([tool]),
|
|
);
|
|
});
|
|
|
|
it('leaves messages without tools byte-identical to the old estimate', () => {
|
|
const message = {
|
|
role: 'user',
|
|
content: [{ type: 'text', text: 'hello world' }] satisfies ContentPart[],
|
|
toolCalls: [{ name: 'Read', arguments: { file: 'a.ts' } }],
|
|
};
|
|
const expected =
|
|
estimateTokens('user') +
|
|
estimateTokens('hello world') +
|
|
estimateTokens('Read') +
|
|
estimateTokens(JSON.stringify({ file: 'a.ts' }));
|
|
expect(estimateTokensForMessage(message)).toBe(expected);
|
|
});
|
|
});
|