@ymy88/pi-subagents 0.0.0-stage → 0.24.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.
- package/CHANGELOG.md +904 -0
- package/CONTRIBUTING.md +72 -0
- package/LICENSE +21 -0
- package/README.md +1170 -2
- package/SECURITY.md +95 -0
- package/dist/abortable.d.ts +12 -0
- package/dist/abortable.js +42 -0
- package/dist/agent-color.d.ts +35 -0
- package/dist/agent-color.js +123 -0
- package/dist/agent-file-toggle.d.ts +133 -0
- package/dist/agent-file-toggle.js +287 -0
- package/dist/agent-manager.d.ts +552 -0
- package/dist/agent-manager.js +1466 -0
- package/dist/agent-record-entry.d.ts +96 -0
- package/dist/agent-record-entry.js +121 -0
- package/dist/agent-runner.d.ts +310 -0
- package/dist/agent-runner.js +1195 -0
- package/dist/agent-source-badge.d.ts +50 -0
- package/dist/agent-source-badge.js +74 -0
- package/dist/agent-types.d.ts +119 -0
- package/dist/agent-types.js +286 -0
- package/dist/child-context.d.ts +2 -0
- package/dist/child-context.js +12 -0
- package/dist/completion-outbox.d.ts +63 -0
- package/dist/completion-outbox.js +104 -0
- package/dist/context.d.ts +12 -0
- package/dist/context.js +56 -0
- package/dist/cross-extension-rpc.d.ts +72 -0
- package/dist/cross-extension-rpc.js +153 -0
- package/dist/custom-agents.d.ts +77 -0
- package/dist/custom-agents.js +396 -0
- package/dist/default-agents.d.ts +7 -0
- package/dist/default-agents.js +121 -0
- package/dist/enabled-models.d.ts +49 -0
- package/dist/enabled-models.js +145 -0
- package/dist/env.d.ts +6 -0
- package/dist/env.js +28 -0
- package/dist/group-join.d.ts +32 -0
- package/dist/group-join.js +116 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +4111 -0
- package/dist/invocation-config.d.ts +108 -0
- package/dist/invocation-config.js +83 -0
- package/dist/memory.d.ts +53 -0
- package/dist/memory.js +165 -0
- package/dist/mention-clone.d.ts +79 -0
- package/dist/mention-clone.js +155 -0
- package/dist/mention.d.ts +81 -0
- package/dist/mention.js +131 -0
- package/dist/model-resolver.d.ts +36 -0
- package/dist/model-resolver.js +95 -0
- package/dist/model-scope.d.ts +49 -0
- package/dist/model-scope.js +48 -0
- package/dist/nested-tools.d.ts +57 -0
- package/dist/nested-tools.js +303 -0
- package/dist/output-file.d.ts +43 -0
- package/dist/output-file.js +153 -0
- package/dist/package-resources.d.ts +240 -0
- package/dist/package-resources.js +519 -0
- package/dist/prompts.d.ts +76 -0
- package/dist/prompts.js +121 -0
- package/dist/schedule-store.d.ts +38 -0
- package/dist/schedule-store.js +155 -0
- package/dist/schedule.d.ts +109 -0
- package/dist/schedule.js +359 -0
- package/dist/settings.d.ts +398 -0
- package/dist/settings.js +282 -0
- package/dist/skill-loader.d.ts +24 -0
- package/dist/skill-loader.js +93 -0
- package/dist/status-note.d.ts +61 -0
- package/dist/status-note.js +85 -0
- package/dist/structured-output.d.ts +61 -0
- package/dist/structured-output.js +112 -0
- package/dist/types.d.ts +391 -0
- package/dist/types.js +5 -0
- package/dist/ui/agent-mention.d.ts +82 -0
- package/dist/ui/agent-mention.js +187 -0
- package/dist/ui/agent-widget.d.ts +219 -0
- package/dist/ui/agent-widget.js +585 -0
- package/dist/ui/conversation-viewer.d.ts +177 -0
- package/dist/ui/conversation-viewer.js +905 -0
- package/dist/ui/fleet-list.d.ts +211 -0
- package/dist/ui/fleet-list.js +489 -0
- package/dist/ui/schedule-menu.d.ts +16 -0
- package/dist/ui/schedule-menu.js +94 -0
- package/dist/ui/select-item.d.ts +27 -0
- package/dist/ui/select-item.js +34 -0
- package/dist/ui/viewer-keys.d.ts +22 -0
- package/dist/ui/viewer-keys.js +18 -0
- package/dist/ui/workflow-card.d.ts +175 -0
- package/dist/ui/workflow-card.js +332 -0
- package/dist/ui/workflow-dialog.d.ts +305 -0
- package/dist/ui/workflow-dialog.js +843 -0
- package/dist/ui/workflow-menu.d.ts +60 -0
- package/dist/ui/workflow-menu.js +147 -0
- package/dist/usage.d.ts +135 -0
- package/dist/usage.js +120 -0
- package/dist/workflow/collisions.d.ts +95 -0
- package/dist/workflow/collisions.js +88 -0
- package/dist/workflow/entry.d.ts +32 -0
- package/dist/workflow/entry.js +29 -0
- package/dist/workflow/host.d.ts +62 -0
- package/dist/workflow/host.js +419 -0
- package/dist/workflow/journal.d.ts +97 -0
- package/dist/workflow/journal.js +120 -0
- package/dist/workflow/json-schema.d.ts +52 -0
- package/dist/workflow/json-schema.js +112 -0
- package/dist/workflow/meta.d.ts +67 -0
- package/dist/workflow/meta.js +317 -0
- package/dist/workflow/progress.d.ts +224 -0
- package/dist/workflow/progress.js +361 -0
- package/dist/workflow/runtime.d.ts +334 -0
- package/dist/workflow/runtime.js +830 -0
- package/dist/workflow/saved.d.ts +102 -0
- package/dist/workflow/saved.js +247 -0
- package/dist/workflow/task.d.ts +136 -0
- package/dist/workflow/task.js +207 -0
- package/dist/workflow/tool-description.d.ts +38 -0
- package/dist/workflow/tool-description.js +199 -0
- package/dist/workflow/worker-source.d.ts +47 -0
- package/dist/workflow/worker-source.js +778 -0
- package/dist/worktree.d.ts +52 -0
- package/dist/worktree.js +164 -0
- package/dist/xml.d.ts +10 -0
- package/dist/xml.js +12 -0
- package/docs/packages.md +211 -0
- package/docs/rpc.md +192 -0
- package/docs/workflows.md +447 -0
- package/examples/agent-tool-description.md +42 -0
- package/examples/workflows/compose.js +51 -0
- package/examples/workflows/fan-out-audit.js +47 -0
- package/examples/workflows/gated-fix.js +60 -0
- package/examples/workflows/lib/count-child.js +27 -0
- package/examples/workflows/review-panel.js +63 -0
- package/examples/workflows/structured-findings.js +78 -0
- package/package.json +74 -4
- package/src/abortable.ts +43 -0
- package/src/agent-color.ts +161 -0
- package/src/agent-file-toggle.ts +297 -0
- package/src/agent-manager.ts +1735 -0
- package/src/agent-record-entry.ts +153 -0
- package/src/agent-runner.ts +1468 -0
- package/src/agent-source-badge.ts +75 -0
- package/src/agent-types.ts +346 -0
- package/src/child-context.ts +15 -0
- package/src/completion-outbox.ts +116 -0
- package/src/context.ts +58 -0
- package/src/cross-extension-rpc.ts +221 -0
- package/src/custom-agents.ts +452 -0
- package/src/default-agents.ts +125 -0
- package/src/enabled-models.ts +180 -0
- package/src/env.ts +33 -0
- package/src/group-join.ts +141 -0
- package/src/index.ts +4433 -0
- package/src/invocation-config.ts +155 -0
- package/src/memory.ts +179 -0
- package/src/mention-clone.ts +195 -0
- package/src/mention.ts +141 -0
- package/src/model-resolver.ts +118 -0
- package/src/model-scope.ts +70 -0
- package/src/nested-tools.ts +426 -0
- package/src/output-file.ts +164 -0
- package/src/package-resources.ts +652 -0
- package/src/prompts.ts +171 -0
- package/src/schedule-store.ts +153 -0
- package/src/schedule.ts +386 -0
- package/src/settings.ts +650 -0
- package/src/skill-loader.ts +102 -0
- package/src/status-note.ts +90 -0
- package/src/structured-output.ts +130 -0
- package/src/types.ts +404 -0
- package/src/ui/agent-mention.ts +216 -0
- package/src/ui/agent-widget.ts +661 -0
- package/src/ui/conversation-viewer.ts +928 -0
- package/src/ui/fleet-list.ts +559 -0
- package/src/ui/schedule-menu.ts +105 -0
- package/src/ui/select-item.ts +45 -0
- package/src/ui/viewer-keys.ts +43 -0
- package/src/ui/workflow-card.ts +470 -0
- package/src/ui/workflow-dialog.ts +1115 -0
- package/src/ui/workflow-menu.ts +193 -0
- package/src/usage.ts +167 -0
- package/src/workflow/collisions.ts +123 -0
- package/src/workflow/entry.ts +47 -0
- package/src/workflow/host.ts +467 -0
- package/src/workflow/journal.ts +164 -0
- package/src/workflow/json-schema.ts +129 -0
- package/src/workflow/meta.ts +325 -0
- package/src/workflow/progress.ts +551 -0
- package/src/workflow/runtime.ts +1219 -0
- package/src/workflow/saved.ts +259 -0
- package/src/workflow/task.ts +302 -0
- package/src/workflow/tool-description.ts +200 -0
- package/src/workflow/worker-source.ts +781 -0
- package/src/worktree.ts +205 -0
- package/src/xml.ts +13 -0
|
@@ -0,0 +1,1195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results.
|
|
3
|
+
*/
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
7
|
+
import { createAgentSession, createCodemodeExtension, createMcpExtension, createToolSearchExtension, DefaultResourceLoader, getAgentDir, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
|
|
9
|
+
import { runInChildSessionContext } from "./child-context.js";
|
|
10
|
+
import { buildParentContext, extractText } from "./context.js";
|
|
11
|
+
import { DEFAULT_AGENTS } from "./default-agents.js";
|
|
12
|
+
import { detectEnv } from "./env.js";
|
|
13
|
+
import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
|
|
14
|
+
import { createNestedSubagentTools, getMaxSubagentDepth } from "./nested-tools.js";
|
|
15
|
+
import { buildAgentPrompt, inheritableParentPrompt } from "./prompts.js";
|
|
16
|
+
import { preloadSkills } from "./skill-loader.js";
|
|
17
|
+
import { createStructuredCapture, createStructuredOutputTool, structuredRetryPrompt } from "./structured-output.js";
|
|
18
|
+
/**
|
|
19
|
+
* Tool names registered by THIS extension. Single source of truth so the
|
|
20
|
+
* registration sites (index.ts) and the subagent exclusion list below can't
|
|
21
|
+
* drift apart. These are our own tools, not pi built-ins, so they can't be
|
|
22
|
+
* derived from pi — but they only need defining once.
|
|
23
|
+
*/
|
|
24
|
+
export const SUBAGENT_TOOL_NAMES = {
|
|
25
|
+
AGENT: "Agent",
|
|
26
|
+
WORKFLOW: "SubagentWorkflow",
|
|
27
|
+
GET_RESULT: "get_subagent_result",
|
|
28
|
+
STEER: "steer_subagent",
|
|
29
|
+
};
|
|
30
|
+
/** Names of tools registered by this extension that subagents must NOT inherit. */
|
|
31
|
+
const EXCLUDED_TOOL_NAMES = Object.values(SUBAGENT_TOOL_NAMES);
|
|
32
|
+
/**
|
|
33
|
+
* Canonical name of an extension for `extensions: [...]` allowlist matching.
|
|
34
|
+
* Lowercased — extension names match case-insensitively so `extensions: [Mcp]`
|
|
35
|
+
* resolves the same as `[mcp]`. Tool names within `ext:foo/bar` are not affected.
|
|
36
|
+
* Directory extensions (`foo/index.ts`) resolve to the parent directory name;
|
|
37
|
+
* single-file extensions to the basename minus `.ts`/`.js`.
|
|
38
|
+
*/
|
|
39
|
+
export function extensionCanonicalName(extPath) {
|
|
40
|
+
const base = basename(extPath);
|
|
41
|
+
const name = base === "index.ts" || base === "index.js"
|
|
42
|
+
? basename(dirname(extPath))
|
|
43
|
+
: base.replace(/\.(ts|js)$/, "");
|
|
44
|
+
return name.toLowerCase();
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The unscoped, lowercased npm short name of the pi package that DECLARES
|
|
48
|
+
* `extPath` as an extension entry — or undefined if the entry doesn't belong to
|
|
49
|
+
* such a package.
|
|
50
|
+
*
|
|
51
|
+
* Climbs from the entry's directory looking for the package that owns it, and
|
|
52
|
+
* stays strictly within that package's tree by stopping at two structural
|
|
53
|
+
* boundaries — no hardcoded depth:
|
|
54
|
+
* - the FIRST `package.json` found (the package root); the entry's own
|
|
55
|
+
* manifest always sits at the root, above the entry, below any node_modules.
|
|
56
|
+
* - a `node_modules` directory: a package never spans one (it's where OTHER
|
|
57
|
+
* packages live), so reaching it means we've climbed out of the package —
|
|
58
|
+
* stop before reading a consumer's or parent package's manifest.
|
|
59
|
+
* The name is then taken only when that root's `pi.extensions` manifest declares
|
|
60
|
+
* this entry, either as the file itself or as a directory containing it (pi
|
|
61
|
+
* expands `"./dist"` to `dist/index.js`). That "declares this entry" check is
|
|
62
|
+
* deliberate: our own test fixtures live under this repo, whose root manifest
|
|
63
|
+
* declares `./src/index.ts` as `@tintinweb/pi-subagents`, so a looser rule would
|
|
64
|
+
* misattribute every co-located file to `pi-subagents`.
|
|
65
|
+
*/
|
|
66
|
+
function extensionPackageName(extPath) {
|
|
67
|
+
// `builtin:<name>` names no file; climbing from it would read the process cwd's manifest.
|
|
68
|
+
if (extPath.startsWith("builtin:"))
|
|
69
|
+
return undefined;
|
|
70
|
+
const entry = resolve(extPath);
|
|
71
|
+
let dir = dirname(extPath);
|
|
72
|
+
for (;;) {
|
|
73
|
+
// Climbing into node_modules means we've left the owning package's tree.
|
|
74
|
+
if (basename(dir) === "node_modules")
|
|
75
|
+
return undefined;
|
|
76
|
+
let pkg;
|
|
77
|
+
try {
|
|
78
|
+
pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8"));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
const parent = dirname(dir);
|
|
82
|
+
if (parent === dir)
|
|
83
|
+
return undefined; // walked to the filesystem root
|
|
84
|
+
dir = parent;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
// First package.json wins — it's the package root; decide here.
|
|
88
|
+
const entries = pkg.pi?.extensions;
|
|
89
|
+
if (typeof pkg.name === "string" &&
|
|
90
|
+
Array.isArray(entries) &&
|
|
91
|
+
entries.some((e) => {
|
|
92
|
+
if (typeof e !== "string")
|
|
93
|
+
return false;
|
|
94
|
+
// A declaration may name a directory (`"./dist"`) that pi resolves to
|
|
95
|
+
// `dist/index.js`, so an entry belongs to this package when it equals the
|
|
96
|
+
// declaration or lives underneath it. The `${sep}` boundary keeps that
|
|
97
|
+
// prefix test platform-correct (`dist-v2` never matches `"./dist"`).
|
|
98
|
+
const declared = resolve(dir, e);
|
|
99
|
+
return declared === entry || entry.startsWith(`${declared}${sep}`);
|
|
100
|
+
})) {
|
|
101
|
+
const short = pkg.name.startsWith("@") ? pkg.name.slice(pkg.name.indexOf("/") + 1) : pkg.name;
|
|
102
|
+
return short.toLowerCase();
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* All names an extension answers to for allowlist matching (lowercased): its
|
|
109
|
+
* path-derived {@link extensionCanonicalName} plus, when a pi package manifest
|
|
110
|
+
* declares this entry, that package's unscoped short name (`@scope/foo` → `foo`).
|
|
111
|
+
* #143: an extension installed via `pi.extensions: ["./src/index.ts"]` would
|
|
112
|
+
* otherwise only ever match as `src` (the source directory), never by its
|
|
113
|
+
* package name. The path-derived name is preserved, so it keeps matching too.
|
|
114
|
+
*/
|
|
115
|
+
export function extensionCanonicalNames(extPath) {
|
|
116
|
+
const canonical = extensionCanonicalName(extPath);
|
|
117
|
+
const pkg = extensionPackageName(extPath);
|
|
118
|
+
return pkg && pkg !== canonical ? [canonical, pkg] : [canonical];
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Classify `extensions: string[]` frontmatter entries for the loader-level filter.
|
|
122
|
+
*
|
|
123
|
+
* An entry is a PATH iff it contains a path separator or starts with `~`; otherwise
|
|
124
|
+
* it is a NAME. `"*"` sets the wildcard flag (keep all default-discovered extensions).
|
|
125
|
+
*
|
|
126
|
+
* Path entries are resolved (`~` expanded, made absolute against `cwd`) into `paths`
|
|
127
|
+
* — and their canonical name is also added to `names`. The loader override matches
|
|
128
|
+
* everything by canonical name, so path-loaded extensions are matched via their name
|
|
129
|
+
* rather than their post-staging `Extension.path`.
|
|
130
|
+
*/
|
|
131
|
+
export function parseExtensionsSpec(entries, cwd) {
|
|
132
|
+
const names = new Set();
|
|
133
|
+
const paths = [];
|
|
134
|
+
let wildcard = false;
|
|
135
|
+
for (const entry of entries) {
|
|
136
|
+
if (!entry)
|
|
137
|
+
continue;
|
|
138
|
+
if (entry === "*") {
|
|
139
|
+
wildcard = true;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const isPathEntry = entry.includes("/") || entry.includes("\\") || entry.startsWith("~");
|
|
143
|
+
if (!isPathEntry) {
|
|
144
|
+
names.add(entry.toLowerCase());
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
let p = entry;
|
|
148
|
+
if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) {
|
|
149
|
+
p = homedir() + p.slice(1);
|
|
150
|
+
}
|
|
151
|
+
const abs = isAbsolute(p) ? p : resolve(cwd, p);
|
|
152
|
+
paths.push(abs);
|
|
153
|
+
names.add(extensionCanonicalName(abs));
|
|
154
|
+
}
|
|
155
|
+
return { names, paths, wildcard };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Parse raw `ext:` selector strings (from the `tools:` CSV) into the set of
|
|
159
|
+
* extension names to keep loaded and a per-extension tool-narrowing map.
|
|
160
|
+
*
|
|
161
|
+
* `ext:foo` → `extNames` has `foo`, no narrowing entry (all of foo's tools).
|
|
162
|
+
* `ext:foo/bar` → `extNames` has `foo`, `narrowing.foo` has `bar` (only `bar`).
|
|
163
|
+
* A name lands in `narrowing` only when a `/tool` form is seen, so a bare
|
|
164
|
+
* `ext:foo` alongside `ext:foo/bar` leaves narrowing in effect (narrowing wins).
|
|
165
|
+
* The split is on the first `/`; extension canonical names never contain `/`.
|
|
166
|
+
*/
|
|
167
|
+
export function parseExtSelectors(entries) {
|
|
168
|
+
const extNames = new Set();
|
|
169
|
+
const narrowing = new Map();
|
|
170
|
+
for (const raw of entries) {
|
|
171
|
+
if (!raw)
|
|
172
|
+
continue;
|
|
173
|
+
const body = raw.slice("ext:".length);
|
|
174
|
+
const slash = body.indexOf("/");
|
|
175
|
+
// Extension name matches case-insensitively (matches the loader-side canonical
|
|
176
|
+
// name). Tool names are case-preserved — they're matched against pi-mono's
|
|
177
|
+
// registered identifiers, which are case-sensitive.
|
|
178
|
+
const name = (slash === -1 ? body : body.slice(0, slash)).trim().toLowerCase();
|
|
179
|
+
if (!name)
|
|
180
|
+
continue;
|
|
181
|
+
extNames.add(name);
|
|
182
|
+
if (slash === -1)
|
|
183
|
+
continue;
|
|
184
|
+
const tool = body.slice(slash + 1).trim();
|
|
185
|
+
if (!tool)
|
|
186
|
+
continue;
|
|
187
|
+
let set = narrowing.get(name);
|
|
188
|
+
if (!set) {
|
|
189
|
+
set = new Set();
|
|
190
|
+
narrowing.set(name, set);
|
|
191
|
+
}
|
|
192
|
+
set.add(tool);
|
|
193
|
+
}
|
|
194
|
+
return { extNames, narrowing };
|
|
195
|
+
}
|
|
196
|
+
/** pi's MCP resource tools, which it counts as MCP tools alongside `mcp__<server>__<tool>`. */
|
|
197
|
+
const MCP_RESOURCE_TOOL_NAMES = new Set(["list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"]);
|
|
198
|
+
/** The tools that reach `codemode`/`deferred` MCP tools: neither is declared to the model. */
|
|
199
|
+
const MCP_BRIDGE_TOOL_NAMES = new Set(["codemode", "tool_search"]);
|
|
200
|
+
const isMcpToolName = (name) => name.startsWith("mcp__") || MCP_RESOURCE_TOOL_NAMES.has(name);
|
|
201
|
+
/** Whether `name` matches one of the `tools:` `mcp__` entries: an exact name or a `*` pattern. */
|
|
202
|
+
function matchesToolPattern(name, patterns) {
|
|
203
|
+
return patterns.some((p) => p.includes("*")
|
|
204
|
+
? new RegExp(`^${p.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*")}$`).test(name)
|
|
205
|
+
: p === name);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Keep a subagent's tool scope correct as extensions register tools over time.
|
|
209
|
+
*
|
|
210
|
+
* Extensions may call `registerTool` long after load — pi-mcp from `session_start`,
|
|
211
|
+
* context-mode from `before_agent_start` — so scope has to be re-derived rather than
|
|
212
|
+
* snapshotted. `registerTool` writes into the very `extension.tools` maps this reads,
|
|
213
|
+
* so `inScope()` sees late arrivals on the next call.
|
|
214
|
+
*
|
|
215
|
+
* Two enforcement points, because neither covers the whole picture:
|
|
216
|
+
*
|
|
217
|
+
* - `turn_end` re-narrows the ACTIVE set. pi emits `turn_end` immediately before
|
|
218
|
+
* `prepareNextTurn` re-snapshots `agent.state.tools`, and session listeners run
|
|
219
|
+
* synchronously, so the narrow lands in time for turns 2..N.
|
|
220
|
+
* - `beforeToolCall` blocks out-of-scope calls. Turn 1 cannot be narrowed at all:
|
|
221
|
+
* `before_agent_start` fires INSIDE `prompt()` and may widen the tool set, but
|
|
222
|
+
* `createContextSnapshot()` freezes that turn's tools immediately after — there
|
|
223
|
+
* is no hook in between. A call-time check is the only correct guard there.
|
|
224
|
+
*
|
|
225
|
+
* Both are installed on the session and deliberately NOT unsubscribed: they must
|
|
226
|
+
* outlive the `runAgent` call so resumed/steered turns stay scoped. pi's `dispose()`
|
|
227
|
+
* clears `_eventListeners`, so they die with the session rather than leaking.
|
|
228
|
+
*
|
|
229
|
+
* Only meaningful when extensions are loaded — under `noExtensions`/`isolated` the
|
|
230
|
+
* static `allowedToolNames` allowlist already gates the registry itself.
|
|
231
|
+
*/
|
|
232
|
+
export function installExtensionToolScope(session, ctx) {
|
|
233
|
+
const { loader, toolNames, disallowedSet, extNames, narrowing, mcpToolPatterns, readmitToolNames, nestedCallGate } = ctx;
|
|
234
|
+
// The names allowed right now. Mirrors the `ext:` opt-in flip: when any `ext:`
|
|
235
|
+
// selector is present, extension tools become an explicit allowlist — a loaded
|
|
236
|
+
// extension not named by a selector contributes nothing (its handlers still ran),
|
|
237
|
+
// and `ext:foo/bar` narrows `foo` to just `bar`.
|
|
238
|
+
const inScope = () => {
|
|
239
|
+
const keep = new Set(toolNames.filter((t) => !disallowedSet?.has(t)));
|
|
240
|
+
const optInActive = extNames.size > 0;
|
|
241
|
+
for (const extension of loader.getExtensions().extensions) {
|
|
242
|
+
const canons = extensionCanonicalNames(extension.path);
|
|
243
|
+
if (optInActive && !canons.some((c) => extNames.has(c)))
|
|
244
|
+
continue;
|
|
245
|
+
// First alias that carries a narrowing set — a user won't narrow one
|
|
246
|
+
// extension under two different names, so first-match is correct.
|
|
247
|
+
const narrowed = canons.map((c) => narrowing.get(c)).find(Boolean);
|
|
248
|
+
for (const name of extension.tools.keys()) {
|
|
249
|
+
if (narrowed && !narrowed.has(name))
|
|
250
|
+
continue;
|
|
251
|
+
if (disallowedSet?.has(name))
|
|
252
|
+
continue;
|
|
253
|
+
keep.add(name);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// Bare `mcp__` entries, as in pi's `--tools`: only matching MCP tools stay, from
|
|
257
|
+
// whichever extension serves them (pi's MCP or an adapter), even one the `ext:`
|
|
258
|
+
// opt-in left out — and so do the bridges that reach them.
|
|
259
|
+
if (mcpToolPatterns) {
|
|
260
|
+
for (const name of keep) {
|
|
261
|
+
if (isMcpToolName(name) && !matchesToolPattern(name, mcpToolPatterns))
|
|
262
|
+
keep.delete(name);
|
|
263
|
+
}
|
|
264
|
+
for (const extension of loader.getExtensions().extensions) {
|
|
265
|
+
for (const name of extension.tools.keys()) {
|
|
266
|
+
if (disallowedSet?.has(name))
|
|
267
|
+
continue;
|
|
268
|
+
if (MCP_BRIDGE_TOOL_NAMES.has(name) || matchesToolPattern(name, mcpToolPatterns))
|
|
269
|
+
keep.add(name);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
for (const name of EXCLUDED_TOOL_NAMES)
|
|
274
|
+
keep.delete(name);
|
|
275
|
+
// Injected tools are legitimately active for this agent — re-admit them so
|
|
276
|
+
// the renarrow keeps them in the active set and beforeToolCall doesn't
|
|
277
|
+
// block them. Already vetted against `disallowed_tools` by the caller,
|
|
278
|
+
// which is the only place that knows which kind may be taken back.
|
|
279
|
+
for (const name of readmitToolNames)
|
|
280
|
+
keep.add(name);
|
|
281
|
+
return keep;
|
|
282
|
+
};
|
|
283
|
+
// Names this agent selects explicitly: its built-ins, injected tools, `ext:foo/bar`
|
|
284
|
+
// narrowings and exact `mcp__` entries. pi activates a named tool even when it is
|
|
285
|
+
// `defaultActive: false`, as `--tools` does.
|
|
286
|
+
const named = new Set([
|
|
287
|
+
...toolNames,
|
|
288
|
+
...readmitToolNames,
|
|
289
|
+
...[...narrowing.values()].flatMap((s) => [...s]),
|
|
290
|
+
...(mcpToolPatterns ?? []).filter((p) => !p.includes("*")),
|
|
291
|
+
]);
|
|
292
|
+
// Activate what pi itself would: only tools declared to the model (`direct`/
|
|
293
|
+
// `model-only` exposure), and of those only named ones or ones not opted out
|
|
294
|
+
// with `defaultActive: false`. `codemode`/`deferred` tools (pi's MCP tools by
|
|
295
|
+
// default) stay undeclared until their extension or `tool_search` activates
|
|
296
|
+
// them — so an already-active tool is kept, or turn_end would undo that.
|
|
297
|
+
const renarrow = () => {
|
|
298
|
+
const allowed = inScope();
|
|
299
|
+
const current = session.getActiveToolNames();
|
|
300
|
+
const active = new Set(current);
|
|
301
|
+
const next = session
|
|
302
|
+
.getAllTools()
|
|
303
|
+
.filter(({ name, exposure = "direct" }) => {
|
|
304
|
+
if (!allowed.has(name))
|
|
305
|
+
return false;
|
|
306
|
+
if (active.has(name))
|
|
307
|
+
return true;
|
|
308
|
+
if (exposure !== "direct" && exposure !== "model-only")
|
|
309
|
+
return false;
|
|
310
|
+
return named.has(name) || session.getToolDefinition(name)?.defaultActive !== false;
|
|
311
|
+
})
|
|
312
|
+
.map((t) => t.name);
|
|
313
|
+
// setActiveToolsByName unconditionally rebuilds the system prompt, so skip
|
|
314
|
+
// the no-op that steady-state turns would otherwise pay for every turn.
|
|
315
|
+
if (next.length !== current.length || next.some((n, i) => n !== current[i])) {
|
|
316
|
+
session.setActiveToolsByName(next);
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
// Activate what registered during session_start (eager MCP servers); pi would
|
|
320
|
+
// otherwise leave only its four default built-ins active at turn 1.
|
|
321
|
+
renarrow();
|
|
322
|
+
session.subscribe((event) => {
|
|
323
|
+
if (event.type === "turn_end")
|
|
324
|
+
renarrow();
|
|
325
|
+
});
|
|
326
|
+
nestedCallGate.allows = (name) => inScope().has(name);
|
|
327
|
+
const priorBeforeToolCall = session.agent.beforeToolCall;
|
|
328
|
+
session.agent.beforeToolCall = async (context, signal) => {
|
|
329
|
+
if (!inScope().has(context.toolCall.name)) {
|
|
330
|
+
return {
|
|
331
|
+
block: true,
|
|
332
|
+
reason: `Tool "${context.toolCall.name}" is not available to this subagent.`,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
return priorBeforeToolCall?.(context, signal);
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Inline extension that applies the subagent's tool scope to calls a tool makes
|
|
340
|
+
* through `ctx.executeTool()` — codemode scripts calling MCP tools. pi runs those
|
|
341
|
+
* past extensions' `tool_call` handlers only, never `agent.beforeToolCall`, and
|
|
342
|
+
* every registered `codemode`/`deferred` tool is callable there whether or not
|
|
343
|
+
* it is in scope.
|
|
344
|
+
*/
|
|
345
|
+
const SCOPE_GATE_NAME = "pi-subagents-scope";
|
|
346
|
+
function scopeGateExtension(gate) {
|
|
347
|
+
return {
|
|
348
|
+
name: SCOPE_GATE_NAME,
|
|
349
|
+
hidden: true,
|
|
350
|
+
factory: (ext) => {
|
|
351
|
+
ext.on("tool_call", (event) => gate.allows?.(event.toolName) === false
|
|
352
|
+
? { block: true, reason: `Tool "${event.toolName}" is not available to this subagent.` }
|
|
353
|
+
: undefined);
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
/** Default max turns. undefined = unlimited (no turn limit). */
|
|
358
|
+
let defaultMaxTurns;
|
|
359
|
+
/** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
|
|
360
|
+
export function normalizeMaxTurns(n) {
|
|
361
|
+
if (n == null || n === 0)
|
|
362
|
+
return undefined;
|
|
363
|
+
return Math.max(1, n);
|
|
364
|
+
}
|
|
365
|
+
/** Get the default max turns value. undefined = unlimited. */
|
|
366
|
+
export function getDefaultMaxTurns() { return defaultMaxTurns; }
|
|
367
|
+
/** Set the default max turns value. undefined or 0 = unlimited, otherwise minimum 1. */
|
|
368
|
+
export function setDefaultMaxTurns(n) { defaultMaxTurns = normalizeMaxTurns(n); }
|
|
369
|
+
/**
|
|
370
|
+
* The turn limit a run of `type` will actually enforce: an explicit value if the
|
|
371
|
+
* caller supplied one, else the agent's own `max_turns`, else the project
|
|
372
|
+
* default. `undefined` = unlimited.
|
|
373
|
+
*
|
|
374
|
+
* Exported because the widget's turn counter (`↻3≤20`) has to predict this
|
|
375
|
+
* before the run starts, and a second copy of the expression would drift from
|
|
376
|
+
* the one below that enforces it.
|
|
377
|
+
*/
|
|
378
|
+
export function resolveEffectiveMaxTurns(type, explicit) {
|
|
379
|
+
return normalizeMaxTurns(explicit ?? getAgentConfig(type)?.maxTurns ?? defaultMaxTurns);
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Project default for `persist_session`, from the `rememberAgents` setting.
|
|
383
|
+
* On by default: a persisted session is what lets `@handle` reopen an agent's
|
|
384
|
+
* conversation after its record has been evicted, which is the whole point of
|
|
385
|
+
* addressing an agent by a name that outlives one run. Per-agent frontmatter
|
|
386
|
+
* still overrides it in both directions.
|
|
387
|
+
*/
|
|
388
|
+
let rememberAgents = true;
|
|
389
|
+
/** Whether subagent sessions are persisted by default. */
|
|
390
|
+
export function getRememberAgents() { return rememberAgents; }
|
|
391
|
+
/** Set whether subagent sessions are persisted by default. */
|
|
392
|
+
export function setRememberAgents(b) { rememberAgents = b; }
|
|
393
|
+
/** Additional turns allowed after the soft limit steer message. */
|
|
394
|
+
let graceTurns = 5;
|
|
395
|
+
/** Get the grace turns value. */
|
|
396
|
+
export function getGraceTurns() { return graceTurns; }
|
|
397
|
+
/** Set the grace turns value (minimum 1). */
|
|
398
|
+
export function setGraceTurns(n) { graceTurns = Math.max(1, n); }
|
|
399
|
+
/**
|
|
400
|
+
* Try to find the right model for an agent type.
|
|
401
|
+
* Priority: explicit option > config.model > parent model.
|
|
402
|
+
*/
|
|
403
|
+
export function resolveDefaultModel(parentModel, registry, configModel) {
|
|
404
|
+
if (configModel) {
|
|
405
|
+
const slashIdx = configModel.indexOf("/");
|
|
406
|
+
if (slashIdx !== -1) {
|
|
407
|
+
const provider = configModel.slice(0, slashIdx);
|
|
408
|
+
const modelId = configModel.slice(slashIdx + 1);
|
|
409
|
+
// Build a set of available model keys for fast lookup
|
|
410
|
+
const available = registry.getAvailable?.();
|
|
411
|
+
const availableKeys = available
|
|
412
|
+
? new Set(available.map((m) => `${m.provider}/${m.id}`))
|
|
413
|
+
: undefined;
|
|
414
|
+
const isAvailable = (p, id) => !availableKeys || availableKeys.has(`${p}/${id}`);
|
|
415
|
+
const found = registry.find(provider, modelId);
|
|
416
|
+
if (found && isAvailable(provider, modelId))
|
|
417
|
+
return found;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return parentModel;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Subscribe to a session and collect the last assistant message text.
|
|
424
|
+
* Returns an object with a `getText()` getter and an `unsubscribe` function.
|
|
425
|
+
*/
|
|
426
|
+
function collectResponseText(session) {
|
|
427
|
+
let text = "";
|
|
428
|
+
const unsubscribe = session.subscribe((event) => {
|
|
429
|
+
// message_start also fires for user and toolResult messages — resetting on
|
|
430
|
+
// those would wipe assistant text already collected. Reset only when a new
|
|
431
|
+
// ASSISTANT message begins, so getText() is the last assistant message's text.
|
|
432
|
+
if (event.type === "message_start" && event.message.role === "assistant") {
|
|
433
|
+
text = "";
|
|
434
|
+
}
|
|
435
|
+
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
|
436
|
+
text += event.assistantMessageEvent.delta;
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
return { getText: () => text, unsubscribe };
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Get the last non-empty assistant text produced during THIS invocation.
|
|
443
|
+
* `startIndex` is the message count captured before the prompt, so the walk-back
|
|
444
|
+
* never crosses into a previous turn: on a resume whose new turn failed empty,
|
|
445
|
+
* this returns "" instead of the prior turn's answer (#144). Defaults to 0 (a
|
|
446
|
+
* fresh spawn, where the whole history belongs to this run).
|
|
447
|
+
*/
|
|
448
|
+
function getLastAssistantText(session, startIndex = 0) {
|
|
449
|
+
for (let i = session.messages.length - 1; i >= startIndex; i--) {
|
|
450
|
+
const msg = session.messages[i];
|
|
451
|
+
if (msg.role !== "assistant")
|
|
452
|
+
continue;
|
|
453
|
+
const text = extractText(msg.content).trim();
|
|
454
|
+
if (text)
|
|
455
|
+
return text;
|
|
456
|
+
}
|
|
457
|
+
return "";
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Error message of THIS invocation's final assistant message, when that turn
|
|
461
|
+
* failed. Two failure shapes, both keyed off how the final turn STOPPED:
|
|
462
|
+
* - stopReason "error": a provider failure pi resolved instead of rejecting
|
|
463
|
+
* (any text; partial output is surfaced separately).
|
|
464
|
+
* - stopReason "length" with NO text: a silent max-token death — the run hit
|
|
465
|
+
* the output-token ceiling before writing anything, which would otherwise
|
|
466
|
+
* land as a "completed" run with an empty result (the #144 symptom).
|
|
467
|
+
* Everything else completes: a clean "stop"/"toolUse" final, and — crucially — a
|
|
468
|
+
* "length" stop that DID produce text (a legitimate truncated-but-useful answer).
|
|
469
|
+
* "aborted" is handled by the manager's abort flag / "stopped" guard, not here.
|
|
470
|
+
* Bounded by `startIndex` (like the text fallback) so a resume that produced no
|
|
471
|
+
* assistant message of its own never inherits a PRIOR turn's stop reason.
|
|
472
|
+
*/
|
|
473
|
+
function finalTurnError(session, startIndex = 0) {
|
|
474
|
+
for (let i = session.messages.length - 1; i >= startIndex; i--) {
|
|
475
|
+
const msg = session.messages[i];
|
|
476
|
+
if (msg.role !== "assistant")
|
|
477
|
+
continue;
|
|
478
|
+
if (msg.stopReason === "error") {
|
|
479
|
+
return msg.errorMessage?.trim() || "provider error with no output";
|
|
480
|
+
}
|
|
481
|
+
if (msg.stopReason === "length" && !extractText(msg.content).trim()) {
|
|
482
|
+
return "run hit the output token limit before producing any text";
|
|
483
|
+
}
|
|
484
|
+
return undefined;
|
|
485
|
+
}
|
|
486
|
+
return undefined;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Observe one prompt of a session — a spawn's or a resume's — and enforce its
|
|
490
|
+
* turn limit: steer once at `maxTurns`, hard-abort after the grace turns.
|
|
491
|
+
* `steered` / `aborted` are live fields, read while the prompt is still in
|
|
492
|
+
* flight (the structured-output retry) and after it settles (the result).
|
|
493
|
+
* Shared by `runAgent` and `resumeAgent` so a resumed run is bounded exactly
|
|
494
|
+
* like the first one (#368).
|
|
495
|
+
*/
|
|
496
|
+
function observeRun(session, options) {
|
|
497
|
+
let turnCount = 0;
|
|
498
|
+
let currentMessageText = "";
|
|
499
|
+
const run = {
|
|
500
|
+
steered: false,
|
|
501
|
+
aborted: false,
|
|
502
|
+
unsubscribe: () => { },
|
|
503
|
+
};
|
|
504
|
+
run.unsubscribe = session.subscribe((event) => {
|
|
505
|
+
if (event.type === "turn_end") {
|
|
506
|
+
turnCount++;
|
|
507
|
+
options.onTurnEnd?.(turnCount);
|
|
508
|
+
if (options.maxTurns != null) {
|
|
509
|
+
if (!run.steered && turnCount >= options.maxTurns) {
|
|
510
|
+
run.steered = true;
|
|
511
|
+
session.steer("You have reached your turn limit. Wrap up immediately — provide your final answer now.");
|
|
512
|
+
}
|
|
513
|
+
else if (run.steered && turnCount >= options.maxTurns + graceTurns) {
|
|
514
|
+
run.aborted = true;
|
|
515
|
+
session.abort();
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (event.type === "message_start") {
|
|
520
|
+
currentMessageText = "";
|
|
521
|
+
}
|
|
522
|
+
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
|
523
|
+
currentMessageText += event.assistantMessageEvent.delta;
|
|
524
|
+
options.onTextDelta?.(event.assistantMessageEvent.delta, currentMessageText);
|
|
525
|
+
}
|
|
526
|
+
if (event.type === "tool_execution_start") {
|
|
527
|
+
options.onToolActivity?.({ type: "start", toolName: event.toolName });
|
|
528
|
+
}
|
|
529
|
+
if (event.type === "tool_execution_end") {
|
|
530
|
+
options.onToolActivity?.({ type: "end", toolName: event.toolName });
|
|
531
|
+
}
|
|
532
|
+
if (event.type === "message_end" && event.message.role === "assistant") {
|
|
533
|
+
const u = event.message.usage;
|
|
534
|
+
if (u)
|
|
535
|
+
options.onAssistantUsage?.({
|
|
536
|
+
input: u.input ?? 0,
|
|
537
|
+
output: u.output ?? 0,
|
|
538
|
+
cacheWrite: u.cacheWrite ?? 0,
|
|
539
|
+
cacheRead: u.cacheRead ?? 0,
|
|
540
|
+
cost: u.cost?.total ?? 0,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
if (event.type === "compaction_end" && !event.aborted && event.result) {
|
|
544
|
+
options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore });
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
return run;
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Wire an AbortSignal to abort a session.
|
|
551
|
+
* Returns a cleanup function to remove the listener.
|
|
552
|
+
*/
|
|
553
|
+
function forwardAbortSignal(session, signal) {
|
|
554
|
+
if (!signal)
|
|
555
|
+
return () => { };
|
|
556
|
+
const onAbort = () => session.abort();
|
|
557
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
558
|
+
return () => signal.removeEventListener("abort", onAbort);
|
|
559
|
+
}
|
|
560
|
+
function resolveConfiguredSessionDir(sessionDir, cwd) {
|
|
561
|
+
if (!sessionDir)
|
|
562
|
+
return undefined;
|
|
563
|
+
if (sessionDir === "~" || sessionDir.startsWith("~/"))
|
|
564
|
+
return resolve(homedir(), sessionDir.slice(2));
|
|
565
|
+
if (isAbsolute(sessionDir))
|
|
566
|
+
return sessionDir;
|
|
567
|
+
return resolve(cwd, sessionDir);
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Open an agent's saved session to continue it — or a copy of it, when the
|
|
571
|
+
* session belongs to another parent.
|
|
572
|
+
*
|
|
573
|
+
* pi's `/fork` and `/clone` copy the parent's entries, saved agent records
|
|
574
|
+
* included, so two parent sessions can each resume the same agent. Appending
|
|
575
|
+
* both continuations to one file would interleave two histories in it. The
|
|
576
|
+
* child's header names the parent that spawned it; a resume from any other
|
|
577
|
+
* parent forks the child alongside, exactly as pi forked the parent, and the
|
|
578
|
+
* copy is that parent's from then on. A header with no parent (a child
|
|
579
|
+
* spawned from a context without a session file) gives nothing to compare,
|
|
580
|
+
* so it opens in place as before.
|
|
581
|
+
*/
|
|
582
|
+
function reopenSession(file, parentSessionFile, sessionDir) {
|
|
583
|
+
const opened = SessionManager.open(file, sessionDir);
|
|
584
|
+
const owner = opened.getHeader()?.parentSession;
|
|
585
|
+
if (owner === undefined || owner === parentSessionFile)
|
|
586
|
+
return opened;
|
|
587
|
+
return SessionManager.forkFrom(file, opened.getCwd(), sessionDir, { parentSession: parentSessionFile });
|
|
588
|
+
}
|
|
589
|
+
export async function runAgent(ctx, type, prompt, options) {
|
|
590
|
+
const config = getConfig(type);
|
|
591
|
+
const agentConfig = getAgentConfig(type);
|
|
592
|
+
// Resolve working directory: worktree override > parent cwd
|
|
593
|
+
const effectiveCwd = options.cwd ?? ctx.cwd;
|
|
594
|
+
// Filesystem work happens in effectiveCwd; config discovery in configCwd.
|
|
595
|
+
// They differ only for SpawnOptions.cwd spawns (config stays with the parent).
|
|
596
|
+
const configCwd = options.configCwd ?? effectiveCwd;
|
|
597
|
+
const env = await detectEnv(options.pi, effectiveCwd);
|
|
598
|
+
// Get parent system prompt for append-mode agents, without the sections the
|
|
599
|
+
// child session renders itself (skills, cwd, extension sections).
|
|
600
|
+
const parentSystemPrompt = inheritableParentPrompt(ctx.getSystemPrompt(), ctx.cwd);
|
|
601
|
+
// Build prompt extras (memory, skill preloading)
|
|
602
|
+
const extras = {};
|
|
603
|
+
if (options.worktreeBase)
|
|
604
|
+
extras.worktreeBase = options.worktreeBase;
|
|
605
|
+
if (options.workflow && !options.structuredOutput)
|
|
606
|
+
extras.workflowChild = true;
|
|
607
|
+
// Resolved here, not by each caller: mention and RPC spawns pass no `isolated`,
|
|
608
|
+
// and the agent file's value must hold on every path.
|
|
609
|
+
const isolated = agentConfig?.isolated ?? options.isolated;
|
|
610
|
+
// Resolve extensions/skills: isolated overrides to false
|
|
611
|
+
const extensions = isolated ? false : config.extensions;
|
|
612
|
+
// Nulling excludes under isolated also suppresses the orphaned-exclude warning —
|
|
613
|
+
// isolation is an intentional override, not a misconfiguration.
|
|
614
|
+
const excludeExtensions = isolated ? undefined : config.excludeExtensions;
|
|
615
|
+
const skills = isolated ? false : config.skills;
|
|
616
|
+
// Skill preloading: when skills is string[], preload their content into prompt
|
|
617
|
+
if (Array.isArray(skills)) {
|
|
618
|
+
const loaded = preloadSkills(skills, configCwd);
|
|
619
|
+
if (loaded.length > 0) {
|
|
620
|
+
extras.skillBlocks = loaded;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
let toolNames = getToolNamesForType(type);
|
|
624
|
+
// Persistent memory: detect write capability and branch accordingly.
|
|
625
|
+
// Account for disallowedTools — a tool in the base set but on the denylist is not truly available.
|
|
626
|
+
if (agentConfig?.memory) {
|
|
627
|
+
const existingNames = new Set(toolNames);
|
|
628
|
+
const denied = agentConfig.disallowedTools ? new Set(agentConfig.disallowedTools) : undefined;
|
|
629
|
+
const effectivelyHas = (name) => existingNames.has(name) && !denied?.has(name);
|
|
630
|
+
const hasWriteTools = effectivelyHas("write") || effectivelyHas("edit");
|
|
631
|
+
if (hasWriteTools) {
|
|
632
|
+
// Read-write memory: add any missing memory tool names (read/write/edit)
|
|
633
|
+
const extraNames = getMemoryToolNames(existingNames);
|
|
634
|
+
if (extraNames.length > 0)
|
|
635
|
+
toolNames = [...toolNames, ...extraNames];
|
|
636
|
+
extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, configCwd);
|
|
637
|
+
}
|
|
638
|
+
else {
|
|
639
|
+
// Read-only memory: only add read tool name, use read-only prompt
|
|
640
|
+
const extraNames = getReadOnlyMemoryToolNames(existingNames);
|
|
641
|
+
if (extraNames.length > 0)
|
|
642
|
+
toolNames = [...toolNames, ...extraNames];
|
|
643
|
+
extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, configCwd);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
// Build system prompt from agent config
|
|
647
|
+
let systemPrompt;
|
|
648
|
+
if (agentConfig) {
|
|
649
|
+
systemPrompt = buildAgentPrompt(agentConfig, effectiveCwd, env, parentSystemPrompt, extras);
|
|
650
|
+
}
|
|
651
|
+
else {
|
|
652
|
+
// Unknown type fallback: spread the canonical general-purpose config (defensive —
|
|
653
|
+
// unreachable in practice since index.ts resolves unknown types before calling runAgent).
|
|
654
|
+
const fallback = DEFAULT_AGENTS.get("general-purpose");
|
|
655
|
+
if (!fallback)
|
|
656
|
+
throw new Error(`No fallback config available for unknown type "${type}"`);
|
|
657
|
+
systemPrompt = buildAgentPrompt({ ...fallback, name: type }, effectiveCwd, env, parentSystemPrompt, extras);
|
|
658
|
+
}
|
|
659
|
+
// When skills is string[], we've already preloaded them into the prompt.
|
|
660
|
+
// Still pass noSkills: true since we don't need the skill loader to load them again.
|
|
661
|
+
const noSkills = skills === false || Array.isArray(skills);
|
|
662
|
+
const agentDir = getAgentDir();
|
|
663
|
+
// Extension loading:
|
|
664
|
+
// - true → all default-discovered extensions
|
|
665
|
+
// - false → none (noExtensions)
|
|
666
|
+
// - string[] → loader-level allowlist. Bare names keep the matching
|
|
667
|
+
// default-discovered extension; path entries load that extension fresh;
|
|
668
|
+
// "*" keeps all default-discovered extensions. Excluded extensions never
|
|
669
|
+
// bind handlers or register tools (their factory still runs once).
|
|
670
|
+
//
|
|
671
|
+
// Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md — upstream's
|
|
672
|
+
// buildSystemPrompt() re-appends both AFTER systemPromptOverride, which
|
|
673
|
+
// would defeat prompt_mode: replace and isolated: true. Parent context, if
|
|
674
|
+
// wanted, reaches the subagent via prompt_mode: append (parentSystemPrompt
|
|
675
|
+
// is embedded in systemPromptOverride) or inherit_context (conversation).
|
|
676
|
+
// `ext:` selectors from the `tools:` CSV narrow which extension tools surface to
|
|
677
|
+
// the LLM. They do NOT control loading — `extensions:` is the sole authority for
|
|
678
|
+
// which extensions load. `ext:foo` against an extension that `extensions:` excluded
|
|
679
|
+
// is an orphan and warns after reload. `isolated` means no extension tools at all.
|
|
680
|
+
const { extNames, narrowing } = parseExtSelectors(isolated ? [] : (agentConfig?.extSelectors ?? []));
|
|
681
|
+
const noExtensions = extensions === false;
|
|
682
|
+
const extensionsSpec = Array.isArray(extensions)
|
|
683
|
+
? parseExtensionsSpec(extensions, configCwd)
|
|
684
|
+
: undefined;
|
|
685
|
+
const keepNames = extensionsSpec?.names ?? new Set();
|
|
686
|
+
// `exclude_extensions:` is a denylist applied AFTER the include set — exclude wins.
|
|
687
|
+
// Plain canonical names only (case-insensitive). Note: excluded extensions'
|
|
688
|
+
// factories still run once during reload() (see comment above) — exclusion
|
|
689
|
+
// suppresses handler binding and tool registration; it is not a sandbox.
|
|
690
|
+
const excludeNames = new Set((excludeExtensions ?? []).map((n) => n.toLowerCase()));
|
|
691
|
+
const hasExcludes = excludeNames.size > 0;
|
|
692
|
+
// The override filters loaded extensions down to `keepNames` minus `excludeNames`.
|
|
693
|
+
// It's only needed when we're neither loading everything without excludes
|
|
694
|
+
// (`extensions: true` or a `"*"` wildcard) nor nothing (`noExtensions`).
|
|
695
|
+
const loadAll = extensions === true || extensionsSpec?.wildcard === true;
|
|
696
|
+
const additionalExtensionPaths = extensionsSpec?.paths.length ? extensionsSpec.paths : undefined;
|
|
697
|
+
// Pre-filter discovered set, captured by the override — the exclude-typo warning
|
|
698
|
+
// must compare against this, not the surviving set (absence from survivors is
|
|
699
|
+
// an exclude *succeeding*).
|
|
700
|
+
let discoveredNames;
|
|
701
|
+
const extensionsOverride = noExtensions || (loadAll && !hasExcludes)
|
|
702
|
+
? undefined
|
|
703
|
+
: (base) => {
|
|
704
|
+
discoveredNames = new Set(base.extensions.flatMap((e) => extensionCanonicalNames(e.path)));
|
|
705
|
+
return {
|
|
706
|
+
...base,
|
|
707
|
+
extensions: base.extensions.filter((e) => {
|
|
708
|
+
if (e.path === `<inline:${SCOPE_GATE_NAME}>`)
|
|
709
|
+
return true; // ours, not the agent's
|
|
710
|
+
const canons = extensionCanonicalNames(e.path);
|
|
711
|
+
if (canons.some((n) => excludeNames.has(n)))
|
|
712
|
+
return false; // exclude wins
|
|
713
|
+
return loadAll || canons.some((n) => keepNames.has(n));
|
|
714
|
+
}),
|
|
715
|
+
};
|
|
716
|
+
};
|
|
717
|
+
// The child inherits the parent's project-trust decision. pi's SettingsManager
|
|
718
|
+
// defaults to trusted, so without this a subagent in a project the user declined
|
|
719
|
+
// to trust would load its `.pi` extensions and settings, and pi's built-in MCP
|
|
720
|
+
// would start the stdio servers in its `.pi/mcp.json`.
|
|
721
|
+
const projectTrusted = ctx.isProjectTrusted();
|
|
722
|
+
const settingsManager = SettingsManager.create(configCwd, agentDir, { projectTrusted });
|
|
723
|
+
// That trust covers the parent's project (`configCwd`), not a directory a spawn
|
|
724
|
+
// was sent into. The session answers `ctx.isProjectTrusted()` for its own cwd,
|
|
725
|
+
// which pi's MCP uses to read `<cwd>/.pi/mcp.json` — so a cross-directory session
|
|
726
|
+
// keeps the parent project's settings but reports its cwd as untrusted. The
|
|
727
|
+
// loader keeps the trusted manager: it loads the parent project's extensions.
|
|
728
|
+
const sessionSettingsManager = resolve(effectiveCwd) === resolve(configCwd)
|
|
729
|
+
? settingsManager
|
|
730
|
+
: Object.assign(SettingsManager.create(configCwd, agentDir, { projectTrusted }), {
|
|
731
|
+
isProjectTrusted: () => false,
|
|
732
|
+
});
|
|
733
|
+
const nestedCallGate = {};
|
|
734
|
+
const loader = new DefaultResourceLoader({
|
|
735
|
+
settingsManager,
|
|
736
|
+
// pi's CLI hands its built-in extensions to the loader it builds; this one starts
|
|
737
|
+
// with none. Forwarded as `builtin:` resources they load as in the main session —
|
|
738
|
+
// default-on, disabled by `-builtin:<name>` in settings — and the per-agent
|
|
739
|
+
// allowlist filters them like any other extension. pi's `llama.cpp` built-in is
|
|
740
|
+
// CLI-only (not exported). Fresh factories per spawn: MCP state lives in the closure.
|
|
741
|
+
extensionFactories: [
|
|
742
|
+
{ name: "codemode", factory: createCodemodeExtension(), builtin: true, replaceable: true },
|
|
743
|
+
{ name: "tool-search", factory: createToolSearchExtension(), builtin: true, replaceable: true },
|
|
744
|
+
{ name: "mcp", factory: createMcpExtension(), builtin: true, replaceable: true },
|
|
745
|
+
// Under `noExtensions` the static allowlist gates the registry itself.
|
|
746
|
+
...(noExtensions ? [] : [scopeGateExtension(nestedCallGate)]),
|
|
747
|
+
],
|
|
748
|
+
// Mirrors pi's CLI: `--no-mcp` keeps MCP out of subagents too, and `--no-extensions`
|
|
749
|
+
// keeps all the built-ins out. Discovered extensions still follow `extensions:`.
|
|
750
|
+
disabledBuiltinExtensions: process.argv.some((a) => a === "--no-extensions" || a === "-ne")
|
|
751
|
+
? ["codemode", "tool-search", "mcp"]
|
|
752
|
+
: process.argv.includes("--no-mcp")
|
|
753
|
+
? ["mcp"]
|
|
754
|
+
: undefined,
|
|
755
|
+
cwd: configCwd,
|
|
756
|
+
agentDir,
|
|
757
|
+
noExtensions,
|
|
758
|
+
additionalExtensionPaths,
|
|
759
|
+
extensionsOverride,
|
|
760
|
+
noSkills,
|
|
761
|
+
noPromptTemplates: true,
|
|
762
|
+
noThemes: true,
|
|
763
|
+
noContextFiles: true,
|
|
764
|
+
systemPromptOverride: () => systemPrompt,
|
|
765
|
+
appendSystemPromptOverride: () => [],
|
|
766
|
+
});
|
|
767
|
+
await runInChildSessionContext(() => loader.reload());
|
|
768
|
+
// Plain entries in `tools:` are expected to be built-in names (extension tools
|
|
769
|
+
// go through `ext:`), so an unknown name there is unambiguously a typo. Previously
|
|
770
|
+
// this produced a silently broken agent (#75) — pi-mono accepted the bogus name
|
|
771
|
+
// into the allowlist, then dropped it at registration with no signal back.
|
|
772
|
+
if (agentConfig?.builtinToolNames?.length) {
|
|
773
|
+
const knownBuiltins = new Set(BUILTIN_TOOL_NAMES);
|
|
774
|
+
for (const name of agentConfig.builtinToolNames) {
|
|
775
|
+
if (!knownBuiltins.has(name)) {
|
|
776
|
+
options.onToolActivity?.({
|
|
777
|
+
type: "end",
|
|
778
|
+
toolName: `tools-error:tool "${name}" requested by agent "${type}" is not a known built-in`,
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
// MCP tools come from extensions, so with none loaded these entries cannot match.
|
|
784
|
+
if (agentConfig?.mcpToolPatterns && noExtensions) {
|
|
785
|
+
options.onToolActivity?.({
|
|
786
|
+
type: "end",
|
|
787
|
+
toolName: `tools-error:${agentConfig.mcpToolPatterns.join(", ")} requested by agent "${type}" need extensions, but it loads none`,
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
// A subagent spawns mid-task, so a bad `extensions:`/`ext:` entry warns rather
|
|
791
|
+
// than aborts. Two distinct misconfigurations to catch:
|
|
792
|
+
// - `extensions: [foo]` but no extension named foo was discovered (typo or
|
|
793
|
+
// path that failed to load — path entries fold their canonical name into
|
|
794
|
+
// `keepNames`, so this covers them too).
|
|
795
|
+
// - `tools: ext:foo` but foo isn't in the loaded set (because `extensions:`
|
|
796
|
+
// didn't include it). Since v0.9, `ext:` no longer pulls extensions in;
|
|
797
|
+
// loading is `extensions:`-authoritative.
|
|
798
|
+
// An exclude_extensions: alongside extensions: false is contradictory — nothing
|
|
799
|
+
// loads, so there is nothing to exclude.
|
|
800
|
+
if (hasExcludes && noExtensions) {
|
|
801
|
+
options.onToolActivity?.({
|
|
802
|
+
type: "end",
|
|
803
|
+
toolName: `extension-error:exclude_extensions has no effect for agent "${type}" — extensions: false loads nothing`,
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
// Exclude typo check: compares against the PRE-filter discovered set (an excluded
|
|
807
|
+
// name absent from the surviving set is the exclude working as intended). Also
|
|
808
|
+
// flags path-like and "*" entries — excludes are plain names only.
|
|
809
|
+
if (hasExcludes && discoveredNames) {
|
|
810
|
+
for (const name of excludeNames) {
|
|
811
|
+
if (!discoveredNames.has(name)) {
|
|
812
|
+
options.onToolActivity?.({
|
|
813
|
+
type: "end",
|
|
814
|
+
toolName: `extension-error:exclude_extensions: "${name}" for agent "${type}" did not match any discovered extension`,
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
if (keepNames.size > 0 || extNames.size > 0) {
|
|
820
|
+
const survivingNames = new Set(loader.getExtensions().extensions.flatMap((e) => extensionCanonicalNames(e.path)));
|
|
821
|
+
for (const name of keepNames) {
|
|
822
|
+
if (!survivingNames.has(name)) {
|
|
823
|
+
options.onToolActivity?.({
|
|
824
|
+
type: "end",
|
|
825
|
+
toolName: excludeNames.has(name)
|
|
826
|
+
? `extension-error:extension "${name}" is in both extensions: and exclude_extensions: for agent "${type}" — exclude wins`
|
|
827
|
+
: `extension-error:extension "${name}" requested by agent "${type}" was not loaded`,
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
for (const name of extNames) {
|
|
832
|
+
if (!survivingNames.has(name)) {
|
|
833
|
+
options.onToolActivity?.({
|
|
834
|
+
type: "end",
|
|
835
|
+
toolName: `extension-error:ext:${name} referenced by agent "${type}" but extension "${name}" is not loaded (check extensions:/exclude_extensions:)`,
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
// Resolve model: explicit option > config.model > parent model
|
|
841
|
+
const model = options.model ?? resolveDefaultModel(ctx.model, ctx.modelRegistry, agentConfig?.model);
|
|
842
|
+
// Resolve thinking level: explicit option > agent config > parent session level.
|
|
843
|
+
// Without the parent step, an unset level falls through to pi's
|
|
844
|
+
// defaultThinkingLevel (e.g. "max") instead of matching the spawning session.
|
|
845
|
+
// Same idiom as mention-clone.ts.
|
|
846
|
+
const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking ?? ctx.thinkingLevel;
|
|
847
|
+
const disallowedSet = agentConfig?.disallowedTools
|
|
848
|
+
? new Set(agentConfig.disallowedTools)
|
|
849
|
+
: undefined;
|
|
850
|
+
// Nested delegation tools (opt-in, ownership-scoped). Empty unless the agent
|
|
851
|
+
// set `allowed_subagents` and a nestedRuntime was provided — and never when
|
|
852
|
+
// isolated. Their names collide with EXCLUDED_TOOL_NAMES by design, so the
|
|
853
|
+
// scoping below re-admits them explicitly (registry deny + active-set narrow).
|
|
854
|
+
const effectiveMaxDepth = options.nestedRuntime?.maxSubagentDepth ?? getMaxSubagentDepth();
|
|
855
|
+
// At (or past) the cap this agent can never spawn, so it can never own a child
|
|
856
|
+
// to fetch from or steer either — inject nothing rather than three tools whose
|
|
857
|
+
// every call is an error. This is also what makes `maxSubagentDepth` 0/1 mean
|
|
858
|
+
// "nesting off" instead of "nesting always fails".
|
|
859
|
+
const nestedRuntime = options.nestedRuntime && options.nestedRuntime.depth < effectiveMaxDepth
|
|
860
|
+
? options.nestedRuntime
|
|
861
|
+
: undefined;
|
|
862
|
+
const nestedTools = agentConfig?.allowedSubagents && nestedRuntime && !isolated
|
|
863
|
+
? createNestedSubagentTools({
|
|
864
|
+
manager: nestedRuntime.manager,
|
|
865
|
+
pi: options.pi,
|
|
866
|
+
parentAgentId: nestedRuntime.parentAgentId,
|
|
867
|
+
depth: nestedRuntime.depth,
|
|
868
|
+
maxSubagentDepth: effectiveMaxDepth,
|
|
869
|
+
allowedSubagents: agentConfig.allowedSubagents,
|
|
870
|
+
configCwd,
|
|
871
|
+
})
|
|
872
|
+
: [];
|
|
873
|
+
const nestedToolNames = new Set(nestedTools.map(tool => tool.name));
|
|
874
|
+
// The `agent({ schema })` contract: this child reports its answer by calling
|
|
875
|
+
// StructuredOutput, and `structuredJson` below is what the caller reads. The
|
|
876
|
+
// schema was already compiled by whoever asked for it, so a bad one failed
|
|
877
|
+
// before any of this ran.
|
|
878
|
+
const structuredCapture = options.structuredOutput ? createStructuredCapture() : undefined;
|
|
879
|
+
const structuredTools = options.structuredOutput && structuredCapture
|
|
880
|
+
? [createStructuredOutputTool(options.structuredOutput, structuredCapture)]
|
|
881
|
+
: [];
|
|
882
|
+
const structuredToolNames = new Set(structuredTools.map(tool => tool.name));
|
|
883
|
+
// Re-admitted together at every gate below. Kept as one set so a new injected
|
|
884
|
+
// tool cannot be added to some of the three gates and forgotten at the rest.
|
|
885
|
+
//
|
|
886
|
+
// `disallowed_tools` is applied HERE rather than at the gates, because the two
|
|
887
|
+
// kinds answer to it differently: a nested delegation tool is an opt-in the
|
|
888
|
+
// agent's own frontmatter can take back, while StructuredOutput exists only
|
|
889
|
+
// because this call asked for a schema — removing it would make the request
|
|
890
|
+
// unsatisfiable by construction rather than merely restricted.
|
|
891
|
+
const readmitToolNames = new Set([
|
|
892
|
+
...[...nestedToolNames].filter(name => !disallowedSet?.has(name)),
|
|
893
|
+
...structuredToolNames,
|
|
894
|
+
]);
|
|
895
|
+
// As in Claude Code, refuse an agent whose `tools:` list resolved to nothing: with
|
|
896
|
+
// no tool it can only narrate calls it cannot make. Extension tools register after
|
|
897
|
+
// spawn (MCP once its servers connect), so they count as possible whenever an
|
|
898
|
+
// extension they could come from loaded, or any `mcp__` entry can reach a bridge.
|
|
899
|
+
const loadedNames = new Set(loader
|
|
900
|
+
.getExtensions()
|
|
901
|
+
.extensions.filter((e) => e.path !== `<inline:${SCOPE_GATE_NAME}>`)
|
|
902
|
+
.flatMap((e) => extensionCanonicalNames(e.path)));
|
|
903
|
+
const unrecognized = agentConfig?.builtinToolNames?.filter((n) => !BUILTIN_TOOL_NAMES.includes(n)) ?? [];
|
|
904
|
+
const unmatched = [...extNames].filter((n) => !loadedNames.has(n));
|
|
905
|
+
// `extensions: false` and `isolated` load nothing, so `loadedNames` is empty then.
|
|
906
|
+
const extensionToolsPossible = loadedNames.size > 0 &&
|
|
907
|
+
(extNames.size === 0 || unmatched.length < extNames.size || !!agentConfig?.mcpToolPatterns);
|
|
908
|
+
if ((unrecognized.length > 0 || unmatched.length > 0) &&
|
|
909
|
+
toolNames.every((n) => !BUILTIN_TOOL_NAMES.includes(n) || disallowedSet?.has(n)) &&
|
|
910
|
+
readmitToolNames.size === 0 &&
|
|
911
|
+
!extensionToolsPossible) {
|
|
912
|
+
const reasons = [
|
|
913
|
+
unrecognized.length > 0 ? `unrecognized [${unrecognized.join(", ")}]` : "",
|
|
914
|
+
unmatched.length > 0
|
|
915
|
+
? `recognized but matched no tools in this session [${unmatched.map((n) => `ext:${n}`).join(", ")}]`
|
|
916
|
+
: "",
|
|
917
|
+
].filter(Boolean);
|
|
918
|
+
throw new Error(`Agent '${type}' would be spawned with zero tools — refusing. Its tools list resolved to nothing: ` +
|
|
919
|
+
`${reasons.join("; ")}. Fix the agent's tools frontmatter or pass a different subagent_type.`);
|
|
920
|
+
}
|
|
921
|
+
// ─── Tool scoping ───────────────────────────────────────────────────────
|
|
922
|
+
//
|
|
923
|
+
// Some extensions register their tools ASYNCHRONOUSLY, long after the
|
|
924
|
+
// `loader.reload()` above: pi-mcp calls registerTool from `session_start`
|
|
925
|
+
// (once its MCP servers connect), context-mode from `before_agent_start`.
|
|
926
|
+
// That is deliberate on their part — eagerly spawning an MCP bridge during
|
|
927
|
+
// extension discovery orphans child processes on pi's non-agent code paths
|
|
928
|
+
// (--help, config, trust probing).
|
|
929
|
+
//
|
|
930
|
+
// So the tool set cannot be snapshotted here. pi's `allowedToolNames` gates
|
|
931
|
+
// tool *registration* (`_refreshToolRegistry`'s `isAllowedTool`), not merely
|
|
932
|
+
// the active set, and is frozen at construction — a name absent from the
|
|
933
|
+
// snapshot is dropped forever, even once the tool actually registers (#125).
|
|
934
|
+
//
|
|
935
|
+
// Whenever extensions are in play we therefore:
|
|
936
|
+
// - leave `allowedToolNames` unset, so pi's live gate admits tools whenever
|
|
937
|
+
// they register;
|
|
938
|
+
// - express the name-stable, permanent part of the scope (our own
|
|
939
|
+
// orchestration tools, built-ins the agent didn't ask for, and
|
|
940
|
+
// `disallowedTools`) as `excludeTools`, which pi re-applies on every
|
|
941
|
+
// registry refresh;
|
|
942
|
+
// - enforce `ext:` narrowing on the ACTIVE set via the live `inScope()`
|
|
943
|
+
// predicate installed after bind — the active set is what the LLM sees,
|
|
944
|
+
// so a registry tool that is never activated is invisible and uncallable.
|
|
945
|
+
//
|
|
946
|
+
// `noExtensions`/`isolated` keeps the historical static allowlist: nothing
|
|
947
|
+
// async can appear there, and a hard registry gate is the correct boundary.
|
|
948
|
+
const builtinToolNameSet = new Set(toolNames);
|
|
949
|
+
let sessionTools;
|
|
950
|
+
let sessionExcludeTools;
|
|
951
|
+
if (noExtensions) {
|
|
952
|
+
// Strict allowlist: built-ins the agent asked for, plus any opt-in nested
|
|
953
|
+
// tools (whose names would otherwise be dropped as EXCLUDED_TOOL_NAMES).
|
|
954
|
+
sessionTools = [
|
|
955
|
+
...toolNames.filter((t) => !EXCLUDED_TOOL_NAMES.includes(t) && !disallowedSet?.has(t)),
|
|
956
|
+
...[...nestedToolNames].filter((t) => !disallowedSet?.has(t)),
|
|
957
|
+
// Not filtered through `disallowedSet`, unlike the nested tools above:
|
|
958
|
+
// the caller asked for a schema, and removing the only tool that can
|
|
959
|
+
// satisfy it would make the request unsatisfiable by construction rather
|
|
960
|
+
// than merely restricted.
|
|
961
|
+
...structuredToolNames,
|
|
962
|
+
];
|
|
963
|
+
}
|
|
964
|
+
else {
|
|
965
|
+
// Deny the orchestration tools EXCEPT the nested ones this agent opted into —
|
|
966
|
+
// those are injected as customTools and must survive the registry gate.
|
|
967
|
+
const denyTools = new Set(EXCLUDED_TOOL_NAMES.filter((t) => !nestedToolNames.has(t)));
|
|
968
|
+
// Keep only the built-ins the agent asked for — deny the rest.
|
|
969
|
+
for (const name of BUILTIN_TOOL_NAMES) {
|
|
970
|
+
if (!builtinToolNameSet.has(name))
|
|
971
|
+
denyTools.add(name);
|
|
972
|
+
}
|
|
973
|
+
if (disallowedSet) {
|
|
974
|
+
// disallowed_tools wins even over an opt-in nested tool of the same name.
|
|
975
|
+
// Not over StructuredOutput, though — see the allowlist branch above.
|
|
976
|
+
for (const name of disallowedSet) {
|
|
977
|
+
if (!structuredToolNames.has(name))
|
|
978
|
+
denyTools.add(name);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
sessionExcludeTools = [...denyTools];
|
|
982
|
+
}
|
|
983
|
+
const configuredSessionDir = resolveConfiguredSessionDir(agentConfig?.sessionDir, effectiveCwd);
|
|
984
|
+
const defaultSessionDir = process.env.PI_CODING_AGENT_SESSION_DIR ?? settingsManager.getSessionDir?.();
|
|
985
|
+
// Frontmatter wins when it says anything; otherwise the project default,
|
|
986
|
+
// which `rememberAgents` supplies for top-level agents only. Same precedence
|
|
987
|
+
// as `outputTranscript`.
|
|
988
|
+
const persistSession = agentConfig?.persistSession ?? (options.nested ? false : rememberAgents);
|
|
989
|
+
const parentSessionFile = ctx.sessionManager?.getSessionFile?.();
|
|
990
|
+
const sessionManager = options.resumeSessionFile
|
|
991
|
+
// Reopening an existing conversation: the file already carries its own
|
|
992
|
+
// header (cwd, parent) and history, so none of the create-time options
|
|
993
|
+
// apply. `sessionDir` still matters for a later /new or /branch off it.
|
|
994
|
+
? reopenSession(options.resumeSessionFile, parentSessionFile, configuredSessionDir ?? defaultSessionDir)
|
|
995
|
+
: persistSession
|
|
996
|
+
? SessionManager.create(effectiveCwd, configuredSessionDir ?? defaultSessionDir, {
|
|
997
|
+
// Optional metadata — it only nests the subagent under its spawner in
|
|
998
|
+
// `/resume`. Until `rememberAgents` this ran solely for the rare
|
|
999
|
+
// `persist_session: true` agent; now it runs for every spawn, so a
|
|
1000
|
+
// context without a session manager (a bare programmatic ctx) must
|
|
1001
|
+
// still persist rather than take the whole spawn down.
|
|
1002
|
+
parentSession: parentSessionFile,
|
|
1003
|
+
})
|
|
1004
|
+
: SessionManager.inMemory(effectiveCwd);
|
|
1005
|
+
// Pi 0.80.8 replaced createAgentSession's modelRegistry option with
|
|
1006
|
+
// modelRuntime, but ExtensionContext still exposes only the registry facade.
|
|
1007
|
+
// Pass both so the full supported Pi range retains the parent's providers.
|
|
1008
|
+
const parentModelRuntime = ctx.modelRegistry.runtime;
|
|
1009
|
+
const sessionOpts = {
|
|
1010
|
+
cwd: effectiveCwd,
|
|
1011
|
+
agentDir,
|
|
1012
|
+
sessionManager,
|
|
1013
|
+
settingsManager: sessionSettingsManager,
|
|
1014
|
+
modelRegistry: ctx.modelRegistry,
|
|
1015
|
+
// `as never` is what keeps this assignable across the supported Pi range:
|
|
1016
|
+
// pre-0.80.8 the field exists only via the `modelRuntime?: unknown` shim
|
|
1017
|
+
// above, while newer Pi types it as `ModelRuntime` — a shape an opaque
|
|
1018
|
+
// `unknown` read off the private facade field can never satisfy.
|
|
1019
|
+
...(parentModelRuntime !== undefined && { modelRuntime: parentModelRuntime }),
|
|
1020
|
+
model,
|
|
1021
|
+
tools: sessionTools,
|
|
1022
|
+
customTools: [...nestedTools, ...structuredTools],
|
|
1023
|
+
resourceLoader: loader,
|
|
1024
|
+
};
|
|
1025
|
+
if (sessionExcludeTools) {
|
|
1026
|
+
sessionOpts.excludeTools = sessionExcludeTools;
|
|
1027
|
+
}
|
|
1028
|
+
if (thinkingLevel) {
|
|
1029
|
+
sessionOpts.thinkingLevel = thinkingLevel;
|
|
1030
|
+
}
|
|
1031
|
+
const { session } = await runInChildSessionContext(() => createAgentSession(sessionOpts));
|
|
1032
|
+
const baseSessionName = agentConfig?.name ?? type;
|
|
1033
|
+
session.setSessionName(options.agentId ? `${baseSessionName}#${options.agentId.slice(0, 8)}` : baseSessionName);
|
|
1034
|
+
// Bind extensions so that session_start fires and extensions can initialize
|
|
1035
|
+
// (e.g. loading credentials, setting up state). Tool gating already happened
|
|
1036
|
+
// at session construction via the `tools:` allowlist above — no separate
|
|
1037
|
+
// post-bind filter is needed. All ExtensionBindings fields are optional.
|
|
1038
|
+
await session.bindExtensions({
|
|
1039
|
+
onError: (err) => {
|
|
1040
|
+
options.onToolActivity?.({
|
|
1041
|
+
type: "end",
|
|
1042
|
+
toolName: `extension-error:${err.extensionPath}`,
|
|
1043
|
+
});
|
|
1044
|
+
},
|
|
1045
|
+
});
|
|
1046
|
+
// With `allowedToolNames` unset, the registry is scoped by `excludeTools` but
|
|
1047
|
+
// the ACTIVE set still needs managing: pi activates only its four default
|
|
1048
|
+
// built-ins at turn 1, and `ext:` narrowing has no registry-level expression
|
|
1049
|
+
// (we can't deny the name of a tool that hasn't registered yet). Both are
|
|
1050
|
+
// handled below by re-deriving scope from the loader's live extension maps —
|
|
1051
|
+
// `registerTool` writes into those same maps, so late arrivals are judged too.
|
|
1052
|
+
if (!noExtensions) {
|
|
1053
|
+
installExtensionToolScope(session, {
|
|
1054
|
+
loader,
|
|
1055
|
+
toolNames,
|
|
1056
|
+
disallowedSet,
|
|
1057
|
+
extNames,
|
|
1058
|
+
narrowing,
|
|
1059
|
+
mcpToolPatterns: isolated ? undefined : agentConfig?.mcpToolPatterns,
|
|
1060
|
+
readmitToolNames,
|
|
1061
|
+
nestedCallGate,
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
options.onSessionCreated?.(session);
|
|
1065
|
+
const run = observeRun(session, { ...options, maxTurns: resolveEffectiveMaxTurns(type, options.maxTurns) });
|
|
1066
|
+
const collector = collectResponseText(session);
|
|
1067
|
+
const cleanupAbort = forwardAbortSignal(session, options.signal);
|
|
1068
|
+
// Build the effective prompt: optionally prepend parent context. Only for a
|
|
1069
|
+
// fresh conversation — a reopened one already holds its history, and a live
|
|
1070
|
+
// resume never re-sends the parent's either.
|
|
1071
|
+
let effectivePrompt = prompt;
|
|
1072
|
+
if (options.inheritContext && !options.resumeSessionFile) {
|
|
1073
|
+
const parentContext = buildParentContext(ctx);
|
|
1074
|
+
if (parentContext) {
|
|
1075
|
+
effectivePrompt = parentContext + prompt;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
// Boundary for the history fallback: only assistant text produced from here
|
|
1079
|
+
// on counts as this run's output (a fresh session, so usually 0).
|
|
1080
|
+
const startLen = session.messages.length;
|
|
1081
|
+
let structuredRetried = false;
|
|
1082
|
+
try {
|
|
1083
|
+
// A signal that fired before this point never calls the listener above, and
|
|
1084
|
+
// pi's `session.abort()` is a no-op until a run is active — so a child whose
|
|
1085
|
+
// parent is already gone is not prompted at all.
|
|
1086
|
+
if (options.signal?.aborted !== true)
|
|
1087
|
+
await session.prompt(effectivePrompt);
|
|
1088
|
+
// One more prompt when a schema was asked for and nothing usable came back
|
|
1089
|
+
// — the model answered in prose, or only ever called the tool invalidly.
|
|
1090
|
+
// Inside this `try`, so the turn tracking, the text collector and above all
|
|
1091
|
+
// the abort forwarding are still live: torn down first, a retry would be
|
|
1092
|
+
// unkillable.
|
|
1093
|
+
if (structuredCapture !== undefined && structuredCapture.json === undefined
|
|
1094
|
+
&& !run.aborted && options.signal?.aborted !== true) {
|
|
1095
|
+
structuredRetried = true;
|
|
1096
|
+
await session.prompt(structuredRetryPrompt(structuredCapture));
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
finally {
|
|
1100
|
+
run.unsubscribe();
|
|
1101
|
+
collector.unsubscribe();
|
|
1102
|
+
cleanupAbort();
|
|
1103
|
+
}
|
|
1104
|
+
const responseText = collector.getText().trim() || getLastAssistantText(session, startLen);
|
|
1105
|
+
// A child asked for structured output that never gave any has failed, however
|
|
1106
|
+
// articulate its prose was. Reported through `failure` so it travels the same
|
|
1107
|
+
// path as a provider error rather than arriving as a successful empty answer.
|
|
1108
|
+
const structuredFailure = structuredCapture !== undefined && structuredCapture.json === undefined
|
|
1109
|
+
? structuredCapture.lastError !== undefined
|
|
1110
|
+
? `The agent's StructuredOutput call did not match the required schema: ${structuredCapture.lastError}`
|
|
1111
|
+
: "The agent did not report its answer through StructuredOutput."
|
|
1112
|
+
: undefined;
|
|
1113
|
+
return {
|
|
1114
|
+
responseText,
|
|
1115
|
+
session,
|
|
1116
|
+
aborted: run.aborted,
|
|
1117
|
+
steered: run.steered,
|
|
1118
|
+
failure: finalTurnError(session, startLen) ?? structuredFailure,
|
|
1119
|
+
...(structuredCapture?.json !== undefined ? { structuredJson: structuredCapture.json } : {}),
|
|
1120
|
+
...(structuredRetried ? { structuredRetried } : {}),
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Send a new prompt to an existing session (resume). A fresh run of the same
|
|
1125
|
+
* agent: the turn limit is resolved as `runAgent` resolves it — an explicit
|
|
1126
|
+
* `maxTurns`, else the agent type's own `max_turns`, else the project default
|
|
1127
|
+
* — and counted from this prompt (#368).
|
|
1128
|
+
*/
|
|
1129
|
+
export async function resumeAgent(session, prompt, options) {
|
|
1130
|
+
// Boundary for the history fallback: the session already holds prior turns,
|
|
1131
|
+
// so only assistant text produced by THIS resume prompt counts as its output
|
|
1132
|
+
// — a failed resume must not surface the previous turn's answer (#144).
|
|
1133
|
+
const startLen = session.messages.length;
|
|
1134
|
+
const collector = collectResponseText(session);
|
|
1135
|
+
const cleanupAbort = forwardAbortSignal(session, options.signal);
|
|
1136
|
+
const run = observeRun(session, { ...options, maxTurns: resolveEffectiveMaxTurns(options.type, options.maxTurns) });
|
|
1137
|
+
try {
|
|
1138
|
+
// Already aborted: not prompted, as in `runAgent`.
|
|
1139
|
+
if (options.signal?.aborted !== true)
|
|
1140
|
+
await session.prompt(prompt);
|
|
1141
|
+
}
|
|
1142
|
+
finally {
|
|
1143
|
+
collector.unsubscribe();
|
|
1144
|
+
run.unsubscribe();
|
|
1145
|
+
cleanupAbort();
|
|
1146
|
+
}
|
|
1147
|
+
return {
|
|
1148
|
+
text: collector.getText().trim() || getLastAssistantText(session, startLen),
|
|
1149
|
+
failure: finalTurnError(session, startLen),
|
|
1150
|
+
aborted: run.aborted,
|
|
1151
|
+
steered: run.steered,
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Send a steering message to a running subagent.
|
|
1156
|
+
* The message will interrupt the agent after its current tool execution.
|
|
1157
|
+
*/
|
|
1158
|
+
export async function steerAgent(session, message) {
|
|
1159
|
+
await session.steer(message);
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Get the subagent's conversation messages as formatted text.
|
|
1163
|
+
*/
|
|
1164
|
+
export function getAgentConversation(session) {
|
|
1165
|
+
const parts = [];
|
|
1166
|
+
for (const msg of session.messages) {
|
|
1167
|
+
if (msg.role === "user") {
|
|
1168
|
+
const text = typeof msg.content === "string"
|
|
1169
|
+
? msg.content
|
|
1170
|
+
: extractText(msg.content);
|
|
1171
|
+
if (text.trim())
|
|
1172
|
+
parts.push(`[User]: ${text.trim()}`);
|
|
1173
|
+
}
|
|
1174
|
+
else if (msg.role === "assistant") {
|
|
1175
|
+
const textParts = [];
|
|
1176
|
+
const toolCalls = [];
|
|
1177
|
+
for (const c of msg.content) {
|
|
1178
|
+
if (c.type === "text" && c.text)
|
|
1179
|
+
textParts.push(c.text);
|
|
1180
|
+
else if (c.type === "toolCall")
|
|
1181
|
+
toolCalls.push(` Tool: ${c.name ?? c.toolName ?? "unknown"}`);
|
|
1182
|
+
}
|
|
1183
|
+
if (textParts.length > 0)
|
|
1184
|
+
parts.push(`[Assistant]: ${textParts.join("\n")}`);
|
|
1185
|
+
if (toolCalls.length > 0)
|
|
1186
|
+
parts.push(`[Tool Calls]:\n${toolCalls.join("\n")}`);
|
|
1187
|
+
}
|
|
1188
|
+
else if (msg.role === "toolResult") {
|
|
1189
|
+
const text = extractText(msg.content);
|
|
1190
|
+
const truncated = text.length > 200 ? text.slice(0, 200) + "..." : text;
|
|
1191
|
+
parts.push(`[Tool Result (${msg.toolName})]: ${truncated}`);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
return parts.join("\n\n");
|
|
1195
|
+
}
|