Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/defer-user-tools.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Allow hosts to defer registered user-tool schemas until needed. Set `disclosure: "deferred"` when registering a tool.
1 change: 1 addition & 0 deletions packages/agent-core-v2/docs/wire-manifest.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,7 @@ interface ToolsRegisterUserToolPayload {
name: string;
description: string;
parameters: Record<string, unknown>;
disclosure?: 'inline' | 'deferred';
}

/**
Expand Down
3 changes: 2 additions & 1 deletion packages/agent-core-v2/src/agent/rpc/core-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import type {
} from '#/agent/goal/types';
import type { PermissionMode } from '#/agent/permissionPolicy/types';
import type { SwarmModeTrigger } from '#/agent/swarm/swarm';
import type { ToolInfo } from '#/tool/toolContract';
import type { ToolDisclosure, ToolInfo } from '#/tool/toolContract';
import type { ResolvedConfig } from '#/app/config/config';
import type { McpServerConfig } from '#/agent/mcp/config-schema';
import type { ExperimentalFeatureState } from '#/app/flag/flag';
Expand Down Expand Up @@ -171,6 +171,7 @@ export interface RegisterToolPayload {
readonly name: string;
readonly description: string;
readonly parameters: Record<string, unknown>;
readonly disclosure?: ToolDisclosure;
}
export interface UnregisterToolPayload {
readonly name: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,12 @@ export class AgentBuiltinToolsRegistrar extends Disposable implements IAgentBuil
ctor,
...(staticArgs as []),
);
this._register(toolRegistry.register(tool, { source: options.source }));
this._register(
toolRegistry.register(tool, {
source: options.source,
disclosure: options.disclosure,
}),
);
}
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@
*/

import type { ServicesAccessor } from '#/_base/di/instantiation';
import type { ExecutableTool, ToolSource } from '#/tool/toolContract';
import type {
ExecutableTool,
ToolDisclosure,
ToolSource,
} from '#/tool/toolContract';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AnyExecutableTool = ExecutableTool<any>;
Expand All @@ -31,6 +35,7 @@ export type ToolCtor<T extends AnyExecutableTool = AnyExecutableTool> = new (...

export interface ToolContributionOptions {
readonly source?: ToolSource;
readonly disclosure?: ToolDisclosure;
readonly when?: (accessor: ServicesAccessor) => boolean;
readonly staticArgs?: (accessor: ServicesAccessor) => readonly unknown[];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,16 @@

import { createDecorator } from '#/_base/di/instantiation';
import { type IDisposable } from '#/_base/di/lifecycle';
import type { ExecutableTool, ToolInfo, ToolSource } from '#/tool/toolContract';
import type {
ExecutableTool,
ToolDisclosure,
ToolInfo,
ToolSource,
} from '#/tool/toolContract';

export interface ToolRegistrationOptions {
readonly source?: ToolSource;
readonly disclosure?: ToolDisclosure;
}

export interface ToolReference {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { toDisposable, type IDisposable } from "#/_base/di/lifecycle";
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import type { ExecutableTool, ToolInfo, ToolSource } from '#/tool/toolContract';
import type {
ExecutableTool,
ToolDisclosure,
ToolInfo,
ToolSource,
} from '#/tool/toolContract';
import {
IAgentToolRegistryService,
type ToolReference,
Expand All @@ -11,6 +16,7 @@ import {
interface ToolEntry {
readonly tool: ExecutableTool;
readonly source: ToolSource;
readonly disclosure?: ToolDisclosure;
}

export class AgentToolRegistryService implements IAgentToolRegistryService {
Expand All @@ -19,7 +25,7 @@ export class AgentToolRegistryService implements IAgentToolRegistryService {

register(tool: ExecutableTool, options: ToolRegistrationOptions = {}): IDisposable {
const source = options.source ?? 'builtin';
const entry: ToolEntry = { tool, source };
const entry: ToolEntry = { tool, source, disclosure: options.disclosure };
this.unregisterTool(tool.name);
this.tools.set(tool.name, entry);

Expand All @@ -32,11 +38,12 @@ export class AgentToolRegistryService implements IAgentToolRegistryService {

list(): readonly ToolInfo[] {
return [...this.tools.values()]
.map(({ tool, source }) => ({
.map(({ tool, source, disclosure }) => ({
name: tool.name,
description: tool.description,
parameters: tool.parameters,
source,
disclosure,
}))
.toSorted((a, b) => a.name.localeCompare(b.name));
}
Expand Down
3 changes: 2 additions & 1 deletion packages/agent-core-v2/src/agent/toolSelect/toolSelect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
* `toolSelect` domain (L4) — progressive tool disclosure contract.
*
* Defines the Agent-scope service that shapes provider-visible tool/history
* views, loads selected MCP schemas, and reports loadable-tool announcements.
* views, loads selected dynamic schemas, and reports loadable-tool
* announcements.
*/

import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
Expand Down
40 changes: 31 additions & 9 deletions packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* `toolSelect` domain (L4) — `IAgentToolSelectService` implementation.
*
* Shapes the provider-visible tool and history views for progressive tool
* disclosure, loads MCP schemas into `contextMemory`, and exposes
* disclosure, loads dynamic schemas into `contextMemory`, and exposes
* loadable-tools announcement text. Reads live tools from `toolRegistry`,
* active-tool and capability state from `profile`, gates through `flag`,
* hooks into `toolExecutor`, and listens to context lifecycle events through
Expand All @@ -19,7 +19,7 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'
import type { ContextMessage } from '#/agent/contextMemory/types';
import { IAgentProfileService } from '#/agent/profile/profile';
import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy';
import type { ToolInfo } from '#/tool/toolContract';
import { isMcpToolName, type ToolInfo } from '#/tool/toolContract';
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';

Expand Down Expand Up @@ -94,7 +94,7 @@ export class AgentToolSelectService extends Disposable implements IAgentToolSele
shaped.push(entry);
continue;
}
if (entry.source !== 'mcp') {
if (!this.isDynamicallyLoadable(entry)) {
shaped.push(entry);
continue;
}
Expand Down Expand Up @@ -156,8 +156,8 @@ export class AgentToolSelectService extends Disposable implements IAgentToolSele

private shouldIntercept(name: string): boolean {
if (!this.enabled()) return false;
const source = this.toolRegistry.list().find((info) => info.name === name)?.source;
if (source !== 'mcp') return false;
const info = this.toolRegistry.list().find((entry) => entry.name === name);
if (info === undefined || !this.isDynamicallyLoadable(info)) return false;
if (!this.loadableToolNames().includes(name)) return false;
return !this.activeLoadedToolNames().has(name);
}
Expand All @@ -172,16 +172,26 @@ export class AgentToolSelectService extends Disposable implements IAgentToolSele
if (!this.enabled()) return undefined;
if (this.toolRegistry.resolve(name) !== undefined) return undefined;
if (!this.loadedToolNames().has(name)) return undefined;
if (isMcpToolName(name)) {
return (
`Tool "${name}" was loaded but its MCP server is currently disconnected. ` +
'It may become available again when the server reconnects; do not retry immediately.'
);
}
return (
`Tool "${name}" was loaded but its MCP server is currently disconnected. ` +
'It may become available again when the server reconnects; do not retry immediately.'
`Tool "${name}" was loaded but is no longer registered. ` +
'Do not retry it unless it becomes available again.'
);
}

private loadableToolNames(): string[] {
return this.toolRegistry
.list()
.filter((info) => info.source === 'mcp' && this.toolPolicy.isToolActive(info.name, info.source))
.filter(
(info) =>
this.isDynamicallyLoadable(info) &&
this.toolPolicy.isToolActive(info.name, info.source),
)
.map((info) => info.name)
.toSorted((a, b) => a.localeCompare(b));
}
Expand All @@ -206,7 +216,19 @@ export class AgentToolSelectService extends Disposable implements IAgentToolSele
}

private isLoadedToolActive(name: string): boolean {
return this.toolPolicy.isToolActive(name, 'mcp');
const info = this.toolRegistry.list().find((entry) => entry.name === name);
if (info !== undefined) {
return (
this.isDynamicallyLoadable(info) &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow inline re-registered tools to execute

When a host re-registers a previously loaded deferred user tool as inline, the old schema message remains in canonical context while shapeHistory merely hides it from the provider view. Because this branch returns false for registered inline tools, isInactiveLoadedTool() still sees the stale loaded name and rejects the next valid inline tool call as “no longer active” before the executor can run it; this affects tools that change disclosure from deferred to inline during a session.

Useful? React with 👍 / 👎.

this.toolPolicy.isToolActive(name, info.source)
);
}
if (isMcpToolName(name)) return this.toolPolicy.isToolActive(name, 'mcp');
return false;
}

private isDynamicallyLoadable(info: ToolInfo): boolean {
return info.source === 'mcp' || info.disclosure === 'deferred';
}

private shapeActiveHistory(messages: readonly ContextMessage[]): readonly ContextMessage[] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
* `toolSelect` domain (L4) — `select_tools`, the load-by-exact-name primitive
* of progressive tool disclosure.
*
* Registers the built-in tool that lets the model load MCP schemas named in
* loadable-tools announcements. Delegates loading to
* Registers the built-in tool that lets the model load dynamic schemas named
* in loadable-tools announcements. Delegates loading to
* `IAgentToolSelectService`; offered by the shaped tool view only while the
* disclosure gate is open.
*/
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core-v2/src/agent/userTool/userTool.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { createDecorator } from '#/_base/di/instantiation';
import type { ToolDisclosure } from '#/tool/toolContract';

export interface UserToolRegistration {
readonly name: string;
readonly description: string;
readonly parameters: Record<string, unknown>;
readonly disclosure?: ToolDisclosure;
}

export interface IAgentUserToolService {
Expand Down
3 changes: 2 additions & 1 deletion packages/agent-core-v2/src/agent/userTool/userToolOps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ function equalRegistration(a: UserToolRegistration, b: UserToolRegistration): bo
return (
a.name === b.name &&
a.description === b.description &&
a.parameters === b.parameters
a.parameters === b.parameters &&
a.disclosure === b.disclosure
);
}

Expand Down
7 changes: 6 additions & 1 deletion packages/agent-core-v2/src/agent/userTool/userToolService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,12 @@ export class AgentUserToolService extends Disposable implements IAgentUserToolSe
execute: (context) => this.executeUserTool(context, name, args),
}),
};
this.registrations.set(name, this._register(this.registry.register(tool, { source: 'user' })));
this.registrations.set(
name,
this._register(
this.registry.register(tool, { source: 'user', disclosure: input.disclosure }),
),
);
if (options?.activate === false) return;
this.profile.addActiveTool(name);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core-v2/src/tool/toolContract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,14 @@ export interface ExecutableTool<Input = unknown> extends Tool {
}

export type ToolSource = 'builtin' | 'user' | 'mcp';
export type ToolDisclosure = 'inline' | 'deferred';

export interface ToolDefinition {
readonly name: string;
readonly description: string;
readonly parameters?: Record<string, unknown>;
readonly source?: ToolSource;
readonly disclosure?: ToolDisclosure;
readonly info?: Record<string, unknown>;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/**
* Scenario (v1 `tool-select.e2e.test.ts` headline parity): progressive tool
* disclosure converges the provider-visible table, keeps it byte-stable
* across loads, makes a loaded tool dispatchable the next step, and
* self-heals the loaded-ledger across undo.
* disclosure converges the provider-visible table for MCP and opted-in user
* tools, keeps it byte-stable across loads, makes a loaded tool dispatchable
* the next step, and self-heals the loaded-ledger across undo.
*
* Responsibilities: assert v1 contract at the provider wire, not via service
* internals: the manifest announcement reaches the model, `select_tools`
Expand All @@ -28,11 +28,13 @@ import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
import { TOOL_SELECT_FLAG_ENV } from '#/agent/toolSelect/flag';
import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect';
import { IAgentToolSelectAnnouncementsService } from '#/agent/toolSelect/toolSelectAnnouncements';
import { IAgentUserToolService } from '#/agent/userTool/userTool';
import '#/agent/toolSelect/tools/select-tools';

import { createTestAgent, type TestAgentContext } from '../../harness';

const MCP_ALPHA = 'mcp__srv__alpha';
const DASHBOARD_TOOL = 'dashboard_create';

const DISCLOSURE_CAPABILITIES = {
image_in: false,
Expand Down Expand Up @@ -172,6 +174,54 @@ describe('progressive tool disclosure end-to-end', () => {
expect(alpha.calls).toBe(1);
});

it('loads and dispatches a user tool registered through the domain service', async () => {
ctx.get(IAgentUserToolService).register({
name: DASHBOARD_TOOL,
description: 'Create a dashboard.',
parameters: {
type: 'object',
properties: { title: { type: 'string' } },
required: ['title'],
additionalProperties: false,
},
disclosure: 'deferred',
});
ctx.mockNextResponse(selectToolsCall('call_select_1', [DASHBOARD_TOOL]));
ctx.mockNextResponse({
type: 'function',
id: 'call_dashboard_1',
name: DASHBOARD_TOOL,
arguments: JSON.stringify({ title: 'Operations' }),
});
ctx.mockNextResponse({ type: 'text', text: 'done' });

await ctx.rpc.prompt({ input: [{ type: 'text', text: 'create a dashboard' }] });
await ctx.untilToolCall({ output: 'dashboard-created' });
await ctx.untilTurnEnd();

const firstWire = ctx.llmCalls[0]!;
expect(toolNames(firstWire.tools)).not.toContain(DASHBOARD_TOOL);
expect(historyText(firstWire.history)).toContain(DASHBOARD_TOOL);

const secondWire = ctx.llmCalls[1]!;
const injected = secondWire.history.find((message) =>
message.tools?.some((tool) => tool.name === DASHBOARD_TOOL),
);
expect(injected?.tools?.find((tool) => tool.name === DASHBOARD_TOOL)?.parameters).toEqual({
type: 'object',
properties: { title: { type: 'string' } },
required: ['title'],
additionalProperties: false,
});
expect(secondWire.tools).toEqual(firstWire.tools);
expect(historyText(ctx.get(IAgentContextMemoryService).get())).toContain(
`Loaded: ${DASHBOARD_TOOL}`,
);
expect(historyText(ctx.get(IAgentContextMemoryService).get())).toContain(
'dashboard-created',
);
});

it('re-injects a selected schema after undo slices the tail of the loaded exchange', async () => {
ctx.get(IAgentContextMemoryService).append({
role: 'user',
Expand Down
Loading