brl-subagent 2.3.4
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/LICENSE +21 -0
- package/README.md +257 -0
- package/package.json +49 -0
- package/presets/code-reviewer.md +37 -0
- package/presets/data-analyst.md +33 -0
- package/presets/debugger.md +36 -0
- package/presets/dev-agent.md +30 -0
- package/presets/rapid-prototyper.md +25 -0
- package/presets/refactorer.md +34 -0
- package/presets/security-auditor.md +39 -0
- package/presets/tech-writer.md +33 -0
- package/presets/test-engineer.md +34 -0
- package/src/concurrency.ts +186 -0
- package/src/diff.ts +102 -0
- package/src/event-bus.ts +86 -0
- package/src/git.ts +187 -0
- package/src/history.ts +177 -0
- package/src/index.ts +3824 -0
- package/src/logging.ts +135 -0
- package/src/messaging.ts +164 -0
- package/src/metrics.ts +221 -0
- package/src/model-availability.ts +73 -0
- package/src/notify-completion.ts +207 -0
- package/src/params.ts +225 -0
- package/src/preflight.ts +150 -0
- package/src/presets.ts +425 -0
- package/src/prompt.ts +169 -0
- package/src/reports.ts +325 -0
- package/src/router.ts +133 -0
- package/src/runner.ts +877 -0
- package/src/sanitize.ts +365 -0
- package/src/scheduler.ts +187 -0
- package/src/session-manager.ts +1065 -0
- package/src/state.ts +590 -0
- package/src/templates.ts +469 -0
- package/src/transcript-tail.ts +327 -0
- package/src/transcript.ts +119 -0
- package/src/tui-format.ts +51 -0
- package/src/tui.ts +3103 -0
- package/src/types.ts +803 -0
- package/src/unit-run.ts +269 -0
- package/src/update.ts +109 -0
- package/src/validate.ts +302 -0
- package/templates/analyze-data.md +9 -0
- package/templates/code-review.md +10 -0
- package/templates/debug-issue.md +9 -0
- package/templates/implement-feature.md +9 -0
- package/templates/prototype.md +9 -0
- package/templates/refactor.md +9 -0
- package/templates/security-audit.md +10 -0
- package/templates/write-docs.md +9 -0
- package/templates/write-tests.md +10 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,3824 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* brl-subagent — multi-agent orchestration for pi.
|
|
3
|
+
* Entry point: tool registrations, command surface, and the multi-mode runners.
|
|
4
|
+
* See AGENT.md for the conductor-facing capability reference.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import type {
|
|
10
|
+
AgentToolResult,
|
|
11
|
+
AgentToolUpdateCallback,
|
|
12
|
+
} from "@earendil-works/pi-agent-core";
|
|
13
|
+
import type { ExtensionAPI, ExtensionContext, Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { Type } from "typebox";
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
SubagentPreset,
|
|
18
|
+
SubagentResult,
|
|
19
|
+
SubagentRun,
|
|
20
|
+
SubagentToolOptions,
|
|
21
|
+
ResolvedParams,
|
|
22
|
+
ThinkingLevel,
|
|
23
|
+
ApprovalMode,
|
|
24
|
+
SubTaskParams,
|
|
25
|
+
SubTaskResult,
|
|
26
|
+
ChainDetails,
|
|
27
|
+
ParallelDetails,
|
|
28
|
+
MultiSubagentDetails,
|
|
29
|
+
GraphTask,
|
|
30
|
+
GraphDetails,
|
|
31
|
+
GraphWave,
|
|
32
|
+
DelegateTaskDetails,
|
|
33
|
+
Priority,
|
|
34
|
+
TaskTemplate,
|
|
35
|
+
ToolResult,
|
|
36
|
+
SubagentEvent,
|
|
37
|
+
} from "./types";
|
|
38
|
+
import {
|
|
39
|
+
resolveThinkingLevel,
|
|
40
|
+
EMPTY_USAGE,
|
|
41
|
+
getFinalOutput,
|
|
42
|
+
isSubagentError,
|
|
43
|
+
classifyError,
|
|
44
|
+
MAX_CHAIN_STEPS,
|
|
45
|
+
MAX_PARALLEL_TASKS,
|
|
46
|
+
MAX_GRAPH_TASKS,
|
|
47
|
+
PREVIOUS_OUTPUT_PLACEHOLDER,
|
|
48
|
+
GRAPH_OUTPUT_PLACEHOLDER_RE,
|
|
49
|
+
DEFAULT_PRIORITY,
|
|
50
|
+
type GitMode,
|
|
51
|
+
type BackgroundAgent,
|
|
52
|
+
} from "./types";
|
|
53
|
+
import { validateGraph, topologicalSort } from "./scheduler";
|
|
54
|
+
import { resolveTemplate, loadAllTemplates, loadBuiltinTemplates, validateTemplatePresetRefs, extractParamNames } from "./templates";
|
|
55
|
+
|
|
56
|
+
import { sanitizeTask, validateCwd, validateOutputFile, stripAnsi, capOutput, getCurrentDepth, sanitizeErrorMessage, buildCrashResult } from "./sanitize";
|
|
57
|
+
import {
|
|
58
|
+
getCurrentBranch,
|
|
59
|
+
hasUncommittedChanges,
|
|
60
|
+
createWorkBranch,
|
|
61
|
+
captureDiff,
|
|
62
|
+
switchToBranch,
|
|
63
|
+
deleteBranch,
|
|
64
|
+
mergeWorkBranch,
|
|
65
|
+
} from "./git";
|
|
66
|
+
import { preflightCheck } from "./preflight";
|
|
67
|
+
import { loadBuiltinPresets, loadCustomPresets, getAllPresets, writePresetFile, formatPresetRestriction, formatToolRestriction } from "./presets";
|
|
68
|
+
import { modelIsAvailable } from "./model-availability";
|
|
69
|
+
import { validatePreTask, diagnoseFailure } from "./validate";
|
|
70
|
+
import { findUnknownParams, KNOWN_DELEGATE_KEYS, resolveSubagentParams, snapshotOriginalParams } from "./params";
|
|
71
|
+
import { createSessionState } from "./state";
|
|
72
|
+
import { makeLiveOnUpdate, createUnitRun, finalizeUnitRun, finalizeUnitRunCrash, pruneHistoryIfNeeded, registerLiveRun } from "./unit-run";
|
|
73
|
+
import { buildSubagentPrompt, describePromptMode } from "./prompt";
|
|
74
|
+
import { runSubagent, cleanupTempDirs } from "./runner";
|
|
75
|
+
import { acquireSlot, releaseSlot, updateStatus, updateProgressStatus } from "./concurrency";
|
|
76
|
+
import {
|
|
77
|
+
finalizeRunRecord,
|
|
78
|
+
resolveRetryParams,
|
|
79
|
+
createEmptyResult,
|
|
80
|
+
} from "./history";
|
|
81
|
+
import { computeSLAMetrics, computeDegradation } from "./metrics";
|
|
82
|
+
import {
|
|
83
|
+
showSelectList,
|
|
84
|
+
showModelSelector,
|
|
85
|
+
showThinkingSelector,
|
|
86
|
+
showConcurrencyInput,
|
|
87
|
+
showDepthInput,
|
|
88
|
+
showHistoryEntriesInput,
|
|
89
|
+
showCostLimitInput,
|
|
90
|
+
showApprovalModeSelector,
|
|
91
|
+
showApprovalDialog,
|
|
92
|
+
showCompletionNotifySelector,
|
|
93
|
+
showPresetManager,
|
|
94
|
+
showTemplateManager,
|
|
95
|
+
showUpdateCheckToggle,
|
|
96
|
+
showSLAConfig,
|
|
97
|
+
showSLAStats,
|
|
98
|
+
showConfigMenu,
|
|
99
|
+
showRunHistory,
|
|
100
|
+
showMonitor,
|
|
101
|
+
showDashboard,
|
|
102
|
+
showRetryMenu,
|
|
103
|
+
renderDelegateCall,
|
|
104
|
+
renderDelegateResult,
|
|
105
|
+
} from "./tui";
|
|
106
|
+
import { createLogger, type Logger } from "./logging";
|
|
107
|
+
import { Intercom } from "./messaging";
|
|
108
|
+
import { checkForUpdates } from "./update";
|
|
109
|
+
import { UPDATE_CHECK_INTERVAL_MS } from "./types";
|
|
110
|
+
import * as eventBus from "./event-bus";
|
|
111
|
+
import {
|
|
112
|
+
buildCompletionMessage,
|
|
113
|
+
resolveDelivery,
|
|
114
|
+
sendCompletionNotification,
|
|
115
|
+
markTerminalSeen,
|
|
116
|
+
normalizeCompletionStatus,
|
|
117
|
+
resolveRunEntry,
|
|
118
|
+
} from "./notify-completion";
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// Helpers
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Sanitize a text preview for markdown-rendered notifications: strip code
|
|
126
|
+
* fences (``` and ~~~) and backticks, keep whole non-empty lines with
|
|
127
|
+
* mid-line truncation for over-long lines, and never split surrogate pairs
|
|
128
|
+
* at the boundary.
|
|
129
|
+
*/
|
|
130
|
+
function sanitizePreview(text: string, maxLen = 500): string {
|
|
131
|
+
// Strip markdown fences and backticks
|
|
132
|
+
const cleaned = text.replace(/```/g, '').replace(/~~~+/g, '').replace(/`/g, '');
|
|
133
|
+
const lines = cleaned.split('\n').filter(l => l.trim());
|
|
134
|
+
let out = '';
|
|
135
|
+
for (const line of lines) {
|
|
136
|
+
if (out.length + line.length + 1 > maxLen) {
|
|
137
|
+
// Mid-line truncation: keep as much of this line as fits
|
|
138
|
+
const remaining = maxLen - out.length - 1;
|
|
139
|
+
if (remaining > 10) {
|
|
140
|
+
out += line.slice(0, remaining - 3) + '...\n';
|
|
141
|
+
}
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
out += line + '\n';
|
|
145
|
+
}
|
|
146
|
+
// Avoid splitting surrogate pairs at the boundary (reachable now via slice above)
|
|
147
|
+
const safe = out.slice(0, maxLen);
|
|
148
|
+
const lastChar = safe[safe.length - 1];
|
|
149
|
+
const stripped = lastChar && /[\uD800-\uDBFF]/.test(lastChar) ? safe.slice(0, -1) : safe;
|
|
150
|
+
return stripped.trimEnd();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* DRY helper (issue #154 review): shape an inventory of items into a single
|
|
155
|
+
* comma-delimited summary line for the LLM-facing delegation guidance. Both
|
|
156
|
+
* the B1 preset-restriction summary and the B1b template summary load an
|
|
157
|
+
* inventory at registration, map each item to a formatted string, then join.
|
|
158
|
+
*/
|
|
159
|
+
export function buildInventorySummary<T>(items: T[], formatItem: (item: T) => string): string {
|
|
160
|
+
return items.map(formatItem).join(", ");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Format one built-in template into a compact summary entry for the conductor:
|
|
165
|
+
* `name` (+ ` (lowercased description; slots ${a}, ${b})`) — the slot note is
|
|
166
|
+
* omitted when the template declares no params, and the description is omitted
|
|
167
|
+
* when it is empty. Mirrors the B1b summary shape (issue #154 review).
|
|
168
|
+
*/
|
|
169
|
+
export function formatTemplateSummaryItem(t: TaskTemplate): string {
|
|
170
|
+
const slots = [...new Set([
|
|
171
|
+
...extractParamNames(t.task),
|
|
172
|
+
...(t.outputFile ? extractParamNames(t.outputFile) : []),
|
|
173
|
+
])];
|
|
174
|
+
const slotNote = slots.length > 0
|
|
175
|
+
? `; slots ${slots.map((s) => `\${${s}}`).join(", ")}`
|
|
176
|
+
: "";
|
|
177
|
+
const desc = t.description
|
|
178
|
+
? `${t.description.charAt(0).toLowerCase()}${t.description.slice(1)}`
|
|
179
|
+
: "";
|
|
180
|
+
return `${t.name}${desc ? ` (${desc}${slotNote})` : slotNote}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Render the template guideline line with the built-in inventory summary
|
|
185
|
+
* interpolated. Exported so the guidance test can assert the RENDERED text
|
|
186
|
+
* rather than the literal `${templateSummary}` placeholder that a double-quoted
|
|
187
|
+
* string would leave in the conductor-facing guidance (issue #154 review).
|
|
188
|
+
*/
|
|
189
|
+
export function buildTemplateGuideline(templateSummary: string): string {
|
|
190
|
+
return `Templates are file-backed task starters with \${param} slots. Built-in templates: ${templateSummary}. Custom templates are NOT listed here — inspect them via /brl-subagent templates before choosing one.`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// Extension entry point
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
export default function (pi: ExtensionAPI) {
|
|
198
|
+
const log = createLogger("brl-subagent");
|
|
199
|
+
|
|
200
|
+
// Read current version from package.json
|
|
201
|
+
const currentVersion = (() => {
|
|
202
|
+
try {
|
|
203
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf-8"));
|
|
204
|
+
return pkg.version || "0.0.0";
|
|
205
|
+
} catch {
|
|
206
|
+
return "0.0.0";
|
|
207
|
+
}
|
|
208
|
+
})();
|
|
209
|
+
|
|
210
|
+
// F7: Session-bound state — initialized per session
|
|
211
|
+
let state = createSessionState(log);
|
|
212
|
+
|
|
213
|
+
// Issue #147: the completion-push subscriber needs the session context to
|
|
214
|
+
// resolve the run entry (state.getRunEntries(ctx)). The event-bus listener
|
|
215
|
+
// receives no ctx, so capture it in session_start where it is available.
|
|
216
|
+
let sessionCtx: ExtensionContext | undefined;
|
|
217
|
+
|
|
218
|
+
// -------------------------------------------------------------------
|
|
219
|
+
// Config change callback
|
|
220
|
+
// -------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
function applyConfig(ctx: ExtensionContext, message: string) {
|
|
223
|
+
updateStatus(state, ctx);
|
|
224
|
+
state.persistState(pi);
|
|
225
|
+
ctx.ui.notify(message, "info");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function resetState(ctx: ExtensionContext) {
|
|
229
|
+
state.reset();
|
|
230
|
+
// Templates are file-backed (issue #66): state.reset() clears
|
|
231
|
+
// state.config.templates, so reload them from disk — otherwise
|
|
232
|
+
// template lookups stay empty until the next session_start.
|
|
233
|
+
// Full stack: custom (project+global) merged over builtins.
|
|
234
|
+
state.config.templates = loadAllTemplates(ctx.cwd, log);
|
|
235
|
+
// Issue #81: cross-check template `preset:` refs against the full
|
|
236
|
+
// preset universe now that both loads are complete — warn (never skip)
|
|
237
|
+
// for dangling references instead of running preset-less silently.
|
|
238
|
+
validateTemplatePresetRefs(
|
|
239
|
+
state.config.templates,
|
|
240
|
+
getAllPresets(state.builtinPresets, state.customPresets),
|
|
241
|
+
log,
|
|
242
|
+
);
|
|
243
|
+
updateStatus(state, ctx);
|
|
244
|
+
state.persistState(pi);
|
|
245
|
+
ctx.ui.notify("Subagent configuration reset", "info");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// -------------------------------------------------------------------
|
|
249
|
+
// Parameter resolution (extracted to src/params.ts — issue #59)
|
|
250
|
+
// -------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
/** Parse "provider/model-id" into {provider, id}. Returns null on bad format. */
|
|
253
|
+
function parseModelString(s: string): { provider: string; id: string } | null {
|
|
254
|
+
const trimmed = s.trim();
|
|
255
|
+
const idx = trimmed.indexOf("/");
|
|
256
|
+
if (idx <= 0 || idx === trimmed.length - 1) return null;
|
|
257
|
+
return { provider: trimmed.slice(0, idx), id: trimmed.slice(idx + 1) };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function resolveSubagentModel(
|
|
261
|
+
ctx: ExtensionContext,
|
|
262
|
+
preset: SubagentPreset | undefined,
|
|
263
|
+
perCallModel?: string, // NEW: top-level delegate_task model param (issue #96)
|
|
264
|
+
):
|
|
265
|
+
| { ok: true; model: { provider: string; id: string } }
|
|
266
|
+
| { ok: false; error: ToolResult<SubagentResult | undefined> } {
|
|
267
|
+
// Precedence: per-call model > preset.model > state.config.model > conductor model
|
|
268
|
+
let subagentModel: { provider: string; id: string } | undefined;
|
|
269
|
+
|
|
270
|
+
if (perCallModel) {
|
|
271
|
+
const parsed = parseModelString(perCallModel);
|
|
272
|
+
if (!parsed) {
|
|
273
|
+
log.warn("Model override is not a valid provider/model-id, falling back", { model: perCallModel });
|
|
274
|
+
} else if (modelIsAvailable(ctx.modelRegistry, parsed)) {
|
|
275
|
+
log.info("Using per-call model override", { model: perCallModel });
|
|
276
|
+
return { ok: true, model: parsed };
|
|
277
|
+
} else {
|
|
278
|
+
log.warn("Model override unavailable, falling back", { model: perCallModel });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (preset?.model) {
|
|
283
|
+
const parsed = parseModelString(preset.model);
|
|
284
|
+
if (parsed && modelIsAvailable(ctx.modelRegistry, parsed)) {
|
|
285
|
+
subagentModel = parsed;
|
|
286
|
+
log.info("Using preset model", { preset: preset.name, model: preset.model });
|
|
287
|
+
} else {
|
|
288
|
+
// Fallback: preset model unavailable → configured model
|
|
289
|
+
log.warn("Preset model unavailable, falling back to configured model", {
|
|
290
|
+
preset: preset.name,
|
|
291
|
+
model: preset.model,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (!subagentModel) {
|
|
297
|
+
subagentModel =
|
|
298
|
+
state.config.model ||
|
|
299
|
+
(ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (!subagentModel) {
|
|
303
|
+
return {
|
|
304
|
+
ok: false,
|
|
305
|
+
error: {
|
|
306
|
+
content: [
|
|
307
|
+
{
|
|
308
|
+
type: "text" as const,
|
|
309
|
+
text: "No model available. Configure API keys first, then use /brl-subagent to set a model.",
|
|
310
|
+
},
|
|
311
|
+
],
|
|
312
|
+
details: undefined,
|
|
313
|
+
isError: true,
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return { ok: true, model: subagentModel };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// C3: Resolve a step's model override (step.model > global resolved model).
|
|
322
|
+
// Parsed + availability-checked; falls back to the global model on failure.
|
|
323
|
+
function resolveStepModel(
|
|
324
|
+
ctx: ExtensionContext,
|
|
325
|
+
stepModel: string | undefined,
|
|
326
|
+
globalModel: { provider: string; id: string },
|
|
327
|
+
): { provider: string; id: string } {
|
|
328
|
+
if (stepModel) {
|
|
329
|
+
const parsed = parseModelString(stepModel);
|
|
330
|
+
if (!parsed) {
|
|
331
|
+
log.warn("Step model is not a valid provider/model-id, falling back to global model", { model: stepModel });
|
|
332
|
+
} else if (modelIsAvailable(ctx.modelRegistry, parsed)) {
|
|
333
|
+
log.info("Using step model override", { model: stepModel });
|
|
334
|
+
return parsed;
|
|
335
|
+
} else {
|
|
336
|
+
log.warn("Step model unavailable, falling back to global model", { model: stepModel });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return globalModel;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// -------------------------------------------------------------------
|
|
343
|
+
// P1+P2: mergeSubTaskParams
|
|
344
|
+
// -------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Merge global resolved params with a SubTaskParams.
|
|
348
|
+
* SubTask fields override global fields. Tools/excludeTools/noBuiltinTools
|
|
349
|
+
* are replaced entirely if set in the subTask, otherwise inherited from global.
|
|
350
|
+
*/
|
|
351
|
+
function mergeSubTaskParams(
|
|
352
|
+
globalParams: ResolvedParams & { resolvedGitMode: GitMode; resolvedApprovalMode: ApprovalMode },
|
|
353
|
+
subTask: SubTaskParams,
|
|
354
|
+
): {
|
|
355
|
+
task: string;
|
|
356
|
+
label: string | undefined;
|
|
357
|
+
model: string | undefined;
|
|
358
|
+
inheritSP: boolean;
|
|
359
|
+
customSP: string | undefined;
|
|
360
|
+
outputFile: string | undefined;
|
|
361
|
+
timeout: number | undefined;
|
|
362
|
+
effectiveCwd: string;
|
|
363
|
+
thinkingLevel: ThinkingLevel;
|
|
364
|
+
priority: Priority | undefined;
|
|
365
|
+
toolOptions: SubagentToolOptions | undefined;
|
|
366
|
+
resolvedGitMode: GitMode;
|
|
367
|
+
resolvedApprovalMode: ApprovalMode;
|
|
368
|
+
} {
|
|
369
|
+
const mergedThinkingLevel = subTask.thinkingLevel
|
|
370
|
+
? resolveThinkingLevel(
|
|
371
|
+
subTask.thinkingLevel as ThinkingLevel,
|
|
372
|
+
state.config.maxThinkingLevel,
|
|
373
|
+
)
|
|
374
|
+
: globalParams.thinkingLevel;
|
|
375
|
+
|
|
376
|
+
const mergedTools = subTask.tools ?? globalParams.toolOptions?.tools;
|
|
377
|
+
// Fix: edit depends on write in pi's tool system.
|
|
378
|
+
const resolvedTools = mergedTools && mergedTools.includes("edit") && !mergedTools.includes("write")
|
|
379
|
+
? [...mergedTools, "write"]
|
|
380
|
+
: mergedTools;
|
|
381
|
+
const mergedExcludeTools = subTask.excludeTools ?? globalParams.toolOptions?.excludeTools;
|
|
382
|
+
const mergedNoBuiltinTools =
|
|
383
|
+
subTask.noBuiltinTools ?? globalParams.toolOptions?.noBuiltinTools;
|
|
384
|
+
|
|
385
|
+
const mergedToolOptions: SubagentToolOptions | undefined =
|
|
386
|
+
resolvedTools || mergedExcludeTools || mergedNoBuiltinTools
|
|
387
|
+
? {
|
|
388
|
+
tools: resolvedTools,
|
|
389
|
+
excludeTools: mergedExcludeTools,
|
|
390
|
+
noBuiltinTools: mergedNoBuiltinTools,
|
|
391
|
+
}
|
|
392
|
+
: undefined;
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
task: subTask.task || globalParams.task,
|
|
396
|
+
label: subTask.label ?? globalParams.label,
|
|
397
|
+
model: subTask.model,
|
|
398
|
+
inheritSP: subTask.inheritSystemPrompt ?? globalParams.inheritSP,
|
|
399
|
+
customSP: subTask.systemPrompt ?? globalParams.customSP,
|
|
400
|
+
outputFile: subTask.outputFile ?? globalParams.outputFile,
|
|
401
|
+
timeout: subTask.timeout ?? globalParams.timeout,
|
|
402
|
+
effectiveCwd: subTask.cwd ?? globalParams.effectiveCwd,
|
|
403
|
+
thinkingLevel: mergedThinkingLevel,
|
|
404
|
+
// Issue #114: per-unit priority — undefined when the step declares none
|
|
405
|
+
// (the caller then falls back to the call-level priority).
|
|
406
|
+
priority: subTask.priority && ["critical", "high", "normal", "low"].includes(subTask.priority)
|
|
407
|
+
? (subTask.priority as Priority)
|
|
408
|
+
: undefined,
|
|
409
|
+
toolOptions: mergedToolOptions,
|
|
410
|
+
resolvedGitMode: globalParams.resolvedGitMode,
|
|
411
|
+
resolvedApprovalMode: globalParams.resolvedApprovalMode,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// -------------------------------------------------------------------
|
|
416
|
+
// P1: runChainMode
|
|
417
|
+
// -------------------------------------------------------------------
|
|
418
|
+
|
|
419
|
+
async function runChainMode(
|
|
420
|
+
params: Record<string, unknown>,
|
|
421
|
+
signal: AbortSignal | undefined,
|
|
422
|
+
onUpdate: AgentToolUpdateCallback<DelegateTaskDetails> | undefined,
|
|
423
|
+
ctx: ExtensionContext,
|
|
424
|
+
): Promise<ToolResult<SubagentResult | ChainDetails | undefined>> {
|
|
425
|
+
const chainSteps = params.chain as SubTaskParams[];
|
|
426
|
+
|
|
427
|
+
// Issue #133: dispatch-start timestamp — the aggregate run entry's
|
|
428
|
+
// startedAt (captured once, at mode entry, not at completion).
|
|
429
|
+
const chainModeStartedAt = new Date().toISOString();
|
|
430
|
+
|
|
431
|
+
// R5: Check session cost limit before spawning
|
|
432
|
+
const perTaskEstimate =
|
|
433
|
+
state.config.perTaskCostEstimate > 0
|
|
434
|
+
? state.config.perTaskCostEstimate
|
|
435
|
+
: 0.05;
|
|
436
|
+
if (state.checkCostLimit(perTaskEstimate * chainSteps.length, ctx)) {
|
|
437
|
+
const currentTotal = state.getSessionTotalCost(ctx);
|
|
438
|
+
const limit = state.config.sessionCostLimit;
|
|
439
|
+
log.warn("Chain delegation rejected: session cost limit reached", {
|
|
440
|
+
currentTotal,
|
|
441
|
+
estimatedCost: perTaskEstimate * chainSteps.length,
|
|
442
|
+
limit,
|
|
443
|
+
});
|
|
444
|
+
return {
|
|
445
|
+
content: [
|
|
446
|
+
{
|
|
447
|
+
type: "text" as const,
|
|
448
|
+
text:
|
|
449
|
+
`Cannot delegate: session cost limit reached ` +
|
|
450
|
+
`($${currentTotal.toFixed(4)} spent of $${limit.toFixed(2)} limit). ` +
|
|
451
|
+
`Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
|
|
452
|
+
},
|
|
453
|
+
],
|
|
454
|
+
details: undefined,
|
|
455
|
+
isError: true,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Reject delegation if recursion depth exceeds configured max
|
|
460
|
+
const currentDepth = getCurrentDepth();
|
|
461
|
+
if (currentDepth >= state.config.maxSubagentDepth) {
|
|
462
|
+
log.warn("Chain delegation rejected: max depth reached", {
|
|
463
|
+
currentDepth,
|
|
464
|
+
maxDepth: state.config.maxSubagentDepth,
|
|
465
|
+
});
|
|
466
|
+
return {
|
|
467
|
+
content: [
|
|
468
|
+
{
|
|
469
|
+
type: "text" as const,
|
|
470
|
+
text:
|
|
471
|
+
`Cannot delegate further: subagent recursion depth limit reached ` +
|
|
472
|
+
`(depth ${currentDepth}/${state.config.maxSubagentDepth}). ` +
|
|
473
|
+
`Subagents can delegate up to ${state.config.maxSubagentDepth} levels deep (configurable via /brl-subagent depth). Complete the remaining work directly.`,
|
|
474
|
+
},
|
|
475
|
+
],
|
|
476
|
+
details: undefined,
|
|
477
|
+
isError: true,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Resolve global params once
|
|
482
|
+
const globalParams = resolveSubagentParams(
|
|
483
|
+
params as {
|
|
484
|
+
task: string;
|
|
485
|
+
label?: string;
|
|
486
|
+
preset?: string;
|
|
487
|
+
systemPrompt?: string;
|
|
488
|
+
inheritSystemPrompt?: boolean;
|
|
489
|
+
thinkingLevel?: string;
|
|
490
|
+
outputFile?: string;
|
|
491
|
+
timeout?: number;
|
|
492
|
+
cwd?: string;
|
|
493
|
+
tools?: string[];
|
|
494
|
+
excludeTools?: string[];
|
|
495
|
+
noBuiltinTools?: boolean;
|
|
496
|
+
gitMode?: string;
|
|
497
|
+
},
|
|
498
|
+
state,
|
|
499
|
+
ctx,
|
|
500
|
+
log,
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
// Validate CWD once
|
|
504
|
+
const cwdResult = validateCwd(globalParams.effectiveCwd, ctx.cwd);
|
|
505
|
+
if (!cwdResult.ok) {
|
|
506
|
+
return {
|
|
507
|
+
content: [
|
|
508
|
+
{ type: "text" as const, text: `Invalid cwd: ${cwdResult.error}` },
|
|
509
|
+
],
|
|
510
|
+
details: undefined,
|
|
511
|
+
isError: true,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
const resolvedCwd = cwdResult.value;
|
|
515
|
+
|
|
516
|
+
// Pre-flight checks — fail fast before consuming resources
|
|
517
|
+
const pfResult = preflightCheck(resolvedCwd);
|
|
518
|
+
if (!pfResult.ok) {
|
|
519
|
+
log.warn("Chain pre-flight check failed", { error: pfResult.error });
|
|
520
|
+
return {
|
|
521
|
+
content: [
|
|
522
|
+
{
|
|
523
|
+
type: "text" as const,
|
|
524
|
+
text: `Pre-flight check failed: ${pfResult.error}`,
|
|
525
|
+
},
|
|
526
|
+
],
|
|
527
|
+
details: undefined,
|
|
528
|
+
isError: true,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// H1: Pre-task validation at mode entry — deterministic check that
|
|
533
|
+
// tools/thinking match the task (mirrors single/background modes, issue
|
|
534
|
+
// #32). Chain top-level tasks are empty (modeCount forbids task+chain), so
|
|
535
|
+
// keyword warnings skip on empty text and only the hard outputFile-vs-write
|
|
536
|
+
// conflict applies (issue #34). The mode-level outputFile on globalParams is
|
|
537
|
+
// the one validated; per-step outputFiles are future work (issue #3).
|
|
538
|
+
const validation = validatePreTask({
|
|
539
|
+
task: globalParams.task,
|
|
540
|
+
toolOptions: globalParams.toolOptions,
|
|
541
|
+
thinkingLevel: globalParams.thinkingLevel,
|
|
542
|
+
gitMode: globalParams.resolvedGitMode,
|
|
543
|
+
outputFile: globalParams.outputFile,
|
|
544
|
+
});
|
|
545
|
+
if (validation.warnings.length > 0) {
|
|
546
|
+
log.warn("Chain pre-task validation warnings", { warnings: validation.warnings });
|
|
547
|
+
}
|
|
548
|
+
if (!validation.valid) {
|
|
549
|
+
const errText = validation.errors.join("; ");
|
|
550
|
+
log.warn("Chain pre-task validation failed", { errors: validation.errors });
|
|
551
|
+
return {
|
|
552
|
+
content: [{ type: "text" as const, text: errText }],
|
|
553
|
+
details: undefined,
|
|
554
|
+
isError: true,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Resolve model once (per-call top-level model override beats preset)
|
|
559
|
+
const modelResult = resolveSubagentModel(ctx, globalParams.resolvedPreset, params.model as string | undefined);
|
|
560
|
+
if (!modelResult.ok) return modelResult.error;
|
|
561
|
+
const subagentModel = modelResult.model;
|
|
562
|
+
|
|
563
|
+
const modelStr = `${subagentModel.provider}/${subagentModel.id}`.trim();
|
|
564
|
+
if (!modelStr || modelStr === "/") {
|
|
565
|
+
log.warn("Model string is empty after resolution", { model: subagentModel });
|
|
566
|
+
return {
|
|
567
|
+
content: [
|
|
568
|
+
{
|
|
569
|
+
type: "text" as const,
|
|
570
|
+
text:
|
|
571
|
+
"Subagent model is not configured. " +
|
|
572
|
+
"Use /brl-subagent to set a model, or ensure your current session has a valid model.",
|
|
573
|
+
},
|
|
574
|
+
],
|
|
575
|
+
details: undefined,
|
|
576
|
+
isError: true,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// R1: Circuit breaker check — reject if circuit is open
|
|
581
|
+
const circuitCheck = state.checkCircuit();
|
|
582
|
+
if (circuitCheck.isOpen) {
|
|
583
|
+
return {
|
|
584
|
+
content: [
|
|
585
|
+
{ type: "text" as const, text: circuitCheck.message! },
|
|
586
|
+
],
|
|
587
|
+
details: undefined,
|
|
588
|
+
isError: true,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Resolve priority (issue #114: no config knob — the call-level default)
|
|
593
|
+
const chainPriority: Priority = (
|
|
594
|
+
params.priority && ["critical", "high", "normal", "low"].includes(params.priority as string)
|
|
595
|
+
? (params.priority as Priority)
|
|
596
|
+
: DEFAULT_PRIORITY
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
// Acquire concurrency slot for the chain
|
|
600
|
+
const acquired = await acquireSlot(state, ctx, signal, chainPriority);
|
|
601
|
+
if (!acquired) {
|
|
602
|
+
return {
|
|
603
|
+
content: [
|
|
604
|
+
{
|
|
605
|
+
type: "text" as const,
|
|
606
|
+
text: "Chain cancelled while waiting for concurrency slot.",
|
|
607
|
+
},
|
|
608
|
+
],
|
|
609
|
+
details: undefined,
|
|
610
|
+
isError: true,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
let chainSuccess = false;
|
|
615
|
+
try {
|
|
616
|
+
const basePrompt = ctx.getSystemPrompt();
|
|
617
|
+
let previousOutput = "";
|
|
618
|
+
const chainResults: SubTaskResult[] = [];
|
|
619
|
+
let stoppedEarly = false;
|
|
620
|
+
|
|
621
|
+
for (let i = 0; i < chainSteps.length; i++) {
|
|
622
|
+
const step = chainSteps[i];
|
|
623
|
+
|
|
624
|
+
// Merge params for this step
|
|
625
|
+
const merged = mergeSubTaskParams(globalParams, step);
|
|
626
|
+
|
|
627
|
+
// C3: Resolve this step's model override (step.model > global resolved model)
|
|
628
|
+
const stepModel = resolveStepModel(ctx, merged.model, subagentModel);
|
|
629
|
+
|
|
630
|
+
// Substitute {previous} placeholder
|
|
631
|
+
merged.task = merged.task.replaceAll(
|
|
632
|
+
PREVIOUS_OUTPUT_PLACEHOLDER,
|
|
633
|
+
previousOutput,
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
// Build system prompt
|
|
637
|
+
const subagentPrompt = buildSubagentPrompt(
|
|
638
|
+
basePrompt,
|
|
639
|
+
merged.inheritSP,
|
|
640
|
+
merged.customSP,
|
|
641
|
+
merged.outputFile,
|
|
642
|
+
merged.toolOptions?.tools,
|
|
643
|
+
globalParams.resolvedPreset?.promptGuideline,
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
// Create SubTaskResult for this step
|
|
647
|
+
const subTaskResult: SubTaskResult = {
|
|
648
|
+
step: i + 1,
|
|
649
|
+
task: merged.task,
|
|
650
|
+
label: merged.label,
|
|
651
|
+
exitCode: 0,
|
|
652
|
+
messages: [],
|
|
653
|
+
stderr: "",
|
|
654
|
+
usage: { ...EMPTY_USAGE },
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
// Issue #133: status-bar breakdown — the mode context shows the
|
|
658
|
+
// current step while the dispatch is in flight.
|
|
659
|
+
state.modeContext = {
|
|
660
|
+
kind: "chain",
|
|
661
|
+
step: i + 1,
|
|
662
|
+
totalSteps: chainSteps.length,
|
|
663
|
+
};
|
|
664
|
+
// Issue #133: render the breakdown — the chain holds ONE mode-level
|
|
665
|
+
// slot (acquired at mode start, released in the finally), so no
|
|
666
|
+
// per-step acquire/release calls updateProgressStatus while the
|
|
667
|
+
// mode context is set. Call it directly here (mirrors how the
|
|
668
|
+
// graph path's per-node slot operations trigger the render).
|
|
669
|
+
updateProgressStatus(state, ctx);
|
|
670
|
+
|
|
671
|
+
// Emit initial progress for this step
|
|
672
|
+
const modeInfo = describePromptMode(
|
|
673
|
+
merged.inheritSP,
|
|
674
|
+
Boolean(merged.customSP),
|
|
675
|
+
);
|
|
676
|
+
onUpdate?.({
|
|
677
|
+
content: [
|
|
678
|
+
{
|
|
679
|
+
type: "text" as const,
|
|
680
|
+
text: `Chain step ${i + 1}/${chainSteps.length} (${modeInfo})...`,
|
|
681
|
+
},
|
|
682
|
+
],
|
|
683
|
+
details: {
|
|
684
|
+
messages: [],
|
|
685
|
+
usage: { ...EMPTY_USAGE },
|
|
686
|
+
exitCode: -1,
|
|
687
|
+
stderr: "",
|
|
688
|
+
},
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
// Issue #133: per-step run entry — mirrors runParallelMode's
|
|
692
|
+
// per-unit block (issue #119): the runId doubles as the live id,
|
|
693
|
+
// so sweepStaleLiveSubagents finds a 'running' record for every
|
|
694
|
+
// foreground live entry (the #130 invariant gap that swept
|
|
695
|
+
// graph/chain entries immediately).
|
|
696
|
+
const { runId, run } = createUnitRun(merged, stepModel, chainPriority, globalParams.resolvedPreset?.name);
|
|
697
|
+
state.persistRun(pi, run);
|
|
698
|
+
|
|
699
|
+
// R2: Prune old run entries if history exceeds limit
|
|
700
|
+
pruneHistoryIfNeeded(state, ctx, log);
|
|
701
|
+
|
|
702
|
+
// Register for live monitor — chain steps now appear in the
|
|
703
|
+
// drill-in under the runId (issue #133 single identity, mirrors
|
|
704
|
+
// parallel mode's issue #119 registration). Fall back to
|
|
705
|
+
// "Step N" when the step carries no label (liveRowName would
|
|
706
|
+
// otherwise show the truncated task).
|
|
707
|
+
registerLiveRun(state, run, ctx, merged.label ?? `Step ${i + 1}`);
|
|
708
|
+
|
|
709
|
+
// Wrap onUpdate so the live monitor sees per-step progress
|
|
710
|
+
// (mirrors parallel mode's taskOnUpdate).
|
|
711
|
+
const stepOnUpdate = makeLiveOnUpdate(state, runId, onUpdate);
|
|
712
|
+
|
|
713
|
+
// Run the subagent for this step — crash-protected (issue #130,
|
|
714
|
+
// #133 R1/C1 discipline): a throw must finalize the run entry as
|
|
715
|
+
// failed AND release the live entry, or the persisted record stays
|
|
716
|
+
// 'running' forever. finalizeLiveSubagent is idempotent; the
|
|
717
|
+
// rethrow is handled by the mode's existing outer catch (chain is
|
|
718
|
+
// sequential — no sibling wave to cancel).
|
|
719
|
+
let result: SubagentResult;
|
|
720
|
+
try {
|
|
721
|
+
result = await runSubagent(
|
|
722
|
+
resolvedCwd,
|
|
723
|
+
subagentPrompt,
|
|
724
|
+
stepModel,
|
|
725
|
+
merged.thinkingLevel,
|
|
726
|
+
merged.task,
|
|
727
|
+
signal,
|
|
728
|
+
stepOnUpdate,
|
|
729
|
+
merged.toolOptions,
|
|
730
|
+
merged.timeout,
|
|
731
|
+
getFinalOutput,
|
|
732
|
+
log,
|
|
733
|
+
currentDepth + 1,
|
|
734
|
+
);
|
|
735
|
+
} catch (err) {
|
|
736
|
+
// Issue #133 (mirrors the #119 R1/C1 discipline): finalize the
|
|
737
|
+
// run entry as failed — crashOutput comes from the live entry's
|
|
738
|
+
// last streamed output (the crash result itself has no
|
|
739
|
+
// messages), and errorCategory is classified onto originalParams.
|
|
740
|
+
finalizeUnitRunCrash(state, pi, run, err, "Chain step", resolvedCwd, log, {
|
|
741
|
+
step: i + 1,
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// Issue #133: finalize the per-step run entry — status, duration,
|
|
746
|
+
// cost, tokens and sanitized output land on the entry (mirrors
|
|
747
|
+
// parallel's issue #119 finalize; result carries the SubagentResult
|
|
748
|
+
// shape finalizeRunRecord expects, errorCategory included).
|
|
749
|
+
finalizeUnitRun(state, pi, ctx, run, result, log);
|
|
750
|
+
|
|
751
|
+
// Fill SubTaskResult
|
|
752
|
+
subTaskResult.exitCode = result.exitCode;
|
|
753
|
+
subTaskResult.messages = result.messages;
|
|
754
|
+
subTaskResult.stderr = result.stderr;
|
|
755
|
+
subTaskResult.usage = result.usage;
|
|
756
|
+
subTaskResult.model = result.model;
|
|
757
|
+
subTaskResult.stopReason = result.stopReason;
|
|
758
|
+
subTaskResult.errorMessage = result.errorMessage;
|
|
759
|
+
subTaskResult.errorCategory = classifyError(result);
|
|
760
|
+
subTaskResult.gitBranch = result.gitBranch;
|
|
761
|
+
subTaskResult.gitDiff = result.gitDiff;
|
|
762
|
+
|
|
763
|
+
chainResults.push(subTaskResult);
|
|
764
|
+
|
|
765
|
+
// Emit update with current ChainDetails
|
|
766
|
+
const completedSteps = chainResults.length;
|
|
767
|
+
const currentDetails: ChainDetails = {
|
|
768
|
+
mode: "chain",
|
|
769
|
+
results: chainResults,
|
|
770
|
+
totalInput: chainResults.reduce(
|
|
771
|
+
(s, r) => s + r.usage.input,
|
|
772
|
+
0,
|
|
773
|
+
),
|
|
774
|
+
totalOutput: chainResults.reduce(
|
|
775
|
+
(s, r) => s + r.usage.output,
|
|
776
|
+
0,
|
|
777
|
+
),
|
|
778
|
+
totalCost: chainResults.reduce(
|
|
779
|
+
(s, r) => s + r.usage.cost,
|
|
780
|
+
0,
|
|
781
|
+
),
|
|
782
|
+
totalTurns: chainResults.reduce(
|
|
783
|
+
(s, r) => s + r.usage.turns,
|
|
784
|
+
0,
|
|
785
|
+
),
|
|
786
|
+
completedSteps,
|
|
787
|
+
totalSteps: chainSteps.length,
|
|
788
|
+
stoppedEarly: false,
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
const stepFailed = isSubagentError(result);
|
|
792
|
+
onUpdate?.({
|
|
793
|
+
content: [
|
|
794
|
+
{
|
|
795
|
+
type: "text" as const,
|
|
796
|
+
text: `Chain step ${completedSteps}/${chainSteps.length} completed${stepFailed ? " (failed)" : ""}`,
|
|
797
|
+
},
|
|
798
|
+
],
|
|
799
|
+
details: currentDetails,
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
// Stop on failure (unless this was the last step)
|
|
803
|
+
if (stepFailed && i < chainSteps.length - 1) {
|
|
804
|
+
stoppedEarly = true;
|
|
805
|
+
break;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Set previous output for the next step
|
|
809
|
+
previousOutput = getFinalOutput(result.messages);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
chainSuccess = chainResults.every((r) => r.exitCode === 0);
|
|
813
|
+
|
|
814
|
+
// Compute aggregated totals
|
|
815
|
+
const totalInput = chainResults.reduce(
|
|
816
|
+
(s, r) => s + r.usage.input,
|
|
817
|
+
0,
|
|
818
|
+
);
|
|
819
|
+
const totalOutput = chainResults.reduce(
|
|
820
|
+
(s, r) => s + r.usage.output,
|
|
821
|
+
0,
|
|
822
|
+
);
|
|
823
|
+
const totalCost = chainResults.reduce(
|
|
824
|
+
(s, r) => s + r.usage.cost,
|
|
825
|
+
0,
|
|
826
|
+
);
|
|
827
|
+
const totalTurns = chainResults.reduce(
|
|
828
|
+
(s, r) => s + r.usage.turns,
|
|
829
|
+
0,
|
|
830
|
+
);
|
|
831
|
+
|
|
832
|
+
const chainDetails: ChainDetails = {
|
|
833
|
+
mode: "chain",
|
|
834
|
+
results: chainResults,
|
|
835
|
+
totalInput,
|
|
836
|
+
totalOutput,
|
|
837
|
+
totalCost,
|
|
838
|
+
totalTurns,
|
|
839
|
+
completedSteps: chainResults.length,
|
|
840
|
+
totalSteps: chainSteps.length,
|
|
841
|
+
stoppedEarly,
|
|
842
|
+
};
|
|
843
|
+
|
|
844
|
+
// R1: Record circuit breaker outcome
|
|
845
|
+
if (chainSuccess) {
|
|
846
|
+
state.recordSuccess();
|
|
847
|
+
} else {
|
|
848
|
+
state.recordFailure();
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
log.info("Chain completed", {
|
|
852
|
+
totalSteps: chainSteps.length,
|
|
853
|
+
completedSteps: chainResults.length,
|
|
854
|
+
stoppedEarly,
|
|
855
|
+
totalInput,
|
|
856
|
+
totalOutput,
|
|
857
|
+
totalCost,
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
// Issue #133: one aggregate run entry per chain dispatch — fresh id,
|
|
861
|
+
// persisted ONCE as done (no live entry: the drill-in shows the
|
|
862
|
+
// per-step entries while running; the aggregate is a post-hoc audit
|
|
863
|
+
// summary). Fields come from the dispatch-level resolved values and
|
|
864
|
+
// the aggregate totals; startedAt is the mode-entry timestamp.
|
|
865
|
+
const chainAggregateRun: SubagentRun = {
|
|
866
|
+
id: crypto.randomUUID(),
|
|
867
|
+
task: `Chain dispatch: ${chainSteps.length} steps`,
|
|
868
|
+
label: globalParams.label ?? "chain",
|
|
869
|
+
// Issue #133: persist the ACTUAL outcome — "failed" when any step
|
|
870
|
+
// failed (chainSuccess === false), "done" otherwise.
|
|
871
|
+
status: chainSuccess ? "done" : "failed",
|
|
872
|
+
model: `${subagentModel.provider}/${subagentModel.id}`,
|
|
873
|
+
thinkingLevel: globalParams.thinkingLevel,
|
|
874
|
+
priority: chainPriority,
|
|
875
|
+
startedAt: chainModeStartedAt,
|
|
876
|
+
finishedAt: new Date().toISOString(),
|
|
877
|
+
durationMs: Date.now() - new Date(chainModeStartedAt).getTime(),
|
|
878
|
+
cost: totalCost,
|
|
879
|
+
tokensIn: totalInput,
|
|
880
|
+
tokensOut: totalOutput,
|
|
881
|
+
outputSummary: capOutput(JSON.stringify(chainDetails, null, 2)),
|
|
882
|
+
};
|
|
883
|
+
state.persistRun(pi, chainAggregateRun);
|
|
884
|
+
|
|
885
|
+
return {
|
|
886
|
+
content: [
|
|
887
|
+
{ type: "text" as const, text: JSON.stringify(chainDetails, null, 2) },
|
|
888
|
+
],
|
|
889
|
+
details: chainDetails,
|
|
890
|
+
};
|
|
891
|
+
} catch (err) {
|
|
892
|
+
// F7 (issue #65): err.message may embed absolute paths — sanitize
|
|
893
|
+
// BEFORE it reaches the main agent's context (tool result content +
|
|
894
|
+
// details.errorMessage). resolvedCwd is the validated subagent cwd.
|
|
895
|
+
const result = buildCrashResult("Chain mode", err, resolvedCwd);
|
|
896
|
+
log.error("Chain mode crashed", { error: result.details.errorMessage });
|
|
897
|
+
return result;
|
|
898
|
+
} finally {
|
|
899
|
+
// Issue #133: clear the status-bar mode breakdown — the dispatch is
|
|
900
|
+
// over (success or outer-catch crash).
|
|
901
|
+
state.modeContext = undefined;
|
|
902
|
+
releaseSlot(state, chainSuccess, ctx);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// -------------------------------------------------------------------
|
|
907
|
+
// P2: runParallelMode
|
|
908
|
+
// -------------------------------------------------------------------
|
|
909
|
+
|
|
910
|
+
async function runParallelMode(
|
|
911
|
+
params: Record<string, unknown>,
|
|
912
|
+
signal: AbortSignal | undefined,
|
|
913
|
+
onUpdate: AgentToolUpdateCallback<DelegateTaskDetails> | undefined,
|
|
914
|
+
ctx: ExtensionContext,
|
|
915
|
+
): Promise<ToolResult<SubagentResult | ParallelDetails | undefined>> {
|
|
916
|
+
const taskList = params.tasks as SubTaskParams[];
|
|
917
|
+
|
|
918
|
+
// R5: Check session cost limit before spawning
|
|
919
|
+
const perTaskEstimate =
|
|
920
|
+
state.config.perTaskCostEstimate > 0
|
|
921
|
+
? state.config.perTaskCostEstimate
|
|
922
|
+
: 0.05;
|
|
923
|
+
if (state.checkCostLimit(perTaskEstimate * taskList.length, ctx)) {
|
|
924
|
+
const currentTotal = state.getSessionTotalCost(ctx);
|
|
925
|
+
const limit = state.config.sessionCostLimit;
|
|
926
|
+
log.warn("Parallel delegation rejected: session cost limit reached", {
|
|
927
|
+
currentTotal,
|
|
928
|
+
estimatedCost: perTaskEstimate * taskList.length,
|
|
929
|
+
limit,
|
|
930
|
+
});
|
|
931
|
+
return {
|
|
932
|
+
content: [
|
|
933
|
+
{
|
|
934
|
+
type: "text" as const,
|
|
935
|
+
text:
|
|
936
|
+
`Cannot delegate: session cost limit reached ` +
|
|
937
|
+
`($${currentTotal.toFixed(4)} spent of $${limit.toFixed(2)} limit). ` +
|
|
938
|
+
`Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
|
|
939
|
+
},
|
|
940
|
+
],
|
|
941
|
+
details: undefined,
|
|
942
|
+
isError: true,
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// Reject delegation if recursion depth exceeds configured max
|
|
947
|
+
const currentDepth = getCurrentDepth();
|
|
948
|
+
if (currentDepth >= state.config.maxSubagentDepth) {
|
|
949
|
+
log.warn("Parallel delegation rejected: max depth reached", {
|
|
950
|
+
currentDepth,
|
|
951
|
+
maxDepth: state.config.maxSubagentDepth,
|
|
952
|
+
});
|
|
953
|
+
return {
|
|
954
|
+
content: [
|
|
955
|
+
{
|
|
956
|
+
type: "text" as const,
|
|
957
|
+
text:
|
|
958
|
+
`Cannot delegate further: subagent recursion depth limit reached ` +
|
|
959
|
+
`(depth ${currentDepth}/${state.config.maxSubagentDepth}). ` +
|
|
960
|
+
`Subagents can delegate up to ${state.config.maxSubagentDepth} levels deep (configurable via /brl-subagent depth). Complete the remaining work directly.`,
|
|
961
|
+
},
|
|
962
|
+
],
|
|
963
|
+
details: undefined,
|
|
964
|
+
isError: true,
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Resolve global params once
|
|
969
|
+
const globalParams = resolveSubagentParams(
|
|
970
|
+
params as {
|
|
971
|
+
task: string;
|
|
972
|
+
label?: string;
|
|
973
|
+
preset?: string;
|
|
974
|
+
systemPrompt?: string;
|
|
975
|
+
inheritSystemPrompt?: boolean;
|
|
976
|
+
thinkingLevel?: string;
|
|
977
|
+
outputFile?: string;
|
|
978
|
+
timeout?: number;
|
|
979
|
+
cwd?: string;
|
|
980
|
+
tools?: string[];
|
|
981
|
+
excludeTools?: string[];
|
|
982
|
+
noBuiltinTools?: boolean;
|
|
983
|
+
gitMode?: string;
|
|
984
|
+
},
|
|
985
|
+
state,
|
|
986
|
+
ctx,
|
|
987
|
+
log,
|
|
988
|
+
);
|
|
989
|
+
|
|
990
|
+
// Validate CWD once
|
|
991
|
+
const cwdResult = validateCwd(globalParams.effectiveCwd, ctx.cwd);
|
|
992
|
+
if (!cwdResult.ok) {
|
|
993
|
+
return {
|
|
994
|
+
content: [
|
|
995
|
+
{ type: "text" as const, text: `Invalid cwd: ${cwdResult.error}` },
|
|
996
|
+
],
|
|
997
|
+
details: undefined,
|
|
998
|
+
isError: true,
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
const resolvedCwd = cwdResult.value;
|
|
1002
|
+
|
|
1003
|
+
// Pre-flight checks — fail fast before consuming resources
|
|
1004
|
+
const pfResult = preflightCheck(resolvedCwd);
|
|
1005
|
+
if (!pfResult.ok) {
|
|
1006
|
+
log.warn("Parallel pre-flight check failed", { error: pfResult.error });
|
|
1007
|
+
return {
|
|
1008
|
+
content: [
|
|
1009
|
+
{
|
|
1010
|
+
type: "text" as const,
|
|
1011
|
+
text: `Pre-flight check failed: ${pfResult.error}`,
|
|
1012
|
+
},
|
|
1013
|
+
],
|
|
1014
|
+
details: undefined,
|
|
1015
|
+
isError: true,
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// H1: Pre-task validation at mode entry — deterministic check that
|
|
1020
|
+
// tools/thinking match the task (mirrors single/background modes, issue
|
|
1021
|
+
// #32). Parallel top-level tasks are empty (modeCount forbids task+tasks),
|
|
1022
|
+
// so keyword warnings skip on empty text and only the hard
|
|
1023
|
+
// outputFile-vs-write conflict applies (issue #34). The mode-level
|
|
1024
|
+
// outputFile on globalParams is the one validated; per-step outputFiles
|
|
1025
|
+
// are future work (issue #3).
|
|
1026
|
+
const validation = validatePreTask({
|
|
1027
|
+
task: globalParams.task,
|
|
1028
|
+
toolOptions: globalParams.toolOptions,
|
|
1029
|
+
thinkingLevel: globalParams.thinkingLevel,
|
|
1030
|
+
gitMode: globalParams.resolvedGitMode,
|
|
1031
|
+
outputFile: globalParams.outputFile,
|
|
1032
|
+
});
|
|
1033
|
+
if (validation.warnings.length > 0) {
|
|
1034
|
+
log.warn("Parallel pre-task validation warnings", { warnings: validation.warnings });
|
|
1035
|
+
}
|
|
1036
|
+
if (!validation.valid) {
|
|
1037
|
+
const errText = validation.errors.join("; ");
|
|
1038
|
+
log.warn("Parallel pre-task validation failed", { errors: validation.errors });
|
|
1039
|
+
return {
|
|
1040
|
+
content: [{ type: "text" as const, text: errText }],
|
|
1041
|
+
details: undefined,
|
|
1042
|
+
isError: true,
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// Resolve model once (per-call top-level model override beats preset)
|
|
1047
|
+
const modelResult = resolveSubagentModel(ctx, globalParams.resolvedPreset, params.model as string | undefined);
|
|
1048
|
+
if (!modelResult.ok) return modelResult.error;
|
|
1049
|
+
const subagentModel = modelResult.model;
|
|
1050
|
+
|
|
1051
|
+
const modelStr = `${subagentModel.provider}/${subagentModel.id}`.trim();
|
|
1052
|
+
if (!modelStr || modelStr === "/") {
|
|
1053
|
+
log.warn("Model string is empty after resolution", { model: subagentModel });
|
|
1054
|
+
return {
|
|
1055
|
+
content: [
|
|
1056
|
+
{
|
|
1057
|
+
type: "text" as const,
|
|
1058
|
+
text:
|
|
1059
|
+
"Subagent model is not configured. " +
|
|
1060
|
+
"Use /brl-subagent to set a model, or ensure your current session has a valid model.",
|
|
1061
|
+
},
|
|
1062
|
+
],
|
|
1063
|
+
details: undefined,
|
|
1064
|
+
isError: true,
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// R1: Circuit breaker check — reject if circuit is open
|
|
1069
|
+
const circuitCheck = state.checkCircuit();
|
|
1070
|
+
if (circuitCheck.isOpen) {
|
|
1071
|
+
return {
|
|
1072
|
+
content: [
|
|
1073
|
+
{ type: "text" as const, text: circuitCheck.message! },
|
|
1074
|
+
],
|
|
1075
|
+
details: undefined,
|
|
1076
|
+
isError: true,
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// Build base prompt once
|
|
1081
|
+
const basePrompt = ctx.getSystemPrompt();
|
|
1082
|
+
|
|
1083
|
+
// Results array — final positions match taskList indices
|
|
1084
|
+
const results: SubTaskResult[] = [];
|
|
1085
|
+
let completedCount = 0;
|
|
1086
|
+
|
|
1087
|
+
// E10: Intercom for subagent-to-subagent messaging
|
|
1088
|
+
const intercom = new Intercom();
|
|
1089
|
+
|
|
1090
|
+
// Individual task runner (captures merged params, runs subagent)
|
|
1091
|
+
const runTask = async (
|
|
1092
|
+
index: number,
|
|
1093
|
+
merged: ReturnType<typeof mergeSubTaskParams>,
|
|
1094
|
+
): Promise<boolean> => {
|
|
1095
|
+
|
|
1096
|
+
// C3: Resolve this step's model override (step.model > global resolved model)
|
|
1097
|
+
const stepModel = resolveStepModel(ctx, merged.model, subagentModel);
|
|
1098
|
+
|
|
1099
|
+
// Build system prompt for this task
|
|
1100
|
+
const subagentPrompt = buildSubagentPrompt(
|
|
1101
|
+
basePrompt,
|
|
1102
|
+
merged.inheritSP,
|
|
1103
|
+
merged.customSP,
|
|
1104
|
+
merged.outputFile,
|
|
1105
|
+
merged.toolOptions?.tools,
|
|
1106
|
+
globalParams.resolvedPreset?.promptGuideline,
|
|
1107
|
+
);
|
|
1108
|
+
|
|
1109
|
+
// Create SubTaskResult
|
|
1110
|
+
const subTaskResult: SubTaskResult = {
|
|
1111
|
+
task: merged.task,
|
|
1112
|
+
label: merged.label,
|
|
1113
|
+
exitCode: 0,
|
|
1114
|
+
messages: [],
|
|
1115
|
+
stderr: "",
|
|
1116
|
+
usage: { ...EMPTY_USAGE },
|
|
1117
|
+
};
|
|
1118
|
+
|
|
1119
|
+
// E10: Register subagent ID for intercom
|
|
1120
|
+
const subagentId = merged.label ?? `parallel-${index}`;
|
|
1121
|
+
intercom.register(subagentId);
|
|
1122
|
+
|
|
1123
|
+
// Issue #119: per-subtask run entry — created at spawn so the
|
|
1124
|
+
// monitor drill-in shows per-unit priority and a post-hoc audit
|
|
1125
|
+
// trail exists per subtask (mirrors single-mode run persistence).
|
|
1126
|
+
const { runId, run } = createUnitRun(merged, stepModel, parallelPriority, globalParams.resolvedPreset?.name);
|
|
1127
|
+
state.persistRun(pi, run);
|
|
1128
|
+
|
|
1129
|
+
// R2: Prune old run entries if history exceeds limit
|
|
1130
|
+
pruneHistoryIfNeeded(state, ctx, log);
|
|
1131
|
+
|
|
1132
|
+
// Register for live monitor — parallel subtasks now appear in the
|
|
1133
|
+
// drill-in with their per-unit priority (issue #119).
|
|
1134
|
+
registerLiveRun(state, run, ctx);
|
|
1135
|
+
|
|
1136
|
+
log.debug("Parallel subtask run registered", { runId, index });
|
|
1137
|
+
|
|
1138
|
+
// Emit initial progress
|
|
1139
|
+
const modeInfo = describePromptMode(
|
|
1140
|
+
merged.inheritSP,
|
|
1141
|
+
Boolean(merged.customSP),
|
|
1142
|
+
);
|
|
1143
|
+
onUpdate?.({
|
|
1144
|
+
content: [
|
|
1145
|
+
{
|
|
1146
|
+
type: "text" as const,
|
|
1147
|
+
text: `Parallel task ${index + 1}/${taskList.length} (${modeInfo})...`,
|
|
1148
|
+
},
|
|
1149
|
+
],
|
|
1150
|
+
details: {
|
|
1151
|
+
messages: [],
|
|
1152
|
+
usage: { ...EMPTY_USAGE },
|
|
1153
|
+
exitCode: -1,
|
|
1154
|
+
stderr: "",
|
|
1155
|
+
},
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
// Wrap onUpdate for per-task progress — also feed the live monitor so
|
|
1159
|
+
// the drill-in streams output instead of showing "waiting for first
|
|
1160
|
+
// output…" for the whole run (mirrors single mode's liveOnUpdate).
|
|
1161
|
+
const taskOnUpdate = makeLiveOnUpdate(state, runId, onUpdate);
|
|
1162
|
+
|
|
1163
|
+
// Run subagent — the post-spawn body is crash-protected (issue #119
|
|
1164
|
+
// R1, C1 fix): a throw here (e.g. runner writeToTempFile) must
|
|
1165
|
+
// finalize the run entry and release the live monitor, or the entry
|
|
1166
|
+
// stays 'running' forever and sweepStaleLiveSubagents cannot reclaim
|
|
1167
|
+
// the live ghost (grace logic treats a 'running' record as live).
|
|
1168
|
+
try {
|
|
1169
|
+
const result = await runSubagent(
|
|
1170
|
+
resolvedCwd,
|
|
1171
|
+
subagentPrompt,
|
|
1172
|
+
stepModel,
|
|
1173
|
+
merged.thinkingLevel,
|
|
1174
|
+
merged.task,
|
|
1175
|
+
signal,
|
|
1176
|
+
taskOnUpdate,
|
|
1177
|
+
merged.toolOptions,
|
|
1178
|
+
merged.timeout,
|
|
1179
|
+
getFinalOutput,
|
|
1180
|
+
log,
|
|
1181
|
+
currentDepth + 1,
|
|
1182
|
+
intercom,
|
|
1183
|
+
subagentId,
|
|
1184
|
+
);
|
|
1185
|
+
|
|
1186
|
+
// Fill SubTaskResult
|
|
1187
|
+
subTaskResult.exitCode = result.exitCode;
|
|
1188
|
+
subTaskResult.messages = result.messages;
|
|
1189
|
+
subTaskResult.stderr = result.stderr;
|
|
1190
|
+
subTaskResult.usage = result.usage;
|
|
1191
|
+
subTaskResult.model = result.model;
|
|
1192
|
+
subTaskResult.stopReason = result.stopReason;
|
|
1193
|
+
subTaskResult.errorMessage = result.errorMessage;
|
|
1194
|
+
subTaskResult.errorCategory = classifyError(result);
|
|
1195
|
+
subTaskResult.gitBranch = result.gitBranch;
|
|
1196
|
+
subTaskResult.gitDiff = result.gitDiff;
|
|
1197
|
+
|
|
1198
|
+
results[index] = subTaskResult;
|
|
1199
|
+
completedCount++;
|
|
1200
|
+
|
|
1201
|
+
// Issue #119: finalize the per-subtask run entry — status, duration,
|
|
1202
|
+
// cost, tokens and sanitized output land on the entry (mirrors
|
|
1203
|
+
// single-mode finalization; result carries the SubagentResult shape
|
|
1204
|
+
// finalizeRunRecord expects, errorCategory included).
|
|
1205
|
+
finalizeUnitRun(state, pi, ctx, run, result, log);
|
|
1206
|
+
} catch (err) {
|
|
1207
|
+
// Issue #119 R1 (C1 fix): mirror the single-mode crash catch — a
|
|
1208
|
+
// post-spawn throw must finalize the entry as failed and release
|
|
1209
|
+
// the live monitor, or the persisted entry stays 'running' and
|
|
1210
|
+
// the live drill-in loops forever. finalizeLiveSubagent is
|
|
1211
|
+
// idempotent; the rethrow keeps Promise.allSettled semantics (the
|
|
1212
|
+
// caller's finally still releases the concurrency slot).
|
|
1213
|
+
finalizeUnitRunCrash(state, pi, run, err, "Parallel subtask", resolvedCwd, log, {
|
|
1214
|
+
index,
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// Emit progress update
|
|
1219
|
+
const completed = results.filter(Boolean).length;
|
|
1220
|
+
const succeeded = results.filter(
|
|
1221
|
+
(r) => r && r.exitCode === 0,
|
|
1222
|
+
).length;
|
|
1223
|
+
const failed = completed - succeeded;
|
|
1224
|
+
|
|
1225
|
+
const partialDetails: ParallelDetails = {
|
|
1226
|
+
mode: "parallel",
|
|
1227
|
+
results: results.filter((r) => r !== undefined),
|
|
1228
|
+
totalInput: results.reduce(
|
|
1229
|
+
(s, r) => s + (r?.usage.input ?? 0),
|
|
1230
|
+
0,
|
|
1231
|
+
),
|
|
1232
|
+
totalOutput: results.reduce(
|
|
1233
|
+
(s, r) => s + (r?.usage.output ?? 0),
|
|
1234
|
+
0,
|
|
1235
|
+
),
|
|
1236
|
+
totalCost: results.reduce(
|
|
1237
|
+
(s, r) => s + (r?.usage.cost ?? 0),
|
|
1238
|
+
0,
|
|
1239
|
+
),
|
|
1240
|
+
totalTurns: results.reduce(
|
|
1241
|
+
(s, r) => s + (r?.usage.turns ?? 0),
|
|
1242
|
+
0,
|
|
1243
|
+
),
|
|
1244
|
+
succeeded,
|
|
1245
|
+
failed,
|
|
1246
|
+
};
|
|
1247
|
+
|
|
1248
|
+
onUpdate?.({
|
|
1249
|
+
content: [
|
|
1250
|
+
{
|
|
1251
|
+
type: "text" as const,
|
|
1252
|
+
text: `Parallel: ${completed}/${taskList.length} completed (${succeeded} succeeded, ${failed} failed)`,
|
|
1253
|
+
},
|
|
1254
|
+
],
|
|
1255
|
+
details: partialDetails,
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
// Issue #137: return the unit's real success so the caller's slot
|
|
1259
|
+
// release is honest — the status bar's "N failed" count reflects the
|
|
1260
|
+
// true outcome (same check as the aggregate chainSuccess).
|
|
1261
|
+
return !isSubagentError(subTaskResult);
|
|
1262
|
+
};
|
|
1263
|
+
|
|
1264
|
+
// Resolve priority — the FALLBACK for per-unit priority (issue #114): a
|
|
1265
|
+
// task-level priority on tasks[] items wins per slot; this is the floor.
|
|
1266
|
+
const parallelPriority: Priority = (
|
|
1267
|
+
params.priority && ["critical", "high", "normal", "low"].includes(params.priority as string)
|
|
1268
|
+
? (params.priority as Priority)
|
|
1269
|
+
: DEFAULT_PRIORITY
|
|
1270
|
+
);
|
|
1271
|
+
|
|
1272
|
+
// Launch all tasks concurrently using acquireSlot for natural concurrency limiting
|
|
1273
|
+
const promises = taskList.map(async (_, index) => {
|
|
1274
|
+
// Issue #114: per-unit priority wins the slot — the step's merged
|
|
1275
|
+
// priority (if any) beats the call-level parallelPriority fallback.
|
|
1276
|
+
const merged = mergeSubTaskParams(globalParams, taskList[index]);
|
|
1277
|
+
const acquired = await acquireSlot(state, ctx, signal, merged.priority ?? parallelPriority);
|
|
1278
|
+
if (!acquired) {
|
|
1279
|
+
results[index] = {
|
|
1280
|
+
task: taskList[index].task,
|
|
1281
|
+
exitCode: 1,
|
|
1282
|
+
messages: [],
|
|
1283
|
+
stderr: "",
|
|
1284
|
+
usage: { ...EMPTY_USAGE },
|
|
1285
|
+
errorMessage:
|
|
1286
|
+
"Cancelled while waiting for concurrency slot",
|
|
1287
|
+
errorCategory: "aborted",
|
|
1288
|
+
};
|
|
1289
|
+
completedCount++;
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
let taskSuccess = false;
|
|
1293
|
+
try {
|
|
1294
|
+
taskSuccess = await runTask(index, merged);
|
|
1295
|
+
} finally {
|
|
1296
|
+
// Issue #137: release with the unit's real success — the status
|
|
1297
|
+
// bar's "N failed" count reflects the true outcome.
|
|
1298
|
+
releaseSlot(state, taskSuccess, ctx);
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
await Promise.allSettled(promises);
|
|
1303
|
+
|
|
1304
|
+
// Compute final aggregates
|
|
1305
|
+
const finalResults = results.filter(Boolean);
|
|
1306
|
+
const totalInput = finalResults.reduce(
|
|
1307
|
+
(s, r) => s + r.usage.input,
|
|
1308
|
+
0,
|
|
1309
|
+
);
|
|
1310
|
+
const totalOutput = finalResults.reduce(
|
|
1311
|
+
(s, r) => s + r.usage.output,
|
|
1312
|
+
0,
|
|
1313
|
+
);
|
|
1314
|
+
const totalCost = finalResults.reduce(
|
|
1315
|
+
(s, r) => s + r.usage.cost,
|
|
1316
|
+
0,
|
|
1317
|
+
);
|
|
1318
|
+
const totalTurns = finalResults.reduce(
|
|
1319
|
+
(s, r) => s + r.usage.turns,
|
|
1320
|
+
0,
|
|
1321
|
+
);
|
|
1322
|
+
const succeeded = finalResults.filter((r) => r.exitCode === 0).length;
|
|
1323
|
+
const failed = finalResults.length - succeeded;
|
|
1324
|
+
|
|
1325
|
+
const parallelDetails: ParallelDetails = {
|
|
1326
|
+
mode: "parallel",
|
|
1327
|
+
results: finalResults,
|
|
1328
|
+
totalInput,
|
|
1329
|
+
totalOutput,
|
|
1330
|
+
totalCost,
|
|
1331
|
+
totalTurns,
|
|
1332
|
+
succeeded,
|
|
1333
|
+
failed,
|
|
1334
|
+
};
|
|
1335
|
+
|
|
1336
|
+
// R1: Record circuit breaker outcome
|
|
1337
|
+
if (failed === 0) {
|
|
1338
|
+
state.recordSuccess();
|
|
1339
|
+
} else {
|
|
1340
|
+
state.recordFailure();
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
log.info("Parallel execution completed", {
|
|
1344
|
+
totalTasks: taskList.length,
|
|
1345
|
+
succeeded,
|
|
1346
|
+
failed,
|
|
1347
|
+
totalInput,
|
|
1348
|
+
totalOutput,
|
|
1349
|
+
totalCost,
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
return {
|
|
1353
|
+
content: [
|
|
1354
|
+
{ type: "text" as const, text: JSON.stringify(parallelDetails, null, 2) },
|
|
1355
|
+
],
|
|
1356
|
+
details: parallelDetails,
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// -------------------------------------------------------------------
|
|
1361
|
+
// P10: runGraphMode
|
|
1362
|
+
// -------------------------------------------------------------------
|
|
1363
|
+
|
|
1364
|
+
async function runGraphMode(
|
|
1365
|
+
params: Record<string, unknown>,
|
|
1366
|
+
signal: AbortSignal | undefined,
|
|
1367
|
+
onUpdate: AgentToolUpdateCallback<DelegateTaskDetails> | undefined,
|
|
1368
|
+
ctx: ExtensionContext,
|
|
1369
|
+
): Promise<ToolResult<SubagentResult | GraphDetails | undefined>> {
|
|
1370
|
+
const graphTasks = params.graph as GraphTask[];
|
|
1371
|
+
|
|
1372
|
+
// Issue #133: dispatch-start timestamp — the aggregate run entry's
|
|
1373
|
+
// startedAt (captured once, at mode entry, not at completion).
|
|
1374
|
+
const graphModeStartedAt = new Date().toISOString();
|
|
1375
|
+
|
|
1376
|
+
// Validate graph
|
|
1377
|
+
const errors = validateGraph(graphTasks);
|
|
1378
|
+
if (errors.length > 0) {
|
|
1379
|
+
return {
|
|
1380
|
+
content: [
|
|
1381
|
+
{
|
|
1382
|
+
type: "text" as const,
|
|
1383
|
+
text: `Graph validation failed:\n${errors.map((e) => ` - ${e}`).join("\n")}`,
|
|
1384
|
+
},
|
|
1385
|
+
],
|
|
1386
|
+
details: undefined,
|
|
1387
|
+
isError: true,
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// R5: Check session cost limit before spawning
|
|
1392
|
+
const perTaskEstimate =
|
|
1393
|
+
state.config.perTaskCostEstimate > 0
|
|
1394
|
+
? state.config.perTaskCostEstimate
|
|
1395
|
+
: 0.05;
|
|
1396
|
+
if (state.checkCostLimit(perTaskEstimate * graphTasks.length, ctx)) {
|
|
1397
|
+
const currentTotal = state.getSessionTotalCost(ctx);
|
|
1398
|
+
const limit = state.config.sessionCostLimit;
|
|
1399
|
+
log.warn("Graph delegation rejected: session cost limit reached", {
|
|
1400
|
+
currentTotal,
|
|
1401
|
+
estimatedCost: perTaskEstimate * graphTasks.length,
|
|
1402
|
+
limit,
|
|
1403
|
+
});
|
|
1404
|
+
return {
|
|
1405
|
+
content: [
|
|
1406
|
+
{
|
|
1407
|
+
type: "text" as const,
|
|
1408
|
+
text:
|
|
1409
|
+
`Cannot delegate: session cost limit reached ` +
|
|
1410
|
+
`($${currentTotal.toFixed(4)} spent of $${limit.toFixed(2)} limit). ` +
|
|
1411
|
+
`Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
|
|
1412
|
+
},
|
|
1413
|
+
],
|
|
1414
|
+
details: undefined,
|
|
1415
|
+
isError: true,
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// Reject delegation if recursion depth exceeds configured max
|
|
1420
|
+
const currentDepth = getCurrentDepth();
|
|
1421
|
+
if (currentDepth >= state.config.maxSubagentDepth) {
|
|
1422
|
+
log.warn("Graph delegation rejected: max depth reached", {
|
|
1423
|
+
currentDepth,
|
|
1424
|
+
maxDepth: state.config.maxSubagentDepth,
|
|
1425
|
+
});
|
|
1426
|
+
return {
|
|
1427
|
+
content: [
|
|
1428
|
+
{
|
|
1429
|
+
type: "text" as const,
|
|
1430
|
+
text:
|
|
1431
|
+
`Cannot delegate further: subagent recursion depth limit reached ` +
|
|
1432
|
+
`(depth ${currentDepth}/${state.config.maxSubagentDepth}). ` +
|
|
1433
|
+
`Subagents can delegate up to ${state.config.maxSubagentDepth} levels deep (configurable via /brl-subagent depth). Complete the remaining work directly.`,
|
|
1434
|
+
},
|
|
1435
|
+
],
|
|
1436
|
+
details: undefined,
|
|
1437
|
+
isError: true,
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// Resolve global params once
|
|
1442
|
+
const globalParams = resolveSubagentParams(
|
|
1443
|
+
params as {
|
|
1444
|
+
task: string;
|
|
1445
|
+
label?: string;
|
|
1446
|
+
preset?: string;
|
|
1447
|
+
systemPrompt?: string;
|
|
1448
|
+
inheritSystemPrompt?: boolean;
|
|
1449
|
+
thinkingLevel?: string;
|
|
1450
|
+
outputFile?: string;
|
|
1451
|
+
timeout?: number;
|
|
1452
|
+
cwd?: string;
|
|
1453
|
+
tools?: string[];
|
|
1454
|
+
excludeTools?: string[];
|
|
1455
|
+
noBuiltinTools?: boolean;
|
|
1456
|
+
gitMode?: string;
|
|
1457
|
+
approvalMode?: string;
|
|
1458
|
+
},
|
|
1459
|
+
state,
|
|
1460
|
+
ctx,
|
|
1461
|
+
log,
|
|
1462
|
+
);
|
|
1463
|
+
|
|
1464
|
+
// Validate CWD once
|
|
1465
|
+
const cwdResult = validateCwd(globalParams.effectiveCwd, ctx.cwd);
|
|
1466
|
+
if (!cwdResult.ok) {
|
|
1467
|
+
return {
|
|
1468
|
+
content: [
|
|
1469
|
+
{ type: "text" as const, text: `Invalid cwd: ${cwdResult.error}` },
|
|
1470
|
+
],
|
|
1471
|
+
details: undefined,
|
|
1472
|
+
isError: true,
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
const resolvedCwd = cwdResult.value;
|
|
1476
|
+
|
|
1477
|
+
// Pre-flight checks
|
|
1478
|
+
const pfResult = preflightCheck(resolvedCwd);
|
|
1479
|
+
if (!pfResult.ok) {
|
|
1480
|
+
log.warn("Graph pre-flight check failed", { error: pfResult.error });
|
|
1481
|
+
return {
|
|
1482
|
+
content: [
|
|
1483
|
+
{
|
|
1484
|
+
type: "text" as const,
|
|
1485
|
+
text: `Pre-flight check failed: ${pfResult.error}`,
|
|
1486
|
+
},
|
|
1487
|
+
],
|
|
1488
|
+
details: undefined,
|
|
1489
|
+
isError: true,
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
// H1: Pre-task validation at mode entry — deterministic check that
|
|
1494
|
+
// tools/thinking match the task (mirrors single/background modes, issue
|
|
1495
|
+
// #32). Graph top-level tasks are empty (modeCount forbids task+graph),
|
|
1496
|
+
// so keyword warnings skip on empty text and only the hard
|
|
1497
|
+
// outputFile-vs-write conflict applies (issue #34). The mode-level
|
|
1498
|
+
// outputFile on globalParams is the one validated; per-step outputFiles
|
|
1499
|
+
// are future work (issue #3).
|
|
1500
|
+
const validation = validatePreTask({
|
|
1501
|
+
task: globalParams.task,
|
|
1502
|
+
toolOptions: globalParams.toolOptions,
|
|
1503
|
+
thinkingLevel: globalParams.thinkingLevel,
|
|
1504
|
+
gitMode: globalParams.resolvedGitMode,
|
|
1505
|
+
outputFile: globalParams.outputFile,
|
|
1506
|
+
});
|
|
1507
|
+
if (validation.warnings.length > 0) {
|
|
1508
|
+
log.warn("Graph pre-task validation warnings", { warnings: validation.warnings });
|
|
1509
|
+
}
|
|
1510
|
+
if (!validation.valid) {
|
|
1511
|
+
const errText = validation.errors.join("; ");
|
|
1512
|
+
log.warn("Graph pre-task validation failed", { errors: validation.errors });
|
|
1513
|
+
return {
|
|
1514
|
+
content: [{ type: "text" as const, text: errText }],
|
|
1515
|
+
details: undefined,
|
|
1516
|
+
isError: true,
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
// Resolve model once (per-call top-level model override beats preset)
|
|
1521
|
+
const modelResult = resolveSubagentModel(ctx, globalParams.resolvedPreset, params.model as string | undefined);
|
|
1522
|
+
if (!modelResult.ok) return modelResult.error;
|
|
1523
|
+
const subagentModel = modelResult.model;
|
|
1524
|
+
|
|
1525
|
+
const modelStr = `${subagentModel.provider}/${subagentModel.id}`.trim();
|
|
1526
|
+
if (!modelStr || modelStr === "/") {
|
|
1527
|
+
log.warn("Model string is empty after resolution", { model: subagentModel });
|
|
1528
|
+
return {
|
|
1529
|
+
content: [
|
|
1530
|
+
{
|
|
1531
|
+
type: "text" as const,
|
|
1532
|
+
text:
|
|
1533
|
+
"Subagent model is not configured. " +
|
|
1534
|
+
"Use /brl-subagent to set a model, or ensure your current session has a valid model.",
|
|
1535
|
+
},
|
|
1536
|
+
],
|
|
1537
|
+
details: undefined,
|
|
1538
|
+
isError: true,
|
|
1539
|
+
};
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
// R1: Circuit breaker check
|
|
1543
|
+
const circuitCheck = state.checkCircuit();
|
|
1544
|
+
if (circuitCheck.isOpen) {
|
|
1545
|
+
return {
|
|
1546
|
+
content: [
|
|
1547
|
+
{ type: "text" as const, text: circuitCheck.message! },
|
|
1548
|
+
],
|
|
1549
|
+
details: undefined,
|
|
1550
|
+
isError: true,
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
// Resolve priority — the FALLBACK for per-unit priority (issue #114): a
|
|
1555
|
+
// task-level priority on graph[] items wins per slot; this is the floor.
|
|
1556
|
+
const graphPriority: Priority = (
|
|
1557
|
+
params.priority && ["critical", "high", "normal", "low"].includes(params.priority as string)
|
|
1558
|
+
? (params.priority as Priority)
|
|
1559
|
+
: DEFAULT_PRIORITY
|
|
1560
|
+
);
|
|
1561
|
+
|
|
1562
|
+
// Topological sort → execution waves
|
|
1563
|
+
const sortResult = topologicalSort(graphTasks);
|
|
1564
|
+
if (!sortResult.ok) {
|
|
1565
|
+
return {
|
|
1566
|
+
content: [
|
|
1567
|
+
{ type: "text" as const, text: `Graph scheduling failed: ${sortResult.error}` },
|
|
1568
|
+
],
|
|
1569
|
+
details: undefined,
|
|
1570
|
+
isError: true,
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1573
|
+
const waves = sortResult.waves;
|
|
1574
|
+
|
|
1575
|
+
// Build base prompt once
|
|
1576
|
+
const basePrompt = ctx.getSystemPrompt();
|
|
1577
|
+
|
|
1578
|
+
// Results map: task id → SubTaskResult (populated as waves complete)
|
|
1579
|
+
const resultMap = new Map<string, SubTaskResult>();
|
|
1580
|
+
const allWaves: GraphWave[] = [];
|
|
1581
|
+
|
|
1582
|
+
// E10: Intercom for subagent-to-subagent messaging
|
|
1583
|
+
const intercom = new Intercom();
|
|
1584
|
+
|
|
1585
|
+
// Issue #133: global node counter — node positions are reported against
|
|
1586
|
+
// the WHOLE dispatch (dispatch-wide ordinal), not the current wave; a
|
|
1587
|
+
// later wave's first node is its true global position (e.g. node 3/5),
|
|
1588
|
+
// not "node 1/5". Incremented once per node as each wave's nodes run.
|
|
1589
|
+
let globalNodeCounter = 0;
|
|
1590
|
+
let chainSuccess = false;
|
|
1591
|
+
try {
|
|
1592
|
+
for (let w = 0; w < waves.length; w++) {
|
|
1593
|
+
const wave = waves[w];
|
|
1594
|
+
const waveIndex = w + 1;
|
|
1595
|
+
const isParallel = wave.length > 1;
|
|
1596
|
+
|
|
1597
|
+
// Issue #133: status-bar breakdown — wave-level context set at
|
|
1598
|
+
// each wave start (nodes within the wave refine it as they start).
|
|
1599
|
+
state.modeContext = {
|
|
1600
|
+
kind: "graph",
|
|
1601
|
+
wave: waveIndex,
|
|
1602
|
+
totalWaves: waves.length,
|
|
1603
|
+
node: 0,
|
|
1604
|
+
totalNodes: graphTasks.length,
|
|
1605
|
+
};
|
|
1606
|
+
|
|
1607
|
+
// Emit initial progress
|
|
1608
|
+
onUpdate?.({
|
|
1609
|
+
content: [
|
|
1610
|
+
{
|
|
1611
|
+
type: "text" as const,
|
|
1612
|
+
text: `Graph wave ${waveIndex}/${waves.length} (${wave.length} task${wave.length > 1 ? "s" : ""})...`,
|
|
1613
|
+
},
|
|
1614
|
+
],
|
|
1615
|
+
details: {
|
|
1616
|
+
messages: [],
|
|
1617
|
+
usage: { ...EMPTY_USAGE },
|
|
1618
|
+
exitCode: -1,
|
|
1619
|
+
stderr: "",
|
|
1620
|
+
},
|
|
1621
|
+
});
|
|
1622
|
+
|
|
1623
|
+
// Run all tasks in this wave concurrently
|
|
1624
|
+
const wavePromises = wave.map(async (graphTask, nodeIndex) => {
|
|
1625
|
+
// Issue #133: capture this node's dispatch-wide ordinal before any
|
|
1626
|
+
// sibling node advances the counter (same-wave siblings run
|
|
1627
|
+
// concurrently, so the value must be pinned here).
|
|
1628
|
+
const globalNode = ++globalNodeCounter;
|
|
1629
|
+
const subTaskParams: SubTaskParams = {
|
|
1630
|
+
task: graphTask.task,
|
|
1631
|
+
label: graphTask.label,
|
|
1632
|
+
model: graphTask.model,
|
|
1633
|
+
thinkingLevel: graphTask.thinkingLevel,
|
|
1634
|
+
priority: graphTask.priority,
|
|
1635
|
+
cwd: graphTask.cwd,
|
|
1636
|
+
timeout: graphTask.timeout,
|
|
1637
|
+
outputFile: graphTask.outputFile,
|
|
1638
|
+
tools: graphTask.tools,
|
|
1639
|
+
excludeTools: graphTask.excludeTools,
|
|
1640
|
+
noBuiltinTools: graphTask.noBuiltinTools,
|
|
1641
|
+
systemPrompt: graphTask.systemPrompt,
|
|
1642
|
+
inheritSystemPrompt: graphTask.inheritSystemPrompt,
|
|
1643
|
+
};
|
|
1644
|
+
|
|
1645
|
+
const merged = mergeSubTaskParams(globalParams, subTaskParams);
|
|
1646
|
+
|
|
1647
|
+
// C3: Resolve this step's model override (step.model > global resolved model)
|
|
1648
|
+
const stepModel = resolveStepModel(ctx, merged.model, subagentModel);
|
|
1649
|
+
|
|
1650
|
+
// Substitute {id} placeholders with previous outputs
|
|
1651
|
+
merged.task = merged.task.replace(GRAPH_OUTPUT_PLACEHOLDER_RE, (_match, id) => {
|
|
1652
|
+
const depResult = resultMap.get(id);
|
|
1653
|
+
if (!depResult) return _match; // Leave unchanged if dep not found
|
|
1654
|
+
return getFinalOutput(depResult.messages);
|
|
1655
|
+
});
|
|
1656
|
+
|
|
1657
|
+
// Build system prompt
|
|
1658
|
+
const subagentPrompt = buildSubagentPrompt(
|
|
1659
|
+
basePrompt,
|
|
1660
|
+
merged.inheritSP,
|
|
1661
|
+
merged.customSP,
|
|
1662
|
+
merged.outputFile,
|
|
1663
|
+
merged.toolOptions?.tools,
|
|
1664
|
+
globalParams.resolvedPreset?.promptGuideline,
|
|
1665
|
+
);
|
|
1666
|
+
|
|
1667
|
+
// Acquire concurrency slot — issue #114: per-unit priority wins the
|
|
1668
|
+
// slot; the call-level graphPriority is the fallback.
|
|
1669
|
+
const acquired = await acquireSlot(state, ctx, signal, merged.priority ?? graphPriority);
|
|
1670
|
+
if (!acquired) {
|
|
1671
|
+
return {
|
|
1672
|
+
id: graphTask.id,
|
|
1673
|
+
result: {
|
|
1674
|
+
task: merged.task,
|
|
1675
|
+
label: merged.label,
|
|
1676
|
+
exitCode: 1,
|
|
1677
|
+
messages: [],
|
|
1678
|
+
stderr: "",
|
|
1679
|
+
usage: { ...EMPTY_USAGE },
|
|
1680
|
+
errorMessage: "Cancelled while waiting for concurrency slot",
|
|
1681
|
+
errorCategory: "aborted" as const,
|
|
1682
|
+
} satisfies SubTaskResult,
|
|
1683
|
+
};
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
const subagentId = graphTask.id;
|
|
1687
|
+
intercom.register(subagentId);
|
|
1688
|
+
|
|
1689
|
+
// Issue #133: status-bar breakdown — the node's dispatch-wide
|
|
1690
|
+
// position (globalNode, not the wave-local nodeIndex) so a later
|
|
1691
|
+
// wave reports its true global ordinal.
|
|
1692
|
+
state.modeContext = {
|
|
1693
|
+
kind: "graph",
|
|
1694
|
+
wave: waveIndex,
|
|
1695
|
+
totalWaves: waves.length,
|
|
1696
|
+
node: globalNode,
|
|
1697
|
+
totalNodes: graphTasks.length,
|
|
1698
|
+
};
|
|
1699
|
+
|
|
1700
|
+
// Issue #133: per-node run entry — mirrors runParallelMode's
|
|
1701
|
+
// per-unit block (issue #119): the runId doubles as the live id,
|
|
1702
|
+
// so sweepStaleLiveSubagents finds a 'running' record for every
|
|
1703
|
+
// foreground live entry (the #130 invariant gap that swept
|
|
1704
|
+
// graph/chain entries immediately).
|
|
1705
|
+
const { runId, run } = createUnitRun(merged, stepModel, graphPriority, globalParams.resolvedPreset?.name);
|
|
1706
|
+
state.persistRun(pi, run);
|
|
1707
|
+
|
|
1708
|
+
// R2: Prune old run entries if history exceeds limit
|
|
1709
|
+
pruneHistoryIfNeeded(state, ctx, log);
|
|
1710
|
+
|
|
1711
|
+
// Register for live monitor — graph nodes now appear in the
|
|
1712
|
+
// drill-in under the runId with their per-unit priority
|
|
1713
|
+
// (issue #133 single identity, mirrors parallel mode's issue
|
|
1714
|
+
// #119 registration). A FRESH runId per node: subagentSessions
|
|
1715
|
+
// is host-global, so raw graph-task ids would collide across
|
|
1716
|
+
// concurrent graph runs (the intercom namespace is per-run and
|
|
1717
|
+
// stays untouched).
|
|
1718
|
+
registerLiveRun(state, run, ctx);
|
|
1719
|
+
|
|
1720
|
+
let nodeSuccess = false;
|
|
1721
|
+
try {
|
|
1722
|
+
// Wrap onUpdate for per-node progress — also feed the live
|
|
1723
|
+
// monitor so the drill-in streams output instead of showing
|
|
1724
|
+
// "waiting for first output…" (mirrors parallel mode's
|
|
1725
|
+
// taskOnUpdate).
|
|
1726
|
+
const stepOnUpdate = makeLiveOnUpdate(state, runId, onUpdate);
|
|
1727
|
+
|
|
1728
|
+
const result = await runSubagent(
|
|
1729
|
+
resolvedCwd,
|
|
1730
|
+
subagentPrompt,
|
|
1731
|
+
stepModel,
|
|
1732
|
+
merged.thinkingLevel,
|
|
1733
|
+
merged.task,
|
|
1734
|
+
signal,
|
|
1735
|
+
stepOnUpdate,
|
|
1736
|
+
merged.toolOptions,
|
|
1737
|
+
merged.timeout,
|
|
1738
|
+
getFinalOutput,
|
|
1739
|
+
log,
|
|
1740
|
+
currentDepth + 1,
|
|
1741
|
+
intercom,
|
|
1742
|
+
subagentId,
|
|
1743
|
+
);
|
|
1744
|
+
|
|
1745
|
+
const subTaskResult: SubTaskResult = {
|
|
1746
|
+
task: merged.task,
|
|
1747
|
+
label: merged.label,
|
|
1748
|
+
exitCode: result.exitCode,
|
|
1749
|
+
messages: result.messages,
|
|
1750
|
+
stderr: result.stderr,
|
|
1751
|
+
usage: result.usage,
|
|
1752
|
+
model: result.model,
|
|
1753
|
+
stopReason: result.stopReason,
|
|
1754
|
+
errorMessage: result.errorMessage,
|
|
1755
|
+
errorCategory: classifyError(result),
|
|
1756
|
+
gitBranch: result.gitBranch,
|
|
1757
|
+
gitDiff: result.gitDiff,
|
|
1758
|
+
};
|
|
1759
|
+
|
|
1760
|
+
// Issue #133: finalize the per-node run entry — status, duration,
|
|
1761
|
+
// cost, tokens and sanitized output land on the entry (mirrors
|
|
1762
|
+
// parallel's issue #119 finalize; result carries the
|
|
1763
|
+
// SubagentResult shape finalizeRunRecord expects, errorCategory
|
|
1764
|
+
// included).
|
|
1765
|
+
finalizeUnitRun(state, pi, ctx, run, result, log);
|
|
1766
|
+
// Issue #137: the node's real success — the status bar's
|
|
1767
|
+
// "N failed" count reflects the true outcome (same check as
|
|
1768
|
+
// the aggregate chainSuccess).
|
|
1769
|
+
nodeSuccess = !isSubagentError(result);
|
|
1770
|
+
|
|
1771
|
+
return { id: graphTask.id, result: subTaskResult };
|
|
1772
|
+
} catch (err) {
|
|
1773
|
+
// Issue #133 (mirrors the #119 R1/C1 discipline): crash-protected
|
|
1774
|
+
// — a post-spawn throw must finalize the run entry as failed AND
|
|
1775
|
+
// release the live entry, or the persisted record stays
|
|
1776
|
+
// 'running' forever and the sweep treats the ghost as live.
|
|
1777
|
+
// finalizeLiveSubagent is idempotent; the rethrow keeps
|
|
1778
|
+
// Promise.allSettled semantics (a failing node must NOT cancel
|
|
1779
|
+
// sibling nodes in the wave).
|
|
1780
|
+
finalizeUnitRunCrash(state, pi, run, err, "Graph node", resolvedCwd, log, {
|
|
1781
|
+
id: graphTask.id,
|
|
1782
|
+
});
|
|
1783
|
+
} finally {
|
|
1784
|
+
releaseSlot(state, nodeSuccess, ctx);
|
|
1785
|
+
}
|
|
1786
|
+
});
|
|
1787
|
+
|
|
1788
|
+
const waveResults = await Promise.allSettled(wavePromises);
|
|
1789
|
+
const graphWaveTasks: SubTaskResult[] = [];
|
|
1790
|
+
|
|
1791
|
+
for (const settled of waveResults) {
|
|
1792
|
+
if (settled.status === "fulfilled") {
|
|
1793
|
+
resultMap.set(settled.value.id, settled.value.result);
|
|
1794
|
+
graphWaveTasks.push(settled.value.result);
|
|
1795
|
+
} else {
|
|
1796
|
+
log.error("Graph wave task rejected", { error: String(settled.reason) });
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
const graphWave: GraphWave = {
|
|
1801
|
+
waveIndex,
|
|
1802
|
+
tasks: graphWaveTasks,
|
|
1803
|
+
parallel: isParallel,
|
|
1804
|
+
};
|
|
1805
|
+
allWaves.push(graphWave);
|
|
1806
|
+
|
|
1807
|
+
// Emit progress update
|
|
1808
|
+
onUpdate?.({
|
|
1809
|
+
details: undefined,
|
|
1810
|
+
content: [
|
|
1811
|
+
{
|
|
1812
|
+
type: "text" as const,
|
|
1813
|
+
text: `Graph wave ${waveIndex}/${waves.length} completed`,
|
|
1814
|
+
},
|
|
1815
|
+
],
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
// Compute final aggregates
|
|
1820
|
+
const allResults = allWaves.flatMap((w) => w.tasks);
|
|
1821
|
+
const totalInput = allResults.reduce((s, r) => s + r.usage.input, 0);
|
|
1822
|
+
const totalOutput = allResults.reduce((s, r) => s + r.usage.output, 0);
|
|
1823
|
+
const totalCost = allResults.reduce((s, r) => s + r.usage.cost, 0);
|
|
1824
|
+
const totalTurns = allResults.reduce((s, r) => s + r.usage.turns, 0);
|
|
1825
|
+
|
|
1826
|
+
chainSuccess = allResults.every(
|
|
1827
|
+
(r) => r.exitCode === 0 && r.stopReason !== "error" && r.stopReason !== "aborted",
|
|
1828
|
+
);
|
|
1829
|
+
|
|
1830
|
+
const graphDetails: GraphDetails = {
|
|
1831
|
+
mode: "graph",
|
|
1832
|
+
waves: allWaves,
|
|
1833
|
+
totalInput,
|
|
1834
|
+
totalOutput,
|
|
1835
|
+
totalCost,
|
|
1836
|
+
totalTurns,
|
|
1837
|
+
};
|
|
1838
|
+
|
|
1839
|
+
// R1: Record circuit breaker outcome
|
|
1840
|
+
if (chainSuccess) {
|
|
1841
|
+
state.recordSuccess();
|
|
1842
|
+
} else {
|
|
1843
|
+
state.recordFailure();
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
log.info("Graph completed", {
|
|
1847
|
+
totalTasks: graphTasks.length,
|
|
1848
|
+
waves: allWaves.length,
|
|
1849
|
+
totalInput,
|
|
1850
|
+
totalOutput,
|
|
1851
|
+
totalCost,
|
|
1852
|
+
});
|
|
1853
|
+
|
|
1854
|
+
// Issue #133: one aggregate run entry per graph dispatch — fresh id,
|
|
1855
|
+
// persisted ONCE as done (no live entry: the drill-in shows the
|
|
1856
|
+
// per-node entries while running; the aggregate is a post-hoc audit
|
|
1857
|
+
// summary). Fields come from the dispatch-level resolved values and
|
|
1858
|
+
// the aggregate totals; startedAt is the mode-entry timestamp.
|
|
1859
|
+
const graphAggregateRun: SubagentRun = {
|
|
1860
|
+
id: crypto.randomUUID(),
|
|
1861
|
+
task: `Graph dispatch: ${graphTasks.length} tasks in ${allWaves.length} waves`,
|
|
1862
|
+
label: globalParams.label ?? "graph",
|
|
1863
|
+
// Issue #133: persist the ACTUAL outcome — "failed" when any node
|
|
1864
|
+
// failed (chainSuccess === false), "done" otherwise.
|
|
1865
|
+
status: chainSuccess ? "done" : "failed",
|
|
1866
|
+
model: `${subagentModel.provider}/${subagentModel.id}`,
|
|
1867
|
+
thinkingLevel: globalParams.thinkingLevel,
|
|
1868
|
+
priority: graphPriority,
|
|
1869
|
+
startedAt: graphModeStartedAt,
|
|
1870
|
+
finishedAt: new Date().toISOString(),
|
|
1871
|
+
durationMs: Date.now() - new Date(graphModeStartedAt).getTime(),
|
|
1872
|
+
cost: totalCost,
|
|
1873
|
+
tokensIn: totalInput,
|
|
1874
|
+
tokensOut: totalOutput,
|
|
1875
|
+
outputSummary: capOutput(JSON.stringify(graphDetails, null, 2)),
|
|
1876
|
+
};
|
|
1877
|
+
state.persistRun(pi, graphAggregateRun);
|
|
1878
|
+
|
|
1879
|
+
return {
|
|
1880
|
+
content: [
|
|
1881
|
+
{ type: "text" as const, text: JSON.stringify(graphDetails, null, 2) },
|
|
1882
|
+
],
|
|
1883
|
+
details: graphDetails,
|
|
1884
|
+
};
|
|
1885
|
+
} catch (err) {
|
|
1886
|
+
// F7 (issue #65): err.message may embed absolute paths — sanitize
|
|
1887
|
+
// BEFORE it reaches the main agent's context (tool result content +
|
|
1888
|
+
// details.errorMessage). resolvedCwd is the validated subagent cwd.
|
|
1889
|
+
const result = buildCrashResult("Graph mode", err, resolvedCwd);
|
|
1890
|
+
log.error("Graph mode crashed", { error: result.details.errorMessage });
|
|
1891
|
+
return result;
|
|
1892
|
+
} finally {
|
|
1893
|
+
// Issue #133: clear the status-bar mode breakdown — the dispatch is
|
|
1894
|
+
// over (success or outer-catch crash).
|
|
1895
|
+
state.modeContext = undefined;
|
|
1896
|
+
releaseSlot(state, chainSuccess, ctx);
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
// -------------------------------------------------------------------
|
|
1901
|
+
// /brl-subagent command
|
|
1902
|
+
// -------------------------------------------------------------------
|
|
1903
|
+
|
|
1904
|
+
pi.registerCommand("brl-subagent", {
|
|
1905
|
+
description: "Configure subagent model and thinking level",
|
|
1906
|
+
getArgumentCompletions: (prefix: string) => {
|
|
1907
|
+
const options = [
|
|
1908
|
+
"history", "historyentries", "monitor", "dashboard", "preset", "templates", "retry", "completionnotify",
|
|
1909
|
+
];
|
|
1910
|
+
const filtered = options.filter((o) => o.startsWith(prefix));
|
|
1911
|
+
return filtered.length > 0
|
|
1912
|
+
? filtered.map((o) => ({ value: o, label: o }))
|
|
1913
|
+
: null;
|
|
1914
|
+
},
|
|
1915
|
+
handler: async (args, ctx) => {
|
|
1916
|
+
const trimmed = args?.trim();
|
|
1917
|
+
|
|
1918
|
+
const handlers: Record<string, () => Promise<void> | void> = {
|
|
1919
|
+
model: () => showModelSelector(ctx, state, applyConfig),
|
|
1920
|
+
thinking: () => showThinkingSelector(ctx, state, applyConfig),
|
|
1921
|
+
concurrency: () => showConcurrencyInput(ctx, state, applyConfig),
|
|
1922
|
+
depth: () => showDepthInput(ctx, state, applyConfig),
|
|
1923
|
+
approval: () => showApprovalModeSelector(ctx, state, applyConfig),
|
|
1924
|
+
completionnotify: () => showCompletionNotifySelector(ctx, state, applyConfig),
|
|
1925
|
+
costlimit: () => showCostLimitInput(ctx, state, applyConfig),
|
|
1926
|
+
reset: () => resetState(ctx),
|
|
1927
|
+
history: () => showRunHistory(ctx, state, () => state.persistState(pi)),
|
|
1928
|
+
historyentries: () => showHistoryEntriesInput(ctx, state, applyConfig),
|
|
1929
|
+
monitor: () => showMonitor(ctx, state),
|
|
1930
|
+
dashboard: () => showDashboard(ctx, state),
|
|
1931
|
+
preset: () => showPresetManager(ctx, state),
|
|
1932
|
+
templates: () => showTemplateManager(ctx, state),
|
|
1933
|
+
retry: () => showRetryMenu(ctx, state),
|
|
1934
|
+
"update-check": () => showUpdateCheckToggle(ctx, state, applyConfig),
|
|
1935
|
+
sla: () => showSLAConfig(ctx, state, applyConfig),
|
|
1936
|
+
"sla-stats": () => showSLAStats(ctx, state),
|
|
1937
|
+
};
|
|
1938
|
+
|
|
1939
|
+
if (trimmed && trimmed in handlers) {
|
|
1940
|
+
await handlers[trimmed]();
|
|
1941
|
+
} else if (trimmed?.startsWith("templates")) {
|
|
1942
|
+
await handlers.templates();
|
|
1943
|
+
} else if (trimmed?.startsWith("preset")) {
|
|
1944
|
+
await handlers.preset();
|
|
1945
|
+
} else {
|
|
1946
|
+
await showConfigMenu(ctx, state, handlers);
|
|
1947
|
+
}
|
|
1948
|
+
},
|
|
1949
|
+
});
|
|
1950
|
+
|
|
1951
|
+
// -------------------------------------------------------------------
|
|
1952
|
+
// delegate_task tool
|
|
1953
|
+
// -------------------------------------------------------------------
|
|
1954
|
+
|
|
1955
|
+
// B1: Load built-in presets once at registration to expose their tool
|
|
1956
|
+
// restrictions to the conductor before any delegate_task call happens.
|
|
1957
|
+
const registrationPresets = loadBuiltinPresets(path.join(__dirname, "..", "presets"));
|
|
1958
|
+
const presetRestrictionSummary = buildInventorySummary(registrationPresets, (p) => {
|
|
1959
|
+
const restricted = p.excludeTools?.length || p.tools?.length;
|
|
1960
|
+
if (!restricted) return p.name;
|
|
1961
|
+
return `${p.name} (${formatPresetRestriction(p)})`;
|
|
1962
|
+
});
|
|
1963
|
+
|
|
1964
|
+
// B1b: Load built-in templates once at registration to expose the built-in
|
|
1965
|
+
// inventory (name + purpose + ${param} slots) to the conductor before any
|
|
1966
|
+
// delegate_task call. Custom/user templates can't be enumerated statically,
|
|
1967
|
+
// so the summary covers built-ins only and points at /brl-subagent templates.
|
|
1968
|
+
const registrationTemplates = loadBuiltinTemplates(path.join(__dirname, "..", "templates"));
|
|
1969
|
+
const templateSummary = buildInventorySummary(registrationTemplates, formatTemplateSummaryItem);
|
|
1970
|
+
|
|
1971
|
+
pi.registerTool({
|
|
1972
|
+
name: "delegate_task",
|
|
1973
|
+
label: "Delegate Task",
|
|
1974
|
+
description: [
|
|
1975
|
+
"Delegate a task to the brl-subagent with isolated context.",
|
|
1976
|
+
"The subagent inherits your current system prompt and runs with",
|
|
1977
|
+
"its configured model and thinking level (set via /brl-subagent).",
|
|
1978
|
+
"If no subagent model is configured, your current model is used.",
|
|
1979
|
+
"The subagent reports what it did when complete.",
|
|
1980
|
+
"Optionally provide a custom systemPrompt and control inheritance via inheritSystemPrompt.",
|
|
1981
|
+
].join(" "),
|
|
1982
|
+
promptSnippet:
|
|
1983
|
+
"Delegate tasks to a subagent for isolated, parallel or background work",
|
|
1984
|
+
promptGuidelines: [
|
|
1985
|
+
"Use delegate_task when the user asks you to hand off work to a subagent, or when a task would benefit from an isolated context window (e.g., deep investigation, parallel research, long-running analysis).",
|
|
1986
|
+
"The authoritative capability reference for delegation is the extension's AGENT.md — read it at ~/.pi/agent/extensions/brl-subagent/AGENT.md, or ./AGENT.md in the project root when present, when planning delegation-heavy work.",
|
|
1987
|
+
"The subagent inherits your system prompt and runs with its own model (configurable via /brl-subagent). It reports what it did when done.",
|
|
1988
|
+
"You can customize per-call via inheritSystemPrompt and systemPrompt: set inheritSystemPrompt: false to save context, provide a systemPrompt for custom instructions, or use both to add instructions on top of inheritance.",
|
|
1989
|
+
"Set thinkingLevel per call to match task complexity. The level is capped at the user's configured maximum. Map tasks to levels using this heuristic: off = file listing, grep, simple read. minimal = file diff, syntax check, find-and-replace. low = refactoring, test generation, documentation. medium = default — code review, debugging, moderate analysis. high = security audit, architecture review, complex debugging. xhigh = multi-step causal reasoning, research, novel problem solving. Default to 'off' or 'minimal' for trivial tasks — do not waste the user's budget.",
|
|
1990
|
+
"Use outputFile to have the subagent write full findings to disk and return only a structured summary — saves context tokens for large investigations.",
|
|
1991
|
+
"Set timeout (in ms) to limit how long a subagent can run. Useful for tasks that might hang or get stuck.",
|
|
1992
|
+
"Set cwd to override the subagent's working directory. Defaults to the current project directory.",
|
|
1993
|
+
"Set label to give the subagent a human-readable name (e.g., 'security-audit' or 'docs-review'). Labels appear in the status bar and tool call display.",
|
|
1994
|
+
`Use preset to apply a delegation configuration (built-in or custom via /brl-subagent preset). Preset values are defaults — explicit parameters override them. IMPORTANT: some presets restrict tools — e.g. outputFile requires the subagent's write tool, which security-auditor and code-reviewer exclude. Built-in presets: ${presetRestrictionSummary}. Custom presets are NOT listed here — inspect them via /brl-subagent preset before combining with outputFile or tool-dependent work. When combining a preset with outputFile or tool-dependent work, verify the preset allows the required tools.`,
|
|
1995
|
+
buildTemplateGuideline(templateSummary),
|
|
1996
|
+
"To retry a failed subagent, pass its run ID as retryRunId. The retried run uses the same task and parameters as the original. Parallel-origin entries retry as a single-subtask run carrying that subtask's task, label, and priority. Explicit parameters on this call override the original's. Use /brl-subagent retry to browse failed runs and get their IDs.",
|
|
1997
|
+
"Set retryOnTimeout: true to automatically retry a subagent that times out. Only retries once — the second timeout is treated as a final failure.",
|
|
1998
|
+
"Set background: true to run the subagent in the background without blocking. The tool returns immediately with an agent ID. Background runs wake the conductor with a structured completion message when they finish — do not poll: polling is only correct when completion notifications are disabled (completionNotify \"off\"); one status check as a stall check is legitimate.",
|
|
1999
|
+
"",
|
|
2000
|
+
"## Conductor Guardrails",
|
|
2001
|
+
"",
|
|
2002
|
+
"Before delegating, verify the subagent configuration matches the task:",
|
|
2003
|
+
"",
|
|
2004
|
+
"2. **Thinking level**: Match thinking level to task complexity: off/minimal for trivial tasks (file listing, grep), low for refactoring/docs, medium for code review/debugging, high for security audits/complex debugging, xhigh for multi-step reasoning/novel problems.",
|
|
2005
|
+
"3. **Git mode**: Use gitMode='branch' for tasks that create commits or PRs. Use gitMode='none' for read-only tasks.",
|
|
2006
|
+
"4. **Tools**: Verify the subagent has the tools it needs. If the task writes files, ensure write and edit are not excluded. If the task runs commands, ensure bash is not excluded.",
|
|
2007
|
+
"5. **Timeout**: Set timeout based on task complexity. Simple: 30s. Medium: 60s. Complex: 120s+. xhigh thinking: at least 120s.",
|
|
2008
|
+
"",
|
|
2009
|
+
"These guardrails prevent common misconfigurations. The extension also validates configuration before spawning (H1): tool warnings are informational, but outputFile with the write tool excluded is a HARD error and the delegation is rejected. Getting it right the first time is faster and more efficient.",
|
|
2010
|
+
"",
|
|
2011
|
+
"Before delegating, evaluate existing presets to find the best match for the task: tech-writer (documentation), code-reviewer (code review), security-auditor (security analysis), test-engineer (test writing), debugger (debugging), refactorer (refactoring), data-analyst (data analysis), rapid-prototyper (quick prototypes). Use the preset parameter to apply the best match. If no preset fits, use dev-agent for general development tasks.",
|
|
2012
|
+
"The autoRoutePreset() function can automatically select the best preset based on task keywords. Consider using it for preset selection.",
|
|
2013
|
+
"Prefer a preset when the delegation matches a standard shape — review/audit/test/docs; override per-call deliberately.",
|
|
2014
|
+
],
|
|
2015
|
+
parameters: Type.Object({
|
|
2016
|
+
task: Type.Optional(Type.String({
|
|
2017
|
+
description: "Detailed description of the task for the subagent to complete (required for single mode, optional for chain/tasks/graph)",
|
|
2018
|
+
})),
|
|
2019
|
+
systemPrompt: Type.Optional(
|
|
2020
|
+
Type.String({
|
|
2021
|
+
description:
|
|
2022
|
+
"Custom system prompt or additional instructions for the subagent. " +
|
|
2023
|
+
"When inheritSystemPrompt is true (default), this is appended after the inherited prompt. " +
|
|
2024
|
+
"When inheritSystemPrompt is false, this replaces the inherited prompt entirely.",
|
|
2025
|
+
}),
|
|
2026
|
+
),
|
|
2027
|
+
inheritSystemPrompt: Type.Optional(
|
|
2028
|
+
Type.Boolean({
|
|
2029
|
+
description:
|
|
2030
|
+
"Whether to inherit the main agent's system prompt. " +
|
|
2031
|
+
"Default: true. Set to false to use only your custom systemPrompt, " +
|
|
2032
|
+
"or to avoid passing a large inherited prompt to the subagent.",
|
|
2033
|
+
}),
|
|
2034
|
+
),
|
|
2035
|
+
thinkingLevel: Type.Optional(
|
|
2036
|
+
Type.String({
|
|
2037
|
+
description:
|
|
2038
|
+
"Requested thinking level for this subagent call. " +
|
|
2039
|
+
"One of: off, minimal, low, medium, high, xhigh. " +
|
|
2040
|
+
"Capped at the user's configured maximum. If omitted, the user's configured level is used.",
|
|
2041
|
+
}),
|
|
2042
|
+
),
|
|
2043
|
+
outputFile: Type.Optional(
|
|
2044
|
+
Type.String({
|
|
2045
|
+
description:
|
|
2046
|
+
"Project-relative path where the subagent should write its full findings. " +
|
|
2047
|
+
"When provided, the subagent is instructed to write complete output to this file " +
|
|
2048
|
+
"and return only a structured summary.",
|
|
2049
|
+
}),
|
|
2050
|
+
),
|
|
2051
|
+
label: Type.Optional(
|
|
2052
|
+
Type.String({
|
|
2053
|
+
description:
|
|
2054
|
+
"Human-readable label for this subagent (e.g., 'security-audit', 'docs-review'). " +
|
|
2055
|
+
"Appears in the status bar and tool call display. " +
|
|
2056
|
+
"Omit to use the default anonymous counter.",
|
|
2057
|
+
}),
|
|
2058
|
+
),
|
|
2059
|
+
model: Type.Optional(Type.String({ description: "Model override (provider/model-id). Defaults to the global subagent model." })),
|
|
2060
|
+
timeout: Type.Optional(
|
|
2061
|
+
Type.Number({
|
|
2062
|
+
description:
|
|
2063
|
+
"Maximum time in milliseconds the subagent is allowed to run. " +
|
|
2064
|
+
"If exceeded, the subagent is killed and an error is returned.",
|
|
2065
|
+
}),
|
|
2066
|
+
),
|
|
2067
|
+
cwd: Type.Optional(
|
|
2068
|
+
Type.String({
|
|
2069
|
+
description:
|
|
2070
|
+
"Working directory for the subagent. Must be an existing directory. " +
|
|
2071
|
+
"Defaults to the conductor's current working directory.",
|
|
2072
|
+
}),
|
|
2073
|
+
),
|
|
2074
|
+
tools: Type.Optional(
|
|
2075
|
+
Type.Array(Type.String(), {
|
|
2076
|
+
description:
|
|
2077
|
+
"Explicit allowlist of tool names for the subagent. Maps to pi's --tools flag.",
|
|
2078
|
+
}),
|
|
2079
|
+
),
|
|
2080
|
+
excludeTools: Type.Optional(
|
|
2081
|
+
Type.Array(Type.String(), {
|
|
2082
|
+
description:
|
|
2083
|
+
"Tool names to disable for the subagent. Maps to pi's --exclude-tools flag.",
|
|
2084
|
+
}),
|
|
2085
|
+
),
|
|
2086
|
+
noBuiltinTools: Type.Optional(
|
|
2087
|
+
Type.Boolean({
|
|
2088
|
+
description:
|
|
2089
|
+
"Disable all built-in tools for the subagent. Maps to pi's --no-builtin-tools flag.",
|
|
2090
|
+
}),
|
|
2091
|
+
),
|
|
2092
|
+
preset: Type.Optional(
|
|
2093
|
+
Type.String({
|
|
2094
|
+
description:
|
|
2095
|
+
"Name of a saved delegation preset (created via /brl-subagent preset). " +
|
|
2096
|
+
"Preset values are used as defaults; explicit parameters on this call override them.",
|
|
2097
|
+
}),
|
|
2098
|
+
),
|
|
2099
|
+
template: Type.Optional(
|
|
2100
|
+
Type.String({
|
|
2101
|
+
description:
|
|
2102
|
+
"Name of a saved task template. Use with params to fill template slots. " +
|
|
2103
|
+
"Templates are file-backed: 9 builtin templates ship with the extension " +
|
|
2104
|
+
"(browse via /brl-subagent templates); override or add via .md files in " +
|
|
2105
|
+
"~/.pi/agent/brl-subagent/templates/ or .pi/brl-subagent/templates/ " +
|
|
2106
|
+
"(project-local wins over user-global over builtin).",
|
|
2107
|
+
}),
|
|
2108
|
+
),
|
|
2109
|
+
params: Type.Optional(
|
|
2110
|
+
Type.Record(Type.String(), Type.String(), {
|
|
2111
|
+
description:
|
|
2112
|
+
"Parameter values for template ${param} slots. " +
|
|
2113
|
+
"Keys are param names, values are the substitution text.",
|
|
2114
|
+
}),
|
|
2115
|
+
),
|
|
2116
|
+
retryRunId: Type.Optional(
|
|
2117
|
+
Type.String({
|
|
2118
|
+
description:
|
|
2119
|
+
"ID of a previously failed subagent run to retry. " +
|
|
2120
|
+
"The retried run uses the same task and parameters as the original. " +
|
|
2121
|
+
"Only works with runs that ended in failure (exitCode != 0, timeout, error, or abort). " +
|
|
2122
|
+
"Explicit parameters on this call override the original's.",
|
|
2123
|
+
}),
|
|
2124
|
+
),
|
|
2125
|
+
gitMode: Type.Optional(
|
|
2126
|
+
Type.String({
|
|
2127
|
+
description:
|
|
2128
|
+
"Git integration mode for this subagent call. " +
|
|
2129
|
+
"'branch' creates a work branch, captures the diff, and switches back. " +
|
|
2130
|
+
"'none' (default) does nothing. Falls back to the configured default.",
|
|
2131
|
+
}),
|
|
2132
|
+
),
|
|
2133
|
+
retryOnTimeout: Type.Optional(
|
|
2134
|
+
Type.Boolean({
|
|
2135
|
+
description:
|
|
2136
|
+
"If true and the subagent times out, automatically retry with the same parameters. " +
|
|
2137
|
+
"Only retries once — the second timeout is treated as a final failure.",
|
|
2138
|
+
}),
|
|
2139
|
+
),
|
|
2140
|
+
approvalMode: Type.Optional(
|
|
2141
|
+
Type.String({
|
|
2142
|
+
description:
|
|
2143
|
+
"Change approval mode: auto (never ask), writes (ask when files changed), " +
|
|
2144
|
+
"always (ask every time). Default is user config (/brl-subagent approval).",
|
|
2145
|
+
}),
|
|
2146
|
+
),
|
|
2147
|
+
background: Type.Optional(
|
|
2148
|
+
Type.Boolean({
|
|
2149
|
+
description:
|
|
2150
|
+
"Run the subagent in the background without blocking the conductor. " +
|
|
2151
|
+
"When true, the tool returns immediately with an agent ID. " +
|
|
2152
|
+
"The conductor is woken with a completion message; use get_subagent_result for post-wake retrieval and stall checks. " +
|
|
2153
|
+
"Default: false (blocking mode).",
|
|
2154
|
+
}),
|
|
2155
|
+
),
|
|
2156
|
+
priority: Type.Optional(
|
|
2157
|
+
Type.Union([
|
|
2158
|
+
Type.Literal("critical"),
|
|
2159
|
+
Type.Literal("high"),
|
|
2160
|
+
Type.Literal("normal"),
|
|
2161
|
+
Type.Literal("low"),
|
|
2162
|
+
], {
|
|
2163
|
+
description: "Concurrency priority for this delegation: critical, high, normal, or low. Higher-priority delegations queue ahead. Defaults to normal.",
|
|
2164
|
+
})
|
|
2165
|
+
),
|
|
2166
|
+
// Issue #114: NO per-step `priority` on chain[] — chain steps never
|
|
2167
|
+
// compete for concurrency slots (the chain holds ONE slot for its whole
|
|
2168
|
+
// duration); array order IS the priority.
|
|
2169
|
+
chain: Type.Optional(Type.Array(Type.Object({
|
|
2170
|
+
task: Type.String({ description: "Task description. Use {previous} to reference the previous step output." }),
|
|
2171
|
+
label: Type.Optional(Type.String({})),
|
|
2172
|
+
model: Type.Optional(Type.String({ description: "Model override for this step (provider/model-id). Defaults to the global subagent model." })),
|
|
2173
|
+
thinkingLevel: Type.Optional(Type.String({})),
|
|
2174
|
+
cwd: Type.Optional(Type.String({})),
|
|
2175
|
+
timeout: Type.Optional(Type.Number({})),
|
|
2176
|
+
outputFile: Type.Optional(Type.String({})),
|
|
2177
|
+
tools: Type.Optional(Type.Array(Type.String({}))),
|
|
2178
|
+
excludeTools: Type.Optional(Type.Array(Type.String({}))),
|
|
2179
|
+
noBuiltinTools: Type.Optional(Type.Boolean({})),
|
|
2180
|
+
systemPrompt: Type.Optional(Type.String({})),
|
|
2181
|
+
inheritSystemPrompt: Type.Optional(Type.Boolean({})),
|
|
2182
|
+
}), {
|
|
2183
|
+
description: "Sequential chain of tasks. Each step receives the previous step output via {previous} placeholder in the task string. Chain stops at the first failure. Max " + MAX_CHAIN_STEPS + " steps."
|
|
2184
|
+
})),
|
|
2185
|
+
tasks: Type.Optional(Type.Array(Type.Object({
|
|
2186
|
+
task: Type.String({ description: "Task description for this parallel subtask" }),
|
|
2187
|
+
label: Type.Optional(Type.String({})),
|
|
2188
|
+
model: Type.Optional(Type.String({ description: "Model override for this step (provider/model-id). Defaults to the global subagent model." })),
|
|
2189
|
+
thinkingLevel: Type.Optional(Type.String({})),
|
|
2190
|
+
priority: Type.Optional(
|
|
2191
|
+
Type.Union([
|
|
2192
|
+
Type.Literal("critical"),
|
|
2193
|
+
Type.Literal("high"),
|
|
2194
|
+
Type.Literal("normal"),
|
|
2195
|
+
Type.Literal("low"),
|
|
2196
|
+
], { description: "Concurrency priority for this subtask (overrides the call-level default)." })
|
|
2197
|
+
),
|
|
2198
|
+
cwd: Type.Optional(Type.String({})),
|
|
2199
|
+
timeout: Type.Optional(Type.Number({})),
|
|
2200
|
+
outputFile: Type.Optional(Type.String({})),
|
|
2201
|
+
tools: Type.Optional(Type.Array(Type.String({}))),
|
|
2202
|
+
excludeTools: Type.Optional(Type.Array(Type.String({}))),
|
|
2203
|
+
noBuiltinTools: Type.Optional(Type.Boolean({})),
|
|
2204
|
+
systemPrompt: Type.Optional(Type.String({})),
|
|
2205
|
+
inheritSystemPrompt: Type.Optional(Type.Boolean({})),
|
|
2206
|
+
}), {
|
|
2207
|
+
description: "Parallel tasks to execute concurrently. All tasks run regardless of individual failures. Max " + MAX_PARALLEL_TASKS + " tasks."
|
|
2208
|
+
})),
|
|
2209
|
+
graph: Type.Optional(Type.Array(Type.Object({
|
|
2210
|
+
id: Type.String({ description: "Unique identifier for this task node" }),
|
|
2211
|
+
task: Type.String({ description: "Task description. Use {otherId} to reference output from another task." }),
|
|
2212
|
+
label: Type.Optional(Type.String({})),
|
|
2213
|
+
model: Type.Optional(Type.String({ description: "Model override for this step (provider/model-id). Defaults to the global subagent model." })),
|
|
2214
|
+
dependsOn: Type.Optional(Type.Array(Type.String({}), { description: "IDs of tasks that must complete before this one starts" })),
|
|
2215
|
+
thinkingLevel: Type.Optional(Type.String({})),
|
|
2216
|
+
priority: Type.Optional(
|
|
2217
|
+
Type.Union([
|
|
2218
|
+
Type.Literal("critical"),
|
|
2219
|
+
Type.Literal("high"),
|
|
2220
|
+
Type.Literal("normal"),
|
|
2221
|
+
Type.Literal("low"),
|
|
2222
|
+
], { description: "Concurrency priority for this subtask (overrides the call-level default)." })
|
|
2223
|
+
),
|
|
2224
|
+
cwd: Type.Optional(Type.String({})),
|
|
2225
|
+
timeout: Type.Optional(Type.Number({})),
|
|
2226
|
+
outputFile: Type.Optional(Type.String({})),
|
|
2227
|
+
tools: Type.Optional(Type.Array(Type.String({}))),
|
|
2228
|
+
excludeTools: Type.Optional(Type.Array(Type.String({}))),
|
|
2229
|
+
noBuiltinTools: Type.Optional(Type.Boolean({})),
|
|
2230
|
+
systemPrompt: Type.Optional(Type.String({})),
|
|
2231
|
+
inheritSystemPrompt: Type.Optional(Type.Boolean({})),
|
|
2232
|
+
}), {
|
|
2233
|
+
description: "Declare tasks with dependencies. The scheduler parallelizes independent tasks and sequences dependent ones. Max " + MAX_GRAPH_TASKS + " tasks."
|
|
2234
|
+
})),
|
|
2235
|
+
}),
|
|
2236
|
+
|
|
2237
|
+
async execute(
|
|
2238
|
+
_toolCallId: string,
|
|
2239
|
+
params: {
|
|
2240
|
+
// Schema registers task as Type.Optional — required for single mode only,
|
|
2241
|
+
// omitted by chain/tasks/graph calls. Aligned with Static<TParams>.
|
|
2242
|
+
task?: string;
|
|
2243
|
+
label?: string;
|
|
2244
|
+
model?: string;
|
|
2245
|
+
preset?: string;
|
|
2246
|
+
systemPrompt?: string;
|
|
2247
|
+
inheritSystemPrompt?: boolean;
|
|
2248
|
+
thinkingLevel?: string;
|
|
2249
|
+
outputFile?: string;
|
|
2250
|
+
timeout?: number;
|
|
2251
|
+
cwd?: string;
|
|
2252
|
+
tools?: string[];
|
|
2253
|
+
excludeTools?: string[];
|
|
2254
|
+
noBuiltinTools?: boolean;
|
|
2255
|
+
template?: string;
|
|
2256
|
+
params?: Record<string, string>;
|
|
2257
|
+
retryRunId?: string;
|
|
2258
|
+
retryOnTimeout?: boolean;
|
|
2259
|
+
background?: boolean;
|
|
2260
|
+
gitMode?: string;
|
|
2261
|
+
priority?: string;
|
|
2262
|
+
chain?: Array<{
|
|
2263
|
+
task: string;
|
|
2264
|
+
label?: string;
|
|
2265
|
+
model?: string;
|
|
2266
|
+
thinkingLevel?: string;
|
|
2267
|
+
cwd?: string;
|
|
2268
|
+
timeout?: number;
|
|
2269
|
+
outputFile?: string;
|
|
2270
|
+
tools?: string[];
|
|
2271
|
+
excludeTools?: string[];
|
|
2272
|
+
noBuiltinTools?: boolean;
|
|
2273
|
+
systemPrompt?: string;
|
|
2274
|
+
inheritSystemPrompt?: boolean;
|
|
2275
|
+
}>;
|
|
2276
|
+
tasks?: Array<{
|
|
2277
|
+
task: string;
|
|
2278
|
+
label?: string;
|
|
2279
|
+
model?: string;
|
|
2280
|
+
thinkingLevel?: string;
|
|
2281
|
+
priority?: string;
|
|
2282
|
+
cwd?: string;
|
|
2283
|
+
timeout?: number;
|
|
2284
|
+
outputFile?: string;
|
|
2285
|
+
tools?: string[];
|
|
2286
|
+
excludeTools?: string[];
|
|
2287
|
+
noBuiltinTools?: boolean;
|
|
2288
|
+
systemPrompt?: string;
|
|
2289
|
+
inheritSystemPrompt?: boolean;
|
|
2290
|
+
}>;
|
|
2291
|
+
graph?: Array<{
|
|
2292
|
+
id: string;
|
|
2293
|
+
task: string;
|
|
2294
|
+
label?: string;
|
|
2295
|
+
model?: string;
|
|
2296
|
+
dependsOn?: string[];
|
|
2297
|
+
thinkingLevel?: string;
|
|
2298
|
+
priority?: string;
|
|
2299
|
+
cwd?: string;
|
|
2300
|
+
timeout?: number;
|
|
2301
|
+
outputFile?: string;
|
|
2302
|
+
tools?: string[];
|
|
2303
|
+
excludeTools?: string[];
|
|
2304
|
+
noBuiltinTools?: boolean;
|
|
2305
|
+
systemPrompt?: string;
|
|
2306
|
+
inheritSystemPrompt?: boolean;
|
|
2307
|
+
}>;
|
|
2308
|
+
},
|
|
2309
|
+
signal: AbortSignal | undefined,
|
|
2310
|
+
onUpdate: AgentToolUpdateCallback<DelegateTaskDetails> | undefined,
|
|
2311
|
+
ctx: ExtensionContext,
|
|
2312
|
+
) {
|
|
2313
|
+
// Issue #99: warn on unknown params — typebox allows additional properties
|
|
2314
|
+
// by default (pi's validateToolArguments passes unknown keys through), so a
|
|
2315
|
+
// typo like `thinkinglevel` or a schema-drifted param is otherwise silently
|
|
2316
|
+
// ignored. Warn-not-reject: never break a delegation.
|
|
2317
|
+
const unknownKeys = findUnknownParams(params, KNOWN_DELEGATE_KEYS);
|
|
2318
|
+
if (unknownKeys.length > 0) {
|
|
2319
|
+
log.warn("delegate_task: unknown param(s) ignored — check spelling or add to schema", { unknown: unknownKeys });
|
|
2320
|
+
// Issue #110: log.warn alone is invisible — pi's TUI swallows extension
|
|
2321
|
+
// console output. Route through pi.sendMessage (the subagent-notification
|
|
2322
|
+
// pattern, index.ts ~2312) so BOTH the LLM (followUp enters the
|
|
2323
|
+
// conversation) and the human (session transcript) see the correction.
|
|
2324
|
+
pi.sendMessage({
|
|
2325
|
+
customType: "delegate-notification",
|
|
2326
|
+
content: `delegate_task: unknown param(s) ignored — check spelling or add to schema: ${unknownKeys.join(", ")}`,
|
|
2327
|
+
display: true,
|
|
2328
|
+
details: { unknown: unknownKeys },
|
|
2329
|
+
}, { deliverAs: "followUp" });
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
// F1: Sanitize task input — skip for chain/parallel modes
|
|
2333
|
+
const hasChain = params.chain && params.chain.length > 0;
|
|
2334
|
+
const hasParallel = params.tasks && params.tasks.length > 0;
|
|
2335
|
+
const hasGraph = params.graph && params.graph.length > 0;
|
|
2336
|
+
if (!hasChain && !hasParallel && !hasGraph) {
|
|
2337
|
+
// task is optional per the schema; single mode requires it. When absent,
|
|
2338
|
+
// feed "" to the sanitizer — same rejection path ("Task must not be
|
|
2339
|
+
// empty.") as when task was a required field.
|
|
2340
|
+
const taskResult = sanitizeTask(params.task ?? "");
|
|
2341
|
+
if (!taskResult.ok) {
|
|
2342
|
+
log.warn("Task rejected by sanitizer", { error: taskResult.error });
|
|
2343
|
+
return {
|
|
2344
|
+
content: [{ type: "text" as const, text: `Invalid task: ${taskResult.error}` }],
|
|
2345
|
+
details: undefined,
|
|
2346
|
+
isError: true,
|
|
2347
|
+
};
|
|
2348
|
+
}
|
|
2349
|
+
params.task = taskResult.value;
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
// Handle retryRunId
|
|
2353
|
+
if (params.retryRunId) {
|
|
2354
|
+
const runEntry = state.findRunById(ctx, params.retryRunId);
|
|
2355
|
+
if (runEntry) {
|
|
2356
|
+
params = resolveRetryParams(params, runEntry);
|
|
2357
|
+
} else {
|
|
2358
|
+
// Issue #98: a silent no-op here made retries of background runs
|
|
2359
|
+
// (which previously never wrote run entries) start as FRESH runs
|
|
2360
|
+
// with no signal to the caller. Fail loudly instead.
|
|
2361
|
+
log.warn("Retry run ID not found", { retryRunId: params.retryRunId });
|
|
2362
|
+
return {
|
|
2363
|
+
content: [{
|
|
2364
|
+
type: "text" as const,
|
|
2365
|
+
text: `Retry run ID not found: ${params.retryRunId}. The run may have been pruned, or it was a background run created before the run-entry fix (issue #98). Pass the run's agent ID — for background runs the agent ID and run ID are now the same.`,
|
|
2366
|
+
}],
|
|
2367
|
+
details: undefined,
|
|
2368
|
+
isError: true,
|
|
2369
|
+
};
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
// Handle template resolution
|
|
2374
|
+
if (params.template) {
|
|
2375
|
+
const templateEntry = state.config.templates.find((t) => t.name === params.template);
|
|
2376
|
+
if (!templateEntry) {
|
|
2377
|
+
const available = state.config.templates.map((t) => t.name).join(", ") || "none";
|
|
2378
|
+
return {
|
|
2379
|
+
content: [
|
|
2380
|
+
{
|
|
2381
|
+
type: "text" as const,
|
|
2382
|
+
text: `Template '${params.template}' not found. Available: ${available}`,
|
|
2383
|
+
},
|
|
2384
|
+
],
|
|
2385
|
+
details: undefined,
|
|
2386
|
+
isError: true,
|
|
2387
|
+
};
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
const resolved = resolveTemplate(templateEntry, params.params ?? {});
|
|
2391
|
+
if (!resolved.ok) {
|
|
2392
|
+
return {
|
|
2393
|
+
content: [
|
|
2394
|
+
{
|
|
2395
|
+
type: "text" as const,
|
|
2396
|
+
text: `Template '${params.template}' resolution failed: ${resolved.error}`,
|
|
2397
|
+
},
|
|
2398
|
+
],
|
|
2399
|
+
details: undefined,
|
|
2400
|
+
isError: true,
|
|
2401
|
+
};
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
const tv = resolved.value;
|
|
2405
|
+
// Use resolved template fields as defaults, explicitly provided params override
|
|
2406
|
+
params.task = tv.task;
|
|
2407
|
+
if (tv.preset && !params.preset) params.preset = tv.preset;
|
|
2408
|
+
if (tv.thinkingLevel && !params.thinkingLevel) params.thinkingLevel = tv.thinkingLevel;
|
|
2409
|
+
if (tv.outputFile && !params.outputFile) params.outputFile = tv.outputFile;
|
|
2410
|
+
if (tv.timeout !== undefined && params.timeout === undefined) params.timeout = tv.timeout;
|
|
2411
|
+
if (tv.tools && !params.tools) params.tools = tv.tools;
|
|
2412
|
+
if (tv.excludeTools && !params.excludeTools) params.excludeTools = tv.excludeTools;
|
|
2413
|
+
if (tv.noBuiltinTools !== undefined && params.noBuiltinTools === undefined) params.noBuiltinTools = tv.noBuiltinTools;
|
|
2414
|
+
if (tv.inheritSystemPrompt !== undefined && params.inheritSystemPrompt === undefined) params.inheritSystemPrompt = tv.inheritSystemPrompt;
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
// P1+P2+P10: Mode detection — graph > chain > parallel > single
|
|
2418
|
+
const isChain = hasChain;
|
|
2419
|
+
const isParallel = hasParallel;
|
|
2420
|
+
const isGraph = hasGraph;
|
|
2421
|
+
const isSingle = typeof params.task === "string" && params.task.length > 0;
|
|
2422
|
+
|
|
2423
|
+
const modeCount = [isChain, isParallel, isGraph, isSingle].filter(Boolean).length;
|
|
2424
|
+
if (modeCount !== 1) {
|
|
2425
|
+
return {
|
|
2426
|
+
content: [
|
|
2427
|
+
{
|
|
2428
|
+
type: "text" as const,
|
|
2429
|
+
text:
|
|
2430
|
+
"Provide exactly one of: task (single), chain (sequential), tasks (parallel), or graph (dependency graph).",
|
|
2431
|
+
},
|
|
2432
|
+
],
|
|
2433
|
+
details: undefined,
|
|
2434
|
+
isError: true,
|
|
2435
|
+
};
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
if (isChain) {
|
|
2439
|
+
if (params.chain!.length > MAX_CHAIN_STEPS) {
|
|
2440
|
+
return {
|
|
2441
|
+
content: [
|
|
2442
|
+
{
|
|
2443
|
+
type: "text" as const,
|
|
2444
|
+
text: `Chain exceeds max ${MAX_CHAIN_STEPS} steps.`,
|
|
2445
|
+
},
|
|
2446
|
+
],
|
|
2447
|
+
details: undefined,
|
|
2448
|
+
isError: true,
|
|
2449
|
+
};
|
|
2450
|
+
}
|
|
2451
|
+
return runChainMode(params, signal, onUpdate, ctx);
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
if (isParallel) {
|
|
2455
|
+
if (params.tasks!.length > MAX_PARALLEL_TASKS) {
|
|
2456
|
+
return {
|
|
2457
|
+
content: [
|
|
2458
|
+
{
|
|
2459
|
+
type: "text" as const,
|
|
2460
|
+
text: `Parallel exceeds max ${MAX_PARALLEL_TASKS} tasks.`,
|
|
2461
|
+
},
|
|
2462
|
+
],
|
|
2463
|
+
details: undefined,
|
|
2464
|
+
isError: true,
|
|
2465
|
+
};
|
|
2466
|
+
}
|
|
2467
|
+
return runParallelMode(params, signal, onUpdate, ctx);
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2470
|
+
if (isGraph) {
|
|
2471
|
+
return runGraphMode(params, signal, onUpdate, ctx);
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
// Single mode: isSingle is true (modeCount===1, none of chain/parallel/graph),
|
|
2475
|
+
// so params.task is a non-empty string. TS can't infer this through the
|
|
2476
|
+
// boolean-array filter — assert, mirroring params.chain!/params.tasks! above.
|
|
2477
|
+
const singleTask = params.task!; // non-empty (sanitizer guarantees; isSingle confirms)
|
|
2478
|
+
|
|
2479
|
+
// Phase 6.5: Background execution — spawn session and return ID immediately.
|
|
2480
|
+
// Resolve the preset and its model BEFORE the background branch so the
|
|
2481
|
+
// preset's model (and system prompt) are honored in background mode.
|
|
2482
|
+
const bgResolved = resolveSubagentParams({ ...params, task: singleTask }, state, ctx, log);
|
|
2483
|
+
const { resolvedPreset: bgResolvedPreset, autoRoutedPreset: bgAutoRoutedPreset } = bgResolved;
|
|
2484
|
+
const bgModelResult = resolveSubagentModel(ctx, bgResolvedPreset, params.model);
|
|
2485
|
+
const bgModel = bgModelResult.ok
|
|
2486
|
+
? `${bgModelResult.model.provider}/${bgModelResult.model.id}`
|
|
2487
|
+
: undefined;
|
|
2488
|
+
|
|
2489
|
+
if (params.background) {
|
|
2490
|
+
const { spawnBackgroundSession, setAgentFinalOutput, extractFinalOutput, updateAgentStatus } = await import('./session-manager');
|
|
2491
|
+
// Issue #31 (PR #76 review, DRY): extract final output from a
|
|
2492
|
+
// possibly-released ref — the poller may observe a terminal agent
|
|
2493
|
+
// whose ref was already nulled by the settlement path.
|
|
2494
|
+
const extractAgentFinalOutput = (a: BackgroundAgent): string =>
|
|
2495
|
+
extractFinalOutput(a._sessionRef ?? { messages: [] });
|
|
2496
|
+
|
|
2497
|
+
try {
|
|
2498
|
+
// W5 (issue #28): approvalMode 'always' cannot work in background —
|
|
2499
|
+
// there is no interactive dialog to approve the diff. Reject loudly
|
|
2500
|
+
// instead of silently running unattended with write access.
|
|
2501
|
+
if (bgResolved.resolvedApprovalMode === 'always') {
|
|
2502
|
+
return {
|
|
2503
|
+
content: [{ type: "text" as const, text:
|
|
2504
|
+
`Cannot spawn background agent with approvalMode 'always': background agents run unattended ` +
|
|
2505
|
+
`and cannot present the approval dialog. Use approvalMode 'auto' (default) or 'writes'.`
|
|
2506
|
+
}],
|
|
2507
|
+
details: undefined,
|
|
2508
|
+
isError: true,
|
|
2509
|
+
};
|
|
2510
|
+
}
|
|
2511
|
+
// W5: 'writes' in background silently auto-approves — warn once so the
|
|
2512
|
+
// caller knows the diff will NOT be gated on approval.
|
|
2513
|
+
if (bgResolved.resolvedApprovalMode === 'writes') {
|
|
2514
|
+
log.warn("Background agent spawned with approvalMode 'writes' — auto-approving (no dialog in background)", {
|
|
2515
|
+
task: params.task,
|
|
2516
|
+
});
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
// W6 (issue #28): session cost limit must gate background spawns too —
|
|
2520
|
+
// the R5 check after the background branch never runs for them.
|
|
2521
|
+
// Same estimate/limit logic as single mode.
|
|
2522
|
+
const bgPerTaskEstimate = state.config.perTaskCostEstimate > 0
|
|
2523
|
+
? state.config.perTaskCostEstimate
|
|
2524
|
+
: 0.05;
|
|
2525
|
+
if (state.checkCostLimit(bgPerTaskEstimate, ctx)) {
|
|
2526
|
+
const bgLimit = state.config.sessionCostLimit;
|
|
2527
|
+
const bgCurrentTotal = state.getSessionTotalCost(ctx);
|
|
2528
|
+
log.warn("Background delegation rejected: session cost limit reached", {
|
|
2529
|
+
currentTotal: bgCurrentTotal,
|
|
2530
|
+
estimatedCost: bgPerTaskEstimate,
|
|
2531
|
+
limit: bgLimit,
|
|
2532
|
+
});
|
|
2533
|
+
return {
|
|
2534
|
+
content: [
|
|
2535
|
+
{
|
|
2536
|
+
type: "text" as const,
|
|
2537
|
+
text:
|
|
2538
|
+
`Cannot delegate: session cost limit reached ` +
|
|
2539
|
+
`($${bgCurrentTotal.toFixed(4)} spent of $${bgLimit.toFixed(2)} limit). ` +
|
|
2540
|
+
`Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
|
|
2541
|
+
},
|
|
2542
|
+
],
|
|
2543
|
+
details: undefined,
|
|
2544
|
+
isError: true,
|
|
2545
|
+
};
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
// F1: Validate cwd + outputFile the same way foreground single mode does —
|
|
2549
|
+
// an unvalidated outputFile would reach the prompt and could steer the
|
|
2550
|
+
// background agent's write tool outside the project root.
|
|
2551
|
+
const bgCwdResult = validateCwd(bgResolved.effectiveCwd, ctx.cwd);
|
|
2552
|
+
if (!bgCwdResult.ok) {
|
|
2553
|
+
return {
|
|
2554
|
+
content: [{ type: "text" as const, text: `Invalid cwd: ${bgCwdResult.error}` }],
|
|
2555
|
+
details: undefined,
|
|
2556
|
+
isError: true,
|
|
2557
|
+
};
|
|
2558
|
+
}
|
|
2559
|
+
let bgResolvedOutputFile: string | undefined;
|
|
2560
|
+
if (bgResolved.outputFile) {
|
|
2561
|
+
const ofResult = validateOutputFile(bgResolved.outputFile, bgCwdResult.value);
|
|
2562
|
+
if (!ofResult.ok) {
|
|
2563
|
+
return {
|
|
2564
|
+
content: [
|
|
2565
|
+
{ type: "text" as const, text: `Invalid outputFile: ${ofResult.error}` },
|
|
2566
|
+
],
|
|
2567
|
+
details: undefined,
|
|
2568
|
+
isError: true,
|
|
2569
|
+
};
|
|
2570
|
+
}
|
|
2571
|
+
bgResolvedOutputFile = ofResult.value;
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
// C: H1 validation for background mode — reject outputFile-vs-write
|
|
2575
|
+
// conflicts before spawning (loud failure, same as single mode).
|
|
2576
|
+
const bgValidation = validatePreTask({
|
|
2577
|
+
task: singleTask,
|
|
2578
|
+
toolOptions: bgResolved.toolOptions,
|
|
2579
|
+
thinkingLevel: bgResolved.thinkingLevel,
|
|
2580
|
+
gitMode: bgResolved.resolvedGitMode,
|
|
2581
|
+
outputFile: bgResolvedOutputFile,
|
|
2582
|
+
});
|
|
2583
|
+
if (bgValidation.warnings.length > 0) {
|
|
2584
|
+
log.warn("Background pre-task validation warnings", { warnings: bgValidation.warnings });
|
|
2585
|
+
}
|
|
2586
|
+
if (!bgValidation.valid) {
|
|
2587
|
+
const errText = bgValidation.errors.join("; ");
|
|
2588
|
+
log.warn("Background pre-task validation failed", { errors: bgValidation.errors });
|
|
2589
|
+
return {
|
|
2590
|
+
content: [{ type: "text" as const, text: errText }],
|
|
2591
|
+
details: undefined,
|
|
2592
|
+
isError: true,
|
|
2593
|
+
};
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
// Build the full prompt the same way foreground single mode does:
|
|
2597
|
+
// base prompt (optionally inherited) + custom prompt + preset guidance.
|
|
2598
|
+
const bgPrompt = buildSubagentPrompt(
|
|
2599
|
+
ctx.getSystemPrompt(),
|
|
2600
|
+
bgResolved.inheritSP,
|
|
2601
|
+
bgResolved.customSP,
|
|
2602
|
+
bgResolvedOutputFile,
|
|
2603
|
+
bgResolved.toolOptions?.tools,
|
|
2604
|
+
bgResolvedPreset?.promptGuideline,
|
|
2605
|
+
);
|
|
2606
|
+
|
|
2607
|
+
const agent = await spawnBackgroundSession(pi, ctx, {
|
|
2608
|
+
task: singleTask,
|
|
2609
|
+
type: params.preset || 'general-purpose',
|
|
2610
|
+
description: params.label,
|
|
2611
|
+
model: bgModel,
|
|
2612
|
+
thinkingLevel: bgResolved.thinkingLevel,
|
|
2613
|
+
// Issue #114: per-unit priority rides the spawn so the background
|
|
2614
|
+
// run entry + drill-in header carry it like foreground runs.
|
|
2615
|
+
priority: params.priority,
|
|
2616
|
+
systemPrompt: bgPrompt,
|
|
2617
|
+
cwd: bgCwdResult.value,
|
|
2618
|
+
toolOptions: bgResolved.toolOptions,
|
|
2619
|
+
timeout: bgResolved.timeout,
|
|
2620
|
+
gitMode: bgResolved.resolvedGitMode,
|
|
2621
|
+
// Issue #98: snapshot the caller's raw params on the run entry so a
|
|
2622
|
+
// retry of this background run restores them — single source of truth
|
|
2623
|
+
// snapshotOriginalParams (issue #108: no more duplicated 11-field literal).
|
|
2624
|
+
originalParams: snapshotOriginalParams(params),
|
|
2625
|
+
});
|
|
2626
|
+
|
|
2627
|
+
// Register for live monitor
|
|
2628
|
+
state.registerLiveSubagent(agent.id, {
|
|
2629
|
+
id: agent.id,
|
|
2630
|
+
label: agent.description,
|
|
2631
|
+
task: agent.task,
|
|
2632
|
+
model: agent.model,
|
|
2633
|
+
thinkingLevel: agent.thinkingLevel,
|
|
2634
|
+
priority: agent.priority,
|
|
2635
|
+
startedAt: agent.startedAt,
|
|
2636
|
+
ctx,
|
|
2637
|
+
});
|
|
2638
|
+
|
|
2639
|
+
// Update footer counters
|
|
2640
|
+
state.activeSubagents++;
|
|
2641
|
+
updateProgressStatus(state, ctx);
|
|
2642
|
+
|
|
2643
|
+
let completed = false;
|
|
2644
|
+
|
|
2645
|
+
// Poll for live progress
|
|
2646
|
+
const pollInterval = setInterval(() => {
|
|
2647
|
+
try {
|
|
2648
|
+
if (completed) return;
|
|
2649
|
+
|
|
2650
|
+
const session = agent._sessionRef;
|
|
2651
|
+
// Issue #31: a nulled ref on a TERMINAL agent is expected (the
|
|
2652
|
+
// settlement path releases the ref) — fall through to finalize
|
|
2653
|
+
// below instead of treating it as a crash. A nulled ref on a
|
|
2654
|
+
// live agent is still a crash.
|
|
2655
|
+
if (!session && !agent.completedAt) {
|
|
2656
|
+
// Session ref not available — session may have crashed.
|
|
2657
|
+
// Defensive: the ref is assigned synchronously before the poller starts
|
|
2658
|
+
// and is never nulled, so this is mostly unreachable — but keep the
|
|
2659
|
+
// bookkeeping safe regardless.
|
|
2660
|
+
completed = true;
|
|
2661
|
+
clearInterval(pollInterval);
|
|
2662
|
+
clearTimeout(hardCapHandle);
|
|
2663
|
+
// Capture whatever output exists (may be empty — fine).
|
|
2664
|
+
setAgentFinalOutput(agent.id, state.subagentSessions.get(agent.id)?.liveOutput ?? '');
|
|
2665
|
+
// Gate the counter on the finalize claim: if the
|
|
2666
|
+
// stale sweep already finalized this entry, the
|
|
2667
|
+
// decrement happened there (PR #71 review).
|
|
2668
|
+
if (state.finalizeLiveSubagent(agent.id)) {
|
|
2669
|
+
state.activeSubagents--;
|
|
2670
|
+
if (state.activeSubagents < 0) state.activeSubagents = 0;
|
|
2671
|
+
}
|
|
2672
|
+
state.failedSubagents++;
|
|
2673
|
+
updateProgressStatus(state, ctx);
|
|
2674
|
+
pi.sendMessage({
|
|
2675
|
+
customType: "subagent-notification",
|
|
2676
|
+
content: `Background agent "${agent.description}" crashed.`,
|
|
2677
|
+
display: true,
|
|
2678
|
+
details: { agentId: agent.id }
|
|
2679
|
+
}, { deliverAs: "followUp" });
|
|
2680
|
+
return;
|
|
2681
|
+
}
|
|
2682
|
+
|
|
2683
|
+
// While running, update the live monitor.
|
|
2684
|
+
// Once completedAt is set, skip straight to finalize below.
|
|
2685
|
+
// Issue #31: past the guard above, the only nulled-ref path left is a
|
|
2686
|
+
// TERMINAL agent (settlement releases the ref), so a live agent always
|
|
2687
|
+
// has its session — the guard already returned on the live-nulled-ref
|
|
2688
|
+
// crash path. The compiler can't correlate the two checks, so assert it.
|
|
2689
|
+
if (!agent.completedAt) {
|
|
2690
|
+
const finalOutput = extractFinalOutput(session!);
|
|
2691
|
+
|
|
2692
|
+
try {
|
|
2693
|
+
const stats = session!.getSessionStats();
|
|
2694
|
+
state.updateLiveSubagent(agent.id, finalOutput, stats.tokens.input, stats.tokens.output);
|
|
2695
|
+
} catch {
|
|
2696
|
+
state.updateLiveSubagent(agent.id, finalOutput, 0, 0);
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
// Authoritative completion: the prompt promise settled — status and
|
|
2701
|
+
// completedAt are set by .then()/.catch() in spawnBackgroundSession.
|
|
2702
|
+
// Do NOT rely on !session.isStreaming: it is false during prompt()
|
|
2703
|
+
// preflight (auth check, model resolution) and for failed starts.
|
|
2704
|
+
if (agent.completedAt) {
|
|
2705
|
+
completed = true;
|
|
2706
|
+
clearInterval(pollInterval);
|
|
2707
|
+
clearTimeout(hardCapHandle);
|
|
2708
|
+
|
|
2709
|
+
// Issue #31: prefer the output already captured at settlement;
|
|
2710
|
+
// the ref may already be nulled on terminal agents, so never
|
|
2711
|
+
// overwrite with empty and stay null-safe.
|
|
2712
|
+
const finalOutput = agent.finalOutput ?? extractAgentFinalOutput(agent);
|
|
2713
|
+
setAgentFinalOutput(agent.id, finalOutput);
|
|
2714
|
+
|
|
2715
|
+
// Gate the counter on the finalize claim: if the
|
|
2716
|
+
// stale sweep already finalized this entry, the
|
|
2717
|
+
// decrement happened there (PR #71 review).
|
|
2718
|
+
if (state.finalizeLiveSubagent(agent.id)) {
|
|
2719
|
+
state.activeSubagents--;
|
|
2720
|
+
if (state.activeSubagents < 0) state.activeSubagents = 0;
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
if (agent.status === 'failed') {
|
|
2724
|
+
state.failedSubagents++;
|
|
2725
|
+
updateProgressStatus(state, ctx);
|
|
2726
|
+
pi.sendMessage({
|
|
2727
|
+
customType: "subagent-notification",
|
|
2728
|
+
content: `Background agent "${agent.description}" failed.`,
|
|
2729
|
+
display: true,
|
|
2730
|
+
details: { agentId: agent.id }
|
|
2731
|
+
}, { deliverAs: "followUp" });
|
|
2732
|
+
} else if (agent.status === 'stopped') {
|
|
2733
|
+
// User-initiated stop (stop_subagent) or deadline abort
|
|
2734
|
+
// (timeout/hard cap) — not a failure.
|
|
2735
|
+
updateProgressStatus(state, ctx);
|
|
2736
|
+
} else {
|
|
2737
|
+
state.completedSubagents++;
|
|
2738
|
+
state.unseenSubagents++;
|
|
2739
|
+
updateProgressStatus(state, ctx);
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
} catch (err) {
|
|
2743
|
+
if (!completed) {
|
|
2744
|
+
completed = true;
|
|
2745
|
+
clearInterval(pollInterval);
|
|
2746
|
+
clearTimeout(hardCapHandle);
|
|
2747
|
+
try {
|
|
2748
|
+
setAgentFinalOutput(agent.id, extractAgentFinalOutput(agent));
|
|
2749
|
+
} catch { /* ignore */ }
|
|
2750
|
+
// Gate the counter on the finalize claim: if the
|
|
2751
|
+
// stale sweep already finalized this entry, the
|
|
2752
|
+
// decrement happened there (PR #71 review).
|
|
2753
|
+
if (state.finalizeLiveSubagent(agent.id)) {
|
|
2754
|
+
state.activeSubagents--;
|
|
2755
|
+
if (state.activeSubagents < 0) state.activeSubagents = 0;
|
|
2756
|
+
}
|
|
2757
|
+
state.failedSubagents++;
|
|
2758
|
+
updateProgressStatus(state, ctx);
|
|
2759
|
+
pi.sendMessage({
|
|
2760
|
+
customType: "subagent-notification",
|
|
2761
|
+
content: `Background agent "${agent.description}" crashed: ${sanitizeErrorMessage((err as Error).message, bgResolved.effectiveCwd)}`,
|
|
2762
|
+
display: true,
|
|
2763
|
+
details: { agentId: agent.id }
|
|
2764
|
+
}, { deliverAs: "followUp" });
|
|
2765
|
+
}
|
|
2766
|
+
}
|
|
2767
|
+
}, 2000);
|
|
2768
|
+
|
|
2769
|
+
// Hard cap: stop polling AND abort the session after the deadline.
|
|
2770
|
+
// W2 (issue #28): previously this only stopped the poller — the pi
|
|
2771
|
+
// session kept running forever (orphaned). Now it pre-sets
|
|
2772
|
+
// 'stopped' (so the .then keeps it, per W1) and aborts the
|
|
2773
|
+
// session. The cap honors a shorter per-agent timeout.
|
|
2774
|
+
const hardCapMs = Math.min(bgResolved.timeout ?? 30 * 60 * 1000, 30 * 60 * 1000);
|
|
2775
|
+
const hardCapHandle = setTimeout(() => {
|
|
2776
|
+
if (!completed && !agent.completedAt) {
|
|
2777
|
+
try {
|
|
2778
|
+
completed = true;
|
|
2779
|
+
clearInterval(pollInterval);
|
|
2780
|
+
// Pre-set stopped BEFORE aborting so the .then in
|
|
2781
|
+
// spawnBackgroundSession keeps the stopped state.
|
|
2782
|
+
updateAgentStatus(agent.id, 'stopped', `Timed out (${hardCapMs}ms hard cap)`);
|
|
2783
|
+
agent._sessionRef?.abort().catch(() => {
|
|
2784
|
+
// Abort may reject if the session is mid-dispose;
|
|
2785
|
+
// the status flip above is already recorded.
|
|
2786
|
+
});
|
|
2787
|
+
// Capture whatever final output exists in the session
|
|
2788
|
+
const hardCapSession = agent._sessionRef;
|
|
2789
|
+
const hardCapFinalOutput = hardCapSession ? extractFinalOutput(hardCapSession) : '';
|
|
2790
|
+
setAgentFinalOutput(agent.id, hardCapFinalOutput);
|
|
2791
|
+
// Gate the counter on the finalize claim: if the
|
|
2792
|
+
// stale sweep already finalized this entry, the
|
|
2793
|
+
// decrement happened there (PR #71 review).
|
|
2794
|
+
if (state.finalizeLiveSubagent(agent.id)) {
|
|
2795
|
+
state.activeSubagents--;
|
|
2796
|
+
if (state.activeSubagents < 0) state.activeSubagents = 0;
|
|
2797
|
+
}
|
|
2798
|
+
// m6: deadline abort is a stop, not a completion — mirror the
|
|
2799
|
+
// W3/poller stopped path (no completedSubagents increment).
|
|
2800
|
+
updateProgressStatus(state, ctx);
|
|
2801
|
+
} catch (err) {
|
|
2802
|
+
// Defensive: never let the hard-cap timer throw uncaught — that would
|
|
2803
|
+
// skip finalizeLiveSubagent, the counter decrement, and the notification.
|
|
2804
|
+
// Do all fallible work first (output capture), then mutate counters,
|
|
2805
|
+
// then notify — a throw mid-path can't double-fire mutations.
|
|
2806
|
+
completed = true;
|
|
2807
|
+
clearInterval(pollInterval);
|
|
2808
|
+
// m3: the try may have thrown BEFORE updateAgentStatus ran (e.g.
|
|
2809
|
+
// persistAgent fs failure) — leave the record terminal so
|
|
2810
|
+
// get_subagent_result doesn't report 'running' and the W3 timer
|
|
2811
|
+
// guard (!agent.completedAt) can't re-fire later.
|
|
2812
|
+
try {
|
|
2813
|
+
updateAgentStatus(agent.id, 'stopped', `Timed out (${hardCapMs}ms hard cap)`);
|
|
2814
|
+
} catch { /* ignore */ }
|
|
2815
|
+
try {
|
|
2816
|
+
setAgentFinalOutput(agent.id, extractAgentFinalOutput(agent));
|
|
2817
|
+
} catch { /* ignore */ }
|
|
2818
|
+
// Gate the counter on the finalize claim: if the
|
|
2819
|
+
// stale sweep already finalized this entry, the
|
|
2820
|
+
// decrement happened there (PR #71 review).
|
|
2821
|
+
if (state.finalizeLiveSubagent(agent.id)) {
|
|
2822
|
+
state.activeSubagents--;
|
|
2823
|
+
if (state.activeSubagents < 0) state.activeSubagents = 0;
|
|
2824
|
+
}
|
|
2825
|
+
state.failedSubagents++;
|
|
2826
|
+
updateProgressStatus(state, ctx);
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
}, hardCapMs);
|
|
2830
|
+
|
|
2831
|
+
log.info("Background agent spawned", { agentId: agent.id, task: params.task, model: agent.model });
|
|
2832
|
+
|
|
2833
|
+
// B2: Surface auto-route decisions in background spawn result too.
|
|
2834
|
+
// Derive the restriction from the RESOLVED toolOptions (which reflects
|
|
2835
|
+
// per-call overrides) rather than the preset's declared values.
|
|
2836
|
+
const bgAutoRouteNote = bgAutoRoutedPreset
|
|
2837
|
+
? `\n\n[auto-routed to preset '${bgAutoRoutedPreset.name}' — ${bgResolved.toolOptions ? formatToolRestriction(bgResolved.toolOptions) : formatPresetRestriction(bgAutoRoutedPreset)}]`
|
|
2838
|
+
: "";
|
|
2839
|
+
|
|
2840
|
+
return {
|
|
2841
|
+
content: [{
|
|
2842
|
+
type: "text" as const,
|
|
2843
|
+
text: `Background agent started: ${agent.id}\n\n` +
|
|
2844
|
+
`Description: ${agent.description}\n` +
|
|
2845
|
+
`Task: ${agent.task}\n` +
|
|
2846
|
+
`Status: ${agent.status}\n\n` +
|
|
2847
|
+
`You'll be woken with a completion message when it finishes; use get_subagent_result({ agent_id: "${agent.id}" }) for retrieval and stall checks.` +
|
|
2848
|
+
bgAutoRouteNote,
|
|
2849
|
+
}],
|
|
2850
|
+
details: undefined,
|
|
2851
|
+
};
|
|
2852
|
+
} catch (err) {
|
|
2853
|
+
const message = sanitizeErrorMessage(
|
|
2854
|
+
err instanceof Error ? err.message : String(err),
|
|
2855
|
+
bgResolved.effectiveCwd,
|
|
2856
|
+
);
|
|
2857
|
+
log.error("Failed to spawn background agent", { error: message });
|
|
2858
|
+
return {
|
|
2859
|
+
content: [{ type: "text" as const, text: `Failed to spawn background agent: ${message}` }],
|
|
2860
|
+
details: undefined,
|
|
2861
|
+
isError: true,
|
|
2862
|
+
};
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
// R5: Check session cost limit before spawning
|
|
2867
|
+
// Use a default per-task estimate of $0.05 if no perTaskCostEstimate is set
|
|
2868
|
+
const perTaskEstimate = state.config.perTaskCostEstimate > 0
|
|
2869
|
+
? state.config.perTaskCostEstimate
|
|
2870
|
+
: 0.05;
|
|
2871
|
+
const currentTotal = state.getSessionTotalCost(ctx);
|
|
2872
|
+
if (state.checkCostLimit(perTaskEstimate, ctx)) {
|
|
2873
|
+
const limit = state.config.sessionCostLimit;
|
|
2874
|
+
log.warn("Subagent delegation rejected: session cost limit reached", {
|
|
2875
|
+
currentTotal,
|
|
2876
|
+
estimatedCost: perTaskEstimate,
|
|
2877
|
+
limit,
|
|
2878
|
+
});
|
|
2879
|
+
return {
|
|
2880
|
+
content: [
|
|
2881
|
+
{
|
|
2882
|
+
type: "text" as const,
|
|
2883
|
+
text:
|
|
2884
|
+
`Cannot delegate: session cost limit reached ` +
|
|
2885
|
+
`($${currentTotal.toFixed(4)} spent of $${limit.toFixed(2)} limit). ` +
|
|
2886
|
+
`Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
|
|
2887
|
+
},
|
|
2888
|
+
],
|
|
2889
|
+
details: undefined,
|
|
2890
|
+
isError: true,
|
|
2891
|
+
};
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
// Reject delegation if recursion depth exceeds configured max.
|
|
2895
|
+
// This prevents subagents from spawning infinite sub-subagents while
|
|
2896
|
+
// still allowing other extensions to function normally in subprocesses.
|
|
2897
|
+
const currentDepth = getCurrentDepth();
|
|
2898
|
+
if (currentDepth >= state.config.maxSubagentDepth) {
|
|
2899
|
+
log.warn("Subagent delegation rejected: max depth reached", {
|
|
2900
|
+
currentDepth,
|
|
2901
|
+
maxDepth: state.config.maxSubagentDepth,
|
|
2902
|
+
});
|
|
2903
|
+
return {
|
|
2904
|
+
content: [
|
|
2905
|
+
{
|
|
2906
|
+
type: "text" as const,
|
|
2907
|
+
text:
|
|
2908
|
+
`Cannot delegate further: subagent recursion depth limit reached ` +
|
|
2909
|
+
`(depth ${currentDepth}/${state.config.maxSubagentDepth}). ` +
|
|
2910
|
+
`Subagents can delegate up to ${state.config.maxSubagentDepth} levels deep (configurable via /brl-subagent depth). Complete the remaining work directly.`,
|
|
2911
|
+
},
|
|
2912
|
+
],
|
|
2913
|
+
details: undefined,
|
|
2914
|
+
isError: true,
|
|
2915
|
+
};
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
const {
|
|
2919
|
+
task,
|
|
2920
|
+
label,
|
|
2921
|
+
inheritSP,
|
|
2922
|
+
customSP,
|
|
2923
|
+
outputFile,
|
|
2924
|
+
timeout,
|
|
2925
|
+
effectiveCwd,
|
|
2926
|
+
thinkingLevel,
|
|
2927
|
+
toolOptions,
|
|
2928
|
+
resolvedGitMode,
|
|
2929
|
+
resolvedApprovalMode,
|
|
2930
|
+
resolvedPreset,
|
|
2931
|
+
autoRoutedPreset,
|
|
2932
|
+
} = resolveSubagentParams({ ...params, task: singleTask }, state, ctx, log);
|
|
2933
|
+
|
|
2934
|
+
// F1: Validate CWD
|
|
2935
|
+
const cwdResult = validateCwd(effectiveCwd, ctx.cwd);
|
|
2936
|
+
if (!cwdResult.ok) {
|
|
2937
|
+
return {
|
|
2938
|
+
content: [{ type: "text" as const, text: `Invalid cwd: ${cwdResult.error}` }],
|
|
2939
|
+
details: undefined,
|
|
2940
|
+
isError: true,
|
|
2941
|
+
};
|
|
2942
|
+
}
|
|
2943
|
+
const resolvedCwd = cwdResult.value;
|
|
2944
|
+
|
|
2945
|
+
// F1: Validate outputFile
|
|
2946
|
+
let resolvedOutputFile: string | undefined;
|
|
2947
|
+
if (outputFile) {
|
|
2948
|
+
const ofResult = validateOutputFile(outputFile, resolvedCwd);
|
|
2949
|
+
if (!ofResult.ok) {
|
|
2950
|
+
return {
|
|
2951
|
+
content: [
|
|
2952
|
+
{ type: "text" as const, text: `Invalid outputFile: ${ofResult.error}` },
|
|
2953
|
+
],
|
|
2954
|
+
details: undefined,
|
|
2955
|
+
isError: true,
|
|
2956
|
+
};
|
|
2957
|
+
}
|
|
2958
|
+
resolvedOutputFile = ofResult.value;
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
// R3: Pre-flight checks — fail fast before consuming resources
|
|
2962
|
+
const pfResult = preflightCheck(resolvedCwd);
|
|
2963
|
+
if (!pfResult.ok) {
|
|
2964
|
+
log.warn("Pre-flight check failed", { error: pfResult.error });
|
|
2965
|
+
return {
|
|
2966
|
+
content: [{ type: "text" as const, text: `Pre-flight check failed: ${pfResult.error}` }],
|
|
2967
|
+
details: undefined,
|
|
2968
|
+
isError: true,
|
|
2969
|
+
};
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
// H1: Pre-task validation — deterministic check that tools/thinking match task
|
|
2973
|
+
const validation = validatePreTask({
|
|
2974
|
+
task,
|
|
2975
|
+
toolOptions,
|
|
2976
|
+
thinkingLevel,
|
|
2977
|
+
gitMode: resolvedGitMode,
|
|
2978
|
+
outputFile: resolvedOutputFile,
|
|
2979
|
+
});
|
|
2980
|
+
if (validation.warnings.length > 0) {
|
|
2981
|
+
log.warn("Pre-task validation warnings", { warnings: validation.warnings });
|
|
2982
|
+
}
|
|
2983
|
+
if (!validation.valid) {
|
|
2984
|
+
const errText = validation.errors.join("; ");
|
|
2985
|
+
log.warn("Pre-task validation failed", { errors: validation.errors });
|
|
2986
|
+
return {
|
|
2987
|
+
content: [{
|
|
2988
|
+
type: "text" as const,
|
|
2989
|
+
text: errText,
|
|
2990
|
+
}],
|
|
2991
|
+
details: undefined,
|
|
2992
|
+
isError: true,
|
|
2993
|
+
};
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
// Resolve model (per-call top-level model override beats preset)
|
|
2997
|
+
const modelResult = resolveSubagentModel(ctx, resolvedPreset, params.model);
|
|
2998
|
+
if (!modelResult.ok) return modelResult.error;
|
|
2999
|
+
const subagentModel = modelResult.model;
|
|
3000
|
+
|
|
3001
|
+
// R3: Verify resolved model provider+id is non-empty
|
|
3002
|
+
const modelStr = `${subagentModel.provider}/${subagentModel.id}`.trim();
|
|
3003
|
+
if (!modelStr || modelStr === "/") {
|
|
3004
|
+
log.warn("Model string is empty after resolution", { model: subagentModel });
|
|
3005
|
+
return {
|
|
3006
|
+
content: [
|
|
3007
|
+
{
|
|
3008
|
+
type: "text" as const,
|
|
3009
|
+
text:
|
|
3010
|
+
"Subagent model is not configured. " +
|
|
3011
|
+
"Use /brl-subagent to set a model, or ensure your current session has a valid model.",
|
|
3012
|
+
},
|
|
3013
|
+
],
|
|
3014
|
+
details: undefined,
|
|
3015
|
+
isError: true,
|
|
3016
|
+
};
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
// Create run record
|
|
3020
|
+
const runId = crypto.randomUUID();
|
|
3021
|
+
const run: SubagentRun = {
|
|
3022
|
+
id: runId,
|
|
3023
|
+
task,
|
|
3024
|
+
label,
|
|
3025
|
+
status: "running",
|
|
3026
|
+
model: `${subagentModel.provider}/${subagentModel.id}`,
|
|
3027
|
+
thinkingLevel,
|
|
3028
|
+
// Issue #114: record-level visibility — the run's priority (absent
|
|
3029
|
+
// when the caller left it to the default).
|
|
3030
|
+
priority: params.priority,
|
|
3031
|
+
startedAt: new Date().toISOString(),
|
|
3032
|
+
originalParams: snapshotOriginalParams(params),
|
|
3033
|
+
};
|
|
3034
|
+
state.persistRun(pi, run);
|
|
3035
|
+
|
|
3036
|
+
// R2: Prune old run entries if history exceeds limit
|
|
3037
|
+
pruneHistoryIfNeeded(state, ctx, log);
|
|
3038
|
+
|
|
3039
|
+
// Register for live monitor
|
|
3040
|
+
registerLiveRun(state, run, ctx);
|
|
3041
|
+
|
|
3042
|
+
// P3: Git integration — set up work branch if gitMode is "branch"
|
|
3043
|
+
let originalBranch: string | undefined;
|
|
3044
|
+
let workBranchName: string | undefined;
|
|
3045
|
+
if (resolvedGitMode === "branch") {
|
|
3046
|
+
try {
|
|
3047
|
+
originalBranch = getCurrentBranch(resolvedCwd);
|
|
3048
|
+
|
|
3049
|
+
if (hasUncommittedChanges(resolvedCwd)) {
|
|
3050
|
+
log.warn("Uncommitted changes detected; proceeding with branch-based workflow anyway", {
|
|
3051
|
+
cwd: resolvedCwd,
|
|
3052
|
+
});
|
|
3053
|
+
}
|
|
3054
|
+
|
|
3055
|
+
const branchResult = createWorkBranch(resolvedCwd, originalBranch);
|
|
3056
|
+
if (branchResult.ok) {
|
|
3057
|
+
workBranchName = branchResult.branch;
|
|
3058
|
+
log.info("Created work branch for subagent", {
|
|
3059
|
+
branch: workBranchName,
|
|
3060
|
+
base: originalBranch,
|
|
3061
|
+
});
|
|
3062
|
+
} else {
|
|
3063
|
+
log.error("Failed to create work branch, falling back to 'none'", {
|
|
3064
|
+
error: branchResult.error,
|
|
3065
|
+
});
|
|
3066
|
+
originalBranch = undefined;
|
|
3067
|
+
}
|
|
3068
|
+
} catch (err) {
|
|
3069
|
+
log.warn("Not a git repository or git error; falling back to gitMode 'none'", {
|
|
3070
|
+
error: (err as Error).message,
|
|
3071
|
+
});
|
|
3072
|
+
originalBranch = undefined;
|
|
3073
|
+
workBranchName = undefined;
|
|
3074
|
+
}
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
// Helper to switch back to original branch and optionally delete work branch
|
|
3078
|
+
const cleanupGitBranch = () => {
|
|
3079
|
+
if (workBranchName && originalBranch) {
|
|
3080
|
+
try {
|
|
3081
|
+
switchToBranch(resolvedCwd, originalBranch);
|
|
3082
|
+
log.info("Switched back to original branch", { branch: originalBranch });
|
|
3083
|
+
// Attempt to delete the work branch (non-critical)
|
|
3084
|
+
deleteBranch(resolvedCwd, workBranchName);
|
|
3085
|
+
} catch {
|
|
3086
|
+
// Non-fatal: best-effort cleanup
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
};
|
|
3090
|
+
|
|
3091
|
+
// R1: Circuit breaker check — reject if circuit is open
|
|
3092
|
+
const circuitCheck = state.checkCircuit();
|
|
3093
|
+
if (circuitCheck.isOpen) {
|
|
3094
|
+
cleanupGitBranch();
|
|
3095
|
+
return {
|
|
3096
|
+
content: [{ type: "text" as const, text: circuitCheck.message! }],
|
|
3097
|
+
details: undefined,
|
|
3098
|
+
isError: true,
|
|
3099
|
+
};
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
// Resolve priority (issue #99 R2: single mode honors priority like chain/parallel/graph)
|
|
3103
|
+
const singlePriority: Priority = (
|
|
3104
|
+
params.priority && ["critical", "high", "normal", "low"].includes(params.priority)
|
|
3105
|
+
? (params.priority as Priority)
|
|
3106
|
+
: DEFAULT_PRIORITY
|
|
3107
|
+
);
|
|
3108
|
+
|
|
3109
|
+
// Acquire concurrency slot
|
|
3110
|
+
const acquired = await acquireSlot(state, ctx, signal, singlePriority);
|
|
3111
|
+
if (!acquired) {
|
|
3112
|
+
cleanupGitBranch();
|
|
3113
|
+
return {
|
|
3114
|
+
content: [
|
|
3115
|
+
{
|
|
3116
|
+
type: "text" as const,
|
|
3117
|
+
text: "Subagent cancelled while waiting for concurrency slot.",
|
|
3118
|
+
},
|
|
3119
|
+
],
|
|
3120
|
+
details: undefined,
|
|
3121
|
+
isError: true,
|
|
3122
|
+
};
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
let success = false;
|
|
3126
|
+
|
|
3127
|
+
// F7 (issue #65): import BEFORE the try — the crash-path catch calls
|
|
3128
|
+
// completeTranscript, and block-scoped consts declared inside the try
|
|
3129
|
+
// are invisible in its catch (pre-fix, a crash here threw
|
|
3130
|
+
// "completeTranscript is not defined" instead of returning the
|
|
3131
|
+
// sanitized crash result).
|
|
3132
|
+
const { startTranscript, completeTranscript } = await import('./transcript');
|
|
3133
|
+
|
|
3134
|
+
try {
|
|
3135
|
+
// Build system prompt
|
|
3136
|
+
const basePrompt = ctx.getSystemPrompt();
|
|
3137
|
+
const subagentPrompt = buildSubagentPrompt(
|
|
3138
|
+
basePrompt,
|
|
3139
|
+
inheritSP,
|
|
3140
|
+
customSP,
|
|
3141
|
+
resolvedOutputFile,
|
|
3142
|
+
toolOptions?.tools,
|
|
3143
|
+
resolvedPreset?.promptGuideline,
|
|
3144
|
+
);
|
|
3145
|
+
|
|
3146
|
+
// Emit initial progress
|
|
3147
|
+
const modeInfo = describePromptMode(inheritSP, Boolean(customSP));
|
|
3148
|
+
onUpdate?.({
|
|
3149
|
+
content: [
|
|
3150
|
+
{
|
|
3151
|
+
type: "text" as const,
|
|
3152
|
+
text: `Starting subagent (${modeInfo})...`,
|
|
3153
|
+
},
|
|
3154
|
+
],
|
|
3155
|
+
details: {
|
|
3156
|
+
messages: [],
|
|
3157
|
+
usage: { ...EMPTY_USAGE },
|
|
3158
|
+
exitCode: -1,
|
|
3159
|
+
stderr: "",
|
|
3160
|
+
},
|
|
3161
|
+
});
|
|
3162
|
+
|
|
3163
|
+
// Wrap onUpdate to feed live monitor
|
|
3164
|
+
const liveOnUpdate = makeLiveOnUpdate(state, runId, onUpdate);
|
|
3165
|
+
|
|
3166
|
+
const childDepth = currentDepth + 1;
|
|
3167
|
+
|
|
3168
|
+
// Start transcript for audit trail
|
|
3169
|
+
startTranscript(runId, task);
|
|
3170
|
+
|
|
3171
|
+
let result = await runSubagent(
|
|
3172
|
+
resolvedCwd,
|
|
3173
|
+
subagentPrompt,
|
|
3174
|
+
subagentModel,
|
|
3175
|
+
thinkingLevel,
|
|
3176
|
+
task,
|
|
3177
|
+
signal,
|
|
3178
|
+
liveOnUpdate,
|
|
3179
|
+
toolOptions,
|
|
3180
|
+
timeout,
|
|
3181
|
+
getFinalOutput,
|
|
3182
|
+
log,
|
|
3183
|
+
childDepth,
|
|
3184
|
+
undefined, // intercom
|
|
3185
|
+
undefined, // subagentId
|
|
3186
|
+
);
|
|
3187
|
+
|
|
3188
|
+
// Auto-retry on timeout
|
|
3189
|
+
if (
|
|
3190
|
+
params.retryOnTimeout &&
|
|
3191
|
+
isSubagentError(result) &&
|
|
3192
|
+
result.errorMessage?.includes("timed out")
|
|
3193
|
+
) {
|
|
3194
|
+
log.info("Auto-retrying after timeout", { runId });
|
|
3195
|
+
state.registerLiveSubagent(runId, {
|
|
3196
|
+
id: runId,
|
|
3197
|
+
label,
|
|
3198
|
+
task,
|
|
3199
|
+
model: run.model,
|
|
3200
|
+
thinkingLevel,
|
|
3201
|
+
priority: params.priority,
|
|
3202
|
+
startedAt: Date.now(),
|
|
3203
|
+
ctx,
|
|
3204
|
+
});
|
|
3205
|
+
|
|
3206
|
+
onUpdate?.({
|
|
3207
|
+
content: [
|
|
3208
|
+
{ type: "text" as const, text: "Retrying after timeout..." },
|
|
3209
|
+
],
|
|
3210
|
+
details: {
|
|
3211
|
+
messages: [],
|
|
3212
|
+
usage: { ...EMPTY_USAGE },
|
|
3213
|
+
exitCode: -1,
|
|
3214
|
+
stderr: "",
|
|
3215
|
+
},
|
|
3216
|
+
});
|
|
3217
|
+
|
|
3218
|
+
result = await runSubagent(
|
|
3219
|
+
resolvedCwd,
|
|
3220
|
+
subagentPrompt,
|
|
3221
|
+
subagentModel,
|
|
3222
|
+
thinkingLevel,
|
|
3223
|
+
task,
|
|
3224
|
+
signal,
|
|
3225
|
+
liveOnUpdate,
|
|
3226
|
+
toolOptions,
|
|
3227
|
+
timeout,
|
|
3228
|
+
getFinalOutput,
|
|
3229
|
+
log,
|
|
3230
|
+
childDepth,
|
|
3231
|
+
undefined, // intercom
|
|
3232
|
+
undefined, // subagentId
|
|
3233
|
+
);
|
|
3234
|
+
}
|
|
3235
|
+
|
|
3236
|
+
// F3: Sanitize and cap output
|
|
3237
|
+
let finalOutput = getFinalOutput(result.messages);
|
|
3238
|
+
finalOutput = stripAnsi(finalOutput);
|
|
3239
|
+
finalOutput = capOutput(finalOutput);
|
|
3240
|
+
|
|
3241
|
+
// Attach label for display
|
|
3242
|
+
result.label = label;
|
|
3243
|
+
|
|
3244
|
+
// P3: Capture git diff if we created a work branch
|
|
3245
|
+
if (workBranchName && originalBranch) {
|
|
3246
|
+
const diffResult = captureDiff(resolvedCwd, originalBranch);
|
|
3247
|
+
if (diffResult.ok) {
|
|
3248
|
+
result.gitBranch = workBranchName;
|
|
3249
|
+
result.gitDiff = diffResult.diff;
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
// Switch back to the original branch
|
|
3253
|
+
const switchResult = switchToBranch(resolvedCwd, originalBranch);
|
|
3254
|
+
if (switchResult.ok) {
|
|
3255
|
+
log.info("Switched back to original branch", { branch: originalBranch });
|
|
3256
|
+
|
|
3257
|
+
// P4: Change approval workflow — let user review the diff
|
|
3258
|
+
const diffContent = (result.gitDiff ?? "").trim();
|
|
3259
|
+
const hasChanges = diffContent.length > 0;
|
|
3260
|
+
const shouldPrompt =
|
|
3261
|
+
ctx.hasUI !== false &&
|
|
3262
|
+
(resolvedApprovalMode === "always" ||
|
|
3263
|
+
(resolvedApprovalMode === "writes" && hasChanges));
|
|
3264
|
+
|
|
3265
|
+
if (shouldPrompt) {
|
|
3266
|
+
const choice = await showApprovalDialog(ctx, label, result.gitDiff ?? "", workBranchName);
|
|
3267
|
+
|
|
3268
|
+
if (choice === "apply") {
|
|
3269
|
+
const mergeResult = mergeWorkBranch(resolvedCwd, workBranchName);
|
|
3270
|
+
if (mergeResult.ok) {
|
|
3271
|
+
log.info("Merged work branch (approved)", { branch: workBranchName });
|
|
3272
|
+
result.approved = true;
|
|
3273
|
+
} else {
|
|
3274
|
+
log.error("Failed to merge work branch", {
|
|
3275
|
+
branch: workBranchName,
|
|
3276
|
+
error: mergeResult.error,
|
|
3277
|
+
});
|
|
3278
|
+
result.approved = false;
|
|
3279
|
+
}
|
|
3280
|
+
} else {
|
|
3281
|
+
// Discard or cancelled
|
|
3282
|
+
deleteBranch(resolvedCwd, workBranchName);
|
|
3283
|
+
log.info("Discarded work branch", { branch: workBranchName });
|
|
3284
|
+
result.approved = false;
|
|
3285
|
+
}
|
|
3286
|
+
workBranchName = undefined; // Prevent double-cleanup
|
|
3287
|
+
} else if (hasChanges) {
|
|
3288
|
+
// Auto-approve: merge the work branch
|
|
3289
|
+
const mergeResult = mergeWorkBranch(resolvedCwd, workBranchName);
|
|
3290
|
+
if (mergeResult.ok) {
|
|
3291
|
+
log.info("Merged work branch (auto-approve)", { branch: workBranchName });
|
|
3292
|
+
result.approved = true;
|
|
3293
|
+
} else {
|
|
3294
|
+
log.error("Failed to merge work branch (auto-approve)", {
|
|
3295
|
+
branch: workBranchName,
|
|
3296
|
+
error: mergeResult.error,
|
|
3297
|
+
});
|
|
3298
|
+
}
|
|
3299
|
+
deleteBranch(resolvedCwd, workBranchName);
|
|
3300
|
+
workBranchName = undefined;
|
|
3301
|
+
} else {
|
|
3302
|
+
// No changes — just delete the empty branch
|
|
3303
|
+
deleteBranch(resolvedCwd, workBranchName);
|
|
3304
|
+
workBranchName = undefined;
|
|
3305
|
+
}
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
// Finalize run record
|
|
3310
|
+
finalizeRunRecord(
|
|
3311
|
+
run,
|
|
3312
|
+
result,
|
|
3313
|
+
finalOutput,
|
|
3314
|
+
new Date(run.startedAt).getTime(),
|
|
3315
|
+
);
|
|
3316
|
+
run.originalParams = {
|
|
3317
|
+
...run.originalParams,
|
|
3318
|
+
errorCategory: result.errorCategory,
|
|
3319
|
+
};
|
|
3320
|
+
state.persistRun(pi, run);
|
|
3321
|
+
|
|
3322
|
+
// R2: Prune old run entries
|
|
3323
|
+
pruneHistoryIfNeeded(state, ctx, log);
|
|
3324
|
+
|
|
3325
|
+
// Finalize live monitor
|
|
3326
|
+
state.finalizeLiveSubagent(runId);
|
|
3327
|
+
|
|
3328
|
+
// E4: SLA tracking — compute metrics if enabled
|
|
3329
|
+
if (state.config.slaTrackingEnabled) {
|
|
3330
|
+
const recentRuns = state.getRunEntries(ctx).slice(0, state.config.slaWindowSize);
|
|
3331
|
+
const metrics = computeSLAMetrics(recentRuns);
|
|
3332
|
+
log.info("SLA metrics computed", {
|
|
3333
|
+
totalRuns: metrics.totalRuns,
|
|
3334
|
+
successRate: metrics.successRate,
|
|
3335
|
+
p95DurationMs: metrics.p95DurationMs,
|
|
3336
|
+
totalCost: metrics.totalCost,
|
|
3337
|
+
});
|
|
3338
|
+
if (state.config.lastSLAMetrics) {
|
|
3339
|
+
const report = computeDegradation(metrics, state.config.lastSLAMetrics);
|
|
3340
|
+
if (report.degraded) {
|
|
3341
|
+
log.warn("SLA degradation detected", {
|
|
3342
|
+
successRateChange: report.successRateChange,
|
|
3343
|
+
p95Change: report.p95Change,
|
|
3344
|
+
recommendations: report.recommendations,
|
|
3345
|
+
});
|
|
3346
|
+
}
|
|
3347
|
+
}
|
|
3348
|
+
state.config.lastSLAMetrics = metrics;
|
|
3349
|
+
}
|
|
3350
|
+
|
|
3351
|
+
if (isSubagentError(result)) {
|
|
3352
|
+
const errorMsg =
|
|
3353
|
+
result.errorMessage ||
|
|
3354
|
+
result.stderr ||
|
|
3355
|
+
finalOutput ||
|
|
3356
|
+
"(no output from subagent)";
|
|
3357
|
+
log.warn("Subagent failed", {
|
|
3358
|
+
runId,
|
|
3359
|
+
error: errorMsg,
|
|
3360
|
+
exitCode: result.exitCode,
|
|
3361
|
+
errorCategory: result.errorCategory,
|
|
3362
|
+
});
|
|
3363
|
+
|
|
3364
|
+
// H3: Post-mortem diagnostics — suggest fixes for the failure
|
|
3365
|
+
const suggestions = diagnoseFailure({
|
|
3366
|
+
task,
|
|
3367
|
+
toolOptions,
|
|
3368
|
+
thinkingLevel,
|
|
3369
|
+
gitMode: resolvedGitMode,
|
|
3370
|
+
errorMessage: result.errorMessage,
|
|
3371
|
+
exitCode: result.exitCode,
|
|
3372
|
+
timeout,
|
|
3373
|
+
});
|
|
3374
|
+
|
|
3375
|
+
// R1: Record failure in circuit breaker
|
|
3376
|
+
state.recordFailure();
|
|
3377
|
+
|
|
3378
|
+
let finalMsg = `Subagent failed: ${errorMsg}`;
|
|
3379
|
+
if (suggestions.length > 0) {
|
|
3380
|
+
finalMsg += "\n\nSuggestions:\n" + suggestions.map((s) => `- ${s}`).join("\n");
|
|
3381
|
+
}
|
|
3382
|
+
|
|
3383
|
+
completeTranscript(runId, 'failed');
|
|
3384
|
+
|
|
3385
|
+
return {
|
|
3386
|
+
content: [
|
|
3387
|
+
{ type: "text" as const, text: finalMsg },
|
|
3388
|
+
],
|
|
3389
|
+
details: result,
|
|
3390
|
+
isError: true,
|
|
3391
|
+
};
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
success = true;
|
|
3395
|
+
completeTranscript(runId, 'completed');
|
|
3396
|
+
log.info("Subagent completed successfully",{
|
|
3397
|
+
runId,
|
|
3398
|
+
tokensIn: result.usage.input,
|
|
3399
|
+
tokensOut: result.usage.output,
|
|
3400
|
+
cost: result.usage.cost,
|
|
3401
|
+
sessionTotalCost: state.getSessionTotalCost(ctx),
|
|
3402
|
+
});
|
|
3403
|
+
|
|
3404
|
+
// R1: Record success in circuit breaker
|
|
3405
|
+
state.recordSuccess();
|
|
3406
|
+
|
|
3407
|
+
// B2: Surface auto-route decisions in the tool result so the conductor
|
|
3408
|
+
// sees which preset was applied and what it restricts. Derive the
|
|
3409
|
+
// restriction from the RESOLVED toolOptions (reflects per-call
|
|
3410
|
+
// overrides) rather than the preset's declared values.
|
|
3411
|
+
const autoRouteNote = autoRoutedPreset
|
|
3412
|
+
? `\n\n[auto-routed to preset '${autoRoutedPreset.name}' — ${toolOptions ? formatToolRestriction(toolOptions) : formatPresetRestriction(autoRoutedPreset)}]`
|
|
3413
|
+
: "";
|
|
3414
|
+
|
|
3415
|
+
return {
|
|
3416
|
+
content: [
|
|
3417
|
+
{ type: "text" as const, text: (finalOutput || "(no output)") + autoRouteNote },
|
|
3418
|
+
],
|
|
3419
|
+
details: result,
|
|
3420
|
+
};
|
|
3421
|
+
} catch (err) {
|
|
3422
|
+
// P3: Clean up git branch on crash
|
|
3423
|
+
cleanupGitBranch();
|
|
3424
|
+
completeTranscript(runId, 'failed');
|
|
3425
|
+
|
|
3426
|
+
const result = buildCrashResult("Subagent", err, resolvedCwd);
|
|
3427
|
+
log.error("Subagent crashed", { runId, error: result.details.errorMessage });
|
|
3428
|
+
|
|
3429
|
+
// R2 (review): mirror the completion path — finalize the live entry
|
|
3430
|
+
// and persist a FAILED run record. Without this the live entry stays
|
|
3431
|
+
// 'running' forever, the stale sweep never reclaims it, and the
|
|
3432
|
+
// drill-in loops on the live branch indefinitely. finalizeLiveSubagent
|
|
3433
|
+
// is idempotent (true = THIS call claimed it; the foreground path has
|
|
3434
|
+
// no activeSubagents counters to gate).
|
|
3435
|
+
state.finalizeLiveSubagent(runId);
|
|
3436
|
+
const crashOutput = state.subagentSessions.get(runId)?.liveOutput ?? "";
|
|
3437
|
+
finalizeRunRecord(
|
|
3438
|
+
run,
|
|
3439
|
+
result.details,
|
|
3440
|
+
crashOutput,
|
|
3441
|
+
new Date(run.startedAt).getTime(),
|
|
3442
|
+
);
|
|
3443
|
+
state.persistRun(pi, run);
|
|
3444
|
+
|
|
3445
|
+
return result;
|
|
3446
|
+
} finally {
|
|
3447
|
+
releaseSlot(state, success, ctx);
|
|
3448
|
+
}
|
|
3449
|
+
},
|
|
3450
|
+
|
|
3451
|
+
renderCall(
|
|
3452
|
+
args: {
|
|
3453
|
+
task?: string;
|
|
3454
|
+
label?: string;
|
|
3455
|
+
systemPrompt?: string;
|
|
3456
|
+
inheritSystemPrompt?: boolean;
|
|
3457
|
+
thinkingLevel?: string;
|
|
3458
|
+
outputFile?: string;
|
|
3459
|
+
timeout?: number;
|
|
3460
|
+
cwd?: string;
|
|
3461
|
+
tools?: string[];
|
|
3462
|
+
excludeTools?: string[];
|
|
3463
|
+
noBuiltinTools?: boolean;
|
|
3464
|
+
},
|
|
3465
|
+
theme: Theme,
|
|
3466
|
+
_context: unknown,
|
|
3467
|
+
) {
|
|
3468
|
+
return renderDelegateCall(args, theme);
|
|
3469
|
+
},
|
|
3470
|
+
|
|
3471
|
+
renderResult(
|
|
3472
|
+
result: AgentToolResult<DelegateTaskDetails>,
|
|
3473
|
+
options: ToolRenderResultOptions,
|
|
3474
|
+
theme: Theme,
|
|
3475
|
+
_context: unknown,
|
|
3476
|
+
) {
|
|
3477
|
+
return renderDelegateResult(result, options, theme);
|
|
3478
|
+
},
|
|
3479
|
+
});
|
|
3480
|
+
|
|
3481
|
+
// -------------------------------------------------------------------
|
|
3482
|
+
// get_subagent_result tool — retrieve terminal status/result on demand
|
|
3483
|
+
// -------------------------------------------------------------------
|
|
3484
|
+
|
|
3485
|
+
pi.registerTool({
|
|
3486
|
+
name: "get_subagent_result",
|
|
3487
|
+
label: "Get Subagent Result",
|
|
3488
|
+
description: [
|
|
3489
|
+
"Check the status of a background agent and retrieve its result.",
|
|
3490
|
+
"The conductor is woken with a completion message when the agent finishes; use this for post-wake retrieval, stall checks, and detail access — not for polling.",
|
|
3491
|
+
"Returns the agent's status, result (if completed), and transcript path.",
|
|
3492
|
+
].join(" "),
|
|
3493
|
+
parameters: Type.Object({
|
|
3494
|
+
agent_id: Type.String({
|
|
3495
|
+
description: "The agent ID returned by delegate_task when background=true",
|
|
3496
|
+
}),
|
|
3497
|
+
wait: Type.Optional(
|
|
3498
|
+
Type.Boolean({
|
|
3499
|
+
description: "If true, wait for the agent to complete before returning. Default: false.",
|
|
3500
|
+
}),
|
|
3501
|
+
),
|
|
3502
|
+
verbose: Type.Optional(
|
|
3503
|
+
Type.Boolean({
|
|
3504
|
+
description: "If true, include the full conversation log. Default: false.",
|
|
3505
|
+
}),
|
|
3506
|
+
),
|
|
3507
|
+
}),
|
|
3508
|
+
execute: async (toolCallId, params) => {
|
|
3509
|
+
const { getAgent } = await import('./session-manager');
|
|
3510
|
+
const { getTranscript } = await import('./transcript');
|
|
3511
|
+
|
|
3512
|
+
const agent = getAgent(params.agent_id);
|
|
3513
|
+
|
|
3514
|
+
if (!agent) {
|
|
3515
|
+
return {
|
|
3516
|
+
content: [{ type: "text" as const, text: `Agent ${params.agent_id} not found` }],
|
|
3517
|
+
details: undefined,
|
|
3518
|
+
isError: true,
|
|
3519
|
+
};
|
|
3520
|
+
}
|
|
3521
|
+
|
|
3522
|
+
let resultText = `Agent: ${agent.description}\n`;
|
|
3523
|
+
resultText += `Status: ${agent.status}\n`;
|
|
3524
|
+
resultText += `Started: ${new Date(agent.startedAt).toISOString()}\n`;
|
|
3525
|
+
|
|
3526
|
+
if (agent.completedAt) {
|
|
3527
|
+
resultText += `Completed: ${new Date(agent.completedAt).toISOString()}\n`;
|
|
3528
|
+
resultText += `Duration: ${agent.completedAt - agent.startedAt}ms\n`;
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3531
|
+
if (agent.error) {
|
|
3532
|
+
resultText += `Error: ${agent.error}\n`;
|
|
3533
|
+
}
|
|
3534
|
+
|
|
3535
|
+
if (agent.result) {
|
|
3536
|
+
resultText += `\nResult:\n`;
|
|
3537
|
+
if (agent.result.messages && agent.result.messages.length > 0) {
|
|
3538
|
+
resultText += agent.result.messages.join('\n');
|
|
3539
|
+
}
|
|
3540
|
+
// W4: surface the background agent's git work-branch diff so the
|
|
3541
|
+
// captured changes are reviewable (the branch itself is discarded).
|
|
3542
|
+
if (agent.result.gitBranch) {
|
|
3543
|
+
resultText += `\nGit branch: ${agent.result.gitBranch}\n`;
|
|
3544
|
+
}
|
|
3545
|
+
if (agent.result.gitDiff) {
|
|
3546
|
+
const diff = agent.result.gitDiff;
|
|
3547
|
+
const truncated = diff.length > 8000 ? diff.slice(0, 8000) + "\n…[diff truncated]" : diff;
|
|
3548
|
+
resultText += `\nGit diff (branch ${agent.result.gitBranch ?? 'work'}):\n${truncated}\n`;
|
|
3549
|
+
}
|
|
3550
|
+
}
|
|
3551
|
+
|
|
3552
|
+
if (agent.finalOutput) {
|
|
3553
|
+
const full = agent.finalOutput;
|
|
3554
|
+
const truncated = full.length > 8000 ? full.slice(0, 8000) + "\n…[truncated — full output in transcript]" : full;
|
|
3555
|
+
resultText += `\n\nFinal output:\n${truncated}`;
|
|
3556
|
+
}
|
|
3557
|
+
|
|
3558
|
+
// Include transcript if verbose
|
|
3559
|
+
if (params.verbose) {
|
|
3560
|
+
const transcript = getTranscript(params.agent_id);
|
|
3561
|
+
if (transcript.length > 0) {
|
|
3562
|
+
resultText += `\n\nTranscript (${transcript.length} entries):\n`;
|
|
3563
|
+
for (const entry of transcript.slice(-10)) { // Last 10 entries
|
|
3564
|
+
resultText += `[${new Date(entry.timestamp).toISOString()}] ${entry.type}: ${entry.content.slice(0, 100)}\n`;
|
|
3565
|
+
}
|
|
3566
|
+
}
|
|
3567
|
+
}
|
|
3568
|
+
|
|
3569
|
+
// Include transcript path
|
|
3570
|
+
const { getTranscriptPath } = await import('./session-manager');
|
|
3571
|
+
resultText += `\nTranscript: ${getTranscriptPath(params.agent_id)}`;
|
|
3572
|
+
|
|
3573
|
+
return {
|
|
3574
|
+
content: [{ type: "text" as const, text: resultText }],
|
|
3575
|
+
details: undefined,
|
|
3576
|
+
};
|
|
3577
|
+
},
|
|
3578
|
+
});
|
|
3579
|
+
|
|
3580
|
+
// -------------------------------------------------------------------
|
|
3581
|
+
// steer_subagent tool — inject messages into running agents
|
|
3582
|
+
// -------------------------------------------------------------------
|
|
3583
|
+
|
|
3584
|
+
// steer_subagent tool — inject messages into running agents
|
|
3585
|
+
pi.registerTool({
|
|
3586
|
+
name: "steer_subagent",
|
|
3587
|
+
label: "Steer Subagent",
|
|
3588
|
+
description: [
|
|
3589
|
+
"Send a steering message to a running background agent.",
|
|
3590
|
+
"The message interrupts after the current tool execution.",
|
|
3591
|
+
"Use this to redirect an agent's work without restarting it.",
|
|
3592
|
+
].join(" "),
|
|
3593
|
+
parameters: Type.Object({
|
|
3594
|
+
agent_id: Type.String({
|
|
3595
|
+
description: "The agent ID to steer",
|
|
3596
|
+
}),
|
|
3597
|
+
message: Type.String({
|
|
3598
|
+
description: "The message to inject into the agent's conversation",
|
|
3599
|
+
}),
|
|
3600
|
+
}),
|
|
3601
|
+
execute: async (toolCallId, params, _signal, _onUpdate, ctx) => {
|
|
3602
|
+
const { steerAgent } = await import('./session-manager');
|
|
3603
|
+
|
|
3604
|
+
try {
|
|
3605
|
+
const agent = steerAgent(params.agent_id, params.message);
|
|
3606
|
+
|
|
3607
|
+
if (!agent) {
|
|
3608
|
+
return {
|
|
3609
|
+
content: [{ type: "text" as const, text: `Agent ${params.agent_id} not found` }],
|
|
3610
|
+
details: undefined,
|
|
3611
|
+
isError: true,
|
|
3612
|
+
};
|
|
3613
|
+
}
|
|
3614
|
+
|
|
3615
|
+
return {
|
|
3616
|
+
content: [{
|
|
3617
|
+
type: "text" as const,
|
|
3618
|
+
text: `Steered agent ${params.agent_id}: "${params.message.slice(0, 50)}${params.message.length > 50 ? '...' : ''}"`,
|
|
3619
|
+
}],
|
|
3620
|
+
details: undefined,
|
|
3621
|
+
};
|
|
3622
|
+
} catch (err) {
|
|
3623
|
+
// F7 (issue #65): sanitize BEFORE the message reaches the main
|
|
3624
|
+
// agent's context. pi passes ctx as the 5th arg to tool execute,
|
|
3625
|
+
// so ctx.cwd is in scope — sibling-project paths are properly
|
|
3626
|
+
// masked (defensive process.cwd() fallback if ctx is undefined).
|
|
3627
|
+
const message = sanitizeErrorMessage(err instanceof Error ? err.message : String(err), ctx?.cwd);
|
|
3628
|
+
return {
|
|
3629
|
+
content: [{ type: "text" as const, text: `Failed to steer agent: ${message}` }],
|
|
3630
|
+
details: undefined,
|
|
3631
|
+
isError: true,
|
|
3632
|
+
};
|
|
3633
|
+
}
|
|
3634
|
+
},
|
|
3635
|
+
});
|
|
3636
|
+
|
|
3637
|
+
pi.registerTool({
|
|
3638
|
+
name: "stop_subagent",
|
|
3639
|
+
label: "Stop Subagent",
|
|
3640
|
+
description: [
|
|
3641
|
+
"Stop a running background agent by aborting its session.",
|
|
3642
|
+
"The agent's current operation is aborted and the session is marked stopped.",
|
|
3643
|
+
"Use this to halt a background agent that is no longer needed.",
|
|
3644
|
+
].join(" "),
|
|
3645
|
+
parameters: Type.Object({
|
|
3646
|
+
agent_id: Type.String({
|
|
3647
|
+
description: "The agent ID to stop",
|
|
3648
|
+
}),
|
|
3649
|
+
}),
|
|
3650
|
+
execute: async (toolCallId, params, _signal, _onUpdate, ctx) => {
|
|
3651
|
+
const { stopAgent } = await import('./session-manager');
|
|
3652
|
+
|
|
3653
|
+
try {
|
|
3654
|
+
const agent = await stopAgent(params.agent_id);
|
|
3655
|
+
|
|
3656
|
+
if (!agent) {
|
|
3657
|
+
return {
|
|
3658
|
+
content: [{ type: "text" as const, text: `Agent ${params.agent_id} not found` }],
|
|
3659
|
+
details: undefined,
|
|
3660
|
+
isError: true,
|
|
3661
|
+
};
|
|
3662
|
+
}
|
|
3663
|
+
|
|
3664
|
+
return {
|
|
3665
|
+
content: [{
|
|
3666
|
+
type: "text" as const,
|
|
3667
|
+
text: `Stopped agent ${params.agent_id} (${agent.description}).`,
|
|
3668
|
+
}],
|
|
3669
|
+
details: undefined,
|
|
3670
|
+
};
|
|
3671
|
+
} catch (err) {
|
|
3672
|
+
// F7 (issue #65): sanitize BEFORE the message reaches the main
|
|
3673
|
+
// agent's context. pi passes ctx as the 5th arg to tool execute,
|
|
3674
|
+
// so ctx.cwd is in scope — sibling-project paths are properly
|
|
3675
|
+
// masked (defensive process.cwd() fallback if ctx is undefined).
|
|
3676
|
+
const message = sanitizeErrorMessage(err instanceof Error ? err.message : String(err), ctx?.cwd);
|
|
3677
|
+
return {
|
|
3678
|
+
content: [{ type: "text" as const, text: `Failed to stop agent: ${message}` }],
|
|
3679
|
+
details: undefined,
|
|
3680
|
+
isError: true,
|
|
3681
|
+
};
|
|
3682
|
+
}
|
|
3683
|
+
},
|
|
3684
|
+
});
|
|
3685
|
+
|
|
3686
|
+
// -------------------------------------------------------------------
|
|
3687
|
+
// Session lifecycle — F7: Session-bound state initialization
|
|
3688
|
+
// -------------------------------------------------------------------
|
|
3689
|
+
|
|
3690
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
3691
|
+
// Issue #147: capture the session context for the completion-push subscriber.
|
|
3692
|
+
sessionCtx = ctx;
|
|
3693
|
+
// Load built-in presets
|
|
3694
|
+
const presetsDir = path.join(__dirname, "..", "presets");
|
|
3695
|
+
state.builtinPresets = loadBuiltinPresets(presetsDir, log);
|
|
3696
|
+
state.customPresets = loadCustomPresets(ctx.cwd, log);
|
|
3697
|
+
// Builtin templates + merged full stack (custom overrides builtin)
|
|
3698
|
+
const templatesDir = path.join(__dirname, "..", "templates");
|
|
3699
|
+
state.builtinTemplates = loadBuiltinTemplates(templatesDir, log);
|
|
3700
|
+
state.config.templates = loadAllTemplates(ctx.cwd, log, templatesDir);
|
|
3701
|
+
|
|
3702
|
+
// Issue #81: cross-check template `preset:` refs against the full
|
|
3703
|
+
// preset universe now that both loads are complete — warn (never skip)
|
|
3704
|
+
// for dangling references instead of running preset-less silently with
|
|
3705
|
+
// auto-route suppressed.
|
|
3706
|
+
validateTemplatePresetRefs(
|
|
3707
|
+
state.config.templates,
|
|
3708
|
+
getAllPresets(state.builtinPresets, state.customPresets),
|
|
3709
|
+
log,
|
|
3710
|
+
);
|
|
3711
|
+
|
|
3712
|
+
// Migration: write any session-persisted presets to files
|
|
3713
|
+
if (state._migratedPresets && state._migratedPresets.length > 0) {
|
|
3714
|
+
for (const preset of state._migratedPresets) {
|
|
3715
|
+
writePresetFile(preset, path.join(ctx.cwd, ".pi", "brl-subagent", "presets"));
|
|
3716
|
+
}
|
|
3717
|
+
state._migratedPresets = undefined;
|
|
3718
|
+
state.customPresets = loadCustomPresets(ctx.cwd, log);
|
|
3719
|
+
state.config.templates = loadAllTemplates(ctx.cwd, log);
|
|
3720
|
+
log?.info("Migrated presets from session to files");
|
|
3721
|
+
}
|
|
3722
|
+
|
|
3723
|
+
// R2: Clean stale temp dirs from previous sessions
|
|
3724
|
+
cleanupTempDirs(ctx.cwd).then((count) => {
|
|
3725
|
+
if (count > 0) log.info("Cleaned stale temp directories", { count });
|
|
3726
|
+
});
|
|
3727
|
+
|
|
3728
|
+
log.info("Session started", {
|
|
3729
|
+
builtinPresets: state.builtinPresets.length,
|
|
3730
|
+
builtinTemplates: state.builtinTemplates.length,
|
|
3731
|
+
presetsDir,
|
|
3732
|
+
});
|
|
3733
|
+
|
|
3734
|
+
// F5/F9: Safe state restoration with type guards
|
|
3735
|
+
state.restoreFromSession(ctx);
|
|
3736
|
+
|
|
3737
|
+
// Check for updates (non-blocking, once per 24h)
|
|
3738
|
+
if (state.config.updateCheckEnabled) {
|
|
3739
|
+
const now = Date.now();
|
|
3740
|
+
if (now - state.config.lastUpdateCheck > UPDATE_CHECK_INTERVAL_MS) {
|
|
3741
|
+
state.config.lastUpdateCheck = now;
|
|
3742
|
+
checkForUpdates(currentVersion, log).then((result) => {
|
|
3743
|
+
if (result?.available) {
|
|
3744
|
+
ctx.ui.notify(
|
|
3745
|
+
"brl-subagent " + result.version + " available (current: " + currentVersion + "). Visit " + result.url + " to update. /brl-subagent update-check to disable.",
|
|
3746
|
+
"info"
|
|
3747
|
+
);
|
|
3748
|
+
log.info("Update available", { current: currentVersion, latest: result.version });
|
|
3749
|
+
}
|
|
3750
|
+
}).catch(() => {}); // silently ignore errors
|
|
3751
|
+
}
|
|
3752
|
+
}
|
|
3753
|
+
|
|
3754
|
+
updateStatus(state, ctx);
|
|
3755
|
+
});
|
|
3756
|
+
|
|
3757
|
+
// F7: Clean up session-bound state on shutdown
|
|
3758
|
+
pi.on("session_shutdown", async (_event, _ctx) => {
|
|
3759
|
+
log.info("Session shutting down", {
|
|
3760
|
+
activeSubagents: state.activeSubagents,
|
|
3761
|
+
});
|
|
3762
|
+
|
|
3763
|
+
// Clear all live subagent sessions
|
|
3764
|
+
state.subagentSessions.clear();
|
|
3765
|
+
// Drop pending finalize claims too (PR #71 review: the claim set must
|
|
3766
|
+
// not outlive the map it guards — a fresh session could otherwise
|
|
3767
|
+
// no-op on an id a stale claim still remembers).
|
|
3768
|
+
state.resetLiveFinalizeClaims();
|
|
3769
|
+
// Reset counters
|
|
3770
|
+
state.activeSubagents = 0;
|
|
3771
|
+
state.completedSubagents = 0;
|
|
3772
|
+
state.failedSubagents = 0;
|
|
3773
|
+
state.unseenSubagents = 0;
|
|
3774
|
+
// Clear pending queue
|
|
3775
|
+
state.pendingQueue.length = 0;
|
|
3776
|
+
});
|
|
3777
|
+
|
|
3778
|
+
// -------------------------------------------------------------------
|
|
3779
|
+
// Issue #147: completion-push wake — subscribe to terminal event-bus events
|
|
3780
|
+
// -------------------------------------------------------------------
|
|
3781
|
+
// The event-bus is the complete terminal chokepoint (subagent:completed /
|
|
3782
|
+
// subagent:failed / subagent:stopped), so the conductor is woken via
|
|
3783
|
+
// pi.sendMessage + triggerTurn when a background run reaches a terminal state.
|
|
3784
|
+
// Delivery is always-on (minimum nextTurn); the completionNotify knob controls
|
|
3785
|
+
// the WAKE (D1+D2 — see notify-completion.ts). The dedupe set defends the
|
|
3786
|
+
// pathological double-emit path (first terminal event per id wins); it is
|
|
3787
|
+
// capped simply (cleared when it exceeds 200 entries).
|
|
3788
|
+
//
|
|
3789
|
+
// Stopped-run degradation — ACCEPTED BY DESIGN (review #2, adjudicated
|
|
3790
|
+
// 2026-09-01): subagent:stopped fires at stop time (updateAgentStatus) BEFORE
|
|
3791
|
+
// finalizeRunEntry stamps cost/duration/errorCategory and before finalOutput
|
|
3792
|
+
// is captured. So the stopped shape may lack those fields and the run entry
|
|
3793
|
+
// resolveRunEntry returns may be the pre-finalize spawn entry. The wake is
|
|
3794
|
+
// the point; details are best-effort — the notification must never throw or
|
|
3795
|
+
// block on their absence.
|
|
3796
|
+
const terminalSeen = new Set<string>();
|
|
3797
|
+
const terminalTypes = ["subagent:completed", "subagent:failed", "subagent:stopped"] as const;
|
|
3798
|
+
for (const type of terminalTypes) {
|
|
3799
|
+
eventBus.on(type, (event) => {
|
|
3800
|
+
void deliverCompletionAlert(event);
|
|
3801
|
+
});
|
|
3802
|
+
}
|
|
3803
|
+
|
|
3804
|
+
async function deliverCompletionAlert(event: SubagentEvent): Promise<void> {
|
|
3805
|
+
const id = event.agentId;
|
|
3806
|
+
if (!markTerminalSeen(terminalSeen, id)) return;
|
|
3807
|
+
const ctx = sessionCtx;
|
|
3808
|
+
if (!ctx) return;
|
|
3809
|
+
try {
|
|
3810
|
+
const { getAgent } = await import("./session-manager");
|
|
3811
|
+
const agent = getAgent(id);
|
|
3812
|
+
if (!agent) return; // pruned or foreign id — nothing to notify
|
|
3813
|
+
const knob = state.config.completionNotify ?? "all";
|
|
3814
|
+
const run = resolveRunEntry(state.getRunEntries(ctx), id);
|
|
3815
|
+
const message = buildCompletionMessage(agent, run);
|
|
3816
|
+
const delivery = resolveDelivery(normalizeCompletionStatus(agent.status), knob);
|
|
3817
|
+
sendCompletionNotification(pi, message, delivery);
|
|
3818
|
+
} catch (err) {
|
|
3819
|
+
log.warn(`completion-push handler failed for ${id}`, {
|
|
3820
|
+
error: (err as Error).message,
|
|
3821
|
+
});
|
|
3822
|
+
}
|
|
3823
|
+
}
|
|
3824
|
+
}
|