From 9f1827ef10eb413e4493f8c508710753e5daed80 Mon Sep 17 00:00:00 2001
From: 7Sageer <7sageer@djwcb.cn>
Date: Thu, 9 Jul 2026 23:27:52 +0800
Subject: [PATCH] feat: add progressive tool disclosure
---
.../tool-select-progressive-disclosure.md | 5 +
.../agent-core-v2/docs/di-scope-domains.puml | 15 +
.../agent-core-v2/docs/di-scope-domains.svg | 2 +-
.../scripts/check-domain-layers.mjs | 3 +
.../contextProjectorService.ts | 7 +-
.../fullCompaction/fullCompactionService.ts | 42 +-
.../src/agent/llmRequester/llmRequestOps.ts | 1 +
.../agent/llmRequester/llmRequesterService.ts | 17 +-
.../policies/default-tool-approve.ts | 1 +
.../src/agent/runtime/runtime.ts | 2 +-
.../src/agent/runtime/runtimeOps.ts | 2 +-
.../src/agent/runtime/runtimeService.ts | 2 +-
.../src/agent/toolExecutor/toolExecutor.ts | 24 +
.../agent/toolExecutor/toolExecutorService.ts | 53 +-
.../src/agent/toolSelect/dynamicTools.ts | 131 +++
.../src/agent/toolSelect/flag.ts | 29 +
.../src/agent/toolSelect/toolSelect.ts | 39 +
.../toolSelect/toolSelectAnnouncements.ts | 15 +
.../toolSelectAnnouncementsService.ts | 65 ++
.../src/agent/toolSelect/toolSelectService.ts | 301 ++++++
.../agent/toolSelect/tools/select-tools.ts | 74 ++
packages/agent-core-v2/src/index.ts | 7 +
.../agentLifecycle/agentLifecycleService.ts | 4 +
.../projector-tool-exchanges.test.ts | 52 ++
.../test/fullCompaction/full.test.ts | 91 ++
packages/agent-core-v2/test/harness/agent.ts | 1 +
.../test/llmRequester/kosong-llm.test.ts | 18 +
.../test/llmRequester/strict-resend.test.ts | 18 +
.../test/toolSelect/dynamicTools.test.ts | 145 +++
.../test/toolSelect/toolSelect.e2e.test.ts | 219 +++++
.../test/toolSelect/toolSelectService.test.ts | 869 ++++++++++++++++++
packages/agent-core-v2/test/turn/stubs.ts | 2 +
32 files changed, 2206 insertions(+), 50 deletions(-)
create mode 100644 .changeset/tool-select-progressive-disclosure.md
create mode 100644 packages/agent-core-v2/src/agent/toolSelect/dynamicTools.ts
create mode 100644 packages/agent-core-v2/src/agent/toolSelect/flag.ts
create mode 100644 packages/agent-core-v2/src/agent/toolSelect/toolSelect.ts
create mode 100644 packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncements.ts
create mode 100644 packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts
create mode 100644 packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts
create mode 100644 packages/agent-core-v2/src/agent/toolSelect/tools/select-tools.ts
create mode 100644 packages/agent-core-v2/test/toolSelect/dynamicTools.test.ts
create mode 100644 packages/agent-core-v2/test/toolSelect/toolSelect.e2e.test.ts
create mode 100644 packages/agent-core-v2/test/toolSelect/toolSelectService.test.ts
diff --git a/.changeset/tool-select-progressive-disclosure.md b/.changeset/tool-select-progressive-disclosure.md
new file mode 100644
index 000000000..13a94cf57
--- /dev/null
+++ b/.changeset/tool-select-progressive-disclosure.md
@@ -0,0 +1,5 @@
+---
+"@moonshot-ai/kimi-code": minor
+---
+
+Port progressive tool disclosure to the new agent engine: MCP tool schemas stay out of the top-level tool list, and the model loads them by name on demand through the announcements plus the select_tools tool, keeping the prompt cache stable. Off by default; set KIMI_CODE_EXPERIMENTAL_TOOL_SELECT=1 to enable.
diff --git a/packages/agent-core-v2/docs/di-scope-domains.puml b/packages/agent-core-v2/docs/di-scope-domains.puml
index 11a6201eb..e1fa352a5 100644
--- a/packages/agent-core-v2/docs/di-scope-domains.puml
+++ b/packages/agent-core-v2/docs/di-scope-domains.puml
@@ -91,6 +91,8 @@ package "Agent scope (per agent)" #FDF5E6 {
rectangle "toolExecutor\nAgent\n IAgentToolExecutorService" as toolExecutor #FDEBD0
rectangle "toolResultTruncation\nAgent\n IAgentToolResultTruncationService" as toolResultTruncation #FDEBD0
rectangle "toolDedupe\nAgent\n IAgentToolDedupeService" as toolDedupe #FDEBD0
+ rectangle "toolSelect\nAgent\n IAgentToolSelectService" as toolSelect #FDEBD0
+ rectangle "toolSelectAnnouncements\nAgent\n IAgentToolSelectAnnouncementsService" as toolSelectAnnouncements #FDEBD0
rectangle "permissionGate\nAgent\n IAgentPermissionGate" as permissionGate #FDEBD0
rectangle "permissionMode\nAgent\n IAgentPermissionModeService" as permissionMode #FDEBD0
rectangle "permissionPolicy\nAgent\n IAgentPermissionPolicyService" as permissionPolicy #FDEBD0
@@ -225,6 +227,7 @@ loop --> config #34495E
llmRequester --> contextMemory #34495E
llmRequester --> contextProjector #34495E
llmRequester --> toolRegistry #34495E
+llmRequester --> toolSelect #34495E
llmRequester --> profile #34495E
llmRequester --> log #34495E
llmRequester --> telemetry #34495E
@@ -242,6 +245,16 @@ toolResultTruncation --> storage #34495E
toolDedupe --> telemetry #34495E
toolDedupe --> loop #34495E
toolDedupe --> toolExecutor #34495E
+toolSelect --> toolRegistry #34495E
+toolSelect --> profile #34495E
+toolSelect --> contextMemory #34495E
+toolSelect --> toolExecutor #34495E
+toolSelect --> flag #34495E
+toolSelect --> event #34495E
+toolSelectAnnouncements --> loop #34495E
+toolSelectAnnouncements --> systemReminder #34495E
+toolSelectAnnouncements --> event #34495E
+toolSelectAnnouncements --> toolSelect #34495E
permissionGate --> permissionMode #34495E
permissionGate --> permissionRules #34495E
permissionGate --> permissionPolicy #34495E
@@ -307,6 +320,7 @@ fullCompaction --> contextSize #34495E
fullCompaction --> llmRequester #34495E
fullCompaction --> profile #34495E
fullCompaction --> toolRegistry #34495E
+fullCompaction --> toolSelect #34495E
fullCompaction --> telemetry #34495E
fullCompaction --> log #34495E
fullCompaction --> wireRecord #34495E
@@ -385,6 +399,7 @@ swarm ..> wire #16A085 : swarm_mode.enter/exit
swarm ..> turn #16A085 : hooks.onEnded
task ..> wire #16A085 : task.started/terminated
task ..> contextMemory #16A085 : hooks.onSpliced
+toolSelect ..> toolExecutor #16A085 : unavailable / missingToolDescriber
externalHooks ..> toolExecutor #16A085 : onWillExecuteTool / onDidExecuteTool
externalHooks ..> permissionGate #16A085 : approval hooks
externalHooks ..> turn #16A085 : prompt/end hooks
diff --git a/packages/agent-core-v2/docs/di-scope-domains.svg b/packages/agent-core-v2/docs/di-scope-domains.svg
index 9dcc66387..47d3c3bb7 100644
--- a/packages/agent-core-v2/docs/di-scope-domains.svg
+++ b/packages/agent-core-v2/docs/di-scope-domains.svg
@@ -1 +1 @@
-
+
\ No newline at end of file
diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs
index 9c36e66b4..c6364309b 100644
--- a/packages/agent-core-v2/scripts/check-domain-layers.mjs
+++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs
@@ -128,6 +128,7 @@ const DOMAIN_LAYER = new Map([
['permissionPolicy', 3],
['permissionRules', 3],
['plugin', 3],
+ ['multiServer', 3],
['record', 3],
['modelCatalog', 3],
['agentProfileCatalog', 3],
@@ -142,7 +143,9 @@ const DOMAIN_LAYER = new Map([
['goal', 4],
['swarm', 4],
['usage', 4],
+ ['runtime', 4],
['toolDedupe', 4],
+ ['toolSelect', 4],
['contextMemory', 4],
['contextInjector', 4],
['agentPlugin', 4],
diff --git a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts
index c7a891a3d..9d8211772 100644
--- a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts
+++ b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts
@@ -285,7 +285,7 @@ function project(history: readonly ContextMessage[], onAnomaly?: OnAnomaly): Mes
const emit = (source: ContextMessage): void => {
const content = projectedContent(source, onAnomaly);
- if (content.length === 0 && source.toolCalls.length === 0) return;
+ if (content.length === 0 && source.toolCalls.length === 0 && !hasDeclaredTools(source)) return;
if (openSlots.size > 0) markForeignBetween();
@@ -461,6 +461,10 @@ function canMergeUserMessage(message: ContextMessage): boolean {
return message.role === 'user' && message.origin?.kind === 'user';
}
+function hasDeclaredTools(message: ContextMessage): boolean {
+ return message.tools !== undefined && message.tools.length > 0;
+}
+
function toWireMessage(message: ContextMessage, content: ContentPart[]): Message {
return {
role: message.role,
@@ -469,6 +473,7 @@ function toWireMessage(message: ContextMessage, content: ContentPart[]): Message
toolCalls: message.toolCalls,
toolCallId: message.toolCallId,
partial: message.partial,
+ tools: message.tools,
};
}
diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts
index d6da29891..a62bedcb4 100644
--- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts
+++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts
@@ -19,6 +19,8 @@ import { IAgentLoopService, type LoopErrorContext } from '#/agent/loop/loop';
import { isAbortError, isContextOverflowError } from '#/agent/loop/errors';
import { IAgentProfileService, type ProfileModelContext } from '#/agent/profile/profile';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
+import { stripDynamicToolContext } from '#/agent/toolSelect/dynamicTools';
+import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect';
import { IAgentTurnService } from '#/agent/turn/turn';
import { IAgentActivityService } from '#/activity/activity';
import { ISessionTodoService } from '#/session/todo/sessionTodo';
@@ -131,6 +133,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
@IAgentLLMRequesterService private readonly llmRequester: IAgentLLMRequesterService,
@IAgentProfileService private readonly profile: IAgentProfileService,
@IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService,
+ @IAgentToolSelectService private readonly toolSelect: IAgentToolSelectService,
@ISessionTodoService private readonly todo: ISessionTodoService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IAgentWireService private readonly wire: IWireService,
@@ -203,13 +206,13 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
}
private defaultTools(): readonly Tool[] {
- return this.toolRegistry
- .list()
- .filter((tool) => this.profile.isToolActive(tool.name, tool.source))
+ return this.toolSelect
+ .shapeTools(this.toolRegistry.list())
.map((tool) => ({
name: tool.name,
description: tool.description,
parameters: tool.parameters ?? EMPTY_TOOL_PARAMETERS,
+ deferred: tool.deferred,
}));
}
@@ -676,39 +679,6 @@ function historySafeToCompact(
return current.slice(original.length).every(isRealUserInput);
}
-const LOADABLE_TOOLS_TRIGGER = 'loadable-tools';
-
-function stripDynamicToolContext(
- history: readonly ContextMessage[],
-): readonly ContextMessage[] {
- if (!history.some((message) => hasDynamicToolSchema(message) || isLoadableToolsAnnouncement(message))) {
- return history;
- }
- const out: ContextMessage[] = [];
- for (const message of history) {
- if (isLoadableToolsAnnouncement(message)) continue;
- if (hasDynamicToolSchema(message)) {
- const { tools: _tools, ...rest } = message;
- void _tools;
- if (rest.content.length === 0 && rest.toolCalls.length === 0) continue;
- out.push(rest);
- continue;
- }
- out.push(message);
- }
- return out;
-}
-
-function hasDynamicToolSchema(message: ContextMessage): boolean {
- return message.tools !== undefined && message.tools.length > 0;
-}
-
-function isLoadableToolsAnnouncement(message: ContextMessage): boolean {
- return (
- message.origin?.kind === 'system_trigger' && message.origin.name === LOADABLE_TOOLS_TRIGGER
- );
-}
-
function shrinkCompactionHistoryAfterOverflow(
messages: readonly T[],
attempt: number,
diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts
index aa601b077..ac7be0bf7 100644
--- a/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts
+++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts
@@ -52,6 +52,7 @@ export interface LlmRequestPayload {
readonly topP?: number;
readonly maxTokens?: number;
readonly betaApi?: boolean;
+ /** Progressive tool disclosure in effect (env flag × model capability). */
readonly toolSelect: boolean;
readonly systemPromptHash: string;
readonly systemPrompt?: string;
diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts
index d1d03f833..1b8bafdd8 100644
--- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts
+++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts
@@ -3,7 +3,8 @@
*
* Thin shell over the god-object `Model` (App scope). Assembles per-turn
* `LLMRequestInput` from `profile` (system prompt), `contextMemory` +
- * `contextProjector` (history), and `toolRegistry` (tools), applies the
+ * `contextProjector` (history), `toolRegistry` (tools), and `toolSelect`
+ * (progressive-disclosure shaping of the tool and history views), applies the
* completion-token budget, then drives `model.request(input, signal)` with
* bounded retry. Forwards streamed `part` events to the caller's `onPart`
* handler, records `usage` through `IAgentUsageService`, resolves to an
@@ -22,6 +23,7 @@ import { IAgentContextProjectorService } from '#/agent/contextProjector/contextP
import { IAgentContextSizeService } from '#/agent/contextSize/contextSize';
import { IAgentProfileService } from '#/agent/profile/profile';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
+import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect';
import { IAgentUsageService } from '#/agent/usage/usage';
import { IConfigService } from '#/app/config/config';
import {
@@ -114,6 +116,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
@IAgentContextProjectorService private readonly projector: IAgentContextProjectorService,
@IAgentContextSizeService private readonly contextSize: IAgentContextSizeService,
@IAgentToolRegistryService private readonly tools: IAgentToolRegistryService,
+ @IAgentToolSelectService private readonly toolSelect: IAgentToolSelectService,
@IAgentProfileService private readonly profile: IAgentProfileService,
@IAgentUsageService private readonly usage: IAgentUsageService,
@IConfigService private readonly config: IConfigService,
@@ -232,8 +235,8 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
systemPrompt: request.systemPrompt,
tools: request.tools,
messages: strict
- ? this.projector.projectStrict(request.messages)
- : this.projector.project(request.messages),
+ ? this.projector.projectStrict(this.toolSelect.shapeHistory(request.messages))
+ : this.projector.project(this.toolSelect.shapeHistory(request.messages)),
});
const run = async (strict: boolean): Promise => {
@@ -411,7 +414,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
topP: input.protocol === 'kimi' ? overrides?.topP : undefined,
maxTokens: input.maxTokens,
betaApi: modelConfig?.betaApi,
- toolSelect: input.tools.some((tool) => tool.deferred === true),
+ toolSelect: this.toolSelect.enabled(),
systemPromptHash,
systemPrompt:
input.systemPrompt === this.profile.data().systemPrompt
@@ -449,13 +452,13 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService {
}
private defaultTools(): readonly Tool[] {
- return this.tools
- .list()
- .filter((tool) => this.profile.isToolActive(tool.name, tool.source))
+ return this.toolSelect
+ .shapeTools(this.tools.list())
.map((tool) => ({
name: tool.name,
description: tool.description,
parameters: tool.parameters ?? EMPTY_TOOL_PARAMETERS,
+ deferred: tool.deferred,
}));
}
}
diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/default-tool-approve.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/default-tool-approve.ts
index 925fd1e31..03165877c 100644
--- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/default-tool-approve.ts
+++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/default-tool-approve.ts
@@ -22,6 +22,7 @@ const DEFAULT_APPROVE_TOOLS = new Set([
'GetGoal',
'SetGoalBudget',
'UpdateGoal',
+ 'select_tools',
]);
export class DefaultToolApprovePermissionPolicyService implements PermissionPolicy {
diff --git a/packages/agent-core-v2/src/agent/runtime/runtime.ts b/packages/agent-core-v2/src/agent/runtime/runtime.ts
index fd661a6a1..fbaa5e68f 100644
--- a/packages/agent-core-v2/src/agent/runtime/runtime.ts
+++ b/packages/agent-core-v2/src/agent/runtime/runtime.ts
@@ -1,5 +1,5 @@
/**
- * `runtime` domain (L5) — Agent-scope live phase contract.
+ * `runtime` domain (L4) — Agent-scope live phase contract.
*
* Defines the public contract of the agent's whole live phase: the `AgentPhase`
* discriminated union (each variant carries its own ancillary fields) and the
diff --git a/packages/agent-core-v2/src/agent/runtime/runtimeOps.ts b/packages/agent-core-v2/src/agent/runtime/runtimeOps.ts
index 09d00a812..5186278c2 100644
--- a/packages/agent-core-v2/src/agent/runtime/runtimeOps.ts
+++ b/packages/agent-core-v2/src/agent/runtime/runtimeOps.ts
@@ -1,5 +1,5 @@
/**
- * `runtime` domain (L5) — wire Model (`RuntimeModel`) and the `runtime.set_phase`
+ * `runtime` domain (L4) — wire Model (`RuntimeModel`) and the `runtime.set_phase`
* Op (`setRuntimePhase`) that holds the agent's whole live phase.
*
* Declares the phase as a single-field wire Model (`{ phase }`, initial
diff --git a/packages/agent-core-v2/src/agent/runtime/runtimeService.ts b/packages/agent-core-v2/src/agent/runtime/runtimeService.ts
index 6799bd23e..2ebd0517f 100644
--- a/packages/agent-core-v2/src/agent/runtime/runtimeService.ts
+++ b/packages/agent-core-v2/src/agent/runtime/runtimeService.ts
@@ -1,5 +1,5 @@
/**
- * `runtime` domain (L5) — `IAgentRuntimeService` implementation and the Agent
+ * `runtime` domain (L4) — `IAgentRuntimeService` implementation and the Agent
* activity projector.
*
* Folds the agent's live activity into a structured `AgentActivitySnapshot`
diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts
index c48a90585..5824d2182 100644
--- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts
+++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts
@@ -1,4 +1,13 @@
+/**
+ * `toolExecutor` domain (L3) — Agent-scope tool execution contract.
+ *
+ * Defines the public execution surface for provider tool calls, will/did
+ * execution hooks, tool-call result settlement, and preflight description
+ * extension points. Bound at Agent scope.
+ */
+
import { createDecorator } from '#/_base/di/instantiation';
+import type { IDisposable } from '#/_base/di/lifecycle';
import type { ToolResult } from '#/agent/tool/toolContract';
import type { ToolDidExecuteContext, ToolWillExecuteContext } from '#/agent/tool/toolHooks';
import type { ToolCall } from '#/app/llmProtocol/message';
@@ -22,6 +31,9 @@ export interface ToolExecutionResult {
readonly result: ToolResult;
}
+export type MissingToolDescriber = (toolName: string) => string | undefined;
+export type UnavailableToolDescriber = (toolName: string) => string | undefined;
+
export interface IAgentToolExecutorService {
readonly _serviceBrand: undefined;
@@ -31,6 +43,18 @@ export interface IAgentToolExecutorService {
readonly onWillExecuteTool: OrderedHookSlot;
readonly onDidExecuteTool: OrderedHookSlot;
};
+
+ /**
+ * Single-slot hook for the "registered but currently unavailable" preflight
+ * message. A second registration overwrites the first; disposing the returned
+ * handle clears the slot only when the same describer still occupies it.
+ */
+ registerUnavailableToolDescriber(describer: UnavailableToolDescriber): IDisposable;
+ /**
+ * Single-slot hook for the tool-miss preflight message (e.g. a loaded tool
+ * whose server dropped). Same single-slot semantics as above.
+ */
+ registerMissingToolDescriber(describer: MissingToolDescriber): IDisposable;
}
export const IAgentToolExecutorService =
diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts
index d2b837fd5..1992567ef 100644
--- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts
+++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts
@@ -1,4 +1,14 @@
+/**
+ * `toolExecutor` domain (L3) — `IAgentToolExecutorService` implementation.
+ *
+ * Resolves executable tools through `toolRegistry`, runs ordered tool hooks,
+ * publishes tool lifecycle events through `event`, records telemetry through
+ * `telemetry`, truncates oversized outputs through `toolResultTruncation`,
+ * and logs parse diagnostics through `log`. Bound at Agent scope.
+ */
+
import { InstantiationType } from '#/_base/di/extensions';
+import { toDisposable } from '#/_base/di/lifecycle';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import type { ContentPart } from '#/app/llmProtocol/message';
import type {
@@ -29,8 +39,10 @@ import { OrderedHookSlot } from '#/hooks';
import { IAgentToolResultTruncationService } from '#/agent/toolResultTruncation/toolResultTruncation';
import {
IAgentToolExecutorService,
+ type MissingToolDescriber,
type ToolExecutionResult,
type ToolExecutorExecuteOptions,
+ type UnavailableToolDescriber,
} from './toolExecutor';
import { ToolScheduler } from './toolScheduler';
@@ -85,6 +97,23 @@ export class AgentToolExecutorService implements IAgentToolExecutorService {
onDidExecuteTool: new OrderedHookSlot(),
};
+ private missingToolDescriber: MissingToolDescriber | undefined;
+ private unavailableToolDescriber: UnavailableToolDescriber | undefined;
+
+ registerUnavailableToolDescriber(describer: UnavailableToolDescriber) {
+ this.unavailableToolDescriber = describer;
+ return toDisposable(() => {
+ if (this.unavailableToolDescriber === describer) this.unavailableToolDescriber = undefined;
+ });
+ }
+
+ registerMissingToolDescriber(describer: MissingToolDescriber) {
+ this.missingToolDescriber = describer;
+ return toDisposable(() => {
+ if (this.missingToolDescriber === describer) this.missingToolDescriber = undefined;
+ });
+ }
+
constructor(
@IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService,
@IEventBus private readonly eventBus: IEventBus,
@@ -100,7 +129,15 @@ export class AgentToolExecutorService implements IAgentToolExecutorService {
): AsyncIterable {
if (calls.length === 0) return;
- const preflighted = calls.map((call) => preflightToolCall(this.toolRegistry, call, this.log));
+ const preflighted = calls.map((call) =>
+ preflightToolCall(
+ this.toolRegistry,
+ call,
+ this.unavailableToolDescriber,
+ this.missingToolDescriber,
+ this.log,
+ ),
+ );
const preparedTasks: Array<{
task: ToolExecutionTask;
call: PreflightedToolCall;
@@ -597,6 +634,8 @@ function buildWillExecuteContext(
function preflightToolCall(
toolRegistry: IAgentToolRegistryService,
toolCall: ToolCall,
+ describeUnavailableTool: UnavailableToolDescriber | undefined,
+ describeMissingTool: MissingToolDescriber | undefined,
log?: ILogService,
): PreflightedToolCall {
const toolName = toolCall.name;
@@ -609,6 +648,16 @@ function preflightToolCall(
error: parsedArgs.error,
});
}
+ const unavailable = describeUnavailableTool?.(toolName);
+ if (unavailable !== undefined) {
+ return {
+ kind: 'rejected',
+ toolCall,
+ toolName,
+ args: parsedArgs.data,
+ output: unavailable,
+ };
+ }
const tool = toolRegistry.resolve(toolName);
if (tool === undefined) {
return {
@@ -616,7 +665,7 @@ function preflightToolCall(
toolCall,
toolName,
args: parsedArgs.data,
- output: `Tool "${toolName}" not found`,
+ output: describeMissingTool?.(toolName) ?? `Tool "${toolName}" not found`,
};
}
const validationError = validateExecutableToolArgs(tool, parsedArgs.data);
diff --git a/packages/agent-core-v2/src/agent/toolSelect/dynamicTools.ts b/packages/agent-core-v2/src/agent/toolSelect/dynamicTools.ts
new file mode 100644
index 000000000..69da6b051
--- /dev/null
+++ b/packages/agent-core-v2/src/agent/toolSelect/dynamicTools.ts
@@ -0,0 +1,131 @@
+/**
+ * `toolSelect` domain (L4) — predicates and shaping helpers for the
+ * select_tools progressive-disclosure protocol context.
+ *
+ * Exposes pure helpers for recognizing injected tool-schema messages,
+ * folding loadable-tool announcements, rendering announcement text, and
+ * stripping dynamic-tool protocol context from an outgoing history view.
+ *
+ * Two kinds of messages carry the protocol state in the history:
+ * - dynamic tool schema messages: `role: 'system'` messages whose `tools`
+ * field holds full tool definitions (origin
+ * `{kind: 'injection', variant: 'dynamic_tool_schema'}`) — tool loading is
+ * protocol context, not conversation. v2's undo cuts histories at the
+ * first real user prompt it finds regardless of origin: schema messages
+ * survive only when the cut lands before them, otherwise
+ * `toolSelectService` reconciles its pending ledger from the surviving
+ * history on the `context.spliced` event so the model can re-select.
+ * - loadable-tools announcements: `/` system
+ * reminders (origin `{kind: 'system_trigger', name: 'loadable-tools'}`) —
+ * undo removes them (they are not `injection`-origin), and the next
+ * turn-boundary diff self-heals by re-announcing the folded delta.
+ *
+ * The loaded-tool ledger is the history itself: there is deliberately no
+ * separate persisted ledger, so undo/compaction/resume all self-heal by
+ * re-folding. Everything here anchors on `origin` or the `tools` field, so
+ * callers that need to filter MUST run before `project()` — projection
+ * strips `origin`.
+ */
+
+import type { ContextMessage } from '#/agent/contextMemory/types';
+
+export const DYNAMIC_TOOL_SCHEMA_VARIANT = 'dynamic_tool_schema';
+
+export const LOADABLE_TOOLS_TRIGGER = 'loadable-tools';
+
+export function isDynamicToolSchemaMessage(message: ContextMessage): boolean {
+ return message.tools !== undefined && message.tools.length > 0;
+}
+
+export function isLoadableToolsAnnouncement(message: ContextMessage): boolean {
+ return (
+ message.origin?.kind === 'system_trigger' &&
+ message.origin.name === LOADABLE_TOOLS_TRIGGER
+ );
+}
+
+export function stripDynamicToolContext(
+ history: readonly ContextMessage[],
+): readonly ContextMessage[] {
+ if (!history.some((m) => isDynamicToolSchemaMessage(m) || isLoadableToolsAnnouncement(m))) {
+ return history;
+ }
+ const out: ContextMessage[] = [];
+ for (const message of history) {
+ if (isLoadableToolsAnnouncement(message)) continue;
+ if (isDynamicToolSchemaMessage(message)) {
+ const { tools: _tools, ...rest } = message;
+ void _tools;
+ if (rest.content.length === 0 && rest.toolCalls.length === 0) continue;
+ out.push(rest);
+ continue;
+ }
+ out.push(message);
+ }
+ return out;
+}
+
+export function collectLoadedDynamicToolNames(
+ history: readonly ContextMessage[],
+): Set {
+ const names = new Set();
+ for (const message of history) {
+ if (message.tools === undefined) continue;
+ for (const tool of message.tools) {
+ names.add(tool.name);
+ }
+ }
+ return names;
+}
+
+const TOOLS_ADDED_BLOCK = /\n?([\s\S]*?)\n?<\/tools_added>/g;
+const TOOLS_REMOVED_BLOCK = /\n?([\s\S]*?)\n?<\/tools_removed>/g;
+
+export function foldAnnouncedToolNames(history: readonly ContextMessage[]): Set {
+ const announced = new Set();
+ for (const message of history) {
+ if (!isLoadableToolsAnnouncement(message)) continue;
+ const text = message.content
+ .map((part) => (part.type === 'text' ? part.text : ''))
+ .join('');
+ for (const name of matchToolNameBlocks(text, TOOLS_REMOVED_BLOCK)) {
+ announced.delete(name);
+ }
+ for (const name of matchToolNameBlocks(text, TOOLS_ADDED_BLOCK)) {
+ announced.add(name);
+ }
+ }
+ return announced;
+}
+
+function matchToolNameBlocks(text: string, pattern: RegExp): string[] {
+ const names: string[] = [];
+ pattern.lastIndex = 0;
+ for (const match of text.matchAll(pattern)) {
+ const body = match[1] ?? '';
+ for (const line of body.split('\n')) {
+ const name = line.trim();
+ if (name.length > 0) names.push(name);
+ }
+ }
+ return names;
+}
+
+export function renderLoadableToolsAnnouncement(
+ added: readonly string[],
+ removed: readonly string[],
+): string {
+ const sections: string[] = [];
+ if (added.length > 0) {
+ sections.push(`\n${added.join('\n')}\n`);
+ }
+ if (removed.length > 0) {
+ sections.push(`\n${removed.join('\n')}\n`);
+ }
+ sections.push(
+ 'Use the select_tools tool with exact names to load full tool definitions before calling them. ' +
+ 'Names listed as removed are no longer loadable — do not select them. ' +
+ 'Fold all announcements in this conversation in order to get the current list.',
+ );
+ return sections.join('\n\n');
+}
diff --git a/packages/agent-core-v2/src/agent/toolSelect/flag.ts b/packages/agent-core-v2/src/agent/toolSelect/flag.ts
new file mode 100644
index 000000000..4015a2b49
--- /dev/null
+++ b/packages/agent-core-v2/src/agent/toolSelect/flag.ts
@@ -0,0 +1,29 @@
+/**
+ * `toolSelect` domain (L4) — registers the `tool-select` experimental flag into
+ * `flag`.
+ *
+ * Gates progressive tool disclosure: MCP tool schemas stay out of the
+ * immutable top-level tools[] and are loaded on demand through the
+ * `select_tools` tool. Off by default; enable via
+ * `KIMI_CODE_EXPERIMENTAL_TOOL_SELECT`, the master
+ * `KIMI_CODE_EXPERIMENTAL_FLAG`, or the `[experimental]` config section.
+ * Imported for its side effect (registers the definition) from the package
+ * barrel.
+ */
+
+import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry';
+
+export const TOOL_SELECT_FLAG_ID = 'tool-select';
+export const TOOL_SELECT_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_TOOL_SELECT';
+
+export const toolSelectFlag: FlagDefinitionInput = {
+ id: TOOL_SELECT_FLAG_ID,
+ 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.',
+ env: TOOL_SELECT_FLAG_ENV,
+ default: false,
+ surface: 'core',
+};
+
+registerFlagDefinition(toolSelectFlag);
diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelect.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelect.ts
new file mode 100644
index 000000000..edd5f8434
--- /dev/null
+++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelect.ts
@@ -0,0 +1,39 @@
+/**
+ * `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.
+ */
+
+import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
+import type { ContextMessage } from '#/agent/contextMemory/types';
+import type { ToolInfo } from '#/agent/tool/toolContract';
+
+export const SELECT_TOOLS_TOOL_NAME = 'select_tools';
+
+export interface ShapedToolEntry extends ToolInfo {
+ readonly deferred?: true;
+}
+
+export interface LoadToolsResult {
+ readonly toLoad: readonly string[];
+ readonly alreadyAvailable: readonly string[];
+ readonly unknown: readonly string[];
+}
+
+export interface IAgentToolSelectService {
+ readonly _serviceBrand: undefined;
+
+ enabled(): boolean;
+
+ shapeTools(entries: readonly ToolInfo[]): readonly ShapedToolEntry[];
+
+ shapeHistory(messages: readonly ContextMessage[]): readonly ContextMessage[];
+
+ load(names: readonly string[]): LoadToolsResult;
+
+ loadableToolsAnnouncement(): string | undefined;
+}
+
+export const IAgentToolSelectService: ServiceIdentifier =
+ createDecorator('agentToolSelectService');
diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncements.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncements.ts
new file mode 100644
index 000000000..faa8578c2
--- /dev/null
+++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncements.ts
@@ -0,0 +1,15 @@
+/**
+ * `toolSelect` domain (L4) — `IAgentToolSelectAnnouncementsService` contract.
+ *
+ * Defines the Agent-scope marker service that appends v1-compatible
+ * loadable-tools announcements through `systemReminder` at loop boundaries.
+ */
+
+import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
+
+export interface IAgentToolSelectAnnouncementsService {
+ readonly _serviceBrand: undefined;
+}
+
+export const IAgentToolSelectAnnouncementsService: ServiceIdentifier =
+ createDecorator('agentToolSelectAnnouncementsService');
diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts
new file mode 100644
index 000000000..6021d98b0
--- /dev/null
+++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts
@@ -0,0 +1,65 @@
+/**
+ * `toolSelect` domain (L4) — `IAgentToolSelectAnnouncementsService`
+ * implementation.
+ *
+ * Appends v1-compatible loadable-tools diff announcements at turn boundaries
+ * through `systemReminder`, hooks into `loop` before each step, reads
+ * announcement text from `IAgentToolSelectService`, and observes compaction
+ * boundaries from `event`. Turn boundaries need no state: every turn starts
+ * at loop step 1, which always evaluates injection. Bound at Agent scope.
+ */
+
+import { InstantiationType } from '#/_base/di/extensions';
+import { Disposable } from '#/_base/di/lifecycle';
+import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
+import { IAgentLoopService } from '#/agent/loop/loop';
+import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder';
+import { IEventBus } from '#/app/event/eventBus';
+
+import { LOADABLE_TOOLS_TRIGGER } from './dynamicTools';
+import { IAgentToolSelectService } from './toolSelect';
+import { IAgentToolSelectAnnouncementsService } from './toolSelectAnnouncements';
+
+export class AgentToolSelectAnnouncementsService extends Disposable implements IAgentToolSelectAnnouncementsService {
+ declare readonly _serviceBrand: undefined;
+ private needsBoundaryInjection = false;
+
+ constructor(
+ @IAgentToolSelectService toolSelect: IAgentToolSelectService,
+ @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService,
+ @IEventBus eventBus: IEventBus,
+ @IAgentLoopService loopService: IAgentLoopService,
+ ) {
+ super();
+ this._register(
+ eventBus.subscribe('compaction.completed', () => {
+ this.needsBoundaryInjection = true;
+ }),
+ );
+ this._register(
+ loopService.hooks.beforeStep.register('toolSelectAnnouncements', async (ctx, next) => {
+ await next();
+ if (ctx.step !== 1 && !this.needsBoundaryInjection) return;
+ this.needsBoundaryInjection = false;
+ this.inject(toolSelect);
+ }),
+ );
+ }
+
+ private inject(toolSelect: IAgentToolSelectService): void {
+ const announcement = toolSelect.loadableToolsAnnouncement();
+ if (announcement === undefined) return;
+ this.reminders.appendSystemReminder(announcement, {
+ kind: 'system_trigger',
+ name: LOADABLE_TOOLS_TRIGGER,
+ });
+ }
+}
+
+registerScopedService(
+ LifecycleScope.Agent,
+ IAgentToolSelectAnnouncementsService,
+ AgentToolSelectAnnouncementsService,
+ InstantiationType.Eager,
+ 'toolSelect',
+);
diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts
new file mode 100644
index 000000000..3011d1252
--- /dev/null
+++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts
@@ -0,0 +1,301 @@
+/**
+ * `toolSelect` domain (L4) — `IAgentToolSelectService` implementation.
+ *
+ * Shapes the provider-visible tool and history views for progressive tool
+ * disclosure, loads MCP 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
+ * `event`. Bound at Agent scope.
+ */
+
+import { InstantiationType } from '#/_base/di/extensions';
+import { Disposable } from '#/_base/di/lifecycle';
+import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
+import { IEventBus } from '#/app/event/eventBus';
+import { IFlagService } from '#/app/flag/flag';
+import type { Tool } from '#/app/llmProtocol/tool';
+import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
+import type { ContextMessage } from '#/agent/contextMemory/types';
+import { IAgentProfileService } from '#/agent/profile/profile';
+import type { ToolInfo } from '#/agent/tool/toolContract';
+import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
+import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
+
+import {
+ collectLoadedDynamicToolNames,
+ DYNAMIC_TOOL_SCHEMA_VARIANT,
+ foldAnnouncedToolNames,
+ renderLoadableToolsAnnouncement,
+ stripDynamicToolContext,
+} from './dynamicTools';
+import { TOOL_SELECT_FLAG_ID } from './flag';
+import {
+ IAgentToolSelectService,
+ SELECT_TOOLS_TOOL_NAME,
+ type LoadToolsResult,
+ type ShapedToolEntry,
+} from './toolSelect';
+
+export class AgentToolSelectService extends Disposable implements IAgentToolSelectService {
+ declare readonly _serviceBrand: undefined;
+ private readonly pendingLoaded = new Set();
+
+ constructor(
+ @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService,
+ @IAgentProfileService private readonly profile: IAgentProfileService,
+ @IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
+ @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService,
+ @IFlagService private readonly flags: IFlagService,
+ @IEventBus eventBus: IEventBus,
+ ) {
+ super();
+ this._register(
+ toolExecutor.registerUnavailableToolDescriber((name) => this.describeUnavailableTool(name)),
+ );
+ this._register(
+ toolExecutor.registerMissingToolDescriber((name) => this.describeMissingTool(name)),
+ );
+ this._register(
+ eventBus.subscribe('compaction.completed', () => {
+ this.pendingLoaded.clear();
+ }),
+ );
+ this._register(
+ eventBus.subscribe('context.spliced', (splice) => {
+ if (splice.deleteCount === 0 || this.pendingLoaded.size === 0) return;
+ // The pending set is only a defer-window lead over the history-backed
+ // ledger, so any deletion splice can falsify it: v2's undo slices the
+ // tail wholesale (v1 keeps `injection`-origin schema messages in place),
+ // which makes full-prefix detection insufficient. Re-fold the pending
+ // set against the surviving history — the event is published after the
+ // memory service has rewritten it.
+ const landed = collectLoadedDynamicToolNames(this.context.get());
+ for (const name of this.pendingLoaded) {
+ if (!landed.has(name)) this.pendingLoaded.delete(name);
+ }
+ }),
+ );
+ }
+
+ enabled(): boolean {
+ const capabilities = this.profile.getModelCapabilities();
+ return (
+ capabilities.select_tools === true &&
+ capabilities.tool_use &&
+ this.flags.enabled(TOOL_SELECT_FLAG_ID)
+ );
+ }
+
+ shapeTools(entries: readonly ToolInfo[]): readonly ShapedToolEntry[] {
+ const disclosure = this.enabled();
+ const activeEntries = this.activeEntries(entries, disclosure);
+ if (!disclosure) return activeEntries;
+ const loaded = this.loadedToolNames();
+ const shaped: ShapedToolEntry[] = [];
+ for (const entry of activeEntries) {
+ if (entry.name === SELECT_TOOLS_TOOL_NAME) {
+ shaped.push(entry);
+ continue;
+ }
+ if (entry.source !== 'mcp') {
+ shaped.push(entry);
+ continue;
+ }
+ if (!loaded.has(entry.name)) continue;
+ shaped.push({ ...entry, deferred: true });
+ }
+ return shaped;
+ }
+
+ shapeHistory(messages: readonly ContextMessage[]): readonly ContextMessage[] {
+ if (this.enabled()) return this.shapeActiveHistory(messages);
+ return stripDynamicToolContext(messages);
+ }
+
+ load(names: readonly string[]): LoadToolsResult {
+ const loadable = new Set(this.loadableToolNames());
+ const loaded = this.activeLoadedToolNames();
+ const toLoad: string[] = [];
+ const alreadyAvailable: string[] = [];
+ const unknown: string[] = [];
+ for (const name of new Set(names)) {
+ if (loaded.has(name)) {
+ alreadyAvailable.push(name);
+ } else if (loadable.has(name)) {
+ toLoad.push(name);
+ } else {
+ unknown.push(name);
+ }
+ }
+ if (toLoad.length > 0) {
+ toLoad.sort((a, b) => a.localeCompare(b));
+ const tools = toLoad
+ .map((name) => this.schemaOf(name))
+ .filter((tool): tool is Tool => tool !== undefined);
+ this.context.append({
+ role: 'system',
+ content: [],
+ toolCalls: [],
+ tools,
+ origin: { kind: 'injection', variant: DYNAMIC_TOOL_SCHEMA_VARIANT },
+ });
+ for (const name of toLoad) this.pendingLoaded.add(name);
+ }
+ return { toLoad, alreadyAvailable, unknown };
+ }
+
+ loadableToolsAnnouncement(): string | undefined {
+ if (!this.enabled()) return undefined;
+ const loadable = this.loadableToolNames();
+ const loadableSet = new Set(loadable);
+ const announced = foldAnnouncedToolNames(this.context.get());
+ const added = loadable.filter((name) => !announced.has(name));
+ const removed = [...announced]
+ .filter((name) => !loadableSet.has(name))
+ .toSorted((a, b) => a.localeCompare(b));
+ if (added.length === 0 && removed.length === 0) return undefined;
+ return renderLoadableToolsAnnouncement(added, removed);
+ }
+
+ 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;
+ if (!this.loadableToolNames().includes(name)) return false;
+ return !this.activeLoadedToolNames().has(name);
+ }
+
+ private describeUnavailableTool(name: string): string | undefined {
+ if (this.isInactiveLoadedTool(name)) return inactiveLoadedToolOutput(name);
+ if (!this.shouldIntercept(name)) return undefined;
+ return notLoadedToolOutput(name);
+ }
+
+ private describeMissingTool(name: string): string | undefined {
+ if (!this.enabled()) return undefined;
+ if (this.toolRegistry.resolve(name) !== undefined) return undefined;
+ if (!this.loadedToolNames().has(name)) return undefined;
+ 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.'
+ );
+ }
+
+ private loadableToolNames(): string[] {
+ return this.toolRegistry
+ .list()
+ .filter((info) => info.source === 'mcp' && this.profile.isToolActive(info.name, info.source))
+ .map((info) => info.name)
+ .toSorted((a, b) => a.localeCompare(b));
+ }
+
+ private loadedToolNames(): Set {
+ const names = collectLoadedDynamicToolNames(this.context.get());
+ for (const name of this.pendingLoaded) names.add(name);
+ return names;
+ }
+
+ private activeLoadedToolNames(): Set {
+ const names = this.loadedToolNames();
+ for (const name of names) {
+ if (!this.isLoadedToolActive(name)) names.delete(name);
+ }
+ return names;
+ }
+
+ private isInactiveLoadedTool(name: string): boolean {
+ if (!this.enabled()) return false;
+ return this.loadedToolNames().has(name) && !this.isLoadedToolActive(name);
+ }
+
+ private isLoadedToolActive(name: string): boolean {
+ return this.profile.isToolActive(name, 'mcp');
+ }
+
+ private shapeActiveHistory(messages: readonly ContextMessage[]): readonly ContextMessage[] {
+ let shaped: ContextMessage[] | undefined;
+ for (let i = 0; i < messages.length; i += 1) {
+ const message = messages[i]!;
+ const next = this.shapeActiveMessage(message);
+ if (next === message) {
+ if (shaped !== undefined) shaped.push(message);
+ continue;
+ }
+ if (shaped === undefined) shaped = messages.slice(0, i);
+ if (next !== undefined) shaped.push(next);
+ }
+ return shaped ?? messages;
+ }
+
+ private shapeActiveMessage(message: ContextMessage): ContextMessage | undefined {
+ const tools = message.tools;
+ if (tools === undefined || tools.length === 0) return message;
+
+ let kept: Tool[] | undefined;
+ for (let i = 0; i < tools.length; i += 1) {
+ const tool = tools[i]!;
+ if (this.isLoadedToolActive(tool.name)) {
+ if (kept !== undefined) kept.push(tool);
+ continue;
+ }
+ if (kept === undefined) kept = tools.slice(0, i);
+ }
+ if (kept === undefined) return message;
+ if (kept.length > 0) return { ...message, tools: kept };
+
+ const { tools: _tools, ...rest } = message;
+ void _tools;
+ if (rest.content.length === 0 && rest.toolCalls.length === 0) return undefined;
+ return rest;
+ }
+
+ private schemaOf(name: string): Tool | undefined {
+ const tool = this.toolRegistry.resolve(name);
+ if (tool === undefined) return undefined;
+ return {
+ name: tool.name,
+ description: tool.description,
+ parameters: tool.parameters,
+ };
+ }
+
+ private activeEntries(entries: readonly ToolInfo[], disclosure: boolean): readonly ToolInfo[] {
+ let filtered: ToolInfo[] | undefined;
+ for (let i = 0; i < entries.length; i += 1) {
+ const entry = entries[i]!;
+ const active =
+ this.profile.isToolActive(entry.name, entry.source) ||
+ (disclosure && entry.name === SELECT_TOOLS_TOOL_NAME);
+ const keep = active && (disclosure || entry.name !== SELECT_TOOLS_TOOL_NAME);
+ if (keep) {
+ if (filtered !== undefined) filtered.push(entry);
+ continue;
+ }
+ if (filtered === undefined) filtered = entries.slice(0, i);
+ }
+ return filtered ?? entries;
+ }
+}
+
+function notLoadedToolOutput(name: string): string {
+ return (
+ `Tool "${name}" is available but not loaded. ` +
+ `Call select_tools with ["${name}"] first, then call the tool.`
+ );
+}
+
+function inactiveLoadedToolOutput(name: string): string {
+ return (
+ `Tool "${name}" was loaded but is no longer active. ` +
+ 'Ask the user to enable it before calling it again.'
+ );
+}
+
+registerScopedService(
+ LifecycleScope.Agent,
+ IAgentToolSelectService,
+ AgentToolSelectService,
+ InstantiationType.Eager,
+ 'toolSelect',
+);
diff --git a/packages/agent-core-v2/src/agent/toolSelect/tools/select-tools.ts b/packages/agent-core-v2/src/agent/toolSelect/tools/select-tools.ts
new file mode 100644
index 000000000..4c64c725a
--- /dev/null
+++ b/packages/agent-core-v2/src/agent/toolSelect/tools/select-tools.ts
@@ -0,0 +1,74 @@
+/**
+ * `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
+ * `IAgentToolSelectService`; offered by the shaped tool view only while the
+ * disclosure gate is open.
+ */
+
+import { z } from 'zod';
+
+import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
+import type { BuiltinTool, ToolExecution } from '#/agent/tool/toolContract';
+import { registerTool } from '#/agent/toolRegistry/toolContribution';
+
+import { IAgentToolSelectService, SELECT_TOOLS_TOOL_NAME } from '../toolSelect';
+
+export const SelectToolsInputSchema = z
+ .object({
+ names: z
+ .array(z.string())
+ .min(1)
+ .describe('Exact tool names to load, taken from the latest announced tool list.'),
+ })
+ .strict();
+
+export type SelectToolsInput = z.infer;
+
+const DESCRIPTION =
+ 'Load one or more tools by name so you can call them. ' +
+ 'All available tool names are listed in the / announcements ' +
+ 'in the system context — fold them in order to get the current list. ' +
+ 'Pass the exact name(s) you need; their full definitions become available immediately, ' +
+ 'so you can call them directly in your next tool call.';
+
+export class SelectToolsTool implements BuiltinTool {
+ readonly name = SELECT_TOOLS_TOOL_NAME;
+ readonly description: string = DESCRIPTION;
+ readonly parameters: Record = toInputJsonSchema(SelectToolsInputSchema);
+
+ constructor(
+ @IAgentToolSelectService private readonly toolSelect: IAgentToolSelectService,
+ ) {}
+
+ resolveExecution(args: SelectToolsInput): ToolExecution {
+ return {
+ description: `Loading ${args.names.join(', ')}`,
+ approvalRule: this.name,
+ execute: async () => {
+ if (!this.toolSelect.enabled()) {
+ return {
+ output: 'select_tools is not available for the current model.',
+ isError: true,
+ };
+ }
+ const { toLoad, alreadyAvailable, unknown } = this.toolSelect.load(args.names);
+
+ const lines: string[] = [];
+ if (toLoad.length > 0) lines.push(`Loaded: ${toLoad.join(', ')}`);
+ if (alreadyAvailable.length > 0) {
+ lines.push(`Already available: ${alreadyAvailable.join(', ')}`);
+ }
+ for (const name of unknown) {
+ lines.push(`Unknown tool: ${name}. Pick from the latest announced tools list.`);
+ }
+ const isError = toLoad.length === 0 && alreadyAvailable.length === 0;
+ return isError ? { output: lines.join('\n'), isError } : { output: lines.join('\n') };
+ },
+ };
+ }
+}
+
+registerTool(SelectToolsTool);
diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts
index c4cd360fd..644d439fc 100644
--- a/packages/agent-core-v2/src/index.ts
+++ b/packages/agent-core-v2/src/index.ts
@@ -194,6 +194,13 @@ export * from '#/agent/runtime/runtimeOps';
export * from '#/agent/runtime/runtimeService';
export * from '#/agent/toolDedupe/toolDedupe';
export * from '#/agent/toolDedupe/toolDedupeService';
+import '#/agent/toolSelect/flag';
+import '#/agent/toolSelect/tools/select-tools';
+export * from '#/agent/toolSelect/dynamicTools';
+export * from '#/agent/toolSelect/toolSelect';
+export * from '#/agent/toolSelect/toolSelectService';
+export * from '#/agent/toolSelect/toolSelectAnnouncements';
+export * from '#/agent/toolSelect/toolSelectAnnouncementsService';
import '#/agent/task/configSection';
import '#/agent/task/tools/task-list';
diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts
index 54218ddf5..e910031c9 100644
--- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts
+++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts
@@ -47,6 +47,8 @@ import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceCo
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { IAgentActivityService, ISessionActivityKernel } from '#/activity/activity';
import { IAgentProfileService } from '#/agent/profile/profile';
+import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect';
+import { IAgentToolSelectAnnouncementsService } from '#/agent/toolSelect/toolSelectAnnouncements';
import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import { IAgentBuiltinToolsRegistrar } from '#/agent/toolRegistry/builtinToolsRegistrar';
@@ -217,6 +219,8 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
// first turn — otherwise plugin/session MCP servers would connect but their
// tools would never register until something explicitly requests the service.
handle.accessor.get(IAgentMcpService);
+ handle.accessor.get(IAgentToolSelectService);
+ handle.accessor.get(IAgentToolSelectAnnouncementsService);
await mcpReady;
await this.ensureWireMetadata(handle, agentScope);
if (opts.binding !== undefined) {
diff --git a/packages/agent-core-v2/test/contextProjector/projector-tool-exchanges.test.ts b/packages/agent-core-v2/test/contextProjector/projector-tool-exchanges.test.ts
index dc23ba004..4afd89102 100644
--- a/packages/agent-core-v2/test/contextProjector/projector-tool-exchanges.test.ts
+++ b/packages/agent-core-v2/test/contextProjector/projector-tool-exchanges.test.ts
@@ -75,6 +75,25 @@ function toolResult(toolCallId: string, text: string): ContextMessage {
return { role: 'tool', content: [{ type: 'text', text }], toolCalls: [], toolCallId };
}
+function schemaMessage(name: string): ContextMessage {
+ return {
+ role: 'system',
+ content: [],
+ toolCalls: [],
+ tools: [
+ {
+ name,
+ description: `${name} desc`,
+ parameters: {
+ type: 'object',
+ properties: { query: { type: 'string' } },
+ },
+ },
+ ],
+ origin: { kind: 'injection', variant: 'dynamic_tool_schema' },
+ };
+}
+
describe('projector tool-exchange normalization', () => {
let disposables: DisposableStore;
let projector: IAgentContextProjectorService;
@@ -248,6 +267,39 @@ describe('projector tool-exchange normalization', () => {
expect(project([message])).toHaveLength(1);
});
+ it('keeps a schema-only system message when it declares dynamic tools', () => {
+ const projected = project([user('load it'), schemaMessage('mcp__srv__query')]);
+
+ expect(projected).toEqual([
+ {
+ role: 'user',
+ name: undefined,
+ content: [{ type: 'text', text: 'load it' }],
+ toolCalls: [],
+ toolCallId: undefined,
+ partial: undefined,
+ },
+ {
+ role: 'system',
+ name: undefined,
+ content: [],
+ toolCalls: [],
+ toolCallId: undefined,
+ partial: undefined,
+ tools: [
+ {
+ name: 'mcp__srv__query',
+ description: 'mcp__srv__query desc',
+ parameters: {
+ type: 'object',
+ properties: { query: { type: 'string' } },
+ },
+ },
+ ],
+ },
+ ]);
+ });
+
it('renders structured tool-result notes only for the model projection', () => {
const note = 'Image compressed.';
const result: ContextMessage = {
diff --git a/packages/agent-core-v2/test/fullCompaction/full.test.ts b/packages/agent-core-v2/test/fullCompaction/full.test.ts
index f97cdae78..2a0369d7b 100644
--- a/packages/agent-core-v2/test/fullCompaction/full.test.ts
+++ b/packages/agent-core-v2/test/fullCompaction/full.test.ts
@@ -1,3 +1,15 @@
+/**
+ * Scenario: full compaction refreshes, retries, and resumes agent context under
+ * context-window pressure.
+ *
+ * Responsibilities: assert manual and automatic compaction outcomes, overflow
+ * recovery, resume compatibility, dynamic tool context handling, and emitted
+ * wire/telemetry effects. Wiring: testAgent harness with fake providers,
+ * filesystem sandboxes, real compaction services, and stubs at external model /
+ * telemetry boundaries. Run:
+ * ../../node_modules/.bin/vitest run test/fullCompaction/full.test.ts
+ */
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'pathe';
@@ -24,8 +36,12 @@ import {
IAgentFullCompactionService,
IOAuthService,
IAgentProfileService,
+ IAgentToolRegistryService,
ISessionTodoService,
+ DYNAMIC_TOOL_SCHEMA_VARIANT,
+ type ExecutableTool,
type ResolvedAgentProfile,
+ type ToolExecution,
} from '#/index';
import { IAgentTurnService } from '#/agent/turn/turn';
import { HostFileSystem } from '#/os/backends/node-local/hostFsService';
@@ -55,6 +71,7 @@ const SNAPSHOT_VISIBLE_TOOLS = [
'EnterPlanMode',
'ExitPlanMode',
] as const;
+const LARGE_MCP_TOOL = 'mcp__srv__large';
const EXACT_COMPACTION_REFRESH_PROFILE: ResolvedAgentProfile = {
name: 'exact-compaction-refresh',
systemPrompt: (context) =>
@@ -1662,6 +1679,63 @@ describe('FullCompaction', () => {
await ctx.expectResumeMatches();
});
+ it('does not trigger auto compaction from a deferred loaded MCP schema', async () => {
+ vi.stubEnv(MASTER_ENV, '1');
+ const ctx = testAgent({
+ initialConfig: {
+ providers: {},
+ loopControl: { reservedContextSize: 0 },
+ },
+ });
+ const parameters = {
+ type: 'object',
+ properties: {
+ payload: {
+ type: 'string',
+ description: 'x'.repeat(40_000),
+ },
+ },
+ };
+ ctx.configure({
+ provider: CATALOGUED_PROVIDER,
+ modelCapabilities: {
+ ...CATALOGUED_MODEL_CAPABILITIES,
+ max_context_tokens: 2_000,
+ select_tools: true,
+ },
+ tools: [LARGE_MCP_TOOL],
+ });
+ const registration = ctx
+ .get(IAgentToolRegistryService)
+ .register(mcpTool(LARGE_MCP_TOOL, parameters), { source: 'mcp' });
+ try {
+ ctx.context.append({
+ role: 'system',
+ content: [],
+ toolCalls: [],
+ tools: [
+ {
+ name: LARGE_MCP_TOOL,
+ description: `${LARGE_MCP_TOOL} desc`,
+ parameters,
+ },
+ ],
+ origin: { kind: 'injection', variant: DYNAMIC_TOOL_SCHEMA_VARIANT },
+ });
+ ctx.appendExchange(1, 'old user one', 'old assistant one', 20);
+
+ ctx.mockNextResponse({ type: 'text', text: 'Answered without tool-schema compaction.' });
+ await ctx.rpc.prompt({ input: [{ type: 'text', text: 'small prompt' }] });
+ const events = await ctx.untilTurnEnd();
+
+ expect(eventIndex(events, 'compaction.started')).toBe(-1);
+ expect(ctx.llmCalls).toHaveLength(1);
+ expect(messageText(ctx.llmCalls[0]?.history.at(-1))).toBe('small prompt');
+ } finally {
+ registration.dispose();
+ }
+ });
+
it('triggers auto compaction when pending tokens cross the reserved threshold', async () => {
const ctx = testAgent({
initialConfig: {
@@ -2649,6 +2723,23 @@ function textMessage(role: 'user' | 'assistant', text: string): Message {
};
}
+function mcpTool(
+ name: string,
+ parameters: Record,
+): ExecutableTool> {
+ return {
+ name,
+ description: `${name} desc`,
+ parameters,
+ resolveExecution(): ToolExecution {
+ return {
+ approvalRule: name,
+ execute: async () => ({ output: 'mcp ok' }),
+ };
+ },
+ };
+}
+
function bashCall(): ToolCall {
return {
type: 'function',
diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts
index e649b4578..45ba0bf67 100644
--- a/packages/agent-core-v2/test/harness/agent.ts
+++ b/packages/agent-core-v2/test/harness/agent.ts
@@ -2252,6 +2252,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 ? 'select_tools' : undefined,
].filter((capability): capability is string => capability !== undefined);
}
diff --git a/packages/agent-core-v2/test/llmRequester/kosong-llm.test.ts b/packages/agent-core-v2/test/llmRequester/kosong-llm.test.ts
index 92c33f71b..0bc81008a 100644
--- a/packages/agent-core-v2/test/llmRequester/kosong-llm.test.ts
+++ b/packages/agent-core-v2/test/llmRequester/kosong-llm.test.ts
@@ -1,4 +1,5 @@
import { APIConnectionError, APIStatusError } from '#/app/llmProtocol/errors';
+import { TOOL_SELECT_FLAG_ENV } from '#/agent/toolSelect/flag';
import { type StreamedMessagePart } from '#/app/llmProtocol/message';
import type { Tool } from '#/app/llmProtocol/tool';
import { emptyUsage } from '#/app/llmProtocol/usage';
@@ -71,11 +72,14 @@ describe('LLMRequester service migration coverage', () => {
];
beforeEach(() => {
+ // Stubbed before createTestAgent snapshots the env into bootstrap.
+ vi.stubEnv(TOOL_SELECT_FLAG_ENV, '1');
ctx = createTestAgent();
llmRequester = ctx.get(IAgentLLMRequesterService);
});
afterEach(async () => {
+ vi.unstubAllEnvs();
try {
await ctx.expectResumeMatches();
} finally {
@@ -84,6 +88,20 @@ describe('LLMRequester service migration coverage', () => {
});
it('records one tools snapshot per unique provider-visible tool table and one request per outbound call', async () => {
+ // Gate the scenario on like v1's recorder contract requires: `toolSelect`
+ // in the record is the disclosure gate (flag × capability), not the
+ // presence of deferred entries in this request's tool table.
+ ctx.configure({
+ modelCapabilities: {
+ image_in: false,
+ video_in: false,
+ audio_in: false,
+ thinking: false,
+ tool_use: true,
+ max_context_tokens: 128_000,
+ select_tools: true,
+ },
+ });
ctx.mockNextResponse({ type: 'text', text: 'first response' });
await llmRequester.request({
messages: [userMessage('first direct request')],
diff --git a/packages/agent-core-v2/test/llmRequester/strict-resend.test.ts b/packages/agent-core-v2/test/llmRequester/strict-resend.test.ts
index 570f9b4d1..228a10b73 100644
--- a/packages/agent-core-v2/test/llmRequester/strict-resend.test.ts
+++ b/packages/agent-core-v2/test/llmRequester/strict-resend.test.ts
@@ -1,3 +1,14 @@
+/**
+ * Scenario: LLM requester retries once with strict projection after provider
+ * tool-use adjacency rejection.
+ *
+ * Responsibilities: assert retry eligibility, strict-history rebuilding,
+ * request recording, and usage accounting. Wiring: real
+ * AgentLLMRequesterService with stubbed context memory, projector, context
+ * sizing, profile, model, telemetry, and wire/log services. Run:
+ * ../../node_modules/.bin/vitest run test/llmRequester/strict-resend.test.ts
+ */
+
import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
@@ -9,6 +20,7 @@ import { IAgentLLMRequesterService } from '#/agent/llmRequester/llmRequester';
import { IAgentContextSizeService } from '#/agent/contextSize/contextSize';
import { IAgentProfileService } from '#/agent/profile/profile';
import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
+import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect';
import { IAgentUsageService } from '#/agent/usage/usage';
import { IConfigService } from '#/app/config/config';
import { APIStatusError } from '#/app/llmProtocol/errors';
@@ -117,8 +129,14 @@ function createService(
};
const log = { info: () => undefined, warn: () => undefined };
const telemetry = { track: () => undefined };
+ const toolSelect: Partial = {
+ enabled: () => false,
+ shapeTools: (entries) => entries,
+ shapeHistory: (messages) => messages,
+ };
ix.stub(IAgentContextMemoryService, context);
+ ix.stub(IAgentToolSelectService, toolSelect);
ix.stub(IAgentContextProjectorService, projector);
ix.stub(IAgentContextSizeService, contextSize);
ix.stub(IAgentToolRegistryService, tools);
diff --git a/packages/agent-core-v2/test/toolSelect/dynamicTools.test.ts b/packages/agent-core-v2/test/toolSelect/dynamicTools.test.ts
new file mode 100644
index 000000000..b0ba9efa5
--- /dev/null
+++ b/packages/agent-core-v2/test/toolSelect/dynamicTools.test.ts
@@ -0,0 +1,145 @@
+/**
+ * Scenario: pure helpers fold loadable-tool announcements, strip dynamic
+ * schema context, and classify dynamic tool protocol messages.
+ *
+ * Responsibilities: assert the rendered announcement grammar, origin-based
+ * predicates, loaded-tool ledger scan, and outgoing history stripping.
+ * Wiring: pure functions only; no DI container or external boundary.
+ * Run: ../../node_modules/.bin/vitest run test/toolSelect/dynamicTools.test.ts
+ */
+import { describe, expect, it } from 'vitest';
+
+import {
+ collectLoadedDynamicToolNames,
+ foldAnnouncedToolNames,
+ isDynamicToolSchemaMessage,
+ isLoadableToolsAnnouncement,
+ LOADABLE_TOOLS_TRIGGER,
+ renderLoadableToolsAnnouncement,
+ stripDynamicToolContext,
+} from '#/agent/toolSelect/dynamicTools';
+import type { ContextMessage } from '#/agent/contextMemory/types';
+
+function announcement(added: readonly string[], removed: readonly string[]): ContextMessage {
+ const text = `\n${renderLoadableToolsAnnouncement(added, removed).trim()}\n`;
+ return {
+ role: 'user',
+ content: [{ type: 'text', text }],
+ toolCalls: [],
+ origin: { kind: 'system_trigger', name: LOADABLE_TOOLS_TRIGGER },
+ };
+}
+
+function schemaMessage(names: readonly string[]): ContextMessage {
+ return {
+ role: 'system',
+ content: [],
+ toolCalls: [],
+ tools: names.map((name) => ({ name, description: `${name} desc`, parameters: {} })),
+ origin: { kind: 'injection', variant: 'dynamic_tool_schema' },
+ };
+}
+
+function userMessage(text: string): ContextMessage {
+ return { role: 'user', content: [{ type: 'text', text }], toolCalls: [] };
+}
+
+describe('foldAnnouncedToolNames', () => {
+ it('folds added and removed blocks in order (removed first within a message)', () => {
+ const history = [
+ announcement(['a', 'b'], []),
+ userMessage('hello'),
+ announcement(['c'], ['a']),
+ ];
+ expect([...foldAnnouncedToolNames(history)].toSorted()).toEqual(['b', 'c']);
+ });
+
+ it('re-adding a removed name wins (last announcement wins)', () => {
+ const history = [announcement(['a'], []), announcement([], ['a']), announcement(['a'], [])];
+ expect([...foldAnnouncedToolNames(history)]).toEqual(['a']);
+ });
+
+ it('ignores messages without the loadable-tools origin, even with matching text', () => {
+ const impostor: ContextMessage = {
+ role: 'user',
+ content: [{ type: 'text', text: '\nmallory\n' }],
+ toolCalls: [],
+ };
+ expect(foldAnnouncedToolNames([impostor]).size).toBe(0);
+ });
+
+ it('folds v1 system_trigger announcements as the loadable-tools ledger', () => {
+ const trigger: ContextMessage = {
+ role: 'user',
+ content: [
+ {
+ type: 'text',
+ text: `\n${renderLoadableToolsAnnouncement(['a'], [])}\n`,
+ },
+ ],
+ toolCalls: [],
+ origin: { kind: 'system_trigger', name: 'loadable-tools' },
+ };
+ expect([...foldAnnouncedToolNames([trigger])]).toEqual(['a']);
+ });
+
+ it('is not confused by the guidance sentence in the same message', () => {
+ const history = [announcement(['x'], ['y'])];
+ expect([...foldAnnouncedToolNames(history)]).toEqual(['x']);
+ });
+});
+
+describe('renderLoadableToolsAnnouncement', () => {
+ it('emits only the non-empty blocks', () => {
+ const addedOnly = renderLoadableToolsAnnouncement(['a'], []);
+ expect(addedOnly).toContain('\na\n');
+ expect(addedOnly).not.toContain('');
+
+ const removedOnly = renderLoadableToolsAnnouncement([], ['b']);
+ expect(removedOnly).toContain('\nb\n');
+ expect(removedOnly).not.toContain('');
+ });
+});
+
+describe('stripDynamicToolContext', () => {
+ it('returns the identical array when there is nothing to strip', () => {
+ const history = [userMessage('a'), userMessage('b')];
+ expect(stripDynamicToolContext(history)).toBe(history);
+ });
+
+ it('drops announcements and content-free schema messages, keeps everything else', () => {
+ const history = [
+ userMessage('a'),
+ announcement(['t'], []),
+ schemaMessage(['t']),
+ userMessage('b'),
+ ];
+ const stripped = stripDynamicToolContext(history);
+ expect(stripped.map((m) => m.role)).toEqual(['user', 'user']);
+ });
+
+ it('strips only the tools field from a message that also has content', () => {
+ const mixed: ContextMessage = {
+ ...schemaMessage(['t']),
+ content: [{ type: 'text', text: 'note' }],
+ };
+ const stripped = stripDynamicToolContext([mixed]);
+ expect(stripped).toHaveLength(1);
+ expect(stripped[0]!.tools).toBeUndefined();
+ expect(stripped[0]!.content).toEqual([{ type: 'text', text: 'note' }]);
+ });
+});
+
+describe('predicates and ledger scan', () => {
+ it('classifies schema messages and announcements by their anchors', () => {
+ expect(isDynamicToolSchemaMessage(schemaMessage(['t']))).toBe(true);
+ expect(isDynamicToolSchemaMessage(userMessage('x'))).toBe(false);
+ expect(isLoadableToolsAnnouncement(announcement(['t'], []))).toBe(true);
+ expect(isLoadableToolsAnnouncement(userMessage('x'))).toBe(false);
+ });
+
+ it('collects the union of loaded names across schema messages', () => {
+ const history = [schemaMessage(['a', 'b']), userMessage('x'), schemaMessage(['b', 'c'])];
+ expect([...collectLoadedDynamicToolNames(history)].toSorted()).toEqual(['a', 'b', 'c']);
+ });
+});
diff --git a/packages/agent-core-v2/test/toolSelect/toolSelect.e2e.test.ts b/packages/agent-core-v2/test/toolSelect/toolSelect.e2e.test.ts
new file mode 100644
index 000000000..c8e126f0c
--- /dev/null
+++ b/packages/agent-core-v2/test/toolSelect/toolSelect.e2e.test.ts
@@ -0,0 +1,219 @@
+/**
+ * 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.
+ *
+ * Responsibilities: assert v1 contract at the provider wire, not via service
+ * internals: the manifest announcement reaches the model, `select_tools`
+ * loads a schema into the next request, the top-level table never changes
+ * across loads, the record carries the disclosure gate (v1 recorder parity,
+ * F2), and a tail-slicing undo re-enables re-injection (F1). Wiring:
+ * testAgent harness with scripted provider, real toolSelect / executor /
+ * projector / announcer services; harness builds the Agent scope without
+ * `AgentLifecycleService.create`, so the eager-instantiation production
+ * would do (agentLifecycleService create) is forced here the same way.
+ * The flag env is stubbed before `createTestAgent` snapshots it into
+ * bootstrap, and module imports register the flag / tool contributions the
+ * way `src/index.ts` does in production.
+ * Run: ../../node_modules/.bin/vitest run test/toolSelect/toolSelect.e2e.test.ts
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
+import type { ContextMessage } from '#/agent/contextMemory/types';
+import type { ExecutableTool, ToolExecution } from '#/agent/tool/toolContract';
+import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
+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';
+// Registers the select_tools tool contribution (mirrors src/index.ts).
+import '#/agent/toolSelect/tools/select-tools';
+
+import { createTestAgent, type TestAgentContext } from '../harness';
+
+const MCP_ALPHA = 'mcp__srv__alpha';
+
+const DISCLOSURE_CAPABILITIES = {
+ image_in: false,
+ video_in: false,
+ audio_in: false,
+ thinking: false,
+ tool_use: true,
+ max_context_tokens: 128_000,
+ select_tools: true,
+} as const;
+
+type WireEvent = Extract<
+ TestAgentContext['allEvents'][number],
+ { readonly type: '[wire]' }
+>;
+
+class StubMcpTool implements ExecutableTool> {
+ readonly description: string;
+ readonly parameters: Record = {
+ type: 'object',
+ properties: { query: { type: 'string' } },
+ additionalProperties: false,
+ };
+ calls = 0;
+
+ constructor(readonly name: string) {
+ this.description = `${name} desc`;
+ }
+
+ resolveExecution(): ToolExecution {
+ return {
+ description: `stub ${this.name}`,
+ approvalRule: this.name,
+ execute: async () => {
+ this.calls += 1;
+ return { output: 'mcp ok' };
+ },
+ };
+ }
+}
+
+function wireEvents(ctx: TestAgentContext, eventName: string): readonly WireEvent[] {
+ return ctx.allEvents.filter(
+ (event): event is WireEvent => event.type === '[wire]' && event.event === eventName,
+ );
+}
+
+function selectToolsCall(id: string, names: readonly string[]) {
+ return {
+ type: 'function' as const,
+ id,
+ name: 'select_tools',
+ arguments: JSON.stringify({ names }),
+ };
+}
+
+function toolNames(tools: readonly { readonly name: string }[]): string[] {
+ return tools.map((tool) => tool.name);
+}
+
+function historyText(history: readonly ContextMessage[]): string {
+ return history
+ .flatMap((message) => message.content)
+ .map((part) => (part.type === 'text' ? part.text : ''))
+ .join('\n');
+}
+
+describe('progressive tool disclosure end-to-end', () => {
+ let ctx: TestAgentContext;
+ let alpha: StubMcpTool;
+ let registration: { dispose(): void } | undefined;
+
+ beforeEach(async () => {
+ // Stubbed before createTestAgent snapshots the env into bootstrap.
+ vi.stubEnv(TOOL_SELECT_FLAG_ENV, '1');
+ ctx = createTestAgent();
+ // Production mounts these through AgentLifecycleService.create's eager
+ // gets; the harness builds the Agent scope directly, so force the same
+ // instantiation here before any loop step runs.
+ ctx.get(IAgentToolSelectService);
+ ctx.get(IAgentToolSelectAnnouncementsService);
+ ctx.get(IAgentToolExecutorService);
+ ctx.configure({ modelCapabilities: DISCLOSURE_CAPABILITIES });
+ await ctx.rpc.setPermission({ mode: 'yolo' });
+ alpha = new StubMcpTool(MCP_ALPHA);
+ registration = ctx.get(IAgentToolRegistryService).register(alpha, { source: 'mcp' });
+ });
+
+ afterEach(async () => {
+ registration?.dispose();
+ vi.unstubAllEnvs();
+ await ctx.dispose();
+ });
+
+ it('announces the manifest, loads by name, keeps the top-level table byte-stable, and dispatches on the next step', async () => {
+ ctx.mockNextResponse(selectToolsCall('call_select_1', [MCP_ALPHA]));
+ ctx.mockNextResponse({
+ type: 'function',
+ id: 'call_alpha_1',
+ name: MCP_ALPHA,
+ arguments: JSON.stringify({ query: 'moon' }),
+ });
+ ctx.mockNextResponse({ type: 'text', text: 'done' });
+
+ await ctx.rpc.prompt({ input: [{ type: 'text', text: 'try the srv alpha tool' }] });
+ await ctx.untilTurnEnd();
+
+ expect(ctx.llmCalls).toHaveLength(3);
+
+ // Turn-boundary manifest announcement reached the model on the first request.
+ const firstWire = ctx.llmCalls[0]!;
+ expect(toolNames(firstWire.tools)).not.toContain(MCP_ALPHA);
+ expect(toolNames(firstWire.tools)).toContain('select_tools');
+ const announcementText = firstWire.history
+ .map((message) =>
+ message.content.map((part) => (part.type === 'text' ? part.text : '')).join(''),
+ )
+ .join('\n');
+ expect(announcementText).toContain('');
+ expect(announcementText).toContain(MCP_ALPHA);
+
+ // The record carries the disclosure gate state (v1 recorder parity).
+ const requests = wireEvents(ctx, 'llm.request').filter(
+ (event) => (event.args as { kind?: string }).kind === 'loop',
+ );
+ expect(requests.length).toBeGreaterThan(0);
+ for (const request of requests) {
+ expect((request.args as { toolSelect?: boolean }).toolSelect).toBe(true);
+ }
+
+ // Loaded schema rides the next request as a message-level declaration.
+ const secondWire = ctx.llmCalls[1]!;
+ const schemaMessages = secondWire.history.filter(
+ (message) => message.tools?.some((tool) => tool.name === MCP_ALPHA),
+ );
+ expect(schemaMessages).toHaveLength(1);
+
+ const alphaFromSchema = schemaMessages[0]!.tools!.find((tool) => tool.name === MCP_ALPHA)!;
+ expect(alphaFromSchema.parameters).toEqual(alpha.parameters);
+
+ // Top-level table is byte-stable across the load (v1 prompt-cache contract):
+ // the provider-visible table of the post-load request equals the pre-load one.
+ expect(secondWire.tools).toEqual(firstWire.tools);
+ expect(wireEvents(ctx, 'llm.tools_snapshot')).toHaveLength(1);
+
+ // The loaded tool is dispatchable on a later step of the same turn.
+ expect(alpha.calls).toBe(1);
+ });
+
+ it('re-injects a selected schema after undo slices the tail of the loaded exchange', async () => {
+ // Seed an older real user prompt so the undo cut lands at start > 0: the
+ // F1 stale-ledger window only opens when the cut is not full-prefix.
+ ctx.get(IAgentContextMemoryService).append({
+ role: 'user',
+ content: [{ type: 'text', text: 'earlier question' }],
+ toolCalls: [],
+ origin: { kind: 'user' },
+ });
+
+ ctx.mockNextResponse(selectToolsCall('call_select_1', [MCP_ALPHA]));
+ ctx.mockNextResponse({ type: 'text', text: 'alpha is loaded' });
+ await ctx.rpc.prompt({ input: [{ type: 'text', text: 'load alpha' }] });
+ await ctx.untilTurnEnd();
+
+ ctx.get(IAgentContextMemoryService).undo(1);
+ const afterUndo = ctx.get(IAgentContextMemoryService).get();
+ expect(afterUndo.some((message) => message.tools?.some((tool) => tool.name === MCP_ALPHA))).toBe(
+ false,
+ );
+
+ ctx.mockNextResponse(selectToolsCall('call_select_2', [MCP_ALPHA]));
+ ctx.mockNextResponse({ type: 'text', text: 'reloaded' });
+ await ctx.rpc.prompt({ input: [{ type: 'text', text: 'load alpha again' }] });
+ await ctx.untilTurnEnd();
+
+ const afterReload = ctx.get(IAgentContextMemoryService).get();
+ expect(
+ afterReload.some((message) => message.tools?.some((tool) => tool.name === MCP_ALPHA)),
+ ).toBe(true);
+ expect(historyText(afterReload)).toContain('Loaded: mcp__srv__alpha');
+ expect(historyText(afterReload)).not.toContain('Already available: mcp__srv__alpha');
+ });
+});
diff --git a/packages/agent-core-v2/test/toolSelect/toolSelectService.test.ts b/packages/agent-core-v2/test/toolSelect/toolSelectService.test.ts
new file mode 100644
index 000000000..4d0995f51
--- /dev/null
+++ b/packages/agent-core-v2/test/toolSelect/toolSelectService.test.ts
@@ -0,0 +1,869 @@
+/**
+ * Scenario: progressive tool disclosure shapes the provider-visible tool view,
+ * dynamic history, selection results, executor interception, and announcements.
+ *
+ * Responsibilities: assert the gate contract, profile-active filtering,
+ * loadable/loaded MCP settlement, and the select_tools built-in behavior.
+ * Wiring: real toolSelect, registry, announcement sidecar, system reminder,
+ * and hook slots with fake loop/context memory/profile/flag/event services;
+ * executor tests use the real executor with telemetry and truncation stubs.
+ * Run: ../../node_modules/.bin/vitest run test/toolSelect/toolSelectService.test.ts
+ */
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+
+import { DisposableStore, toDisposable } from '#/_base/di/lifecycle';
+import { createServices, type ServiceRegistration, type TestInstantiationService } from '#/_base/di/test';
+import { OrderedHookSlot } from '#/hooks';
+import { IEventBus, type DomainEvent } from '#/app/event/eventBus';
+import { IFlagService } from '#/app/flag/flag';
+import type { ModelCapability } from '#/app/llmProtocol/capability';
+import type { ToolCall } from '#/app/llmProtocol/message';
+import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
+import type { UndoCut } from '#/agent/contextMemory/contextOps';
+import type { ContextMessage } from '#/agent/contextMemory/types';
+import type { LoopRecordedEvent } from '#/agent/contextMemory/loopEventFold';
+import {
+ IAgentLoopService,
+ type AfterStepContext,
+ type BeforeStepContext,
+ type LoopErrorContext,
+ type LoopRunOptions,
+ type LoopRunResult,
+} from '#/agent/loop/loop';
+import { IAgentProfileService } from '#/agent/profile/profile';
+import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder';
+import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService';
+import type { ExecutableTool, ToolExecution } from '#/agent/tool/toolContract';
+import { IAgentToolExecutorService, type ToolExecutionResult } from '#/agent/toolExecutor/toolExecutor';
+import { AgentToolExecutorService } from '#/agent/toolExecutor/toolExecutorService';
+import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry';
+import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService';
+import { DYNAMIC_TOOL_SCHEMA_VARIANT, LOADABLE_TOOLS_TRIGGER } from '#/agent/toolSelect/dynamicTools';
+import { TOOL_SELECT_FLAG_ID } from '#/agent/toolSelect/flag';
+import { IAgentToolSelectService, SELECT_TOOLS_TOOL_NAME } from '#/agent/toolSelect/toolSelect';
+import { IAgentToolSelectAnnouncementsService } from '#/agent/toolSelect/toolSelectAnnouncements';
+import { AgentToolSelectAnnouncementsService } from '#/agent/toolSelect/toolSelectAnnouncementsService';
+import { AgentToolSelectService } from '#/agent/toolSelect/toolSelectService';
+import { SelectToolsTool } from '#/agent/toolSelect/tools/select-tools';
+import { ITelemetryService } from '#/app/telemetry/telemetry';
+import { registerLogServices } from '../log/stubs';
+import { recordingTelemetry } from '../telemetry/stubs';
+import { registerToolResultTruncationServices } from '../toolResultTruncation/stubs';
+import { stubToolExecutor } from '../turn/stubs';
+
+const MCP_ALPHA = 'mcp__srv__alpha';
+const MCP_BETA = 'mcp__srv__beta';
+const MCP_GAMMA = 'mcp__srv__gamma';
+const MCP_GONE = 'mcp__srv__gone';
+const REQUIRED_PAYLOAD_PARAMETERS = {
+ type: 'object',
+ required: ['payload'],
+ properties: { payload: { type: 'string' } },
+ additionalProperties: false,
+};
+
+let disposables: DisposableStore;
+let capabilities: ModelCapability;
+let flagEnabled: boolean;
+let activeToolNames: ReadonlySet | undefined;
+
+beforeEach(() => {
+ disposables = new DisposableStore();
+ capabilities = makeCapabilities({ tool_use: true, select_tools: true });
+ flagEnabled = false;
+ activeToolNames = undefined;
+});
+
+afterEach(() => disposables.dispose());
+
+function makeCapabilities(overrides: {
+ readonly tool_use?: boolean;
+ readonly select_tools?: boolean;
+} = {}): ModelCapability {
+ return {
+ image_in: false,
+ video_in: false,
+ audio_in: false,
+ thinking: false,
+ tool_use: overrides.tool_use ?? false,
+ max_context_tokens: 128_000,
+ select_tools: overrides.select_tools,
+ };
+}
+
+function toolCall(id: string, name: string, args: unknown = {}): ToolCall {
+ return { type: 'function', id, name, arguments: JSON.stringify(args) };
+}
+
+function userMessage(text: string): ContextMessage {
+ return { role: 'user', content: [{ type: 'text', text }], toolCalls: [] };
+}
+
+function schemaMessage(...names: string[]): ContextMessage {
+ return {
+ role: 'system',
+ content: [],
+ toolCalls: [],
+ tools: names.map((name) => ({ name, description: `${name} desc`, parameters: {} })),
+ origin: { kind: 'injection', variant: DYNAMIC_TOOL_SCHEMA_VARIANT },
+ };
+}
+
+class StubMcpTool implements ExecutableTool> {
+ readonly description: string;
+ calls = 0;
+ readonly parameters: Record;
+
+ constructor(
+ readonly name: string,
+ private readonly output: string = 'mcp ok',
+ parameters?: Record,
+ ) {
+ this.description = `${name} desc`;
+ this.parameters = parameters ?? {
+ type: 'object',
+ additionalProperties: true,
+ };
+ }
+
+ resolveExecution(): ToolExecution {
+ return {
+ approvalRule: this.name,
+ execute: async () => {
+ this.calls += 1;
+ return { output: this.output };
+ },
+ };
+ }
+}
+
+class EchoTool implements ExecutableTool> {
+ readonly description = 'Echo input text.';
+ readonly parameters: Record = { type: 'object', additionalProperties: true };
+ calls = 0;
+
+ constructor(readonly name = 'Echo') {}
+
+ resolveExecution(): ToolExecution {
+ return {
+ approvalRule: this.name,
+ execute: async () => {
+ this.calls += 1;
+ return { output: 'echo ok' };
+ },
+ };
+ }
+}
+
+class RecordingEventBus implements IEventBus {
+ readonly _serviceBrand = undefined;
+ private readonly typedHandlers = new Map void>>();
+ private readonly allHandlers: Array<(event: DomainEvent) => void> = [];
+ readonly published: DomainEvent[] = [];
+
+ publish(event: DomainEvent): void {
+ this.published.push(event);
+ for (const handler of this.allHandlers) handler(event);
+ for (const handler of this.typedHandlers.get(event.type) ?? []) handler(event);
+ }
+
+ subscribe(
+ typeOrHandler: string | ((event: DomainEvent) => void),
+ maybeHandler?: (event: DomainEvent) => void,
+ ) {
+ if (typeof typeOrHandler === 'function') {
+ this.allHandlers.push(typeOrHandler);
+ return toDisposable(() => {
+ const index = this.allHandlers.indexOf(typeOrHandler);
+ if (index >= 0) this.allHandlers.splice(index, 1);
+ });
+ }
+ const list = this.typedHandlers.get(typeOrHandler) ?? [];
+ const handler = maybeHandler!;
+ list.push(handler);
+ this.typedHandlers.set(typeOrHandler, list);
+ return toDisposable(() => {
+ const index = list.indexOf(handler);
+ if (index >= 0) list.splice(index, 1);
+ });
+ }
+
+ emit(type: string, payload: Record = {}): void {
+ this.publish({ type, ...payload } as DomainEvent);
+ }
+}
+
+class FakeLoopService implements IAgentLoopService {
+ readonly _serviceBrand = undefined;
+
+ readonly hooks: IAgentLoopService['hooks'] = {
+ beforeStep: new OrderedHookSlot(),
+ afterStep: new OrderedHookSlot(),
+ onError: new OrderedHookSlot(),
+ };
+
+ run(_options: LoopRunOptions): Promise {
+ throw new Error('unused in this suite');
+ }
+}
+
+class FakeContextMemory implements IAgentContextMemoryService {
+ readonly _serviceBrand = undefined;
+ readonly history: ContextMessage[] = [];
+ readonly appended: ContextMessage[] = [];
+
+ get(): readonly ContextMessage[] {
+ return this.history;
+ }
+
+ append(...messages: readonly ContextMessage[]): void {
+ this.appended.push(...messages);
+ }
+
+ appendLoopEvent(_event: LoopRecordedEvent): void {
+ throw new Error('unused in this suite');
+ }
+
+ clear(): void {
+ this.history.length = 0;
+ this.appended.length = 0;
+ }
+
+ undo(): UndoCut {
+ throw new Error('unused in this suite');
+ }
+
+ applyCompaction(): never {
+ throw new Error('unused in this suite');
+ }
+
+ landAppended(): void {
+ this.history.push(...this.appended);
+ this.appended.length = 0;
+ }
+
+ landAnnouncement(content: string): void {
+ this.history.push({
+ role: 'user',
+ content: [{ type: 'text', text: `\n${content.trim()}\n` }],
+ toolCalls: [],
+ origin: { kind: 'system_trigger', name: LOADABLE_TOOLS_TRIGGER },
+ });
+ }
+}
+
+interface Harness {
+ readonly ix: TestInstantiationService;
+ readonly sut: IAgentToolSelectService;
+ readonly registry: IAgentToolRegistryService;
+ readonly contextMemory: FakeContextMemory;
+ readonly loop: FakeLoopService;
+ readonly eventBus: RecordingEventBus;
+}
+
+function registerSharedServices(
+ reg: ServiceRegistration,
+ contextMemory: FakeContextMemory,
+ loop: FakeLoopService,
+ eventBus: RecordingEventBus,
+): void {
+ reg.defineInstance(IEventBus, eventBus);
+ reg.defineInstance(IAgentLoopService, loop);
+ reg.defineInstance(IAgentContextMemoryService, contextMemory);
+ reg.definePartialInstance(IAgentProfileService, {
+ getModelCapabilities: () => capabilities,
+ isToolActive: (name: string) => activeToolNames === undefined || activeToolNames.has(name),
+ });
+ reg.definePartialInstance(IFlagService, {
+ enabled: (id: string) => (id === TOOL_SELECT_FLAG_ID ? flagEnabled : false),
+ });
+ reg.define(IAgentToolRegistryService, AgentToolRegistryService);
+ reg.define(IAgentToolSelectService, AgentToolSelectService);
+ reg.define(IAgentToolSelectAnnouncementsService, AgentToolSelectAnnouncementsService);
+ reg.define(IAgentSystemReminderService, AgentSystemReminderService);
+ registerLogServices(reg);
+}
+
+function mountAnnouncements(ix: TestInstantiationService): void {
+ ix.get(IAgentToolSelectAnnouncementsService);
+}
+
+function createHarness(): Harness {
+ const contextMemory = new FakeContextMemory();
+ const loop = new FakeLoopService();
+ const eventBus = new RecordingEventBus();
+ const ix = createServices(disposables, {
+ additionalServices: (reg) => {
+ registerSharedServices(reg, contextMemory, loop, eventBus);
+ reg.defineInstance(IAgentToolExecutorService, stubToolExecutor());
+ },
+ strict: true,
+ });
+ mountAnnouncements(ix);
+ return {
+ ix,
+ sut: ix.get(IAgentToolSelectService),
+ registry: ix.get(IAgentToolRegistryService),
+ contextMemory,
+ loop,
+ eventBus,
+ };
+}
+
+interface ExecutorHarness extends Harness {
+ readonly executor: IAgentToolExecutorService;
+}
+
+function createExecutorHarness(): ExecutorHarness {
+ const contextMemory = new FakeContextMemory();
+ const loop = new FakeLoopService();
+ const eventBus = new RecordingEventBus();
+ const ix = createServices(disposables, {
+ additionalServices: (reg) => {
+ registerSharedServices(reg, contextMemory, loop, eventBus);
+ reg.defineInstance(ITelemetryService, recordingTelemetry([]));
+ reg.define(IAgentToolExecutorService, AgentToolExecutorService);
+ registerToolResultTruncationServices(reg);
+ },
+ strict: true,
+ });
+ mountAnnouncements(ix);
+ return {
+ ix,
+ sut: ix.get(IAgentToolSelectService),
+ registry: ix.get(IAgentToolRegistryService),
+ executor: ix.get(IAgentToolExecutorService),
+ contextMemory,
+ loop,
+ eventBus,
+ };
+}
+
+function registerMcp(h: Harness, tool: StubMcpTool): void {
+ disposables.add(h.registry.register(tool, { source: 'mcp' }));
+}
+
+function registerBuiltin(h: Harness, tool: EchoTool): void {
+ disposables.add(h.registry.register(tool, { source: 'builtin' }));
+}
+
+async function announce(h: Harness, step = 1): Promise {
+ const before = h.contextMemory.appended.length;
+ await h.loop.hooks.beforeStep.run({
+ turnId: 1,
+ step,
+ signal: new AbortController().signal,
+ });
+ const announcement = h.contextMemory.appended
+ .slice(before)
+ .find(
+ (message) =>
+ message.origin?.kind === 'system_trigger' &&
+ message.origin.name === LOADABLE_TOOLS_TRIGGER,
+ );
+ h.contextMemory.landAppended();
+ if (announcement === undefined) return undefined;
+ return announcement.content
+ .map((part) => (part.type === 'text' ? part.text : ''))
+ .join('');
+}
+
+async function execute(
+ h: ExecutorHarness,
+ call: ToolCall,
+): Promise {
+ const results: ToolExecutionResult[] = [];
+ for await (const result of h.executor.execute([call], {
+ signal: new AbortController().signal,
+ turnId: 1,
+ })) {
+ results.push(result);
+ }
+ return results;
+}
+
+describe('AgentToolSelectService gate', () => {
+ it('opens only when select_tools capability, tool_use capability and flag are all on', () => {
+ flagEnabled = true;
+ const { sut } = createHarness();
+ expect(sut.enabled()).toBe(true);
+ });
+
+ it('stays closed without the select_tools capability', () => {
+ flagEnabled = true;
+ capabilities = makeCapabilities({ tool_use: true, select_tools: false });
+ const { sut } = createHarness();
+ expect(sut.enabled()).toBe(false);
+ });
+
+ it('stays closed without tool_use capability', () => {
+ flagEnabled = true;
+ capabilities = makeCapabilities({ tool_use: false, select_tools: true });
+ const { sut } = createHarness();
+ expect(sut.enabled()).toBe(false);
+ });
+
+ it('stays closed without the flag', () => {
+ flagEnabled = false;
+ const { sut } = createHarness();
+ expect(sut.enabled()).toBe(false);
+ });
+});
+
+describe('AgentToolSelectService S0 baseline (gate closed)', () => {
+ it('shapeTools returns the identical array when select_tools is absent', () => {
+ const h = createHarness();
+ registerBuiltin(h, new EchoTool());
+ registerMcp(h, new StubMcpTool(MCP_ALPHA));
+ const entries = h.registry.list();
+ expect(h.sut.shapeTools(entries)).toBe(entries);
+ });
+
+ it('shapeHistory returns the identical array when there is nothing to strip', () => {
+ const h = createHarness();
+ const messages: readonly ContextMessage[] = [userMessage('a'), userMessage('b')];
+ expect(h.sut.shapeHistory(messages)).toBe(messages);
+ });
+
+ it('shapeTools filters select_tools itself out of the view', () => {
+ const h = createHarness();
+ registerBuiltin(h, new EchoTool());
+ const selectTools = h.ix.createInstance(SelectToolsTool);
+ disposables.add(h.registry.register(selectTools, { source: 'builtin' }));
+ const shaped = h.sut.shapeTools(h.registry.list());
+ expect(shaped.map((entry) => entry.name)).toEqual(['Echo']);
+ expect(shaped.every((entry) => entry.deferred === undefined)).toBe(true);
+ });
+
+ it('shapeTools applies profile filtering and removes select_tools while the gate is closed', () => {
+ const h = createHarness();
+ registerBuiltin(h, new EchoTool());
+ registerMcp(h, new StubMcpTool(MCP_ALPHA));
+ const selectTools = h.ix.createInstance(SelectToolsTool);
+ disposables.add(h.registry.register(selectTools, { source: 'builtin' }));
+ activeToolNames = new Set(['Echo']);
+
+ const shaped = h.sut.shapeTools(h.registry.list());
+ expect(shaped.map((entry) => entry.name)).toEqual(['Echo']);
+ });
+
+ it('select_tools execution self-guards while the gate is closed', async () => {
+ const h = createHarness();
+ const selectTools = h.ix.createInstance(SelectToolsTool);
+ const execution = selectTools.resolveExecution({ names: [MCP_ALPHA] });
+ expect(execution.isError).toBeUndefined();
+ if (execution.isError === true) throw new Error('expected a runnable execution');
+ const result = await execution.execute({
+ turnId: 1,
+ toolCallId: 'call-1',
+ signal: new AbortController().signal,
+ });
+ expect(result).toEqual({
+ output: 'select_tools is not available for the current model.',
+ isError: true,
+ });
+ });
+
+ it('shapeHistory strips dynamic-tool protocol context without touching the canonical history', () => {
+ const h = createHarness();
+ h.contextMemory.landAnnouncement('\nt\n');
+ h.contextMemory.history.push(schemaMessage('t'), userMessage('keep'));
+ const shaped = h.sut.shapeHistory(h.contextMemory.get());
+ expect(shaped.map((message) => message.role)).toEqual(['user']);
+ expect(h.contextMemory.get()).toHaveLength(3);
+ });
+
+ it('missing-tool wording falls back to the default message', async () => {
+ const h = createExecutorHarness();
+ const results = await execute(h, toolCall('call-1', MCP_GONE));
+ expect(results).toHaveLength(1);
+ expect(results[0]!.result.output).toBe(`Tool "${MCP_GONE}" not found`);
+ expect(results[0]!.result.isError).toBe(true);
+ });
+});
+
+describe('AgentToolSelectService view shaping (gate open)', () => {
+ beforeEach(() => {
+ flagEnabled = true;
+ });
+
+ it('hides unloaded MCP tools, marks loaded MCP tools deferred, keeps builtins and select_tools', () => {
+ const h = createHarness();
+ registerBuiltin(h, new EchoTool());
+ registerMcp(h, new StubMcpTool(MCP_ALPHA));
+ registerMcp(h, new StubMcpTool(MCP_BETA));
+ const selectTools = h.ix.createInstance(SelectToolsTool);
+ disposables.add(h.registry.register(selectTools, { source: 'builtin' }));
+ h.contextMemory.history.push(schemaMessage(MCP_ALPHA));
+
+ const shaped = h.sut.shapeTools(h.registry.list());
+ expect(shaped.map((entry) => entry.name)).toEqual(['Echo', MCP_ALPHA, SELECT_TOOLS_TOOL_NAME]);
+ const byName = new Map(shaped.map((entry) => [entry.name, entry]));
+ expect(byName.get(MCP_ALPHA)?.deferred).toBe(true);
+ expect(byName.get('Echo')?.deferred).toBeUndefined();
+ expect(byName.get(SELECT_TOOLS_TOOL_NAME)?.deferred).toBeUndefined();
+ });
+
+ it('keeps select_tools visible when the profile omits it while hiding inactive tools', () => {
+ const h = createHarness();
+ registerBuiltin(h, new EchoTool());
+ registerMcp(h, new StubMcpTool(MCP_ALPHA));
+ const selectTools = h.ix.createInstance(SelectToolsTool);
+ disposables.add(h.registry.register(selectTools, { source: 'builtin' }));
+ h.contextMemory.history.push(schemaMessage(MCP_ALPHA));
+ activeToolNames = new Set([MCP_ALPHA]);
+
+ const shaped = h.sut.shapeTools(h.registry.list());
+ expect(shaped.map((entry) => entry.name)).toEqual([
+ MCP_ALPHA,
+ SELECT_TOOLS_TOOL_NAME,
+ ]);
+ });
+
+ it('shapeHistory returns the identical array', () => {
+ const h = createHarness();
+ h.contextMemory.history.push(userMessage('a'), schemaMessage(MCP_ALPHA));
+ const messages = h.contextMemory.get();
+ expect(h.sut.shapeHistory(messages)).toBe(messages);
+ });
+
+ it('shapeHistory removes loaded schemas when the profile disables them', () => {
+ const h = createHarness();
+ h.contextMemory.history.push(schemaMessage(MCP_ALPHA, MCP_BETA), userMessage('keep'));
+ activeToolNames = new Set([MCP_BETA]);
+
+ const shaped = h.sut.shapeHistory(h.contextMemory.get());
+
+ expect(shaped).toHaveLength(2);
+ expect(shaped[0]!.tools?.map((tool) => tool.name)).toEqual([MCP_BETA]);
+ expect(h.contextMemory.get()[0]!.tools?.map((tool) => tool.name)).toEqual([
+ MCP_ALPHA,
+ MCP_BETA,
+ ]);
+ });
+});
+
+describe('AgentToolSelectService.load', () => {
+ beforeEach(() => {
+ flagEnabled = true;
+ });
+
+ it('settles per name: toLoad, alreadyAvailable, unknown', () => {
+ const h = createHarness();
+ registerMcp(h, new StubMcpTool(MCP_ALPHA));
+ registerMcp(h, new StubMcpTool(MCP_BETA));
+ h.contextMemory.history.push(schemaMessage(MCP_ALPHA));
+
+ const result = h.sut.load([MCP_BETA, MCP_ALPHA, MCP_GONE]);
+ expect(result.toLoad).toEqual([MCP_BETA]);
+ expect(result.alreadyAvailable).toEqual([MCP_ALPHA]);
+ expect(result.unknown).toEqual([MCP_GONE]);
+
+ expect(h.contextMemory.appended).toHaveLength(1);
+ const appended = h.contextMemory.appended[0]!;
+ expect(appended.role).toBe('system');
+ expect(appended.tools?.map((tool) => tool.name)).toEqual([MCP_BETA]);
+ expect(appended.origin).toEqual({ kind: 'injection', variant: DYNAMIC_TOOL_SCHEMA_VARIANT });
+ });
+
+ it('sorts the injected schemas by name', () => {
+ const h = createHarness();
+ registerMcp(h, new StubMcpTool(MCP_BETA));
+ registerMcp(h, new StubMcpTool(MCP_ALPHA));
+
+ h.sut.load([MCP_BETA, MCP_ALPHA]);
+ expect(h.contextMemory.appended[0]!.tools?.map((tool) => tool.name)).toEqual([
+ MCP_ALPHA,
+ MCP_BETA,
+ ]);
+ });
+
+ it('reports names filtered out by the profile as unknown', () => {
+ const h = createHarness();
+ registerMcp(h, new StubMcpTool(MCP_ALPHA));
+ registerMcp(h, new StubMcpTool(MCP_BETA));
+ activeToolNames = new Set([MCP_ALPHA]);
+
+ const result = h.sut.load([MCP_ALPHA, MCP_BETA]);
+ expect(result.toLoad).toEqual([MCP_ALPHA]);
+ expect(result.unknown).toEqual([MCP_BETA]);
+ });
+
+ it('pending ledger leads the history inside the defer window', () => {
+ const h = createHarness();
+ registerMcp(h, new StubMcpTool(MCP_ALPHA));
+
+ h.sut.load([MCP_ALPHA]);
+ expect(h.contextMemory.get().some((message) => message.tools !== undefined)).toBe(false);
+ const reselect = h.sut.load([MCP_ALPHA]);
+ expect(reselect.alreadyAvailable).toEqual([MCP_ALPHA]);
+ expect(reselect.toLoad).toEqual([]);
+
+ h.contextMemory.landAppended();
+ const afterLanding = h.sut.load([MCP_ALPHA]);
+ expect(afterLanding.alreadyAvailable).toEqual([MCP_ALPHA]);
+ });
+
+ it('clears the pending ledger after compaction completes', () => {
+ const h = createHarness();
+ registerMcp(h, new StubMcpTool(MCP_ALPHA));
+
+ h.sut.load([MCP_ALPHA]);
+ h.contextMemory.appended.length = 0;
+ h.eventBus.emit('compaction.completed');
+ expect(h.sut.load([MCP_ALPHA]).toLoad).toEqual([MCP_ALPHA]);
+ });
+
+ it('clears the pending ledger after a full-prefix context splice', () => {
+ const h = createHarness();
+ registerMcp(h, new StubMcpTool(MCP_ALPHA));
+
+ h.sut.load([MCP_ALPHA]);
+ h.contextMemory.appended.length = 0;
+ h.eventBus.emit('context.spliced', { start: 0, deleteCount: 2, messages: [] });
+ expect(h.sut.load([MCP_ALPHA]).toLoad).toEqual([MCP_ALPHA]);
+ });
+
+ it('reconciles the pending ledger with history when a mid-history splice removes schema messages', () => {
+ const h = createHarness();
+ registerMcp(h, new StubMcpTool(MCP_ALPHA));
+ registerMcp(h, new StubMcpTool(MCP_BETA));
+
+ h.sut.load([MCP_ALPHA]);
+ h.contextMemory.landAppended();
+ h.sut.load([MCP_BETA]);
+ h.contextMemory.landAppended();
+ expect(h.sut.load([MCP_ALPHA]).alreadyAvailable).toEqual([MCP_ALPHA]);
+ expect(h.sut.load([MCP_BETA]).alreadyAvailable).toEqual([MCP_BETA]);
+
+ // Undo-style rewrite (v2's undo slices the tail wholesale): beta's schema
+ // message is gone while alpha's survives; the event is published after the
+ // memory service has rewritten history.
+ h.contextMemory.history.splice(1, 1);
+ h.eventBus.emit('context.spliced', { start: 1, deleteCount: 2, messages: [] });
+
+ expect(h.sut.load([MCP_ALPHA]).alreadyAvailable).toEqual([MCP_ALPHA]);
+ expect(h.sut.load([MCP_BETA]).toLoad).toEqual([MCP_BETA]);
+ });
+
+ it('keeps the pending ledger across tail appends', () => {
+ const h = createHarness();
+ registerMcp(h, new StubMcpTool(MCP_ALPHA));
+
+ h.sut.load([MCP_ALPHA]);
+ h.eventBus.emit('context.spliced', { start: 3, deleteCount: 0, messages: [userMessage('x')] });
+ expect(h.sut.load([MCP_ALPHA]).alreadyAvailable).toEqual([MCP_ALPHA]);
+ });
+
+ it('renders the select_tools tool output per name for mixed load results', async () => {
+ const h = createHarness();
+ registerMcp(h, new StubMcpTool(MCP_ALPHA));
+ registerMcp(h, new StubMcpTool(MCP_BETA));
+ h.contextMemory.history.push(schemaMessage(MCP_ALPHA));
+ const selectTools = h.ix.createInstance(SelectToolsTool);
+ const ctx = { turnId: 1, toolCallId: 'call-1', signal: new AbortController().signal };
+
+ const mixed = selectTools.resolveExecution({ names: [MCP_BETA, MCP_ALPHA, MCP_GONE] });
+ if (mixed.isError === true) throw new Error('expected a runnable execution');
+ expect(await mixed.execute(ctx)).toEqual({
+ output: [
+ `Loaded: ${MCP_BETA}`,
+ `Already available: ${MCP_ALPHA}`,
+ `Unknown tool: ${MCP_GONE}. Pick from the latest announced tools list.`,
+ ].join('\n'),
+ });
+ });
+
+ it('returns an error when select_tools only receives unknown names', async () => {
+ const h = createHarness();
+ const selectTools = h.ix.createInstance(SelectToolsTool);
+ const ctx = { turnId: 1, toolCallId: 'call-1', signal: new AbortController().signal };
+ const unknownOnly = selectTools.resolveExecution({ names: [MCP_GONE] });
+ if (unknownOnly.isError === true) throw new Error('expected a runnable execution');
+ expect(await unknownOnly.execute(ctx)).toEqual({
+ output: `Unknown tool: ${MCP_GONE}. Pick from the latest announced tools list.`,
+ isError: true,
+ });
+ });
+});
+
+describe('AgentToolSelectService executor interception', () => {
+ beforeEach(() => {
+ flagEnabled = true;
+ });
+
+ it('the executor settles the intercepted call without running the tool', async () => {
+ const h = createExecutorHarness();
+ const alpha = new StubMcpTool(MCP_ALPHA);
+ registerMcp(h, alpha);
+
+ const results = await execute(h, toolCall('call-1', MCP_ALPHA));
+ expect(results).toHaveLength(1);
+ expect(results[0]!.result.isError).toBe(true);
+ expect(results[0]!.result.output).toContain('is available but not loaded');
+ expect(alpha.calls).toBe(0);
+ });
+
+ it('the executor returns loading guidance before validating args for an unloaded MCP tool', async () => {
+ const h = createExecutorHarness();
+ const alpha = new StubMcpTool(MCP_ALPHA, 'mcp ok', REQUIRED_PAYLOAD_PARAMETERS);
+ registerMcp(h, alpha);
+
+ const results = await execute(h, toolCall('call-1', MCP_ALPHA, { unexpected: true }));
+ expect(results).toHaveLength(1);
+ expect(results[0]!.result).toEqual({
+ output:
+ `Tool "${MCP_ALPHA}" is available but not loaded. ` +
+ `Call select_tools with ["${MCP_ALPHA}"] first, then call the tool.`,
+ isError: true,
+ });
+ expect(alpha.calls).toBe(0);
+ });
+
+ it('the executor runs the tool once its schema is loaded', async () => {
+ const h = createExecutorHarness();
+ const alpha = new StubMcpTool(MCP_ALPHA);
+ registerMcp(h, alpha);
+ h.contextMemory.history.push(schemaMessage(MCP_ALPHA));
+
+ const results = await execute(h, toolCall('call-1', MCP_ALPHA));
+ expect(results).toHaveLength(1);
+ expect(results[0]!.result.output).toBe('mcp ok');
+ expect(alpha.calls).toBe(1);
+ });
+
+ it('the executor rejects a loaded MCP tool when the profile disables it', async () => {
+ const h = createExecutorHarness();
+ const alpha = new StubMcpTool(MCP_ALPHA);
+ registerMcp(h, alpha);
+ h.contextMemory.history.push(schemaMessage(MCP_ALPHA));
+ activeToolNames = new Set([]);
+
+ const results = await execute(h, toolCall('call-1', MCP_ALPHA));
+
+ expect(results).toHaveLength(1);
+ expect(results[0]!.result).toEqual({
+ output:
+ `Tool "${MCP_ALPHA}" was loaded but is no longer active. Ask the user to enable it before calling it again.`,
+ isError: true,
+ });
+ expect(alpha.calls).toBe(0);
+ });
+
+ it('the executor runs non-MCP tools without loading', async () => {
+ const h = createExecutorHarness();
+ const echo = new EchoTool();
+ registerBuiltin(h, echo);
+
+ const results = await execute(h, toolCall('call-1', 'Echo'));
+ expect(results).toHaveLength(1);
+ expect(results[0]!.result.output).toBe('echo ok');
+ expect(echo.calls).toBe(1);
+ });
+});
+
+describe('AgentToolSelectService missing tool wording', () => {
+ beforeEach(() => {
+ flagEnabled = true;
+ });
+
+ it('tells a loaded-but-disconnected MCP tool apart from an unknown name', async () => {
+ const h = createExecutorHarness();
+ h.contextMemory.history.push(schemaMessage(MCP_GONE));
+
+ const results = await execute(h, toolCall('call-1', MCP_GONE));
+ expect(results).toHaveLength(1);
+ expect(results[0]!.result.isError).toBe(true);
+ expect(results[0]!.result.output).toBe(
+ `Tool "${MCP_GONE}" was loaded but its MCP server is currently disconnected. ` +
+ 'It may become available again when the server reconnects; do not retry immediately.',
+ );
+ });
+
+ it('keeps the default message for a name that was never loaded', async () => {
+ const h = createExecutorHarness();
+ const results = await execute(h, toolCall('call-1', MCP_GONE));
+ expect(results[0]!.result.output).toBe(`Tool "${MCP_GONE}" not found`);
+ });
+});
+
+describe('AgentToolSelectService loadable-tools announcements', () => {
+ beforeEach(() => {
+ flagEnabled = true;
+ });
+
+ it('announces the full loadable set on first run, then stays silent while unchanged', async () => {
+ const h = createHarness();
+ registerMcp(h, new StubMcpTool(MCP_BETA));
+ registerMcp(h, new StubMcpTool(MCP_ALPHA));
+
+ const first = await announce(h);
+ expect(first).toContain(`\n${MCP_ALPHA}\n${MCP_BETA}\n`);
+ expect(first).not.toContain('');
+
+ expect(await announce(h, 2)).toBeUndefined();
+ });
+
+ it('waits until the next boundary before announcing registry diffs', async () => {
+ const h = createHarness();
+ registerMcp(h, new StubMcpTool(MCP_ALPHA));
+ await announce(h);
+
+ registerMcp(h, new StubMcpTool(MCP_GAMMA));
+ expect(await announce(h, 2)).toBeUndefined();
+
+ h.eventBus.emit('turn.started');
+ const diff = await announce(h);
+ expect(diff).toContain(`\n${MCP_GAMMA}\n`);
+ });
+
+ it('diffs registry additions and removals against the folded announcements', async () => {
+ const h = createHarness();
+ registerMcp(h, new StubMcpTool(MCP_ALPHA));
+ const betaRegistration = h.registry.register(new StubMcpTool(MCP_BETA), { source: 'mcp' });
+ disposables.add(betaRegistration);
+
+ await announce(h);
+
+ betaRegistration.dispose();
+ registerMcp(h, new StubMcpTool(MCP_GAMMA));
+ h.eventBus.emit('turn.started');
+
+ const diff = await announce(h);
+ expect(diff).toContain(`\n${MCP_GAMMA}\n`);
+ expect(diff).toContain(`\n${MCP_BETA}\n`);
+ });
+
+ it('re-announces the full set after compaction discards the history', async () => {
+ const h = createHarness();
+ registerMcp(h, new StubMcpTool(MCP_ALPHA));
+ registerMcp(h, new StubMcpTool(MCP_BETA));
+
+ await announce(h);
+ expect(await announce(h, 2)).toBeUndefined();
+
+ h.contextMemory.clear();
+ h.eventBus.emit('compaction.completed');
+
+ const reannounced = await announce(h, 2);
+ expect(reannounced).toContain(`\n${MCP_ALPHA}\n${MCP_BETA}\n`);
+ });
+
+ it('announces only profile-active tools', async () => {
+ const h = createHarness();
+ registerMcp(h, new StubMcpTool(MCP_ALPHA));
+ registerMcp(h, new StubMcpTool(MCP_BETA));
+ activeToolNames = new Set([MCP_BETA]);
+
+ const first = await announce(h);
+ expect(first).toContain(`\n${MCP_BETA}\n`);
+ expect(first).not.toContain(MCP_ALPHA);
+ });
+
+ it('stays silent while the gate is closed', async () => {
+ flagEnabled = false;
+ const h = createHarness();
+ registerMcp(h, new StubMcpTool(MCP_ALPHA));
+ expect(await announce(h)).toBeUndefined();
+ });
+});
diff --git a/packages/agent-core-v2/test/turn/stubs.ts b/packages/agent-core-v2/test/turn/stubs.ts
index 8646ffc7c..f085a29bb 100644
--- a/packages/agent-core-v2/test/turn/stubs.ts
+++ b/packages/agent-core-v2/test/turn/stubs.ts
@@ -149,5 +149,7 @@ export function stubToolExecutor(): IAgentToolExecutorService {
'onWillExecuteTool',
'onDidExecuteTool',
]) as IAgentToolExecutorService['hooks'],
+ registerUnavailableToolDescriber: () => ({ dispose: () => {} }),
+ registerMissingToolDescriber: () => ({ dispose: () => {} }),
};
}