feat: allowlist xdev prompt docs
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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}}
|
||||
</system-notice>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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://<tool>` 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}<tool> 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<string>, 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) {
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user