feat(coding-agent): stabilize system-prompt cache across xd:// mount changes via delta notices

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.
This commit is contained in:
can1357
2026-07-15 19:06:58 +02:00
parent 34bfa9ad50
commit bf3764fa4d
6 changed files with 109 additions and 48 deletions
+2 -10
View File
@@ -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<string, unknown> | 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;
@@ -0,0 +1,16 @@
<system-notice>
The xd:// device inventory changed.
{{#if added.length}}
These tools became available:
{{#each added}}
- xd://{{this.name}} — {{this.summary}}
{{/each}}
Read `xd://<tool>` for docs + JSON schema before first use; write the JSON args object to `xd://<tool>` to execute.
{{/if}}
{{#if removed.length}}
No longer mounted (writes to these devices will fail):
{{#each removed}}
- xd://{{this.name}}
{{/each}}
{{/if}}
</system-notice>
@@ -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://<tool>` away). The full docs join
* the system prompt opportunistically on the next unrelated rebuild.
*/
#notifyXdevMountDelta(previousMounted: ReadonlySet<string>): 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}`;
}
/**
+17 -5
View File
@@ -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://<tool>` docs.
* Dynamic mounts embed at most {@link EXTERNAL_DESCRIPTION_CAP} description
* chars (schema always intact); `read xd://<tool>` 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;
@@ -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();
@@ -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);
}
});
});