pi-subagents-lite 0.4.1 → 1.0.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/README.md +128 -28
- package/package.json +6 -3
- package/src/agent-discovery.ts +11 -17
- package/src/agent-manager.ts +6 -17
- package/src/agent-runner.ts +254 -63
- package/src/agent-types.ts +65 -19
- package/src/config-io.ts +2 -4
- package/src/context.ts +9 -6
- package/src/default-agents.ts +3 -3
- package/src/index.ts +7 -4
- package/src/menus.ts +36 -9
- package/src/model-precedence.ts +4 -2
- package/src/model-selector.ts +1 -7
- package/src/output-file.ts +3 -6
- package/src/prompts.ts +2 -3
- package/src/result-viewer.ts +1 -4
- package/src/skill-loader.ts +0 -2
- package/src/tool-execution.ts +10 -14
- package/src/types.ts +20 -22
- package/src/ui/agent-widget.ts +2 -5
- package/src/utils.ts +4 -5
package/src/agent-runner.ts
CHANGED
|
@@ -1,16 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Core execution engine: creates sessions, runs agents, collects results.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* - Removed buildParentContext import and inheritContext code path
|
|
6
|
-
* - Removed buildMemoryBlock/buildReadOnlyMemoryBlock imports and memory code paths
|
|
7
|
-
* - Replaced import { detectEnv } from env.ts with inline git detection via pi.exec()
|
|
8
|
-
* - Handles `isolated` parameter internally (sets extensions=false, skills=false)
|
|
9
|
-
* - RunOptions: keeps pi: ExtensionAPI, isolated?: boolean. Removes inheritContext, isolation
|
|
10
|
-
* - PromptExtras: removed memoryBlock — keeps skillBlocks[] only
|
|
11
|
-
* - EXCLUDED_TOOL_NAMES prevents sub-subagent spawning
|
|
4
|
+
* EXCLUDED_TOOL_NAMES prevents sub-subagent spawning.
|
|
12
5
|
*/
|
|
13
6
|
|
|
7
|
+
import path from "node:path";
|
|
14
8
|
import type { Model } from "@earendil-works/pi-ai";
|
|
15
9
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
10
|
import {
|
|
@@ -23,20 +17,20 @@ import {
|
|
|
23
17
|
SessionManager,
|
|
24
18
|
SettingsManager,
|
|
25
19
|
} from "@earendil-works/pi-coding-agent";
|
|
26
|
-
import { getAgentConfig, getConfig, getToolNamesForType } from "./agent-types.js";
|
|
20
|
+
import { getAgentConfig, getConfig, getToolNamesForType, BUILTIN_TOOL_NAMES } from "./agent-types.js";
|
|
27
21
|
import { extractText } from "./context.js";
|
|
28
22
|
import type { LifetimeUsage } from "./usage.js";
|
|
29
23
|
import { findModelInRegistry } from "./utils.js";
|
|
30
24
|
import { DEFAULT_AGENTS } from "./default-agents.js";
|
|
31
|
-
import { buildAgentPrompt, type PromptExtras
|
|
32
|
-
import { preloadSkills, loadSkillMeta } from "./skill-loader.js";
|
|
25
|
+
import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
|
|
26
|
+
import { preloadSkills, loadSkillMeta, type SkillMeta } from "./skill-loader.js";
|
|
33
27
|
import { type CompactionInfo, type EnvInfo, SHORT_ID_LENGTH, type SubagentType, type ThinkingLevel } from "./types.js";
|
|
34
28
|
|
|
35
29
|
/** Names of tools registered by this extension that subagents must NOT inherit. */
|
|
36
|
-
|
|
30
|
+
const EXCLUDED_TOOL_NAMES = ["Agent"];
|
|
37
31
|
|
|
38
|
-
/**
|
|
39
|
-
const
|
|
32
|
+
/** Default grace turns when not specified in config. */
|
|
33
|
+
const DEFAULT_GRACE_TURNS = 6;
|
|
40
34
|
|
|
41
35
|
/** Timeout for quick git commands (branch detection, repo check). */
|
|
42
36
|
const GIT_EXEC_TIMEOUT_MS = 5000;
|
|
@@ -61,8 +55,6 @@ interface RunOptions {
|
|
|
61
55
|
model?: Model<any>;
|
|
62
56
|
maxTurns?: number;
|
|
63
57
|
signal?: AbortSignal;
|
|
64
|
-
/** When true, agent gets only built-in tools (no extensions, no skills). */
|
|
65
|
-
isolated?: boolean;
|
|
66
58
|
thinkingLevel?: ThinkingLevel;
|
|
67
59
|
/** Override working directory. */
|
|
68
60
|
cwd?: string;
|
|
@@ -84,6 +76,8 @@ interface RunOptions {
|
|
|
84
76
|
* pre-compaction context size estimate. Aborted compactions don't fire.
|
|
85
77
|
*/
|
|
86
78
|
onCompaction?: (info: CompactionInfo) => void;
|
|
79
|
+
/** Grace turns: extra turns allowed after hitting maxTurns. Defaults to 6. */
|
|
80
|
+
graceTurns?: number;
|
|
87
81
|
}
|
|
88
82
|
|
|
89
83
|
interface RunResult {
|
|
@@ -187,36 +181,166 @@ export function subscribeToSessionEvents(
|
|
|
187
181
|
}
|
|
188
182
|
|
|
189
183
|
/**
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
184
|
+
* Extract the extension name from an extension's file path.
|
|
185
|
+
*
|
|
186
|
+
* Handles all distribution methods:
|
|
187
|
+
* - git packages: `.../git/github.com/<user>/<pkg>/...` → "<pkg>"
|
|
188
|
+
* - npm packages: `.../node_modules/[...]pkg/...` → "pkg"
|
|
189
|
+
* - local extensions: `~/.pi/agent/extensions/<name>/...` → "<name>"
|
|
190
|
+
* - direct files: `extensions/<name>.ts` → "<name>"
|
|
191
|
+
*
|
|
192
|
+
* Does NOT depend on internal directory structure (dist/, lib/, src/, etc).
|
|
193
|
+
* Only cares about the package root, which is determined by distribution method.
|
|
194
|
+
*/
|
|
195
|
+
function extractExtensionName(extPath: string): string {
|
|
196
|
+
const parts = extPath.split(path.sep);
|
|
197
|
+
|
|
198
|
+
// 1. Git package: .../git/github.com/<user>/<pkg>/...
|
|
199
|
+
// Package name is 3 dirs after 'git' (github.com/user/pkg)
|
|
200
|
+
const gitIdx = parts.indexOf("git");
|
|
201
|
+
if (gitIdx !== -1 && gitIdx + 3 < parts.length) {
|
|
202
|
+
return parts[gitIdx + 3];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 2. npm package: .../node_modules/[...]pkg/...
|
|
206
|
+
const nmIdx = parts.lastIndexOf("node_modules");
|
|
207
|
+
if (nmIdx !== -1 && nmIdx + 1 < parts.length) {
|
|
208
|
+
const next = parts[nmIdx + 1];
|
|
209
|
+
if (next.startsWith("@") && nmIdx + 2 < parts.length) {
|
|
210
|
+
return parts[nmIdx + 2]; // @scope/pkg → pkg
|
|
211
|
+
}
|
|
212
|
+
return next;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 3. Local extension: .../extensions/<name>/... or .../extensions/<name>.ts
|
|
216
|
+
const extIdx = parts.lastIndexOf("extensions");
|
|
217
|
+
if (extIdx !== -1 && extIdx + 1 < parts.length) {
|
|
218
|
+
const afterExt = parts[extIdx + 1];
|
|
219
|
+
// Subdirectory: extensions/tavily/index.ts → tavily
|
|
220
|
+
if (afterExt && !afterExt.includes(".")) {
|
|
221
|
+
return afterExt;
|
|
222
|
+
}
|
|
223
|
+
// Direct file: extensions/review.ts → review
|
|
224
|
+
const file = parts[parts.length - 1];
|
|
225
|
+
return path.basename(file, path.extname(file));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Fallback: parent dir name
|
|
229
|
+
return path.basename(path.dirname(extPath));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Resolve tool entries (with ext/* syntax) into concrete tool names.
|
|
234
|
+
* Returns a set of resolved tool names.
|
|
235
|
+
*/
|
|
236
|
+
function resolveToolEntries(
|
|
237
|
+
entries: string[],
|
|
238
|
+
extToolMap: Map<string, string[]> | undefined,
|
|
239
|
+
notify?: (msg: string) => void,
|
|
240
|
+
): Set<string> {
|
|
241
|
+
const resolved = new Set<string>();
|
|
242
|
+
|
|
243
|
+
for (const entry of entries) {
|
|
244
|
+
const slashIdx = entry.indexOf("/");
|
|
245
|
+
if (slashIdx !== -1) {
|
|
246
|
+
// ext/* or ext/tool syntax
|
|
247
|
+
const extName = entry.slice(0, slashIdx);
|
|
248
|
+
const toolPart = entry.slice(slashIdx + 1);
|
|
249
|
+
if (toolPart === "*") {
|
|
250
|
+
const extTools = extToolMap?.get(extName);
|
|
251
|
+
if (extTools && extTools.length > 0) {
|
|
252
|
+
for (const t of extTools) resolved.add(t);
|
|
253
|
+
} else {
|
|
254
|
+
notify?.(`extension "${extName}" is not loaded, "${entry}" will have no effect`);
|
|
255
|
+
}
|
|
256
|
+
} else {
|
|
257
|
+
// ext/tool syntax: e.g. "tavily/web_search"
|
|
258
|
+
resolved.add(toolPart);
|
|
259
|
+
}
|
|
260
|
+
} else {
|
|
261
|
+
// Bare tool name
|
|
262
|
+
resolved.add(entry);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return resolved;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Filter active tools: apply tools allowlist/denylist and EXCLUDED_TOOL_NAMES.
|
|
271
|
+
*
|
|
272
|
+
* The `tools` config controls which tool schemas the LLM sees (built-in + extension).
|
|
273
|
+
* The `extensions` config controls which extensions are loaded (hooks + commands).
|
|
274
|
+
* `extensions` does NOT affect tool visibility — that's `tools`'s job.
|
|
275
|
+
*
|
|
276
|
+
* Supports ext/* syntax for both whitelist and blacklist modes.
|
|
277
|
+
*
|
|
278
|
+
* `tools` and `excludeTools` are mutually exclusive. If both set, `tools` wins.
|
|
279
|
+
*
|
|
280
|
+
* Returns null when no filtering is needed, otherwise the filtered tool list.
|
|
193
281
|
*/
|
|
194
282
|
function filterActiveTools(
|
|
195
283
|
activeTools: string[],
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
284
|
+
extToolMap: Map<string, string[]> | undefined,
|
|
285
|
+
tools: true | string[] | false | undefined,
|
|
286
|
+
excludeTools: string[] | undefined,
|
|
287
|
+
notify?: (msg: string) => void,
|
|
199
288
|
): string[] | null {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
289
|
+
// Blacklist mode: excludeTools set and tools not set as whitelist
|
|
290
|
+
if (excludeTools && !Array.isArray(tools)) {
|
|
291
|
+
const excludeSet = resolveToolEntries(excludeTools, extToolMap, notify);
|
|
292
|
+
const filtered = activeTools.filter(t =>
|
|
293
|
+
!EXCLUDED_TOOL_NAMES.includes(t) && !excludeSet.has(t)
|
|
294
|
+
);
|
|
206
295
|
return filtered.length !== activeTools.length ? filtered : null;
|
|
207
296
|
}
|
|
208
297
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
298
|
+
if (Array.isArray(tools)) {
|
|
299
|
+
// Whitelist mode: resolve entries with ext/* expansion
|
|
300
|
+
const allBuiltinSet = new Set(BUILTIN_TOOL_NAMES);
|
|
301
|
+
const allowedTools = resolveToolEntries(tools, extToolMap, notify);
|
|
302
|
+
|
|
303
|
+
// Warn about unknown entries
|
|
304
|
+
for (const entry of tools) {
|
|
305
|
+
const slashIdx = entry.indexOf("/");
|
|
306
|
+
if (slashIdx === -1 && !allBuiltinSet.has(entry)) {
|
|
307
|
+
// Bare name, not a known built-in — check if it's an extension tool
|
|
308
|
+
const toolExts = extToolMap ? [...extToolMap.entries()].filter(([, tools]) => tools.includes(entry)) : [];
|
|
309
|
+
if (toolExts.length === 0) {
|
|
310
|
+
notify?.(`tool "${entry}" not found in any loaded extension`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
216
313
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
314
|
+
|
|
315
|
+
const visibleSet = new Set<string>();
|
|
316
|
+
for (const t of activeTools) {
|
|
317
|
+
if (EXCLUDED_TOOL_NAMES.includes(t)) continue;
|
|
318
|
+
if (allowedTools.has(t)) {
|
|
319
|
+
visibleSet.add(t);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Warn if a loaded extension has none of its tools in `tools`
|
|
324
|
+
if (extToolMap) {
|
|
325
|
+
for (const [extName, extTools] of extToolMap) {
|
|
326
|
+
const hasAny = extTools.some(t => allowedTools.has(t));
|
|
327
|
+
if (!hasAny) {
|
|
328
|
+
notify?.(`extension "${extName}" is loaded but none of its tools are in tools: [${tools.join(", ")}]`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return [...visibleSet];
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (tools === false) {
|
|
337
|
+
return [];
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// tools: true or undefined — all tools visible (except excluded)
|
|
341
|
+
const hasExcluded = activeTools.some(t => EXCLUDED_TOOL_NAMES.includes(t));
|
|
342
|
+
if (!hasExcluded) return null;
|
|
343
|
+
return activeTools.filter(t => !EXCLUDED_TOOL_NAMES.includes(t));
|
|
220
344
|
}
|
|
221
345
|
|
|
222
346
|
/** Run a git command via pi.exec, returning stdout on success or null on failure. */
|
|
@@ -254,21 +378,32 @@ export async function runAgent(
|
|
|
254
378
|
const config = getConfig(type);
|
|
255
379
|
const agentConfig = getAgentConfig(type);
|
|
256
380
|
|
|
381
|
+
// Warn on mutual exclusion violations
|
|
382
|
+
const notify = (msg: string) => {
|
|
383
|
+
if (ctx.ui?.notify) {
|
|
384
|
+
ctx.ui.notify(`[pi-subagents] ${msg}`, "warning");
|
|
385
|
+
} else {
|
|
386
|
+
console.warn(`[pi-subagents] ${msg}`);
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
if (agentConfig?.excludeTools && Array.isArray(agentConfig.tools)) {
|
|
390
|
+
notify(`agent "${type}": both tools and exclude_tools set — tools (whitelist) wins`);
|
|
391
|
+
}
|
|
392
|
+
if (agentConfig?.excludeExtensions && Array.isArray(agentConfig.extensions)) {
|
|
393
|
+
notify(`agent "${type}": both extensions and exclude_extensions set — extensions (whitelist) wins`);
|
|
394
|
+
}
|
|
395
|
+
|
|
257
396
|
// Resolve working directory
|
|
258
397
|
const effectiveCwd = options.cwd ?? ctx.cwd;
|
|
259
398
|
|
|
260
399
|
const env = await detectEnv(options.pi, effectiveCwd);
|
|
261
400
|
|
|
262
|
-
// Resolve extensions/skills
|
|
263
|
-
|
|
264
|
-
const
|
|
265
|
-
const
|
|
266
|
-
const skills = effectiveIsolated ? false : config.skills;
|
|
267
|
-
const preloadSkillsList = effectiveIsolated ? false : agentConfig?.preloadSkills;
|
|
401
|
+
// Resolve extensions/skills from agent config (frontmatter)
|
|
402
|
+
const extensions = config.extensions;
|
|
403
|
+
const skills = config.skills;
|
|
404
|
+
const preloadSkillsList = agentConfig?.preloadSkills;
|
|
268
405
|
|
|
269
|
-
// Build prompt extras (
|
|
270
|
-
// - preloadSkills: force full content into system prompt
|
|
271
|
-
// - skills: metadata only (whitelist), agent reads on-demand
|
|
406
|
+
// Build prompt extras (skills only).
|
|
272
407
|
const extras: PromptExtras = {};
|
|
273
408
|
if (Array.isArray(preloadSkillsList)) {
|
|
274
409
|
extras.skillBlocks = preloadSkills(preloadSkillsList, effectiveCwd);
|
|
@@ -299,7 +434,10 @@ export async function runAgent(
|
|
|
299
434
|
const agentDir = getAgentDir();
|
|
300
435
|
|
|
301
436
|
// Load extensions/skills: true or string[] → load; false → don't.
|
|
302
|
-
|
|
437
|
+
// When extensions is an array, use extensionsOverride to selectively load
|
|
438
|
+
// only the listed extensions (hooks/commands of excluded ones never fire).
|
|
439
|
+
// When excludeExtensions is set (and extensions is not string[]), filter out those extensions.
|
|
440
|
+
const loaderOpts: ConstructorParameters<typeof DefaultResourceLoader>[0] = {
|
|
303
441
|
cwd: effectiveCwd,
|
|
304
442
|
agentDir,
|
|
305
443
|
noExtensions: extensions === false,
|
|
@@ -309,9 +447,44 @@ export async function runAgent(
|
|
|
309
447
|
noContextFiles: true,
|
|
310
448
|
systemPromptOverride: () => systemPrompt,
|
|
311
449
|
appendSystemPromptOverride: () => [],
|
|
312
|
-
}
|
|
450
|
+
};
|
|
451
|
+
const excludeExtSet = agentConfig?.excludeExtensions
|
|
452
|
+
? new Set(agentConfig.excludeExtensions)
|
|
453
|
+
: undefined;
|
|
454
|
+
if (Array.isArray(extensions)) {
|
|
455
|
+
// Whitelist mode: only load listed extensions
|
|
456
|
+
const allowedNames = new Set(extensions.map(ext => {
|
|
457
|
+
const slashIdx = ext.indexOf("/");
|
|
458
|
+
return slashIdx !== -1 ? ext.slice(0, slashIdx) : ext;
|
|
459
|
+
}));
|
|
460
|
+
loaderOpts.extensionsOverride = (result) => ({
|
|
461
|
+
...result,
|
|
462
|
+
extensions: result.extensions.filter(ext =>
|
|
463
|
+
allowedNames.has(extractExtensionName(ext.path)),
|
|
464
|
+
),
|
|
465
|
+
});
|
|
466
|
+
} else if (excludeExtSet) {
|
|
467
|
+
// Blacklist mode: load all except excluded extensions
|
|
468
|
+
loaderOpts.extensionsOverride = (result) => ({
|
|
469
|
+
...result,
|
|
470
|
+
extensions: result.extensions.filter(ext =>
|
|
471
|
+
!excludeExtSet.has(extractExtensionName(ext.path)),
|
|
472
|
+
),
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
const loader = new DefaultResourceLoader(loaderOpts);
|
|
313
476
|
await loader.reload();
|
|
314
477
|
|
|
478
|
+
// Build extension name → tool names map from loaded extensions.
|
|
479
|
+
// Used by filterActiveTools to resolve extension names in the extensions frontmatter field.
|
|
480
|
+
const extResult = loader.getExtensions();
|
|
481
|
+
const extToolMap = new Map<string, string[]>();
|
|
482
|
+
for (const ext of extResult.extensions) {
|
|
483
|
+
const name = extractExtensionName(ext.path);
|
|
484
|
+
const tools = [...ext.tools.keys()];
|
|
485
|
+
if (tools.length > 0) extToolMap.set(name, tools);
|
|
486
|
+
}
|
|
487
|
+
|
|
315
488
|
// Resolve model: explicit option > config.model > parent model
|
|
316
489
|
const model = options.model ?? findModelInRegistry(
|
|
317
490
|
agentConfig?.model, ctx.modelRegistry, ctx.model,
|
|
@@ -341,19 +514,9 @@ export async function runAgent(
|
|
|
341
514
|
options.agentId ? `${baseSessionName}#${options.agentId.slice(0, SHORT_ID_LENGTH)}` : baseSessionName,
|
|
342
515
|
);
|
|
343
516
|
|
|
344
|
-
// Filter active tools: remove our own tools to prevent nesting,
|
|
345
|
-
// apply extension allowlist if specified, and apply disallowedTools denylist
|
|
346
|
-
const filteredTools = filterActiveTools(
|
|
347
|
-
session.getActiveToolNames(),
|
|
348
|
-
toolNames,
|
|
349
|
-
extensions,
|
|
350
|
-
agentConfig?.disallowedTools,
|
|
351
|
-
);
|
|
352
|
-
if (filteredTools) {
|
|
353
|
-
session.setActiveToolsByName(filteredTools);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
517
|
// Bind extensions so that session_start fires and extensions can initialize
|
|
518
|
+
// This must happen BEFORE tool filtering — extensions like pi-mcp-adapter
|
|
519
|
+
// register tools lazily during session_start, not at extension load time.
|
|
357
520
|
await session.bindExtensions({
|
|
358
521
|
onError: (err) => {
|
|
359
522
|
options.onToolActivity?.({
|
|
@@ -363,6 +526,33 @@ export async function runAgent(
|
|
|
363
526
|
},
|
|
364
527
|
});
|
|
365
528
|
|
|
529
|
+
// Rebuild extToolMap after session_start — extensions may have registered
|
|
530
|
+
// new tools (e.g., pi-mcp-adapter registers 'mcp' tool at session_start).
|
|
531
|
+
const postBindExtToolMap = new Map<string, string[]>();
|
|
532
|
+
for (const ext of extResult.extensions) {
|
|
533
|
+
const name = extractExtensionName(ext.path);
|
|
534
|
+
const tools = [...ext.tools.keys()];
|
|
535
|
+
if (tools.length > 0) postBindExtToolMap.set(name, tools);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Filter active tools: apply tools allowlist/denylist and EXCLUDED_TOOL_NAMES
|
|
539
|
+
const filteredTools = filterActiveTools(
|
|
540
|
+
session.getActiveToolNames(),
|
|
541
|
+
postBindExtToolMap,
|
|
542
|
+
agentConfig?.tools,
|
|
543
|
+
agentConfig?.excludeTools,
|
|
544
|
+
(msg) => {
|
|
545
|
+
if (ctx.ui?.notify) {
|
|
546
|
+
ctx.ui.notify(`[pi-subagents] ${msg}`, "warning");
|
|
547
|
+
} else {
|
|
548
|
+
console.warn(`[pi-subagents] ${msg}`);
|
|
549
|
+
}
|
|
550
|
+
},
|
|
551
|
+
);
|
|
552
|
+
if (filteredTools) {
|
|
553
|
+
session.setActiveToolsByName(filteredTools);
|
|
554
|
+
}
|
|
555
|
+
|
|
366
556
|
options.onSessionCreated?.(session);
|
|
367
557
|
|
|
368
558
|
// Track turns for graceful max_turns enforcement
|
|
@@ -370,6 +560,7 @@ export async function runAgent(
|
|
|
370
560
|
const maxTurns = normalizeMaxTurns(options.maxTurns ?? agentConfig?.maxTurns);
|
|
371
561
|
let softLimitReached = false;
|
|
372
562
|
let aborted = false;
|
|
563
|
+
const graceTurns = options.graceTurns ?? DEFAULT_GRACE_TURNS;
|
|
373
564
|
|
|
374
565
|
const unsubEvents = subscribeToSessionEvents(session, options);
|
|
375
566
|
|
|
@@ -381,7 +572,7 @@ export async function runAgent(
|
|
|
381
572
|
if (!softLimitReached && turnCount >= maxTurns) {
|
|
382
573
|
softLimitReached = true;
|
|
383
574
|
session.steer("You have reached your turn limit. Wrap up immediately — provide your final answer now.");
|
|
384
|
-
} else if (softLimitReached && turnCount >= maxTurns +
|
|
575
|
+
} else if (softLimitReached && turnCount >= maxTurns + graceTurns) {
|
|
385
576
|
aborted = true;
|
|
386
577
|
session.abort();
|
|
387
578
|
}
|
package/src/agent-types.ts
CHANGED
|
@@ -3,24 +3,36 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Merges embedded default agents with user-defined agents from .pi/agents/*.md.
|
|
5
5
|
* User agents override defaults with the same name. Disabled agents are kept but excluded from spawning.
|
|
6
|
-
*
|
|
7
|
-
* Trimmed from upstream: removed getMemoryToolNames(), getReadOnlyMemoryToolNames(),
|
|
8
|
-
* MEMORY_TOOL_NAMES, READONLY_MEMORY_TOOL_NAMES (memory feature cut).
|
|
9
6
|
*/
|
|
10
7
|
|
|
8
|
+
import { scanAgentFilesInDir, mergeAgents } from "./agent-discovery.js";
|
|
11
9
|
import { DEFAULT_AGENTS } from "./default-agents.js";
|
|
12
10
|
import type { AgentConfig } from "./types.js";
|
|
13
11
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
12
|
+
/**
|
|
13
|
+
* All tool names that Pi can provide to a session.
|
|
14
|
+
*
|
|
15
|
+
* Note: only `read`, `bash`, `edit`, `write` are active by default.
|
|
16
|
+
* `grep` must be explicitly activated via setActiveToolsByName().
|
|
17
|
+
* `find` and `ls` were removed — they're thin wrappers over bash commands
|
|
18
|
+
* that add ~180 tokens/turn with no real benefit.
|
|
19
|
+
*/
|
|
20
|
+
export const BUILTIN_TOOL_NAMES: string[] = ["read", "bash", "edit", "write", "grep"];
|
|
16
21
|
|
|
17
22
|
/** Unified runtime registry of all agents (defaults + user-defined). */
|
|
18
23
|
const agents = new Map<string, AgentConfig>();
|
|
19
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Directories to scan for agent .md files at startup and on-demand.
|
|
27
|
+
* Set by setAgentScanDirs() during session_start.
|
|
28
|
+
*/
|
|
29
|
+
let userAgentDir = "";
|
|
30
|
+
let projectAgentDir = "";
|
|
31
|
+
|
|
20
32
|
/**
|
|
21
33
|
* Register agents into the unified registry.
|
|
22
34
|
* Starts with DEFAULT_AGENTS, then overlays user agents (overrides defaults with same name).
|
|
23
|
-
*
|
|
35
|
+
* Hidden agents (hidden === true) are kept in the registry but excluded from spawning.
|
|
24
36
|
*/
|
|
25
37
|
export function registerAgents(userAgents: Map<string, AgentConfig>): void {
|
|
26
38
|
agents.clear();
|
|
@@ -36,6 +48,37 @@ export function registerAgents(userAgents: Map<string, AgentConfig>): void {
|
|
|
36
48
|
}
|
|
37
49
|
}
|
|
38
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Set the agent scan directories for on-demand discovery.
|
|
53
|
+
* Called during session_start alongside scanAndRegisterAgents.
|
|
54
|
+
*/
|
|
55
|
+
export function setAgentScanDirs(userDir: string, projectDir: string): void {
|
|
56
|
+
userAgentDir = userDir;
|
|
57
|
+
projectAgentDir = projectDir;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Scan the known agent directories and register any newly discovered agents
|
|
62
|
+
* that aren't already in the registry. Returns the number of new agents added.
|
|
63
|
+
*/
|
|
64
|
+
export async function discoverNewAgents(): Promise<number> {
|
|
65
|
+
const [userAgents, projectAgents] = await Promise.all([
|
|
66
|
+
scanAgentFilesInDir(userAgentDir, "user"),
|
|
67
|
+
scanAgentFilesInDir(projectAgentDir, "project"),
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
const merged = mergeAgents(DEFAULT_AGENTS, userAgents, projectAgents);
|
|
71
|
+
|
|
72
|
+
let count = 0;
|
|
73
|
+
for (const [name, config] of merged) {
|
|
74
|
+
if (!agents.has(name)) {
|
|
75
|
+
agents.set(name, config);
|
|
76
|
+
count++;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return count;
|
|
80
|
+
}
|
|
81
|
+
|
|
39
82
|
/** Resolve a type name case-insensitively. Also matches displayName. Returns the canonical key or undefined. */
|
|
40
83
|
export function resolveType(name: string): string | undefined {
|
|
41
84
|
if (!name) return undefined;
|
|
@@ -54,14 +97,14 @@ export function getAgentConfig(name: string): AgentConfig | undefined {
|
|
|
54
97
|
return key ? agents.get(key) : undefined;
|
|
55
98
|
}
|
|
56
99
|
|
|
57
|
-
/** Get all
|
|
100
|
+
/** Get all visible type names (for spawning and tool descriptions). */
|
|
58
101
|
export function getAvailableTypes(): string[] {
|
|
59
102
|
return [...agents.entries()]
|
|
60
|
-
.filter(([_, config]) => config.
|
|
103
|
+
.filter(([_, config]) => config.hidden !== true)
|
|
61
104
|
.map(([name]) => name);
|
|
62
105
|
}
|
|
63
106
|
|
|
64
|
-
/** Get all type names including
|
|
107
|
+
/** Get all type names including hidden (for UI listing). */
|
|
65
108
|
export function getAllTypes(): string[] {
|
|
66
109
|
return [...agents.keys()];
|
|
67
110
|
}
|
|
@@ -69,16 +112,18 @@ export function getAllTypes(): string[] {
|
|
|
69
112
|
/** Get built-in tool names for a type (case-insensitive). */
|
|
70
113
|
export function getToolNamesForType(type: string): string[] {
|
|
71
114
|
const config = getAgentConfig(type);
|
|
72
|
-
return config?.
|
|
73
|
-
? config.
|
|
115
|
+
return config?.registeredTools?.length
|
|
116
|
+
? config.registeredTools
|
|
74
117
|
: [...BUILTIN_TOOL_NAMES];
|
|
75
118
|
}
|
|
76
119
|
|
|
77
120
|
/** Resolved config shape returned by getConfig. */
|
|
78
|
-
interface ResolvedAgentConfig {
|
|
121
|
+
export interface ResolvedAgentConfig {
|
|
79
122
|
displayName: string;
|
|
80
123
|
description: string;
|
|
81
|
-
|
|
124
|
+
registeredTools: string[];
|
|
125
|
+
/** Controls tool schema visibility. true = all, string[] = listed, false = none. */
|
|
126
|
+
tools?: true | string[] | false;
|
|
82
127
|
extensions: true | string[] | false;
|
|
83
128
|
skills: true | string[] | false;
|
|
84
129
|
}
|
|
@@ -87,7 +132,8 @@ function toResolved(config: AgentConfig): ResolvedAgentConfig {
|
|
|
87
132
|
return {
|
|
88
133
|
displayName: config.displayName ?? config.name,
|
|
89
134
|
description: config.description,
|
|
90
|
-
|
|
135
|
+
registeredTools: config.registeredTools ?? BUILTIN_TOOL_NAMES,
|
|
136
|
+
tools: config.tools,
|
|
91
137
|
extensions: config.extensions,
|
|
92
138
|
skills: config.skills,
|
|
93
139
|
};
|
|
@@ -98,20 +144,20 @@ export function getConfig(type: string): ResolvedAgentConfig {
|
|
|
98
144
|
const resolvedKey = resolveType(type);
|
|
99
145
|
const config = resolvedKey ? agents.get(resolvedKey) : undefined;
|
|
100
146
|
|
|
101
|
-
// If config exists and is
|
|
102
|
-
const activeConfig = config?.
|
|
147
|
+
// If config exists and is not hidden, use it; otherwise fall back to general-purpose
|
|
148
|
+
const activeConfig = config?.hidden !== true
|
|
103
149
|
? config
|
|
104
150
|
: agents.get("general-purpose");
|
|
105
151
|
|
|
106
|
-
if (activeConfig && activeConfig.
|
|
152
|
+
if (activeConfig && activeConfig.hidden !== true) {
|
|
107
153
|
return toResolved(activeConfig);
|
|
108
154
|
}
|
|
109
155
|
|
|
110
|
-
// Absolute fallback — general-purpose was
|
|
156
|
+
// Absolute fallback — general-purpose was hidden or missing
|
|
111
157
|
return {
|
|
112
158
|
displayName: "Agent",
|
|
113
159
|
description: "General-purpose agent for complex, multi-step tasks",
|
|
114
|
-
|
|
160
|
+
registeredTools: BUILTIN_TOOL_NAMES,
|
|
115
161
|
extensions: true,
|
|
116
162
|
skills: true,
|
|
117
163
|
};
|
package/src/config-io.ts
CHANGED
|
@@ -14,7 +14,7 @@ const CONFIG_PATH = path.join(CONFIG_DIR, "subagents-lite.json");
|
|
|
14
14
|
|
|
15
15
|
/** Default configuration — used when config file doesn't exist or is invalid. */
|
|
16
16
|
export const DEFAULT_CONFIG: SubagentsConfig = {
|
|
17
|
-
agent: { default: null, forceBackground: false },
|
|
17
|
+
agent: { default: null, forceBackground: false, graceTurns: 6 },
|
|
18
18
|
concurrency: { default: 4 },
|
|
19
19
|
};
|
|
20
20
|
|
|
@@ -24,10 +24,8 @@ export function loadConfig(): SubagentsConfig {
|
|
|
24
24
|
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
|
|
25
25
|
return JSON.parse(raw) as SubagentsConfig;
|
|
26
26
|
} catch {
|
|
27
|
-
|
|
27
|
+
return { ...DEFAULT_CONFIG, agent: { ...DEFAULT_CONFIG.agent }, concurrency: { ...DEFAULT_CONFIG.concurrency } };
|
|
28
28
|
}
|
|
29
|
-
|
|
30
|
-
return { ...DEFAULT_CONFIG, agent: { ...DEFAULT_CONFIG.agent }, concurrency: { ...DEFAULT_CONFIG.concurrency } };
|
|
31
29
|
}
|
|
32
30
|
|
|
33
31
|
/** Write config to disk with atomic rename. */
|
package/src/context.ts
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* context.ts —
|
|
2
|
+
* context.ts — Message content extraction and conversation snapshot formatting.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* when inherit_context was cut.
|
|
4
|
+
* extractText: pull text from message content blocks.
|
|
5
|
+
* buildSnapshotMarkdown: format agent conversation as markdown for snapshot viewer.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
8
|
import { summarizeToolArgs } from "./output-file.js";
|
|
10
9
|
|
|
10
|
+
function isTextBlock(c: unknown): c is { type: "text"; text: string } {
|
|
11
|
+
return typeof c === "object" && c !== null && (c as Record<string, unknown>).type === "text";
|
|
12
|
+
}
|
|
13
|
+
|
|
11
14
|
/** Extract text from a message content block array. */
|
|
12
15
|
export function extractText(content: unknown[]): string {
|
|
13
16
|
return content
|
|
14
|
-
.filter(
|
|
15
|
-
.map((c
|
|
17
|
+
.filter(isTextBlock)
|
|
18
|
+
.map((c) => c.text)
|
|
16
19
|
.join("\n");
|
|
17
20
|
}
|
|
18
21
|
|
package/src/default-agents.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { AgentConfig } from "./types.js";
|
|
9
9
|
|
|
10
|
-
const READ_ONLY_TOOLS = ["read", "bash", "grep"
|
|
10
|
+
const READ_ONLY_TOOLS = ["read", "bash", "grep"];
|
|
11
11
|
|
|
12
12
|
export const DEFAULT_AGENTS: Map<string, AgentConfig> = new Map([
|
|
13
13
|
[
|
|
@@ -16,7 +16,7 @@ export const DEFAULT_AGENTS: Map<string, AgentConfig> = new Map([
|
|
|
16
16
|
name: "general-purpose",
|
|
17
17
|
displayName: "Agent",
|
|
18
18
|
description: "General-purpose agent for complex, multi-step tasks",
|
|
19
|
-
//
|
|
19
|
+
// registeredTools omitted — means "all available tools" (resolved at lookup time)
|
|
20
20
|
extensions: true,
|
|
21
21
|
skills: true,
|
|
22
22
|
systemPrompt: "",
|
|
@@ -29,7 +29,7 @@ export const DEFAULT_AGENTS: Map<string, AgentConfig> = new Map([
|
|
|
29
29
|
name: "Explore",
|
|
30
30
|
displayName: "Explore",
|
|
31
31
|
description: "Fast codebase exploration agent (read-only)",
|
|
32
|
-
|
|
32
|
+
registeredTools: READ_ONLY_TOOLS,
|
|
33
33
|
extensions: true,
|
|
34
34
|
skills: true,
|
|
35
35
|
model: "anthropic/claude-haiku-4-5-20251001",
|