feat: add progressive tool disclosure

This commit is contained in:
7Sageer 2026-07-09 23:27:52 +08:00
parent 643e585315
commit 9f1827ef10
32 changed files with 2206 additions and 50 deletions

View file

@ -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.

View file

@ -91,6 +91,8 @@ package "Agent scope (per agent)" #FDF5E6 {
rectangle "<b>toolExecutor</b>\n<size:9><i>Agent</i></size>\n IAgentToolExecutorService" as toolExecutor #FDEBD0
rectangle "<b>toolResultTruncation</b>\n<size:9><i>Agent</i></size>\n IAgentToolResultTruncationService" as toolResultTruncation #FDEBD0
rectangle "<b>toolDedupe</b>\n<size:9><i>Agent</i></size>\n IAgentToolDedupeService" as toolDedupe #FDEBD0
rectangle "<b>toolSelect</b>\n<size:9><i>Agent</i></size>\n IAgentToolSelectService" as toolSelect #FDEBD0
rectangle "<b>toolSelectAnnouncements</b>\n<size:9><i>Agent</i></size>\n IAgentToolSelectAnnouncementsService" as toolSelectAnnouncements #FDEBD0
rectangle "<b>permissionGate</b>\n<size:9><i>Agent</i></size>\n IAgentPermissionGate" as permissionGate #FDEBD0
rectangle "<b>permissionMode</b>\n<size:9><i>Agent</i></size>\n IAgentPermissionModeService" as permissionMode #FDEBD0
rectangle "<b>permissionPolicy</b>\n<size:9><i>Agent</i></size>\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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 286 KiB

Before After
Before After

View file

@ -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],

View file

@ -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,
};
}

View file

@ -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<T extends Message>(
messages: readonly T[],
attempt: number,

View file

@ -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;

View file

@ -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<LLMRequestFinish> => {
@ -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,
}));
}
}

View file

@ -22,6 +22,7 @@ const DEFAULT_APPROVE_TOOLS = new Set([
'GetGoal',
'SetGoalBudget',
'UpdateGoal',
'select_tools',
]);
export class DefaultToolApprovePermissionPolicyService implements PermissionPolicy {

View file

@ -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

View file

@ -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

View file

@ -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`

View file

@ -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<ToolWillExecuteContext>;
readonly onDidExecuteTool: OrderedHookSlot<ToolDidExecuteContext>;
};
/**
* 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 =

View file

@ -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<ToolDidExecuteContext>(),
};
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<ToolExecutionResult> {
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);

View file

@ -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: `<tools_added>/<tools_removed>` 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<string> {
const names = new Set<string>();
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 = /<tools_added>\n?([\s\S]*?)\n?<\/tools_added>/g;
const TOOLS_REMOVED_BLOCK = /<tools_removed>\n?([\s\S]*?)\n?<\/tools_removed>/g;
export function foldAnnouncedToolNames(history: readonly ContextMessage[]): Set<string> {
const announced = new Set<string>();
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(`<tools_added>\n${added.join('\n')}\n</tools_added>`);
}
if (removed.length > 0) {
sections.push(`<tools_removed>\n${removed.join('\n')}\n</tools_removed>`);
}
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');
}

View file

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

View file

@ -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<IAgentToolSelectService> =
createDecorator<IAgentToolSelectService>('agentToolSelectService');

View file

@ -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<IAgentToolSelectAnnouncementsService> =
createDecorator<IAgentToolSelectAnnouncementsService>('agentToolSelectAnnouncementsService');

View file

@ -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',
);

View file

@ -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<string>();
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<string> {
const names = collectLoadedDynamicToolNames(this.context.get());
for (const name of this.pendingLoaded) names.add(name);
return names;
}
private activeLoadedToolNames(): Set<string> {
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',
);

View file

@ -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<typeof SelectToolsInputSchema>;
const DESCRIPTION =
'Load one or more tools by name so you can call them. ' +
'All available tool names are listed in the <tools_added>/<tools_removed> 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<SelectToolsInput> {
readonly name = SELECT_TOOLS_TOOL_NAME;
readonly description: string = DESCRIPTION;
readonly parameters: Record<string, unknown> = 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);

View file

@ -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';

View file

@ -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) {

View file

@ -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 = '<system>Image compressed.</system>';
const result: ContextMessage = {

View file

@ -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<string, unknown>,
): ExecutableTool<Record<string, unknown>> {
return {
name,
description: `${name} desc`,
parameters,
resolveExecution(): ToolExecution {
return {
approvalRule: name,
execute: async () => ({ output: 'mcp ok' }),
};
},
};
}
function bashCall(): ToolCall {
return {
type: 'function',

View file

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

View file

@ -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')],

View file

@ -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<IAgentToolSelectService> = {
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);

View file

@ -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 = `<system-reminder>\n${renderLoadableToolsAnnouncement(added, removed).trim()}\n</system-reminder>`;
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: '<tools_added>\nmallory\n</tools_added>' }],
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: `<system-reminder>\n${renderLoadableToolsAnnouncement(['a'], [])}\n</system-reminder>`,
},
],
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('<tools_added>\na\n</tools_added>');
expect(addedOnly).not.toContain('<tools_removed>');
const removedOnly = renderLoadableToolsAnnouncement([], ['b']);
expect(removedOnly).toContain('<tools_removed>\nb\n</tools_removed>');
expect(removedOnly).not.toContain('<tools_added>');
});
});
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']);
});
});

View file

@ -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<Record<string, unknown>> {
readonly description: string;
readonly parameters: Record<string, unknown> = {
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('<tools_added>');
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');
});
});

View file

@ -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<string> | 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<Record<string, unknown>> {
readonly description: string;
calls = 0;
readonly parameters: Record<string, unknown>;
constructor(
readonly name: string,
private readonly output: string = 'mcp ok',
parameters?: Record<string, unknown>,
) {
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<Record<string, unknown>> {
readonly description = 'Echo input text.';
readonly parameters: Record<string, unknown> = { 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<string, Array<(event: DomainEvent) => 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<string, unknown> = {}): void {
this.publish({ type, ...payload } as DomainEvent);
}
}
class FakeLoopService implements IAgentLoopService {
readonly _serviceBrand = undefined;
readonly hooks: IAgentLoopService['hooks'] = {
beforeStep: new OrderedHookSlot<BeforeStepContext>(),
afterStep: new OrderedHookSlot<AfterStepContext>(),
onError: new OrderedHookSlot<LoopErrorContext>(),
};
run(_options: LoopRunOptions): Promise<LoopRunResult> {
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: `<system-reminder>\n${content.trim()}\n</system-reminder>` }],
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<string | undefined> {
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<readonly ToolExecutionResult[]> {
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('<tools_added>\nt\n</tools_added>');
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(`<tools_added>\n${MCP_ALPHA}\n${MCP_BETA}\n</tools_added>`);
expect(first).not.toContain('<tools_removed>');
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(`<tools_added>\n${MCP_GAMMA}\n</tools_added>`);
});
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(`<tools_added>\n${MCP_GAMMA}\n</tools_added>`);
expect(diff).toContain(`<tools_removed>\n${MCP_BETA}\n</tools_removed>`);
});
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(`<tools_added>\n${MCP_ALPHA}\n${MCP_BETA}\n</tools_added>`);
});
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(`<tools_added>\n${MCP_BETA}\n</tools_added>`);
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();
});
});

View file

@ -149,5 +149,7 @@ export function stubToolExecutor(): IAgentToolExecutorService {
'onWillExecuteTool',
'onDidExecuteTool',
]) as IAgentToolExecutorService['hooks'],
registerUnavailableToolDescriber: () => ({ dispose: () => {} }),
registerMissingToolDescriber: () => ({ dispose: () => {} }),
};
}