import type { Agent, AgentTool } from "@oh-my-pi/pi-agent-core"; import type { Model } from "@oh-my-pi/pi-ai"; import { isRecord, logger, prompt, stringProperty } from "@oh-my-pi/pi-utils"; import { reset as resetCapabilities } from "../capability"; import type { ModelRegistry } from "../config/model-registry"; import { formatModelString } from "../config/model-resolver"; import type { Settings, SkillsSettings } from "../config/settings"; import type { CustomTool, CustomToolContext } from "../extensibility/custom-tools/types"; import { CustomToolAdapter } from "../extensibility/custom-tools/wrapper"; import type { ExtensionRunner } from "../extensibility/extensions"; import { ExtensionToolWrapper } from "../extensibility/extensions/wrapper"; import { loadSkills, type Skill, type SkillWarning, setActiveSkills } from "../extensibility/skills"; import { type LocalProtocolOptions, XD_URL_PREFIX } from "../internal-urls"; import { deduplicateMCPToolsByName } from "../mcp/tool-bridge"; import { resolveMemoryBackend } from "../memory-backend/resolve"; import { MEMORY_BACKEND_TOOL_NAMES } from "../memory-backend/tool-names"; import type { MemoryBackendStartOptions } from "../memory-backend/types"; import xdevMountNoticePrompt from "../prompts/system/xdev-mount-notice.md" with { type: "text" }; import { usesCodexTaskPrompt } from "../task/prompt-policy"; import { isMCPToolName, normalizeToolNames } from "../tools/builtin-names"; import { computerExposureMode } from "../tools/computer/exposure"; import { wrapToolWithMetaNotice } from "../tools/output-meta"; import { ToolAbortError, ToolError } from "../tools/tool-errors"; import { isMountableUnderXdev, listXdevTools, type XdevState, xdevDocsFor, xdevEntries } from "../tools/xdev"; import { type EditMode, resolveEditMode } from "../utils/edit-mode"; import { type InspectImageMode, isInspectImageToolActive } from "../utils/inspect-image-mode"; import { formatLocalCalendarDate } from "../utils/local-date"; import { extractPermissionLocations, getPermissionIntent, PERMISSION_OPTIONS, PERMISSION_OPTIONS_BY_ID, PERMISSION_REQUIRED_TOOLS, } from "./acp-permission-gate"; import type { ClientBridge, ClientBridgePermissionOutcome } from "./client-bridge"; import type { CustomMessage } from "./messages"; import type { SessionManager } from "./session-manager"; /** Capabilities borrowed from the owning AgentSession. */ export interface SessionToolsHost { agent: Agent; sessionManager: SessionManager; settings: Settings; modelRegistry: ModelRegistry; extensionRunner(): ExtensionRunner | undefined; clientBridge(): ClientBridge | undefined; agentKind(): "main" | "sub"; isDisposed(): boolean; isStreaming(): boolean; queuedMessageCount(): number; planModeEnabled(): boolean; model(): Model | undefined; memoryBackendSession(): MemoryBackendStartOptions["session"]; clearInheritedProviderPromptCacheKey(): void; clearMemoryPromotionSnapshot(): void; captureMemoryPromotionSnapshot(prompt: string[]): void; emitNotice(level: "info" | "warning" | "error", message: string, source?: string): void; notifyCommandMetadataChanged(): void; localProtocolOptions(): LocalProtocolOptions; /** Session-scoped `/vision` override; undefined means "follow the persisted setting". */ getInspectImageModeOverride(): InspectImageMode | undefined; setInspectImageModeOverride(mode: InspectImageMode | undefined): void; } interface SessionToolsOptions { autoApprove?: boolean; toolRegistry?: Map; createVibeTools?: () => AgentTool[]; createComputerTool?: () => Promise; /** Creates the built-in `inspect_image` tool for session-scoped runtime enablement (see {@link SessionTools.setInspectImageMode}). */ createInspectImageTool?: () => Promise; builtInToolNames?: Iterable; presentationPinnedToolNames?: ReadonlySet; ensureWriteRegistered?: () => Promise; rebuildSystemPrompt?: ( toolNames: string[], tools: Map, ) => Promise<{ systemPrompt: string[]; xdevCatalogNames?: readonly string[] }>; getLocalCalendarDate?: () => string; getMcpServerInstructions?: () => Map | undefined; xdev?: XdevState; setActiveToolNames?: (names: Iterable) => void; baseSystemPrompt: string[]; skills?: Skill[]; skillWarnings?: SkillWarning[]; skillsSettings?: SkillsSettings; skillsReloadable?: boolean; } export interface MountedMCPToolRouteSource { readonly name: string; readonly mcpServerName?: unknown; readonly mcpToolName?: unknown; } export interface MountedMCPToolRoute { readonly mcpServerName: string; readonly mcpToolName: string; readonly name: string; } export interface MCPXdevGuidanceMapping extends MountedMCPToolRoute { readonly label: string; readonly path: string; } export interface MCPXdevGuidanceProjection { readonly mappings: readonly MCPXdevGuidanceMapping[]; readonly hasOmittedMappings: boolean; } const MAX_MCP_XDEV_GUIDANCE_MAPPING_DATA_LENGTH = 4000; const MAX_MCP_XDEV_GUIDANCE_MAPPINGS = 64; /** Yield exact mounted MCP ownership and route metadata. */ export function* collectMountedMCPToolRoutes( tools: Iterable, ): Generator { for (const tool of tools) { if (typeof tool.mcpServerName !== "string" || typeof tool.mcpToolName !== "string") continue; yield { mcpServerName: tool.mcpServerName, mcpToolName: tool.mcpToolName, name: tool.name, }; } } function formatMCPXdevGuidanceLabel(label: string): string { return (JSON.stringify(label) ?? '""') .replaceAll("`", "\\u0060") .replaceAll("\u2028", "\\u2028") .replaceAll("\u2029", "\\u2029"); } /** * Project exact live MCP routes into the bounded, Markdown-safe mapping data * rendered by the static MCP guidance prompt. */ export function projectMountedMCPXdevGuidance(routes: Iterable): MCPXdevGuidanceProjection { const mappings: MCPXdevGuidanceMapping[] = []; let remainingMappingDataLength = MAX_MCP_XDEV_GUIDANCE_MAPPING_DATA_LENGTH; let hasOmittedMappings = false; for (const route of routes) { const rawMappingDataLength = route.mcpToolName.length + XD_URL_PREFIX.length + route.name.length; if (mappings.length >= MAX_MCP_XDEV_GUIDANCE_MAPPINGS || rawMappingDataLength > remainingMappingDataLength) { hasOmittedMappings = true; continue; } const label = formatMCPXdevGuidanceLabel(route.mcpToolName); const path = `${XD_URL_PREFIX}${route.name}`; const mappingDataLength = label.length + path.length; if (mappingDataLength > remainingMappingDataLength) { hasOmittedMappings = true; continue; } mappings.push({ ...route, label, path }); remainingMappingDataLength -= mappingDataLength; } return { mappings, hasOmittedMappings }; } const XDEV_MOUNT_NOTICE_MESSAGE_TYPE = "xdev-mount-notice"; /** * Structured payload persisted on each {@link XDEV_MOUNT_NOTICE_MESSAGE_TYPE} * custom message. Lets a resumed session reconstruct which dynamic devices the * model has already been told about, so reconnecting hosts do not re-announce * (and re-splice a redundant developer message that busts the provider * prompt-cache prefix). */ interface XdevMountNoticeDetails { added: string[]; removed: string[]; } /** Owns tool registration, presentation, prompt rebuilding, skills, and permissions. */ export class SessionTools { readonly #host: SessionToolsHost; #autoApprove: boolean; #toolRegistry: Map; #createVibeTools: (() => AgentTool[]) | undefined; #createComputerTool: SessionToolsOptions["createComputerTool"]; #createInspectImageTool: SessionToolsOptions["createInspectImageTool"]; #installedVibeToolNames = new Set(); #builtInToolNames: Set; #rpcHostToolNames = new Set(); #xdev: XdevState | undefined; #pendingXdevMountDelta: { added: Set; removed: Set } | undefined; /** * Dynamic (`xd://`) devices the model has already been told are mounted. * Seeded lazily from persisted history on resume (see * {@link #ensureAnnouncedMountsSeeded}) and updated as notices are emitted, so * a host reconnect that re-mounts the same device does not re-announce it. */ #announcedMounts = new Set(); #announcedMountsSeeded = false; #presentationPinnedToolNames: ReadonlySet | undefined; #runtimeSelectedToolNames: ReadonlySet | undefined; #baseSystemPrompt: string[]; #lastAppliedToolSignature: string | undefined; /** * `xd://` device names the current base system prompt renders in its catalog * (the last rebuild's {@link BuildSystemPromptResult.xdevCatalogNames}). Consulted * when a pending mount notice is delivered: a device the outgoing prompt already * lists is recorded as announced without a redundant notice line. Empty when the * prompt carries no catalog (no mounts, or a custom prompt that omits the section). */ #basePromptXdevNames: ReadonlySet = new Set(); #mcpRefreshTail: Promise = Promise.resolve(); #promptModelKey: string | undefined; #rebuildSystemPrompt: SessionToolsOptions["rebuildSystemPrompt"]; #getLocalCalendarDate: () => string; #getMcpServerInstructions: SessionToolsOptions["getMcpServerInstructions"]; #setActiveToolNames: SessionToolsOptions["setActiveToolNames"]; #ensureWriteRegistered: SessionToolsOptions["ensureWriteRegistered"]; #skills: Skill[]; #skillWarnings: SkillWarning[]; #skillsSettings: SkillsSettings | undefined; #skillsReloadable: boolean; #acpPermissionDecisions = new Map(); constructor(host: SessionToolsHost, options: SessionToolsOptions) { this.#host = host; this.#autoApprove = options.autoApprove === true; this.#toolRegistry = options.toolRegistry ?? new Map(); this.#createVibeTools = options.createVibeTools; this.#createComputerTool = options.createComputerTool; this.#createInspectImageTool = options.createInspectImageTool; this.#builtInToolNames = new Set(options.builtInToolNames ?? []); this.#presentationPinnedToolNames = options.presentationPinnedToolNames; this.#ensureWriteRegistered = options.ensureWriteRegistered; this.#rebuildSystemPrompt = options.rebuildSystemPrompt; this.#getLocalCalendarDate = options.getLocalCalendarDate ?? formatLocalCalendarDate; this.#getMcpServerInstructions = options.getMcpServerInstructions; this.#xdev = options.xdev; if (this.#xdev && this.#xdev.tools !== this.#toolRegistry) { throw new Error("xd:// state must reference the canonical session tool map"); } if (this.#xdev) this.#xdev.decorateExecution = tool => this.#wrapToolForAcpPermission(tool); this.#setActiveToolNames = options.setActiveToolNames; this.#baseSystemPrompt = options.baseSystemPrompt; this.#skills = options.skills ?? []; this.#skillWarnings = options.skillWarnings ?? []; this.#skillsSettings = options.skillsSettings; this.#skillsReloadable = options.skillsReloadable ?? true; this.#promptModelKey = this.#currentPromptModelKey(); } /** Mutable registry shared with controller hosts that inspect available tools. */ get registry(): Map { return this.#toolRegistry; } /** Current stable base system prompt. */ get baseSystemPrompt(): string[] { return this.#baseSystemPrompt; } /** Replaces the controller-owned base prompt without applying it to the agent. */ setBaseSystemPrompt(prompt: string[]): void { this.#baseSystemPrompt = prompt; } /** Skills currently rendered into the system prompt. */ get skills(): Skill[] { return this.#skills; } /** Diagnostics produced while loading the current skills. */ get skillWarnings(): SkillWarning[] { return this.#skillWarnings; } /** Settings snapshot used for the current skill discovery. */ get skillsSettings(): SkillsSettings | undefined { return this.#skillsSettings; } /** Drops cached per-session ACP `allow_always`/`reject_always` decisions. */ clearAcpPermissionDecisions(): void { this.#acpPermissionDecisions.clear(); } /** Drops cached ACP decisions and re-wraps active tools after the client changes. */ refreshAcpPermissionGates(): void { this.#acpPermissionDecisions.clear(); const activeTools = this.getActiveToolNames() .map(name => this.#toolRegistry.get(name)) .filter((tool): tool is AgentTool => tool !== undefined) .map(tool => this.#wrapToolForAcpPermission(tool)); this.#host.agent.setTools(activeTools); } #getActiveNonMCPToolNames(): string[] { return this.getEnabledToolNames().filter(name => !isMCPToolName(name) && this.#toolRegistry.has(name)); } /** Names of tools currently exposed at the top level. */ getActiveToolNames(): string[] { return this.#host.agent.state.tools.map(t => t.name); } /** Enabled top-level and discoverable tool names. */ getEnabledToolNames(): string[] { const mountedNames = this.#xdev?.mountedNames; if (!mountedNames || mountedNames.size === 0) return this.getActiveToolNames(); return [...this.getActiveToolNames(), ...mountedNames]; } /** Names currently presented as `xd://` devices. */ getMountedXdevToolNames(): string[] { return [...(this.#xdev?.mountedNames ?? [])]; } /** Whether the edit tool is registered. */ get hasEditTool(): boolean { return this.#toolRegistry.has("edit"); } /** Looks up a registered tool by name. */ getToolByName(name: string): AgentTool | undefined { return this.#toolRegistry.get(name); } /** Whether a registry entry came from a built-in factory. */ hasBuiltInTool(name: string): boolean { return this.#builtInToolNames.has(name); } /** Names of every registered tool. */ getAllToolNames(): string[] { return Array.from(this.#toolRegistry.keys()); } #wrapRuntimeTool(tool: AgentTool): AgentTool { const wrapped = wrapToolWithMetaNotice(tool); const extensionRunner = this.#host.extensionRunner(); return extensionRunner ? new ExtensionToolWrapper(wrapped, extensionRunner) : wrapped; } /** Installs and activates the ephemeral vibe tool set. */ async activateVibeTools(baseToolNames: string[]): Promise { const createVibeTools = this.#createVibeTools; if (!createVibeTools) { throw new Error("Vibe tools are unavailable in this session."); } const tools = createVibeTools(); const vibeToolNames = tools.map(tool => tool.name); if (new Set(vibeToolNames).size !== vibeToolNames.length) { throw new Error("Vibe tool names must be unique."); } for (const tool of tools) { if (this.#toolRegistry.has(tool.name)) continue; this.#toolRegistry.set(tool.name, this.#wrapRuntimeTool(tool)); this.#builtInToolNames.add(tool.name); this.#installedVibeToolNames.add(tool.name); } await this.applyActiveToolsByName([...new Set([...baseToolNames, ...vibeToolNames])]); } /** Uninstalls vibe tools and activates the replacement set. */ async deactivateVibeTools(nextToolNames: string[]): Promise { this.#uninstallVibeTools(); await this.applyActiveToolsByName(nextToolNames); } /** Removes vibe tools without restoring a source-session snapshot. */ async removeVibeToolsPreservingActive(): Promise { const removed = new Set(this.#installedVibeToolNames); this.#uninstallVibeTools(); const nextActive = this.getActiveToolNames().filter(name => !removed.has(name)); await this.applyActiveToolsByName(nextActive); } #uninstallVibeTools(): void { for (const name of this.#installedVibeToolNames) { this.#toolRegistry.delete(name); this.#builtInToolNames.delete(name); } this.#installedVibeToolNames.clear(); } #getEditModeSession() { return { settings: this.#host.settings, getActiveModelString: () => { const model = this.#host.model(); return model ? formatModelString(model) : undefined; }, } as const; } /** Resolves the edit mode for the active model and settings. */ resolveActiveEditMode(): EditMode { return resolveEditMode(this.#getEditModeSession()); } #currentPromptModelKey(): string | undefined { const activeModel = this.#host.model(); const model = activeModel ? formatModelString(activeModel) : undefined; if (!model || this.#host.settings.get("includeModelInPrompt")) return model; return usesCodexTaskPrompt(model) ? "task-policy:gpt-5.6" : "task-policy:default"; } #logComputerState(message: string, enabled: boolean): void { const model = this.#host.model(); logger.debug(message, { enabled, active: this.getEnabledToolNames().includes("computer"), model: model ? formatModelString(model) : undefined, exposure: computerExposureMode(model), }); } /** Rebuilds model-dependent tool prompts after a model change. */ async syncAfterModelChange(previousEditMode: EditMode): Promise { const currentEditMode = this.resolveActiveEditMode(); const editModeChanged = previousEditMode !== currentEditMode && this.getActiveToolNames().includes("edit"); // The system prompt selects model-specific policy even when it does not display the model id. const modelChanged = this.#currentPromptModelKey() !== this.#promptModelKey; if (editModeChanged || modelChanged) { await this.refreshBaseSystemPrompt(); } const computerExpected = this.#host.settings.get("computer.enabled"); const computerActive = this.getEnabledToolNames().includes("computer"); if (computerExpected && !computerActive) { const model = this.#host.model(); const modelName = model ? formatModelString(model) : "the current model"; logger.warn("Enabled computer tool missing after model change", { model: modelName }); this.#host.emitNotice( "warning", `Computer use remains enabled, but the computer tool is unavailable to ${modelName}.`, "computer", ); } else if (computerExpected) { this.#logComputerState("Computer tool retained after model change", true); } // inspect_image auto mode keys off model image capability, so a model // switch can flip the tool either way. await this.reconcileInspectImageAfterModelChange(); } /** Enabled MCP tools in their current presentation partition. */ getSelectedMCPToolNames(): string[] { // Every connected MCP tool is enabled; presentation (top-level vs xd://) is // decided by loadMode. Return the enabled MCP tools in the current set. return this.getEnabledToolNames().filter(name => isMCPToolName(name) && this.#toolRegistry.has(name)); } /** * Wrap a tool with a permission-gate proxy when an ACP client is connected. * Only wraps tools whose name is in PERMISSION_REQUIRED_TOOLS and only when * the bridge exposes `requestPermission`. No-ops for all other cases. * * When the user has explicitly opted into `yolo` / auto-approve behavior (via * the SDK/CLI `autoApprove` flag or a configured `tools.approvalMode: yolo`), * skips the gate unless the per-tool policy explicitly requires a prompt or * deny. The schema default is also `yolo`, so an explicit configuration or * explicit session flag is required: default-config ACP sessions keep the * client-side permission gate. */ #wrapToolForAcpPermission(tool: T): T { const bridge = this.#host.clientBridge(); // Match the capability+method gating pattern used by read/write/bash. if (!bridge?.capabilities.requestPermission || !bridge.requestPermission) return tool; if (PERMISSION_REQUIRED_TOOLS[tool.name] !== true) return tool; // Skip the gate only on explicit yolo opt-in; honour per-tool policies // that require a prompt or deny (matching the normal approval wrapper). if (this.#isExplicitAutoApproveMode()) { const userPolicies = (this.#host.settings.get("tools.approval") ?? {}) as Record; const toolPolicy = userPolicies[tool.name]; if (!toolPolicy || toolPolicy === "allow") return tool; } return new Proxy(tool, { get: (target, prop) => { if (prop !== "execute") return target[prop as keyof T]; return async ( toolCallId: string, args: unknown, signal: AbortSignal | undefined, onUpdate: never, ctx: never, ) => { const permissionIntent = getPermissionIntent(target.name, args); if (!permissionIntent) { return await target.execute(toolCallId, args as never, signal, onUpdate, ctx); } const command = target.name === "bash" && args && typeof args === "object" && !Array.isArray(args) ? stringProperty(args, "command") : undefined; const commandContent = command ? [{ type: "content" as const, content: { type: "text" as const, text: `$ ${command}` } }] : undefined; // Short-circuit on persisted decisions. const persisted = this.#acpPermissionDecisions.get(permissionIntent.cacheKey); if (persisted === "allow_always") { return await target.execute(toolCallId, args as never, signal, onUpdate, ctx); } if (persisted === "reject_always") { throw new ToolError(`Tool call rejected by user (preference)`); } if (signal?.aborted) { throw new ToolAbortError("Permission request cancelled"); } type PermissionRaceResult = | { kind: "permission"; outcome: ClientBridgePermissionOutcome } | { kind: "aborted" }; const { promise: abortPromise, resolve: resolveAbort } = Promise.withResolvers(); const onAbort = () => resolveAbort({ kind: "aborted" }); signal?.addEventListener("abort", onAbort, { once: true }); let raced: PermissionRaceResult; try { const permissionPromise = bridge.requestPermission!( { toolCallId, toolName: target.name, title: permissionIntent.title, ...(target.name === "bash" ? { kind: "execute" } : {}), status: "pending", rawInput: args, ...(commandContent ? { content: commandContent } : {}), locations: extractPermissionLocations( args, this.#host.sessionManager.getCwd(), permissionIntent.paths, ), }, PERMISSION_OPTIONS, signal, ).then(outcome => ({ kind: "permission" as const, outcome })); raced = await Promise.race([permissionPromise, abortPromise]); } finally { signal?.removeEventListener("abort", onAbort); } if (raced.kind === "aborted" || signal?.aborted) { throw new ToolAbortError("Permission request cancelled"); } const outcome = raced.outcome; if (outcome.outcome === "cancelled") { throw new ToolAbortError("Permission request cancelled"); } const selectedOption = PERMISSION_OPTIONS_BY_ID.get(outcome.optionId); if (!selectedOption) { throw new ToolError(`Tool permission response used unknown option ID: ${outcome.optionId}`); } if (selectedOption.kind === "allow_always") { this.#acpPermissionDecisions.set(permissionIntent.cacheKey, "allow_always"); } else if (selectedOption.kind === "reject_always") { this.#acpPermissionDecisions.set(permissionIntent.cacheKey, "reject_always"); } if (selectedOption.kind === "reject_once" || selectedOption.kind === "reject_always") { throw new ToolError(`Tool call rejected by user (${target.name})`); } return await target.execute(toolCallId, args as never, signal, onUpdate, ctx); }; }, }) as T; } #isExplicitAutoApproveMode(): boolean { return ( this.#autoApprove || (this.#host.settings.isConfigured("tools.approvalMode") && this.#host.settings.get("tools.approvalMode") === "yolo") ); } /** Applies an enabled tool set and reconciles its `xd://` partition. */ async applyActiveToolsByName(toolNames: string[]): Promise { toolNames = normalizeToolNames(toolNames); let builtInWriteAvailable = this.#builtInToolNames.has("write"); if (toolNames.includes("write") && !builtInWriteAvailable) { builtInWriteAvailable = (await this.#ensureWriteRegistered?.()) === true; if (builtInWriteAvailable) this.#builtInToolNames.add("write"); } const selectedTools = toolNames.flatMap(name => { const tool = this.#toolRegistry.get(name); return tool ? [{ name, tool }] : []; }); const xdevReadAvailable = this.#builtInToolNames.has("read") && selectedTools.some(({ name }) => name === "read"); const xdevWriteAvailable = builtInWriteAvailable && selectedTools.some(({ name }) => name === "write"); const isPresentationPinned = (name: string): boolean => this.#presentationPinnedToolNames?.has(name) === true || this.#runtimeSelectedToolNames?.has(name) === true; const mountCandidates = selectedTools.filter( ({ name, tool }) => this.#xdev !== undefined && xdevReadAvailable && xdevWriteAvailable && !isPresentationPinned(name) && isMountableUnderXdev(tool), ); const mountNames = new Set(mountCandidates.map(({ name }) => name)); const tools: AgentTool[] = []; const validToolNames: string[] = []; for (const { name, tool } of selectedTools) { if (mountNames.has(name)) continue; tools.push(this.#wrapToolForAcpPermission(tool)); validToolNames.push(name); } const pinnedWrite = isPresentationPinned("write"); const activeDeferrableTool = tools.some(tool => tool.deferrable === true); const transportNeeded = mountNames.size > 0 || activeDeferrableTool || this.#host.planModeEnabled(); if (transportNeeded && !builtInWriteAvailable) { builtInWriteAvailable = (await this.#ensureWriteRegistered?.()) === true; if (builtInWriteAvailable) this.#builtInToolNames.add("write"); } if (transportNeeded && builtInWriteAvailable) { const write = this.#toolRegistry.get("write"); if (write && !validToolNames.includes("write")) { tools.push(this.#wrapToolForAcpPermission(write)); validToolNames.push("write"); } } else if ( !pinnedWrite && (this.#presentationPinnedToolNames !== undefined || this.#runtimeSelectedToolNames !== undefined) ) { const writeNameIndex = validToolNames.indexOf("write"); if (writeNameIndex >= 0 && this.#builtInToolNames.has("write")) validToolNames.splice(writeNameIndex, 1); const writeToolIndex = tools.findIndex(tool => tool.name === "write" && this.#builtInToolNames.has("write")); if (writeToolIndex >= 0) tools.splice(writeToolIndex, 1); } const previousMounted = new Set(this.#xdev?.mountedNames ?? []); const previousActiveToolNames = this.getActiveToolNames(); this.#setMountedNames(mountNames); this.#setActiveToolNames?.(validToolNames); let rebuiltSystemPrompt: string[] | undefined; let rebuiltSignature: string | undefined; let rebuiltXdevCatalogNames: readonly string[] | undefined; try { if (this.#rebuildSystemPrompt) { const signature = this.#computeAppliedToolSignature(validToolNames, tools); if (signature !== this.#lastAppliedToolSignature) { const built = await this.#rebuildSystemPrompt(validToolNames, this.#toolRegistry); rebuiltSystemPrompt = built.systemPrompt; rebuiltSignature = signature; rebuiltXdevCatalogNames = built.xdevCatalogNames; } } } catch (error) { this.#setMountedNames(previousMounted); this.#setActiveToolNames?.(previousActiveToolNames); throw error; } if (this.#host.isDisposed()) { this.#setMountedNames(previousMounted); this.#setActiveToolNames?.(previousActiveToolNames); return; } this.#notifyXdevMountDelta(previousMounted); this.#host.agent.setTools(tools); if (rebuiltSystemPrompt && rebuiltSignature) { if (this.#lastAppliedToolSignature !== undefined) this.#host.clearInheritedProviderPromptCacheKey(); this.#baseSystemPrompt = rebuiltSystemPrompt; this.#host.clearMemoryPromotionSnapshot(); this.#host.agent.setSystemPrompt(this.#baseSystemPrompt); this.#lastAppliedToolSignature = rebuiltSignature; this.#promptModelKey = this.#currentPromptModelKey(); this.#basePromptXdevNames = new Set(rebuiltXdevCatalogNames); } } #setMountedNames(names: Iterable): void { const mountedNames = this.#xdev?.mountedNames; if (!mountedNames) return; mountedNames.clear(); for (const name of names) mountedNames.add(name); } /** * Record a mid-session `xd://` mount delta for the model. Non-MCP mount * churn remains notice-only, leaving the system prompt and provider cache * prefix byte-stable; mounted MCP route changes additionally rebuild the * global route guidance through the applied-tool signature. The delta is NOT * steered immediately — a steered notice landing at a run's stop boundary * (or while the session is idle) forces an unsolicited extra assistant turn * — so it is coalesced into {@link #pendingXdevMountDelta} and rides along * with the next prompt (docs + schema stay one `read xd://` away). * Full docs join the system prompt opportunistically on a rebuild. */ #notifyXdevMountDelta(previousMounted: ReadonlySet): void { const current = this.#xdev?.mountedNames; if (!current) return; const addedNames = [...current].filter(name => !previousMounted.has(name)); const removedNames = [...previousMounted].filter(name => !current.has(name)); if (addedNames.length === 0 && removedNames.length === 0) return; // Coalesce against the unannounced delta: an unmount cancels a pending // mount the model never learned about, and a remount cancels a pending // unmount. const pending = this.#pendingXdevMountDelta ?? { added: new Set(), removed: new Set() }; for (const name of addedNames) { if (!pending.removed.delete(name)) pending.added.add(name); } for (const name of removedNames) { if (!pending.added.delete(name)) pending.removed.add(name); } this.#pendingXdevMountDelta = pending.added.size > 0 || pending.removed.size > 0 ? pending : undefined; if (this.#host.settings.get("startup.quiet")) return; const parts: string[] = []; if (addedNames.length > 0) parts.push(`mounted ${addedNames.join(", ")}`); if (removedNames.length > 0) parts.push(`unmounted ${removedNames.join(", ")}`); this.#host.emitNotice("info", `xd://: ${parts.join("; ")}`, "xdev"); } /** * Forget the announced-mount baseline for a replaced transcript. Called when * session history is swapped wholesale (`/new`, `switchSession`, `branch`): the * previous transcript's persisted notices no longer apply, so the next notice * re-seeds from the new history and a device reconnecting into it announces * again. * * The pending delta is deliberately preserved: it holds mounts that are still * live but not yet delivered to the model, and `branch()` does not rebuild the * base system prompt, so dropping it would leave the branched transcript * unaware of a still-mounted device that no later refresh would re-queue. */ resetAnnouncedMounts(): void { this.#announcedMounts.clear(); this.#announcedMountsSeeded = false; } /** * Seed {@link #announcedMounts} from persisted mount notices the first time a * notice is consumed. On resume the in-memory mount set is rebuilt from * scratch, so without replaying history every already-announced dynamic device * would look freshly mounted and re-announce. */ #ensureAnnouncedMountsSeeded(): void { if (this.#announcedMountsSeeded) return; this.#announcedMountsSeeded = true; for (const message of this.#host.agent.state.messages) { if (message.role !== "custom" || message.customType !== XDEV_MOUNT_NOTICE_MESSAGE_TYPE) continue; const details = message.details; if ( isRecord(details) && Array.isArray(details.added) && details.added.every(name => typeof name === "string") && Array.isArray(details.removed) && details.removed.every(name => typeof name === "string") ) { for (const name of details.added) this.#announcedMounts.add(name); for (const name of details.removed) this.#announcedMounts.delete(name); continue; } // Releases before structured notice details persisted only the rendered // prompt. Replay its two stable inventory sections so the first resume // after upgrading does not re-announce every dynamic device once. if (typeof message.content !== "string") continue; let section: "added" | "removed" | undefined; for (const line of message.content.split("\n")) { if (line === "These tools became available:") { section = "added"; continue; } if (line.startsWith("No longer mounted")) { section = "removed"; continue; } if (line === "Configured inline device docs:" || line === "") break; if (line.startsWith("Read `xd://`")) { section = undefined; continue; } if (!section) continue; const match = /^- xd:\/\/(\S+?)(?:\s+—|$)/.exec(line); const name = match?.[1]; if (!name) continue; if (section === "added") this.#announcedMounts.add(name); else this.#announcedMounts.delete(name); } } } /** Consumes the hidden notice for unannounced `xd://` mount changes. */ takePendingXdevMountNotice(baseCatalogDelivered: boolean): CustomMessage | undefined { const pending = this.#pendingXdevMountDelta; if (!pending) return undefined; this.#pendingXdevMountDelta = undefined; this.#ensureAnnouncedMountsSeeded(); // A pending add for a device the outgoing base prompt already lists in its // catalog needs no notice line — but only when the final provider prompt // still carries that base catalog. A `before_agent_start` replacement drops // it, so its additions must remain in the notice. Record prompt-carried // devices announced here, after the final prompt is known and immediately // before delivery. The pending delta remains untouched by rebuilds, letting // {@link #notifyXdevMountDelta} cancel a mount followed by an unmount before // any request is sent (issue #7139 reviews). if (baseCatalogDelivered) { for (const name of pending.added) { if (this.#basePromptXdevNames.has(name)) this.#announcedMounts.add(name); } } // Only announce a net change relative to what the model already knows (from // this session and persisted history): a re-mount of an already-announced // device — the common resume/reconnect case — and an unmount for a device // it was never told about are both suppressed, keeping the provider prompt // cache prefix byte-stable across resumes. const addedNames = [...pending.added].filter(name => !this.#announcedMounts.has(name)); const removedNames = [...pending.removed].filter(name => this.#announcedMounts.has(name)); if (addedNames.length === 0 && removedNames.length === 0) return undefined; const summaries = new Map(this.#xdev ? xdevEntries(this.#xdev).map(entry => [entry.name, entry.summary]) : []); const added = addedNames.map(name => ({ name, summary: summaries.get(name) ?? "" })); const removed = removedNames.map(name => ({ name })); const docs = this.#xdev ? xdevDocsFor( this.#xdev, new Set(addedNames), this.#host.settings.get("tools.xdevDocs"), this.#host.settings.get("tools.xdevInlineDevices"), ) : ""; for (const name of addedNames) this.#announcedMounts.add(name); for (const name of removedNames) this.#announcedMounts.delete(name); return { role: "custom", customType: XDEV_MOUNT_NOTICE_MESSAGE_TYPE, content: prompt.render(xdevMountNoticePrompt, { added, removed, docs }), details: { added: addedNames, removed: removedNames }, attribution: "agent", display: false, timestamp: Date.now(), }; } /** Rediscovers reloadable skills and refreshes prompt metadata. */ async refreshSkills(): Promise { resetCapabilities(); if (this.#skillsReloadable) { const skillsSettings = this.#host.settings.getGroup("skills"); const discovered = await loadSkills({ ...skillsSettings, cwd: this.#host.sessionManager.getCwd(), disabledExtensions: this.#host.settings.get("disabledExtensions") ?? [], }); this.#skills = discovered.skills; this.#skillWarnings = discovered.warnings; this.#skillsSettings = skillsSettings; if (this.#host.agentKind() === "main") { setActiveSkills(this.#skills); } } await this.refreshBaseSystemPrompt(); this.#host.notifyCommandMetadataChanged(); } /** Selects enabled tools, ignoring names absent from the registry. */ async setActiveToolsByName(toolNames: string[]): Promise { const normalized = normalizeToolNames(toolNames); // Transport-write eligibility keys off the *current* active set: an ordinary // selection change should not demote `write` unless it is already active. await this.#applyToolPresentation( normalized, this.#xdev?.mountedNames ?? new Set(), this.getActiveToolNames().includes("write"), ); } /** * Restore an enabled tool set with its exact top-level versus `xd://` partition. * * Both inputs are required because {@link setActiveToolsByName} only receives the * enabled name list and classifies mounts from the current presentation set. * Rollback/restore callers must pass the snapshotted mounted subset so names that * were top-level stay pinned (`#runtimeSelectedToolNames`) and names that were under * `xd://` remain mount-eligible, even when the live mount set has drifted. * * Names outside `mountedToolNames` are pinned top-level for this application; * names in the mounted subset remain eligible for xdev mounting. Delegates the * actual apply through {@link applyActiveToolsByName} and restores the prior runtime * selection if that apply throws. */ async setActiveToolPresentation(toolNames: string[], mountedToolNames: string[]): Promise { const normalized = normalizeToolNames(toolNames); // Restoration targets a snapshot, so write eligibility comes from the // *target* set rather than whatever happens to be active mid-rollback. await this.#applyToolPresentation( normalized, new Set(normalizeToolNames(mountedToolNames)), normalized.includes("write"), ); } /** * Shared body for {@link setActiveToolsByName} and {@link setActiveToolPresentation}: * pins non-mounted names as the runtime selection (holding `write` back when it is * transport-only) and applies the set, rolling the selection back if apply throws. */ async #applyToolPresentation( normalized: string[], mounted: ReadonlySet, writeSelected: boolean, ): Promise { const transportWriteActive = writeSelected && this.#builtInToolNames.has("write") && this.#presentationPinnedToolNames?.has("write") !== true && this.#runtimeSelectedToolNames?.has("write") !== true && (mounted.size > 0 || this.#host.planModeEnabled()); const previousRuntimeSelectedToolNames = this.#runtimeSelectedToolNames; this.#runtimeSelectedToolNames = new Set( normalized.filter(name => !mounted.has(name) && !(name === "write" && transportWriteActive)), ); try { await this.applyActiveToolsByName(normalized); } catch (error) { this.#runtimeSelectedToolNames = previousRuntimeSelectedToolNames; throw error; } } /** Replaces memory-backend tools while preserving unrelated selections. */ async replaceMemoryTools(tools: AgentTool[]): Promise { const removed = new Set(MEMORY_BACKEND_TOOL_NAMES.filter(name => this.#builtInToolNames.has(name))); const nextActive = this.getEnabledToolNames().filter(name => !removed.has(name)); for (const name of removed) { this.#toolRegistry.delete(name); this.#builtInToolNames.delete(name); } for (const tool of tools) { if (!MEMORY_BACKEND_TOOL_NAMES.some(name => name === tool.name) || this.#toolRegistry.has(tool.name)) { continue; } const wrapped = this.#wrapRuntimeTool(tool); this.#toolRegistry.set(wrapped.name, wrapped); this.#builtInToolNames.add(wrapped.name); nextActive.push(wrapped.name); } await this.applyActiveToolsByName([...new Set(nextActive)]); } /** * Session-scoped enable/disable for the settings-gated `computer` tool. * * `createTools` derives the built-in slate once at session start, so a runtime * `computer.enabled` override alone never changes the active tools. Enabling * builds the tool through the config factory on first use (later toggles reuse * the registry entry, so only one desktop controller is ever registered) and * activates it; disabling drops it from the active set while keeping the * registry entry. Takes effect before the next model call. * * @returns false when enabling was requested but this session cannot build the * tool (e.g. restricted child sessions have no factory). */ async setComputerToolEnabled(enabled: boolean): Promise { const logState = (): void => this.#logComputerState("Computer tool state changed", enabled); const active = this.getEnabledToolNames(); if (!enabled) { if (active.includes("computer")) { await this.applyActiveToolsByName(active.filter(name => name !== "computer")); } logState(); return true; } if (!this.#toolRegistry.has("computer")) { const tool = await this.#createComputerTool?.(); if (tool?.name !== "computer") { const model = this.#host.model(); logger.warn("Computer tool could not be created", { model: model ? formatModelString(model) : undefined, }); return false; } const wrapped = this.#wrapRuntimeTool(tool); this.#toolRegistry.set(wrapped.name, wrapped); this.#builtInToolNames.add(wrapped.name); } if (!active.includes("computer")) { await this.applyActiveToolsByName([...active, "computer"]); } logState(); return true; } /** Current effective inspect_image state for `/vision status`. */ inspectImageState(): { mode: InspectImageMode; active: boolean; model: string | undefined } { const model = this.#host.model(); return { mode: this.#host.getInspectImageModeOverride() ?? this.#host.settings.get("inspect_image.mode"), active: this.getEnabledToolNames().includes("inspect_image"), model: model ? formatModelString(model) : undefined, }; } /** * Brings the active tool set in line with the effective inspect_image state * (mode setting, `/vision` override, active-model image capability). * Mirrors {@link setComputerToolEnabled}: enabling builds the tool through * the config factory on first use and reuses the registry entry afterwards. * Idempotent — safe to call from every model/settings change path. * * @returns false when the tool should be active but this session cannot * build it (e.g. restricted child sessions have no factory). */ async reconcileInspectImageTool(): Promise { const expected = isInspectImageToolActive({ settings: this.#host.settings, getActiveModel: () => this.#host.model(), getInspectImageModeOverride: () => this.#host.getInspectImageModeOverride(), }); // Keep the read tool's advertised description in sync BEFORE any prompt // rebuild below, passing the post-change availability so the prompt never // lags a flip in either direction. Per-read lazy sync is the backstop. const syncReadDescription = (available: boolean): void => { const readTool = this.#toolRegistry.get("read") as | { syncInspectImageState?: (available?: boolean) => boolean } | undefined; readTool?.syncInspectImageState?.(available); }; const active = this.getEnabledToolNames(); const isActive = active.includes("inspect_image"); if (expected === isActive) { syncReadDescription(isActive); return true; } if (!expected) { syncReadDescription(false); await this.applyActiveToolsByName(active.filter(name => name !== "inspect_image")); return true; } if (!this.#toolRegistry.has("inspect_image")) { const tool = await this.#createInspectImageTool?.(); if (tool?.name !== "inspect_image") { logger.warn("inspect_image tool could not be created", { model: this.#host.model()?.id, }); syncReadDescription(false); return false; } const wrapped = this.#wrapRuntimeTool(tool); this.#toolRegistry.set(wrapped.name, wrapped); this.#builtInToolNames.add(wrapped.name); } syncReadDescription(true); await this.applyActiveToolsByName([...active, "inspect_image"]); return true; } /** * Reconciles inspect_image after a model change and surfaces a notice when * the visible tool set actually flipped. Called from every model-change * path — including retry-fallback switches that bypass * {@link syncAfterModelChange}. */ async reconcileInspectImageAfterModelChange(): Promise { const before = this.getEnabledToolNames().includes("inspect_image"); const reconciled = await this.reconcileInspectImageTool(); const after = this.getEnabledToolNames().includes("inspect_image"); if (!reconciled || before === after) return; const model = this.#host.model(); const modelName = model ? formatModelString(model) : "the current model"; this.#host.emitNotice( "info", after ? `inspect_image is now available: ${modelName} has no native image input.` : `inspect_image is now hidden: ${modelName} supports image input natively. Override with /vision on.`, "vision", ); } /** * Session-scoped `/vision` override. `auto` clears the override so the * persisted `inspect_image.mode` setting (itself possibly `auto`) decides; * `on`/`off` force the tool for this session only. Takes effect before the * next model call. * * @returns false when `on` was requested but the tool cannot be built here. */ async setInspectImageMode(mode: InspectImageMode): Promise { this.#host.setInspectImageModeOverride(mode === "auto" ? undefined : mode); const applied = await this.reconcileInspectImageTool(); const { active, model } = this.inspectImageState(); logger.debug("inspect_image mode changed", { mode, active, model }); return applied; } /** Rebuilds the stable base prompt for the current tools and model. */ async refreshBaseSystemPrompt(): Promise { if (this.#host.isDisposed() || !this.#rebuildSystemPrompt) return; const activeToolNames = this.getActiveToolNames(); this.#setActiveToolNames?.(activeToolNames); const previousBaseSystemPrompt = this.#baseSystemPrompt; const built = await this.#rebuildSystemPrompt(activeToolNames, this.#toolRegistry); if (this.#host.isDisposed()) return; this.#baseSystemPrompt = built.systemPrompt; this.#basePromptXdevNames = new Set(built.xdevCatalogNames); this.#host.clearMemoryPromotionSnapshot(); if ( previousBaseSystemPrompt.length !== this.#baseSystemPrompt.length || previousBaseSystemPrompt.some((part, index) => part !== this.#baseSystemPrompt[index]) ) { this.#host.clearInheritedProviderPromptCacheKey(); } this.#host.agent.setSystemPrompt(this.#baseSystemPrompt); this.#promptModelKey = this.#currentPromptModelKey(); // Refresh the cached signature so a subsequent `applyActiveToolsByName` with // the same tool set does not re-rebuild on top of the explicit refresh we // just performed (and conversely, a different set forces a fresh rebuild). const activeTools = activeToolNames .map(name => this.#toolRegistry.get(name)) .filter((tool): tool is AgentTool => tool != null); this.#lastAppliedToolSignature = this.#computeAppliedToolSignature(activeToolNames, activeTools); } /** Applies one-turn memory prompt injection before an agent run. */ async buildSystemPromptForAgentStart(promptText: string): Promise { const backend = await resolveMemoryBackend(this.#host.settings); if (!backend.beforeAgentStartPrompt) return this.#baseSystemPrompt; try { const injected = await backend.beforeAgentStartPrompt(this.#host.memoryBackendSession(), promptText); if (!injected) return this.#baseSystemPrompt; const previousBaseSystemPrompt = this.#baseSystemPrompt; try { await this.refreshBaseSystemPrompt(); } catch (refreshErr) { logger.debug("Memory backend prompt refresh after beforeAgentStartPrompt failed", { backend: backend.id, error: String(refreshErr), }); } if ( this.#baseSystemPrompt.length !== previousBaseSystemPrompt.length || this.#baseSystemPrompt.some((part, index) => part !== previousBaseSystemPrompt[index]) ) { return this.#baseSystemPrompt; } this.#host.captureMemoryPromotionSnapshot(previousBaseSystemPrompt); const stablePrompt = [...previousBaseSystemPrompt, injected]; this.#baseSystemPrompt = stablePrompt; this.#host.agent.setSystemPrompt(stablePrompt); return stablePrompt; } catch (err) { logger.debug("Memory backend beforeAgentStartPrompt failed", { backend: backend.id, error: String(err), }); return this.#baseSystemPrompt; } } /** * Compose a stable signature for the inputs that `rebuildSystemPrompt` reads. * Two calls producing identical signatures are guaranteed to produce identical * system prompt bytes, so the rebuild can be skipped. * * The signature covers: * 1. Active tool names in order (the prompt renders them in this order). * 2. Active tool labels, descriptions, and wire-visible names — all are * rendered into the prompt body (see `system-prompt.md` `{{label}}: \`{{name}}\`` * and `toolPromptNames` in `buildSystemPrompt`). The wire name comes from * `tool.customWireName` and overrides the internal name on the model wire * (e.g. `edit` exposes itself as `apply_patch` to GPT-5 in apply_patch mode); * a stale wire name would desync prompt guidance from actual tool routing. * 3. The bounded mounted-MCP projection: escaped original-name labels, * actual `xd://` paths, and the omission flag in catalog order. These are * the exact values rendered by the global transport guidance; catalog * churn wholly behind the fallback does not change the prompt. * 4. MCP server instructions text (per server), since `rebuildSystemPrompt` * embeds these in the appended prompt under "## MCP Server Instructions". * A server upgrade can change instructions while keeping tools identical. * * Settings-driven tool metadata is covered automatically: built-in tools that * depend on settings expose `description`/`label` via getters (see `TaskTool`, * `SearchToolBm25Tool`, `EditTool`), and the signature reads them live on every * call - so a settings flip that mutates the rendered string differs the signature * the next time {@link applyActiveToolsByName} runs. Do not refactor `describeTool` * to cache per-tool strings without preserving this property. * * Inputs NOT covered: tool input schemas; memory instructions read from disk; * and SDK-init-time closure constants in `sdk.ts` (`inlineToolDescriptors`, * `eagerTasks`, `intentField`, `mcpDiscoveryEnabled`, `secretsEnabled`). The * closure-captured ones cannot change at runtime regardless of skip behavior. * For everything else, callers must explicitly call {@link refreshBaseSystemPrompt} * after side-effecting changes; see the memory hooks and {@link syncAfterModelChange}. * * The current calendar date IS covered (appended as a segment) because * `buildSystemPrompt` injects it into the prompt body (`Today is '{{date}}'`). * Without this, a session spanning midnight with only tool-stable MCP * reconnects would keep yesterday's date indefinitely. */ #computeAppliedToolSignature(toolNames: string[], tools: AgentTool[]): string { // Order-preserving join: any reorder must produce a different signature so // the rebuild fires and the new tool list reaches the API. const nameSegment = toolNames.join("\u0001"); const describeTool = (tool: AgentTool): string => `${tool.name}=${tool.label ?? ""}|${tool.description ?? ""}|${tool.customWireName ?? ""}`; const descriptionSegment = tools.map(describeTool).join("\u0002"); const mountedMCPProjection = projectMountedMCPXdevGuidance( collectMountedMCPToolRoutes(this.#xdev ? listXdevTools(this.#xdev) : []), ); const mountedMCPRouteSegment = JSON.stringify({ mappings: mountedMCPProjection.mappings.map(mapping => [mapping.label, mapping.path] as const), hasOmittedMappings: mountedMCPProjection.hasOmittedMappings, }) ?? "{}"; const serverInstructions = this.#getMcpServerInstructions?.(); let instructionsSegment = ""; if (serverInstructions && serverInstructions.size > 0) { // Sort by server name so transport flap order does not perturb the signature. const entries: string[] = []; for (const [server, instructions] of serverInstructions) { entries.push(`${server}=${instructions}`); } entries.sort(); instructionsSegment = entries.join("\u0006"); } // The non-MCP remainder of the xd:// inventory is deliberately NOT part // of the signature: its mount/unmount announces itself through // `#notifyXdevMountDelta` rather than rewriting the system prompt, keeping // the provider cache prefix byte-stable. Mounted MCP routes are the narrow // exception above, bounded to the exact projection rendered in the global // route guidance so churn wholly behind its fallback does not rebuild. const date = this.#getLocalCalendarDate(); return `${nameSegment}\u0003${descriptionSegment}\u0007${instructionsSegment}\u0008${mountedMCPRouteSegment}|${date}`; } /** * Replace MCP tools in the registry and enable them immediately. Refreshes * are serialized so an older asynchronous prompt rebuild cannot commit * after a newer catalog snapshot. Every connected MCP tool becomes available * (mounted under `xd://` when that transport is active, else top-level). */ refreshMCPTools(mcpTools: CustomTool[]): Promise { const snapshot = [...mcpTools]; const refresh = this.#mcpRefreshTail.then(() => this.#host.isDisposed() ? undefined : this.#applyMCPToolRefresh(snapshot), ); this.#mcpRefreshTail = refresh.catch(() => {}); return refresh; } async #applyMCPToolRefresh(mcpTools: CustomTool[]): Promise { const existingNames = Array.from(this.#toolRegistry.keys()); const previousMcpTools = new Map( existingNames.flatMap(name => { const tool = this.#toolRegistry.get(name); return isMCPToolName(name) && tool ? [[name, tool] as const] : []; }), ); const restorePreviousMcpTools = () => { for (const name of this.#toolRegistry.keys()) { if (isMCPToolName(name)) this.#toolRegistry.delete(name); } for (const [name, tool] of previousMcpTools) this.#toolRegistry.set(name, tool); }; for (const name of existingNames) { if (isMCPToolName(name)) { this.#toolRegistry.delete(name); } } const getCustomToolContext = (): CustomToolContext => ({ sessionManager: this.#host.sessionManager, modelRegistry: this.#host.modelRegistry, model: this.#host.model(), isIdle: () => !this.#host.isStreaming(), hasQueuedMessages: () => this.#host.queuedMessageCount() > 0, abort: () => { this.#host.agent.abort(); }, settings: this.#host.settings, localProtocolOptions: this.#host.localProtocolOptions(), }); const extensionRunner = this.#host.extensionRunner(); const uniqueMcpTools = deduplicateMCPToolsByName(mcpTools); for (const customTool of uniqueMcpTools) { const wrapped = wrapToolWithMetaNotice(CustomToolAdapter.wrap(customTool, getCustomToolContext) as AgentTool); const finalTool = ( extensionRunner ? new ExtensionToolWrapper(wrapped, extensionRunner) : wrapped ) as AgentTool; this.#toolRegistry.set(finalTool.name, finalTool); } // Every connected MCP tool is selected; centralized repartitioning owns // presentation pins and write-transport activation/removal. const nextActive = [...new Set([...this.#getActiveNonMCPToolNames(), ...uniqueMcpTools.map(tool => tool.name)])]; try { await this.applyActiveToolsByName(nextActive); if (this.#host.isDisposed()) restorePreviousMcpTools(); } catch (error) { restorePreviousMcpTools(); throw error; } } /** Replaces RPC host-owned tools and refreshes the active set before the next model call. */ async refreshRpcHostTools(rpcTools: AgentTool[]): Promise { const nextToolNames = rpcTools.map(tool => tool.name); const uniqueToolNames = new Set(nextToolNames); if (uniqueToolNames.size !== nextToolNames.length) { throw new Error("RPC host tool names must be unique"); } for (const name of uniqueToolNames) { if (this.#toolRegistry.has(name) && !this.#rpcHostToolNames.has(name)) { throw new Error(`RPC host tool "${name}" conflicts with an existing tool`); } } const previousRpcHostToolNames = new Set(this.#rpcHostToolNames); const previousActiveToolNames = this.getEnabledToolNames(); const previousRpcHostTools = new Map( [...previousRpcHostToolNames].flatMap(name => { const tool = this.#toolRegistry.get(name); return tool ? [[name, tool] as const] : []; }), ); for (const name of previousRpcHostToolNames) { this.#toolRegistry.delete(name); } this.#rpcHostToolNames.clear(); const extensionRunner = this.#host.extensionRunner(); for (const tool of rpcTools) { const metaWrapped = wrapToolWithMetaNotice(tool); const finalTool = ( extensionRunner ? new ExtensionToolWrapper(metaWrapped, extensionRunner) : metaWrapped ) as AgentTool; this.#toolRegistry.set(finalTool.name, finalTool); this.#rpcHostToolNames.add(finalTool.name); } const activeNonRpcToolNames = previousActiveToolNames.filter(name => !previousRpcHostToolNames.has(name)); const preservedRpcToolNames = previousActiveToolNames.filter( name => previousRpcHostToolNames.has(name) && this.#rpcHostToolNames.has(name), ); const autoActivatedRpcToolNames = rpcTools .filter(tool => !tool.hidden && !previousRpcHostToolNames.has(tool.name)) .map(tool => tool.name); try { await this.applyActiveToolsByName( Array.from(new Set([...activeNonRpcToolNames, ...preservedRpcToolNames, ...autoActivatedRpcToolNames])), ); } catch (error) { for (const name of this.#rpcHostToolNames) this.#toolRegistry.delete(name); this.#rpcHostToolNames = previousRpcHostToolNames; for (const [name, tool] of previousRpcHostTools) this.#toolRegistry.set(name, tool); throw error; } } }