@tintinweb/pi-subagents 0.9.1 → 0.10.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 +25 -0
- package/README.md +47 -15
- package/dist/agent-runner.d.ts +49 -0
- package/dist/agent-runner.js +225 -35
- package/dist/agent-types.d.ts +8 -1
- package/dist/agent-types.js +15 -4
- package/dist/custom-agents.js +21 -1
- package/dist/index.js +13 -17
- package/dist/prompts.d.ts +6 -3
- package/dist/prompts.js +12 -4
- package/dist/status-note.d.ts +13 -0
- package/dist/status-note.js +24 -0
- package/dist/types.d.ts +3 -0
- package/dist/ui/agent-widget.d.ts +4 -4
- package/dist/ui/agent-widget.js +6 -6
- package/dist/ui/conversation-viewer.d.ts +9 -1
- package/dist/ui/conversation-viewer.js +35 -2
- package/package.json +2 -1
- package/src/agent-runner.ts +238 -34
- package/src/agent-types.ts +15 -4
- package/src/custom-agents.ts +23 -1
- package/src/index.ts +13 -18
- package/src/prompts.ts +12 -4
- package/src/status-note.ts +25 -0
- package/src/types.ts +3 -0
- package/src/ui/agent-widget.ts +6 -6
- package/src/ui/conversation-viewer.ts +32 -1
package/src/agent-runner.ts
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
* agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { basename, dirname, isAbsolute, resolve } from "node:path";
|
|
5
7
|
import type { Model } from "@earendil-works/pi-ai";
|
|
6
|
-
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import type { ExtensionContext, LoadExtensionsResult } from "@earendil-works/pi-coding-agent";
|
|
7
9
|
import {
|
|
8
10
|
type AgentSession,
|
|
9
11
|
type AgentSessionEvent,
|
|
@@ -14,7 +16,7 @@ import {
|
|
|
14
16
|
SessionManager,
|
|
15
17
|
SettingsManager,
|
|
16
18
|
} from "@earendil-works/pi-coding-agent";
|
|
17
|
-
import { getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
|
|
19
|
+
import { BUILTIN_TOOL_NAMES, getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
|
|
18
20
|
import { buildParentContext, extractText } from "./context.js";
|
|
19
21
|
import { DEFAULT_AGENTS } from "./default-agents.js";
|
|
20
22
|
import { detectEnv } from "./env.js";
|
|
@@ -23,8 +25,114 @@ import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
|
|
|
23
25
|
import { preloadSkills } from "./skill-loader.js";
|
|
24
26
|
import type { SubagentType, ThinkingLevel } from "./types.js";
|
|
25
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Tool names registered by THIS extension. Single source of truth so the
|
|
30
|
+
* registration sites (index.ts) and the subagent exclusion list below can't
|
|
31
|
+
* drift apart. These are our own tools, not pi built-ins, so they can't be
|
|
32
|
+
* derived from pi — but they only need defining once.
|
|
33
|
+
*/
|
|
34
|
+
export const SUBAGENT_TOOL_NAMES = {
|
|
35
|
+
AGENT: "Agent",
|
|
36
|
+
GET_RESULT: "get_subagent_result",
|
|
37
|
+
STEER: "steer_subagent",
|
|
38
|
+
} as const;
|
|
39
|
+
|
|
26
40
|
/** Names of tools registered by this extension that subagents must NOT inherit. */
|
|
27
|
-
const EXCLUDED_TOOL_NAMES
|
|
41
|
+
const EXCLUDED_TOOL_NAMES: string[] = Object.values(SUBAGENT_TOOL_NAMES);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Canonical name of an extension for `extensions: [...]` allowlist matching.
|
|
45
|
+
* Lowercased — extension names match case-insensitively so `extensions: [Mcp]`
|
|
46
|
+
* resolves the same as `[mcp]`. Tool names within `ext:foo/bar` are not affected.
|
|
47
|
+
* Directory extensions (`foo/index.ts`) resolve to the parent directory name;
|
|
48
|
+
* single-file extensions to the basename minus `.ts`/`.js`.
|
|
49
|
+
*/
|
|
50
|
+
export function extensionCanonicalName(extPath: string): string {
|
|
51
|
+
const base = basename(extPath);
|
|
52
|
+
const name = base === "index.ts" || base === "index.js"
|
|
53
|
+
? basename(dirname(extPath))
|
|
54
|
+
: base.replace(/\.(ts|js)$/, "");
|
|
55
|
+
return name.toLowerCase();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Classify `extensions: string[]` frontmatter entries for the loader-level filter.
|
|
60
|
+
*
|
|
61
|
+
* An entry is a PATH iff it contains a path separator or starts with `~`; otherwise
|
|
62
|
+
* it is a NAME. `"*"` sets the wildcard flag (keep all default-discovered extensions).
|
|
63
|
+
*
|
|
64
|
+
* Path entries are resolved (`~` expanded, made absolute against `cwd`) into `paths`
|
|
65
|
+
* — and their canonical name is also added to `names`. The loader override matches
|
|
66
|
+
* everything by canonical name, so path-loaded extensions are matched via their name
|
|
67
|
+
* rather than their post-staging `Extension.path`.
|
|
68
|
+
*/
|
|
69
|
+
export function parseExtensionsSpec(
|
|
70
|
+
entries: string[],
|
|
71
|
+
cwd: string,
|
|
72
|
+
): { names: Set<string>; paths: string[]; wildcard: boolean } {
|
|
73
|
+
const names = new Set<string>();
|
|
74
|
+
const paths: string[] = [];
|
|
75
|
+
let wildcard = false;
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
if (!entry) continue;
|
|
78
|
+
if (entry === "*") {
|
|
79
|
+
wildcard = true;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const isPathEntry = entry.includes("/") || entry.includes("\\") || entry.startsWith("~");
|
|
83
|
+
if (!isPathEntry) {
|
|
84
|
+
names.add(entry.toLowerCase());
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
let p = entry;
|
|
88
|
+
if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) {
|
|
89
|
+
p = homedir() + p.slice(1);
|
|
90
|
+
}
|
|
91
|
+
const abs = isAbsolute(p) ? p : resolve(cwd, p);
|
|
92
|
+
paths.push(abs);
|
|
93
|
+
names.add(extensionCanonicalName(abs));
|
|
94
|
+
}
|
|
95
|
+
return { names, paths, wildcard };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Parse raw `ext:` selector strings (from the `tools:` CSV) into the set of
|
|
100
|
+
* extension names to keep loaded and a per-extension tool-narrowing map.
|
|
101
|
+
*
|
|
102
|
+
* `ext:foo` → `extNames` has `foo`, no narrowing entry (all of foo's tools).
|
|
103
|
+
* `ext:foo/bar` → `extNames` has `foo`, `narrowing.foo` has `bar` (only `bar`).
|
|
104
|
+
* A name lands in `narrowing` only when a `/tool` form is seen, so a bare
|
|
105
|
+
* `ext:foo` alongside `ext:foo/bar` leaves narrowing in effect (narrowing wins).
|
|
106
|
+
* The split is on the first `/`; extension canonical names never contain `/`.
|
|
107
|
+
*/
|
|
108
|
+
export function parseExtSelectors(entries: string[]): {
|
|
109
|
+
extNames: Set<string>;
|
|
110
|
+
narrowing: Map<string, Set<string>>;
|
|
111
|
+
} {
|
|
112
|
+
const extNames = new Set<string>();
|
|
113
|
+
const narrowing = new Map<string, Set<string>>();
|
|
114
|
+
for (const raw of entries) {
|
|
115
|
+
if (!raw) continue;
|
|
116
|
+
const body = raw.slice("ext:".length);
|
|
117
|
+
const slash = body.indexOf("/");
|
|
118
|
+
// Extension name matches case-insensitively (matches the loader-side canonical
|
|
119
|
+
// name). Tool names are case-preserved — they're matched against pi-mono's
|
|
120
|
+
// registered identifiers, which are case-sensitive.
|
|
121
|
+
const name = (slash === -1 ? body : body.slice(0, slash)).trim().toLowerCase();
|
|
122
|
+
if (!name) continue;
|
|
123
|
+
extNames.add(name);
|
|
124
|
+
if (slash === -1) continue;
|
|
125
|
+
const tool = body.slice(slash + 1).trim();
|
|
126
|
+
if (!tool) continue;
|
|
127
|
+
let set = narrowing.get(name);
|
|
128
|
+
if (!set) {
|
|
129
|
+
set = new Set();
|
|
130
|
+
narrowing.set(name, set);
|
|
131
|
+
}
|
|
132
|
+
set.add(tool);
|
|
133
|
+
}
|
|
134
|
+
return { extNames, narrowing };
|
|
135
|
+
}
|
|
28
136
|
|
|
29
137
|
/** Default max turns. undefined = unlimited (no turn limit). */
|
|
30
138
|
let defaultMaxTurns: number | undefined;
|
|
@@ -239,16 +347,51 @@ export async function runAgent(
|
|
|
239
347
|
|
|
240
348
|
const agentDir = getAgentDir();
|
|
241
349
|
|
|
242
|
-
//
|
|
350
|
+
// Extension loading:
|
|
351
|
+
// - true → all default-discovered extensions
|
|
352
|
+
// - false → none (noExtensions)
|
|
353
|
+
// - string[] → loader-level allowlist. Bare names keep the matching
|
|
354
|
+
// default-discovered extension; path entries load that extension fresh;
|
|
355
|
+
// "*" keeps all default-discovered extensions. Excluded extensions never
|
|
356
|
+
// bind handlers or register tools (their factory still runs once).
|
|
357
|
+
//
|
|
243
358
|
// Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md — upstream's
|
|
244
359
|
// buildSystemPrompt() re-appends both AFTER systemPromptOverride, which
|
|
245
360
|
// would defeat prompt_mode: replace and isolated: true. Parent context, if
|
|
246
361
|
// wanted, reaches the subagent via prompt_mode: append (parentSystemPrompt
|
|
247
362
|
// is embedded in systemPromptOverride) or inherit_context (conversation).
|
|
363
|
+
// `ext:` selectors from the `tools:` CSV narrow which extension tools surface to
|
|
364
|
+
// the LLM. They do NOT control loading — `extensions:` is the sole authority for
|
|
365
|
+
// which extensions load. `ext:foo` against an extension that `extensions:` excluded
|
|
366
|
+
// is an orphan and warns after reload. `isolated` means no extension tools at all.
|
|
367
|
+
const { extNames, narrowing } = parseExtSelectors(
|
|
368
|
+
options.isolated ? [] : (agentConfig?.extSelectors ?? []),
|
|
369
|
+
);
|
|
370
|
+
const noExtensions = extensions === false;
|
|
371
|
+
|
|
372
|
+
const extensionsSpec = Array.isArray(extensions)
|
|
373
|
+
? parseExtensionsSpec(extensions, effectiveCwd)
|
|
374
|
+
: undefined;
|
|
375
|
+
const keepNames = extensionsSpec?.names ?? new Set<string>();
|
|
376
|
+
// The override filters loaded extensions down to `keepNames`. It's only needed
|
|
377
|
+
// when we're neither loading everything (`extensions: true` or a `"*"` wildcard)
|
|
378
|
+
// nor nothing (`noExtensions`).
|
|
379
|
+
const loadAll = extensions === true || extensionsSpec?.wildcard === true;
|
|
380
|
+
const additionalExtensionPaths = extensionsSpec?.paths.length ? extensionsSpec.paths : undefined;
|
|
381
|
+
const extensionsOverride: ((base: LoadExtensionsResult) => LoadExtensionsResult) | undefined =
|
|
382
|
+
loadAll || noExtensions
|
|
383
|
+
? undefined
|
|
384
|
+
: (base) => ({
|
|
385
|
+
...base,
|
|
386
|
+
extensions: base.extensions.filter((e) => keepNames.has(extensionCanonicalName(e.path))),
|
|
387
|
+
});
|
|
388
|
+
|
|
248
389
|
const loader = new DefaultResourceLoader({
|
|
249
390
|
cwd: effectiveCwd,
|
|
250
391
|
agentDir,
|
|
251
|
-
noExtensions
|
|
392
|
+
noExtensions,
|
|
393
|
+
additionalExtensionPaths,
|
|
394
|
+
extensionsOverride,
|
|
252
395
|
noSkills,
|
|
253
396
|
noPromptTemplates: true,
|
|
254
397
|
noThemes: true,
|
|
@@ -258,6 +401,52 @@ export async function runAgent(
|
|
|
258
401
|
});
|
|
259
402
|
await loader.reload();
|
|
260
403
|
|
|
404
|
+
// Plain entries in `tools:` are expected to be built-in names (extension tools
|
|
405
|
+
// go through `ext:`), so an unknown name there is unambiguously a typo. Previously
|
|
406
|
+
// this produced a silently broken agent (#75) — pi-mono accepted the bogus name
|
|
407
|
+
// into the allowlist, then dropped it at registration with no signal back.
|
|
408
|
+
if (agentConfig?.builtinToolNames?.length) {
|
|
409
|
+
const knownBuiltins = new Set(BUILTIN_TOOL_NAMES);
|
|
410
|
+
for (const name of agentConfig.builtinToolNames) {
|
|
411
|
+
if (!knownBuiltins.has(name)) {
|
|
412
|
+
options.onToolActivity?.({
|
|
413
|
+
type: "end",
|
|
414
|
+
toolName: `tools-error:tool "${name}" requested by agent "${type}" is not a known built-in`,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// A subagent spawns mid-task, so a bad `extensions:`/`ext:` entry warns rather
|
|
421
|
+
// than aborts. Two distinct misconfigurations to catch:
|
|
422
|
+
// - `extensions: [foo]` but no extension named foo was discovered (typo or
|
|
423
|
+
// path that failed to load — path entries fold their canonical name into
|
|
424
|
+
// `keepNames`, so this covers them too).
|
|
425
|
+
// - `tools: ext:foo` but foo isn't in the loaded set (because `extensions:`
|
|
426
|
+
// didn't include it). Since v0.9, `ext:` no longer pulls extensions in;
|
|
427
|
+
// loading is `extensions:`-authoritative.
|
|
428
|
+
if (keepNames.size > 0 || extNames.size > 0) {
|
|
429
|
+
const survivingNames = new Set(
|
|
430
|
+
loader.getExtensions().extensions.map((e) => extensionCanonicalName(e.path)),
|
|
431
|
+
);
|
|
432
|
+
for (const name of keepNames) {
|
|
433
|
+
if (!survivingNames.has(name)) {
|
|
434
|
+
options.onToolActivity?.({
|
|
435
|
+
type: "end",
|
|
436
|
+
toolName: `extension-error:extension "${name}" requested by agent "${type}" was not loaded`,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
for (const name of extNames) {
|
|
441
|
+
if (!survivingNames.has(name)) {
|
|
442
|
+
options.onToolActivity?.({
|
|
443
|
+
type: "end",
|
|
444
|
+
toolName: `extension-error:ext:${name} referenced by agent "${type}" but extension "${name}" is not loaded (add it to extensions:)`,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
261
450
|
// Resolve model: explicit option > config.model > parent model
|
|
262
451
|
const model = options.model ?? resolveDefaultModel(
|
|
263
452
|
ctx.model, ctx.modelRegistry, agentConfig?.model,
|
|
@@ -266,6 +455,46 @@ export async function runAgent(
|
|
|
266
455
|
// Resolve thinking level: explicit option > agent config > undefined (inherit)
|
|
267
456
|
const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking;
|
|
268
457
|
|
|
458
|
+
const disallowedSet = agentConfig?.disallowedTools
|
|
459
|
+
? new Set(agentConfig.disallowedTools)
|
|
460
|
+
: undefined;
|
|
461
|
+
|
|
462
|
+
// Enumerate extension-registered tool names from the loaded resource loader.
|
|
463
|
+
// Extensions populate `extension.tools` during `loader.reload()` and the set
|
|
464
|
+
// is stable afterwards — `bindExtensions` does not register new tools.
|
|
465
|
+
//
|
|
466
|
+
// Opt-in flip: when any `ext:` selector is present, extension tools become an
|
|
467
|
+
// explicit allowlist — a loaded extension not named by a selector contributes
|
|
468
|
+
// no tools (its handlers still ran), and `ext:foo/bar` narrows `foo` to `bar`.
|
|
469
|
+
const extensionToolNames: string[] = [];
|
|
470
|
+
if (!noExtensions) {
|
|
471
|
+
const optInActive = extNames.size > 0;
|
|
472
|
+
for (const extension of loader.getExtensions().extensions) {
|
|
473
|
+
const canon = extensionCanonicalName(extension.path);
|
|
474
|
+
if (optInActive && !extNames.has(canon)) continue;
|
|
475
|
+
const narrowed = narrowing.get(canon);
|
|
476
|
+
for (const toolName of extension.tools.keys()) {
|
|
477
|
+
if (narrowed && !narrowed.has(toolName)) continue;
|
|
478
|
+
extensionToolNames.push(toolName);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Build the master tool allowlist applied at session construction.
|
|
484
|
+
// pi-mono's `allowedToolNames` gates BOTH registration and the initial active
|
|
485
|
+
// set, so listing the exact final set here means the session is correctly
|
|
486
|
+
// scoped from the first instant — no post-construction narrowing required.
|
|
487
|
+
const builtinToolNameSet = new Set(toolNames);
|
|
488
|
+
const allowedTools = [...toolNames, ...extensionToolNames].filter((t) => {
|
|
489
|
+
if (EXCLUDED_TOOL_NAMES.includes(t)) return false;
|
|
490
|
+
if (disallowedSet?.has(t)) return false;
|
|
491
|
+
if (builtinToolNameSet.has(t)) return true;
|
|
492
|
+
// Reached only for extension tools. The extension set was already filtered
|
|
493
|
+
// at the loader (extensionsOverride / noExtensions) and at enumeration
|
|
494
|
+
// (`ext:` opt-in flip), so any extension tool in `extensionToolNames` is allowed.
|
|
495
|
+
return !noExtensions;
|
|
496
|
+
});
|
|
497
|
+
|
|
269
498
|
const sessionOpts: Parameters<typeof createAgentSession>[0] = {
|
|
270
499
|
cwd: effectiveCwd,
|
|
271
500
|
agentDir,
|
|
@@ -273,7 +502,7 @@ export async function runAgent(
|
|
|
273
502
|
settingsManager: SettingsManager.create(effectiveCwd, agentDir),
|
|
274
503
|
modelRegistry: ctx.modelRegistry,
|
|
275
504
|
model,
|
|
276
|
-
tools:
|
|
505
|
+
tools: allowedTools,
|
|
277
506
|
resourceLoader: loader,
|
|
278
507
|
};
|
|
279
508
|
if (thinkingLevel) {
|
|
@@ -287,35 +516,10 @@ export async function runAgent(
|
|
|
287
516
|
options.agentId ? `${baseSessionName}#${options.agentId.slice(0, 8)}` : baseSessionName,
|
|
288
517
|
);
|
|
289
518
|
|
|
290
|
-
// Build disallowed tools set from agent config
|
|
291
|
-
const disallowedSet = agentConfig?.disallowedTools
|
|
292
|
-
? new Set(agentConfig.disallowedTools)
|
|
293
|
-
: undefined;
|
|
294
|
-
|
|
295
|
-
// Filter active tools: remove our own tools to prevent nesting,
|
|
296
|
-
// apply extension allowlist if specified, and apply disallowedTools denylist
|
|
297
|
-
if (extensions !== false) {
|
|
298
|
-
const builtinToolNameSet = new Set(toolNames);
|
|
299
|
-
const activeTools = session.getActiveToolNames().filter((t) => {
|
|
300
|
-
if (EXCLUDED_TOOL_NAMES.includes(t)) return false;
|
|
301
|
-
if (disallowedSet?.has(t)) return false;
|
|
302
|
-
if (builtinToolNameSet.has(t)) return true;
|
|
303
|
-
if (Array.isArray(extensions)) {
|
|
304
|
-
return extensions.some(ext => t.startsWith(ext) || t.includes(ext));
|
|
305
|
-
}
|
|
306
|
-
return true;
|
|
307
|
-
});
|
|
308
|
-
session.setActiveToolsByName(activeTools);
|
|
309
|
-
} else if (disallowedSet) {
|
|
310
|
-
// Even with extensions disabled, apply denylist to built-in tools
|
|
311
|
-
const activeTools = session.getActiveToolNames().filter(t => !disallowedSet.has(t));
|
|
312
|
-
session.setActiveToolsByName(activeTools);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
519
|
// Bind extensions so that session_start fires and extensions can initialize
|
|
316
|
-
// (e.g. loading credentials, setting up state).
|
|
317
|
-
//
|
|
318
|
-
//
|
|
520
|
+
// (e.g. loading credentials, setting up state). Tool gating already happened
|
|
521
|
+
// at session construction via the `tools:` allowlist above — no separate
|
|
522
|
+
// post-bind filter is needed. All ExtensionBindings fields are optional.
|
|
319
523
|
await session.bindExtensions({
|
|
320
524
|
onError: (err) => {
|
|
321
525
|
options.onToolActivity?.({
|
package/src/agent-types.ts
CHANGED
|
@@ -5,11 +5,21 @@
|
|
|
5
5
|
* User agents override defaults with the same name. Disabled agents are kept but excluded from spawning.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { createCodingTools, createReadOnlyTools } from "@earendil-works/pi-coding-agent";
|
|
8
9
|
import { DEFAULT_AGENTS } from "./default-agents.js";
|
|
9
10
|
import type { AgentConfig } from "./types.js";
|
|
10
11
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* All known built-in tool names, derived from pi's own tool factories rather
|
|
14
|
+
* than hardcoded so the set tracks pi-mono if it adds/renames a built-in.
|
|
15
|
+
* `createCodingTools` → read/bash/edit/write; `createReadOnlyTools` →
|
|
16
|
+
* read/grep/find/ls; their de-duplicated union is the 7 built-ins
|
|
17
|
+
* (read, bash, edit, write, grep, find, ls). The `cwd` only binds tool
|
|
18
|
+
* operations we never invoke here — we read each tool's `.name` and discard it.
|
|
19
|
+
*/
|
|
20
|
+
export const BUILTIN_TOOL_NAMES: string[] = [
|
|
21
|
+
...new Set([...createCodingTools("."), ...createReadOnlyTools(".")].map((t) => t.name)),
|
|
22
|
+
];
|
|
13
23
|
|
|
14
24
|
/** Unified runtime registry of all agents (defaults + user-defined). */
|
|
15
25
|
const agents = new Map<string, AgentConfig>();
|
|
@@ -112,8 +122,9 @@ export function getToolNamesForType(type: string): string[] {
|
|
|
112
122
|
const key = resolveKey(type);
|
|
113
123
|
const raw = key ? agents.get(key) : undefined;
|
|
114
124
|
const config = raw?.enabled !== false ? raw : undefined;
|
|
115
|
-
|
|
116
|
-
|
|
125
|
+
// `undefined` (definition omitted the field) → all built-ins; an explicit `[]`
|
|
126
|
+
// (`tools: none` or a `tools:` with only `ext:` entries) → zero built-ins.
|
|
127
|
+
return config?.builtinToolNames ?? [...BUILTIN_TOOL_NAMES];
|
|
117
128
|
}
|
|
118
129
|
|
|
119
130
|
/** Get config for a type (case-insensitive, returns a SubagentTypeConfig-compatible object). Falls back to general-purpose. */
|
package/src/custom-agents.ts
CHANGED
|
@@ -50,11 +50,14 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
|
|
|
50
50
|
|
|
51
51
|
const { frontmatter: fm, body } = parseFrontmatter<Record<string, unknown>>(content);
|
|
52
52
|
|
|
53
|
+
const { builtinToolNames, extSelectors } = parseToolsField(fm.tools);
|
|
54
|
+
|
|
53
55
|
agents.set(name, {
|
|
54
56
|
name,
|
|
55
57
|
displayName: str(fm.display_name),
|
|
56
58
|
description: str(fm.description) ?? name,
|
|
57
|
-
builtinToolNames
|
|
59
|
+
builtinToolNames,
|
|
60
|
+
extSelectors,
|
|
58
61
|
disallowedTools: csvListOptional(fm.disallowed_tools),
|
|
59
62
|
extensions: inheritField(fm.extensions ?? fm.inherit_extensions),
|
|
60
63
|
skills: inheritField(fm.skills ?? fm.inherit_skills),
|
|
@@ -107,6 +110,25 @@ function csvList(val: unknown, defaults: string[]): string[] {
|
|
|
107
110
|
return parseCsvField(val) ?? [];
|
|
108
111
|
}
|
|
109
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Partition the `tools:` CSV into the built-in tool allowlist and raw `ext:` selectors.
|
|
115
|
+
* `*` (and the case-insensitive alias `all`, for `tools: all`) expands to all
|
|
116
|
+
* built-ins; plain entries are built-in names; `ext:` entries are extension-tool
|
|
117
|
+
* selectors parsed later by the runner. omitted → all built-ins, no selectors.
|
|
118
|
+
* `tools:` present with only `ext:` entries → zero built-ins (use `*`).
|
|
119
|
+
*/
|
|
120
|
+
function parseToolsField(val: unknown): { builtinToolNames: string[]; extSelectors: string[] | undefined } {
|
|
121
|
+
const entries = csvList(val, BUILTIN_TOOL_NAMES);
|
|
122
|
+
const isWildcard = (e: string) => e === "*" || e.toLowerCase() === "all";
|
|
123
|
+
const hasWildcard = entries.some(isWildcard);
|
|
124
|
+
const plain = entries.filter(e => !isWildcard(e) && !e.startsWith("ext:"));
|
|
125
|
+
const extEntries = entries.filter(e => e.startsWith("ext:"));
|
|
126
|
+
return {
|
|
127
|
+
builtinToolNames: hasWildcard ? [...new Set([...BUILTIN_TOOL_NAMES, ...plain])] : plain,
|
|
128
|
+
extSelectors: extEntries.length > 0 ? extEntries : undefined,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
110
132
|
/**
|
|
111
133
|
* Parse an optional comma-separated list field.
|
|
112
134
|
* omitted → undefined; "none"/empty → undefined; csv → listed items.
|
package/src/index.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type Exten
|
|
|
16
16
|
import { Container, Key, matchesKey, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
|
|
17
17
|
import { Type } from "@sinclair/typebox";
|
|
18
18
|
import { AgentManager } from "./agent-manager.js";
|
|
19
|
-
import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
|
|
19
|
+
import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
|
|
20
20
|
import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getDefaultAgentNames, getUserAgentNames, registerAgents, resolveType } from "./agent-types.js";
|
|
21
21
|
import { registerRpcHandlers } from "./cross-extension-rpc.js";
|
|
22
22
|
import { loadCustomAgents } from "./custom-agents.js";
|
|
@@ -28,6 +28,7 @@ import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./o
|
|
|
28
28
|
import { SubagentScheduler } from "./schedule.js";
|
|
29
29
|
import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
|
|
30
30
|
import { applyAndEmitLoaded, type SubagentsSettings, saveAndEmitChanged } from "./settings.js";
|
|
31
|
+
import { getStatusNote } from "./status-note.js";
|
|
31
32
|
import { type AgentConfig, type AgentInvocation, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType } from "./types.js";
|
|
32
33
|
import {
|
|
33
34
|
type AgentActivity,
|
|
@@ -118,16 +119,6 @@ function getStatusLabel(status: string, error?: string): string {
|
|
|
118
119
|
}
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
/** Parenthetical status note for completed agent result text. */
|
|
122
|
-
function getStatusNote(status: string): string {
|
|
123
|
-
switch (status) {
|
|
124
|
-
case "aborted": return " (aborted — max turns exceeded, output may be incomplete)";
|
|
125
|
-
case "steered": return " (wrapped up — reached turn limit)";
|
|
126
|
-
case "stopped": return " (stopped by user)";
|
|
127
|
-
default: return "";
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
122
|
/** Escape XML special characters to prevent injection in structured notifications. */
|
|
132
123
|
function escapeXml(s: string): string {
|
|
133
124
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
@@ -154,7 +145,7 @@ function formatTaskNotification(record: AgentRecord, resultMaxLen: number): stri
|
|
|
154
145
|
record.toolCallId ? `<tool-use-id>${escapeXml(record.toolCallId)}</tool-use-id>` : null,
|
|
155
146
|
record.outputFile ? `<output-file>${escapeXml(record.outputFile)}</output-file>` : null,
|
|
156
147
|
`<status>${escapeXml(status)}</status>`,
|
|
157
|
-
`<summary>Agent "${escapeXml(record.description)}" ${record.status}</summary>`,
|
|
148
|
+
`<summary>Agent "${escapeXml(record.description)}" ${record.status}${getStatusNote(record.status)}</summary>`,
|
|
158
149
|
`<result>${escapeXml(resultPreview)}</result>`,
|
|
159
150
|
`<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}<duration_ms>${durationMs}</duration_ms></usage>`,
|
|
160
151
|
`</task-notification>`,
|
|
@@ -651,7 +642,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
651
642
|
: "";
|
|
652
643
|
|
|
653
644
|
pi.registerTool(defineTool({
|
|
654
|
-
name:
|
|
645
|
+
name: SUBAGENT_TOOL_NAMES.AGENT,
|
|
655
646
|
label: "Agent",
|
|
656
647
|
description: `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
|
|
657
648
|
|
|
@@ -772,7 +763,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
772
763
|
return new Text(text, 0, 0);
|
|
773
764
|
}
|
|
774
765
|
|
|
775
|
-
// Helper: build "haiku · thinking: high ·
|
|
766
|
+
// Helper: build "haiku · thinking: high · ↻5≤30 · 3 tool uses · 33.8k tokens" stats string
|
|
776
767
|
const stats = (d: AgentDetails) => {
|
|
777
768
|
const parts: string[] = [];
|
|
778
769
|
if (d.modelName) parts.push(d.modelName);
|
|
@@ -1188,7 +1179,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1188
1179
|
// ---- get_subagent_result tool ----
|
|
1189
1180
|
|
|
1190
1181
|
pi.registerTool(defineTool({
|
|
1191
|
-
name:
|
|
1182
|
+
name: SUBAGENT_TOOL_NAMES.GET_RESULT,
|
|
1192
1183
|
label: "Get Agent Result",
|
|
1193
1184
|
description:
|
|
1194
1185
|
"Check status and retrieve results from a background agent. Use the agent ID returned by Agent with run_in_background.",
|
|
@@ -1236,7 +1227,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1236
1227
|
|
|
1237
1228
|
let output =
|
|
1238
1229
|
`Agent: ${record.id}\n` +
|
|
1239
|
-
`Type: ${displayName} | Status: ${record.status} | ${statsParts.join(" | ")}\n` +
|
|
1230
|
+
`Type: ${displayName} | Status: ${record.status}${getStatusNote(record.status)} | ${statsParts.join(" | ")}\n` +
|
|
1240
1231
|
`Description: ${record.description}\n\n`;
|
|
1241
1232
|
|
|
1242
1233
|
if (record.status === "running") {
|
|
@@ -1268,7 +1259,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1268
1259
|
// ---- steer_subagent tool ----
|
|
1269
1260
|
|
|
1270
1261
|
pi.registerTool(defineTool({
|
|
1271
|
-
name:
|
|
1262
|
+
name: SUBAGENT_TOOL_NAMES.STEER,
|
|
1272
1263
|
label: "Steer Agent",
|
|
1273
1264
|
description:
|
|
1274
1265
|
"Send a steering message to a running agent. The message will interrupt the agent after its current tool execution " +
|
|
@@ -1491,7 +1482,11 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1491
1482
|
|
|
1492
1483
|
await ctx.ui.custom<undefined>(
|
|
1493
1484
|
(tui, theme, _keybindings, done) => {
|
|
1494
|
-
return new ConversationViewer(tui, session, record, activity, theme, done)
|
|
1485
|
+
return new ConversationViewer(tui, session, record, activity, theme, done, () => {
|
|
1486
|
+
if (manager.abort(record.id)) {
|
|
1487
|
+
ctx.ui.notify(`Stopped "${record.description}".`, "info");
|
|
1488
|
+
}
|
|
1489
|
+
});
|
|
1495
1490
|
},
|
|
1496
1491
|
{
|
|
1497
1492
|
overlay: true,
|
package/src/prompts.ts
CHANGED
|
@@ -16,12 +16,15 @@ export interface PromptExtras {
|
|
|
16
16
|
* Build the system prompt for an agent from its config.
|
|
17
17
|
*
|
|
18
18
|
* - "replace" mode: env header + config.systemPrompt (full control, no parent identity)
|
|
19
|
-
* - "append" mode:
|
|
19
|
+
* - "append" mode: parent system prompt + sub-agent context + env header + config.systemPrompt
|
|
20
20
|
* - "append" with empty systemPrompt: pure parent clone
|
|
21
21
|
*
|
|
22
|
-
* Both modes
|
|
22
|
+
* Both modes include an `<active_agent name="${config.name}"/>` tag so downstream
|
|
23
23
|
* extensions (e.g. permission/policy systems) can resolve per-agent policy
|
|
24
|
-
* inside the child session by parsing the system prompt.
|
|
24
|
+
* inside the child session by parsing the system prompt. In replace mode the tag
|
|
25
|
+
* is prepended; in append mode it follows the shared inherited content so the
|
|
26
|
+
* parent prompt forms an identical, cacheable byte prefix with the parent
|
|
27
|
+
* session (the LLM's KV cache can then reuse those tokens across every spawn).
|
|
25
28
|
*
|
|
26
29
|
* @param parentSystemPrompt The parent agent's effective system prompt (for append mode).
|
|
27
30
|
* @param extras Optional extra sections to inject (memory, preloaded skills).
|
|
@@ -72,7 +75,12 @@ You are operating as a sub-agent invoked to handle a specific task.
|
|
|
72
75
|
? `\n\n<agent_instructions>\n${config.systemPrompt}\n</agent_instructions>`
|
|
73
76
|
: "";
|
|
74
77
|
|
|
75
|
-
|
|
78
|
+
// Place shared/stable content first so the LLM's KV cache can reuse the
|
|
79
|
+
// inherited prefix across all subagent invocations. The parent prompt is
|
|
80
|
+
// placed verbatim (no wrapper tag) so it forms an identical byte prefix
|
|
81
|
+
// with the parent session, maximising KV cache hits. The <active_agent>
|
|
82
|
+
// tag and env block vary per call and are placed after the cached prefix.
|
|
83
|
+
return identity + "\n\n" + bridge + "\n\n" + activeAgentTag + envBlock + customSection + extrasSuffix;
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
// "replace" mode — env header + the config's full system prompt
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* status-note.ts — Parenthetical status note appended to agent result text.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Explicit parenthetical note for a non-normal terminal outcome, so the parent
|
|
7
|
+
* agent can't mistake partial output for a completed result. Empty string for a
|
|
8
|
+
* clean completion (and any unknown/non-terminal status).
|
|
9
|
+
*
|
|
10
|
+
* `stopped` (a human aborted it) is deliberately distinct from `aborted` (the
|
|
11
|
+
* turn limit was hit) — the parent should treat human intervention differently
|
|
12
|
+
* from a budget cutoff.
|
|
13
|
+
*/
|
|
14
|
+
export function getStatusNote(status: string): string {
|
|
15
|
+
switch (status) {
|
|
16
|
+
case "stopped":
|
|
17
|
+
return " (STOPPED BY THE USER before completion — output is partial; the task was NOT finished)";
|
|
18
|
+
case "aborted":
|
|
19
|
+
return " (aborted — hit the turn limit before completion; output may be incomplete)";
|
|
20
|
+
case "steered":
|
|
21
|
+
return " (wrapped up at the turn limit — output may be partial)";
|
|
22
|
+
default:
|
|
23
|
+
return "";
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -26,6 +26,9 @@ export interface AgentConfig {
|
|
|
26
26
|
displayName?: string;
|
|
27
27
|
description: string;
|
|
28
28
|
builtinToolNames?: string[];
|
|
29
|
+
/** Raw `ext:` selector entries from the `tools:` CSV, e.g. ["ext:foo", "ext:bar/x"].
|
|
30
|
+
* Presence of any entry flips extension tools to an explicit allowlist. */
|
|
31
|
+
extSelectors?: string[];
|
|
29
32
|
/** Tool denylist — these tools are removed even if `builtinToolNames` or extensions include them. */
|
|
30
33
|
disallowedTools?: string[];
|
|
31
34
|
/** true = inherit all, string[] = only listed, false = none */
|
package/src/ui/agent-widget.ts
CHANGED
|
@@ -100,12 +100,12 @@ export function formatTokens(count: number): string {
|
|
|
100
100
|
/**
|
|
101
101
|
* Token count with optional context-fill % and compaction-count annotations.
|
|
102
102
|
* Thresholds for percent: <70% dim, 70–85% warning, ≥85% error.
|
|
103
|
-
* Compaction count rendered as
|
|
103
|
+
* Compaction count rendered as `⇊N` in dim.
|
|
104
104
|
*
|
|
105
105
|
* "12.3k token" — no annotations
|
|
106
106
|
* "12.3k token (45%)" — percent only
|
|
107
|
-
* "12.3k token (
|
|
108
|
-
* "12.3k token (45% ·
|
|
107
|
+
* "12.3k token (⇊2)" — compactions only (e.g. right after compact)
|
|
108
|
+
* "12.3k token (45% · ⇊2)" — both
|
|
109
109
|
*/
|
|
110
110
|
export function formatSessionTokens(
|
|
111
111
|
tokens: number,
|
|
@@ -120,15 +120,15 @@ export function formatSessionTokens(
|
|
|
120
120
|
annot.push(theme.fg(color, `${Math.round(percent)}%`));
|
|
121
121
|
}
|
|
122
122
|
if (compactions > 0) {
|
|
123
|
-
annot.push(theme.fg("dim",
|
|
123
|
+
annot.push(theme.fg("dim", `⇊${compactions}`));
|
|
124
124
|
}
|
|
125
125
|
if (annot.length === 0) return tokenStr;
|
|
126
126
|
return `${tokenStr} (${annot.join(" · ")})`;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
/** Format turn count with optional max limit: "
|
|
129
|
+
/** Format turn count with optional max limit: "↻5≤30" or "↻5". */
|
|
130
130
|
export function formatTurns(turnCount: number, maxTurns?: number | null): string {
|
|
131
|
-
return maxTurns != null ?
|
|
131
|
+
return maxTurns != null ? `↻${turnCount}≤${maxTurns}` : `↻${turnCount}`;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
/** Format milliseconds as human-readable duration. */
|