@tintinweb/pi-subagents 0.10.1 → 0.10.2
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 +9 -0
- package/README.md +21 -1
- package/dist/agent-runner.js +54 -10
- package/dist/agent-types.d.ts +1 -0
- package/dist/agent-types.js +2 -0
- package/dist/custom-agents.js +1 -0
- package/dist/index.js +104 -7
- package/dist/settings.d.ts +13 -0
- package/dist/settings.js +6 -0
- package/dist/types.d.ts +3 -0
- package/dist/ui/conversation-viewer.d.ts +5 -1
- package/dist/ui/conversation-viewer.js +10 -5
- package/dist/ui/viewer-keys.d.ts +20 -0
- package/dist/ui/viewer-keys.js +17 -0
- package/examples/agent-tool-description.md +42 -0
- package/package.json +1 -1
- package/src/agent-runner.ts +53 -10
- package/src/agent-types.ts +3 -0
- package/src/custom-agents.ts +1 -0
- package/src/index.ts +106 -8
- package/src/settings.ts +19 -0
- package/src/types.ts +3 -0
- package/src/ui/conversation-viewer.ts +9 -4
- package/src/ui/viewer-keys.ts +39 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.10.2] - 2026-06-10
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`exclude_extensions:` agent frontmatter — extension denylist for subagents** ([#94](https://github.com/tintinweb/pi-subagents/issues/94) — thanks [@ramhaidar](https://github.com/ramhaidar)). Applied after the `extensions:` include set; exclude wins, including over `tools: ext:` selectors (an excluded extension never loads, so its `ext:` reference becomes the usual orphan warning). The key use case: `extensions: true` + `exclude_extensions: pi-notify` — all extensions except a noisy one, without hand-maintaining an allowlist. Plain canonical names only (case-insensitive); paths, `*`, and unmatched names fire `extension-error:…` warnings (warn-not-abort, as with `extensions:` mismatches); `extensions: false` + an exclude warns that the exclude has no effect. **Not a sandbox:** excluded extensions' factory code still executes once during loading — exclusion suppresses handler binding and tool registration, not load-time side effects. The negation syntax `extensions: ["*", "!name"]` was deliberately rejected: an unquoted `!name` is a YAML tag and silently mis-parses.
|
|
14
|
+
- **`toolDescriptionMode` setting — opt-in compact Agent tool description** ([#91](https://github.com/tintinweb/pi-subagents/issues/91) — thanks [@tiberiuichim](https://github.com/tiberiuichim)). The full Claude Code-style description costs ~1,400 tokens with the default agents and grows with each custom agent (the type list embeds full agent descriptions) — significant for small/local models. `toolDescriptionMode: "compact"` (via `/agents → Settings → Tool description` or `subagents.json`) swaps in a ~75% smaller description: one-line type list (first sentence of each agent description), terse usage notes, per-option details left to the parameter descriptions. Default `"full"` is byte-identical to before — the rich description's guardrails are deliberately load-bearing and stay the default. A third mode, `"custom"`, registers a user-authored description from `<cwd>/.pi/agent-tool-description.md` (project) or `<agentDir>/agent-tool-description.md` (global; project wins), with `{{placeholder}}` substitution keeping the dynamic parts live — `{{typeList}}`, `{{compactTypeList}}`, `{{agentDir}}`, `{{scheduleGuideline}}` — so a hand-written description can't drift out of sync with the registered agents (the advertised-vs-spawnable staleness [#92](https://github.com/tintinweb/pi-subagents/issues/92) just fixed). Unknown placeholders are left verbatim with a stderr warning; a missing/empty file falls back to `"full"`. Only the prose is customizable — the parameter schema stays code-owned. A ready-made starting point ships at `examples/agent-tool-description.md`, reproducing the full description exactly (CI-enforced byte-identical, so the example can't go stale). Like `schedulingEnabled`, the mode is read at tool registration — changing it applies on the next pi session. The issue's original ask (move the description to a skill) isn't possible in pi: tools must register their description in the tool schema for the model to call them; skills are lazily-loaded instructions, not tool registrations.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- **Conversation viewer honors custom `tui.select.*` keybindings** ([#99](https://github.com/tintinweb/pi-subagents/issues/99) — thanks [@owenniles](https://github.com/owenniles)). The viewer hardcoded its scroll keys and discarded the `KeybindingsManager` pi injects into `ctx.ui.custom()`, so user bindings (e.g. emacs-style `ctrl+p`/`ctrl+n` on `tui.select.up`/`down`) worked in pi core selectors but not here. Scrolling now resolves through `tui.select.up`/`down`/`pageUp`/`pageDown`; the viewer-specific `k`/`j` and `shift+arrow` aliases still work alongside, and behavior without custom bindings is unchanged (the `tui.select.*` defaults are the previously hardcoded keys).
|
|
18
|
+
|
|
10
19
|
## [0.10.1] - 2026-06-10
|
|
11
20
|
|
|
12
21
|
### Added
|
package/README.md
CHANGED
|
@@ -196,6 +196,7 @@ All fields are optional — sensible defaults for everything.
|
|
|
196
196
|
| `display_name` | — | Display name for UI (e.g. widget, agent list) |
|
|
197
197
|
| `tools` | all 7 | Which tools the agent can call. Built-in names (`read, grep, …`), `*` / `all` (all built-ins), `none`, and `ext:<extension>` / `ext:<extension>/<tool>` selectors for extension tools. See [Tool & extension scoping](#tool--extension-scoping) below |
|
|
198
198
|
| `extensions` | `true` | Which extensions to load for the agent. `true` (all defaults), `false` (none), or an explicit list: `[mcp, "/abs/path.ts", "*"]`. See [Tool & extension scoping](#tool--extension-scoping) below |
|
|
199
|
+
| `exclude_extensions` | — | Extension denylist applied after `extensions:` — exclude wins. Plain names only (case-insensitive), no paths or `*`. Useful with `extensions: true` to drop one extension (e.g. `pi-notify`) |
|
|
199
200
|
| `skills` | `true` | Inherit skills from parent. Can be a comma-separated list of skill names to preload (see [Skill Preloading](#skill-preloading) for discovery locations) |
|
|
200
201
|
| `memory` | — | Persistent agent memory scope: `project`, `local`, or `user`. Auto-detects read-only agents |
|
|
201
202
|
| `disallowed_tools` | — | Comma-separated tools to deny even if extensions provide them |
|
|
@@ -227,6 +228,8 @@ extensions: false # no extensions load
|
|
|
227
228
|
extensions: [mcp] # only mcp loads
|
|
228
229
|
extensions: ["*", "/abs/foo.ts"] # all defaults plus one path-loaded extension
|
|
229
230
|
|
|
231
|
+
exclude_extensions: pi-notify # everything except pi-notify (with extensions: true)
|
|
232
|
+
|
|
230
233
|
# Specialist: load one extension, expose only one of its tools, keep built-ins
|
|
231
234
|
extensions: [mcp]
|
|
232
235
|
tools: "*, ext:mcp/search"
|
|
@@ -240,6 +243,8 @@ A few rules the examples don't make obvious:
|
|
|
240
243
|
- Any `ext:` entry flips extension tools to an explicit allowlist — unnamed extensions still load (handlers fire) but expose no tools. So `tools: "*, ext:mcp/search"` exposes only `search` from `mcp`, nothing from any other extension.
|
|
241
244
|
- Extension names match case-insensitively (`[Mcp]` = `[mcp]`); tool names in `ext:foo/bar` stay case-sensitive.
|
|
242
245
|
- Plain `tools:` typos fail loudly: `tools: reed, grep` fires `tools-error:…` instead of silently producing an under-tooled agent.
|
|
246
|
+
- `exclude_extensions:` wins over `extensions:` and over `ext:` selectors — an excluded extension never loads and a `tools: ext:` entry can't pull it back. Plain names only (no paths, no `*`); a name matching nothing fires an `extension-error:…` warning.
|
|
247
|
+
- `exclude_extensions:` is **not a sandbox**: excluded extensions' factory code still executes once during loading — exclusion suppresses their handlers and tools, not their load-time side effects. Don't rely on it to contain an untrusted extension.
|
|
243
248
|
- Array and string forms are equivalent: `[a, b]` == `"a, b"`.
|
|
244
249
|
|
|
245
250
|
## Tools
|
|
@@ -365,7 +370,7 @@ When on, each subagent spawn's effective model is validated against pi's own `en
|
|
|
365
370
|
|
|
366
371
|
## Persistent Settings
|
|
367
372
|
|
|
368
|
-
Runtime tuning values set via `/agents` → Settings (max concurrency, default max turns, grace turns, default join mode, scheduling on/off, scope models on/off, disable defaults on/off) persist across pi restarts. Two files, merged on load:
|
|
373
|
+
Runtime tuning values set via `/agents` → Settings (max concurrency, default max turns, grace turns, default join mode, scheduling on/off, scope models on/off, disable defaults on/off, tool description full/compact/custom) persist across pi restarts. Two files, merged on load:
|
|
369
374
|
|
|
370
375
|
- **Global:** `~/.pi/agent/subagents.json` — your machine-wide defaults. Edit by hand; the `/agents` menu never writes here.
|
|
371
376
|
- **Project:** `<cwd>/.pi/subagents.json` — per-project overrides. Written by `/agents` → Settings.
|
|
@@ -374,6 +379,21 @@ Runtime tuning values set via `/agents` → Settings (max concurrency, default m
|
|
|
374
379
|
|
|
375
380
|
**Disable defaults** (`disableDefaultAgents`, default `false`): when on, the three built-in agents (general-purpose, Explore, Plan) are not registered — only your `.pi/agents/*.md` agents are advertised and spawnable. User-defined agents are unaffected, including ones that override a default by name. The Agent tool's type list updates on the next pi session (the tool schema is registered at startup).
|
|
376
381
|
|
|
382
|
+
**Tool description** (`toolDescriptionMode`, default `"full"`): which Agent tool description the LLM sees. `"full"` is the rich Claude Code-style prompt (~1,400 tokens with the default agents); `"compact"` is ~75% smaller — one-line agent type list, terse usage notes — for small/local models where tool-spec tokens are expensive. Per-option details stay in the parameter descriptions in every mode (the parameter schema is never customizable). Applies on the next pi session.
|
|
383
|
+
|
|
384
|
+
`"custom"` registers your own description from `<cwd>/.pi/agent-tool-description.md` (project) or `<agentDir>/agent-tool-description.md` (global; project wins). The file is read once at tool registration, so edits also apply on the next pi session. Dynamic parts stay live via placeholders — a static agent list would go stale the moment you add a custom agent:
|
|
385
|
+
|
|
386
|
+
```markdown
|
|
387
|
+
Launch an autonomous agent. Available types:
|
|
388
|
+
{{typeList}}
|
|
389
|
+
|
|
390
|
+
Custom agents live in .pi/agents/ or {{agentDir}}/agents/.
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Placeholders: `{{typeList}}` (full per-agent descriptions), `{{compactTypeList}}` (first sentence each), `{{agentDir}}`, `{{scheduleGuideline}}` (expands with its own leading newline + `- ` bullet when scheduling is on — place it directly after your last rule line; empty when scheduling is off). Unknown placeholders are left verbatim with a stderr warning; a missing or empty file falls back to `"full"` with a warning. Note the usual trust umbrella: a project-level file shapes the orchestrator's prompt, same as project agents and extensions do.
|
|
394
|
+
|
|
395
|
+
**Starting point:** copy [`examples/agent-tool-description.md`](examples/agent-tool-description.md) — it reproduces the default full description exactly (a CI test keeps it in sync), so you can trim from a known-good baseline instead of writing from scratch.
|
|
396
|
+
|
|
377
397
|
**Example — global defaults for a beefy machine:**
|
|
378
398
|
|
|
379
399
|
```bash
|
package/dist/agent-runner.js
CHANGED
|
@@ -206,6 +206,9 @@ export async function runAgent(ctx, type, prompt, options) {
|
|
|
206
206
|
const extras = {};
|
|
207
207
|
// Resolve extensions/skills: isolated overrides to false
|
|
208
208
|
const extensions = options.isolated ? false : config.extensions;
|
|
209
|
+
// Nulling excludes under isolated also suppresses the orphaned-exclude warning —
|
|
210
|
+
// isolation is an intentional override, not a misconfiguration.
|
|
211
|
+
const excludeExtensions = options.isolated ? undefined : config.excludeExtensions;
|
|
209
212
|
const skills = options.isolated ? false : config.skills;
|
|
210
213
|
// Skill preloading: when skills is string[], preload their content into prompt
|
|
211
214
|
if (Array.isArray(skills)) {
|
|
@@ -277,17 +280,35 @@ export async function runAgent(ctx, type, prompt, options) {
|
|
|
277
280
|
? parseExtensionsSpec(extensions, effectiveCwd)
|
|
278
281
|
: undefined;
|
|
279
282
|
const keepNames = extensionsSpec?.names ?? new Set();
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
+
// `exclude_extensions:` is a denylist applied AFTER the include set — exclude wins.
|
|
284
|
+
// Plain canonical names only (case-insensitive). Note: excluded extensions'
|
|
285
|
+
// factories still run once during reload() (see comment above) — exclusion
|
|
286
|
+
// suppresses handler binding and tool registration; it is not a sandbox.
|
|
287
|
+
const excludeNames = new Set((excludeExtensions ?? []).map((n) => n.toLowerCase()));
|
|
288
|
+
const hasExcludes = excludeNames.size > 0;
|
|
289
|
+
// The override filters loaded extensions down to `keepNames` minus `excludeNames`.
|
|
290
|
+
// It's only needed when we're neither loading everything without excludes
|
|
291
|
+
// (`extensions: true` or a `"*"` wildcard) nor nothing (`noExtensions`).
|
|
283
292
|
const loadAll = extensions === true || extensionsSpec?.wildcard === true;
|
|
284
293
|
const additionalExtensionPaths = extensionsSpec?.paths.length ? extensionsSpec.paths : undefined;
|
|
285
|
-
|
|
294
|
+
// Pre-filter discovered set, captured by the override — the exclude-typo warning
|
|
295
|
+
// must compare against this, not the surviving set (absence from survivors is
|
|
296
|
+
// an exclude *succeeding*).
|
|
297
|
+
let discoveredNames;
|
|
298
|
+
const extensionsOverride = noExtensions || (loadAll && !hasExcludes)
|
|
286
299
|
? undefined
|
|
287
|
-
: (base) =>
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
300
|
+
: (base) => {
|
|
301
|
+
discoveredNames = new Set(base.extensions.map((e) => extensionCanonicalName(e.path)));
|
|
302
|
+
return {
|
|
303
|
+
...base,
|
|
304
|
+
extensions: base.extensions.filter((e) => {
|
|
305
|
+
const name = extensionCanonicalName(e.path);
|
|
306
|
+
if (excludeNames.has(name))
|
|
307
|
+
return false; // exclude wins
|
|
308
|
+
return loadAll || keepNames.has(name);
|
|
309
|
+
}),
|
|
310
|
+
};
|
|
311
|
+
};
|
|
291
312
|
const loader = new DefaultResourceLoader({
|
|
292
313
|
cwd: effectiveCwd,
|
|
293
314
|
agentDir,
|
|
@@ -325,13 +346,36 @@ export async function runAgent(ctx, type, prompt, options) {
|
|
|
325
346
|
// - `tools: ext:foo` but foo isn't in the loaded set (because `extensions:`
|
|
326
347
|
// didn't include it). Since v0.9, `ext:` no longer pulls extensions in;
|
|
327
348
|
// loading is `extensions:`-authoritative.
|
|
349
|
+
// An exclude_extensions: alongside extensions: false is contradictory — nothing
|
|
350
|
+
// loads, so there is nothing to exclude.
|
|
351
|
+
if (hasExcludes && noExtensions) {
|
|
352
|
+
options.onToolActivity?.({
|
|
353
|
+
type: "end",
|
|
354
|
+
toolName: `extension-error:exclude_extensions has no effect for agent "${type}" — extensions: false loads nothing`,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
// Exclude typo check: compares against the PRE-filter discovered set (an excluded
|
|
358
|
+
// name absent from the surviving set is the exclude working as intended). Also
|
|
359
|
+
// flags path-like and "*" entries — excludes are plain names only.
|
|
360
|
+
if (hasExcludes && discoveredNames) {
|
|
361
|
+
for (const name of excludeNames) {
|
|
362
|
+
if (!discoveredNames.has(name)) {
|
|
363
|
+
options.onToolActivity?.({
|
|
364
|
+
type: "end",
|
|
365
|
+
toolName: `extension-error:exclude_extensions: "${name}" for agent "${type}" did not match any discovered extension`,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
328
370
|
if (keepNames.size > 0 || extNames.size > 0) {
|
|
329
371
|
const survivingNames = new Set(loader.getExtensions().extensions.map((e) => extensionCanonicalName(e.path)));
|
|
330
372
|
for (const name of keepNames) {
|
|
331
373
|
if (!survivingNames.has(name)) {
|
|
332
374
|
options.onToolActivity?.({
|
|
333
375
|
type: "end",
|
|
334
|
-
toolName:
|
|
376
|
+
toolName: excludeNames.has(name)
|
|
377
|
+
? `extension-error:extension "${name}" is in both extensions: and exclude_extensions: for agent "${type}" — exclude wins`
|
|
378
|
+
: `extension-error:extension "${name}" requested by agent "${type}" was not loaded`,
|
|
335
379
|
});
|
|
336
380
|
}
|
|
337
381
|
}
|
|
@@ -339,7 +383,7 @@ export async function runAgent(ctx, type, prompt, options) {
|
|
|
339
383
|
if (!survivingNames.has(name)) {
|
|
340
384
|
options.onToolActivity?.({
|
|
341
385
|
type: "end",
|
|
342
|
-
toolName: `extension-error:ext:${name} referenced by agent "${type}" but extension "${name}" is not loaded (
|
|
386
|
+
toolName: `extension-error:ext:${name} referenced by agent "${type}" but extension "${name}" is not loaded (check extensions:/exclude_extensions:)`,
|
|
343
387
|
});
|
|
344
388
|
}
|
|
345
389
|
}
|
package/dist/agent-types.d.ts
CHANGED
package/dist/agent-types.js
CHANGED
|
@@ -127,6 +127,7 @@ export function getConfig(type) {
|
|
|
127
127
|
description: config.description,
|
|
128
128
|
builtinToolNames: config.builtinToolNames ?? BUILTIN_TOOL_NAMES,
|
|
129
129
|
extensions: config.extensions,
|
|
130
|
+
excludeExtensions: config.excludeExtensions,
|
|
130
131
|
skills: config.skills,
|
|
131
132
|
promptMode: config.promptMode,
|
|
132
133
|
};
|
|
@@ -139,6 +140,7 @@ export function getConfig(type) {
|
|
|
139
140
|
description: gp.description,
|
|
140
141
|
builtinToolNames: gp.builtinToolNames ?? BUILTIN_TOOL_NAMES,
|
|
141
142
|
extensions: gp.extensions,
|
|
143
|
+
excludeExtensions: gp.excludeExtensions,
|
|
142
144
|
skills: gp.skills,
|
|
143
145
|
promptMode: gp.promptMode,
|
|
144
146
|
};
|
package/dist/custom-agents.js
CHANGED
|
@@ -52,6 +52,7 @@ function loadFromDir(dir, agents, source) {
|
|
|
52
52
|
extSelectors,
|
|
53
53
|
disallowedTools: csvListOptional(fm.disallowed_tools),
|
|
54
54
|
extensions: inheritField(fm.extensions ?? fm.inherit_extensions),
|
|
55
|
+
excludeExtensions: csvListOptional(fm.exclude_extensions),
|
|
55
56
|
skills: inheritField(fm.skills ?? fm.inherit_skills),
|
|
56
57
|
model: str(fm.model),
|
|
57
58
|
thinking: str(fm.thinking),
|
package/dist/index.js
CHANGED
|
@@ -473,6 +473,13 @@ export default function (pi) {
|
|
|
473
473
|
setDefaultsDisabled(b);
|
|
474
474
|
reloadCustomAgents(); // re-register with new setting
|
|
475
475
|
}
|
|
476
|
+
// ---- Agent tool description mode ----
|
|
477
|
+
// "full" (default) keeps the rich Claude Code-style description; "compact"
|
|
478
|
+
// swaps in a ~75% smaller one for small/local models (#91). Read once at
|
|
479
|
+
// tool registration — flipping it applies on the next pi session.
|
|
480
|
+
let toolDescriptionMode = "full";
|
|
481
|
+
function getToolDescriptionMode() { return toolDescriptionMode; }
|
|
482
|
+
function setToolDescriptionMode(mode) { toolDescriptionMode = mode; }
|
|
476
483
|
// ---- Batch tracking for smart join mode ----
|
|
477
484
|
// Collects background agent IDs spawned in the current turn for smart grouping.
|
|
478
485
|
// Uses a debounced timer: each new agent resets the 100ms window so that all
|
|
@@ -539,6 +546,16 @@ export default function (pi) {
|
|
|
539
546
|
return `- ${name}: ${cfg?.description ?? name}${modelSuffix}${toolsSuffix}`;
|
|
540
547
|
}).join("\n");
|
|
541
548
|
};
|
|
549
|
+
/** First sentence of an agent description — for the compact type list. */
|
|
550
|
+
const firstSentence = (text) => {
|
|
551
|
+
const match = text.match(/^.*?[.!?](?=\s|$)/s);
|
|
552
|
+
return (match ? match[0] : text).replace(/\s+/g, " ").trim();
|
|
553
|
+
};
|
|
554
|
+
/** Compact type list: one line per agent, first sentence only. */
|
|
555
|
+
const buildCompactTypeListText = () => getAvailableTypes().map((name) => {
|
|
556
|
+
const cfg = getAgentConfig(name);
|
|
557
|
+
return `- ${name}: ${firstSentence(cfg?.description ?? name)} (Tools: ${formatToolsSuffix(cfg)})`;
|
|
558
|
+
}).join("\n");
|
|
542
559
|
/** Derive a short model label from a model string. */
|
|
543
560
|
function getModelLabelFromConfig(model) {
|
|
544
561
|
// Strip provider prefix (e.g. "anthropic/claude-sonnet-4-6" → "claude-sonnet-4-6")
|
|
@@ -557,6 +574,7 @@ export default function (pi) {
|
|
|
557
574
|
setSchedulingEnabled,
|
|
558
575
|
setScopeModels: setScopeModelsEnabled,
|
|
559
576
|
setDisableDefaultAgents: setDisableDefaultAgents,
|
|
577
|
+
setToolDescriptionMode: setToolDescriptionMode,
|
|
560
578
|
}, (event, payload) => pi.events.emit(event, payload));
|
|
561
579
|
// ---- Agent tool ----
|
|
562
580
|
// Schedule param + its guideline are gated on `schedulingEnabled` (read once
|
|
@@ -575,10 +593,21 @@ export default function (pi) {
|
|
|
575
593
|
const scheduleGuideline = isSchedulingEnabled()
|
|
576
594
|
? `\n- Use \`schedule\` only when the user explicitly asked for scheduled / recurring / delayed execution (e.g. "every Monday", "in an hour"). Don't auto-schedule from vague intent like "monitor X" — run once now or ask.`
|
|
577
595
|
: "";
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
596
|
+
// Compact Agent tool description (#91, `toolDescriptionMode: "compact"`) —
|
|
597
|
+
// the same load-bearing facts as the full version at ~75% fewer tokens, for
|
|
598
|
+
// small/local models. Per-option details live in the param descriptions.
|
|
599
|
+
const compactAgentToolDescription = `Launch an autonomous agent for complex, multi-step tasks. Agent types:
|
|
600
|
+
${buildCompactTypeListText()}
|
|
601
|
+
|
|
602
|
+
Custom agents: .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global).
|
|
603
|
+
|
|
604
|
+
Notes:
|
|
605
|
+
- description: 3-5 words (shown in UI). Prompts must be self-contained — the agent has not seen this conversation.
|
|
606
|
+
- Parallel work: one message, multiple Agent calls, run_in_background: true on each. You are notified when background agents finish — never poll or sleep.
|
|
607
|
+
- The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done.
|
|
608
|
+
- resume continues a previous agent by ID; steer_subagent messages a running one.
|
|
609
|
+
- isolation: "worktree" runs the agent in an isolated git worktree; changes land on a branch.`;
|
|
610
|
+
const fullAgentToolDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
|
|
582
611
|
|
|
583
612
|
Available agent types and the tools they have access to:
|
|
584
613
|
${buildTypeListText()}
|
|
@@ -619,7 +648,61 @@ Provide clear, detailed prompts so the agent can work autonomously. Brief it lik
|
|
|
619
648
|
|
|
620
649
|
Terse command-style prompts produce shallow, generic work.
|
|
621
650
|
|
|
622
|
-
**Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change
|
|
651
|
+
**Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.`;
|
|
652
|
+
// `toolDescriptionMode: "custom"` — user-authored description with live
|
|
653
|
+
// dynamic parts. Project file wins over global; missing/empty falls back to
|
|
654
|
+
// "full" (a stale fallback beats a blank tool description). Only the prose
|
|
655
|
+
// is customizable — the parameter schema stays code-owned.
|
|
656
|
+
const renderToolDescriptionTemplate = (template) => {
|
|
657
|
+
const vars = {
|
|
658
|
+
typeList: buildTypeListText,
|
|
659
|
+
compactTypeList: buildCompactTypeListText,
|
|
660
|
+
agentDir: getAgentDir,
|
|
661
|
+
scheduleGuideline: () => scheduleGuideline,
|
|
662
|
+
};
|
|
663
|
+
// Replacement callback (not a string) — agent descriptions may contain `$&` etc.
|
|
664
|
+
return template.replace(/\{\{(\w+)\}\}/g, (raw, name) => {
|
|
665
|
+
if (vars[name])
|
|
666
|
+
return vars[name]();
|
|
667
|
+
console.warn(`[pi-subagents] agent-tool-description.md: unknown placeholder ${raw} left as-is`);
|
|
668
|
+
return raw;
|
|
669
|
+
});
|
|
670
|
+
};
|
|
671
|
+
const loadCustomToolDescription = () => {
|
|
672
|
+
for (const path of [
|
|
673
|
+
join(process.cwd(), ".pi", "agent-tool-description.md"),
|
|
674
|
+
join(getAgentDir(), "agent-tool-description.md"),
|
|
675
|
+
]) {
|
|
676
|
+
try {
|
|
677
|
+
if (!existsSync(path))
|
|
678
|
+
continue;
|
|
679
|
+
const text = readFileSync(path, "utf-8").trim();
|
|
680
|
+
if (text)
|
|
681
|
+
return renderToolDescriptionTemplate(text);
|
|
682
|
+
console.warn(`[pi-subagents] ${path} is empty — ignoring`);
|
|
683
|
+
}
|
|
684
|
+
catch (err) {
|
|
685
|
+
console.warn(`[pi-subagents] failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return undefined;
|
|
689
|
+
};
|
|
690
|
+
const agentToolDescription = (() => {
|
|
691
|
+
const mode = getToolDescriptionMode();
|
|
692
|
+
if (mode === "compact")
|
|
693
|
+
return compactAgentToolDescription;
|
|
694
|
+
if (mode === "custom") {
|
|
695
|
+
const custom = loadCustomToolDescription();
|
|
696
|
+
if (custom)
|
|
697
|
+
return custom;
|
|
698
|
+
console.warn('[pi-subagents] toolDescriptionMode is "custom" but no agent-tool-description.md found — using "full"');
|
|
699
|
+
}
|
|
700
|
+
return fullAgentToolDescription;
|
|
701
|
+
})();
|
|
702
|
+
pi.registerTool(defineTool({
|
|
703
|
+
name: SUBAGENT_TOOL_NAMES.AGENT,
|
|
704
|
+
label: "Agent",
|
|
705
|
+
description: agentToolDescription,
|
|
623
706
|
promptSnippet: "Launch autonomous sub-agents for complex multi-step tasks",
|
|
624
707
|
promptGuidelines: [
|
|
625
708
|
"Use Agent with specialized agents when the task matches an agent type's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing — if you delegate research to a subagent, do not also perform the same searches yourself.",
|
|
@@ -1326,12 +1409,12 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1326
1409
|
const { ConversationViewer, VIEWPORT_HEIGHT_PCT } = await import("./ui/conversation-viewer.js");
|
|
1327
1410
|
const session = record.session;
|
|
1328
1411
|
const activity = agentActivity.get(record.id);
|
|
1329
|
-
await ctx.ui.custom((tui, theme,
|
|
1412
|
+
await ctx.ui.custom((tui, theme, keybindings, done) => {
|
|
1330
1413
|
return new ConversationViewer(tui, session, record, activity, theme, done, () => {
|
|
1331
1414
|
if (manager.abort(record.id)) {
|
|
1332
1415
|
ctx.ui.notify(`Stopped "${record.description}".`, "info");
|
|
1333
1416
|
}
|
|
1334
|
-
});
|
|
1417
|
+
}, keybindings);
|
|
1335
1418
|
}, {
|
|
1336
1419
|
overlay: true,
|
|
1337
1420
|
overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
|
|
@@ -1439,6 +1522,8 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1439
1522
|
fmFields.push("extensions: false");
|
|
1440
1523
|
else if (Array.isArray(cfg.extensions))
|
|
1441
1524
|
fmFields.push(`extensions: ${cfg.extensions.join(", ")}`);
|
|
1525
|
+
if (cfg.excludeExtensions?.length)
|
|
1526
|
+
fmFields.push(`exclude_extensions: ${cfg.excludeExtensions.join(", ")}`);
|
|
1442
1527
|
if (cfg.skills === false)
|
|
1443
1528
|
fmFields.push("skills: false");
|
|
1444
1529
|
else if (Array.isArray(cfg.skills))
|
|
@@ -1704,6 +1789,7 @@ ${systemPrompt}
|
|
|
1704
1789
|
schedulingEnabled: isSchedulingEnabled(),
|
|
1705
1790
|
scopeModels: isScopeModelsEnabled(),
|
|
1706
1791
|
disableDefaultAgents: isDefaultsDisabled(),
|
|
1792
|
+
toolDescriptionMode: getToolDescriptionMode(),
|
|
1707
1793
|
};
|
|
1708
1794
|
}
|
|
1709
1795
|
const NUMERIC_IDS = new Set(["maxConcurrent", "defaultMaxTurns", "graceTurns"]);
|
|
@@ -1762,6 +1848,13 @@ ${systemPrompt}
|
|
|
1762
1848
|
currentValue: isDefaultsDisabled() ? "on" : "off",
|
|
1763
1849
|
values: ["on", "off"],
|
|
1764
1850
|
},
|
|
1851
|
+
{
|
|
1852
|
+
id: "toolDescriptionMode",
|
|
1853
|
+
label: "Tool description",
|
|
1854
|
+
description: "Agent tool description sent to the LLM: full (rich, default), compact (~75% fewer tokens, for small/local models), or custom (.pi/agent-tool-description.md with {{placeholders}})",
|
|
1855
|
+
currentValue: getToolDescriptionMode(),
|
|
1856
|
+
values: ["full", "compact", "custom"],
|
|
1857
|
+
},
|
|
1765
1858
|
];
|
|
1766
1859
|
}
|
|
1767
1860
|
function applyValue(id, value) {
|
|
@@ -1816,6 +1909,10 @@ ${systemPrompt}
|
|
|
1816
1909
|
setDisableDefaultAgents(enabled);
|
|
1817
1910
|
notifyApplied(ctx, `Default agents ${enabled ? "disabled" : "enabled"}. Tool spec change takes effect on next pi session.`);
|
|
1818
1911
|
}
|
|
1912
|
+
else if (id === "toolDescriptionMode") {
|
|
1913
|
+
setToolDescriptionMode(value);
|
|
1914
|
+
notifyApplied(ctx, `Tool description set to ${value}. Takes effect on next pi session.`);
|
|
1915
|
+
}
|
|
1819
1916
|
}
|
|
1820
1917
|
let list;
|
|
1821
1918
|
// Track current selection index directly (SettingsList doesn't expose it).
|
package/dist/settings.d.ts
CHANGED
|
@@ -47,7 +47,19 @@ export interface SubagentsSettings {
|
|
|
47
47
|
* Defaults to false.
|
|
48
48
|
*/
|
|
49
49
|
disableDefaultAgents?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Which Agent tool description the LLM sees. "full" (default) is the rich
|
|
52
|
+
* Claude Code-style prompt; "compact" is a ~75% smaller version (one-line
|
|
53
|
+
* agent type list, terse usage notes) for small/local models where tool-spec
|
|
54
|
+
* tokens are expensive; "custom" reads `.pi/agent-tool-description.md`
|
|
55
|
+
* (project, falling back to `<agentDir>/agent-tool-description.md`) with
|
|
56
|
+
* `{{placeholder}}` substitution — a missing/empty file falls back to "full".
|
|
57
|
+
* The mode is read once at tool registration — changing it applies on the
|
|
58
|
+
* next pi session.
|
|
59
|
+
*/
|
|
60
|
+
toolDescriptionMode?: ToolDescriptionMode;
|
|
50
61
|
}
|
|
62
|
+
export type ToolDescriptionMode = "full" | "compact" | "custom";
|
|
51
63
|
/** Setter hooks used by applySettings to wire persisted values into in-memory state. */
|
|
52
64
|
export interface SettingsAppliers {
|
|
53
65
|
setMaxConcurrent: (n: number) => void;
|
|
@@ -57,6 +69,7 @@ export interface SettingsAppliers {
|
|
|
57
69
|
setSchedulingEnabled: (b: boolean) => void;
|
|
58
70
|
setScopeModels: (enabled: boolean) => void;
|
|
59
71
|
setDisableDefaultAgents: (b: boolean) => void;
|
|
72
|
+
setToolDescriptionMode: (mode: ToolDescriptionMode) => void;
|
|
60
73
|
}
|
|
61
74
|
/** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */
|
|
62
75
|
export type SettingsEmit = (event: string, payload: unknown) => void;
|
package/dist/settings.js
CHANGED
|
@@ -5,6 +5,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
const VALID_JOIN_MODES = new Set(["async", "group", "smart"]);
|
|
8
|
+
const VALID_TOOL_DESCRIPTION_MODES = new Set(["full", "compact", "custom"]);
|
|
8
9
|
// Sanity ceilings — prevent hand-edited configs from asking for values that
|
|
9
10
|
// make no operational sense (e.g. 1e6 concurrent subagents). Permissive enough
|
|
10
11
|
// that any realistic power-user setting passes through.
|
|
@@ -44,6 +45,9 @@ function sanitize(raw) {
|
|
|
44
45
|
if (typeof r.disableDefaultAgents === "boolean") {
|
|
45
46
|
out.disableDefaultAgents = r.disableDefaultAgents;
|
|
46
47
|
}
|
|
48
|
+
if (typeof r.toolDescriptionMode === "string" && VALID_TOOL_DESCRIPTION_MODES.has(r.toolDescriptionMode)) {
|
|
49
|
+
out.toolDescriptionMode = r.toolDescriptionMode;
|
|
50
|
+
}
|
|
47
51
|
return out;
|
|
48
52
|
}
|
|
49
53
|
function globalPath() {
|
|
@@ -105,6 +109,8 @@ export function applySettings(s, appliers) {
|
|
|
105
109
|
appliers.setScopeModels(s.scopeModels);
|
|
106
110
|
if (typeof s.disableDefaultAgents === "boolean")
|
|
107
111
|
appliers.setDisableDefaultAgents(s.disableDefaultAgents);
|
|
112
|
+
if (s.toolDescriptionMode)
|
|
113
|
+
appliers.setToolDescriptionMode(s.toolDescriptionMode);
|
|
108
114
|
}
|
|
109
115
|
/**
|
|
110
116
|
* Format the user-facing toast for a settings mutation. Pure function —
|
package/dist/types.d.ts
CHANGED
|
@@ -26,6 +26,9 @@ export interface AgentConfig {
|
|
|
26
26
|
disallowedTools?: string[];
|
|
27
27
|
/** true = inherit all, string[] = only listed, false = none */
|
|
28
28
|
extensions: true | string[] | false;
|
|
29
|
+
/** Extension-name denylist applied after the `extensions:` include set. Exclude wins.
|
|
30
|
+
* Plain canonical names only (case-insensitive); no paths, no wildcard. */
|
|
31
|
+
excludeExtensions?: string[];
|
|
29
32
|
/** true = inherit all, string[] = only listed, false = none */
|
|
30
33
|
skills: true | string[] | false;
|
|
31
34
|
model?: string;
|
|
@@ -9,6 +9,7 @@ import { type Component, type TUI } from "@earendil-works/pi-tui";
|
|
|
9
9
|
import type { AgentRecord } from "../types.js";
|
|
10
10
|
import type { Theme } from "./agent-widget.js";
|
|
11
11
|
import { type AgentActivity } from "./agent-widget.js";
|
|
12
|
+
import { type ViewerKeybindings } from "./viewer-keys.js";
|
|
12
13
|
/** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
|
|
13
14
|
export declare const VIEWPORT_HEIGHT_PCT = 70;
|
|
14
15
|
export declare class ConversationViewer implements Component {
|
|
@@ -27,9 +28,12 @@ export declare class ConversationViewer implements Component {
|
|
|
27
28
|
private closed;
|
|
28
29
|
/** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */
|
|
29
30
|
private stopArmed;
|
|
31
|
+
private keys;
|
|
30
32
|
constructor(tui: TUI, session: AgentSession, record: AgentRecord, activity: AgentActivity | undefined, theme: Theme, done: (result: undefined) => void,
|
|
31
33
|
/** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */
|
|
32
|
-
onStop?: (() => void) | undefined
|
|
34
|
+
onStop?: (() => void) | undefined,
|
|
35
|
+
/** User keybindings from `ctx.ui.custom()`. Omitted → hardcoded defaults. */
|
|
36
|
+
keybindings?: ViewerKeybindings);
|
|
33
37
|
handleInput(data: string): void;
|
|
34
38
|
render(width: number): string[];
|
|
35
39
|
/** Stoppable only when a stop handler exists and the agent is still active. */
|
|
@@ -8,6 +8,7 @@ import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@ea
|
|
|
8
8
|
import { extractText } from "../context.js";
|
|
9
9
|
import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
|
|
10
10
|
import { buildInvocationTags, describeActivity, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
|
|
11
|
+
import { createViewerKeys } from "./viewer-keys.js";
|
|
11
12
|
/** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
|
|
12
13
|
const CHROME_LINES_BASE = 6;
|
|
13
14
|
const MIN_VIEWPORT = 3;
|
|
@@ -28,9 +29,12 @@ export class ConversationViewer {
|
|
|
28
29
|
closed = false;
|
|
29
30
|
/** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */
|
|
30
31
|
stopArmed = false;
|
|
32
|
+
keys;
|
|
31
33
|
constructor(tui, session, record, activity, theme, done,
|
|
32
34
|
/** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */
|
|
33
|
-
onStop
|
|
35
|
+
onStop,
|
|
36
|
+
/** User keybindings from `ctx.ui.custom()`. Omitted → hardcoded defaults. */
|
|
37
|
+
keybindings) {
|
|
34
38
|
this.tui = tui;
|
|
35
39
|
this.session = session;
|
|
36
40
|
this.record = record;
|
|
@@ -38,6 +42,7 @@ export class ConversationViewer {
|
|
|
38
42
|
this.theme = theme;
|
|
39
43
|
this.done = done;
|
|
40
44
|
this.onStop = onStop;
|
|
45
|
+
this.keys = createViewerKeys(keybindings);
|
|
41
46
|
this.unsubscribe = session.subscribe(() => {
|
|
42
47
|
if (this.closed)
|
|
43
48
|
return;
|
|
@@ -70,19 +75,19 @@ export class ConversationViewer {
|
|
|
70
75
|
const totalLines = this.buildContentLines(this.lastInnerW).length;
|
|
71
76
|
const viewportHeight = this.viewportHeight();
|
|
72
77
|
const maxScroll = Math.max(0, totalLines - viewportHeight);
|
|
73
|
-
if (
|
|
78
|
+
if (this.keys.scrollUp(data)) {
|
|
74
79
|
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
|
|
75
80
|
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
76
81
|
}
|
|
77
|
-
else if (
|
|
82
|
+
else if (this.keys.scrollDown(data)) {
|
|
78
83
|
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
|
|
79
84
|
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
80
85
|
}
|
|
81
|
-
else if (
|
|
86
|
+
else if (this.keys.pageUp(data)) {
|
|
82
87
|
this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
|
|
83
88
|
this.autoScroll = false;
|
|
84
89
|
}
|
|
85
|
-
else if (
|
|
90
|
+
else if (this.keys.pageDown(data)) {
|
|
86
91
|
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
|
|
87
92
|
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
88
93
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* viewer-keys.ts — Scroll key matchers for the conversation viewer.
|
|
3
|
+
*
|
|
4
|
+
* Resolves `tui.select.*` through the user's keybindings when pi provides a
|
|
5
|
+
* manager, falling back to the previous hardcoded keys otherwise. The viewer's
|
|
6
|
+
* k/j and shift+arrow aliases always work alongside whatever is bound.
|
|
7
|
+
*/
|
|
8
|
+
/** The `tui.select.*` keybinding ids the viewer resolves. */
|
|
9
|
+
export type ViewerScrollKeybinding = "tui.select.up" | "tui.select.down" | "tui.select.pageUp" | "tui.select.pageDown";
|
|
10
|
+
/** Structural subset of pi-tui's `KeybindingsManager` (which satisfies it). */
|
|
11
|
+
export interface ViewerKeybindings {
|
|
12
|
+
matches(data: string, keybinding: ViewerScrollKeybinding): boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface ViewerKeys {
|
|
15
|
+
scrollUp(data: string): boolean;
|
|
16
|
+
scrollDown(data: string): boolean;
|
|
17
|
+
pageUp(data: string): boolean;
|
|
18
|
+
pageDown(data: string): boolean;
|
|
19
|
+
}
|
|
20
|
+
export declare function createViewerKeys(keybindings?: ViewerKeybindings): ViewerKeys;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* viewer-keys.ts — Scroll key matchers for the conversation viewer.
|
|
3
|
+
*
|
|
4
|
+
* Resolves `tui.select.*` through the user's keybindings when pi provides a
|
|
5
|
+
* manager, falling back to the previous hardcoded keys otherwise. The viewer's
|
|
6
|
+
* k/j and shift+arrow aliases always work alongside whatever is bound.
|
|
7
|
+
*/
|
|
8
|
+
import { matchesKey } from "@earendil-works/pi-tui";
|
|
9
|
+
export function createViewerKeys(keybindings) {
|
|
10
|
+
const matches = (data, id, fallback) => keybindings ? keybindings.matches(data, id) : matchesKey(data, fallback);
|
|
11
|
+
return {
|
|
12
|
+
scrollUp: (data) => matches(data, "tui.select.up", "up") || matchesKey(data, "k"),
|
|
13
|
+
scrollDown: (data) => matches(data, "tui.select.down", "down") || matchesKey(data, "j"),
|
|
14
|
+
pageUp: (data) => matches(data, "tui.select.pageUp", "pageUp") || matchesKey(data, "shift+up"),
|
|
15
|
+
pageDown: (data) => matches(data, "tui.select.pageDown", "pageDown") || matchesKey(data, "shift+down"),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
|
|
2
|
+
|
|
3
|
+
Available agent types and the tools they have access to:
|
|
4
|
+
{{typeList}}
|
|
5
|
+
|
|
6
|
+
Custom agents can be defined in .pi/agents/<name>.md (project) or {{agentDir}}/agents/<name>.md (global) — they are picked up automatically. Project-level agents override global ones. Creating a .md file with the same name as a default agent overrides it.
|
|
7
|
+
|
|
8
|
+
When using the Agent tool, specify a subagent_type parameter to select which agent type to use.
|
|
9
|
+
|
|
10
|
+
## When not to use
|
|
11
|
+
|
|
12
|
+
If the target is already known, use a direct tool — `read` for a known path, `grep`/`find` for a specific symbol or string. Reserve this tool for open-ended questions that span the codebase, or tasks that match an available agent type.
|
|
13
|
+
|
|
14
|
+
## Usage notes
|
|
15
|
+
|
|
16
|
+
- Always include a short (3-5 word) description summarizing what the agent will do (shown in UI).
|
|
17
|
+
- When you launch multiple agents for independent work, send them in a single message with multiple tool uses, with run_in_background: true on each, so they run concurrently. If the user specifies that they want agents run "in parallel", you MUST send a single message with multiple tool calls. Foreground calls run sequentially — only one executes at a time.
|
|
18
|
+
- When the agent is done, it returns a single message back to you. The result is not visible to the user — to show the user, send a text message with a concise summary.
|
|
19
|
+
- Trust but verify: an agent's summary describes what it intended to do, not necessarily what it did. When an agent writes or edits code, check the actual changes before reporting work as done.
|
|
20
|
+
- Use run_in_background for work you don't need immediately. You will be notified when it completes — do NOT poll or sleep waiting for it. Continue with other work or respond to the user instead.
|
|
21
|
+
- Foreground vs background: use foreground (default) when you need the agent's results before you can proceed. Use background when you have genuinely independent work to do in parallel.
|
|
22
|
+
- Use resume with an agent ID to continue a previous agent's work. A new (non-resume) Agent call starts a fresh agent with no memory of prior runs, so the prompt must be self-contained.
|
|
23
|
+
- Use steer_subagent to send mid-run messages to a running background agent.
|
|
24
|
+
- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, etc.), since it is not aware of the user's intent.
|
|
25
|
+
- If an agent's description says it should be used proactively, try to use it without the user having to ask for it first.
|
|
26
|
+
- Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
|
|
27
|
+
- Use thinking to control extended thinking level.
|
|
28
|
+
- Use inherit_context if the agent needs the parent conversation history.
|
|
29
|
+
- Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications). The worktree is automatically cleaned up if the agent makes no changes; otherwise the path and branch are returned in the result.{{scheduleGuideline}}
|
|
30
|
+
|
|
31
|
+
## Writing the prompt
|
|
32
|
+
|
|
33
|
+
Provide clear, detailed prompts so the agent can work autonomously. Brief it like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters.
|
|
34
|
+
- Explain what you're trying to accomplish and why.
|
|
35
|
+
- Describe what you've already learned or ruled out.
|
|
36
|
+
- Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction.
|
|
37
|
+
- If you need a short response, say so ("report in under 200 words").
|
|
38
|
+
- Lookups: hand over the exact command. Investigations: hand over the question — prescribed steps become dead weight when the premise is wrong.
|
|
39
|
+
|
|
40
|
+
Terse command-style prompts produce shallow, generic work.
|
|
41
|
+
|
|
42
|
+
**Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.
|
package/package.json
CHANGED
package/src/agent-runner.ts
CHANGED
|
@@ -296,6 +296,9 @@ export async function runAgent(
|
|
|
296
296
|
|
|
297
297
|
// Resolve extensions/skills: isolated overrides to false
|
|
298
298
|
const extensions = options.isolated ? false : config.extensions;
|
|
299
|
+
// Nulling excludes under isolated also suppresses the orphaned-exclude warning —
|
|
300
|
+
// isolation is an intentional override, not a misconfiguration.
|
|
301
|
+
const excludeExtensions = options.isolated ? undefined : config.excludeExtensions;
|
|
299
302
|
const skills = options.isolated ? false : config.skills;
|
|
300
303
|
|
|
301
304
|
// Skill preloading: when skills is string[], preload their content into prompt
|
|
@@ -373,18 +376,35 @@ export async function runAgent(
|
|
|
373
376
|
? parseExtensionsSpec(extensions, effectiveCwd)
|
|
374
377
|
: undefined;
|
|
375
378
|
const keepNames = extensionsSpec?.names ?? new Set<string>();
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
+
// `exclude_extensions:` is a denylist applied AFTER the include set — exclude wins.
|
|
380
|
+
// Plain canonical names only (case-insensitive). Note: excluded extensions'
|
|
381
|
+
// factories still run once during reload() (see comment above) — exclusion
|
|
382
|
+
// suppresses handler binding and tool registration; it is not a sandbox.
|
|
383
|
+
const excludeNames = new Set((excludeExtensions ?? []).map((n) => n.toLowerCase()));
|
|
384
|
+
const hasExcludes = excludeNames.size > 0;
|
|
385
|
+
// The override filters loaded extensions down to `keepNames` minus `excludeNames`.
|
|
386
|
+
// It's only needed when we're neither loading everything without excludes
|
|
387
|
+
// (`extensions: true` or a `"*"` wildcard) nor nothing (`noExtensions`).
|
|
379
388
|
const loadAll = extensions === true || extensionsSpec?.wildcard === true;
|
|
380
389
|
const additionalExtensionPaths = extensionsSpec?.paths.length ? extensionsSpec.paths : undefined;
|
|
390
|
+
// Pre-filter discovered set, captured by the override — the exclude-typo warning
|
|
391
|
+
// must compare against this, not the surviving set (absence from survivors is
|
|
392
|
+
// an exclude *succeeding*).
|
|
393
|
+
let discoveredNames: Set<string> | undefined;
|
|
381
394
|
const extensionsOverride: ((base: LoadExtensionsResult) => LoadExtensionsResult) | undefined =
|
|
382
|
-
|
|
395
|
+
noExtensions || (loadAll && !hasExcludes)
|
|
383
396
|
? undefined
|
|
384
|
-
: (base) =>
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
397
|
+
: (base) => {
|
|
398
|
+
discoveredNames = new Set(base.extensions.map((e) => extensionCanonicalName(e.path)));
|
|
399
|
+
return {
|
|
400
|
+
...base,
|
|
401
|
+
extensions: base.extensions.filter((e) => {
|
|
402
|
+
const name = extensionCanonicalName(e.path);
|
|
403
|
+
if (excludeNames.has(name)) return false; // exclude wins
|
|
404
|
+
return loadAll || keepNames.has(name);
|
|
405
|
+
}),
|
|
406
|
+
};
|
|
407
|
+
};
|
|
388
408
|
|
|
389
409
|
const loader = new DefaultResourceLoader({
|
|
390
410
|
cwd: effectiveCwd,
|
|
@@ -425,6 +445,27 @@ export async function runAgent(
|
|
|
425
445
|
// - `tools: ext:foo` but foo isn't in the loaded set (because `extensions:`
|
|
426
446
|
// didn't include it). Since v0.9, `ext:` no longer pulls extensions in;
|
|
427
447
|
// loading is `extensions:`-authoritative.
|
|
448
|
+
// An exclude_extensions: alongside extensions: false is contradictory — nothing
|
|
449
|
+
// loads, so there is nothing to exclude.
|
|
450
|
+
if (hasExcludes && noExtensions) {
|
|
451
|
+
options.onToolActivity?.({
|
|
452
|
+
type: "end",
|
|
453
|
+
toolName: `extension-error:exclude_extensions has no effect for agent "${type}" — extensions: false loads nothing`,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
// Exclude typo check: compares against the PRE-filter discovered set (an excluded
|
|
457
|
+
// name absent from the surviving set is the exclude working as intended). Also
|
|
458
|
+
// flags path-like and "*" entries — excludes are plain names only.
|
|
459
|
+
if (hasExcludes && discoveredNames) {
|
|
460
|
+
for (const name of excludeNames) {
|
|
461
|
+
if (!discoveredNames.has(name)) {
|
|
462
|
+
options.onToolActivity?.({
|
|
463
|
+
type: "end",
|
|
464
|
+
toolName: `extension-error:exclude_extensions: "${name}" for agent "${type}" did not match any discovered extension`,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
428
469
|
if (keepNames.size > 0 || extNames.size > 0) {
|
|
429
470
|
const survivingNames = new Set(
|
|
430
471
|
loader.getExtensions().extensions.map((e) => extensionCanonicalName(e.path)),
|
|
@@ -433,7 +474,9 @@ export async function runAgent(
|
|
|
433
474
|
if (!survivingNames.has(name)) {
|
|
434
475
|
options.onToolActivity?.({
|
|
435
476
|
type: "end",
|
|
436
|
-
toolName:
|
|
477
|
+
toolName: excludeNames.has(name)
|
|
478
|
+
? `extension-error:extension "${name}" is in both extensions: and exclude_extensions: for agent "${type}" — exclude wins`
|
|
479
|
+
: `extension-error:extension "${name}" requested by agent "${type}" was not loaded`,
|
|
437
480
|
});
|
|
438
481
|
}
|
|
439
482
|
}
|
|
@@ -441,7 +484,7 @@ export async function runAgent(
|
|
|
441
484
|
if (!survivingNames.has(name)) {
|
|
442
485
|
options.onToolActivity?.({
|
|
443
486
|
type: "end",
|
|
444
|
-
toolName: `extension-error:ext:${name} referenced by agent "${type}" but extension "${name}" is not loaded (
|
|
487
|
+
toolName: `extension-error:ext:${name} referenced by agent "${type}" but extension "${name}" is not loaded (check extensions:/exclude_extensions:)`,
|
|
445
488
|
});
|
|
446
489
|
}
|
|
447
490
|
}
|
package/src/agent-types.ts
CHANGED
|
@@ -144,6 +144,7 @@ export function getConfig(type: string): {
|
|
|
144
144
|
description: string;
|
|
145
145
|
builtinToolNames: string[];
|
|
146
146
|
extensions: true | string[] | false;
|
|
147
|
+
excludeExtensions?: string[];
|
|
147
148
|
skills: true | string[] | false;
|
|
148
149
|
promptMode: "replace" | "append";
|
|
149
150
|
} {
|
|
@@ -155,6 +156,7 @@ export function getConfig(type: string): {
|
|
|
155
156
|
description: config.description,
|
|
156
157
|
builtinToolNames: config.builtinToolNames ?? BUILTIN_TOOL_NAMES,
|
|
157
158
|
extensions: config.extensions,
|
|
159
|
+
excludeExtensions: config.excludeExtensions,
|
|
158
160
|
skills: config.skills,
|
|
159
161
|
promptMode: config.promptMode,
|
|
160
162
|
};
|
|
@@ -168,6 +170,7 @@ export function getConfig(type: string): {
|
|
|
168
170
|
description: gp.description,
|
|
169
171
|
builtinToolNames: gp.builtinToolNames ?? BUILTIN_TOOL_NAMES,
|
|
170
172
|
extensions: gp.extensions,
|
|
173
|
+
excludeExtensions: gp.excludeExtensions,
|
|
171
174
|
skills: gp.skills,
|
|
172
175
|
promptMode: gp.promptMode,
|
|
173
176
|
};
|
package/src/custom-agents.ts
CHANGED
|
@@ -60,6 +60,7 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
|
|
|
60
60
|
extSelectors,
|
|
61
61
|
disallowedTools: csvListOptional(fm.disallowed_tools),
|
|
62
62
|
extensions: inheritField(fm.extensions ?? fm.inherit_extensions),
|
|
63
|
+
excludeExtensions: csvListOptional(fm.exclude_extensions),
|
|
63
64
|
skills: inheritField(fm.skills ?? fm.inherit_skills),
|
|
64
65
|
model: str(fm.model),
|
|
65
66
|
thinking: str(fm.thinking) as ThinkingLevel | undefined,
|
package/src/index.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { type ModelRegistry, resolveModel } from "./model-resolver.js";
|
|
|
27
27
|
import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
|
|
28
28
|
import { SubagentScheduler } from "./schedule.js";
|
|
29
29
|
import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
|
|
30
|
-
import { applyAndEmitLoaded, type SubagentsSettings, saveAndEmitChanged } from "./settings.js";
|
|
30
|
+
import { applyAndEmitLoaded, type SubagentsSettings, saveAndEmitChanged, type ToolDescriptionMode } from "./settings.js";
|
|
31
31
|
import { getStatusNote } from "./status-note.js";
|
|
32
32
|
import { type AgentConfig, type AgentInvocation, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType } from "./types.js";
|
|
33
33
|
import {
|
|
@@ -533,6 +533,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
533
533
|
reloadCustomAgents(); // re-register with new setting
|
|
534
534
|
}
|
|
535
535
|
|
|
536
|
+
// ---- Agent tool description mode ----
|
|
537
|
+
// "full" (default) keeps the rich Claude Code-style description; "compact"
|
|
538
|
+
// swaps in a ~75% smaller one for small/local models (#91). Read once at
|
|
539
|
+
// tool registration — flipping it applies on the next pi session.
|
|
540
|
+
let toolDescriptionMode: ToolDescriptionMode = "full";
|
|
541
|
+
function getToolDescriptionMode(): ToolDescriptionMode { return toolDescriptionMode; }
|
|
542
|
+
function setToolDescriptionMode(mode: ToolDescriptionMode): void { toolDescriptionMode = mode; }
|
|
543
|
+
|
|
536
544
|
// ---- Batch tracking for smart join mode ----
|
|
537
545
|
// Collects background agent IDs spawned in the current turn for smart grouping.
|
|
538
546
|
// Uses a debounced timer: each new agent resets the 100ms window so that all
|
|
@@ -604,6 +612,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
604
612
|
}).join("\n");
|
|
605
613
|
};
|
|
606
614
|
|
|
615
|
+
/** First sentence of an agent description — for the compact type list. */
|
|
616
|
+
const firstSentence = (text: string): string => {
|
|
617
|
+
const match = text.match(/^.*?[.!?](?=\s|$)/s);
|
|
618
|
+
return (match ? match[0] : text).replace(/\s+/g, " ").trim();
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
/** Compact type list: one line per agent, first sentence only. */
|
|
622
|
+
const buildCompactTypeListText = () =>
|
|
623
|
+
getAvailableTypes().map((name) => {
|
|
624
|
+
const cfg = getAgentConfig(name);
|
|
625
|
+
return `- ${name}: ${firstSentence(cfg?.description ?? name)} (Tools: ${formatToolsSuffix(cfg)})`;
|
|
626
|
+
}).join("\n");
|
|
627
|
+
|
|
607
628
|
/** Derive a short model label from a model string. */
|
|
608
629
|
function getModelLabelFromConfig(model: string): string {
|
|
609
630
|
// Strip provider prefix (e.g. "anthropic/claude-sonnet-4-6" → "claude-sonnet-4-6")
|
|
@@ -624,6 +645,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
624
645
|
setSchedulingEnabled,
|
|
625
646
|
setScopeModels: setScopeModelsEnabled,
|
|
626
647
|
setDisableDefaultAgents: setDisableDefaultAgents,
|
|
648
|
+
setToolDescriptionMode: setToolDescriptionMode,
|
|
627
649
|
},
|
|
628
650
|
(event, payload) => pi.events.emit(event, payload),
|
|
629
651
|
);
|
|
@@ -652,10 +674,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
652
674
|
? `\n- Use \`schedule\` only when the user explicitly asked for scheduled / recurring / delayed execution (e.g. "every Monday", "in an hour"). Don't auto-schedule from vague intent like "monitor X" — run once now or ask.`
|
|
653
675
|
: "";
|
|
654
676
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
677
|
+
// Compact Agent tool description (#91, `toolDescriptionMode: "compact"`) —
|
|
678
|
+
// the same load-bearing facts as the full version at ~75% fewer tokens, for
|
|
679
|
+
// small/local models. Per-option details live in the param descriptions.
|
|
680
|
+
const compactAgentToolDescription = `Launch an autonomous agent for complex, multi-step tasks. Agent types:
|
|
681
|
+
${buildCompactTypeListText()}
|
|
682
|
+
|
|
683
|
+
Custom agents: .pi/agents/<name>.md (project) or ${getAgentDir()}/agents/<name>.md (global).
|
|
684
|
+
|
|
685
|
+
Notes:
|
|
686
|
+
- description: 3-5 words (shown in UI). Prompts must be self-contained — the agent has not seen this conversation.
|
|
687
|
+
- Parallel work: one message, multiple Agent calls, run_in_background: true on each. You are notified when background agents finish — never poll or sleep.
|
|
688
|
+
- The result is not shown to the user — summarize it for them. Verify an agent's claimed code changes before reporting work done.
|
|
689
|
+
- resume continues a previous agent by ID; steer_subagent messages a running one.
|
|
690
|
+
- isolation: "worktree" runs the agent in an isolated git worktree; changes land on a branch.`;
|
|
691
|
+
|
|
692
|
+
const fullAgentToolDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. Each agent type has specific capabilities and tools available to it.
|
|
659
693
|
|
|
660
694
|
Available agent types and the tools they have access to:
|
|
661
695
|
${buildTypeListText()}
|
|
@@ -696,7 +730,59 @@ Provide clear, detailed prompts so the agent can work autonomously. Brief it lik
|
|
|
696
730
|
|
|
697
731
|
Terse command-style prompts produce shallow, generic work.
|
|
698
732
|
|
|
699
|
-
**Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change
|
|
733
|
+
**Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.`;
|
|
734
|
+
|
|
735
|
+
// `toolDescriptionMode: "custom"` — user-authored description with live
|
|
736
|
+
// dynamic parts. Project file wins over global; missing/empty falls back to
|
|
737
|
+
// "full" (a stale fallback beats a blank tool description). Only the prose
|
|
738
|
+
// is customizable — the parameter schema stays code-owned.
|
|
739
|
+
const renderToolDescriptionTemplate = (template: string): string => {
|
|
740
|
+
const vars: Record<string, () => string> = {
|
|
741
|
+
typeList: buildTypeListText,
|
|
742
|
+
compactTypeList: buildCompactTypeListText,
|
|
743
|
+
agentDir: getAgentDir,
|
|
744
|
+
scheduleGuideline: () => scheduleGuideline,
|
|
745
|
+
};
|
|
746
|
+
// Replacement callback (not a string) — agent descriptions may contain `$&` etc.
|
|
747
|
+
return template.replace(/\{\{(\w+)\}\}/g, (raw, name: string) => {
|
|
748
|
+
if (vars[name]) return vars[name]();
|
|
749
|
+
console.warn(`[pi-subagents] agent-tool-description.md: unknown placeholder ${raw} left as-is`);
|
|
750
|
+
return raw;
|
|
751
|
+
});
|
|
752
|
+
};
|
|
753
|
+
|
|
754
|
+
const loadCustomToolDescription = (): string | undefined => {
|
|
755
|
+
for (const path of [
|
|
756
|
+
join(process.cwd(), ".pi", "agent-tool-description.md"),
|
|
757
|
+
join(getAgentDir(), "agent-tool-description.md"),
|
|
758
|
+
]) {
|
|
759
|
+
try {
|
|
760
|
+
if (!existsSync(path)) continue;
|
|
761
|
+
const text = readFileSync(path, "utf-8").trim();
|
|
762
|
+
if (text) return renderToolDescriptionTemplate(text);
|
|
763
|
+
console.warn(`[pi-subagents] ${path} is empty — ignoring`);
|
|
764
|
+
} catch (err) {
|
|
765
|
+
console.warn(`[pi-subagents] failed to read ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return undefined;
|
|
769
|
+
};
|
|
770
|
+
|
|
771
|
+
const agentToolDescription = (() => {
|
|
772
|
+
const mode = getToolDescriptionMode();
|
|
773
|
+
if (mode === "compact") return compactAgentToolDescription;
|
|
774
|
+
if (mode === "custom") {
|
|
775
|
+
const custom = loadCustomToolDescription();
|
|
776
|
+
if (custom) return custom;
|
|
777
|
+
console.warn('[pi-subagents] toolDescriptionMode is "custom" but no agent-tool-description.md found — using "full"');
|
|
778
|
+
}
|
|
779
|
+
return fullAgentToolDescription;
|
|
780
|
+
})();
|
|
781
|
+
|
|
782
|
+
pi.registerTool(defineTool({
|
|
783
|
+
name: SUBAGENT_TOOL_NAMES.AGENT,
|
|
784
|
+
label: "Agent",
|
|
785
|
+
description: agentToolDescription,
|
|
700
786
|
promptSnippet: "Launch autonomous sub-agents for complex multi-step tasks",
|
|
701
787
|
promptGuidelines: [
|
|
702
788
|
"Use Agent with specialized agents when the task matches an agent type's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing — if you delegate research to a subagent, do not also perform the same searches yourself.",
|
|
@@ -1494,12 +1580,12 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1494
1580
|
const activity = agentActivity.get(record.id);
|
|
1495
1581
|
|
|
1496
1582
|
await ctx.ui.custom<undefined>(
|
|
1497
|
-
(tui, theme,
|
|
1583
|
+
(tui, theme, keybindings, done) => {
|
|
1498
1584
|
return new ConversationViewer(tui, session, record, activity, theme, done, () => {
|
|
1499
1585
|
if (manager.abort(record.id)) {
|
|
1500
1586
|
ctx.ui.notify(`Stopped "${record.description}".`, "info");
|
|
1501
1587
|
}
|
|
1502
|
-
});
|
|
1588
|
+
}, keybindings);
|
|
1503
1589
|
},
|
|
1504
1590
|
{
|
|
1505
1591
|
overlay: true,
|
|
@@ -1601,6 +1687,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1601
1687
|
fmFields.push(`prompt_mode: ${cfg.promptMode}`);
|
|
1602
1688
|
if (cfg.extensions === false) fmFields.push("extensions: false");
|
|
1603
1689
|
else if (Array.isArray(cfg.extensions)) fmFields.push(`extensions: ${cfg.extensions.join(", ")}`);
|
|
1690
|
+
if (cfg.excludeExtensions?.length) fmFields.push(`exclude_extensions: ${cfg.excludeExtensions.join(", ")}`);
|
|
1604
1691
|
if (cfg.skills === false) fmFields.push("skills: false");
|
|
1605
1692
|
else if (Array.isArray(cfg.skills)) fmFields.push(`skills: ${cfg.skills.join(", ")}`);
|
|
1606
1693
|
if (cfg.disallowedTools?.length) fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`);
|
|
@@ -1869,6 +1956,7 @@ ${systemPrompt}
|
|
|
1869
1956
|
schedulingEnabled: isSchedulingEnabled(),
|
|
1870
1957
|
scopeModels: isScopeModelsEnabled(),
|
|
1871
1958
|
disableDefaultAgents: isDefaultsDisabled(),
|
|
1959
|
+
toolDescriptionMode: getToolDescriptionMode(),
|
|
1872
1960
|
};
|
|
1873
1961
|
}
|
|
1874
1962
|
|
|
@@ -1930,6 +2018,13 @@ ${systemPrompt}
|
|
|
1930
2018
|
currentValue: isDefaultsDisabled() ? "on" : "off",
|
|
1931
2019
|
values: ["on", "off"],
|
|
1932
2020
|
},
|
|
2021
|
+
{
|
|
2022
|
+
id: "toolDescriptionMode",
|
|
2023
|
+
label: "Tool description",
|
|
2024
|
+
description: "Agent tool description sent to the LLM: full (rich, default), compact (~75% fewer tokens, for small/local models), or custom (.pi/agent-tool-description.md with {{placeholders}})",
|
|
2025
|
+
currentValue: getToolDescriptionMode(),
|
|
2026
|
+
values: ["full", "compact", "custom"],
|
|
2027
|
+
},
|
|
1933
2028
|
];
|
|
1934
2029
|
}
|
|
1935
2030
|
|
|
@@ -1978,6 +2073,9 @@ ${systemPrompt}
|
|
|
1978
2073
|
const enabled = value === "on";
|
|
1979
2074
|
setDisableDefaultAgents(enabled);
|
|
1980
2075
|
notifyApplied(ctx, `Default agents ${enabled ? "disabled" : "enabled"}. Tool spec change takes effect on next pi session.`);
|
|
2076
|
+
} else if (id === "toolDescriptionMode") {
|
|
2077
|
+
setToolDescriptionMode(value as ToolDescriptionMode);
|
|
2078
|
+
notifyApplied(ctx, `Tool description set to ${value}. Takes effect on next pi session.`);
|
|
1981
2079
|
}
|
|
1982
2080
|
}
|
|
1983
2081
|
|
package/src/settings.ts
CHANGED
|
@@ -55,8 +55,21 @@ export interface SubagentsSettings {
|
|
|
55
55
|
* Defaults to false.
|
|
56
56
|
*/
|
|
57
57
|
disableDefaultAgents?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Which Agent tool description the LLM sees. "full" (default) is the rich
|
|
60
|
+
* Claude Code-style prompt; "compact" is a ~75% smaller version (one-line
|
|
61
|
+
* agent type list, terse usage notes) for small/local models where tool-spec
|
|
62
|
+
* tokens are expensive; "custom" reads `.pi/agent-tool-description.md`
|
|
63
|
+
* (project, falling back to `<agentDir>/agent-tool-description.md`) with
|
|
64
|
+
* `{{placeholder}}` substitution — a missing/empty file falls back to "full".
|
|
65
|
+
* The mode is read once at tool registration — changing it applies on the
|
|
66
|
+
* next pi session.
|
|
67
|
+
*/
|
|
68
|
+
toolDescriptionMode?: ToolDescriptionMode;
|
|
58
69
|
}
|
|
59
70
|
|
|
71
|
+
export type ToolDescriptionMode = "full" | "compact" | "custom";
|
|
72
|
+
|
|
60
73
|
/** Setter hooks used by applySettings to wire persisted values into in-memory state. */
|
|
61
74
|
export interface SettingsAppliers {
|
|
62
75
|
setMaxConcurrent: (n: number) => void;
|
|
@@ -66,12 +79,14 @@ export interface SettingsAppliers {
|
|
|
66
79
|
setSchedulingEnabled: (b: boolean) => void;
|
|
67
80
|
setScopeModels: (enabled: boolean) => void;
|
|
68
81
|
setDisableDefaultAgents: (b: boolean) => void;
|
|
82
|
+
setToolDescriptionMode: (mode: ToolDescriptionMode) => void;
|
|
69
83
|
}
|
|
70
84
|
|
|
71
85
|
/** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */
|
|
72
86
|
export type SettingsEmit = (event: string, payload: unknown) => void;
|
|
73
87
|
|
|
74
88
|
const VALID_JOIN_MODES: ReadonlySet<string> = new Set<JoinMode>(["async", "group", "smart"]);
|
|
89
|
+
const VALID_TOOL_DESCRIPTION_MODES: ReadonlySet<string> = new Set<ToolDescriptionMode>(["full", "compact", "custom"]);
|
|
75
90
|
|
|
76
91
|
// Sanity ceilings — prevent hand-edited configs from asking for values that
|
|
77
92
|
// make no operational sense (e.g. 1e6 concurrent subagents). Permissive enough
|
|
@@ -118,6 +133,9 @@ function sanitize(raw: unknown): SubagentsSettings {
|
|
|
118
133
|
if (typeof r.disableDefaultAgents === "boolean") {
|
|
119
134
|
out.disableDefaultAgents = r.disableDefaultAgents;
|
|
120
135
|
}
|
|
136
|
+
if (typeof r.toolDescriptionMode === "string" && VALID_TOOL_DESCRIPTION_MODES.has(r.toolDescriptionMode)) {
|
|
137
|
+
out.toolDescriptionMode = r.toolDescriptionMode as ToolDescriptionMode;
|
|
138
|
+
}
|
|
121
139
|
return out;
|
|
122
140
|
}
|
|
123
141
|
|
|
@@ -175,6 +193,7 @@ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers):
|
|
|
175
193
|
if (typeof s.schedulingEnabled === "boolean") appliers.setSchedulingEnabled(s.schedulingEnabled);
|
|
176
194
|
if (typeof s.scopeModels === "boolean") appliers.setScopeModels(s.scopeModels);
|
|
177
195
|
if (typeof s.disableDefaultAgents === "boolean") appliers.setDisableDefaultAgents(s.disableDefaultAgents);
|
|
196
|
+
if (s.toolDescriptionMode) appliers.setToolDescriptionMode(s.toolDescriptionMode);
|
|
178
197
|
}
|
|
179
198
|
|
|
180
199
|
/**
|
package/src/types.ts
CHANGED
|
@@ -33,6 +33,9 @@ export interface AgentConfig {
|
|
|
33
33
|
disallowedTools?: string[];
|
|
34
34
|
/** true = inherit all, string[] = only listed, false = none */
|
|
35
35
|
extensions: true | string[] | false;
|
|
36
|
+
/** Extension-name denylist applied after the `extensions:` include set. Exclude wins.
|
|
37
|
+
* Plain canonical names only (case-insensitive); no paths, no wildcard. */
|
|
38
|
+
excludeExtensions?: string[];
|
|
36
39
|
/** true = inherit all, string[] = only listed, false = none */
|
|
37
40
|
skills: true | string[] | false;
|
|
38
41
|
model?: string;
|
|
@@ -12,6 +12,7 @@ import type { AgentRecord } from "../types.js";
|
|
|
12
12
|
import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
|
|
13
13
|
import type { Theme } from "./agent-widget.js";
|
|
14
14
|
import { type AgentActivity, buildInvocationTags, describeActivity, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
|
|
15
|
+
import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
|
|
15
16
|
|
|
16
17
|
/** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
|
|
17
18
|
const CHROME_LINES_BASE = 6;
|
|
@@ -27,6 +28,7 @@ export class ConversationViewer implements Component {
|
|
|
27
28
|
private closed = false;
|
|
28
29
|
/** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */
|
|
29
30
|
private stopArmed = false;
|
|
31
|
+
private keys: ViewerKeys;
|
|
30
32
|
|
|
31
33
|
constructor(
|
|
32
34
|
private tui: TUI,
|
|
@@ -37,7 +39,10 @@ export class ConversationViewer implements Component {
|
|
|
37
39
|
private done: (result: undefined) => void,
|
|
38
40
|
/** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */
|
|
39
41
|
private onStop?: () => void,
|
|
42
|
+
/** User keybindings from `ctx.ui.custom()`. Omitted → hardcoded defaults. */
|
|
43
|
+
keybindings?: ViewerKeybindings,
|
|
40
44
|
) {
|
|
45
|
+
this.keys = createViewerKeys(keybindings);
|
|
41
46
|
this.unsubscribe = session.subscribe(() => {
|
|
42
47
|
if (this.closed) return;
|
|
43
48
|
this.tui.requestRender();
|
|
@@ -71,16 +76,16 @@ export class ConversationViewer implements Component {
|
|
|
71
76
|
const viewportHeight = this.viewportHeight();
|
|
72
77
|
const maxScroll = Math.max(0, totalLines - viewportHeight);
|
|
73
78
|
|
|
74
|
-
if (
|
|
79
|
+
if (this.keys.scrollUp(data)) {
|
|
75
80
|
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
|
|
76
81
|
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
77
|
-
} else if (
|
|
82
|
+
} else if (this.keys.scrollDown(data)) {
|
|
78
83
|
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
|
|
79
84
|
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
80
|
-
} else if (
|
|
85
|
+
} else if (this.keys.pageUp(data)) {
|
|
81
86
|
this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
|
|
82
87
|
this.autoScroll = false;
|
|
83
|
-
} else if (
|
|
88
|
+
} else if (this.keys.pageDown(data)) {
|
|
84
89
|
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
|
|
85
90
|
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
86
91
|
} else if (matchesKey(data, "home")) {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* viewer-keys.ts — Scroll key matchers for the conversation viewer.
|
|
3
|
+
*
|
|
4
|
+
* Resolves `tui.select.*` through the user's keybindings when pi provides a
|
|
5
|
+
* manager, falling back to the previous hardcoded keys otherwise. The viewer's
|
|
6
|
+
* k/j and shift+arrow aliases always work alongside whatever is bound.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { type KeyId, matchesKey } from "@earendil-works/pi-tui";
|
|
10
|
+
|
|
11
|
+
/** The `tui.select.*` keybinding ids the viewer resolves. */
|
|
12
|
+
export type ViewerScrollKeybinding =
|
|
13
|
+
| "tui.select.up"
|
|
14
|
+
| "tui.select.down"
|
|
15
|
+
| "tui.select.pageUp"
|
|
16
|
+
| "tui.select.pageDown";
|
|
17
|
+
|
|
18
|
+
/** Structural subset of pi-tui's `KeybindingsManager` (which satisfies it). */
|
|
19
|
+
export interface ViewerKeybindings {
|
|
20
|
+
matches(data: string, keybinding: ViewerScrollKeybinding): boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ViewerKeys {
|
|
24
|
+
scrollUp(data: string): boolean;
|
|
25
|
+
scrollDown(data: string): boolean;
|
|
26
|
+
pageUp(data: string): boolean;
|
|
27
|
+
pageDown(data: string): boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createViewerKeys(keybindings?: ViewerKeybindings): ViewerKeys {
|
|
31
|
+
const matches = (data: string, id: ViewerScrollKeybinding, fallback: KeyId): boolean =>
|
|
32
|
+
keybindings ? keybindings.matches(data, id) : matchesKey(data, fallback);
|
|
33
|
+
return {
|
|
34
|
+
scrollUp: (data) => matches(data, "tui.select.up", "up") || matchesKey(data, "k"),
|
|
35
|
+
scrollDown: (data) => matches(data, "tui.select.down", "down") || matchesKey(data, "j"),
|
|
36
|
+
pageUp: (data) => matches(data, "tui.select.pageUp", "pageUp") || matchesKey(data, "shift+up"),
|
|
37
|
+
pageDown: (data) => matches(data, "tui.select.pageDown", "pageDown") || matchesKey(data, "shift+down"),
|
|
38
|
+
};
|
|
39
|
+
}
|