@bacnh85/pi-subagent 0.15.3 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/README.md +51 -12
- package/agent-format.md +22 -6
- package/agents/general-purpose.md +1 -5
- package/agents/planner.md +1 -4
- package/agents/reviewer.md +1 -4
- package/agents/scout.md +1 -4
- package/agents/tester.md +1 -4
- package/agents/worker.md +1 -5
- package/extensions/agents.ts +2 -0
- package/extensions/background.ts +13 -0
- package/extensions/history.ts +5 -2
- package/extensions/index.ts +225 -121
- package/extensions/model.ts +94 -8
- package/extensions/roles-panel.ts +182 -0
- package/extensions/roles.ts +263 -0
- package/extensions/runner.ts +3 -1
- package/extensions/security.ts +8 -0
- package/extensions/service.ts +56 -71
- package/package.json +12 -7
package/extensions/index.ts
CHANGED
|
@@ -60,13 +60,15 @@ import {
|
|
|
60
60
|
} from "./render.ts";
|
|
61
61
|
import { type SubagentThread, threadStore } from "./threads.ts";
|
|
62
62
|
import { SUBAGENT_REQUEST_EVENT, runNamedAgent, type SubagentRunRequest } from "./service.ts";
|
|
63
|
-
import { resolveModel } from "./model.ts";
|
|
63
|
+
import { resolveModel, runWithModelFallback } from "./model.ts";
|
|
64
|
+
import { DEFAULT_ROLES, describeAgentModels, readSubagentRoles, readSubagentRolesGlobal, resolveAgentModelChain, type RolesConfig } from "./roles.ts";
|
|
64
65
|
import { ThreadViewer, type ThreadViewerCallbacks } from "./thread-viewer.ts";
|
|
65
66
|
import { createTaskWidgetController, renderLiveThreadLine, type TaskWidgetController } from "./widget.ts";
|
|
66
67
|
import {
|
|
67
68
|
startBackgroundTask,
|
|
68
69
|
cancelBackgroundTask,
|
|
69
70
|
getBackgroundTask,
|
|
71
|
+
getAllBackgroundTasks,
|
|
70
72
|
snapshotTask,
|
|
71
73
|
clearBackgroundTasks,
|
|
72
74
|
} from "./background.ts";
|
|
@@ -193,10 +195,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
193
195
|
trustedProjectAgentDirs.clear();
|
|
194
196
|
// Clear any widget from a prior session.
|
|
195
197
|
widget.clearWidgetIfIdle();
|
|
196
|
-
// Mark prior-session running tasks as interrupted (we can't resume them)
|
|
198
|
+
// Mark prior-session running tasks as interrupted (we can't resume them),
|
|
199
|
+
// but keep entries for background tasks still live in this process — only
|
|
200
|
+
// shutdown aborts them, so a session reload must not mislabel them.
|
|
197
201
|
// ponytail: honest about the in-process ceiling — no live-session resume.
|
|
198
202
|
try {
|
|
199
|
-
|
|
203
|
+
const liveBgIds = new Set(getAllBackgroundTasks().map((t) => t.id));
|
|
204
|
+
markInterruptedOnRestart(path.join(ctx.cwd, CONFIG_DIR_NAME), liveBgIds);
|
|
200
205
|
} catch { /* history file not writable — non-fatal */ }
|
|
201
206
|
});
|
|
202
207
|
|
|
@@ -213,12 +218,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
213
218
|
pi.on("before_agent_start", async (event) => {
|
|
214
219
|
const ctx = currentCtx;
|
|
215
220
|
const discovery = discoverAgents(ctx?.cwd ?? process.cwd(), "both", bundledAgentsDir);
|
|
216
|
-
const
|
|
221
|
+
const projectTrusted = ctx?.isProjectTrusted?.() ?? false;
|
|
222
|
+
// Security: project agents are repo-controlled (untrusted until the user
|
|
223
|
+
// approves them). Never let their description text reach the parent's
|
|
224
|
+
// system prompt unless the project is trusted — same gate as AGENTS.md.
|
|
225
|
+
const catalogAgents = discovery.agents.filter(
|
|
226
|
+
(agent) => projectTrusted || agent.source !== "project",
|
|
227
|
+
);
|
|
228
|
+
const rolesCfg = readSubagentRoles(ctx);
|
|
229
|
+
const catalog = catalogAgents
|
|
217
230
|
.map((agent) => {
|
|
218
|
-
const
|
|
219
|
-
const modelInfo = candidates.length > 0
|
|
220
|
-
? ` (models: ${candidates.join(" → ")} → parent fallback)`
|
|
221
|
-
: " (parent fallback)";
|
|
231
|
+
const modelInfo = ` (models: ${describeAgentModels(agent, rolesCfg)})`;
|
|
222
232
|
const thinkingInfo = agent.thinking ? `, thinking: ${agent.thinking}` : "";
|
|
223
233
|
const sandboxInfo = agent.sandbox ? `, sandbox: ${agent.sandbox}` : "";
|
|
224
234
|
// ponytail: one-line inheritance hint; the model picks agents by description, this just sets expectations.
|
|
@@ -263,6 +273,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
263
273
|
instructions: request.instructions,
|
|
264
274
|
signal: request.signal,
|
|
265
275
|
readOnly: request.readOnly,
|
|
276
|
+
allowExternalCwd: getTrustedConfig(ctx).allowExternalCwd,
|
|
266
277
|
onMessage: (result) => threadStore.updateThread(thread.id, { result }),
|
|
267
278
|
onProgress: (progress) => { threadStore.updateProgress(thread.id, progress); request.onProgress?.(progress); },
|
|
268
279
|
}).then((result) => {
|
|
@@ -317,7 +328,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
317
328
|
return container;
|
|
318
329
|
});
|
|
319
330
|
pi.registerCommand("subagent", {
|
|
320
|
-
description: "
|
|
331
|
+
description: "Configure model roles (/subagent), list agents (/subagent list), agent details (/subagent <name>), role detail (/subagent @role), reload definitions (/subagent reload), history (/subagent history)",
|
|
332
|
+
getArgumentCompletions: (prefix) => {
|
|
333
|
+
const ctx = currentCtx;
|
|
334
|
+
const keywords = ["list", "all", "agents", "roles", "reload", "refresh", "history"];
|
|
335
|
+
const vocab = [...keywords];
|
|
336
|
+
if (ctx) {
|
|
337
|
+
const discovery = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
|
|
338
|
+
vocab.push(...discovery.agents.map((a) => a.name));
|
|
339
|
+
try { vocab.push(...Object.keys(readSubagentRoles(ctx).roles).map((r) => `@${r}`)); } catch { /* roles optional */ }
|
|
340
|
+
}
|
|
341
|
+
const q = prefix.trim().toLowerCase();
|
|
342
|
+
const items = vocab.filter((v) => v.toLowerCase().startsWith(q))
|
|
343
|
+
.map((v) => ({ value: v, label: v, description: keywords.includes(v) ? "subagent command" : "agent / role" }));
|
|
344
|
+
return items.length > 0 ? items : null;
|
|
345
|
+
},
|
|
321
346
|
handler: async (args, ctx) => {
|
|
322
347
|
const cmd = args.trim().toLowerCase();
|
|
323
348
|
const discovery = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
|
|
@@ -351,6 +376,68 @@ export default function (pi: ExtensionAPI) {
|
|
|
351
376
|
return;
|
|
352
377
|
}
|
|
353
378
|
|
|
379
|
+
const openRolesEditor = async (): Promise<void> => {
|
|
380
|
+
// Role mapping editor: panel in TUI, plain text otherwise.
|
|
381
|
+
const [{ openConfigPanel }, { buildRows, buildRolesPanelCfg, cfgToPatch, preserveUnknownAgentModels, writeSubagentSection }] = await Promise.all([
|
|
382
|
+
import("@bacnh85/pi-config-panel"),
|
|
383
|
+
import("./roles-panel.ts"),
|
|
384
|
+
]);
|
|
385
|
+
if (ctx.mode !== "tui" || !ctx.hasUI) {
|
|
386
|
+
const rolesCfg = readSubagentRoles(ctx);
|
|
387
|
+
const lines = discovery.agents.map((a) => ` ${a.name.padEnd(16)} ${describeAgentModels(a, rolesCfg)}`);
|
|
388
|
+
pi.sendMessage({
|
|
389
|
+
customType: "pi-subagent",
|
|
390
|
+
content: [
|
|
391
|
+
"Model roles (edit ~/.pi/agent/settings.json → subagent.roles, or run /subagent in a TUI):",
|
|
392
|
+
...Object.entries(rolesCfg.roles).map(([name, chain]) =>
|
|
393
|
+
` @${name} = ${Array.isArray(chain) ? chain.join(", ") : chain}`),
|
|
394
|
+
"",
|
|
395
|
+
"Effective models per agent:",
|
|
396
|
+
...lines,
|
|
397
|
+
].join("\n"),
|
|
398
|
+
display: true,
|
|
399
|
+
});
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const current = readSubagentRolesGlobal();
|
|
403
|
+
const working = buildRolesPanelCfg(discovery.agents, current);
|
|
404
|
+
const panelOptions = {
|
|
405
|
+
models: () => {
|
|
406
|
+
try { return ctx.modelRegistry.getAvailable().map((m) => `${m.provider}/${m.id}`); }
|
|
407
|
+
catch { return []; }
|
|
408
|
+
},
|
|
409
|
+
roles: () => [...Object.keys(DEFAULT_ROLES), ...Object.keys(readSubagentRolesGlobal().roles)],
|
|
410
|
+
};
|
|
411
|
+
await openConfigPanel({
|
|
412
|
+
ctx,
|
|
413
|
+
cfg: working,
|
|
414
|
+
build: (cfg) => buildRows(cfg, discovery.agents, panelOptions),
|
|
415
|
+
title: "Subagent model roles",
|
|
416
|
+
onSave: (saved, editedKeys) => {
|
|
417
|
+
if (!(saved && editedKeys && editedKeys.size > 0)) return;
|
|
418
|
+
const patch = cfgToPatch(working);
|
|
419
|
+
patch.agentModels = preserveUnknownAgentModels(
|
|
420
|
+
patch.agentModels,
|
|
421
|
+
discovery.agents.map((a) => a.name),
|
|
422
|
+
current.agentModels,
|
|
423
|
+
);
|
|
424
|
+
try {
|
|
425
|
+
writeSubagentSection(patch);
|
|
426
|
+
invalidateAgentCache();
|
|
427
|
+
ctx.ui.notify("Model roles saved to settings.json", "info");
|
|
428
|
+
} catch (err) {
|
|
429
|
+
ctx.ui.notify(`Not saved — ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
return;
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
if (cmd === "roles") {
|
|
437
|
+
await openRolesEditor();
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
354
441
|
if (cmd === "reload" || cmd === "refresh") {
|
|
355
442
|
invalidateAgentCache();
|
|
356
443
|
const fresh = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
|
|
@@ -379,28 +466,61 @@ export default function (pi: ExtensionAPI) {
|
|
|
379
466
|
: "";
|
|
380
467
|
pi.sendMessage({
|
|
381
468
|
customType: "pi-subagent",
|
|
382
|
-
content: `Available agents (${discovery.agents.length}):\n ${list.text}${extra}${diagText}\n\nScopes searched:\n user: ${path.join(getAgentDir(), "agents")}${dirs}\n bundled: ${bundledAgentsDir}\n\nUse /subagent <name> for agent details, /subagent reload to refresh.`,
|
|
469
|
+
content: `Available agents (${discovery.agents.length}):\n ${list.text}${extra}${diagText}\n\nScopes searched:\n user: ${path.join(getAgentDir(), "agents")}${dirs}\n bundled: ${bundledAgentsDir}\n\nUse /subagent <name> for agent details, /subagent @role for role detail, /subagent reload to refresh.`,
|
|
383
470
|
display: true,
|
|
384
471
|
});
|
|
385
472
|
return;
|
|
386
473
|
}
|
|
387
474
|
|
|
388
475
|
if (cmd) {
|
|
389
|
-
// Show details for a specific agent
|
|
476
|
+
// Show details for a specific agent; fall back to a role detail view
|
|
477
|
+
// when the name matches a model role (e.g. "/subagent coder").
|
|
390
478
|
const agent = discovery.agents.find(
|
|
391
479
|
(a) => a.name.toLowerCase() === cmd,
|
|
392
480
|
);
|
|
393
481
|
if (!agent) {
|
|
394
|
-
|
|
482
|
+
const rolesCfg = readSubagentRoles(ctx);
|
|
483
|
+
const arg = args.trim().toLowerCase();
|
|
484
|
+
const roleName = arg.startsWith("@") ? arg.slice(1) : arg;
|
|
485
|
+
// Resolve the role key case-insensitively (role names are free-form).
|
|
486
|
+
const roleKey = Object.keys(rolesCfg.roles).find((k) => k.toLowerCase() === roleName);
|
|
487
|
+
const roleChain = roleKey !== undefined ? rolesCfg.roles[roleKey] : undefined;
|
|
488
|
+
const role = roleKey !== undefined && roleChain !== undefined
|
|
489
|
+
? { key: roleKey, chain: roleChain }
|
|
490
|
+
: undefined;
|
|
491
|
+
if (role) {
|
|
492
|
+
const { key, chain } = role;
|
|
493
|
+
const chainText = Array.isArray(chain) ? chain.join(" → ") : String(chain);
|
|
494
|
+
const users = discovery.agents.filter((a) => getModelCandidates(a).some((c) => c.toLowerCase().split(":")[0] === `@${key.toLowerCase()}`));
|
|
495
|
+
const overrides = Object.entries(rolesCfg.agentModels).filter(([, v]) => v.toLowerCase().split(":")[0] === `@${key.toLowerCase()}`);
|
|
496
|
+
const defaultText = DEFAULT_ROLES[key] !== undefined
|
|
497
|
+
? (Array.isArray(DEFAULT_ROLES[key]) ? (DEFAULT_ROLES[key] as string[]).join(" → ") : String(DEFAULT_ROLES[key]))
|
|
498
|
+
: "(custom role)";
|
|
499
|
+
pi.sendMessage({
|
|
500
|
+
customType: "pi-subagent",
|
|
501
|
+
content: [
|
|
502
|
+
`Role: @${key}`,
|
|
503
|
+
`Chain: ${chainText} → parent fallback`,
|
|
504
|
+
`Default: ${defaultText}`,
|
|
505
|
+
users.length > 0 ? `Agents using @${key}: ${users.map((a) => a.name).join(", ")}` : `No agent references @${key} yet`,
|
|
506
|
+
overrides.length > 0 ? `Overrides via @${key}: ${overrides.map(([n]) => n).join(", ")}` : "",
|
|
507
|
+
"",
|
|
508
|
+
`Edit with /subagent (roles editor) or ~/.pi/agent/settings.json → subagent.roles.`,
|
|
509
|
+
].filter(Boolean).join("\n"),
|
|
510
|
+
display: true,
|
|
511
|
+
});
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
ctx.ui.notify(`Unknown agent: "${args.trim()}". Use /subagent list to list all.`, "error");
|
|
395
515
|
return;
|
|
396
516
|
}
|
|
397
|
-
const
|
|
517
|
+
const rolesCfg = readSubagentRoles(ctx);
|
|
398
518
|
pi.sendMessage({
|
|
399
519
|
customType: "pi-subagent",
|
|
400
520
|
content: [
|
|
401
521
|
`Agent: ${agent.name} (${agent.source})`,
|
|
402
522
|
`Description: ${agent.description}`,
|
|
403
|
-
`Models: ${
|
|
523
|
+
`Models: ${describeAgentModels(agent, rolesCfg)}`,
|
|
404
524
|
`Thinking: ${agent.thinking || "off"}`,
|
|
405
525
|
`Tools: ${agent.tools?.join(", ") || "all default"}`,
|
|
406
526
|
`Source file: ${agent.filePath}`,
|
|
@@ -413,18 +533,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
413
533
|
return;
|
|
414
534
|
}
|
|
415
535
|
|
|
416
|
-
//
|
|
417
|
-
|
|
418
|
-
const extra = list.remaining > 0 ? `\n ... +${list.remaining} more` : "";
|
|
419
|
-
const dirs = discovery.projectAgentsDir ? `\n project: ${discovery.projectAgentsDir}` : "";
|
|
420
|
-
const diagText = discovery.diagnostics.length > 0
|
|
421
|
-
? "\n\nWarnings:\n" + discovery.diagnostics.map(d => ` - [${d.severity}] ${d.filePath}: ${d.issue}`).join("\n")
|
|
422
|
-
: "";
|
|
423
|
-
pi.sendMessage({
|
|
424
|
-
customType: "pi-subagent",
|
|
425
|
-
content: `Available agents (${discovery.agents.length}):\n ${list.text}${extra}${diagText}\n\nScopes searched:\n user: ${path.join(getAgentDir(), "agents")}${dirs}\n bundled: ${bundledAgentsDir}\n\nUse /subagent <name> for agent details, /subagent reload to refresh.`,
|
|
426
|
-
display: true,
|
|
427
|
-
});
|
|
536
|
+
// Bare /subagent — open the roles editor (list moved to /subagent list).
|
|
537
|
+
await openRolesEditor();
|
|
428
538
|
},
|
|
429
539
|
});
|
|
430
540
|
|
|
@@ -475,7 +585,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
475
585
|
"Bundled agents: scout (fast recon), tester (verification), worker (implementation), general-purpose (fallback), planner (planning), reviewer (review).",
|
|
476
586
|
"For background single tasks use background:true — you will be notified on completion; DO NOT poll or sleep.",
|
|
477
587
|
"Use operation: \"status\" with taskId to inspect a running/completed background task; operation: \"cancel\" to abort one.",
|
|
478
|
-
"Use /subagent to list all available agents
|
|
588
|
+
"Use /subagent list to list all available agents, /subagent <name> for agent details, /subagent @role for role detail.",
|
|
479
589
|
],
|
|
480
590
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
481
591
|
// Surface env-var timeout warnings collected at module load. The
|
|
@@ -584,6 +694,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
584
694
|
},
|
|
585
695
|
],
|
|
586
696
|
details: makeDetails("single")([]),
|
|
697
|
+
isError: true,
|
|
587
698
|
};
|
|
588
699
|
}
|
|
589
700
|
|
|
@@ -637,6 +748,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
637
748
|
const modelRuntime = (modelRegistry as any).runtime;
|
|
638
749
|
const authStorage = (modelRegistry as any).authStorage;
|
|
639
750
|
|
|
751
|
+
// Roles + per-agent overrides are read once per execute() call so every
|
|
752
|
+
// child in this run sees a consistent mapping.
|
|
753
|
+
const rolesCfg: RolesConfig = readSubagentRoles(ctx);
|
|
754
|
+
|
|
640
755
|
// Parent session's registered tool names. Agents that omit `tools` inherit
|
|
641
756
|
// the full set (minus the denylist); agents with an explicit `tools` line
|
|
642
757
|
// are validated against built-ins ∪ this set.
|
|
@@ -652,9 +767,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
652
767
|
return safe.path;
|
|
653
768
|
}
|
|
654
769
|
|
|
770
|
+
// Helper: stable history id for a foreground run (shared between the
|
|
771
|
+
// running entry written at start and the completion entry).
|
|
772
|
+
function makeForegroundHistoryId(startedAt: number): string {
|
|
773
|
+
return `fg-${startedAt.toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Helper: record a running foreground task so a crash mid-run shows as
|
|
777
|
+
// "interrupted" after restart (completion upserts by id and replaces it).
|
|
778
|
+
function recordForegroundStart(entryId: string, agentName: string, taskText: string, startedAt: number): void {
|
|
779
|
+
try {
|
|
780
|
+
appendHistory(path.join(ctx.cwd, CONFIG_DIR_NAME), {
|
|
781
|
+
id: entryId,
|
|
782
|
+
agent: agentName,
|
|
783
|
+
task: taskText,
|
|
784
|
+
status: "running",
|
|
785
|
+
startedAt,
|
|
786
|
+
});
|
|
787
|
+
} catch { /* history file not writable — non-fatal */ }
|
|
788
|
+
}
|
|
789
|
+
|
|
655
790
|
// Helper: record a completed foreground task to the history registry.
|
|
656
791
|
// ponytail: best-effort — history is non-fatal metadata for /subagent history.
|
|
657
792
|
function recordForegroundHistory(
|
|
793
|
+
entryId: string,
|
|
658
794
|
agentName: string,
|
|
659
795
|
taskText: string,
|
|
660
796
|
result: SubAgentResult,
|
|
@@ -672,7 +808,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
672
808
|
: "failed"
|
|
673
809
|
: "completed";
|
|
674
810
|
appendHistory(path.join(ctx.cwd, CONFIG_DIR_NAME), {
|
|
675
|
-
id:
|
|
811
|
+
id: entryId,
|
|
676
812
|
agent: agentName,
|
|
677
813
|
task: taskText,
|
|
678
814
|
status,
|
|
@@ -743,7 +879,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
743
879
|
};
|
|
744
880
|
}
|
|
745
881
|
|
|
746
|
-
const
|
|
882
|
+
const agentChain = resolveAgentModelChain(agent, rolesCfg);
|
|
883
|
+
const resolved = await resolveModel(agentChain.candidates, ctx.model, ctx.modelRegistry);
|
|
747
884
|
if (!resolved.model) {
|
|
748
885
|
const tried = resolved.attempted.join(", ") || "none";
|
|
749
886
|
const parentInfo = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "none";
|
|
@@ -786,9 +923,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
786
923
|
};
|
|
787
924
|
}
|
|
788
925
|
|
|
789
|
-
// Retry loop: rate-limit model fallback
|
|
790
|
-
|
|
791
|
-
|
|
926
|
+
// Retry loop: rate-limit model fallback (candidates already role-expanded).
|
|
927
|
+
// Shared with the service path — single source of truth for triedModels
|
|
928
|
+
// bookkeeping and per-candidate `:thinking` resolution.
|
|
929
|
+
const candidates = agentChain.candidates;
|
|
792
930
|
|
|
793
931
|
// Transport keep-alive only: resets parent idle timeout so a long child
|
|
794
932
|
// run isn't killed. The visible progress now lives in the live widget;
|
|
@@ -799,32 +937,47 @@ export default function (pi: ExtensionAPI) {
|
|
|
799
937
|
widget.requestRender();
|
|
800
938
|
}) : undefined;
|
|
801
939
|
try {
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
940
|
+
return await runWithModelFallback<SubAgentResult>({
|
|
941
|
+
candidates,
|
|
942
|
+
parentModel: ctx.model,
|
|
943
|
+
modelRegistry: ctx.modelRegistry,
|
|
944
|
+
thinkingByCandidate: agentChain.thinkingByCandidate,
|
|
945
|
+
defaultThinking: agent.thinking,
|
|
946
|
+
runAttempt: (model, thinkingLevel) =>
|
|
947
|
+
runSubAgent({
|
|
948
|
+
cwd: safeCwd,
|
|
949
|
+
sandbox: agent.sandbox === "worktree" ? "worktree" : undefined,
|
|
950
|
+
systemPrompt: params.instructions
|
|
951
|
+
? `${agent.systemPrompt}\n\n## Task Contract\n${params.instructions.slice(0, MAX_INSTRUCTIONS_LENGTH)}`
|
|
952
|
+
: agent.systemPrompt,
|
|
809
953
|
task,
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
954
|
+
tools,
|
|
955
|
+
model,
|
|
956
|
+
modelRuntime,
|
|
957
|
+
authStorage,
|
|
958
|
+
modelRegistry,
|
|
959
|
+
signal: parentSignal,
|
|
960
|
+
timeoutMs: effectiveTimeoutMs,
|
|
961
|
+
agentName,
|
|
962
|
+
thinkingLevel,
|
|
963
|
+
onMessage: onProgress,
|
|
964
|
+
onProgress: onActivity,
|
|
965
|
+
loadExtensions,
|
|
966
|
+
projectTrusted,
|
|
967
|
+
}),
|
|
968
|
+
isRateLimited: (result) => Boolean(result.errorMessage && isRateLimitError(result.errorMessage)),
|
|
969
|
+
onExhausted: (reason, triedModels, remaining) => {
|
|
970
|
+
const exhaustedStderr = reason === "no-model"
|
|
971
|
+
? [
|
|
972
|
+
`All models rate-limited or unavailable.`,
|
|
973
|
+
`Tried: ${triedModels.join(" → ") || "(none)"}.`,
|
|
974
|
+
`Remaining candidates: ${remaining.join(", ") || "none"}.`,
|
|
975
|
+
`Parent: ${ctx.model?.provider}/${ctx.model?.id}.`,
|
|
976
|
+
].join(" ")
|
|
977
|
+
: [
|
|
978
|
+
`All available models exhausted.`,
|
|
979
|
+
`Tried: ${triedModels.join(" → ")}.`,
|
|
980
|
+
].join(" ");
|
|
828
981
|
return {
|
|
829
982
|
agent: agentName,
|
|
830
983
|
task,
|
|
@@ -832,69 +985,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
832
985
|
status: "error" as const,
|
|
833
986
|
stopReason: "error" as const,
|
|
834
987
|
messages: [],
|
|
835
|
-
stderr:
|
|
836
|
-
`All available models exhausted.`,
|
|
837
|
-
`Tried: ${triedModels.join(" → ")}.`,
|
|
838
|
-
].join(" "),
|
|
988
|
+
stderr: exhaustedStderr,
|
|
839
989
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
840
|
-
errorMessage:
|
|
990
|
+
errorMessage: reason === "no-model"
|
|
991
|
+
? `All models exhausted (tried: ${triedModels.join(" → ") || "none"})`
|
|
992
|
+
: `All available models exhausted (tried: ${triedModels.join(" → ")})`,
|
|
841
993
|
};
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
// Also track the raw candidate name so candidates.filter() can
|
|
845
|
-
// exclude it even when the agent uses unqualified names.
|
|
846
|
-
// Avoid duplicating when candidate name is already qualified (matchedCandidate === triedName).
|
|
847
|
-
if (fallbackResolved.matchedCandidate && fallbackResolved.matchedCandidate !== triedName) {
|
|
848
|
-
triedModels.push(fallbackResolved.matchedCandidate);
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
const result = await runSubAgent({
|
|
852
|
-
cwd: safeCwd,
|
|
853
|
-
sandbox: agent.sandbox === "worktree" ? "worktree" : undefined,
|
|
854
|
-
systemPrompt: params.instructions
|
|
855
|
-
? `${agent.systemPrompt}\n\n## Task Contract\n${params.instructions.slice(0, MAX_INSTRUCTIONS_LENGTH)}`
|
|
856
|
-
: agent.systemPrompt,
|
|
857
|
-
task,
|
|
858
|
-
tools,
|
|
859
|
-
model: fallbackResolved.model,
|
|
860
|
-
modelRuntime,
|
|
861
|
-
authStorage,
|
|
862
|
-
modelRegistry,
|
|
863
|
-
signal: parentSignal,
|
|
864
|
-
timeoutMs: effectiveTimeoutMs,
|
|
865
|
-
agentName,
|
|
866
|
-
thinkingLevel: agent.thinking,
|
|
867
|
-
onMessage: onProgress,
|
|
868
|
-
onProgress: onActivity,
|
|
869
|
-
loadExtensions,
|
|
870
|
-
projectTrusted,
|
|
871
|
-
});
|
|
872
|
-
|
|
873
|
-
if (result.errorMessage && isRateLimitError(result.errorMessage)) {
|
|
874
|
-
// If the model that just rate-limited was the parent fallback
|
|
875
|
-
// (no remaining candidates), stop — no further options.
|
|
876
|
-
if (isParentFallback) {
|
|
877
|
-
return {
|
|
878
|
-
agent: agentName,
|
|
879
|
-
task,
|
|
880
|
-
exitCode: 1,
|
|
881
|
-
status: "error" as const,
|
|
882
|
-
stopReason: "error" as const,
|
|
883
|
-
messages: [],
|
|
884
|
-
stderr: [
|
|
885
|
-
`All available models exhausted.`,
|
|
886
|
-
`Tried: ${triedModels.join(" → ")}.`,
|
|
887
|
-
].join(" "),
|
|
888
|
-
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
889
|
-
errorMessage: `All available models exhausted (tried: ${triedModels.join(" → ")})`,
|
|
890
|
-
};
|
|
891
|
-
}
|
|
892
|
-
return tryWithFallback();
|
|
893
|
-
}
|
|
894
|
-
return result;
|
|
895
|
-
};
|
|
896
|
-
|
|
897
|
-
return tryWithFallback();
|
|
994
|
+
},
|
|
995
|
+
});
|
|
898
996
|
} finally {
|
|
899
997
|
stopHeartbeat?.();
|
|
900
998
|
}
|
|
@@ -917,6 +1015,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
917
1015
|
color: agentToThemeColor(step.agent),
|
|
918
1016
|
});
|
|
919
1017
|
if (ctx.mode === "tui") widget.ensureWidget(ctx);
|
|
1018
|
+
const historyId = makeForegroundHistoryId(thread.createdAt);
|
|
1019
|
+
recordForegroundStart(historyId, step.agent, taskWithContext, thread.createdAt);
|
|
920
1020
|
const result = await runOne(
|
|
921
1021
|
step.agent, taskWithContext, step.cwd,
|
|
922
1022
|
signal, step.timeout ?? params.timeout,
|
|
@@ -929,7 +1029,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
929
1029
|
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
|
930
1030
|
result,
|
|
931
1031
|
});
|
|
932
|
-
recordForegroundHistory(step.agent, taskWithContext, result, thread.createdAt);
|
|
1032
|
+
recordForegroundHistory(historyId, step.agent, taskWithContext, result, thread.createdAt);
|
|
933
1033
|
results.push(result);
|
|
934
1034
|
|
|
935
1035
|
const isError = isFailedResult(result);
|
|
@@ -1075,6 +1175,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1075
1175
|
emitParallelUpdate();
|
|
1076
1176
|
return skippedResult;
|
|
1077
1177
|
}
|
|
1178
|
+
const historyId = makeForegroundHistoryId(parallelThreads[index].createdAt);
|
|
1179
|
+
recordForegroundStart(historyId, t.agent, t.task, parallelThreads[index].createdAt);
|
|
1078
1180
|
const result = await runOne(
|
|
1079
1181
|
t.agent, t.task, t.cwd,
|
|
1080
1182
|
parallelController.signal, t.timeout ?? params.timeout,
|
|
@@ -1088,7 +1190,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1088
1190
|
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
|
1089
1191
|
result,
|
|
1090
1192
|
});
|
|
1091
|
-
recordForegroundHistory(t.agent, t.task, result, parallelThreads[index].createdAt);
|
|
1193
|
+
recordForegroundHistory(historyId, t.agent, t.task, result, parallelThreads[index].createdAt);
|
|
1092
1194
|
// Early-abort: if this task failed and abortOnFailure is set
|
|
1093
1195
|
if (abortOnFailure && isFailedResult(result) && !abortCause) {
|
|
1094
1196
|
abortCause = result.stopReason === "timeout" ? "timeout" : "sibling";
|
|
@@ -1152,6 +1254,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1152
1254
|
color: agentToThemeColor(params.agent),
|
|
1153
1255
|
});
|
|
1154
1256
|
if (ctx.mode === "tui") widget.ensureWidget(ctx);
|
|
1257
|
+
const historyId = makeForegroundHistoryId(thread.createdAt);
|
|
1258
|
+
recordForegroundStart(historyId, params.agent, params.task, thread.createdAt);
|
|
1155
1259
|
const result = await runOne(
|
|
1156
1260
|
params.agent, params.task, params.cwd,
|
|
1157
1261
|
signal, params.timeout,
|
|
@@ -1164,7 +1268,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1164
1268
|
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
|
1165
1269
|
result,
|
|
1166
1270
|
});
|
|
1167
|
-
recordForegroundHistory(params.agent, params.task, result, thread.createdAt);
|
|
1271
|
+
recordForegroundHistory(historyId, params.agent, params.task, result, thread.createdAt);
|
|
1168
1272
|
const isError = isFailedResult(result);
|
|
1169
1273
|
|
|
1170
1274
|
if (onUpdate) {
|
package/extensions/model.ts
CHANGED
|
@@ -13,12 +13,15 @@
|
|
|
13
13
|
|
|
14
14
|
import type { Model } from "@earendil-works/pi-ai";
|
|
15
15
|
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { splitThinkingSuffix, type SubagentThinkingLevel } from "./roles.ts";
|
|
16
17
|
|
|
17
18
|
export interface ResolvedModel {
|
|
18
19
|
model: Model<any> | null;
|
|
19
20
|
attempted: string[];
|
|
20
21
|
/** The raw candidate name that matched, if a candidate resolved. Undefined for parent fallback. */
|
|
21
22
|
matchedCandidate?: string;
|
|
23
|
+
/** Thinking level carried by the matched candidate's `:level` suffix, if any. */
|
|
24
|
+
matchedThinking?: SubagentThinkingLevel;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
/** Known provider prefixes for unqualified model names. */
|
|
@@ -46,19 +49,20 @@ export async function resolveModel(
|
|
|
46
49
|
};
|
|
47
50
|
|
|
48
51
|
for (const modelName of [...new Set(modelNames.map((name) => name.trim()).filter(Boolean))]) {
|
|
49
|
-
const
|
|
52
|
+
const { name: bareName, thinking } = splitThinkingSuffix(modelName);
|
|
53
|
+
const idx = bareName.indexOf("/");
|
|
50
54
|
if (idx > 0) {
|
|
51
|
-
const found = tryAvailable(
|
|
52
|
-
if (found) return { model: found, attempted, matchedCandidate: modelName };
|
|
55
|
+
const found = tryAvailable(bareName);
|
|
56
|
+
if (found) return { model: found, attempted, matchedCandidate: modelName, matchedThinking: thinking };
|
|
53
57
|
continue;
|
|
54
58
|
}
|
|
55
59
|
for (const [provider, pattern] of KNOWN_PROVIDERS) {
|
|
56
|
-
if (!pattern.test(
|
|
57
|
-
const found = tryAvailable(`${provider}/${
|
|
58
|
-
if (found) return { model: found, attempted, matchedCandidate: modelName };
|
|
60
|
+
if (!pattern.test(bareName)) continue;
|
|
61
|
+
const found = tryAvailable(`${provider}/${bareName}`);
|
|
62
|
+
if (found) return { model: found, attempted, matchedCandidate: modelName, matchedThinking: thinking };
|
|
59
63
|
}
|
|
60
|
-
const found = tryAvailable(`anthropic/${
|
|
61
|
-
if (found) return { model: found, attempted, matchedCandidate: modelName };
|
|
64
|
+
const found = tryAvailable(`anthropic/${bareName}`);
|
|
65
|
+
if (found) return { model: found, attempted, matchedCandidate: modelName, matchedThinking: thinking };
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
if (parentModel) {
|
|
@@ -67,3 +71,85 @@ export async function resolveModel(
|
|
|
67
71
|
}
|
|
68
72
|
return { model: null, attempted };
|
|
69
73
|
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Rate-limit model fallback (shared by tool path and service path)
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
export type ModelFallbackExhaustReason = "no-model" | "already-tried" | "parent-rate-limited";
|
|
80
|
+
|
|
81
|
+
export interface ModelFallbackOptions<T> {
|
|
82
|
+
candidates: readonly string[];
|
|
83
|
+
parentModel: Model<any> | undefined;
|
|
84
|
+
modelRegistry?: ModelRegistry;
|
|
85
|
+
/** thinking suffix per stripped candidate name (from resolveAgentModelChain). */
|
|
86
|
+
thinkingByCandidate: ReadonlyMap<string, SubagentThinkingLevel>;
|
|
87
|
+
/** Fallback thinking when no candidate carries a `:level` suffix. */
|
|
88
|
+
defaultThinking?: SubagentThinkingLevel;
|
|
89
|
+
runAttempt: (model: Model<any>, thinkingLevel: SubagentThinkingLevel | undefined) => Promise<T>;
|
|
90
|
+
isRateLimited: (result: T) => boolean;
|
|
91
|
+
/** Build the terminal value when all models are exhausted (path-specific error mapping). */
|
|
92
|
+
onExhausted: (reason: ModelFallbackExhaustReason, triedModels: string[], remaining: string[]) => T;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Retry loop shared by the tool handler (index.ts) and the event-driven
|
|
97
|
+
* service path (service.ts): try candidates in priority order, falling back to
|
|
98
|
+
* the parent model, advancing on rate-limit errors. Single source of truth for
|
|
99
|
+
* `triedModels` bookkeeping and per-candidate `:thinking` resolution.
|
|
100
|
+
*/
|
|
101
|
+
export async function runWithModelFallback<T>(options: ModelFallbackOptions<T>): Promise<T> {
|
|
102
|
+
const {
|
|
103
|
+
candidates,
|
|
104
|
+
parentModel,
|
|
105
|
+
modelRegistry,
|
|
106
|
+
thinkingByCandidate,
|
|
107
|
+
defaultThinking,
|
|
108
|
+
runAttempt,
|
|
109
|
+
isRateLimited,
|
|
110
|
+
onExhausted,
|
|
111
|
+
} = options;
|
|
112
|
+
const triedModels: string[] = [];
|
|
113
|
+
|
|
114
|
+
const attempt = async (): Promise<T> => {
|
|
115
|
+
const remaining = candidates.filter((m) => !triedModels.includes(m));
|
|
116
|
+
const isParentFallback = remaining.length === 0;
|
|
117
|
+
const fallbackResolved = await resolveModel(remaining, parentModel, modelRegistry);
|
|
118
|
+
if (!fallbackResolved.model) {
|
|
119
|
+
return onExhausted("no-model", triedModels, remaining);
|
|
120
|
+
}
|
|
121
|
+
const triedName = `${fallbackResolved.model!.provider}/${fallbackResolved.model!.id}`;
|
|
122
|
+
if (triedModels.includes(triedName)) {
|
|
123
|
+
// Already tried this model (e.g., all candidates unavailable
|
|
124
|
+
// and parent fallback) — no further options.
|
|
125
|
+
return onExhausted("already-tried", triedModels, remaining);
|
|
126
|
+
}
|
|
127
|
+
triedModels.push(triedName);
|
|
128
|
+
// Also track the raw candidate name so candidates.filter() can
|
|
129
|
+
// exclude it even when the agent uses unqualified names.
|
|
130
|
+
// Avoid duplicating when candidate name is already qualified (matchedCandidate === triedName).
|
|
131
|
+
if (fallbackResolved.matchedCandidate && fallbackResolved.matchedCandidate !== triedName) {
|
|
132
|
+
triedModels.push(fallbackResolved.matchedCandidate);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The `:thinking` suffix lives on the candidate as written; resolve it from
|
|
136
|
+
// the stripped-name map (matchedCandidate is the raw candidate string).
|
|
137
|
+
const thinkingLevel =
|
|
138
|
+
thinkingByCandidate.get(fallbackResolved.matchedCandidate ?? triedName) ??
|
|
139
|
+
fallbackResolved.matchedThinking ??
|
|
140
|
+
defaultThinking;
|
|
141
|
+
|
|
142
|
+
const result = await runAttempt(fallbackResolved.model, thinkingLevel);
|
|
143
|
+
if (result && isRateLimited(result)) {
|
|
144
|
+
// If the model that just rate-limited was the parent fallback
|
|
145
|
+
// (no remaining candidates), stop — no further options.
|
|
146
|
+
if (isParentFallback) {
|
|
147
|
+
return onExhausted("parent-rate-limited", triedModels, remaining);
|
|
148
|
+
}
|
|
149
|
+
return attempt();
|
|
150
|
+
}
|
|
151
|
+
return result;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
return attempt();
|
|
155
|
+
}
|