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