@signalridge/pi-subagents 1.10.2 → 1.11.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 +35 -0
- package/README.md +21 -10
- package/examples/agent-tool-description.md +1 -1
- package/package.json +7 -6
- package/src/agent-manager.ts +1405 -486
- package/src/agent-runner.ts +486 -254
- package/src/ask-tools.ts +31 -21
- package/src/context-boundary.ts +54 -0
- package/src/context.ts +121 -36
- package/src/custom-agents.ts +7 -0
- package/src/enabled-models.ts +72 -78
- package/src/index.ts +988 -799
- package/src/mention-clone.ts +129 -83
- package/src/model-runtime-bridge.ts +68 -0
- package/src/model-scope.ts +4 -2
- package/src/nested-tools.ts +3 -1
- package/src/types.ts +6 -4
package/src/agent-runner.ts
CHANGED
|
@@ -2,20 +2,21 @@
|
|
|
2
2
|
* agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { readFileSync } from "node:fs";
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
8
|
+
import { performance } from "node:perf_hooks";
|
|
8
9
|
import type { Model } from "@earendil-works/pi-ai";
|
|
9
|
-
import type { ExtensionContext, LoadExtensionsResult } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import type { Extension, ExtensionContext, InlineExtension, LoadExtensionsResult, ToolCallEventResult } from "@earendil-works/pi-coding-agent";
|
|
10
11
|
import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
|
|
11
12
|
import {
|
|
12
13
|
type AgentSession,
|
|
13
14
|
type AgentSessionEvent,
|
|
14
15
|
createAgentSession,
|
|
16
|
+
DefaultPackageManager,
|
|
15
17
|
DefaultResourceLoader,
|
|
16
18
|
type ExtensionAPI,
|
|
17
19
|
getAgentDir,
|
|
18
|
-
type ModelRuntime,
|
|
19
20
|
SessionManager,
|
|
20
21
|
SettingsManager,
|
|
21
22
|
} from "@earendil-works/pi-coding-agent";
|
|
@@ -23,7 +24,8 @@ import { type AgentTierResolutionSnapshot, resolveAgentTier } from "./agent-tier
|
|
|
23
24
|
import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
|
|
24
25
|
import { createAskGate } from "./ask-tools.js";
|
|
25
26
|
import { runInChildSessionContext } from "./child-context.js";
|
|
26
|
-
import {
|
|
27
|
+
import { extractText } from "./context.js";
|
|
28
|
+
import { assertNoRawInheritance, assertSafeChildSession } from "./context-boundary.js";
|
|
27
29
|
import { DEFAULT_AGENTS } from "./default-agents.js";
|
|
28
30
|
import { detectEnv } from "./env.js";
|
|
29
31
|
import { formatGateVerdict, type GateExec, runGate, workspaceFingerprint } from "./gate.js";
|
|
@@ -35,6 +37,7 @@ import {
|
|
|
35
37
|
} from "./internal-run.js";
|
|
36
38
|
import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
|
|
37
39
|
import { type ModelRegistry, resolveModel, shortModelLabel } from "./model-resolver.js";
|
|
40
|
+
import { parentModelSessionOptions } from "./model-runtime-bridge.js";
|
|
38
41
|
import { checkModelScope } from "./model-scope.js";
|
|
39
42
|
import { createNestedSubagentTools, getMaxSubagentDepth, type NestedAgentManager } from "./nested-tools.js";
|
|
40
43
|
import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
|
|
@@ -57,6 +60,7 @@ export const SUBAGENT_TOOL_NAMES = {
|
|
|
57
60
|
|
|
58
61
|
/** Names of tools registered by this extension that subagents must NOT inherit. */
|
|
59
62
|
const EXCLUDED_TOOL_NAMES: string[] = Object.values(SUBAGENT_TOOL_NAMES);
|
|
63
|
+
const TOOL_POLICY_EXTENSION = "pi-subagents-tool-policy";
|
|
60
64
|
|
|
61
65
|
/**
|
|
62
66
|
* Canonical name of an extension for `extensions: [...]` allowlist matching.
|
|
@@ -66,6 +70,7 @@ const EXCLUDED_TOOL_NAMES: string[] = Object.values(SUBAGENT_TOOL_NAMES);
|
|
|
66
70
|
* single-file extensions to the basename minus `.ts`/`.js`.
|
|
67
71
|
*/
|
|
68
72
|
export function extensionCanonicalName(extPath: string): string {
|
|
73
|
+
if (extPath.startsWith("builtin:")) return extPath.slice("builtin:".length).toLowerCase();
|
|
69
74
|
const base = basename(extPath);
|
|
70
75
|
const name = base === "index.ts" || base === "index.js"
|
|
71
76
|
? basename(dirname(extPath))
|
|
@@ -219,118 +224,103 @@ export function parseExtSelectors(entries: string[]): {
|
|
|
219
224
|
*
|
|
220
225
|
* Extensions may call `registerTool` long after load — pi-mcp from `session_start`,
|
|
221
226
|
* context-mode from `before_agent_start` — so scope has to be re-derived rather than
|
|
222
|
-
* snapshotted. `
|
|
223
|
-
* so `inScope()` sees late arrivals
|
|
227
|
+
* snapshotted. Pi's public `getAllTools()` includes each live effective tool and
|
|
228
|
+
* its source, so `inScope()` sees late arrivals and resolves name collisions.
|
|
224
229
|
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
* - `beforeToolCall` blocks out-of-scope calls. Turn 1 cannot be narrowed at all:
|
|
231
|
-
* `before_agent_start` fires INSIDE `prompt()` and may widen the tool set, but
|
|
232
|
-
* `createContextSnapshot()` freezes that turn's tools immediately after — there
|
|
233
|
-
* is no hook in between. A call-time check is the only correct guard there.
|
|
234
|
-
*
|
|
235
|
-
* Both are installed on the session and deliberately NOT unsubscribed: they must
|
|
236
|
-
* outlive the `runAgent` call so resumed/steered turns stay scoped. pi's `dispose()`
|
|
237
|
-
* clears `_eventListeners`, so they die with the session rather than leaking.
|
|
238
|
-
*
|
|
239
|
-
* Only meaningful when extensions are loaded — under `noExtensions`/`isolated` the
|
|
240
|
-
* static `allowedToolNames` allowlist already gates the registry itself.
|
|
230
|
+
* The session's active set is re-narrowed at `turn_end`, before the next turn
|
|
231
|
+
* snapshots its tools. A separate, hidden extension's public `tool_call` handler
|
|
232
|
+
* enforces this same live scope at execution time. That handler also sees calls
|
|
233
|
+
* made through `ctx.executeTool()` (codemode and MCP), which do not pass through
|
|
234
|
+
* the Agent's model-call hook. Both outlive runAgent for resumed/steered turns.
|
|
241
235
|
*/
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
): void {
|
|
256
|
-
const { loader, toolNames, disallowedSet, extNames, narrowing, nestedToolNames, askGate } = ctx;
|
|
257
|
-
|
|
258
|
-
// The names allowed right now. Mirrors the `ext:` opt-in flip: when any `ext:`
|
|
259
|
-
// selector is present, extension tools become an explicit allowlist — a loaded
|
|
260
|
-
// extension not named by a selector contributes nothing (its handlers still ran),
|
|
261
|
-
// and `ext:foo/bar` narrows `foo` to just `bar`.
|
|
262
|
-
const inScope = (): Set<string> => {
|
|
263
|
-
const keep = new Set(toolNames.filter((t) => !disallowedSet?.has(t)));
|
|
236
|
+
function extensionToolNamesInScope(session: AgentSession, ctx: {
|
|
237
|
+
loader: DefaultResourceLoader;
|
|
238
|
+
toolNames: string[];
|
|
239
|
+
disallowedSet: Set<string> | undefined;
|
|
240
|
+
extNames: Set<string>;
|
|
241
|
+
narrowing: Map<string, Set<string>>;
|
|
242
|
+
nestedToolNames: Set<string>;
|
|
243
|
+
}): () => Set<string> {
|
|
244
|
+
const { loader, toolNames, disallowedSet, extNames, narrowing, nestedToolNames } = ctx;
|
|
245
|
+
const builtins = new Set(toolNames);
|
|
246
|
+
return () => {
|
|
247
|
+
const keep = new Set<string>();
|
|
248
|
+
const extensionsByPath = new Map(loader.getExtensions().extensions.map((e) => [e.path, e]));
|
|
264
249
|
const optInActive = extNames.size > 0;
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
250
|
+
// Pi's public registry reports the EFFECTIVE tool definition, after its
|
|
251
|
+
// first-registration-wins collision handling. A union of extension.tools
|
|
252
|
+
// would mistakenly approve a disallowed tool shadowed by an allowed one.
|
|
253
|
+
for (const tool of session.getAllTools()) {
|
|
254
|
+
const { name } = tool;
|
|
255
|
+
if (disallowedSet?.has(name)) continue;
|
|
256
|
+
if (EXCLUDED_TOOL_NAMES.includes(name) && !nestedToolNames.has(name)) continue;
|
|
257
|
+
const path = tool.sourceInfo.path;
|
|
258
|
+
const extension = extensionsByPath.get(path);
|
|
259
|
+
if (extension) {
|
|
260
|
+
const canons = extensionCanonicalNames(extension.path);
|
|
261
|
+
if (optInActive && !canons.some((c) => extNames.has(c))) continue;
|
|
262
|
+
// First alias that carries a narrowing set — a user won't narrow one
|
|
263
|
+
// extension under two different names, so first-match is correct.
|
|
264
|
+
const narrowed = canons.map((c) => narrowing.get(c)).find(Boolean);
|
|
272
265
|
if (narrowed && !narrowed.has(name)) continue;
|
|
273
|
-
|
|
266
|
+
keep.add(name);
|
|
267
|
+
} else if (builtins.has(name) && (path === `builtin:${name}` || path === `<builtin:${name}>`)) {
|
|
268
|
+
// Pi 0.84–0.87 used angle-bracket source paths for core tools; 0.99
|
|
269
|
+
// uses builtin:<name>. Match the actual tool name, never a loose prefix.
|
|
270
|
+
keep.add(name);
|
|
271
|
+
} else if (path === `<sdk:${name}>` && nestedToolNames.has(name)) {
|
|
274
272
|
keep.add(name);
|
|
275
273
|
}
|
|
276
|
-
|
|
277
|
-
for (const name of EXCLUDED_TOOL_NAMES) keep.delete(name);
|
|
278
|
-
// Opt-in nested delegation tools share EXCLUDED_TOOL_NAMES' names but are
|
|
279
|
-
// legitimately active for this agent — re-admit them so the renarrow keeps
|
|
280
|
-
// them in the active set and beforeToolCall doesn't block them.
|
|
281
|
-
for (const name of nestedToolNames) {
|
|
282
|
-
if (!disallowedSet?.has(name)) keep.add(name);
|
|
274
|
+
// An unrecognized source cannot inherit another extension's permission.
|
|
283
275
|
}
|
|
284
276
|
return keep;
|
|
285
277
|
};
|
|
278
|
+
}
|
|
286
279
|
|
|
280
|
+
/** Only needed with extensions; `extensions: false` has a static registry allowlist. */
|
|
281
|
+
export function installExtensionToolScope(session: AgentSession, inScope: () => Set<string>): void {
|
|
282
|
+
// Remember the effective registry winner, not just the name. If Pi or another
|
|
283
|
+
// extension deliberately deactivates a tool, a later turn must not undo that
|
|
284
|
+
// decision; a different source winning the same name is a new registration.
|
|
285
|
+
const seenSources = new Map<string, string>();
|
|
287
286
|
const renarrow = () => {
|
|
288
287
|
const allowed = inScope();
|
|
289
|
-
const
|
|
288
|
+
const tools = session.getAllTools();
|
|
289
|
+
const registered = new Set(tools.map((tool) => tool.name));
|
|
290
|
+
for (const name of seenSources.keys()) {
|
|
291
|
+
if (!registered.has(name)) seenSources.delete(name);
|
|
292
|
+
}
|
|
290
293
|
const current = session.getActiveToolNames();
|
|
294
|
+
const next = current.filter((name) => allowed.has(name) && registered.has(name));
|
|
295
|
+
const active = new Set(next);
|
|
296
|
+
for (const tool of tools) {
|
|
297
|
+
const { name } = tool;
|
|
298
|
+
const source = tool.sourceInfo.path;
|
|
299
|
+
const newSource = seenSources.get(name) !== source;
|
|
300
|
+
seenSources.set(name, source);
|
|
301
|
+
if (!newSource || !allowed.has(name) || active.has(name)) continue;
|
|
302
|
+
// Pi 0.99 deliberately leaves deferred/codemode/hidden tools and
|
|
303
|
+
// defaultActive:false tools inactive. Older hosts expose neither setting
|
|
304
|
+
// and auto-activate every newly registered extension tool.
|
|
305
|
+
const exposure = tool.exposure ?? "direct";
|
|
306
|
+
if ((exposure === "direct" || exposure === "model-only") &&
|
|
307
|
+
session.getToolDefinition?.(name)?.defaultActive !== false) {
|
|
308
|
+
next.push(name);
|
|
309
|
+
active.add(name);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
291
312
|
// setActiveToolsByName unconditionally rebuilds the system prompt, so skip
|
|
292
313
|
// the no-op that steady-state turns would otherwise pay for every turn.
|
|
293
|
-
if (next.length !== current.length || next.some((
|
|
314
|
+
if (next.length !== current.length || next.some((name, i) => name !== current[i])) {
|
|
294
315
|
session.setActiveToolsByName(next);
|
|
295
316
|
}
|
|
296
317
|
};
|
|
297
318
|
|
|
298
|
-
//
|
|
299
|
-
// otherwise leave only its four default built-ins active at turn 1.
|
|
319
|
+
// Reconcile tools registered during session_start before the first turn.
|
|
300
320
|
renarrow();
|
|
301
|
-
|
|
302
321
|
session.subscribe((event: AgentSessionEvent) => {
|
|
303
322
|
if (event.type === "turn_end") renarrow();
|
|
304
323
|
});
|
|
305
|
-
|
|
306
|
-
const priorBeforeToolCall = session.agent.beforeToolCall;
|
|
307
|
-
session.agent.beforeToolCall = async (context, signal) => {
|
|
308
|
-
const run = async () => {
|
|
309
|
-
if (!inScope().has(context.toolCall.name)) {
|
|
310
|
-
return {
|
|
311
|
-
block: true,
|
|
312
|
-
reason: `Tool "${context.toolCall.name}" is not available to this subagent.`,
|
|
313
|
-
} as const;
|
|
314
|
-
}
|
|
315
|
-
// Scope first, then approval: a tool this agent may not use at all is
|
|
316
|
-
// refused without troubling the user about it.
|
|
317
|
-
const gated = await askGate?.(context.toolCall.name, (context.toolCall as { input?: unknown }).input);
|
|
318
|
-
if (gated) return gated;
|
|
319
|
-
return priorBeforeToolCall?.(context, signal);
|
|
320
|
-
};
|
|
321
|
-
// Default per-tool timeout prevents a hung beforeToolCall (slow extension,
|
|
322
|
-
// stuck LLM arbitrator) from stalling the subagent forever. Approval
|
|
323
|
-
// dialogs are wrapped too — a tool that cannot be approved in time is
|
|
324
|
-
// blocked fail-closed rather than left hanging.
|
|
325
|
-
if (defaultToolTimeoutMs > 0) {
|
|
326
|
-
try {
|
|
327
|
-
return await withTimeout(run(), defaultToolTimeoutMs, `beforeToolCall for "${context.toolCall.name}"`);
|
|
328
|
-
} catch (err) {
|
|
329
|
-
return { block: true, reason: (err as Error).message };
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
return run();
|
|
333
|
-
};
|
|
334
324
|
}
|
|
335
325
|
|
|
336
326
|
/**
|
|
@@ -391,6 +381,17 @@ export function setDefaultMaxToolCalls(n: number): void { defaultMaxToolCalls =
|
|
|
391
381
|
/** Default per-tool timeout; `0` disables (no timeout). Mirrors tintinweb — no per-tool timeout by default; hung tools are reclaimed via session abort/quiescence, not a hard tool cut. Set via settings defaultToolTimeoutMs when needed. */
|
|
392
382
|
const DEFAULT_TOOL_TIMEOUT_MS = 0;
|
|
393
383
|
const TOOL_TIMEOUT_CEILING_MS = 600_000;
|
|
384
|
+
// Pi emits tool_execution_start before invoking the public tool_call hooks.
|
|
385
|
+
// Let their shared deadline return a block before the fallback session.abort()
|
|
386
|
+
// timer can turn that result into an unhelpful "Operation aborted".
|
|
387
|
+
const TOOL_CALL_ABORT_GRACE_MS = 25;
|
|
388
|
+
interface ApprovalTimer {
|
|
389
|
+
isInteractiveAsk: (name: string) => boolean;
|
|
390
|
+
armApproved: (toolCallId: string, toolName: string) => void;
|
|
391
|
+
}
|
|
392
|
+
// The policy survives runAgent for resumed turns; the current prompt owns its
|
|
393
|
+
// abort timers, so resume replaces armApproved instead of arming a stale timer.
|
|
394
|
+
const approvalTimers = new WeakMap<AgentSession, ApprovalTimer>();
|
|
394
395
|
let defaultToolTimeoutMs = DEFAULT_TOOL_TIMEOUT_MS;
|
|
395
396
|
export function getDefaultToolTimeoutMs(): number { return defaultToolTimeoutMs; }
|
|
396
397
|
export function setDefaultToolTimeoutMs(n: number): void {
|
|
@@ -409,6 +410,79 @@ export function withTimeout<T>(promise: Promise<T>, ms: number, label: string):
|
|
|
409
410
|
});
|
|
410
411
|
}
|
|
411
412
|
|
|
413
|
+
/**
|
|
414
|
+
* The execution fallback begins before Pi's tool_call chain. Its small grace
|
|
415
|
+
* lets a timed-out hook return a block before aborting the session; while a
|
|
416
|
+
* parent waits on ctx.executeTool, only the nested call's own budget advances.
|
|
417
|
+
* Interactive approval is excluded until the policy handler has a decision.
|
|
418
|
+
*/
|
|
419
|
+
function createToolTimeouts(session: AgentSession, grace: number, isInteractiveAsk: (name: string) => boolean) {
|
|
420
|
+
const pending = new Map<string, ReturnType<typeof setTimeout>>();
|
|
421
|
+
const due = new Map<string, number>();
|
|
422
|
+
const paused = new Map<string, { remaining: number; children: Set<string> }>();
|
|
423
|
+
const clear = (id: string): void => {
|
|
424
|
+
const handle = pending.get(id);
|
|
425
|
+
if (handle) clearTimeout(handle);
|
|
426
|
+
pending.delete(id);
|
|
427
|
+
due.delete(id);
|
|
428
|
+
paused.delete(id);
|
|
429
|
+
};
|
|
430
|
+
const arm = (id: string, name: string, delay = defaultToolTimeoutMs + grace): void => {
|
|
431
|
+
if (defaultToolTimeoutMs <= 0 || name === "contact_supervisor") return;
|
|
432
|
+
due.set(id, performance.now() + delay);
|
|
433
|
+
const handle = setTimeout(() => {
|
|
434
|
+
pending.delete(id);
|
|
435
|
+
due.delete(id);
|
|
436
|
+
try {
|
|
437
|
+
session.abort();
|
|
438
|
+
} catch {
|
|
439
|
+
// The session may already have been torn down.
|
|
440
|
+
}
|
|
441
|
+
}, delay);
|
|
442
|
+
handle.unref?.();
|
|
443
|
+
pending.set(id, handle);
|
|
444
|
+
};
|
|
445
|
+
return {
|
|
446
|
+
armApproved: (id: string, name: string): void => arm(id, name),
|
|
447
|
+
start: (event: { toolCallId: string; toolName: string; parentToolCallId?: string }): void => {
|
|
448
|
+
if (event.parentToolCallId) {
|
|
449
|
+
const parent = paused.get(event.parentToolCallId);
|
|
450
|
+
if (parent) parent.children.add(event.toolCallId);
|
|
451
|
+
else {
|
|
452
|
+
const handle = pending.get(event.parentToolCallId);
|
|
453
|
+
const deadline = due.get(event.parentToolCallId);
|
|
454
|
+
if (handle && deadline !== undefined) {
|
|
455
|
+
clearTimeout(handle);
|
|
456
|
+
pending.delete(event.parentToolCallId);
|
|
457
|
+
due.delete(event.parentToolCallId);
|
|
458
|
+
paused.set(event.parentToolCallId, {
|
|
459
|
+
remaining: Math.max(0, deadline - performance.now()),
|
|
460
|
+
children: new Set([event.toolCallId]),
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (!isInteractiveAsk(event.toolName)) arm(event.toolCallId, event.toolName);
|
|
466
|
+
},
|
|
467
|
+
end: (event: { toolCallId: string; parentToolCallId?: string }): void => {
|
|
468
|
+
clear(event.toolCallId);
|
|
469
|
+
if (!event.parentToolCallId) return;
|
|
470
|
+
const parent = paused.get(event.parentToolCallId);
|
|
471
|
+
if (!parent) return;
|
|
472
|
+
parent.children.delete(event.toolCallId);
|
|
473
|
+
if (parent.children.size > 0) return;
|
|
474
|
+
paused.delete(event.parentToolCallId);
|
|
475
|
+
arm(event.parentToolCallId, "", parent.remaining);
|
|
476
|
+
},
|
|
477
|
+
dispose: (): void => {
|
|
478
|
+
for (const handle of pending.values()) clearTimeout(handle);
|
|
479
|
+
pending.clear();
|
|
480
|
+
due.clear();
|
|
481
|
+
paused.clear();
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
412
486
|
let graceTurns = 5;
|
|
413
487
|
|
|
414
488
|
/** Get the grace turns value. */
|
|
@@ -752,6 +826,68 @@ function resolveConfiguredSessionDir(sessionDir: string | undefined, cwd: string
|
|
|
752
826
|
return resolve(cwd, sessionDir);
|
|
753
827
|
}
|
|
754
828
|
|
|
829
|
+
/**
|
|
830
|
+
* Give Pi a live, guarded view of an extension's tool_call handlers. Pi's
|
|
831
|
+
* public loader returns Extension objects whose handlers map is also the target
|
|
832
|
+
* of later pi.on() registrations. Copying that map once misses late handlers;
|
|
833
|
+
* replacing its raw arrays breaks pi.on()'s unsubscribe closure. Intercept
|
|
834
|
+
* reads instead, leaving registration/unsubscription and all metadata intact.
|
|
835
|
+
* Each read wraps the CURRENT callbacks before Pi takes its event snapshot.
|
|
836
|
+
*/
|
|
837
|
+
function guardExtensionToolCalls(
|
|
838
|
+
extension: Extension,
|
|
839
|
+
deadlines: WeakMap<object, number>,
|
|
840
|
+
): Extension {
|
|
841
|
+
type Handler = NonNullable<ReturnType<Extension["handlers"]["get"]>>[number];
|
|
842
|
+
const guarded = new WeakMap<Handler, Handler>();
|
|
843
|
+
const handlers = new Proxy(extension.handlers, {
|
|
844
|
+
get(target, key) {
|
|
845
|
+
if (key === "get") return (event: string) => {
|
|
846
|
+
const raw = target.get(event);
|
|
847
|
+
if (event !== "tool_call" || !raw) return raw;
|
|
848
|
+
return raw.map((handler) => {
|
|
849
|
+
let wrapper = guarded.get(handler);
|
|
850
|
+
if (!wrapper) {
|
|
851
|
+
wrapper = async (...args: unknown[]) => {
|
|
852
|
+
const call = args[0];
|
|
853
|
+
if (!call || typeof call !== "object") {
|
|
854
|
+
return { block: true, reason: "Invalid subagent tool_call event." };
|
|
855
|
+
}
|
|
856
|
+
const deadline = deadlines.get(call);
|
|
857
|
+
if (deadline === undefined) {
|
|
858
|
+
return { block: true, reason: "Subagent tool policy was bypassed." };
|
|
859
|
+
}
|
|
860
|
+
if (deadline === Infinity) return handler(...args);
|
|
861
|
+
const toolName = Reflect.get(call, "toolName");
|
|
862
|
+
const label = `tool_call for "${typeof toolName === "string" ? toolName : "unknown"}"`;
|
|
863
|
+
const remaining = Math.ceil(deadline - performance.now());
|
|
864
|
+
if (remaining <= 0) return { block: true, reason: `${label} timed out before the extension handler.` };
|
|
865
|
+
try {
|
|
866
|
+
// This race blocks the call; it cannot cancel side effects of
|
|
867
|
+
// a handler that keeps running after its Promise loses.
|
|
868
|
+
return await withTimeout(Promise.resolve().then(() => handler(...args)), remaining, label);
|
|
869
|
+
} catch (error) {
|
|
870
|
+
// Pi does not catch tool_call handler failures. A timeout must
|
|
871
|
+
// block the tool, not leave a rejected event or an awaited hook.
|
|
872
|
+
return { block: true, reason: error instanceof Error ? error.message : String(error) };
|
|
873
|
+
}
|
|
874
|
+
};
|
|
875
|
+
guarded.set(handler, wrapper);
|
|
876
|
+
}
|
|
877
|
+
return wrapper;
|
|
878
|
+
});
|
|
879
|
+
};
|
|
880
|
+
const value: unknown = Reflect.get(target, key, target);
|
|
881
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
882
|
+
},
|
|
883
|
+
});
|
|
884
|
+
return new Proxy(extension, {
|
|
885
|
+
get(target, key, receiver) {
|
|
886
|
+
return key === "handlers" ? handlers : Reflect.get(target, key, receiver);
|
|
887
|
+
},
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
|
|
755
891
|
export async function runAgent(
|
|
756
892
|
ctx: ExtensionContext,
|
|
757
893
|
type: SubagentType,
|
|
@@ -796,6 +932,12 @@ export async function runAgent(
|
|
|
796
932
|
inheritContext: internalOverride.inheritContext,
|
|
797
933
|
}
|
|
798
934
|
: loadedAgentConfig;
|
|
935
|
+
// Reject raw parent-history inheritance before building context, loading
|
|
936
|
+
// extensions, creating a session, or dispatching a provider request.
|
|
937
|
+
assertNoRawInheritance(internalOverride?.inheritContext ?? options.inheritContext ?? agentConfig?.inheritContext);
|
|
938
|
+
// A missing parent bridge must fail before child resource discovery or session
|
|
939
|
+
// allocation. Re-check the selected model below once tier resolution finishes.
|
|
940
|
+
parentModelSessionOptions(ctx);
|
|
799
941
|
const parentPolicySnapshot = options[INTERNAL_PARENT_POLICY_SNAPSHOT];
|
|
800
942
|
const parentModel = parentPolicySnapshot ? parentPolicySnapshot.model : ctx.model;
|
|
801
943
|
const parentThinking = parentPolicySnapshot
|
|
@@ -913,8 +1055,9 @@ export async function runAgent(
|
|
|
913
1055
|
systemPrompt = buildAgentPrompt({ ...fallback, name: type }, effectiveCwd, env, parentSystemPrompt, extras);
|
|
914
1056
|
}
|
|
915
1057
|
|
|
916
|
-
//
|
|
917
|
-
//
|
|
1058
|
+
// Named skills are preloaded into the prompt, not exposed through Pi's skill catalog.
|
|
1059
|
+
// noSkills only skips initial discovery; resources_discover can add extension
|
|
1060
|
+
// skills later, so the loader override must enforce this policy on every update.
|
|
918
1061
|
const noSkills = skills === false || Array.isArray(skills);
|
|
919
1062
|
|
|
920
1063
|
const agentDir = getAgentDir();
|
|
@@ -922,16 +1065,16 @@ export async function runAgent(
|
|
|
922
1065
|
// Extension loading:
|
|
923
1066
|
// - true → all default-discovered extensions
|
|
924
1067
|
// - false → none (noExtensions)
|
|
925
|
-
// - string[] →
|
|
926
|
-
//
|
|
927
|
-
//
|
|
928
|
-
//
|
|
1068
|
+
// - string[] → allowlist. Bare names keep matching default-discovered
|
|
1069
|
+
// extensions; path entries load explicitly; "*" keeps all defaults.
|
|
1070
|
+
// On Pi with replaceable built-ins, restrictive policies resolve paths
|
|
1071
|
+
// before loading, so excluded factories cannot replace a selected built-in.
|
|
929
1072
|
//
|
|
930
1073
|
// Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md — upstream's
|
|
931
1074
|
// buildSystemPrompt() re-appends both AFTER systemPromptOverride, which
|
|
932
1075
|
// would defeat prompt_mode: replace and isolated: true. Parent context, if
|
|
933
|
-
//
|
|
934
|
-
// is
|
|
1076
|
+
// append-mode agents receive system instructions through systemPromptOverride;
|
|
1077
|
+
// raw parent conversation inheritance is unavailable on these Pi hosts.
|
|
935
1078
|
// `ext:` selectors from the `tools:` CSV narrow which extension tools surface to
|
|
936
1079
|
// the LLM. They do NOT control loading — `extensions:` is the sole authority for
|
|
937
1080
|
// which extensions load. `ext:foo` against an extension that `extensions:` excluded
|
|
@@ -946,42 +1089,176 @@ export async function runAgent(
|
|
|
946
1089
|
: undefined;
|
|
947
1090
|
const keepNames = extensionsSpec?.names ?? new Set<string>();
|
|
948
1091
|
// `exclude_extensions:` is a denylist applied AFTER the include set — exclude wins.
|
|
949
|
-
// Plain canonical names only (case-insensitive).
|
|
950
|
-
//
|
|
951
|
-
// suppresses handler binding and tool registration; it is not a sandbox.
|
|
1092
|
+
// Plain canonical names only (case-insensitive). On older hosts filtering is
|
|
1093
|
+
// post-factory; this is not a sandbox for extensions that already loaded.
|
|
952
1094
|
const excludeNames = new Set((excludeExtensions ?? []).map((n) => n.toLowerCase()));
|
|
953
1095
|
const hasExcludes = excludeNames.size > 0;
|
|
954
|
-
// The override filters
|
|
955
|
-
//
|
|
956
|
-
//
|
|
1096
|
+
// The override filters on older hosts, validates preselection on new hosts,
|
|
1097
|
+
// and ALWAYS puts our tool policy first. Pi passes one mutable
|
|
1098
|
+
// tool_call event through handlers in extension order; if an earlier extension
|
|
1099
|
+
// changes event.toolName, a later gate would inspect a different name from the
|
|
1100
|
+
// one Pi actually executes. Ordering before untrusted handlers is essential.
|
|
957
1101
|
const loadAll = extensions === true || extensionsSpec?.wildcard === true;
|
|
958
|
-
const
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
1102
|
+
const bareNames = new Set(Array.isArray(extensions)
|
|
1103
|
+
? extensions.filter((entry) => entry !== "*" && !entry.includes("/") && !entry.includes("\\") && !entry.startsWith("~"))
|
|
1104
|
+
.map((entry) => entry.toLowerCase())
|
|
1105
|
+
: []);
|
|
1106
|
+
// Pre-filter discovered set, captured by the override (or the resolver) — the
|
|
1107
|
+
// exclude-typo warning must compare against this, not the surviving set.
|
|
962
1108
|
let discoveredNames: Set<string> | undefined;
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1109
|
+
let prefilteredExtensionPaths: Set<string> | undefined;
|
|
1110
|
+
const resolvedExplicitNames = new Set<string>();
|
|
1111
|
+
const interactiveAskNames = new Set(ctx.hasUI ? (agentConfig?.askTools ?? []).map((name) => name.toLowerCase()) : []);
|
|
1112
|
+
const approvalTimer: ApprovalTimer = {
|
|
1113
|
+
isInteractiveAsk: (name) => interactiveAskNames.has(name.toLowerCase()),
|
|
1114
|
+
armApproved: () => {},
|
|
1115
|
+
};
|
|
1116
|
+
// The policy starts a single monotonic deadline for each public tool_call
|
|
1117
|
+
// event. All subsequently selected extension handlers share its remainder,
|
|
1118
|
+
// including calls initiated by another tool through ctx.executeTool().
|
|
1119
|
+
const toolCallDeadlines = new WeakMap<object, number>();
|
|
1120
|
+
const extensionsOverride = (base: LoadExtensionsResult): LoadExtensionsResult => {
|
|
1121
|
+
const policy = base.extensions.find((e) => e.path === `<inline:${TOOL_POLICY_EXTENSION}>`);
|
|
1122
|
+
if (!policy) throw new Error("Subagent tool policy extension failed to load.");
|
|
1123
|
+
let selected = base.extensions;
|
|
1124
|
+
if (prefilteredExtensionPaths) {
|
|
1125
|
+
// Fail closed if a host loads anything outside the pre-resolved policy.
|
|
1126
|
+
// Filtering it now would be too late to undo its factory side effects.
|
|
1127
|
+
const allowedPaths = prefilteredExtensionPaths;
|
|
1128
|
+
const unexpected = base.extensions.find((e) => e !== policy && !allowedPaths.has(e.path));
|
|
1129
|
+
if (unexpected) throw new Error(`Pi loaded unselected extension "${unexpected.path}" for agent "${type}".`);
|
|
1130
|
+
} else if (!noExtensions && (!loadAll || hasExcludes)) {
|
|
1131
|
+
discoveredNames = new Set(base.extensions.flatMap((e) => extensionCanonicalNames(e.path)));
|
|
1132
|
+
selected = base.extensions.filter((e) => {
|
|
1133
|
+
if (e === policy) return true; // never filter the child policy itself
|
|
1134
|
+
const canons = extensionCanonicalNames(e.path);
|
|
1135
|
+
if (canons.some((n) => excludeNames.has(n))) return false; // exclude wins
|
|
1136
|
+
return loadAll || canons.some((n) => keepNames.has(n));
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
return {
|
|
1140
|
+
...base,
|
|
1141
|
+
extensions: [policy, ...selected.filter((e) => e !== policy)
|
|
1142
|
+
.map((extension) => guardExtensionToolCalls(extension, toolCallDeadlines))],
|
|
1143
|
+
};
|
|
1144
|
+
};
|
|
1145
|
+
|
|
1146
|
+
// The public extension `tool_call` event covers BOTH model calls and nested
|
|
1147
|
+
// ctx.executeTool calls. A policy factory also loads with noExtensions: true,
|
|
1148
|
+
// without loading any discovered extension. Deny until the child policy is
|
|
1149
|
+
// installed, including calls an extension might make during session_start.
|
|
1150
|
+
let checkToolCall: (name: string, input: unknown, deadline: number) => Promise<ToolCallEventResult | undefined> =
|
|
1151
|
+
async () => ({ block: true, reason: "Subagent tool policy is not ready." });
|
|
1152
|
+
const extensionFactories: InlineExtension[] = [{
|
|
1153
|
+
name: TOOL_POLICY_EXTENSION,
|
|
1154
|
+
hidden: true,
|
|
1155
|
+
factory: (pi) => {
|
|
1156
|
+
pi.on("tool_call", async (event, hookCtx) => {
|
|
1157
|
+
if (hookCtx?.signal?.aborted) return { block: true, reason: "Subagent tool call was aborted." };
|
|
1158
|
+
const waitingForHuman = approvalTimer.isInteractiveAsk(event.toolName);
|
|
1159
|
+
// Human decision time is not tool execution time. The public event
|
|
1160
|
+
// arrives after tool_execution_start, so the normal abort timer also
|
|
1161
|
+
// waits until approval; only then do later handlers get a deadline.
|
|
1162
|
+
const deadline = !waitingForHuman && defaultToolTimeoutMs > 0
|
|
1163
|
+
? performance.now() + defaultToolTimeoutMs
|
|
1164
|
+
: Infinity;
|
|
1165
|
+
toolCallDeadlines.set(event, deadline);
|
|
1166
|
+
const decision = await checkToolCall(event.toolName, event.input, deadline);
|
|
1167
|
+
// Abort cannot interrupt an already-open human dialog on these hosts.
|
|
1168
|
+
// Do not execute a tool if that dialog eventually approves a stopped run.
|
|
1169
|
+
if (hookCtx?.signal?.aborted) return { block: true, reason: "Subagent tool call was aborted." };
|
|
1170
|
+
if (waitingForHuman && !decision?.block) {
|
|
1171
|
+
toolCallDeadlines.set(event, defaultToolTimeoutMs > 0
|
|
1172
|
+
? performance.now() + defaultToolTimeoutMs
|
|
1173
|
+
: Infinity);
|
|
1174
|
+
approvalTimer.armApproved(event.toolCallId, event.toolName);
|
|
1175
|
+
}
|
|
1176
|
+
return decision;
|
|
1177
|
+
});
|
|
1178
|
+
},
|
|
1179
|
+
}];
|
|
1180
|
+
// Pi 0.99 exposes these as public SDK factories. Earlier supported hosts do
|
|
1181
|
+
// not export them, so capability-check the namespace instead of importing a
|
|
1182
|
+
// missing named export (which would prevent the whole extension from loading).
|
|
1183
|
+
if ("createCodemodeExtension" in PiCodingAgent && typeof PiCodingAgent.createCodemodeExtension === "function") {
|
|
1184
|
+
extensionFactories.push({ name: "codemode", builtin: true, replaceable: true, factory: PiCodingAgent.createCodemodeExtension() });
|
|
1185
|
+
}
|
|
1186
|
+
if ("createToolSearchExtension" in PiCodingAgent && typeof PiCodingAgent.createToolSearchExtension === "function") {
|
|
1187
|
+
extensionFactories.push({ name: "tool-search", builtin: true, replaceable: true, factory: PiCodingAgent.createToolSearchExtension() });
|
|
1188
|
+
}
|
|
1189
|
+
if ("createMcpExtension" in PiCodingAgent && typeof PiCodingAgent.createMcpExtension === "function") {
|
|
1190
|
+
extensionFactories.push({ name: "mcp", builtin: true, replaceable: true, factory: PiCodingAgent.createMcpExtension() });
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
const settingsManager = SettingsManager.create(configCwd, agentDir);
|
|
1194
|
+
let additionalExtensionPaths = extensionsSpec?.paths.length ? extensionsSpec.paths : undefined;
|
|
1195
|
+
let settingsBeforeLoad: string | undefined;
|
|
1196
|
+
// Pi 0.99 resolves replacement conflicts BEFORE extensionsOverride. A rogue
|
|
1197
|
+
// discovered extension can register /mcp, replace builtin:mcp, and only then
|
|
1198
|
+
// get filtered out by our allowlist. Resolve configured resources through the
|
|
1199
|
+
// public package API first and feed only the selected paths into a loader with
|
|
1200
|
+
// discovery off. Older Pi hosts have no replaceable built-ins; keep their
|
|
1201
|
+
// existing post-load filter instead of relying on new host behavior.
|
|
1202
|
+
const builtinNames = extensionFactories.filter((factory) => typeof factory !== "function" && factory.builtin)
|
|
1203
|
+
.map((factory) => factory.name);
|
|
1204
|
+
const prefilter = !noExtensions && (!loadAll || hasExcludes) && builtinNames.length > 0;
|
|
1205
|
+
if (prefilter) {
|
|
1206
|
+
await settingsManager.reload();
|
|
1207
|
+
throwIfAborted(options.signal);
|
|
1208
|
+
const packages = new DefaultPackageManager({ cwd: configCwd, agentDir, settingsManager, builtinExtensions: builtinNames });
|
|
1209
|
+
// As with the normal loader, resolve() may install configured packages, and
|
|
1210
|
+
// temporary sources may install a requested package. Do not read private Pi
|
|
1211
|
+
// state or replace its source-resolution rules with path heuristics.
|
|
1212
|
+
const configured = await packages.resolve();
|
|
1213
|
+
throwIfAborted(options.signal);
|
|
1214
|
+
const explicit = await packages.resolveExtensionSources(extensionsSpec?.paths ?? [], { temporary: true });
|
|
1215
|
+
throwIfAborted(options.signal);
|
|
1216
|
+
for (const source of extensionsSpec?.paths ?? []) {
|
|
1217
|
+
if (!existsSync(source) || !explicit.extensions.some((resource) => resource.enabled && resource.metadata.source === source)) {
|
|
1218
|
+
throw new Error(`Extension source "${source}" requested by agent "${type}" was not found or has no enabled extensions.`);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
settingsBeforeLoad = JSON.stringify([settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()]);
|
|
1222
|
+
const enabledConfigured = configured.extensions.filter((resource) => resource.enabled).map((resource) => resource.path);
|
|
1223
|
+
const enabledExplicit = explicit.extensions.filter((resource) => resource.enabled);
|
|
1224
|
+
discoveredNames = new Set([...enabledConfigured, ...enabledExplicit.map((resource) => resource.path)].flatMap(extensionCanonicalNames));
|
|
1225
|
+
const selectedExplicit = enabledExplicit.filter((resource) =>
|
|
1226
|
+
!extensionCanonicalNames(resource.path).some((name) => excludeNames.has(name)));
|
|
1227
|
+
const explicitPaths = new Set(selectedExplicit.map((resource) => resource.path));
|
|
1228
|
+
// For priority, use the package's declared name rather than a generic entry
|
|
1229
|
+
// alias like "src"; unrelated packages often have src/index.ts too.
|
|
1230
|
+
const explicitNames = new Set(selectedExplicit.map((resource) =>
|
|
1231
|
+
extensionPackageName(resource.path) ?? extensionCanonicalName(resource.path)));
|
|
1232
|
+
for (const source of extensionsSpec?.paths ?? []) {
|
|
1233
|
+
if (selectedExplicit.some((resource) => resource.metadata.source === source)) {
|
|
1234
|
+
resolvedExplicitNames.add(extensionCanonicalName(source));
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
const selectedPaths = [
|
|
1238
|
+
// The loader normally loads temporary paths before discovered paths. Keep
|
|
1239
|
+
// that priority (including for directory/package paths with many entries).
|
|
1240
|
+
...selectedExplicit.map((resource) => resource.path),
|
|
1241
|
+
...enabledConfigured.filter((path) => {
|
|
1242
|
+
const names = extensionCanonicalNames(path);
|
|
1243
|
+
if (names.some((name) => excludeNames.has(name))) return false;
|
|
1244
|
+
if (explicitPaths.has(path) || explicitNames.has(extensionPackageName(path) ?? extensionCanonicalName(path))) return false;
|
|
1245
|
+
return loadAll || names.some((name) => bareNames.has(name));
|
|
1246
|
+
}),
|
|
1247
|
+
];
|
|
1248
|
+
prefilteredExtensionPaths = new Set(selectedPaths);
|
|
1249
|
+
additionalExtensionPaths = [...prefilteredExtensionPaths];
|
|
1250
|
+
}
|
|
977
1251
|
|
|
978
1252
|
const loader = new DefaultResourceLoader({
|
|
979
1253
|
cwd: configCwd,
|
|
980
1254
|
agentDir,
|
|
981
|
-
|
|
1255
|
+
settingsManager,
|
|
1256
|
+
noExtensions: noExtensions || prefilter,
|
|
982
1257
|
additionalExtensionPaths,
|
|
983
1258
|
extensionsOverride,
|
|
1259
|
+
extensionFactories,
|
|
984
1260
|
noSkills,
|
|
1261
|
+
skillsOverride: noSkills ? () => ({ skills: [], diagnostics: [] }) : undefined,
|
|
985
1262
|
noPromptTemplates: true,
|
|
986
1263
|
noThemes: true,
|
|
987
1264
|
noContextFiles: true,
|
|
@@ -990,6 +1267,18 @@ export async function runAgent(
|
|
|
990
1267
|
});
|
|
991
1268
|
await runInChildSessionContext(() => loader.reload());
|
|
992
1269
|
throwIfAborted(options.signal);
|
|
1270
|
+
if (prefilteredExtensionPaths) {
|
|
1271
|
+
if (settingsBeforeLoad !== JSON.stringify([settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()])) {
|
|
1272
|
+
throw new Error(`Pi extension settings changed while loading agent "${type}"; retry with the current settings.`);
|
|
1273
|
+
}
|
|
1274
|
+
const loadedPaths = new Set(loader.getExtensions().extensions.map((extension) => extension.path));
|
|
1275
|
+
for (const path of prefilteredExtensionPaths) {
|
|
1276
|
+
if (!loadedPaths.has(path)) {
|
|
1277
|
+
const diagnostic = loader.getExtensions().errors.find((error) => error.path === path);
|
|
1278
|
+
throw new Error(`Selected extension "${path}" was not loaded for agent "${type}"${diagnostic ? `: ${diagnostic.error}` : " (possibly replaced by another extension)"}.`);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
993
1282
|
|
|
994
1283
|
// Plain entries in `tools:` are expected to be built-in names (extension tools
|
|
995
1284
|
// go through `ext:`), so an unknown name there is unambiguously a typo. Previously
|
|
@@ -1011,11 +1300,13 @@ export async function runAgent(
|
|
|
1011
1300
|
}
|
|
1012
1301
|
}
|
|
1013
1302
|
|
|
1014
|
-
//
|
|
1015
|
-
//
|
|
1016
|
-
//
|
|
1017
|
-
//
|
|
1018
|
-
//
|
|
1303
|
+
// Bare `extensions:` names and `ext:` tool selectors warn when unmatched.
|
|
1304
|
+
// On prefiltered hosts, missing explicit paths and failed selected factories
|
|
1305
|
+
// already raised above instead of silently losing a requested capability.
|
|
1306
|
+
// Two remaining misconfigurations to catch:
|
|
1307
|
+
// - `extensions: [foo]` but no extension named foo was discovered (typo;
|
|
1308
|
+
// on older hosts path entries also fold their canonical name into
|
|
1309
|
+
// `keepNames`, so a failed path load is warned here).
|
|
1019
1310
|
// - `tools: ext:foo` but foo isn't in the loaded set (because `extensions:`
|
|
1020
1311
|
// didn't include it). Since v0.9, `ext:` no longer pulls extensions in;
|
|
1021
1312
|
// loading is `extensions:`-authoritative.
|
|
@@ -1045,6 +1336,10 @@ export async function runAgent(
|
|
|
1045
1336
|
loader.getExtensions().extensions.flatMap((e) => extensionCanonicalNames(e.path)),
|
|
1046
1337
|
);
|
|
1047
1338
|
for (const name of keepNames) {
|
|
1339
|
+
// A directory/package source can resolve to entries with different
|
|
1340
|
+
// canonical names; its resolved paths were checked above, not by the
|
|
1341
|
+
// basename of the requested directory.
|
|
1342
|
+
if (prefilteredExtensionPaths && resolvedExplicitNames.has(name) && !bareNames.has(name)) continue;
|
|
1048
1343
|
if (!survivingNames.has(name)) {
|
|
1049
1344
|
options.onToolActivity?.({
|
|
1050
1345
|
type: "end",
|
|
@@ -1089,6 +1384,7 @@ export async function runAgent(
|
|
|
1089
1384
|
if (scopeVerdict.kind === "error") throw new Error(scopeVerdict.message);
|
|
1090
1385
|
if (scopeVerdict.kind === "warn" && ctx.hasUI) ctx.ui.notify(scopeVerdict.message, "warning");
|
|
1091
1386
|
}
|
|
1387
|
+
const parentModels = parentModelSessionOptions(ctx, model);
|
|
1092
1388
|
const disallowedSet = (() => {
|
|
1093
1389
|
const names = [...(agentConfig?.disallowedTools ?? []), ...(options.excludeTools ?? [])];
|
|
1094
1390
|
return names.length > 0 ? new Set(names) : undefined;
|
|
@@ -1139,6 +1435,11 @@ export async function runAgent(
|
|
|
1139
1435
|
// the scoping pass below keeps exactly the names it is given.
|
|
1140
1436
|
const injectedTools = [...nestedTools, ...supervisorTools];
|
|
1141
1437
|
const nestedToolNames = new Set(injectedTools.map(tool => tool.name));
|
|
1438
|
+
const askGate = createAskGate({
|
|
1439
|
+
askTools: agentConfig?.askTools ?? [],
|
|
1440
|
+
agentLabel: agentConfig?.displayName ?? type,
|
|
1441
|
+
...(ctx.hasUI ? { confirm: (title: string, message: string) => ctx.ui.confirm(title, message) } : {}),
|
|
1442
|
+
});
|
|
1142
1443
|
|
|
1143
1444
|
// ─── Tool scoping ───────────────────────────────────────────────────────
|
|
1144
1445
|
//
|
|
@@ -1203,7 +1504,6 @@ export async function runAgent(
|
|
|
1203
1504
|
// allowlist. Concrete tool availability remains owned by the agent config.
|
|
1204
1505
|
}
|
|
1205
1506
|
|
|
1206
|
-
const settingsManager = SettingsManager.create(configCwd, agentDir);
|
|
1207
1507
|
const configuredSessionDir = resolveConfiguredSessionDir(agentConfig?.sessionDir, effectiveCwd);
|
|
1208
1508
|
const defaultSessionDir = process.env.PI_CODING_AGENT_SESSION_DIR ?? settingsManager.getSessionDir?.();
|
|
1209
1509
|
// Frontmatter wins when it says anything; otherwise the project default,
|
|
@@ -1226,21 +1526,18 @@ export async function runAgent(
|
|
|
1226
1526
|
? SessionManager.create(effectiveCwd, configuredSessionDir ?? defaultSessionDir, { parentSession })
|
|
1227
1527
|
: SessionManager.create(effectiveCwd, configuredSessionDir ?? defaultSessionDir)
|
|
1228
1528
|
: SessionManager.inMemory(effectiveCwd);
|
|
1529
|
+
if (options.resumeSessionFile) {
|
|
1530
|
+
assertSafeChildSession({ messages: [], sessionManager });
|
|
1531
|
+
}
|
|
1229
1532
|
|
|
1230
|
-
// Pi 0.80.8 replaced createAgentSession's modelRegistry option with
|
|
1231
|
-
// modelRuntime, but ExtensionContext still exposes only the registry facade.
|
|
1232
|
-
// Pass both so the full supported Pi range retains the parent's providers.
|
|
1233
|
-
const parentModelRuntime = (ctx.modelRegistry as unknown as { runtime?: ModelRuntime }).runtime;
|
|
1234
1533
|
const sessionOpts: Parameters<typeof createAgentSession>[0] & {
|
|
1235
1534
|
modelRegistry: ExtensionContext["modelRegistry"];
|
|
1236
|
-
modelRuntime?: ModelRuntime;
|
|
1237
1535
|
} = {
|
|
1238
1536
|
cwd: effectiveCwd,
|
|
1239
1537
|
agentDir,
|
|
1240
1538
|
sessionManager,
|
|
1241
1539
|
settingsManager,
|
|
1242
|
-
|
|
1243
|
-
...(parentModelRuntime !== undefined && { modelRuntime: parentModelRuntime }),
|
|
1540
|
+
...parentModels,
|
|
1244
1541
|
model,
|
|
1245
1542
|
tools: sessionTools,
|
|
1246
1543
|
customTools: injectedTools,
|
|
@@ -1254,6 +1551,7 @@ export async function runAgent(
|
|
|
1254
1551
|
}
|
|
1255
1552
|
|
|
1256
1553
|
const { session } = await runInChildSessionContext(() => createAgentSession(sessionOpts));
|
|
1554
|
+
approvalTimers.set(session, approvalTimer);
|
|
1257
1555
|
// Install the forwarding listener immediately after session creation, before
|
|
1258
1556
|
// bindExtensions/session_start can do asynchronous work. The old placement
|
|
1259
1557
|
// just before prompt missed an abort during extension activation.
|
|
@@ -1263,6 +1561,27 @@ export async function runAgent(
|
|
|
1263
1561
|
try {
|
|
1264
1562
|
throwIfAborted(options.signal);
|
|
1265
1563
|
|
|
1564
|
+
const inScope = noExtensions ? undefined : extensionToolNamesInScope(session, {
|
|
1565
|
+
loader, toolNames, disallowedSet, extNames, narrowing, nestedToolNames,
|
|
1566
|
+
});
|
|
1567
|
+
checkToolCall = async (name, input, deadline) => {
|
|
1568
|
+
const run = async (): Promise<ToolCallEventResult | undefined> => {
|
|
1569
|
+
// Scope first, then approval: never prompt for a tool this child cannot use.
|
|
1570
|
+
if (inScope && !inScope().has(name)) {
|
|
1571
|
+
return { block: true, reason: `Tool "${name}" is not available to this subagent.` };
|
|
1572
|
+
}
|
|
1573
|
+
return askGate?.(name, input);
|
|
1574
|
+
};
|
|
1575
|
+
if (deadline === Infinity) return run();
|
|
1576
|
+
const remaining = deadline - performance.now();
|
|
1577
|
+
if (remaining <= 0) return { block: true, reason: `tool_call for "${name}" timed out` };
|
|
1578
|
+
try {
|
|
1579
|
+
return await withTimeout(run(), remaining, `tool_call for "${name}"`);
|
|
1580
|
+
} catch (err) {
|
|
1581
|
+
return { block: true, reason: (err as Error).message };
|
|
1582
|
+
}
|
|
1583
|
+
};
|
|
1584
|
+
|
|
1266
1585
|
const baseSessionName = options.thread ? `workflow-thread:${options.thread}` : (agentConfig?.name ?? type);
|
|
1267
1586
|
session.setSessionName(
|
|
1268
1587
|
options.agentId && !options.thread ? `${baseSessionName}#${options.agentId.slice(0, 8)}` : baseSessionName,
|
|
@@ -1282,50 +1601,13 @@ export async function runAgent(
|
|
|
1282
1601
|
});
|
|
1283
1602
|
throwIfAborted(options.signal);
|
|
1284
1603
|
|
|
1285
|
-
// With `allowedToolNames` unset, the
|
|
1286
|
-
//
|
|
1287
|
-
//
|
|
1288
|
-
//
|
|
1289
|
-
//
|
|
1290
|
-
//
|
|
1291
|
-
|
|
1292
|
-
// exist — so it applies to isolated agents too, where the scope installer
|
|
1293
|
-
// below never runs because the registry is already statically allowlisted.
|
|
1294
|
-
const askGate = createAskGate({
|
|
1295
|
-
askTools: agentConfig?.askTools ?? [],
|
|
1296
|
-
agentLabel: agentConfig?.displayName ?? type,
|
|
1297
|
-
...(ctx.hasUI ? { confirm: (title: string, message: string) => ctx.ui.confirm(title, message) } : {}),
|
|
1298
|
-
});
|
|
1299
|
-
|
|
1300
|
-
if (!noExtensions) {
|
|
1301
|
-
installExtensionToolScope(session, {
|
|
1302
|
-
loader,
|
|
1303
|
-
toolNames,
|
|
1304
|
-
disallowedSet,
|
|
1305
|
-
extNames,
|
|
1306
|
-
narrowing,
|
|
1307
|
-
nestedToolNames,
|
|
1308
|
-
...(askGate ? { askGate } : {}),
|
|
1309
|
-
});
|
|
1310
|
-
} else if (askGate) {
|
|
1311
|
-
// Same hook, without the scope check the allowlist already performed.
|
|
1312
|
-
const priorBeforeToolCall = session.agent.beforeToolCall;
|
|
1313
|
-
session.agent.beforeToolCall = async (context, signal) => {
|
|
1314
|
-
const run = async () => {
|
|
1315
|
-
const gated = await askGate(context.toolCall.name, (context.toolCall as { input?: unknown }).input);
|
|
1316
|
-
if (gated) return gated;
|
|
1317
|
-
return priorBeforeToolCall?.(context, signal);
|
|
1318
|
-
};
|
|
1319
|
-
if (defaultToolTimeoutMs > 0) {
|
|
1320
|
-
try {
|
|
1321
|
-
return await withTimeout(run(), defaultToolTimeoutMs, `beforeToolCall for "${context.toolCall.name}"`);
|
|
1322
|
-
} catch (err) {
|
|
1323
|
-
return { block: true, reason: (err as Error).message };
|
|
1324
|
-
}
|
|
1325
|
-
}
|
|
1326
|
-
return run();
|
|
1327
|
-
};
|
|
1328
|
-
}
|
|
1604
|
+
// With `allowedToolNames` unset, the ACTIVE set still needs managing: Pi
|
|
1605
|
+
// activates default built-ins and eligible extension tools at registration.
|
|
1606
|
+
// The same live inScope
|
|
1607
|
+
// predicate drives the public tool_call handler (also for nested calls) and
|
|
1608
|
+
// active-set narrowing. Under extensions: false the static registry allowlist
|
|
1609
|
+
// handles scope; the tool_call handler still enforces ask_tools.
|
|
1610
|
+
if (inScope) installExtensionToolScope(session, inScope);
|
|
1329
1611
|
|
|
1330
1612
|
if (options.onSessionCreated) {
|
|
1331
1613
|
// Mark ownership before invoking the callback: manager wrappers assign the
|
|
@@ -1391,30 +1673,8 @@ export async function runAgent(
|
|
|
1391
1673
|
// timeout the session is aborted — which propagates via the tool's
|
|
1392
1674
|
// AbortSignal into the hanging execute() and surfaces as an error result.
|
|
1393
1675
|
// `contact_supervisor` is excluded: it intentionally waits for a human.
|
|
1394
|
-
const
|
|
1395
|
-
|
|
1396
|
-
const handle = pendingToolTimeouts.get(toolCallId);
|
|
1397
|
-
if (handle) {
|
|
1398
|
-
clearTimeout(handle);
|
|
1399
|
-
pendingToolTimeouts.delete(toolCallId);
|
|
1400
|
-
}
|
|
1401
|
-
};
|
|
1402
|
-
const armToolTimeout = (toolCallId: string, toolName: string): void => {
|
|
1403
|
-
if (defaultToolTimeoutMs <= 0) return;
|
|
1404
|
-
if (toolName === "contact_supervisor") return;
|
|
1405
|
-
const handle = setTimeout(() => {
|
|
1406
|
-
pendingToolTimeouts.delete(toolCallId);
|
|
1407
|
-
try {
|
|
1408
|
-
session.abort();
|
|
1409
|
-
} catch {
|
|
1410
|
-
// The session may already be torn down by the time this fires; the
|
|
1411
|
-
// timeout's only job is to stop a hung tool, and there is nothing
|
|
1412
|
-
// left to stop.
|
|
1413
|
-
}
|
|
1414
|
-
}, defaultToolTimeoutMs);
|
|
1415
|
-
handle.unref?.();
|
|
1416
|
-
pendingToolTimeouts.set(toolCallId, handle);
|
|
1417
|
-
};
|
|
1676
|
+
const toolTimeouts = createToolTimeouts(session, noExtensions ? 0 : TOOL_CALL_ABORT_GRACE_MS, approvalTimer.isInteractiveAsk);
|
|
1677
|
+
approvalTimer.armApproved = toolTimeouts.armApproved;
|
|
1418
1678
|
// Own finalized output before observer dispatch: a synchronous callback
|
|
1419
1679
|
// failure must remain visible without erasing the answer it interrupted.
|
|
1420
1680
|
const collector = collectResponseText(session);
|
|
@@ -1441,10 +1701,10 @@ export async function runAgent(
|
|
|
1441
1701
|
}
|
|
1442
1702
|
if (event.type === "tool_execution_start") {
|
|
1443
1703
|
options.onToolActivity?.({ type: "start", toolName: event.toolName });
|
|
1444
|
-
|
|
1704
|
+
toolTimeouts.start(event);
|
|
1445
1705
|
}
|
|
1446
1706
|
if (event.type === "tool_execution_end") {
|
|
1447
|
-
|
|
1707
|
+
toolTimeouts.end(event);
|
|
1448
1708
|
options.onToolActivity?.({ type: "end", toolName: event.toolName });
|
|
1449
1709
|
budgetToolCalls++;
|
|
1450
1710
|
toolSoftReached = applyBudget(budgetToolCalls, toolCallBudget, toolSoftReached, "tool call");
|
|
@@ -1468,24 +1728,14 @@ export async function runAgent(
|
|
|
1468
1728
|
}
|
|
1469
1729
|
});
|
|
1470
1730
|
|
|
1471
|
-
// Build the effective prompt: optionally prepend parent context
|
|
1472
|
-
let effectivePrompt = prompt;
|
|
1473
|
-
const inheritContext = internalOverride?.inheritContext ?? options.inheritContext;
|
|
1474
|
-
if (inheritContext) {
|
|
1475
|
-
const parentContext = buildParentContext(ctx);
|
|
1476
|
-
if (parentContext) {
|
|
1477
|
-
effectivePrompt = parentContext + prompt;
|
|
1478
|
-
}
|
|
1479
|
-
}
|
|
1480
|
-
|
|
1481
1731
|
try {
|
|
1482
1732
|
throwIfAborted(options.signal);
|
|
1483
|
-
await session.prompt(
|
|
1733
|
+
await session.prompt(prompt);
|
|
1484
1734
|
} finally {
|
|
1735
|
+
approvalTimer.armApproved = () => {};
|
|
1485
1736
|
unsubTurns();
|
|
1486
1737
|
collector.unsubscribe();
|
|
1487
|
-
|
|
1488
|
-
pendingToolTimeouts.clear();
|
|
1738
|
+
toolTimeouts.dispose();
|
|
1489
1739
|
}
|
|
1490
1740
|
|
|
1491
1741
|
const baseText = collector.getText();
|
|
@@ -1527,6 +1777,7 @@ export async function resumeAgent(
|
|
|
1527
1777
|
} = {},
|
|
1528
1778
|
): Promise<{ text: string; failure?: string }> {
|
|
1529
1779
|
throwIfAborted(options.signal);
|
|
1780
|
+
assertSafeChildSession(session);
|
|
1530
1781
|
const collector = collectResponseText(session);
|
|
1531
1782
|
const cleanupAbort = forwardAbortSignal(session, options.signal);
|
|
1532
1783
|
|
|
@@ -1547,46 +1798,27 @@ export async function resumeAgent(
|
|
|
1547
1798
|
}
|
|
1548
1799
|
})
|
|
1549
1800
|
: () => {};
|
|
1550
|
-
//
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
session.abort();
|
|
1562
|
-
} catch {
|
|
1563
|
-
// The session may already be torn down by the time this fires; the
|
|
1564
|
-
// timeout's only job is to stop a hung tool, and there is nothing
|
|
1565
|
-
// left to stop.
|
|
1566
|
-
}
|
|
1567
|
-
}, defaultToolTimeoutMs);
|
|
1568
|
-
handle.unref?.();
|
|
1569
|
-
resumePendingTimeouts.set(id, handle);
|
|
1570
|
-
} else if (event.type === "tool_execution_end") {
|
|
1571
|
-
const id = (event as { toolCallId: string }).toolCallId;
|
|
1572
|
-
const handle = resumePendingTimeouts.get(id);
|
|
1573
|
-
if (handle) {
|
|
1574
|
-
clearTimeout(handle);
|
|
1575
|
-
resumePendingTimeouts.delete(id);
|
|
1576
|
-
}
|
|
1577
|
-
}
|
|
1578
|
-
})
|
|
1579
|
-
: () => {};
|
|
1801
|
+
// The child policy and its consent gate survive resumed turns. Replace the
|
|
1802
|
+
// original prompt's timer callback so a delayed approval cannot arm a stale
|
|
1803
|
+
// timer that the first prompt no longer clears.
|
|
1804
|
+
const approvalTimer = approvalTimers.get(session);
|
|
1805
|
+
const toolTimeouts = createToolTimeouts(session, TOOL_CALL_ABORT_GRACE_MS,
|
|
1806
|
+
approvalTimer?.isInteractiveAsk ?? (() => false));
|
|
1807
|
+
if (approvalTimer) approvalTimer.armApproved = toolTimeouts.armApproved;
|
|
1808
|
+
const unsubResumeTimeout = session.subscribe((event: AgentSessionEvent) => {
|
|
1809
|
+
if (event.type === "tool_execution_start") toolTimeouts.start(event);
|
|
1810
|
+
else if (event.type === "tool_execution_end") toolTimeouts.end(event);
|
|
1811
|
+
});
|
|
1580
1812
|
|
|
1581
1813
|
try {
|
|
1582
1814
|
throwIfAborted(options.signal);
|
|
1583
1815
|
await session.prompt(prompt);
|
|
1584
1816
|
} finally {
|
|
1817
|
+
if (approvalTimer) approvalTimer.armApproved = () => {};
|
|
1585
1818
|
collector.unsubscribe();
|
|
1586
1819
|
unsubEvents();
|
|
1587
1820
|
unsubResumeTimeout();
|
|
1588
|
-
|
|
1589
|
-
resumePendingTimeouts.clear();
|
|
1821
|
+
toolTimeouts.dispose();
|
|
1590
1822
|
cleanupAbort();
|
|
1591
1823
|
}
|
|
1592
1824
|
|