@arhen/pi-core-subagent 1.3.61 → 1.3.63
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 +7 -4
- package/package.json +6 -6
- package/src/format.ts +79 -4
- package/src/index.ts +93 -26
- package/src/manager.ts +14 -29
- package/src/modelconfig.ts +131 -0
- package/src/models.ts +184 -0
- package/src/presentation.ts +209 -0
- package/src/schemas.ts +26 -5
- package/src/types.ts +52 -0
package/README.md
CHANGED
|
@@ -53,7 +53,7 @@ flowchart LR
|
|
|
53
53
|
- **Agent files respected.** A spawn goal (name + task) that matches a user agent file's `description` (`.agents/agents`, `.claude/agents`, `.pi/agents` — project then home) loads that file — body = system prompt, frontmatter `model`/`tools` apply, file `model` validated against the pi model registry. File wins over inline; no match → on-demand definition.
|
|
54
54
|
- **Two toolsets, plus explicit override.** Read-only (`read, grep, find, ls, codemode` — default) or write (`read, grep, find, ls, bash, edit, write, codemode` — `write: true`); `tools:` sets an explicit per-task allowlist. Children run with `noExtensions`, so `codemode` is the one extension they get: it lets a child batch `tools.*` calls instead of spending a model turn per call.
|
|
55
55
|
- **In-process** — children are `AgentSession`s in the same runtime. No process spawn, no context bleed.
|
|
56
|
-
- **Zero parent-context injection.** No catalog, no context hook.
|
|
56
|
+
- **Zero parent-context injection.** No catalog, no context hook. 9 slim tools total.
|
|
57
57
|
- **Throttled updates** — widget/stream updates coalesce to ~6/s; no per-event deep clones.
|
|
58
58
|
- **Bounded always** — a child is limited only by wall clock: explicit `maxRuntimeMs`, else the 1 h ceiling with `/subagents auto-limit on`, else the 6 h safety ceiling (default).
|
|
59
59
|
|
|
@@ -284,10 +284,13 @@ Background (default) + intercom — the run returns a runId immediately; you sta
|
|
|
284
284
|
|
|
285
285
|
**Resuming a failed child:** a child that dies mid-work (provider rate limit, timeout, network error) keeps its session JSONL and its worktree branch. `resume_subagent({ runId, taskId, model?: "openai/gpt-5", message? })` reopens that session with full context, re-attaches the branch, and prompts it to recap and continue — no respawn, no lost tokens. `model` swaps provider when the original one is exhausted. Refused for tasks that never started (no session file); those you respawn. Wait for the run to settle before resuming (the tool tells you if it hasn't).
|
|
286
286
|
|
|
287
|
+
**Choosing a model:** `subagent_models` lists what this session may use — the same set `/scoped-models` shows when scoping is configured, and every model with usable credentials when it is not (the output states which case applies). Each row gives the exact `model` value to pass, the thinking levels the runtime honors, the context window, and pi's catalog price per million tokens (`free` only when the reported rates are zero; absent rates read `unavailable`; catalog rates are a planning guide, not a billing quote). `model` is optional: omit it to inherit the leader's current session model, or name one (a matched agent file's `model` frontmatter still wins) to pin the run. `~/.pi/agent/subagent-models.json` may shape the listing only — `prefer` sorts, `hide` omits, `default` is surfaced as a suggestion and never applied. It never grants or blocks a model: a hidden model still runs when named, because pi owns what may run.
|
|
288
|
+
|
|
287
289
|
## Tools
|
|
288
290
|
|
|
289
291
|
| Tool | Purpose |
|
|
290
292
|
|---|---|
|
|
293
|
+
| `subagent_models` | list the models a subagent task may name, each with the exact `model` value, thinking levels the runtime honors, context window, and catalog price; scoped to the session's enabled set when scoping is configured, else the full available catalogue (the output says which) |
|
|
291
294
|
| `subagent` | single / `tasks` (parallel or graph via `needs`) / `chain` (`{previous}`); every run is background — returns a runId, completion notifies you; `autoAwait:true` parks the call until the run finishes and returns the final result inline; children always carry talk tools (ask/notify/mailbox); `notifyPerTask` (default true) wakes you as each task completes |
|
|
292
295
|
| `subagent_status` | live per-task snapshot (non-blocking), including each child's session file path |
|
|
293
296
|
| `subagent_result` | full output of a run or one task |
|
|
@@ -299,7 +302,7 @@ Background (default) + intercom — the run returns a runId immediately; you sta
|
|
|
299
302
|
|
|
300
303
|
### Per-task fields
|
|
301
304
|
|
|
302
|
-
`agent` (name you invent — required), `task` (required), `prompt` (system prompt, optional — minimal default used), `write` (toolset, default read-only), plus optional `model` (`provider/model-id
|
|
305
|
+
`agent` (name you invent — required), `task` (required), `prompt` (system prompt, optional — minimal default used), `write` (toolset, default read-only), plus optional `model` (`provider/model-id`; omitted → the leader's current session model, a matched agent file's frontmatter wins), `thinking` (validated enum: `off|minimal|low|medium|high|xhigh|max`), `tools` (explicit allowlist), `cwd`, `maxRuntimeMs`, `id`, `needs` (dependency edges — see [Graph mode](#graph-mode--needs)). Top-level only: `autoAwait`, `notifyPerTask`, `concurrency`.
|
|
303
306
|
|
|
304
307
|
### Child talk tools (always on)
|
|
305
308
|
|
|
@@ -356,9 +359,9 @@ The extension has no multiplexer integration and does not want one: it exposes t
|
|
|
356
359
|
|
|
357
360
|
## Context budget
|
|
358
361
|
|
|
359
|
-
- Parent tools:
|
|
362
|
+
- Parent tools: 9 schemas with short descriptions. **No catalog, no context hook** — nothing injected per request.
|
|
360
363
|
- Background completion: 3-line notice. Full text only via `subagent_result`.
|
|
361
|
-
- Children: isolated sessions; talk tools always injected; each child's prompt states its own task id and its siblings' so mailbox addressing works. Model resolution: explicit `provider/model-id` or bare id via the pi model registry → the parent's current model → settings default. Thinking levels validated against the resolved model's `thinkingLevelMap
|
|
364
|
+
- Children: isolated sessions; talk tools always injected; each child's prompt states its own task id and its siblings' so mailbox addressing works. Model resolution: explicit `provider/model-id` or bare id via the pi model registry → the parent's current model → settings default. Thinking levels validated against the resolved model's `thinkingLevelMap`; `subagent_models` lists the levels the runtime honors when you need a safe set.
|
|
362
365
|
|
|
363
366
|
## What this is built on
|
|
364
367
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arhen/pi-core-subagent",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.63",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "pi extension: fast in-process subagents with a dependency-graph scheduler (needs edges gate tasks and carry upstream output into dependent prompts), plus background runs, intercom and agent-to-agent mailbox. Leader defines agents inline.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,11 +29,11 @@
|
|
|
29
29
|
"graph-protocol"
|
|
30
30
|
],
|
|
31
31
|
"peerDependencies": {
|
|
32
|
-
"@earendil-works/pi-ai": "
|
|
33
|
-
"@earendil-works/pi-agent-core": "
|
|
34
|
-
"@earendil-works/pi-coding-agent": "
|
|
35
|
-
"@earendil-works/pi-tui": "
|
|
36
|
-
"typebox": "
|
|
32
|
+
"@earendil-works/pi-ai": "*",
|
|
33
|
+
"@earendil-works/pi-agent-core": "*",
|
|
34
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
35
|
+
"@earendil-works/pi-tui": "*",
|
|
36
|
+
"typebox": "*"
|
|
37
37
|
},
|
|
38
38
|
"pi": {
|
|
39
39
|
"extensions": [
|
package/src/format.ts
CHANGED
|
@@ -3,6 +3,8 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
|
3
3
|
import { type Component, truncateToWidth } from "@earendil-works/pi-tui";
|
|
4
4
|
import {
|
|
5
5
|
MAX_TASKS,
|
|
6
|
+
type ModelCatalog,
|
|
7
|
+
type ModelPricing,
|
|
6
8
|
type RunSnapshot,
|
|
7
9
|
type RunStatus,
|
|
8
10
|
type TaskSnapshot,
|
|
@@ -246,8 +248,8 @@ export function makeTaskNotice(run: RunSnapshot, task: TaskSnapshot, kind: strin
|
|
|
246
248
|
isStartupFailure(task, kind)
|
|
247
249
|
? "Never started — stop and diagnose before spawning anything else: a config-level error (model, plan, auth, agent file) fails identically on every respawn."
|
|
248
250
|
: kind === "completed"
|
|
249
|
-
? `Use subagent_result(runId: "${run.id}", taskId: "${task.id}") for full output.`
|
|
250
|
-
: `Session file kept — resume_subagent(runId: "${run.id}", taskId: "${task.id}", model?: ...) revives it with full context. subagent_result for what it produced so far.`,
|
|
251
|
+
? `Use subagent_result({ runId: "${run.id}", taskId: "${task.id}" }) for full output.`
|
|
252
|
+
: `Session file kept — resume_subagent({ runId: "${run.id}", taskId: "${task.id}", model?: ... }) revives it with full context. subagent_result for what it produced so far.`,
|
|
251
253
|
].join("\n");
|
|
252
254
|
}
|
|
253
255
|
export function makeAskNotice(
|
|
@@ -255,7 +257,7 @@ export function makeAskNotice(
|
|
|
255
257
|
extra: { taskId?: string; agent?: string; question?: string; urgent?: boolean },
|
|
256
258
|
): string {
|
|
257
259
|
const who = extra.agent ? `${extra.agent} (${extra.taskId ?? "task"})` : (extra.taskId ?? "a subagent");
|
|
258
|
-
const reply = `reply_subagent(runId: "${run.id}", taskId: "${extra.taskId ?? ""}", message: ...)`;
|
|
260
|
+
const reply = `reply_subagent({ runId: "${run.id}", taskId: "${extra.taskId ?? ""}", message: ... })`;
|
|
259
261
|
return extra.urgent
|
|
260
262
|
? `[URGENT] Subagent ${who} is blocked and cannot continue until you answer: ${extra.question ?? ""}\nAnswer now, before your next step, with ${reply}.`
|
|
261
263
|
: `[not urgent] Subagent ${who} asks: ${extra.question ?? ""}\nIt waits while you keep working — finish your current step first if you want, then answer with ${reply}.`;
|
|
@@ -268,6 +270,79 @@ export function makeNotice(run: RunSnapshot, kind: string): string {
|
|
|
268
270
|
for (const task of run.tasks) {
|
|
269
271
|
lines.push(`- ${task.agent}: ${task.status}${task.error ? ` — ${truncateText(task.error, 200)}` : ""}`);
|
|
270
272
|
}
|
|
271
|
-
lines.push(`Use subagent_result(runId: "${run.id}") for full output.`);
|
|
273
|
+
lines.push(`Use subagent_result({ runId: "${run.id}" }) for full output.`);
|
|
272
274
|
return lines.join("\n");
|
|
273
275
|
}
|
|
276
|
+
|
|
277
|
+
/** Per-million-token rates, terse. A zero rate reads as "free"; absent rates are "unavailable", not free. */
|
|
278
|
+
function priceTag(cost: ModelPricing | undefined): string {
|
|
279
|
+
if (!cost) return "unavailable (provider did not report rates)";
|
|
280
|
+
if (cost.input === 0 && cost.output === 0 && cost.cacheRead === 0 && cost.cacheWrite === 0) return "free";
|
|
281
|
+
const rate = (n: number) => (n === 0 ? "free" : `$${n.toFixed(n < 0.01 ? 4 : 2)}`);
|
|
282
|
+
const parts = [`in ${rate(cost.input)}`, `out ${rate(cost.output)}`];
|
|
283
|
+
if (cost.cacheRead === undefined) parts.push("cache-read unavailable");
|
|
284
|
+
else if (cost.cacheRead > 0) parts.push(`cache-read ${rate(cost.cacheRead)}`);
|
|
285
|
+
if (cost.cacheWrite === undefined) parts.push("cache-write unavailable");
|
|
286
|
+
else if (cost.cacheWrite > 0) parts.push(`cache-write ${rate(cost.cacheWrite)}`);
|
|
287
|
+
return `${parts.join(", ")} per Mtok`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Render the model catalog as the `subagent_models` tool result. Pure, so the exact text an agent
|
|
292
|
+
* receives is testable: the tool handler holds no formatting of its own. Throws when the list is
|
|
293
|
+
* empty — the SDK tool loop discards a returned `isError` for any `execute` that does not throw.
|
|
294
|
+
*/
|
|
295
|
+
export function renderModelCatalog(catalog: ModelCatalog): {
|
|
296
|
+
content: { type: "text"; text: string }[];
|
|
297
|
+
details: ModelCatalog;
|
|
298
|
+
} {
|
|
299
|
+
if (catalog.models.length === 0) {
|
|
300
|
+
throw new Error(
|
|
301
|
+
`No models can be listed for subagent tasks: ${catalog.unavailable ?? "the model registry returned no models"}. This is not an empty catalog — a task without \`model\` still inherits the session model, but naming one needs the references. Fix model configuration, then retry.`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
const lines = catalog.models.map((model) =>
|
|
305
|
+
[
|
|
306
|
+
`- model: "${model.reference}"`,
|
|
307
|
+
` ${model.name}; ${model.contextWindow > 0 ? `${model.contextWindow.toLocaleString("en-US")} context` : "context window unreported"}`,
|
|
308
|
+
` price: ${priceTag(model.cost)}`,
|
|
309
|
+
model.reasoning
|
|
310
|
+
? ` thinking levels: ${model.thinkingLevels.join(" | ")}`
|
|
311
|
+
: ` thinking: not supported — omit it or pass "off"`,
|
|
312
|
+
].join("\n"),
|
|
313
|
+
);
|
|
314
|
+
const heading =
|
|
315
|
+
catalog.scope === "session"
|
|
316
|
+
? `${catalog.models.length} model(s) enabled for this session. Pass the \`model\` value verbatim in a subagent task:`
|
|
317
|
+
: `${catalog.models.length} model(s) available — this session has no model scoping, so every model with usable credentials is listed. Pass the \`model\` value verbatim in a subagent task:`;
|
|
318
|
+
const suggestion = catalog.preferredDefault
|
|
319
|
+
? `\n\nThis configuration suggests \`model: "${catalog.preferredDefault}"\`. It is a preference, never applied automatically.`
|
|
320
|
+
: "";
|
|
321
|
+
const unlistedDefault = catalog.unlistedDefault
|
|
322
|
+
? `\n\nNOTE: configured default \`${catalog.unlistedDefault}\` is not in the displayed catalog; it is not suggested or applied.`
|
|
323
|
+
: "";
|
|
324
|
+
const hidden =
|
|
325
|
+
catalog.hidden && catalog.hidden > 0
|
|
326
|
+
? `\n\n${catalog.hidden} enabled model(s) are hidden by your model preferences.`
|
|
327
|
+
: "";
|
|
328
|
+
const unused = catalog.unusedPatterns?.length
|
|
329
|
+
? `\n\nNOTE: these preference patterns matched no listed model and did nothing: ${catalog.unusedPatterns.join(", ")}. They may target models that are not enabled.`
|
|
330
|
+
: "";
|
|
331
|
+
const ambiguous = catalog.ambiguous?.length
|
|
332
|
+
? `\n\nDo not pass these references: ${catalog.ambiguous.join(", ")} — ${catalog.reason}.`
|
|
333
|
+
: "";
|
|
334
|
+
const unresolved = catalog.unresolved?.length ? `\n\n${catalog.unresolvedReason}` : "";
|
|
335
|
+
const configError = catalog.configError
|
|
336
|
+
? `\n\nWARNING: the model preferences file could not be used (${catalog.configError}). Continuing with no preferences.`
|
|
337
|
+
: "";
|
|
338
|
+
const billing = "\n\nPrices are pi catalog list rates per Mtok, not a billing quote.";
|
|
339
|
+
return {
|
|
340
|
+
content: [
|
|
341
|
+
{
|
|
342
|
+
type: "text",
|
|
343
|
+
text: `${heading}\n${lines.join("\n")}${suggestion}${unlistedDefault}${hidden}${unused}${ambiguous}${unresolved}${configError}${billing}`,
|
|
344
|
+
},
|
|
345
|
+
],
|
|
346
|
+
details: catalog,
|
|
347
|
+
};
|
|
348
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
-
import {
|
|
3
|
+
import type { TSchema } from "typebox";
|
|
4
|
+
import {
|
|
5
|
+
compactLines,
|
|
6
|
+
formatUsage,
|
|
7
|
+
makeSummary,
|
|
8
|
+
renderModelCatalog,
|
|
9
|
+
statusIcon,
|
|
10
|
+
taskLine,
|
|
11
|
+
truncateText,
|
|
12
|
+
} from "./format.ts";
|
|
4
13
|
import { waveNotation } from "./graph.ts";
|
|
5
14
|
import { cloneRun, type ParkedMsg, SubagentManager } from "./manager.ts";
|
|
15
|
+
import { listSelectableModels } from "./models.ts";
|
|
6
16
|
import { createPeekPane, type PeekTask } from "./peek.ts";
|
|
17
|
+
import { type AnyToolDefinition, createPresentation, isSubagentMode } from "./presentation.ts";
|
|
7
18
|
import {
|
|
8
19
|
AwaitParam,
|
|
20
|
+
ModelsParam,
|
|
9
21
|
ReplyParam,
|
|
10
22
|
ResultParam,
|
|
11
23
|
ResumeParam,
|
|
@@ -14,11 +26,18 @@ import {
|
|
|
14
26
|
SubagentParams,
|
|
15
27
|
type SubagentParamsShape,
|
|
16
28
|
} from "./schemas.ts";
|
|
17
|
-
import { type RunDetails, type RunSnapshot, TERMINAL } from "./types.ts";
|
|
29
|
+
import { type ModelCatalog, type RunDetails, type RunSnapshot, TERMINAL } from "./types.ts";
|
|
18
30
|
import { cleanupMerged, ownerAlive, reapDeadWorktrees, repoRoot, sweepStale } from "./worktree.ts";
|
|
19
31
|
|
|
20
32
|
export default function (pi: ExtensionAPI) {
|
|
21
33
|
const manager = new SubagentManager(pi);
|
|
34
|
+
const definitions: AnyToolDefinition[] = [];
|
|
35
|
+
const presentation = createPresentation(pi, definitions);
|
|
36
|
+
const defineTool = <TParams extends TSchema, TDetails = unknown, TState = any>(
|
|
37
|
+
definition: ToolDefinition<TParams, TDetails, TState>,
|
|
38
|
+
): void => {
|
|
39
|
+
definitions.push(definition as AnyToolDefinition);
|
|
40
|
+
};
|
|
22
41
|
|
|
23
42
|
const openPeek = async (ctx: ExtensionContext) => {
|
|
24
43
|
if (!ctx.hasUI) return;
|
|
@@ -55,11 +74,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
55
74
|
};
|
|
56
75
|
pi.registerCommand("subagents", {
|
|
57
76
|
description:
|
|
58
|
-
"List subagent runs. `/subagents peek` opens the browsable pane; `/subagents auto-limit on|off` toggles the 1 h default runtime ceiling (default off = 6 h).",
|
|
77
|
+
"List subagent runs. `/subagents peek` opens the browsable pane; `/subagents mode [auto|direct|codemode]` reports or switches the tool exposure profile; `/subagents auto-limit on|off` toggles the 1 h default runtime ceiling (default off = 6 h).",
|
|
59
78
|
handler: async (args, ctx) => {
|
|
60
79
|
const arg = String(args ?? "")
|
|
61
80
|
.trim()
|
|
62
81
|
.toLowerCase();
|
|
82
|
+
if (arg === "mode" || arg.startsWith("mode ")) {
|
|
83
|
+
const value = arg.split(/\s+/)[1];
|
|
84
|
+
if (value === undefined) {
|
|
85
|
+
ctx.ui.notify(presentation.describe(), "info");
|
|
86
|
+
} else if (isSubagentMode(value)) {
|
|
87
|
+
presentation.setPreference(value);
|
|
88
|
+
// A switch during a streaming turn or an executing script would rewrite the loadout under
|
|
89
|
+
// a live call; defer it to the next request boundary in that case.
|
|
90
|
+
const idle = typeof ctx.isIdle === "function" ? ctx.isIdle() : true;
|
|
91
|
+
if (idle) presentation.sync();
|
|
92
|
+
ctx.ui.notify(
|
|
93
|
+
`Subagent exposure mode set to ${value}${idle ? "." : " — applies at the next request boundary."} ${presentation.describe()}`,
|
|
94
|
+
"info",
|
|
95
|
+
);
|
|
96
|
+
} else {
|
|
97
|
+
ctx.ui.notify(`Unknown subagent mode "${value}". Use \`/subagents mode auto|direct|codemode\`.`, "warning");
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
63
101
|
if (arg === "peek") return openPeek(ctx);
|
|
64
102
|
if (arg === "auto-limit" || arg.startsWith("auto-limit ")) {
|
|
65
103
|
const value = arg.split(/\s+/)[1];
|
|
@@ -93,8 +131,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
93
131
|
manager.turnActivity = false;
|
|
94
132
|
});
|
|
95
133
|
|
|
134
|
+
pi.on("before_agent_start", () => {
|
|
135
|
+
presentation.sync();
|
|
136
|
+
});
|
|
137
|
+
// Successive model turns of the same run are safe boundaries too: a codemode availability or
|
|
138
|
+
// helper-selection change made between turns must reach the next request without a new prompt.
|
|
139
|
+
pi.on("turn_start", () => {
|
|
140
|
+
presentation.sync();
|
|
141
|
+
});
|
|
142
|
+
|
|
96
143
|
pi.on("session_start", async (_event, ctx) => {
|
|
97
144
|
await manager.restoreFromSidecar(ctx);
|
|
145
|
+
presentation.restore(ctx);
|
|
146
|
+
presentation.sync();
|
|
98
147
|
|
|
99
148
|
const roots = new Set<string>();
|
|
100
149
|
const cwdRoot = repoRoot(ctx.cwd);
|
|
@@ -115,6 +164,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
115
164
|
} catch {}
|
|
116
165
|
}
|
|
117
166
|
});
|
|
167
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
168
|
+
presentation.restore(ctx);
|
|
169
|
+
presentation.sync();
|
|
170
|
+
});
|
|
171
|
+
|
|
118
172
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
119
173
|
if (ctx?.hasUI) {
|
|
120
174
|
try {
|
|
@@ -124,25 +178,36 @@ export default function (pi: ExtensionAPI) {
|
|
|
124
178
|
manager.clearRuns();
|
|
125
179
|
});
|
|
126
180
|
|
|
127
|
-
|
|
181
|
+
defineTool<typeof ModelsParam, ModelCatalog>({
|
|
182
|
+
name: "subagent_models",
|
|
183
|
+
label: "Subagent Models",
|
|
184
|
+
description:
|
|
185
|
+
"List the models a subagent task may name: the exact `model` value to pass, the thinking levels the runtime honors, the context window and catalog price. Scoped to this session's models when scoping is configured, else every usable model; ambiguous references are called out. Full reference: `describeNamespace('subagents')`.",
|
|
186
|
+
promptSnippet: "List the models a subagent task can name (reference, thinking levels, context, price).",
|
|
187
|
+
parameters: ModelsParam,
|
|
188
|
+
async execute(_id, _params, _signal, _onUpdate, ctx) {
|
|
189
|
+
return renderModelCatalog(listSelectableModels(ctx));
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
defineTool<typeof SubagentParams, RunDetails>({
|
|
128
194
|
name: "subagent",
|
|
129
195
|
label: "Subagent",
|
|
130
196
|
|
|
131
197
|
description:
|
|
132
|
-
"Run isolated subagents (own context
|
|
198
|
+
"Run isolated subagents (own context/session) in the background; returns a runId and completion notifies you. One call = one agent (`agent`+`task`) or many (`tasks`, `chain`, or `needs` edges that gate tasks and prepend upstream output). Write tasks use an isolated git worktree and report a branch; a matching agent file is authoritative (matched by description, body/model win). Full reference: `describeNamespace('subagents')`.",
|
|
133
199
|
promptSnippet: "Define and delegate work to specialized subagents.",
|
|
134
200
|
promptGuidelines: [
|
|
201
|
+
"`model` is optional: omit it to inherit your current session model, or name one (agent-file `model` frontmatter wins) to pin the run. Call subagent_models for the exact references, thinking levels, and prices this session may use.",
|
|
135
202
|
"Use subagent when independent review, testing, research, or parallel analysis improves quality.",
|
|
136
203
|
"Batch every sub-task in ONE call: subagent({ tasks: [...] }) — never multiple parallel subagent calls.",
|
|
137
204
|
"Declare ordering with `needs` edges on the tasks, never by splitting into separate calls; dependents receive upstream outputs automatically — do not restate them. Prefer flat `tasks` (plain parallel); add `needs` only when ordering genuinely matters.",
|
|
205
|
+
"Define each agent inline: invented name, focused system prompt, read-only by default (write:true to edit). Agent files are matched by description/goal, not name; a match is authoritative (body/model), and only per-call tools/write override its tools.",
|
|
206
|
+
"Write agents work in an isolated git worktree; review the branch diff and merge with `git merge --no-ff <branch>` when done.",
|
|
207
|
+
"After spawning, call subagent_status({ runId }) ONCE to confirm the tasks started; fix or respawn a task that died on spawn.",
|
|
138
208
|
"End each task with a runnable check, e.g. 'Verify: bun test'. A subagent's claim of success is not evidence.",
|
|
139
|
-
"
|
|
140
|
-
"
|
|
141
|
-
"Right after spawning, call subagent_status(runId) ONCE before any other work — a child that died on spawn (or never started) is invisible until far later otherwise. If it shows a task failed/never started, fix or respawn immediately.",
|
|
142
|
-
"Never block with nothing to do: if you have no work left after spawning, end your turn — completion notifies you and wakes a fresh turn with the results. await_subagent/autoAwait while idle only burns time and tokens.",
|
|
143
|
-
"A failed task interrupts you immediately as a steering message — handle it in the same turn (resume, swap model, re-dispatch) instead of finishing the plan on a broken intermediate result. Completes and aborts queue as follow-ups.",
|
|
144
|
-
"autoAwait:true only when this SAME turn must consume the result immediately. await_subagent is for syncing with your own parallel work — not the default follow-up to a spawn.",
|
|
145
|
-
"A task that failed mid-work (provider error, rate limit, timeout) keeps its session file and branch: resume_subagent(runId, taskId, model?) revives it with full context — prefer that over respawning. Respawn only when it never started (no session file).",
|
|
209
|
+
"When you have no work left, end your turn — completion notifies you. await_subagent/autoAwait only when this turn must consume the result immediately.",
|
|
210
|
+
"A failed task keeps its session file and branch: resume_subagent({ runId, taskId }) revives it; respawn only when it never started.",
|
|
146
211
|
],
|
|
147
212
|
parameters: SubagentParams,
|
|
148
213
|
executionMode: "parallel",
|
|
@@ -172,7 +237,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
172
237
|
? `\n${asks.length} child(ren) waiting for your answer:\n${asks
|
|
173
238
|
.map(
|
|
174
239
|
(a) =>
|
|
175
|
-
`- ${a.agent} (${a.taskId}): ${a.text}\n reply_subagent(runId: "${run.id}", taskId: "${a.taskId}", message: ...)`,
|
|
240
|
+
`- ${a.agent} (${a.taskId}): ${a.text}\n reply_subagent({ runId: "${run.id}", taskId: "${a.taskId}", message: ... })`,
|
|
176
241
|
)
|
|
177
242
|
.join("\n")}\nAnswer each, then await_subagent again for the result.`
|
|
178
243
|
: "",
|
|
@@ -185,7 +250,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
185
250
|
content: [
|
|
186
251
|
{
|
|
187
252
|
type: "text",
|
|
188
|
-
text: `Background run started: ${details.run.id} (${details.run.mode}, ${details.run.tasks.length} task${details.run.tasks.length > 1 ? "s" : ""}).\nNext: call subagent_status("${details.run.id}") now to confirm the tasks actually started before doing anything else.\nAfter that, completion will notify you — if you have no other work, end your turn instead of waiting.\nOther tools: subagent_result / reply_subagent / steer_subagent / resume_subagent / subagent_cancel.`,
|
|
253
|
+
text: `Background run started: ${details.run.id} (${details.run.mode}, ${details.run.tasks.length} task${details.run.tasks.length > 1 ? "s" : ""}).\nNext: call subagent_status({ runId: "${details.run.id}" }) now to confirm the tasks actually started before doing anything else.\nAfter that, completion will notify you — if you have no other work, end your turn instead of waiting.\nOther tools: subagent_result / reply_subagent / steer_subagent / resume_subagent / subagent_cancel.`,
|
|
189
254
|
},
|
|
190
255
|
],
|
|
191
256
|
details,
|
|
@@ -259,11 +324,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
259
324
|
},
|
|
260
325
|
});
|
|
261
326
|
|
|
262
|
-
|
|
327
|
+
defineTool<typeof RunIdParam, { run?: RunSnapshot }>({
|
|
263
328
|
name: "subagent_status",
|
|
264
329
|
label: "Subagent Status",
|
|
265
330
|
description:
|
|
266
|
-
"Live per-task status of a subagent run (non-blocking), incl. each child's session file path
|
|
331
|
+
"Live per-task status of a subagent run (non-blocking), incl. each child's session file path to `tail -f`. Call once right after spawning to verify children actually started.",
|
|
267
332
|
promptSnippet: "Check progress of a subagent run; use right after spawn as a health check.",
|
|
268
333
|
parameters: RunIdParam,
|
|
269
334
|
async execute(_id, params) {
|
|
@@ -280,7 +345,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
280
345
|
},
|
|
281
346
|
});
|
|
282
347
|
|
|
283
|
-
|
|
348
|
+
defineTool<typeof ResultParam, { run?: RunSnapshot }>({
|
|
284
349
|
name: "subagent_result",
|
|
285
350
|
label: "Subagent Result",
|
|
286
351
|
description: "Full result (finalText + usage) of a run or one task. Non-blocking.",
|
|
@@ -307,11 +372,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
307
372
|
},
|
|
308
373
|
});
|
|
309
374
|
|
|
310
|
-
|
|
375
|
+
defineTool<typeof AwaitParam, { run?: RunSnapshot }>({
|
|
311
376
|
name: "await_subagent",
|
|
312
377
|
label: "Await Subagent",
|
|
313
378
|
description:
|
|
314
|
-
"Block until a run finishes (or timeoutMs elapses). Only when you have your own work to sync
|
|
379
|
+
"Block until a run finishes (or timeoutMs elapses). Only when you have your own work to sync; otherwise end your turn. Child messages that arrive while parked wake the wait and are returned.",
|
|
315
380
|
parameters: AwaitParam,
|
|
316
381
|
async execute(_id, params) {
|
|
317
382
|
const { runId, timeoutMs } = params as { runId: string; timeoutMs?: number };
|
|
@@ -329,7 +394,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
329
394
|
},
|
|
330
395
|
});
|
|
331
396
|
|
|
332
|
-
|
|
397
|
+
defineTool<typeof ReplyParam, { run?: RunSnapshot }>({
|
|
333
398
|
name: "reply_subagent",
|
|
334
399
|
label: "Reply Subagent",
|
|
335
400
|
description: "Answer a child's ask_parent question; resumes its run.",
|
|
@@ -350,7 +415,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
350
415
|
},
|
|
351
416
|
});
|
|
352
417
|
|
|
353
|
-
|
|
418
|
+
defineTool<typeof SteerParam, { steered?: string[] }>({
|
|
354
419
|
name: "steer_subagent",
|
|
355
420
|
label: "Steer Subagent",
|
|
356
421
|
description:
|
|
@@ -377,11 +442,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
377
442
|
},
|
|
378
443
|
});
|
|
379
444
|
|
|
380
|
-
|
|
445
|
+
defineTool<typeof ResumeParam, { run?: RunSnapshot }>({
|
|
381
446
|
name: "resume_subagent",
|
|
382
447
|
label: "Resume Subagent",
|
|
383
448
|
description:
|
|
384
|
-
"Revive a failed/aborted task in its original session (full context + worktree branch preserved). Optional `model` swaps provider
|
|
449
|
+
"Revive a failed/aborted task in its original session (full context + worktree branch preserved). Optional `model` swaps provider after e.g. a rate limit; `thinking` is clamped to the target model; `message` replaces the default recap prompt. Refuses tasks that never started — respawn those.",
|
|
385
450
|
parameters: ResumeParam,
|
|
386
451
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
387
452
|
const { runId, taskId, message, model, thinking } = params as {
|
|
@@ -398,7 +463,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
398
463
|
content: [
|
|
399
464
|
{
|
|
400
465
|
type: "text",
|
|
401
|
-
text: `Resumed ${runId}/${taskId} (${res.task.agent})${model ? ` on ${model}` : ""} from ${res.task.sessionFile}${res.task.branch ? `, branch ${res.task.branch}` : ""}.${res.note ? ` Adjusted ${res.note}.` : ""}\nNext: subagent_status("${runId}") to confirm it is running; completion will notify you.`,
|
|
466
|
+
text: `Resumed ${runId}/${taskId} (${res.task.agent})${model ? ` on ${model}` : ""} from ${res.task.sessionFile}${res.task.branch ? `, branch ${res.task.branch}` : ""}.${res.note ? ` Adjusted ${res.note}.` : ""}\nNext: subagent_status({ runId: "${runId}" }) to confirm it is running; completion will notify you.`,
|
|
402
467
|
},
|
|
403
468
|
],
|
|
404
469
|
details: { run: run ? cloneRun(run) : undefined },
|
|
@@ -406,7 +471,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
406
471
|
},
|
|
407
472
|
});
|
|
408
473
|
|
|
409
|
-
|
|
474
|
+
defineTool<typeof RunIdParam, { aborted?: number }>({
|
|
410
475
|
name: "subagent_cancel",
|
|
411
476
|
label: "Subagent Cancel",
|
|
412
477
|
description: "Abort a running/queued subagent run. Children are killed; run becomes aborted.",
|
|
@@ -423,4 +488,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
423
488
|
};
|
|
424
489
|
},
|
|
425
490
|
});
|
|
491
|
+
|
|
492
|
+
presentation.registerInitial();
|
|
426
493
|
}
|
package/src/manager.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
} from "./format.ts";
|
|
38
38
|
import { applyUpstream, resolveNeeds, runWaveScheduler } from "./graph.ts";
|
|
39
39
|
import { createMailbox, type Mailbox } from "./mailbox.ts";
|
|
40
|
+
import { chooseModel, resolveChildModel } from "./models.ts";
|
|
40
41
|
import type { SubagentParamsShape, TaskInput } from "./schemas.ts";
|
|
41
42
|
import {
|
|
42
43
|
MAX_TASKS,
|
|
@@ -62,6 +63,9 @@ import {
|
|
|
62
63
|
type Worktree,
|
|
63
64
|
} from "./worktree.ts";
|
|
64
65
|
|
|
66
|
+
/** Legacy callers import the resolver from here; its owner is `models.ts`. */
|
|
67
|
+
export { resolveChildModel };
|
|
68
|
+
|
|
65
69
|
export const DEFAULT_CONCURRENCY = 3;
|
|
66
70
|
export const MAX_CONCURRENCY = 8;
|
|
67
71
|
const DEFAULT_RUNTIME_MS = 3_600_000;
|
|
@@ -174,27 +178,6 @@ export function clampResumeThinking(
|
|
|
174
178
|
return clampThinkingLevel(model, thinking) as ThinkingLevel;
|
|
175
179
|
}
|
|
176
180
|
|
|
177
|
-
export function resolveChildModel(ctx: ExtensionContext, explicit: string | undefined) {
|
|
178
|
-
if (!explicit?.trim()) return ctx.model;
|
|
179
|
-
const ref = explicit.trim();
|
|
180
|
-
if (!ctx.modelRegistry) return ctx.model;
|
|
181
|
-
const available = ctx.modelRegistry.getAvailable();
|
|
182
|
-
const sessionProvider = ctx.model?.provider;
|
|
183
|
-
if (sessionProvider && !ref.includes("/")) {
|
|
184
|
-
const own = available.filter((m) => m.provider === sessionProvider);
|
|
185
|
-
const hit = own.find((m) => m.id === ref) ?? own.find((m) => m.id.endsWith(`/${ref}`));
|
|
186
|
-
if (hit) return hit;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const byId = available.find((m) => m.id === ref);
|
|
190
|
-
if (byId) return byId;
|
|
191
|
-
for (let slash = ref.indexOf("/"); slash > 0; slash = ref.indexOf("/", slash + 1)) {
|
|
192
|
-
const model = ctx.modelRegistry.find(ref.slice(0, slash), ref.slice(slash + 1));
|
|
193
|
-
if (model) return model;
|
|
194
|
-
}
|
|
195
|
-
throw new Error(`Model not found: ${ref}`);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
181
|
const PROBE_THINKING_LEVELS: ThinkingLevel[] = ["low", "minimal", "medium", "high", "xhigh", "max"];
|
|
199
182
|
|
|
200
183
|
/**
|
|
@@ -816,7 +799,7 @@ export class SubagentManager {
|
|
|
816
799
|
|
|
817
800
|
let model: Model<Api> | undefined;
|
|
818
801
|
try {
|
|
819
|
-
model = resolveChildModel(ctx, file
|
|
802
|
+
model = resolveChildModel(ctx, chooseModel(file, input.model).requested);
|
|
820
803
|
validateThinking(model, thinking);
|
|
821
804
|
|
|
822
805
|
const checked = await ensureUsableModel(ctx, model, signal, thinking);
|
|
@@ -1207,12 +1190,12 @@ export class SubagentManager {
|
|
|
1207
1190
|
const input = inputs[i] as TaskInput;
|
|
1208
1191
|
const cwd = input.cwd ?? ctx.cwd;
|
|
1209
1192
|
const file = resolveAgentFile(input.agent, input.task, cwd, getAgentDir());
|
|
1210
|
-
const
|
|
1193
|
+
const choice = chooseModel(file, input.model);
|
|
1211
1194
|
try {
|
|
1212
|
-
validateThinking(resolveChildModel(ctx, requested), input.thinking);
|
|
1195
|
+
validateThinking(resolveChildModel(ctx, choice.requested), input.thinking);
|
|
1213
1196
|
} catch (err) {
|
|
1214
|
-
const where =
|
|
1215
|
-
? ` (from agent file ${
|
|
1197
|
+
const where = choice.sourceFile
|
|
1198
|
+
? ` (from agent file ${choice.sourceFile}, which overrides the requested model${input.model ? ` "${input.model}"` : ""})`
|
|
1216
1199
|
: "";
|
|
1217
1200
|
throw new Error(
|
|
1218
1201
|
`Task ${input.id ?? `task_${i + 1}`} (${input.agent}): ${err instanceof Error ? err.message : String(err)}${where}`,
|
|
@@ -1435,11 +1418,13 @@ export class SubagentManager {
|
|
|
1435
1418
|
const tools = task.tools?.filter((t) => !(CHILD_TALK_TOOLS as readonly string[]).includes(t));
|
|
1436
1419
|
const write = tools?.some((t) => WRITE_CAPABLE.includes(t)) ?? false;
|
|
1437
1420
|
// A resume may swap the model, so the level stored on the task can be one the new model
|
|
1438
|
-
// rejects (a mode-clamped xhigh onto a model that only takes low|high|max). Clamp it
|
|
1439
|
-
// the
|
|
1421
|
+
// rejects (a mode-clamped xhigh onto a model that only takes low|high|max). Clamp it against
|
|
1422
|
+
// the model runChild will actually use — including a matched agent file's, whose frontmatter
|
|
1423
|
+
// overrides the inline model at spawn — or the clamp would validate a different model.
|
|
1424
|
+
const file = resolveAgentFile(task.agent, task.task, task.cwd, getAgentDir());
|
|
1440
1425
|
let resumeModel: Model<Api> | undefined;
|
|
1441
1426
|
try {
|
|
1442
|
-
resumeModel = resolveChildModel(ctx, opts.model ?? task.model);
|
|
1427
|
+
resumeModel = resolveChildModel(ctx, chooseModel(file, opts.model ?? task.model).requested);
|
|
1443
1428
|
} catch {}
|
|
1444
1429
|
const requestedThinking = (opts.thinking ?? task.thinking) as ThinkingLevel | undefined;
|
|
1445
1430
|
const thinking = clampResumeThinking(resumeModel, requestedThinking);
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Optional user preferences for `subagent_models`. This filters and orders what the tool shows and
|
|
6
|
+
* nothing else: it never grants or blocks a model, because pi already owns what may run. A missing
|
|
7
|
+
* file, malformed JSON, or an unknown key degrades to no preferences, so a config typo cannot break
|
|
8
|
+
* delegation.
|
|
9
|
+
*/
|
|
10
|
+
export interface ModelPreferences {
|
|
11
|
+
/** Patterns to sort first, in declaration order. */
|
|
12
|
+
prefer: string[];
|
|
13
|
+
/** Patterns to omit from the listing. */
|
|
14
|
+
hide: string[];
|
|
15
|
+
/** Suggested model, surfaced but never applied as a substitute. */
|
|
16
|
+
default?: string;
|
|
17
|
+
/** Set when the file existed but could not be used, so the failure is reportable, not silent. */
|
|
18
|
+
error?: string;
|
|
19
|
+
/** The path that was read, or would be. */
|
|
20
|
+
path: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const MODEL_CONFIG_FILENAME = "subagent-models.json";
|
|
24
|
+
|
|
25
|
+
export const NO_PREFERENCES = (path: string): ModelPreferences => ({ prefer: [], hide: [], path });
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Match a model reference against a config pattern. `provider/id` is matched as a whole; a bare
|
|
29
|
+
* provider also matches everything from that provider, so `"openai-codex"` is shorthand for
|
|
30
|
+
* `"openai-codex/*"`. `*` matches any run of characters, other metacharacters are literal, and
|
|
31
|
+
* matching is case-insensitive.
|
|
32
|
+
*/
|
|
33
|
+
export function matchesPattern(reference: string, pattern: string): boolean {
|
|
34
|
+
const ref = reference.toLowerCase();
|
|
35
|
+
const pat = pattern.trim().toLowerCase();
|
|
36
|
+
if (!pat) return false;
|
|
37
|
+
if (!pat.includes("*")) return ref === pat || ref.startsWith(`${pat}/`);
|
|
38
|
+
const escaped = pat.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
39
|
+
return new RegExp(`^${escaped}$`).test(ref);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function asStringArray(value: unknown, field: string, errors: string[]): string[] {
|
|
43
|
+
if (value === undefined) return [];
|
|
44
|
+
if (!Array.isArray(value)) {
|
|
45
|
+
errors.push(`${field} must be an array of strings`);
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
const out: string[] = [];
|
|
49
|
+
for (const item of value) {
|
|
50
|
+
if (typeof item !== "string" || !item.trim()) errors.push(`${field} contains a non-string entry`);
|
|
51
|
+
else out.push(item.trim());
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Parse config text. Exported so the validation rules are testable without touching the filesystem. */
|
|
57
|
+
export function parsePreferences(text: string, path = MODEL_CONFIG_FILENAME): ModelPreferences {
|
|
58
|
+
let raw: unknown;
|
|
59
|
+
try {
|
|
60
|
+
raw = JSON.parse(text);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
return {
|
|
63
|
+
prefer: [],
|
|
64
|
+
hide: [],
|
|
65
|
+
path,
|
|
66
|
+
error: `not valid JSON (${err instanceof Error ? err.message : String(err)})`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
70
|
+
return { prefer: [], hide: [], path, error: "must be a JSON object" };
|
|
71
|
+
}
|
|
72
|
+
const obj = raw as Record<string, unknown>;
|
|
73
|
+
const errors: string[] = [];
|
|
74
|
+
const known = new Set(["prefer", "hide", "default"]);
|
|
75
|
+
const unknown = Object.keys(obj).filter((k) => !known.has(k));
|
|
76
|
+
if (unknown.length > 0) errors.push(`unknown key(s): ${unknown.join(", ")}`);
|
|
77
|
+
|
|
78
|
+
const prefer = asStringArray(obj.prefer, "prefer", errors);
|
|
79
|
+
const hide = asStringArray(obj.hide, "hide", errors);
|
|
80
|
+
let fallback: string | undefined;
|
|
81
|
+
if (obj.default !== undefined) {
|
|
82
|
+
if (typeof obj.default !== "string" || !obj.default.trim()) errors.push("default must be a non-empty string");
|
|
83
|
+
else fallback = obj.default.trim();
|
|
84
|
+
}
|
|
85
|
+
if (errors.length > 0) return { prefer: [], hide: [], path, error: errors.join("; ") };
|
|
86
|
+
return { prefer, hide, ...(fallback ? { default: fallback } : {}), path };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Read the preferences file if present. Never throws: an unusable file degrades to no preferences. */
|
|
90
|
+
export function loadPreferences(agentDir: string): ModelPreferences {
|
|
91
|
+
const path = join(agentDir, MODEL_CONFIG_FILENAME);
|
|
92
|
+
if (!existsSync(path)) return NO_PREFERENCES(path);
|
|
93
|
+
try {
|
|
94
|
+
return parsePreferences(readFileSync(path, "utf8"), path);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
return {
|
|
97
|
+
prefer: [],
|
|
98
|
+
hide: [],
|
|
99
|
+
path,
|
|
100
|
+
error: `could not be read (${err instanceof Error ? err.message : String(err)})`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Hide first, then order: preferred matches by declaration order, the rest after, stable. Also
|
|
107
|
+
* reports patterns that matched nothing — an inert `hide` entry must not read as though it worked.
|
|
108
|
+
*/
|
|
109
|
+
export function applyPreferences<T extends { reference: string }>(
|
|
110
|
+
entries: T[],
|
|
111
|
+
prefs: ModelPreferences,
|
|
112
|
+
): { entries: T[]; unusedPatterns: string[] } {
|
|
113
|
+
if (prefs.error) return { entries: [...entries], unusedPatterns: [] };
|
|
114
|
+
const used = new Set<string>();
|
|
115
|
+
const kept = entries.filter((entry) => {
|
|
116
|
+
const hit = prefs.hide.find((pattern) => matchesPattern(entry.reference, pattern));
|
|
117
|
+
if (hit) used.add(hit);
|
|
118
|
+
return !hit;
|
|
119
|
+
});
|
|
120
|
+
const unusedPatterns = prefs.hide.filter((pattern) => !used.has(pattern));
|
|
121
|
+
if (prefs.prefer.length === 0) return { entries: kept, unusedPatterns };
|
|
122
|
+
|
|
123
|
+
const ranked = kept.map((entry, index) => {
|
|
124
|
+
const pattern = prefs.prefer.find((candidate) => matchesPattern(entry.reference, candidate));
|
|
125
|
+
if (pattern) used.add(pattern);
|
|
126
|
+
return { entry, index, rank: pattern ? prefs.prefer.indexOf(pattern) : Number.MAX_SAFE_INTEGER };
|
|
127
|
+
});
|
|
128
|
+
for (const pattern of prefs.prefer) if (!used.has(pattern)) unusedPatterns.push(pattern);
|
|
129
|
+
ranked.sort((a, b) => a.rank - b.rank || a.index - b.index);
|
|
130
|
+
return { entries: ranked.map((r) => r.entry), unusedPatterns };
|
|
131
|
+
}
|
package/src/models.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { type Api, getSupportedThinkingLevels, type Model, type ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
2
|
+
import { type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { applyPreferences, loadPreferences, type ModelPreferences } from "./modelconfig.ts";
|
|
4
|
+
import type { ModelCatalog, ModelPricing, SelectableModel } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve a task's `model` reference against the registry. An absent reference inherits the
|
|
8
|
+
* session model — delegation stays usable without naming one; naming one is a pin, not a
|
|
9
|
+
* requirement.
|
|
10
|
+
*/
|
|
11
|
+
export function resolveChildModel(ctx: ExtensionContext, explicit: string | undefined) {
|
|
12
|
+
if (!explicit?.trim()) return ctx.model;
|
|
13
|
+
const ref = explicit.trim();
|
|
14
|
+
if (!ctx.modelRegistry) return ctx.model;
|
|
15
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
16
|
+
const sessionProvider = ctx.model?.provider;
|
|
17
|
+
if (sessionProvider && !ref.includes("/")) {
|
|
18
|
+
const own = available.filter((m) => m.provider === sessionProvider);
|
|
19
|
+
const hit = own.find((m) => m.id === ref) ?? own.find((m) => m.id.endsWith(`/${ref}`));
|
|
20
|
+
if (hit) return hit;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const byId = available.find((m) => m.id === ref);
|
|
24
|
+
if (byId) return byId;
|
|
25
|
+
for (let slash = ref.indexOf("/"); slash > 0; slash = ref.indexOf("/", slash + 1)) {
|
|
26
|
+
const model = ctx.modelRegistry.find(ref.slice(0, slash), ref.slice(slash + 1));
|
|
27
|
+
if (model) return model;
|
|
28
|
+
}
|
|
29
|
+
throw new Error(`Model not found: ${ref}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ModelChoice {
|
|
33
|
+
/** The reference to resolve, or undefined when the caller named none (inherit the session model). */
|
|
34
|
+
requested: string | undefined;
|
|
35
|
+
/** Set when an agent file supplied the model, so errors can say where it came from. */
|
|
36
|
+
sourceFile?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Single owner of the model-precedence rule: a matched agent file's `model` wins over the inline
|
|
41
|
+
* one. Spawn, pre-creation validation, and resume all resolve through this, so they cannot disagree
|
|
42
|
+
* about which model a task actually uses.
|
|
43
|
+
*/
|
|
44
|
+
export function chooseModel(
|
|
45
|
+
file: { model?: string; path?: string } | undefined,
|
|
46
|
+
inline: string | undefined,
|
|
47
|
+
): ModelChoice {
|
|
48
|
+
const fromFile = file?.model?.trim();
|
|
49
|
+
return fromFile ? { requested: fromFile, sourceFile: file?.path } : { requested: inline };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The thinking levels a model honors at runtime, from pi's own resolver, so the catalog cannot
|
|
54
|
+
* promise a level the runtime would silently clamp: `xhigh`/`max` count only when explicitly
|
|
55
|
+
* mapped. An accepted input can still be clamped; the catalog lists only honored levels.
|
|
56
|
+
*/
|
|
57
|
+
export function supportedThinkingLevels(model: Model<Api> | undefined): ModelThinkingLevel[] {
|
|
58
|
+
if (!model) return [];
|
|
59
|
+
return [...getSupportedThinkingLevels(model)];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Keep only finite, non-negative per-Mtok rates; a partial or absent cost is unavailable, not free. */
|
|
63
|
+
export function normalizeCost(cost: unknown): ModelPricing | undefined {
|
|
64
|
+
if (!cost || typeof cost !== "object") return undefined;
|
|
65
|
+
const raw = cost as Partial<Record<keyof ModelPricing, unknown>>;
|
|
66
|
+
const rate = (value: unknown): number | undefined =>
|
|
67
|
+
typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
68
|
+
const input = rate(raw.input);
|
|
69
|
+
const output = rate(raw.output);
|
|
70
|
+
if (input === undefined || output === undefined) return undefined;
|
|
71
|
+
const cacheRead = rate(raw.cacheRead);
|
|
72
|
+
const cacheWrite = rate(raw.cacheWrite);
|
|
73
|
+
return {
|
|
74
|
+
input,
|
|
75
|
+
output,
|
|
76
|
+
...(cacheRead !== undefined ? { cacheRead } : {}),
|
|
77
|
+
...(cacheWrite !== undefined ? { cacheWrite } : {}),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Models a subagent task may name, scoped the way pi scopes them.
|
|
83
|
+
*
|
|
84
|
+
* `ctx.scopedModels` is pi's own resolution of `enabledModels` and `--models` — the set
|
|
85
|
+
* `/scoped-models` shows. When no scoping is configured pi reports an empty list, and only then
|
|
86
|
+
* does this fall back to the full available catalogue; `scope` tells the caller which case applies.
|
|
87
|
+
*
|
|
88
|
+
* A reference is listed only when it resolves back to the model it describes. Resolution is not a
|
|
89
|
+
* pure `provider/id` split (a model whose bare id contains a slash can shadow another provider's
|
|
90
|
+
* `provider/id`), so building the string naively could advertise a reference that silently selects
|
|
91
|
+
* a different model. Anything ambiguous is reported as such, and a registry fault is reported
|
|
92
|
+
* separately because the caller's fix differs.
|
|
93
|
+
*/
|
|
94
|
+
export function listSelectableModels(ctx: ExtensionContext, preferences?: ModelPreferences): ModelCatalog {
|
|
95
|
+
if (!ctx.modelRegistry) {
|
|
96
|
+
return { models: [], scope: "all", unavailable: "this context exposes no model registry" };
|
|
97
|
+
}
|
|
98
|
+
const scoped = ctx.scopedModels ?? [];
|
|
99
|
+
const scope: ModelCatalog["scope"] = scoped.length > 0 ? "session" : "all";
|
|
100
|
+
|
|
101
|
+
let candidate: Model<Api>[];
|
|
102
|
+
try {
|
|
103
|
+
candidate = scoped.length > 0 ? scoped.map((entry) => entry.model) : (ctx.modelRegistry.getAvailable() ?? []);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
return { models: [], scope, unavailable: err instanceof Error ? err.message : String(err) };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const seen = new Set<string>();
|
|
109
|
+
const models: SelectableModel[] = [];
|
|
110
|
+
const ambiguous: string[] = [];
|
|
111
|
+
const unresolved: string[] = [];
|
|
112
|
+
for (const model of candidate) {
|
|
113
|
+
const reference = `${model.provider}/${model.id}`;
|
|
114
|
+
if (seen.has(reference)) continue;
|
|
115
|
+
seen.add(reference);
|
|
116
|
+
|
|
117
|
+
let resolved: Model<Api> | undefined;
|
|
118
|
+
let lookupError: string | undefined;
|
|
119
|
+
try {
|
|
120
|
+
resolved = resolveChildModel(ctx, reference);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
lookupError = err instanceof Error ? err.message : String(err);
|
|
123
|
+
}
|
|
124
|
+
if (!resolved) {
|
|
125
|
+
unresolved.push(lookupError ? `${reference} (${lookupError})` : reference);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (resolved.provider !== model.provider || resolved.id !== model.id) {
|
|
129
|
+
ambiguous.push(reference);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
models.push({
|
|
133
|
+
reference,
|
|
134
|
+
provider: model.provider,
|
|
135
|
+
id: model.id,
|
|
136
|
+
name: typeof model.name === "string" && model.name.trim() ? model.name : model.id,
|
|
137
|
+
reasoning: Boolean(model.reasoning),
|
|
138
|
+
thinkingLevels: supportedThinkingLevels(model),
|
|
139
|
+
contextWindow:
|
|
140
|
+
typeof model.contextWindow === "number" && Number.isFinite(model.contextWindow) && model.contextWindow > 0
|
|
141
|
+
? model.contextWindow
|
|
142
|
+
: 0,
|
|
143
|
+
cost: normalizeCost((model as { cost?: unknown }).cost),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Preferences filter and order only. `preferences` is injectable so the file-to-catalog wiring
|
|
148
|
+
// is testable without touching the user's real config.
|
|
149
|
+
const prefs = preferences ?? loadPreferences(getAgentDir());
|
|
150
|
+
const { entries: shown, unusedPatterns } = applyPreferences(models, prefs);
|
|
151
|
+
const hiddenCount = models.length - shown.length;
|
|
152
|
+
const suggested = prefs.error ? undefined : prefs.default;
|
|
153
|
+
const listedDefault = suggested && shown.some((entry) => entry.reference === suggested);
|
|
154
|
+
return {
|
|
155
|
+
models: shown,
|
|
156
|
+
scope,
|
|
157
|
+
...(listedDefault ? { preferredDefault: suggested } : {}),
|
|
158
|
+
...(suggested && !listedDefault ? { unlistedDefault: suggested } : {}),
|
|
159
|
+
...(prefs.error ? { configError: `${prefs.path}: ${prefs.error}` } : {}),
|
|
160
|
+
...(hiddenCount > 0 ? { hidden: hiddenCount } : {}),
|
|
161
|
+
...(unusedPatterns.length > 0 ? { unusedPatterns } : {}),
|
|
162
|
+
...(ambiguous.length > 0
|
|
163
|
+
? {
|
|
164
|
+
ambiguous,
|
|
165
|
+
reason: `another model's bare id would win resolution for ${ambiguous.join(", ")}`,
|
|
166
|
+
}
|
|
167
|
+
: {}),
|
|
168
|
+
...(unresolved.length > 0
|
|
169
|
+
? { unresolved, unresolvedReason: `the registry could not resolve these entries: ${unresolved.join(", ")}` }
|
|
170
|
+
: {}),
|
|
171
|
+
...(shown.length === 0
|
|
172
|
+
? {
|
|
173
|
+
unavailable:
|
|
174
|
+
models.length > 0
|
|
175
|
+
? `every listed model is hidden by ${prefs.path} — unhide one or remove \`hide\``
|
|
176
|
+
: unresolved.length > 0
|
|
177
|
+
? `the registry could not resolve any of its available models: ${unresolved.join("; ")}`
|
|
178
|
+
: scope === "session"
|
|
179
|
+
? "the session's enabled models could not be resolved"
|
|
180
|
+
: "no model has usable credentials",
|
|
181
|
+
}
|
|
182
|
+
: {}),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
ToolDefinition,
|
|
5
|
+
ToolExposure,
|
|
6
|
+
ToolLoadout,
|
|
7
|
+
ToolLoadoutChanges,
|
|
8
|
+
ToolNamespace,
|
|
9
|
+
} from "@earendil-works/pi-coding-agent";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Presentation preference for the subagent toolset.
|
|
13
|
+
* - auto: native codemode presentation when the codemode tool is active, direct otherwise.
|
|
14
|
+
* - direct: subagent tools are declared to the model (model-only exposure).
|
|
15
|
+
* - codemode: subagent tools are callable from codemode scripts but not declared or listed.
|
|
16
|
+
*/
|
|
17
|
+
export type SubagentMode = "auto" | "direct" | "codemode";
|
|
18
|
+
export type EffectiveMode = "direct" | "codemode";
|
|
19
|
+
|
|
20
|
+
/** Branch-scoped custom entry holding the preference, so resume/tree keep it. */
|
|
21
|
+
export const MODE_ENTRY_TYPE = "subagent-mode";
|
|
22
|
+
export const CODEMODE_TOOL_NAME = "codemode";
|
|
23
|
+
|
|
24
|
+
export const SUBAGENT_NAMESPACE: ToolNamespace = {
|
|
25
|
+
name: "subagents",
|
|
26
|
+
description: "Isolated background subagents with a dependency-graph scheduler, git-worktree isolation and intercom.",
|
|
27
|
+
instructions: `Isolated subagents with their own context, session and optional git worktree. Delegate independent review, testing, research or parallel analysis.
|
|
28
|
+
|
|
29
|
+
## Modes
|
|
30
|
+
- auto (default): codemode presentation while the \`codemode\` tool is active, direct otherwise.
|
|
31
|
+
- direct: subagent tools are declared to the model (model-only, not callable from scripts).
|
|
32
|
+
- codemode: active tools are callable from scripts but not declared; inactive tools stay model-only and are not callable at all.
|
|
33
|
+
Switch with \`/subagents mode auto|direct|codemode\`. The preference is stored on the session branch and restored on reload, resume and tree navigation.
|
|
34
|
+
|
|
35
|
+
## Operations
|
|
36
|
+
Object arguments, exactly as validated when issued by the model:
|
|
37
|
+
- \`subagent({ agent, task, prompt?, write?, tools?, model?, thinking?, cwd?, maxRuntimeMs?, autoAwait?, notifyPerTask? })\` runs one agent; \`tasks: [...]\`, \`chain: [...]\` with \`{previous}\` and \`needs\` edges gate tasks and prepend upstream output.
|
|
38
|
+
- \`subagent_models()\` lists the models a task may name: the exact \`model\` value to pass, the thinking levels the runtime honors, the context window and pi catalog price per million tokens. The list follows this session's scoped models when scoping is configured, else every usable model; the output states which case applies, and references that would resolve to another model are called out as ambiguous.
|
|
39
|
+
- \`subagent_status({ runId })\` returns live per-task status and session file paths; call once right after spawning.
|
|
40
|
+
- \`subagent_result({ runId, taskId? })\` returns final text, usage and the worktree branch/diff summary.
|
|
41
|
+
- \`await_subagent({ runId, timeoutMs? })\` blocks only when this turn must consume the result.
|
|
42
|
+
- \`reply_subagent({ runId, taskId, message })\` answers a child \`ask_parent\` question and resumes it.
|
|
43
|
+
- \`steer_subagent({ runId, taskId?, message })\` injects a message into running tasks.
|
|
44
|
+
- \`resume_subagent({ runId, taskId, message?, model?, thinking? })\` revives a failed/aborted task with its context and branch.
|
|
45
|
+
- \`subagent_cancel({ runId })\` aborts the run and kills its children.
|
|
46
|
+
|
|
47
|
+
## Agents
|
|
48
|
+
- Define each agent inline: invented name, focused system prompt. Agent files (\`.agents/agents\`, \`.claude/agents\`, \`.pi/agents\`; project dirs, then home) are matched by \`description\` against the goal, never by name. A match is authoritative: its body is the system prompt, its \`model\` frontmatter wins over the inline value, and its \`tools\` apply — only the per-call \`tools\` and \`write\` override them.
|
|
49
|
+
- Agents are read-only by default; \`write: true\` gives an isolated git worktree branch. Review the diff and merge with \`git merge --no-ff <branch>\`.
|
|
50
|
+
- \`model\` is optional: omit it to inherit the session model, or name one from \`subagent_models\` to pin the run.
|
|
51
|
+
|
|
52
|
+
## Rules
|
|
53
|
+
- Batch every sub-task in ONE call with \`tasks\`/\`needs\`; do not split parallel work into separate calls.
|
|
54
|
+
- After spawning, call \`subagent_status({ runId })\` once to confirm the tasks started; fix or respawn a task that died on spawn.
|
|
55
|
+
- A task that failed mid-work keeps its session file and branch: \`resume_subagent({ runId, taskId })\`, model swap included, revives it; respawn only a task that never started.
|
|
56
|
+
- Completion and failures notify you: end the turn when you have no work left; use \`autoAwait\` only when the same turn must consume the result.
|
|
57
|
+
- End each task with a runnable check, e.g. 'Verify: bun test'. A subagent's claim of success is not evidence.
|
|
58
|
+
|
|
59
|
+
## Codemode
|
|
60
|
+
When codemode is active these tools are not declared. Call an active operation as \`tools.<name>(args)\`; spawning uses \`await tools.subagent(args)\`. If its signature is unknown, inspect only \`text(await describeTool('subagent'))\` once and reuse it. For another operation, inspect only its exact name when needed. Broad search and tool-list dumps are unnecessary for these known names; this namespace is the optional full reference. Inactive tools remain unavailable. Arguments are validated exactly like model-issued calls.`,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Adds the script-call path to the upfront rules, but only in the codemode profile. */
|
|
64
|
+
export const CODEMODE_DISCOVERY_GUIDELINE =
|
|
65
|
+
"Codemode subagents: call active tools as tools.<name>(args); spawn with await tools.subagent(args). If its signature is unknown, inspect only text(await describeTool('subagent')) once, then reuse it. Other helpers: describeTool(exactName) only when needed. No broad search or tool-list dump required; describeNamespace('subagents') is optional full reference.";
|
|
66
|
+
|
|
67
|
+
/** The full-reference pointer only resolves while codemode scripts can reach `describeNamespace`. */
|
|
68
|
+
const NAMESPACE_REFERENCE = / Full reference: `describeNamespace\('subagents'\)`\./;
|
|
69
|
+
|
|
70
|
+
export function isSubagentMode(value: unknown): value is SubagentMode {
|
|
71
|
+
return value === "auto" || value === "direct" || value === "codemode";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type AnyToolDefinition = ToolDefinition<any, any, any>;
|
|
75
|
+
|
|
76
|
+
export interface Presentation {
|
|
77
|
+
readonly preference: SubagentMode;
|
|
78
|
+
/** The profile currently registered, or undefined before the first registration. */
|
|
79
|
+
readonly applied: EffectiveMode | undefined;
|
|
80
|
+
/** Effective mode for the given active set, defaulting to the session's live active tools. */
|
|
81
|
+
effective(active?: readonly string[]): EffectiveMode;
|
|
82
|
+
/** Report preference, effective mode and codemode availability. */
|
|
83
|
+
describe(): string;
|
|
84
|
+
setPreference(mode: SubagentMode): void;
|
|
85
|
+
/** Restore the latest preference stored on the session branch. */
|
|
86
|
+
restore(ctx: ExtensionContext): void;
|
|
87
|
+
/** Register the tools for the current effective mode; no-op when already applied. */
|
|
88
|
+
sync(): boolean;
|
|
89
|
+
/** Initial registration during extension load, before any session state exists. */
|
|
90
|
+
registerInitial(): void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Owns the subagent exposure profile. Tools stay registered while only their exposure changes, so
|
|
95
|
+
* the active selection (and the running manager) is preserved across mode switches.
|
|
96
|
+
*/
|
|
97
|
+
export function createPresentation(pi: ExtensionAPI, definitions: readonly AnyToolDefinition[]): Presentation {
|
|
98
|
+
const ownNames = (): string[] => definitions.map((definition) => definition.name);
|
|
99
|
+
let preference: SubagentMode = "auto";
|
|
100
|
+
let applied: EffectiveMode | undefined;
|
|
101
|
+
let appliedOwn = new Set<string>();
|
|
102
|
+
|
|
103
|
+
const codemodeActive = (active: readonly string[]): boolean => active.includes(CODEMODE_TOOL_NAME);
|
|
104
|
+
const effectiveFrom = (active: readonly string[]): EffectiveMode =>
|
|
105
|
+
preference !== "direct" && codemodeActive(active) ? "codemode" : "direct";
|
|
106
|
+
const sameSet = (a: ReadonlySet<string>, b: ReadonlySet<string>): boolean =>
|
|
107
|
+
a.size === b.size && [...a].every((name) => b.has(name));
|
|
108
|
+
|
|
109
|
+
// Hides only the declarations of active subagent tools; other tools keep their loadout. Runs on
|
|
110
|
+
// every loadout recompute, so it follows the registered profile: a preference that is only
|
|
111
|
+
// pending (mid-stream) must not rewrite a live loadout.
|
|
112
|
+
const hideDeclarations = (loadout: ToolLoadout): ToolLoadoutChanges | undefined => {
|
|
113
|
+
if (applied !== "codemode") return undefined;
|
|
114
|
+
const active = new Set(loadout.declared.map((tool) => tool.name));
|
|
115
|
+
const hidden = definitions.map((definition) => definition.name).filter((name) => active.has(name));
|
|
116
|
+
return hidden.length > 0 ? { hiddenDeclarations: hidden } : undefined;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Register the profile for the given effective mode. Only own tools that are really active stay
|
|
120
|
+
// script-callable (`deferred`); inactive own tools are `model-only` and never callable. Re-registering
|
|
121
|
+
// must not reactivate a tool the user deactivated, so `defaultActive: false` replaces the native
|
|
122
|
+
// default once the initial registration has made the tools available by default.
|
|
123
|
+
const register = (mode: EffectiveMode, ownActive: ReadonlySet<string>, initial = false): void => {
|
|
124
|
+
const own = new Set(ownNames());
|
|
125
|
+
applied = mode;
|
|
126
|
+
for (const definition of definitions) {
|
|
127
|
+
const scriptCallable = mode === "codemode" && ownActive.has(definition.name);
|
|
128
|
+
const exposure: ToolExposure = scriptCallable ? "deferred" : "model-only";
|
|
129
|
+
const promptGuidelines = [...(definition.promptGuidelines ?? [])];
|
|
130
|
+
if (scriptCallable && !promptGuidelines.includes(CODEMODE_DISCOVERY_GUIDELINE))
|
|
131
|
+
promptGuidelines.push(CODEMODE_DISCOVERY_GUIDELINE);
|
|
132
|
+
// In the direct profile there is no script path, so the pointer would be unreachable.
|
|
133
|
+
const description =
|
|
134
|
+
mode === "direct" ? definition.description.replace(NAMESPACE_REFERENCE, "") : definition.description;
|
|
135
|
+
pi.registerTool({
|
|
136
|
+
...definition,
|
|
137
|
+
description,
|
|
138
|
+
exposure,
|
|
139
|
+
namespace: SUBAGENT_NAMESPACE,
|
|
140
|
+
promptGuidelines,
|
|
141
|
+
prepareLoadout: hideDeclarations,
|
|
142
|
+
...(initial ? {} : { defaultActive: false }),
|
|
143
|
+
} as AnyToolDefinition);
|
|
144
|
+
}
|
|
145
|
+
// With an explicit --tools/defaultTools allowlist the SDK force-activates every registered
|
|
146
|
+
// declarable tool it names, ignoring `defaultActive`. Restore exactly the own-name membership
|
|
147
|
+
// that was asked for; every unrelated name, including a newly registered one, is preserved.
|
|
148
|
+
if (!initial) {
|
|
149
|
+
const active = pi.getActiveTools();
|
|
150
|
+
if (active.some((name) => own.has(name) && !ownActive.has(name)))
|
|
151
|
+
pi.setActiveTools(active.filter((name) => !own.has(name) || ownActive.has(name)));
|
|
152
|
+
}
|
|
153
|
+
appliedOwn = new Set(ownActive);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
get preference() {
|
|
158
|
+
return preference;
|
|
159
|
+
},
|
|
160
|
+
get applied() {
|
|
161
|
+
return applied;
|
|
162
|
+
},
|
|
163
|
+
effective: (active = pi.getActiveTools()) => effectiveFrom(active),
|
|
164
|
+
describe() {
|
|
165
|
+
const active = pi.getActiveTools();
|
|
166
|
+
const target = effectiveFrom(active);
|
|
167
|
+
const shown = applied ?? target;
|
|
168
|
+
const available = codemodeActive(active);
|
|
169
|
+
const availability = available ? "codemode is active" : "codemode is not active";
|
|
170
|
+
const fallback = !available && preference === "codemode" ? "; using direct fallback" : "";
|
|
171
|
+
const pending = target !== shown ? `; pending ${target} at the next request boundary` : "";
|
|
172
|
+
return `Subagent exposure: mode ${preference} → effective ${shown} (${availability}${fallback}${pending}). Use /subagents mode auto|direct|codemode.`;
|
|
173
|
+
},
|
|
174
|
+
setPreference(mode) {
|
|
175
|
+
preference = mode;
|
|
176
|
+
pi.appendEntry(MODE_ENTRY_TYPE, { mode });
|
|
177
|
+
},
|
|
178
|
+
restore(ctx) {
|
|
179
|
+
let restored: SubagentMode | undefined;
|
|
180
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
181
|
+
const candidate = entry as { type?: string; customType?: string; data?: { mode?: unknown } };
|
|
182
|
+
if (
|
|
183
|
+
candidate.type === "custom" &&
|
|
184
|
+
candidate.customType === MODE_ENTRY_TYPE &&
|
|
185
|
+
isSubagentMode(candidate.data?.mode)
|
|
186
|
+
)
|
|
187
|
+
restored = candidate.data.mode;
|
|
188
|
+
}
|
|
189
|
+
// Branch-sensitive state: a branch without an entry uses the package default again.
|
|
190
|
+
preference = restored ?? "auto";
|
|
191
|
+
},
|
|
192
|
+
sync() {
|
|
193
|
+
const own = new Set(ownNames());
|
|
194
|
+
const active = pi.getActiveTools();
|
|
195
|
+
const next = effectiveFrom(active);
|
|
196
|
+
const ownActive = new Set(active.filter((name) => own.has(name)));
|
|
197
|
+
// Members inside a profile are part of the registration: a helper activated or deactivated
|
|
198
|
+
// natively must change its exposure (deferred vs model-only) at the next boundary.
|
|
199
|
+
if (next === applied && sameSet(ownActive, appliedOwn)) return false;
|
|
200
|
+
register(next, ownActive);
|
|
201
|
+
return true;
|
|
202
|
+
},
|
|
203
|
+
registerInitial() {
|
|
204
|
+
// Before the session runtime exists `pi.getActiveTools()` is not bound; the optimistic
|
|
205
|
+
// all-active set is corrected by `sync()` once the session starts.
|
|
206
|
+
register("direct", new Set(ownNames()), true);
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
package/src/schemas.ts
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
|
-
import { StringEnum } from "@earendil-works/pi-ai";
|
|
1
|
+
import { type ModelThinkingLevel, StringEnum } from "@earendil-works/pi-ai";
|
|
2
2
|
import { type Static, Type } from "typebox";
|
|
3
3
|
import { DEFAULT_CONCURRENCY, MAX_CONCURRENCY } from "./manager.ts";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/** Schema vocabulary; per-model supported levels come from pi-ai. */
|
|
6
|
+
const THINKING_LEVELS = [
|
|
7
|
+
"off",
|
|
8
|
+
"minimal",
|
|
9
|
+
"low",
|
|
10
|
+
"medium",
|
|
11
|
+
"high",
|
|
12
|
+
"xhigh",
|
|
13
|
+
"max",
|
|
14
|
+
] as const satisfies readonly ModelThinkingLevel[];
|
|
6
15
|
const TaskItem = Type.Object({
|
|
7
16
|
id: Type.Optional(Type.String({ description: "Optional stable task id" })),
|
|
8
17
|
agent: Type.String({ minLength: 1, description: "Agent name you invent (defined inline via `prompt`)" }),
|
|
@@ -10,10 +19,16 @@ const TaskItem = Type.Object({
|
|
|
10
19
|
prompt: Type.Optional(Type.String({ description: "System prompt defining this agent's behavior" })),
|
|
11
20
|
write: Type.Optional(
|
|
12
21
|
Type.Boolean({
|
|
13
|
-
description:
|
|
22
|
+
description:
|
|
23
|
+
"true = write toolset (adds bash, edit, write); default false = read-only (read, grep, find, ls, codemode)",
|
|
24
|
+
}),
|
|
25
|
+
),
|
|
26
|
+
model: Type.Optional(
|
|
27
|
+
Type.String({
|
|
28
|
+
description:
|
|
29
|
+
"Model override (provider/model-id). Omit to inherit the session model; call subagent_models for the exact references this session accepts.",
|
|
14
30
|
}),
|
|
15
31
|
),
|
|
16
|
-
model: Type.Optional(Type.String({ description: "Model override (provider/model-id)" })),
|
|
17
32
|
thinking: Type.Optional(StringEnum(THINKING_LEVELS, { description: "Thinking level override" })),
|
|
18
33
|
cwd: Type.Optional(Type.String({ description: "Working directory (default: current project)" })),
|
|
19
34
|
tools: Type.Optional(Type.Array(Type.String(), { description: "Explicit tool allowlist (overrides the toolset)" })),
|
|
@@ -35,7 +50,12 @@ export const SubagentParams = Type.Object({
|
|
|
35
50
|
),
|
|
36
51
|
tasks: Type.Optional(Type.Array(TaskItem, { description: "Parallel tasks" })),
|
|
37
52
|
chain: Type.Optional(Type.Array(TaskItem, { description: "Sequential tasks; {previous} = prior output" })),
|
|
38
|
-
model: Type.Optional(
|
|
53
|
+
model: Type.Optional(
|
|
54
|
+
Type.String({
|
|
55
|
+
description:
|
|
56
|
+
"Model override (single mode). Omit to inherit the session model; call subagent_models for the exact references this session accepts.",
|
|
57
|
+
}),
|
|
58
|
+
),
|
|
39
59
|
thinking: Type.Optional(StringEnum(THINKING_LEVELS, { description: "Thinking level override (single mode)" })),
|
|
40
60
|
cwd: Type.Optional(Type.String({ description: "Working directory (single mode). Default: current project." })),
|
|
41
61
|
concurrency: Type.Optional(
|
|
@@ -64,6 +84,7 @@ export type TaskInput = Static<typeof TaskItem>;
|
|
|
64
84
|
export type SubagentParamsShape = Static<typeof SubagentParams>;
|
|
65
85
|
|
|
66
86
|
export const RunIdParam = Type.Object({ runId: Type.String({ description: "Run id from subagent()" }) });
|
|
87
|
+
export const ModelsParam = Type.Object({});
|
|
67
88
|
export const ResultParam = Type.Object({
|
|
68
89
|
runId: Type.String(),
|
|
69
90
|
taskId: Type.Optional(Type.String({ description: "Specific task id; defaults to all" })),
|
package/src/types.ts
CHANGED
|
@@ -71,3 +71,55 @@ export interface RunDetails {
|
|
|
71
71
|
export interface PendingReply {
|
|
72
72
|
resolve: (message: string) => void;
|
|
73
73
|
}
|
|
74
|
+
|
|
75
|
+
/** Per-million-token USD rates, as pi reports them. */
|
|
76
|
+
export interface ModelPricing {
|
|
77
|
+
input: number;
|
|
78
|
+
output: number;
|
|
79
|
+
cacheRead?: number;
|
|
80
|
+
cacheWrite?: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One selectable model, as the agent needs it to choose: what to pass, what it supports, what it costs. */
|
|
84
|
+
export interface SelectableModel {
|
|
85
|
+
/** The value to pass as `model` ("provider/id"). */
|
|
86
|
+
reference: string;
|
|
87
|
+
provider: string;
|
|
88
|
+
id: string;
|
|
89
|
+
name: string;
|
|
90
|
+
reasoning: boolean;
|
|
91
|
+
/** Levels the runtime honors without clamping. */
|
|
92
|
+
thinkingLevels: string[];
|
|
93
|
+
/** 0 when the provider did not report one. */
|
|
94
|
+
contextWindow: number;
|
|
95
|
+
/** pi's catalog rates, or undefined when the provider reported none — distinct from free. */
|
|
96
|
+
cost?: ModelPricing;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface ModelCatalog {
|
|
100
|
+
models: SelectableModel[];
|
|
101
|
+
/**
|
|
102
|
+
* What the list was scoped to. pi resolves `enabledModels` (and `--models`) into scoped models,
|
|
103
|
+
* so this is normally the session's own enabled set rather than every model with credentials.
|
|
104
|
+
*/
|
|
105
|
+
scope: "session" | "all";
|
|
106
|
+
/** The config's suggested model, surfaced for the caller to weigh. Never applied automatically. */
|
|
107
|
+
preferredDefault?: string;
|
|
108
|
+
unlistedDefault?: string;
|
|
109
|
+
/** Set when the preferences file existed but was unusable, so a typo is reported, not silent. */
|
|
110
|
+
configError?: string;
|
|
111
|
+
/** How many models the config hid, so a surprising absence is explained. */
|
|
112
|
+
hidden?: number;
|
|
113
|
+
/** Preferred/hidden patterns that matched no listed model — inert config, reported not hidden. */
|
|
114
|
+
unusedPatterns?: string[];
|
|
115
|
+
/** References that are NOT safe to pass because another model's bare id would win resolution. */
|
|
116
|
+
ambiguous?: string[];
|
|
117
|
+
/** Why the ambiguous references are unsafe, in full, so the agent can act instead of retrying blindly. */
|
|
118
|
+
reason?: string;
|
|
119
|
+
/** Entries the registry itself could not resolve — a registry fault, distinct from a name collision. */
|
|
120
|
+
unresolved?: string[];
|
|
121
|
+
/** Why those entries failed, so a registry fault is never mistaken for a collision. */
|
|
122
|
+
unresolvedReason?: string;
|
|
123
|
+
/** Set when the catalog is empty, so the caller knows it is not looking at a legitimate empty list. */
|
|
124
|
+
unavailable?: string;
|
|
125
|
+
}
|