fix(tool-search): avoid blocking known group activation

This commit is contained in:
云服务部-叶林立
2026-08-27 10:13:52 +08:00
parent 874d4da096
commit bf874455db
7 changed files with 112 additions and 22 deletions
+4
View File
@@ -16,6 +16,10 @@
- Added sequential `ssh_cd` to the authoritative remote-shell workflow and direct the model to wait for a successful workspace change before dependent remote calls, avoiding both the unknown-tools fallback and cwd races.
- Added the authoritative `user-interaction` group for the maintained `ask_user_question` TUI tool.
- Added authoritative `chrome-navigation`, `chrome-interaction`, and `chrome-debugging` groups for all 21 tools registered by the pinned `pi-chrome` bridge after explicit session authorization.
### Fixed
- Treat Pi's optional `powershell` definition as the same pinned local-shell/core class as `bash` on Windows, while excluding it from non-Windows catalogs so it cannot become an unknown-tool model group.
- Activate exact existing groups from the current catalog before optional unknown-tool model enrichment, preventing unrelated catalog generation from delaying known group loads.
## [0.3.6] - 2026-04-24
+3 -3
View File
@@ -10,15 +10,15 @@ Generated metadata never replaces executable schemas. Exact names, parameter typ
## Catalog lifecycle
1. `session_start` keeps `tool_search`, Pi core tools, and configured `alwaysEnabled` names active.
1. `session_start` keeps `tool_search`, Pi core tools, and configured `alwaysEnabled` names active. PowerShell is filtered out on non-Windows hosts; on Windows it is pinned beside Bash as the same local-shell capability class.
2. Hidden tool definitions are hashed with the grouping constraints.
3. Every tool exposed by the standard `my-pi` bundle is matched against the checked-in catalog in `extensions/bundle-groups.ts`. If all tools are recognized, no model call and no user cache are needed.
4. User `groupOverrides` take priority over checked-in assignments. Optional or unavailable bundle tools are simply filtered out of their predefined groups.
5. Additional user or third-party tools receive deterministic prefix/source groups immediately. A matching private model-enriched cache at `<agent-dir>/tool-search/catalog-v1.json` is reused when present.
6. Only when unrecognized tools exist does the first `tool_search` call ask the current authenticated model to enrich the complete catalog. Validation requires all checked-in bundle tools to remain in their predefined groups before a `0600` cache is accepted.
6. Only when unrecognized tools exist and the call does not name an exact existing group may `tool_search` ask the current authenticated model to enrich the complete catalog. Exact group activation always uses the current deterministic catalog immediately. Validation requires all checked-in bundle tools to remain in their predefined groups before a `0600` cache is accepted.
7. Missing authentication, invalid JSON, timeout, cancellation, changed bundle assignments, or cache errors leave the checked-in plus deterministic hybrid catalog usable.
A standard bundle installation therefore sends no tool definitions to a model during catalog setup. Complete definitions are sent to the selected provider only when extra unrecognized tools require enrichment; that nested call's usage is attached to the `tool_search` result.
A standard bundle installation therefore sends no tool definitions to a model during catalog setup. Complete definitions are sent to the selected provider only when extra unrecognized tools require enrichment during an unknown-group or query lookup; exact existing group loads never wait for that nested call. Any nested-call usage is attached to the `tool_search` result.
Run `/tool-search-rebuild` to remove model enrichment. Standard bundle tools immediately return to the checked-in catalog; extra tools can be enriched lazily on the next search.
+2 -2
View File
@@ -2,11 +2,11 @@
`pi-tool-search` keeps full low-frequency schemas hidden behind a compact, validated group catalog.
1. `session_start` activates `tool_search`, the six Pi core tools, and configured `alwaysEnabled` names. These pinned tools do not consume the dynamic-group budget.
1. `session_start` activates `tool_search`, the six cross-platform Pi core tools, and configured `alwaysEnabled` names. On Windows, `powershell` is pinned beside `bash` as a seventh local-shell/core tool; on non-Windows hosts it is excluded from the catalog. These pinned tools do not consume the dynamic-group budget.
2. Hidden definitions are hashed with grouping constraints and matched against `extensions/bundle-groups.ts`. Available standard tools receive checked-in groups; unavailable optional tools are omitted.
3. If every hidden tool is recognized, the bundle catalog is used directly. No model call or per-user cache is required.
4. Additional tools receive immediate deterministic prefix/source groups. A matching model-enriched cache is reused only if it preserves all checked-in bundle assignments.
5. Only a hybrid catalog with unrecognized tools can call `ModelRegistry.complete()`. The prompt marks bundle assignments as fixed; validation rejects any response that moves them.
5. Only a hybrid catalog with unrecognized tools can call `ModelRegistry.complete()`, and an exact existing group skips that enrichment so activation cannot wait on an unrelated unknown tool. Unknown-group and query lookups may still enrich the catalog. The prompt marks bundle assignments as fixed; validation rejects any response that moves them.
6. Code validation still requires every exact tool name exactly once and rejects unknown names, duplicates, oversized generated groups, omissions, and tool-card/group mismatches. Generated text never changes executable schemas.
7. Loading a group calls `setActiveTools()` with its original full tool definitions. Pi supplies newly added schemas on the next model request.
8. Group load and member execution update an in-session LRU counter. Loading past `maxActiveGroups` or `maxDynamicTools` removes the least-recently-used dynamic groups.
+26 -13
View File
@@ -31,10 +31,25 @@ import {
import { ensureToolSearchDefaults, readToolSearchConfig } from "./config.ts";
const TOOL_SEARCH_NAME = "tool_search";
const POWERSHELL_TOOL_NAME = "powershell";
const CORE_TOOLS = ["read", "write", "edit", "bash", "grep", "find"];
type CatalogSource = "bundle" | "cache" | "fallback" | "hybrid" | "model";
type ModelUsage = Awaited<ReturnType<ExtensionContext["modelRegistry"]["complete"]>>["usage"];
type CatalogGenerationResult = { notice?: string; usage?: ModelUsage };
export function platformToolPolicy<T extends { name: string }>(
definitions: readonly T[],
platform: NodeJS.Platform,
): { tools: T[]; coreTools: string[] } {
const tools = platform === "win32"
? [...definitions]
: definitions.filter((tool) => tool.name !== POWERSHELL_TOOL_NAME);
const coreTools = platform === "win32"
? [...CORE_TOOLS, POWERSHELL_TOOL_NAME]
: [...CORE_TOOLS];
return { tools, coreTools };
}
function unique(values: Iterable<string>): string[] {
return [...new Set(values)];
@@ -191,20 +206,14 @@ export default function toolSearchExtension(pi: ExtensionAPI): void {
}),
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);
let selected = requestedId ? groupById(requestedId) : undefined;
const generation: CatalogGenerationResult = selected
? {}
: 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}.`);
}
if (!selected && requestedId) selected = groupById(requestedId);
const ranked = params.query ? rankGroups(catalog, params.query).slice(0, 3) : [];
if (!selected && params.query && (ranked[0]?.score ?? 0) > 0) selected = ranked[0]?.group;
@@ -338,9 +347,13 @@ export default function toolSearchExtension(pi: ExtensionAPI): void {
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 registeredTools = pi.getAllTools().filter((tool) => tool.name !== TOOL_SEARCH_NAME);
const platformPolicy = platformToolPolicy(registeredTools, process.platform);
const allTools = platformPolicy.tools;
const availableNames = new Set(allTools.map((tool) => tool.name));
const nextPinned = new Set([...CORE_TOOLS, ...nextConfig.alwaysEnabled].filter((name) => availableNames.has(name)));
const nextPinned = new Set(
[...platformPolicy.coreTools, ...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),
+74 -1
View File
@@ -5,7 +5,7 @@ import { join } from "node:path";
import test from "node:test";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import toolSearchExtension from "../extensions/index.ts";
import toolSearchExtension, { platformToolPolicy } from "../extensions/index.ts";
interface ToolSearchResult {
details: {
@@ -249,3 +249,76 @@ test("uses the precomputed bundle groups without calling a model", async () => {
await rm(agentDir, { recursive: true, force: true });
}
});
test("keeps PowerShell with Bash only on Windows", () => {
const definitions = [
sourceTool("bash", "Run Bash commands"),
sourceTool("powershell", "Run PowerShell commands"),
sourceTool("read", "Read a file"),
];
const nonWindows = platformToolPolicy(definitions, "darwin");
assert.deepEqual(nonWindows.tools.map((tool) => tool.name), ["bash", "read"]);
assert.ok(nonWindows.coreTools.includes("bash"));
assert.ok(!nonWindows.coreTools.includes("powershell"));
const windows = platformToolPolicy(definitions, "win32");
assert.deepEqual(windows.tools.map((tool) => tool.name), ["bash", "powershell", "read"]);
assert.ok(windows.coreTools.includes("bash"));
assert.ok(windows.coreTools.includes("powershell"));
});
test("loads an exact known group without waiting for unknown-tool model enrichment", async () => {
const agentDir = await mkdtemp(join(tmpdir(), "my-pi-tool-search-fast-path-"));
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = agentDir;
try {
const handlers = new Map<string, (...args: any[]) => unknown>();
const registered = new Map<string, RegisteredTool>();
let modelCalls = 0;
const sourceTools = [
sourceTool("read", "Read a file"),
sourceTool("tavily_web_search", "Search broadly for current web sources"),
sourceTool("tavily_web_fetch", "Fetch a selected Tavily source"),
sourceTool("unrecognized_extra", "An unrecognized third-party capability"),
];
const api = {
getAllTools: () => sourceTools,
registerTool: (definition: unknown) => {
const tool = definition as RegisteredTool & { name: string };
registered.set(tool.name, tool);
},
registerCommand: () => {},
setActiveTools: () => {},
on: (event: string, handler: (...args: any[]) => unknown) => handlers.set(event, handler),
} as unknown as ExtensionAPI;
toolSearchExtension(api);
const ctx = testContext({
model: { provider: "test", id: "catalog-model" } as ExtensionContext["model"],
modelRegistry: {
hasConfiguredAuth: () => true,
complete: async () => {
modelCalls += 1;
throw new Error("an exact known group must not wait for model enrichment");
},
} as unknown as ExtensionContext["modelRegistry"],
});
handlers.get("session_start")?.({}, ctx);
const result = await registered.get("tool_search")?.execute(
"call-1",
{ group: "web-tavily" },
undefined,
undefined,
ctx,
);
assert.equal(result?.details.loadedGroup, "web-tavily");
assert.equal(result?.details.catalogSource, "hybrid");
assert.equal(modelCalls, 0);
} finally {
if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previousAgentDir;
await rm(agentDir, { recursive: true, force: true });
}
});