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>
353 lines
13 KiB
TypeScript
353 lines
13 KiB
TypeScript
/**
|
|
* select_tools progressive disclosure — kosong-side contract tests.
|
|
*
|
|
* Covers the three primitives this package contributes:
|
|
* - `Message.tools` serialization on the Kimi wire (`messages[].tools`,
|
|
* `{type:'function', function:{...}}` wrapping, no `content`, schema
|
|
* normalization and the `$` builtin branch shared with top-level tools);
|
|
* - `Tool.deferred` stripping in `generate()` (single strip point for every
|
|
* provider call — the marker itself must never reach the wire);
|
|
* - the `select_tools` capability bit (unknown/default-off semantics).
|
|
*/
|
|
|
|
import { UNKNOWN_CAPABILITY, isUnknownCapability } from '#/capability';
|
|
import { catalogModelToCapability } from '#/catalog';
|
|
import { generate } from '#/generate';
|
|
import { isToolDeclarationOnlyMessage } from '#/message';
|
|
import type { Message, StreamedMessagePart } from '#/message';
|
|
import { AnthropicChatProvider } from '#/providers/anthropic';
|
|
import { messagesToGoogleGenAIContents } from '#/providers/google-genai';
|
|
import { KimiChatProvider } from '#/providers/kimi';
|
|
import { OpenAILegacyChatProvider } from '#/providers/openai-legacy';
|
|
import { OpenAIResponsesChatProvider } from '#/providers/openai-responses';
|
|
import type { ChatProvider, StreamedMessage, ThinkingEffort } from '#/provider';
|
|
import type { Tool } from '#/tool';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
const ADD_TOOL: Tool = {
|
|
name: 'add',
|
|
description: 'Add two integers.',
|
|
parameters: {
|
|
type: 'object',
|
|
properties: {
|
|
a: { type: 'integer', description: 'First number' },
|
|
b: { type: 'integer', description: 'Second number' },
|
|
},
|
|
required: ['a', 'b'],
|
|
},
|
|
};
|
|
|
|
const BUILTIN_TOOL: Tool = {
|
|
name: '$web_search',
|
|
description: 'Search the web',
|
|
parameters: { type: 'object', properties: {} },
|
|
};
|
|
|
|
function makeChatCompletionResponse() {
|
|
return {
|
|
id: 'chatcmpl-test123',
|
|
object: 'chat.completion',
|
|
created: 1234567890,
|
|
model: 'kimi-test',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
message: { role: 'assistant', content: 'Hello' },
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
|
};
|
|
}
|
|
|
|
async function captureRequestBody(
|
|
tools: Tool[],
|
|
history: Message[],
|
|
): Promise<Record<string, unknown>> {
|
|
const provider = new KimiChatProvider({
|
|
model: 'kimi-test',
|
|
apiKey: 'test-key',
|
|
stream: false,
|
|
});
|
|
let capturedBody: Record<string, unknown> | undefined;
|
|
(provider as any)._client.chat.completions.create = vi
|
|
.fn()
|
|
.mockImplementation((params: unknown) => {
|
|
capturedBody = params as Record<string, unknown>;
|
|
return Promise.resolve(makeChatCompletionResponse());
|
|
});
|
|
const stream = await provider.generate('system prompt', tools, history);
|
|
for await (const part of stream) {
|
|
void part;
|
|
}
|
|
if (capturedBody === undefined) {
|
|
throw new Error('Expected provider.generate() to call chat.completions.create');
|
|
}
|
|
return capturedBody;
|
|
}
|
|
|
|
describe('Kimi messages[].tools serialization', () => {
|
|
it('serializes a system message carrying tools with function wrapping and no content', async () => {
|
|
const history: Message[] = [
|
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
|
|
{ role: 'system', content: [], toolCalls: [], tools: [ADD_TOOL] },
|
|
];
|
|
const body = await captureRequestBody([], history);
|
|
const messages = body['messages'] as Array<Record<string, unknown>>;
|
|
// [system prompt, user, system+tools]
|
|
expect(messages).toHaveLength(3);
|
|
const toolsMessage = messages[2]!;
|
|
expect(toolsMessage['role']).toBe('system');
|
|
expect('content' in toolsMessage).toBe(false);
|
|
expect(toolsMessage['tools']).toEqual([
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'add',
|
|
description: 'Add two integers.',
|
|
parameters: ADD_TOOL.parameters,
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('routes $-prefixed names through the builtin_function branch, same as top-level tools', async () => {
|
|
const history: Message[] = [
|
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
|
|
{ role: 'system', content: [], toolCalls: [], tools: [BUILTIN_TOOL] },
|
|
];
|
|
const body = await captureRequestBody([], history);
|
|
const messages = body['messages'] as Array<Record<string, unknown>>;
|
|
expect(messages[2]!['tools']).toEqual([
|
|
{ type: 'builtin_function', function: { name: '$web_search' } },
|
|
]);
|
|
});
|
|
|
|
it('leaves messages without tools untouched (no tools key)', async () => {
|
|
const history: Message[] = [
|
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
|
|
];
|
|
const body = await captureRequestBody([ADD_TOOL], history);
|
|
const messages = body['messages'] as Array<Record<string, unknown>>;
|
|
for (const message of messages) {
|
|
expect('tools' in message).toBe(false);
|
|
}
|
|
// Top-level tools[] unchanged by the feature.
|
|
expect(body['tools']).toEqual([
|
|
{
|
|
type: 'function',
|
|
function: {
|
|
name: 'add',
|
|
description: 'Add two integers.',
|
|
parameters: ADD_TOOL.parameters,
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('does not serialize the deferred marker even if a marked tool reaches convertMessage', async () => {
|
|
const history: Message[] = [
|
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
|
|
{
|
|
role: 'system',
|
|
content: [],
|
|
toolCalls: [],
|
|
tools: [{ ...ADD_TOOL, deferred: true }],
|
|
},
|
|
];
|
|
const body = await captureRequestBody([], history);
|
|
const messages = body['messages'] as Array<Record<string, unknown>>;
|
|
const serialized = JSON.stringify(messages[2]!['tools']);
|
|
expect(serialized).not.toContain('deferred');
|
|
});
|
|
});
|
|
|
|
describe('generate() deferred tool stripping', () => {
|
|
function createCapturingProvider(): { provider: ChatProvider; seenTools: () => Tool[] } {
|
|
let captured: Tool[] = [];
|
|
const stream: StreamedMessage = {
|
|
id: null,
|
|
usage: null,
|
|
finishReason: 'completed',
|
|
rawFinishReason: 'stop',
|
|
async *[Symbol.asyncIterator](): AsyncIterator<StreamedMessagePart> {
|
|
yield { type: 'text', text: 'ok' };
|
|
},
|
|
};
|
|
const provider: ChatProvider = {
|
|
name: 'mock',
|
|
modelName: 'mock-model',
|
|
thinkingEffort: null as ThinkingEffort | null,
|
|
generate: async (_systemPrompt, tools, _history) => {
|
|
captured = tools;
|
|
return stream;
|
|
},
|
|
withThinking(_effort: ThinkingEffort): ChatProvider {
|
|
return this;
|
|
},
|
|
};
|
|
return { provider, seenTools: () => captured };
|
|
}
|
|
|
|
it('strips deferred tools before the provider builds the request', async () => {
|
|
const { provider, seenTools } = createCapturingProvider();
|
|
await generate(provider, 'sys', [ADD_TOOL, { ...BUILTIN_TOOL, deferred: true }], [
|
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
|
|
]);
|
|
expect(seenTools()).toEqual([ADD_TOOL]);
|
|
});
|
|
|
|
it('passes the identical array through when nothing is deferred', async () => {
|
|
const { provider, seenTools } = createCapturingProvider();
|
|
const tools = [ADD_TOOL, BUILTIN_TOOL];
|
|
await generate(provider, 'sys', tools, [
|
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
|
|
]);
|
|
expect(seenTools()).toBe(tools);
|
|
});
|
|
});
|
|
|
|
describe('providers without message-level tool declarations', () => {
|
|
const TOOLS_ONLY_MESSAGE: Message = {
|
|
role: 'system',
|
|
content: [],
|
|
toolCalls: [],
|
|
tools: [ADD_TOOL],
|
|
};
|
|
const HISTORY: Message[] = [
|
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
|
|
TOOLS_ONLY_MESSAGE,
|
|
];
|
|
|
|
it('classifies tool-declaration-only messages', () => {
|
|
expect(isToolDeclarationOnlyMessage(TOOLS_ONLY_MESSAGE)).toBe(true);
|
|
expect(isToolDeclarationOnlyMessage(HISTORY[0]!)).toBe(false);
|
|
// A message that also carries content is NOT skipped wholesale (only the
|
|
// tools field stays off the wire via explicit field construction).
|
|
expect(
|
|
isToolDeclarationOnlyMessage({
|
|
...TOOLS_ONLY_MESSAGE,
|
|
content: [{ type: 'text', text: 'x' }],
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('anthropic skips the message instead of emitting a <system></system> husk', async () => {
|
|
const provider = new AnthropicChatProvider({ model: 'k25', apiKey: 'test-key', stream: false });
|
|
let captured: Record<string, unknown> | undefined;
|
|
(provider as any)._client.messages.create = vi.fn().mockImplementation((params: unknown) => {
|
|
captured = params as Record<string, unknown>;
|
|
return Promise.resolve({
|
|
id: 'msg_test_123',
|
|
type: 'message',
|
|
role: 'assistant',
|
|
model: 'k25',
|
|
content: [{ type: 'text', text: 'Hello' }],
|
|
stop_reason: 'end_turn',
|
|
usage: { input_tokens: 10, output_tokens: 5 },
|
|
});
|
|
});
|
|
const stream = await provider.generate('sys', [], HISTORY);
|
|
for await (const part of stream) void part;
|
|
expect(JSON.stringify(captured!['messages'])).not.toContain('<system>');
|
|
expect(captured!['messages'] as unknown[]).toHaveLength(1);
|
|
});
|
|
|
|
it('openai chat completions skips the message instead of sending a content-free system entry', async () => {
|
|
const provider = new OpenAILegacyChatProvider({ model: 'gpt-4.1', apiKey: 'test-key', stream: false });
|
|
let captured: Record<string, unknown> | undefined;
|
|
(provider as any)._client.chat.completions.create = vi
|
|
.fn()
|
|
.mockImplementation((params: unknown) => {
|
|
captured = params as Record<string, unknown>;
|
|
return Promise.resolve({
|
|
id: 'chatcmpl-test123',
|
|
object: 'chat.completion',
|
|
created: 1234567890,
|
|
model: 'gpt-4.1',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
message: { role: 'assistant', content: 'Hello' },
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
|
});
|
|
});
|
|
const stream = await provider.generate('sys', [], HISTORY);
|
|
for await (const part of stream) void part;
|
|
const messages = captured!['messages'] as Array<Record<string, unknown>>;
|
|
// [system prompt, user] — no content-free leftover entry.
|
|
expect(messages).toHaveLength(2);
|
|
for (const message of messages) {
|
|
expect(message['content']).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('openai responses skips the message', async () => {
|
|
const provider = new OpenAIResponsesChatProvider({ model: 'gpt-4.1', apiKey: 'test-key' });
|
|
(provider as any)._stream = false;
|
|
let captured: Record<string, unknown> | undefined;
|
|
((provider as any)._client.responses as Record<string, unknown>)['create'] = vi
|
|
.fn()
|
|
.mockImplementation((params: unknown) => {
|
|
captured = params as Record<string, unknown>;
|
|
return Promise.resolve({
|
|
id: 'resp_test123',
|
|
object: 'response',
|
|
created_at: 1234567890,
|
|
status: 'completed',
|
|
model: 'gpt-4.1',
|
|
output: [
|
|
{
|
|
type: 'message',
|
|
id: 'msg_test',
|
|
role: 'assistant',
|
|
content: [{ type: 'output_text', text: 'Hello', annotations: [] }],
|
|
},
|
|
],
|
|
usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
|
|
});
|
|
});
|
|
const stream = await provider.generate('sys', [], HISTORY);
|
|
for await (const part of stream) void part;
|
|
// The tools-only message contributes no input item at all.
|
|
expect(captured!['input'] as unknown[]).toHaveLength(1);
|
|
expect(JSON.stringify(captured!['input'])).not.toContain('"tools"');
|
|
});
|
|
|
|
it('google genai skips the message explicitly (not just via the empty-text coincidence)', () => {
|
|
const contents = messagesToGoogleGenAIContents(HISTORY);
|
|
expect(contents).toHaveLength(1);
|
|
expect(JSON.stringify(contents)).not.toContain('<system>');
|
|
});
|
|
});
|
|
|
|
describe('select_tools capability bit', () => {
|
|
it('defaults to false on UNKNOWN_CAPABILITY', () => {
|
|
expect(UNKNOWN_CAPABILITY.select_tools).toBe(false);
|
|
});
|
|
|
|
it('a capability that only has select_tools is not "unknown"', () => {
|
|
expect(
|
|
isUnknownCapability({
|
|
image_in: false,
|
|
video_in: false,
|
|
audio_in: false,
|
|
thinking: false,
|
|
tool_use: false,
|
|
max_context_tokens: 0,
|
|
select_tools: true,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('catalog entries map select_tools and default it to false', () => {
|
|
const base = { id: 'm', limit: { context: 1000 } };
|
|
expect(catalogModelToCapability(base)?.capability.select_tools).toBe(false);
|
|
expect(
|
|
catalogModelToCapability({ ...base, select_tools: true })?.capability.select_tools,
|
|
).toBe(true);
|
|
});
|
|
});
|