@agent-controller/runtime 0.3.1
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/LICENSE +21 -0
- package/README.md +98 -0
- package/dist/adapter.d.ts +23 -0
- package/dist/adapter.js +980 -0
- package/dist/honesty.d.ts +59 -0
- package/dist/honesty.js +226 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +40 -0
- package/dist/testing/fake-provider.d.ts +60 -0
- package/dist/testing/fake-provider.js +170 -0
- package/dist/types.d.ts +112 -0
- package/dist/types.js +2 -0
- package/dist/wire.d.ts +5 -0
- package/dist/wire.js +8 -0
- package/package.json +54 -0
package/dist/adapter.js
ADDED
|
@@ -0,0 +1,980 @@
|
|
|
1
|
+
import { createAgentSession, DefaultResourceLoader, SessionManager, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { getModel } from "@earendil-works/pi-ai";
|
|
3
|
+
import { join, basename, resolve, dirname } from "node:path";
|
|
4
|
+
import { mkdirSync, writeFileSync, existsSync, readFileSync, copyFileSync, realpathSync, readdirSync, unlinkSync } from "node:fs";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { stamp } from "./wire.js";
|
|
9
|
+
import { CORRECTION_PROMPT, HONESTY_PREAMBLE, detectHallucinatedToolCalls, stripHallucinationXml, wrapSkillBody, } from "./honesty.js";
|
|
10
|
+
import { resolveFakeModelIfRequested } from "./testing/fake-provider.js";
|
|
11
|
+
/**
|
|
12
|
+
* Resolve the effective hallucination-detector mode for this session.
|
|
13
|
+
* Defaults to "block" when the spec omits the guardrails block or the field.
|
|
14
|
+
* Unknown string values fall back to "block" with a stderr warning so a
|
|
15
|
+
* typo in the spec fails safe rather than silently downgrading guardrails.
|
|
16
|
+
*/
|
|
17
|
+
function resolveHallucinationMode(spec) {
|
|
18
|
+
const raw = spec.guardrails?.hallucinationDetector;
|
|
19
|
+
if (!raw)
|
|
20
|
+
return "block";
|
|
21
|
+
if (raw === "warn" || raw === "block" || raw === "correct")
|
|
22
|
+
return raw;
|
|
23
|
+
process.stderr.write(`[agent-controller] WARNING: unknown spec.guardrails.hallucinationDetector value ` +
|
|
24
|
+
`"${raw}"; falling back to "block".\n`);
|
|
25
|
+
return "block";
|
|
26
|
+
}
|
|
27
|
+
// Use createRequire so we can resolve CommonJS/ESM packages by name from the
|
|
28
|
+
// runtime package root, regardless of whether the runtime itself is ESM.
|
|
29
|
+
const _require = createRequire(import.meta.url);
|
|
30
|
+
/**
|
|
31
|
+
* Build the object that goes into <cwd>/.pi/mcp.json.
|
|
32
|
+
* pi-mcp-extension expects the servers keyed by name (not an array).
|
|
33
|
+
*/
|
|
34
|
+
function buildMcpJson(servers) {
|
|
35
|
+
const mcpServers = {};
|
|
36
|
+
for (const s of servers) {
|
|
37
|
+
// Build a minimal server config — omit undefined/empty fields so
|
|
38
|
+
// pi-mcp-extension's Zod validation doesn't complain.
|
|
39
|
+
const entry = { transport: s.transport };
|
|
40
|
+
if (s.lifecycle)
|
|
41
|
+
entry.lifecycle = s.lifecycle;
|
|
42
|
+
if (s.command)
|
|
43
|
+
entry.command = s.command;
|
|
44
|
+
if (s.args && s.args.length > 0)
|
|
45
|
+
entry.args = s.args;
|
|
46
|
+
if (s.env && Object.keys(s.env).length > 0)
|
|
47
|
+
entry.env = s.env;
|
|
48
|
+
if (s.url)
|
|
49
|
+
entry.url = s.url;
|
|
50
|
+
if (s.headers && Object.keys(s.headers).length > 0)
|
|
51
|
+
entry.headers = s.headers;
|
|
52
|
+
mcpServers[s.name] = entry;
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
settings: { toolPrefix: "mcp" },
|
|
56
|
+
mcpServers,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Write <cwd>/.pi/mcp.json from the ADL mcpServers list.
|
|
61
|
+
*
|
|
62
|
+
* If the file already exists and its contents differ from what we'd write,
|
|
63
|
+
* this throws an error to avoid silently clobbering user config. When the
|
|
64
|
+
* contents are identical (idempotent re-run), we skip the write.
|
|
65
|
+
*/
|
|
66
|
+
function writeMcpJson(cwd, servers) {
|
|
67
|
+
const dir = join(cwd, ".pi");
|
|
68
|
+
const filePath = join(dir, "mcp.json");
|
|
69
|
+
const payload = buildMcpJson(servers);
|
|
70
|
+
const newContent = JSON.stringify(payload, null, 2);
|
|
71
|
+
if (existsSync(filePath)) {
|
|
72
|
+
const existing = readFileSync(filePath, "utf8");
|
|
73
|
+
if (existing.trim() === newContent.trim()) {
|
|
74
|
+
// Identical — idempotent, nothing to do.
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
throw new Error(`Cannot write MCP config: ${filePath} already exists with different contents.\n` +
|
|
78
|
+
`Remove or reconcile the file before running an agent with spec.mcpServers.`);
|
|
79
|
+
}
|
|
80
|
+
mkdirSync(dir, { recursive: true });
|
|
81
|
+
writeFileSync(filePath, newContent, "utf8");
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Materialize subagent .md files into <cwd>/.pi/agents/ so Pi's subagent
|
|
85
|
+
* extension can discover them via discoverAgents(cwd, "both"|"project").
|
|
86
|
+
*
|
|
87
|
+
* Each subagent's entrypoint is the absolute path to a .md file in our
|
|
88
|
+
* agents/ directory. We copy it to <cwd>/.pi/agents/<slug>.md.
|
|
89
|
+
*
|
|
90
|
+
* If the destination already exists with identical content, we no-op
|
|
91
|
+
* (idempotent). If different, we overwrite — .pi/agents/ is project-local
|
|
92
|
+
* and fully owned by agent-controller.
|
|
93
|
+
*/
|
|
94
|
+
function writeAgentFiles(cwd, subagents) {
|
|
95
|
+
if (subagents.length === 0)
|
|
96
|
+
return;
|
|
97
|
+
const agentsDir = join(cwd, ".pi", "agents");
|
|
98
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
99
|
+
// Build the set of filenames we are about to write so we can identify stale entries.
|
|
100
|
+
const declaredBasenames = new Set(subagents.map((s) => basename(s.entrypoint)));
|
|
101
|
+
// Remove any pre-existing .md files in agentsDir that are NOT in the declared set.
|
|
102
|
+
// This enforces the ADL allowlist: only spec.subagents[] agents are present in
|
|
103
|
+
// .pi/agents/, preventing the subagent extension (scoped to "project") from
|
|
104
|
+
// discovering stale or injected agent files.
|
|
105
|
+
if (existsSync(agentsDir)) {
|
|
106
|
+
let entries;
|
|
107
|
+
try {
|
|
108
|
+
entries = readdirSync(agentsDir);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
entries = [];
|
|
112
|
+
}
|
|
113
|
+
for (const entry of entries) {
|
|
114
|
+
if (!entry.endsWith(".md"))
|
|
115
|
+
continue;
|
|
116
|
+
if (!declaredBasenames.has(entry)) {
|
|
117
|
+
try {
|
|
118
|
+
unlinkSync(join(agentsDir, entry));
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
// Fail closed: if we can't remove a stale agent file, the project
|
|
122
|
+
// scope still exposes it to the subagent discovery, which defeats
|
|
123
|
+
// the ADL allowlist enforcement. Better to abort the run with a
|
|
124
|
+
// clear error than silently leave an undeclared agent reachable.
|
|
125
|
+
const path = join(agentsDir, entry);
|
|
126
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
127
|
+
throw new Error(`Failed to remove stale agent file ${path}: ${msg}. ` +
|
|
128
|
+
`agent-controller cannot guarantee the subagent allowlist while ` +
|
|
129
|
+
`undeclared .md files remain in .pi/agents/. Delete the file ` +
|
|
130
|
+
`manually or fix the permissions, then re-run.`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const subagent of subagents) {
|
|
136
|
+
const destPath = join(agentsDir, basename(subagent.entrypoint));
|
|
137
|
+
if (existsSync(destPath)) {
|
|
138
|
+
const existing = readFileSync(destPath, "utf8");
|
|
139
|
+
const incoming = readFileSync(subagent.entrypoint, "utf8");
|
|
140
|
+
if (existing === incoming) {
|
|
141
|
+
// Identical — idempotent, skip.
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
// Overwrite — agent-controller owns this directory.
|
|
145
|
+
}
|
|
146
|
+
copyFileSync(subagent.entrypoint, destPath);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Write <cwd>/.pi/agent/models.json so that child `pi` processes spawned by
|
|
151
|
+
* the subagent extension route through the same Anthropic gateway as the
|
|
152
|
+
* parent adapter session.
|
|
153
|
+
*
|
|
154
|
+
* When ANTHROPIC_BASE_URL is set, standalone `pi` does not pick it up
|
|
155
|
+
* automatically — pi's anthropic provider always passes `baseURL: model.baseUrl`
|
|
156
|
+
* (hardcoded to api.anthropic.com) explicitly to the SDK, overriding any env
|
|
157
|
+
* var. Pi's `models.json` supports a `providers.<name>.baseUrl` override that
|
|
158
|
+
* IS applied at model-resolution time, so we use that mechanism.
|
|
159
|
+
*
|
|
160
|
+
* We write the file to <cwd>/.pi/agent/ (not the global ~/.pi/agent/) and tell
|
|
161
|
+
* child pi processes to use that directory via PI_CODING_AGENT_DIR, keeping
|
|
162
|
+
* the override project-local and non-destructive to global user config.
|
|
163
|
+
*
|
|
164
|
+
* Returns the local agent-dir path (for PI_CODING_AGENT_DIR), or undefined
|
|
165
|
+
* when ANTHROPIC_BASE_URL is not set.
|
|
166
|
+
*/
|
|
167
|
+
function writeSubagentModelsJson(cwd) {
|
|
168
|
+
const anthropicBaseUrl = process.env.ANTHROPIC_BASE_URL;
|
|
169
|
+
if (!anthropicBaseUrl)
|
|
170
|
+
return undefined; // nothing to override
|
|
171
|
+
const localAgentDir = join(cwd, ".pi", "agent");
|
|
172
|
+
const filePath = join(localAgentDir, "models.json");
|
|
173
|
+
// Pi's models.json format: providers.<name>.baseUrl overrides the built-in
|
|
174
|
+
// model baseUrl. An empty models list means "override-only" (no custom models
|
|
175
|
+
// added, just the provider URL replaced). The auth.json must also exist so pi
|
|
176
|
+
// doesn't try to write auth to a missing location.
|
|
177
|
+
const payload = {
|
|
178
|
+
providers: {
|
|
179
|
+
anthropic: {
|
|
180
|
+
baseUrl: anthropicBaseUrl,
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
const newContent = JSON.stringify(payload, null, 2);
|
|
185
|
+
mkdirSync(localAgentDir, { recursive: true });
|
|
186
|
+
if (existsSync(filePath)) {
|
|
187
|
+
const existing = readFileSync(filePath, "utf8");
|
|
188
|
+
if (existing.trim() === newContent.trim()) {
|
|
189
|
+
return localAgentDir; // idempotent
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
writeFileSync(filePath, newContent, "utf8");
|
|
193
|
+
// Ensure auth.json exists (pi writes to it at startup; if missing it will
|
|
194
|
+
// fail to start when PI_CODING_AGENT_DIR points to an empty directory).
|
|
195
|
+
const authPath = join(localAgentDir, "auth.json");
|
|
196
|
+
if (!existsSync(authPath)) {
|
|
197
|
+
writeFileSync(authPath, "{}", "utf8");
|
|
198
|
+
}
|
|
199
|
+
return localAgentDir;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Copy tool extensions declared in the CompiledSpec into the local agent dir's
|
|
203
|
+
* extensions/ folder so child `pi` processes (spawned by the subagent extension)
|
|
204
|
+
* can load them via the default PI_CODING_AGENT_DIR discovery path.
|
|
205
|
+
*
|
|
206
|
+
* Pi's DefaultResourceLoader auto-discovers extensions from
|
|
207
|
+
* <agentDir>/extensions/<name>/index.ts (or index.js). When we redirect
|
|
208
|
+
* PI_CODING_AGENT_DIR to our local .pi/agent/, we copy each tool's entrypoint
|
|
209
|
+
* there named as index.ts so Pi's package-manager discovery picks it up.
|
|
210
|
+
*
|
|
211
|
+
* Each tool's entrypoint is copied to:
|
|
212
|
+
* <localAgentDir>/extensions/<name>/index.ts
|
|
213
|
+
*/
|
|
214
|
+
function copyToolExtensionsToLocalAgentDir(localAgentDir, tools) {
|
|
215
|
+
if (tools.length === 0)
|
|
216
|
+
return;
|
|
217
|
+
const extDir = join(localAgentDir, "extensions");
|
|
218
|
+
mkdirSync(extDir, { recursive: true });
|
|
219
|
+
for (const tool of tools) {
|
|
220
|
+
const toolDir = join(extDir, tool.name);
|
|
221
|
+
mkdirSync(toolDir, { recursive: true });
|
|
222
|
+
// Pi discovers extensions by looking for index.ts or index.js in each subdir
|
|
223
|
+
// under <agentDir>/extensions/. Always write as index.ts regardless of the
|
|
224
|
+
// original entrypoint filename so auto-discovery works.
|
|
225
|
+
const destPath = join(toolDir, "index.ts");
|
|
226
|
+
if (existsSync(destPath)) {
|
|
227
|
+
const existing = readFileSync(destPath, "utf8");
|
|
228
|
+
const incoming = readFileSync(tool.entrypoint, "utf8");
|
|
229
|
+
if (existing === incoming)
|
|
230
|
+
continue; // idempotent
|
|
231
|
+
}
|
|
232
|
+
copyFileSync(tool.entrypoint, destPath);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Build the session's system prompt. Always starts with the honesty
|
|
237
|
+
* preamble (see runtime/src/honesty.ts) so the model has clear rules
|
|
238
|
+
* against fabricated tool calls before it sees anything else. Persona
|
|
239
|
+
* role and instructions are appended after when present.
|
|
240
|
+
*
|
|
241
|
+
* Returns a non-empty string in every case — there is no scenario where
|
|
242
|
+
* an agent-controller session should run without the honesty rules.
|
|
243
|
+
*/
|
|
244
|
+
function buildSystemPrompt(persona) {
|
|
245
|
+
const parts = [HONESTY_PREAMBLE];
|
|
246
|
+
if (persona?.role)
|
|
247
|
+
parts.push(`# Role\n${persona.role}`);
|
|
248
|
+
if (persona?.instructions)
|
|
249
|
+
parts.push(`# Instructions\n${persona.instructions}`);
|
|
250
|
+
return parts.join("\n\n");
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Resolve the pi binary for the runtime process.
|
|
254
|
+
*
|
|
255
|
+
* Precedence:
|
|
256
|
+
* 1. AC_PI_BIN env var (set by adapter when spawning subagents)
|
|
257
|
+
* 2. PI_BIN env var (user override)
|
|
258
|
+
* 3. runtime/node_modules/.bin/pi (sibling of THIS file's node_modules)
|
|
259
|
+
* 4. System pi on PATH via `which`
|
|
260
|
+
*/
|
|
261
|
+
function resolvePiBinForRuntime() {
|
|
262
|
+
if (process.env.AC_PI_BIN && existsSync(process.env.AC_PI_BIN)) {
|
|
263
|
+
return process.env.AC_PI_BIN;
|
|
264
|
+
}
|
|
265
|
+
if (process.env.PI_BIN && existsSync(process.env.PI_BIN)) {
|
|
266
|
+
return process.env.PI_BIN;
|
|
267
|
+
}
|
|
268
|
+
// Walk up from this file to find node_modules/.bin/pi
|
|
269
|
+
const __filename2 = fileURLToPath(import.meta.url);
|
|
270
|
+
const __dirname2 = dirname(__filename2);
|
|
271
|
+
const candidates = [
|
|
272
|
+
join(__dirname2, "..", "node_modules", ".bin", "pi"),
|
|
273
|
+
join(__dirname2, "..", "..", "node_modules", ".bin", "pi"),
|
|
274
|
+
];
|
|
275
|
+
for (const c of candidates) {
|
|
276
|
+
try {
|
|
277
|
+
const real = realpathSync(c);
|
|
278
|
+
if (existsSync(real))
|
|
279
|
+
return real;
|
|
280
|
+
}
|
|
281
|
+
catch { /* not found */ }
|
|
282
|
+
}
|
|
283
|
+
// Fall back to system PATH
|
|
284
|
+
const which = spawnSync("which", ["pi"], { encoding: "utf8" });
|
|
285
|
+
if (which.status === 0) {
|
|
286
|
+
const p = which.stdout.trim();
|
|
287
|
+
if (p && existsSync(p))
|
|
288
|
+
return p;
|
|
289
|
+
}
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Resolve a source-bound extension: install it if needed, read its
|
|
294
|
+
* pi.extensions manifest, and return the absolute entrypoint path.
|
|
295
|
+
*
|
|
296
|
+
* @param source — e.g. "npm:pi-mcp-extension"
|
|
297
|
+
* @returns absolute path to the extension entrypoint
|
|
298
|
+
* @throws when source scheme is unsupported, pi is missing, install fails,
|
|
299
|
+
* or the package declares no extensions
|
|
300
|
+
*/
|
|
301
|
+
export function resolveSourceBoundExtension(source) {
|
|
302
|
+
// ── Parse source ──────────────────────────────────────────────────────────
|
|
303
|
+
if (!source.startsWith("npm:")) {
|
|
304
|
+
throw new Error(`Unsupported source scheme in "${source}". ` +
|
|
305
|
+
`Only "npm:" is supported at v0.1.6.`);
|
|
306
|
+
}
|
|
307
|
+
const pkgName = source.slice("npm:".length);
|
|
308
|
+
if (!pkgName) {
|
|
309
|
+
throw new Error(`source "${source}" has an empty package name.`);
|
|
310
|
+
}
|
|
311
|
+
// ── Locate installed package ───────────────────────────────────────────────
|
|
312
|
+
// Pi installs npm packages to ~/.pi/agent/npm/node_modules/<name>/
|
|
313
|
+
// OR they may already be in the runtime's own node_modules (e.g. pi-mcp-extension).
|
|
314
|
+
const piAgentDir = getAgentDir();
|
|
315
|
+
const piManagedPath = join(piAgentDir, "npm", "node_modules", pkgName);
|
|
316
|
+
// Runtime's own node_modules (resolved via require)
|
|
317
|
+
let runtimeNodeModulesPath;
|
|
318
|
+
try {
|
|
319
|
+
// createRequire-based resolver anchored to this file
|
|
320
|
+
const req = createRequire(import.meta.url);
|
|
321
|
+
const resolved = req.resolve(`${pkgName}/package.json`);
|
|
322
|
+
// resolved is /abs/.../pkgName/package.json → dirname is the package root
|
|
323
|
+
runtimeNodeModulesPath = dirname(resolved);
|
|
324
|
+
}
|
|
325
|
+
catch { /* not found in runtime node_modules */ }
|
|
326
|
+
// Pick the first path that has a package.json
|
|
327
|
+
let pkgRoot;
|
|
328
|
+
if (runtimeNodeModulesPath && existsSync(join(runtimeNodeModulesPath, "package.json"))) {
|
|
329
|
+
pkgRoot = runtimeNodeModulesPath;
|
|
330
|
+
}
|
|
331
|
+
else if (existsSync(join(piManagedPath, "package.json"))) {
|
|
332
|
+
pkgRoot = piManagedPath;
|
|
333
|
+
}
|
|
334
|
+
// ── Install if missing ────────────────────────────────────────────────────
|
|
335
|
+
if (!pkgRoot) {
|
|
336
|
+
// Safety valve: if auto-installation is disabled, emit a clear error.
|
|
337
|
+
if (process.env.AGENT_CONTROLLER_NO_AUTO_INSTALL === "1") {
|
|
338
|
+
throw new Error(`Auto-installation is disabled (AGENT_CONTROLLER_NO_AUTO_INSTALL=1). ` +
|
|
339
|
+
`Run \`agentctl install ${source}\` first, then re-run.`);
|
|
340
|
+
}
|
|
341
|
+
const piBin = resolvePiBinForRuntime();
|
|
342
|
+
if (!piBin) {
|
|
343
|
+
throw new Error(`pi binary not found. Cannot auto-install "${source}".\n` +
|
|
344
|
+
`Run \`agentctl install ${source}\` manually, or install pi and retry.\n` +
|
|
345
|
+
`Alternatively, set the PI_BIN environment variable to point at pi.`);
|
|
346
|
+
}
|
|
347
|
+
const result = spawnSync(piBin, ["install", source], {
|
|
348
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
349
|
+
encoding: "utf8",
|
|
350
|
+
});
|
|
351
|
+
if (result.error) {
|
|
352
|
+
throw new Error(`Failed to spawn pi to install "${source}": ${result.error.message}`);
|
|
353
|
+
}
|
|
354
|
+
if (result.status !== 0) {
|
|
355
|
+
throw new Error(`pi install ${source} failed with exit code ${result.status ?? "(signal)"}. ` +
|
|
356
|
+
`Check pi output above for details.`);
|
|
357
|
+
}
|
|
358
|
+
// After install, Pi places the package at ~/.pi/agent/npm/node_modules/<name>/
|
|
359
|
+
if (existsSync(join(piManagedPath, "package.json"))) {
|
|
360
|
+
pkgRoot = piManagedPath;
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
throw new Error(`pi install ${source} appeared to succeed but the package was not found at ` +
|
|
364
|
+
`${piManagedPath}. Check Pi's installation output.`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// ── Read pi.extensions from package.json ─────────────────────────────────
|
|
368
|
+
const pkgJsonPath = join(pkgRoot, "package.json");
|
|
369
|
+
let pkgJson;
|
|
370
|
+
try {
|
|
371
|
+
pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
|
|
372
|
+
}
|
|
373
|
+
catch (err) {
|
|
374
|
+
throw new Error(`Failed to read package.json for "${pkgName}" at ${pkgJsonPath}: ${err.message}`);
|
|
375
|
+
}
|
|
376
|
+
const piBlock = pkgJson.pi;
|
|
377
|
+
const extensionsArr = piBlock?.extensions;
|
|
378
|
+
if (!Array.isArray(extensionsArr) || extensionsArr.length === 0) {
|
|
379
|
+
throw new Error(`Package "${pkgName}" declares no Pi extensions (pi.extensions is absent or empty in its package.json). ` +
|
|
380
|
+
`Cannot use it as a source-bound extension.`);
|
|
381
|
+
}
|
|
382
|
+
const relEntrypoint = extensionsArr[0];
|
|
383
|
+
return resolve(pkgRoot, relEntrypoint);
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* runSession assembles a Pi session from the CompiledSpec, subscribes to
|
|
387
|
+
* its events, submits the task as the initial prompt, and resolves when
|
|
388
|
+
* the session ends. emit is invoked once per outgoing wire event.
|
|
389
|
+
*
|
|
390
|
+
* Extension configuration is passed to extensions via the
|
|
391
|
+
* AGENT_CONTROLLER_EXT_CONFIG env var (JSON object keyed by extension name).
|
|
392
|
+
* This is a deliberate MVP convention; v0.2 will migrate to Pi's
|
|
393
|
+
* settingsManager once that API stabilizes (tracked in spec §12 research
|
|
394
|
+
* item 5).
|
|
395
|
+
*/
|
|
396
|
+
export async function runSession(spec, emit) {
|
|
397
|
+
// Emit deprecation warning if spec.installs[] is non-empty.
|
|
398
|
+
// spec.installs[] is deprecated in favour of spec.extensions[].source.
|
|
399
|
+
if (spec.installs && spec.installs.length > 0) {
|
|
400
|
+
process.stderr.write("[agent-controller] DEPRECATION WARNING: `spec.installs[]` is deprecated; " +
|
|
401
|
+
"prefer `spec.extensions[].source` instead.\n");
|
|
402
|
+
}
|
|
403
|
+
// Resolve source-bound extensions (spec.extensions[].source is set).
|
|
404
|
+
// For each such entry, install the package if needed and determine its
|
|
405
|
+
// entrypoint; then treat it exactly like a locally-resolved extension.
|
|
406
|
+
const resolvedExtensionPaths = spec.extensions.map((e) => {
|
|
407
|
+
if (e.source) {
|
|
408
|
+
// Source-bound: install if needed and resolve entrypoint.
|
|
409
|
+
return resolveSourceBoundExtension(e.source);
|
|
410
|
+
}
|
|
411
|
+
// Normal registry-resolved extension.
|
|
412
|
+
return e.entrypoint;
|
|
413
|
+
});
|
|
414
|
+
const entrypointPaths = [
|
|
415
|
+
// Pi-builtin tools (bash/read/edit/write) have no entrypoint — they
|
|
416
|
+
// contribute only to the active-tool allowlist (handled below where
|
|
417
|
+
// toolAllowlist is built from spec.tools[].name). Filter them out of
|
|
418
|
+
// additionalExtensionPaths so we don't try to load nonexistent paths.
|
|
419
|
+
...spec.tools.filter((t) => !t.builtin && t.entrypoint).map((t) => t.entrypoint),
|
|
420
|
+
...resolvedExtensionPaths,
|
|
421
|
+
];
|
|
422
|
+
// If spec.mcpServers is non-empty, write <cwd>/.pi/mcp.json and add the
|
|
423
|
+
// pi-mcp-extension entrypoint to the loader paths. This must happen BEFORE
|
|
424
|
+
// DefaultResourceLoader is constructed so the extension is loaded during reload().
|
|
425
|
+
if (spec.mcpServers && spec.mcpServers.length > 0) {
|
|
426
|
+
writeMcpJson(process.cwd(), spec.mcpServers);
|
|
427
|
+
// Only add pi-mcp-extension if it wasn't already loaded via a source-bound
|
|
428
|
+
// spec.extensions[] entry (e.g. { name: "pi-mcp-extension", source: "npm:..." }).
|
|
429
|
+
// Deduplication prevents the extension from loading twice, which would cause
|
|
430
|
+
// a "ResourceCollision" error from DefaultResourceLoader.
|
|
431
|
+
const mcpAlreadyLoaded = spec.extensions.some((e) => e.source === "npm:pi-mcp-extension" || e.name === "pi-mcp-extension");
|
|
432
|
+
if (!mcpAlreadyLoaded) {
|
|
433
|
+
// Resolve the pi-mcp-extension entrypoint from the runtime's own node_modules.
|
|
434
|
+
// The package.json "pi.extensions" field declares "./src/index.ts" as the
|
|
435
|
+
// extension entrypoint — Pi loads .ts files via jiti (no build step needed).
|
|
436
|
+
const mcpPkg = _require.resolve("pi-mcp-extension/src/index.ts");
|
|
437
|
+
entrypointPaths.push(mcpPkg);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
// If spec.subagents is non-empty, materialize the .md files into
|
|
441
|
+
// <cwd>/.pi/agents/ so Pi's subagent extension discovers them, then
|
|
442
|
+
// add the vendored subagent extension entrypoint to the loader paths.
|
|
443
|
+
// The subagent extension registers the "subagent" tool which the parent
|
|
444
|
+
// agent uses to invoke child agents.
|
|
445
|
+
if (spec.subagents && spec.subagents.length > 0) {
|
|
446
|
+
// Subagents always have an entrypoint (the .md path). Narrow the type
|
|
447
|
+
// for writeAgentFiles so it can use entrypoint directly without
|
|
448
|
+
// having to guard for undefined.
|
|
449
|
+
const resolvedSubagents = spec.subagents
|
|
450
|
+
.filter((s) => Boolean(s.entrypoint));
|
|
451
|
+
writeAgentFiles(process.cwd(), resolvedSubagents);
|
|
452
|
+
// Write <cwd>/.pi/agent/models.json with providers.anthropic.baseUrl so
|
|
453
|
+
// child `pi` processes use the same gateway as the parent session.
|
|
454
|
+
// This only makes sense when ANTHROPIC_BASE_URL is set; when using the
|
|
455
|
+
// default Anthropic SDK (just ANTHROPIC_API_KEY), skip the model override.
|
|
456
|
+
const localAgentDir = writeSubagentModelsJson(process.cwd());
|
|
457
|
+
if (localAgentDir) {
|
|
458
|
+
process.env.AC_SUBAGENT_AGENT_DIR = localAgentDir;
|
|
459
|
+
}
|
|
460
|
+
// Copy parent's tool extensions into the local agent dir so child pi
|
|
461
|
+
// sessions can discover and activate them (e.g. get_time).
|
|
462
|
+
// This must run whenever subagents are declared, regardless of whether
|
|
463
|
+
// ANTHROPIC_BASE_URL is set — tools must be available even with a bare
|
|
464
|
+
// ANTHROPIC_API_KEY setup.
|
|
465
|
+
{
|
|
466
|
+
// Use the localAgentDir if available (custom gateway path) or fall back
|
|
467
|
+
// to a default local agent dir so child pi can always find the tools.
|
|
468
|
+
const agentDirForTools = localAgentDir ?? join(process.cwd(), ".pi", "agent");
|
|
469
|
+
// Only entrypoint-backed tools can be copied (builtins are Pi-shipped).
|
|
470
|
+
const resolvedTools = spec.tools
|
|
471
|
+
.filter((t) => !t.builtin && Boolean(t.entrypoint));
|
|
472
|
+
copyToolExtensionsToLocalAgentDir(agentDirForTools, resolvedTools);
|
|
473
|
+
}
|
|
474
|
+
// Expose the `pi` CLI binary path via AC_PI_BIN so the vendored subagent
|
|
475
|
+
// extension can spawn the correct `pi` when `pi` is not on the system PATH.
|
|
476
|
+
// The package's exports map blocks deep-path _require.resolve, so we look
|
|
477
|
+
// for the .bin/pi symlink relative to node_modules, then fall back to the
|
|
478
|
+
// known conventional path for our vendored package.
|
|
479
|
+
//
|
|
480
|
+
// Strategy: walk up from our own file to find a node_modules/.bin/pi that
|
|
481
|
+
// resolves to a real file, or construct the known path from the package we
|
|
482
|
+
// import from. We use __filename (adapter.ts/adapter.js) as the anchor.
|
|
483
|
+
{
|
|
484
|
+
const __filename2 = fileURLToPath(import.meta.url);
|
|
485
|
+
const __dirname2 = dirname(__filename2);
|
|
486
|
+
// Candidate paths to check, from most-local to most-global:
|
|
487
|
+
const piCandidates = [
|
|
488
|
+
join(__dirname2, "..", "node_modules", ".bin", "pi"), // runtime/node_modules/.bin/pi
|
|
489
|
+
join(__dirname2, "..", "..", "node_modules", ".bin", "pi"), // root/node_modules/.bin/pi
|
|
490
|
+
join(__dirname2, "..", "node_modules", "@earendil-works", "pi-coding-agent", "dist", "cli.js"),
|
|
491
|
+
join(__dirname2, "..", "node_modules", "@mariozechner", "pi-coding-agent", "dist", "cli.js"),
|
|
492
|
+
];
|
|
493
|
+
for (const candidate of piCandidates) {
|
|
494
|
+
try {
|
|
495
|
+
// Use realpathSync to resolve symlinks (like .bin/pi -> ../pkg/dist/cli.js)
|
|
496
|
+
const real = realpathSync(candidate);
|
|
497
|
+
if (existsSync(real)) {
|
|
498
|
+
process.env.AC_PI_BIN = real;
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
// not found, try next
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
// Resolve the vendored subagent extension entrypoint relative to THIS
|
|
508
|
+
// compiled file. After tsc, adapter.js is at runtime/dist/adapter.js and
|
|
509
|
+
// the vendored extension is at extensions/subagent/entrypoint.ts (two dirs
|
|
510
|
+
// up from runtime/dist → runtime/ → root/). Pi loads .ts files via jiti.
|
|
511
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
512
|
+
const __dirname = dirname(__filename);
|
|
513
|
+
const subagentExtPath = resolve(__dirname, "../../extensions/subagent/entrypoint.ts");
|
|
514
|
+
entrypointPaths.push(subagentExtPath);
|
|
515
|
+
}
|
|
516
|
+
// Populate the extension-config env var BEFORE constructing the loader
|
|
517
|
+
// or session — extensions read it inside their default export, which
|
|
518
|
+
// executes during session construction.
|
|
519
|
+
//
|
|
520
|
+
// Include both spec.tools[].config and spec.extensions[].config entries,
|
|
521
|
+
// keyed by name. Tools are loaded as Pi extension entrypoints and have no
|
|
522
|
+
// other config channel. Extensions win on name collision (last-write), but
|
|
523
|
+
// we emit a warning so the conflict is visible.
|
|
524
|
+
const extConfig = {};
|
|
525
|
+
for (const t of spec.tools) {
|
|
526
|
+
if (t.config)
|
|
527
|
+
extConfig[t.name] = t.config;
|
|
528
|
+
}
|
|
529
|
+
for (const e of spec.extensions) {
|
|
530
|
+
if (e.config) {
|
|
531
|
+
if (e.name in extConfig) {
|
|
532
|
+
process.stderr.write(`[agent-controller] WARNING: tool and extension share the name "${e.name}"; extension config wins.\n`);
|
|
533
|
+
}
|
|
534
|
+
extConfig[e.name] = e.config;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
process.env.AGENT_CONTROLLER_EXT_CONFIG = JSON.stringify(extConfig);
|
|
538
|
+
// Skill paths: each skill entrypoint is the absolute path to SKILL.md.
|
|
539
|
+
// Pi's loadSkillsFromDir treats a directory containing SKILL.md as a skill
|
|
540
|
+
// root, and additionalSkillPaths accepts either files or directories.
|
|
541
|
+
// We pass the parent directory of each SKILL.md so Pi uses its standard
|
|
542
|
+
// directory-based discovery rule.
|
|
543
|
+
//
|
|
544
|
+
// Use path.dirname() (already imported from node:path) for platform-safe
|
|
545
|
+
// parent-dir derivation — avoids manual split("/") which breaks on Windows.
|
|
546
|
+
const skillDirs = spec.skills
|
|
547
|
+
// Skills always have an entrypoint (the SKILL.md path) — no source/builtin
|
|
548
|
+
// shortcut applies to skills. Filter defensively in case the compiler ever
|
|
549
|
+
// emits a malformed ResolvedRef.
|
|
550
|
+
.filter((s) => Boolean(s.entrypoint))
|
|
551
|
+
.map((s) =>
|
|
552
|
+
// s.entrypoint = "<root>/skills/<name>/SKILL.md"
|
|
553
|
+
// dirname gives "<root>/skills/<name>" which Pi treats as a skill root.
|
|
554
|
+
dirname(s.entrypoint));
|
|
555
|
+
// Pi's formatSkillsForPrompt only injects each skill's frontmatter
|
|
556
|
+
// {name, description, filePath} into the system prompt. The body is
|
|
557
|
+
// lazy-loaded by the agent via the `read` tool when it decides the skill
|
|
558
|
+
// matches the task — but our ADL allowlist typically excludes `read`,
|
|
559
|
+
// and even when it doesn't, the model frequently won't bother loading
|
|
560
|
+
// skills it could be following.
|
|
561
|
+
//
|
|
562
|
+
// For ADL-declared skills the user clearly opted in, so we additionally
|
|
563
|
+
// inline each skill's body (everything after the YAML frontmatter) into
|
|
564
|
+
// appendSystemPrompt. The body is then unconditionally active for the
|
|
565
|
+
// session, matching the declarative-governance semantics users expect.
|
|
566
|
+
// The lazy/read-tool path still works for skills the model wants to
|
|
567
|
+
// re-read or inspect mid-task.
|
|
568
|
+
const skillBodies = [];
|
|
569
|
+
for (const s of spec.skills) {
|
|
570
|
+
if (!s.entrypoint)
|
|
571
|
+
continue; // defensive: skip malformed skill refs
|
|
572
|
+
try {
|
|
573
|
+
const raw = readFileSync(s.entrypoint, "utf8");
|
|
574
|
+
// Strip YAML frontmatter: leading "---\n...\n---\n".
|
|
575
|
+
const stripped = raw.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, "");
|
|
576
|
+
if (stripped.trim().length > 0) {
|
|
577
|
+
skillBodies.push(wrapSkillBody(s.name, stripped.trim()));
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
catch (err) {
|
|
581
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
582
|
+
process.stderr.write(`[agent-controller] WARNING: could not read skill ${s.name} at ${s.entrypoint}: ${msg}\n`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
// appendSystemPrompt is string[] (Pi joins with "\n\n"); pass an array.
|
|
586
|
+
const skillsAppend = skillBodies.length > 0 ? skillBodies : undefined;
|
|
587
|
+
// DefaultResourceLoader requires cwd and agentDir to resolve paths;
|
|
588
|
+
// we use the process working directory and a local .agent-controller dir
|
|
589
|
+
// as sensible defaults for the MVP runner.
|
|
590
|
+
//
|
|
591
|
+
// systemPrompt is derived from spec.persona (if present) and passed here so
|
|
592
|
+
// the resource loader serves it to Pi — the resourceLoader.getSystemPrompt()
|
|
593
|
+
// method is called internally by createAgentSession when building the agent.
|
|
594
|
+
//
|
|
595
|
+
// noExtensions: true prevents DefaultResourceLoader.reload() from scanning
|
|
596
|
+
// ~/.pi/agent/extensions/ and <cwd>/.pi/extensions/ for ambient extensions.
|
|
597
|
+
// Without this, extensions outside the ADL allowlist would silently load even
|
|
598
|
+
// when noTools: "builtin" is set. We still pass additionalExtensionPaths so
|
|
599
|
+
// the spec-declared tools and extensions are registered normally.
|
|
600
|
+
//
|
|
601
|
+
// noSkills: true suppresses Pi's default skill scan from ~/.pi/agent/skills/
|
|
602
|
+
// and <cwd>/.pi/skills/. Only ADL-declared skills (via additionalSkillPaths)
|
|
603
|
+
// are loaded, keeping the environment hermetic and consistent with the ADL
|
|
604
|
+
// allowlist principle used for extensions.
|
|
605
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
606
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
607
|
+
cwd: process.cwd(),
|
|
608
|
+
agentDir: process.cwd() + "/.agent-controller",
|
|
609
|
+
additionalExtensionPaths: entrypointPaths,
|
|
610
|
+
additionalSkillPaths: skillDirs,
|
|
611
|
+
systemPrompt: buildSystemPrompt(spec.persona),
|
|
612
|
+
// appendSystemPrompt: each declared skill's body is concatenated and
|
|
613
|
+
// appended to the system prompt verbatim. This makes skills *active by
|
|
614
|
+
// default* rather than lazy-loadable via the read tool — matches what
|
|
615
|
+
// users expect when they declare a skill in spec.skills[].
|
|
616
|
+
appendSystemPrompt: skillsAppend,
|
|
617
|
+
noExtensions: true,
|
|
618
|
+
// noSkills suppresses the default skill scan from ~/.pi/agent/skills/ and
|
|
619
|
+
// <cwd>/.pi/skills/. We always set it to keep the environment hermetic;
|
|
620
|
+
// ADL-declared skills reach the loader exclusively via additionalSkillPaths.
|
|
621
|
+
noSkills: true,
|
|
622
|
+
});
|
|
623
|
+
// Must call reload() explicitly: createAgentSession only calls reload() when
|
|
624
|
+
// it constructs DefaultResourceLoader itself. When we pass a pre-built loader
|
|
625
|
+
// it assumes the caller has already loaded it. Without this call no extensions
|
|
626
|
+
// (get_time, audit-log) are active.
|
|
627
|
+
await resourceLoader.reload();
|
|
628
|
+
// Fail fast if any ADL-declared entrypoint failed to load. Pi records load
|
|
629
|
+
// failures in extensionsResult.errors as { path, error } pairs but continues
|
|
630
|
+
// silently — without this check, Pi's tool allowlist would simply contain a
|
|
631
|
+
// name that maps to no implementation, and the run would proceed without
|
|
632
|
+
// the declared tools.
|
|
633
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
634
|
+
const extensionsResult = resourceLoader.getExtensions?.();
|
|
635
|
+
if (extensionsResult?.errors?.length) {
|
|
636
|
+
const declared = new Set(entrypointPaths);
|
|
637
|
+
const relevant = extensionsResult.errors.filter((e) => declared.has(e.path));
|
|
638
|
+
if (relevant.length > 0) {
|
|
639
|
+
const summary = relevant
|
|
640
|
+
.map((e) => ` - ${e.path}: ${e.error}`)
|
|
641
|
+
.join("\n");
|
|
642
|
+
throw new Error(`Failed to load ADL-declared extensions:\n${summary}\n` +
|
|
643
|
+
`These are listed in spec.tools[]/spec.extensions[] but the runtime ` +
|
|
644
|
+
`could not load their entrypoints. Check the manifest's entrypoint path.`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
// E2E test hook: when AGENT_CONTROLLER_USE_FAKE_PROVIDER=1 and a fake
|
|
648
|
+
// has been installed via the testing helper, use it in place of the
|
|
649
|
+
// real provider. The env var alone does nothing without a script.
|
|
650
|
+
// See runtime/src/testing/fake-provider.ts.
|
|
651
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
652
|
+
let model = resolveFakeModelIfRequested();
|
|
653
|
+
if (!model) {
|
|
654
|
+
// getModel uses branded literal generics; cast provider/name to `any`
|
|
655
|
+
// since at runtime they come from user YAML and cannot be statically typed.
|
|
656
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
657
|
+
model = getModel(spec.model.provider, spec.model.name);
|
|
658
|
+
}
|
|
659
|
+
if (!model) {
|
|
660
|
+
throw new Error(`Model ${spec.model.provider}/${spec.model.name} not found. ` +
|
|
661
|
+
`Check provider and model name; pi-ai may not support this combination.`);
|
|
662
|
+
}
|
|
663
|
+
// If ANTHROPIC_BASE_URL is set (e.g. a corporate LLM gateway or local
|
|
664
|
+
// dev proxy), override the model's hardcoded baseUrl so Pi routes
|
|
665
|
+
// requests there. Skipped for the fake provider (its baseUrl points
|
|
666
|
+
// to http://fake.invalid which is never dialed). We also ensure
|
|
667
|
+
// ANTHROPIC_API_KEY is non-empty so Pi's env-key check passes; the
|
|
668
|
+
// proxy itself handles auth, so any placeholder value works.
|
|
669
|
+
if (process.env.ANTHROPIC_BASE_URL && spec.model.provider === "anthropic" && model.api !== "fake-test") {
|
|
670
|
+
model.baseUrl = process.env.ANTHROPIC_BASE_URL;
|
|
671
|
+
if (!process.env.ANTHROPIC_API_KEY) {
|
|
672
|
+
process.env.ANTHROPIC_API_KEY = "proxy-managed";
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
const sessionId = `s_${Date.now().toString(36)}`;
|
|
676
|
+
// When spec.sessionId is set (via --resume <id>), open/continue the named
|
|
677
|
+
// persistent session under <agentDir>/sessions/agentctl/<id>/.
|
|
678
|
+
// On first run the dir is empty so continueRecent creates a new session;
|
|
679
|
+
// on subsequent runs it picks up the most-recently-modified file in that dir.
|
|
680
|
+
// When spec.sessionId is absent, use an in-memory session (default Pi behaviour).
|
|
681
|
+
const sessionManager = spec.sessionId
|
|
682
|
+
? SessionManager.continueRecent(process.cwd(), join(getAgentDir(), "sessions", "agentctl", spec.sessionId))
|
|
683
|
+
: SessionManager.inMemory(process.cwd());
|
|
684
|
+
// Enforce the ADL tool allowlist by passing the spec-declared tool names as
|
|
685
|
+
// Pi's `tools` option. This is *both* the activation list and the allowlist:
|
|
686
|
+
// - Pi's built-in tools (read, bash, edit, write) are NOT in the list, so
|
|
687
|
+
// they are filtered out at the registry level.
|
|
688
|
+
// - Only tools whose names appear in this array become active in the
|
|
689
|
+
// model's tool catalog. Extension-registered tools whose names match
|
|
690
|
+
// `spec.tools[].name` get activated; others stay registered but inactive.
|
|
691
|
+
//
|
|
692
|
+
// IMPORTANT — MCP interaction:
|
|
693
|
+
// When spec.mcpServers is non-empty, pi-mcp-extension registers MCP tools
|
|
694
|
+
// AFTER session start (during the session_start event, after connecting to
|
|
695
|
+
// the MCP server). Pi's _refreshToolRegistry filters tools against
|
|
696
|
+
// `allowedToolNames` (derived from the `tools` option) when adding them to
|
|
697
|
+
// `_toolRegistry`. With `tools: []`, `allowedToolNames` becomes an empty
|
|
698
|
+
// Set (truthy), so `isAllowedTool(name)` always returns false — MCP tools
|
|
699
|
+
// never enter `_toolRegistry` and `setActiveTools(["mcp_…"])` silently
|
|
700
|
+
// no-ops. The model receives zero tools and hallucinates XML <invoke> tags.
|
|
701
|
+
//
|
|
702
|
+
// Fix: when mcpServers is non-empty, use `noTools: "builtin"` to suppress
|
|
703
|
+
// Pi's built-in read/bash/edit/write tools without setting `allowedToolNames`
|
|
704
|
+
// to an empty Set. With `allowedToolNames = undefined`, every registered
|
|
705
|
+
// tool can enter `_toolRegistry`, and Pi's "new tools" auto-activation logic
|
|
706
|
+
// (the `!options?.activeToolNames` branch in _refreshToolRegistry) activates
|
|
707
|
+
// MCP tools as they register. Declared spec.tools entrypoints also load via
|
|
708
|
+
// additionalExtensionPaths and auto-activate the same way.
|
|
709
|
+
//
|
|
710
|
+
// Security note: `noExtensions: true` + `additionalExtensionPaths` (only
|
|
711
|
+
// declared entrypoints) already enforce the ADL allowlist at the loading
|
|
712
|
+
// level. The `tools:` option is redundant for that purpose when mcpServers
|
|
713
|
+
// is present; `noTools: "builtin"` is sufficient.
|
|
714
|
+
//
|
|
715
|
+
// When subagents are declared, automatically include the "subagent" tool
|
|
716
|
+
// (registered by the vendored subagent extension) in the parent's allowlist.
|
|
717
|
+
// Without this, the parent model would not be able to call the subagent tool
|
|
718
|
+
// even though the extension is loaded.
|
|
719
|
+
const hasMcpServers = spec.mcpServers && spec.mcpServers.length > 0;
|
|
720
|
+
const toolAllowlist = [
|
|
721
|
+
...spec.tools.map((t) => t.name),
|
|
722
|
+
...(spec.subagents && spec.subagents.length > 0 ? ["subagent"] : []),
|
|
723
|
+
];
|
|
724
|
+
const { session } = await createAgentSession({
|
|
725
|
+
model,
|
|
726
|
+
resourceLoader,
|
|
727
|
+
// When MCP servers are declared: omit `tools` so allowedToolNames stays
|
|
728
|
+
// undefined (MCP tools can enter the registry and auto-activate), and use
|
|
729
|
+
// noTools: "builtin" to suppress Pi's built-in read/bash/edit/write tools.
|
|
730
|
+
// When no MCP servers: pass tools: toolAllowlist as before (explicit
|
|
731
|
+
// allowlist that blocks builtins and activates only declared tool names).
|
|
732
|
+
...(hasMcpServers
|
|
733
|
+
? { noTools: "builtin" }
|
|
734
|
+
: { tools: toolAllowlist }),
|
|
735
|
+
sessionManager,
|
|
736
|
+
});
|
|
737
|
+
// Forward spec.model.temperature to the provider via session.agent.onPayload.
|
|
738
|
+
// Pi's CreateAgentSessionOptions has no temperature field; onPayload is the
|
|
739
|
+
// documented hook for injecting per-request provider parameters. The hook
|
|
740
|
+
// receives the raw provider payload (e.g. the Anthropic messages body) and
|
|
741
|
+
// returns a (possibly modified) copy.
|
|
742
|
+
//
|
|
743
|
+
// IMPORTANT: Anthropic's API rejects requests where `temperature` is set to
|
|
744
|
+
// anything other than 1 while `thinking` (extended thinking) is enabled.
|
|
745
|
+
// Pi enables thinking by default for Claude Sonnet. We therefore *only*
|
|
746
|
+
// apply spec.model.temperature when thinking is NOT in the payload — when
|
|
747
|
+
// it is, Pi's default temperature (which is compatible with thinking) wins
|
|
748
|
+
// and the ADL temperature is silently ignored. This is a documented MVP
|
|
749
|
+
// limitation; v0.2 will add a way to disable thinking from ADL.
|
|
750
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
751
|
+
if (spec.model.temperature !== undefined) {
|
|
752
|
+
const specTemperature = spec.model.temperature;
|
|
753
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
754
|
+
const prevOnPayload = session.agent.onPayload;
|
|
755
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
756
|
+
session.agent.onPayload = async (payload, m) => {
|
|
757
|
+
const base = prevOnPayload ? await prevOnPayload(payload, m) : payload;
|
|
758
|
+
if (typeof base === "object" && base !== null && !base.thinking) {
|
|
759
|
+
base.temperature = specTemperature;
|
|
760
|
+
}
|
|
761
|
+
return base;
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
// Track terminal failure conditions so the final session.ended reflects
|
|
765
|
+
// whether Pi actually completed or errored. Pi reports provider/runtime
|
|
766
|
+
// failures via assistant message_end with stopReason: "error" (and via
|
|
767
|
+
// agent_end with an error field). Without this tracking, session.prompt
|
|
768
|
+
// resolves normally and we'd report a failed run as "completed".
|
|
769
|
+
let errorMessage;
|
|
770
|
+
// Hallucination-guardrail state. `hallucinationMode` is fixed for the
|
|
771
|
+
// session; `correctionRequested` is flipped when the subscriber sees a
|
|
772
|
+
// hallucinated message_end and mode is "correct"; `correctionSent` is
|
|
773
|
+
// flipped once the corrective re-prompt has actually been dispatched
|
|
774
|
+
// post-turn, so we cap at one correction per outer task invocation.
|
|
775
|
+
const hallucinationMode = resolveHallucinationMode(spec);
|
|
776
|
+
let correctionRequested = false;
|
|
777
|
+
let correctionSent = false;
|
|
778
|
+
// Register the subscriber BEFORE the first emit and BEFORE prompting,
|
|
779
|
+
// so no Pi event is missed.
|
|
780
|
+
session.subscribe((piEvent) => {
|
|
781
|
+
// Hallucinated tool-call detection runs ahead of translatePiEvent for
|
|
782
|
+
// message_end so warn/correct modes can scrub the offending XML out of
|
|
783
|
+
// the user-visible `message` event before it's emitted.
|
|
784
|
+
//
|
|
785
|
+
// The detector must be gated on role === "assistant". In correct mode
|
|
786
|
+
// we send CORRECTION_PROMPT back via session.prompt(); that prompt
|
|
787
|
+
// mentions <invoke>/<function_calls>/<Skill> by name as part of its
|
|
788
|
+
// instructions to the model, so Pi's user-role message_end carries
|
|
789
|
+
// exactly the XML patterns we detect. Without the role gate, the
|
|
790
|
+
// runtime flags its own corrective re-prompt as a hallucination
|
|
791
|
+
// (caught by codex review of v0.1.10).
|
|
792
|
+
let scrubbedAssistantText;
|
|
793
|
+
let hallucinationFindings = [];
|
|
794
|
+
if (piEvent.type === "message_end" && piEvent.message?.role === "assistant") {
|
|
795
|
+
const assistantText = extractAssistantText(piEvent.message);
|
|
796
|
+
if (assistantText) {
|
|
797
|
+
hallucinationFindings = detectHallucinatedToolCalls(assistantText);
|
|
798
|
+
if (hallucinationFindings.length > 0 && hallucinationMode !== "block") {
|
|
799
|
+
scrubbedAssistantText = stripHallucinationXml(assistantText).text;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
// Emit the translated wire event. For warn/correct mode we substitute
|
|
804
|
+
// a scrubbed `message` event in place of the default translation so
|
|
805
|
+
// downstream consumers see clean assistant prose.
|
|
806
|
+
//
|
|
807
|
+
// Note: we deliberately do NOT mutate piEvent.message.content. Pi's
|
|
808
|
+
// conversation state retains the original (unscrubbed) assistant
|
|
809
|
+
// message. Two reasons:
|
|
810
|
+
// (a) The audit log + persisted session must reflect what actually
|
|
811
|
+
// happened. Scrubbing is a display concern, not a rewrite-history
|
|
812
|
+
// concern.
|
|
813
|
+
// (b) In correct mode the corrective re-prompt explicitly tells the
|
|
814
|
+
// model "your previous message contained fabricated tool-call
|
|
815
|
+
// XML." If we scrubbed the conversation history, the model would
|
|
816
|
+
// see a clean previous message and be unable to understand what
|
|
817
|
+
// it's being asked to correct.
|
|
818
|
+
// Codex pass 4 flagged this as a possible issue; the design is
|
|
819
|
+
// intentional. See examples/guardrails-correct.yaml for the flow.
|
|
820
|
+
if (scrubbedAssistantText !== undefined) {
|
|
821
|
+
const role = piEvent.message?.role ?? "unknown";
|
|
822
|
+
emit(stamp(sessionId, "message", { text: scrubbedAssistantText, role }));
|
|
823
|
+
}
|
|
824
|
+
else {
|
|
825
|
+
const translated = translatePiEvent(sessionId, piEvent);
|
|
826
|
+
if (translated)
|
|
827
|
+
emit(translated);
|
|
828
|
+
}
|
|
829
|
+
if (piEvent.type === "message_end") {
|
|
830
|
+
const stop = piEvent.message?.stopReason;
|
|
831
|
+
if (stop === "error" || stop === "aborted") {
|
|
832
|
+
const detail = piEvent.message?.errorMessage
|
|
833
|
+
? `: ${piEvent.message.errorMessage}`
|
|
834
|
+
: "";
|
|
835
|
+
errorMessage ??= `Pi message ended with stopReason=${stop}${detail}`;
|
|
836
|
+
}
|
|
837
|
+
if (hallucinationFindings.length > 0) {
|
|
838
|
+
const baseMessage = `Assistant message contains fabricated tool-call XML: ${hallucinationFindings.join(", ")}. ` +
|
|
839
|
+
`The model is hallucinating tool invocations instead of using the runtime's ` +
|
|
840
|
+
`tool channel. Consider strengthening the persona, narrowing the active skill ` +
|
|
841
|
+
`set, or adding the missing tool to spec.tools[].`;
|
|
842
|
+
if (hallucinationMode === "block") {
|
|
843
|
+
// Legacy v0.1.8 behavior: hard failure. Wire `error`, errorMessage
|
|
844
|
+
// set so session ends with reason=error and the CLI exits non-zero.
|
|
845
|
+
emit(stamp(sessionId, "error", {
|
|
846
|
+
kind: "hallucinated_tool_call",
|
|
847
|
+
mode: hallucinationMode,
|
|
848
|
+
message: baseMessage,
|
|
849
|
+
patterns: hallucinationFindings,
|
|
850
|
+
}));
|
|
851
|
+
errorMessage ??= `Assistant message contained fabricated tool-call XML (${hallucinationFindings.join(", ")})`;
|
|
852
|
+
}
|
|
853
|
+
else {
|
|
854
|
+
// warn / correct: non-fatal. Emit a `warning` event so listeners
|
|
855
|
+
// can surface the finding without ending the session.
|
|
856
|
+
emit(stamp(sessionId, "warning", {
|
|
857
|
+
kind: "hallucinated_tool_call",
|
|
858
|
+
mode: hallucinationMode,
|
|
859
|
+
message: baseMessage,
|
|
860
|
+
patterns: hallucinationFindings,
|
|
861
|
+
}));
|
|
862
|
+
if (hallucinationMode === "correct" && !correctionSent) {
|
|
863
|
+
// The actual re-prompt happens after session.prompt() returns
|
|
864
|
+
// from the current turn — see the post-prompt block below.
|
|
865
|
+
correctionRequested = true;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
if (piEvent.type === "agent_end" && piEvent.error) {
|
|
871
|
+
errorMessage ??= String(piEvent.error?.message ?? piEvent.error);
|
|
872
|
+
}
|
|
873
|
+
});
|
|
874
|
+
emit(stamp(sessionId, "session.started", {
|
|
875
|
+
agentName: spec.metadata.name,
|
|
876
|
+
model: spec.model,
|
|
877
|
+
}));
|
|
878
|
+
// Fire the Pi `session_start` lifecycle event by calling bindExtensions().
|
|
879
|
+
// This is REQUIRED for extensions to initialise — without it pi-mcp-extension
|
|
880
|
+
// never connects to MCP servers and never registers tools.
|
|
881
|
+
//
|
|
882
|
+
// bindExtensions() accepts an ExtensionBindings object whose fields are all
|
|
883
|
+
// optional. We cast through `any` to avoid reconstructing the full
|
|
884
|
+
// ExtensionUIContext interface (which has ~30 methods). We only provide
|
|
885
|
+
// `notify` so that MCP start-up messages and errors are visible on stderr
|
|
886
|
+
// without disturbing the wire stream. All other UI methods stay as Pi's
|
|
887
|
+
// built-in noOpUIContext (set during ExtensionRunner construction).
|
|
888
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
889
|
+
await session.bindExtensions({
|
|
890
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
891
|
+
uiContext: { notify: (msg) => process.stderr.write(`[pi-mcp] ${msg}\n`) },
|
|
892
|
+
});
|
|
893
|
+
try {
|
|
894
|
+
// session.prompt() is async and resolves when the agent finishes the
|
|
895
|
+
// turn. This is the correct way to run a single-turn task per the
|
|
896
|
+
// Pi SDK examples (see examples/sdk/01-minimal.ts).
|
|
897
|
+
await session.prompt(spec.task);
|
|
898
|
+
// Correct mode: if the model fabricated tool-call XML during the
|
|
899
|
+
// primary turn, send one corrective re-prompt and let it redo its
|
|
900
|
+
// last message without the XML. Pi doesn't expose a mid-stream
|
|
901
|
+
// injection API, so the corrective turn shows up as a separate
|
|
902
|
+
// message in the wire stream — that's intentional and visible.
|
|
903
|
+
//
|
|
904
|
+
// Skip the correction if a terminal failure already happened during
|
|
905
|
+
// the primary turn (errorMessage set). The session is going to end
|
|
906
|
+
// with reason=error regardless; making another model call would
|
|
907
|
+
// burn tokens after a cancellation/provider-error without changing
|
|
908
|
+
// the outcome. Codex pass 5 flagged this.
|
|
909
|
+
if (correctionRequested && !correctionSent && !errorMessage) {
|
|
910
|
+
correctionSent = true;
|
|
911
|
+
await session.prompt(CORRECTION_PROMPT);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
catch (err) {
|
|
915
|
+
errorMessage ??= err instanceof Error ? err.message : String(err);
|
|
916
|
+
}
|
|
917
|
+
finally {
|
|
918
|
+
session.dispose();
|
|
919
|
+
}
|
|
920
|
+
if (errorMessage) {
|
|
921
|
+
emit(stamp(sessionId, "error", { message: errorMessage }));
|
|
922
|
+
emit(stamp(sessionId, "session.ended", { reason: "error", message: errorMessage }));
|
|
923
|
+
}
|
|
924
|
+
else {
|
|
925
|
+
emit(stamp(sessionId, "session.ended", { reason: "completed" }));
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
function translatePiEvent(sessionId, piEvent) {
|
|
929
|
+
switch (piEvent.type) {
|
|
930
|
+
// Pi emits tool_execution_start / tool_execution_end to session subscribers.
|
|
931
|
+
// The tool_call / tool_result labels are extension-hook events, not subscriber events.
|
|
932
|
+
case "tool_execution_start":
|
|
933
|
+
return stamp(sessionId, "tool.call", {
|
|
934
|
+
toolName: piEvent.toolName,
|
|
935
|
+
callId: piEvent.toolCallId,
|
|
936
|
+
args: piEvent.args,
|
|
937
|
+
});
|
|
938
|
+
case "tool_execution_end":
|
|
939
|
+
return stamp(sessionId, "tool.result", {
|
|
940
|
+
callId: piEvent.toolCallId,
|
|
941
|
+
isError: piEvent.isError,
|
|
942
|
+
content: piEvent.result,
|
|
943
|
+
});
|
|
944
|
+
case "message_end": {
|
|
945
|
+
const text = extractAssistantText(piEvent.message);
|
|
946
|
+
const role = piEvent.message?.role ?? "unknown";
|
|
947
|
+
return stamp(sessionId, "message", { text, role });
|
|
948
|
+
}
|
|
949
|
+
case "before_provider_request":
|
|
950
|
+
return stamp(sessionId, "model.request", { messageCount: piEvent.messageCount });
|
|
951
|
+
case "after_provider_response":
|
|
952
|
+
return stamp(sessionId, "model.response", {
|
|
953
|
+
tokensIn: piEvent.tokensIn,
|
|
954
|
+
tokensOut: piEvent.tokensOut,
|
|
955
|
+
finishReason: piEvent.finishReason,
|
|
956
|
+
});
|
|
957
|
+
default:
|
|
958
|
+
return undefined;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* Extract the plain assistant text from a Pi AppMessage. Pi's message
|
|
963
|
+
* content can be a string or an array of {type,text} blocks; we collect
|
|
964
|
+
* the text blocks. Returns "" when there is no text content.
|
|
965
|
+
*/
|
|
966
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
967
|
+
function extractAssistantText(message) {
|
|
968
|
+
const raw = message?.content;
|
|
969
|
+
if (typeof raw === "string")
|
|
970
|
+
return raw;
|
|
971
|
+
if (Array.isArray(raw)) {
|
|
972
|
+
return raw
|
|
973
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
974
|
+
.filter((c) => c.type === "text")
|
|
975
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
976
|
+
.map((c) => c.text)
|
|
977
|
+
.join("");
|
|
978
|
+
}
|
|
979
|
+
return "";
|
|
980
|
+
}
|