Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/dynamically-loaded-tools-capability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@moonshot-ai/kosong": patch
"@moonshot-ai/kimi-code": patch
"@moonshot-ai/kimi-code-sdk": patch
---

Rename the dynamic tool loading model capability from `select_tools` to `dynamically_loaded_tools`.
4 changes: 4 additions & 0 deletions apps/kimi-code/scripts/update-catalog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ const KEEP_MODEL = new Set([
"reasoning",
"interleaved",
"modalities",
// Message-level tool declarations capability — kosong's
// catalogModelToCapability reads it; stripping it here would silently
// disable tool-select for catalog-imported aliases.
"dynamically_loaded_tools",
]);

function resolveOutputFile(args) {
Expand Down
8 changes: 4 additions & 4 deletions packages/agent-core/src/agent/context/dynamic-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,10 @@ export function isLoadableToolsAnnouncement(message: ContextMessage): boolean {
* Shape a history for a consumer that must not see dynamic-tool protocol
* context: drop the loadable-tools announcements and strip `message.tools`
* (dropping the message entirely when nothing else remains). Two callers:
* - projection for a model without the `select_tools` capability (mid-session
* model switch — the canonical history keeps its shape, only the outgoing
* view changes; announcements would be noise and even reference a
* select_tools tool the model does not have);
* - projection for a model without the dynamically-loaded-tools capability
* (mid-session model switch — the canonical history keeps its shape, only
* the outgoing view changes; announcements would be noise and even
* reference a select_tools tool the model does not have);
* - the compaction summarizer input (schemas and announcements are protocol
* context, not conversation — summarizing them wastes tokens and risks
* leaking schema text into the summary).
Expand Down
11 changes: 6 additions & 5 deletions packages/agent-core/src/agent/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,11 +376,12 @@ export class ContextMemory {

project(messages: readonly ContextMessage[], options?: ProjectOptions): Message[] {
// Shape for the current model BEFORE projecting: a model without the
// select_tools capability must not see dynamic-tool schema messages or
// loadable-tools announcements (the canonical history keeps them; only
// this outgoing view is shaped). Must run pre-projection — project()
// strips `origin`, the only anchor for the announcements. setModel never
// rewrites history, so a mid-session switch degrades/upgrades losslessly.
// dynamically-loaded-tools capability must not see dynamic-tool schema
// messages or loadable-tools announcements (the canonical history keeps
// them; only this outgoing view is shaped). Must run pre-projection —
// project() strips `origin`, the only anchor for the announcements.
// setModel never rewrites history, so a mid-session switch
// degrades/upgrades losslessly.
const shaped = this.agent.toolSelectEnabled ? messages : stripDynamicToolContext(messages);
const anomalies: ProjectionAnomaly[] = [];
const result = project(this.agent.microCompaction.compact(shaped), {
Expand Down
17 changes: 9 additions & 8 deletions packages/agent-core/src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,18 +236,19 @@ export class Agent {

/**
* Single decision point for select_tools progressive disclosure. All three
* gates must be open: the model declares the `select_tools` capability, the
* model declares `tool_use` (a model without tool use registering
* select_tools is a contradiction), and the `tool-select` experimental flag
* is on. Every consumer — top-level tools[] convergence, select_tools
* registration, manifest announcements, projection shaping — reads this
* instead of re-deriving the conditions, so degradation is lossless: any
* closed gate reproduces the inline behavior byte-for-byte.
* gates must be open: the model has the `dynamically_loaded_tools`
* capability (message-level tool declarations), the model declares
* `tool_use` (a model without tool use loading tools dynamically is a
* contradiction), and the `tool-select` experimental flag is on. Every
* consumer — top-level tools[] convergence, select_tools registration,
* manifest announcements, projection shaping — reads this instead of
* re-deriving the conditions, so degradation is lossless: any closed gate
* reproduces the inline behavior byte-for-byte.
*/
get toolSelectEnabled(): boolean {
const capability = this.config.modelCapabilities;
return (
capability.select_tools === true &&
capability.dynamically_loaded_tools === true &&
capability.tool_use &&
this.experimentalFlags.enabled('tool-select')
);
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/src/flags/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const FLAG_DEFINITIONS = [
id: 'tool-select',
title: 'Tool select (progressive tool disclosure)',
description:
'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.',
'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.',
env: 'KIMI_CODE_EXPERIMENTAL_TOOL_SELECT',
default: false,
surface: 'core',
Expand Down
8 changes: 5 additions & 3 deletions packages/agent-core/src/session/provider-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,10 +233,12 @@ function resolveModelCapabilities(
thinking: declared.has('thinking') || declared.has('always_thinking') || detected.thinking,
tool_use: declared.has('tool_use') || detected.tool_use,
max_context_tokens: alias.maxContextSize,
// Message-level tool declarations (select_tools progressive disclosure).
// Every field here must be merged explicitly — a capability registered in
// Message-level tool declarations ("dynamically loaded tools"). Every
// field here must be merged explicitly — a capability registered in
// kosong that is not forwarded here never reaches the agent.
select_tools: declared.has('select_tools') || detected.select_tools === true,
dynamically_loaded_tools:
declared.has('dynamically_loaded_tools') ||
Comment thread
starquakee marked this conversation as resolved.
detected.dynamically_loaded_tools === true,
};
}

Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/test/agent/harness/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1094,7 +1094,7 @@ function capabilityNames(capabilities: ModelCapability | undefined): string[] {
capabilities.audio_in ? 'audio_in' : undefined,
capabilities.thinking ? 'thinking' : undefined,
capabilities.tool_use ? 'tool_use' : undefined,
capabilities.select_tools === true ? 'select_tools' : undefined,
capabilities.dynamically_loaded_tools === true ? 'dynamically_loaded_tools' : undefined,
].filter((capability): capability is string => capability !== undefined);
}

Expand Down
4 changes: 2 additions & 2 deletions packages/agent-core/test/agent/tool-select.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* select_tools progressive disclosure — end-to-end agent tests.
*
* Uses the scripted-generate harness: real ToolManager/turn loop/context, fake
* LLM. The three-condition gate (model capability.select_tools ×
* LLM. The three-condition gate (model capability.dynamically_loaded_tools ×
* capability.tool_use × `tool-select` flag) is driven through the alias
* capability declarations and an injected FlagResolver.
*
Expand Down Expand Up @@ -34,7 +34,7 @@ const DISCLOSURE_CAPABILITIES = {
thinking: false,
tool_use: true,
max_context_tokens: 256_000,
select_tools: true,
dynamically_loaded_tools: true,
} as const;

const INLINE_PROVIDER = { type: 'kimi', apiKey: 'test-key', model: 'inline-model' } as const;
Expand Down
15 changes: 8 additions & 7 deletions packages/kosong/src/capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@ export interface ModelCapability {
readonly tool_use: boolean;
readonly max_context_tokens: number;
/**
* Model accepts message-level tool declarations (`messages[].tools`), the
* primitive behind select_tools progressive disclosure. Absent means
* unsupported: only models explicitly catalogued or declared with this
* capability may ever receive a message carrying `tools`.
* Model accepts message-level tool declarations (`messages[].tools`) — the
* "dynamically loaded tools" wire feature that clients can drive with
* progressive tool disclosure. Absent means unsupported: only models
* explicitly catalogued or declared with this capability may ever receive a
* message carrying `tools`.
*/
readonly select_tools?: boolean;
readonly dynamically_loaded_tools?: boolean;
}

const UNKNOWN_CAPABILITY_MARKER = Symbol.for('moonshot-ai.kosong.UNKNOWN_CAPABILITY');
Expand All @@ -40,7 +41,7 @@ export const UNKNOWN_CAPABILITY: ModelCapability = Object.freeze(
thinking: false,
tool_use: false,
max_context_tokens: 0,
select_tools: false,
dynamically_loaded_tools: false,
},
UNKNOWN_CAPABILITY_MARKER,
{ value: true },
Expand All @@ -58,7 +59,7 @@ export function isUnknownCapability(capability: ModelCapability): boolean {
!capability.audio_in &&
!capability.thinking &&
!capability.tool_use &&
capability.select_tools !== true &&
capability.dynamically_loaded_tools !== true &&
capability.max_context_tokens === 0
);
}
4 changes: 2 additions & 2 deletions packages/kosong/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export interface CatalogModelEntry {
readonly tool_call?: boolean;
readonly reasoning?: boolean;
/** Accepts message-level tool declarations (`messages[].tools`). Defaults to false. */
readonly select_tools?: boolean;
readonly dynamically_loaded_tools?: boolean;
readonly interleaved?: boolean | { readonly field?: string };
readonly modalities?: {
readonly input?: readonly string[];
Expand Down Expand Up @@ -138,7 +138,7 @@ export function catalogModelToCapability(model: CatalogModelEntry): CatalogModel
thinking: Boolean(model.reasoning),
tool_use: model.tool_call ?? true,
max_context_tokens: context,
select_tools: model.select_tools === true,
dynamically_loaded_tools: model.dynamically_loaded_tools === true,
},
};
}
Expand Down
2 changes: 1 addition & 1 deletion packages/kosong/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export { KimiChatProvider } from './providers/kimi';
export type { ExtraBody, GenerationKwargs, KimiOptions, ThinkingConfig } from './providers/kimi';

// Model capability matrix
export { UNKNOWN_CAPABILITY, isUnknownCapability } from './capability';
export { isUnknownCapability, UNKNOWN_CAPABILITY } from './capability';
export type { ModelCapability } from './capability';

// Model catalog (models.dev-style) metadata
Expand Down
2 changes: 1 addition & 1 deletion packages/kosong/test/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ describe('catalogModelToCapability', () => {
thinking: true,
tool_use: true,
max_context_tokens: 200000,
select_tools: false,
dynamically_loaded_tools: false,
},
});
});
Expand Down
38 changes: 20 additions & 18 deletions packages/kosong/test/select-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* 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).
* - the `dynamically_loaded_tools` capability bit (unknown/default-off semantics).
*/

import { UNKNOWN_CAPABILITY, isUnknownCapability } from '#/capability';
Expand Down Expand Up @@ -324,30 +324,32 @@ describe('providers without message-level tool declarations', () => {
});
});

describe('select_tools capability bit', () => {
describe('dynamically_loaded_tools capability bit', () => {
const BASE_CAPABILITY = {
image_in: false,
video_in: false,
audio_in: false,
thinking: false,
tool_use: false,
max_context_tokens: 0,
};

it('defaults to false on UNKNOWN_CAPABILITY', () => {
expect(UNKNOWN_CAPABILITY.select_tools).toBe(false);
expect(UNKNOWN_CAPABILITY.dynamically_loaded_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('a capability that only has the bit set is not "unknown"', () => {
expect(isUnknownCapability({ ...BASE_CAPABILITY, dynamically_loaded_tools: true })).toBe(
false,
);
});

it('catalog entries map select_tools and default it to false', () => {
it('catalog entries map the capability, defaulting to false', () => {
const base = { id: 'm', limit: { context: 1000 } };
expect(catalogModelToCapability(base)?.capability.select_tools).toBe(false);
expect(catalogModelToCapability(base)?.capability.dynamically_loaded_tools).toBe(false);
expect(
catalogModelToCapability({ ...base, select_tools: true })?.capability.select_tools,
catalogModelToCapability({ ...base, dynamically_loaded_tools: true })?.capability
.dynamically_loaded_tools,
).toBe(true);
});
});
2 changes: 1 addition & 1 deletion packages/node-sdk/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ function capabilityToStrings(capability: ModelCapability): string[] | undefined
if (capability.audio_in) caps.push('audio_in');
if (capability.thinking) caps.push('thinking');
if (capability.tool_use) caps.push('tool_use');
if (capability.select_tools === true) caps.push('select_tools');
if (capability.dynamically_loaded_tools === true) caps.push('dynamically_loaded_tools');
return caps.length > 0 ? caps : undefined;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/node-sdk/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ describe('KimiHarness config API', () => {
id: 'tool-select',
title: 'Tool select (progressive tool disclosure)',
description:
'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.',
'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.',
surface: 'core',
env: 'KIMI_CODE_EXPERIMENTAL_TOOL_SELECT',
defaultEnabled: false,
Expand Down
Loading