Skip to content

Commit 9260081

Browse files
author
fengchenchen
committed
refactor(kosong): rename select_tools capability to dynamically_loaded_tools
ModelCapability describes what the model can do; the model's ability is accepting message-level tool declarations (dynamically loaded tools) — select_tools is the name of the client-side tool that drives it, and the config-to-mechanism translation is the agent layer's business. Rename the capability bit to the canonical dynamically_loaded_tools everywhere it is declared, detected, catalogued, and forwarded. No breaking change: the deprecated select_tools spelling stays accepted — as a ModelCapability/CatalogModelEntry field and as a model-alias capabilities config string — through the single hasDynamicallyLoadedTools read point, so existing configs keep working unchanged. The client-side vocabulary (the select_tools builtin tool, the tool-select experimental flag) is deliberately untouched.
1 parent f30781b commit 9260081

16 files changed

Lines changed: 150 additions & 55 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@moonshot-ai/kosong": patch
3+
"@moonshot-ai/kimi-code": patch
4+
"@moonshot-ai/kimi-code-sdk": patch
5+
---
6+
7+
Rename the dynamic tool loading model capability to its canonical name `dynamically_loaded_tools` (the model-level ability to accept message-level tool declarations). The previous `select_tools` spelling — which named the client-side mechanism rather than the model capability — remains fully supported as a deprecated alias in model alias `capabilities` config and in catalog entries, so no configuration changes are required. The SDK's catalog-to-alias mapping now emits the canonical name.

.changeset/select-tools-discard-on-compaction.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"@moonshot-ai/kimi-code": patch
33
---
44

5-
Progressive tool disclosure (`select_tools`, experimental): compaction now discards the loaded tool schemas instead of re-injecting them. After a compaction the boundary announcement re-lists every loadable tool name and the model re-selects what it still needs; a from-memory call to a no-longer-loaded tool is rejected with guidance to select it first. This keeps the post-compaction context at its minimal users+summary floor and removes the schema-rebuild budget heuristics. No effect unless the `tool-select` experimental flag and a `select_tools`-capable model are active.
5+
Progressive tool disclosure (`select_tools`, experimental): compaction now discards the loaded tool schemas instead of re-injecting them. After a compaction the boundary announcement re-lists every loadable tool name and the model re-selects what it still needs; a from-memory call to a no-longer-loaded tool is rejected with guidance to select it first. This keeps the post-compaction context at its minimal users+summary floor and removes the schema-rebuild budget heuristics. No effect unless the `tool-select` experimental flag and a model with the dynamically-loaded-tools capability are active.

packages/agent-core/src/agent/context/dynamic-tools.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,10 @@ export function isLoadableToolsAnnouncement(message: ContextMessage): boolean {
3939
* Shape a history for a consumer that must not see dynamic-tool protocol
4040
* context: drop the loadable-tools announcements and strip `message.tools`
4141
* (dropping the message entirely when nothing else remains). Two callers:
42-
* - projection for a model without the `select_tools` capability (mid-session
43-
* model switch — the canonical history keeps its shape, only the outgoing
44-
* view changes; announcements would be noise and even reference a
45-
* select_tools tool the model does not have);
42+
* - projection for a model without the dynamically-loaded-tools capability
43+
* (mid-session model switch — the canonical history keeps its shape, only
44+
* the outgoing view changes; announcements would be noise and even
45+
* reference a select_tools tool the model does not have);
4646
* - the compaction summarizer input (schemas and announcements are protocol
4747
* context, not conversation — summarizing them wastes tokens and risks
4848
* leaking schema text into the summary).

packages/agent-core/src/agent/context/index.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -376,11 +376,12 @@ export class ContextMemory {
376376

377377
project(messages: readonly ContextMessage[], options?: ProjectOptions): Message[] {
378378
// Shape for the current model BEFORE projecting: a model without the
379-
// select_tools capability must not see dynamic-tool schema messages or
380-
// loadable-tools announcements (the canonical history keeps them; only
381-
// this outgoing view is shaped). Must run pre-projection — project()
382-
// strips `origin`, the only anchor for the announcements. setModel never
383-
// rewrites history, so a mid-session switch degrades/upgrades losslessly.
379+
// dynamically-loaded-tools capability must not see dynamic-tool schema
380+
// messages or loadable-tools announcements (the canonical history keeps
381+
// them; only this outgoing view is shaped). Must run pre-projection —
382+
// project() strips `origin`, the only anchor for the announcements.
383+
// setModel never rewrites history, so a mid-session switch
384+
// degrades/upgrades losslessly.
384385
const shaped = this.agent.toolSelectEnabled ? messages : stripDynamicToolContext(messages);
385386
const anomalies: ProjectionAnomaly[] = [];
386387
const result = project(this.agent.microCompaction.compact(shaped), {

packages/agent-core/src/agent/index.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { ErrorCodes, KimiError, makeErrorPayload } from '#/errors';
66
import { log } from '#/logging/logger';
77
import type { Logger } from '#/logging/types';
88
import type { AgentAPI, AgentEvent, KimiConfig, SDKAgentRPC, UsageStatus } from '#/rpc';
9-
import { generate } from '@moonshot-ai/kosong';
9+
import { generate, hasDynamicallyLoadedTools } from '@moonshot-ai/kosong';
1010

1111
import type { EnabledPluginSessionStart, PluginCommandDef } from '#/plugin';
1212
import { expandCommandArguments } from '../plugin/commands';
@@ -236,18 +236,20 @@ export class Agent {
236236

237237
/**
238238
* Single decision point for select_tools progressive disclosure. All three
239-
* gates must be open: the model declares the `select_tools` capability, the
240-
* model declares `tool_use` (a model without tool use registering
241-
* select_tools is a contradiction), and the `tool-select` experimental flag
242-
* is on. Every consumer — top-level tools[] convergence, select_tools
243-
* registration, manifest announcements, projection shaping — reads this
244-
* instead of re-deriving the conditions, so degradation is lossless: any
245-
* closed gate reproduces the inline behavior byte-for-byte.
239+
* gates must be open: the model has the dynamically-loaded-tools capability
240+
* (message-level tool declarations; the deprecated `select_tools` spelling
241+
* is honored via hasDynamicallyLoadedTools), the model declares `tool_use`
242+
* (a model without tool use loading tools dynamically is a contradiction),
243+
* and the `tool-select` experimental flag is on. Every consumer — top-level
244+
* tools[] convergence, select_tools registration, manifest announcements,
245+
* projection shaping — reads this instead of re-deriving the conditions, so
246+
* degradation is lossless: any closed gate reproduces the inline behavior
247+
* byte-for-byte.
246248
*/
247249
get toolSelectEnabled(): boolean {
248250
const capability = this.config.modelCapabilities;
249251
return (
250-
capability.select_tools === true &&
252+
hasDynamicallyLoadedTools(capability) &&
251253
capability.tool_use &&
252254
this.experimentalFlags.enabled('tool-select')
253255
);

packages/agent-core/src/flags/registry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export const FLAG_DEFINITIONS = [
2727
id: 'tool-select',
2828
title: 'Tool select (progressive tool disclosure)',
2929
description:
30-
'Keep MCP tool schemas out of the immutable top-level tools[]; the model loads them on demand via the select_tools tool. Only takes effect on models whose capability catalog declares select_tools.',
30+
'Keep MCP tool schemas out of the immutable top-level tools[]; the model loads them on demand via the select_tools tool. Only takes effect on models whose capability catalog declares dynamically loaded tools.',
3131
env: 'KIMI_CODE_EXPERIMENTAL_TOOL_SELECT',
3232
default: false,
3333
surface: 'core',

packages/agent-core/src/session/provider-manager.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Logger } from '#/logging/types';
22
import type { ProviderConfig as KosongProviderConfig, ModelCapability, ProviderRequestAuth } from '@moonshot-ai/kosong';
3-
import { APIStatusError, getModelCapability, UNKNOWN_CAPABILITY } from '@moonshot-ai/kosong';
3+
import { APIStatusError, getModelCapability, hasDynamicallyLoadedTools, UNKNOWN_CAPABILITY } from '@moonshot-ai/kosong';
44
import { parseKimiCodeCustomHeaders } from '@moonshot-ai/kimi-code-oauth';
55
import {
66
effectiveModelAlias,
@@ -233,10 +233,14 @@ function resolveModelCapabilities(
233233
thinking: declared.has('thinking') || declared.has('always_thinking') || detected.thinking,
234234
tool_use: declared.has('tool_use') || detected.tool_use,
235235
max_context_tokens: alias.maxContextSize,
236-
// Message-level tool declarations (select_tools progressive disclosure).
237-
// Every field here must be merged explicitly — a capability registered in
238-
// kosong that is not forwarded here never reaches the agent.
239-
select_tools: declared.has('select_tools') || detected.select_tools === true,
236+
// Message-level tool declarations ("dynamically loaded tools"). Every
237+
// field here must be merged explicitly — a capability registered in
238+
// kosong that is not forwarded here never reaches the agent. The
239+
// deprecated `select_tools` spelling stays accepted in alias configs.
240+
dynamically_loaded_tools:
241+
declared.has('dynamically_loaded_tools') ||
242+
declared.has('select_tools') ||
243+
hasDynamicallyLoadedTools(detected),
240244
};
241245
}
242246

packages/agent-core/test/agent/harness/agent.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1094,6 +1094,9 @@ function capabilityNames(capabilities: ModelCapability | undefined): string[] {
10941094
capabilities.audio_in ? 'audio_in' : undefined,
10951095
capabilities.thinking ? 'thinking' : undefined,
10961096
capabilities.tool_use ? 'tool_use' : undefined,
1097+
capabilities.dynamically_loaded_tools === true ? 'dynamically_loaded_tools' : undefined,
1098+
// Deliberately kept as the legacy string so tests exercising the
1099+
// deprecated field also exercise the deprecated declared-name path.
10971100
capabilities.select_tools === true ? 'select_tools' : undefined,
10981101
].filter((capability): capability is string => capability !== undefined);
10991102
}

packages/agent-core/test/agent/tool-select.e2e.test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* select_tools progressive disclosure — end-to-end agent tests.
33
*
44
* Uses the scripted-generate harness: real ToolManager/turn loop/context, fake
5-
* LLM. The three-condition gate (model capability.select_tools ×
5+
* LLM. The three-condition gate (model capability.dynamically_loaded_tools ×
66
* capability.tool_use × `tool-select` flag) is driven through the alias
77
* capability declarations and an injected FlagResolver.
88
*
@@ -28,6 +28,18 @@ import { testAgent, type TestAgentContext } from './harness/agent';
2828

2929
const DISCLOSURE_PROVIDER = { type: 'kimi', apiKey: 'test-key', model: 'select-capable-model' } as const;
3030
const DISCLOSURE_CAPABILITIES = {
31+
image_in: false,
32+
video_in: false,
33+
audio_in: false,
34+
thinking: false,
35+
tool_use: true,
36+
max_context_tokens: 256_000,
37+
dynamically_loaded_tools: true,
38+
} as const;
39+
40+
// Same capability through the deprecated field name — must keep working
41+
// (declared as the legacy `select_tools` capability string by the harness).
42+
const LEGACY_DISCLOSURE_CAPABILITIES = {
3143
image_in: false,
3244
video_in: false,
3345
audio_in: false,
@@ -220,6 +232,31 @@ describe('disclosure mode — top-level convergence and announcements', () => {
220232
expect(JSON.stringify(call.history)).toContain(GRAFANA_TOOL);
221233
});
222234

235+
it('honors the deprecated select_tools capability spelling end to end', async () => {
236+
// Declared through the harness as the legacy `select_tools` capability
237+
// string; the gate, convergence, select and dispatch must all still work.
238+
const callLog: Array<[string, unknown]> = [];
239+
const ctx = testAgent({ experimentalFlags: toolSelectFlagOn() });
240+
ctx.configure({
241+
tools: ['Read', 'mcp__*'],
242+
provider: DISCLOSURE_PROVIDER,
243+
modelCapabilities: LEGACY_DISCLOSURE_CAPABILITIES,
244+
});
245+
await registerGrafana(ctx, callLog);
246+
await ctx.rpc.setPermission({ mode: 'yolo' });
247+
expect(ctx.agent.toolSelectEnabled).toBe(true);
248+
249+
ctx.mockNextResponse({ type: 'text', text: 'loading' }, selectCall('call-1', [GRAFANA_TOOL]));
250+
ctx.mockNextResponse({ type: 'text', text: 'querying' }, mcpCall('call-2', 'errors'));
251+
ctx.mockNextResponse({ type: 'text', text: 'done' });
252+
await runTurn(ctx, 'use the tool');
253+
254+
const wireNames = ctx.llmCalls.at(-3)!.tools.map((t) => t.name);
255+
expect(wireNames).toContain('select_tools');
256+
expect(wireNames.some((n) => n.startsWith('mcp__'))).toBe(false);
257+
expect(callLog).toEqual([['query_range', { query: 'errors' }]]);
258+
});
259+
223260
it('does not re-announce when the loadable set is unchanged', async () => {
224261
const ctx = await disclosureAgent();
225262
ctx.mockNextResponse({ type: 'text', text: 'one' });

packages/kosong/src/capability.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,31 @@ export interface ModelCapability {
1616
readonly tool_use: boolean;
1717
readonly max_context_tokens: number;
1818
/**
19-
* Model accepts message-level tool declarations (`messages[].tools`), the
20-
* primitive behind select_tools progressive disclosure. Absent means
21-
* unsupported: only models explicitly catalogued or declared with this
22-
* capability may ever receive a message carrying `tools`.
19+
* Model accepts message-level tool declarations (`messages[].tools`) — the
20+
* "dynamically loaded tools" wire feature that clients can drive with
21+
* progressive tool disclosure. Absent means unsupported: only models
22+
* explicitly catalogued or declared with this capability may ever receive a
23+
* message carrying `tools`.
24+
*/
25+
readonly dynamically_loaded_tools?: boolean;
26+
/**
27+
* @deprecated Legacy alias of {@link ModelCapability.dynamically_loaded_tools}
28+
* (this bit originally shipped under the client-side mechanism's tool name).
29+
* Still honored everywhere via {@link hasDynamicallyLoadedTools}; write the
30+
* canonical field instead.
2331
*/
2432
readonly select_tools?: boolean;
2533
}
2634

35+
/**
36+
* True when the model accepts message-level tool declarations. Single read
37+
* point for the capability so the deprecated `select_tools` spelling keeps
38+
* working wherever the bit is consumed.
39+
*/
40+
export function hasDynamicallyLoadedTools(capability: ModelCapability): boolean {
41+
return capability.dynamically_loaded_tools === true || capability.select_tools === true;
42+
}
43+
2744
const UNKNOWN_CAPABILITY_MARKER = Symbol.for('moonshot-ai.kosong.UNKNOWN_CAPABILITY');
2845

2946
/**
@@ -40,7 +57,10 @@ export const UNKNOWN_CAPABILITY: ModelCapability = Object.freeze(
4057
thinking: false,
4158
tool_use: false,
4259
max_context_tokens: 0,
43-
select_tools: false,
60+
// Only the canonical spelling in the default output shape; the
61+
// deprecated `select_tools` field is accepted on input but never
62+
// emitted (this object reaches RPC/SDK consumers via config snapshots).
63+
dynamically_loaded_tools: false,
4464
},
4565
UNKNOWN_CAPABILITY_MARKER,
4666
{ value: true },
@@ -58,7 +78,7 @@ export function isUnknownCapability(capability: ModelCapability): boolean {
5878
!capability.audio_in &&
5979
!capability.thinking &&
6080
!capability.tool_use &&
61-
capability.select_tools !== true &&
81+
!hasDynamicallyLoadedTools(capability) &&
6282
capability.max_context_tokens === 0
6383
);
6484
}

0 commit comments

Comments
 (0)