@ferris1225/pi-subagents 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -5
- package/agents/explore.md +1 -0
- package/agents/reviewer.md +63 -45
- package/agents/worker.md +1 -0
- package/package.json +1 -1
- package/src/agents.ts +23 -6
- package/src/completion.ts +153 -0
- package/src/config.ts +51 -3
- package/src/index.ts +67 -22
- package/src/monitor.ts +5 -1
- package/src/prompt.ts +5 -4
- package/src/setup.ts +98 -17
- package/src/spawn.ts +52 -1
package/README.md
CHANGED
|
@@ -21,8 +21,8 @@ agent, and keep the workflow moving without manual polling.
|
|
|
21
21
|
- **Parallel fan-out** — run independent tasks together, with a bounded background queue.
|
|
22
22
|
- **Live progress** — a TUI widget shows each agent's status, activity, model, usage, and
|
|
23
23
|
elapsed time; completion also produces a concise notification.
|
|
24
|
-
- **Per-agent configuration** — enable agents,
|
|
25
|
-
concurrency limits, and choose discovery scope from `/subagents-setup`.
|
|
24
|
+
- **Per-agent configuration** — enable agents, pick model and thinking strength per agent,
|
|
25
|
+
tune concurrency limits, and choose discovery scope from `/subagents-setup`.
|
|
26
26
|
- **Leaf processes** — child agents cannot access the `subagent` tool, so delegation cannot
|
|
27
27
|
recurse.
|
|
28
28
|
|
|
@@ -122,13 +122,25 @@ Start dependent work after the relevant result has been delivered to the main ag
|
|
|
122
122
|
Configuration is stored at `~/.pi/agent/pi-subagents.json`. The location follows
|
|
123
123
|
`PI_CODING_AGENT_DIR` when set.
|
|
124
124
|
|
|
125
|
+
The `/subagents-setup` wizard drives the main fields interactively: for each agent, picking a
|
|
126
|
+
model is immediately followed by picking that agent's thinking strength (or inheriting the
|
|
127
|
+
agent's default — its frontmatter `thinking`, else the global default). The global
|
|
128
|
+
`thinkingLevel` is set first and applies as the final fallback. `notifyOnReviewPass` and
|
|
129
|
+
`maxResultLines` are edited directly in `pi-subagents.json`.
|
|
130
|
+
|
|
125
131
|
```json
|
|
126
132
|
{
|
|
127
133
|
"enabledAgents": ["explore", "worker", "reviewer"],
|
|
128
134
|
"agentModels": {
|
|
129
135
|
"explore": "anthropic/claude-haiku-4-5"
|
|
130
136
|
},
|
|
131
|
-
"
|
|
137
|
+
"agentThinkingLevels": {
|
|
138
|
+
"explore": "low",
|
|
139
|
+
"worker": "high"
|
|
140
|
+
},
|
|
141
|
+
"thinkingLevel": "high",
|
|
142
|
+
"notifyOnReviewPass": false,
|
|
143
|
+
"maxResultLines": 80,
|
|
132
144
|
"proactiveInjection": true,
|
|
133
145
|
"agentScope": "user",
|
|
134
146
|
"maxConcurrency": 4,
|
|
@@ -141,7 +153,10 @@ Configuration is stored at `~/.pi/agent/pi-subagents.json`. The location follows
|
|
|
141
153
|
| --- | --- |
|
|
142
154
|
| `enabledAgents` | Agent names exposed to discovery and prompt injection. An empty array disables all agents. |
|
|
143
155
|
| `agentModels` | Optional `provider/model-id` override per agent. |
|
|
144
|
-
| `
|
|
156
|
+
| `agentThinkingLevels` | Optional thinking level per agent; agents without an entry use the agent's frontmatter `thinking`, then `thinkingLevel`. |
|
|
157
|
+
| `thinkingLevel` | Default thinking level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max` (default `high`). |
|
|
158
|
+
| `notifyOnReviewPass` | When `true`, a passing reviewer result is delivered without waking the main agent (default `false`). |
|
|
159
|
+
| `maxResultLines` | Max lines of a sub-agent result carried in the completion message (default `80`). Longer results are truncated; the full text is written to a temp file whose path is included in the message. |
|
|
145
160
|
| `proactiveInjection` | Whether to add the delegation directive to the main system prompt. |
|
|
146
161
|
| `agentScope` | `user`, `project`, or `both`; controls which user/project agent directories are discovered. |
|
|
147
162
|
| `maxConcurrency` | How many sub-agent processes run at once (1–16, default 4). Extra work waits in the queue. |
|
|
@@ -155,7 +170,7 @@ The config file migrates itself on load — no manual steps after an upgrade:
|
|
|
155
170
|
- **Schema upgrades** — a config written by an older version (missing newer keys or
|
|
156
171
|
holding invalid values) is normalized and saved back with the new fields filled in.
|
|
157
172
|
- **Removed agents** — agents no longer shipped (e.g. the old `plan` agent) are stripped
|
|
158
|
-
from `enabledAgents` and `
|
|
173
|
+
from `enabledAgents`, `agentModels`, and `agentThinkingLevels` automatically.
|
|
159
174
|
|
|
160
175
|
Model selection uses this precedence:
|
|
161
176
|
|
|
@@ -166,6 +181,8 @@ configured agent model → current main-session model → agent frontmatter mode
|
|
|
166
181
|
Unavailable configured models are replaced with a usable current-session model when possible
|
|
167
182
|
and the repaired configuration is saved.
|
|
168
183
|
|
|
184
|
+
Thinking strength uses this precedence: `agentThinkingLevels` entry → agent frontmatter `thinking` → `thinkingLevel` default.
|
|
185
|
+
|
|
169
186
|
## Agent discovery and overrides
|
|
170
187
|
|
|
171
188
|
- Built-in agents are shipped with the package.
|
|
@@ -176,6 +193,10 @@ and the repaired configuration is saved.
|
|
|
176
193
|
Use a matching Markdown filename and `name` field to replace a built-in agent. Keep the task
|
|
177
194
|
brief explicit: include the goal, relevant paths, constraints, and expected handoff.
|
|
178
195
|
|
|
196
|
+
Optional frontmatter fields: `model` (default model reference) and `thinking` (default
|
|
197
|
+
thinking strength). Both are overridden by `agentModels` / `agentThinkingLevels` in
|
|
198
|
+
`pi-subagents.json` when set.
|
|
199
|
+
|
|
179
200
|
## Development
|
|
180
201
|
|
|
181
202
|
```bash
|
package/agents/explore.md
CHANGED
|
@@ -3,6 +3,7 @@ name: explore
|
|
|
3
3
|
description: Fast read-only codebase reconnaissance. Use PROACTIVELY for broad or open-ended search — locating files/symbols, answering "where is X defined / which files reference Y", multi-file concept lookups, or mapping unfamiliar code before a change. Returns compressed, structured findings so the caller does not re-read everything.
|
|
4
4
|
tools: read, grep, find, ls, bash
|
|
5
5
|
model: claude-haiku-4-5
|
|
6
|
+
thinking: low
|
|
6
7
|
# Model selection: SPEED over depth. Pick the fastest available model.
|
|
7
8
|
# What matters: fast grep/find/read, structured output. What doesn't: deep reasoning.
|
|
8
9
|
---
|
package/agents/reviewer.md
CHANGED
|
@@ -1,45 +1,63 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: reviewer
|
|
3
|
-
description: Adversarial code reviewer and pre-commit quality gate. Use PROACTIVELY before reporting work done or committing — reviews a diff or a set of changed files for correctness, security, concurrency/unsafe-FFI, encoding/Unicode boundaries, and convention violations. Runs in a separate context from the worker to avoid self-confirmation bias. Read-only; never edits, builds, or runs tests.
|
|
4
|
-
tools: read, grep, find, ls, bash
|
|
5
|
-
model: claude-sonnet-4-5
|
|
6
|
-
|
|
7
|
-
#
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
##
|
|
38
|
-
-
|
|
39
|
-
|
|
40
|
-
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
1
|
+
---
|
|
2
|
+
name: reviewer
|
|
3
|
+
description: Adversarial code reviewer and pre-commit quality gate. Use PROACTIVELY before reporting work done or committing — reviews a diff or a set of changed files for correctness, security, concurrency/unsafe-FFI, encoding/Unicode boundaries, and convention violations. Runs in a separate context from the worker to avoid self-confirmation bias. Read-only; never edits, builds, or runs tests. Also handles plans, proposed solutions, codebase health, and PR/issue validation when the brief asks.
|
|
4
|
+
tools: read, grep, find, ls, bash
|
|
5
|
+
model: claude-sonnet-4-5
|
|
6
|
+
thinking: high
|
|
7
|
+
# Model selection: ATTENTION TO DETAIL + SECURITY AWARENESS. This is the quality gate —
|
|
8
|
+
# use the strongest available reasoning model.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
You are a senior, adversarial code reviewer. Your job is to FIND WHAT IS WRONG, not to validate. Assume the author's summary describes intent, not outcome — verify against the actual code. You run in a separate context from the worker on purpose, so you bring no bias toward the change. You have NOT got the caller's conversation history.
|
|
12
|
+
|
|
13
|
+
## Hard constraints
|
|
14
|
+
- You are READ-ONLY. Do NOT modify files, run builds, or run tests.
|
|
15
|
+
- Bash is for read-only commands only: `git diff`, `git status`, `git log`, `git show`, `grep`, `find`, `cat`.
|
|
16
|
+
- Assume tool permissions are not perfectly enforceable; keep every command strictly read-only by intent.
|
|
17
|
+
|
|
18
|
+
## Review types you handle
|
|
19
|
+
Match the type to the task brief; the hunt checklist below applies to every type.
|
|
20
|
+
|
|
21
|
+
### 1. Code diffs (default)
|
|
22
|
+
1. Run `git diff` and `git status` to see the recent changes. If a specific file set was given, read those files.
|
|
23
|
+
2. Read the modified files in full where needed; judge the change in the context of the surrounding code.
|
|
24
|
+
|
|
25
|
+
### 2. Plans
|
|
26
|
+
Validate a proposed plan for feasibility and completeness: missing steps, hidden risks, alignment with the existing architecture, and whether the scope is appropriately bounded.
|
|
27
|
+
|
|
28
|
+
### 3. Proposed solutions
|
|
29
|
+
Evaluate a suggested approach: correctness and tradeoffs, fit with existing codebase patterns, simpler alternatives, edge cases the proposal may miss.
|
|
30
|
+
|
|
31
|
+
### 4. Codebase health
|
|
32
|
+
Assess key files, tests, and structure: architecture drift or tech debt, inconsistent patterns, untested or undocumented areas, obvious bugs, fragile code.
|
|
33
|
+
|
|
34
|
+
### 5. Specific PR or issue
|
|
35
|
+
Understand the context first, then verify: the fix addresses the root cause, changes are minimal and focused, no regressions, tests and docs updated as needed.
|
|
36
|
+
|
|
37
|
+
## Hunt across these categories
|
|
38
|
+
- Logic bugs, off-by-one, wrong edge-case handling.
|
|
39
|
+
- Error handling gaps; swallowed failures; unreported unrun checks.
|
|
40
|
+
- Security: injection, path traversal, secrets in code/logs, trusting untrusted input.
|
|
41
|
+
- Concurrency: shared mutable state, locks held across await, races.
|
|
42
|
+
- Encoding/Unicode: assuming `char*`/files/CLI text is UTF-8; wrong `A` vs `W` Win32 APIs; boundary conversions.
|
|
43
|
+
- Resource leaks; violations of the project's stated conventions.
|
|
44
|
+
- Classify severity honestly. Distinguish blockers from nits; do not pad with style preferences.
|
|
45
|
+
|
|
46
|
+
## Collaboration
|
|
47
|
+
- Independent of `worker` by design — your verdict is the gate before commit. Fix nothing yourself; report so the caller can dispatch a worker.
|
|
48
|
+
|
|
49
|
+
## Output format
|
|
50
|
+
## Files Reviewed
|
|
51
|
+
- `path/to/file.ts`
|
|
52
|
+
## Critical (must fix)
|
|
53
|
+
- `file.ts:42` — concrete issue and why it breaks.
|
|
54
|
+
## Warnings (should fix)
|
|
55
|
+
- `file.ts:10` — issue and suggested direction.
|
|
56
|
+
## Suggestions (consider)
|
|
57
|
+
- Optional improvements.
|
|
58
|
+
## Verdict
|
|
59
|
+
One of: APPROVE / APPROVE_WITH_NITS / REQUEST_CHANGES, plus a 2-3 sentence rationale.
|
|
60
|
+
End with exactly one machine-readable line: `VERDICT: REVIEW_PASS` for APPROVE or APPROVE_WITH_NITS; `VERDICT: REVIEW_FAIL` for REQUEST_CHANGES.
|
|
61
|
+
|
|
62
|
+
## Quality standards
|
|
63
|
+
Specific file paths and line numbers. No vague feedback. A clean report means you looked hard, not that you found nothing to say.
|
package/agents/worker.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
name: worker
|
|
3
3
|
description: General-purpose implementation agent with full tools in an isolated context. Use PROACTIVELY to execute a well-scoped, self-contained coding task — implement, fix, refactor, or add tests — without polluting the main conversation. Plans internally, then implements and verifies. Give it a complete, self-contained brief.
|
|
4
4
|
model: claude-sonnet-4-5
|
|
5
|
+
thinking: high
|
|
5
6
|
# Model selection: CODING ABILITY + TOOL USE. The primary implementation model —
|
|
6
7
|
# balance quality against cost. No `tools` field => inherits all tools (full capability).
|
|
7
8
|
---
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ferris1225/pi-subagents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Focused sub-agent delegation for pi: explore / worker / reviewer agents in isolated context, with proactive dispatch injection and per-agent model selection.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/agents.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { type Dirent, existsSync, readdirSync, readFileSync, statSync } from "no
|
|
|
15
15
|
import { dirname, join } from "node:path";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
17
|
import { CONFIG_DIR_NAME, getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
18
|
-
import type
|
|
18
|
+
import { THINKING_LEVEL_VALUES, type AgentScope, type ThinkingLevel } from "./config.ts";
|
|
19
19
|
|
|
20
20
|
export type AgentSource = "builtin" | "user" | "project";
|
|
21
21
|
|
|
@@ -24,6 +24,8 @@ export interface AgentConfig {
|
|
|
24
24
|
description: string;
|
|
25
25
|
tools?: string[];
|
|
26
26
|
model?: string;
|
|
27
|
+
/** Per-agent default thinking strength (frontmatter `thinking`); config override wins. */
|
|
28
|
+
thinking?: ThinkingLevel;
|
|
27
29
|
systemPrompt: string;
|
|
28
30
|
source: AgentSource;
|
|
29
31
|
filePath: string;
|
|
@@ -38,6 +40,11 @@ const here = dirname(fileURLToPath(import.meta.url));
|
|
|
38
40
|
/** <package>/agents — the agents shipped with this extension. */
|
|
39
41
|
export const BUILTIN_AGENTS_DIR = join(here, "..", "agents");
|
|
40
42
|
|
|
43
|
+
/** Agents shipped with the package (used by the setup wizard for per-agent defaults). */
|
|
44
|
+
export function loadBuiltinAgents(): AgentConfig[] {
|
|
45
|
+
return loadAgentsFromDir(BUILTIN_AGENTS_DIR, "builtin");
|
|
46
|
+
}
|
|
47
|
+
|
|
41
48
|
function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
|
|
42
49
|
const agents: AgentConfig[] = [];
|
|
43
50
|
if (!existsSync(dir)) return agents;
|
|
@@ -62,19 +69,29 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
|
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(content);
|
|
72
|
+
// YAML values are not guaranteed strings; anything non-string is invalid for these fields.
|
|
73
|
+
const str = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined);
|
|
74
|
+
const name = str(frontmatter.name);
|
|
75
|
+
const description = str(frontmatter.description);
|
|
65
76
|
// name + description are required; skip malformed files silently.
|
|
66
|
-
if (!
|
|
77
|
+
if (!name || !description) continue;
|
|
67
78
|
|
|
68
|
-
const
|
|
79
|
+
const rawTools = str(frontmatter.tools);
|
|
80
|
+
const tools = rawTools
|
|
69
81
|
?.split(",")
|
|
70
82
|
.map((t) => t.trim())
|
|
71
83
|
.filter(Boolean);
|
|
84
|
+
const rawThinking = str(frontmatter.thinking)?.trim();
|
|
85
|
+
const thinking = (THINKING_LEVEL_VALUES as readonly string[]).includes(rawThinking ?? "")
|
|
86
|
+
? (rawThinking as ThinkingLevel)
|
|
87
|
+
: undefined;
|
|
72
88
|
|
|
73
89
|
agents.push({
|
|
74
|
-
name
|
|
75
|
-
description
|
|
90
|
+
name,
|
|
91
|
+
description,
|
|
76
92
|
tools: tools && tools.length > 0 ? tools : undefined,
|
|
77
|
-
model: frontmatter.model,
|
|
93
|
+
model: str(frontmatter.model),
|
|
94
|
+
...(thinking ? { thinking } : {}),
|
|
78
95
|
systemPrompt: body,
|
|
79
96
|
source,
|
|
80
97
|
filePath,
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart batching for successful background completions.
|
|
3
|
+
*
|
|
4
|
+
* A short debounce coalesces sibling runs while a max-wait timer, measured from
|
|
5
|
+
* the first item in the open group, bounds delivery latency. Runs that finish
|
|
6
|
+
* shortly after an emitted group use a smaller straggler window. Failures are
|
|
7
|
+
* intentionally handled by the caller: flush held successes, then emit the
|
|
8
|
+
* failure directly so it is never delayed.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { getResultOutput, isFailedResult, reviewVerdict, type SingleResult } from "./spawn.ts";
|
|
12
|
+
|
|
13
|
+
export interface CompletionBatchTimings {
|
|
14
|
+
debounceMs: number;
|
|
15
|
+
maxWaitMs: number;
|
|
16
|
+
stragglerDebounceMs: number;
|
|
17
|
+
stragglerMaxWaitMs: number;
|
|
18
|
+
stragglerWindowMs: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_COMPLETION_BATCH_TIMINGS: CompletionBatchTimings = {
|
|
22
|
+
debounceMs: 150,
|
|
23
|
+
maxWaitMs: 1_000,
|
|
24
|
+
stragglerDebounceMs: 75,
|
|
25
|
+
stragglerMaxWaitMs: 400,
|
|
26
|
+
stragglerWindowMs: 2_000,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type TimerHandle = unknown;
|
|
30
|
+
|
|
31
|
+
export interface TimerApi {
|
|
32
|
+
setTimeout(handler: () => void, delayMs: number): TimerHandle;
|
|
33
|
+
clearTimeout(handle: TimerHandle): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const defaultTimers: TimerApi = {
|
|
37
|
+
setTimeout: (handler, delayMs) => setTimeout(handler, delayMs),
|
|
38
|
+
clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function unrefHandle(handle: TimerHandle): void {
|
|
42
|
+
if (
|
|
43
|
+
handle &&
|
|
44
|
+
typeof handle === "object" &&
|
|
45
|
+
"unref" in handle &&
|
|
46
|
+
typeof (handle as { unref: unknown }).unref === "function"
|
|
47
|
+
) {
|
|
48
|
+
(handle as { unref: () => void }).unref();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface CompletionBatcherOptions<T> {
|
|
53
|
+
emit: (items: T[]) => void;
|
|
54
|
+
timings?: Partial<CompletionBatchTimings>;
|
|
55
|
+
timers?: TimerApi;
|
|
56
|
+
now?: () => number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface CompletionBatcher<T> {
|
|
60
|
+
/** Add an item to the current debounced group. */
|
|
61
|
+
push(item: T): void;
|
|
62
|
+
/** Emit any held items immediately as one group. */
|
|
63
|
+
flush(): void;
|
|
64
|
+
/** Clear timers and return held items without emitting them. */
|
|
65
|
+
dispose(): T[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createCompletionBatcher<T>(options: CompletionBatcherOptions<T>): CompletionBatcher<T> {
|
|
69
|
+
const timers = options.timers ?? defaultTimers;
|
|
70
|
+
const now = options.now ?? Date.now;
|
|
71
|
+
const timings = { ...DEFAULT_COMPLETION_BATCH_TIMINGS, ...options.timings };
|
|
72
|
+
let pending: T[] = [];
|
|
73
|
+
let debounceTimer: TimerHandle | null = null;
|
|
74
|
+
let maxWaitTimer: TimerHandle | null = null;
|
|
75
|
+
let straggler = false;
|
|
76
|
+
let lastEmitAt: number | null = null;
|
|
77
|
+
|
|
78
|
+
const clearTimers = (): void => {
|
|
79
|
+
if (debounceTimer !== null) {
|
|
80
|
+
timers.clearTimeout(debounceTimer);
|
|
81
|
+
debounceTimer = null;
|
|
82
|
+
}
|
|
83
|
+
if (maxWaitTimer !== null) {
|
|
84
|
+
timers.clearTimeout(maxWaitTimer);
|
|
85
|
+
maxWaitTimer = null;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const emitGroup = (): void => {
|
|
90
|
+
clearTimers();
|
|
91
|
+
if (pending.length === 0) return;
|
|
92
|
+
const items = pending;
|
|
93
|
+
pending = [];
|
|
94
|
+
lastEmitAt = now();
|
|
95
|
+
options.emit(items);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
push(item: T): void {
|
|
100
|
+
if (pending.length === 0) {
|
|
101
|
+
straggler = lastEmitAt !== null && now() - lastEmitAt < timings.stragglerWindowMs;
|
|
102
|
+
}
|
|
103
|
+
pending.push(item);
|
|
104
|
+
|
|
105
|
+
if (debounceTimer !== null) timers.clearTimeout(debounceTimer);
|
|
106
|
+
const debounceDelay = straggler ? timings.stragglerDebounceMs : timings.debounceMs;
|
|
107
|
+
debounceTimer = timers.setTimeout(emitGroup, debounceDelay);
|
|
108
|
+
unrefHandle(debounceTimer);
|
|
109
|
+
|
|
110
|
+
if (maxWaitTimer === null) {
|
|
111
|
+
const maxWaitDelay = straggler ? timings.stragglerMaxWaitMs : timings.maxWaitMs;
|
|
112
|
+
maxWaitTimer = timers.setTimeout(emitGroup, maxWaitDelay);
|
|
113
|
+
unrefHandle(maxWaitTimer);
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
flush: emitGroup,
|
|
117
|
+
dispose(): T[] {
|
|
118
|
+
clearTimers();
|
|
119
|
+
const abandoned = pending;
|
|
120
|
+
pending = [];
|
|
121
|
+
return abandoned;
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface CompletionMessageItem {
|
|
127
|
+
agent: string;
|
|
128
|
+
block: string;
|
|
129
|
+
triggerTurn: boolean;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Keep the established single-result shape; add a summary only for real groups. */
|
|
133
|
+
export function formatCompletionMessage(items: readonly CompletionMessageItem[]): string {
|
|
134
|
+
if (items.length === 0) return "";
|
|
135
|
+
if (items.length === 1) return items[0].block;
|
|
136
|
+
const agents = items.map((item) => item.agent).join(", ");
|
|
137
|
+
return `### Subagents completed (${items.length}): ${agents}\n\n${items.map((item) => item.block).join("\n\n")}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A grouped completion wakes the main agent when any member requires a turn. */
|
|
141
|
+
export function completionGroupTriggersTurn(items: readonly CompletionMessageItem[]): boolean {
|
|
142
|
+
return items.some((item) => item.triggerTurn);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Passing reviewer notifications may opt out of waking; every other result wakes. */
|
|
146
|
+
export function completionTriggersTurn(result: SingleResult, notifyOnReviewPass: boolean): boolean {
|
|
147
|
+
if (isFailedResult(result)) return true;
|
|
148
|
+
return !(
|
|
149
|
+
notifyOnReviewPass &&
|
|
150
|
+
result.agent === "reviewer" &&
|
|
151
|
+
reviewVerdict(getResultOutput(result)) === "pass"
|
|
152
|
+
);
|
|
153
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -36,7 +36,12 @@ export type AgentScope = (typeof AGENT_SCOPE_VALUES)[number];
|
|
|
36
36
|
/** Thinking levels accepted by pi's `--thinking` option. */
|
|
37
37
|
export const THINKING_LEVEL_VALUES = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
38
38
|
export type ThinkingLevel = (typeof THINKING_LEVEL_VALUES)[number];
|
|
39
|
-
export const DEFAULT_THINKING_LEVEL: ThinkingLevel = "
|
|
39
|
+
export const DEFAULT_THINKING_LEVEL: ThinkingLevel = "high";
|
|
40
|
+
|
|
41
|
+
/** How many lines of a sub-agent result the completion message may carry. Default: 80. */
|
|
42
|
+
export const DEFAULT_MAX_RESULT_LINES = 80;
|
|
43
|
+
/** Upper bound accepted for maxResultLines (defensive clamp). */
|
|
44
|
+
export const MAX_RESULT_LINES_LIMIT = 2000;
|
|
40
45
|
|
|
41
46
|
export const CONFIG_FILE_NAME = "pi-subagents.json";
|
|
42
47
|
|
|
@@ -61,8 +66,21 @@ export interface SubagentsConfig {
|
|
|
61
66
|
enabledAgents: string[];
|
|
62
67
|
/** Per-agent model override, keyed by agent name, as "provider/model-id". */
|
|
63
68
|
agentModels: Record<string, string>;
|
|
64
|
-
/**
|
|
69
|
+
/** Per-agent thinking-level override, keyed by agent name. */
|
|
70
|
+
agentThinkingLevels: Record<string, ThinkingLevel>;
|
|
71
|
+
/** Thinking level for sub-agents without a per-agent override or frontmatter default. Default: "high". */
|
|
65
72
|
thinkingLevel: ThinkingLevel;
|
|
73
|
+
/**
|
|
74
|
+
* When a review passes (REVIEW_PASS verdict), deliver it without waking the
|
|
75
|
+
* main agent. Disabled by default so passing reviews still resume orchestration.
|
|
76
|
+
*/
|
|
77
|
+
notifyOnReviewPass: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Max lines of a sub-agent result carried in the completion message. Longer
|
|
80
|
+
* results are truncated; the full text is written to a temp file whose path
|
|
81
|
+
* is included in the message. Default: 80.
|
|
82
|
+
*/
|
|
83
|
+
maxResultLines: number;
|
|
66
84
|
/** Whether to inject the delegation directive into the parent system prompt. Default: true. */
|
|
67
85
|
proactiveInjection: boolean;
|
|
68
86
|
/** Which agent directories to discover from. Default: "user". */
|
|
@@ -78,7 +96,10 @@ export interface SubagentsConfig {
|
|
|
78
96
|
export const DEFAULT_CONFIG: SubagentsConfig = {
|
|
79
97
|
enabledAgents: [...DEFAULT_ENABLED_AGENTS],
|
|
80
98
|
agentModels: {},
|
|
99
|
+
agentThinkingLevels: {},
|
|
81
100
|
thinkingLevel: DEFAULT_THINKING_LEVEL,
|
|
101
|
+
notifyOnReviewPass: false,
|
|
102
|
+
maxResultLines: DEFAULT_MAX_RESULT_LINES,
|
|
82
103
|
proactiveInjection: true,
|
|
83
104
|
agentScope: "user",
|
|
84
105
|
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
|
|
@@ -121,7 +142,10 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
|
|
|
121
142
|
const config: SubagentsConfig = {
|
|
122
143
|
enabledAgents: [...DEFAULT_CONFIG.enabledAgents],
|
|
123
144
|
agentModels: {},
|
|
145
|
+
agentThinkingLevels: {},
|
|
124
146
|
thinkingLevel: DEFAULT_CONFIG.thinkingLevel,
|
|
147
|
+
notifyOnReviewPass: DEFAULT_CONFIG.notifyOnReviewPass,
|
|
148
|
+
maxResultLines: DEFAULT_CONFIG.maxResultLines,
|
|
125
149
|
proactiveInjection: DEFAULT_CONFIG.proactiveInjection,
|
|
126
150
|
agentScope: DEFAULT_CONFIG.agentScope,
|
|
127
151
|
maxConcurrency: DEFAULT_CONFIG.maxConcurrency,
|
|
@@ -147,10 +171,29 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
|
|
|
147
171
|
}
|
|
148
172
|
}
|
|
149
173
|
|
|
174
|
+
if (isRecord(raw.agentThinkingLevels)) {
|
|
175
|
+
for (const [key, value] of Object.entries(raw.agentThinkingLevels)) {
|
|
176
|
+
if (REMOVED_AGENT_NAMES.includes(key.trim())) continue;
|
|
177
|
+
if (
|
|
178
|
+
typeof value === "string" &&
|
|
179
|
+
(THINKING_LEVEL_VALUES as readonly string[]).includes(value)
|
|
180
|
+
) {
|
|
181
|
+
config.agentThinkingLevels[key.trim()] = value as ThinkingLevel;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
150
186
|
if (typeof raw.thinkingLevel === "string" && (THINKING_LEVEL_VALUES as readonly string[]).includes(raw.thinkingLevel)) {
|
|
151
187
|
config.thinkingLevel = raw.thinkingLevel as ThinkingLevel;
|
|
152
188
|
}
|
|
153
189
|
|
|
190
|
+
if (typeof raw.notifyOnReviewPass === "boolean") {
|
|
191
|
+
config.notifyOnReviewPass = raw.notifyOnReviewPass;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const maxResultLines = clampCount(raw.maxResultLines, MAX_RESULT_LINES_LIMIT);
|
|
195
|
+
if (maxResultLines !== undefined) config.maxResultLines = maxResultLines;
|
|
196
|
+
|
|
154
197
|
if (typeof raw.proactiveInjection === "boolean") {
|
|
155
198
|
config.proactiveInjection = raw.proactiveInjection;
|
|
156
199
|
}
|
|
@@ -174,7 +217,12 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
|
|
|
174
217
|
}
|
|
175
218
|
|
|
176
219
|
function defaultConfig(): SubagentsConfig {
|
|
177
|
-
return {
|
|
220
|
+
return {
|
|
221
|
+
...DEFAULT_CONFIG,
|
|
222
|
+
enabledAgents: [...DEFAULT_CONFIG.enabledAgents],
|
|
223
|
+
agentModels: {},
|
|
224
|
+
agentThinkingLevels: {},
|
|
225
|
+
};
|
|
178
226
|
}
|
|
179
227
|
|
|
180
228
|
/**
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,13 @@ import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
|
18
18
|
import { Type } from "typebox";
|
|
19
19
|
import { discoverAgents, type AgentConfig } from "./agents.ts";
|
|
20
20
|
import { BackgroundTaskQueue } from "./background.ts";
|
|
21
|
+
import {
|
|
22
|
+
completionGroupTriggersTurn,
|
|
23
|
+
completionTriggersTurn,
|
|
24
|
+
createCompletionBatcher,
|
|
25
|
+
formatCompletionMessage,
|
|
26
|
+
type CompletionMessageItem,
|
|
27
|
+
} from "./completion.ts";
|
|
21
28
|
import { getConfigPath, loadConfig, loadConfigSync, saveConfig } from "./config.ts";
|
|
22
29
|
import { repairUnavailableModelOverrides } from "./models.ts";
|
|
23
30
|
import { buildDelegationDirective } from "./prompt.ts";
|
|
@@ -28,6 +35,8 @@ import {
|
|
|
28
35
|
getResultOutput,
|
|
29
36
|
isFailedResult,
|
|
30
37
|
runSingleAgent,
|
|
38
|
+
truncateResultOutput,
|
|
39
|
+
writeResultArtifact,
|
|
31
40
|
type SingleResult,
|
|
32
41
|
type SubagentDetails,
|
|
33
42
|
type SubagentLiveEvent,
|
|
@@ -59,7 +68,7 @@ function emptyUsage(): UsageStats {
|
|
|
59
68
|
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
|
|
60
69
|
}
|
|
61
70
|
|
|
62
|
-
function queuedResult(agent: AgentConfig, task: string): SingleResult {
|
|
71
|
+
function queuedResult(agent: AgentConfig, task: string, thinking?: string): SingleResult {
|
|
63
72
|
return {
|
|
64
73
|
agent: agent.name,
|
|
65
74
|
agentSource: agent.source,
|
|
@@ -69,6 +78,7 @@ function queuedResult(agent: AgentConfig, task: string): SingleResult {
|
|
|
69
78
|
stderr: "",
|
|
70
79
|
usage: emptyUsage(),
|
|
71
80
|
model: agent.model,
|
|
81
|
+
...(thinking ? { thinking } : {}),
|
|
72
82
|
};
|
|
73
83
|
}
|
|
74
84
|
|
|
@@ -114,6 +124,19 @@ function formatUsage(usage: UsageStats): string {
|
|
|
114
124
|
return parts.join(" ");
|
|
115
125
|
}
|
|
116
126
|
|
|
127
|
+
function formatCompletionBlock(result: SingleResult, maxResultLines: number): string {
|
|
128
|
+
const status = isFailedResult(result) ? "failed" : "completed";
|
|
129
|
+
const usage = formatUsage(result.usage);
|
|
130
|
+
const output = getResultOutput(result);
|
|
131
|
+
const { text, truncated } = truncateResultOutput(output, maxResultLines);
|
|
132
|
+
const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}`, "", `Task: ${formatTaskSummary(result.task)}`, "", text];
|
|
133
|
+
if (truncated) {
|
|
134
|
+
// The full text lives on disk so the main agent can read it on demand.
|
|
135
|
+
lines.push("", `(output truncated to ${maxResultLines} lines; full result: ${writeResultArtifact(output, result.agent)})`);
|
|
136
|
+
}
|
|
137
|
+
return lines.join("\n");
|
|
138
|
+
}
|
|
139
|
+
|
|
117
140
|
export default function (pi: ExtensionAPI): void {
|
|
118
141
|
const configPath = getConfigPath(getAgentDir());
|
|
119
142
|
// Init-time decisions need the config synchronously; the full (migrating)
|
|
@@ -121,6 +144,23 @@ export default function (pi: ExtensionAPI): void {
|
|
|
121
144
|
const initialConfig = loadConfigSync(configPath);
|
|
122
145
|
const backgroundQueue = new BackgroundTaskQueue(initialConfig.maxConcurrency);
|
|
123
146
|
let sessionActive = true;
|
|
147
|
+
const sendCompletionGroup = (items: CompletionMessageItem[]): void => {
|
|
148
|
+
if (!sessionActive || items.length === 0) return;
|
|
149
|
+
const message = {
|
|
150
|
+
customType: "subagent-result",
|
|
151
|
+
content: formatCompletionMessage(items),
|
|
152
|
+
display: true,
|
|
153
|
+
};
|
|
154
|
+
if (completionGroupTriggersTurn(items)) {
|
|
155
|
+
pi.sendMessage(message, { deliverAs: "followUp", triggerTurn: true });
|
|
156
|
+
} else {
|
|
157
|
+
// No-wake delivery: nextTurn rides along with the next user turn and can
|
|
158
|
+
// never start a continuation by itself. followUp would auto-continue
|
|
159
|
+
// whenever pi is already streaming, defeating the opt-out.
|
|
160
|
+
pi.sendMessage(message, { deliverAs: "nextTurn" });
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
const completionBatcher = createCompletionBatcher<CompletionMessageItem>({ emit: sendCompletionGroup });
|
|
124
164
|
|
|
125
165
|
// Recursion guard: sub-agents at the configured depth are leaf processes and
|
|
126
166
|
// cannot delegate again. maxSubagentDepth 0 disables the tool entirely.
|
|
@@ -148,6 +188,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
148
188
|
|
|
149
189
|
pi.on("session_shutdown", () => {
|
|
150
190
|
sessionActive = false;
|
|
191
|
+
completionBatcher.dispose();
|
|
151
192
|
backgroundQueue.cancelAll();
|
|
152
193
|
});
|
|
153
194
|
|
|
@@ -162,11 +203,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
162
203
|
"Each agent has no memory of this conversation — brief it fully (goal, exact paths, constraints, expected output)."
|
|
163
204
|
].join(" "),
|
|
164
205
|
promptSnippet:
|
|
165
|
-
"Start background subagents: explore (read-only search), worker (implement), reviewer (adversarial review); completion automatically resumes the main agent.",
|
|
206
|
+
"Start background subagents: explore (read-only search), worker (implement), reviewer (adversarial review); completion automatically resumes the main agent. Simple tasks: use direct tools, not subagents.",
|
|
166
207
|
promptGuidelines: [
|
|
167
|
-
"
|
|
168
|
-
"Use subagent with agent 'explore' for broad or open-ended code search before large changes.",
|
|
169
|
-
"Use subagent with agent 'worker'
|
|
208
|
+
"Delegate only when an isolated context genuinely pays: broad exploration, a self-contained implementation, or a review gate. Handle simple lookups and one-line edits inline with direct tools — never spawn a sub-agent for them.",
|
|
209
|
+
"Use subagent with agent 'explore' for broad or open-ended code search before large changes; a targeted 'where is X' is a direct grep/read.",
|
|
210
|
+
"Use subagent with agent 'worker' for a self-contained implementation task worth a separate context; it plans internally.",
|
|
170
211
|
"Use subagent with agent 'reviewer' for a fresh read-only review before reporting work done or committing.",
|
|
171
212
|
"subagent launches work in the background and ends the current turn; when a result arrives, the main agent is automatically resumed with it.",
|
|
172
213
|
"Run independent tasks in parallel by passing a tasks array to subagent; let the automatically resumed main agent start dependent work after results arrive.",
|
|
@@ -296,8 +337,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
296
337
|
const agent = agents.find((candidate) => candidate.name === agentName);
|
|
297
338
|
if (!agent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
|
|
298
339
|
|
|
299
|
-
|
|
300
|
-
const
|
|
340
|
+
// Effective strength: config override > agent frontmatter default > global default.
|
|
341
|
+
const thinkingLevel = config.agentThinkingLevels[agent.name] ?? agent.thinking ?? config.thinkingLevel;
|
|
342
|
+
const pending = queuedResult(agent, task, thinkingLevel);
|
|
343
|
+
const runId = monitor.addRun(agent.name, task, agent.model, thinkingLevel);
|
|
301
344
|
const onLive = makeLiveHandler(runId);
|
|
302
345
|
|
|
303
346
|
backgroundQueue.enqueue(
|
|
@@ -310,7 +353,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
310
353
|
agentName,
|
|
311
354
|
task,
|
|
312
355
|
cwd,
|
|
313
|
-
thinkingLevel
|
|
356
|
+
thinkingLevel,
|
|
314
357
|
signal: backgroundSignal,
|
|
315
358
|
onLive,
|
|
316
359
|
makeDetails: makeDetails("single", true),
|
|
@@ -328,18 +371,20 @@ export default function (pi: ExtensionAPI): void {
|
|
|
328
371
|
}
|
|
329
372
|
|
|
330
373
|
if (!sessionActive) return;
|
|
331
|
-
const
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
//
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
374
|
+
const failed = isFailedResult(result);
|
|
375
|
+
const completion: CompletionMessageItem = {
|
|
376
|
+
agent: result.agent,
|
|
377
|
+
block: formatCompletionBlock(result, config.maxResultLines),
|
|
378
|
+
triggerTurn: completionTriggersTurn(result, config.notifyOnReviewPass),
|
|
379
|
+
};
|
|
380
|
+
if (failed) {
|
|
381
|
+
// Failures never wait and never hide behind a success turn: deliver
|
|
382
|
+
// first so the wake-up leads with the failure; held successes follow.
|
|
383
|
+
sendCompletionGroup([completion]);
|
|
384
|
+
completionBatcher.flush();
|
|
385
|
+
} else {
|
|
386
|
+
completionBatcher.push(completion);
|
|
387
|
+
}
|
|
343
388
|
},
|
|
344
389
|
() => finishRun(runId, "failed"),
|
|
345
390
|
);
|
|
@@ -426,7 +471,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
426
471
|
const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme);
|
|
427
472
|
const usage = formatUsage(r.usage);
|
|
428
473
|
const model = r.model ?? "?";
|
|
429
|
-
const line = `${theme.fg("toolTitle", theme.bold("subagent "))}${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`;
|
|
474
|
+
const line = `${theme.fg("toolTitle", theme.bold("subagent "))}${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${r.thinking ? ` · thinking ${r.thinking}` : ""}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`;
|
|
430
475
|
return new Text(line, 0, 0);
|
|
431
476
|
}
|
|
432
477
|
|
|
@@ -439,7 +484,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
439
484
|
const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme);
|
|
440
485
|
const usage = formatUsage(r.usage);
|
|
441
486
|
const model = r.model ?? "?";
|
|
442
|
-
lines.push(` ${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`);
|
|
487
|
+
lines.push(` ${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${r.thinking ? ` · thinking ${r.thinking}` : ""}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`);
|
|
443
488
|
}
|
|
444
489
|
return new Text(lines.join("\n"), 0, 0);
|
|
445
490
|
},
|
package/src/monitor.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface RunView {
|
|
|
26
26
|
agent: string;
|
|
27
27
|
task: string;
|
|
28
28
|
model?: string;
|
|
29
|
+
/** Effective thinking strength this run was launched with (frontmatter/config/global). */
|
|
30
|
+
thinking?: string;
|
|
29
31
|
status: RunStatus;
|
|
30
32
|
usage: UsageStats;
|
|
31
33
|
/** Concise current activity ("thinking", "read src/index.ts"); last writer wins. */
|
|
@@ -166,13 +168,14 @@ export class MonitorStore {
|
|
|
166
168
|
this.notify();
|
|
167
169
|
}
|
|
168
170
|
|
|
169
|
-
addRun(agent: string, task: string, model?: string): number {
|
|
171
|
+
addRun(agent: string, task: string, model?: string, thinking?: string): number {
|
|
170
172
|
const id = this.nextId++;
|
|
171
173
|
this.runs.push({
|
|
172
174
|
id,
|
|
173
175
|
agent,
|
|
174
176
|
task,
|
|
175
177
|
model,
|
|
178
|
+
thinking,
|
|
176
179
|
status: "queued",
|
|
177
180
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
178
181
|
});
|
|
@@ -231,6 +234,7 @@ export class MonitorStore {
|
|
|
231
234
|
const usage = formatUsageCompact(run.usage);
|
|
232
235
|
const parts = [run.agent];
|
|
233
236
|
if (run.model) parts.push(run.model);
|
|
237
|
+
if (run.thinking) parts.push(`thinking ${run.thinking}`);
|
|
234
238
|
if (usage) parts.push(usage);
|
|
235
239
|
const elapsed = formatElapsed(run);
|
|
236
240
|
if (elapsed) parts.push(elapsed);
|
package/src/prompt.ts
CHANGED
|
@@ -16,8 +16,8 @@ import { formatCatalogEntry } from "./agents.ts";
|
|
|
16
16
|
|
|
17
17
|
/** Compact role routing hints, emitted only for roles that are enabled. */
|
|
18
18
|
const ROLE_ROUTING: Record<string, string> = {
|
|
19
|
-
explore: "explore — broad/open-ended code search,
|
|
20
|
-
worker: "worker — implement/fix/refactor/test a
|
|
19
|
+
explore: "explore — broad/open-ended code search, multi-file lookups (read-only, cheap); NOT for one-line lookups.",
|
|
20
|
+
worker: "worker — implement/fix/refactor/test a self-contained task worth a separate context (full tools; plans internally).",
|
|
21
21
|
reviewer: "reviewer — adversarial pre-commit review of a diff (read-only; independent context).",
|
|
22
22
|
};
|
|
23
23
|
|
|
@@ -45,8 +45,9 @@ Available agents:
|
|
|
45
45
|
${catalog}
|
|
46
46
|
|
|
47
47
|
${routing ? `Routing:\n${routing}\n` : ""}Dispatch discipline:
|
|
48
|
-
-
|
|
49
|
-
-
|
|
48
|
+
- Handle SIMPLE work INLINE with direct tools: a single lookup, one-line edit, or a quick question is a grep/read/edit in the main context — never a sub-agent. Sub-agents cost startup time, tokens, and a context switch.
|
|
49
|
+
- Delegate only when isolation genuinely pays: broad exploration of an unfamiliar area, a self-contained implementation/fix with its own validation, or a fresh-context review gate.
|
|
50
|
+
- When in doubt, start with a direct tool call in the main context; escalate to a sub-agent only if the work turns out broad.
|
|
50
51
|
- For an already-known or trivial target, use a direct search/read tool (e.g. grep/find/read) — do not over-delegate a one-line lookup.
|
|
51
52
|
${hasMultiple ? "- Run INDEPENDENT tasks in parallel: one subagent call with a `tasks` array, and track them with your todo list. Let the automatically resumed main agent launch dependent work only after its prerequisite result arrives (e.g. explore, then worker, then reviewer).\n" : ""}- Brief each sub-agent as self-contained: goal, exact paths, constraints, expected output. It has NO memory of this conversation.
|
|
52
53
|
- Treat delegated agents as leaf workers: do not ask a sub-agent to dispatch another sub-agent; child processes do not have this tool.
|
package/src/setup.ts
CHANGED
|
@@ -27,9 +27,19 @@ import {
|
|
|
27
27
|
} from "./config.ts";
|
|
28
28
|
import { availableModelRefs, repairUnavailableModelOverrides } from "./models.ts";
|
|
29
29
|
import { promptSelectMany, promptSelectOne } from "./ui.ts";
|
|
30
|
+
import { loadBuiltinAgents } from "./agents.ts";
|
|
30
31
|
|
|
31
32
|
const INHERIT = "__inherit__";
|
|
32
33
|
|
|
34
|
+
/** Effective per-agent default strength from builtin frontmatter (config overrides win at spawn). */
|
|
35
|
+
function builtinThinkingDefaults(): Map<string, ThinkingLevel> {
|
|
36
|
+
const map = new Map<string, ThinkingLevel>();
|
|
37
|
+
for (const agent of loadBuiltinAgents()) {
|
|
38
|
+
if (agent.thinking) map.set(agent.name, agent.thinking);
|
|
39
|
+
}
|
|
40
|
+
return map;
|
|
41
|
+
}
|
|
42
|
+
|
|
33
43
|
/** Short, selection-friendly descriptions for the built-in agents. */
|
|
34
44
|
const MODULE_HINTS: Record<string, string> = {
|
|
35
45
|
explore: "read-only codebase recon (fast model)",
|
|
@@ -65,20 +75,24 @@ async function pickEnabledAgents(
|
|
|
65
75
|
);
|
|
66
76
|
}
|
|
67
77
|
|
|
68
|
-
async function
|
|
78
|
+
async function pickAgentModelsAndStrength(
|
|
69
79
|
ctx: ExtensionCommandContext,
|
|
70
80
|
enabledAgents: readonly string[],
|
|
71
|
-
|
|
72
|
-
|
|
81
|
+
currentModels: Record<string, string>,
|
|
82
|
+
currentStrengths: Record<string, ThinkingLevel>,
|
|
83
|
+
defaultLevel: ThinkingLevel,
|
|
84
|
+
defaults: ReadonlyMap<string, ThinkingLevel>,
|
|
85
|
+
): Promise<{ models: Record<string, string>; strengths: Record<string, ThinkingLevel> } | undefined> {
|
|
73
86
|
const refs = availableModelRefs(ctx);
|
|
74
87
|
if (refs.length === 0) {
|
|
75
88
|
ctx.ui.notify("No Pi models are currently available; model overrides left unchanged.", "warning");
|
|
76
|
-
return { ...
|
|
89
|
+
return { models: { ...currentModels }, strengths: { ...currentStrengths } };
|
|
77
90
|
}
|
|
78
91
|
|
|
79
|
-
const
|
|
92
|
+
const models: Record<string, string> = {};
|
|
93
|
+
const strengths: Record<string, ThinkingLevel> = {};
|
|
80
94
|
for (const name of enabledAgents) {
|
|
81
|
-
const currentRef =
|
|
95
|
+
const currentRef = currentModels[name];
|
|
82
96
|
const items = [
|
|
83
97
|
{
|
|
84
98
|
value: INHERIT,
|
|
@@ -95,9 +109,15 @@ async function pickAgentModels(
|
|
|
95
109
|
items,
|
|
96
110
|
);
|
|
97
111
|
if (choice === undefined) return undefined; // Esc aborts the whole wizard
|
|
98
|
-
if (choice !== INHERIT)
|
|
112
|
+
if (choice !== INHERIT) models[name] = choice;
|
|
113
|
+
|
|
114
|
+
// Convenience: the model pick is immediately followed by the strength pick,
|
|
115
|
+
// so per-agent model + strength are configured in one pass.
|
|
116
|
+
const strength = await pickAgentStrength(ctx, name, currentStrengths[name], defaultLevel, defaults);
|
|
117
|
+
if (strength === undefined) return undefined; // Esc aborts the whole wizard
|
|
118
|
+
if (strength !== INHERIT) strengths[name] = strength;
|
|
99
119
|
}
|
|
100
|
-
return
|
|
120
|
+
return { models, strengths };
|
|
101
121
|
}
|
|
102
122
|
|
|
103
123
|
const THINKING_LEVEL_HINTS: Record<ThinkingLevel, string> = {
|
|
@@ -107,9 +127,52 @@ const THINKING_LEVEL_HINTS: Record<ThinkingLevel, string> = {
|
|
|
107
127
|
medium: "balanced reasoning",
|
|
108
128
|
high: "deep reasoning",
|
|
109
129
|
xhigh: "extra-deep reasoning",
|
|
110
|
-
max: "strongest reasoning
|
|
130
|
+
max: "strongest reasoning",
|
|
111
131
|
};
|
|
112
132
|
|
|
133
|
+
/** Single strength pick for one agent; the inherit option keeps the effective default. */
|
|
134
|
+
async function pickAgentStrength(
|
|
135
|
+
ctx: ExtensionCommandContext,
|
|
136
|
+
agentName: string,
|
|
137
|
+
current: ThinkingLevel | undefined,
|
|
138
|
+
defaultLevel: ThinkingLevel,
|
|
139
|
+
defaults: ReadonlyMap<string, ThinkingLevel>,
|
|
140
|
+
): Promise<ThinkingLevel | typeof INHERIT | undefined> {
|
|
141
|
+
const options = THINKING_LEVEL_VALUES.map((level) => ({
|
|
142
|
+
value: level,
|
|
143
|
+
label: current === level ? `${level} — ${THINKING_LEVEL_HINTS[level]} (current)` : `${level} — ${THINKING_LEVEL_HINTS[level]}`,
|
|
144
|
+
}));
|
|
145
|
+
const agentDefault = defaults.get(agentName);
|
|
146
|
+
const inheritLabel = agentDefault
|
|
147
|
+
? `(inherit agent default — ${agentDefault})`
|
|
148
|
+
: `(inherit global default — ${defaultLevel})`;
|
|
149
|
+
const choice = await promptSelectOne(
|
|
150
|
+
ctx,
|
|
151
|
+
`Thinking strength for "${agentName}"?`,
|
|
152
|
+
"Type to filter • ↑/↓ • Enter selects • Esc cancels setup",
|
|
153
|
+
[{ value: INHERIT, label: inheritLabel }, ...options],
|
|
154
|
+
);
|
|
155
|
+
if (choice === undefined) return undefined;
|
|
156
|
+
return choice === INHERIT ? INHERIT : (choice as ThinkingLevel);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Strength picks for every enabled agent (inherit keeps the effective default). */
|
|
160
|
+
async function pickAgentStrengths(
|
|
161
|
+
ctx: ExtensionCommandContext,
|
|
162
|
+
enabledAgents: readonly string[],
|
|
163
|
+
currentStrengths: Record<string, ThinkingLevel>,
|
|
164
|
+
defaultLevel: ThinkingLevel,
|
|
165
|
+
defaults: ReadonlyMap<string, ThinkingLevel>,
|
|
166
|
+
): Promise<Record<string, ThinkingLevel> | undefined> {
|
|
167
|
+
const strengths: Record<string, ThinkingLevel> = {};
|
|
168
|
+
for (const name of enabledAgents) {
|
|
169
|
+
const strength = await pickAgentStrength(ctx, name, currentStrengths[name], defaultLevel, defaults);
|
|
170
|
+
if (strength === undefined) return undefined; // Esc aborts
|
|
171
|
+
if (strength !== INHERIT) strengths[name] = strength;
|
|
172
|
+
}
|
|
173
|
+
return strengths;
|
|
174
|
+
}
|
|
175
|
+
|
|
113
176
|
async function pickThinkingLevel(
|
|
114
177
|
ctx: ExtensionCommandContext,
|
|
115
178
|
current: ThinkingLevel,
|
|
@@ -117,7 +180,7 @@ async function pickThinkingLevel(
|
|
|
117
180
|
const options = THINKING_LEVEL_VALUES.map((level) =>
|
|
118
181
|
level === current ? `${level} — ${THINKING_LEVEL_HINTS[level]} (current)` : `${level} — ${THINKING_LEVEL_HINTS[level]}`,
|
|
119
182
|
);
|
|
120
|
-
const choice = await ctx.ui.select("
|
|
183
|
+
const choice = await ctx.ui.select("Default thinking strength for sub-agents?", options);
|
|
121
184
|
if (choice === undefined) return undefined;
|
|
122
185
|
return THINKING_LEVEL_VALUES.find((level) => choice.startsWith(`${level} —`));
|
|
123
186
|
}
|
|
@@ -202,12 +265,14 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
|
|
|
202
265
|
const enabled = await pickEnabledAgents(ctx, base.enabledAgents);
|
|
203
266
|
if (enabled === undefined) return notifyCancelled(ctx);
|
|
204
267
|
|
|
205
|
-
|
|
206
|
-
if (models === undefined) return notifyCancelled(ctx);
|
|
207
|
-
|
|
268
|
+
// Global default first, so per-agent strength picks can show "inherit" against it.
|
|
208
269
|
const thinkingLevel = await pickThinkingLevel(ctx, base.thinkingLevel);
|
|
209
270
|
if (thinkingLevel === undefined) return notifyCancelled(ctx);
|
|
210
271
|
|
|
272
|
+
const defaults = builtinThinkingDefaults();
|
|
273
|
+
const picked = await pickAgentModelsAndStrength(ctx, enabled, base.agentModels, base.agentThinkingLevels, thinkingLevel, defaults);
|
|
274
|
+
if (picked === undefined) return notifyCancelled(ctx);
|
|
275
|
+
|
|
211
276
|
const injection = await pickInjection(ctx, base.proactiveInjection);
|
|
212
277
|
if (injection === undefined) return notifyCancelled(ctx);
|
|
213
278
|
|
|
@@ -234,8 +299,11 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
|
|
|
234
299
|
|
|
235
300
|
const next: SubagentsConfig = {
|
|
236
301
|
enabledAgents: enabled,
|
|
237
|
-
agentModels: repairStaleModels(ctx, models),
|
|
302
|
+
agentModels: repairStaleModels(ctx, picked.models),
|
|
303
|
+
agentThinkingLevels: picked.strengths,
|
|
238
304
|
thinkingLevel,
|
|
305
|
+
notifyOnReviewPass: base.notifyOnReviewPass,
|
|
306
|
+
maxResultLines: base.maxResultLines,
|
|
239
307
|
proactiveInjection: injection,
|
|
240
308
|
agentScope: scope,
|
|
241
309
|
maxConcurrency,
|
|
@@ -268,13 +336,26 @@ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config:
|
|
|
268
336
|
if (enabled === undefined) return notifyCancelled(ctx);
|
|
269
337
|
next.enabledAgents = enabled;
|
|
270
338
|
} else if (choice.startsWith("Change agent models")) {
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
339
|
+
const defaults = builtinThinkingDefaults();
|
|
340
|
+
const picked = await pickAgentModelsAndStrength(
|
|
341
|
+
ctx,
|
|
342
|
+
config.enabledAgents,
|
|
343
|
+
config.agentModels,
|
|
344
|
+
config.agentThinkingLevels,
|
|
345
|
+
config.thinkingLevel,
|
|
346
|
+
defaults,
|
|
347
|
+
);
|
|
348
|
+
if (picked === undefined) return notifyCancelled(ctx);
|
|
349
|
+
next.agentModels = repairStaleModels(ctx, picked.models);
|
|
350
|
+
next.agentThinkingLevels = picked.strengths;
|
|
274
351
|
} else if (choice.startsWith("Change thinking")) {
|
|
352
|
+
// Global first so per-agent "inherit" labels reflect the value that will be stored.
|
|
275
353
|
const thinkingLevel = await pickThinkingLevel(ctx, config.thinkingLevel);
|
|
276
354
|
if (thinkingLevel === undefined) return notifyCancelled(ctx);
|
|
277
355
|
next.thinkingLevel = thinkingLevel;
|
|
356
|
+
const strengths = await pickAgentStrengths(ctx, config.enabledAgents, config.agentThinkingLevels, thinkingLevel, builtinThinkingDefaults());
|
|
357
|
+
if (strengths === undefined) return notifyCancelled(ctx);
|
|
358
|
+
next.agentThinkingLevels = strengths;
|
|
278
359
|
} else if (choice.startsWith("Toggle")) {
|
|
279
360
|
const injection = await pickInjection(ctx, config.proactiveInjection);
|
|
280
361
|
if (injection === undefined) return notifyCancelled(ctx);
|
package/src/spawn.ts
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
14
|
+
import { existsSync, mkdirSync, unlinkSync, rmdirSync, writeFileSync } from "node:fs";
|
|
14
15
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
15
|
-
import { existsSync, unlinkSync, rmdirSync } from "node:fs";
|
|
16
16
|
import { tmpdir } from "node:os";
|
|
17
17
|
import { basename, join } from "node:path";
|
|
18
18
|
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
@@ -51,6 +51,8 @@ export interface SingleResult {
|
|
|
51
51
|
stderr: string;
|
|
52
52
|
usage: UsageStats;
|
|
53
53
|
model?: string;
|
|
54
|
+
/** Effective thinking strength this run was launched with. */
|
|
55
|
+
thinking?: string;
|
|
54
56
|
stopReason?: string;
|
|
55
57
|
errorMessage?: string;
|
|
56
58
|
}
|
|
@@ -88,6 +90,54 @@ export function getFinalOutput(messages: Message[]): string {
|
|
|
88
90
|
return "";
|
|
89
91
|
}
|
|
90
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Parse the machine-readable verdict a reviewer emits (see agents/reviewer.md).
|
|
95
|
+
* Only the LAST standalone `VERDICT: REVIEW_PASS/FAIL` line counts, so a report
|
|
96
|
+
* that merely discusses the tokens cannot be misclassified. Returns undefined
|
|
97
|
+
* when no verdict marker is present, so non-review agents are never mistaken
|
|
98
|
+
* for reviews.
|
|
99
|
+
*/
|
|
100
|
+
export function reviewVerdict(output: string): "pass" | "fail" | undefined {
|
|
101
|
+
const lines = output.split("\n");
|
|
102
|
+
for (let index = lines.length - 1; index >= 0; index--) {
|
|
103
|
+
const match = /^\s*VERDICT:\s*REVIEW_(PASS|FAIL)\s*$/i.exec(lines[index]);
|
|
104
|
+
if (match) return match[1].toUpperCase() === "PASS" ? "pass" : "fail";
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Hard cap for a single line inside a truncated result (minified blobs must not blow up). */
|
|
110
|
+
export const RESULT_LINE_MAX = 200;
|
|
111
|
+
|
|
112
|
+
export interface TruncatedOutput {
|
|
113
|
+
/** The result text that fits in the completion message. */
|
|
114
|
+
text: string;
|
|
115
|
+
/** True when lines were dropped or shortened, so the full text is written to disk. */
|
|
116
|
+
truncated: boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Cap result text for the main conversation: keep the first `maxLines` lines, at most RESULT_LINE_MAX chars each. */
|
|
120
|
+
export function truncateResultOutput(output: string, maxLines: number): TruncatedOutput {
|
|
121
|
+
const lines = output.split("\n");
|
|
122
|
+
if (lines.length <= maxLines && lines.every((line) => line.length <= RESULT_LINE_MAX)) {
|
|
123
|
+
return { text: output, truncated: false };
|
|
124
|
+
}
|
|
125
|
+
const kept = lines.slice(0, maxLines).map((line) =>
|
|
126
|
+
line.length > RESULT_LINE_MAX ? `${line.slice(0, RESULT_LINE_MAX)}…` : line,
|
|
127
|
+
);
|
|
128
|
+
return { text: kept.join("\n"), truncated: true };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Persist the full result where the main agent can read it on demand. Returns the file path. */
|
|
132
|
+
export function writeResultArtifact(output: string, agentName: string): string {
|
|
133
|
+
const dir = join(tmpdir(), "pi-subagents-results");
|
|
134
|
+
mkdirSync(dir, { recursive: true });
|
|
135
|
+
const safeName = agentName.replace(/[^\w.-]+/g, "_");
|
|
136
|
+
const filePath = join(dir, `${Date.now()}-${safeName}.md`);
|
|
137
|
+
writeFileSync(filePath, output, "utf8");
|
|
138
|
+
return filePath;
|
|
139
|
+
}
|
|
140
|
+
|
|
91
141
|
export function isFailedResult(result: SingleResult): boolean {
|
|
92
142
|
return result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
|
|
93
143
|
}
|
|
@@ -238,6 +288,7 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
|
|
|
238
288
|
stderr: "",
|
|
239
289
|
usage: emptyUsage(),
|
|
240
290
|
model: agent.model,
|
|
291
|
+
thinking: thinkingLevel,
|
|
241
292
|
};
|
|
242
293
|
|
|
243
294
|
const emitUpdate = (): void => {
|