From bf3764fa4d50ad6b7f4ad4281131bfc3ab02fb13 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 15 Jul 2026 19:06:58 +0200 Subject: [PATCH] feat(coding-agent): stabilize system-prompt cache across xd:// mount changes via delta notices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of including the xd:// device inventory in the system-prompt signature, mount/unmount events now inject a steered `xdev-mount-notice` message so the system prompt (and its provider cache prefix) stays byte-stable across MCP connects and disconnects. Full device docs are picked up opportunistically on the next unrelated rebuild. Also caps external (dynamic-mount) device descriptions to 200 chars in `docsAll` to prevent server-controlled prose from consuming prompt budget; built-ins keep their full curated docs, and `read xd://` always returns the untruncated text. Legacy `discoveryMode: "off"` → `tools.xdev: false` migration is removed; the setting keeps its own default without inference from the deprecated key. --- packages/coding-agent/src/config/settings.ts | 12 +---- .../src/prompts/system/xdev-mount-notice.md | 16 ++++++ .../coding-agent/src/session/agent-session.ts | 49 +++++++++++++++++-- packages/coding-agent/src/tools/xdev.ts | 22 +++++++-- .../components/tool-execution-spinner.test.ts | 28 ----------- .../test/write-xdev-dispatch.test.ts | 30 ++++++++++++ 6 files changed, 109 insertions(+), 48 deletions(-) create mode 100644 packages/coding-agent/src/prompts/system/xdev-mount-notice.md diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index dc52ddd52..22bbf3897 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -1254,18 +1254,10 @@ export class Settings { delete raw.fastModeScope; // BM25 tool discovery removal: tools.discoveryMode / tools.essentialOverride / - // mcp.discoveryMode / mcp.discoveryDefaultServers are gone. The one intent - // worth carrying over is discoveryMode "off" ("no discovery layer, every - // tool ships top-level"), whose modern equivalent is disabling the xd:// - // transport; the other values map to the default (xdev on). Dead keys are + // mcp.discoveryMode / mcp.discoveryDefaultServers are gone with no + // replacement (`tools.xdev` stays at its own default). Dead keys are // deleted so they stop lingering in config.yml. const toolsObj = raw.tools as Record | undefined; - const legacyDiscoveryMode = toolsObj?.discoveryMode ?? raw["tools.discoveryMode"]; - if (legacyDiscoveryMode === "off" && toolsObj?.xdev === undefined && raw["tools.xdev"] === undefined) { - const toolsRoot = toolsObj ?? {}; - toolsRoot.xdev = false; - raw.tools = toolsRoot; - } if (toolsObj) { delete toolsObj.discoveryMode; delete toolsObj.essentialOverride; diff --git a/packages/coding-agent/src/prompts/system/xdev-mount-notice.md b/packages/coding-agent/src/prompts/system/xdev-mount-notice.md new file mode 100644 index 000000000..ff32af062 --- /dev/null +++ b/packages/coding-agent/src/prompts/system/xdev-mount-notice.md @@ -0,0 +1,16 @@ + +The xd:// device inventory changed. +{{#if added.length}} +These tools became available: +{{#each added}} +- xd://{{this.name}} — {{this.summary}} +{{/each}} +Read `xd://` for docs + JSON schema before first use; write the JSON args object to `xd://` to execute. +{{/if}} +{{#if removed.length}} +No longer mounted (writes to these devices will fail): +{{#each removed}} +- xd://{{this.name}} +{{/each}} +{{/if}} + diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index d82507381..830ed76a8 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -286,6 +286,7 @@ import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { t import ttsrToolReminderTemplate from "../prompts/system/ttsr-tool-reminder.md" with { type: "text" }; import unexpectedStopRetryTemplate from "../prompts/system/unexpected-stop-retry.md" with { type: "text" }; import vibeModeActivePrompt from "../prompts/system/vibe-mode-active.md" with { type: "text" }; +import xdevMountNoticePrompt from "../prompts/system/xdev-mount-notice.md" with { type: "text" }; import { AgentRegistry } from "../registry/agent-registry"; import { deobfuscateAssistantContent, @@ -403,7 +404,7 @@ const PLAN_MODE_REMINDER_MAX = 3; */ const MID_RUN_TODO_NUDGE_MUTATION_THRESHOLD = 12; /** Mid-run nudges per prompt cycle. Deliberately tighter than - * `todo.reminders.max` (the stop-time budget): this is a gentle hidden hint, + * `todo.remindersMax` (the stop-time budget): this is a gentle hidden hint, * not an escalation ladder. */ const MID_RUN_TODO_NUDGE_MAX_PER_CYCLE = 2; /** Tool results that count as landed work for the mid-run todo nudge. */ @@ -481,6 +482,9 @@ const PREWALK_CONTINUE_MESSAGE_TYPE = "prewalk-continue"; * multi-site fixes, unnecessarily broad rewrites, and reported-test-only * verification. */ const PREWALK_CHECKLIST_MESSAGE_TYPE = "prewalk-checklist"; +/** Hidden steered notice announcing a mid-session `xd://` mount/unmount delta + * (see {@link AgentSession.#notifyXdevMountDelta}). */ +const XDEV_MOUNT_NOTICE_MESSAGE_TYPE = "xdev-mount-notice"; /** Tools whose first successful call triggers the switch — once the todo * gate is open (see {@link AgentSession.#prewalkTodoSeen}). Bash is * deliberately excluded: it doubles as exploration (ls/cat) and fired @@ -6754,8 +6758,10 @@ export class AgentSession { // Reconcile the dynamic `xd://` mounts: newly-active discoverable tools are // mounted, deactivated ones dropped (built-in devices are preserved). A // removed or disconnected tool must not stay callable through a stale device. + const previousMounted = this.#mountedXdevToolNames; this.#mountedXdevToolNames = new Set(mountedTools.map(tool => tool.name)); this.#xdevRegistry?.reconcile(mountedTools); + this.#notifyXdevMountDelta(previousMounted); this.#setActiveToolNames?.(validToolNames); this.agent.setTools(tools); @@ -6780,6 +6786,37 @@ export class AgentSession { } } + /** + * Announce a mid-session `xd://` mount delta to the model as a steered + * system notice instead of rewriting the system prompt: the prompt (and + * its provider cache prefix) stays byte-stable across MCP connects and + * disconnects, and the model learns about new devices from the notice + * (docs + schema stay one `read xd://` away). The full docs join + * the system prompt opportunistically on the next unrelated rebuild. + */ + #notifyXdevMountDelta(previousMounted: ReadonlySet): void { + const registry = this.#xdevRegistry; + if (!registry) return; + const current = this.#mountedXdevToolNames; + const addedNames = [...current].filter(name => !previousMounted.has(name)); + const removed = [...previousMounted].filter(name => !current.has(name)).map(name => ({ name })); + if (addedNames.length === 0 && removed.length === 0) return; + const summaries = new Map(registry.entries().map(entry => [entry.name, entry.summary])); + const added = addedNames.map(name => ({ name, summary: summaries.get(name) ?? "" })); + this.agent.steer({ + role: "custom", + customType: XDEV_MOUNT_NOTICE_MESSAGE_TYPE, + content: prompt.render(xdevMountNoticePrompt, { added, removed }), + attribution: "agent", + display: false, + timestamp: Date.now(), + }); + const parts: string[] = []; + if (added.length > 0) parts.push(`mounted ${added.map(entry => entry.name).join(", ")}`); + if (removed.length > 0) parts.push(`unmounted ${removed.map(entry => entry.name).join(", ")}`); + this.emitNotice("info", `xd://: ${parts.join("; ")}`, "xdev"); + } + /** * Set active tools by name. * Only tools in the registry can be enabled. Unknown tool names are ignored. @@ -6913,11 +6950,13 @@ export class AgentSession { entries.sort(); instructionsSegment = entries.join("\u0006"); } - // The xd:// device inventory (built-in + dynamic mounts) is rendered into the - // prompt, so a mount/unmount must differ the signature and trigger a rebuild. - const mountedSegment = this.#xdevRegistry ? this.#xdevRegistry.list().map(describeTool).join("\u0008") : ""; + // The xd:// device inventory is deliberately NOT part of the signature: + // a mount/unmount announces itself via `#notifyXdevMountDelta` instead of + // rewriting the system prompt, so MCP connects/disconnects keep the + // prompt (and its provider cache prefix) byte-stable. Rebuilds triggered + // by other inputs pick up the current device docs opportunistically. const date = this.#getLocalCalendarDate(); - return `${nameSegment}\u0003${descriptionSegment}\u0007${instructionsSegment}\u0009${mountedSegment}|${date}`; + return `${nameSegment}\u0003${descriptionSegment}\u0007${instructionsSegment}|${date}`; } /** diff --git a/packages/coding-agent/src/tools/xdev.ts b/packages/coding-agent/src/tools/xdev.ts index 0cdab8c91..f05d3785a 100644 --- a/packages/coding-agent/src/tools/xdev.ts +++ b/packages/coding-agent/src/tools/xdev.ts @@ -84,18 +84,22 @@ function schemaDeclaresIntentField(schema: unknown): boolean { return !!props && typeof props === "object" && "i" in props; } -function renderDocs(inst: Tool, heading = "#"): string { +function renderDocs(inst: Tool, heading = "#", descriptionCap?: number): string { const schema = JSON.stringify(toolWireSchema(inst as AiTool), null, 1); + let description = inst.description ?? ""; + if (descriptionCap !== undefined && description.length > descriptionCap) { + description = `${description.slice(0, descriptionCap).trimEnd()}… (full docs: read ${XD_URL_PREFIX}${inst.name})`; + } return [ `${heading} ${inst.name}${inst.label ? ` — ${inst.label}` : ""}`, "", - inst.description ?? "", + description, "", - `${heading}# Parameters (JSON schema)`, + `${heading}# Schema`, "```json", schema, "```", - `Execute by writing the JSON args object to ${XD_URL_PREFIX}${inst.name}.`, + `Execute by writing JSON to ${XD_URL_PREFIX}${inst.name}.`, ].join("\n"); } @@ -237,19 +241,27 @@ export class XdevRegistry { /** 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://` docs. + * Dynamic mounts embed at most {@link EXTERNAL_DESCRIPTION_CAP} description + * chars (schema always intact); `read xd://` returns the full text. */ docsAll(): string { const sections: string[] = []; const overflow: Tool[] = []; let used = 0; for (const tool of this.list()) { - const docs = renderDocs(tool, "##"); + 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; diff --git a/packages/coding-agent/test/modes/components/tool-execution-spinner.test.ts b/packages/coding-agent/test/modes/components/tool-execution-spinner.test.ts index 81f348bd1..dd041c668 100644 --- a/packages/coding-agent/test/modes/components/tool-execution-spinner.test.ts +++ b/packages/coding-agent/test/modes/components/tool-execution-spinner.test.ts @@ -46,34 +46,6 @@ describe("ToolExecutionComponent live preview spinners", () => { } }); - it("animates a shell pending header while the call is live", () => { - vi.useFakeTimers(); - const requestRender = vi.fn(); - const requestComponentRender = vi.fn(); - const component = new ToolExecutionComponent( - "ssh", - { host: "example.test", command: "sleep 10" }, - {}, - undefined, - { requestRender, requestComponentRender } as unknown as TUI, - process.cwd(), - ); - - try { - const firstFrame = stripVTControlCharacters(component.render(80).join("\n")); - vi.advanceTimersByTime(120); - const secondFrame = stripVTControlCharacters(component.render(80).join("\n")); - - expect(requestComponentRender).toHaveBeenCalledWith(component); - expect(requestRender).not.toHaveBeenCalled(); - expect(firstFrame).toContain("sleep 10"); - expect(secondFrame).toContain("sleep 10"); - expect(secondFrame).not.toBe(firstFrame); - } finally { - component.stopAnimation(); - } - }); - it("does not tick headerless bash pending previews", () => { vi.useFakeTimers(); const requestRender = vi.fn(); diff --git a/packages/coding-agent/test/write-xdev-dispatch.test.ts b/packages/coding-agent/test/write-xdev-dispatch.test.ts index 7a405f070..4d127ef1e 100644 --- a/packages/coding-agent/test/write-xdev-dispatch.test.ts +++ b/packages/coding-agent/test/write-xdev-dispatch.test.ts @@ -132,4 +132,34 @@ describe("read and write route xd:// device URLs", () => { await removeWithRetries(tempDir); } }); + + it("docsAll truncates external (dynamic-mount) descriptions to the cap; built-ins and read xd:// stay full", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "write-xdev-external-")); + try { + const session = xdevSession(tempDir); + await createTools(session); + const registry = session.xdevRegistry; + if (!registry) throw new Error("expected xdev registry"); + const mounted = registry.list(); + + 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 }); + registry.reconcile([external]); + + const docs = registry.docsAll(); + // External device: schema section present, description cut at the cap. + expect(docs).toContain("## mcp_external_tool"); + expect(docs).toContain("LEDE "); + expect(docs).not.toContain("TAIL"); + expect(docs).toContain("… (full docs: read xd://mcp_external_tool)"); + // Built-in devices keep their full curated description. + expect(docs).toContain(mounted[0]!.description ?? ""); + // On-demand docs return the untruncated text. + expect(registry.docs("mcp_external_tool")).toContain("TAIL"); + } finally { + await removeWithRetries(tempDir); + } + }); });