@bacnh85/pi-subagent 0.15.3 → 0.16.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 +25 -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 +204 -121
- package/extensions/model.ts +94 -8
- package/extensions/roles-panel.ts +154 -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 +6 -1
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,7 @@ 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)",
|
|
321
332
|
handler: async (args, ctx) => {
|
|
322
333
|
const cmd = args.trim().toLowerCase();
|
|
323
334
|
const discovery = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
|
|
@@ -351,6 +362,61 @@ export default function (pi: ExtensionAPI) {
|
|
|
351
362
|
return;
|
|
352
363
|
}
|
|
353
364
|
|
|
365
|
+
const openRolesEditor = async (): Promise<void> => {
|
|
366
|
+
// Role mapping editor: panel in TUI, plain text otherwise.
|
|
367
|
+
const [{ openConfigPanel }, { buildRows, buildRolesPanelCfg, cfgToPatch, preserveUnknownAgentModels, writeSubagentSection }] = await Promise.all([
|
|
368
|
+
import("@bacnh85/pi-config-panel"),
|
|
369
|
+
import("./roles-panel.ts"),
|
|
370
|
+
]);
|
|
371
|
+
if (ctx.mode !== "tui" || !ctx.hasUI) {
|
|
372
|
+
const rolesCfg = readSubagentRoles(ctx);
|
|
373
|
+
const lines = discovery.agents.map((a) => ` ${a.name.padEnd(16)} ${describeAgentModels(a, rolesCfg)}`);
|
|
374
|
+
pi.sendMessage({
|
|
375
|
+
customType: "pi-subagent",
|
|
376
|
+
content: [
|
|
377
|
+
"Model roles (edit ~/.pi/agent/settings.json → subagent.roles, or run /subagent in a TUI):",
|
|
378
|
+
...Object.entries(rolesCfg.roles).map(([name, chain]) =>
|
|
379
|
+
` @${name} = ${Array.isArray(chain) ? chain.join(", ") : chain}`),
|
|
380
|
+
"",
|
|
381
|
+
"Effective models per agent:",
|
|
382
|
+
...lines,
|
|
383
|
+
].join("\n"),
|
|
384
|
+
display: true,
|
|
385
|
+
});
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const current = readSubagentRolesGlobal();
|
|
389
|
+
const working = buildRolesPanelCfg(discovery.agents, current);
|
|
390
|
+
await openConfigPanel({
|
|
391
|
+
ctx,
|
|
392
|
+
cfg: working,
|
|
393
|
+
build: (cfg) => buildRows(cfg, discovery.agents),
|
|
394
|
+
title: "Subagent model roles",
|
|
395
|
+
onSave: (saved, editedKeys) => {
|
|
396
|
+
if (!(saved && editedKeys && editedKeys.size > 0)) return;
|
|
397
|
+
const patch = cfgToPatch(working);
|
|
398
|
+
patch.agentModels = preserveUnknownAgentModels(
|
|
399
|
+
patch.agentModels,
|
|
400
|
+
discovery.agents.map((a) => a.name),
|
|
401
|
+
current.agentModels,
|
|
402
|
+
);
|
|
403
|
+
try {
|
|
404
|
+
writeSubagentSection(patch);
|
|
405
|
+
invalidateAgentCache();
|
|
406
|
+
ctx.ui.notify("Model roles saved to settings.json", "info");
|
|
407
|
+
} catch (err) {
|
|
408
|
+
ctx.ui.notify(`Not saved — ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
return;
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
if (cmd === "roles") {
|
|
416
|
+
await openRolesEditor();
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
354
420
|
if (cmd === "reload" || cmd === "refresh") {
|
|
355
421
|
invalidateAgentCache();
|
|
356
422
|
const fresh = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
|
|
@@ -379,28 +445,61 @@ export default function (pi: ExtensionAPI) {
|
|
|
379
445
|
: "";
|
|
380
446
|
pi.sendMessage({
|
|
381
447
|
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.`,
|
|
448
|
+
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
449
|
display: true,
|
|
384
450
|
});
|
|
385
451
|
return;
|
|
386
452
|
}
|
|
387
453
|
|
|
388
454
|
if (cmd) {
|
|
389
|
-
// Show details for a specific agent
|
|
455
|
+
// Show details for a specific agent; fall back to a role detail view
|
|
456
|
+
// when the name matches a model role (e.g. "/subagent coder").
|
|
390
457
|
const agent = discovery.agents.find(
|
|
391
458
|
(a) => a.name.toLowerCase() === cmd,
|
|
392
459
|
);
|
|
393
460
|
if (!agent) {
|
|
394
|
-
|
|
461
|
+
const rolesCfg = readSubagentRoles(ctx);
|
|
462
|
+
const arg = args.trim().toLowerCase();
|
|
463
|
+
const roleName = arg.startsWith("@") ? arg.slice(1) : arg;
|
|
464
|
+
// Resolve the role key case-insensitively (role names are free-form).
|
|
465
|
+
const roleKey = Object.keys(rolesCfg.roles).find((k) => k.toLowerCase() === roleName);
|
|
466
|
+
const roleChain = roleKey !== undefined ? rolesCfg.roles[roleKey] : undefined;
|
|
467
|
+
const role = roleKey !== undefined && roleChain !== undefined
|
|
468
|
+
? { key: roleKey, chain: roleChain }
|
|
469
|
+
: undefined;
|
|
470
|
+
if (role) {
|
|
471
|
+
const { key, chain } = role;
|
|
472
|
+
const chainText = Array.isArray(chain) ? chain.join(" → ") : String(chain);
|
|
473
|
+
const users = discovery.agents.filter((a) => getModelCandidates(a).some((c) => c.toLowerCase().split(":")[0] === `@${key.toLowerCase()}`));
|
|
474
|
+
const overrides = Object.entries(rolesCfg.agentModels).filter(([, v]) => v.toLowerCase().split(":")[0] === `@${key.toLowerCase()}`);
|
|
475
|
+
const defaultText = DEFAULT_ROLES[key] !== undefined
|
|
476
|
+
? (Array.isArray(DEFAULT_ROLES[key]) ? (DEFAULT_ROLES[key] as string[]).join(" → ") : String(DEFAULT_ROLES[key]))
|
|
477
|
+
: "(custom role)";
|
|
478
|
+
pi.sendMessage({
|
|
479
|
+
customType: "pi-subagent",
|
|
480
|
+
content: [
|
|
481
|
+
`Role: @${key}`,
|
|
482
|
+
`Chain: ${chainText} → parent fallback`,
|
|
483
|
+
`Default: ${defaultText}`,
|
|
484
|
+
users.length > 0 ? `Agents using @${key}: ${users.map((a) => a.name).join(", ")}` : `No agent references @${key} yet`,
|
|
485
|
+
overrides.length > 0 ? `Overrides via @${key}: ${overrides.map(([n]) => n).join(", ")}` : "",
|
|
486
|
+
"",
|
|
487
|
+
`Edit with /subagent (roles editor) or ~/.pi/agent/settings.json → subagent.roles.`,
|
|
488
|
+
].filter(Boolean).join("\n"),
|
|
489
|
+
display: true,
|
|
490
|
+
});
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
ctx.ui.notify(`Unknown agent: "${args.trim()}". Use /subagent list to list all.`, "error");
|
|
395
494
|
return;
|
|
396
495
|
}
|
|
397
|
-
const
|
|
496
|
+
const rolesCfg = readSubagentRoles(ctx);
|
|
398
497
|
pi.sendMessage({
|
|
399
498
|
customType: "pi-subagent",
|
|
400
499
|
content: [
|
|
401
500
|
`Agent: ${agent.name} (${agent.source})`,
|
|
402
501
|
`Description: ${agent.description}`,
|
|
403
|
-
`Models: ${
|
|
502
|
+
`Models: ${describeAgentModels(agent, rolesCfg)}`,
|
|
404
503
|
`Thinking: ${agent.thinking || "off"}`,
|
|
405
504
|
`Tools: ${agent.tools?.join(", ") || "all default"}`,
|
|
406
505
|
`Source file: ${agent.filePath}`,
|
|
@@ -413,18 +512,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
413
512
|
return;
|
|
414
513
|
}
|
|
415
514
|
|
|
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
|
-
});
|
|
515
|
+
// Bare /subagent — open the roles editor (list moved to /subagent list).
|
|
516
|
+
await openRolesEditor();
|
|
428
517
|
},
|
|
429
518
|
});
|
|
430
519
|
|
|
@@ -475,7 +564,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
475
564
|
"Bundled agents: scout (fast recon), tester (verification), worker (implementation), general-purpose (fallback), planner (planning), reviewer (review).",
|
|
476
565
|
"For background single tasks use background:true — you will be notified on completion; DO NOT poll or sleep.",
|
|
477
566
|
"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
|
|
567
|
+
"Use /subagent list to list all available agents, /subagent <name> for agent details, /subagent @role for role detail.",
|
|
479
568
|
],
|
|
480
569
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
481
570
|
// Surface env-var timeout warnings collected at module load. The
|
|
@@ -584,6 +673,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
584
673
|
},
|
|
585
674
|
],
|
|
586
675
|
details: makeDetails("single")([]),
|
|
676
|
+
isError: true,
|
|
587
677
|
};
|
|
588
678
|
}
|
|
589
679
|
|
|
@@ -637,6 +727,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
637
727
|
const modelRuntime = (modelRegistry as any).runtime;
|
|
638
728
|
const authStorage = (modelRegistry as any).authStorage;
|
|
639
729
|
|
|
730
|
+
// Roles + per-agent overrides are read once per execute() call so every
|
|
731
|
+
// child in this run sees a consistent mapping.
|
|
732
|
+
const rolesCfg: RolesConfig = readSubagentRoles(ctx);
|
|
733
|
+
|
|
640
734
|
// Parent session's registered tool names. Agents that omit `tools` inherit
|
|
641
735
|
// the full set (minus the denylist); agents with an explicit `tools` line
|
|
642
736
|
// are validated against built-ins ∪ this set.
|
|
@@ -652,9 +746,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
652
746
|
return safe.path;
|
|
653
747
|
}
|
|
654
748
|
|
|
749
|
+
// Helper: stable history id for a foreground run (shared between the
|
|
750
|
+
// running entry written at start and the completion entry).
|
|
751
|
+
function makeForegroundHistoryId(startedAt: number): string {
|
|
752
|
+
return `fg-${startedAt.toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Helper: record a running foreground task so a crash mid-run shows as
|
|
756
|
+
// "interrupted" after restart (completion upserts by id and replaces it).
|
|
757
|
+
function recordForegroundStart(entryId: string, agentName: string, taskText: string, startedAt: number): void {
|
|
758
|
+
try {
|
|
759
|
+
appendHistory(path.join(ctx.cwd, CONFIG_DIR_NAME), {
|
|
760
|
+
id: entryId,
|
|
761
|
+
agent: agentName,
|
|
762
|
+
task: taskText,
|
|
763
|
+
status: "running",
|
|
764
|
+
startedAt,
|
|
765
|
+
});
|
|
766
|
+
} catch { /* history file not writable — non-fatal */ }
|
|
767
|
+
}
|
|
768
|
+
|
|
655
769
|
// Helper: record a completed foreground task to the history registry.
|
|
656
770
|
// ponytail: best-effort — history is non-fatal metadata for /subagent history.
|
|
657
771
|
function recordForegroundHistory(
|
|
772
|
+
entryId: string,
|
|
658
773
|
agentName: string,
|
|
659
774
|
taskText: string,
|
|
660
775
|
result: SubAgentResult,
|
|
@@ -672,7 +787,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
672
787
|
: "failed"
|
|
673
788
|
: "completed";
|
|
674
789
|
appendHistory(path.join(ctx.cwd, CONFIG_DIR_NAME), {
|
|
675
|
-
id:
|
|
790
|
+
id: entryId,
|
|
676
791
|
agent: agentName,
|
|
677
792
|
task: taskText,
|
|
678
793
|
status,
|
|
@@ -743,7 +858,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
743
858
|
};
|
|
744
859
|
}
|
|
745
860
|
|
|
746
|
-
const
|
|
861
|
+
const agentChain = resolveAgentModelChain(agent, rolesCfg);
|
|
862
|
+
const resolved = await resolveModel(agentChain.candidates, ctx.model, ctx.modelRegistry);
|
|
747
863
|
if (!resolved.model) {
|
|
748
864
|
const tried = resolved.attempted.join(", ") || "none";
|
|
749
865
|
const parentInfo = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "none";
|
|
@@ -786,9 +902,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
786
902
|
};
|
|
787
903
|
}
|
|
788
904
|
|
|
789
|
-
// Retry loop: rate-limit model fallback
|
|
790
|
-
|
|
791
|
-
|
|
905
|
+
// Retry loop: rate-limit model fallback (candidates already role-expanded).
|
|
906
|
+
// Shared with the service path — single source of truth for triedModels
|
|
907
|
+
// bookkeeping and per-candidate `:thinking` resolution.
|
|
908
|
+
const candidates = agentChain.candidates;
|
|
792
909
|
|
|
793
910
|
// Transport keep-alive only: resets parent idle timeout so a long child
|
|
794
911
|
// run isn't killed. The visible progress now lives in the live widget;
|
|
@@ -799,32 +916,47 @@ export default function (pi: ExtensionAPI) {
|
|
|
799
916
|
widget.requestRender();
|
|
800
917
|
}) : undefined;
|
|
801
918
|
try {
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
919
|
+
return await runWithModelFallback<SubAgentResult>({
|
|
920
|
+
candidates,
|
|
921
|
+
parentModel: ctx.model,
|
|
922
|
+
modelRegistry: ctx.modelRegistry,
|
|
923
|
+
thinkingByCandidate: agentChain.thinkingByCandidate,
|
|
924
|
+
defaultThinking: agent.thinking,
|
|
925
|
+
runAttempt: (model, thinkingLevel) =>
|
|
926
|
+
runSubAgent({
|
|
927
|
+
cwd: safeCwd,
|
|
928
|
+
sandbox: agent.sandbox === "worktree" ? "worktree" : undefined,
|
|
929
|
+
systemPrompt: params.instructions
|
|
930
|
+
? `${agent.systemPrompt}\n\n## Task Contract\n${params.instructions.slice(0, MAX_INSTRUCTIONS_LENGTH)}`
|
|
931
|
+
: agent.systemPrompt,
|
|
809
932
|
task,
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
933
|
+
tools,
|
|
934
|
+
model,
|
|
935
|
+
modelRuntime,
|
|
936
|
+
authStorage,
|
|
937
|
+
modelRegistry,
|
|
938
|
+
signal: parentSignal,
|
|
939
|
+
timeoutMs: effectiveTimeoutMs,
|
|
940
|
+
agentName,
|
|
941
|
+
thinkingLevel,
|
|
942
|
+
onMessage: onProgress,
|
|
943
|
+
onProgress: onActivity,
|
|
944
|
+
loadExtensions,
|
|
945
|
+
projectTrusted,
|
|
946
|
+
}),
|
|
947
|
+
isRateLimited: (result) => Boolean(result.errorMessage && isRateLimitError(result.errorMessage)),
|
|
948
|
+
onExhausted: (reason, triedModels, remaining) => {
|
|
949
|
+
const exhaustedStderr = reason === "no-model"
|
|
950
|
+
? [
|
|
951
|
+
`All models rate-limited or unavailable.`,
|
|
952
|
+
`Tried: ${triedModels.join(" → ") || "(none)"}.`,
|
|
953
|
+
`Remaining candidates: ${remaining.join(", ") || "none"}.`,
|
|
954
|
+
`Parent: ${ctx.model?.provider}/${ctx.model?.id}.`,
|
|
955
|
+
].join(" ")
|
|
956
|
+
: [
|
|
957
|
+
`All available models exhausted.`,
|
|
958
|
+
`Tried: ${triedModels.join(" → ")}.`,
|
|
959
|
+
].join(" ");
|
|
828
960
|
return {
|
|
829
961
|
agent: agentName,
|
|
830
962
|
task,
|
|
@@ -832,69 +964,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
832
964
|
status: "error" as const,
|
|
833
965
|
stopReason: "error" as const,
|
|
834
966
|
messages: [],
|
|
835
|
-
stderr:
|
|
836
|
-
`All available models exhausted.`,
|
|
837
|
-
`Tried: ${triedModels.join(" → ")}.`,
|
|
838
|
-
].join(" "),
|
|
967
|
+
stderr: exhaustedStderr,
|
|
839
968
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
840
|
-
errorMessage:
|
|
969
|
+
errorMessage: reason === "no-model"
|
|
970
|
+
? `All models exhausted (tried: ${triedModels.join(" → ") || "none"})`
|
|
971
|
+
: `All available models exhausted (tried: ${triedModels.join(" → ")})`,
|
|
841
972
|
};
|
|
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();
|
|
973
|
+
},
|
|
974
|
+
});
|
|
898
975
|
} finally {
|
|
899
976
|
stopHeartbeat?.();
|
|
900
977
|
}
|
|
@@ -917,6 +994,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
917
994
|
color: agentToThemeColor(step.agent),
|
|
918
995
|
});
|
|
919
996
|
if (ctx.mode === "tui") widget.ensureWidget(ctx);
|
|
997
|
+
const historyId = makeForegroundHistoryId(thread.createdAt);
|
|
998
|
+
recordForegroundStart(historyId, step.agent, taskWithContext, thread.createdAt);
|
|
920
999
|
const result = await runOne(
|
|
921
1000
|
step.agent, taskWithContext, step.cwd,
|
|
922
1001
|
signal, step.timeout ?? params.timeout,
|
|
@@ -929,7 +1008,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
929
1008
|
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
|
930
1009
|
result,
|
|
931
1010
|
});
|
|
932
|
-
recordForegroundHistory(step.agent, taskWithContext, result, thread.createdAt);
|
|
1011
|
+
recordForegroundHistory(historyId, step.agent, taskWithContext, result, thread.createdAt);
|
|
933
1012
|
results.push(result);
|
|
934
1013
|
|
|
935
1014
|
const isError = isFailedResult(result);
|
|
@@ -1075,6 +1154,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1075
1154
|
emitParallelUpdate();
|
|
1076
1155
|
return skippedResult;
|
|
1077
1156
|
}
|
|
1157
|
+
const historyId = makeForegroundHistoryId(parallelThreads[index].createdAt);
|
|
1158
|
+
recordForegroundStart(historyId, t.agent, t.task, parallelThreads[index].createdAt);
|
|
1078
1159
|
const result = await runOne(
|
|
1079
1160
|
t.agent, t.task, t.cwd,
|
|
1080
1161
|
parallelController.signal, t.timeout ?? params.timeout,
|
|
@@ -1088,7 +1169,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1088
1169
|
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
|
1089
1170
|
result,
|
|
1090
1171
|
});
|
|
1091
|
-
recordForegroundHistory(t.agent, t.task, result, parallelThreads[index].createdAt);
|
|
1172
|
+
recordForegroundHistory(historyId, t.agent, t.task, result, parallelThreads[index].createdAt);
|
|
1092
1173
|
// Early-abort: if this task failed and abortOnFailure is set
|
|
1093
1174
|
if (abortOnFailure && isFailedResult(result) && !abortCause) {
|
|
1094
1175
|
abortCause = result.stopReason === "timeout" ? "timeout" : "sibling";
|
|
@@ -1152,6 +1233,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1152
1233
|
color: agentToThemeColor(params.agent),
|
|
1153
1234
|
});
|
|
1154
1235
|
if (ctx.mode === "tui") widget.ensureWidget(ctx);
|
|
1236
|
+
const historyId = makeForegroundHistoryId(thread.createdAt);
|
|
1237
|
+
recordForegroundStart(historyId, params.agent, params.task, thread.createdAt);
|
|
1155
1238
|
const result = await runOne(
|
|
1156
1239
|
params.agent, params.task, params.cwd,
|
|
1157
1240
|
signal, params.timeout,
|
|
@@ -1164,7 +1247,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1164
1247
|
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
|
1165
1248
|
result,
|
|
1166
1249
|
});
|
|
1167
|
-
recordForegroundHistory(params.agent, params.task, result, thread.createdAt);
|
|
1250
|
+
recordForegroundHistory(historyId, params.agent, params.task, result, thread.createdAt);
|
|
1168
1251
|
const isError = isFailedResult(result);
|
|
1169
1252
|
|
|
1170
1253
|
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
|
+
}
|