From f4641c165e306bec4be2930321abbc4c0074a444 Mon Sep 17 00:00:00 2001 From: Joe Shull <42043763+joeshull@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:06:57 -0600 Subject: [PATCH] feat: allowlist xdev prompt docs --- .../src/config/settings-schema.ts | 12 +++++ .../modes/controllers/selector-controller.ts | 5 ++ .../src/prompts/system/xdev-mount-notice.md | 4 ++ packages/coding-agent/src/sdk.ts | 6 ++- .../coding-agent/src/session/session-tools.ts | 7 ++- packages/coding-agent/src/tools/xdev.ts | 54 +++++++++++++++++-- .../agent-session-tool-rebuild-skip.test.ts | 35 ++++++++++++ .../selector-controller-settings.test.ts | 20 +++++++ .../test/write-xdev-dispatch.test.ts | 21 +++++++- 9 files changed, 155 insertions(+), 9 deletions(-) create mode 100644 packages/coding-agent/test/modes/controllers/selector-controller-settings.test.ts diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 0b693f077..cabd381d5 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -4058,6 +4058,18 @@ export const SETTINGS_SCHEMA = { }, }, + "tools.xdevInlineDevices": { + type: "array", + default: EMPTY_STRING_ARRAY, + ui: { + tab: "tools", + group: "Discovery & MCP", + label: "xd:// Inline Devices", + description: + "When xd:// Prompt Docs is Built-ins Only, inline dynamic devices whose names match these glob patterns (for example mcp__context_mode_*). Catalog Only ignores this setting.", + }, + }, + // MCP "mcp.enableProjectConfig": { type: "boolean", diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 4bf5681dd..c1ac0c477 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -437,6 +437,11 @@ export class SelectorController { this.ctx.showError(`Failed to apply personality: ${err}`); }); break; + case "tools.xdevDocs": + void this.ctx.session.refreshBaseSystemPrompt().catch(err => { + this.ctx.showError(`Failed to apply xd:// prompt docs setting: ${err}`); + }); + break; case "memory.backend": void this.ctx.session.applyMemoryBackend().catch(err => { this.ctx.showError(`Failed to apply memory backend: ${err}`); diff --git a/packages/coding-agent/src/prompts/system/xdev-mount-notice.md b/packages/coding-agent/src/prompts/system/xdev-mount-notice.md index ff32af062..28778fc6a 100644 --- a/packages/coding-agent/src/prompts/system/xdev-mount-notice.md +++ b/packages/coding-agent/src/prompts/system/xdev-mount-notice.md @@ -13,4 +13,8 @@ No longer mounted (writes to these devices will fail): - xd://{{this.name}} {{/each}} {{/if}} +{{#if docs}} +Configured inline device docs: +{{docs}} +{{/if}} diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 80e6cd58e..36c147a68 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -2596,7 +2596,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} cwd, additionalWorkspaceRoots: sessionManager.getAdditionalDirectories(), xdevTools: toolSession.xdevRegistry?.entries() ?? [], - xdevDocs: toolSession.xdevRegistry?.docsAll(settings.get("tools.xdevDocs")) ?? "", + xdevDocs: + toolSession.xdevRegistry?.docsAll( + settings.get("tools.xdevDocs"), + settings.get("tools.xdevInlineDevices"), + ) ?? "", autoQaEnabled: !restrictToolNames && isAutoQaEnabled(settings), resolvedCustomPrompt: options.customSystemPrompt, skills: session?.skills ?? skills, diff --git a/packages/coding-agent/src/session/session-tools.ts b/packages/coding-agent/src/session/session-tools.ts index fab146740..63672050c 100644 --- a/packages/coding-agent/src/session/session-tools.ts +++ b/packages/coding-agent/src/session/session-tools.ts @@ -566,10 +566,15 @@ export class SessionTools { const summaries = new Map(this.#xdevRegistry?.entries().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"), + ); return { role: "custom", customType: XDEV_MOUNT_NOTICE_MESSAGE_TYPE, - content: prompt.render(xdevMountNoticePrompt, { added, removed }), + content: prompt.render(xdevMountNoticePrompt, { added, removed, docs }), attribution: "agent", display: false, timestamp: Date.now(), diff --git a/packages/coding-agent/src/tools/xdev.ts b/packages/coding-agent/src/tools/xdev.ts index d85665d18..009567050 100644 --- a/packages/coding-agent/src/tools/xdev.ts +++ b/packages/coding-agent/src/tools/xdev.ts @@ -173,6 +173,16 @@ function toolSummary(inst: Tool): string { return firstLine?.trim() ?? inst.label ?? inst.name; } +function promptCatalogSummary(inst: Tool, maxLength?: number): string { + const summary = + toolSummary(inst) + .split("\n") + .find(line => line.trim().length > 0) + ?.trim() ?? inst.name; + if (maxLength === undefined || summary.length <= maxLength) return summary; + return `${summary.slice(0, maxLength).trimEnd()}…`; +} + /** Decode the (possibly partially streamed) inner args JSON string into display args. */ function decodeInnerArgs(raw: unknown): Record { if (typeof raw !== "string" || raw.length === 0) return {}; @@ -233,7 +243,13 @@ export class XdevRegistry { /** `{name, summary}` pairs for prompt templates and /tools display. */ entries(): Array<{ name: string; summary: string }> { - return this.list().map(tool => ({ name: tool.name, summary: toolSummary(tool) })); + 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. */ @@ -276,13 +292,12 @@ export class XdevRegistry { * Dynamic mounts embed at most {@link EXTERNAL_DESCRIPTION_CAP} description * chars (schema always intact); `read xd://` returns the full text. */ - docsAll(mode: XdevDocsMode = "inline"): string { + docsAll(mode: XdevDocsMode = "inline", inlinePatterns: readonly string[] = []): string { const sections: string[] = []; const overflow: Tool[] = []; let used = 0; for (const tool of this.list()) { - const isBuiltin = this.#builtins.has(tool.name); - if (mode === "catalog" || (mode === "builtins" && !isBuiltin)) { + if (!this.#shouldInline(tool, mode, inlinePatterns)) { overflow.push(tool); continue; } @@ -299,7 +314,10 @@ export class XdevRegistry { sections.push( [ "## Additional devices (docs on demand)", - ...overflow.map(tool => `- ${XD_URL_PREFIX}${tool.name} — ${toolSummary(tool)}`), + ...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} for full docs + JSON schema before first use.`, ].join("\n"), @@ -308,6 +326,32 @@ export class XdevRegistry { return sections.join("\n\n"); } + /** Docs for selected mounted devices under the configured prompt-doc policy. */ + docsFor(names: Iterable, mode: XdevDocsMode, inlinePatterns: readonly string[] = []): string { + const sections: string[] = []; + let used = 0; + for (const name of names) { + const tool = this.get(name); + if (!tool || !this.#shouldInline(tool, mode, inlinePatterns)) 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, inlinePatterns: readonly string[]): boolean { + return ( + mode !== "catalog" && + (mode === "inline" || + this.#builtins.has(tool.name) || + inlinePatterns.some(pattern => new Bun.Glob(pattern).match(tool.name))) + ); + } + #resolve(name: string): Tool { const inst = this.get(name); if (!inst) { diff --git a/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts b/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts index 9b73566ef..f9b8a5a0f 100644 --- a/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts +++ b/packages/coding-agent/test/agent-session-tool-rebuild-skip.test.ts @@ -601,6 +601,41 @@ describe("AgentSession refreshMCPTools rebuild skipping", () => { expect(allNotices[1]).not.toContain("became available"); }); + it("caps dynamic xd:// mount-notice summaries", async () => { + const { session, contexts } = newSession(async toolNames => `tools:${toolNames.join(",")}`, { + xdevRegistry: new XdevRegistry([]), + responses: [{ content: ["ok"] }], + }); + const description = `Search ${"x".repeat(XdevRegistry.EXTERNAL_DESCRIPTION_CAP * 3)} TAIL`; + const search = createMcpCustomTool("mcp__nucleus_search", "nucleus", "search", description); + + await session.refreshMCPTools([search]); + await session.prompt("hello"); + + const notices = mountNoticesIn(contexts[0]); + expect(notices).toHaveLength(1); + expect(notices[0]).toContain("xd://mcp__nucleus_search"); + expect(notices[0]).not.toContain("TAIL"); + }); + + it("inlines configured late xd:// device docs in mount notices", async () => { + const { session, contexts } = newSession(async toolNames => `tools:${toolNames.join(",")}`, { + xdevRegistry: new XdevRegistry([]), + responses: [{ content: ["ok"] }], + }); + session.settings.set("tools.xdevDocs", "builtins"); + session.settings.set("tools.xdevInlineDevices", ["mcp__nucleus_*"]); + const search = createMcpCustomTool("mcp__nucleus_search", "nucleus", "search", "Search nucleus"); + + await session.refreshMCPTools([search]); + await session.prompt("hello"); + + const notices = mountNoticesIn(contexts[0]); + expect(notices).toHaveLength(1); + expect(notices[0]).toContain("## mcp__nucleus_search"); + expect(notices[0]).toContain("## Schema"); + }); + 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([]), diff --git a/packages/coding-agent/test/modes/controllers/selector-controller-settings.test.ts b/packages/coding-agent/test/modes/controllers/selector-controller-settings.test.ts new file mode 100644 index 000000000..a24bec87e --- /dev/null +++ b/packages/coding-agent/test/modes/controllers/selector-controller-settings.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it, vi } from "bun:test"; +import { SelectorController } from "@oh-my-pi/pi-coding-agent/modes/controllers/selector-controller"; +import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; + +describe("SelectorController prompt-affecting settings", () => { + it("refreshes the active prompt when xdev docs mode changes", async () => { + const refreshBaseSystemPrompt = vi.fn(async () => {}); + const ctx = { + session: { refreshBaseSystemPrompt }, + showError: vi.fn(), + } as unknown as InteractiveModeContext; + const controller = new SelectorController(ctx); + + controller.handleSettingChange("tools.xdevDocs", "catalog"); + await Promise.resolve(); + + expect(refreshBaseSystemPrompt).toHaveBeenCalledTimes(1); + expect(ctx.showError).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/coding-agent/test/write-xdev-dispatch.test.ts b/packages/coding-agent/test/write-xdev-dispatch.test.ts index cd87faf6d..6f6c7f9dd 100644 --- a/packages/coding-agent/test/write-xdev-dispatch.test.ts +++ b/packages/coding-agent/test/write-xdev-dispatch.test.ts @@ -219,10 +219,13 @@ describe("read and write route xd:// device URLs", () => { if (!registry) throw new Error("expected xdev registry"); const mounted = registry.list(); - const longDescription = "LEDE " + "y".repeat(XdevRegistry.EXTERNAL_DESCRIPTION_CAP * 3) + " TAIL"; + const longDescription = `LEDE ${"y".repeat(XdevRegistry.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`, + }); registry.reconcile([external]); const inlineDocs = registry.docsAll("inline"); @@ -235,12 +238,26 @@ describe("read and write route xd:// device URLs", () => { 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"); 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"); + + 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]); + + const allowlistedDocs = registry.docsAll("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_*"]); + expect(catalogWithAllowlistDocs).not.toContain("## mcp__context_mode_ctx_execute"); } finally { await removeWithRetries(tempDir); }