Files
my-pi/extensions/system-prompt.ts
T
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 360d8cffc8 feat(pi-subagents): 重新启用子 Agent 并使用独立的子 Agent 提示词
- 根包恢复 file:./pi-subagents 依赖与 extensions/subagents.ts 加载
  (排在 permission-system 之后、Tool Search 之前),并刷新根锁文件;
  Agent / get_subagent_result / steer_subagent 权限设为 allow,
  子 Agent 内的实际操作仍逐个经权限系统并转发主会话。
- 新增 config/prompt/subagent/:执行者角色 preamble 与组合包自带的
  general-purpose / Explore / Plan 定义(替换上游内置,去掉 bash find
  冲突规则与写死的 Haiku 模型)。
- pi-subagents 新增 basePrompt / bundledAgentsDir / excludedExtensionPaths
  选项:子 Agent 不再嵌入父会话提示词,身份标记置于开头;子会话排除
  pi-ask-user。未传选项时保持上游行为。
- system-prompt.ts:customPrompt 自带开场白时不再覆盖(修复子 Agent 角色
  与 <active_agent> 身份被抹掉);仅凭 customPrompt 上的 <active_agent>
  识别子会话,routing 规则支持 scope,sections 支持 subagentExclude
  (子会话去掉 memory_policy 与 chrome_profile_bridge)。
- pi-hermes-memory:子会话只读,记忆/skill 写入工具返回错误,后台整理、
  纠错检测、会话 flush 与索引写入全部跳过。

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

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

182 lines
6.6 KiB
TypeScript

import type { BeforeAgentStartEvent, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import {
applyToolCatalog,
isSubagentCustomPrompt,
loadRoutingRules,
loadSectionsConfig,
loadToolCatalog,
orderSections,
readPreamble,
renderRouting,
spliceCover,
type MutablePromptOptions,
type RoutingConfig,
type SectionsConfig,
type ToolCatalog,
} from "./system-prompt-config.ts";
const DUMP_RELATIVE_PATH = ".pi-debug/effective-system-prompt.md";
export function getSystemPromptDumpPath(cwd: string): string {
return resolve(cwd, DUMP_RELATIVE_PATH);
}
/** The options object as Pi 1.3 normalizes it; `MutablePromptOptions` omits `forceSystemPrompt`/`customPrompt`. */
type RuntimePromptOptions = MutablePromptOptions & {
forceSystemPrompt?: string;
customPrompt?: string;
};
/**
* Injectable seams so the composer can be exercised under `node --test`
* without touching the real `config/prompt/` files.
*/
export interface SystemPromptComposerDeps {
loadToolCatalog?: () => ToolCatalog;
loadRoutingRules?: () => RoutingConfig;
loadSectionsConfig?: () => SectionsConfig;
readPreamble?: () => string | undefined;
}
export interface SystemPromptComposer {
handleBeforeAgentStart: (
event: BeforeAgentStartEvent,
ctx: ExtensionContext,
) => { systemPrompt: string } | undefined;
getLastComposed: () => string | undefined;
}
/**
* The bundle's single system-prompt owner. It is the only extension allowed to
* return `systemPrompt`; every other bundle or third-party extension must
* contribute structurally through `event.systemPromptOptions`.
*
* Per turn it applies `config/prompt/tools.jsonc` (tool snippets/guidelines),
* renders `config/prompt/routing.jsonc` into a `<tool_routing>` section, orders
* contributed sections per `config/prompt/sections.jsonc`, then reads the
* re-rendered prompt and splices in `config/prompt/preamble.md` before
* returning the final text.
*
* If an earlier extension violated the contract by setting
* `forceSystemPrompt`, the prompt is already frozen: the composer degrades to
* text splicing (preamble + appended `<tool_routing>` when absent) and warns
* once. Config load/validation failures warn once and leave Pi's default
* prompt untouched — the handler never throws.
*/
export function createSystemPromptComposer(deps: SystemPromptComposerDeps = {}): SystemPromptComposer {
const loadCatalog = deps.loadToolCatalog ?? loadToolCatalog;
const loadRouting = deps.loadRoutingRules ?? loadRoutingRules;
const loadSections = deps.loadSectionsConfig ?? loadSectionsConfig;
const loadPreamble = deps.readPreamble ?? readPreamble;
let lastComposed: string | undefined;
let forcedNotified = false;
let configErrorNotified = false;
const notifiedMissingSets = new Set<string>();
function handleBeforeAgentStart(
event: BeforeAgentStartEvent,
ctx: ExtensionContext,
): { systemPrompt: string } | undefined {
const options = event.systemPromptOptions as unknown as RuntimePromptOptions | undefined;
const forced = options !== undefined && options.forceSystemPrompt !== undefined;
if (forced && ctx.hasUI && !forcedNotified) {
forcedNotified = true;
ctx.ui.notify(
"检测到其他扩展直接改写了系统提示词(违反单一入口规则),本轮降级为文本拼接。",
"warning",
);
}
let catalog: ToolCatalog;
let routing: RoutingConfig;
let sections: SectionsConfig;
let preamble: string | undefined;
try {
catalog = loadCatalog();
routing = loadRouting();
sections = loadSections();
preamble = loadPreamble();
} catch (error) {
if (ctx.hasUI && !configErrorNotified) {
configErrorNotified = true;
const detail = error instanceof Error ? error.message : String(error);
ctx.ui.notify(`系统提示词配置加载失败:${detail}`, "warning");
}
return undefined;
}
// A subagent session is identified by its customPrompt carrying the
// pi-subagents <active_agent/> tag — never by the rendered prompt, which
// a main session may legitimately contain the literal tag inside.
const isSubagent = isSubagentCustomPrompt(options?.customPrompt);
const audience = isSubagent ? "subagent" : "main";
let routingText = "";
if (options) {
const missing = applyToolCatalog(options, catalog);
if (missing.length > 0 && ctx.hasUI) {
const key = [...missing].sort().join("\n");
if (!notifiedMissingSets.has(key)) {
notifiedMissingSets.add(key);
ctx.ui.notify(`以下工具未在 config/prompt/tools.jsonc 登记:${missing.join(", ")}`, "warning");
}
}
routingText = renderRouting(routing.rules, options.selectedTools, audience);
options.sections ??= {};
if (isSubagent) {
for (const name of sections.subagentExclude) {
delete options.sections[name];
}
}
if (routingText.length > 0) {
options.sections.tool_routing = routingText;
}
options.sections = orderSections(options.sections, sections.order);
} else {
routingText = renderRouting(routing.rules, undefined, audience);
}
let rendered = event.systemPrompt;
if (routingText.length > 0 && !rendered.includes("<tool_routing>")) {
// Degrade path: the prompt was frozen by forceSystemPrompt, so the
// contributed section cannot render — append it as plain text.
rendered += `\n\n<tool_routing>\n${routingText}\n</tool_routing>`;
}
// A non-empty customPrompt (pi-subagents agent prompt, SYSTEM.md) owns
// the whole preamble — including identities like <active_agent/> that
// the permission system reads — so the bundle cover must not replace it.
const ownsPreamble = typeof options?.customPrompt === "string" && options.customPrompt.length > 0;
const final = preamble && !ownsPreamble ? spliceCover(rendered, preamble) : rendered;
lastComposed = final;
return { systemPrompt: final };
}
return {
handleBeforeAgentStart,
getLastComposed: () => lastComposed,
};
}
export default function systemPromptExtension(pi: ExtensionAPI): void {
const composer = createSystemPromptComposer();
pi.on("before_agent_start", (event, ctx) => composer.handleBeforeAgentStart(event, ctx));
pi.registerCommand("dump-system-prompt", {
description: "Write the effective Pi system prompt to .pi-debug/effective-system-prompt.md",
handler: async (_args, ctx) => {
const outputPath = getSystemPromptDumpPath(ctx.cwd);
const effectivePrompt = composer.getLastComposed() ?? ctx.getSystemPrompt();
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, effectivePrompt, "utf8");
ctx.ui.notify(`System prompt written to ${outputPath} (${effectivePrompt.length} chars)`, "info");
},
});
}