| import { Service } from '#/_base/di/service'; |
| import { LifecycleScope } from '#/app/scopes'; |
| import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; |
| import { defineState } from '#/state/state'; |
| import { IEventBus } from '#/app/event/eventBus'; |
| import { IFlagService } from '#/app/flag/flag'; |
| import type { ToolDescription as Tool } from '#human/llm/message'; |
| import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; |
| import { ContextSpliced } from '#/agent/contextMemory/contextEvents'; |
| import type { ContextMessage } from '#/agent/contextMemory/types'; |
| import { CompactionCompleted } from '#/agent/fullCompaction/compactionOps'; |
| import { IAgentProfileService } from '#/agent/profile/profile'; |
| import { IAgentStateService } from '#/agent/state/agentState'; |
| import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; |
| import { isMcpToolName, type ToolInfo } from '#/tool/toolContract'; |
| import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; |
| import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; |
|
|
| import { |
| collectLoadedDynamicToolNames, |
| 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 const toolSelectPendingLoadedKey = defineState<Set<string>>( |
| 'toolSelect.pendingLoaded', |
| () => new Set(), |
| ); |
|
|
| export class AgentToolSelectService extends Service implements IAgentToolSelectService { |
| declare readonly _serviceBrand: undefined; |
|
|
| constructor( |
| @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, |
| @IAgentProfileService private readonly profile: IAgentProfileService, |
| @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, |
| @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, |
| @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, |
| @IFlagService private readonly flags: IFlagService, |
| @IEventBus eventBus: IEventBus, |
| @IAgentStateService private readonly states: IAgentStateService, |
| ) { |
| super(); |
| this.states.contributeState(toolSelectPendingLoadedKey); |
| this._register( |
| toolExecutor.registerUnavailableToolDescriber((name) => this.describeUnavailableTool(name)), |
| ); |
| this._register( |
| toolExecutor.registerMissingToolDescriber((name) => this.describeMissingTool(name)), |
| ); |
| this._register( |
| eventBus.subscribe(CompactionCompleted, () => { |
| this.pendingLoaded.clear(); |
| }), |
| ); |
| this._register( |
| eventBus.subscribe(ContextSpliced, (splice) => { |
| if (splice.deleteCount === 0 || splice.messages.length > 0) return; |
| this.dropPendingLoadedNotLanded(); |
| }), |
| ); |
| } |
|
|
| private get pendingLoaded(): Set<string> { |
| return this.states.get(toolSelectPendingLoadedKey); |
| } |
|
|
| private dropPendingLoadedNotLanded(): void { |
| if (this.pendingLoaded.size === 0) return; |
| 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.dynamically_loaded_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 (!this.isDynamicallyLoadable(entry)) { |
| 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) { |
| for (const name of toLoad) this.pendingLoaded.add(name); |
| } |
| return { toLoad, alreadyAvailable, unknown }; |
| } |
|
|
| drainPendingToolSchemas(): readonly Tool[] | undefined { |
| if (!this.enabled() || this.pendingLoaded.size === 0) return undefined; |
| const names = [...this.pendingLoaded].toSorted((a, b) => a.localeCompare(b)); |
| const tools: Tool[] = []; |
| for (const name of names) { |
| const tool = this.schemaOf(name); |
| if (tool === undefined) continue; |
| this.pendingLoaded.delete(name); |
| tools.push(tool); |
| } |
| return tools.length === 0 ? undefined : tools; |
| } |
|
|
| 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 info = this.toolRegistry.list().find((entry) => entry.name === name); |
| if (info === undefined || !this.isDynamicallyLoadable(info)) return false; |
| if (!this.loadableToolNames().includes(name)) return false; |
| return !this.activeLoadedToolNames().has(name); |
| } |
|
|
| 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; |
| if (isMcpToolName(name)) { |
| return ( |
| `Tool "${name}" was loaded but its MCP server is currently disconnected. ` + |
| 'It may become available again when the server reconnects; do not retry immediately.' |
| ); |
| } |
| return ( |
| `Tool "${name}" was loaded but is no longer registered. ` + |
| 'Do not retry it unless it becomes available again.' |
| ); |
| } |
|
|
| private loadableToolNames(): string[] { |
| return this.toolRegistry |
| .list() |
| .filter( |
| (info) => |
| this.isDynamicallyLoadable(info) && |
| this.toolPolicy.isToolActive(info.name, info.source), |
| ) |
| .map((info) => info.name) |
| .toSorted((a, b) => a.localeCompare(b)); |
| } |
|
|
| private loadedToolNames(): Set<string> { |
| const names = collectLoadedDynamicToolNames(this.context.get()); |
| for (const name of this.pendingLoaded) names.add(name); |
| return names; |
| } |
|
|
| private activeLoadedToolNames(): Set<string> { |
| const names = this.loadedToolNames(); |
| for (const name of names) { |
| if (!this.isLoadedToolActive(name)) names.delete(name); |
| } |
| return names; |
| } |
|
|
| private isInactiveLoadedTool(name: string): boolean { |
| if (!this.enabled()) return false; |
| return this.loadedToolNames().has(name) && !this.isLoadedToolActive(name); |
| } |
|
|
| private isLoadedToolActive(name: string): boolean { |
| const info = this.toolRegistry.list().find((entry) => entry.name === name); |
| if (info !== undefined) { |
| return ( |
| this.isDynamicallyLoadable(info) && |
| this.toolPolicy.isToolActive(name, info.source) |
| ); |
| } |
| if (isMcpToolName(name)) return this.toolPolicy.isToolActive(name, 'mcp'); |
| return false; |
| } |
|
|
| private isDynamicallyLoadable(info: ToolInfo): boolean { |
| return info.disclosure === 'deferred'; |
| } |
|
|
| 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.toolPolicy.isToolActive(entry.name, entry.source) || |
| (disclosure && |
| entry.name === SELECT_TOOLS_TOOL_NAME && |
| this.toolPolicy.isToolActiveForDisclosure(entry.name, entry.source)); |
| 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, |
| ScopeActivation.OnScopeCreated, |
| 'toolSelect', |
| ); |
|
|