mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
- 根包恢复 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>
423 lines
18 KiB
TypeScript
423 lines
18 KiB
TypeScript
import type { Model } from "@earendil-works/pi-ai";
|
|
import {
|
|
type AgentSession,
|
|
defineTool,
|
|
type ExtensionAPI,
|
|
type ExtensionContext,
|
|
type ToolDefinition,
|
|
} from "@earendil-works/pi-coding-agent";
|
|
import { Type } from "@sinclair/typebox";
|
|
import { abortable } from "./abortable.js";
|
|
import {
|
|
buildAgentRegistry,
|
|
getAgentConfigIn,
|
|
getAvailableTypesIn,
|
|
resolveEnabledTypeIn,
|
|
resolveTypeIn,
|
|
} from "./agent-types.js";
|
|
import { loadBundledAgents, loadCustomAgents } from "./custom-agents.js";
|
|
import { isolationParam, resolveAgentInvocationConfig } from "./invocation-config.js";
|
|
import { resolveModel } from "./model-resolver.js";
|
|
import { checkModelScope } from "./model-scope.js";
|
|
import {
|
|
createOutputFilePath,
|
|
getOutputTranscriptDefault,
|
|
streamToOutputFile,
|
|
writeInitialEntry,
|
|
} from "./output-file.js";
|
|
import { getForegroundOutcomeNote, getStatusNote, partialOutputSuffix } from "./status-note.js";
|
|
import type {
|
|
AgentConfig,
|
|
AgentInvocation,
|
|
AgentRecord,
|
|
IsolationMode,
|
|
ThinkingLevel,
|
|
} from "./types.js";
|
|
import { addUsage } from "./usage.js";
|
|
import { isWorktreeIsolationEnabled } from "./worktree.js";
|
|
|
|
/**
|
|
* Hard ceiling on nesting for every branch: main session = 0, its subagents = 1,
|
|
* their children = 2. `0`/`1` disables nesting entirely. Set from
|
|
* `subagents.json` (`maxSubagentDepth`). Read when a subagent session is built,
|
|
* so a change applies to sessions started after it.
|
|
*/
|
|
let maxSubagentDepth = 2;
|
|
|
|
export function getMaxSubagentDepth(): number { return maxSubagentDepth; }
|
|
export function setMaxSubagentDepth(n: number): void { maxSubagentDepth = Math.max(0, Math.floor(n)); }
|
|
|
|
const NESTED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"] as const;
|
|
|
|
interface NestedSpawnOptions {
|
|
description: string;
|
|
model?: Model<any>;
|
|
maxTurns?: number;
|
|
isolated?: boolean;
|
|
inheritContext?: boolean;
|
|
thinkingLevel?: ThinkingLevel;
|
|
isBackground?: boolean;
|
|
isolation?: IsolationMode;
|
|
invocation?: AgentInvocation;
|
|
signal?: AbortSignal;
|
|
onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
|
|
onSessionCreated?: (session: AgentSession) => void;
|
|
depth: number;
|
|
parentAgentId: string;
|
|
maxSubagentDepth: number;
|
|
configCwd?: string;
|
|
rootSessionId?: string;
|
|
}
|
|
|
|
export interface NestedAgentManager {
|
|
spawn(
|
|
pi: ExtensionAPI,
|
|
ctx: ExtensionContext,
|
|
type: string,
|
|
prompt: string,
|
|
options: NestedSpawnOptions,
|
|
): string;
|
|
spawnAndWait(
|
|
pi: ExtensionAPI,
|
|
ctx: ExtensionContext,
|
|
type: string,
|
|
prompt: string,
|
|
options: Omit<NestedSpawnOptions, "isBackground">,
|
|
/** Fires synchronously after spawn, before the session exists — where the transcript is attached. */
|
|
onSpawned?: (id: string) => void,
|
|
): Promise<{ id: string; record: AgentRecord }>;
|
|
getRecord(id: string): AgentRecord | undefined;
|
|
resume(id: string, prompt: string, signal?: AbortSignal): Promise<AgentRecord | undefined>;
|
|
}
|
|
|
|
export interface NestedToolContext {
|
|
manager: NestedAgentManager;
|
|
pi: ExtensionAPI;
|
|
parentAgentId: string;
|
|
depth: number;
|
|
maxSubagentDepth: number;
|
|
/** "all" = any enabled agent; string[] = only those types. Never empty. */
|
|
allowedSubagents: "all" | string[];
|
|
/** Root used for agent/config discovery; may differ from the agent's working directory. */
|
|
configCwd: string;
|
|
/** Bundle-shipped agent .md directory — registered as embedded defaults, like the top-level registry. */
|
|
bundledAgentsDir?: string;
|
|
}
|
|
|
|
function textResult(text: string, isError = false) {
|
|
return { content: [{ type: "text" as const, text }], isError, details: {} };
|
|
}
|
|
|
|
function ownsRecord(record: AgentRecord | undefined, parentAgentId: string): record is AgentRecord {
|
|
return record?.parentAgentId === parentAgentId;
|
|
}
|
|
|
|
/**
|
|
* How the caller received this record, which decides the outcome wording.
|
|
*
|
|
* - "inline": a foreground spawn or a resume. The full output is in this very
|
|
* result and no agent id was handed back, so the note must say there is
|
|
* nothing left to fetch — otherwise the parent invents an id, calls
|
|
* `get_subagent_result`, and hits "not owned by this parent" (#174, the same
|
|
* trap the top-level foreground path fell into).
|
|
* - "fetched": `get_subagent_result` on a background child. The parent holds a
|
|
* valid id and can poll again, so the background wording applies.
|
|
*/
|
|
type ResultPosition = "inline" | "fetched";
|
|
|
|
function formatRecord(record: AgentRecord, position: ResultPosition): string {
|
|
if (record.status === "error") {
|
|
return `Agent failed: ${record.error ?? "unknown error"}${partialOutputSuffix(record)}`;
|
|
}
|
|
if (record.status === "queued" || record.status === "running") {
|
|
return `Agent ${record.id} is ${record.status}.`;
|
|
}
|
|
// A truncated run must not read as a finished one. The top-level path carries
|
|
// this in its result headline; a nested result has no headline, so the note
|
|
// leads — appended, it would look like part of the child's own output.
|
|
const text = record.result?.trim() || record.error?.trim() || "No output.";
|
|
const note = position === "inline"
|
|
? getForegroundOutcomeNote(record.status)
|
|
: getStatusNote(record.status);
|
|
return note ? `Nested agent${note}.\n\n${text}` : text;
|
|
}
|
|
|
|
/** Build child-safe orchestration tools scoped to one parent agent instance. */
|
|
export function createNestedSubagentTools(context: NestedToolContext): ToolDefinition[] {
|
|
// Agents resolve from a registry built for THIS branch's config root (under
|
|
// worktree isolation, the copy). Never via registerAgents — that is
|
|
// process-global state shared with the main session and every other agent.
|
|
const loadRegistry = () => buildAgentRegistry(
|
|
loadCustomAgents(context.configCwd),
|
|
context.bundledAgentsDir === undefined ? undefined : loadBundledAgents(context.bundledAgentsDir),
|
|
);
|
|
const allowedTypesIn = (registry: Map<string, AgentConfig>): Set<string> | undefined =>
|
|
context.allowedSubagents === "all"
|
|
? undefined
|
|
: new Set(context.allowedSubagents.map(name => resolveTypeIn(registry, name) ?? name));
|
|
const availableIn = (registry: Map<string, AgentConfig>): string[] => {
|
|
const allowed = allowedTypesIn(registry);
|
|
return getAvailableTypesIn(registry).filter(name => allowed === undefined || allowed.has(name));
|
|
};
|
|
|
|
const agentTool = defineTool({
|
|
name: NESTED_TOOL_NAMES[0],
|
|
label: "Agent",
|
|
description:
|
|
"Launch a child-safe nested subagent for bounded delegated work. " +
|
|
"Only use agent types allowed by this parent agent; nesting is depth-limited.",
|
|
parameters: Type.Object({
|
|
prompt: Type.String({ description: "Self-contained task for the nested agent." }),
|
|
description: Type.String({ description: "Short 3-5 word task description." }),
|
|
subagent_type: Type.String({ description: `Allowed nested agent type. Available: ${availableIn(loadRegistry()).join(", ") || "none"}.` }),
|
|
model: Type.Optional(Type.String({ description: "Optional provider/model override." })),
|
|
thinking: Type.Optional(Type.String({ description: "Optional thinking level." })),
|
|
max_turns: Type.Optional(Type.Number({ minimum: 1 })),
|
|
run_in_background: Type.Optional(
|
|
Type.Boolean({
|
|
description: "Defaults to false for nested spawns — the call blocks and returns the child's result inline. Set true only for work you will collect later with get_subagent_result; a detached child is stopped when you finish.",
|
|
}),
|
|
),
|
|
resume: Type.Optional(Type.String({ description: "Resume a nested agent owned by this parent." })),
|
|
isolated: Type.Optional(Type.Boolean()),
|
|
inherit_context: Type.Optional(Type.Boolean()),
|
|
...isolationParam(isWorktreeIsolationEnabled()),
|
|
}),
|
|
execute: async (_toolCallId, params, signal, _onUpdate, ctx) => {
|
|
if (params.resume) {
|
|
const existing = context.manager.getRecord(params.resume);
|
|
if (!ownsRecord(existing, context.parentAgentId)) {
|
|
return textResult(`Nested agent not found or not owned by this parent: "${params.resume}".`, true);
|
|
}
|
|
const resumed = await context.manager.resume(params.resume, params.prompt, signal);
|
|
return resumed
|
|
? textResult(formatRecord(resumed, "inline"), resumed.status === "error")
|
|
: textResult(`Failed to resume nested agent "${params.resume}".`, true);
|
|
}
|
|
|
|
if (context.depth >= context.maxSubagentDepth) {
|
|
return textResult(
|
|
`Nested subagent call blocked (depth=${context.depth}, max=${context.maxSubagentDepth}). Complete the task directly.`,
|
|
true,
|
|
);
|
|
}
|
|
|
|
// Reloaded per call so new agent files are picked up without a restart.
|
|
const registry = loadRegistry();
|
|
const rawType = params.subagent_type;
|
|
// Strict resolve, never the fallback policy: a project-level
|
|
// `fallbackSubagent` must not hand a nested caller an agent its allowlist
|
|
// never named. The list stays allowlist-filtered so a typo can't enumerate
|
|
// agents this parent may not reach.
|
|
const resolvedType = resolveEnabledTypeIn(registry, rawType);
|
|
if (resolvedType === undefined) {
|
|
return textResult(
|
|
`Unknown or disabled nested agent type: "${rawType}". Allowed: ${availableIn(registry).join(", ") || "none"}.`,
|
|
true,
|
|
);
|
|
}
|
|
const allowed = allowedTypesIn(registry);
|
|
if (allowed !== undefined && !allowed.has(resolvedType)) {
|
|
return textResult(
|
|
`Nested agent type "${resolvedType}" is not allowed for this parent. Allowed: ${[...allowed].join(", ")}.`,
|
|
true,
|
|
);
|
|
}
|
|
|
|
const config = getAgentConfigIn(registry, resolvedType);
|
|
// Foreground regardless of `backgroundByDefault` — see the reasoning on
|
|
// ResolveOptions. An explicit `true` here still opts in.
|
|
const invocation = resolveAgentInvocationConfig(config, params, {
|
|
worktreeAllowed: isWorktreeIsolationEnabled(),
|
|
defaultRunInBackground: false,
|
|
});
|
|
let model = ctx.model;
|
|
if (invocation.modelInput) {
|
|
const resolvedModel = resolveModel(invocation.modelInput, ctx.modelRegistry);
|
|
if (typeof resolvedModel === "string") {
|
|
if (invocation.modelFromParams) return textResult(resolvedModel, true);
|
|
} else {
|
|
model = resolvedModel;
|
|
}
|
|
}
|
|
|
|
// Same scopeModels policy as the top-level Agent tool — a nested spawn
|
|
// must not escape the allowlist. A "warn" verdict proceeds silently:
|
|
// child sessions have no UI surface to toast to.
|
|
const scopeVerdict = checkModelScope({
|
|
model,
|
|
cwd: context.configCwd,
|
|
modelRegistry: ctx.modelRegistry,
|
|
callerSupplied: invocation.modelFromParams,
|
|
agentLabel: config?.displayName ?? resolvedType,
|
|
modelInput: invocation.modelInput,
|
|
});
|
|
if (scopeVerdict.kind === "error") return textResult(scopeVerdict.message, true);
|
|
|
|
// The whole branch shares the root session's transcript directory; read it
|
|
// off the owning parent rather than this child session's own id.
|
|
const rootSessionId = context.manager.getRecord(context.parentAgentId)?.rootSessionId;
|
|
const childDepth = context.depth + 1;
|
|
const options: NestedSpawnOptions = {
|
|
description: params.description,
|
|
model,
|
|
maxTurns: invocation.maxTurns,
|
|
isolated: invocation.isolated,
|
|
inheritContext: invocation.inheritContext,
|
|
thinkingLevel: invocation.thinking,
|
|
isolation: invocation.isolation,
|
|
invocation: {
|
|
thinking: invocation.thinking,
|
|
maxTurns: invocation.maxTurns,
|
|
isolated: invocation.isolated,
|
|
inheritContext: invocation.inheritContext,
|
|
runInBackground: invocation.runInBackground,
|
|
isolation: invocation.isolation,
|
|
},
|
|
// Nested children are hidden from every reporting surface, so their spend
|
|
// would otherwise be unattributable. Fold it into every ancestor's record:
|
|
// the top-level one appears in lifecycle events, completion notifications,
|
|
// and `/agents`, and those all read `lifetimeUsage`. The whole chain is
|
|
// walked, not just the immediate parent — a spawn callback only fires for
|
|
// that child's OWN turns, so stopping at one level would hide a
|
|
// great-grandchild from the only record anyone can see. (The live
|
|
// widget/fleet counters read their own per-agent activity tracker, which
|
|
// still sees only the top-level agent's own turns.)
|
|
onAssistantUsage: (usage) => {
|
|
for (let id: string | undefined = context.parentAgentId; id !== undefined; ) {
|
|
const ancestor = context.manager.getRecord(id);
|
|
if (!ancestor) break;
|
|
addUsage(ancestor.lifetimeUsage, usage);
|
|
id = ancestor.parentAgentId;
|
|
}
|
|
},
|
|
depth: childDepth,
|
|
parentAgentId: context.parentAgentId,
|
|
maxSubagentDepth: context.maxSubagentDepth,
|
|
configCwd: context.configCwd,
|
|
rootSessionId,
|
|
};
|
|
|
|
// Transcript wiring, same gate as the top-level path: the child's
|
|
// `output_transcript` frontmatter wins, else the project default. Without
|
|
// it a nested run leaves no artifact but the string it returned — the
|
|
// parent's own transcript records the call and the answer, never the tool
|
|
// calls in between, which is exactly what a misbehaving child needs to
|
|
// explain itself. Filed under the ROOT session and this branch's config
|
|
// root, so a nested transcript lands in the same `tasks/` directory as its
|
|
// ancestors' rather than in a directory of its own.
|
|
const transcriptSessionId =
|
|
rootSessionId !== undefined && (config?.outputTranscript ?? getOutputTranscriptDefault())
|
|
? rootSessionId
|
|
: undefined;
|
|
let childId: string | undefined;
|
|
const attachTranscript = (id: string): void => {
|
|
childId = id;
|
|
if (transcriptSessionId === undefined) return;
|
|
const rec = context.manager.getRecord(id);
|
|
if (!rec) return;
|
|
rec.outputFile = createOutputFilePath(context.configCwd, id, transcriptSessionId);
|
|
writeInitialEntry(rec.outputFile, id, params.prompt, ctx.cwd);
|
|
};
|
|
options.onSessionCreated = (session) => {
|
|
const rec = childId === undefined ? undefined : context.manager.getRecord(childId);
|
|
if (rec?.outputFile && childId !== undefined) {
|
|
rec.outputCleanup = streamToOutputFile(session, rec.outputFile, childId, ctx.cwd);
|
|
}
|
|
};
|
|
|
|
// `ctx` is forwarded to the manager unmodified, never captured at tool-build
|
|
// time: each AgentSession builds its own ExtensionRunner from that session's
|
|
// cwd/sessionManager/modelRegistry, so this is the CHILD's context. Capturing
|
|
// one earlier would silently give a grandchild the wrong worktree base, the
|
|
// wrong conversation under inherit_context, and the wrong inherited model.
|
|
//
|
|
// spawn() throws on strict worktree-isolation failure and cwd validation —
|
|
// report it as a tool error, like the top-level Agent tool does, instead of
|
|
// letting it escape into the child's turn.
|
|
try {
|
|
if (invocation.runInBackground) {
|
|
const id = context.manager.spawn(context.pi, ctx, resolvedType, params.prompt, {
|
|
...options,
|
|
isBackground: true,
|
|
});
|
|
// Synchronous, before the event loop yields — onSessionCreated fires
|
|
// asynchronously inside runAgent, so the file is attached in time.
|
|
attachTranscript(id);
|
|
return textResult(`Nested agent started in background. Agent ID: ${id}`);
|
|
}
|
|
|
|
const { record } = await context.manager.spawnAndWait(
|
|
context.pi,
|
|
ctx,
|
|
resolvedType,
|
|
params.prompt,
|
|
{ ...options, signal },
|
|
attachTranscript,
|
|
);
|
|
return textResult(formatRecord(record, "inline"), record.status === "error");
|
|
} catch (err) {
|
|
return textResult(err instanceof Error ? err.message : String(err), true);
|
|
}
|
|
},
|
|
});
|
|
|
|
const resultTool = defineTool({
|
|
name: NESTED_TOOL_NAMES[1],
|
|
label: "Get Nested Agent Result",
|
|
description: "Check or wait for a background nested agent owned by this parent.",
|
|
parameters: Type.Object({
|
|
agent_id: Type.String(),
|
|
wait: Type.Optional(Type.Boolean()),
|
|
}),
|
|
execute: async (_toolCallId, params, signal) => {
|
|
const record = context.manager.getRecord(params.agent_id);
|
|
if (!ownsRecord(record, context.parentAgentId)) {
|
|
return textResult(`Nested agent not found or not owned by this parent: "${params.agent_id}".`, true);
|
|
}
|
|
// Wait for completion if requested. Cancellation (e.g. the parent's tool
|
|
// call is aborted) stops only this wait; the nested child keeps running and
|
|
// stays unconsumed. Queued records have no promise until the manager starts
|
|
// them, so poll — abortably — until they leave the queue, then await.
|
|
if (params.wait && (record.status === "queued" || record.status === "running")) {
|
|
while (record.status === "queued") {
|
|
await abortable(new Promise<void>(resolve => setTimeout(resolve, 250)), signal);
|
|
}
|
|
if (record.promise) await abortable(record.promise, signal);
|
|
}
|
|
return textResult(formatRecord(record, "fetched"), record.status === "error");
|
|
},
|
|
});
|
|
|
|
const steerTool = defineTool({
|
|
name: NESTED_TOOL_NAMES[2],
|
|
label: "Steer Nested Agent",
|
|
description: "Send guidance to a running nested agent owned by this parent.",
|
|
parameters: Type.Object({
|
|
agent_id: Type.String(),
|
|
message: Type.String(),
|
|
}),
|
|
execute: async (_toolCallId, params) => {
|
|
const record = context.manager.getRecord(params.agent_id);
|
|
if (!ownsRecord(record, context.parentAgentId) || record.status !== "running") {
|
|
return textResult(`Running nested agent not found or not owned by this parent: "${params.agent_id}".`, true);
|
|
}
|
|
// Session not ready yet — queue the steer. The manager flushes pending
|
|
// steers when the session is created (same contract as the top-level tool).
|
|
if (!record.session) {
|
|
if (!record.pendingSteers) record.pendingSteers = [];
|
|
record.pendingSteers.push(params.message);
|
|
return textResult(`Steering message queued for nested agent ${params.agent_id}.`);
|
|
}
|
|
try {
|
|
await record.session.steer(params.message);
|
|
} catch (err) {
|
|
return textResult(`Failed to steer nested agent: ${err instanceof Error ? err.message : String(err)}`, true);
|
|
}
|
|
return textResult(`Steering message sent to nested agent ${params.agent_id}.`);
|
|
},
|
|
});
|
|
|
|
return [agentTool, resultTool, steerTool];
|
|
}
|