@oai404iao/pi-subagent 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,139 @@
1
+ import { rm } from "node:fs/promises";
2
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
3
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
4
+ import type { SubagentMode, SubagentProviderName } from "./types.ts";
5
+
6
+ export interface SessionView {
7
+ getBranch(): SessionEntry[];
8
+ getCwd(): string;
9
+ getSessionDir(): string;
10
+ getSessionFile(): string | undefined;
11
+ getSessionId(): string;
12
+ }
13
+
14
+ export interface ProviderParent {
15
+ sessionManager: SessionView;
16
+ }
17
+
18
+ export interface PreparedChildSession {
19
+ sessionManager: SessionManager;
20
+ seedMessageCount: number;
21
+ rollback(): Promise<void>;
22
+ }
23
+
24
+ export interface ChildProvider {
25
+ name: SubagentProviderName;
26
+ inheritsParentContext: boolean;
27
+ supportsContinuable: boolean;
28
+ prepare(parent: ProviderParent, mode: SubagentMode): Promise<PreparedChildSession>;
29
+ }
30
+
31
+ async function removeOwnedSession(path: string | undefined): Promise<void> {
32
+ if (!path) return;
33
+ await rm(path, { force: true });
34
+ }
35
+
36
+ function freshSession(parent: ProviderParent): PreparedChildSession {
37
+ const parentFile = parent.sessionManager.getSessionFile();
38
+ const options = parentFile ? { parentSession: parentFile } : undefined;
39
+ const sessionManager = parentFile
40
+ ? SessionManager.create(
41
+ parent.sessionManager.getCwd(),
42
+ parent.sessionManager.getSessionDir(),
43
+ options,
44
+ )
45
+ : SessionManager.inMemory(parent.sessionManager.getCwd(), options);
46
+ const childFile = sessionManager.getSessionFile();
47
+ return {
48
+ sessionManager,
49
+ seedMessageCount: 0,
50
+ rollback: () => removeOwnedSession(childFile),
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Return the latest assistant entry that closed a completed turn.
56
+ *
57
+ * A tool-calling assistant message has stopReason "toolUse" and is not a safe
58
+ * fork boundary. The current parent turn is therefore excluded.
59
+ */
60
+ export function completedTurnBoundaryId(entries: readonly SessionEntry[]): string | undefined {
61
+ for (let index = entries.length - 1; index >= 0; index--) {
62
+ const entry = entries[index];
63
+ if (entry.type !== "message" || entry.message.role !== "assistant") continue;
64
+ if (entry.message.stopReason !== "toolUse") return entry.id;
65
+ }
66
+ return undefined;
67
+ }
68
+
69
+ function forkedSession(parent: ProviderParent): PreparedChildSession {
70
+ const parentFile = parent.sessionManager.getSessionFile();
71
+ const boundaryId = completedTurnBoundaryId(parent.sessionManager.getBranch());
72
+ if (!boundaryId) return freshSession(parent);
73
+ if (!parentFile) {
74
+ throw new Error(
75
+ "fork provider cannot copy completed history from an ephemeral parent session; use spawn instead",
76
+ );
77
+ }
78
+
79
+ const clone = SessionManager.open(
80
+ parentFile,
81
+ parent.sessionManager.getSessionDir(),
82
+ parent.sessionManager.getCwd(),
83
+ );
84
+ if (!clone.getEntry(boundaryId)) return freshSession(parent);
85
+ const childFile = clone.createBranchedSession(boundaryId);
86
+ if (!childFile) return freshSession(parent);
87
+ const sessionManager = SessionManager.open(
88
+ childFile,
89
+ parent.sessionManager.getSessionDir(),
90
+ parent.sessionManager.getCwd(),
91
+ );
92
+ return {
93
+ sessionManager,
94
+ seedMessageCount: sessionManager.buildSessionContext().messages.length,
95
+ rollback: () => removeOwnedSession(childFile),
96
+ };
97
+ }
98
+
99
+ export class SpawnProvider implements ChildProvider {
100
+ readonly name = "spawn";
101
+ readonly inheritsParentContext = false;
102
+ readonly supportsContinuable = true;
103
+
104
+ prepare(parent: ProviderParent, _mode: SubagentMode): Promise<PreparedChildSession> {
105
+ return Promise.resolve(freshSession(parent));
106
+ }
107
+ }
108
+
109
+ export class ForkProvider implements ChildProvider {
110
+ readonly name = "fork";
111
+ readonly inheritsParentContext = true;
112
+ readonly supportsContinuable = false;
113
+
114
+ async prepare(parent: ProviderParent, mode: SubagentMode): Promise<PreparedChildSession> {
115
+ if (mode === "continuable") {
116
+ throw new Error("fork provider is one-shot only; use spawn for continuable background work");
117
+ }
118
+ return forkedSession(parent);
119
+ }
120
+ }
121
+
122
+ export class ProviderRegistry {
123
+ private readonly providers = new Map<SubagentProviderName, ChildProvider>();
124
+
125
+ register(provider: ChildProvider): void {
126
+ if (this.providers.has(provider.name)) throw new Error(`duplicate subagent provider: ${provider.name}`);
127
+ this.providers.set(provider.name, provider);
128
+ }
129
+
130
+ get(name: SubagentProviderName): ChildProvider {
131
+ const provider = this.providers.get(name);
132
+ if (!provider) throw new Error(`subagent provider is not registered: ${name}`);
133
+ return provider;
134
+ }
135
+
136
+ list(): ChildProvider[] {
137
+ return [...this.providers.values()];
138
+ }
139
+ }
package/src/render.ts ADDED
@@ -0,0 +1,109 @@
1
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
2
+ import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
3
+ import type { DelegationDetails, ParentMessageDetails } from "./types.ts";
4
+ import { formatUsage } from "./result.ts";
5
+
6
+ export function renderDelegationCall(
7
+ args: { agent?: string; description?: string; prompt?: string; run_in_background?: boolean },
8
+ theme: {
9
+ fg(color: any, text: string): string;
10
+ bold(text: string): string;
11
+ },
12
+ provider: "spawn" | "fork",
13
+ ): Text {
14
+ const mode =
15
+ provider === "fork"
16
+ ? "fork · foreground"
17
+ : args.run_in_background === false
18
+ ? "spawn · foreground"
19
+ : args.run_in_background === true
20
+ ? "spawn · background"
21
+ : "spawn · configured default";
22
+ let text =
23
+ theme.fg("toolTitle", theme.bold(provider === "fork" ? "subagent_fork " : "subagent ")) +
24
+ theme.fg("accent", args.agent ?? "…") +
25
+ theme.fg("muted", ` [${mode}]`);
26
+ if (args.description) text += `\n ${theme.fg("dim", args.description)}`;
27
+ return new Text(text, 0, 0);
28
+ }
29
+
30
+ export function renderDelegationResult(
31
+ details: DelegationDetails | undefined,
32
+ content: string,
33
+ options: { expanded: boolean; isPartial: boolean },
34
+ theme: {
35
+ fg(color: any, text: string): string;
36
+ bold(text: string): string;
37
+ },
38
+ ): Text | Container {
39
+ if (!details) return new Text(content || "(no output)", 0, 0);
40
+ const running = options.isPartial || details.status === "starting" || details.status === "running";
41
+ const icon = running
42
+ ? theme.fg("warning", "◌")
43
+ : details.status === "failed"
44
+ ? theme.fg("error", "✗")
45
+ : theme.fg("success", "✓");
46
+ const header = `${icon} ${theme.fg("toolTitle", theme.bold(details.agent))} ${theme.fg(
47
+ "muted",
48
+ `[${details.provider}/${details.mode}]`,
49
+ )} ${theme.fg("dim", details.id)}`;
50
+
51
+ if (!options.expanded) {
52
+ const lines = [header, theme.fg("muted", details.label)];
53
+ const trace = details.trace.slice(-5);
54
+ for (const item of trace) {
55
+ const prefix = item.type === "tool" ? "→ " : "";
56
+ const preview = item.text.split("\n").slice(0, 2).join("\n");
57
+ lines.push(theme.fg(item.type === "tool" ? "muted" : "toolOutput", `${prefix}${preview}`));
58
+ }
59
+ if (details.usage) lines.push(theme.fg("dim", formatUsage(details.usage)));
60
+ if (details.sessionFile) lines.push(theme.fg("dim", `session: ${details.sessionFile}`));
61
+ return new Text(lines.join("\n"), 0, 0);
62
+ }
63
+
64
+ const container = new Container();
65
+ container.addChild(new Text(header, 0, 0));
66
+ container.addChild(new Text(theme.fg("muted", `Task: ${details.label}`), 0, 0));
67
+ if (details.trace.length > 0) {
68
+ container.addChild(new Spacer(1));
69
+ container.addChild(new Text(theme.fg("muted", "─── Activity ───"), 0, 0));
70
+ for (const item of details.trace) {
71
+ if (item.type === "tool") container.addChild(new Text(theme.fg("muted", `→ ${item.text}`), 0, 0));
72
+ }
73
+ }
74
+ if (details.output) {
75
+ container.addChild(new Spacer(1));
76
+ container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
77
+ container.addChild(new Markdown(details.output, 0, 0, getMarkdownTheme()));
78
+ }
79
+ if (details.usage) {
80
+ container.addChild(new Spacer(1));
81
+ container.addChild(new Text(theme.fg("dim", formatUsage(details.usage)), 0, 0));
82
+ }
83
+ if (details.sessionFile) container.addChild(new Text(theme.fg("dim", `session: ${details.sessionFile}`), 0, 0));
84
+ return container;
85
+ }
86
+
87
+ export function renderParentMessage(
88
+ content: string,
89
+ details: ParentMessageDetails | undefined,
90
+ expanded: boolean,
91
+ outputPad: number,
92
+ theme: {
93
+ fg(color: any, text: string): string;
94
+ bold(text: string): string;
95
+ },
96
+ ): Text | Markdown {
97
+ if (expanded) return new Markdown(content, outputPad, 0, getMarkdownTheme());
98
+ const kind = details?.kind === "report" ? "report" : "settled";
99
+ const icon = details?.stopReason && details.stopReason !== "completed" ? "◐" : "●";
100
+ const label = details?.label ? ` — ${details.label}` : "";
101
+ return new Text(
102
+ theme.fg("accent", icon) +
103
+ " " +
104
+ theme.fg("toolTitle", theme.bold(`subagent ${kind}`)) +
105
+ theme.fg("muted", ` ${details?.childId ?? "unknown"}${label}`),
106
+ outputPad,
107
+ 0,
108
+ );
109
+ }
package/src/result.ts ADDED
@@ -0,0 +1,131 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import type { Usage } from "@earendil-works/pi-ai";
3
+ import type { SubagentStopReason, SubagentUsage } from "./types.ts";
4
+
5
+ export interface TruncatedText {
6
+ text: string;
7
+ truncated: boolean;
8
+ omittedBytes: number;
9
+ }
10
+
11
+ export function truncateUtf8(input: string, maxBytes: number): TruncatedText {
12
+ const totalBytes = Buffer.byteLength(input, "utf8");
13
+ if (totalBytes <= maxBytes) return { text: input, truncated: false, omittedBytes: 0 };
14
+ const buffer = Buffer.from(input, "utf8");
15
+ let end = Math.min(maxBytes, buffer.length);
16
+ while (end > 0 && (buffer[end] & 0xc0) === 0x80) end--;
17
+ const text = buffer.subarray(0, end).toString("utf8");
18
+ return { text, truncated: true, omittedBytes: totalBytes - Buffer.byteLength(text, "utf8") };
19
+ }
20
+
21
+ export function emptyUsage(): SubagentUsage {
22
+ return {
23
+ input: 0,
24
+ output: 0,
25
+ cacheRead: 0,
26
+ cacheWrite: 0,
27
+ totalTokens: 0,
28
+ cost: {
29
+ input: 0,
30
+ output: 0,
31
+ cacheRead: 0,
32
+ cacheWrite: 0,
33
+ total: 0,
34
+ },
35
+ turns: 0,
36
+ };
37
+ }
38
+
39
+ export function addUsage(target: SubagentUsage, usage: Usage, countTurn = true): void {
40
+ target.input += usage.input;
41
+ target.output += usage.output;
42
+ target.cacheRead += usage.cacheRead;
43
+ target.cacheWrite += usage.cacheWrite;
44
+ target.totalTokens += usage.totalTokens;
45
+ target.cost.input += usage.cost.input;
46
+ target.cost.output += usage.cost.output;
47
+ target.cost.cacheRead += usage.cost.cacheRead;
48
+ target.cost.cacheWrite += usage.cost.cacheWrite;
49
+ target.cost.total += usage.cost.total;
50
+ if (countTurn) target.turns++;
51
+ }
52
+
53
+ function assistantText(message: AgentMessage): string {
54
+ if (message.role !== "assistant") return "";
55
+ return message.content
56
+ .filter((part): part is Extract<(typeof message.content)[number], { type: "text" }> => part.type === "text")
57
+ .map((part) => part.text)
58
+ .join("");
59
+ }
60
+
61
+ export function finalAssistantText(messages: readonly AgentMessage[], startIndex: number, streamedFallback = ""): string {
62
+ for (let index = messages.length - 1; index >= startIndex; index--) {
63
+ const text = assistantText(messages[index]);
64
+ if (text.trim().length > 0) return text;
65
+ }
66
+ return streamedFallback;
67
+ }
68
+
69
+ export function finalStopReason(
70
+ messages: readonly AgentMessage[],
71
+ startIndex: number,
72
+ fallback: SubagentStopReason = "error",
73
+ ): SubagentStopReason {
74
+ for (let index = messages.length - 1; index >= startIndex; index--) {
75
+ const message = messages[index];
76
+ if (message.role !== "assistant") continue;
77
+ switch (message.stopReason) {
78
+ case "stop":
79
+ return "completed";
80
+ case "length":
81
+ return "max-tokens";
82
+ case "aborted":
83
+ return "aborted";
84
+ case "error":
85
+ return "error";
86
+ case "toolUse":
87
+ case "pending":
88
+ return fallback;
89
+ default:
90
+ return fallback;
91
+ }
92
+ }
93
+ return fallback;
94
+ }
95
+
96
+ export function formatUsage(usage: SubagentUsage): string {
97
+ const parts = [`${usage.turns} turn${usage.turns === 1 ? "" : "s"}`];
98
+ if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
99
+ if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
100
+ if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
101
+ if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
102
+ if (usage.cost.total) parts.push(`$${usage.cost.total.toFixed(4)}`);
103
+ return parts.join(" ");
104
+ }
105
+
106
+ function formatTokens(count: number): string {
107
+ if (count < 1000) return String(count);
108
+ if (count < 10_000) return `${(count / 1000).toFixed(1)}k`;
109
+ if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
110
+ return `${(count / 1_000_000).toFixed(1)}M`;
111
+ }
112
+
113
+ export function formatToolArguments(name: string, args: Record<string, unknown>): string {
114
+ if (name === "bash" && typeof args.command === "string") {
115
+ return `$ ${singleLine(args.command, 100)}`;
116
+ }
117
+ const path = args.path ?? args.file_path;
118
+ if (typeof path === "string" && ["read", "write", "edit", "ls"].includes(name)) {
119
+ return `${name} ${singleLine(path, 100)}`;
120
+ }
121
+ if (name === "grep" && typeof args.pattern === "string") {
122
+ return `grep /${singleLine(args.pattern, 60)}/`;
123
+ }
124
+ const encoded = JSON.stringify(args);
125
+ return `${name} ${singleLine(encoded, 100)}`;
126
+ }
127
+
128
+ function singleLine(value: string, limit: number): string {
129
+ const line = value.replace(/\s+/g, " ").trim();
130
+ return line.length > limit ? `${line.slice(0, limit)}…` : line;
131
+ }
package/src/schemas.ts ADDED
@@ -0,0 +1,130 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import { Type } from "typebox";
3
+
4
+ function agentNameParameter(agentNames?: readonly string[]) {
5
+ if (agentNames === undefined) {
6
+ return Type.String({
7
+ description: "Agent definition name",
8
+ minLength: 1,
9
+ maxLength: 64,
10
+ });
11
+ }
12
+ return StringEnum([...new Set(agentNames)], {
13
+ description: "Available agent definition name",
14
+ });
15
+ }
16
+
17
+ function delegationFields(agentNames?: readonly string[]) {
18
+ return {
19
+ agent: agentNameParameter(agentNames),
20
+ description: Type.String({
21
+ description: "Short 3-5 word display label for the delegated task",
22
+ minLength: 1,
23
+ maxLength: 200,
24
+ }),
25
+ prompt: Type.String({
26
+ description: "Complete task for the child agent",
27
+ minLength: 1,
28
+ }),
29
+ };
30
+ }
31
+
32
+ function forkDelegationFields(agentNames?: readonly string[]) {
33
+ return {
34
+ ...delegationFields(agentNames),
35
+ prompt: Type.String({
36
+ description:
37
+ "Task for a child that already sees all completed parent turns; state only the new work",
38
+ minLength: 1,
39
+ }),
40
+ };
41
+ }
42
+
43
+ function createDelegationParameters(
44
+ enableRunInBackground: boolean,
45
+ agentNames?: readonly string[],
46
+ ) {
47
+ const fields = delegationFields(agentNames);
48
+ return Type.Object(
49
+ enableRunInBackground
50
+ ? {
51
+ ...fields,
52
+ run_in_background: Type.Optional(
53
+ Type.Boolean({
54
+ description:
55
+ "Run as a continuable background child. The spawn provider defaults this from configuration.",
56
+ }),
57
+ ),
58
+ }
59
+ : fields,
60
+ { additionalProperties: false },
61
+ );
62
+ }
63
+
64
+ export const ForegroundDelegationParameters = createDelegationParameters(false);
65
+
66
+ export const DelegationParameters = createDelegationParameters(true);
67
+
68
+ export function delegationParameters(
69
+ enableRunInBackground: boolean,
70
+ agentNames?: readonly string[],
71
+ ) {
72
+ if (agentNames === undefined) {
73
+ return enableRunInBackground ? DelegationParameters : ForegroundDelegationParameters;
74
+ }
75
+ return createDelegationParameters(enableRunInBackground, agentNames);
76
+ }
77
+
78
+ export const ForkDelegationParameters = Type.Object(
79
+ forkDelegationFields(),
80
+ { additionalProperties: false },
81
+ );
82
+
83
+ export function forkDelegationParameters(agentNames?: readonly string[]) {
84
+ if (agentNames === undefined) return ForkDelegationParameters;
85
+ return Type.Object(forkDelegationFields(agentNames), { additionalProperties: false });
86
+ }
87
+
88
+ export const SendMessageParameters = Type.Object(
89
+ {
90
+ subagent_id: Type.String({ description: "Durable id of a direct continuable child", minLength: 1 }),
91
+ message: Type.String({
92
+ description: "Message to enqueue as the child's next FIFO turn",
93
+ minLength: 1,
94
+ }),
95
+ },
96
+ { additionalProperties: false },
97
+ );
98
+
99
+ export const InterruptParameters = Type.Object(
100
+ {
101
+ agent_id: Type.String({
102
+ description: "Id of a live child or deeper descendant whose current turn should stop",
103
+ minLength: 1,
104
+ }),
105
+ },
106
+ { additionalProperties: false },
107
+ );
108
+
109
+ export const ListAgentsParameters = Type.Object(
110
+ {
111
+ scope: Type.Optional(
112
+ StringEnum(["children", "descendants"] as const, {
113
+ description: "List direct continuable children (default) or the complete descendant tree",
114
+ default: "children",
115
+ }),
116
+ ),
117
+ },
118
+ { additionalProperties: false },
119
+ );
120
+
121
+ export const ReportParameters = Type.Object(
122
+ {
123
+ output: Type.String({
124
+ description:
125
+ "Self-contained update for the agent that started you. Reporting does not end your turn.",
126
+ minLength: 1,
127
+ }),
128
+ },
129
+ { additionalProperties: false },
130
+ );
@@ -0,0 +1,131 @@
1
+ export const MUTATION_TOOL_GROUP = "$mutation";
2
+
3
+ const TOOL_GROUP_PREFIX = "$";
4
+ const MUTATION_TOOL_CANDIDATES = ["apply_patch", "edit", "write"] as const;
5
+
6
+ export interface ToolPolicyOptions {
7
+ requested: readonly string[] | undefined;
8
+ mandatory?: readonly string[];
9
+ denied?: readonly string[];
10
+ }
11
+
12
+ export interface ResolvedToolPolicy {
13
+ activeTools: string[];
14
+ resolvedRequestedTools?: string[];
15
+ }
16
+
17
+ function unique(values: Iterable<string>): string[] {
18
+ return [...new Set(values)];
19
+ }
20
+
21
+ export function assertSupportedToolReferences(tools: readonly string[], field = "tools"): void {
22
+ const unsupported = tools.find(
23
+ (tool) => tool.startsWith(TOOL_GROUP_PREFIX) && tool !== MUTATION_TOOL_GROUP,
24
+ );
25
+ if (unsupported) {
26
+ throw new Error(
27
+ `${field} contains unsupported logical tool "${unsupported}"; supported logical tools: ${MUTATION_TOOL_GROUP}`,
28
+ );
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Build Pi's hard tool registry ceiling before child extensions initialize.
34
+ *
35
+ * Logical groups expand to every implementation that an extension may choose.
36
+ * The post-initialization resolver narrows this ceiling to the implementation
37
+ * that the selected model and its extensions actually left active.
38
+ */
39
+ export function buildToolCeiling(options: ToolPolicyOptions): string[] | undefined {
40
+ if (options.requested === undefined) return undefined;
41
+ assertSupportedToolReferences(options.requested);
42
+
43
+ const denied = new Set(options.denied ?? []);
44
+ const requestedDenied = options.requested.find((tool) => denied.has(tool));
45
+ if (requestedDenied) {
46
+ throw new Error(`tool "${requestedDenied}" is unavailable in this subagent mode`);
47
+ }
48
+
49
+ const ceiling: string[] = [];
50
+ for (const tool of options.requested) {
51
+ if (tool === MUTATION_TOOL_GROUP) {
52
+ ceiling.push(...MUTATION_TOOL_CANDIDATES.filter((candidate) => !denied.has(candidate)));
53
+ } else if (!denied.has(tool)) {
54
+ ceiling.push(tool);
55
+ }
56
+ }
57
+ for (const tool of options.mandatory ?? []) {
58
+ if (!denied.has(tool)) ceiling.push(tool);
59
+ }
60
+ return unique(ceiling);
61
+ }
62
+
63
+ function resolveMutationTools(registered: Set<string>, active: Set<string>): string[] {
64
+ if (registered.has("apply_patch") && active.has("apply_patch")) return ["apply_patch"];
65
+ const native = MUTATION_TOOL_CANDIDATES
66
+ .filter((tool) => tool !== "apply_patch")
67
+ .filter((tool) => registered.has(tool) && active.has(tool));
68
+ if (native.length > 0) return native;
69
+ throw new Error(
70
+ `${MUTATION_TOOL_GROUP} has no active implementation; apply_patch, edit, and write are unavailable for the selected model and child extensions`,
71
+ );
72
+ }
73
+
74
+ /**
75
+ * Apply the agent policy after extension `session_start` handlers have selected
76
+ * model-specific tools. Explicit names may narrow that selection but never
77
+ * reactivate a tool that an extension left inactive.
78
+ */
79
+ export function resolveToolPolicy(
80
+ options: ToolPolicyOptions & {
81
+ registered: readonly string[];
82
+ active: readonly string[];
83
+ },
84
+ ): ResolvedToolPolicy {
85
+ assertSupportedToolReferences(options.requested ?? []);
86
+ const registered = new Set(options.registered);
87
+ const active = new Set(options.active);
88
+ const mandatory = unique(options.mandatory ?? []);
89
+ const mandatorySet = new Set(mandatory);
90
+ const denied = new Set(options.denied ?? []);
91
+
92
+ for (const tool of mandatory) {
93
+ if (denied.has(tool)) throw new Error(`mandatory tool "${tool}" is denied by the runtime`);
94
+ if (!registered.has(tool)) throw new Error(`mandatory tool "${tool}" is not registered`);
95
+ }
96
+
97
+ if (options.requested === undefined) {
98
+ const selected = options.active.filter((tool) => !denied.has(tool));
99
+ for (const tool of mandatory) {
100
+ if (!selected.includes(tool)) selected.push(tool);
101
+ }
102
+ return { activeTools: unique(selected) };
103
+ }
104
+
105
+ const resolvedRequestedTools: string[] = [];
106
+ for (const tool of options.requested) {
107
+ if (denied.has(tool)) throw new Error(`tool "${tool}" is unavailable in this subagent mode`);
108
+ if (tool === MUTATION_TOOL_GROUP) {
109
+ resolvedRequestedTools.push(...resolveMutationTools(registered, active));
110
+ continue;
111
+ }
112
+ if (!registered.has(tool)) {
113
+ throw new Error(`requested tool "${tool}" is not registered by Pi or a loaded child extension`);
114
+ }
115
+ if (!active.has(tool) && !mandatorySet.has(tool)) {
116
+ throw new Error(
117
+ `requested tool "${tool}" is inactive for the selected model or child extension policy`,
118
+ );
119
+ }
120
+ resolvedRequestedTools.push(tool);
121
+ }
122
+
123
+ const selected = unique(resolvedRequestedTools.filter((tool) => !denied.has(tool)));
124
+ for (const tool of mandatory) {
125
+ if (!selected.includes(tool)) selected.push(tool);
126
+ }
127
+ return {
128
+ activeTools: selected,
129
+ resolvedRequestedTools: unique(resolvedRequestedTools),
130
+ };
131
+ }