@diousk/pi-subagents-fast 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +81 -4
- package/dist/agent-manager.d.ts +7 -1
- package/dist/agent-manager.js +65 -2
- package/dist/agent-runner.d.ts +3 -1
- package/dist/agent-runner.js +3 -3
- package/dist/custom-agents.js +9 -5
- package/dist/index.js +45 -4
- package/dist/model-routing.d.ts +57 -0
- package/dist/model-routing.js +214 -0
- package/dist/nested-tools.d.ts +4 -1
- package/dist/nested-tools.js +4 -1
- package/dist/routing-config.d.ts +13 -0
- package/dist/routing-config.js +38 -0
- package/dist/schedule.js +23 -22
- package/dist/settings.d.ts +16 -0
- package/dist/settings.js +58 -11
- package/dist/types.d.ts +6 -1
- package/dist/ui/model-routing-menu.d.ts +8 -0
- package/dist/ui/model-routing-menu.js +116 -0
- package/dist/workflow/host.js +2 -0
- package/docs/rpc.md +7 -0
- package/docs/workflows.md +4 -0
- package/package.json +7 -7
- package/src/agent-manager.ts +67 -3
- package/src/agent-runner.ts +6 -4
- package/src/custom-agents.ts +8 -5
- package/src/index.ts +43 -4
- package/src/model-routing.ts +235 -0
- package/src/nested-tools.ts +6 -1
- package/src/routing-config.ts +43 -0
- package/src/schedule.ts +23 -22
- package/src/settings.ts +62 -11
- package/src/types.ts +6 -1
- package/src/ui/model-routing-menu.ts +93 -0
- package/src/workflow/host.ts +2 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Input, Text } from "@earendil-works/pi-tui";
|
|
2
|
+
import { loadRoutingPolicy } from "../model-routing.js";
|
|
3
|
+
import { parseJevConfig, ROUTING_THINKING_LEVELS } from "../routing-config.js";
|
|
4
|
+
import { loadSettings, projectRoutingSettings } from "../settings.js";
|
|
5
|
+
/** Input handles paste/editing, but its unmasked renderer is never called. */
|
|
6
|
+
export async function maskedApiKey(ctx) {
|
|
7
|
+
return ctx.ui.custom((_tui, _theme, _kb, done) => {
|
|
8
|
+
const input = new Input();
|
|
9
|
+
input.onSubmit = value => done(value.trim());
|
|
10
|
+
input.onEscape = () => done(undefined);
|
|
11
|
+
return {
|
|
12
|
+
render: (width) => new Text(`Typesafe API key: ${"*".repeat(input.getValue().length)}\nEnter to save; Esc to cancel`, 0, 0).render(width),
|
|
13
|
+
invalidate: () => { },
|
|
14
|
+
handleInput: (data) => input.handleInput(data),
|
|
15
|
+
};
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/** Simple mode, guideline, models and credentials; saves project overrides. */
|
|
19
|
+
export async function showRoutingMenu(ctx) {
|
|
20
|
+
const settings = loadSettings(ctx.cwd);
|
|
21
|
+
const local = projectRoutingSettings(ctx.cwd);
|
|
22
|
+
const policy = loadRoutingPolicy(ctx.cwd);
|
|
23
|
+
const labels = { agents: "Custom agents", guideline: "Custom guideline", jev: "Jev", baseline: "Existing model" };
|
|
24
|
+
const inactive = policy.source === "agents" ? "Guideline and Jev are inactive while custom agents exist."
|
|
25
|
+
: policy.source === "guideline" ? "Jev is inactive while a custom guideline is configured." : "";
|
|
26
|
+
const note = policy.mode === "off" ? "No routing guidance or Jev requests."
|
|
27
|
+
: policy.mode === "shadow" ? `Observe Jev; actual choice: ${labels[policy.source]}. Jev calls may incur charges.`
|
|
28
|
+
: policy.mode === "jev" ? `Jev first; fallback: ${labels[policy.fallbackSource ?? "baseline"]}.${policy.jev ? "" : " Configure Jev models and credentials to use Jev."}`
|
|
29
|
+
: policy.diagnostic ?? inactive;
|
|
30
|
+
const choice = await ctx.ui.select(`Model routing: ${policy.mode} — ${note || labels[policy.source]}`, [
|
|
31
|
+
"Routing mode", "Custom guideline path", "Jev models and descriptions", "Typesafe API key", "Use Pi/environment credentials", "Disable custom guideline", "Disable Jev", "Back",
|
|
32
|
+
]);
|
|
33
|
+
if (!choice || choice === "Back")
|
|
34
|
+
return;
|
|
35
|
+
if (choice === "Routing mode") {
|
|
36
|
+
const modes = [
|
|
37
|
+
{ mode: "auto", label: "auto — default priority: agents, guideline, Jev, existing model" },
|
|
38
|
+
{ mode: "shadow", label: "shadow — record Jev suggestions; keep the default model (may incur charges)" },
|
|
39
|
+
{ mode: "jev", label: "jev — Jev first; default-priority fallback on uncertainty or failure" },
|
|
40
|
+
{ mode: "off", label: "off — no routing guidance or Jev requests" },
|
|
41
|
+
];
|
|
42
|
+
const selected = await ctx.ui.select(`Routing mode (current: ${policy.mode})`, modes.map(entry => entry.label));
|
|
43
|
+
const mode = modes.find(entry => entry.label === selected)?.mode;
|
|
44
|
+
return mode ? { routingMode: mode } : undefined;
|
|
45
|
+
}
|
|
46
|
+
if (choice === "Disable custom guideline")
|
|
47
|
+
return { customGuideline: false };
|
|
48
|
+
if (choice === "Disable Jev")
|
|
49
|
+
return { jev: false };
|
|
50
|
+
if (choice === "Custom guideline path") {
|
|
51
|
+
const path = await ctx.ui.input("Routing guideline Markdown path", typeof settings.customGuideline === "string" ? settings.customGuideline : "~/.pi/agent/agents/custom-route.md");
|
|
52
|
+
return path?.trim() ? { customGuideline: path.trim() } : undefined;
|
|
53
|
+
}
|
|
54
|
+
const models = settings.jev ? settings.jev.models.map(entry => ({ ...entry })) : [];
|
|
55
|
+
// Creating a project block must not silently copy a global credential.
|
|
56
|
+
const localKey = local.jev ? local.jev.TYPESAFE_API_KEY : undefined;
|
|
57
|
+
if (choice === "Use Pi/environment credentials")
|
|
58
|
+
return models.length ? { jev: { models } } : undefined;
|
|
59
|
+
if (choice === "Typesafe API key") {
|
|
60
|
+
if (!models.length) {
|
|
61
|
+
ctx.ui.notify("Configure Jev models first.", "info");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const key = await maskedApiKey(ctx);
|
|
65
|
+
if (!key)
|
|
66
|
+
return;
|
|
67
|
+
try {
|
|
68
|
+
return { jev: parseJevConfig({ models, TYPESAFE_API_KEY: key }) };
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
ctx.ui.notify(err instanceof Error ? err.message : "Invalid Jev settings", "warning");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (;;) {
|
|
76
|
+
const options = [...models.map((entry, index) => `${index + 1}. ${entry.model} (${entry.thinkingLevel})`), "Add model", "Save", "Cancel"];
|
|
77
|
+
const action = await ctx.ui.select("Jev models — describe which tasks each model should handle", options);
|
|
78
|
+
if (!action || action === "Cancel")
|
|
79
|
+
return;
|
|
80
|
+
if (action === "Save") {
|
|
81
|
+
try {
|
|
82
|
+
return { jev: parseJevConfig({ models, ...(localKey ? { TYPESAFE_API_KEY: localKey } : {}) }) };
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
ctx.ui.notify(err instanceof Error ? err.message : "Invalid Jev models", "warning");
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const index = options.indexOf(action);
|
|
90
|
+
const current = action === "Add model" ? undefined : models[index];
|
|
91
|
+
if (current) {
|
|
92
|
+
const edit = await ctx.ui.select(current.model, ["Edit", "Remove", "Back"]);
|
|
93
|
+
if (edit === "Remove") {
|
|
94
|
+
models.splice(index, 1);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (edit !== "Edit")
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const model = await ctx.ui.input("Exact provider/model-id", current?.model);
|
|
101
|
+
if (!model)
|
|
102
|
+
continue;
|
|
103
|
+
const description = await ctx.ui.editor("Tasks this model is suitable for", current?.description ?? "");
|
|
104
|
+
if (!description?.trim())
|
|
105
|
+
continue;
|
|
106
|
+
const selectedLevel = await ctx.ui.select(`Thinking level (required${current ? `; current: ${current.thinkingLevel}` : ""})`, [...ROUTING_THINKING_LEVELS]);
|
|
107
|
+
const thinkingLevel = ROUTING_THINKING_LEVELS.find(level => level === selectedLevel);
|
|
108
|
+
if (!thinkingLevel)
|
|
109
|
+
continue;
|
|
110
|
+
const entry = { model: model.trim(), description: description.trim(), thinkingLevel };
|
|
111
|
+
if (current)
|
|
112
|
+
models[index] = entry;
|
|
113
|
+
else
|
|
114
|
+
models.push(entry);
|
|
115
|
+
}
|
|
116
|
+
}
|
package/dist/workflow/host.js
CHANGED
|
@@ -260,6 +260,8 @@ export function createWorkflowHost(deps) {
|
|
|
260
260
|
try {
|
|
261
261
|
const { record } = await manager.spawnAndWait(pi, ctx, dispatch.type, request.prompt, {
|
|
262
262
|
description: request.label,
|
|
263
|
+
agentConfig: config,
|
|
264
|
+
routing: { modelExplicit: modelInput !== undefined, thinkingExplicit: request.effort !== undefined || config?.thinking !== undefined, entrypoint: "workflow" },
|
|
263
265
|
// The stamp is what keeps this child out of the session's
|
|
264
266
|
// `maxConcurrent` pool — see `occupiesPoolSlot`. The run already
|
|
265
267
|
// bounds how many of its agents run at once, and counting them
|
package/docs/rpc.md
CHANGED
|
@@ -34,6 +34,7 @@ For the channel list, the reply envelope, the per-channel snippets and the event
|
|
|
34
34
|
|
|
35
35
|
| Field | Why it is taken away |
|
|
36
36
|
|---|---|
|
|
37
|
+
| `routing`, `agentConfig` | Private routing provenance and selected agent definition; callers cannot turn an explicit model into an inherited default or inject a Jev policy |
|
|
37
38
|
| `parentAgentId` | Ownership. A forged parent hides your agent under someone else's nested tools |
|
|
38
39
|
| `workflowId` | A forged value would hide an RPC-spawned agent inside someone else's workflow — and take it out of the concurrency pool with it |
|
|
39
40
|
| `depth`, `maxSubagentDepth` | The nesting cap is inherited, not declared |
|
|
@@ -52,6 +53,12 @@ Four things that are not obvious from the tables:
|
|
|
52
53
|
- **`structuredOutput` is documented "set only by the workflow host"** (`src/agent-manager.ts:231-234`) and is also not stripped.
|
|
53
54
|
- **`signal` and the `on*` callbacks are function values.** They work only because the bus is in-process. A caller that genuinely serializes its payload cannot use them, and they arrive as `undefined` rather than failing.
|
|
54
55
|
|
|
56
|
+
### Model routing
|
|
57
|
+
|
|
58
|
+
RPC and the manager registry follow the [same routingMode setting](../README.md#model-routing) as the tools. Under `auto` (default), enabled custom agents and a configured guideline take priority; with Jev active, a fresh spawn omitting both `model` and `thinkingLevel` is classified before worktree/session creation. Providing either field skips classification only under `auto`. A selected Jev profile applies its model and required `thinkingLevel` together; Pi clamps the level to model support. Shadow and fallback preserve both original values. Under `jev`, Jev chooses first, including over explicit or agent-file models; missing credentials, invalid configuration, uncertainty or errors keep the default-priority choice. Under `shadow`, the same check records a suggestion but preserves that choice. Under `off`, routing guidance and Jev are disabled. `null` means omitted. No mode creates an extra main-agent turn to interpret descriptions or Markdown. `awaitStartup` includes classification and its two-second deadline, while cancellation stops startup.
|
|
59
|
+
|
|
60
|
+
Completion events expose the credential-free `routing` decision and optional classifier-only `routingUsage`. `routing.mode` identifies the mode, `model` and `thinkingLevel` an applied Jev profile, `suggestedModel` and `suggestedThinkingLevel` an observed shadow profile, and `fallbackSource` the preserved default source. Classifier tokens are separate from coding token totals; reported classifier cost, including shadow requests, is included in total cost once. `routing.unpriced` means the catalog does not provide a price. Settings events omit literal TypeSafe keys.
|
|
61
|
+
|
|
55
62
|
### Names that look right and are not
|
|
56
63
|
|
|
57
64
|
One of these already shipped as a bug in this project's own README example, so it is worth reading the table even if you are sure.
|
package/docs/workflows.md
CHANGED
|
@@ -308,6 +308,10 @@ A run's concurrency limit is its own, independent of the session's `maxConcurren
|
|
|
308
308
|
|
|
309
309
|
### Settings and the CLI flag
|
|
310
310
|
|
|
311
|
+
Workflow children follow [Model routing](../README.md#model-routing). With `routingMode: "auto"` (default), custom agents come first, then a main-agent Markdown guideline, then optional Jev, then the existing model. The main agent receives the guideline before writing the script and can express its choice through `agent(prompt, { model, effort })`. Either explicit option skips Jev under `auto`; workflow options retain their precedence over agent-file defaults. A selected Jev profile applies its model and required `thinkingLevel` together; Pi clamps the level to model support. Shadow and fallback preserve both original values. Under `jev`, Jev chooses first and those defaults remain the fallback on uncertainty, missing credentials or errors. Under `shadow`, Jev records a suggestion without changing the model; under `off`, routing guidance and Jev are disabled. An inherited parent model is eligible for Jev. Resuming a child does not classify again in any mode.
|
|
312
|
+
|
|
313
|
+
Jev classification happens once at child startup through Pi's native API, with a separate concurrency limit of four. Uncertainty, errors or a two-second timeout keep the existing model; stopping the workflow cancels classification without launching another child. Classifier tokens do not affect `budget.spent()` or coding context. Reported classifier cost rolls into child/ancestor cost totals once; classifier usage and the routing decision remain separately available on the agent record. Catalog-zero classifier prices mean unavailable pricing.
|
|
314
|
+
|
|
311
315
|
`workflowsEnabled` is **on**; leaving it unset means *auto*, which is on unless another extension already offers a `Workflow` or `SubagentWorkflow` tool, in which case this one stands down for the session. Setting it explicitly pins it. See [Persistent settings](../README.md#persistent-settings).
|
|
312
316
|
|
|
313
317
|
`pi --subagents-workflow-file=<path>` runs a workflow at startup, including headless under `pi -p`. Use the `=` form — the bare `--flag value` spelling swallows the next argument. See [CLI flags](../README.md#cli-flags).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@diousk/pi-subagents-fast",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "A pi extension that brings Claude Code-like sub-agents and workflow orchestration to pi — parallel execution, live widget, fleet view, custom agent types, mid-run steering, dynamic workflows, Claude Code compatibility, look and feel.",
|
|
5
5
|
"author": "tintinweb",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
"autonomous"
|
|
25
25
|
],
|
|
26
26
|
"peerDependencies": {
|
|
27
|
-
"@earendil-works/pi-ai": ">=0.
|
|
28
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
29
|
-
"@earendil-works/pi-tui": ">=0.
|
|
27
|
+
"@earendil-works/pi-ai": ">=1.0.0",
|
|
28
|
+
"@earendil-works/pi-coding-agent": ">=1.0.0",
|
|
29
|
+
"@earendil-works/pi-tui": ">=1.0.0"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@sinclair/typebox": "^0.34.49",
|
|
@@ -50,9 +50,9 @@
|
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@biomejs/biome": "^2.4.14",
|
|
53
|
-
"@earendil-works/pi-ai": "0.
|
|
54
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
55
|
-
"@earendil-works/pi-tui": "0.
|
|
53
|
+
"@earendil-works/pi-ai": "1.0.0",
|
|
54
|
+
"@earendil-works/pi-coding-agent": "1.0.0",
|
|
55
|
+
"@earendil-works/pi-tui": "1.0.0",
|
|
56
56
|
"@types/node": "^25.5.0",
|
|
57
57
|
"@vitest/coverage-istanbul": "^4.1.10",
|
|
58
58
|
"typescript": "^6.0.0",
|
package/src/agent-manager.ts
CHANGED
|
@@ -19,10 +19,12 @@ import { statSync } from "node:fs";
|
|
|
19
19
|
import { isAbsolute } from "node:path";
|
|
20
20
|
import type { Model } from "@earendil-works/pi-ai";
|
|
21
21
|
import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
22
|
-
import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
|
|
22
|
+
import { resolveDefaultModel, resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
|
|
23
|
+
import { getAgentConfig } from "./agent-types.js";
|
|
23
24
|
import { assignHandle, handleBase } from "./mention.js";
|
|
24
25
|
import { describeModel } from "./model-resolver.js";
|
|
25
|
-
import
|
|
26
|
+
import { loadRoutingPolicy, ModelRouter, type RoutingInput } from "./model-routing.js";
|
|
27
|
+
import type { AgentConfig, AgentInvocation, AgentRecord, AgentTombstone, IsolationMode, MentionResolution, SubagentType, ThinkingLevel } from "./types.js";
|
|
26
28
|
import { addUsage, type LifetimeUsage } from "./usage.js";
|
|
27
29
|
import type { CompiledSchema } from "./workflow/json-schema.js";
|
|
28
30
|
import { cleanupWorktree, createWorktree, isWorktreeIsolationEnabled, pruneWorktrees, } from "./worktree.js";
|
|
@@ -167,6 +169,10 @@ interface SpawnArgs {
|
|
|
167
169
|
}
|
|
168
170
|
|
|
169
171
|
interface SpawnOptions {
|
|
172
|
+
/** Internal only; stripped at the programmatic/RPC boundary. */
|
|
173
|
+
routing?: RoutingInput;
|
|
174
|
+
/** Branch-local definition selected by a trusted invocation resolver. */
|
|
175
|
+
agentConfig?: AgentConfig;
|
|
170
176
|
description: string;
|
|
171
177
|
/**
|
|
172
178
|
* Optional memorable name for this instance, becoming a second handle
|
|
@@ -361,6 +367,7 @@ async function shutdownChildSession(session: AgentSession | undefined): Promise<
|
|
|
361
367
|
}
|
|
362
368
|
|
|
363
369
|
export class AgentManager {
|
|
370
|
+
private router = new ModelRouter();
|
|
364
371
|
private agents = new Map<string, AgentRecord>();
|
|
365
372
|
private cleanupInterval: ReturnType<typeof setInterval>;
|
|
366
373
|
private onComplete?: OnAgentComplete;
|
|
@@ -550,7 +557,7 @@ export class AgentManager {
|
|
|
550
557
|
record.alias = assignHandle(handleBase(options.name), this.takenHandles());
|
|
551
558
|
}
|
|
552
559
|
|
|
553
|
-
const args: SpawnArgs = { pi, ctx, type, prompt, options };
|
|
560
|
+
const args: SpawnArgs = { pi, ctx, type, prompt, options: { ...options, agentConfig: options.agentConfig ?? getAgentConfig(type) } };
|
|
554
561
|
|
|
555
562
|
const pool = this.poolFor(record);
|
|
556
563
|
if (pool !== undefined && !options.bypassQueue && !this.poolHasRoom(pool)) {
|
|
@@ -696,12 +703,67 @@ export class AgentManager {
|
|
|
696
703
|
if (pool === "background") this.runningBackground--;
|
|
697
704
|
else if (pool === "foreground") this.runningForeground--;
|
|
698
705
|
};
|
|
706
|
+
const wasQueued = record.startGate !== undefined;
|
|
699
707
|
record.status = "running";
|
|
700
708
|
record.startedAt = Date.now();
|
|
701
709
|
record.startGate = undefined;
|
|
702
710
|
if (pool === "background") this.runningBackground++;
|
|
703
711
|
else if (pool === "foreground") this.runningForeground++;
|
|
704
712
|
|
|
713
|
+
const config = options.agentConfig;
|
|
714
|
+
const provenance = options.routing;
|
|
715
|
+
const explicit = provenance
|
|
716
|
+
? provenance.modelExplicit || provenance.thinkingExplicit
|
|
717
|
+
: options.model != null || options.thinkingLevel != null;
|
|
718
|
+
const currentPolicy = wasQueued || !provenance?.policy ? loadRoutingPolicy(options.configCwd ?? ctx.cwd) : undefined;
|
|
719
|
+
const policy = currentPolicy ?? provenance!.policy!;
|
|
720
|
+
record.routing = { mode: policy.mode, source: policy.source, fallbackSource: policy.fallbackSource,
|
|
721
|
+
code: policy.mode === "off" ? "off" : policy.diagnostic ? "guideline_unavailable" : "baseline",
|
|
722
|
+
reason: policy.mode === "off" ? "Model routing and routing guidance are off" : policy.diagnostic ?? "Using the existing model", guidelinePath: policy.guidelinePath, guidelineHash: policy.guidelineHash };
|
|
723
|
+
if (policy.diagnostic) console.warn(`[pi-subagents] ${policy.diagnostic}`);
|
|
724
|
+
const route = policy.mode === "jev" || policy.mode === "shadow" ||
|
|
725
|
+
(policy.mode === "auto" && !explicit && !config?.model && !config?.thinking && policy.source === "jev");
|
|
726
|
+
if (!options.resumeSessionFile && provenance?.entrypoint !== "internal" && route) {
|
|
727
|
+
const stop = () => this.abort(id);
|
|
728
|
+
options.signal?.addEventListener("abort", stop, { once: true });
|
|
729
|
+
if (options.signal?.aborted) stop();
|
|
730
|
+
try {
|
|
731
|
+
const routed = await this.router.choose(ctx, policy, prompt, options.description ?? type,
|
|
732
|
+
options.model ?? resolveDefaultModel(ctx.model, ctx.modelRegistry, config?.model),
|
|
733
|
+
record.abortController!.signal, usage => {
|
|
734
|
+
record.routingUsage = usage;
|
|
735
|
+
// Classifier tokens stay separate from coding/context/output budgets.
|
|
736
|
+
const delta = { input: usage.input, output: usage.output, cacheWrite: usage.cacheWrite, cacheRead: usage.cacheRead, cost: usage.cost.total };
|
|
737
|
+
this.onUsage?.(record, delta);
|
|
738
|
+
for (let current: AgentRecord | undefined = record; current; ) {
|
|
739
|
+
current.lifetimeUsage.cost = (current.lifetimeUsage.cost ?? 0) + usage.cost.total;
|
|
740
|
+
current = current.parentAgentId ? this.agents.get(current.parentAgentId) : undefined;
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
record.routing = { ...routed.decision, guidelinePath: policy.guidelinePath, guidelineHash: policy.guidelineHash };
|
|
744
|
+
if (routed.model && policy.mode === "shadow") {
|
|
745
|
+
record.routing = { ...record.routing, code: "shadow", model: undefined, thinkingLevel: undefined, suggestedModel: routed.decision.model,
|
|
746
|
+
fallbackSource: policy.source, reason: "Jev suggested a model; shadow mode kept the default-priority model" };
|
|
747
|
+
} else if (routed.model) {
|
|
748
|
+
options.model = routed.model;
|
|
749
|
+
options.thinkingLevel = routed.thinkingLevel;
|
|
750
|
+
record.invocation = { ...record.invocation, thinking: routed.thinkingLevel, requestedThinking: undefined };
|
|
751
|
+
}
|
|
752
|
+
} catch {
|
|
753
|
+
record.routing.code = "classifier_error";
|
|
754
|
+
record.routing.reason = "Jev could not choose a model; using the existing model";
|
|
755
|
+
} finally {
|
|
756
|
+
options.signal?.removeEventListener("abort", stop);
|
|
757
|
+
}
|
|
758
|
+
if (record.status !== "running" || record.abortController!.signal.aborted) {
|
|
759
|
+
this.settleRun(record, true, pool);
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
} else if (policy.mode === "auto" && (explicit || config?.model || config?.thinking)) {
|
|
763
|
+
record.routing.code = "explicit";
|
|
764
|
+
record.routing.reason = "Explicit model or thinking; automatic routing skipped";
|
|
765
|
+
}
|
|
766
|
+
|
|
705
767
|
// Worktree isolation: try to create a temporary git worktree. Strict —
|
|
706
768
|
// fail loud if not possible (no silent fallback to main tree). Done BEFORE
|
|
707
769
|
// the run is kicked off so a failure doesn't leave a half-running agent.
|
|
@@ -761,6 +823,7 @@ export class AgentManager {
|
|
|
761
823
|
const promise = runAgent(ctx, type, prompt, {
|
|
762
824
|
pi,
|
|
763
825
|
agentId: id,
|
|
826
|
+
agentConfig: options.agentConfig,
|
|
764
827
|
model: options.model,
|
|
765
828
|
maxTurns: options.maxTurns,
|
|
766
829
|
isolated: options.isolated,
|
|
@@ -1556,6 +1619,7 @@ export class AgentManager {
|
|
|
1556
1619
|
*/
|
|
1557
1620
|
async dispose(pi?: ExtensionAPI): Promise<void> {
|
|
1558
1621
|
clearInterval(this.cleanupInterval);
|
|
1622
|
+
this.abortAll();
|
|
1559
1623
|
// Clear queue — via dequeue, so anyone blocked in spawnAndWait is woken
|
|
1560
1624
|
// rather than left awaiting a gate nothing will ever resolve.
|
|
1561
1625
|
this.dequeue(() => true);
|
package/src/agent-runner.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { createNestedSubagentTools, getMaxSubagentDepth, type NestedAgentManager
|
|
|
28
28
|
import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
|
|
29
29
|
import { preloadSkills } from "./skill-loader.js";
|
|
30
30
|
import { createStructuredCapture, createStructuredOutputTool, structuredRetryPrompt } from "./structured-output.js";
|
|
31
|
-
import type { ServiceTier, SubagentType, ThinkingLevel } from "./types.js";
|
|
31
|
+
import type { AgentConfig, ServiceTier, SubagentType, ThinkingLevel } from "./types.js";
|
|
32
32
|
import type { LifetimeUsage } from "./usage.js";
|
|
33
33
|
import type { CompiledSchema } from "./workflow/json-schema.js";
|
|
34
34
|
|
|
@@ -436,6 +436,8 @@ export interface ToolActivity {
|
|
|
436
436
|
}
|
|
437
437
|
|
|
438
438
|
export interface RunOptions {
|
|
439
|
+
/** Snapshot of the selected definition for this branch. */
|
|
440
|
+
agentConfig?: AgentConfig;
|
|
439
441
|
/** ExtensionAPI instance — used for pi.exec() instead of execSync. */
|
|
440
442
|
pi: ExtensionAPI;
|
|
441
443
|
/** Manager-assigned id; suffixes session name to disambiguate parallel spawns (e.g. `Explore#a1b2c3d4`). */
|
|
@@ -654,8 +656,8 @@ export async function runAgent(
|
|
|
654
656
|
prompt: string,
|
|
655
657
|
options: RunOptions,
|
|
656
658
|
): Promise<RunResult> {
|
|
657
|
-
const
|
|
658
|
-
const
|
|
659
|
+
const agentConfig = options.agentConfig ?? getAgentConfig(type);
|
|
660
|
+
const config = agentConfig ?? getConfig(type);
|
|
659
661
|
|
|
660
662
|
// Resolve working directory: worktree override > parent cwd
|
|
661
663
|
const effectiveCwd = options.cwd ?? ctx.cwd;
|
|
@@ -688,7 +690,7 @@ export async function runAgent(
|
|
|
688
690
|
}
|
|
689
691
|
}
|
|
690
692
|
|
|
691
|
-
let toolNames = getToolNamesForType(type);
|
|
693
|
+
let toolNames = options.agentConfig ? options.agentConfig.builtinToolNames ?? [...BUILTIN_TOOL_NAMES] : getToolNamesForType(type);
|
|
692
694
|
|
|
693
695
|
// Persistent memory: detect write capability and branch accordingly.
|
|
694
696
|
// Account for disallowedTools — a tool in the base set but on the denylist is not truly available.
|
package/src/custom-agents.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
6
|
-
import { basename, join } from "node:path";
|
|
6
|
+
import { basename, join, resolve } from "node:path";
|
|
7
7
|
import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
|
|
9
|
+
import { loadRoutingSettings } from "./settings.js";
|
|
9
10
|
import type { AgentConfig, IsolationMode, MemoryScope, ServiceTier, ThinkingLevel } from "./types.js";
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -47,9 +48,10 @@ export function loadCustomAgents(cwd: string, strict = false): Map<string, Agent
|
|
|
47
48
|
const projectDir = join(cwd, ".pi", "agents");
|
|
48
49
|
|
|
49
50
|
const agents = new Map<string, AgentConfig>();
|
|
50
|
-
|
|
51
|
-
loadFromDir(
|
|
52
|
-
loadFromDir(
|
|
51
|
+
const excluded = loadRoutingSettings(cwd).guidelineFile;
|
|
52
|
+
loadFromDir(globalDir, agents, "global", strict, excluded);
|
|
53
|
+
loadFromDir(workspaceProjectDir, agents, "project", strict, excluded);
|
|
54
|
+
loadFromDir(projectDir, agents, "project", strict, excluded);
|
|
53
55
|
|
|
54
56
|
warnedLastLoad = warnedThisLoad;
|
|
55
57
|
warnedThisLoad = new Set();
|
|
@@ -57,7 +59,7 @@ export function loadCustomAgents(cwd: string, strict = false): Map<string, Agent
|
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
/** Load agent configs from a directory into the map. */
|
|
60
|
-
function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "project" | "global", strict: boolean): void {
|
|
62
|
+
function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "project" | "global", strict: boolean, excluded?: string): void {
|
|
61
63
|
if (!existsSync(dir)) return;
|
|
62
64
|
|
|
63
65
|
let files: string[];
|
|
@@ -68,6 +70,7 @@ function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "pro
|
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
for (const file of files) {
|
|
73
|
+
if (file.toLowerCase() === "custom-route.md" || resolve(dir, file) === excluded) continue;
|
|
71
74
|
const filenameType = basename(file, ".md");
|
|
72
75
|
|
|
73
76
|
const path = join(dir, file);
|
package/src/index.ts
CHANGED
|
@@ -29,12 +29,13 @@ import { isolationParam, resolveAgentInvocationConfig, resolveJoinMode } from ".
|
|
|
29
29
|
import { describeMention, handleBase, isReservedHandle, parseMention, resolveHandleToType, stripAgentPrefix } from "./mention.js";
|
|
30
30
|
import { runMentionClone } from "./mention-clone.js";
|
|
31
31
|
import { describeModel, type ModelRegistry, resolveModel } from "./model-resolver.js";
|
|
32
|
+
import { loadRoutingPolicy, routingGuidance } from "./model-routing.js";
|
|
32
33
|
import { checkModelScope, isScopeModelsEnabled, setScopeModelsEnabled } from "./model-scope.js";
|
|
33
34
|
import { getMaxSubagentDepth, setMaxSubagentDepth } from "./nested-tools.js";
|
|
34
35
|
import { createOutputFilePath, ensureOutputFile, getOutputTranscriptDefault, sessionTaskDir, setOutputTranscriptDefault, streamToOutputFile, writeInitialEntry } from "./output-file.js";
|
|
35
36
|
import { SubagentScheduler } from "./schedule.js";
|
|
36
37
|
import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
|
|
37
|
-
import { applyAndEmitLoaded, loadSettings, type SubagentsSettings, saveAndEmitChanged, type ToolDescriptionMode } from "./settings.js";
|
|
38
|
+
import { applyAndEmitLoaded, loadSettings, projectRoutingSettings, type SubagentsSettings, saveAndEmitChanged, type ToolDescriptionMode } from "./settings.js";
|
|
38
39
|
import { getForegroundOutcomeNote, getStatusNote, partialOutputSuffix } from "./status-note.js";
|
|
39
40
|
import { type AgentConfig, type AgentInvocation, type AgentMentionMode, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType, type ViewerMarkdownMode, type WidgetMode } from "./types.js";
|
|
40
41
|
import { createMentionProvider, mentionRoster, type TypeInfo } from "./ui/agent-mention.js";
|
|
@@ -57,6 +58,7 @@ import {
|
|
|
57
58
|
type UICtx,
|
|
58
59
|
} from "./ui/agent-widget.js";
|
|
59
60
|
import { FleetList, type FleetUICtx, type FleetWorkflow } from "./ui/fleet-list.js";
|
|
61
|
+
import { showRoutingMenu } from "./ui/model-routing-menu.js";
|
|
60
62
|
import { showSchedulesMenu } from "./ui/schedule-menu.js";
|
|
61
63
|
import { selectItem } from "./ui/select-item.js";
|
|
62
64
|
import { renderWorkflowCard, renderWorkflowEntryCard } from "./ui/workflow-card.js";
|
|
@@ -396,6 +398,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
396
398
|
const reloadCustomAgents = (strict = false) => {
|
|
397
399
|
const userAgents = loadCustomAgents(process.cwd(), strict);
|
|
398
400
|
registerAgents(userAgents);
|
|
401
|
+
return userAgents;
|
|
399
402
|
};
|
|
400
403
|
|
|
401
404
|
// Initial load — the only strict one. A bad edit mid-session must not kill the
|
|
@@ -561,6 +564,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
561
564
|
durationMs,
|
|
562
565
|
tokens,
|
|
563
566
|
usage,
|
|
567
|
+
routing: record.routing,
|
|
568
|
+
routingUsage: record.routingUsage,
|
|
564
569
|
};
|
|
565
570
|
}
|
|
566
571
|
|
|
@@ -586,6 +591,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
586
591
|
id: record.id, type: record.type, description: record.description,
|
|
587
592
|
status: record.status, result: record.result, error: record.error,
|
|
588
593
|
startedAt: record.startedAt, completedAt: record.completedAt,
|
|
594
|
+
routing: record.routing, routingUsage: record.routingUsage,
|
|
589
595
|
});
|
|
590
596
|
|
|
591
597
|
// Skip notification if result was already consumed via get_subagent_result
|
|
@@ -697,6 +703,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
697
703
|
|
|
698
704
|
const spawnTopLevel = (piRef: any, ctxRef: any, type: string, prompt: string, options: any) => {
|
|
699
705
|
const safeOptions = { ...(options ?? {}) };
|
|
706
|
+
delete safeOptions.routing;
|
|
707
|
+
delete safeOptions.agentConfig;
|
|
700
708
|
delete safeOptions.parentAgentId;
|
|
701
709
|
// Internal too: a forged value would hide an RPC-spawned agent inside
|
|
702
710
|
// someone else's workflow, and take it out of the concurrency pool with it.
|
|
@@ -1364,6 +1372,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
1364
1372
|
widget.onTurnStart();
|
|
1365
1373
|
});
|
|
1366
1374
|
|
|
1375
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
1376
|
+
const guidance = routingGuidance(loadRoutingPolicy(ctx.cwd));
|
|
1377
|
+
const description = agentToolDescription + (guidance ? "\n\n" + guidance : "");
|
|
1378
|
+
if (agentTool.description !== description) {
|
|
1379
|
+
agentTool.description = description;
|
|
1380
|
+
pi.registerTool(agentTool);
|
|
1381
|
+
}
|
|
1382
|
+
if (guidance) return { systemPrompt: event.systemPrompt + "\n\n" + guidance };
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1367
1385
|
/** Build the full type list text dynamically from available agents only. */
|
|
1368
1386
|
const buildTypeListText = () => {
|
|
1369
1387
|
const available = getAvailableTypes();
|
|
@@ -1578,10 +1596,11 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1578
1596
|
// Held rather than registered inline: the mention clone reuses this exact
|
|
1579
1597
|
// definition, so the agent it starts is an ordinary top-level spawn instead
|
|
1580
1598
|
// of a second implementation that has to be kept in step with this one.
|
|
1599
|
+
const initialRoutingGuidance = routingGuidance(loadRoutingPolicy(process.cwd()));
|
|
1581
1600
|
const agentTool = defineTool({
|
|
1582
1601
|
name: SUBAGENT_TOOL_NAMES.AGENT,
|
|
1583
1602
|
label: "Agent",
|
|
1584
|
-
description: agentToolDescription,
|
|
1603
|
+
description: agentToolDescription + (initialRoutingGuidance ? "\n\n" + initialRoutingGuidance : ""),
|
|
1585
1604
|
promptSnippet: "Launch autonomous sub-agents for complex multi-step tasks",
|
|
1586
1605
|
promptGuidelines: [
|
|
1587
1606
|
"Use Agent with specialized agents when the task matches an agent type's description. Subagents are valuable for parallelizing independent queries or for protecting the main context window from excessive results, but should not be used excessively when not needed. Importantly, avoid duplicating work that subagents are already doing — if you delegate research to a subagent, do not also perform the same searches yourself.",
|
|
@@ -1769,7 +1788,8 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1769
1788
|
widget.setUICtx(ctx.ui as UICtx);
|
|
1770
1789
|
|
|
1771
1790
|
// Reload custom agents so new project/global .md files are picked up without restart
|
|
1772
|
-
reloadCustomAgents();
|
|
1791
|
+
const userAgents = reloadCustomAgents();
|
|
1792
|
+
const routingPolicy = loadRoutingPolicy(ctx.cwd, userAgents);
|
|
1773
1793
|
|
|
1774
1794
|
const rawType = params.subagent_type as SubagentType;
|
|
1775
1795
|
// Single decision point for dispatch (#183): unknown, disabled and
|
|
@@ -1916,6 +1936,10 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1916
1936
|
const { modelName: recModelName, tags } = buildInvocationTags(rec.invocation);
|
|
1917
1937
|
const recModeLabel = getPromptModeLabel(type);
|
|
1918
1938
|
const recTags = recModeLabel ? [recModeLabel, ...tags] : tags;
|
|
1939
|
+
if (rec.routing?.source === "jev" && (rec.routingUsage || rec.routing.unpriced !== undefined)) {
|
|
1940
|
+
recTags.push(rec.routing.mode === "shadow" ? "Jev shadow" : "Jev");
|
|
1941
|
+
if (rec.routing.unpriced) recTags.push("Jev price unavailable");
|
|
1942
|
+
}
|
|
1919
1943
|
return {
|
|
1920
1944
|
displayName: getDisplayName(type),
|
|
1921
1945
|
description: rec.description,
|
|
@@ -1952,7 +1976,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
1952
1976
|
subagent_type: requestedType,
|
|
1953
1977
|
prompt: params.prompt as string,
|
|
1954
1978
|
model: params.model as string | undefined,
|
|
1955
|
-
thinking: thinking,
|
|
1979
|
+
thinking: params.thinking as typeof thinking,
|
|
1956
1980
|
max_turns: effectiveMaxTurns,
|
|
1957
1981
|
isolated: isolated,
|
|
1958
1982
|
isolation: isolation,
|
|
@@ -2057,6 +2081,8 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2057
2081
|
description: params.description,
|
|
2058
2082
|
name: params.name as string | undefined,
|
|
2059
2083
|
model,
|
|
2084
|
+
agentConfig: customConfig,
|
|
2085
|
+
routing: { policy: routingPolicy, modelExplicit: !!resolvedConfig.modelInput, thinkingExplicit: thinking !== undefined, entrypoint: "agent" },
|
|
2060
2086
|
maxTurns: effectiveMaxTurns,
|
|
2061
2087
|
isolated,
|
|
2062
2088
|
inheritContext,
|
|
@@ -2211,6 +2237,8 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2211
2237
|
description: params.description,
|
|
2212
2238
|
name: params.name as string | undefined,
|
|
2213
2239
|
model,
|
|
2240
|
+
agentConfig: customConfig,
|
|
2241
|
+
routing: { policy: routingPolicy, modelExplicit: !!resolvedConfig.modelInput, thinkingExplicit: thinking !== undefined, entrypoint: "agent" },
|
|
2214
2242
|
maxTurns: effectiveMaxTurns,
|
|
2215
2243
|
isolated,
|
|
2216
2244
|
inheritContext,
|
|
@@ -2933,6 +2961,7 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2933
2961
|
// Actions
|
|
2934
2962
|
options.push("Create new agent");
|
|
2935
2963
|
options.push("Settings");
|
|
2964
|
+
options.push("Model routing");
|
|
2936
2965
|
|
|
2937
2966
|
const noAgentsMsg = allNames.length === 0 && agents.length === 0
|
|
2938
2967
|
? "No agents found. Create specialized subagents that can be delegated to.\n\n" +
|
|
@@ -2964,6 +2993,14 @@ Terse command-style prompts produce shallow, generic work.
|
|
|
2964
2993
|
} else if (choice === "Settings") {
|
|
2965
2994
|
await showSettings(ctx);
|
|
2966
2995
|
await showAgentsMenu(ctx);
|
|
2996
|
+
} else if (choice === "Model routing") {
|
|
2997
|
+
const patch = await showRoutingMenu(ctx);
|
|
2998
|
+
if (patch) {
|
|
2999
|
+
const toast = saveAndEmitChanged({ ...snapshotSettings(), ...patch }, "Model routing settings updated", (event, payload) => pi.events.emit(event, payload), ctx.cwd);
|
|
3000
|
+
ctx.ui.notify(toast.message, toast.level);
|
|
3001
|
+
reloadCustomAgents();
|
|
3002
|
+
}
|
|
3003
|
+
await showAgentsMenu(ctx);
|
|
2967
3004
|
}
|
|
2968
3005
|
}
|
|
2969
3006
|
|
|
@@ -3331,6 +3368,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3331
3368
|
|
|
3332
3369
|
const { record } = await manager.spawnAndWait(pi, ctx, "general-purpose", generatePrompt, {
|
|
3333
3370
|
description: `Generate ${name} agent`,
|
|
3371
|
+
routing: { modelExplicit: false, thinkingExplicit: false, entrypoint: "internal" },
|
|
3334
3372
|
maxTurns: 5,
|
|
3335
3373
|
// Exempt from maxConcurrentForeground. This runs from a modal wizard, not
|
|
3336
3374
|
// a tool call: it passes no signal, and Esc in `ctx.ui` never reaches the
|
|
@@ -3440,6 +3478,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
|
|
|
3440
3478
|
*/
|
|
3441
3479
|
function snapshotSettings() {
|
|
3442
3480
|
return {
|
|
3481
|
+
...projectRoutingSettings(process.cwd()),
|
|
3443
3482
|
maxConcurrent: manager.getMaxConcurrent(),
|
|
3444
3483
|
// 0 = unlimited, and the default — see SubagentsSettings.
|
|
3445
3484
|
maxConcurrentForeground: manager.getMaxConcurrentForeground(),
|