Files
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 87940da51a feat(prompt): 重新注册 bash,把"不要用 bash 搜索"写进工具描述
真实会话里模型仍习惯性先调 bash grep,被拒后才改用 grep 工具;系统提示词里已有 4 处
提醒,但模型下调用决定时看的是工具 schema 描述,而 Pi 内置 bash 描述对此只字未提。

- 新增 extensions/bash-override.ts:用 Pi 的 createBashToolDefinition 重新注册 bash,
  执行、输出结构与 codemode 说明完全沿用上游;shellPath/commandPrefix 按 ctx.cwd
  经 SettingsManager 惰性解析,与内置一致
- tools.jsonc 新增 descriptionAppend 字段,由 prompt-guard 注册时拼接到上游描述后
- bash 增加命令→工具对照规则(grep -n / grep -rl / find -name),并提示
  文件清单用 $(git ls-files) 传参、不要接 xargs(会触发权限审核)

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-22 01:20:52 -07:00

121 lines
4.5 KiB
TypeScript

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import {
loadToolCatalog,
type ToolCatalog,
type ToolCatalogEntry,
} from "./system-prompt-config.ts";
type AnyMessage = { role?: string; content?: unknown } & Record<string, unknown>;
type AnyHandler = (event: never, ctx: never) => unknown;
/**
* The description a tool registers with under its catalog entry: a `description`
* override replaces the upstream text, a `descriptionAppend` is joined onto
* whichever base wins (override first, upstream otherwise) with a blank line.
*/
export function catalogDescription(description: string, entry: ToolCatalogEntry): string {
if ("inherit" in entry) return description;
const base = typeof entry.description === "string" ? entry.description : description;
return typeof entry.descriptionAppend === "string"
? `${base.trimEnd()}\n\n${entry.descriptionAppend}`
: base;
}
export interface PromptGuardOptions {
/**
* Post-process the messages a wrapped `context` handler leaves behind.
* Receives the message list snapshot from before the handler ran and the
* list after; returns the list the handler's result should report.
*/
contextFilter?: (before: AnyMessage[], after: AnyMessage[]) => AnyMessage[];
}
/**
* Proxy an `ExtensionAPI` for third-party extensions so they cannot bypass the
* bundle's single system-prompt owner (`extensions/system-prompt.ts`):
*
* - `registerTool` applies the tool's `description` override and
* `descriptionAppend` suffix from `config/prompt/tools.jsonc` when the
* catalog carries them (lazily loaded; a catalog error keeps the upstream
* description). An append composes onto the override when both are set, or
* onto the upstream description when only the append is set.
* - `before_agent_start` handlers keep every returned field except
* `systemPrompt`, which is dropped.
* - `context` handlers are post-processed through `opts.contextFilter`.
* - Everything else passes through unchanged.
*/
export function guardExtensionApi(pi: ExtensionAPI, opts: PromptGuardOptions = {}): ExtensionAPI {
let catalogCache: ToolCatalog | null | undefined;
const catalog = (): ToolCatalog | null => {
if (catalogCache === undefined) {
try {
catalogCache = loadToolCatalog();
} catch {
catalogCache = null;
}
}
return catalogCache;
};
const registerTool: ExtensionAPI["registerTool"] = (tool) => {
const entry = catalog()?.tools[tool.name];
const description =
entry === undefined ? tool.description : catalogDescription(tool.description, entry);
if (description === tool.description) {
pi.registerTool(tool);
} else {
pi.registerTool({ ...tool, description });
}
};
const on: ExtensionAPI["on"] = ((event: string, handler: AnyHandler): unknown => {
let wrapped = handler;
if (event === "before_agent_start") {
wrapped = (async (eventPayload: unknown, ctx: unknown) => {
const result = await handler(eventPayload as never, ctx as never);
if (result !== null && typeof result === "object" && "systemPrompt" in result) {
const rest = { ...(result as Record<string, unknown>) };
delete rest.systemPrompt;
return rest;
}
return result;
}) as AnyHandler;
} else if (event === "context" && opts.contextFilter) {
const filter = opts.contextFilter;
wrapped = (async (eventPayload: { messages?: AnyMessage[] }, ctx: unknown) => {
const messages = Array.isArray(eventPayload.messages) ? eventPayload.messages : [];
const before = messages.slice();
const result = await handler(eventPayload as never, ctx as never);
const resultMessages =
result !== null &&
typeof result === "object" &&
Array.isArray((result as { messages?: unknown }).messages)
? ((result as { messages: AnyMessage[] }).messages)
: messages;
const filtered = filter(before, resultMessages);
if (filtered === resultMessages) {
return result;
}
return {
...(result !== null && typeof result === "object" ? (result as Record<string, unknown>) : {}),
messages: filtered,
};
}) as AnyHandler;
}
// `on` is overloaded per event name; drop to a loosely typed call and
// forward whatever the real registration returns (e.g. a disposer).
return (pi.on as (name: string, h: AnyHandler) => unknown)(event, wrapped);
}) as ExtensionAPI["on"];
return new Proxy(pi, {
get(target, prop, receiver) {
if (prop === "registerTool") return registerTool;
if (prop === "on") return on;
const value = Reflect.get(target, prop, receiver);
return typeof value === "function" ? value.bind(target) : value;
},
});
}