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:
@@ -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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user