@bacnh85/pi-subagent 0.15.2 → 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 +31 -0
- package/README.md +53 -14
- 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 +214 -125
- package/extensions/model.ts +94 -8
- package/extensions/roles-panel.ts +154 -0
- package/extensions/roles.ts +263 -0
- package/extensions/runner.ts +9 -3
- package/extensions/security.ts +87 -3
- package/extensions/service.ts +56 -71
- package/package.json +13 -2
package/extensions/index.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
startHeartbeat,
|
|
40
40
|
} from "./runner.ts";
|
|
41
41
|
import {
|
|
42
|
+
flushWarnings,
|
|
42
43
|
isRateLimitError,
|
|
43
44
|
normalizeTimeout,
|
|
44
45
|
resolveSafeCwd,
|
|
@@ -59,13 +60,15 @@ import {
|
|
|
59
60
|
} from "./render.ts";
|
|
60
61
|
import { type SubagentThread, threadStore } from "./threads.ts";
|
|
61
62
|
import { SUBAGENT_REQUEST_EVENT, runNamedAgent, type SubagentRunRequest } from "./service.ts";
|
|
62
|
-
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";
|
|
63
65
|
import { ThreadViewer, type ThreadViewerCallbacks } from "./thread-viewer.ts";
|
|
64
66
|
import { createTaskWidgetController, renderLiveThreadLine, type TaskWidgetController } from "./widget.ts";
|
|
65
67
|
import {
|
|
66
68
|
startBackgroundTask,
|
|
67
69
|
cancelBackgroundTask,
|
|
68
70
|
getBackgroundTask,
|
|
71
|
+
getAllBackgroundTasks,
|
|
69
72
|
snapshotTask,
|
|
70
73
|
clearBackgroundTasks,
|
|
71
74
|
} from "./background.ts";
|
|
@@ -107,14 +110,14 @@ const TaskItem = Type.Object({
|
|
|
107
110
|
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
108
111
|
task: Type.String({ description: "Task to delegate to the agent" }),
|
|
109
112
|
cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
|
|
110
|
-
timeout: Type.Optional(Type.Number({ description: "Inactivity timeout in
|
|
113
|
+
timeout: Type.Optional(Type.Number({ description: "Inactivity timeout in ms; aborts on no activity within timeout. Default: 3 min (PI_SUBAGENT_INACTIVITY_TIMEOUT_MINS). The agent always has a lifetime cap: default 20 min or (PI_SUBAGENT_HARD_TIMEOUT_MINS)." })),
|
|
111
114
|
});
|
|
112
115
|
|
|
113
116
|
const ChainItem = Type.Object({
|
|
114
117
|
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
115
118
|
task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }),
|
|
116
119
|
cwd: Type.Optional(Type.String({ description: "Working directory for the agent" })),
|
|
117
|
-
timeout: Type.Optional(Type.Number({ description: "Inactivity timeout in
|
|
120
|
+
timeout: Type.Optional(Type.Number({ description: "Inactivity timeout in ms; aborts on no activity within timeout. Default: 3 min (PI_SUBAGENT_INACTIVITY_TIMEOUT_MINS). The agent always has a lifetime cap: default 20 min or (PI_SUBAGENT_HARD_TIMEOUT_MINS)." })),
|
|
118
121
|
});
|
|
119
122
|
|
|
120
123
|
const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
|
|
@@ -153,7 +156,7 @@ const SubagentParams = Type.Object({
|
|
|
153
156
|
// Project-agent confirmation is enforced via trusted configuration.
|
|
154
157
|
// See Security model section in README.
|
|
155
158
|
cwd: Type.Optional(Type.String({ description: "Working directory (single mode, must be inside workspace)" })),
|
|
156
|
-
timeout: Type.Optional(Type.Number({ description: "
|
|
159
|
+
timeout: Type.Optional(Type.Number({ description: "Inactivity timeout for the whole run, in ms; resets on activity, aborts on silence. Default 3 min (PI_SUBAGENT_INACTIVITY_TIMEOUT_MINS). Lifetime cap: 20 min or (PI_SUBAGENT_HARD_TIMEOUT_MINS)." })),
|
|
157
160
|
instructions: Type.Optional(Type.String({ description: "Bounded repository/task instructions passed to each child (max 16 KB)" })),
|
|
158
161
|
abortOnFailure: Type.Optional(Type.Boolean({ description: "In parallel mode, cancel remaining tasks when one fails. Default: false.", default: false })),
|
|
159
162
|
});
|
|
@@ -192,10 +195,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
192
195
|
trustedProjectAgentDirs.clear();
|
|
193
196
|
// Clear any widget from a prior session.
|
|
194
197
|
widget.clearWidgetIfIdle();
|
|
195
|
-
// 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.
|
|
196
201
|
// ponytail: honest about the in-process ceiling — no live-session resume.
|
|
197
202
|
try {
|
|
198
|
-
|
|
203
|
+
const liveBgIds = new Set(getAllBackgroundTasks().map((t) => t.id));
|
|
204
|
+
markInterruptedOnRestart(path.join(ctx.cwd, CONFIG_DIR_NAME), liveBgIds);
|
|
199
205
|
} catch { /* history file not writable — non-fatal */ }
|
|
200
206
|
});
|
|
201
207
|
|
|
@@ -212,12 +218,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
212
218
|
pi.on("before_agent_start", async (event) => {
|
|
213
219
|
const ctx = currentCtx;
|
|
214
220
|
const discovery = discoverAgents(ctx?.cwd ?? process.cwd(), "both", bundledAgentsDir);
|
|
215
|
-
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
|
|
216
230
|
.map((agent) => {
|
|
217
|
-
const
|
|
218
|
-
const modelInfo = candidates.length > 0
|
|
219
|
-
? ` (models: ${candidates.join(" → ")} → parent fallback)`
|
|
220
|
-
: " (parent fallback)";
|
|
231
|
+
const modelInfo = ` (models: ${describeAgentModels(agent, rolesCfg)})`;
|
|
221
232
|
const thinkingInfo = agent.thinking ? `, thinking: ${agent.thinking}` : "";
|
|
222
233
|
const sandboxInfo = agent.sandbox ? `, sandbox: ${agent.sandbox}` : "";
|
|
223
234
|
// ponytail: one-line inheritance hint; the model picks agents by description, this just sets expectations.
|
|
@@ -262,6 +273,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
262
273
|
instructions: request.instructions,
|
|
263
274
|
signal: request.signal,
|
|
264
275
|
readOnly: request.readOnly,
|
|
276
|
+
allowExternalCwd: getTrustedConfig(ctx).allowExternalCwd,
|
|
265
277
|
onMessage: (result) => threadStore.updateThread(thread.id, { result }),
|
|
266
278
|
onProgress: (progress) => { threadStore.updateProgress(thread.id, progress); request.onProgress?.(progress); },
|
|
267
279
|
}).then((result) => {
|
|
@@ -316,7 +328,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
316
328
|
return container;
|
|
317
329
|
});
|
|
318
330
|
pi.registerCommand("subagent", {
|
|
319
|
-
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)",
|
|
320
332
|
handler: async (args, ctx) => {
|
|
321
333
|
const cmd = args.trim().toLowerCase();
|
|
322
334
|
const discovery = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
|
|
@@ -350,6 +362,61 @@ export default function (pi: ExtensionAPI) {
|
|
|
350
362
|
return;
|
|
351
363
|
}
|
|
352
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
|
+
|
|
353
420
|
if (cmd === "reload" || cmd === "refresh") {
|
|
354
421
|
invalidateAgentCache();
|
|
355
422
|
const fresh = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
|
|
@@ -378,28 +445,61 @@ export default function (pi: ExtensionAPI) {
|
|
|
378
445
|
: "";
|
|
379
446
|
pi.sendMessage({
|
|
380
447
|
customType: "pi-subagent",
|
|
381
|
-
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.`,
|
|
382
449
|
display: true,
|
|
383
450
|
});
|
|
384
451
|
return;
|
|
385
452
|
}
|
|
386
453
|
|
|
387
454
|
if (cmd) {
|
|
388
|
-
// 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").
|
|
389
457
|
const agent = discovery.agents.find(
|
|
390
458
|
(a) => a.name.toLowerCase() === cmd,
|
|
391
459
|
);
|
|
392
460
|
if (!agent) {
|
|
393
|
-
|
|
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");
|
|
394
494
|
return;
|
|
395
495
|
}
|
|
396
|
-
const
|
|
496
|
+
const rolesCfg = readSubagentRoles(ctx);
|
|
397
497
|
pi.sendMessage({
|
|
398
498
|
customType: "pi-subagent",
|
|
399
499
|
content: [
|
|
400
500
|
`Agent: ${agent.name} (${agent.source})`,
|
|
401
501
|
`Description: ${agent.description}`,
|
|
402
|
-
`Models: ${
|
|
502
|
+
`Models: ${describeAgentModels(agent, rolesCfg)}`,
|
|
403
503
|
`Thinking: ${agent.thinking || "off"}`,
|
|
404
504
|
`Tools: ${agent.tools?.join(", ") || "all default"}`,
|
|
405
505
|
`Source file: ${agent.filePath}`,
|
|
@@ -412,18 +512,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
412
512
|
return;
|
|
413
513
|
}
|
|
414
514
|
|
|
415
|
-
//
|
|
416
|
-
|
|
417
|
-
const extra = list.remaining > 0 ? `\n ... +${list.remaining} more` : "";
|
|
418
|
-
const dirs = discovery.projectAgentsDir ? `\n project: ${discovery.projectAgentsDir}` : "";
|
|
419
|
-
const diagText = discovery.diagnostics.length > 0
|
|
420
|
-
? "\n\nWarnings:\n" + discovery.diagnostics.map(d => ` - [${d.severity}] ${d.filePath}: ${d.issue}`).join("\n")
|
|
421
|
-
: "";
|
|
422
|
-
pi.sendMessage({
|
|
423
|
-
customType: "pi-subagent",
|
|
424
|
-
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.`,
|
|
425
|
-
display: true,
|
|
426
|
-
});
|
|
515
|
+
// Bare /subagent — open the roles editor (list moved to /subagent list).
|
|
516
|
+
await openRolesEditor();
|
|
427
517
|
},
|
|
428
518
|
});
|
|
429
519
|
|
|
@@ -474,9 +564,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
474
564
|
"Bundled agents: scout (fast recon), tester (verification), worker (implementation), general-purpose (fallback), planner (planning), reviewer (review).",
|
|
475
565
|
"For background single tasks use background:true — you will be notified on completion; DO NOT poll or sleep.",
|
|
476
566
|
"Use operation: \"status\" with taskId to inspect a running/completed background task; operation: \"cancel\" to abort one.",
|
|
477
|
-
"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.",
|
|
478
568
|
],
|
|
479
569
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
570
|
+
// Surface env-var timeout warnings collected at module load. The
|
|
571
|
+
// interactive TUI swallows module-load stderr, so notify on launch.
|
|
572
|
+
for (const msg of flushWarnings()) {
|
|
573
|
+
ctx.ui?.notify?.(msg, "warning");
|
|
574
|
+
}
|
|
480
575
|
const agentScope: AgentScope = params.agentScope ?? "user";
|
|
481
576
|
const discovery = discoverAgents(ctx.cwd, agentScope, bundledAgentsDir);
|
|
482
577
|
const agents = discovery.agents;
|
|
@@ -578,6 +673,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
578
673
|
},
|
|
579
674
|
],
|
|
580
675
|
details: makeDetails("single")([]),
|
|
676
|
+
isError: true,
|
|
581
677
|
};
|
|
582
678
|
}
|
|
583
679
|
|
|
@@ -631,6 +727,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
631
727
|
const modelRuntime = (modelRegistry as any).runtime;
|
|
632
728
|
const authStorage = (modelRegistry as any).authStorage;
|
|
633
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
|
+
|
|
634
734
|
// Parent session's registered tool names. Agents that omit `tools` inherit
|
|
635
735
|
// the full set (minus the denylist); agents with an explicit `tools` line
|
|
636
736
|
// are validated against built-ins ∪ this set.
|
|
@@ -646,9 +746,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
646
746
|
return safe.path;
|
|
647
747
|
}
|
|
648
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
|
+
|
|
649
769
|
// Helper: record a completed foreground task to the history registry.
|
|
650
770
|
// ponytail: best-effort — history is non-fatal metadata for /subagent history.
|
|
651
771
|
function recordForegroundHistory(
|
|
772
|
+
entryId: string,
|
|
652
773
|
agentName: string,
|
|
653
774
|
taskText: string,
|
|
654
775
|
result: SubAgentResult,
|
|
@@ -666,7 +787,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
666
787
|
: "failed"
|
|
667
788
|
: "completed";
|
|
668
789
|
appendHistory(path.join(ctx.cwd, CONFIG_DIR_NAME), {
|
|
669
|
-
id:
|
|
790
|
+
id: entryId,
|
|
670
791
|
agent: agentName,
|
|
671
792
|
task: taskText,
|
|
672
793
|
status,
|
|
@@ -737,7 +858,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
737
858
|
};
|
|
738
859
|
}
|
|
739
860
|
|
|
740
|
-
const
|
|
861
|
+
const agentChain = resolveAgentModelChain(agent, rolesCfg);
|
|
862
|
+
const resolved = await resolveModel(agentChain.candidates, ctx.model, ctx.modelRegistry);
|
|
741
863
|
if (!resolved.model) {
|
|
742
864
|
const tried = resolved.attempted.join(", ") || "none";
|
|
743
865
|
const parentInfo = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "none";
|
|
@@ -780,9 +902,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
780
902
|
};
|
|
781
903
|
}
|
|
782
904
|
|
|
783
|
-
// Retry loop: rate-limit model fallback
|
|
784
|
-
|
|
785
|
-
|
|
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;
|
|
786
909
|
|
|
787
910
|
// Transport keep-alive only: resets parent idle timeout so a long child
|
|
788
911
|
// run isn't killed. The visible progress now lives in the live widget;
|
|
@@ -793,32 +916,47 @@ export default function (pi: ExtensionAPI) {
|
|
|
793
916
|
widget.requestRender();
|
|
794
917
|
}) : undefined;
|
|
795
918
|
try {
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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,
|
|
803
932
|
task,
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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(" ");
|
|
822
960
|
return {
|
|
823
961
|
agent: agentName,
|
|
824
962
|
task,
|
|
@@ -826,69 +964,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
826
964
|
status: "error" as const,
|
|
827
965
|
stopReason: "error" as const,
|
|
828
966
|
messages: [],
|
|
829
|
-
stderr:
|
|
830
|
-
`All available models exhausted.`,
|
|
831
|
-
`Tried: ${triedModels.join(" → ")}.`,
|
|
832
|
-
].join(" "),
|
|
967
|
+
stderr: exhaustedStderr,
|
|
833
968
|
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
834
|
-
errorMessage:
|
|
969
|
+
errorMessage: reason === "no-model"
|
|
970
|
+
? `All models exhausted (tried: ${triedModels.join(" → ") || "none"})`
|
|
971
|
+
: `All available models exhausted (tried: ${triedModels.join(" → ")})`,
|
|
835
972
|
};
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
// Also track the raw candidate name so candidates.filter() can
|
|
839
|
-
// exclude it even when the agent uses unqualified names.
|
|
840
|
-
// Avoid duplicating when candidate name is already qualified (matchedCandidate === triedName).
|
|
841
|
-
if (fallbackResolved.matchedCandidate && fallbackResolved.matchedCandidate !== triedName) {
|
|
842
|
-
triedModels.push(fallbackResolved.matchedCandidate);
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
const result = await runSubAgent({
|
|
846
|
-
cwd: safeCwd,
|
|
847
|
-
sandbox: agent.sandbox === "worktree" ? "worktree" : undefined,
|
|
848
|
-
systemPrompt: params.instructions
|
|
849
|
-
? `${agent.systemPrompt}\n\n## Task Contract\n${params.instructions.slice(0, MAX_INSTRUCTIONS_LENGTH)}`
|
|
850
|
-
: agent.systemPrompt,
|
|
851
|
-
task,
|
|
852
|
-
tools,
|
|
853
|
-
model: fallbackResolved.model,
|
|
854
|
-
modelRuntime,
|
|
855
|
-
authStorage,
|
|
856
|
-
modelRegistry,
|
|
857
|
-
signal: parentSignal,
|
|
858
|
-
timeoutMs: effectiveTimeoutMs,
|
|
859
|
-
agentName,
|
|
860
|
-
thinkingLevel: agent.thinking,
|
|
861
|
-
onMessage: onProgress,
|
|
862
|
-
onProgress: onActivity,
|
|
863
|
-
loadExtensions,
|
|
864
|
-
projectTrusted,
|
|
865
|
-
});
|
|
866
|
-
|
|
867
|
-
if (result.errorMessage && isRateLimitError(result.errorMessage)) {
|
|
868
|
-
// If the model that just rate-limited was the parent fallback
|
|
869
|
-
// (no remaining candidates), stop — no further options.
|
|
870
|
-
if (isParentFallback) {
|
|
871
|
-
return {
|
|
872
|
-
agent: agentName,
|
|
873
|
-
task,
|
|
874
|
-
exitCode: 1,
|
|
875
|
-
status: "error" as const,
|
|
876
|
-
stopReason: "error" as const,
|
|
877
|
-
messages: [],
|
|
878
|
-
stderr: [
|
|
879
|
-
`All available models exhausted.`,
|
|
880
|
-
`Tried: ${triedModels.join(" → ")}.`,
|
|
881
|
-
].join(" "),
|
|
882
|
-
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
883
|
-
errorMessage: `All available models exhausted (tried: ${triedModels.join(" → ")})`,
|
|
884
|
-
};
|
|
885
|
-
}
|
|
886
|
-
return tryWithFallback();
|
|
887
|
-
}
|
|
888
|
-
return result;
|
|
889
|
-
};
|
|
890
|
-
|
|
891
|
-
return tryWithFallback();
|
|
973
|
+
},
|
|
974
|
+
});
|
|
892
975
|
} finally {
|
|
893
976
|
stopHeartbeat?.();
|
|
894
977
|
}
|
|
@@ -911,6 +994,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
911
994
|
color: agentToThemeColor(step.agent),
|
|
912
995
|
});
|
|
913
996
|
if (ctx.mode === "tui") widget.ensureWidget(ctx);
|
|
997
|
+
const historyId = makeForegroundHistoryId(thread.createdAt);
|
|
998
|
+
recordForegroundStart(historyId, step.agent, taskWithContext, thread.createdAt);
|
|
914
999
|
const result = await runOne(
|
|
915
1000
|
step.agent, taskWithContext, step.cwd,
|
|
916
1001
|
signal, step.timeout ?? params.timeout,
|
|
@@ -923,7 +1008,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
923
1008
|
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
|
924
1009
|
result,
|
|
925
1010
|
});
|
|
926
|
-
recordForegroundHistory(step.agent, taskWithContext, result, thread.createdAt);
|
|
1011
|
+
recordForegroundHistory(historyId, step.agent, taskWithContext, result, thread.createdAt);
|
|
927
1012
|
results.push(result);
|
|
928
1013
|
|
|
929
1014
|
const isError = isFailedResult(result);
|
|
@@ -1069,6 +1154,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1069
1154
|
emitParallelUpdate();
|
|
1070
1155
|
return skippedResult;
|
|
1071
1156
|
}
|
|
1157
|
+
const historyId = makeForegroundHistoryId(parallelThreads[index].createdAt);
|
|
1158
|
+
recordForegroundStart(historyId, t.agent, t.task, parallelThreads[index].createdAt);
|
|
1072
1159
|
const result = await runOne(
|
|
1073
1160
|
t.agent, t.task, t.cwd,
|
|
1074
1161
|
parallelController.signal, t.timeout ?? params.timeout,
|
|
@@ -1082,7 +1169,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1082
1169
|
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
|
1083
1170
|
result,
|
|
1084
1171
|
});
|
|
1085
|
-
recordForegroundHistory(t.agent, t.task, result, parallelThreads[index].createdAt);
|
|
1172
|
+
recordForegroundHistory(historyId, t.agent, t.task, result, parallelThreads[index].createdAt);
|
|
1086
1173
|
// Early-abort: if this task failed and abortOnFailure is set
|
|
1087
1174
|
if (abortOnFailure && isFailedResult(result) && !abortCause) {
|
|
1088
1175
|
abortCause = result.stopReason === "timeout" ? "timeout" : "sibling";
|
|
@@ -1146,6 +1233,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1146
1233
|
color: agentToThemeColor(params.agent),
|
|
1147
1234
|
});
|
|
1148
1235
|
if (ctx.mode === "tui") widget.ensureWidget(ctx);
|
|
1236
|
+
const historyId = makeForegroundHistoryId(thread.createdAt);
|
|
1237
|
+
recordForegroundStart(historyId, params.agent, params.task, thread.createdAt);
|
|
1149
1238
|
const result = await runOne(
|
|
1150
1239
|
params.agent, params.task, params.cwd,
|
|
1151
1240
|
signal, params.timeout,
|
|
@@ -1158,7 +1247,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1158
1247
|
status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
|
|
1159
1248
|
result,
|
|
1160
1249
|
});
|
|
1161
|
-
recordForegroundHistory(params.agent, params.task, result, thread.createdAt);
|
|
1250
|
+
recordForegroundHistory(historyId, params.agent, params.task, result, thread.createdAt);
|
|
1162
1251
|
const isError = isFailedResult(result);
|
|
1163
1252
|
|
|
1164
1253
|
if (onUpdate) {
|
|
@@ -1711,4 +1800,4 @@ export default function (pi: ExtensionAPI) {
|
|
|
1711
1800
|
}
|
|
1712
1801
|
}, { overlay: true, overlayOptions: { maxHeight: "70%" } }); // Overlay: editor stays visible below
|
|
1713
1802
|
}
|
|
1714
|
-
}
|
|
1803
|
+
}
|