mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat(tool-search): expand dynamic group activation
This commit is contained in:
@@ -16,9 +16,15 @@
|
||||
- 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.
|
||||
- Raised the bundle defaults to five active groups and 28 dynamic tools, and pinned the high-frequency Context Mode execution trio alongside CodeGraph exploration and LSP diagnostics.
|
||||
- Added atomic multi-group activation through `tool_search.groups`, bounded to five exact group ids and rejected without state changes when the complete workflow cannot fit.
|
||||
- Weighted exact tool names, group metadata, and common SSH/Chrome/Context aliases during query routing; weak or ambiguous matches now return candidates instead of activating arbitrarily.
|
||||
- Added `/tool-search-status` for catalog source, capacity, active-group LRU order, and latest eviction diagnostics.
|
||||
- Added an explicit `update.sh` migration for the complete former `3`/`20` bundle-default snapshot, with owner-only backup, atomic replacement, provenance versioning, and fail-safe preservation of custom or invalid user settings.
|
||||
### 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.
|
||||
- Include pinned tools in the optional footer's total-tool denominator.
|
||||
|
||||
|
||||
## [0.3.6] - 2026-04-24
|
||||
|
||||
+29
-13
@@ -20,7 +20,7 @@ Generated metadata never replaces executable schemas. Exact names, parameter typ
|
||||
|
||||
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.
|
||||
Run `/tool-search-status` to inspect the current catalog source, pinned count, dynamic capacity, LRU order, and latest eviction reason. Run `/tool-search-rebuild` to remove model enrichment; standard bundle tools immediately return to the checked-in catalog and extra tools can be enriched lazily on the next search.
|
||||
|
||||
## Precomputed my-pi groups
|
||||
|
||||
@@ -48,7 +48,7 @@ Run `/tool-search-rebuild` to remove model enrichment. Standard bundle tools imm
|
||||
| `chrome-debugging` | Chrome page evaluation, console/network diagnostics, response retrieval, and file upload |
|
||||
| `mcp-management` | Shared MCP adapter management |
|
||||
|
||||
Pinned tools are removed from these dynamic groups at runtime. For example, the default-pinned `codegraph_explore` and `lsp_diagnostics` do not consume the `code-intelligence` group budget.
|
||||
Pinned tools are removed from these dynamic groups at runtime. The defaults pin `codegraph_explore`, `lsp_diagnostics`, and the three high-frequency Context Mode execution tools, so none of them consume dynamic group or tool capacity.
|
||||
|
||||
## Loading and eviction
|
||||
|
||||
@@ -58,37 +58,53 @@ Call `tool_search` with an exact catalog group id when possible:
|
||||
{ "group": "web-tavily" }
|
||||
```
|
||||
|
||||
A natural-language `query` can rank checked-in, generated, and deterministic group metadata when the group is unclear. Loading a group exposes the original full schemas for all tools in that group on Pi's next model request.
|
||||
A workflow can atomically activate up to five exact groups in one call. The complete request must fit the configured group and tool limits; otherwise no active state is changed:
|
||||
|
||||
Pinned tools do not consume the dynamic group budget. Dynamic groups update their recency when loaded or when any member starts executing. Loading beyond `maxActiveGroups` or `maxDynamicTools` evicts least-recently-used, non-pinned groups before activating the new group.
|
||||
```json
|
||||
{
|
||||
"groups": ["ssh-connection", "ssh-remote-shell", "ssh-remote-files", "ssh-remote-search"]
|
||||
}
|
||||
```
|
||||
|
||||
A natural-language `query` uses weighted group, alias, and exact-tool-name metadata when the group is unclear. Weak or closely tied matches return ranked candidates without activating an arbitrary group. Loading exposes the original full schemas on Pi's next model request.
|
||||
|
||||
Pinned tools do not consume the dynamic budget. Dynamic groups update their recency when loaded or when a member starts executing. Loading beyond `maxActiveGroups` or `maxDynamicTools` atomically evicts least-recently-used non-requested groups before activation.
|
||||
|
||||
The first loads are purely additive and retain Pi's native deferred-loading propagation. An eviction necessarily removes and adds schemas in one transition, so Pi uses its safe fallback rather than treating that transition as purely additive.
|
||||
|
||||
## Configuration
|
||||
|
||||
The bundle adds these fields only when missing and never replaces explicit user choices:
|
||||
Pi reads this configuration from `<agent-dir>/settings.json` (normally `~/.pi/agent/settings.json`). The bundle adds fields only when missing and never replaces explicit user choices:
|
||||
|
||||
```json
|
||||
{
|
||||
"toolSearch": {
|
||||
"alwaysEnabled": ["codegraph_explore", "lsp_diagnostics"],
|
||||
"alwaysEnabled": [
|
||||
"codegraph_explore",
|
||||
"lsp_diagnostics",
|
||||
"ctx_execute",
|
||||
"ctx_execute_file",
|
||||
"ctx_batch_execute"
|
||||
],
|
||||
"showToolSearchFooterStatus": false,
|
||||
"maxActiveGroups": 3,
|
||||
"maxActiveGroups": 5,
|
||||
"maxToolsPerGroup": 8,
|
||||
"maxDynamicTools": 20,
|
||||
"groupOverrides": {}
|
||||
"maxDynamicTools": 28,
|
||||
"groupOverrides": {},
|
||||
"bundleDefaultsVersion": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Bundle default | Description |
|
||||
|---|---|---|
|
||||
| `alwaysEnabled` | `codegraph_explore`, `lsp_diagnostics` | Exact names pinned alongside Pi core tools |
|
||||
| `alwaysEnabled` | CodeGraph explore, LSP diagnostics, Context execution trio | Exact names pinned alongside Pi core tools |
|
||||
| `showToolSearchFooterStatus` | `false` | Show active tool/group counts |
|
||||
| `maxActiveGroups` | `3` | Maximum simultaneously active dynamic groups |
|
||||
| `maxActiveGroups` | `5` | Maximum simultaneously active dynamic groups |
|
||||
| `maxToolsPerGroup` | `8` | Maximum size accepted from model-generated groups |
|
||||
| `maxDynamicTools` | `20` | Hard cap across active dynamic group tools |
|
||||
| `maxDynamicTools` | `28` | Hard cap across active dynamic group tools |
|
||||
| `groupOverrides` | `{}` | Exact tool lists that override checked-in, generated, or fallback assignments |
|
||||
| `bundleDefaultsVersion` | `2` on a newly created default section | Bundle provenance for controlled default migrations; ignored by runtime routing |
|
||||
|
||||
Example override:
|
||||
|
||||
@@ -103,7 +119,7 @@ Example override:
|
||||
}
|
||||
```
|
||||
|
||||
An explicit override larger than `maxDynamicTools` remains visible but cannot be activated until the limit or override is corrected. Invalid `settings.json` or an invalid `toolSearch` value is never overwritten.
|
||||
An explicit override larger than `maxDynamicTools` remains visible but cannot be activated until the limit or override is corrected. Normal extension loading preserves existing values and writes owner-only settings through a unique same-directory temporary file. When the user explicitly runs the bundle's updated `update.sh`, its post-update migration helper changes only the complete former `3`/`20` default snapshot: it creates an owner-only timestamped backup, atomically writes the current defaults, and records `bundleDefaultsVersion: 2`. Customized or invalid settings are preserved, and a content change detected immediately before commit aborts the migration.
|
||||
|
||||
## Current host integration
|
||||
|
||||
|
||||
@@ -2,20 +2,21 @@
|
||||
|
||||
`pi-tool-search` keeps full low-frequency schemas hidden behind a compact, validated group catalog.
|
||||
|
||||
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.
|
||||
1. `session_start` activates `tool_search`, the six cross-platform Pi core tools, and configured `alwaysEnabled` names. The my-pi defaults also pin CodeGraph exploration, LSP diagnostics, and the three high-frequency Context Mode execution tools. On Windows, `powershell` is pinned beside `bash`; on non-Windows hosts it is excluded. Pinned tools do not consume the dynamic-group or dynamic-tool 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()`, 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.
|
||||
9. `turn_start` notices tool or grouping-configuration changes by hash, resets stale dynamic groups, and reconstructs the checked-in/hybrid catalog.
|
||||
7. Loading one exact group or an atomic `groups` batch calls `setActiveTools()` once with the original full tool definitions. A batch is rejected without changing active state if its requested groups alone exceed either capacity limit. Pi supplies newly added schemas on the next model request.
|
||||
8. Group load and member execution update an in-session LRU counter. Loading past the five-group or 28-tool defaults removes least-recently-used non-requested dynamic groups. `/tool-search-status` reports current capacity, LRU order, and the latest eviction reason.
|
||||
9. Natural-language lookup gives exact tool names and workflow aliases more weight than descriptive overlap. Weak or closely tied matches return candidates without activation.
|
||||
10. `turn_start` notices tool or grouping-configuration changes by hash, resets stale dynamic groups, and reconstructs the checked-in/hybrid catalog.
|
||||
|
||||
The standard bundle assigns the official Hippo Pi extension's five `hippo_*` tools to recall and management groups, and assigns `Agent`, `get_subagent_result`, and `steer_subagent` to the `subagents` orchestration group. `context_tree_query` remains in `memory-recall` because it recovers condensed tool output; retired Hermes names and third-party `tff-memory_*` names are not seeded.
|
||||
The maintained `pi-ssh` tools are also split by workflow: sequential reviewed agent-controlled connection lives in `ssh-connection`, structured `ssh_read`/`ssh_write`/`ssh_edit` file operations live in `ssh-remote-files`, bounded `ssh_find`/`ssh_grep` discovery lives in `ssh-remote-search`, and sequential reviewed workspace changes plus reviewed command execution live in `ssh-remote-shell`. The workflows tell the model to call `ssh_connect` or `ssh_cd` separately and wait for success before issuing remote calls that depend on the new connection or cwd.
|
||||
The maintained `ask_user_question` tool has its own `user-interaction` group so the model can load the complete interactive schema only when a missing decision or preference must come from the user.
|
||||
The pinned `pi-chrome` bridge registers its 21 `chrome_*` tools only after explicit per-session authorization. Once registered, catalog refresh assigns them to `chrome-navigation`, `chrome-interaction`, and `chrome-debugging`; revocation still remains enforced inside every upstream tool execution even if a schema is later reactivated by Tool Search.
|
||||
The pinned `pi-chrome` bridge registers its 21 `chrome_*` tools only after explicit per-session authorization. Once registered, catalog refresh assigns them to `chrome-navigation`, `chrome-interaction`, and `chrome-debugging`; the 28-tool default allows all three groups to coexist. Revocation remains enforced inside every upstream tool execution even if a schema is later reactivated by Tool Search.
|
||||
|
||||
A purely additive first load receives Pi's native dynamic-tool result propagation. A replacement that evicts one group while adding another is intentionally non-additive; current Pi detects the removal and uses its safe next-request fallback instead of attaching an invalid additive-only hint.
|
||||
|
||||
|
||||
@@ -334,6 +334,17 @@ export function writeCachedCatalog(cachePath: string, catalog: ToolCatalog): voi
|
||||
renameSync(temporaryPath, cachePath);
|
||||
}
|
||||
|
||||
const GROUP_QUERY_ALIASES: Record<string, string[]> = {
|
||||
"context-execution": ["context mode", "run command", "test build logs", "执行命令", "运行测试", "分析日志"],
|
||||
"ssh-connection": ["ssh connect", "remote server connection", "连接服务器", "远程连接"],
|
||||
"ssh-remote-shell": ["ssh shell", "remote command", "remote terminal", "远程命令", "远程终端"],
|
||||
"ssh-remote-files": ["ssh file", "remote read write edit", "远程文件", "远程读写"],
|
||||
"ssh-remote-search": ["ssh search", "remote find grep", "远程搜索", "远程查找"],
|
||||
"chrome-navigation": ["chrome browser navigation", "browser tabs pages", "浏览器导航", "网页导航"],
|
||||
"chrome-interaction": ["chrome browser interaction", "click type form", "浏览器交互", "点击输入"],
|
||||
"chrome-debugging": ["chrome browser debugging", "console network screenshot", "浏览器调试", "控制台网络"],
|
||||
};
|
||||
|
||||
function queryTerms(value: string): Set<string> {
|
||||
const normalized = value.toLowerCase();
|
||||
const terms = new Set(normalized.match(/[\p{L}\p{N}]+/gu) ?? []);
|
||||
@@ -343,24 +354,92 @@ function queryTerms(value: string): Set<string> {
|
||||
return terms;
|
||||
}
|
||||
|
||||
export function rankGroups(catalog: ToolCatalog, query: string): Array<{ group: GroupCard; score: number }> {
|
||||
const querySet = queryTerms(query);
|
||||
const GENERIC_QUERY_TERMS = new Set([
|
||||
"edit",
|
||||
"execute",
|
||||
"fetch",
|
||||
"file",
|
||||
"files",
|
||||
"find",
|
||||
"get",
|
||||
"list",
|
||||
"read",
|
||||
"run",
|
||||
"search",
|
||||
"show",
|
||||
"tool",
|
||||
"tools",
|
||||
"web",
|
||||
"write",
|
||||
"列出",
|
||||
"写入",
|
||||
"工具",
|
||||
"执行",
|
||||
"搜索",
|
||||
"文件",
|
||||
"显示",
|
||||
"查找",
|
||||
"获取",
|
||||
"编辑",
|
||||
"网页",
|
||||
"读取",
|
||||
"运行",
|
||||
]);
|
||||
|
||||
function addTermWeights(target: Map<string, number>, querySet: Set<string>, value: string, weight: number): void {
|
||||
const terms = queryTerms(value);
|
||||
for (const term of querySet) {
|
||||
if (terms.has(term)) target.set(term, Math.max(target.get(term) ?? 0, weight));
|
||||
}
|
||||
}
|
||||
|
||||
export interface RankedGroup {
|
||||
group: GroupCard;
|
||||
score: number;
|
||||
matchedTerms: string[];
|
||||
}
|
||||
|
||||
export function rankGroups(catalog: ToolCatalog, query: string): RankedGroup[] {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const querySet = queryTerms(normalizedQuery);
|
||||
return catalog.groups
|
||||
.map((group) => {
|
||||
const cards = catalog.tools.filter((tool) => tool.primaryGroup === group.id);
|
||||
const searchable = [
|
||||
group.id,
|
||||
group.title,
|
||||
group.summary,
|
||||
...group.useWhen,
|
||||
...group.avoidWhen,
|
||||
...cards.flatMap((tool) => [tool.name, tool.summary, ...tool.useWhen, ...tool.keywords]),
|
||||
].join(" ");
|
||||
const terms = queryTerms(searchable);
|
||||
let score = 0;
|
||||
for (const term of querySet) if (terms.has(term)) score += term.length > 1 ? 3 : 1;
|
||||
if (group.id === query.trim().toLowerCase()) score += 100;
|
||||
return { group, score };
|
||||
const aliases = GROUP_QUERY_ALIASES[group.id] ?? [];
|
||||
const termWeights = new Map<string, number>();
|
||||
addTermWeights(termWeights, querySet, group.id, 8);
|
||||
addTermWeights(termWeights, querySet, group.title, 6);
|
||||
addTermWeights(termWeights, querySet, group.summary, 2);
|
||||
addTermWeights(termWeights, querySet, group.useWhen.join(" "), 3);
|
||||
addTermWeights(termWeights, querySet, group.avoidWhen.join(" "), 1);
|
||||
addTermWeights(termWeights, querySet, aliases.join(" "), 5);
|
||||
for (const card of cards) {
|
||||
addTermWeights(termWeights, querySet, card.name, 10);
|
||||
addTermWeights(termWeights, querySet, card.summary, 2);
|
||||
addTermWeights(termWeights, querySet, card.useWhen.join(" "), 3);
|
||||
addTermWeights(termWeights, querySet, card.keywords.join(" "), 5);
|
||||
}
|
||||
let score = [...termWeights].reduce(
|
||||
(total, [term, weight]) => total + (term.length > 1 ? 3 : 1) * weight,
|
||||
0,
|
||||
);
|
||||
if (cards.some((card) => normalizedQuery === card.name.toLowerCase())) score += 800;
|
||||
if (group.id === normalizedQuery) score += 1_000;
|
||||
if (aliases.some((alias) => alias.toLowerCase() === normalizedQuery)) score += 500;
|
||||
return { group, score, matchedTerms: [...termWeights.keys()] };
|
||||
})
|
||||
.sort((left, right) => right.score - left.score || left.group.id.localeCompare(right.group.id));
|
||||
}
|
||||
|
||||
/** Avoid auto-activating a generic, weak, or ambiguous natural-language match. */
|
||||
export function selectConfidentGroup(ranked: RankedGroup[], _query: string): GroupCard | undefined {
|
||||
const [first, second] = ranked;
|
||||
if (!first || first.score < 24) return undefined;
|
||||
if (first.score >= 500) return first.group;
|
||||
const informativeTerms = first.matchedTerms.filter(
|
||||
(term) => term.length > 1 && !GENERIC_QUERY_TERMS.has(term),
|
||||
);
|
||||
if (informativeTerms.length === 0) return undefined;
|
||||
if (second && first.score - second.score < 12) return undefined;
|
||||
return first.group;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,35 @@
|
||||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
chmodSync,
|
||||
closeSync,
|
||||
fsyncSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
export type ToolSearchDefaultResult = "updated" | "unchanged" | "skipped-invalid";
|
||||
|
||||
export const BUNDLE_TOOL_SEARCH_DEFAULTS = {
|
||||
alwaysEnabled: ["codegraph_explore", "lsp_diagnostics"],
|
||||
alwaysEnabled: [
|
||||
"codegraph_explore",
|
||||
"lsp_diagnostics",
|
||||
"ctx_execute",
|
||||
"ctx_execute_file",
|
||||
"ctx_batch_execute",
|
||||
],
|
||||
showToolSearchFooterStatus: false,
|
||||
maxActiveGroups: 3,
|
||||
maxActiveGroups: 5,
|
||||
maxToolsPerGroup: 8,
|
||||
maxDynamicTools: 20,
|
||||
maxDynamicTools: 28,
|
||||
groupOverrides: {},
|
||||
} as const;
|
||||
|
||||
export const BUNDLE_TOOL_SEARCH_DEFAULTS_VERSION = 2;
|
||||
|
||||
export interface ToolSearchConfig {
|
||||
alwaysEnabled: string[];
|
||||
showToolSearchFooterStatus: boolean;
|
||||
@@ -100,12 +118,28 @@ export function ensureToolSearchDefaults(agentDir: string): ToolSearchDefaultRes
|
||||
toolSearch[key] = Array.isArray(value) ? [...value] : isObject(value) ? { ...value } : value;
|
||||
changed = true;
|
||||
}
|
||||
if (existing === undefined) {
|
||||
toolSearch.bundleDefaultsVersion = BUNDLE_TOOL_SEARCH_DEFAULTS_VERSION;
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return "unchanged";
|
||||
|
||||
settings.toolSearch = toolSearch;
|
||||
mkdirSync(dirname(targetPath), { recursive: true });
|
||||
const temporaryPath = `${targetPath}.my-pi.tmp`;
|
||||
writeFileSync(temporaryPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
||||
renameSync(temporaryPath, targetPath);
|
||||
const temporaryPath = `${targetPath}.my-pi-${process.pid}-${Math.random().toString(16).slice(2)}.tmp`;
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
descriptor = openSync(temporaryPath, "wx", 0o600);
|
||||
writeFileSync(descriptor, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
||||
fsyncSync(descriptor);
|
||||
closeSync(descriptor);
|
||||
descriptor = undefined;
|
||||
renameSync(temporaryPath, targetPath);
|
||||
chmodSync(targetPath, 0o600);
|
||||
} catch (error) {
|
||||
if (descriptor !== undefined) closeSync(descriptor);
|
||||
rmSync(temporaryPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
return "updated";
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
parseGeneratedCatalog,
|
||||
rankGroups,
|
||||
readCachedCatalog,
|
||||
selectConfidentGroup,
|
||||
writeCachedCatalog,
|
||||
type CatalogConstraints,
|
||||
type GroupCard,
|
||||
@@ -32,6 +33,7 @@ import { ensureToolSearchDefaults, readToolSearchConfig } from "./config.ts";
|
||||
|
||||
const TOOL_SEARCH_NAME = "tool_search";
|
||||
const POWERSHELL_TOOL_NAME = "powershell";
|
||||
const MAX_BATCH_GROUPS = 5;
|
||||
const CORE_TOOLS = ["read", "write", "edit", "bash", "grep", "find"];
|
||||
|
||||
type CatalogSource = "bundle" | "cache" | "fallback" | "hybrid" | "model";
|
||||
@@ -102,7 +104,7 @@ function groupDescription(catalog: ToolCatalog, source: CatalogSource): string {
|
||||
})
|
||||
.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.",
|
||||
"Activate one or more complete tool groups for the current task. Prefer exact group ids from this catalog; use query only when no id clearly matches.",
|
||||
`Catalog source: ${sourceLabel}.`,
|
||||
"Available groups:",
|
||||
groups || " (no hidden tool groups)",
|
||||
@@ -131,6 +133,7 @@ export default function toolSearchExtension(pi: ExtensionAPI): void {
|
||||
const activeGroups = new Map<string, number>();
|
||||
const pinnedTools = new Set<string>();
|
||||
let bundleState: BundleCatalogResult = { catalog, coveredNames: new Set(), unknownTools: [] };
|
||||
let lastEviction = "none";
|
||||
|
||||
function constraints(): CatalogConstraints {
|
||||
return {
|
||||
@@ -171,12 +174,32 @@ export default function toolSearchExtension(pi: ExtensionAPI): void {
|
||||
return catalog.groups.find((group) => group.id === id);
|
||||
}
|
||||
|
||||
function dynamicToolCount(): number {
|
||||
return unique([...activeGroups.keys()].flatMap((id) => groupById(id)?.tools ?? [])).length;
|
||||
function dynamicToolCount(groupIds: Iterable<string> = activeGroups.keys()): number {
|
||||
return unique([...groupIds].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 leastRecentlyUsedGroup(
|
||||
groups: Map<string, number> = activeGroups,
|
||||
protectedGroups: ReadonlySet<string> = new Set(),
|
||||
): string | undefined {
|
||||
return [...groups]
|
||||
.filter(([id]) => !protectedGroups.has(id))
|
||||
.sort((left, right) => left[1] - right[1] || left[0].localeCompare(right[0]))[0]?.[0];
|
||||
}
|
||||
|
||||
function currentStatusText(): string {
|
||||
const lru = [...activeGroups]
|
||||
.sort((left, right) => left[1] - right[1] || left[0].localeCompare(right[0]))
|
||||
.map(([id]) => id);
|
||||
return [
|
||||
"Tool Search status",
|
||||
`Catalog: ${catalogSource}`,
|
||||
`Pinned tools: ${pinnedTools.size}`,
|
||||
`Dynamic groups: ${activeGroups.size}/${config.maxActiveGroups}`,
|
||||
`Dynamic tools: ${dynamicToolCount()}/${config.maxDynamicTools}`,
|
||||
`LRU oldest → newest: ${lru.join(" → ") || "none"}`,
|
||||
`Last eviction: ${lastEviction}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function applyActiveTools(): void {
|
||||
@@ -189,7 +212,7 @@ export default function toolSearchExtension(pi: ExtensionAPI): void {
|
||||
ctx.ui.setStatus(
|
||||
"tool-search",
|
||||
config.showToolSearchFooterStatus
|
||||
? `${activeToolCount} / ${tools.length + 1} tools · ${activeGroups.size} / ${config.maxActiveGroups} groups`
|
||||
? `${activeToolCount} / ${tools.length + pinnedTools.size + 1} tools · ${activeGroups.size} / ${config.maxActiveGroups} groups`
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
@@ -199,71 +222,147 @@ export default function toolSearchExtension(pi: ExtensionAPI): void {
|
||||
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`,
|
||||
promptSnippet: `Activate relevant tool groups on demand; up to ${config.maxActiveGroups} dynamic groups can remain active. Use groups for workflows that need several groups together`,
|
||||
parameters: Type.Object({
|
||||
group: Type.Optional(Type.String({ description: "Exact group id from the tool_search catalog" })),
|
||||
groups: Type.Optional(
|
||||
Type.Array(Type.String({ description: "Exact group id from the tool_search catalog" }), {
|
||||
description: `Exact group ids to activate atomically (maximum ${MAX_BATCH_GROUPS})`,
|
||||
minItems: 1,
|
||||
maxItems: MAX_BATCH_GROUPS,
|
||||
}),
|
||||
),
|
||||
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();
|
||||
let selected = requestedId ? groupById(requestedId) : undefined;
|
||||
const generation: CatalogGenerationResult = selected
|
||||
? {}
|
||||
: await ensureModelCatalog(ctx, signal);
|
||||
const singleGroup = typeof params.group === "string" ? params.group.trim() : "";
|
||||
const batchGroups = Array.isArray(params.groups) ? params.groups : [];
|
||||
const query = typeof params.query === "string" ? params.query.trim() : "";
|
||||
const hasSingle = singleGroup.length > 0;
|
||||
const hasBatch = batchGroups.length > 0;
|
||||
const hasQuery = query.length > 0;
|
||||
const modes = Number(hasSingle) + Number(hasBatch) + Number(hasQuery);
|
||||
const lines: string[] = [];
|
||||
const emptyDetails = (candidates: Array<{ id: string; score: number }> = []) => ({
|
||||
loadedGroup: undefined,
|
||||
loadedGroups: [] as string[],
|
||||
evictedGroups: [] as string[],
|
||||
activeGroups: [...activeGroups.keys()],
|
||||
candidates,
|
||||
catalogSource,
|
||||
});
|
||||
|
||||
if (modes !== 1) {
|
||||
lines.push("Provide exactly one of group, groups, or query.");
|
||||
return { content: [{ type: "text", text: lines.join("\n") }], details: emptyDetails() };
|
||||
}
|
||||
if (batchGroups.length > MAX_BATCH_GROUPS) {
|
||||
lines.push(`groups accepts at most ${MAX_BATCH_GROUPS} group ids.`);
|
||||
return { content: [{ type: "text", text: lines.join("\n") }], details: emptyDetails() };
|
||||
}
|
||||
|
||||
const requestedIds = unique(
|
||||
(hasBatch ? batchGroups : hasSingle ? [singleGroup] : [])
|
||||
.map((id) => id.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
);
|
||||
let selectedGroups = requestedIds.map(groupById).filter((group): group is GroupCard => group !== undefined);
|
||||
const needsCatalog = hasQuery || selectedGroups.length !== requestedIds.length;
|
||||
const generation: CatalogGenerationResult = needsCatalog ? await ensureModelCatalog(ctx, signal) : {};
|
||||
if (generation.notice) lines.push(generation.notice);
|
||||
|
||||
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;
|
||||
selectedGroups = requestedIds.map(groupById).filter((group): group is GroupCard => group !== undefined);
|
||||
const ranked = hasQuery ? rankGroups(catalog, query).slice(0, 3) : [];
|
||||
if (hasQuery) {
|
||||
const selected = selectConfidentGroup(ranked, query);
|
||||
if (selected) selectedGroups = [selected];
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
const missingIds = requestedIds.filter((id) => !groupById(id));
|
||||
if (selectedGroups.length === 0 || missingIds.length > 0) {
|
||||
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.",
|
||||
missingIds.length > 0
|
||||
? `Unknown groups: ${missingIds.join(", ")}`
|
||||
: hasQuery
|
||||
? "No group was activated because the query match was weak or ambiguous. Use an exact group id from the candidates."
|
||||
: "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 },
|
||||
details: emptyDetails(candidates.map(({ group, score }) => ({ id: group.id, score }))),
|
||||
...(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}.`);
|
||||
const selectedIds = selectedGroups.map((group) => group.id);
|
||||
const selectedTools = unique(selectedGroups.flatMap((group) => group.tools));
|
||||
if (selectedIds.length > config.maxActiveGroups || selectedTools.length > config.maxDynamicTools) {
|
||||
lines.push(
|
||||
`Requested workflow needs ${selectedIds.length} groups and ${selectedTools.length} tools, exceeding the configured limits of ${config.maxActiveGroups} groups and ${config.maxDynamicTools} tools.`,
|
||||
);
|
||||
return {
|
||||
content: [{ type: "text", text: lines.join("\n") }],
|
||||
details: { loadedGroup: undefined, evictedGroups: [], activeGroups: [...activeGroups.keys()], candidates: [], catalogSource },
|
||||
details: emptyDetails(),
|
||||
...(generation.usage ? { usage: generation.usage } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const requestedSet = new Set(selectedIds);
|
||||
const nextActive = new Map(activeGroups);
|
||||
for (const id of selectedIds) if (!nextActive.has(id)) nextActive.set(id, Number.MAX_SAFE_INTEGER);
|
||||
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);
|
||||
}
|
||||
const evictionReasons = new Set<string>();
|
||||
while (nextActive.size > config.maxActiveGroups || dynamicToolCount(nextActive.keys()) > config.maxDynamicTools) {
|
||||
if (nextActive.size > config.maxActiveGroups) evictionReasons.add(`group cap ${config.maxActiveGroups}`);
|
||||
if (dynamicToolCount(nextActive.keys()) > config.maxDynamicTools) evictionReasons.add(`tool cap ${config.maxDynamicTools}`);
|
||||
const evicted = leastRecentlyUsedGroup(nextActive, requestedSet);
|
||||
if (!evicted) break;
|
||||
nextActive.delete(evicted);
|
||||
evictedGroups.push(evicted);
|
||||
}
|
||||
activeGroups.set(selected.id, ++clock);
|
||||
applyActiveTools();
|
||||
registerToolSearch();
|
||||
updateStatus(ctx);
|
||||
|
||||
lines.push(`Loaded group: ${selected.id} (${groupTools.join(", ")})`);
|
||||
const previousActive = new Map(activeGroups);
|
||||
const previousClock = clock;
|
||||
const previousLastEviction = lastEviction;
|
||||
activeGroups.clear();
|
||||
for (const [id, recency] of nextActive) activeGroups.set(id, recency);
|
||||
for (const id of selectedIds) activeGroups.set(id, ++clock);
|
||||
if (evictedGroups.length > 0) {
|
||||
lastEviction = `${evictedGroups.join(", ")} (${[...evictionReasons].join("; ")})`;
|
||||
}
|
||||
try {
|
||||
applyActiveTools();
|
||||
registerToolSearch();
|
||||
updateStatus(ctx);
|
||||
} catch (error) {
|
||||
activeGroups.clear();
|
||||
for (const [id, recency] of previousActive) activeGroups.set(id, recency);
|
||||
clock = previousClock;
|
||||
lastEviction = previousLastEviction;
|
||||
try {
|
||||
applyActiveTools();
|
||||
registerToolSearch();
|
||||
updateStatus(ctx);
|
||||
} catch {
|
||||
// Preserve the original host activation failure while retaining rolled-back internal state.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (selectedGroups.length === 1) {
|
||||
lines.push(`Loaded group: ${selectedGroups[0]?.id} (${selectedTools.join(", ")})`);
|
||||
} else {
|
||||
lines.push(`Loaded groups: ${selectedIds.join(", ")} (${selectedTools.length} unique tools)`);
|
||||
}
|
||||
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,
|
||||
loadedGroup: selectedIds.at(-1),
|
||||
loadedGroups: selectedIds,
|
||||
evictedGroups,
|
||||
activeGroups: [...activeGroups.keys()],
|
||||
candidates: ranked.map(({ group, score }) => ({ id: group.id, score })),
|
||||
@@ -379,17 +478,23 @@ export default function toolSearchExtension(pi: ExtensionAPI): void {
|
||||
if (catalogChanged) {
|
||||
activeGroups.clear();
|
||||
clock = 0;
|
||||
lastEviction = "none";
|
||||
attemptedGenerationHash = undefined;
|
||||
installBaseCatalog(true);
|
||||
}
|
||||
|
||||
let capacityChanged = false;
|
||||
const refreshEvictions: string[] = [];
|
||||
const refreshReasons = new Set<string>();
|
||||
while (activeGroups.size > config.maxActiveGroups || dynamicToolCount() > config.maxDynamicTools) {
|
||||
if (activeGroups.size > config.maxActiveGroups) refreshReasons.add(`group cap ${config.maxActiveGroups}`);
|
||||
if (dynamicToolCount() > config.maxDynamicTools) refreshReasons.add(`tool cap ${config.maxDynamicTools}`);
|
||||
const evicted = leastRecentlyUsedGroup();
|
||||
if (!evicted) break;
|
||||
activeGroups.delete(evicted);
|
||||
capacityChanged = true;
|
||||
refreshEvictions.push(evicted);
|
||||
}
|
||||
const capacityChanged = refreshEvictions.length > 0;
|
||||
if (capacityChanged) lastEviction = `${refreshEvictions.join(", ")} (${[...refreshReasons].join("; ")}; settings refresh)`;
|
||||
if (catalogChanged || policyChanged || capacityChanged) {
|
||||
registerToolSearch();
|
||||
applyActiveTools();
|
||||
@@ -397,6 +502,13 @@ export default function toolSearchExtension(pi: ExtensionAPI): void {
|
||||
updateStatus(ctx);
|
||||
}
|
||||
|
||||
pi.registerCommand("tool-search-status", {
|
||||
description: "Show Tool Search catalog, capacity, active-group LRU order, and the latest eviction reason",
|
||||
handler: async (_args, ctx) => {
|
||||
if (ctx.hasUI) ctx.ui.notify(currentStatusText(), "info");
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("tool-search-rebuild", {
|
||||
description: "Invalidate the generated tool-group catalog; rebuild lazily on the next tool_search call",
|
||||
handler: async (_args, ctx) => {
|
||||
@@ -407,6 +519,7 @@ export default function toolSearchExtension(pi: ExtensionAPI): void {
|
||||
}
|
||||
activeGroups.clear();
|
||||
attemptedGenerationHash = undefined;
|
||||
lastEviction = "none";
|
||||
installBaseCatalog(false);
|
||||
registerToolSearch();
|
||||
applyActiveTools();
|
||||
|
||||
@@ -3,7 +3,7 @@ import test from "node:test";
|
||||
|
||||
import type { ToolInfo } from "@earendil-works/pi-coding-agent";
|
||||
import { BUNDLE_GROUP_DEFINITIONS, createBundleCatalog } from "../extensions/bundle-groups.ts";
|
||||
import { buildManifestHash } from "../extensions/catalog.ts";
|
||||
import { buildManifestHash, rankGroups, selectConfidentGroup } from "../extensions/catalog.ts";
|
||||
|
||||
function tool(name: string): ToolInfo {
|
||||
return {
|
||||
@@ -38,6 +38,14 @@ test("bundle seed names are unique and cover the declared my-pi tools", () => {
|
||||
assert.equal(result.catalog.tools.find((card) => card.name === "chrome_snapshot")?.primaryGroup, "chrome-navigation");
|
||||
assert.equal(result.catalog.tools.find((card) => card.name === "chrome_click")?.primaryGroup, "chrome-interaction");
|
||||
assert.equal(result.catalog.tools.find((card) => card.name === "chrome_list_network_requests")?.primaryGroup, "chrome-debugging");
|
||||
for (const query of ["read", "read banana", "search", "web search", "查找", "运行", "查找香蕉"]) {
|
||||
assert.equal(selectConfidentGroup(rankGroups(result.catalog, query), query), undefined);
|
||||
}
|
||||
assert.equal(selectConfidentGroup(rankGroups(result.catalog, "ssh_read"), "ssh_read")?.id, "ssh-remote-files");
|
||||
assert.equal(
|
||||
selectConfidentGroup(rankGroups(result.catalog, "remote find grep"), "remote find grep")?.id,
|
||||
"ssh-remote-search",
|
||||
);
|
||||
});
|
||||
|
||||
test("user overrides take priority and unknown tools retain deterministic fallback groups", () => {
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
parseGeneratedCatalog,
|
||||
rankGroups,
|
||||
readCachedCatalog,
|
||||
selectConfidentGroup,
|
||||
writeCachedCatalog,
|
||||
type ToolCatalog,
|
||||
} from "../extensions/catalog.ts";
|
||||
|
||||
function tool(name: string, description: string): ToolInfo {
|
||||
@@ -62,6 +64,29 @@ test("validates exact generated assignments and ranks generated metadata", () =>
|
||||
);
|
||||
});
|
||||
|
||||
test("prefers exact tool names and refuses weak or ambiguous query activation", () => {
|
||||
const catalog: ToolCatalog = {
|
||||
version: 1,
|
||||
manifestHash: "test",
|
||||
generatedAt: new Date(0).toISOString(),
|
||||
groups: [
|
||||
{ id: "remote-files", title: "Remote files", summary: "Read remote files", useWhen: [], avoidWhen: [], tools: ["ssh_read"] },
|
||||
{ id: "web-reader", title: "Web reader", summary: "Read web pages", useWhen: [], avoidWhen: [], tools: ["web_read"] },
|
||||
],
|
||||
tools: [
|
||||
{ name: "ssh_read", summary: "Read a remote file", useWhen: [], avoidWhen: [], keywords: ["SSH"], primaryGroup: "remote-files" },
|
||||
{ name: "web_read", summary: "Read a web page", useWhen: [], avoidWhen: [], keywords: ["web"], primaryGroup: "web-reader" },
|
||||
],
|
||||
};
|
||||
|
||||
assert.equal(selectConfidentGroup(rankGroups(catalog, "ssh_read"), "ssh_read")?.id, "remote-files");
|
||||
assert.equal(selectConfidentGroup(rankGroups(catalog, "read"), "read"), undefined);
|
||||
assert.equal(
|
||||
selectConfidentGroup(rankGroups(catalog, "completely unrelated capability"), "completely unrelated capability"),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("writes a private cache and rejects a stale manifest hash", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "tool-search-catalog-"));
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
@@ -18,16 +18,25 @@ async function withAgentDir(run: (agentDir: string) => Promise<void>): Promise<v
|
||||
test("writes bundle defaults when toolSearch is absent", async () => {
|
||||
await withAgentDir(async (agentDir) => {
|
||||
assert.equal(ensureToolSearchDefaults(agentDir), "updated");
|
||||
assert.deepEqual(BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled, [
|
||||
"codegraph_explore",
|
||||
"lsp_diagnostics",
|
||||
"ctx_execute",
|
||||
"ctx_execute_file",
|
||||
"ctx_batch_execute",
|
||||
]);
|
||||
assert.deepEqual(JSON.parse(await readFile(join(agentDir, "settings.json"), "utf8")), {
|
||||
toolSearch: {
|
||||
alwaysEnabled: [...BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled],
|
||||
showToolSearchFooterStatus: false,
|
||||
maxActiveGroups: 3,
|
||||
maxActiveGroups: 5,
|
||||
maxToolsPerGroup: 8,
|
||||
maxDynamicTools: 20,
|
||||
maxDynamicTools: 28,
|
||||
groupOverrides: {},
|
||||
bundleDefaultsVersion: 2,
|
||||
},
|
||||
});
|
||||
assert.equal((await stat(join(agentDir, "settings.json"))).mode & 0o777, 0o600);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +52,7 @@ test("fills missing defaults and preserves explicit settings", async () => {
|
||||
maxActiveGroups: 2,
|
||||
showToolSearchFooterStatus: false,
|
||||
maxToolsPerGroup: 8,
|
||||
maxDynamicTools: 20,
|
||||
maxDynamicTools: 28,
|
||||
groupOverrides: {},
|
||||
},
|
||||
});
|
||||
@@ -67,26 +76,26 @@ test("normalizes invalid runtime values without overwriting the file", async ()
|
||||
assert.deepEqual(readToolSearchConfig(agentDir), {
|
||||
alwaysEnabled: ["one"],
|
||||
showToolSearchFooterStatus: false,
|
||||
maxActiveGroups: 3,
|
||||
maxActiveGroups: 5,
|
||||
maxToolsPerGroup: 4,
|
||||
maxDynamicTools: 20,
|
||||
maxDynamicTools: 28,
|
||||
groupOverrides: { web: ["search"] },
|
||||
});
|
||||
assert.equal(await readFile(path, "utf8"), original);
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves explicit complete configuration", async () => {
|
||||
test("preserves a complete explicit configuration including former defaults", async () => {
|
||||
await withAgentDir(async (agentDir) => {
|
||||
const path = join(agentDir, "settings.json");
|
||||
const original = `${JSON.stringify({
|
||||
toolSearch: {
|
||||
alwaysEnabled: [],
|
||||
showToolSearchFooterStatus: true,
|
||||
maxActiveGroups: 1,
|
||||
maxToolsPerGroup: 2,
|
||||
maxDynamicTools: 2,
|
||||
groupOverrides: { custom: ["one"] },
|
||||
alwaysEnabled: ["codegraph_explore", "lsp_diagnostics"],
|
||||
showToolSearchFooterStatus: false,
|
||||
maxActiveGroups: 3,
|
||||
maxToolsPerGroup: 8,
|
||||
maxDynamicTools: 20,
|
||||
groupOverrides: {},
|
||||
},
|
||||
}, null, 2)}\n`;
|
||||
await writeFile(path, original, "utf8");
|
||||
|
||||
@@ -10,6 +10,7 @@ import toolSearchExtension, { platformToolPolicy } from "../extensions/index.ts"
|
||||
interface ToolSearchResult {
|
||||
details: {
|
||||
loadedGroup?: string;
|
||||
loadedGroups: string[];
|
||||
evictedGroups: string[];
|
||||
activeGroups: string[];
|
||||
catalogSource: string;
|
||||
@@ -20,7 +21,7 @@ interface ToolSearchResult {
|
||||
interface RegisteredTool {
|
||||
execute(
|
||||
id: string,
|
||||
params: { group?: string; query?: string },
|
||||
params: { group?: string; groups?: string[]; query?: string },
|
||||
signal: AbortSignal | undefined,
|
||||
onUpdate: undefined,
|
||||
ctx: ExtensionContext,
|
||||
@@ -67,6 +68,9 @@ test("loads whole groups and evicts the least-recently-used group", async () =>
|
||||
const handlers = new Map<string, (...args: any[]) => unknown>();
|
||||
const registered = new Map<string, RegisteredTool>();
|
||||
const activeCalls: string[][] = [];
|
||||
const commands = new Map<string, (...args: any[]) => unknown>();
|
||||
const notifications: string[] = [];
|
||||
let failActivation = false;
|
||||
const sourceTools = [
|
||||
sourceTool("read", "Read a file"),
|
||||
sourceTool("bash", "Run a command"),
|
||||
@@ -85,13 +89,22 @@ test("loads whole groups and evicts the least-recently-used group", async () =>
|
||||
const tool = definition as RegisteredTool & { name: string };
|
||||
registered.set(tool.name, tool);
|
||||
},
|
||||
registerCommand: () => {},
|
||||
setActiveTools: (names: string[]) => activeCalls.push([...names]),
|
||||
registerCommand: (name: string, definition: { handler: (...args: any[]) => unknown }) => commands.set(name, definition.handler),
|
||||
setActiveTools: (names: string[]) => {
|
||||
if (failActivation) throw new Error("simulated setActiveTools failure");
|
||||
activeCalls.push([...names]);
|
||||
},
|
||||
on: (event: string, handler: (...args: any[]) => unknown) => handlers.set(event, handler),
|
||||
} as unknown as ExtensionAPI;
|
||||
|
||||
toolSearchExtension(api);
|
||||
const ctx = testContext();
|
||||
const ctx = testContext({
|
||||
hasUI: true,
|
||||
ui: {
|
||||
setStatus: () => {},
|
||||
notify: (message: string) => notifications.push(message),
|
||||
} as unknown as ExtensionContext["ui"],
|
||||
});
|
||||
handlers.get("session_start")?.({}, ctx);
|
||||
|
||||
assert.ok(activeCalls.at(-1)?.includes("tool_search"));
|
||||
@@ -113,6 +126,36 @@ test("loads whole groups and evicts the least-recently-used group", async () =>
|
||||
assert.ok(activeCalls.at(-1)?.includes("alpha_one"));
|
||||
assert.ok(activeCalls.at(-1)?.includes("gamma_one"));
|
||||
assert.ok(!activeCalls.at(-1)?.includes("beta_one"));
|
||||
|
||||
const batch = await tool?.execute("call-4", { groups: ["beta", "gamma"] }, undefined, undefined, ctx);
|
||||
assert.deepEqual(batch?.details.loadedGroups, ["beta", "gamma"]);
|
||||
assert.deepEqual(batch?.details.evictedGroups, ["alpha"]);
|
||||
assert.deepEqual(batch?.details.activeGroups, ["gamma", "beta"]);
|
||||
|
||||
const rejected = await tool?.execute(
|
||||
"call-5",
|
||||
{ groups: ["alpha", "beta", "gamma"] },
|
||||
undefined,
|
||||
undefined,
|
||||
ctx,
|
||||
);
|
||||
assert.deepEqual(rejected?.details.loadedGroups, []);
|
||||
assert.deepEqual(rejected?.details.activeGroups, ["gamma", "beta"]);
|
||||
|
||||
await commands.get("tool-search-status")?.("", ctx);
|
||||
assert.match(notifications.at(-1) ?? "", /Dynamic groups: 2\/2/);
|
||||
assert.match(notifications.at(-1) ?? "", /LRU oldest → newest: beta → gamma/);
|
||||
assert.match(notifications.at(-1) ?? "", /Last eviction: alpha \(group cap 2\)/);
|
||||
|
||||
failActivation = true;
|
||||
await assert.rejects(
|
||||
tool!.execute("call-6", { groups: ["alpha", "gamma"] }, undefined, undefined, ctx),
|
||||
/simulated setActiveTools failure/,
|
||||
);
|
||||
failActivation = false;
|
||||
await commands.get("tool-search-status")?.("", ctx);
|
||||
assert.match(notifications.at(-1) ?? "", /LRU oldest → newest: beta → gamma/);
|
||||
assert.match(notifications.at(-1) ?? "", /Last eviction: alpha \(group cap 2\)/);
|
||||
} finally {
|
||||
if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
||||
else process.env.PI_CODING_AGENT_DIR = previousAgentDir;
|
||||
@@ -203,11 +246,15 @@ test("uses the precomputed bundle groups without calling a model", async () => {
|
||||
try {
|
||||
const handlers = new Map<string, (...args: any[]) => unknown>();
|
||||
const registered = new Map<string, RegisteredTool>();
|
||||
const activeCalls: string[][] = [];
|
||||
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("ctx_execute", "Run commands in a context sandbox"),
|
||||
sourceTool("ctx_execute_file", "Analyze a file in a context sandbox"),
|
||||
sourceTool("ctx_batch_execute", "Run a bounded command batch"),
|
||||
];
|
||||
const api = {
|
||||
getAllTools: () => sourceTools,
|
||||
@@ -216,7 +263,7 @@ test("uses the precomputed bundle groups without calling a model", async () => {
|
||||
registered.set(tool.name, tool);
|
||||
},
|
||||
registerCommand: () => {},
|
||||
setActiveTools: () => {},
|
||||
setActiveTools: (names: string[]) => activeCalls.push([...names]),
|
||||
on: (event: string, handler: (...args: any[]) => unknown) => handlers.set(event, handler),
|
||||
} as unknown as ExtensionAPI;
|
||||
toolSearchExtension(api);
|
||||
@@ -231,10 +278,21 @@ test("uses the precomputed bundle groups without calling a model", async () => {
|
||||
} as unknown as ExtensionContext["modelRegistry"],
|
||||
});
|
||||
handlers.get("session_start")?.({}, ctx);
|
||||
assert.ok(["ctx_execute", "ctx_execute_file", "ctx_batch_execute"].every((name) => activeCalls.at(-1)?.includes(name)));
|
||||
|
||||
const ambiguous = await registered.get("tool_search")?.execute(
|
||||
"call-1",
|
||||
{ query: "web search" },
|
||||
undefined,
|
||||
undefined,
|
||||
ctx,
|
||||
);
|
||||
assert.equal(ambiguous?.details.loadedGroup, undefined);
|
||||
assert.deepEqual(ambiguous?.details.activeGroups, []);
|
||||
|
||||
const result = await registered.get("tool_search")?.execute(
|
||||
"call-1",
|
||||
{ group: "web-tavily" },
|
||||
"call-2",
|
||||
{ query: "tavily_web_search" },
|
||||
undefined,
|
||||
undefined,
|
||||
ctx,
|
||||
|
||||
Reference in New Issue
Block a user