feat: vendor grouped tool search

This commit is contained in:
云服务部-叶林立
2026-08-19 16:43:36 +08:00
parent 779a845e18
commit 3507f85363
22 changed files with 2236 additions and 6 deletions
+423
View File
@@ -0,0 +1,423 @@
/**
* pi-tool-search — model-built tool groups with bounded dynamic schema loading.
*
* Imported from https://github.com/tuansondinh/pi-tool-search and maintained
* locally from snapshot ddfb23646fd3957b791214de278e23aa393c9b13 (v0.3.6).
*/
import { randomUUID } from "node:crypto";
import { unlinkSync } from "node:fs";
import { join } from "node:path";
import { getAgentDir, type ExtensionAPI, type ExtensionContext, type ToolInfo } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import {
buildCatalogModelInput,
buildManifestHash,
createFallbackCatalog,
parseGeneratedCatalog,
rankGroups,
readCachedCatalog,
writeCachedCatalog,
type CatalogConstraints,
type GroupCard,
type ToolCatalog,
} from "./catalog.ts";
import {
catalogPreservesBundleAssignments,
createBundleCatalog,
type BundleCatalogResult,
} from "./bundle-groups.ts";
import { ensureToolSearchDefaults, readToolSearchConfig } from "./config.ts";
const TOOL_SEARCH_NAME = "tool_search";
const CORE_TOOLS = ["read", "write", "edit", "bash", "grep", "find"];
type CatalogSource = "bundle" | "cache" | "fallback" | "hybrid" | "model";
type ModelUsage = Awaited<ReturnType<ExtensionContext["modelRegistry"]["complete"]>>["usage"];
function unique(values: Iterable<string>): string[] {
return [...new Set(values)];
}
function catalogPrompt(toolsJson: string, maxToolsPerGroup: number, fixedGroupsJson: string): string {
return [
"Build a compact retrieval catalog for the provided Pi tools.",
"The catalog is metadata only: never rename tools or invent parameters.",
"Group tools that are commonly needed together in one workflow, but keep providers or security-sensitive administration separate when that improves routing.",
`Every tool must appear exactly once and every group must contain 1-${maxToolsPerGroup} tools.`,
"Tools listed in <fixed-groups> must keep exactly those primaryGroup ids; these are curated bundle assignments.",
"Summaries must preserve capability, important boundaries, and when not to use a tool.",
"Keywords should include common English terms and useful Chinese equivalents when applicable.",
"Return JSON only with this exact shape:",
'{"groups":[{"id":"kebab-case","title":"...","summary":"...","useWhen":["..."],"avoidWhen":["..."],"tools":["exact_tool_name"]}],"tools":[{"name":"exact_tool_name","summary":"...","useWhen":["..."],"avoidWhen":["..."],"keywords":["..."],"primaryGroup":"kebab-case"}]}',
"Do not include markdown fences or explanatory text.",
"",
"<fixed-groups>",
fixedGroupsJson,
"</fixed-groups>",
"",
"<tools>",
toolsJson,
"</tools>",
].join("\n");
}
function responseText(content: Array<{ type: string; text?: string }>): string {
return content
.filter((item): item is { type: string; text: string } => item.type === "text" && typeof item.text === "string")
.map((item) => item.text)
.join("\n");
}
function groupDescription(catalog: ToolCatalog, source: CatalogSource): string {
const sourceLabel =
source === "bundle"
? "precomputed my-pi bundle catalog; no model generation required"
: source === "hybrid"
? "my-pi bundle catalog plus deterministic groups for unrecognized tools; the first search may enrich the unknown tools"
: source === "fallback"
? "deterministic fallback"
: `${source} catalog`;
const groups = catalog.groups
.map((group) => {
const when = group.useWhen.slice(0, 2).join("; ");
const detail = [group.summary, when].filter(Boolean).join(" Use when: ").slice(0, 360);
return ` ${group.id}: ${detail}`;
})
.join("\n");
return [
"Activate a complete tool group for the current task. Prefer an exact group id from this catalog; use query only when no id clearly matches.",
`Catalog source: ${sourceLabel}.`,
"Available groups:",
groups || " (no hidden tool groups)",
].join("\n");
}
export default function toolSearchExtension(pi: ExtensionAPI): void {
const agentDir = getAgentDir();
const cachePath = join(agentDir, "tool-search", "catalog-v1.json");
const defaultResult = ensureToolSearchDefaults(agentDir);
if (defaultResult === "skipped-invalid") {
console.warn("my-pi: skipped pi-tool-search defaults because settings.json or toolSearch is invalid");
}
let config = readToolSearchConfig(agentDir);
let tools: ToolInfo[] = [];
let catalog: ToolCatalog = createFallbackCatalog([], "", {
maxToolsPerGroup: Math.min(config.maxToolsPerGroup, config.maxDynamicTools),
groupOverrides: {},
});
let catalogSource: CatalogSource = "fallback";
let manifestHash = "";
let policySignature = "";
let attemptedGenerationHash: string | undefined;
let clock = 0;
const activeGroups = new Map<string, number>();
const pinnedTools = new Set<string>();
let bundleState: BundleCatalogResult = { catalog, coveredNames: new Set(), unknownTools: [] };
function constraints(): CatalogConstraints {
return {
maxToolsPerGroup: Math.min(config.maxToolsPerGroup, config.maxDynamicTools),
groupOverrides: config.groupOverrides,
};
}
function installBaseCatalog(useCache: boolean): void {
bundleState = createBundleCatalog(tools, manifestHash, constraints());
if (bundleState.unknownTools.length === 0) {
catalog = bundleState.catalog;
catalogSource = "bundle";
return;
}
const cached = useCache ? readCachedCatalog(cachePath, tools, manifestHash, constraints()) : undefined;
if (cached && catalogPreservesBundleAssignments(cached, bundleState.catalog, bundleState.coveredNames)) {
catalog = cached;
catalogSource = "cache";
return;
}
catalog = bundleState.catalog;
catalogSource = "hybrid";
}
function fixedBundleGroupsJson(): string {
return JSON.stringify(
bundleState.catalog.groups
.map((group) => ({
id: group.id,
tools: group.tools.filter((name) => bundleState.coveredNames.has(name)),
}))
.filter((group) => group.tools.length > 0),
);
}
function groupById(id: string): GroupCard | undefined {
return catalog.groups.find((group) => group.id === id);
}
function dynamicToolCount(): number {
return unique([...activeGroups.keys()].flatMap((id) => groupById(id)?.tools ?? [])).length;
}
function leastRecentlyUsedGroup(): string | undefined {
return [...activeGroups].sort((left, right) => left[1] - right[1] || left[0].localeCompare(right[0]))[0]?.[0];
}
function applyActiveTools(): void {
const groupTools = [...activeGroups.keys()].flatMap((id) => groupById(id)?.tools ?? []);
pi.setActiveTools(unique([TOOL_SEARCH_NAME, ...pinnedTools, ...groupTools]));
}
function updateStatus(ctx: Pick<ExtensionContext, "ui">): void {
const activeToolCount = unique([TOOL_SEARCH_NAME, ...pinnedTools, ...[...activeGroups.keys()].flatMap((id) => groupById(id)?.tools ?? [])]).length;
ctx.ui.setStatus(
"tool-search",
config.showToolSearchFooterStatus
? `${activeToolCount} / ${tools.length + 1} tools · ${activeGroups.size} / ${config.maxActiveGroups} groups`
: undefined,
);
}
function registerToolSearch(): void {
pi.registerTool({
name: TOOL_SEARCH_NAME,
label: "Tool Search",
description: groupDescription(catalog, catalogSource),
promptSnippet: `Activate relevant tool groups on demand; at most ${config.maxActiveGroups} dynamic groups remain active`,
parameters: Type.Object({
group: Type.Optional(Type.String({ description: "Exact group id from the tool_search catalog" })),
query: Type.Optional(Type.String({ description: "Natural-language task used to rank groups when an exact id is unclear" })),
}),
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
const requestedId = params.group?.trim().toLowerCase();
const preGenerationGroup = requestedId ? groupById(requestedId) : undefined;
const generation = await ensureModelCatalog(ctx, signal);
const lines: string[] = [];
if (generation.notice) lines.push(generation.notice);
let selected = requestedId ? groupById(requestedId) : undefined;
if (!selected && preGenerationGroup) {
const previousNames = new Set(preGenerationGroup.tools);
selected = [...catalog.groups]
.map((group) => ({ group, overlap: group.tools.filter((name) => previousNames.has(name)).length }))
.sort((left, right) => right.overlap - left.overlap || left.group.id.localeCompare(right.group.id))
.find((candidate) => candidate.overlap > 0)?.group;
if (selected) lines.push(`Mapped initial fallback group ${preGenerationGroup.id} to generated group ${selected.id}.`);
}
const ranked = params.query ? rankGroups(catalog, params.query).slice(0, 3) : [];
if (!selected && params.query && (ranked[0]?.score ?? 0) > 0) selected = ranked[0]?.group;
if (!selected) {
const candidates = ranked.length > 0 ? ranked : catalog.groups.slice(0, 5).map((group) => ({ group, score: 0 }));
lines.push(
params.group ? `Unknown group: ${params.group}` : "No group was activated. Provide an exact group id from the catalog.",
`Candidates: ${candidates.map(({ group }) => group.id).join(", ") || "none"}`,
);
return {
content: [{ type: "text", text: lines.join("\n") }],
details: { loadedGroup: undefined, evictedGroups: [], activeGroups: [...activeGroups.keys()], candidates: candidates.map(({ group, score }) => ({ id: group.id, score })), catalogSource },
...(generation.usage ? { usage: generation.usage } : {}),
};
}
const groupTools = unique(selected.tools);
if (groupTools.length > config.maxDynamicTools) {
lines.push(`Group ${selected.id} has ${groupTools.length} tools, exceeding maxDynamicTools=${config.maxDynamicTools}.`);
return {
content: [{ type: "text", text: lines.join("\n") }],
details: { loadedGroup: undefined, evictedGroups: [], activeGroups: [...activeGroups.keys()], candidates: [], catalogSource },
...(generation.usage ? { usage: generation.usage } : {}),
};
}
const evictedGroups: string[] = [];
if (!activeGroups.has(selected.id)) {
while (
activeGroups.size >= config.maxActiveGroups ||
(activeGroups.size > 0 && dynamicToolCount() + groupTools.length > config.maxDynamicTools)
) {
const evicted = leastRecentlyUsedGroup();
if (!evicted) break;
activeGroups.delete(evicted);
evictedGroups.push(evicted);
}
}
activeGroups.set(selected.id, ++clock);
applyActiveTools();
registerToolSearch();
updateStatus(ctx);
lines.push(`Loaded group: ${selected.id} (${groupTools.join(", ")})`);
if (evictedGroups.length > 0) lines.push(`Evicted least-recently-used: ${evictedGroups.join(", ")}`);
lines.push(`Active groups: ${[...activeGroups.keys()].join(", ")}`);
return {
content: [{ type: "text", text: lines.join("\n") }],
details: {
loadedGroup: selected.id,
evictedGroups,
activeGroups: [...activeGroups.keys()],
candidates: ranked.map(({ group, score }) => ({ id: group.id, score })),
catalogSource,
},
...(generation.usage ? { usage: generation.usage } : {}),
};
},
});
}
async function ensureModelCatalog(
ctx: ExtensionContext,
signal: AbortSignal | undefined,
): Promise<{ notice?: string; usage?: ModelUsage }> {
if (catalogSource !== "hybrid" || attemptedGenerationHash === manifestHash || tools.length === 0) return {};
attemptedGenerationHash = manifestHash;
if (!ctx.model || !ctx.modelRegistry.hasConfiguredAuth(ctx.model)) {
return { notice: "Using the precomputed bundle catalog plus deterministic groups for unrecognized tools because the current model is unavailable or unauthenticated." };
}
let generationUsage: ModelUsage | undefined;
try {
const response = await ctx.modelRegistry.complete(
ctx.model,
{
messages: [
{
role: "user",
content: [
{
type: "text",
text: catalogPrompt(
buildCatalogModelInput(tools),
constraints().maxToolsPerGroup,
fixedBundleGroupsJson(),
),
},
],
timestamp: Date.now(),
},
],
},
{
signal,
reasoningEffort: "low",
cacheRetention: "none",
sessionId: randomUUID(),
maxTokens: 12_000,
},
);
generationUsage = response.usage;
catalog = parseGeneratedCatalog(
responseText(response.content),
tools,
manifestHash,
constraints(),
`${ctx.model.provider}/${ctx.model.id}`,
);
if (!catalogPreservesBundleAssignments(catalog, bundleState.catalog, bundleState.coveredNames)) {
throw new Error("generated catalog changed one or more curated my-pi bundle assignments");
}
catalogSource = "model";
try {
writeCachedCatalog(cachePath, catalog);
} catch (error) {
console.warn(`my-pi: could not cache pi-tool-search catalog: ${error instanceof Error ? error.message : String(error)}`);
}
registerToolSearch();
return {
notice: `Generated and cached ${catalog.groups.length} groups while preserving the precomputed my-pi assignments.`,
usage: generationUsage,
};
} catch (error) {
return {
notice: `Model enrichment failed; using the precomputed bundle catalog plus deterministic unknown-tool groups (${error instanceof Error ? error.message : String(error)}).`,
...(generationUsage ? { usage: generationUsage } : {}),
};
}
}
function refreshState(ctx: Pick<ExtensionContext, "ui">, forceReset: boolean): void {
const nextConfig = readToolSearchConfig(agentDir);
const allTools = pi.getAllTools().filter((tool) => tool.name !== TOOL_SEARCH_NAME);
const availableNames = new Set(allTools.map((tool) => tool.name));
const nextPinned = new Set([...CORE_TOOLS, ...nextConfig.alwaysEnabled].filter((name) => availableNames.has(name)));
const hiddenTools = allTools.filter((tool) => !nextPinned.has(tool.name));
const nextConstraints = {
maxToolsPerGroup: Math.min(nextConfig.maxToolsPerGroup, nextConfig.maxDynamicTools),
groupOverrides: nextConfig.groupOverrides,
};
const nextHash = buildManifestHash(hiddenTools, nextConstraints);
const nextPolicySignature = JSON.stringify({
alwaysEnabled: [...nextPinned].sort(),
maxActiveGroups: nextConfig.maxActiveGroups,
maxDynamicTools: nextConfig.maxDynamicTools,
showToolSearchFooterStatus: nextConfig.showToolSearchFooterStatus,
});
const catalogChanged = forceReset || nextHash !== manifestHash;
const policyChanged = nextPolicySignature !== policySignature;
config = nextConfig;
tools = hiddenTools;
pinnedTools.clear();
for (const name of nextPinned) pinnedTools.add(name);
manifestHash = nextHash;
policySignature = nextPolicySignature;
if (catalogChanged) {
activeGroups.clear();
clock = 0;
attemptedGenerationHash = undefined;
installBaseCatalog(true);
}
let capacityChanged = false;
while (activeGroups.size > config.maxActiveGroups || dynamicToolCount() > config.maxDynamicTools) {
const evicted = leastRecentlyUsedGroup();
if (!evicted) break;
activeGroups.delete(evicted);
capacityChanged = true;
}
if (catalogChanged || policyChanged || capacityChanged) {
registerToolSearch();
applyActiveTools();
}
updateStatus(ctx);
}
pi.registerCommand("tool-search-rebuild", {
description: "Invalidate the generated tool-group catalog; rebuild lazily on the next tool_search call",
handler: async (_args, ctx) => {
try {
unlinkSync(cachePath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
activeGroups.clear();
attemptedGenerationHash = undefined;
installBaseCatalog(false);
registerToolSearch();
applyActiveTools();
updateStatus(ctx);
if (ctx.hasUI) {
const message =
bundleState.unknownTools.length === 0
? "Restored the precomputed my-pi tool-group catalog; no model rebuild is needed"
: "Restored my-pi groups; unrecognized tools may be enriched on the next tool_search call";
ctx.ui.notify(message, "info");
}
},
});
pi.on("session_start", (_event, ctx) => {
refreshState(ctx, true);
});
pi.on("turn_start", (_event, ctx) => {
refreshState(ctx, false);
});
pi.on("tool_execution_start", (event) => {
const group = catalog.tools.find((tool) => tool.name === event.toolName)?.primaryGroup;
if (group && activeGroups.has(group)) activeGroups.set(group, ++clock);
});
}