kimi-code/packages/kosong/test/select-tools.test.ts
STAR-QUAKE f0896a53b0
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 (#1369)
* 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>
2026-07-06 15:51:08 +08:00

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);
});
});