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
@@ -18,7 +18,7 @@
|
||||
- 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,默认以最多 5 个动态组、28 个动态工具的 LRU 策略加载原始完整 schema,并支持原子批量激活。
|
||||
- 本仓库维护的 `pi-chrome`:从上游 `0.15.46` 源码导入,通过显式会话授权接入用户当前已登录的 Chrome Profile;大体积页面快照写入 Session 临时文件并交给 Context Mode 隔离分析,21 个 `chrome_*` 工具仍由 Tool Search 按工作流加载。
|
||||
- 本仓库维护的 `pi-permission-system`:只读取一份全局普通策略与一份独立 Hard Guards,按 pattern 具体度裁决 allow/ask/deny,并整合 Codex Guardian 风格 AutoReview 与人工 `defer` 兜底;自动评审模型完全由配置文件指定,未配置时提示并转人工审批;工具显隐仍只由 Tool Search 管理。
|
||||
- 本仓库保留维护的 `pi-subagents` 源码及权限集成实现,但当前已从根组合包依赖和默认扩展加载列表中移除,不会随核心包启用。
|
||||
- 本仓库维护的 `pi-subagents`:从 `@tintinweb/pi-subagents@0.18.0` 源码导入并默认加载,提供隔离子 Agent、后台执行、steering、resume、嵌套代理与自定义 Agent;通过 mandatory permission wrapper 接入权限 child lifecycle、根 authority `ask` 转发和根 transcript auto-review 身份桥接,其 3 个编排工具由 Tool Search 的 `subagents` 组按需加载。子 Agent 使用独立提示词画像 `config/prompt/subagent/`(自有 preamble 与内置 Agent 定义),不嵌入父会话提示词。
|
||||
- 本仓库维护的 `pi-ssh`:通过纯 Node `ssh2` 持久连接、显式 `ssh_cd` 远端工作区切换、SFTP 与自适应有界搜索提供独立的远端工具;Agent 只在用户明确指定已导入主机及具体任务后发起受 AutoReview 复核的连接。
|
||||
- `extensions/system-prompt.ts`:组合包唯一的系统提示词出口(排在最后加载),按 `config/prompt/` 组合开场白、工具简介与跨工具路由规则;提供 `/dump-system-prompt` 导出当前有效提示词。
|
||||
|
||||
|
||||
@@ -54,6 +54,12 @@
|
||||
"ctx_doctor": "allow",
|
||||
"tool_search": "allow",
|
||||
"ask_user_question": "allow",
|
||||
// Launching, steering or reading a subagent has no side effect itself;
|
||||
// every child action is still gated by the permission system and any
|
||||
// unresolved "ask" is forwarded to the root session.
|
||||
"Agent": "allow",
|
||||
"get_subagent_result": "allow",
|
||||
"steer_subagent": "allow",
|
||||
"web_search": "allow",
|
||||
"chrome_snapshot": "allow",
|
||||
"chrome_find": "allow",
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
// <tool_routing> section. A rule is included when every tool in "whenAll"
|
||||
// and at least one tool in "whenAny" is active; a rule with neither is
|
||||
// always included. Rules render in file order as "- <text>".
|
||||
// Optional "scope": "main" | "subagent" restricts a rule to that session
|
||||
// audience (main session vs pi-subagents child); absent means both.
|
||||
"version": 1,
|
||||
"rules": [
|
||||
{
|
||||
@@ -27,8 +29,14 @@
|
||||
},
|
||||
{
|
||||
"whenAny": ["ssh_connect"],
|
||||
"scope": "main",
|
||||
"text": "Use ssh_connect only when the user explicitly names an imported host as part of a concrete remote task. Call it as a separate step and wait for success before calling other ssh_* tools; never infer or substitute a different host."
|
||||
},
|
||||
{
|
||||
"whenAny": ["ssh_connect"],
|
||||
"scope": "subagent",
|
||||
"text": "Use ssh_connect only when the delegated task explicitly names an imported host as part of a concrete remote task. Call it as a separate step and wait for success before calling other ssh_* tools; never infer or substitute a different host."
|
||||
},
|
||||
{
|
||||
"whenAny": ["ssh_cd"],
|
||||
"text": "Treat ssh_cd as a reviewed persistent workspace transition: call it as a separate step and wait for success before issuing ssh_bash or relative ssh_read/ssh_write/ssh_edit/ssh_find/ssh_grep calls that depend on the new remote cwd. Use cd inside ssh_bash only for an intentionally temporary, command-local directory change."
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
// systemPromptOptions.sections. extensions/system-prompt.ts reorders the
|
||||
// contributed sections to this list; contributed sections not listed here
|
||||
// keep their contribution order after the listed ones.
|
||||
// Optional "subagentExclude": section names removed entirely in subagent
|
||||
// (customPrompt) sessions — e.g. sections that describe main-session-only
|
||||
// affordances. Defaults to [].
|
||||
"version": 1,
|
||||
"order": ["memory_policy", "remote_ssh", "chrome_profile_bridge", "tool_routing"]
|
||||
"order": ["memory_policy", "remote_ssh", "chrome_profile_bridge", "tool_routing"],
|
||||
"subagentExclude": ["memory_policy", "chrome_profile_bridge"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Explore
|
||||
display_name: Explore
|
||||
description: 'Fast read-only search agent for locating code. Use it to find files by pattern (eg. "src/components/**/*.tsx"), grep for symbols or keywords (eg. "API endpoints"), or answer "where is X defined / which files reference Y." Do NOT use it for code review, design-doc auditing, cross-file consistency checks, or open-ended analysis — it reads excerpts rather than whole files and will miss content past its read window. When calling, specify search breadth: "quick" for a single targeted lookup, "medium" for moderate exploration, or "very thorough" to search across multiple locations and naming conventions.'
|
||||
tools: read, bash, grep, find, ls
|
||||
extensions: true
|
||||
skills: true
|
||||
prompt_mode: replace
|
||||
---
|
||||
# Role: read-only code search
|
||||
|
||||
You locate code and answer "where is X / what references Y" questions for the delegating agent. You never modify anything.
|
||||
|
||||
## Hard limits
|
||||
- Do not create, modify, move, copy, or delete files, including temporary files anywhere (also /tmp).
|
||||
- Use bash only for read-only commands such as git status, git log, git diff, git show, and ls. Never use output redirection, heredocs, or any command that changes system state.
|
||||
- Never search through bash (grep, rg, find, fd); use the find and grep tools.
|
||||
|
||||
## Approach
|
||||
- Match the requested breadth: "quick" is one targeted lookup, "medium" covers a few related locations, "very thorough" searches multiple locations and naming conventions.
|
||||
- When codegraph_explore is available, start there for symbols and relationships. Otherwise use find to narrow paths, grep inside the narrowed paths, and read small ranges only to confirm a match.
|
||||
- Make independent lookups in parallel.
|
||||
|
||||
## Report
|
||||
- Lead with the direct answer.
|
||||
- Cite every finding with an absolute path and line number.
|
||||
- State what you searched for and did not find, so the delegating agent knows the coverage.
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: Plan
|
||||
display_name: Plan
|
||||
description: Software architect agent for designing implementation plans. Use this when you need to plan the implementation strategy for a task. Returns step-by-step plans, identifies critical files, and considers architectural trade-offs.
|
||||
tools: read, bash, grep, find, ls
|
||||
extensions: true
|
||||
skills: true
|
||||
prompt_mode: replace
|
||||
---
|
||||
# Role: read-only implementation planner
|
||||
|
||||
You explore the codebase and return an implementation plan to the delegating agent. You never modify anything.
|
||||
|
||||
## Hard limits
|
||||
- Do not create, modify, move, copy, or delete files, including temporary files anywhere (also /tmp).
|
||||
- Use bash only for read-only commands such as git status, git log, git diff, git show, and ls. Never use output redirection, heredocs, or any command that changes system state.
|
||||
- Never search through bash (grep, rg, find, fd); use the find and grep tools.
|
||||
|
||||
## Process
|
||||
1. Restate the goal and the constraints you were given. Record anything ambiguous as an open question instead of guessing.
|
||||
2. Explore enough to ground every step in real files, existing patterns, and reusable helpers.
|
||||
3. Design the change: sequencing, interfaces and data shapes, edge cases, and the tests that prove it.
|
||||
4. Call out trade-offs and risks, and name the rejected alternative when the choice is not obvious.
|
||||
|
||||
## Report
|
||||
- A step-by-step plan where each step names the files it touches.
|
||||
- Open questions, if any.
|
||||
- End with:
|
||||
|
||||
### Critical files
|
||||
3–5 absolute paths, each with a one-line reason.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: general-purpose
|
||||
display_name: Agent
|
||||
description: General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.
|
||||
extensions: true
|
||||
skills: true
|
||||
prompt_mode: append
|
||||
---
|
||||
@@ -0,0 +1,8 @@
|
||||
You are a subagent operating inside pi, a coding agent harness. Another agent — the delegating agent — assigned you one bounded task, and it is your only audience. The human user does not see your messages and cannot answer questions.
|
||||
|
||||
- Do exactly the assigned task. Do not expand its scope, start unrelated work, or delegate further unless the task requires it.
|
||||
- If the task is ambiguous, make the most reasonable assumption, state it, and continue. If you are blocked, stop and report precisely what is missing instead of guessing.
|
||||
- Your final message is the deliverable returned to the delegating agent. Make it self-contained: the answer or result first, then the supporting evidence (absolute file paths with line numbers, commands you ran and their outcomes), then assumptions, risks, and anything left unfinished.
|
||||
- Keep verified facts separate from inferences. Never claim something was changed, run, or tested unless you actually did it in this session.
|
||||
- Durable memory and skill writes are unavailable here. If you learn something worth remembering, include it in your final report for the delegating agent to decide.
|
||||
- Be concise but complete. Do not use emojis.
|
||||
@@ -1,5 +1,9 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createPiSubagentsExtension } from "../pi-subagents/src/index.ts";
|
||||
import {
|
||||
readSubagentPreamble,
|
||||
resolveSubagentAgentsDir,
|
||||
} from "./system-prompt-config.ts";
|
||||
|
||||
// Bundle authority: Agent frontmatter may disable optional extensions, but it
|
||||
// cannot remove the exact wrapper that deploys my-pi's permission baseline and
|
||||
@@ -8,6 +12,17 @@ const BUNDLED_PERMISSION_EXTENSION = fileURLToPath(
|
||||
new URL("./permission-system.ts", import.meta.url),
|
||||
);
|
||||
|
||||
// Subagents get their own prompt profile: config/prompt/subagent/preamble.md
|
||||
// replaces the parent-prompt embedding upstream performs, and
|
||||
// config/prompt/subagent/agents/*.md are loaded as built-in agent types.
|
||||
// ask_user_question is main-session-only — a child has no user to ask.
|
||||
const ASK_USER_EXTENSION = fileURLToPath(
|
||||
new URL("../pi-ask-user/index.ts", import.meta.url),
|
||||
);
|
||||
|
||||
export default createPiSubagentsExtension({
|
||||
mandatoryExtensionPaths: [BUNDLED_PERMISSION_EXTENSION],
|
||||
basePrompt: readSubagentPreamble,
|
||||
bundledAgentsDir: resolveSubagentAgentsDir(),
|
||||
excludedExtensionPaths: [ASK_USER_EXTENSION],
|
||||
});
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface ToolCatalog {
|
||||
export interface RoutingRule {
|
||||
whenAll?: string[];
|
||||
whenAny?: string[];
|
||||
/** Which sessions the rule renders for; absent means both. */
|
||||
scope?: "main" | "subagent";
|
||||
text: string;
|
||||
}
|
||||
|
||||
@@ -31,6 +33,8 @@ export interface RoutingConfig {
|
||||
export interface SectionsConfig {
|
||||
version: 1;
|
||||
order: string[];
|
||||
/** Contributed sections removed from subagent (customPrompt) sessions. */
|
||||
subagentExclude: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,6 +68,14 @@ export function resolveSectionsPath(): string {
|
||||
return fileURLToPath(new URL("sections.jsonc", PROMPT_CONFIG_DIR));
|
||||
}
|
||||
|
||||
export function resolveSubagentPreamblePath(): string {
|
||||
return fileURLToPath(new URL("subagent/preamble.md", PROMPT_CONFIG_DIR));
|
||||
}
|
||||
|
||||
export function resolveSubagentAgentsDir(): string {
|
||||
return fileURLToPath(new URL("subagent/agents/", PROMPT_CONFIG_DIR));
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip `//` and `/* ... *\/` comments while respecting string literals, so a
|
||||
* `//` inside a quoted value (for example `"https://…"`) is never removed.
|
||||
@@ -185,7 +197,7 @@ function validateToolCatalog(raw: unknown): ToolCatalog {
|
||||
return { version: 1, tools };
|
||||
}
|
||||
|
||||
const ROUTING_RULE_KEYS = new Set(["whenAll", "whenAny", "text"]);
|
||||
const ROUTING_RULE_KEYS = new Set(["whenAll", "whenAny", "scope", "text"]);
|
||||
|
||||
function validateRoutingRule(index: number, raw: unknown): RoutingRule {
|
||||
if (!isPlainObject(raw)) {
|
||||
@@ -205,10 +217,13 @@ function validateRoutingRule(index: number, raw: unknown): RoutingRule {
|
||||
if ("whenAny" in raw && !isStringArray(raw.whenAny)) {
|
||||
fail("routing.jsonc", `rule #${index}.whenAny must be an array of strings`);
|
||||
}
|
||||
if ("scope" in raw && raw.scope !== "main" && raw.scope !== "subagent") {
|
||||
fail("routing.jsonc", `rule #${index}.scope must be "main" or "subagent"`);
|
||||
}
|
||||
return raw as RoutingRule;
|
||||
}
|
||||
|
||||
function validateRoutingConfig(raw: unknown): RoutingConfig {
|
||||
export function validateRoutingConfig(raw: unknown): RoutingConfig {
|
||||
if (!isPlainObject(raw) || raw.version !== 1 || !Array.isArray(raw.rules)) {
|
||||
fail("routing.jsonc", 'expected { "version": 1, "rules": [ ... ] }');
|
||||
}
|
||||
@@ -220,7 +235,7 @@ function validateRoutingConfig(raw: unknown): RoutingConfig {
|
||||
|
||||
const SECTION_NAME_PATTERN = /^[a-z][a-z0-9_-]*$/;
|
||||
|
||||
function validateSectionsConfig(raw: unknown): SectionsConfig {
|
||||
export function validateSectionsConfig(raw: unknown): SectionsConfig {
|
||||
if (!isPlainObject(raw) || raw.version !== 1 || !isStringArray(raw.order)) {
|
||||
fail("sections.jsonc", 'expected { "version": 1, "order": [ ... ] }');
|
||||
}
|
||||
@@ -229,7 +244,19 @@ function validateSectionsConfig(raw: unknown): SectionsConfig {
|
||||
fail("sections.jsonc", `"${name}" is not a valid section name`);
|
||||
}
|
||||
}
|
||||
return { version: 1, order: raw.order };
|
||||
if ("subagentExclude" in raw && !isStringArray(raw.subagentExclude)) {
|
||||
fail("sections.jsonc", `"subagentExclude" must be an array of section names`);
|
||||
}
|
||||
for (const name of raw.subagentExclude ?? []) {
|
||||
if (!SECTION_NAME_PATTERN.test(name) || name === "preamble") {
|
||||
fail("sections.jsonc", `"subagentExclude" entry "${name}" is not a valid section name`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
order: raw.order,
|
||||
subagentExclude: isStringArray(raw.subagentExclude) ? raw.subagentExclude : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function loadToolCatalog(): ToolCatalog {
|
||||
@@ -262,6 +289,34 @@ export function readPreamble(): string | undefined {
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the bundle's subagent base preamble (`config/prompt/subagent/preamble.md`),
|
||||
* re-read on each call so the file is never cached between spawns.
|
||||
* Blank or missing → `undefined`, which keeps pi-subagents' upstream composition.
|
||||
*/
|
||||
export function readSubagentPreamble(): string | undefined {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(resolveSubagentPreamblePath(), "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = raw.replace(/\s+$/, "");
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `<active_agent name="..."/>` tag pi-subagents stamps into every child
|
||||
* prompt — tested ONLY against `systemPromptOptions.customPrompt`, never the
|
||||
* rendered prompt: a main session may contain the literal tag via project
|
||||
* docs and must still be treated as main.
|
||||
*/
|
||||
const ACTIVE_AGENT_TAG = /<active_agent\s+name=["'][^"']+["'][^>]*>/i;
|
||||
|
||||
export function isSubagentCustomPrompt(customPrompt: unknown): boolean {
|
||||
return typeof customPrompt === "string" && ACTIVE_AGENT_TAG.test(customPrompt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the tool catalog to the mutable prompt options: a `snippet` string
|
||||
* replaces the tool's `<tools>` line (`null` hides it), `guidelines` replaces
|
||||
@@ -302,11 +357,19 @@ export function applyToolCatalog(options: MutablePromptOptions, catalog: ToolCat
|
||||
* `whenAny` tool is active (a rule with neither always applies). An absent
|
||||
* `selectedTools` means every tool counts as active, matching the previous
|
||||
* tool-routing behavior.
|
||||
*
|
||||
* `audience` filters `scope`: rules without a scope render for both "main"
|
||||
* and "subagent" sessions, scoped rules only for their own audience.
|
||||
*/
|
||||
export function renderRouting(rules: RoutingRule[], selectedTools: string[] | undefined): string {
|
||||
export function renderRouting(
|
||||
rules: RoutingRule[],
|
||||
selectedTools: string[] | undefined,
|
||||
audience: "main" | "subagent" = "main",
|
||||
): string {
|
||||
const isActive = (name: string) => selectedTools === undefined || selectedTools.includes(name);
|
||||
const lines: string[] = [];
|
||||
for (const rule of rules) {
|
||||
if (rule.scope !== undefined && rule.scope !== audience) continue;
|
||||
if (rule.whenAll !== undefined && !rule.whenAll.every(isActive)) continue;
|
||||
if (rule.whenAny !== undefined && !rule.whenAny.some(isActive)) continue;
|
||||
lines.push(`- ${rule.text}`);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { dirname, resolve } from "node:path";
|
||||
|
||||
import {
|
||||
applyToolCatalog,
|
||||
isSubagentCustomPrompt,
|
||||
loadRoutingRules,
|
||||
loadSectionsConfig,
|
||||
loadToolCatalog,
|
||||
@@ -23,8 +24,11 @@ export function getSystemPromptDumpPath(cwd: string): string {
|
||||
return resolve(cwd, DUMP_RELATIVE_PATH);
|
||||
}
|
||||
|
||||
/** The options object as Pi 1.3 normalizes it; the vendored types predate `sections`/`forceSystemPrompt`. */
|
||||
type RuntimePromptOptions = MutablePromptOptions & { forceSystemPrompt?: string };
|
||||
/** 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`
|
||||
@@ -105,6 +109,12 @@ export function createSystemPromptComposer(deps: SystemPromptComposerDeps = {}):
|
||||
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);
|
||||
@@ -115,14 +125,19 @@ export function createSystemPromptComposer(deps: SystemPromptComposerDeps = {}):
|
||||
ctx.ui.notify(`以下工具未在 config/prompt/tools.jsonc 登记:${missing.join(", ")}`, "warning");
|
||||
}
|
||||
}
|
||||
routingText = renderRouting(routing.rules, options.selectedTools);
|
||||
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);
|
||||
routingText = renderRouting(routing.rules, undefined, audience);
|
||||
}
|
||||
|
||||
let rendered = event.systemPrompt;
|
||||
@@ -132,7 +147,11 @@ export function createSystemPromptComposer(deps: SystemPromptComposerDeps = {}):
|
||||
rendered += `\n\n<tool_routing>\n${routingText}\n</tool_routing>`;
|
||||
}
|
||||
|
||||
const final = preamble ? spliceCover(rendered, preamble) : rendered;
|
||||
// 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 };
|
||||
}
|
||||
|
||||
Generated
+1384
-2299
File diff suppressed because it is too large
Load Diff
@@ -60,6 +60,7 @@
|
||||
"./pi-minimal-footer/index.ts",
|
||||
"./pi-notify/src/index.ts",
|
||||
"./extensions/permission-system.ts",
|
||||
"./extensions/subagents.ts",
|
||||
"./pi-ssh/index.ts",
|
||||
"./pi-ask-user/index.ts",
|
||||
"./pi-worktree/index.ts",
|
||||
@@ -76,6 +77,7 @@
|
||||
"@gotgenes/pi-permission-system": "file:./pi-permission-system",
|
||||
"@ogulcancelik/pi-minimal-footer": "file:./pi-minimal-footer",
|
||||
"@smoose/pi-notify": "file:./pi-notify",
|
||||
"@tintinweb/pi-subagents": "file:./pi-subagents",
|
||||
"context-mode": "1.0.169",
|
||||
"jiti": "2.7.0",
|
||||
"pi-ask-user": "file:./pi-ask-user",
|
||||
|
||||
@@ -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);
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
A [pi](https://pi.dev) extension that brings **Claude Code-style autonomous sub-agents** to pi. Spawn specialized agents that run in isolated sessions — each with its own tools, system prompt, model, and thinking level. Run them in the background (the default) or block on them, steer them mid-run, resume completed sessions, and define your own custom agent types.
|
||||
|
||||
> **my-pi maintenance note:** This directory was imported from upstream tag `v0.18.0` at commit `3f9d35cd078d18a141eb5a6d8f4fc5010d756280` and is maintained directly from source. Its source, tests, host-owned `extensions/subagents.ts` wrapper, and `pi-permission-system` child lifecycle bridge are retained, but the root bundle currently declares no `pi-subagents` runtime dependency and does not default-load the wrapper. See [`UPSTREAM.md`](UPSTREAM.md).
|
||||
> **my-pi maintenance note:** This directory was imported from upstream tag `v0.18.0` at commit `3f9d35cd078d18a141eb5a6d8f4fc5010d756280` and is maintained directly from source. The root bundle installs it through `file:./pi-subagents`, default-loads the host-owned `extensions/subagents.ts` wrapper, and exposes its orchestration tools through Tool Search's checked-in `subagents` group. The local source implements the `pi-permission-system` child lifecycle bridge described below. See [`UPSTREAM.md`](UPSTREAM.md).
|
||||
|
||||
## my-pi permission integration
|
||||
|
||||
Before a child calls `bindExtensions()`, it publishes `subagents:child:session-created` with the child session id and the interactive root session id. This lets `pi-permission-system` classify the headless session as a registered child before its own `session_start`, prevents the child permission service from replacing the root process-global service, and routes unresolved `ask` requests to the root authority. Nested descendants retain the same interactive root instead of forwarding to an intermediate child with no UI. The matching `subagents:child:disposed` event is published once, after the child has actually closed.
|
||||
|
||||
The retained bundle wrapper configures this extension through `createPiSubagentsExtension({ mandatoryExtensionPaths })`, although that wrapper is currently disabled in the root extension list. If re-enabled, these exact, host-owned absolute paths are canonicalized and merged *after* Agent frontmatter, so `extensions: false`, `isolated: true`, and `exclude_extensions:` cannot remove the permission wrapper. The child aborts before session creation if an exact mandatory entry is unavailable or does not survive loader filtering. Mandatory means lifecycle handlers bind; it does **not** expose that extension's tools when normal Agent extension policy hid them. Agent files cannot add to or alter this list.
|
||||
The bundle loads this extension through `createPiSubagentsExtension({ mandatoryExtensionPaths })`. These exact, host-owned absolute paths are canonicalized and merged *after* Agent frontmatter, so `extensions: false`, `isolated: true`, and `exclude_extensions:` cannot remove the permission wrapper. The child aborts before session creation if an exact mandatory entry is unavailable or does not survive loader filtering. Mandatory means lifecycle handlers bind; it does **not** expose that extension's tools when normal Agent extension policy hid them. Agent files cannot add to or alter this list.
|
||||
|
||||
Each child system prompt carries one sanitized `<active_agent name="..."/>` identity. Any inherited parent identity is removed first, so per-agent permission configuration and forwarded-request attribution resolve against the child definition rather than the orchestrator.
|
||||
|
||||
|
||||
@@ -237,7 +237,14 @@ async function shutdownChildSession(session: AgentSession | undefined): Promise<
|
||||
finally { unregisterPermissionChildSession(session); }
|
||||
}
|
||||
|
||||
export interface AgentManagerOptions extends MandatoryExtensionPolicy {}
|
||||
export interface AgentManagerOptions extends MandatoryExtensionPolicy {
|
||||
/** Canonical bundle-owned extension entries removed from every child session regardless of Agent frontmatter. */
|
||||
excludedExtensionPaths?: readonly string[];
|
||||
/** Per-spawn base prompt supplier — non-blank replaces the inherited parent prompt in buildAgentPrompt. */
|
||||
basePrompt?: () => string | undefined;
|
||||
/** Bundle-shipped agent .md directory forwarded to nested-delegation registries. */
|
||||
bundledAgentsDir?: string;
|
||||
}
|
||||
|
||||
export class AgentManager {
|
||||
private agents = new Map<string, AgentRecord>();
|
||||
@@ -248,6 +255,9 @@ export class AgentManager {
|
||||
private onUsage?: OnAgentUsage;
|
||||
private maxConcurrent: number;
|
||||
private readonly mandatoryExtensionPaths: readonly string[];
|
||||
private readonly excludedExtensionPaths: readonly string[];
|
||||
private readonly basePrompt?: () => string | undefined;
|
||||
private readonly bundledAgentsDir?: string;
|
||||
/** Base repos worktrees were created from — so dispose() can prune them all,
|
||||
* not just the parent repo (caller-supplied cwd can target other repos). */
|
||||
private worktreeRepos = new Set<string>();
|
||||
@@ -281,6 +291,11 @@ export class AgentManager {
|
||||
this.mandatoryExtensionPaths = normalizeMandatoryExtensionPaths(
|
||||
options.mandatoryExtensionPaths,
|
||||
);
|
||||
this.excludedExtensionPaths = normalizeMandatoryExtensionPaths(
|
||||
options.excludedExtensionPaths,
|
||||
);
|
||||
this.basePrompt = options.basePrompt;
|
||||
this.bundledAgentsDir = options.bundledAgentsDir;
|
||||
// Cleanup completed agents after 10 minutes (but keep sessions for resume)
|
||||
this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
|
||||
this.cleanupInterval.unref();
|
||||
@@ -442,6 +457,9 @@ export class AgentManager {
|
||||
// rootSessionId; programmatic callers fall back to their current context.
|
||||
authoritySessionId: record.rootSessionId ?? ctx.sessionManager?.getSessionId?.(),
|
||||
mandatoryExtensionPaths: this.mandatoryExtensionPaths,
|
||||
excludedExtensionPaths: this.excludedExtensionPaths,
|
||||
basePrompt: this.basePrompt,
|
||||
bundledAgentsDir: this.bundledAgentsDir,
|
||||
// Worktree wins for the working dir (the agent must run in the copy —
|
||||
// which, with a custom cwd, was created from that target). Config stays
|
||||
// with the parent project when a caller-supplied cwd is in play; it must
|
||||
|
||||
@@ -24,6 +24,7 @@ import { DEFAULT_AGENTS } from "./default-agents.js";
|
||||
import { detectEnv } from "./env.js";
|
||||
import {
|
||||
assertMandatoryExtensionsLoaded,
|
||||
extensionPathMatches,
|
||||
isMandatoryExtensionPath,
|
||||
normalizeMandatoryExtensionPaths,
|
||||
} from "./mandatory-extensions.js";
|
||||
@@ -432,6 +433,21 @@ export interface RunOptions {
|
||||
authoritySessionId?: string;
|
||||
/** Canonical bundle-owned extension entries that Agent config cannot disable. */
|
||||
mandatoryExtensionPaths?: readonly string[];
|
||||
/**
|
||||
* Canonical bundle-owned extension entries removed from every child session
|
||||
* regardless of Agent frontmatter — the host-level counterpart of
|
||||
* `exclude_extensions`. Overlap with `mandatoryExtensionPaths` is rejected
|
||||
* by `createPiSubagentsExtension`.
|
||||
*/
|
||||
excludedExtensionPaths?: readonly string[];
|
||||
/**
|
||||
* Per-spawn base prompt supplier. A non-blank return replaces the inherited
|
||||
* parent prompt and the `<sub_agent_context>` bridge / replace-mode header
|
||||
* inside `buildAgentPrompt`; undefined or blank keeps upstream composition.
|
||||
*/
|
||||
basePrompt?: () => string | undefined;
|
||||
/** Bundle-shipped agent .md directory forwarded to nested-delegation registries. */
|
||||
bundledAgentsDir?: string;
|
||||
/** Override working directory (e.g. for worktree isolation). */
|
||||
cwd?: string;
|
||||
/**
|
||||
@@ -651,16 +667,18 @@ export async function runAgent(
|
||||
}
|
||||
}
|
||||
|
||||
// Build system prompt from agent config
|
||||
// Build system prompt from agent config. `basePrompt` resolves once per
|
||||
// spawn — a host-owned base owns the prompt base instead of the parent's.
|
||||
const basePrompt = options.basePrompt?.();
|
||||
let systemPrompt: string;
|
||||
if (agentConfig) {
|
||||
systemPrompt = buildAgentPrompt(agentConfig, effectiveCwd, env, parentSystemPrompt, extras);
|
||||
systemPrompt = buildAgentPrompt(agentConfig, effectiveCwd, env, parentSystemPrompt, extras, basePrompt);
|
||||
} else {
|
||||
// Unknown type fallback: spread the canonical general-purpose config (defensive —
|
||||
// unreachable in practice since index.ts resolves unknown types before calling runAgent).
|
||||
const fallback = DEFAULT_AGENTS.get("general-purpose");
|
||||
if (!fallback) throw new Error(`No fallback config available for unknown type "${type}"`);
|
||||
systemPrompt = buildAgentPrompt({ ...fallback, name: type }, effectiveCwd, env, parentSystemPrompt, extras);
|
||||
systemPrompt = buildAgentPrompt({ ...fallback, name: type }, effectiveCwd, env, parentSystemPrompt, extras, basePrompt);
|
||||
}
|
||||
// Child permission policy is selected by the agent-definition filename,
|
||||
// which is also where pi-permission-system reads `permission:` frontmatter.
|
||||
@@ -701,6 +719,11 @@ export async function runAgent(
|
||||
);
|
||||
const optionalExtensionsDisabled = extensions === false;
|
||||
const mandatoryExtensionPaths = normalizeMandatoryExtensionPaths(options.mandatoryExtensionPaths);
|
||||
// Bundle-level exclusion: canonicalized like mandatory paths, removed from
|
||||
// every child's loaded set regardless of the Agent's own extension policy.
|
||||
const excludedExtensionPaths = normalizeMandatoryExtensionPaths(options.excludedExtensionPaths);
|
||||
const isExcludedExtension = (extensionPath: string) =>
|
||||
excludedExtensionPaths.some((path) => extensionPathMatches(extensionPath, path));
|
||||
const noExtensions = optionalExtensionsDisabled && mandatoryExtensionPaths.length === 0;
|
||||
|
||||
const extensionsSpec = Array.isArray(extensions)
|
||||
@@ -718,10 +741,12 @@ export async function runAgent(
|
||||
if (canons.some((name) => excludeNames.has(name))) return false;
|
||||
return loadAll || canons.some((name) => keepNames.has(name));
|
||||
};
|
||||
// An excluded path stays out even when an `extensions:` path entry asks for
|
||||
// it — host policy outranks Agent frontmatter, same as everywhere else.
|
||||
const requestedPaths = [
|
||||
...(extensionsSpec?.paths ?? []),
|
||||
...mandatoryExtensionPaths,
|
||||
];
|
||||
].filter((path) => !isExcludedExtension(path));
|
||||
const additionalExtensionPaths = requestedPaths.length
|
||||
? [...new Set(requestedPaths)]
|
||||
: undefined;
|
||||
@@ -729,16 +754,25 @@ export async function runAgent(
|
||||
// against discovery, while mandatory identity is checked by exact canonical path.
|
||||
let discoveredNames: Set<string> | undefined;
|
||||
const extensionsOverride: ((base: LoadExtensionsResult) => LoadExtensionsResult) | undefined =
|
||||
noExtensions || (loadAll && !hasExcludes)
|
||||
(noExtensions || (loadAll && !hasExcludes)) && excludedExtensionPaths.length === 0
|
||||
? undefined
|
||||
: (base) => {
|
||||
discoveredNames = new Set(base.extensions.flatMap((e) => extensionCanonicalNames(e.path)));
|
||||
return {
|
||||
...base,
|
||||
extensions: base.extensions.filter((extension) =>
|
||||
isMandatoryExtensionPath(extension.path, mandatoryExtensionPaths)
|
||||
|| normalPolicySelects(extension.path)
|
||||
),
|
||||
extensions: base.extensions.filter((extension) => {
|
||||
if (isExcludedExtension(extension.path)) {
|
||||
if (normalPolicySelects(extension.path)) {
|
||||
options.onToolActivity?.({
|
||||
type: "end",
|
||||
toolName: `extension-error:extension "${basename(extension.path)}" is excluded from subagent sessions by bundle policy`,
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return isMandatoryExtensionPath(extension.path, mandatoryExtensionPaths)
|
||||
|| normalPolicySelects(extension.path);
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -880,6 +914,7 @@ export async function runAgent(
|
||||
maxSubagentDepth: effectiveMaxDepth,
|
||||
allowedSubagents: agentConfig.allowedSubagents,
|
||||
configCwd,
|
||||
bundledAgentsDir: options.bundledAgentsDir,
|
||||
})
|
||||
: [];
|
||||
const nestedToolNames = new Set(nestedTools.map(tool => tool.name));
|
||||
|
||||
@@ -55,14 +55,20 @@ export function setFallbackSubagent(v: string | undefined): void { fallbackSubag
|
||||
|
||||
/**
|
||||
* Build a registry map: DEFAULT_AGENTS first (unless disabled via settings),
|
||||
* then user agents overlaid on top (same name overrides the default).
|
||||
* then bundle-shipped defaults (bundledAgentsDir — they replace same-name
|
||||
* embedded defaults and are themselves stamped `isDefault`), then user agents
|
||||
* overlaid on top (same name overrides everything before it).
|
||||
* Pure — callers that must not disturb the process-wide registry (nested
|
||||
* delegation resolving agents from its own config root) build their own map.
|
||||
*/
|
||||
export function buildAgentRegistry(userAgents: Map<string, AgentConfig>): Map<string, AgentConfig> {
|
||||
export function buildAgentRegistry(
|
||||
userAgents: Map<string, AgentConfig>,
|
||||
bundledAgents?: Map<string, AgentConfig>,
|
||||
): Map<string, AgentConfig> {
|
||||
const registry = new Map<string, AgentConfig>();
|
||||
if (!disableDefaults) {
|
||||
for (const [name, config] of DEFAULT_AGENTS) registry.set(name, config);
|
||||
if (bundledAgents) for (const [name, config] of bundledAgents) registry.set(name, config);
|
||||
}
|
||||
for (const [name, config] of userAgents) registry.set(name, config);
|
||||
return registry;
|
||||
@@ -70,12 +76,16 @@ export function buildAgentRegistry(userAgents: Map<string, AgentConfig>): Map<st
|
||||
|
||||
/**
|
||||
* Register agents into the unified registry.
|
||||
* Starts with DEFAULT_AGENTS, then overlays user agents (overrides defaults with same name).
|
||||
* Starts with DEFAULT_AGENTS (then bundle-shipped defaults), then overlays
|
||||
* user agents (overrides defaults with same name).
|
||||
* Disabled agents (enabled === false) are kept in the registry but excluded from spawning.
|
||||
*/
|
||||
export function registerAgents(userAgents: Map<string, AgentConfig>): void {
|
||||
export function registerAgents(
|
||||
userAgents: Map<string, AgentConfig>,
|
||||
bundledAgents?: Map<string, AgentConfig>,
|
||||
): void {
|
||||
agents.clear();
|
||||
for (const [name, config] of buildAgentRegistry(userAgents)) {
|
||||
for (const [name, config] of buildAgentRegistry(userAgents, bundledAgents)) {
|
||||
agents.set(name, config);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,32 +79,60 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
|
||||
}
|
||||
const { frontmatter: fm, body } = parsed;
|
||||
|
||||
// Claude Code's rule: `name:` IS the agent type, and the filename need not
|
||||
// match. Absent, the filename stands in — Claude Code requires the field,
|
||||
// but most files here predate it and must keep loading.
|
||||
// A reserved-name skip carries no `warnSkippedOverride`: this file would
|
||||
// have registered under its *declared* name, which nothing else can hold
|
||||
// (a colon keeps it out of the registry), so it shadowed nothing. Passing
|
||||
// the filename instead would report a substitution of an unrelated agent
|
||||
// that never happened.
|
||||
const name = resolveDeclaredName(path, filenameType, fm);
|
||||
if (name === undefined) continue;
|
||||
|
||||
agents.set(name, configFromFrontmatter(name, fm, body, { source, sourcePath: path }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code's rule: `name:` IS the agent type, and the filename need not
|
||||
* match. Absent, the filename stands in — Claude Code requires the field, but
|
||||
* most files here predate it and must keep loading. `||`, not `??`: a quoted
|
||||
* empty or all-whitespace `name:` would otherwise register the agent under the
|
||||
* empty type — unspawnable, and it takes the filename-derived one down with it.
|
||||
*
|
||||
* A declared name containing ":" is refused — reserved for plugin-scoped
|
||||
* identifiers. Refusing beats silently substituting: the file would otherwise
|
||||
* load under its filename, so `Agent({subagent_type})` would succeed against
|
||||
* an agent whose declared identity nothing honoured.
|
||||
*/
|
||||
function resolveDeclaredName(
|
||||
path: string,
|
||||
filenameType: string,
|
||||
fm: Record<string, unknown>,
|
||||
): string | undefined {
|
||||
const declared = str(fm.name)?.trim();
|
||||
if (declared?.includes(RESERVED_IN_TYPE)) {
|
||||
// Refusing beats silently substituting: the file would otherwise load
|
||||
// under its filename, so `Agent({subagent_type})` would succeed against
|
||||
// an agent whose declared identity nothing honoured.
|
||||
warnIfNew(
|
||||
`Agent file ${path} declares name "${declared}", which contains "${RESERVED_IN_TYPE}" — reserved for `
|
||||
+ "plugin-scoped identifiers. Rename it, or move the label to `display_name:`. Skipping.",
|
||||
);
|
||||
// No `warnSkippedOverride`: this file would have registered under its
|
||||
// *declared* name, which nothing else can hold (a colon keeps it out of
|
||||
// the registry), so it shadowed nothing. Passing the filename instead
|
||||
// would report a substitution of an unrelated agent that never happened.
|
||||
continue;
|
||||
return undefined;
|
||||
}
|
||||
// `||`, not `??`: a quoted empty or all-whitespace `name:` would otherwise
|
||||
// register the agent under the empty type — unspawnable, and it takes the
|
||||
// filename-derived one down with it.
|
||||
const name = declared || filenameType;
|
||||
return declared || filenameType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an AgentConfig from parsed frontmatter. `provenance` is the
|
||||
* project/global source record; bundled agents pass none and are stamped as
|
||||
* embedded defaults instead — `isDefault` with no `source`/`sourcePath`, so
|
||||
* UIs treat them as built-ins rather than editable/deletable files.
|
||||
*/
|
||||
function configFromFrontmatter(
|
||||
name: string,
|
||||
fm: Record<string, unknown>,
|
||||
body: string,
|
||||
provenance?: { source: "project" | "global"; sourcePath: string },
|
||||
): AgentConfig {
|
||||
const { builtinToolNames, extSelectors } = parseToolsField(fm.tools);
|
||||
|
||||
agents.set(name, {
|
||||
return {
|
||||
name,
|
||||
// Only `display_name` now: `name` is the type, and `getConfig` already
|
||||
// falls back to the type when no label is set — so a Claude Code file
|
||||
@@ -133,10 +161,40 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
|
||||
memory: parseMemory(fm.memory),
|
||||
isolation: parseIsolation(fm.isolation),
|
||||
enabled: fm.enabled !== false, // default true; explicitly false disables
|
||||
source,
|
||||
sourcePath: path,
|
||||
});
|
||||
isDefault: provenance === undefined ? true : undefined,
|
||||
source: provenance?.source,
|
||||
sourcePath: provenance?.sourcePath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load bundle-shipped agent .md files as additional embedded defaults
|
||||
* (the `bundledAgentsDir` extension option). Same frontmatter parser as
|
||||
* user agent files; entries carry `isDefault` with no `source`/`sourcePath`
|
||||
* so UIs treat them as built-ins rather than editable files. Files are read
|
||||
* in sorted order so registration order is deterministic.
|
||||
*/
|
||||
export function loadBundledAgents(dir: string, strict = false): Map<string, AgentConfig> {
|
||||
const agents = new Map<string, AgentConfig>();
|
||||
if (!existsSync(dir)) return agents;
|
||||
|
||||
let files: string[];
|
||||
try {
|
||||
files = readdirSync(dir).filter(f => f.endsWith(".md")).sort();
|
||||
} catch {
|
||||
return agents;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const filenameType = basename(file, ".md");
|
||||
const path = join(dir, file);
|
||||
const parsed = readAgentFile(path, strict);
|
||||
if (!parsed) continue;
|
||||
const name = resolveDeclaredName(path, filenameType, parsed.frontmatter);
|
||||
if (name === undefined) continue;
|
||||
agents.set(name, configFromFrontmatter(name, parsed.frontmatter, parsed.body));
|
||||
}
|
||||
return agents;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,15 +18,15 @@ import { Type } from "@sinclair/typebox";
|
||||
import { abortable } from "./abortable.js";
|
||||
import { hasAgentBadge, renderAgentName } from "./agent-color.js";
|
||||
import { buildNewAgentFile, disableInContent, enableInContent, isEmptyStub, locateAgentFile, personalAgentsDir, projectAgentsDir, serializeAgentFile } from "./agent-file-toggle.js";
|
||||
import { AgentManager } from "./agent-manager.js";
|
||||
import { AgentManager, type AgentManagerOptions } from "./agent-manager.js";
|
||||
import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, steerAgent } from "./agent-runner.js";
|
||||
import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getConfig, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js";
|
||||
import { inChildSessionContext } from "./child-context.js";
|
||||
import { type RpcHandle, registerRpcHandlers } from "./cross-extension-rpc.js";
|
||||
import { loadCustomAgents } from "./custom-agents.js";
|
||||
import { loadBundledAgents, loadCustomAgents } from "./custom-agents.js";
|
||||
import { GroupJoinManager } from "./group-join.js";
|
||||
import { isolationParam, resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
|
||||
import type { MandatoryExtensionPolicy } from "./mandatory-extensions.js";
|
||||
import { canonicalExistingPath, isMandatoryExtensionPath, normalizeMandatoryExtensionPaths } from "./mandatory-extensions.js";
|
||||
import { describeMention, handleBase, isReservedHandle, parseMention, resolveHandleToType, stripAgentPrefix } from "./mention.js";
|
||||
import { runMentionClone } from "./mention-clone.js";
|
||||
import { type ModelRegistry, resolveModel } from "./model-resolver.js";
|
||||
@@ -281,13 +281,53 @@ export function formatToolsSuffix(cfg: AgentConfig | undefined): string {
|
||||
return isFullSet ? "*" : tools.join(", ");
|
||||
}
|
||||
|
||||
export interface PiSubagentsExtensionOptions extends MandatoryExtensionPolicy {}
|
||||
export interface PiSubagentsExtensionOptions extends AgentManagerOptions {
|
||||
/**
|
||||
* Per-spawn base prompt supplier. A non-blank return owns the child's prompt
|
||||
* base: `buildAgentPrompt` drops the inherited parent prompt, the
|
||||
* `<sub_agent_context>` bridge and the replace-mode header, keeping the
|
||||
* env/worktree blocks, the `<agent_instructions>` section and extras.
|
||||
* Undefined or blank keeps upstream composition byte-for-byte.
|
||||
*/
|
||||
basePrompt?: () => string | undefined;
|
||||
/**
|
||||
* Directory of bundle-shipped agent `.md` files, loaded with the same
|
||||
* frontmatter parser as user agents and registered as embedded defaults
|
||||
* (`isDefault`, no `source`/`sourcePath`). Priority: embedded < bundled <
|
||||
* global < workspace < project.
|
||||
*/
|
||||
bundledAgentsDir?: string;
|
||||
/**
|
||||
* Bundle-owned extension entry paths removed from every child session
|
||||
* regardless of Agent frontmatter — the host-level counterpart of
|
||||
* `exclude_extensions`. Canonicalized like `mandatoryExtensionPaths`;
|
||||
* overlap with it is rejected at creation.
|
||||
*/
|
||||
excludedExtensionPaths?: readonly string[];
|
||||
}
|
||||
|
||||
/** Create a host-configured extension; Agent frontmatter cannot mutate these options. */
|
||||
export function createPiSubagentsExtension(
|
||||
options: PiSubagentsExtensionOptions = {},
|
||||
): (pi: ExtensionAPI) => void {
|
||||
return (pi) => installPiSubagentsExtension(pi, options);
|
||||
// Fail fast at bundle-configuration time: a mandatory entry is the one thing
|
||||
// an excluded entry can never be, so an overlap is a broken bundle policy.
|
||||
const mandatoryExtensionPaths = normalizeMandatoryExtensionPaths(options.mandatoryExtensionPaths);
|
||||
const excludedExtensionPaths = normalizeMandatoryExtensionPaths(options.excludedExtensionPaths);
|
||||
for (const path of excludedExtensionPaths) {
|
||||
if (isMandatoryExtensionPath(path, mandatoryExtensionPaths)) {
|
||||
throw new Error(`Extension path is both mandatory and excluded: ${path}`);
|
||||
}
|
||||
}
|
||||
const bundledAgentsDir = options.bundledAgentsDir === undefined
|
||||
? undefined
|
||||
: canonicalExistingPath(options.bundledAgentsDir);
|
||||
return (pi) => installPiSubagentsExtension(pi, {
|
||||
...options,
|
||||
mandatoryExtensionPaths,
|
||||
excludedExtensionPaths,
|
||||
bundledAgentsDir,
|
||||
});
|
||||
}
|
||||
|
||||
const defaultExtension = createPiSubagentsExtension();
|
||||
@@ -371,10 +411,13 @@ function installPiSubagentsExtension(
|
||||
// the initial load, which happens hundreds of lines before settings are applied.
|
||||
let strictAgentFiles = loadSettings(process.cwd()).strictAgentFiles === true;
|
||||
|
||||
/** Reload agents from project/global custom agent dirs and merge with defaults (called on init and each Agent invocation). */
|
||||
/** Reload agents from bundled/project/global dirs and merge with defaults (called on init and each Agent invocation). */
|
||||
const reloadCustomAgents = (strict = false) => {
|
||||
const bundledAgents = options.bundledAgentsDir === undefined
|
||||
? undefined
|
||||
: loadBundledAgents(options.bundledAgentsDir, strict);
|
||||
const userAgents = loadCustomAgents(process.cwd(), strict);
|
||||
registerAgents(userAgents);
|
||||
registerAgents(userAgents, bundledAgents);
|
||||
};
|
||||
|
||||
// Initial load — the only strict one. A bad edit mid-session must not kill the
|
||||
@@ -599,7 +642,12 @@ function installPiSubagentsExtension(
|
||||
// see `PendingUsagePool`. Skipped entirely when the feature is off, so no
|
||||
// pool grows in a session that will never drain it.
|
||||
if (reportUsage) pendingUsage.add(usage);
|
||||
}, { mandatoryExtensionPaths: options.mandatoryExtensionPaths });
|
||||
}, {
|
||||
mandatoryExtensionPaths: options.mandatoryExtensionPaths,
|
||||
excludedExtensionPaths: options.excludedExtensionPaths,
|
||||
basePrompt: options.basePrompt,
|
||||
bundledAgentsDir: options.bundledAgentsDir,
|
||||
});
|
||||
|
||||
// Expose manager via Symbol.for() global registry for cross-package access.
|
||||
// Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface MandatoryExtensionPolicy {
|
||||
mandatoryExtensionPaths?: readonly string[];
|
||||
}
|
||||
|
||||
function canonicalExistingPath(path: string): string {
|
||||
export function canonicalExistingPath(path: string): string {
|
||||
if (!isAbsolute(path)) {
|
||||
throw new Error(`Mandatory extension path must be absolute: ${path}`);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
resolveEnabledTypeIn,
|
||||
resolveTypeIn,
|
||||
} from "./agent-types.js";
|
||||
import { loadCustomAgents } from "./custom-agents.js";
|
||||
import { loadBundledAgents, loadCustomAgents } from "./custom-agents.js";
|
||||
import { isolationParam, resolveAgentInvocationConfig } from "./invocation-config.js";
|
||||
import { resolveModel } from "./model-resolver.js";
|
||||
import { checkModelScope } from "./model-scope.js";
|
||||
@@ -100,6 +100,8 @@ export interface NestedToolContext {
|
||||
allowedSubagents: "all" | string[];
|
||||
/** Root used for agent/config discovery; may differ from the agent's working directory. */
|
||||
configCwd: string;
|
||||
/** Bundle-shipped agent .md directory — registered as embedded defaults, like the top-level registry. */
|
||||
bundledAgentsDir?: string;
|
||||
}
|
||||
|
||||
function textResult(text: string, isError = false) {
|
||||
@@ -145,7 +147,10 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
|
||||
// Agents resolve from a registry built for THIS branch's config root (under
|
||||
// worktree isolation, the copy). Never via registerAgents — that is
|
||||
// process-global state shared with the main session and every other agent.
|
||||
const loadRegistry = () => buildAgentRegistry(loadCustomAgents(context.configCwd));
|
||||
const loadRegistry = () => buildAgentRegistry(
|
||||
loadCustomAgents(context.configCwd),
|
||||
context.bundledAgentsDir === undefined ? undefined : loadBundledAgents(context.bundledAgentsDir),
|
||||
);
|
||||
const allowedTypesIn = (registry: Map<string, AgentConfig>): Set<string> | undefined =>
|
||||
context.allowedSubagents === "all"
|
||||
? undefined
|
||||
|
||||
@@ -32,8 +32,18 @@ export interface PromptExtras {
|
||||
* parent prompt forms an identical, cacheable byte prefix with the parent
|
||||
* session (the LLM's KV cache can then reuse those tokens across every spawn).
|
||||
*
|
||||
* A non-blank `base` (the `basePrompt` extension option, resolved per spawn)
|
||||
* owns the child's prompt base instead of the inherited parent prompt: the
|
||||
* `<sub_agent_context>` bridge and the replace-mode header are omitted, while
|
||||
* the env/worktree blocks, agent-instructions section and extras keep their
|
||||
* usual positions. Without an inherited prefix to keep cacheable, the
|
||||
* active_agent tag leads the prompt in both modes — same as upstream replace
|
||||
* mode — so session hooks can identify the child before before_agent_start.
|
||||
* Undefined/blank keeps upstream composition byte-for-byte.
|
||||
*
|
||||
* @param parentSystemPrompt The parent agent's effective system prompt (for append mode).
|
||||
* @param extras Optional extra sections to inject (memory, preloaded skills).
|
||||
* @param base Host-owned base prompt that replaces the inherited parent prompt when non-blank.
|
||||
*/
|
||||
export function buildAgentPrompt(
|
||||
config: AgentConfig,
|
||||
@@ -41,6 +51,7 @@ export function buildAgentPrompt(
|
||||
env: EnvInfo,
|
||||
parentSystemPrompt?: string,
|
||||
extras?: PromptExtras,
|
||||
base?: string,
|
||||
): string {
|
||||
const activeAgentTag = `<active_agent name="${config.name}"/>\n\n`;
|
||||
|
||||
@@ -72,7 +83,22 @@ Work only inside it — never in ${extras.worktreeBase}, even if other instructi
|
||||
}
|
||||
const extrasSuffix = extraSections.length > 0 ? "\n\n" + extraSections.join("\n") : "";
|
||||
|
||||
const bundleBase = base?.trim();
|
||||
|
||||
if (config.promptMode === "append") {
|
||||
const customSection = config.systemPrompt?.trim()
|
||||
? `\n\n<agent_instructions>\n${config.systemPrompt}\n</agent_instructions>`
|
||||
: "";
|
||||
|
||||
if (bundleBase) {
|
||||
// Host-owned base: replaces both the inherited parent prompt and the
|
||||
// sub_agent_context bridge (the bundle preamble already encodes how a
|
||||
// sub-agent works). With no parent prompt to keep cacheable, the tag
|
||||
// leads the prompt — same position as replace mode — so early session
|
||||
// hooks can spot the child before before_agent_start.
|
||||
return activeAgentTag + bundleBase + "\n\n" + envBlock + worktreeBlock + customSection + extrasSuffix;
|
||||
}
|
||||
|
||||
const identity = parentSystemPrompt || genericBase;
|
||||
|
||||
const bridge = `<sub_agent_context>
|
||||
@@ -88,10 +114,6 @@ You are operating as a sub-agent invoked to handle a specific task.
|
||||
- Be concise but complete
|
||||
</sub_agent_context>`;
|
||||
|
||||
const customSection = config.systemPrompt?.trim()
|
||||
? `\n\n<agent_instructions>\n${config.systemPrompt}\n</agent_instructions>`
|
||||
: "";
|
||||
|
||||
// Place shared/stable content first so the LLM's KV cache can reuse the
|
||||
// inherited prefix across all subagent invocations. The parent prompt is
|
||||
// placed verbatim (no wrapper tag) so it forms an identical byte prefix
|
||||
@@ -101,6 +123,13 @@ You are operating as a sub-agent invoked to handle a specific task.
|
||||
}
|
||||
|
||||
// "replace" mode — env header + the config's full system prompt
|
||||
if (bundleBase) {
|
||||
// Host-owned base: drops the "You are a pi coding agent sub-agent" header
|
||||
// (it duplicates the base's own role statement); env/worktree stay.
|
||||
const configSection = config.systemPrompt?.trim() ? `\n\n${config.systemPrompt}` : "";
|
||||
return activeAgentTag + bundleBase + "\n\n" + envBlock + worktreeBlock + configSection + extrasSuffix;
|
||||
}
|
||||
|
||||
const replaceHeader = `You are a pi coding agent sub-agent.
|
||||
You have been invoked to handle a specific task autonomously.
|
||||
|
||||
|
||||
@@ -1763,6 +1763,148 @@ describe("mandatory extension policy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("excluded extension paths", () => {
|
||||
function excludedEntry(name = "pi-ask-user"): { dir: string; path: string } {
|
||||
const dir = mkdtempSync(join(tmpdir(), "subagents-excluded-runner-"));
|
||||
const extDir = join(dir, name);
|
||||
mkdirSync(extDir, { recursive: true });
|
||||
const path = join(extDir, "index.ts");
|
||||
writeFileSync(path, "export default () => {};\n");
|
||||
return { dir, path };
|
||||
}
|
||||
|
||||
function setup(extensions: true | string[] | false, excludeExtensions?: string[]) {
|
||||
vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions, excludeExtensions }));
|
||||
vi.mocked(getAgentConfig).mockReturnValueOnce(
|
||||
makeAgentConfig({ extensions, excludeExtensions }),
|
||||
);
|
||||
vi.mocked(getToolNamesForType).mockReturnValueOnce(BUILTINS_7);
|
||||
}
|
||||
|
||||
it("removes the extension under extensions:true, tools and all", async () => {
|
||||
const fixture = excludedEntry();
|
||||
try {
|
||||
setup(true);
|
||||
withExtensions({
|
||||
[fixture.path]: ["ask_user_question"],
|
||||
"/ext/other.ts": ["other_tool"],
|
||||
});
|
||||
const { session } = createSession("OK");
|
||||
createAgentSession.mockResolvedValue({ session });
|
||||
const onToolActivity = vi.fn();
|
||||
|
||||
await runAgent(ctx, "Explore", "go", {
|
||||
pi,
|
||||
excludedExtensionPaths: [fixture.path],
|
||||
onToolActivity,
|
||||
});
|
||||
|
||||
// Exclusion forces the override on even though extensions:true + no
|
||||
// frontmatter excludes would otherwise skip it.
|
||||
expect(lastLoaderOpts().extensionsOverride).toBeDefined();
|
||||
expect(loaderExtensionsRef.current.extensions.map((e) => e.path))
|
||||
.not.toContain(fixture.path);
|
||||
const tools = lastToolsPassed();
|
||||
expect(tools).not.toContain("ask_user_question");
|
||||
expect(tools).toContain("other_tool");
|
||||
expect(onToolActivity).toHaveBeenCalledWith(expect.objectContaining({
|
||||
toolName: expect.stringContaining("excluded from subagent sessions"),
|
||||
}));
|
||||
} finally {
|
||||
rmSync(fixture.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("removes the extension even when an `extensions:` name entry selects it", async () => {
|
||||
const fixture = excludedEntry();
|
||||
try {
|
||||
setup(["pi-ask-user"]);
|
||||
withExtensions({ [fixture.path]: ["ask_user_question"] });
|
||||
const { session } = createSession("OK");
|
||||
createAgentSession.mockResolvedValue({ session });
|
||||
const onToolActivity = vi.fn();
|
||||
|
||||
await runAgent(ctx, "Explore", "go", {
|
||||
pi,
|
||||
excludedExtensionPaths: [fixture.path],
|
||||
onToolActivity,
|
||||
});
|
||||
|
||||
expect(loaderExtensionsRef.current.extensions.map((e) => e.path))
|
||||
.not.toContain(fixture.path);
|
||||
expect(lastToolsPassed()).not.toContain("ask_user_question");
|
||||
expect(onToolActivity).toHaveBeenCalledWith(expect.objectContaining({
|
||||
toolName: expect.stringContaining("excluded from subagent sessions"),
|
||||
}));
|
||||
} finally {
|
||||
rmSync(fixture.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("removes the extension even when an `extensions:` path entry requests it", async () => {
|
||||
const fixture = excludedEntry();
|
||||
try {
|
||||
setup([fixture.path]);
|
||||
withExtensions({ [fixture.path]: ["ask_user_question"] });
|
||||
const { session } = createSession("OK");
|
||||
createAgentSession.mockResolvedValue({ session });
|
||||
|
||||
await runAgent(ctx, "Explore", "go", {
|
||||
pi,
|
||||
excludedExtensionPaths: [fixture.path],
|
||||
});
|
||||
|
||||
expect(lastLoaderOpts().additionalExtensionPaths).toBeUndefined();
|
||||
expect(loaderExtensionsRef.current.extensions.map((e) => e.path))
|
||||
.not.toContain(fixture.path);
|
||||
expect(lastToolsPassed()).not.toContain("ask_user_question");
|
||||
} finally {
|
||||
rmSync(fixture.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("is a no-op when empty — no override installed for wildcard agents", async () => {
|
||||
setup(["*"]);
|
||||
withExtensions({ "/ext/a.ts": ["tool_a"] });
|
||||
const { session } = createSession("OK");
|
||||
createAgentSession.mockResolvedValue({ session });
|
||||
|
||||
await runAgent(ctx, "Explore", "go", { pi, excludedExtensionPaths: [] });
|
||||
|
||||
expect(lastLoaderOpts().extensionsOverride).toBeUndefined();
|
||||
expect(lastToolsPassed()).toContain("tool_a");
|
||||
});
|
||||
|
||||
it("keeps mandatory entries while removing excluded ones", async () => {
|
||||
const mandatoryDir = mkdtempSync(join(tmpdir(), "subagents-mandatory-runner-"));
|
||||
const mandatoryPath = join(mandatoryDir, "permission-system.ts");
|
||||
writeFileSync(mandatoryPath, "export default () => {};\n");
|
||||
const fixture = excludedEntry();
|
||||
try {
|
||||
setup(true);
|
||||
withExtensions({
|
||||
[mandatoryPath]: ["permission_admin"],
|
||||
[fixture.path]: ["ask_user_question"],
|
||||
});
|
||||
const { session } = createSession("OK");
|
||||
createAgentSession.mockResolvedValue({ session });
|
||||
|
||||
await runAgent(ctx, "Explore", "go", {
|
||||
pi,
|
||||
mandatoryExtensionPaths: [mandatoryPath],
|
||||
excludedExtensionPaths: [fixture.path],
|
||||
});
|
||||
|
||||
const loaded = loaderExtensionsRef.current.extensions.map((e) => e.path);
|
||||
expect(loaded).toContain(mandatoryPath);
|
||||
expect(loaded).not.toContain(fixture.path);
|
||||
} finally {
|
||||
rmSync(mandatoryDir, { recursive: true, force: true });
|
||||
rmSync(fixture.dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent-runner extension allowlist", () => {
|
||||
function setupArrayAgent(extensions: string[]) {
|
||||
vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions }));
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* bundled-agents.test.ts — `bundledAgentsDir` extension option: bundle-shipped
|
||||
* agent .md files register as embedded defaults (isDefault, no source/path),
|
||||
* sit between embedded defaults and user agents in precedence, and stay out of
|
||||
* the registry when the option is unset.
|
||||
*/
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { buildAgentRegistry, getAgentConfig, getDefaultAgentNames, registerAgents } from "../src/agent-types.js";
|
||||
import { loadBundledAgents } from "../src/custom-agents.js";
|
||||
import { createPiSubagentsExtension } from "../src/index.js";
|
||||
|
||||
let tmpDir: string;
|
||||
let bundledDir: string;
|
||||
let originalHome: string | undefined;
|
||||
let originalAgentDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "pi-bundled-agents-"));
|
||||
bundledDir = join(tmpDir, "bundled");
|
||||
mkdirSync(bundledDir, { recursive: true });
|
||||
originalHome = process.env.HOME;
|
||||
originalAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
process.env.HOME = join(tmpDir, "home");
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalHome == null) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
if (originalAgentDir == null) delete process.env.PI_CODING_AGENT_DIR;
|
||||
else process.env.PI_CODING_AGENT_DIR = originalAgentDir;
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
registerAgents(new Map());
|
||||
});
|
||||
|
||||
function writeBundled(name: string, content: string) {
|
||||
writeFileSync(join(bundledDir, `${name}.md`), content);
|
||||
}
|
||||
|
||||
describe("loadBundledAgents", () => {
|
||||
it("parses agent files with the custom-agent parser and stamps them as built-ins", () => {
|
||||
writeBundled("scout", `---
|
||||
name: scout
|
||||
description: Bundle scout
|
||||
prompt_mode: append
|
||||
tools: read, grep
|
||||
---
|
||||
|
||||
Bundle prompt body.`);
|
||||
|
||||
const agents = loadBundledAgents(bundledDir);
|
||||
const scout = agents.get("scout");
|
||||
expect(scout).toBeDefined();
|
||||
expect(scout?.description).toBe("Bundle scout");
|
||||
expect(scout?.systemPrompt).toBe("Bundle prompt body.");
|
||||
expect(scout?.promptMode).toBe("append");
|
||||
expect(scout?.builtinToolNames).toEqual(["read", "grep"]);
|
||||
expect(scout?.isDefault).toBe(true);
|
||||
expect(scout?.source).toBeUndefined();
|
||||
expect(scout?.sourcePath).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns an empty map for a missing or empty directory", () => {
|
||||
expect(loadBundledAgents(join(tmpDir, "missing")).size).toBe(0);
|
||||
expect(loadBundledAgents(bundledDir).size).toBe(0);
|
||||
});
|
||||
|
||||
it("skips non-.md files and reserved ':' names", () => {
|
||||
writeBundled("fine", "---\ndescription: ok\n---\nbody");
|
||||
writeFileSync(join(bundledDir, "notes.txt"), "not an agent");
|
||||
writeBundled("bad", "---\nname: 'plugin:scout'\ndescription: bad\n---\nbody");
|
||||
const agents = loadBundledAgents(bundledDir);
|
||||
expect([...agents.keys()]).toEqual(["fine"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundled agent precedence", () => {
|
||||
it("replaces a same-name embedded default and reports as a default", () => {
|
||||
writeBundled("Explore", `---
|
||||
name: Explore
|
||||
description: Bundled Explore override
|
||||
prompt_mode: replace
|
||||
---
|
||||
|
||||
Bundled Explore body.`);
|
||||
|
||||
const bundled = loadBundledAgents(bundledDir);
|
||||
const registry = buildAgentRegistry(new Map(), bundled);
|
||||
expect(registry.get("Explore")?.systemPrompt).toBe("Bundled Explore body.");
|
||||
expect(registry.get("Explore")?.isDefault).toBe(true);
|
||||
// Unrelated embedded defaults are untouched.
|
||||
expect(registry.get("general-purpose")?.systemPrompt).not.toBe("Bundled Explore body.");
|
||||
});
|
||||
|
||||
it("new bundled names join the default set", () => {
|
||||
writeBundled("bundle-only", "---\ndescription: only in bundle\n---\nbody");
|
||||
const registry = buildAgentRegistry(new Map(), loadBundledAgents(bundledDir));
|
||||
expect(registry.get("bundle-only")?.isDefault).toBe(true);
|
||||
registerAgents(new Map(), loadBundledAgents(bundledDir));
|
||||
expect(getDefaultAgentNames()).toContain("bundle-only");
|
||||
expect(getAgentConfig("bundle-only")?.description).toBe("only in bundle");
|
||||
});
|
||||
|
||||
it("user agents still override bundled definitions", () => {
|
||||
writeBundled("scout", "---\ndescription: bundled\n---\nbundled body");
|
||||
const user = new Map([
|
||||
["scout", {
|
||||
name: "scout",
|
||||
description: "project override",
|
||||
systemPrompt: "project body",
|
||||
promptMode: "replace" as const,
|
||||
extensions: true as const,
|
||||
skills: true as const,
|
||||
source: "project" as const,
|
||||
sourcePath: "/proj/.pi/agents/scout.md",
|
||||
}],
|
||||
]);
|
||||
const registry = buildAgentRegistry(user, loadBundledAgents(bundledDir));
|
||||
expect(registry.get("scout")?.description).toBe("project override");
|
||||
expect(registry.get("scout")?.isDefault).not.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extension option validation", () => {
|
||||
it("rejects a path that is both mandatory and excluded", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pi-subagents-overlap-"));
|
||||
const path = join(dir, "permission-system.ts");
|
||||
writeFileSync(path, "export default () => {};\n");
|
||||
expect(() =>
|
||||
createPiSubagentsExtension({
|
||||
mandatoryExtensionPaths: [path],
|
||||
excludedExtensionPaths: [path],
|
||||
}),
|
||||
).toThrow("both mandatory and excluded");
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("canonicalizes bundledAgentsDir and rejects missing directories", () => {
|
||||
expect(() =>
|
||||
createPiSubagentsExtension({ bundledAgentsDir: join(tmpDir, "missing") }),
|
||||
).toThrow("is unavailable");
|
||||
expect(() => createPiSubagentsExtension({ bundledAgentsDir: bundledDir })).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -433,4 +433,116 @@ describe("buildAgentPrompt", () => {
|
||||
expect(prompt.indexOf("<worktree_isolation>")).toBeGreaterThan(prompt.indexOf("<sub_agent_context>"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("host-owned base prompt", () => {
|
||||
const BASE = "You are a subagent operating inside pi. The human user does not see your messages.";
|
||||
|
||||
function baseConfig(promptMode: "append" | "replace", systemPrompt = "Custom instructions."): AgentConfig {
|
||||
return {
|
||||
name: "bundled-agent",
|
||||
description: "Bundled",
|
||||
builtinToolNames: [],
|
||||
extensions: true,
|
||||
skills: true,
|
||||
systemPrompt,
|
||||
promptMode,
|
||||
inheritContext: false,
|
||||
runInBackground: false,
|
||||
isolated: false,
|
||||
};
|
||||
}
|
||||
|
||||
it("append mode: replaces parent prompt and drops the bridge, keeps env/instructions/tag", () => {
|
||||
const prompt = buildAgentPrompt(
|
||||
baseConfig("append"),
|
||||
"/workspace",
|
||||
env,
|
||||
"SECRET parent prompt content",
|
||||
undefined,
|
||||
BASE,
|
||||
);
|
||||
// No inherited parent prefix to keep cacheable, so the tag leads the
|
||||
// prompt — same position as replace mode — and lets early session hooks
|
||||
// identify the child before before_agent_start.
|
||||
expect(prompt.startsWith('<active_agent name="bundled-agent"/>')).toBe(true);
|
||||
expect(prompt).not.toContain("SECRET parent prompt content");
|
||||
expect(prompt).not.toContain("<sub_agent_context>");
|
||||
expect(prompt).not.toContain("You are a pi coding agent sub-agent");
|
||||
expect(prompt).toContain("Working directory: /workspace");
|
||||
expect(prompt).toContain("<agent_instructions>");
|
||||
expect(prompt).toContain("Custom instructions.");
|
||||
expect(prompt.match(/<active_agent name="bundled-agent"\/>/g)).toHaveLength(1);
|
||||
const order = [
|
||||
'<active_agent name="bundled-agent"/>',
|
||||
BASE,
|
||||
"# Environment",
|
||||
"<agent_instructions>",
|
||||
].map((s) => prompt.indexOf(s));
|
||||
expect(order.every((i) => i >= 0)).toBe(true);
|
||||
expect(order).toEqual([...order].sort((a, b) => a - b));
|
||||
});
|
||||
|
||||
it("replace mode: drops the sub-agent header, keeps env + config prompt + tag", () => {
|
||||
const prompt = buildAgentPrompt(
|
||||
baseConfig("replace", "You are a read-only search agent."),
|
||||
"/workspace",
|
||||
env,
|
||||
"SECRET parent prompt content",
|
||||
undefined,
|
||||
BASE,
|
||||
);
|
||||
expect(prompt).not.toContain("You are a pi coding agent sub-agent");
|
||||
expect(prompt).not.toContain("SECRET parent prompt content");
|
||||
expect(prompt).not.toContain("<sub_agent_context>");
|
||||
expect(prompt).toContain("Working directory: /workspace");
|
||||
expect(prompt).toContain("You are a read-only search agent.");
|
||||
expect(prompt.match(/<active_agent name="bundled-agent"\/>/g)).toHaveLength(1);
|
||||
// The tag leads, then base, then env, then the config's system prompt.
|
||||
const order = [
|
||||
'<active_agent name="bundled-agent"/>',
|
||||
BASE,
|
||||
"# Environment",
|
||||
"You are a read-only search agent.",
|
||||
].map((s) => prompt.indexOf(s));
|
||||
expect(order.every((i) => i >= 0)).toBe(true);
|
||||
expect(order).toEqual([...order].sort((a, b) => a - b));
|
||||
});
|
||||
|
||||
it("keeps worktree and extras blocks", () => {
|
||||
const prompt = buildAgentPrompt(
|
||||
baseConfig("append"),
|
||||
"/wt/copy",
|
||||
env,
|
||||
undefined,
|
||||
{ worktreeBase: "/repo", memoryBlock: "# Agent Memory\nRemember this." },
|
||||
BASE,
|
||||
);
|
||||
expect(prompt).toContain("isolated git worktree copy of /repo");
|
||||
expect(prompt).toContain("# Agent Memory");
|
||||
expect(prompt).not.toContain("<sub_agent_context>");
|
||||
});
|
||||
|
||||
it("empty agent systemPrompt yields no agent_instructions in append mode", () => {
|
||||
const prompt = buildAgentPrompt(
|
||||
baseConfig("append", ""),
|
||||
"/workspace",
|
||||
env,
|
||||
"Parent.",
|
||||
undefined,
|
||||
BASE,
|
||||
);
|
||||
expect(prompt).not.toContain("<agent_instructions>");
|
||||
});
|
||||
|
||||
it("blank base keeps upstream composition byte-for-byte", () => {
|
||||
for (const promptMode of ["append", "replace"] as const) {
|
||||
const config = baseConfig(promptMode);
|
||||
for (const blank of [undefined, "", " \n "]) {
|
||||
expect(buildAgentPrompt(config, "/workspace", env, "Parent.", undefined, blank)).toBe(
|
||||
buildAgentPrompt(config, "/workspace", env, "Parent."),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,3 +136,26 @@ test("every package-local @earendil-works/* devDependency is pinned to latest",
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("subagent prompt profile: preamble non-blank, agent names match filename stems", async () => {
|
||||
const subagentDir = join(bundleConfigDir, "prompt", "subagent");
|
||||
const preamble = await readFile(join(subagentDir, "preamble.md"), "utf8");
|
||||
assert.ok(
|
||||
preamble.trim().length > 0,
|
||||
"config/prompt/subagent/preamble.md must not be blank",
|
||||
);
|
||||
const agentsDir = join(subagentDir, "agents");
|
||||
const agentFiles = (await readdir(agentsDir)).filter((name) => name.endsWith(".md"));
|
||||
assert.ok(agentFiles.length > 0, "config/prompt/subagent/agents/ must ship agent files");
|
||||
for (const file of agentFiles) {
|
||||
const raw = await readFile(join(agentsDir, file), "utf8");
|
||||
const frontmatter = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
assert.ok(frontmatter, `${file} must open with a frontmatter block`);
|
||||
const name = frontmatter[1].match(/^name:[ \t]*["']?([^"'\r\n]+?)["']?[ \t]*$/m)?.[1];
|
||||
assert.equal(
|
||||
name,
|
||||
file.slice(0, -".md".length),
|
||||
`config/prompt/subagent/agents/${file} frontmatter name must match its filename stem`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,19 +10,38 @@ import test from "node:test";
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const hasPi = spawnSync("pi", ["--version"], { encoding: "utf8" }).status === 0;
|
||||
|
||||
test("subagents source is retained but disabled in the root bundle", async () => {
|
||||
test("enabled subagents wrapper loads with its bundle-owned mandatory permission entry", { skip: !hasPi }, async () => {
|
||||
const packageJson = JSON.parse(await readFile(join(repositoryRoot, "package.json"), "utf8")) as {
|
||||
dependencies: Record<string, string>;
|
||||
files: string[];
|
||||
pi: { extensions: string[] };
|
||||
};
|
||||
assert.ok(packageJson.files.includes("pi-subagents"), "the bundle must retain the maintained subagents source");
|
||||
assert.equal(packageJson.dependencies["@tintinweb/pi-subagents"], undefined);
|
||||
assert.equal(
|
||||
assert.ok(packageJson.files.includes("pi-subagents"));
|
||||
assert.equal(packageJson.dependencies["@tintinweb/pi-subagents"], "file:./pi-subagents");
|
||||
assert.ok(
|
||||
packageJson.pi.extensions.includes("./extensions/subagents.ts"),
|
||||
false,
|
||||
"the bundle must not default-load the disabled subagents wrapper",
|
||||
"the bundle must default-load the locally maintained subagents wrapper",
|
||||
);
|
||||
|
||||
const home = await mkdtemp(join(tmpdir(), "my-pi-subagents-wrapper-load-"));
|
||||
const result = spawnSync(
|
||||
"pi",
|
||||
["--no-extensions", "-e", join(repositoryRoot, "extensions", "subagents.ts"), "--mode", "rpc"],
|
||||
{
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
XDG_CONFIG_HOME: join(home, ".config"),
|
||||
PI_CODING_AGENT_DIR: join(home, ".pi-agent"),
|
||||
PI_OFFLINE: "1",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /Failed to load extension/u);
|
||||
});
|
||||
|
||||
test("all package extensions load together without global registration conflicts", { skip: !hasPi }, async () => {
|
||||
@@ -117,6 +136,9 @@ test("all package extensions load together without global registration conflicts
|
||||
assert.equal(permissionConfig.permission.ssh_connect, "ask", "SSH connection must enter the permission gate");
|
||||
assert.equal(permissionConfig.permission.ssh_cd, "ask", "remote workspace changes must enter the permission gate");
|
||||
assert.deepEqual(permissionConfig.authorizerChain, ["auto-review"], "SSH connection asks must reach AutoReview");
|
||||
assert.equal(permissionConfig.permission.Agent, "allow", "launching a subagent is side-effect free; child actions stay gated");
|
||||
assert.equal(permissionConfig.permission.get_subagent_result, "allow");
|
||||
assert.equal(permissionConfig.permission.steer_subagent, "allow");
|
||||
assert.equal(hardGuardConfig.permission.path, undefined, "sensitive paths must not be hard-denied by default");
|
||||
assert.equal(hardGuardConfig.permission.bash["rm -rf /"], "deny");
|
||||
assert.equal(
|
||||
@@ -149,8 +171,18 @@ test("all package extensions load together without global registration conflicts
|
||||
"the packed bundle must include the locally maintained notification source",
|
||||
);
|
||||
assert.equal(packageJson.dependencies["@ogulcancelik/pi-minimal-footer"], "file:./pi-minimal-footer");
|
||||
assert.equal(packageJson.dependencies["@tintinweb/pi-subagents"], undefined);
|
||||
assert.equal(packageJson.pi.extensions.includes("./extensions/subagents.ts"), false);
|
||||
assert.equal(packageJson.dependencies["@tintinweb/pi-subagents"], "file:./pi-subagents");
|
||||
assert.ok(packageJson.pi.extensions.includes("./extensions/subagents.ts"));
|
||||
assert.ok(
|
||||
packageJson.pi.extensions.indexOf("./extensions/permission-system.ts") <
|
||||
packageJson.pi.extensions.indexOf("./extensions/subagents.ts"),
|
||||
"the permission service must load before the subagents wrapper injects it as a mandatory child extension",
|
||||
);
|
||||
assert.ok(
|
||||
packageJson.pi.extensions.indexOf("./extensions/subagents.ts") <
|
||||
packageJson.pi.extensions.indexOf("./pi-tool-search/extensions/index.ts"),
|
||||
"the subagent orchestration tools must register before Tool Search builds its catalog",
|
||||
);
|
||||
assert.ok(
|
||||
packageJson.pi.extensions.includes("./pi-hermes-memory/src/index.ts"),
|
||||
"the bundle must load the locally maintained Hermes memory extension",
|
||||
|
||||
+158
-1
@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
applyToolCatalog,
|
||||
isSubagentCustomPrompt,
|
||||
loadRoutingRules,
|
||||
loadSectionsConfig,
|
||||
loadToolCatalog,
|
||||
@@ -18,6 +19,8 @@ import {
|
||||
resolvePreamblePath,
|
||||
spliceCover,
|
||||
stripJsoncComments,
|
||||
validateRoutingConfig,
|
||||
validateSectionsConfig,
|
||||
type MutablePromptOptions,
|
||||
type RoutingRule,
|
||||
} from "../extensions/system-prompt-config.ts";
|
||||
@@ -74,6 +77,40 @@ test("config/prompt files load with the expected shapes", () => {
|
||||
"chrome_profile_bridge",
|
||||
"tool_routing",
|
||||
]);
|
||||
assert.deepEqual(sections.subagentExclude, ["memory_policy", "chrome_profile_bridge"]);
|
||||
});
|
||||
|
||||
test("routing scope validation: main/subagent accepted, other values rejected", () => {
|
||||
assert.doesNotThrow(() =>
|
||||
validateRoutingConfig({
|
||||
version: 1,
|
||||
rules: [
|
||||
{ text: "both" },
|
||||
{ scope: "main", text: "m" },
|
||||
{ scope: "subagent", text: "s" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.throws(
|
||||
() => validateRoutingConfig({ version: 1, rules: [{ scope: "everyone", text: "x" }] }),
|
||||
/scope must be "main" or "subagent"/,
|
||||
);
|
||||
});
|
||||
|
||||
test("sections.jsonc validation: subagentExclude defaults to [] and rejects bad shapes", () => {
|
||||
assert.deepEqual(validateSectionsConfig({ version: 1, order: ["tool_routing"] }), {
|
||||
version: 1,
|
||||
order: ["tool_routing"],
|
||||
subagentExclude: [],
|
||||
});
|
||||
assert.throws(
|
||||
() => validateSectionsConfig({ version: 1, order: [], subagentExclude: "memory_policy" }),
|
||||
/subagentExclude.*array of section names/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateSectionsConfig({ version: 1, order: [], subagentExclude: ["Bad Name"] }),
|
||||
/not a valid section name/,
|
||||
);
|
||||
});
|
||||
|
||||
test("readPreamble reads the bundle preamble from config/prompt/", () => {
|
||||
@@ -185,6 +222,40 @@ test("renderRouting honours whenAll, whenAny and unconditional rules", () => {
|
||||
assert.equal(renderRouting([], ["a"]), "");
|
||||
});
|
||||
|
||||
test("renderRouting filters scoped rules by audience, unscoped rules render for both", () => {
|
||||
const rules: RoutingRule[] = [
|
||||
{ text: "both audiences" },
|
||||
{ scope: "main", text: "main only" },
|
||||
{ scope: "subagent", text: "subagent only" },
|
||||
];
|
||||
|
||||
assert.equal(renderRouting(rules, undefined, "main"), "- both audiences\n- main only");
|
||||
assert.equal(renderRouting(rules, undefined, "subagent"), "- both audiences\n- subagent only");
|
||||
// The omitted-audience default stays "main" for callers that predate scopes.
|
||||
assert.equal(renderRouting(rules, undefined), "- both audiences\n- main only");
|
||||
});
|
||||
|
||||
test("isSubagentCustomPrompt matches the <active_agent> contract only on customPrompt", () => {
|
||||
assert.ok(isSubagentCustomPrompt('a\n<active_agent name="Explore"/>'));
|
||||
assert.ok(isSubagentCustomPrompt("<active_agent name='x'/>"));
|
||||
assert.ok(!isSubagentCustomPrompt("plain main-session prompt"));
|
||||
assert.ok(!isSubagentCustomPrompt(undefined));
|
||||
assert.ok(!isSubagentCustomPrompt(42));
|
||||
});
|
||||
|
||||
test("bundled ssh_connect rules differ per audience", () => {
|
||||
const routing = loadRoutingRules();
|
||||
const tools = ["ssh_connect", "ssh_bash"];
|
||||
|
||||
const main = renderRouting(routing.rules, tools, "main");
|
||||
assert.match(main, /the user explicitly names an imported host/);
|
||||
assert.doesNotMatch(main, /the delegated task explicitly names/);
|
||||
|
||||
const subagent = renderRouting(routing.rules, tools, "subagent");
|
||||
assert.match(subagent, /the delegated task explicitly names an imported host/);
|
||||
assert.doesNotMatch(subagent, /the user explicitly names an imported host/);
|
||||
});
|
||||
|
||||
test("bundled routing rules select the same guidance the retired tool-routing extension emitted", () => {
|
||||
const routing = loadRoutingRules();
|
||||
|
||||
@@ -275,12 +346,18 @@ test("spliceCover degenerates to the preamble when the prompt has no sections",
|
||||
|
||||
interface FakePromptOptions extends MutablePromptOptions {
|
||||
forceSystemPrompt?: string;
|
||||
customPrompt?: string;
|
||||
}
|
||||
|
||||
/** Simplified re-render of Pi's prompt from the mutable options object. */
|
||||
function renderPiPrompt(options: FakePromptOptions): string {
|
||||
if (options.forceSystemPrompt !== undefined) return options.forceSystemPrompt;
|
||||
const parts = ["PI DEFAULT PREAMBLE"];
|
||||
// Like the real host: a non-empty customPrompt owns the preamble section.
|
||||
const preamble =
|
||||
typeof options.customPrompt === "string" && options.customPrompt.length > 0
|
||||
? options.customPrompt
|
||||
: "PI DEFAULT PREAMBLE";
|
||||
const parts = [preamble];
|
||||
const snippets = Object.entries(options.toolSnippets ?? {});
|
||||
if (snippets.length > 0) {
|
||||
parts.push(`<tools>\n${snippets.map(([n, s]) => `- ${n}: ${s}`).join("\n")}\n</tools>`);
|
||||
@@ -378,6 +455,86 @@ test("composer degrades to text splicing when an earlier extension froze the pro
|
||||
);
|
||||
});
|
||||
|
||||
test("composer keeps a customPrompt-owned preamble (subagent / SYSTEM.md) intact", () => {
|
||||
const composer = createSystemPromptComposer();
|
||||
const customPrompt = 'You are the Explore agent.\n<active_agent name="Explore"/>';
|
||||
const options: FakePromptOptions = {
|
||||
customPrompt,
|
||||
selectedTools: ["read"],
|
||||
toolSnippets: { read: "reads files" },
|
||||
};
|
||||
const { ctx } = makeUiCtx();
|
||||
const event = {
|
||||
systemPromptOptions: options,
|
||||
systemPrompt: `${customPrompt}\n\n<tools>\n- read: reads files\n</tools>`,
|
||||
};
|
||||
|
||||
const result = composer.handleBeforeAgentStart(event as never, ctx);
|
||||
const output = result?.systemPrompt ?? "";
|
||||
|
||||
assert.ok(output.startsWith(customPrompt), "the agent's own preamble is kept verbatim");
|
||||
assert.ok(
|
||||
output.includes('<active_agent name="Explore"/>'),
|
||||
"the <active_agent> identity the permission system reads survives",
|
||||
);
|
||||
assert.ok(
|
||||
!output.includes(BUNDLE_PREAMBLE),
|
||||
"the bundle preamble must not be spliced over a customPrompt-owned preamble",
|
||||
);
|
||||
assert.match(output, /<tool_routing>/, "routing still degrades to an appended section");
|
||||
});
|
||||
|
||||
test("composer treats an <active_agent> customPrompt as a subagent session", () => {
|
||||
const composer = createSystemPromptComposer();
|
||||
const customPrompt =
|
||||
'You are a subagent.\n<active_agent name="Explore"/>\n# Environment\nWorking directory: /work';
|
||||
const options: FakePromptOptions = {
|
||||
customPrompt,
|
||||
selectedTools: ["read", "ssh_connect"],
|
||||
toolSnippets: { read: "reads files", ssh_connect: "connects" },
|
||||
sections: {
|
||||
memory_policy: "memory guidance for main sessions",
|
||||
chrome_profile_bridge: "chrome bridge preamble",
|
||||
remote_ssh: "remote ssh preamble",
|
||||
},
|
||||
};
|
||||
const { ctx } = makeUiCtx();
|
||||
|
||||
const result = composer.handleBeforeAgentStart(makeEvent(options) as never, ctx);
|
||||
const output = result?.systemPrompt ?? "";
|
||||
|
||||
assert.ok(output.startsWith(customPrompt), "the child preamble stays untouched");
|
||||
assert.doesNotMatch(output, /<memory_policy>/, "subagentExclude removes memory_policy");
|
||||
assert.doesNotMatch(output, /<chrome_profile_bridge>/, "subagentExclude removes chrome_profile_bridge");
|
||||
assert.match(output, /<remote_ssh>/, "unexcluded sections are kept");
|
||||
assert.match(output, /the delegated task explicitly names an imported host/, "subagent-scope routing renders");
|
||||
assert.doesNotMatch(output, /the user explicitly names an imported host/, "main-scope routing stays out");
|
||||
});
|
||||
|
||||
test("a main session keeps excluded sections and main-scope routing even with a literal <active_agent> tag", () => {
|
||||
const composer = createSystemPromptComposer();
|
||||
// The tag appears inside a contributed section (project docs can carry the
|
||||
// literal text) but there is no customPrompt — this is a main session.
|
||||
const options: FakePromptOptions = {
|
||||
selectedTools: ["read", "ssh_connect"],
|
||||
toolSnippets: { read: "reads files", ssh_connect: "connects" },
|
||||
sections: {
|
||||
memory_policy: 'docs mention <active_agent name="Guide"/>',
|
||||
chrome_profile_bridge: "chrome bridge preamble",
|
||||
},
|
||||
};
|
||||
const { ctx } = makeUiCtx();
|
||||
|
||||
const result = composer.handleBeforeAgentStart(makeEvent(options) as never, ctx);
|
||||
const output = result?.systemPrompt ?? "";
|
||||
|
||||
assert.ok(output.startsWith(BUNDLE_PREAMBLE), "the bundle preamble still splices for a main session");
|
||||
assert.match(output, /<memory_policy>/, "main sessions are unaffected by subagentExclude");
|
||||
assert.match(output, /<chrome_profile_bridge>/);
|
||||
assert.match(output, /the user explicitly names an imported host/, "main-scope routing renders");
|
||||
assert.doesNotMatch(output, /the delegated task explicitly names/, "subagent-scope routing stays out");
|
||||
});
|
||||
|
||||
test("composer falls back to text appends when the host exposes no prompt options", () => {
|
||||
const composer = createSystemPromptComposer();
|
||||
const { ctx } = makeUiCtx();
|
||||
|
||||
Reference in New Issue
Block a user