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-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,默认以最多 5 个动态组、28 个动态工具的 LRU 策略加载原始完整 schema,并支持原子批量激活。
|
||||||
- 本仓库维护的 `pi-chrome`:从上游 `0.15.46` 源码导入,通过显式会话授权接入用户当前已登录的 Chrome Profile;大体积页面快照写入 Session 临时文件并交给 Context Mode 隔离分析,21 个 `chrome_*` 工具仍由 Tool Search 按工作流加载。
|
- 本仓库维护的 `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-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 复核的连接。
|
- 本仓库维护的 `pi-ssh`:通过纯 Node `ssh2` 持久连接、显式 `ssh_cd` 远端工作区切换、SFTP 与自适应有界搜索提供独立的远端工具;Agent 只在用户明确指定已导入主机及具体任务后发起受 AutoReview 复核的连接。
|
||||||
- `extensions/system-prompt.ts`:组合包唯一的系统提示词出口(排在最后加载),按 `config/prompt/` 组合开场白、工具简介与跨工具路由规则;提供 `/dump-system-prompt` 导出当前有效提示词。
|
- `extensions/system-prompt.ts`:组合包唯一的系统提示词出口(排在最后加载),按 `config/prompt/` 组合开场白、工具简介与跨工具路由规则;提供 `/dump-system-prompt` 导出当前有效提示词。
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,12 @@
|
|||||||
"ctx_doctor": "allow",
|
"ctx_doctor": "allow",
|
||||||
"tool_search": "allow",
|
"tool_search": "allow",
|
||||||
"ask_user_question": "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",
|
"web_search": "allow",
|
||||||
"chrome_snapshot": "allow",
|
"chrome_snapshot": "allow",
|
||||||
"chrome_find": "allow",
|
"chrome_find": "allow",
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
// <tool_routing> section. A rule is included when every tool in "whenAll"
|
// <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
|
// and at least one tool in "whenAny" is active; a rule with neither is
|
||||||
// always included. Rules render in file order as "- <text>".
|
// 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,
|
"version": 1,
|
||||||
"rules": [
|
"rules": [
|
||||||
{
|
{
|
||||||
@@ -27,8 +29,14 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"whenAny": ["ssh_connect"],
|
"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."
|
"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"],
|
"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."
|
"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
|
// systemPromptOptions.sections. extensions/system-prompt.ts reorders the
|
||||||
// contributed sections to this list; contributed sections not listed here
|
// contributed sections to this list; contributed sections not listed here
|
||||||
// keep their contribution order after the listed ones.
|
// 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,
|
"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 { fileURLToPath } from "node:url";
|
||||||
import { createPiSubagentsExtension } from "../pi-subagents/src/index.ts";
|
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
|
// Bundle authority: Agent frontmatter may disable optional extensions, but it
|
||||||
// cannot remove the exact wrapper that deploys my-pi's permission baseline and
|
// 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),
|
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({
|
export default createPiSubagentsExtension({
|
||||||
mandatoryExtensionPaths: [BUNDLED_PERMISSION_EXTENSION],
|
mandatoryExtensionPaths: [BUNDLED_PERMISSION_EXTENSION],
|
||||||
|
basePrompt: readSubagentPreamble,
|
||||||
|
bundledAgentsDir: resolveSubagentAgentsDir(),
|
||||||
|
excludedExtensionPaths: [ASK_USER_EXTENSION],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface ToolCatalog {
|
|||||||
export interface RoutingRule {
|
export interface RoutingRule {
|
||||||
whenAll?: string[];
|
whenAll?: string[];
|
||||||
whenAny?: string[];
|
whenAny?: string[];
|
||||||
|
/** Which sessions the rule renders for; absent means both. */
|
||||||
|
scope?: "main" | "subagent";
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +33,8 @@ export interface RoutingConfig {
|
|||||||
export interface SectionsConfig {
|
export interface SectionsConfig {
|
||||||
version: 1;
|
version: 1;
|
||||||
order: string[];
|
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));
|
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
|
* Strip `//` and `/* ... *\/` comments while respecting string literals, so a
|
||||||
* `//` inside a quoted value (for example `"https://…"`) is never removed.
|
* `//` inside a quoted value (for example `"https://…"`) is never removed.
|
||||||
@@ -185,7 +197,7 @@ function validateToolCatalog(raw: unknown): ToolCatalog {
|
|||||||
return { version: 1, tools };
|
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 {
|
function validateRoutingRule(index: number, raw: unknown): RoutingRule {
|
||||||
if (!isPlainObject(raw)) {
|
if (!isPlainObject(raw)) {
|
||||||
@@ -205,10 +217,13 @@ function validateRoutingRule(index: number, raw: unknown): RoutingRule {
|
|||||||
if ("whenAny" in raw && !isStringArray(raw.whenAny)) {
|
if ("whenAny" in raw && !isStringArray(raw.whenAny)) {
|
||||||
fail("routing.jsonc", `rule #${index}.whenAny must be an array of strings`);
|
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;
|
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)) {
|
if (!isPlainObject(raw) || raw.version !== 1 || !Array.isArray(raw.rules)) {
|
||||||
fail("routing.jsonc", 'expected { "version": 1, "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_-]*$/;
|
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)) {
|
if (!isPlainObject(raw) || raw.version !== 1 || !isStringArray(raw.order)) {
|
||||||
fail("sections.jsonc", 'expected { "version": 1, "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`);
|
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 {
|
export function loadToolCatalog(): ToolCatalog {
|
||||||
@@ -262,6 +289,34 @@ export function readPreamble(): string | undefined {
|
|||||||
return trimmed.length > 0 ? trimmed : 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
|
* Apply the tool catalog to the mutable prompt options: a `snippet` string
|
||||||
* replaces the tool's `<tools>` line (`null` hides it), `guidelines` replaces
|
* 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
|
* `whenAny` tool is active (a rule with neither always applies). An absent
|
||||||
* `selectedTools` means every tool counts as active, matching the previous
|
* `selectedTools` means every tool counts as active, matching the previous
|
||||||
* tool-routing behavior.
|
* 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 isActive = (name: string) => selectedTools === undefined || selectedTools.includes(name);
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
for (const rule of rules) {
|
for (const rule of rules) {
|
||||||
|
if (rule.scope !== undefined && rule.scope !== audience) continue;
|
||||||
if (rule.whenAll !== undefined && !rule.whenAll.every(isActive)) continue;
|
if (rule.whenAll !== undefined && !rule.whenAll.every(isActive)) continue;
|
||||||
if (rule.whenAny !== undefined && !rule.whenAny.some(isActive)) continue;
|
if (rule.whenAny !== undefined && !rule.whenAny.some(isActive)) continue;
|
||||||
lines.push(`- ${rule.text}`);
|
lines.push(`- ${rule.text}`);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { dirname, resolve } from "node:path";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
applyToolCatalog,
|
applyToolCatalog,
|
||||||
|
isSubagentCustomPrompt,
|
||||||
loadRoutingRules,
|
loadRoutingRules,
|
||||||
loadSectionsConfig,
|
loadSectionsConfig,
|
||||||
loadToolCatalog,
|
loadToolCatalog,
|
||||||
@@ -23,8 +24,11 @@ export function getSystemPromptDumpPath(cwd: string): string {
|
|||||||
return resolve(cwd, DUMP_RELATIVE_PATH);
|
return resolve(cwd, DUMP_RELATIVE_PATH);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The options object as Pi 1.3 normalizes it; the vendored types predate `sections`/`forceSystemPrompt`. */
|
/** The options object as Pi 1.3 normalizes it; `MutablePromptOptions` omits `forceSystemPrompt`/`customPrompt`. */
|
||||||
type RuntimePromptOptions = MutablePromptOptions & { forceSystemPrompt?: string };
|
type RuntimePromptOptions = MutablePromptOptions & {
|
||||||
|
forceSystemPrompt?: string;
|
||||||
|
customPrompt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Injectable seams so the composer can be exercised under `node --test`
|
* Injectable seams so the composer can be exercised under `node --test`
|
||||||
@@ -105,6 +109,12 @@ export function createSystemPromptComposer(deps: SystemPromptComposerDeps = {}):
|
|||||||
return undefined;
|
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 = "";
|
let routingText = "";
|
||||||
if (options) {
|
if (options) {
|
||||||
const missing = applyToolCatalog(options, catalog);
|
const missing = applyToolCatalog(options, catalog);
|
||||||
@@ -115,14 +125,19 @@ export function createSystemPromptComposer(deps: SystemPromptComposerDeps = {}):
|
|||||||
ctx.ui.notify(`以下工具未在 config/prompt/tools.jsonc 登记:${missing.join(", ")}`, "warning");
|
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 ??= {};
|
options.sections ??= {};
|
||||||
|
if (isSubagent) {
|
||||||
|
for (const name of sections.subagentExclude) {
|
||||||
|
delete options.sections[name];
|
||||||
|
}
|
||||||
|
}
|
||||||
if (routingText.length > 0) {
|
if (routingText.length > 0) {
|
||||||
options.sections.tool_routing = routingText;
|
options.sections.tool_routing = routingText;
|
||||||
}
|
}
|
||||||
options.sections = orderSections(options.sections, sections.order);
|
options.sections = orderSections(options.sections, sections.order);
|
||||||
} else {
|
} else {
|
||||||
routingText = renderRouting(routing.rules, undefined);
|
routingText = renderRouting(routing.rules, undefined, audience);
|
||||||
}
|
}
|
||||||
|
|
||||||
let rendered = event.systemPrompt;
|
let rendered = event.systemPrompt;
|
||||||
@@ -132,7 +147,11 @@ export function createSystemPromptComposer(deps: SystemPromptComposerDeps = {}):
|
|||||||
rendered += `\n\n<tool_routing>\n${routingText}\n</tool_routing>`;
|
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;
|
lastComposed = final;
|
||||||
return { systemPrompt: 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-minimal-footer/index.ts",
|
||||||
"./pi-notify/src/index.ts",
|
"./pi-notify/src/index.ts",
|
||||||
"./extensions/permission-system.ts",
|
"./extensions/permission-system.ts",
|
||||||
|
"./extensions/subagents.ts",
|
||||||
"./pi-ssh/index.ts",
|
"./pi-ssh/index.ts",
|
||||||
"./pi-ask-user/index.ts",
|
"./pi-ask-user/index.ts",
|
||||||
"./pi-worktree/index.ts",
|
"./pi-worktree/index.ts",
|
||||||
@@ -76,6 +77,7 @@
|
|||||||
"@gotgenes/pi-permission-system": "file:./pi-permission-system",
|
"@gotgenes/pi-permission-system": "file:./pi-permission-system",
|
||||||
"@ogulcancelik/pi-minimal-footer": "file:./pi-minimal-footer",
|
"@ogulcancelik/pi-minimal-footer": "file:./pi-minimal-footer",
|
||||||
"@smoose/pi-notify": "file:./pi-notify",
|
"@smoose/pi-notify": "file:./pi-notify",
|
||||||
|
"@tintinweb/pi-subagents": "file:./pi-subagents",
|
||||||
"context-mode": "1.0.169",
|
"context-mode": "1.0.169",
|
||||||
"jiti": "2.7.0",
|
"jiti": "2.7.0",
|
||||||
"pi-ask-user": "file:./pi-ask-user",
|
"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
|
### 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.
|
- **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
|
### Added
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { MemoryStore } from "../store/memory-store.js";
|
|||||||
import { DatabaseManager } from "../store/db.js";
|
import { DatabaseManager } from "../store/db.js";
|
||||||
import type { MemoryConfig } from "../types.js";
|
import type { MemoryConfig } from "../types.js";
|
||||||
import type { EnsureMemoryReady } from "../memory-initialization.js";
|
import type { EnsureMemoryReady } from "../memory-initialization.js";
|
||||||
|
import { isSubagentSession } from "../subagent-mode.js";
|
||||||
import { applyRecentMessageLimit, collectMessageParts } from "./message-parts.js";
|
import { applyRecentMessageLimit, collectMessageParts } from "./message-parts.js";
|
||||||
import { execChildPrompt, resolveChildPiModel } from "./pi-child-process.js";
|
import { execChildPrompt, resolveChildPiModel } from "./pi-child-process.js";
|
||||||
import { REVIEW_COMPLETION_TIMEOUT_MS, runDirectMemoryCompletion, usesDirectTransport, type DirectReviewResult } from "./review-memory-ops.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) => {
|
pi.on("turn_end", async (event, ctx) => {
|
||||||
|
if (isSubagentSession(ctx)) return;
|
||||||
turnsSinceReview++;
|
turnsSinceReview++;
|
||||||
|
|
||||||
if (!config.reviewEnabled) return;
|
if (!config.reviewEnabled) return;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from "../constants.js";
|
} from "../constants.js";
|
||||||
import type { MemoryConfig } from "../types.js";
|
import type { MemoryConfig } from "../types.js";
|
||||||
import type { EnsureMemoryReady } from "../memory-initialization.js";
|
import type { EnsureMemoryReady } from "../memory-initialization.js";
|
||||||
|
import { isSubagentSession } from "../subagent-mode.js";
|
||||||
import { getMessageText } from "../types.js";
|
import { getMessageText } from "../types.js";
|
||||||
import { execChildPrompt, resolveChildPiModel } from "./pi-child-process.js";
|
import { execChildPrompt, resolveChildPiModel } from "./pi-child-process.js";
|
||||||
import { resolveProjectName, resolveProjectStore, type ProjectNameRef, type ProjectStoreRef } from "../project-context.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)
|
// Trigger on turn_end (we need full context: user correction + what agent said)
|
||||||
pi.on("turn_end", async (event, ctx) => {
|
pi.on("turn_end", async (event, ctx) => {
|
||||||
|
if (isSubagentSession(ctx)) return;
|
||||||
if (!pendingCorrection) {
|
if (!pendingCorrection) {
|
||||||
turnsSinceLastCorrection++;
|
turnsSinceLastCorrection++;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
import type { MemoryConfig } from "../types.js";
|
import type { MemoryConfig } from "../types.js";
|
||||||
import type { EnsureMemoryReady } from "../memory-initialization.js";
|
import type { EnsureMemoryReady } from "../memory-initialization.js";
|
||||||
import { measureLifecycle } from "../lifecycle-timing.js";
|
import { measureLifecycle } from "../lifecycle-timing.js";
|
||||||
|
import { isSubagentSession } from "../subagent-mode.js";
|
||||||
import { collectMessageParts } from "./message-parts.js";
|
import { collectMessageParts } from "./message-parts.js";
|
||||||
import { execChildPrompt, resolveChildPiModel } from "./pi-child-process.js";
|
import { execChildPrompt, resolveChildPiModel } from "./pi-child-process.js";
|
||||||
import { runDirectMemoryCompletion, usesDirectTransport } from "./review-memory-ops.js";
|
import { runDirectMemoryCompletion, usesDirectTransport } from "./review-memory-ops.js";
|
||||||
@@ -167,6 +168,7 @@ export function setupSessionFlush(
|
|||||||
kind: FlushKind,
|
kind: FlushKind,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
if (isSubagentSession(ctx)) return;
|
||||||
if (userTurnCount < config.flushMinTurns) return;
|
if (userTurnCount < config.flushMinTurns) return;
|
||||||
|
|
||||||
let entries;
|
let entries;
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import { AGENT_ROOT } from "./paths.js";
|
|||||||
import { isDatabaseMigrationPending } from "./extension-root-migration.js";
|
import { isDatabaseMigrationPending } from "./extension-root-migration.js";
|
||||||
import { measureLifecycle, measureLifecycleSync } from "./lifecycle-timing.js";
|
import { measureLifecycle, measureLifecycleSync } from "./lifecycle-timing.js";
|
||||||
import { createMemoryInitializer, withMemoryInitialization, type EnsureMemoryReady } from "./memory-initialization.js";
|
import { createMemoryInitializer, withMemoryInitialization, type EnsureMemoryReady } from "./memory-initialization.js";
|
||||||
|
import { isSubagentSession, markSubagentSession, markSubagentSessionFromPrompt } from "./subagent-mode.js";
|
||||||
|
|
||||||
export function resolveProjectSkillDiscovery(
|
export function resolveProjectSkillDiscovery(
|
||||||
skillStore: SkillStore,
|
skillStore: SkillStore,
|
||||||
@@ -195,8 +196,12 @@ export default function (pi: ExtensionAPI) {
|
|||||||
|
|
||||||
const initialization = createMemoryInitializer(async (ctx) => {
|
const initialization = createMemoryInitializer(async (ctx) => {
|
||||||
const timingPrefix = lazy ? "memory-init" : "session-start";
|
const timingPrefix = lazy ? "memory-init" : "session-start";
|
||||||
if (lazy) migrateLegacyProjectMemoryDirs(agentRoot, config.projectsMemoryDir);
|
// Subagent sessions are read-only: skip the write side of initialization
|
||||||
if (!persistenceInitialized) {
|
// (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 {
|
try {
|
||||||
await measureLifecycle(`${timingPrefix}.persistence-sync`, async () => {
|
await measureLifecycle(`${timingPrefix}.persistence-sync`, async () => {
|
||||||
// Startup reconciles the global files plus the current project only:
|
// 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.
|
// Skills and pinned instructions must be available even without a lookup.
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
sessionContext = 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.
|
// Pinned directives must not depend on migration/SQLite being healthy.
|
||||||
if (standingStore) await standingStore.load();
|
if (standingStore) await standingStore.load();
|
||||||
if (!lazy) await ensureMemoryReady(ctx);
|
if (!lazy) await ensureMemoryReady(ctx);
|
||||||
refreshSkillProjectContext(ctx.cwd);
|
refreshSkillProjectContext(ctx.cwd);
|
||||||
|
if (!isSubagentSession(ctx)) {
|
||||||
await skillStore.migrateLegacySkills();
|
await skillStore.migrateLegacySkills();
|
||||||
await skillStore.ensureDiscoveredRoots();
|
await skillStore.ensureDiscoveredRoots();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
registerProjectSkillDiscoveryHandler(pi, skillStore, config.projectsMemoryDir);
|
registerProjectSkillDiscoveryHandler(pi, skillStore, config.projectsMemoryDir);
|
||||||
|
|
||||||
// ── 2. Inject memory policy by default; legacy mode keeps full frozen memory blocks ──
|
// ── 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);
|
const promptContext = await buildPromptContext(config, store, projectStoreRef(), projectNameRef(), standingStore);
|
||||||
if (!promptContext) return;
|
if (!promptContext) return;
|
||||||
|
|
||||||
@@ -400,6 +412,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
|
|
||||||
// ── 10. Live session indexing ──
|
// ── 10. Live session indexing ──
|
||||||
pi.on("message_end", async (_event, ctx) => {
|
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.
|
// Cold lazy sessions remain in Pi's JSONL files and are backfilled on use.
|
||||||
if (lazy && !initialization.isReady()) return;
|
if (lazy && !initialization.isReady()) return;
|
||||||
scheduleLiveSessionIndex(dbManager, ctx.sessionManager, {
|
scheduleLiveSessionIndex(dbManager, ctx.sessionManager, {
|
||||||
@@ -432,6 +445,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
if (isSubagentSession(ctx)) return;
|
||||||
measureLifecycleSync("shutdown.active-index", () => {
|
measureLifecycleSync("shutdown.active-index", () => {
|
||||||
const sessionFile = ctx.sessionManager.getSessionFile();
|
const sessionFile = ctx.sessionManager.getSessionFile();
|
||||||
if (sessionFile && fs.existsSync(sessionFile)) {
|
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 { normalizeMemoryLookupText } from "../store/memory-lookup.js";
|
||||||
import { createSharedToolResultRenderer } from "./shared-output-view.js";
|
import { createSharedToolResultRenderer } from "./shared-output-view.js";
|
||||||
import { memoryResultView } from "./tool-result-views.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 {
|
function appendSyncWarning(result: MemoryResult, warning: string): MemoryResult {
|
||||||
const warnings = [...(((result as any).warnings ?? []) as string[]), warning];
|
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),
|
renderResult: createSharedToolResultRenderer(memoryResultView),
|
||||||
parameters,
|
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) {
|
if (bindProjectFromCwd && ctx?.cwd) {
|
||||||
await 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 { SKILL_TOOL_DESCRIPTION } from "../constants.js";
|
||||||
import { createSharedToolResultRenderer } from "./shared-output-view.js";
|
import { createSharedToolResultRenderer } from "./shared-output-view.js";
|
||||||
import { skillResultView } from "./tool-result-views.js";
|
import { skillResultView } from "./tool-result-views.js";
|
||||||
|
import { isSubagentSession, SUBAGENT_WRITE_DISABLED_MESSAGE } from "../subagent-mode.js";
|
||||||
|
|
||||||
function normalizeTextList(value: unknown): string[] {
|
function normalizeTextList(value: unknown): string[] {
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
@@ -90,6 +91,13 @@ const SKILL_TOOL_PARAMETERS = Type.Object({
|
|||||||
export const SKILL_MANAGE_TOOL_NAME = "skill_manage";
|
export const SKILL_MANAGE_TOOL_NAME = "skill_manage";
|
||||||
|
|
||||||
export function registerSkillTool(pi: ExtensionAPI, store: SkillStore): void {
|
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({
|
pi.registerTool({
|
||||||
name: SKILL_MANAGE_TOOL_NAME,
|
name: SKILL_MANAGE_TOOL_NAME,
|
||||||
label: "Skill Manager",
|
label: "Skill Manager",
|
||||||
@@ -108,6 +116,12 @@ export function registerSkillTool(pi: ExtensionAPI, store: SkillStore): void {
|
|||||||
renderResult: createSharedToolResultRenderer(skillResultView),
|
renderResult: createSharedToolResultRenderer(skillResultView),
|
||||||
parameters: SKILL_TOOL_PARAMETERS,
|
parameters: SKILL_TOOL_PARAMETERS,
|
||||||
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
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 {
|
const skillParams = params as {
|
||||||
action: "create" | "view" | "patch" | "update" | "edit" | "delete";
|
action: "create" | "view" | "patch" | "update" | "edit" | "delete";
|
||||||
name?: string;
|
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.
|
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
|
## 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.
|
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.
|
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); }
|
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 {
|
export class AgentManager {
|
||||||
private agents = new Map<string, AgentRecord>();
|
private agents = new Map<string, AgentRecord>();
|
||||||
@@ -248,6 +255,9 @@ export class AgentManager {
|
|||||||
private onUsage?: OnAgentUsage;
|
private onUsage?: OnAgentUsage;
|
||||||
private maxConcurrent: number;
|
private maxConcurrent: number;
|
||||||
private readonly mandatoryExtensionPaths: readonly string[];
|
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,
|
/** Base repos worktrees were created from — so dispose() can prune them all,
|
||||||
* not just the parent repo (caller-supplied cwd can target other repos). */
|
* not just the parent repo (caller-supplied cwd can target other repos). */
|
||||||
private worktreeRepos = new Set<string>();
|
private worktreeRepos = new Set<string>();
|
||||||
@@ -281,6 +291,11 @@ export class AgentManager {
|
|||||||
this.mandatoryExtensionPaths = normalizeMandatoryExtensionPaths(
|
this.mandatoryExtensionPaths = normalizeMandatoryExtensionPaths(
|
||||||
options.mandatoryExtensionPaths,
|
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)
|
// Cleanup completed agents after 10 minutes (but keep sessions for resume)
|
||||||
this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
|
this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
|
||||||
this.cleanupInterval.unref();
|
this.cleanupInterval.unref();
|
||||||
@@ -442,6 +457,9 @@ export class AgentManager {
|
|||||||
// rootSessionId; programmatic callers fall back to their current context.
|
// rootSessionId; programmatic callers fall back to their current context.
|
||||||
authoritySessionId: record.rootSessionId ?? ctx.sessionManager?.getSessionId?.(),
|
authoritySessionId: record.rootSessionId ?? ctx.sessionManager?.getSessionId?.(),
|
||||||
mandatoryExtensionPaths: this.mandatoryExtensionPaths,
|
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 —
|
// 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
|
// 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
|
// 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 { detectEnv } from "./env.js";
|
||||||
import {
|
import {
|
||||||
assertMandatoryExtensionsLoaded,
|
assertMandatoryExtensionsLoaded,
|
||||||
|
extensionPathMatches,
|
||||||
isMandatoryExtensionPath,
|
isMandatoryExtensionPath,
|
||||||
normalizeMandatoryExtensionPaths,
|
normalizeMandatoryExtensionPaths,
|
||||||
} from "./mandatory-extensions.js";
|
} from "./mandatory-extensions.js";
|
||||||
@@ -432,6 +433,21 @@ export interface RunOptions {
|
|||||||
authoritySessionId?: string;
|
authoritySessionId?: string;
|
||||||
/** Canonical bundle-owned extension entries that Agent config cannot disable. */
|
/** Canonical bundle-owned extension entries that Agent config cannot disable. */
|
||||||
mandatoryExtensionPaths?: readonly string[];
|
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). */
|
/** Override working directory (e.g. for worktree isolation). */
|
||||||
cwd?: string;
|
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;
|
let systemPrompt: string;
|
||||||
if (agentConfig) {
|
if (agentConfig) {
|
||||||
systemPrompt = buildAgentPrompt(agentConfig, effectiveCwd, env, parentSystemPrompt, extras);
|
systemPrompt = buildAgentPrompt(agentConfig, effectiveCwd, env, parentSystemPrompt, extras, basePrompt);
|
||||||
} else {
|
} else {
|
||||||
// Unknown type fallback: spread the canonical general-purpose config (defensive —
|
// Unknown type fallback: spread the canonical general-purpose config (defensive —
|
||||||
// unreachable in practice since index.ts resolves unknown types before calling runAgent).
|
// unreachable in practice since index.ts resolves unknown types before calling runAgent).
|
||||||
const fallback = DEFAULT_AGENTS.get("general-purpose");
|
const fallback = DEFAULT_AGENTS.get("general-purpose");
|
||||||
if (!fallback) throw new Error(`No fallback config available for unknown type "${type}"`);
|
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,
|
// Child permission policy is selected by the agent-definition filename,
|
||||||
// which is also where pi-permission-system reads `permission:` frontmatter.
|
// which is also where pi-permission-system reads `permission:` frontmatter.
|
||||||
@@ -701,6 +719,11 @@ export async function runAgent(
|
|||||||
);
|
);
|
||||||
const optionalExtensionsDisabled = extensions === false;
|
const optionalExtensionsDisabled = extensions === false;
|
||||||
const mandatoryExtensionPaths = normalizeMandatoryExtensionPaths(options.mandatoryExtensionPaths);
|
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 noExtensions = optionalExtensionsDisabled && mandatoryExtensionPaths.length === 0;
|
||||||
|
|
||||||
const extensionsSpec = Array.isArray(extensions)
|
const extensionsSpec = Array.isArray(extensions)
|
||||||
@@ -718,10 +741,12 @@ export async function runAgent(
|
|||||||
if (canons.some((name) => excludeNames.has(name))) return false;
|
if (canons.some((name) => excludeNames.has(name))) return false;
|
||||||
return loadAll || canons.some((name) => keepNames.has(name));
|
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 = [
|
const requestedPaths = [
|
||||||
...(extensionsSpec?.paths ?? []),
|
...(extensionsSpec?.paths ?? []),
|
||||||
...mandatoryExtensionPaths,
|
...mandatoryExtensionPaths,
|
||||||
];
|
].filter((path) => !isExcludedExtension(path));
|
||||||
const additionalExtensionPaths = requestedPaths.length
|
const additionalExtensionPaths = requestedPaths.length
|
||||||
? [...new Set(requestedPaths)]
|
? [...new Set(requestedPaths)]
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -729,16 +754,25 @@ export async function runAgent(
|
|||||||
// against discovery, while mandatory identity is checked by exact canonical path.
|
// against discovery, while mandatory identity is checked by exact canonical path.
|
||||||
let discoveredNames: Set<string> | undefined;
|
let discoveredNames: Set<string> | undefined;
|
||||||
const extensionsOverride: ((base: LoadExtensionsResult) => LoadExtensionsResult) | undefined =
|
const extensionsOverride: ((base: LoadExtensionsResult) => LoadExtensionsResult) | undefined =
|
||||||
noExtensions || (loadAll && !hasExcludes)
|
(noExtensions || (loadAll && !hasExcludes)) && excludedExtensionPaths.length === 0
|
||||||
? undefined
|
? undefined
|
||||||
: (base) => {
|
: (base) => {
|
||||||
discoveredNames = new Set(base.extensions.flatMap((e) => extensionCanonicalNames(e.path)));
|
discoveredNames = new Set(base.extensions.flatMap((e) => extensionCanonicalNames(e.path)));
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
extensions: base.extensions.filter((extension) =>
|
extensions: base.extensions.filter((extension) => {
|
||||||
isMandatoryExtensionPath(extension.path, mandatoryExtensionPaths)
|
if (isExcludedExtension(extension.path)) {
|
||||||
|| normalPolicySelects(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,
|
maxSubagentDepth: effectiveMaxDepth,
|
||||||
allowedSubagents: agentConfig.allowedSubagents,
|
allowedSubagents: agentConfig.allowedSubagents,
|
||||||
configCwd,
|
configCwd,
|
||||||
|
bundledAgentsDir: options.bundledAgentsDir,
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
const nestedToolNames = new Set(nestedTools.map(tool => tool.name));
|
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),
|
* 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
|
* Pure — callers that must not disturb the process-wide registry (nested
|
||||||
* delegation resolving agents from its own config root) build their own map.
|
* 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>();
|
const registry = new Map<string, AgentConfig>();
|
||||||
if (!disableDefaults) {
|
if (!disableDefaults) {
|
||||||
for (const [name, config] of DEFAULT_AGENTS) registry.set(name, config);
|
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);
|
for (const [name, config] of userAgents) registry.set(name, config);
|
||||||
return registry;
|
return registry;
|
||||||
@@ -70,12 +76,16 @@ export function buildAgentRegistry(userAgents: Map<string, AgentConfig>): Map<st
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Register agents into the unified registry.
|
* 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.
|
* 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();
|
agents.clear();
|
||||||
for (const [name, config] of buildAgentRegistry(userAgents)) {
|
for (const [name, config] of buildAgentRegistry(userAgents, bundledAgents)) {
|
||||||
agents.set(name, config);
|
agents.set(name, config);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,32 +79,60 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
|
|||||||
}
|
}
|
||||||
const { frontmatter: fm, body } = parsed;
|
const { frontmatter: fm, body } = parsed;
|
||||||
|
|
||||||
// Claude Code's rule: `name:` IS the agent type, and the filename need not
|
// A reserved-name skip carries no `warnSkippedOverride`: this file would
|
||||||
// match. Absent, the filename stands in — Claude Code requires the field,
|
// have registered under its *declared* name, which nothing else can hold
|
||||||
// but most files here predate it and must keep loading.
|
// (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();
|
const declared = str(fm.name)?.trim();
|
||||||
if (declared?.includes(RESERVED_IN_TYPE)) {
|
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(
|
warnIfNew(
|
||||||
`Agent file ${path} declares name "${declared}", which contains "${RESERVED_IN_TYPE}" — reserved for `
|
`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.",
|
+ "plugin-scoped identifiers. Rename it, or move the label to `display_name:`. Skipping.",
|
||||||
);
|
);
|
||||||
// No `warnSkippedOverride`: this file would have registered under its
|
return undefined;
|
||||||
// *declared* name, which nothing else can hold (a colon keeps it out of
|
}
|
||||||
// the registry), so it shadowed nothing. Passing the filename instead
|
return declared || filenameType;
|
||||||
// would report a substitution of an unrelated agent that never happened.
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
// `||`, 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;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
const { builtinToolNames, extSelectors } = parseToolsField(fm.tools);
|
||||||
|
return {
|
||||||
agents.set(name, {
|
|
||||||
name,
|
name,
|
||||||
// Only `display_name` now: `name` is the type, and `getConfig` already
|
// 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
|
// 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),
|
memory: parseMemory(fm.memory),
|
||||||
isolation: parseIsolation(fm.isolation),
|
isolation: parseIsolation(fm.isolation),
|
||||||
enabled: fm.enabled !== false, // default true; explicitly false disables
|
enabled: fm.enabled !== false, // default true; explicitly false disables
|
||||||
source,
|
isDefault: provenance === undefined ? true : undefined,
|
||||||
sourcePath: path,
|
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 { abortable } from "./abortable.js";
|
||||||
import { hasAgentBadge, renderAgentName } from "./agent-color.js";
|
import { hasAgentBadge, renderAgentName } from "./agent-color.js";
|
||||||
import { buildNewAgentFile, disableInContent, enableInContent, isEmptyStub, locateAgentFile, personalAgentsDir, projectAgentsDir, serializeAgentFile } from "./agent-file-toggle.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 { 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 { 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 { inChildSessionContext } from "./child-context.js";
|
||||||
import { type RpcHandle, registerRpcHandlers } from "./cross-extension-rpc.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 { GroupJoinManager } from "./group-join.js";
|
||||||
import { isolationParam, resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.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 { describeMention, handleBase, isReservedHandle, parseMention, resolveHandleToType, stripAgentPrefix } from "./mention.js";
|
||||||
import { runMentionClone } from "./mention-clone.js";
|
import { runMentionClone } from "./mention-clone.js";
|
||||||
import { type ModelRegistry, resolveModel } from "./model-resolver.js";
|
import { type ModelRegistry, resolveModel } from "./model-resolver.js";
|
||||||
@@ -281,13 +281,53 @@ export function formatToolsSuffix(cfg: AgentConfig | undefined): string {
|
|||||||
return isFullSet ? "*" : tools.join(", ");
|
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. */
|
/** Create a host-configured extension; Agent frontmatter cannot mutate these options. */
|
||||||
export function createPiSubagentsExtension(
|
export function createPiSubagentsExtension(
|
||||||
options: PiSubagentsExtensionOptions = {},
|
options: PiSubagentsExtensionOptions = {},
|
||||||
): (pi: ExtensionAPI) => void {
|
): (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();
|
const defaultExtension = createPiSubagentsExtension();
|
||||||
@@ -371,10 +411,13 @@ function installPiSubagentsExtension(
|
|||||||
// the initial load, which happens hundreds of lines before settings are applied.
|
// the initial load, which happens hundreds of lines before settings are applied.
|
||||||
let strictAgentFiles = loadSettings(process.cwd()).strictAgentFiles === true;
|
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 reloadCustomAgents = (strict = false) => {
|
||||||
|
const bundledAgents = options.bundledAgentsDir === undefined
|
||||||
|
? undefined
|
||||||
|
: loadBundledAgents(options.bundledAgentsDir, strict);
|
||||||
const userAgents = loadCustomAgents(process.cwd(), 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
|
// 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
|
// see `PendingUsagePool`. Skipped entirely when the feature is off, so no
|
||||||
// pool grows in a session that will never drain it.
|
// pool grows in a session that will never drain it.
|
||||||
if (reportUsage) pendingUsage.add(usage);
|
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.
|
// Expose manager via Symbol.for() global registry for cross-package access.
|
||||||
// Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
|
// Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.).
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export interface MandatoryExtensionPolicy {
|
|||||||
mandatoryExtensionPaths?: readonly string[];
|
mandatoryExtensionPaths?: readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function canonicalExistingPath(path: string): string {
|
export function canonicalExistingPath(path: string): string {
|
||||||
if (!isAbsolute(path)) {
|
if (!isAbsolute(path)) {
|
||||||
throw new Error(`Mandatory extension path must be absolute: ${path}`);
|
throw new Error(`Mandatory extension path must be absolute: ${path}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
resolveEnabledTypeIn,
|
resolveEnabledTypeIn,
|
||||||
resolveTypeIn,
|
resolveTypeIn,
|
||||||
} from "./agent-types.js";
|
} 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 { isolationParam, resolveAgentInvocationConfig } from "./invocation-config.js";
|
||||||
import { resolveModel } from "./model-resolver.js";
|
import { resolveModel } from "./model-resolver.js";
|
||||||
import { checkModelScope } from "./model-scope.js";
|
import { checkModelScope } from "./model-scope.js";
|
||||||
@@ -100,6 +100,8 @@ export interface NestedToolContext {
|
|||||||
allowedSubagents: "all" | string[];
|
allowedSubagents: "all" | string[];
|
||||||
/** Root used for agent/config discovery; may differ from the agent's working directory. */
|
/** Root used for agent/config discovery; may differ from the agent's working directory. */
|
||||||
configCwd: string;
|
configCwd: string;
|
||||||
|
/** Bundle-shipped agent .md directory — registered as embedded defaults, like the top-level registry. */
|
||||||
|
bundledAgentsDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function textResult(text: string, isError = false) {
|
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
|
// Agents resolve from a registry built for THIS branch's config root (under
|
||||||
// worktree isolation, the copy). Never via registerAgents — that is
|
// worktree isolation, the copy). Never via registerAgents — that is
|
||||||
// process-global state shared with the main session and every other agent.
|
// 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 =>
|
const allowedTypesIn = (registry: Map<string, AgentConfig>): Set<string> | undefined =>
|
||||||
context.allowedSubagents === "all"
|
context.allowedSubagents === "all"
|
||||||
? undefined
|
? undefined
|
||||||
|
|||||||
@@ -32,8 +32,18 @@ export interface PromptExtras {
|
|||||||
* parent prompt forms an identical, cacheable byte prefix with the parent
|
* 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).
|
* 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 parentSystemPrompt The parent agent's effective system prompt (for append mode).
|
||||||
* @param extras Optional extra sections to inject (memory, preloaded skills).
|
* @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(
|
export function buildAgentPrompt(
|
||||||
config: AgentConfig,
|
config: AgentConfig,
|
||||||
@@ -41,6 +51,7 @@ export function buildAgentPrompt(
|
|||||||
env: EnvInfo,
|
env: EnvInfo,
|
||||||
parentSystemPrompt?: string,
|
parentSystemPrompt?: string,
|
||||||
extras?: PromptExtras,
|
extras?: PromptExtras,
|
||||||
|
base?: string,
|
||||||
): string {
|
): string {
|
||||||
const activeAgentTag = `<active_agent name="${config.name}"/>\n\n`;
|
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 extrasSuffix = extraSections.length > 0 ? "\n\n" + extraSections.join("\n") : "";
|
||||||
|
|
||||||
|
const bundleBase = base?.trim();
|
||||||
|
|
||||||
if (config.promptMode === "append") {
|
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 identity = parentSystemPrompt || genericBase;
|
||||||
|
|
||||||
const bridge = `<sub_agent_context>
|
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
|
- Be concise but complete
|
||||||
</sub_agent_context>`;
|
</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
|
// Place shared/stable content first so the LLM's KV cache can reuse the
|
||||||
// inherited prefix across all subagent invocations. The parent prompt is
|
// inherited prefix across all subagent invocations. The parent prompt is
|
||||||
// placed verbatim (no wrapper tag) so it forms an identical byte prefix
|
// 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
|
// "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.
|
const replaceHeader = `You are a pi coding agent sub-agent.
|
||||||
You have been invoked to handle a specific task autonomously.
|
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", () => {
|
describe("agent-runner extension allowlist", () => {
|
||||||
function setupArrayAgent(extensions: string[]) {
|
function setupArrayAgent(extensions: string[]) {
|
||||||
vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions }));
|
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>"));
|
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 repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
const hasPi = spawnSync("pi", ["--version"], { encoding: "utf8" }).status === 0;
|
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 {
|
const packageJson = JSON.parse(await readFile(join(repositoryRoot, "package.json"), "utf8")) as {
|
||||||
dependencies: Record<string, string>;
|
dependencies: Record<string, string>;
|
||||||
files: string[];
|
files: string[];
|
||||||
pi: { extensions: string[] };
|
pi: { extensions: string[] };
|
||||||
};
|
};
|
||||||
assert.ok(packageJson.files.includes("pi-subagents"), "the bundle must retain the maintained subagents source");
|
assert.ok(packageJson.files.includes("pi-subagents"));
|
||||||
assert.equal(packageJson.dependencies["@tintinweb/pi-subagents"], undefined);
|
assert.equal(packageJson.dependencies["@tintinweb/pi-subagents"], "file:./pi-subagents");
|
||||||
assert.equal(
|
assert.ok(
|
||||||
packageJson.pi.extensions.includes("./extensions/subagents.ts"),
|
packageJson.pi.extensions.includes("./extensions/subagents.ts"),
|
||||||
false,
|
"the bundle must default-load the locally maintained subagents wrapper",
|
||||||
"the bundle must not default-load the disabled 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 () => {
|
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_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.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.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.path, undefined, "sensitive paths must not be hard-denied by default");
|
||||||
assert.equal(hardGuardConfig.permission.bash["rm -rf /"], "deny");
|
assert.equal(hardGuardConfig.permission.bash["rm -rf /"], "deny");
|
||||||
assert.equal(
|
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",
|
"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["@ogulcancelik/pi-minimal-footer"], "file:./pi-minimal-footer");
|
||||||
assert.equal(packageJson.dependencies["@tintinweb/pi-subagents"], undefined);
|
assert.equal(packageJson.dependencies["@tintinweb/pi-subagents"], "file:./pi-subagents");
|
||||||
assert.equal(packageJson.pi.extensions.includes("./extensions/subagents.ts"), false);
|
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(
|
assert.ok(
|
||||||
packageJson.pi.extensions.includes("./pi-hermes-memory/src/index.ts"),
|
packageJson.pi.extensions.includes("./pi-hermes-memory/src/index.ts"),
|
||||||
"the bundle must load the locally maintained Hermes memory extension",
|
"the bundle must load the locally maintained Hermes memory extension",
|
||||||
|
|||||||
+158
-1
@@ -9,6 +9,7 @@ import { fileURLToPath } from "node:url";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
applyToolCatalog,
|
applyToolCatalog,
|
||||||
|
isSubagentCustomPrompt,
|
||||||
loadRoutingRules,
|
loadRoutingRules,
|
||||||
loadSectionsConfig,
|
loadSectionsConfig,
|
||||||
loadToolCatalog,
|
loadToolCatalog,
|
||||||
@@ -18,6 +19,8 @@ import {
|
|||||||
resolvePreamblePath,
|
resolvePreamblePath,
|
||||||
spliceCover,
|
spliceCover,
|
||||||
stripJsoncComments,
|
stripJsoncComments,
|
||||||
|
validateRoutingConfig,
|
||||||
|
validateSectionsConfig,
|
||||||
type MutablePromptOptions,
|
type MutablePromptOptions,
|
||||||
type RoutingRule,
|
type RoutingRule,
|
||||||
} from "../extensions/system-prompt-config.ts";
|
} from "../extensions/system-prompt-config.ts";
|
||||||
@@ -74,6 +77,40 @@ test("config/prompt files load with the expected shapes", () => {
|
|||||||
"chrome_profile_bridge",
|
"chrome_profile_bridge",
|
||||||
"tool_routing",
|
"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/", () => {
|
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"]), "");
|
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", () => {
|
test("bundled routing rules select the same guidance the retired tool-routing extension emitted", () => {
|
||||||
const routing = loadRoutingRules();
|
const routing = loadRoutingRules();
|
||||||
|
|
||||||
@@ -275,12 +346,18 @@ test("spliceCover degenerates to the preamble when the prompt has no sections",
|
|||||||
|
|
||||||
interface FakePromptOptions extends MutablePromptOptions {
|
interface FakePromptOptions extends MutablePromptOptions {
|
||||||
forceSystemPrompt?: string;
|
forceSystemPrompt?: string;
|
||||||
|
customPrompt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Simplified re-render of Pi's prompt from the mutable options object. */
|
/** Simplified re-render of Pi's prompt from the mutable options object. */
|
||||||
function renderPiPrompt(options: FakePromptOptions): string {
|
function renderPiPrompt(options: FakePromptOptions): string {
|
||||||
if (options.forceSystemPrompt !== undefined) return options.forceSystemPrompt;
|
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 ?? {});
|
const snippets = Object.entries(options.toolSnippets ?? {});
|
||||||
if (snippets.length > 0) {
|
if (snippets.length > 0) {
|
||||||
parts.push(`<tools>\n${snippets.map(([n, s]) => `- ${n}: ${s}`).join("\n")}\n</tools>`);
|
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", () => {
|
test("composer falls back to text appends when the host exposes no prompt options", () => {
|
||||||
const composer = createSystemPromptComposer();
|
const composer = createSystemPromptComposer();
|
||||||
const { ctx } = makeUiCtx();
|
const { ctx } = makeUiCtx();
|
||||||
|
|||||||
Reference in New Issue
Block a user