mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
feat: add progressive tool disclosure
This commit is contained in:
parent
643e585315
commit
9f1827ef10
32 changed files with 2206 additions and 50 deletions
5
.changeset/tool-select-progressive-disclosure.md
Normal file
5
.changeset/tool-select-progressive-disclosure.md
Normal 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.
|
||||
|
|
@ -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 |
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const DEFAULT_APPROVE_TOOLS = new Set([
|
|||
'GetGoal',
|
||||
'SetGoalBudget',
|
||||
'UpdateGoal',
|
||||
'select_tools',
|
||||
]);
|
||||
|
||||
export class DefaultToolApprovePermissionPolicyService implements PermissionPolicy {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
131
packages/agent-core-v2/src/agent/toolSelect/dynamicTools.ts
Normal file
131
packages/agent-core-v2/src/agent/toolSelect/dynamicTools.ts
Normal 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');
|
||||
}
|
||||
29
packages/agent-core-v2/src/agent/toolSelect/flag.ts
Normal file
29
packages/agent-core-v2/src/agent/toolSelect/flag.ts
Normal 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);
|
||||
39
packages/agent-core-v2/src/agent/toolSelect/toolSelect.ts
Normal file
39
packages/agent-core-v2/src/agent/toolSelect/toolSelect.ts
Normal 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');
|
||||
|
|
@ -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');
|
||||
|
|
@ -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',
|
||||
);
|
||||
301
packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts
Normal file
301
packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts
Normal 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',
|
||||
);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
145
packages/agent-core-v2/test/toolSelect/dynamicTools.test.ts
Normal file
145
packages/agent-core-v2/test/toolSelect/dynamicTools.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
219
packages/agent-core-v2/test/toolSelect/toolSelect.e2e.test.ts
Normal file
219
packages/agent-core-v2/test/toolSelect/toolSelect.e2e.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
869
packages/agent-core-v2/test/toolSelect/toolSelectService.test.ts
Normal file
869
packages/agent-core-v2/test/toolSelect/toolSelectService.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -149,5 +149,7 @@ export function stubToolExecutor(): IAgentToolExecutorService {
|
|||
'onWillExecuteTool',
|
||||
'onDidExecuteTool',
|
||||
]) as IAgentToolExecutorService['hooks'],
|
||||
registerUnavailableToolDescriber: () => ({ dispose: () => {} }),
|
||||
registerMissingToolDescriber: () => ({ dispose: () => {} }),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue