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>
This commit is contained in:
云服务部-叶林立
2026-10-20 19:43:22 -07:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent b817d66a11
commit 360d8cffc8
37 changed files with 2837 additions and 2416 deletions
@@ -17,6 +17,7 @@ import { MemoryStore } from "../store/memory-store.js";
import { DatabaseManager } from "../store/db.js";
import type { MemoryConfig } from "../types.js";
import type { EnsureMemoryReady } from "../memory-initialization.js";
import { isSubagentSession } from "../subagent-mode.js";
import { applyRecentMessageLimit, collectMessageParts } from "./message-parts.js";
import { execChildPrompt, resolveChildPiModel } from "./pi-child-process.js";
import { REVIEW_COMPLETION_TIMEOUT_MS, runDirectMemoryCompletion, usesDirectTransport, type DirectReviewResult } from "./review-memory-ops.js";
@@ -196,6 +197,7 @@ export function setupBackgroundReview(
});
pi.on("turn_end", async (event, ctx) => {
if (isSubagentSession(ctx)) return;
turnsSinceReview++;
if (!config.reviewEnabled) return;
@@ -23,6 +23,7 @@ import {
} from "../constants.js";
import type { MemoryConfig } from "../types.js";
import type { EnsureMemoryReady } from "../memory-initialization.js";
import { isSubagentSession } from "../subagent-mode.js";
import { getMessageText } from "../types.js";
import { execChildPrompt, resolveChildPiModel } from "./pi-child-process.js";
import { resolveProjectName, resolveProjectStore, type ProjectNameRef, type ProjectStoreRef } from "../project-context.js";
@@ -151,6 +152,7 @@ export function setupCorrectionDetector(
// Trigger on turn_end (we need full context: user correction + what agent said)
pi.on("turn_end", async (event, ctx) => {
if (isSubagentSession(ctx)) return;
if (!pendingCorrection) {
turnsSinceLastCorrection++;
return;
@@ -37,6 +37,7 @@ import {
import type { MemoryConfig } from "../types.js";
import type { EnsureMemoryReady } from "../memory-initialization.js";
import { measureLifecycle } from "../lifecycle-timing.js";
import { isSubagentSession } from "../subagent-mode.js";
import { collectMessageParts } from "./message-parts.js";
import { execChildPrompt, resolveChildPiModel } from "./pi-child-process.js";
import { runDirectMemoryCompletion, usesDirectTransport } from "./review-memory-ops.js";
@@ -167,6 +168,7 @@ export function setupSessionFlush(
kind: FlushKind,
): Promise<void> {
try {
if (isSubagentSession(ctx)) return;
if (userTurnCount < config.flushMinTurns) return;
let entries;
+19 -5
View File
@@ -61,6 +61,7 @@ import { AGENT_ROOT } from "./paths.js";
import { isDatabaseMigrationPending } from "./extension-root-migration.js";
import { measureLifecycle, measureLifecycleSync } from "./lifecycle-timing.js";
import { createMemoryInitializer, withMemoryInitialization, type EnsureMemoryReady } from "./memory-initialization.js";
import { isSubagentSession, markSubagentSession, markSubagentSessionFromPrompt } from "./subagent-mode.js";
export function resolveProjectSkillDiscovery(
skillStore: SkillStore,
@@ -195,8 +196,12 @@ export default function (pi: ExtensionAPI) {
const initialization = createMemoryInitializer(async (ctx) => {
const timingPrefix = lazy ? "memory-init" : "session-start";
if (lazy) migrateLegacyProjectMemoryDirs(agentRoot, config.projectsMemoryDir);
if (!persistenceInitialized) {
// Subagent sessions are read-only: skip the write side of initialization
// (legacy-dir migration, markdown↔SQLite sync, pruning, backfill) while
// still loading the store so memory_search/session_search keep working.
const subagent = isSubagentSession(ctx);
if (lazy && !subagent) migrateLegacyProjectMemoryDirs(agentRoot, config.projectsMemoryDir);
if (!persistenceInitialized && !subagent) {
try {
await measureLifecycle(`${timingPrefix}.persistence-sync`, async () => {
// Startup reconciles the global files plus the current project only:
@@ -290,18 +295,25 @@ export default function (pi: ExtensionAPI) {
// Skills and pinned instructions must be available even without a lookup.
pi.on("session_start", async (_event, ctx) => {
sessionContext = ctx;
// session_start runs before before_agent_start; a child's rendered prompt
// leads with the <active_agent> tag, so detect it here early enough to
// skip the session_start writes below.
markSubagentSessionFromPrompt(ctx.getSystemPrompt?.() ?? "", ctx);
// Pinned directives must not depend on migration/SQLite being healthy.
if (standingStore) await standingStore.load();
if (!lazy) await ensureMemoryReady(ctx);
refreshSkillProjectContext(ctx.cwd);
await skillStore.migrateLegacySkills();
await skillStore.ensureDiscoveredRoots();
if (!isSubagentSession(ctx)) {
await skillStore.migrateLegacySkills();
await skillStore.ensureDiscoveredRoots();
}
});
registerProjectSkillDiscoveryHandler(pi, skillStore, config.projectsMemoryDir);
// ── 2. Inject memory policy by default; legacy mode keeps full frozen memory blocks ──
pi.on("before_agent_start", async (event, _ctx) => {
pi.on("before_agent_start", async (event, ctx) => {
markSubagentSession(event, ctx);
const promptContext = await buildPromptContext(config, store, projectStoreRef(), projectNameRef(), standingStore);
if (!promptContext) return;
@@ -400,6 +412,7 @@ export default function (pi: ExtensionAPI) {
// ── 10. Live session indexing ──
pi.on("message_end", async (_event, ctx) => {
if (isSubagentSession(ctx)) return;
// Cold lazy sessions remain in Pi's JSONL files and are backfilled on use.
if (lazy && !initialization.isReady()) return;
scheduleLiveSessionIndex(dbManager, ctx.sessionManager, {
@@ -432,6 +445,7 @@ export default function (pi: ExtensionAPI) {
return;
}
try {
if (isSubagentSession(ctx)) return;
measureLifecycleSync("shutdown.active-index", () => {
const sessionFile = ctx.sessionManager.getSessionFile();
if (sessionFile && fs.existsSync(sessionFile)) {
+58
View File
@@ -0,0 +1,58 @@
/**
* subagent-mode.ts — subagent sessions are read-only for memory and skill
* writes. All automatic write-related work (background review, correction
* detection, session flushes, session indexing) is skipped as well.
*
* A child session is identified by the `<active_agent name="..."/>` identity
* tag that pi-subagents stamps into every child prompt
* (permission-system-bridge.ts): the tag arrives via
* `systemPromptOptions.customPrompt`. The tag is only trusted on that field —
* never in the rendered system prompt, which a main session can legitimately
* contain through project docs.
*
* Membership is tracked per session by its sessionManager identity: the same
* process hosts the parent and its children concurrently, so a global boolean
* would let a child's turn flip the parent's session into read-only mid-turn.
*/
import type { BeforeAgentStartEvent, ExtensionContext } from "@earendil-works/pi-coding-agent";
/** pi-subagents `<active_agent name="X"/>` identity tag contract. */
const ACTIVE_AGENT_TAG = /<active_agent\s+name=["'][^"']+["'][^>]*>/i;
const LEADING_ACTIVE_AGENT_TAG = /^\s*<active_agent\s+name=["'][^"']+["'][^>]*>/i;
export const SUBAGENT_WRITE_DISABLED_MESSAGE =
"Memory and skill writes are disabled in subagent sessions; include this in your final report so the delegating agent can decide.";
const subagentSessionManagers = new WeakSet<object>();
/** Same regex as the pi-subagents `<active_agent>` contract, applied only to customPrompt. */
export function isSubagentCustomPrompt(customPrompt: string | undefined | null): boolean {
return typeof customPrompt === "string" && ACTIVE_AGENT_TAG.test(customPrompt);
}
/**
* before_agent_start hook: authoritative detection via the event's
* systemPromptOptions.customPrompt.
*/
export function markSubagentSession(event: BeforeAgentStartEvent, ctx: ExtensionContext): void {
if (isSubagentCustomPrompt(event.systemPromptOptions?.customPrompt)) {
subagentSessionManagers.add(ctx.sessionManager);
}
}
/**
* session_start runs before before_agent_start; detect tag-leading prompts
* early so session_start writes (skill migration, root setup) can be skipped.
* A main prompt cannot lead with the tag — without customPrompt the rendered
* prompt always opens with the base preamble.
*/
export function markSubagentSessionFromPrompt(systemPrompt: string, ctx: ExtensionContext): void {
if (LEADING_ACTIVE_AGENT_TAG.test(systemPrompt)) {
subagentSessionManagers.add(ctx.sessionManager);
}
}
export function isSubagentSession(ctx: unknown): boolean {
const sm = (ctx as { sessionManager?: unknown } | undefined | null)?.sessionManager;
return typeof sm === "object" && sm !== null && subagentSessionManagers.has(sm);
}
+8 -1
View File
@@ -25,6 +25,7 @@ import type { MemoryCategory, MemoryResult } from "../types.js";
import { normalizeMemoryLookupText } from "../store/memory-lookup.js";
import { createSharedToolResultRenderer } from "./shared-output-view.js";
import { memoryResultView } from "./tool-result-views.js";
import { isSubagentSession, SUBAGENT_WRITE_DISABLED_MESSAGE } from "../subagent-mode.js";
function appendSyncWarning(result: MemoryResult, warning: string): MemoryResult {
const warnings = [...(((result as any).warnings ?? []) as string[]), warning];
@@ -425,7 +426,13 @@ This action-specific tool accepts only the parameters listed in its schema.`;
],
renderResult: createSharedToolResultRenderer(memoryResultView),
parameters,
async execute(_toolCallId, params, signal, _onUpdate, ctx?: { cwd?: string }) {
async execute(_toolCallId, params, signal, _onUpdate, ctx?: { cwd?: string; sessionManager?: object }) {
if (isSubagentSession(ctx)) {
return {
content: [{ type: "text" as const, text: SUBAGENT_WRITE_DISABLED_MESSAGE }],
details: { success: false, error: SUBAGENT_WRITE_DISABLED_MESSAGE },
};
}
if (bindProjectFromCwd && ctx?.cwd) {
await bindProjectFromCwd(ctx.cwd);
}
+14
View File
@@ -10,6 +10,7 @@ import { SkillStore } from "../store/skill-store.js";
import { SKILL_TOOL_DESCRIPTION } from "../constants.js";
import { createSharedToolResultRenderer } from "./shared-output-view.js";
import { skillResultView } from "./tool-result-views.js";
import { isSubagentSession, SUBAGENT_WRITE_DISABLED_MESSAGE } from "../subagent-mode.js";
function normalizeTextList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
@@ -90,6 +91,13 @@ const SKILL_TOOL_PARAMETERS = Type.Object({
export const SKILL_MANAGE_TOOL_NAME = "skill_manage";
export function registerSkillTool(pi: ExtensionAPI, store: SkillStore): void {
if (typeof pi.on === "function") {
pi.on("tool_result", (event) => {
if (event.toolName !== SKILL_MANAGE_TOOL_NAME) return;
const details = event.details as { success?: unknown } | undefined;
if (details?.success === false) return { isError: true };
});
}
pi.registerTool({
name: SKILL_MANAGE_TOOL_NAME,
label: "Skill Manager",
@@ -108,6 +116,12 @@ export function registerSkillTool(pi: ExtensionAPI, store: SkillStore): void {
renderResult: createSharedToolResultRenderer(skillResultView),
parameters: SKILL_TOOL_PARAMETERS,
async execute(toolCallId, params, signal, onUpdate, ctx) {
if (isSubagentSession(ctx)) {
return {
content: [{ type: "text", text: SUBAGENT_WRITE_DISABLED_MESSAGE }],
details: { success: false, error: SUBAGENT_WRITE_DISABLED_MESSAGE },
};
}
const skillParams = params as {
action: "create" | "view" | "patch" | "update" | "edit" | "delete";
name?: string;