refactor(coding-agent): replaced xdevregistry with state interface and helpers
- Replaced the `XdevRegistry` class with the `XdevState` interface and pure helper functions across core and session tools. - Updated session configurations, tool execution, and renderers to utilize canonical tool map initialization and sharing. - Adapted unit tests and mocks to use `XdevState` and associated helper functions for permission and dispatch verification.
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- Direct and `xd://` dispatch now share one canonical tool map: `write xd://<tool>` executes any enabled top-level or mounted tool, and `read xd://<tool>` returns its docs, instead of failing when the name was exposed through the other layer. Mounted names are presentation metadata only, so tool replacement and disconnection cannot leave stale device instances; disabled tools remain unreachable and the dispatched tool's approval tier still applies.
|
||||
|
||||
## [17.1.7] - 2026-07-27
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -25,6 +25,7 @@ import { isWaitingPollDetails } from "../../tools/hub";
|
||||
import { formatStatusIcon, replaceTabs, resolveImageOptions } from "../../tools/render-utils";
|
||||
import { type FirstResultViewportRepaint, toolRenderers } from "../../tools/renderers";
|
||||
import { TODO_STRIKE_TOTAL_FRAMES, type TodoToolDetails } from "../../tools/todo";
|
||||
import type { XdevState } from "../../tools/xdev";
|
||||
import { isFramedBlockComponent, markFramedBlockComponent, renderStatusLine, WidthAwareText } from "../../tui";
|
||||
import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
|
||||
import { renderDiff } from "./diff";
|
||||
@@ -1316,13 +1317,13 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
||||
}
|
||||
context.renderDiff = renderDiff;
|
||||
} else if (this.#toolName === "write") {
|
||||
// Device-dispatch previews delegate to the mounted tool's own renderer;
|
||||
// expose the session's xd:// registry so custom/MCP renderers survive dispatch.
|
||||
const writeTool = this.#tool as
|
||||
| { session?: { xdevRegistry?: { get(name: string): AgentTool | undefined } } }
|
||||
| undefined;
|
||||
const registry = writeTool?.session?.xdevRegistry;
|
||||
if (registry) context.resolveXdevMounted = (name: string) => registry.get(name);
|
||||
// Device-dispatch previews resolve renderers from the canonical tool map.
|
||||
const writeTool = this.#tool as { session?: { xdev?: XdevState } } | undefined;
|
||||
const xdev = writeTool?.session?.xdev;
|
||||
if (xdev) {
|
||||
context.resolveXdevMounted = (name: string) =>
|
||||
xdev.mountedNames.has(name) ? xdev.tools.get(name) : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return context;
|
||||
|
||||
@@ -190,20 +190,23 @@ import {
|
||||
HIDDEN_TOOLS,
|
||||
isMountableUnderXdev,
|
||||
type LspStartupServerInfo,
|
||||
listXdevTools,
|
||||
ReadTool,
|
||||
releaseComputerSessionsForOwner,
|
||||
resolveMountedXdevExecutable,
|
||||
type Tool,
|
||||
type ToolSession,
|
||||
WebSearchTool,
|
||||
WriteTool,
|
||||
warmupLspServers,
|
||||
xdevDocsAll,
|
||||
xdevEntries,
|
||||
} from "./tools";
|
||||
import { isMCPToolName, normalizeToolNames } from "./tools/builtin-names";
|
||||
import { ToolContextStore } from "./tools/context";
|
||||
import { isIrcEnabled } from "./tools/hub";
|
||||
import { getImageGenTools } from "./tools/image-gen";
|
||||
import { wrapToolWithMetaNotice } from "./tools/output-meta";
|
||||
import { isAutoQaEnabled } from "./tools/report-tool-issue";
|
||||
import { queueResolveHandler } from "./tools/resolve";
|
||||
import { USER_TODO_EDIT_CUSTOM_TYPE } from "./tools/todo";
|
||||
import { ttsTool } from "./tools/tts";
|
||||
@@ -1620,6 +1623,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
// mutation (any tool) bumped it in the meantime.
|
||||
const fileMutationVersions = new Map<string, number>();
|
||||
const activeToolNames = new Set<string>();
|
||||
const toolRegistry = new Map<string, Tool>();
|
||||
const setActiveToolNames = (names: Iterable<string>): void => {
|
||||
activeToolNames.clear();
|
||||
for (const name of names) {
|
||||
@@ -1632,6 +1636,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
},
|
||||
isToolActive: name => activeToolNames.has(name),
|
||||
setActiveToolNames,
|
||||
toolRegistry,
|
||||
hasUI: options.hasUI ?? false,
|
||||
get additionalDirectories() {
|
||||
return sessionManager.getAdditionalDirectories();
|
||||
@@ -1777,7 +1782,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
);
|
||||
|
||||
// Create built-in tools (already wrapped with meta notice formatting)
|
||||
const builtinTools = await logger.time("createAllTools", createTools, toolSession, options.toolNames);
|
||||
await logger.time("createAllTools", createTools, toolSession, options.toolNames);
|
||||
|
||||
// Restricted sessions cannot inherit or discover MCP capabilities.
|
||||
const enableMCP = !restrictToolNames && (options.enableMCP ?? true);
|
||||
@@ -1869,7 +1874,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
// to mirror the AsyncJobManager ownership rule.
|
||||
if (mcpManager && !options.parentTaskPrefix) MCPManager.setInstance(mcpManager);
|
||||
|
||||
const builtInToolNames = builtinTools.map(t => t.name);
|
||||
const builtInToolNames = [...toolRegistry.keys()];
|
||||
let customToolPaths: ToolPathWithSource[] = [];
|
||||
const inlineExtensions: ExtensionFactory[] = [];
|
||||
if (!restrictToolNames) {
|
||||
@@ -2550,12 +2555,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
);
|
||||
|
||||
// All built-in tools are active (conditional tools like git/ask return null from factory if disabled)
|
||||
const builtInRegistryToolNames = new Set<string>();
|
||||
const toolRegistry = new Map<string, Tool>();
|
||||
for (const tool of builtinTools) {
|
||||
toolRegistry.set(tool.name, tool);
|
||||
builtInRegistryToolNames.add(tool.name);
|
||||
}
|
||||
const builtInRegistryToolNames = toolSession.xdev?.builtInNames ?? new Set(toolRegistry.keys());
|
||||
if (!restrictToolNames && !toolRegistry.has("goal") && settings.get("goal.enabled")) {
|
||||
const goalTool = await logger.time("createTools:goal:session", HIDDEN_TOOLS.goal, toolSession);
|
||||
if (goalTool) {
|
||||
@@ -2607,26 +2607,21 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
// Existing staged/device paths need write registered before active-set assembly.
|
||||
// Deferred MCP also registers it now, but refresh activates it only after a server connects.
|
||||
const hasDeferrableTools = Array.from(toolRegistry.values()).some(tool => tool.deferrable === true);
|
||||
const hasXdevTools = (toolSession.xdevRegistry?.size ?? 0) > 0;
|
||||
const hasXdevTools = (toolSession.xdev?.mountedNames.size ?? 0) > 0;
|
||||
const planModeAvailable = settings.get("plan.enabled");
|
||||
if (!restrictToolNames && (hasDeferrableTools || hasXdevTools || planModeAvailable || deferMCPDiscoveryForUI)) {
|
||||
await ensureWriteRegistered();
|
||||
}
|
||||
|
||||
let cursorEventEmitter: ((event: AgentEvent) => void) | undefined;
|
||||
// Built-in xd:// devices (ast_edit, debug, browser, lsp, web_search) are
|
||||
// mounted in createTools BEFORE this loop wraps registry entries in
|
||||
// ExtensionToolWrapper, so the registry holds them unwrapped. The normal
|
||||
// `write xd://<tool>` path runs approval through the wrapped `write` tool's
|
||||
// tier gate, but Cursor invokes advertised devices via `tool.execute()`
|
||||
// directly, and the agent loop's fallback resolver executes mounted
|
||||
// devices the model called by their top-level name — so wrap unwrapped
|
||||
// devices here to keep the approval/deny/prompt gate. Dynamic mounts
|
||||
// (custom/MCP) already come from the wrapped registry.
|
||||
// Cursor and the agent loop may call a mounted device by its top-level
|
||||
// name. Resolve that name from the canonical map and apply the same
|
||||
// execution-only ACP decorator used by `write xd://<tool>`; docs and
|
||||
// renderer lookup continue to use the undecorated canonical instance.
|
||||
const resolveDeviceTool = (name: string): AgentTool | undefined => {
|
||||
const device = toolSession.xdevRegistry?.get(name);
|
||||
if (!device) return undefined;
|
||||
return device instanceof ExtensionToolWrapper ? device : new ExtensionToolWrapper(device, extensionRunner);
|
||||
const state = toolSession.xdev;
|
||||
if (!state) return undefined;
|
||||
return resolveMountedXdevExecutable(state, name);
|
||||
};
|
||||
const cursorExecHandlers = new CursorExecHandlers({
|
||||
cwd,
|
||||
@@ -2681,7 +2676,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
if (memoryInstructions) appendParts.push(memoryInstructions);
|
||||
if (autoLearnInstructions) appendParts.push(autoLearnInstructions);
|
||||
const projection = projectMountedMCPXdevGuidance(
|
||||
collectMountedMCPToolRoutes(toolSession.xdevRegistry?.list() ?? []),
|
||||
collectMountedMCPToolRoutes(toolSession.xdev ? listXdevTools(toolSession.xdev) : []),
|
||||
);
|
||||
if (projection.mappings.length > 0 || projection.hasOmittedMappings) {
|
||||
appendParts.push(
|
||||
@@ -2724,13 +2719,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
const defaultPrompt = await buildSystemPromptInternal({
|
||||
cwd,
|
||||
additionalWorkspaceRoots: sessionManager.getAdditionalDirectories(),
|
||||
xdevTools: toolSession.xdevRegistry?.entries() ?? [],
|
||||
xdevDocs:
|
||||
toolSession.xdevRegistry?.docsAll(
|
||||
settings.get("tools.xdevDocs"),
|
||||
settings.get("tools.xdevInlineDevices"),
|
||||
) ?? "",
|
||||
autoQaEnabled: !restrictToolNames && isAutoQaEnabled(settings),
|
||||
xdevTools: toolSession.xdev ? xdevEntries(toolSession.xdev) : [],
|
||||
xdevDocs: toolSession.xdev
|
||||
? xdevDocsAll(toolSession.xdev, settings.get("tools.xdevDocs"), settings.get("tools.xdevInlineDevices"))
|
||||
: "",
|
||||
resolvedCustomPrompt: options.customSystemPrompt,
|
||||
skills: session?.skills ?? skills,
|
||||
contextFiles,
|
||||
@@ -2854,31 +2846,24 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
// constructed and attached. Startup failure therefore leaves it revivable.
|
||||
hasRegistered = options.expectedAgentRef === undefined || options.expectedAgentRef === null;
|
||||
|
||||
// Partition the initial enabled set for the xd:// transport: ambient
|
||||
// discoverable tools become mounted devices, while explicitly requested
|
||||
// tools keep their top-level presentation. The registry already holds the
|
||||
// default-set built-in devices from createTools; this reconciles dynamic
|
||||
// mounts (image-gen, TTS, startup MCP, active extension tools).
|
||||
let initialMountedXdevToolNames: string[] = [];
|
||||
if (toolSession.xdevRegistry) {
|
||||
// Partition the initial enabled set for the xd:// transport. Tool instances
|
||||
// remain in the canonical map; only presentation names move between layers.
|
||||
if (toolSession.xdev) {
|
||||
const topLevelToolNames: string[] = [];
|
||||
const mountedTools: Tool[] = [];
|
||||
const mountedNames: string[] = [];
|
||||
for (const name of initialToolNames) {
|
||||
const tool = toolRegistry.get(name);
|
||||
const explicitlyRequested = explicitlyRequestedToolNameSet?.has(name) === true;
|
||||
if (tool && xdevReadAvailable && !explicitlyRequested && isMountableUnderXdev(tool))
|
||||
mountedTools.push(tool);
|
||||
mountedNames.push(name);
|
||||
else topLevelToolNames.push(name);
|
||||
}
|
||||
const writeTransportAvailable = mountedTools.length === 0 || (await ensureWriteRegistered());
|
||||
const writeTransportAvailable = mountedNames.length === 0 || (await ensureWriteRegistered());
|
||||
toolSession.xdev.mountedNames.clear();
|
||||
if (writeTransportAvailable) {
|
||||
toolSession.xdevRegistry.reconcile(mountedTools);
|
||||
initialMountedXdevToolNames = mountedTools.map(tool => tool.name);
|
||||
for (const name of mountedNames) toolSession.xdev.mountedNames.add(name);
|
||||
initialToolNames = topLevelToolNames;
|
||||
if (initialMountedXdevToolNames.length > 0 && !initialToolNames.includes("write"))
|
||||
initialToolNames.push("write");
|
||||
} else {
|
||||
toolSession.xdevRegistry.reconcile([]);
|
||||
if (mountedNames.length > 0 && !initialToolNames.includes("write")) initialToolNames.push("write");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3056,7 +3041,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
return settingsAwareStreamFn(streamModel, context, streamOptions);
|
||||
},
|
||||
cursorExecHandlers,
|
||||
getCursorTools: () => [...(toolSession.xdevRegistry?.list() ?? [])],
|
||||
getCursorTools: () => (toolSession.xdev ? listXdevTools(toolSession.xdev) : []),
|
||||
transformToolCallArguments,
|
||||
resolveFallbackTool: resolveDeviceTool,
|
||||
intentTracing: !!intentField,
|
||||
@@ -3117,7 +3102,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
// write transport), so xd:// devices are unreachable and read must never
|
||||
// advertise inspect_image — images are inlined, and the provider
|
||||
// boundary handles text-only advisor models.
|
||||
xdevRegistry: undefined,
|
||||
xdev: undefined,
|
||||
isToolActive: name => name !== "inspect_image" && toolSession.isToolActive?.(name) === true,
|
||||
};
|
||||
const advisorToolBuilds: Array<Tool | null | Promise<Tool | null>> = [];
|
||||
@@ -3203,9 +3188,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||
preferWebsockets: preferOpenAICodexWebsockets,
|
||||
convertToLlm: convertToLlmFinal,
|
||||
rebuildSystemPrompt,
|
||||
getXdevToolEntries: () => toolSession.xdevRegistry?.entries() ?? [],
|
||||
xdevRegistry: toolSession.xdevRegistry,
|
||||
initialMountedXdevToolNames,
|
||||
getXdevToolEntries: () => (toolSession.xdev ? xdevEntries(toolSession.xdev) : []),
|
||||
xdev: toolSession.xdev,
|
||||
presentationPinnedToolNames: explicitlyRequestedToolNameSet,
|
||||
setActiveToolNames,
|
||||
ensureWriteRegistered,
|
||||
|
||||
@@ -26,7 +26,7 @@ import type { Skill, SkillWarning } from "../extensibility/skills";
|
||||
import type { FileSlashCommand } from "../extensibility/slash-commands";
|
||||
import type { SecretObfuscator } from "../secrets/obfuscator";
|
||||
import type { ConfiguredThinkingLevel } from "../thinking";
|
||||
import type { XdevRegistry } from "../tools/xdev";
|
||||
import type { XdevState } from "../tools/xdev";
|
||||
import type { SessionManager } from "./session-manager";
|
||||
|
||||
/** Maximum time the interactive shutdown path waits for Mnemopi consolidation. */
|
||||
@@ -179,10 +179,8 @@ export interface AgentSessionConfig {
|
||||
getLocalCalendarDate?: () => string;
|
||||
/** Tools mounted under `xd://`, for `/tools` display. */
|
||||
getXdevToolEntries?: () => Array<{ name: string; summary: string }>;
|
||||
/** Session-owned `xd://` registry. */
|
||||
xdevRegistry?: XdevRegistry;
|
||||
/** Discoverable tools mounted under `xd://` in the initial enabled set. */
|
||||
initialMountedXdevToolNames?: string[];
|
||||
/** `xd://` presentation state backed by the canonical tool map. */
|
||||
xdev?: XdevState;
|
||||
/** Names pinned top-level during runtime repartitioning. */
|
||||
presentationPinnedToolNames?: ReadonlySet<string>;
|
||||
/** Accessor for live MCP server instructions. */
|
||||
|
||||
@@ -1106,8 +1106,7 @@ export class AgentSession {
|
||||
rebuildSystemPrompt: config.rebuildSystemPrompt,
|
||||
getLocalCalendarDate: config.getLocalCalendarDate,
|
||||
getMcpServerInstructions: config.getMcpServerInstructions,
|
||||
xdevRegistry: config.xdevRegistry,
|
||||
initialMountedXdevToolNames: config.initialMountedXdevToolNames,
|
||||
xdev: config.xdev,
|
||||
setActiveToolNames: config.setActiveToolNames,
|
||||
baseSystemPrompt: this.agent.state.systemPrompt,
|
||||
skills: config.skills,
|
||||
|
||||
@@ -21,7 +21,7 @@ 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, type XdevRegistry } from "../tools/xdev";
|
||||
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";
|
||||
@@ -75,8 +75,7 @@ interface SessionToolsOptions {
|
||||
rebuildSystemPrompt?: (toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>;
|
||||
getLocalCalendarDate?: () => string;
|
||||
getMcpServerInstructions?: () => Map<string, string> | undefined;
|
||||
xdevRegistry?: XdevRegistry;
|
||||
initialMountedXdevToolNames?: string[];
|
||||
xdev?: XdevState;
|
||||
setActiveToolNames?: (names: Iterable<string>) => void;
|
||||
baseSystemPrompt: string[];
|
||||
skills?: Skill[];
|
||||
@@ -171,8 +170,7 @@ export class SessionTools {
|
||||
#installedVibeToolNames = new Set<string>();
|
||||
#builtInToolNames: Set<string>;
|
||||
#rpcHostToolNames = new Set<string>();
|
||||
#xdevRegistry: XdevRegistry | undefined;
|
||||
#mountedXdevToolNames: Set<string>;
|
||||
#xdev: XdevState | undefined;
|
||||
#pendingXdevMountDelta: { added: Set<string>; removed: Set<string> } | undefined;
|
||||
#presentationPinnedToolNames: ReadonlySet<string> | undefined;
|
||||
#runtimeSelectedToolNames: ReadonlySet<string> | undefined;
|
||||
@@ -204,8 +202,11 @@ export class SessionTools {
|
||||
this.#rebuildSystemPrompt = options.rebuildSystemPrompt;
|
||||
this.#getLocalCalendarDate = options.getLocalCalendarDate ?? formatLocalCalendarDate;
|
||||
this.#getMcpServerInstructions = options.getMcpServerInstructions;
|
||||
this.#xdevRegistry = options.xdevRegistry;
|
||||
this.#mountedXdevToolNames = new Set(options.initialMountedXdevToolNames ?? []);
|
||||
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 ?? [];
|
||||
@@ -250,7 +251,7 @@ export class SessionTools {
|
||||
this.#acpPermissionDecisions.clear();
|
||||
}
|
||||
|
||||
/** Re-wraps active and mounted tools after the ACP client changes. */
|
||||
/** Drops cached ACP decisions and re-wraps active tools after the client changes. */
|
||||
refreshAcpPermissionGates(): void {
|
||||
this.#acpPermissionDecisions.clear();
|
||||
const activeTools = this.getActiveToolNames()
|
||||
@@ -258,11 +259,6 @@ export class SessionTools {
|
||||
.filter((tool): tool is AgentTool => tool !== undefined)
|
||||
.map(tool => this.#wrapToolForAcpPermission(tool));
|
||||
this.#host.agent.setTools(activeTools);
|
||||
const mountedTools = [...this.#mountedXdevToolNames]
|
||||
.map(name => this.#toolRegistry.get(name))
|
||||
.filter((tool): tool is AgentTool => tool !== undefined)
|
||||
.map(tool => this.#wrapToolForAcpPermission(tool));
|
||||
this.#xdevRegistry?.reconcile(mountedTools);
|
||||
}
|
||||
|
||||
#getActiveNonMCPToolNames(): string[] {
|
||||
@@ -276,13 +272,14 @@ export class SessionTools {
|
||||
|
||||
/** Enabled top-level and discoverable tool names. */
|
||||
getEnabledToolNames(): string[] {
|
||||
if (this.#mountedXdevToolNames.size === 0) return this.getActiveToolNames();
|
||||
return [...this.getActiveToolNames(), ...this.#mountedXdevToolNames];
|
||||
const mountedNames = this.#xdev?.mountedNames;
|
||||
if (!mountedNames || mountedNames.size === 0) return this.getActiveToolNames();
|
||||
return [...this.getActiveToolNames(), ...mountedNames];
|
||||
}
|
||||
|
||||
/** Names of dynamic tools mounted under `xd://`. */
|
||||
/** Names currently presented as `xd://` devices. */
|
||||
getMountedXdevToolNames(): string[] {
|
||||
return [...this.#mountedXdevToolNames];
|
||||
return [...(this.#xdev?.mountedNames ?? [])];
|
||||
}
|
||||
|
||||
/** Whether the edit tool is registered. */
|
||||
@@ -555,10 +552,7 @@ export class SessionTools {
|
||||
this.#presentationPinnedToolNames?.has(name) === true || this.#runtimeSelectedToolNames?.has(name) === true;
|
||||
const mountCandidates = selectedTools.filter(
|
||||
({ name, tool }) =>
|
||||
this.#xdevRegistry !== undefined &&
|
||||
xdevReadAvailable &&
|
||||
!isPresentationPinned(name) &&
|
||||
isMountableUnderXdev(tool),
|
||||
this.#xdev !== undefined && xdevReadAvailable && !isPresentationPinned(name) && isMountableUnderXdev(tool),
|
||||
);
|
||||
|
||||
let builtInWriteAvailable = this.#builtInToolNames.has("write");
|
||||
@@ -569,19 +563,15 @@ export class SessionTools {
|
||||
const mountNames = builtInWriteAvailable ? new Set(mountCandidates.map(({ name }) => name)) : new Set<string>();
|
||||
const tools: AgentTool[] = [];
|
||||
const validToolNames: string[] = [];
|
||||
const mountedTools: AgentTool[] = [];
|
||||
for (const { name, tool } of selectedTools) {
|
||||
if (mountNames.has(name)) {
|
||||
mountedTools.push(this.#wrapToolForAcpPermission(tool));
|
||||
} else {
|
||||
tools.push(this.#wrapToolForAcpPermission(tool));
|
||||
validToolNames.push(name);
|
||||
}
|
||||
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 = mountedTools.length > 0 || activeDeferrableTool || this.#host.planModeEnabled();
|
||||
const transportNeeded = mountNames.size > 0 || activeDeferrableTool || this.#host.planModeEnabled();
|
||||
if (transportNeeded && !builtInWriteAvailable) {
|
||||
builtInWriteAvailable = (await this.#ensureWriteRegistered?.()) === true;
|
||||
if (builtInWriteAvailable) this.#builtInToolNames.add("write");
|
||||
@@ -602,14 +592,9 @@ export class SessionTools {
|
||||
if (writeToolIndex >= 0) tools.splice(writeToolIndex, 1);
|
||||
}
|
||||
|
||||
const previousMounted = this.#mountedXdevToolNames;
|
||||
const previousMountedTools = [...previousMounted].flatMap(name => {
|
||||
const tool = this.#xdevRegistry?.get(name);
|
||||
return tool ? [tool] : [];
|
||||
});
|
||||
const previousMounted = new Set(this.#xdev?.mountedNames ?? []);
|
||||
const previousActiveToolNames = this.getActiveToolNames();
|
||||
this.#mountedXdevToolNames = new Set(mountedTools.map(tool => tool.name));
|
||||
this.#xdevRegistry?.reconcile(mountedTools);
|
||||
this.#setMountedNames(mountNames);
|
||||
this.#setActiveToolNames?.(validToolNames);
|
||||
|
||||
let rebuiltSystemPrompt: string[] | undefined;
|
||||
@@ -624,15 +609,13 @@ export class SessionTools {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.#mountedXdevToolNames = previousMounted;
|
||||
this.#xdevRegistry?.reconcile(previousMountedTools);
|
||||
this.#setMountedNames(previousMounted);
|
||||
this.#setActiveToolNames?.(previousActiveToolNames);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (this.#host.isDisposed()) {
|
||||
this.#mountedXdevToolNames = previousMounted;
|
||||
this.#xdevRegistry?.reconcile(previousMountedTools);
|
||||
this.#setMountedNames(previousMounted);
|
||||
this.#setActiveToolNames?.(previousActiveToolNames);
|
||||
return;
|
||||
}
|
||||
@@ -649,6 +632,13 @@ export class SessionTools {
|
||||
}
|
||||
}
|
||||
|
||||
#setMountedNames(names: Iterable<string>): 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
|
||||
@@ -661,9 +651,8 @@ export class SessionTools {
|
||||
* Full docs join the system prompt opportunistically on a rebuild.
|
||||
*/
|
||||
#notifyXdevMountDelta(previousMounted: ReadonlySet<string>): void {
|
||||
const registry = this.#xdevRegistry;
|
||||
if (!registry) return;
|
||||
const current = this.#mountedXdevToolNames;
|
||||
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;
|
||||
@@ -690,14 +679,17 @@ export class SessionTools {
|
||||
const pending = this.#pendingXdevMountDelta;
|
||||
if (!pending) return undefined;
|
||||
this.#pendingXdevMountDelta = undefined;
|
||||
const summaries = new Map(this.#xdevRegistry?.entries().map(entry => [entry.name, entry.summary]) ?? []);
|
||||
const summaries = new Map(this.#xdev ? xdevEntries(this.#xdev).map(entry => [entry.name, entry.summary]) : []);
|
||||
const added = [...pending.added].map(name => ({ name, summary: summaries.get(name) ?? "" }));
|
||||
const removed = [...pending.removed].map(name => ({ name }));
|
||||
const docs = this.#xdevRegistry?.docsFor(
|
||||
pending.added,
|
||||
this.#host.settings.get("tools.xdevDocs"),
|
||||
this.#host.settings.get("tools.xdevInlineDevices"),
|
||||
);
|
||||
const docs = this.#xdev
|
||||
? xdevDocsFor(
|
||||
this.#xdev,
|
||||
pending.added,
|
||||
this.#host.settings.get("tools.xdevDocs"),
|
||||
this.#host.settings.get("tools.xdevInlineDevices"),
|
||||
)
|
||||
: "";
|
||||
return {
|
||||
role: "custom",
|
||||
customType: XDEV_MOUNT_NOTICE_MESSAGE_TYPE,
|
||||
@@ -739,7 +731,7 @@ export class SessionTools {
|
||||
// selection change should not demote `write` unless it is already active.
|
||||
await this.#applyToolPresentation(
|
||||
normalized,
|
||||
this.#mountedXdevToolNames,
|
||||
this.#xdev?.mountedNames ?? new Set(),
|
||||
this.getActiveToolNames().includes("write"),
|
||||
);
|
||||
}
|
||||
@@ -748,7 +740,7 @@ export class SessionTools {
|
||||
* 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 `#mountedXdevToolNames`.
|
||||
* 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.
|
||||
@@ -1078,7 +1070,7 @@ export class SessionTools {
|
||||
`${tool.name}=${tool.label ?? ""}|${tool.description ?? ""}|${tool.customWireName ?? ""}`;
|
||||
const descriptionSegment = tools.map(describeTool).join("\u0002");
|
||||
const mountedMCPProjection = projectMountedMCPXdevGuidance(
|
||||
collectMountedMCPToolRoutes(this.#xdevRegistry?.list() ?? []),
|
||||
collectMountedMCPToolRoutes(this.#xdev ? listXdevTools(this.#xdev) : []),
|
||||
);
|
||||
const mountedMCPRouteSegment =
|
||||
JSON.stringify({
|
||||
|
||||
@@ -62,7 +62,7 @@ import { ReadTool } from "./read";
|
||||
import type { PlanProposalHandler } from "./resolve";
|
||||
import { type TodoPhase, TodoTool } from "./todo";
|
||||
import { WriteTool } from "./write";
|
||||
import { isMountableUnderXdev, XdevRegistry } from "./xdev";
|
||||
import { isMountableUnderXdev, type XdevState } from "./xdev";
|
||||
import { YieldTool } from "./yield";
|
||||
|
||||
export * from "../edit";
|
||||
@@ -244,8 +244,10 @@ export interface ToolSession {
|
||||
isToolActive?: (name: string) => boolean;
|
||||
/** Update the active built-in tool predicate when a session changes tools mid-run. */
|
||||
setActiveToolNames?: (names: Iterable<string>) => void;
|
||||
/** Tools mounted under `xd://` (set by createTools when `tools.xdev` is active); read/write consult it at execute time. */
|
||||
xdevRegistry?: XdevRegistry;
|
||||
/** Canonical map containing every registered tool exactly once. */
|
||||
toolRegistry?: Map<string, Tool>;
|
||||
/** `xd://` presentation state backed by {@link toolRegistry}. */
|
||||
xdev?: XdevState;
|
||||
/** Agent registry for IRC routing across live sessions. */
|
||||
agentRegistry?: AgentRegistry;
|
||||
/** Idle→parked→revive lifecycle owner; lets the hub kill a non-job-backed agent registration. Default: AgentLifecycleManager.global(). */
|
||||
@@ -616,6 +618,10 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
|
||||
}),
|
||||
);
|
||||
let tools = baseResults.filter((r): r is Tool => r !== null);
|
||||
const toolRegistry = session.toolRegistry ?? new Map<string, Tool>();
|
||||
session.toolRegistry = toolRegistry;
|
||||
const builtInNames = new Set(tools.map(tool => tool.name));
|
||||
for (const tool of tools) toolRegistry.set(tool.name, tool);
|
||||
|
||||
// Ordinary sessions use xd:// for discoverable built-ins, custom tools, and
|
||||
// MCP tools. Structured children must expose only their host-provided names,
|
||||
@@ -624,26 +630,26 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
|
||||
const xdevEnabled = !restrictToolNames && session.settings.get("tools.xdev");
|
||||
const mountBuiltinTools = requestedTools === undefined;
|
||||
if (xdevEnabled) {
|
||||
const mounted: Tool[] = [];
|
||||
const mountedNames = new Set<string>();
|
||||
const kept: Tool[] = [];
|
||||
for (const tool of tools) {
|
||||
const mountable = mountBuiltinTools && isMountableUnderXdev(tool) && tool.name in BUILTIN_TOOLS;
|
||||
(mountable ? mounted : kept).push(tool);
|
||||
if (mountable) mountedNames.add(tool.name);
|
||||
else kept.push(tool);
|
||||
}
|
||||
session.xdevRegistry = new XdevRegistry(mounted);
|
||||
session.xdev = {
|
||||
tools: toolRegistry,
|
||||
mountedNames,
|
||||
builtInNames,
|
||||
isActive: name => session.isToolActive?.(name) === true,
|
||||
};
|
||||
tools = kept;
|
||||
const finalActiveNames = new Set(tools.map(tool => tool.name));
|
||||
if (session.setActiveToolNames) {
|
||||
session.setActiveToolNames(finalActiveNames);
|
||||
} else {
|
||||
session.isToolActive = name => finalActiveNames.has(name);
|
||||
}
|
||||
}
|
||||
// The xd:// transport rides read/write: `read xd://` lists+documents devices,
|
||||
// `write xd://<tool>` executes them. Staged previews from deferrable tools
|
||||
// (e.g. ast_edit) also resolve through a `write` to xd://resolve/reject. Retain
|
||||
// both whenever any device is mounted or a deferrable tool can stage one.
|
||||
const xdevMounted = (session.xdevRegistry?.size ?? 0) > 0;
|
||||
const xdevMounted = (session.xdev?.mountedNames.size ?? 0) > 0;
|
||||
if (
|
||||
!restrictToolNames &&
|
||||
(tools.some(tool => tool.deferrable === true) || xdevMounted) &&
|
||||
@@ -651,15 +657,26 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
|
||||
) {
|
||||
const writeTool = await logger.time("createTools:write", BUILTIN_TOOLS.write, session);
|
||||
if (writeTool) {
|
||||
tools.push(wrapToolWithMetaNotice(writeTool));
|
||||
const wrapped = wrapToolWithMetaNotice(writeTool);
|
||||
tools.push(wrapped);
|
||||
toolRegistry.set(wrapped.name, wrapped);
|
||||
builtInNames.add(wrapped.name);
|
||||
}
|
||||
}
|
||||
if (!restrictToolNames && xdevMounted && !tools.some(tool => tool.name === "read")) {
|
||||
const readTool = await logger.time("createTools:read", BUILTIN_TOOLS.read, session);
|
||||
if (readTool) {
|
||||
tools.push(wrapToolWithMetaNotice(readTool));
|
||||
const wrapped = wrapToolWithMetaNotice(readTool);
|
||||
tools.push(wrapped);
|
||||
toolRegistry.set(wrapped.name, wrapped);
|
||||
builtInNames.add(wrapped.name);
|
||||
}
|
||||
}
|
||||
if (xdevEnabled) {
|
||||
const finalActiveNames = new Set(tools.map(tool => tool.name));
|
||||
if (session.setActiveToolNames) session.setActiveToolNames(finalActiveNames);
|
||||
else session.isToolActive = name => finalActiveNames.has(name);
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
@@ -134,6 +134,7 @@ import {
|
||||
} from "./sqlite-reader";
|
||||
import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
|
||||
import { toolResult } from "./tool-result";
|
||||
import { xdevDocs, xdevListing } from "./xdev";
|
||||
|
||||
// Per-session memo for tree-sitter summaries. `summarizeCode` is a pure function
|
||||
// of (code, path, fold settings) but costs ~12-18ms for a ~1500-line file, and a
|
||||
@@ -906,10 +907,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
*/
|
||||
#resolveInspectImageAvailability(): boolean {
|
||||
const topLevel = this.session.isToolActive?.("inspect_image");
|
||||
const registry = this.session.xdevRegistry;
|
||||
if (topLevel === undefined && registry === undefined) return isInspectImageToolActive(this.session);
|
||||
const xdev = this.session.xdev;
|
||||
if (topLevel === undefined && xdev === undefined) return isInspectImageToolActive(this.session);
|
||||
if (topLevel === true) return true;
|
||||
return registry?.get("inspect_image") !== undefined && isInspectImageToolActive(this.session);
|
||||
return xdev?.mountedNames.has("inspect_image") === true && isInspectImageToolActive(this.session);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3303,9 +3304,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
||||
read: async name => {
|
||||
if (name === REPORT_ISSUE_DEVICE_NAME) return reportIssueDeviceUsage();
|
||||
if (name && isResolutionDeviceName(name)) return resolutionDeviceUsage(name);
|
||||
const registry = this.session.xdevRegistry;
|
||||
if (!registry || registry.size === 0) throw new ToolError("xd:// is not mounted in this session.");
|
||||
return name === null ? registry.listing() : registry.docs(name);
|
||||
const xdev = this.session.xdev;
|
||||
if (!xdev) throw new ToolError("xd:// is not mounted in this session.");
|
||||
return name === null ? xdevListing(xdev) : xdevDocs(xdev, name);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -88,7 +88,14 @@ import {
|
||||
} from "./sqlite-reader";
|
||||
import { ToolError } from "./tool-errors";
|
||||
import { toolResult } from "./tool-result";
|
||||
import { renderXdevCall, renderXdevResult, type XdevDispatch } from "./xdev";
|
||||
import {
|
||||
dispatchXdevTool,
|
||||
renderXdevCall,
|
||||
renderXdevResult,
|
||||
resolveXdevTool,
|
||||
type XdevDispatch,
|
||||
xdevListing,
|
||||
} from "./xdev";
|
||||
|
||||
const LOOSE_HASHLINE_HEADER_RE = /^\s*\[[^#\r\n]+#[^ \t\r\n]*\]\s*$/;
|
||||
const EXECUTABLE_NOTICE = "[Notice: Made executable via chmod +x]";
|
||||
@@ -471,7 +478,8 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
|
||||
if (xdevTarget) {
|
||||
if (xdevTarget.name === REPORT_ISSUE_DEVICE_NAME) return "write";
|
||||
if (xdevTarget.name && isResolutionDeviceName(xdevTarget.name)) return "read";
|
||||
const inst = xdevTarget.name ? this.session.xdevRegistry?.get(xdevTarget.name) : undefined;
|
||||
const inst =
|
||||
xdevTarget.name && this.session.xdev ? resolveXdevTool(this.session.xdev, xdevTarget.name) : undefined;
|
||||
if (!inst) return "exec";
|
||||
// Decode the device JSON payload and evaluate the mounted tool's own
|
||||
// approval (which may be argument-dependent, e.g. ast_edit is read-tier
|
||||
@@ -1104,14 +1112,15 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
|
||||
};
|
||||
return;
|
||||
}
|
||||
const registry = this.session.xdevRegistry;
|
||||
if (!registry || registry.size === 0) {
|
||||
const xdev = this.session.xdev;
|
||||
if (!xdev) {
|
||||
throw new ToolError("xd:// is not mounted in this session.");
|
||||
}
|
||||
if (!name) {
|
||||
throw new ToolError(`Cannot write to xd:// itself — pick a device:\n${registry.listing()}`);
|
||||
throw new ToolError(`Cannot write to xd:// itself — pick a device:\n${xdevListing(xdev)}`);
|
||||
}
|
||||
const { result, xdev } = await registry.dispatch(
|
||||
const { result, xdev: dispatch } = await dispatchXdevTool(
|
||||
xdev,
|
||||
name,
|
||||
deviceContent,
|
||||
_toolCallId,
|
||||
@@ -1124,7 +1133,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
|
||||
);
|
||||
xdResult = {
|
||||
content: result.content,
|
||||
details: { xdev },
|
||||
details: { xdev: dispatch },
|
||||
isError: result.isError,
|
||||
useless: result.useless,
|
||||
};
|
||||
|
||||
@@ -9,15 +9,20 @@
|
||||
* read xd://<tool> → tool docs + JSON parameter schema
|
||||
* write xd://<tool> → execute: `content` is the JSON args object
|
||||
*
|
||||
* Direct and device dispatch share one canonical tool map. The mounted-name
|
||||
* set controls presentation only; dispatch accepts the enabled union of
|
||||
* top-level active and mounted names. Listing and prompt docs stay
|
||||
* mounted-only because top-level tools already ship their schemas.
|
||||
*
|
||||
* Args go through the same machinery as native tool calls: validated with
|
||||
* pi-ai's `validateToolArguments` (the schema is returned on mismatch, so a
|
||||
* malformed call self-corrects without a round trip) and streamed through
|
||||
* the write tool's existing incremental `content` decoding for live render
|
||||
* previews. Compared to a dispatcher def this still costs zero *schema
|
||||
* duplication* — one wire schema per tool instead of one per dispatcher
|
||||
* branch — but full docs + schema for every mounted device are inlined into
|
||||
* the system prompt (`XdevRegistry.docsAll()`) so no discovery `read` is
|
||||
* needed before first use; `read xd://<tool>` remains for on-demand re-fetch.
|
||||
* branch — but full docs + schema for every mounted device can be inlined
|
||||
* into the system prompt, so no discovery read is needed before first use;
|
||||
* `read xd://<tool>` remains for on-demand re-fetch.
|
||||
*
|
||||
* Rendering: the write renderer draws NOTHING until the streamed `path` is
|
||||
* known and provably does not target `xd://`; device writes then delegate to
|
||||
@@ -69,8 +74,7 @@ export type XdevDocsMode = "inline" | "builtins" | "catalog";
|
||||
* while the `xd://` transport is active. Discoverable tools mount unless they
|
||||
* are pinned top-level by {@link XDEV_KEEP_TOP_LEVEL} or carry the transport
|
||||
* itself ({@link XDEV_TRANSPORT_TOOLS}); essential tools never do. The caller
|
||||
* gates this on the transport being active (a session-owned
|
||||
* {@link XdevRegistry} existing).
|
||||
* gates this on the transport being active.
|
||||
*/
|
||||
export function isMountableUnderXdev(tool: { name: string; loadMode?: ToolLoadMode }): boolean {
|
||||
if (tool.name in XDEV_TRANSPORT_TOOLS || tool.name in XDEV_KEEP_TOP_LEVEL) return false;
|
||||
@@ -208,227 +212,211 @@ function decodeInnerArgs(raw: unknown): Record<string, unknown> {
|
||||
/** Device-write content that requests docs instead of executing: empty, `?`, or `help`. */
|
||||
const HELP_CONTENT_RE = /^\s*(\?|help)?\s*$/i;
|
||||
|
||||
/**
|
||||
* Registry of tools mounted under `xd://` for one session. `createTools`
|
||||
* mounts discoverable built-ins first; SDK assembly adds custom tools that do
|
||||
* not opt out. `read`/`write` consult it at execute time.
|
||||
*/
|
||||
export class XdevRegistry {
|
||||
/** Discoverable built-ins mounted at construction; never reconciled away. */
|
||||
#builtins = new Map<string, Tool>();
|
||||
/**
|
||||
* Dynamic mounts (custom, MCP, extension, autoresearch) — replaced wholesale
|
||||
* by {@link reconcile} as the active tool set changes, so a deactivated or
|
||||
* disconnected tool is no longer callable through a stale device.
|
||||
*/
|
||||
#dynamic = new Map<string, Tool>();
|
||||
/** Shared tool state consumed by the `xd://` presentation layer. */
|
||||
export interface XdevState {
|
||||
/** Canonical session tool map; direct and device dispatch read the same instances. */
|
||||
readonly tools: Map<string, Tool>;
|
||||
/** Ordered names currently presented as mounted devices. */
|
||||
readonly mountedNames: Set<string>;
|
||||
/** Names originating from built-in factories, used only for prompt presentation. */
|
||||
readonly builtInNames: Set<string>;
|
||||
/** Whether a name is active at the top level. */
|
||||
readonly isActive: (name: string) => boolean;
|
||||
/** Optional execution-only decorator, such as the ACP permission gate. */
|
||||
decorateExecution?(tool: Tool): Tool;
|
||||
}
|
||||
|
||||
constructor(builtins: Iterable<Tool>) {
|
||||
for (const tool of builtins) this.#builtins.set(tool.name, tool);
|
||||
}
|
||||
/** Full-doc character budget for system-prompt mounted-device sections. */
|
||||
export const XDEV_DOCS_TOTAL_BUDGET = 48_000;
|
||||
/** Per-device cap preventing one pathological description from starving later devices. */
|
||||
export const XDEV_DOCS_PER_DEVICE_CAP = 10_000;
|
||||
/** Description cap for external mounted tools; their full docs remain readable on demand. */
|
||||
export const XDEV_EXTERNAL_DESCRIPTION_CAP = 200;
|
||||
|
||||
/**
|
||||
* Replace the dynamic mount set while preserving the built-in devices. Order
|
||||
* follows `tools`; names absent from it are dropped. A built-in device is
|
||||
* never shadowed by a same-named dynamic entry.
|
||||
*/
|
||||
reconcile(tools: Iterable<Tool>): void {
|
||||
const next = new Map<string, Tool>();
|
||||
for (const tool of tools) {
|
||||
if (this.#builtins.has(tool.name)) continue;
|
||||
next.set(tool.name, tool);
|
||||
/** Resolve any enabled tool through the canonical session map. */
|
||||
export function resolveXdevTool(state: XdevState, name: string): Tool | undefined {
|
||||
if (!state.mountedNames.has(name) && !state.isActive(name)) return undefined;
|
||||
return state.tools.get(name);
|
||||
}
|
||||
|
||||
/** Resolve a mounted tool for top-level fallback execution. */
|
||||
export function resolveMountedXdevTool(state: XdevState, name: string): Tool | undefined {
|
||||
return state.mountedNames.has(name) ? state.tools.get(name) : undefined;
|
||||
}
|
||||
|
||||
/** Resolve a mounted tool with its execution-only permission decorator. */
|
||||
export function resolveMountedXdevExecutable(state: XdevState, name: string): Tool | undefined {
|
||||
const tool = resolveMountedXdevTool(state, name);
|
||||
return tool && state.decorateExecution ? state.decorateExecution(tool) : tool;
|
||||
}
|
||||
/** Mounted tools in presentation order, resolved from the canonical map. */
|
||||
export function listXdevTools(state: XdevState): Tool[] {
|
||||
return [...state.mountedNames].flatMap(name => {
|
||||
const tool = state.tools.get(name);
|
||||
return tool ? [tool] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** `{name, summary}` pairs for prompt templates and `/tools` display. */
|
||||
export function xdevEntries(state: XdevState): Array<{ name: string; summary: string }> {
|
||||
return listXdevTools(state).map(tool => ({
|
||||
name: tool.name,
|
||||
summary: promptCatalogSummary(
|
||||
tool,
|
||||
state.builtInNames.has(tool.name) ? undefined : XDEV_EXTERNAL_DESCRIPTION_CAP,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
/** `read xd://` listing with one device per line. */
|
||||
export function xdevListing(state: XdevState): string {
|
||||
const rows = xdevEntries(state).map(({ name, summary }) => `${XD_URL_PREFIX}${name.padEnd(14)} ${summary}`);
|
||||
return [
|
||||
`${XD_URL_PREFIX} ${state.mountedNames.size} mounted tool devices.`,
|
||||
...rows,
|
||||
"",
|
||||
`Read ${XD_URL_PREFIX}<tool> for docs + JSON schema; write the JSON args object to ${XD_URL_PREFIX}<tool> to execute. Active top-level tools accept the same dispatch.`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Docs + schema for any enabled tool. */
|
||||
export function xdevDocs(state: XdevState, name: string): string {
|
||||
return renderDocs(resolveRequiredXdevTool(state, name));
|
||||
}
|
||||
|
||||
/** Docs + schema for mounted devices under the configured prompt-doc policy. */
|
||||
export function xdevDocsAll(
|
||||
state: XdevState,
|
||||
mode: XdevDocsMode = "inline",
|
||||
inlinePatterns: readonly string[] = [],
|
||||
): string {
|
||||
const sections: string[] = [];
|
||||
const overflow: Tool[] = [];
|
||||
const inlineGlobs = compileInlineGlobs(inlinePatterns);
|
||||
let used = 0;
|
||||
for (const tool of listXdevTools(state)) {
|
||||
if (!shouldInlineXdevTool(state, tool, mode, inlineGlobs)) {
|
||||
overflow.push(tool);
|
||||
continue;
|
||||
}
|
||||
this.#dynamic = next;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.#builtins.size + this.#dynamic.size;
|
||||
}
|
||||
|
||||
/** Mounted tools in catalog order: built-ins first, then dynamic mounts. */
|
||||
list(): readonly Tool[] {
|
||||
return [...this.#builtins.values(), ...this.#dynamic.values()];
|
||||
}
|
||||
|
||||
get(name: string): Tool | undefined {
|
||||
return this.#builtins.get(name) ?? this.#dynamic.get(name);
|
||||
}
|
||||
|
||||
/** `{name, summary}` pairs for prompt templates and /tools display. */
|
||||
entries(): Array<{ name: string; summary: string }> {
|
||||
return this.list().map(tool => ({
|
||||
name: tool.name,
|
||||
summary: promptCatalogSummary(
|
||||
tool,
|
||||
this.#dynamic.has(tool.name) ? XdevRegistry.EXTERNAL_DESCRIPTION_CAP : undefined,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
/** `read xd://` listing with one device per line. */
|
||||
listing(): string {
|
||||
const rows = this.entries().map(({ name, summary }) => `${XD_URL_PREFIX}${name.padEnd(14)} ${summary}`);
|
||||
return [
|
||||
`${XD_URL_PREFIX} ${this.size} mounted tool devices.`,
|
||||
...rows,
|
||||
"",
|
||||
`Read ${XD_URL_PREFIX}<tool> for docs + JSON schema; write the JSON args object to ${XD_URL_PREFIX}<tool> to execute.`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Docs + schema for one device; throws with the listing when unknown. */
|
||||
docs(name: string): string {
|
||||
return renderDocs(this.#resolve(name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Char budget for the full docs inlined into the system prompt. Large MCP
|
||||
* catalogs previously shipped every schema top-level; without a cap they
|
||||
* would bloat every request. Devices past the budget fall back to a
|
||||
* one-line summary — their docs stay one `read xd://<tool>` away.
|
||||
*/
|
||||
static readonly DOCS_TOTAL_BUDGET = 48_000;
|
||||
/** A single device's docs above this size never inline: one pathological
|
||||
* MCP description must not starve every later device. */
|
||||
static readonly DOCS_PER_DEVICE_CAP = 10_000;
|
||||
/** Description cap for EXTERNAL devices (dynamic mounts: MCP, custom,
|
||||
* extension, …) in the system-prompt embedding. Built-in devices inline
|
||||
* their full curated docs; external descriptions are server-controlled
|
||||
* prose the model can re-fetch, so only the lede earns prompt space. */
|
||||
static readonly EXTERNAL_DESCRIPTION_CAP = 200;
|
||||
|
||||
/**
|
||||
* Docs + schema for mounted devices, nested under `##` headings for
|
||||
* system-prompt embedding. Inlines full docs in catalog order (built-ins
|
||||
* first) until {@link DOCS_TOTAL_BUDGET} is spent; the rest are listed by
|
||||
* name + summary with a pointer to on-demand `read xd://<tool>` docs.
|
||||
* Dynamic mounts embed at most {@link EXTERNAL_DESCRIPTION_CAP} description
|
||||
* chars (schema always intact); `read xd://<tool>` returns the full text.
|
||||
*/
|
||||
docsAll(mode: XdevDocsMode = "inline", inlinePatterns: readonly string[] = []): string {
|
||||
const sections: string[] = [];
|
||||
const overflow: Tool[] = [];
|
||||
const inlineGlobs = compileInlineGlobs(inlinePatterns);
|
||||
let used = 0;
|
||||
for (const tool of this.list()) {
|
||||
if (!this.#shouldInline(tool, mode, inlineGlobs)) {
|
||||
overflow.push(tool);
|
||||
continue;
|
||||
}
|
||||
const descriptionCap = this.#dynamic.has(tool.name) ? XdevRegistry.EXTERNAL_DESCRIPTION_CAP : undefined;
|
||||
const docs = renderDocs(tool, "##", descriptionCap);
|
||||
if (docs.length > XdevRegistry.DOCS_PER_DEVICE_CAP || used + docs.length > XdevRegistry.DOCS_TOTAL_BUDGET) {
|
||||
overflow.push(tool);
|
||||
continue;
|
||||
}
|
||||
used += docs.length;
|
||||
sections.push(docs);
|
||||
const descriptionCap = state.builtInNames.has(tool.name) ? undefined : XDEV_EXTERNAL_DESCRIPTION_CAP;
|
||||
const docs = renderDocs(tool, "##", descriptionCap);
|
||||
if (docs.length > XDEV_DOCS_PER_DEVICE_CAP || used + docs.length > XDEV_DOCS_TOTAL_BUDGET) {
|
||||
overflow.push(tool);
|
||||
continue;
|
||||
}
|
||||
if (overflow.length > 0) {
|
||||
sections.push(
|
||||
[
|
||||
"## Additional devices (docs on demand)",
|
||||
...overflow.map(tool => {
|
||||
const maxLength = this.#dynamic.has(tool.name) ? XdevRegistry.EXTERNAL_DESCRIPTION_CAP : undefined;
|
||||
return `- ${XD_URL_PREFIX}${tool.name} — ${promptCatalogSummary(tool, maxLength)}`;
|
||||
}),
|
||||
"",
|
||||
`Read ${XD_URL_PREFIX}<tool> for full docs + JSON schema before first use.`,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
return sections.join("\n\n");
|
||||
used += docs.length;
|
||||
sections.push(docs);
|
||||
}
|
||||
|
||||
/** Docs for selected mounted devices under the configured prompt-doc policy. */
|
||||
docsFor(names: Iterable<string>, mode: XdevDocsMode, inlinePatterns: readonly string[] = []): string {
|
||||
const sections: string[] = [];
|
||||
const inlineGlobs = compileInlineGlobs(inlinePatterns);
|
||||
let used = 0;
|
||||
for (const name of names) {
|
||||
const tool = this.get(name);
|
||||
if (!tool || !this.#shouldInline(tool, mode, inlineGlobs)) continue;
|
||||
const descriptionCap = this.#dynamic.has(tool.name) ? XdevRegistry.EXTERNAL_DESCRIPTION_CAP : undefined;
|
||||
const docs = renderDocs(tool, "##", descriptionCap);
|
||||
if (docs.length > XdevRegistry.DOCS_PER_DEVICE_CAP || used + docs.length > XdevRegistry.DOCS_TOTAL_BUDGET)
|
||||
continue;
|
||||
used += docs.length;
|
||||
sections.push(docs);
|
||||
}
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
#shouldInline(tool: Tool, mode: XdevDocsMode, inlineGlobs: readonly Bun.Glob[]): boolean {
|
||||
return (
|
||||
mode !== "catalog" &&
|
||||
(mode === "inline" || this.#builtins.has(tool.name) || inlineGlobs.some(glob => glob.match(tool.name)))
|
||||
if (overflow.length > 0) {
|
||||
sections.push(
|
||||
[
|
||||
"## Additional devices (docs on demand)",
|
||||
...overflow.map(tool => {
|
||||
const maxLength = state.builtInNames.has(tool.name) ? undefined : XDEV_EXTERNAL_DESCRIPTION_CAP;
|
||||
return `- ${XD_URL_PREFIX}${tool.name} — ${promptCatalogSummary(tool, maxLength)}`;
|
||||
}),
|
||||
"",
|
||||
`Read ${XD_URL_PREFIX}<tool> for full docs + JSON schema before first use.`,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
#resolve(name: string): Tool {
|
||||
const inst = this.get(name);
|
||||
if (!inst) {
|
||||
throw new ToolError(
|
||||
`No such tool device: ${XD_URL_PREFIX}${name}. Mounted: ${this.list()
|
||||
.map(tool => tool.name)
|
||||
.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
return inst;
|
||||
/** Docs for selected mounted devices under the configured prompt-doc policy. */
|
||||
export function xdevDocsFor(
|
||||
state: XdevState,
|
||||
names: Iterable<string>,
|
||||
mode: XdevDocsMode,
|
||||
inlinePatterns: readonly string[] = [],
|
||||
): string {
|
||||
const sections: string[] = [];
|
||||
const inlineGlobs = compileInlineGlobs(inlinePatterns);
|
||||
let used = 0;
|
||||
for (const name of names) {
|
||||
const tool = resolveMountedXdevTool(state, name);
|
||||
if (!tool || !shouldInlineXdevTool(state, tool, mode, inlineGlobs)) continue;
|
||||
const descriptionCap = state.builtInNames.has(tool.name) ? undefined : XDEV_EXTERNAL_DESCRIPTION_CAP;
|
||||
const docs = renderDocs(tool, "##", descriptionCap);
|
||||
if (docs.length > XDEV_DOCS_PER_DEVICE_CAP || used + docs.length > XDEV_DOCS_TOTAL_BUDGET) continue;
|
||||
used += docs.length;
|
||||
sections.push(docs);
|
||||
}
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a device write: `content` is the JSON args object (empty, `?`, or
|
||||
* `help` returns docs). Args validate against the wrapped tool's schema —
|
||||
* the schema comes back in the error on mismatch.
|
||||
*/
|
||||
async dispatch(
|
||||
name: string,
|
||||
content: string,
|
||||
toolCallId: string,
|
||||
signal?: AbortSignal,
|
||||
onUpdate?: AgentToolUpdateCallback,
|
||||
context?: AgentToolContext,
|
||||
): Promise<{ result: AgentToolResult<unknown>; xdev: XdevDispatch }> {
|
||||
let xdev: XdevDispatch = { tool: name, mode: "execute" };
|
||||
try {
|
||||
const inst = this.#resolve(name);
|
||||
function shouldInlineXdevTool(
|
||||
state: XdevState,
|
||||
tool: Tool,
|
||||
mode: XdevDocsMode,
|
||||
inlineGlobs: readonly Bun.Glob[],
|
||||
): boolean {
|
||||
return (
|
||||
mode !== "catalog" &&
|
||||
(mode === "inline" || state.builtInNames.has(tool.name) || inlineGlobs.some(glob => glob.match(tool.name)))
|
||||
);
|
||||
}
|
||||
|
||||
if (HELP_CONTENT_RE.test(content)) {
|
||||
return {
|
||||
result: { content: [{ type: "text", text: renderDocs(inst) }] },
|
||||
xdev: { tool: name, mode: "help" },
|
||||
};
|
||||
}
|
||||
function resolveRequiredXdevTool(state: XdevState, name: string): Tool {
|
||||
const inst = resolveXdevTool(state, name);
|
||||
if (!inst) {
|
||||
throw new ToolError(
|
||||
`No such tool: ${XD_URL_PREFIX}${name}. Mounted devices: ${[...state.mountedNames].join(", ")}. Active top-level tools are also dispatchable via ${XD_URL_PREFIX}<tool>.`,
|
||||
);
|
||||
}
|
||||
return inst;
|
||||
}
|
||||
|
||||
const validated = parseDeviceArgs(inst as AiTool, content, toolCallId, () => renderDocs(inst));
|
||||
xdev = { ...xdev, args: validated };
|
||||
const innerOnUpdate: AgentToolUpdateCallback | undefined = onUpdate
|
||||
? partial =>
|
||||
onUpdate({
|
||||
content: partial.content,
|
||||
details: { xdev: { ...xdev, inner: partial.details } },
|
||||
isError: partial.isError,
|
||||
})
|
||||
: undefined;
|
||||
const result = await inst.execute(toolCallId, validated as never, signal, innerOnUpdate, context);
|
||||
return { result, xdev: { ...xdev, inner: result.details } };
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ToolAbortError ||
|
||||
signal?.aborted ||
|
||||
(error instanceof Error && error.name === "AbortError")
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
/** Execute an enabled canonical tool through `write xd://<tool>`. */
|
||||
export async function dispatchXdevTool(
|
||||
state: XdevState,
|
||||
name: string,
|
||||
content: string,
|
||||
toolCallId: string,
|
||||
signal?: AbortSignal,
|
||||
onUpdate?: AgentToolUpdateCallback,
|
||||
context?: AgentToolContext,
|
||||
): Promise<{ result: AgentToolResult<unknown>; xdev: XdevDispatch }> {
|
||||
let xdev: XdevDispatch = { tool: name, mode: "execute" };
|
||||
try {
|
||||
const canonical = resolveRequiredXdevTool(state, name);
|
||||
|
||||
if (HELP_CONTENT_RE.test(content)) {
|
||||
return {
|
||||
result: {
|
||||
content: [{ type: "text", text: renderError(error) }],
|
||||
isError: true,
|
||||
},
|
||||
xdev,
|
||||
result: { content: [{ type: "text", text: renderDocs(canonical) }] },
|
||||
xdev: { tool: name, mode: "help" },
|
||||
};
|
||||
}
|
||||
|
||||
const validated = parseDeviceArgs(canonical as AiTool, content, toolCallId, () => renderDocs(canonical));
|
||||
xdev = { ...xdev, args: validated };
|
||||
const innerOnUpdate: AgentToolUpdateCallback | undefined = onUpdate
|
||||
? partial =>
|
||||
onUpdate({
|
||||
content: partial.content,
|
||||
details: { xdev: { ...xdev, inner: partial.details } },
|
||||
isError: partial.isError,
|
||||
})
|
||||
: undefined;
|
||||
const executable = state.decorateExecution?.(canonical) ?? canonical;
|
||||
const result = await executable.execute(toolCallId, validated as never, signal, innerOnUpdate, context);
|
||||
return { result, xdev: { ...xdev, inner: result.details } };
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ToolAbortError ||
|
||||
signal?.aborted ||
|
||||
(error instanceof Error && error.name === "AbortError")
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
result: {
|
||||
content: [{ type: "text", text: renderError(error) }],
|
||||
isError: true,
|
||||
},
|
||||
xdev,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import type {
|
||||
import { convertToLlm } from "@oh-my-pi/pi-coding-agent/session/messages";
|
||||
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
import { XdevRegistry } from "@oh-my-pi/pi-coding-agent/tools/xdev";
|
||||
import { dispatchXdevTool, resolveMountedXdevExecutable, type XdevState } from "@oh-my-pi/pi-coding-agent/tools/xdev";
|
||||
import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
import { type } from "arktype";
|
||||
|
||||
@@ -84,9 +84,8 @@ async function createSession(
|
||||
bridge?: ClientBridge,
|
||||
settingsOverrides: Partial<Record<SettingPath, unknown>> = {},
|
||||
options?: {
|
||||
xdevRegistry?: XdevRegistry;
|
||||
xdev?: XdevState;
|
||||
builtInToolNames?: string[];
|
||||
initialMountedXdevToolNames?: string[];
|
||||
persist?: boolean;
|
||||
},
|
||||
): Promise<AgentSession> {
|
||||
@@ -110,15 +109,16 @@ async function createSession(
|
||||
streamFn: () => new AssistantMessageEventStream(),
|
||||
});
|
||||
|
||||
const toolRegistry = options?.xdev?.tools ?? new Map<string, AgentTool>();
|
||||
for (const tool of tools) toolRegistry.set(tool.name, tool);
|
||||
const sess = new AgentSession({
|
||||
agent,
|
||||
sessionManager,
|
||||
settings,
|
||||
modelRegistry: {} as never,
|
||||
toolRegistry: new Map(tools.map(t => [t.name, t])),
|
||||
xdevRegistry: options?.xdevRegistry,
|
||||
toolRegistry,
|
||||
xdev: options?.xdev,
|
||||
builtInToolNames: options?.builtInToolNames,
|
||||
initialMountedXdevToolNames: options?.initialMountedXdevToolNames,
|
||||
});
|
||||
|
||||
if (bridge) sess.setClientBridge(bridge);
|
||||
@@ -270,29 +270,28 @@ it("delete and move tools request ACP permission before executing", async () =>
|
||||
expect(moveTool.executeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("mounted destructive tools retain the ACP permission gate", async () => {
|
||||
it("top-level fallback preserves ACP permission for mounted destructive tools", async () => {
|
||||
const readTool = makeFakeTool("read");
|
||||
const writeTool = makeFakeTool("write");
|
||||
const deleteTool = makeFakeTool("delete");
|
||||
deleteTool.loadMode = "discoverable";
|
||||
const bridge = makeBridge({ outcome: "selected", optionId: "allow_once", kind: "allow_once" });
|
||||
const permissionSpy = spyOn(bridge, "requestPermission");
|
||||
const xdevRegistry = new XdevRegistry([]);
|
||||
session = await createSession(
|
||||
[readTool, writeTool],
|
||||
bridge,
|
||||
{},
|
||||
{
|
||||
xdevRegistry,
|
||||
builtInToolNames: ["read", "write"],
|
||||
},
|
||||
);
|
||||
const tools = new Map([readTool, writeTool].map(tool => [tool.name, tool]));
|
||||
const xdev: XdevState = {
|
||||
tools,
|
||||
mountedNames: new Set(),
|
||||
builtInNames: new Set(["read", "write"]),
|
||||
isActive: name => name === "read" || name === "write",
|
||||
};
|
||||
session = await createSession([readTool, writeTool], bridge, {}, { xdev, builtInToolNames: ["read", "write"] });
|
||||
|
||||
await session.refreshRpcHostTools([deleteTool]);
|
||||
const mountedDelete = xdevRegistry.get("delete");
|
||||
expect(mountedDelete).toBeDefined();
|
||||
expect(xdev.mountedNames.has("delete")).toBe(true);
|
||||
expect(session.getActiveToolNames()).not.toContain("delete");
|
||||
await mountedDelete!.execute(
|
||||
const fallbackTool = resolveMountedXdevExecutable(xdev, "delete");
|
||||
expect(fallbackTool).toBeDefined();
|
||||
await fallbackTool!.execute(
|
||||
"call-mounted-delete",
|
||||
{ path: "/tmp/gone.ts" },
|
||||
undefined,
|
||||
@@ -311,28 +310,27 @@ it("startup-mounted destructive tools gain the ACP permission gate when the brid
|
||||
deleteTool.loadMode = "discoverable";
|
||||
const bridge = makeBridge({ outcome: "selected", optionId: "allow_once", kind: "allow_once" });
|
||||
const permissionSpy = spyOn(bridge, "requestPermission");
|
||||
const xdevRegistry = new XdevRegistry([]);
|
||||
xdevRegistry.reconcile([deleteTool]);
|
||||
const tools = new Map([readTool, writeTool, deleteTool].map(tool => [tool.name, tool]));
|
||||
const xdev: XdevState = {
|
||||
tools,
|
||||
mountedNames: new Set(["delete"]),
|
||||
builtInNames: new Set(["read", "write"]),
|
||||
isActive: name => name === "read" || name === "write",
|
||||
};
|
||||
session = await createSession(
|
||||
[readTool, writeTool, deleteTool],
|
||||
bridge,
|
||||
{},
|
||||
{
|
||||
xdevRegistry,
|
||||
builtInToolNames: ["read", "write"],
|
||||
initialMountedXdevToolNames: ["delete"],
|
||||
},
|
||||
{ xdev, builtInToolNames: ["read", "write"] },
|
||||
);
|
||||
|
||||
const mountedDelete = xdevRegistry.get("delete");
|
||||
expect(mountedDelete).toBeDefined();
|
||||
await mountedDelete!.execute(
|
||||
const dispatched = await dispatchXdevTool(
|
||||
xdev,
|
||||
"delete",
|
||||
JSON.stringify({ path: "/tmp/gone.ts" }),
|
||||
"call-startup-delete",
|
||||
{ path: "/tmp/gone.ts" },
|
||||
undefined,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
);
|
||||
expect(dispatched.result.isError).toBeUndefined();
|
||||
|
||||
expect(permissionSpy).toHaveBeenCalledTimes(1);
|
||||
expect(deleteTool.executeCalls).toBe(1);
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
collectMountedMCPToolRoutes,
|
||||
projectMountedMCPXdevGuidance,
|
||||
} from "@oh-my-pi/pi-coding-agent/session/session-tools";
|
||||
import { XdevRegistry } from "@oh-my-pi/pi-coding-agent/tools/xdev";
|
||||
import { listXdevTools, XDEV_EXTERNAL_DESCRIPTION_CAP, type XdevState } from "@oh-my-pi/pi-coding-agent/tools/xdev";
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
import { type } from "arktype";
|
||||
|
||||
@@ -76,6 +76,15 @@ function mountNoticesIn(messages: Message[]): string[] {
|
||||
});
|
||||
}
|
||||
|
||||
function createTestXdevState(): XdevState {
|
||||
return {
|
||||
tools: new Map(),
|
||||
mountedNames: new Set(),
|
||||
builtInNames: new Set(["read", "write"]),
|
||||
isActive: () => true,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AgentSession refreshMCPTools rebuild skipping", () => {
|
||||
const sessions: AgentSession[] = [];
|
||||
|
||||
@@ -89,7 +98,7 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => {
|
||||
interface NewSessionOptions {
|
||||
getMcpServerInstructions?: () => Map<string, string> | undefined;
|
||||
getLocalCalendarDate?: () => string;
|
||||
xdevRegistry?: XdevRegistry;
|
||||
xdev?: XdevState;
|
||||
lazyWrite?: boolean;
|
||||
/** Scripted mock model responses; enables driving `session.prompt()`. */
|
||||
responses?: MockResponseSource;
|
||||
@@ -108,11 +117,10 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => {
|
||||
const readTool = createBasicTool("read", "Read");
|
||||
const initialMcp = createMcpCustomTool("mcp__nucleus_search", "nucleus", "search", "Search nucleus");
|
||||
const writeTool = createBasicTool("write", "Write");
|
||||
const toolRegistry = new Map<string, AgentTool>([
|
||||
[readTool.name, readTool],
|
||||
[initialMcp.name, initialMcp as unknown as AgentTool],
|
||||
]);
|
||||
if (options.xdevRegistry && !options.lazyWrite) toolRegistry.set(writeTool.name, writeTool);
|
||||
const toolRegistry = options.xdev?.tools ?? new Map<string, AgentTool>();
|
||||
toolRegistry.set(readTool.name, readTool);
|
||||
toolRegistry.set(initialMcp.name, initialMcp as unknown as AgentTool);
|
||||
if (options.xdev && !options.lazyWrite) toolRegistry.set(writeTool.name, writeTool);
|
||||
const mock = options.responses ? createMockModel({ responses: options.responses }) : undefined;
|
||||
const contexts: Message[][] = [];
|
||||
const agent = new Agent({
|
||||
@@ -120,7 +128,7 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => {
|
||||
initialState: {
|
||||
model: createModel(),
|
||||
systemPrompt: ["initial"],
|
||||
tools: options.xdevRegistry
|
||||
tools: options.xdev
|
||||
? options.lazyWrite
|
||||
? [readTool, initialMcp as unknown as AgentTool]
|
||||
: [readTool, writeTool, initialMcp as unknown as AgentTool]
|
||||
@@ -141,9 +149,9 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => {
|
||||
settings: Settings.isolated({ "compaction.enabled": false }),
|
||||
modelRegistry: { getApiKey: async () => "test-key" } as never,
|
||||
toolRegistry,
|
||||
builtInToolNames: options.xdevRegistry && !options.lazyWrite ? ["read", "write"] : ["read"],
|
||||
builtInToolNames: options.xdev && !options.lazyWrite ? ["read", "write"] : ["read"],
|
||||
ensureWriteRegistered: async () => {
|
||||
if (!options.xdevRegistry) return false;
|
||||
if (!options.xdev) return false;
|
||||
if (!toolRegistry.has("write")) toolRegistry.set("write", writeTool);
|
||||
return true;
|
||||
},
|
||||
@@ -152,7 +160,7 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => {
|
||||
}),
|
||||
getMcpServerInstructions: options.getMcpServerInstructions,
|
||||
getLocalCalendarDate: options.getLocalCalendarDate,
|
||||
xdevRegistry: options.xdevRegistry,
|
||||
xdev: options.xdev,
|
||||
});
|
||||
sessions.push(session);
|
||||
return { session, contexts, toolRegistry };
|
||||
@@ -265,7 +273,7 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => {
|
||||
});
|
||||
|
||||
it("rebuilds generated guidance when its ordered mounted MCP route projection changes", async () => {
|
||||
const xdevRegistry = new XdevRegistry([]);
|
||||
const xdevState = createTestXdevState();
|
||||
const serverInstructions = new Map([
|
||||
["archive", "Archive instructions"],
|
||||
["nucleus", "Nucleus instructions"],
|
||||
@@ -275,14 +283,14 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => {
|
||||
const { session } = newSession(
|
||||
async () => {
|
||||
rebuildCount++;
|
||||
const projection = projectMountedMCPXdevGuidance(collectMountedMCPToolRoutes(xdevRegistry.list()));
|
||||
const projection = projectMountedMCPXdevGuidance(collectMountedMCPToolRoutes(listXdevTools(xdevState)));
|
||||
const generatedPrompt = `mounted:${projection.mappings
|
||||
.map(mapping => `${mapping.label}=${mapping.path}`)
|
||||
.join(",")}`;
|
||||
renderedPrompts.push(generatedPrompt);
|
||||
return generatedPrompt;
|
||||
},
|
||||
{ xdevRegistry, getMcpServerInstructions: () => serverInstructions },
|
||||
{ xdev: xdevState, getMcpServerInstructions: () => serverInstructions },
|
||||
);
|
||||
const search = createMcpCustomTool("mcp__nucleus_search", "nucleus", "search", "Search nucleus");
|
||||
const fetch = createMcpCustomTool("mcp__nucleus_fetch", "nucleus", "fetch", "Fetch nucleus");
|
||||
@@ -379,14 +387,14 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => {
|
||||
});
|
||||
|
||||
it("skips rebuild when only an omitted mounted MCP mapping changes", async () => {
|
||||
const xdevRegistry = new XdevRegistry([]);
|
||||
const xdevState = createTestXdevState();
|
||||
let rebuildCount = 0;
|
||||
const { session } = newSession(
|
||||
async () => {
|
||||
rebuildCount++;
|
||||
return "bounded mounted MCP guidance";
|
||||
},
|
||||
{ xdevRegistry },
|
||||
{ xdev: xdevState },
|
||||
);
|
||||
const tools = Array.from({ length: 65 }, (_, index) =>
|
||||
createMcpCustomTool(`mcp__archive_tool_${index}`, "archive", `tool_${index}`, "Archive tool"),
|
||||
@@ -404,14 +412,14 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => {
|
||||
});
|
||||
|
||||
it("skips rebuild when only non-MCP xd mounts change", async () => {
|
||||
const xdevRegistry = new XdevRegistry([]);
|
||||
const xdevState = createTestXdevState();
|
||||
let rebuildCount = 0;
|
||||
const { session, toolRegistry } = newSession(
|
||||
async toolNames => {
|
||||
rebuildCount++;
|
||||
return `tools:${toolNames.join(",")}`;
|
||||
},
|
||||
{ xdevRegistry },
|
||||
{ xdev: xdevState },
|
||||
);
|
||||
const search = createMcpCustomTool("mcp__nucleus_search", "nucleus", "search", "Search nucleus");
|
||||
const catalog = {
|
||||
@@ -812,7 +820,7 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => {
|
||||
return `tools:${toolNames.join(",")}`;
|
||||
},
|
||||
{
|
||||
xdevRegistry: new XdevRegistry([]),
|
||||
xdev: createTestXdevState(),
|
||||
responses: [
|
||||
async () => {
|
||||
firstCallStarted.resolve();
|
||||
@@ -863,10 +871,10 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => {
|
||||
|
||||
it("caps dynamic xd:// mount-notice summaries", async () => {
|
||||
const { session, contexts } = newSession(async toolNames => `tools:${toolNames.join(",")}`, {
|
||||
xdevRegistry: new XdevRegistry([]),
|
||||
xdev: createTestXdevState(),
|
||||
responses: [{ content: ["ok"] }],
|
||||
});
|
||||
const description = `Search ${"x".repeat(XdevRegistry.EXTERNAL_DESCRIPTION_CAP * 3)} TAIL`;
|
||||
const description = `Search ${"x".repeat(XDEV_EXTERNAL_DESCRIPTION_CAP * 3)} TAIL`;
|
||||
const search = createMcpCustomTool("mcp__nucleus_search", "nucleus", "search", description);
|
||||
|
||||
await session.refreshMCPTools([search]);
|
||||
@@ -880,7 +888,7 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => {
|
||||
|
||||
it("inlines configured late xd:// device docs in mount notices", async () => {
|
||||
const { session, contexts } = newSession(async toolNames => `tools:${toolNames.join(",")}`, {
|
||||
xdevRegistry: new XdevRegistry([]),
|
||||
xdev: createTestXdevState(),
|
||||
responses: [{ content: ["ok"] }],
|
||||
});
|
||||
session.settings.set("tools.xdevDocs", "builtins");
|
||||
@@ -898,7 +906,7 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => {
|
||||
|
||||
it("drops a mount delta that cancels out before the next prompt", async () => {
|
||||
const { session, contexts } = newSession(async toolNames => `tools:${toolNames.join(",")}`, {
|
||||
xdevRegistry: new XdevRegistry([]),
|
||||
xdev: createTestXdevState(),
|
||||
responses: [{ content: ["ok"] }],
|
||||
});
|
||||
const search = createMcpCustomTool("mcp__nucleus_search", "nucleus", "search", "Search nucleus");
|
||||
@@ -920,7 +928,7 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => {
|
||||
|
||||
it("keeps xd:// mount deltas model-visible without rendering them during quiet startup", async () => {
|
||||
const { session, contexts } = newSession(async toolNames => `tools:${toolNames.join(",")}`, {
|
||||
xdevRegistry: new XdevRegistry([]),
|
||||
xdev: createTestXdevState(),
|
||||
responses: [{ content: ["ok"] }],
|
||||
});
|
||||
session.settings.set("startup.quiet", true);
|
||||
@@ -941,13 +949,13 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => {
|
||||
|
||||
it("keeps lazy write registration while rolling back applied state on rebuild failure", async () => {
|
||||
let failRebuild = true;
|
||||
const xdevRegistry = new XdevRegistry([]);
|
||||
const xdevState = createTestXdevState();
|
||||
const { session } = newSession(
|
||||
async toolNames => {
|
||||
if (failRebuild) throw new Error("rebuild failed");
|
||||
return `tools:${toolNames.join(",")}`;
|
||||
},
|
||||
{ xdevRegistry, lazyWrite: true },
|
||||
{ xdev: xdevState, lazyWrite: true },
|
||||
);
|
||||
const search = createMcpCustomTool("mcp__nucleus_search", "nucleus", "search", "Search nucleus");
|
||||
const activeBefore = session.getActiveToolNames();
|
||||
@@ -969,13 +977,13 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => {
|
||||
it("rolls back MCP catalog replacement when prompt rebuild fails", async () => {
|
||||
let failRebuild = false;
|
||||
let date = "2026-07-16";
|
||||
const xdevRegistry = new XdevRegistry([]);
|
||||
const xdevState = createTestXdevState();
|
||||
const { session } = newSession(
|
||||
async toolNames => {
|
||||
if (failRebuild) throw new Error("rebuild failed");
|
||||
return `tools:${toolNames.join(",")}`;
|
||||
},
|
||||
{ xdevRegistry, getLocalCalendarDate: () => date },
|
||||
{ xdev: xdevState, getLocalCalendarDate: () => date },
|
||||
);
|
||||
const oldTool = createMcpCustomTool("mcp__nucleus_old", "nucleus", "old", "Old tool");
|
||||
const newTool = createMcpCustomTool("mcp__nucleus_new", "nucleus", "new", "New tool");
|
||||
@@ -998,13 +1006,13 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => {
|
||||
it("rolls back RPC catalog replacement when prompt rebuild fails", async () => {
|
||||
let failRebuild = false;
|
||||
let date = "2026-07-16";
|
||||
const xdevRegistry = new XdevRegistry([]);
|
||||
const xdevState = createTestXdevState();
|
||||
const { session } = newSession(
|
||||
async toolNames => {
|
||||
if (failRebuild) throw new Error("rebuild failed");
|
||||
return `tools:${toolNames.join(",")}`;
|
||||
},
|
||||
{ xdevRegistry, getLocalCalendarDate: () => date },
|
||||
{ xdev: xdevState, getLocalCalendarDate: () => date },
|
||||
);
|
||||
const oldTool = { ...createBasicTool("rpc_old", "RPC Old"), loadMode: "discoverable" as const };
|
||||
const newTool = { ...createBasicTool("rpc_new", "RPC New"), loadMode: "discoverable" as const };
|
||||
|
||||
@@ -121,9 +121,11 @@ describe("read tool follows actual tool availability", () => {
|
||||
...(options.xdevMounted === undefined
|
||||
? {}
|
||||
: {
|
||||
xdevRegistry: {
|
||||
get: (name: string) =>
|
||||
name === "inspect_image" && options.xdevMounted ? { name: "inspect_image" } : undefined,
|
||||
xdev: {
|
||||
tools: new Map(),
|
||||
mountedNames: new Set(options.xdevMounted ? ["inspect_image"] : []),
|
||||
builtInNames: new Set(["inspect_image"]),
|
||||
isActive: () => false,
|
||||
},
|
||||
}),
|
||||
} as unknown as ToolSession;
|
||||
|
||||
@@ -12,7 +12,7 @@ import { type } from "arktype";
|
||||
import { ModelRegistry } from "../src/config/model-registry";
|
||||
import type { CustomTool } from "../src/extensibility/custom-tools/types";
|
||||
import { InteractiveMode } from "../src/modes/interactive-mode";
|
||||
import { XdevRegistry } from "../src/tools/xdev";
|
||||
import { resolveXdevTool, type XdevState } from "../src/tools/xdev";
|
||||
|
||||
function makeTool(name: string): AgentTool {
|
||||
return {
|
||||
@@ -30,7 +30,7 @@ interface HarnessOptions {
|
||||
extraRegistryTools?: readonly AgentTool[];
|
||||
builtInToolNames?: Iterable<string>;
|
||||
rebuildGate?: { fail: boolean; calls?: number };
|
||||
xdevRegistry?: XdevRegistry;
|
||||
xdev?: XdevState;
|
||||
}
|
||||
|
||||
describe("InteractiveMode plan.defaultOnStartup", () => {
|
||||
@@ -83,12 +83,13 @@ describe("InteractiveMode plan.defaultOnStartup", () => {
|
||||
// AgentSession requires a Map-typed tool registry; `read` is the initial
|
||||
// active tool. Plan approval is a `write` to xd://propose, so plan-mode
|
||||
// entry only augments the built-in `write` tool when present.
|
||||
const toolRegistry = new Map<string, AgentTool>([[readTool.name, readTool]]);
|
||||
const xdev = options.xdev;
|
||||
const toolRegistry = xdev?.tools ?? new Map<string, AgentTool>();
|
||||
toolRegistry.set(readTool.name, readTool);
|
||||
for (const tool of options.extraRegistryTools ?? []) {
|
||||
toolRegistry.set(tool.name, tool);
|
||||
}
|
||||
const manager = SessionManager.create(tempDir.path(), path.join(tempDir.path(), `active-${Bun.nanoseconds()}`));
|
||||
const xdevRegistry = options.xdevRegistry;
|
||||
const createdSession = new AgentSession({
|
||||
agent: new Agent({
|
||||
initialState: {
|
||||
@@ -111,7 +112,7 @@ describe("InteractiveMode plan.defaultOnStartup", () => {
|
||||
return { systemPrompt: ["Test"] };
|
||||
}
|
||||
: undefined,
|
||||
xdevRegistry,
|
||||
xdev,
|
||||
});
|
||||
session = createdSession;
|
||||
mode = new InteractiveMode(createdSession, "test");
|
||||
@@ -217,11 +218,16 @@ describe("InteractiveMode plan.defaultOnStartup", () => {
|
||||
return { content: [{ type: "text", text: "ok" }] };
|
||||
},
|
||||
};
|
||||
const xdevRegistry = new XdevRegistry([]);
|
||||
const xdev: XdevState = {
|
||||
tools: new Map(),
|
||||
mountedNames: new Set(),
|
||||
builtInNames: new Set(["read", "write"]),
|
||||
isActive: name => session?.getActiveToolNames().includes(name) === true,
|
||||
};
|
||||
const created = createHarness(settings, {
|
||||
extraRegistryTools: [writeTool, planSelectedTool],
|
||||
builtInToolNames: ["read", "write"],
|
||||
xdevRegistry,
|
||||
xdev,
|
||||
});
|
||||
const previousModel = session?.model;
|
||||
await created.init({ suppressWelcomeIntro: true });
|
||||
@@ -235,7 +241,7 @@ describe("InteractiveMode plan.defaultOnStartup", () => {
|
||||
expect(session?.configuredThinkingLevel()).toBe(Effort.High);
|
||||
expect(planActiveTools).toEqual(["read", "write", planSelectedTool.name]);
|
||||
expect(planMountedTools).toEqual([mountedTool.name]);
|
||||
expect(xdevRegistry.get(mountedTool.name)?.name).toBe(mountedTool.name);
|
||||
expect(xdev.mountedNames.has(mountedTool.name)).toBe(true);
|
||||
|
||||
const setModelTemporary = session!.setModelTemporary.bind(session);
|
||||
const restoreModel = vi.spyOn(session!, "setModelTemporary").mockImplementationOnce(async (...args) => {
|
||||
@@ -253,7 +259,7 @@ describe("InteractiveMode plan.defaultOnStartup", () => {
|
||||
expect(session?.getEnabledToolNames()).toEqual(planTools);
|
||||
expect(session?.getActiveToolNames()).toEqual(planActiveTools);
|
||||
expect(session?.getMountedXdevToolNames()).toEqual(planMountedTools);
|
||||
expect(xdevRegistry.get(mountedTool.name)?.name).toBe(mountedTool.name);
|
||||
expect(xdev.mountedNames.has(mountedTool.name)).toBe(true);
|
||||
|
||||
restoreModel.mockRestore();
|
||||
await created.handlePlanModeCommand();
|
||||
@@ -264,7 +270,8 @@ describe("InteractiveMode plan.defaultOnStartup", () => {
|
||||
// pre-plan tool set drops the MCP device and plan-only selections entirely.
|
||||
expect(session?.getActiveToolNames()).toEqual(["read"]);
|
||||
expect(session?.getMountedXdevToolNames()).toEqual([]);
|
||||
expect(xdevRegistry.get(mountedTool.name)).toBeUndefined();
|
||||
expect(xdev.tools.has(mountedTool.name)).toBe(true);
|
||||
expect(resolveXdevTool(xdev, mountedTool.name)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears old plan UI state when target-session reconciliation restore fails", async () => {
|
||||
|
||||
@@ -148,12 +148,12 @@ describe("createTools", () => {
|
||||
expect(names).toEqual(["read", "write"]);
|
||||
});
|
||||
|
||||
it("creates an xd:// registry without remounting explicitly requested built-ins", async () => {
|
||||
it("creates xd:// presentation state without remounting explicitly requested built-ins", async () => {
|
||||
const session = createTestSession();
|
||||
const tools = await createTools(session, ["read", "lsp"]);
|
||||
|
||||
expect(session.xdevRegistry).toBeDefined();
|
||||
expect(session.xdevRegistry?.entries()).toEqual([]);
|
||||
expect(session.xdev).toBeDefined();
|
||||
expect(session.xdev?.mountedNames.size).toBe(0);
|
||||
expect(tools.map(tool => tool.name)).toEqual(["read", "lsp"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,11 +6,20 @@ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import * as themeModule from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||
import { ToolChoiceQueue } from "@oh-my-pi/pi-coding-agent/session/tool-choice-queue";
|
||||
import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
import { createTools, type Tool, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
import { githubToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/gh-renderer";
|
||||
import { ToolError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors";
|
||||
import { WriteTool, writeToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/write";
|
||||
import { XdevRegistry } from "@oh-my-pi/pi-coding-agent/tools/xdev";
|
||||
import {
|
||||
listXdevTools,
|
||||
resolveMountedXdevTool,
|
||||
XDEV_DOCS_PER_DEVICE_CAP,
|
||||
XDEV_DOCS_TOTAL_BUDGET,
|
||||
XDEV_EXTERNAL_DESCRIPTION_CAP,
|
||||
type XdevState,
|
||||
xdevDocs,
|
||||
xdevDocsAll,
|
||||
} from "@oh-my-pi/pi-coding-agent/tools/xdev";
|
||||
import { removeWithRetries } from "@oh-my-pi/pi-utils";
|
||||
import { type } from "arktype";
|
||||
|
||||
@@ -31,6 +40,15 @@ function xdevSession(cwd: string, overrides: Partial<ToolSession> = {}): ToolSes
|
||||
};
|
||||
}
|
||||
|
||||
function createTestXdevState(tools: Tool[], builtInNames: Iterable<string> = tools.map(tool => tool.name)): XdevState {
|
||||
return {
|
||||
tools: new Map(tools.map(tool => [tool.name, tool])),
|
||||
mountedNames: new Set(tools.map(tool => tool.name)),
|
||||
builtInNames: new Set(builtInNames),
|
||||
isActive: () => false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("read and write route xd:// device URLs", () => {
|
||||
it("lists, documents, and dispatches an ast_edit device", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "write-xdev-"));
|
||||
@@ -201,8 +219,8 @@ describe("read and write route xd:// device URLs", () => {
|
||||
throw new ToolError("gh: Not Found (HTTP 404)");
|
||||
},
|
||||
};
|
||||
const registry = new XdevRegistry([githubDevice]);
|
||||
const write = new WriteTool(xdevSession(process.cwd(), { xdevRegistry: registry }));
|
||||
const xdev = createTestXdevState([githubDevice]);
|
||||
const write = new WriteTool(xdevSession(process.cwd(), { xdev }));
|
||||
const content = JSON.stringify({ op: "repo_view" });
|
||||
|
||||
const result = await write.execute("write-xdev-error", { path: "xd://github", content });
|
||||
@@ -218,7 +236,7 @@ describe("read and write route xd:// device URLs", () => {
|
||||
{
|
||||
expanded: false,
|
||||
isPartial: false,
|
||||
renderContext: { resolveXdevMounted: name => registry.get(name) },
|
||||
renderContext: { resolveXdevMounted: name => resolveMountedXdevTool(xdev, name) },
|
||||
},
|
||||
uiTheme,
|
||||
{ path: "xd://github", content },
|
||||
@@ -243,8 +261,8 @@ describe("read and write route xd:// device URLs", () => {
|
||||
return { content: [{ type: "text", text: "Tokyo: 22°C" }] };
|
||||
},
|
||||
};
|
||||
const registry = new XdevRegistry([weatherDevice]);
|
||||
const write = new WriteTool(xdevSession(process.cwd(), { xdevRegistry: registry }));
|
||||
const xdev = createTestXdevState([weatherDevice]);
|
||||
const write = new WriteTool(xdevSession(process.cwd(), { xdev }));
|
||||
const content = JSON.stringify({ query: "Tokyo" });
|
||||
const result = await write.execute("write-xdev-default-renderer", {
|
||||
path: "xd://weather",
|
||||
@@ -256,7 +274,7 @@ describe("read and write route xd:// device URLs", () => {
|
||||
{
|
||||
expanded: false,
|
||||
isPartial: false,
|
||||
renderContext: { resolveXdevMounted: name => registry.get(name) },
|
||||
renderContext: { resolveXdevMounted: name => resolveMountedXdevTool(xdev, name) },
|
||||
},
|
||||
uiTheme,
|
||||
{ path: "xd://weather", content },
|
||||
@@ -279,18 +297,22 @@ describe("read and write route xd:// device URLs", () => {
|
||||
const session = xdevSession(tempDir);
|
||||
expect(session.settings.get("tools.xdevDocs")).toBe("builtins");
|
||||
await createTools(session);
|
||||
const mounted = session.xdevRegistry?.list() ?? [];
|
||||
const xdev = session.xdev;
|
||||
if (!xdev) throw new Error("expected xdev state");
|
||||
const mounted = listXdevTools(xdev);
|
||||
expect(mounted.length).toBeGreaterThan(0);
|
||||
|
||||
// One device with a pathological description must fall back to the
|
||||
// listing without starving the rest of the catalog.
|
||||
const giant = Object.create(mounted[0]!) as (typeof mounted)[number];
|
||||
Object.defineProperty(giant, "name", { value: "giant_mcp_tool" });
|
||||
Object.defineProperty(giant, "description", { value: "x".repeat(XdevRegistry.DOCS_PER_DEVICE_CAP + 1) });
|
||||
const registry = new XdevRegistry([...mounted, giant]);
|
||||
Object.defineProperty(giant, "description", { value: "x".repeat(XDEV_DOCS_PER_DEVICE_CAP + 1) });
|
||||
xdev.tools.set(giant.name, giant);
|
||||
xdev.mountedNames.add(giant.name);
|
||||
xdev.builtInNames.add(giant.name);
|
||||
|
||||
const docs = registry.docsAll();
|
||||
expect(docs.length).toBeLessThan(XdevRegistry.DOCS_TOTAL_BUDGET + XdevRegistry.DOCS_PER_DEVICE_CAP);
|
||||
const docs = xdevDocsAll(xdev);
|
||||
expect(docs.length).toBeLessThan(XDEV_DOCS_TOTAL_BUDGET + XDEV_DOCS_PER_DEVICE_CAP);
|
||||
expect(docs).toContain(`## ${mounted[0]!.name}`);
|
||||
expect(docs).toContain("## Additional devices (docs on demand)");
|
||||
expect(docs).toContain("- xd://giant_mcp_tool —");
|
||||
@@ -306,56 +328,61 @@ describe("read and write route xd:// device URLs", () => {
|
||||
const session = xdevSession(tempDir);
|
||||
expect(session.settings.get("tools.xdevDocs")).toBe("builtins");
|
||||
await createTools(session);
|
||||
const registry = session.xdevRegistry;
|
||||
if (!registry) throw new Error("expected xdev registry");
|
||||
const mounted = registry.list();
|
||||
const xdev = session.xdev;
|
||||
if (!xdev) throw new Error("expected xdev state");
|
||||
const mounted = listXdevTools(xdev);
|
||||
const builtInMountedNames = [...xdev.mountedNames];
|
||||
|
||||
const longDescription = `LEDE ${"y".repeat(XdevRegistry.EXTERNAL_DESCRIPTION_CAP * 3)} TAIL`;
|
||||
const longDescription = `LEDE ${"y".repeat(XDEV_EXTERNAL_DESCRIPTION_CAP * 3)} TAIL`;
|
||||
const external = Object.create(mounted[0]!) as (typeof mounted)[number];
|
||||
Object.defineProperty(external, "name", { value: "mcp_external_tool" });
|
||||
Object.defineProperty(external, "description", { value: longDescription });
|
||||
Object.defineProperty(external, "summary", {
|
||||
value: `SUMMARY ${"z".repeat(XdevRegistry.EXTERNAL_DESCRIPTION_CAP * 3)} TAIL`,
|
||||
value: `SUMMARY ${"z".repeat(XDEV_EXTERNAL_DESCRIPTION_CAP * 3)} TAIL`,
|
||||
});
|
||||
registry.reconcile([external]);
|
||||
xdev.tools.set(external.name, external);
|
||||
xdev.mountedNames.add(external.name);
|
||||
|
||||
const inlineDocs = registry.docsAll("inline");
|
||||
const inlineDocs = xdevDocsAll(xdev, "inline");
|
||||
expect(inlineDocs).toContain("## mcp_external_tool");
|
||||
expect(inlineDocs).toContain("LEDE ");
|
||||
expect(inlineDocs).not.toContain("TAIL");
|
||||
expect(inlineDocs).toContain("… (full docs: read xd://mcp_external_tool)");
|
||||
|
||||
const builtinsDocs = registry.docsAll("builtins");
|
||||
const builtinsDocs = xdevDocsAll(xdev, "builtins");
|
||||
expect(builtinsDocs).toContain("## ");
|
||||
expect(builtinsDocs).not.toContain("## mcp_external_tool");
|
||||
expect(builtinsDocs).toContain("- xd://mcp_external_tool —");
|
||||
expect(builtinsDocs).not.toContain("TAIL");
|
||||
const catalogDocs = registry.docsAll("catalog");
|
||||
const catalogDocs = xdevDocsAll(xdev, "catalog");
|
||||
expect(catalogDocs).not.toContain(`## ${mounted[0]!.name}`);
|
||||
expect(catalogDocs).toContain("- xd://");
|
||||
expect(catalogDocs).toContain("- xd://mcp_external_tool —");
|
||||
expect(registry.docs("mcp_external_tool")).toContain("TAIL");
|
||||
expect(xdevDocs(xdev, "mcp_external_tool")).toContain("TAIL");
|
||||
|
||||
const contextMode = Object.create(mounted[0]!) as (typeof mounted)[number];
|
||||
Object.defineProperty(contextMode, "name", { value: "mcp__context_mode_ctx_execute" });
|
||||
const unrelatedMcp = Object.create(mounted[0]!) as (typeof mounted)[number];
|
||||
Object.defineProperty(unrelatedMcp, "name", { value: "mcp__other_server_execute" });
|
||||
registry.reconcile([contextMode, unrelatedMcp]);
|
||||
xdev.tools.set(contextMode.name, contextMode);
|
||||
xdev.tools.set(unrelatedMcp.name, unrelatedMcp);
|
||||
xdev.mountedNames.clear();
|
||||
for (const name of [...builtInMountedNames, contextMode.name, unrelatedMcp.name]) xdev.mountedNames.add(name);
|
||||
|
||||
const allowlistedDocs = registry.docsAll("builtins", ["mcp__context_mode_*"]);
|
||||
const allowlistedDocs = xdevDocsAll(xdev, "builtins", ["mcp__context_mode_*"]);
|
||||
expect(allowlistedDocs).toContain("## mcp__context_mode_ctx_execute");
|
||||
expect(allowlistedDocs).not.toContain("## mcp__other_server_execute");
|
||||
expect(allowlistedDocs).toContain("- xd://mcp__other_server_execute —");
|
||||
|
||||
const catalogWithAllowlistDocs = registry.docsAll("catalog", ["mcp__context_mode_*"]);
|
||||
const catalogWithAllowlistDocs = xdevDocsAll(xdev, "catalog", ["mcp__context_mode_*"]);
|
||||
expect(catalogWithAllowlistDocs).not.toContain("## mcp__context_mode_ctx_execute");
|
||||
|
||||
// Malformed user config (scalar or non-string entries reach the
|
||||
// registry unvalidated) degrades to the catalog listing instead of
|
||||
// throwing while the system prompt is built.
|
||||
const scalarAllowlistDocs = registry.docsAll("builtins", "mcp__context_mode_*" as never);
|
||||
const scalarAllowlistDocs = xdevDocsAll(xdev, "builtins", "mcp__context_mode_*" as never);
|
||||
expect(scalarAllowlistDocs).toContain("- xd://mcp__context_mode_ctx_execute —");
|
||||
const nonStringAllowlistDocs = registry.docsAll("builtins", [123] as never);
|
||||
const nonStringAllowlistDocs = xdevDocsAll(xdev, "builtins", [123] as never);
|
||||
expect(nonStringAllowlistDocs).toContain("- xd://mcp__context_mode_ctx_execute —");
|
||||
} finally {
|
||||
await removeWithRetries(tempDir);
|
||||
@@ -364,7 +391,7 @@ describe("read and write route xd:// device URLs", () => {
|
||||
});
|
||||
|
||||
describe("web_search stays top-level under xdev", () => {
|
||||
it("keeps web_search a direct tool and off the xd:// registry with default config", async () => {
|
||||
it("keeps web_search direct and out of the mounted-name set with default config", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "write-xdev-websearch-"));
|
||||
try {
|
||||
const session = xdevSession(tempDir);
|
||||
@@ -374,8 +401,70 @@ describe("web_search stays top-level under xdev", () => {
|
||||
// Regression for #5973: models call web_search directly, so it must
|
||||
// remain a top-level function and never mount behind the xd:// device.
|
||||
expect(tools.some(entry => entry.name === "web_search")).toBe(true);
|
||||
const mounted = session.xdevRegistry ? [...session.xdevRegistry.list()].map(t => t.name) : [];
|
||||
const mounted = session.xdev ? [...session.xdev.mountedNames] : [];
|
||||
expect(mounted).not.toContain("web_search");
|
||||
|
||||
const write = tools.find(tool => tool.name === "write");
|
||||
const read = tools.find(tool => tool.name === "read");
|
||||
expect(write).toBeDefined();
|
||||
expect(read).toBeDefined();
|
||||
|
||||
const docs = await read!.execute("read-xdev-web-search", { path: "xd://web_search" });
|
||||
expect(docs.content.find(entry => entry.type === "text")?.text).toContain("# web_search");
|
||||
|
||||
// Missing required args fails schema validation after routing to web_search,
|
||||
// rather than failing lookup because the tool is top-level.
|
||||
const dispatched = await write!.execute("write-xdev-web-search", {
|
||||
path: "xd://web_search",
|
||||
content: "{}",
|
||||
});
|
||||
expect(dispatched.isError).toBe(true);
|
||||
expect(dispatched.details?.xdev?.tool).toBe("web_search");
|
||||
expect(dispatched.content.find(entry => entry.type === "text")?.text).not.toContain("No such tool");
|
||||
} finally {
|
||||
await removeWithRetries(tempDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("xd:// and top-level calls share the canonical tool map", () => {
|
||||
it("dispatches and documents an unmounted top-level tool, and still rejects unknown names", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "write-xdev-fallback-"));
|
||||
try {
|
||||
await Bun.write(path.join(tempDir, "haystack.txt"), "alpha\nfallback-needle\nomega\n");
|
||||
const session = xdevSession(tempDir);
|
||||
const tools = await createTools(session);
|
||||
const write = tools.find(entry => entry.name === "write");
|
||||
const read = tools.find(entry => entry.name === "read");
|
||||
expect(write).toBeDefined();
|
||||
expect(read).toBeDefined();
|
||||
|
||||
// grep is kept top-level (XDEV_KEEP_TOP_LEVEL) and thus not a mounted
|
||||
// device — the unified namespace must still dispatch it via xd://.
|
||||
const mounted = [...session.xdev!.mountedNames];
|
||||
expect(mounted).not.toContain("grep");
|
||||
|
||||
const dispatched = await write!.execute("write-xdev-fallback-grep", {
|
||||
path: "xd://grep",
|
||||
content: JSON.stringify({ pattern: "fallback-needle", path: tempDir }),
|
||||
});
|
||||
expect(dispatched.isError).toBeUndefined();
|
||||
expect(dispatched.details?.xdev?.tool).toBe("grep");
|
||||
expect(dispatched.content.find(entry => entry.type === "text")?.text).toContain("fallback-needle");
|
||||
|
||||
// Docs resolve through the same fallback.
|
||||
const docs = await read!.execute("read-xdev-fallback-grep", { path: "xd://grep" });
|
||||
expect(docs.content.find(entry => entry.type === "text")?.text).toContain("# grep");
|
||||
|
||||
// Genuinely unknown names still fail with the catalog error.
|
||||
const unknown = await write!.execute("write-xdev-fallback-unknown", {
|
||||
path: "xd://no_such_tool",
|
||||
content: "{}",
|
||||
});
|
||||
expect(unknown.isError).toBe(true);
|
||||
expect(unknown.content.find(entry => entry.type === "text")?.text).toContain(
|
||||
"No such tool: xd://no_such_tool",
|
||||
);
|
||||
} finally {
|
||||
await removeWithRetries(tempDir);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user