mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
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:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
b817d66a11
commit
360d8cffc8
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- **Subagent (pi-subagents child) sessions are read-only**: sessions whose `systemPromptOptions.customPrompt` carries the `<active_agent name="..."/>` identity tag (detected in `before_agent_start`, tracked per `sessionManager`) get a fixed error result from `memory_add`, `memory_replace`, `memory_remove`, and `skill_manage` — no writes — and skip background review, correction detection, compact/shutdown flush, live and shutdown session indexing, and the write side of initialization (markdown↔SQLite sync, legacy migration, pruning, backfill). `memory_search` and `session_search` stay fully usable. Sessions started from a tag-leading rendered prompt are detected as early as `session_start`; otherwise the authoritative `customPrompt` check at `before_agent_start` applies before any turn-level work.
|
||||
|
||||
- **Opt-in chunked subprocess consolidation** (`consolidationChunking`, default `false`): stores above `consolidationChunkChars` (new, default `4000`) consolidate in bounded rounds — a head slice of entries per child run, reloading from disk between rounds, so a killed run resumes from partial progress instead of restarting. The rounds share one overall time budget (`consolidationTimeoutMs`, same worst case as the single call this replaces), the loop stops at the target's capacity goal (failure memory is a 2× tier and is no longer over-merged down to the slice size), a slice that cannot shrink is skipped rather than blocking later slices, A store that fits one prompt runs a single unscoped decisive round with the whole remaining store; a child exiting 0 without shrinking is reported as an honest partial instead of silent success, and an over-chunk store already within its capacity goal is a clean no-op. Partial runs report `partial: true` with the reason and are surfaced by the warning path, the store's add-retry error, and `/memory-consolidate`; disappearances outside a round's slice (rogue child deletions, legitimate cross-slice dedup, or concurrent writers — indistinguishable to the parent) are detected and reported rather than resurrected, because re-adding through the cap-enforcing add path cannot converge and resurrection ping-pongs across triggers. An over-chunk store already within its capacity goal is a clean no-op. Single-shot behavior below the threshold is unchanged.
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Subagent (pi-subagents child) sessions are read-only: memory/skill write
|
||||
* tools return a fixed error and all automatic writes are skipped.
|
||||
*
|
||||
* Child detection keys on the `<active_agent name="X"/>` tag pi-subagents
|
||||
* stamps into `systemPromptOptions.customPrompt`; membership is tracked per
|
||||
* sessionManager so a child in the same process never flips the parent.
|
||||
*/
|
||||
import { describe, it, beforeEach, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
isSubagentCustomPrompt,
|
||||
isSubagentSession,
|
||||
markSubagentSession,
|
||||
markSubagentSessionFromPrompt,
|
||||
SUBAGENT_WRITE_DISABLED_MESSAGE,
|
||||
} from "../src/subagent-mode.js";
|
||||
import { registerMemoryTool } from "../src/tools/memory-tool.js";
|
||||
import { registerSkillTool } from "../src/tools/skill-tool.js";
|
||||
import { setupSessionFlush } from "../src/handlers/session-flush.js";
|
||||
import { MemoryStore } from "../src/store/memory-store.js";
|
||||
import { SkillStore } from "../src/store/skill-store.js";
|
||||
import type { MemoryConfig } from "../src/types.js";
|
||||
|
||||
const CHILD_PROMPT = 'You are the Explore agent.\n<active_agent name="Explore"/>';
|
||||
|
||||
function beforeAgentStartEvent(customPrompt?: string) {
|
||||
return { systemPromptOptions: customPrompt === undefined ? {} : { customPrompt } } as any;
|
||||
}
|
||||
|
||||
describe("subagent detection", () => {
|
||||
it("matches the pi-subagents <active_agent> contract on customPrompt only", () => {
|
||||
assert.ok(isSubagentCustomPrompt(CHILD_PROMPT));
|
||||
assert.ok(isSubagentCustomPrompt("intro <active_agent name=\"General Purpose\" extra=\"1\"> tail"));
|
||||
assert.ok(!isSubagentCustomPrompt("a normal custom prompt"));
|
||||
assert.ok(!isSubagentCustomPrompt(""));
|
||||
assert.ok(!isSubagentCustomPrompt(undefined));
|
||||
assert.ok(!isSubagentCustomPrompt(null));
|
||||
});
|
||||
|
||||
it("marks only sessions whose before_agent_start customPrompt carries the tag", () => {
|
||||
const childManager = {};
|
||||
const mainManager = {};
|
||||
markSubagentSession(beforeAgentStartEvent(CHILD_PROMPT), { sessionManager: childManager } as any);
|
||||
markSubagentSession(beforeAgentStartEvent(), { sessionManager: mainManager } as any);
|
||||
markSubagentSession(beforeAgentStartEvent("no tag here"), { sessionManager: {} } as any);
|
||||
|
||||
assert.ok(isSubagentSession({ sessionManager: childManager }));
|
||||
assert.ok(!isSubagentSession({ sessionManager: mainManager }));
|
||||
assert.ok(!isSubagentSession(undefined));
|
||||
assert.ok(!isSubagentSession({}));
|
||||
});
|
||||
|
||||
it("detects tag-leading rendered prompts early (session_start), not mid-prompt tags", () => {
|
||||
const childManager = {};
|
||||
const mainManager = {};
|
||||
markSubagentSessionFromPrompt(
|
||||
`<active_agent name="Explore"/>\n\nYou are the Explore agent.\n\nrest`,
|
||||
{ sessionManager: childManager } as any,
|
||||
);
|
||||
markSubagentSessionFromPrompt(
|
||||
`preamble\n\nliteral <active_agent name="X"/> inside project docs`,
|
||||
{ sessionManager: mainManager } as any,
|
||||
);
|
||||
assert.ok(isSubagentSession({ sessionManager: childManager }));
|
||||
assert.ok(!isSubagentSession({ sessionManager: mainManager }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("subagent write gating", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "subagent-mode-test-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makeMemoryStore(): MemoryStore {
|
||||
return new MemoryStore({
|
||||
memoryMode: "policy-only",
|
||||
memoryCharLimit: 5000,
|
||||
userCharLimit: 5000,
|
||||
projectCharLimit: 5000,
|
||||
nudgeInterval: 10,
|
||||
reviewEnabled: false,
|
||||
flushOnCompact: false,
|
||||
flushOnShutdown: false,
|
||||
flushMinTurns: 6,
|
||||
autoConsolidate: false,
|
||||
correctionDetection: false,
|
||||
failureInjectionEnabled: false,
|
||||
failureInjectionMaxAgeDays: 7,
|
||||
failureInjectionMaxEntries: 5,
|
||||
nudgeToolCalls: 15,
|
||||
consolidationTimeoutMs: 60000,
|
||||
memoryDir: tmpDir,
|
||||
});
|
||||
}
|
||||
|
||||
it("memory_add/replace/remove return the disabled error and perform no writes", async () => {
|
||||
const tools = new Map<string, any>();
|
||||
const mockPi = { registerTool: (def: any) => tools.set(def.name, def) } as any;
|
||||
const store = makeMemoryStore();
|
||||
await store.loadFromDisk();
|
||||
let bindCalls = 0;
|
||||
registerMemoryTool(mockPi, store, null, null, null, () => { bindCalls++; });
|
||||
|
||||
const sessionManager = {};
|
||||
markSubagentSession(beforeAgentStartEvent(CHILD_PROMPT), { sessionManager } as any);
|
||||
const ctx = { cwd: tmpDir, sessionManager };
|
||||
|
||||
for (const name of ["memory_add", "memory_replace", "memory_remove"]) {
|
||||
const result = await tools.get(name).execute("tc", { target: "memory", content: "x", old_text: "x" }, undefined, undefined, ctx);
|
||||
assert.strictEqual(result.content[0].text, SUBAGENT_WRITE_DISABLED_MESSAGE, name);
|
||||
assert.strictEqual(result.details.success, false, name);
|
||||
assert.strictEqual(result.details.error, SUBAGENT_WRITE_DISABLED_MESSAGE, name);
|
||||
}
|
||||
assert.strictEqual(bindCalls, 0, "blocked writes must not bind projects");
|
||||
assert.deepStrictEqual(store.getMemoryEntries(), [], "blocked writes must not mutate the store");
|
||||
assert.ok(!fs.existsSync(path.join(tmpDir, "MEMORY.md")), "blocked writes must not write files");
|
||||
});
|
||||
|
||||
it("memory tools still write in an unmarked (main) session", async () => {
|
||||
const tools = new Map<string, any>();
|
||||
const mockPi = { registerTool: (def: any) => tools.set(def.name, def) } as any;
|
||||
const store = makeMemoryStore();
|
||||
await store.loadFromDisk();
|
||||
registerMemoryTool(mockPi, store, null);
|
||||
|
||||
const result = await tools.get("memory_add").execute(
|
||||
"tc",
|
||||
{ target: "memory", content: "main session entry" },
|
||||
undefined,
|
||||
undefined,
|
||||
{ cwd: tmpDir, sessionManager: {} },
|
||||
);
|
||||
assert.strictEqual(result.details.success, true);
|
||||
assert.deepStrictEqual(store.getMemoryEntries(), ["main session entry"]);
|
||||
});
|
||||
|
||||
it("skill_manage returns the disabled error in a subagent session", async () => {
|
||||
let tool: any;
|
||||
const mockPi = { registerTool: (def: any) => { tool = def; } } as any;
|
||||
const store = new SkillStore({
|
||||
globalSkillsDir: path.join(tmpDir, "skills"),
|
||||
piGlobalSkillsDir: path.join(tmpDir, "pi-skills"),
|
||||
projectSkillsDir: null,
|
||||
projectName: null,
|
||||
legacySkillsDir: path.join(tmpDir, "legacy-skills"),
|
||||
migrationSentinelPath: path.join(tmpDir, ".migrated"),
|
||||
});
|
||||
registerSkillTool(mockPi, store);
|
||||
|
||||
const sessionManager = {};
|
||||
markSubagentSession(beforeAgentStartEvent(CHILD_PROMPT), { sessionManager } as any);
|
||||
const result = await tool.execute(
|
||||
"tc",
|
||||
{ action: "create", name: "demo", description: "d", scope: "global", content: "body" },
|
||||
undefined,
|
||||
undefined,
|
||||
{ sessionManager },
|
||||
);
|
||||
assert.strictEqual(result.content[0].text, SUBAGENT_WRITE_DISABLED_MESSAGE);
|
||||
assert.strictEqual(result.details.success, false);
|
||||
assert.ok(!fs.existsSync(path.join(tmpDir, "skills")), "blocked skill writes must not create the skills dir");
|
||||
});
|
||||
|
||||
it("session flush skips compact and shutdown flushes in a subagent session", async () => {
|
||||
const handlers: Record<string, Function[]> = {};
|
||||
const mockPi = {
|
||||
on(event: string, handler: Function) {
|
||||
(handlers[event] ??= []).push(handler);
|
||||
},
|
||||
async exec() {
|
||||
throw new Error("subagent flush must not spawn a child pi");
|
||||
},
|
||||
registerTool() {},
|
||||
registerCommand() {},
|
||||
} as any;
|
||||
|
||||
let directCalls = 0;
|
||||
const config = {
|
||||
flushOnCompact: true,
|
||||
flushOnShutdown: true,
|
||||
flushMinTurns: 1,
|
||||
flushCompactTimeoutMs: 60000,
|
||||
reviewTransport: "direct",
|
||||
} as MemoryConfig;
|
||||
|
||||
const sessionManager = { getBranch: () => [] };
|
||||
markSubagentSession(beforeAgentStartEvent(CHILD_PROMPT), { sessionManager } as any);
|
||||
|
||||
setupSessionFlush(mockPi, { getMemoryEntries: () => [], getUserEntries: () => [] } as any, null, config, null, null, {
|
||||
runDirectMemoryCompletion: async () => {
|
||||
directCalls++;
|
||||
return { ok: true } as any;
|
||||
},
|
||||
});
|
||||
|
||||
const ctx = { sessionManager, cwd: tmpDir };
|
||||
for (const handler of handlers["message_end"] ?? []) {
|
||||
await handler({ message: { role: "user", content: "hi" } }, ctx);
|
||||
}
|
||||
for (const handler of handlers["session_before_compact"] ?? []) {
|
||||
await handler({ signal: undefined }, ctx);
|
||||
}
|
||||
for (const handler of handlers["session_shutdown"] ?? []) {
|
||||
await handler({ reason: "exit" }, ctx);
|
||||
}
|
||||
assert.strictEqual(directCalls, 0, "subagent sessions must not flush");
|
||||
|
||||
// Control: the same handlers must still flush an unmarked (main) session.
|
||||
const mainCtx = { sessionManager: { getBranch: () => [] }, cwd: tmpDir };
|
||||
for (const handler of handlers["message_end"] ?? []) {
|
||||
await handler({ message: { role: "user", content: "hi" } }, mainCtx);
|
||||
}
|
||||
for (const handler of handlers["session_before_compact"] ?? []) {
|
||||
await handler({ signal: undefined }, mainCtx);
|
||||
}
|
||||
assert.ok(directCalls > 0, "main sessions must still flush");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user