brl-subagent 2.3.7 → 2.3.8
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/AGENT.md +9 -4
- package/README.md +18 -5
- package/package.json +5 -5
- package/src/history.ts +8 -1
- package/src/index.ts +757 -386
- package/src/notify-completion.ts +7 -13
- package/src/session-manager.ts +11 -26
- package/src/state.ts +42 -2
- package/src/tui.ts +1 -1
- package/src/types.ts +21 -0
package/src/index.ts
CHANGED
|
@@ -1878,6 +1878,740 @@ export default function (pi: ExtensionAPI) {
|
|
|
1878
1878
|
}
|
|
1879
1879
|
}
|
|
1880
1880
|
|
|
1881
|
+
// -------------------------------------------------------------------
|
|
1882
|
+
// runBackgroundFanOut — background fan-out for tasks mode.
|
|
1883
|
+
// Resolves + validates the WHOLE batch up front (no spawn on any
|
|
1884
|
+
// failure), then spawns the tasks sequentially, in task order, through the
|
|
1885
|
+
// shared startBackgroundAgent tail, and returns immediately with their ids.
|
|
1886
|
+
// Foreground-only gates (preflight, circuit breaker, recursion depth) are
|
|
1887
|
+
// deliberately skipped — the single-background path skips them too.
|
|
1888
|
+
// -------------------------------------------------------------------
|
|
1889
|
+
|
|
1890
|
+
async function runBackgroundFanOut(
|
|
1891
|
+
pi: ExtensionAPI,
|
|
1892
|
+
ctx: ExtensionContext,
|
|
1893
|
+
params: Record<string, unknown>,
|
|
1894
|
+
signal: AbortSignal | undefined,
|
|
1895
|
+
): Promise<ToolResult<SubagentResult | undefined>> {
|
|
1896
|
+
const taskList = params.tasks as SubTaskParams[];
|
|
1897
|
+
|
|
1898
|
+
// Session cost limit gates the WHOLE batch before any spawn — same
|
|
1899
|
+
// estimate/limit logic as runParallelMode, scaled by task count.
|
|
1900
|
+
const perTaskEstimate =
|
|
1901
|
+
state.config.perTaskCostEstimate > 0
|
|
1902
|
+
? state.config.perTaskCostEstimate
|
|
1903
|
+
: 0.05;
|
|
1904
|
+
if (state.checkCostLimit(perTaskEstimate * taskList.length, ctx)) {
|
|
1905
|
+
const currentTotal = state.getSessionTotalCost(ctx);
|
|
1906
|
+
const limit = state.config.sessionCostLimit;
|
|
1907
|
+
log.warn("Background fan-out rejected: session cost limit reached", {
|
|
1908
|
+
currentTotal,
|
|
1909
|
+
estimatedCost: perTaskEstimate * taskList.length,
|
|
1910
|
+
limit,
|
|
1911
|
+
});
|
|
1912
|
+
return {
|
|
1913
|
+
content: [
|
|
1914
|
+
{
|
|
1915
|
+
type: "text" as const,
|
|
1916
|
+
text:
|
|
1917
|
+
`Cannot delegate: session cost limit reached ` +
|
|
1918
|
+
`($${currentTotal.toFixed(4)} spent of $${limit.toFixed(2)} limit). ` +
|
|
1919
|
+
`Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
|
|
1920
|
+
},
|
|
1921
|
+
],
|
|
1922
|
+
details: undefined,
|
|
1923
|
+
isError: true,
|
|
1924
|
+
};
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
// Resolve global params once — same call shape as runParallelMode.
|
|
1928
|
+
const globalParams = resolveSubagentParams(
|
|
1929
|
+
params as {
|
|
1930
|
+
task: string;
|
|
1931
|
+
label?: string;
|
|
1932
|
+
preset?: string;
|
|
1933
|
+
systemPrompt?: string;
|
|
1934
|
+
inheritSystemPrompt?: boolean;
|
|
1935
|
+
thinkingLevel?: string;
|
|
1936
|
+
outputFile?: string;
|
|
1937
|
+
timeout?: number;
|
|
1938
|
+
cwd?: string;
|
|
1939
|
+
tools?: string[];
|
|
1940
|
+
excludeTools?: string[];
|
|
1941
|
+
noBuiltinTools?: boolean;
|
|
1942
|
+
gitMode?: string;
|
|
1943
|
+
},
|
|
1944
|
+
state,
|
|
1945
|
+
ctx,
|
|
1946
|
+
log,
|
|
1947
|
+
);
|
|
1948
|
+
|
|
1949
|
+
// Resolve the global model once (per-call model beats preset/config).
|
|
1950
|
+
const globalModelResult = resolveSubagentModel(
|
|
1951
|
+
ctx,
|
|
1952
|
+
globalParams.resolvedPreset,
|
|
1953
|
+
params.model as string | undefined,
|
|
1954
|
+
);
|
|
1955
|
+
if (!globalModelResult.ok) return globalModelResult.error;
|
|
1956
|
+
const globalModel = globalModelResult.model;
|
|
1957
|
+
|
|
1958
|
+
// Same rejection as the single-background path: approvalMode 'always'
|
|
1959
|
+
// cannot work in background — there is no interactive dialog to approve
|
|
1960
|
+
// the diff, for any of the N agents.
|
|
1961
|
+
if (globalParams.resolvedApprovalMode === 'always') {
|
|
1962
|
+
return {
|
|
1963
|
+
content: [{ type: "text" as const, text:
|
|
1964
|
+
`Cannot spawn background agent with approvalMode 'always': background agents run unattended ` +
|
|
1965
|
+
`and cannot present the approval dialog. Use approvalMode 'auto' (default) or 'writes'.`
|
|
1966
|
+
}],
|
|
1967
|
+
details: undefined,
|
|
1968
|
+
isError: true,
|
|
1969
|
+
};
|
|
1970
|
+
}
|
|
1971
|
+
// W5 parity with the single-background path: 'writes' silently
|
|
1972
|
+
// auto-approves in background — warn once so the caller knows none of
|
|
1973
|
+
// the N agents' diffs will be gated on approval.
|
|
1974
|
+
if (globalParams.resolvedApprovalMode === 'writes') {
|
|
1975
|
+
log.warn("Background fan-out spawned with approvalMode 'writes' — auto-approving (no dialog in background)", {
|
|
1976
|
+
tasks: taskList.length,
|
|
1977
|
+
});
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
// gitMode 'branch' is rejected for the whole batch: the per-repository
|
|
1981
|
+
// git lock is awaited inside the first spawn and held until that agent
|
|
1982
|
+
// settles, so same-repository branch-mode spawns would serialize and
|
|
1983
|
+
// block this call for the whole chain. The rule is blanket (not
|
|
1984
|
+
// same-cwd-only) because the lock is keyed by the cwd string, not the
|
|
1985
|
+
// repository root — different subdirectories of one repo share a working
|
|
1986
|
+
// tree without sharing a lock.
|
|
1987
|
+
if (globalParams.resolvedGitMode === 'branch') {
|
|
1988
|
+
return {
|
|
1989
|
+
content: [{ type: "text" as const, text:
|
|
1990
|
+
`Cannot start ${taskList.length} background agents with gitMode 'branch': the per-repository git lock ` +
|
|
1991
|
+
`is awaited by the first spawn and held until that agent settles, so same-repository branch-mode ` +
|
|
1992
|
+
`spawns would serialize and block this call for the whole chain. Background fan-out therefore ` +
|
|
1993
|
+
`rejects gitMode 'branch' for all tasks in this version. Use gitMode 'none' for the batch, or ` +
|
|
1994
|
+
`dispatch each unit as a separate single-task background call.`
|
|
1995
|
+
}],
|
|
1996
|
+
details: undefined,
|
|
1997
|
+
isError: true,
|
|
1998
|
+
};
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
// Per-task pre-pass: resolve + validate EVERYTHING before any spawn, so a
|
|
2002
|
+
// single bad task rejects the whole batch with nothing started.
|
|
2003
|
+
type FanOutTask = {
|
|
2004
|
+
merged: ReturnType<typeof mergeSubTaskParams>;
|
|
2005
|
+
model: { provider: string; id: string };
|
|
2006
|
+
cwd: string;
|
|
2007
|
+
outputFile: string | undefined;
|
|
2008
|
+
};
|
|
2009
|
+
const displayTaskName = (merged: ReturnType<typeof mergeSubTaskParams>): string =>
|
|
2010
|
+
merged.label ??
|
|
2011
|
+
(merged.task.length > 60 ? `${merged.task.slice(0, 57)}...` : merged.task);
|
|
2012
|
+
|
|
2013
|
+
const fanOutTasks: FanOutTask[] = [];
|
|
2014
|
+
for (let i = 0; i < taskList.length; i++) {
|
|
2015
|
+
const merged = mergeSubTaskParams(globalParams, taskList[i]);
|
|
2016
|
+
const name = displayTaskName(merged);
|
|
2017
|
+
const taskPrefix = `Task ${i + 1} ("${name}")`;
|
|
2018
|
+
// Per-task model override wins; the global model is the fallback.
|
|
2019
|
+
const stepModel = resolveStepModel(ctx, merged.model, globalModel);
|
|
2020
|
+
|
|
2021
|
+
const cwdResult = validateCwd(merged.effectiveCwd, ctx.cwd);
|
|
2022
|
+
if (!cwdResult.ok) {
|
|
2023
|
+
return {
|
|
2024
|
+
content: [{ type: "text" as const, text: `${taskPrefix}: Invalid cwd: ${cwdResult.error}` }],
|
|
2025
|
+
details: undefined,
|
|
2026
|
+
isError: true,
|
|
2027
|
+
};
|
|
2028
|
+
}
|
|
2029
|
+
let resolvedOutputFile: string | undefined;
|
|
2030
|
+
if (merged.outputFile) {
|
|
2031
|
+
const ofResult = validateOutputFile(merged.outputFile, cwdResult.value);
|
|
2032
|
+
if (!ofResult.ok) {
|
|
2033
|
+
return {
|
|
2034
|
+
content: [{ type: "text" as const, text: `${taskPrefix}: Invalid outputFile: ${ofResult.error}` }],
|
|
2035
|
+
details: undefined,
|
|
2036
|
+
isError: true,
|
|
2037
|
+
};
|
|
2038
|
+
}
|
|
2039
|
+
resolvedOutputFile = ofResult.value;
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
const validation = validatePreTask({
|
|
2043
|
+
task: merged.task,
|
|
2044
|
+
toolOptions: merged.toolOptions,
|
|
2045
|
+
thinkingLevel: merged.thinkingLevel,
|
|
2046
|
+
gitMode: globalParams.resolvedGitMode,
|
|
2047
|
+
outputFile: merged.outputFile,
|
|
2048
|
+
});
|
|
2049
|
+
if (validation.warnings.length > 0) {
|
|
2050
|
+
log.warn("Background fan-out pre-task validation warnings", { task: i + 1, warnings: validation.warnings });
|
|
2051
|
+
}
|
|
2052
|
+
if (!validation.valid) {
|
|
2053
|
+
const errText = validation.errors.join("; ");
|
|
2054
|
+
log.warn("Background fan-out pre-task validation failed", { task: i + 1, errors: validation.errors });
|
|
2055
|
+
return {
|
|
2056
|
+
content: [{ type: "text" as const, text: `${taskPrefix}: ${errText}` }],
|
|
2057
|
+
details: undefined,
|
|
2058
|
+
isError: true,
|
|
2059
|
+
};
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
fanOutTasks.push({ merged, model: stepModel, cwd: cwdResult.value, outputFile: resolvedOutputFile });
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// Spawn pass — sequential, in task order, through the shared tail.
|
|
2066
|
+
const started: Array<{ id: string; label?: string }> = [];
|
|
2067
|
+
const startedIdsText = (): string => started.map((s) => s.id).join(", ");
|
|
2068
|
+
|
|
2069
|
+
for (let i = 0; i < fanOutTasks.length; i++) {
|
|
2070
|
+
if (signal?.aborted) {
|
|
2071
|
+
if (started.length === 0) {
|
|
2072
|
+
return {
|
|
2073
|
+
content: [{ type: "text" as const, text: `Background fan-out cancelled before any agent started.` }],
|
|
2074
|
+
details: undefined,
|
|
2075
|
+
isError: true,
|
|
2076
|
+
};
|
|
2077
|
+
}
|
|
2078
|
+
return {
|
|
2079
|
+
content: [{ type: "text" as const, text:
|
|
2080
|
+
`Background fan-out cancelled after starting ${started.length} of ${fanOutTasks.length} agents ` +
|
|
2081
|
+
`(${startedIdsText()}) — they are detached and will still wake you.`
|
|
2082
|
+
}],
|
|
2083
|
+
details: undefined,
|
|
2084
|
+
isError: true,
|
|
2085
|
+
};
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
const { merged, model, cwd, outputFile } = fanOutTasks[i];
|
|
2089
|
+
const name = displayTaskName(merged);
|
|
2090
|
+
const subagentPrompt = buildSubagentPrompt(
|
|
2091
|
+
ctx.getSystemPrompt(),
|
|
2092
|
+
merged.inheritSP,
|
|
2093
|
+
merged.customSP,
|
|
2094
|
+
outputFile,
|
|
2095
|
+
merged.toolOptions?.tools,
|
|
2096
|
+
globalParams.resolvedPreset?.promptGuideline,
|
|
2097
|
+
);
|
|
2098
|
+
const unitPriority = merged.priority ?? (params.priority as string | undefined);
|
|
2099
|
+
|
|
2100
|
+
let agent: BackgroundAgent;
|
|
2101
|
+
try {
|
|
2102
|
+
agent = await startBackgroundAgent(pi, ctx, {
|
|
2103
|
+
task: merged.task,
|
|
2104
|
+
type: (params.preset as string | undefined) || 'general-purpose',
|
|
2105
|
+
description: merged.label,
|
|
2106
|
+
model: `${model.provider}/${model.id}`,
|
|
2107
|
+
thinkingLevel: merged.thinkingLevel,
|
|
2108
|
+
priority: unitPriority,
|
|
2109
|
+
systemPrompt: subagentPrompt,
|
|
2110
|
+
cwd,
|
|
2111
|
+
toolOptions: merged.toolOptions,
|
|
2112
|
+
timeout: merged.timeout,
|
|
2113
|
+
gitMode: globalParams.resolvedGitMode,
|
|
2114
|
+
// Retry parity with createUnitRun: snapshot this unit's resolved
|
|
2115
|
+
// values so a retry restores them.
|
|
2116
|
+
originalParams: snapshotOriginalParams({
|
|
2117
|
+
systemPrompt: merged.customSP,
|
|
2118
|
+
inheritSystemPrompt: merged.inheritSP,
|
|
2119
|
+
model: merged.model,
|
|
2120
|
+
thinkingLevel: merged.thinkingLevel,
|
|
2121
|
+
priority: unitPriority,
|
|
2122
|
+
outputFile: merged.outputFile,
|
|
2123
|
+
timeout: merged.timeout,
|
|
2124
|
+
cwd: merged.effectiveCwd,
|
|
2125
|
+
tools: merged.toolOptions?.tools,
|
|
2126
|
+
excludeTools: merged.toolOptions?.excludeTools,
|
|
2127
|
+
noBuiltinTools: merged.toolOptions?.noBuiltinTools,
|
|
2128
|
+
preset: globalParams.resolvedPreset?.name,
|
|
2129
|
+
}),
|
|
2130
|
+
sanitizeCwd: merged.effectiveCwd,
|
|
2131
|
+
});
|
|
2132
|
+
} catch (err) {
|
|
2133
|
+
const message = sanitizeErrorMessage(
|
|
2134
|
+
err instanceof Error ? err.message : String(err),
|
|
2135
|
+
merged.effectiveCwd,
|
|
2136
|
+
);
|
|
2137
|
+
log.error("Background fan-out spawn failed", { task: i + 1, error: message });
|
|
2138
|
+
const alreadyStarted = started.length > 0
|
|
2139
|
+
? `\n\nAlready started: ${startedIdsText()} — they are detached and will still wake you.`
|
|
2140
|
+
: "";
|
|
2141
|
+
return {
|
|
2142
|
+
content: [{ type: "text" as const, text:
|
|
2143
|
+
`Failed to spawn background agent for Task ${i + 1} ("${name}"): ${message}` + alreadyStarted
|
|
2144
|
+
}],
|
|
2145
|
+
details: undefined,
|
|
2146
|
+
isError: true,
|
|
2147
|
+
};
|
|
2148
|
+
}
|
|
2149
|
+
started.push({ id: agent.id, label: merged.label });
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
// Text-only result: ids in task order + the wake hint.
|
|
2153
|
+
const lines = started
|
|
2154
|
+
.map((s, i) => `${i + 1}. ${s.label ? `"${s.label}" ` : ""}— ${s.id}`)
|
|
2155
|
+
.join("\n");
|
|
2156
|
+
return {
|
|
2157
|
+
content: [{ type: "text" as const, text:
|
|
2158
|
+
`Background agents started: ${started.length}\n\n` +
|
|
2159
|
+
`${lines}\n\n` +
|
|
2160
|
+
`You'll be woken with a completion message as each finishes; use get_subagent_result({ agent_id }) for retrieval and stall checks.`
|
|
2161
|
+
}],
|
|
2162
|
+
details: undefined,
|
|
2163
|
+
};
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
// -------------------------------------------------------------------
|
|
2167
|
+
// startBackgroundAgent — the shared background-spawn tail.
|
|
2168
|
+
// Used by BOTH spawnBackgroundRun (single task) and runBackgroundFanOut
|
|
2169
|
+
// (tasks fan-out): the session-manager import, the spawn call, live-monitor
|
|
2170
|
+
// registration, footer counters, the 2s progress poller and the hard-cap
|
|
2171
|
+
// timer live here exactly once. Callers keep their own prelude, checks,
|
|
2172
|
+
// validations and result formatting. THROWS on spawn failure — callers
|
|
2173
|
+
// translate the error into their own result shape.
|
|
2174
|
+
// -------------------------------------------------------------------
|
|
2175
|
+
|
|
2176
|
+
async function startBackgroundAgent(
|
|
2177
|
+
pi: ExtensionAPI,
|
|
2178
|
+
ctx: ExtensionContext,
|
|
2179
|
+
spawn: {
|
|
2180
|
+
task: string;
|
|
2181
|
+
type?: string;
|
|
2182
|
+
description?: string;
|
|
2183
|
+
model?: string;
|
|
2184
|
+
thinkingLevel: ThinkingLevel;
|
|
2185
|
+
priority?: string;
|
|
2186
|
+
systemPrompt: string;
|
|
2187
|
+
cwd: string;
|
|
2188
|
+
toolOptions?: SubagentToolOptions;
|
|
2189
|
+
timeout?: number;
|
|
2190
|
+
gitMode: GitMode;
|
|
2191
|
+
originalParams?: SubagentRun["originalParams"];
|
|
2192
|
+
/** cwd used to redact paths from crash notifications. */
|
|
2193
|
+
sanitizeCwd: string;
|
|
2194
|
+
},
|
|
2195
|
+
): Promise<BackgroundAgent> {
|
|
2196
|
+
const { spawnBackgroundSession, setAgentFinalOutput, extractFinalOutput, updateAgentStatus } = await import('./session-manager');
|
|
2197
|
+
// Issue #31 (PR #76 review, DRY): extract final output from a
|
|
2198
|
+
// possibly-released ref — the poller may observe a terminal agent
|
|
2199
|
+
// whose ref was already nulled by the settlement path.
|
|
2200
|
+
const extractAgentFinalOutput = (a: BackgroundAgent): string =>
|
|
2201
|
+
extractFinalOutput(a._sessionRef ?? { messages: [] });
|
|
2202
|
+
|
|
2203
|
+
const agent = await spawnBackgroundSession(pi, ctx, {
|
|
2204
|
+
task: spawn.task,
|
|
2205
|
+
type: spawn.type,
|
|
2206
|
+
description: spawn.description,
|
|
2207
|
+
model: spawn.model,
|
|
2208
|
+
thinkingLevel: spawn.thinkingLevel,
|
|
2209
|
+
// Issue #114: per-unit priority rides the spawn so the background
|
|
2210
|
+
// run entry + drill-in header carry it like foreground runs.
|
|
2211
|
+
priority: spawn.priority,
|
|
2212
|
+
systemPrompt: spawn.systemPrompt,
|
|
2213
|
+
cwd: spawn.cwd,
|
|
2214
|
+
toolOptions: spawn.toolOptions,
|
|
2215
|
+
timeout: spawn.timeout,
|
|
2216
|
+
gitMode: spawn.gitMode,
|
|
2217
|
+
// Issue #98/#108: snapshotOriginalParams is the single source of truth
|
|
2218
|
+
// for the caller params a later retry restores.
|
|
2219
|
+
originalParams: spawn.originalParams,
|
|
2220
|
+
});
|
|
2221
|
+
|
|
2222
|
+
// Register for live monitor
|
|
2223
|
+
state.registerLiveSubagent(agent.id, {
|
|
2224
|
+
id: agent.id,
|
|
2225
|
+
label: agent.description,
|
|
2226
|
+
task: agent.task,
|
|
2227
|
+
model: agent.model,
|
|
2228
|
+
thinkingLevel: agent.thinkingLevel,
|
|
2229
|
+
priority: agent.priority,
|
|
2230
|
+
startedAt: agent.startedAt,
|
|
2231
|
+
ctx,
|
|
2232
|
+
});
|
|
2233
|
+
|
|
2234
|
+
// Update footer counters
|
|
2235
|
+
state.activeSubagents++;
|
|
2236
|
+
updateProgressStatus(state, ctx);
|
|
2237
|
+
|
|
2238
|
+
let completed = false;
|
|
2239
|
+
|
|
2240
|
+
// Poll for live progress
|
|
2241
|
+
const pollInterval = setInterval(() => {
|
|
2242
|
+
try {
|
|
2243
|
+
if (completed) return;
|
|
2244
|
+
|
|
2245
|
+
const session = agent._sessionRef;
|
|
2246
|
+
// Issue #31: a nulled ref on a TERMINAL agent is expected (the
|
|
2247
|
+
// settlement path releases the ref) — fall through to finalize
|
|
2248
|
+
// below instead of treating it as a crash. A nulled ref on a
|
|
2249
|
+
// live agent is still a crash.
|
|
2250
|
+
if (!session && !agent.completedAt) {
|
|
2251
|
+
// Session ref not available — session may have crashed.
|
|
2252
|
+
// Defensive: the ref is assigned synchronously before the poller starts
|
|
2253
|
+
// and is never nulled, so this is mostly unreachable — but keep the
|
|
2254
|
+
// bookkeeping safe regardless.
|
|
2255
|
+
completed = true;
|
|
2256
|
+
clearInterval(pollInterval);
|
|
2257
|
+
clearTimeout(hardCapHandle);
|
|
2258
|
+
// Capture whatever output exists (may be empty — fine).
|
|
2259
|
+
setAgentFinalOutput(agent.id, state.subagentSessions.get(agent.id)?.liveOutput ?? '');
|
|
2260
|
+
// Gate the counter on the finalize claim: if the
|
|
2261
|
+
// stale sweep already finalized this entry, the
|
|
2262
|
+
// decrement happened there (PR #71 review).
|
|
2263
|
+
if (state.finalizeLiveSubagent(agent.id)) {
|
|
2264
|
+
state.activeSubagents--;
|
|
2265
|
+
if (state.activeSubagents < 0) state.activeSubagents = 0;
|
|
2266
|
+
}
|
|
2267
|
+
state.failedSubagents++;
|
|
2268
|
+
updateProgressStatus(state, ctx);
|
|
2269
|
+
pi.sendMessage({
|
|
2270
|
+
customType: "subagent-notification",
|
|
2271
|
+
content: `Background agent "${agent.description}" crashed.`,
|
|
2272
|
+
display: true,
|
|
2273
|
+
details: { agentId: agent.id }
|
|
2274
|
+
}, { deliverAs: "followUp" });
|
|
2275
|
+
return;
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
// While running, update the live monitor.
|
|
2279
|
+
// Once completedAt is set, skip straight to finalize below.
|
|
2280
|
+
// Issue #31: past the guard above, the only nulled-ref path left is a
|
|
2281
|
+
// TERMINAL agent (settlement releases the ref), so a live agent always
|
|
2282
|
+
// has its session — the guard already returned on the live-nulled-ref
|
|
2283
|
+
// crash path. The compiler can't correlate the two checks, so assert it.
|
|
2284
|
+
if (!agent.completedAt) {
|
|
2285
|
+
const finalOutput = extractFinalOutput(session!);
|
|
2286
|
+
|
|
2287
|
+
try {
|
|
2288
|
+
const stats = session!.getSessionStats();
|
|
2289
|
+
state.updateLiveSubagent(agent.id, finalOutput, stats.tokens.input, stats.tokens.output);
|
|
2290
|
+
} catch {
|
|
2291
|
+
state.updateLiveSubagent(agent.id, finalOutput, 0, 0);
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
// Authoritative completion: the prompt promise settled — status and
|
|
2296
|
+
// completedAt are set by .then()/.catch() in spawnBackgroundSession.
|
|
2297
|
+
// Do NOT rely on !session.isStreaming: it is false during prompt()
|
|
2298
|
+
// preflight (auth check, model resolution) and for failed starts.
|
|
2299
|
+
if (agent.completedAt) {
|
|
2300
|
+
completed = true;
|
|
2301
|
+
clearInterval(pollInterval);
|
|
2302
|
+
clearTimeout(hardCapHandle);
|
|
2303
|
+
|
|
2304
|
+
// Issue #31: prefer the output already captured at settlement;
|
|
2305
|
+
// the ref may already be nulled on terminal agents, so never
|
|
2306
|
+
// overwrite with empty and stay null-safe.
|
|
2307
|
+
const finalOutput = agent.finalOutput ?? extractAgentFinalOutput(agent);
|
|
2308
|
+
setAgentFinalOutput(agent.id, finalOutput);
|
|
2309
|
+
|
|
2310
|
+
// Gate the counter on the finalize claim: if the
|
|
2311
|
+
// stale sweep already finalized this entry, the
|
|
2312
|
+
// decrement happened there (PR #71 review).
|
|
2313
|
+
if (state.finalizeLiveSubagent(agent.id)) {
|
|
2314
|
+
state.activeSubagents--;
|
|
2315
|
+
if (state.activeSubagents < 0) state.activeSubagents = 0;
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
if (agent.status === 'failed') {
|
|
2319
|
+
state.failedSubagents++;
|
|
2320
|
+
updateProgressStatus(state, ctx);
|
|
2321
|
+
pi.sendMessage({
|
|
2322
|
+
customType: "subagent-notification",
|
|
2323
|
+
content: `Background agent "${agent.description}" failed.`,
|
|
2324
|
+
display: true,
|
|
2325
|
+
details: { agentId: agent.id }
|
|
2326
|
+
}, { deliverAs: "followUp" });
|
|
2327
|
+
} else if (agent.status === 'stopped') {
|
|
2328
|
+
// User-initiated stop (stop_subagent) or deadline abort
|
|
2329
|
+
// (timeout/hard cap) — not a failure.
|
|
2330
|
+
updateProgressStatus(state, ctx);
|
|
2331
|
+
} else {
|
|
2332
|
+
state.completedSubagents++;
|
|
2333
|
+
state.unseenSubagents++;
|
|
2334
|
+
updateProgressStatus(state, ctx);
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
} catch (err) {
|
|
2338
|
+
if (!completed) {
|
|
2339
|
+
completed = true;
|
|
2340
|
+
clearInterval(pollInterval);
|
|
2341
|
+
clearTimeout(hardCapHandle);
|
|
2342
|
+
try {
|
|
2343
|
+
setAgentFinalOutput(agent.id, extractAgentFinalOutput(agent));
|
|
2344
|
+
} catch { /* ignore */ }
|
|
2345
|
+
// Gate the counter on the finalize claim: if the
|
|
2346
|
+
// stale sweep already finalized this entry, the
|
|
2347
|
+
// decrement happened there (PR #71 review).
|
|
2348
|
+
if (state.finalizeLiveSubagent(agent.id)) {
|
|
2349
|
+
state.activeSubagents--;
|
|
2350
|
+
if (state.activeSubagents < 0) state.activeSubagents = 0;
|
|
2351
|
+
}
|
|
2352
|
+
state.failedSubagents++;
|
|
2353
|
+
updateProgressStatus(state, ctx);
|
|
2354
|
+
pi.sendMessage({
|
|
2355
|
+
customType: "subagent-notification",
|
|
2356
|
+
content: `Background agent "${agent.description}" crashed: ${sanitizeErrorMessage((err as Error).message, spawn.sanitizeCwd)}`,
|
|
2357
|
+
display: true,
|
|
2358
|
+
details: { agentId: agent.id }
|
|
2359
|
+
}, { deliverAs: "followUp" });
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
}, 2000);
|
|
2363
|
+
|
|
2364
|
+
// Hard cap: stop polling AND abort the session after the deadline.
|
|
2365
|
+
// W2 (issue #28): previously this only stopped the poller — the pi
|
|
2366
|
+
// session kept running forever (orphaned). Now it pre-sets
|
|
2367
|
+
// 'stopped' (so the .then keeps it, per W1) and aborts the
|
|
2368
|
+
// session. The cap honors a shorter per-agent timeout.
|
|
2369
|
+
const hardCapMs = Math.min(spawn.timeout ?? 30 * 60 * 1000, 30 * 60 * 1000);
|
|
2370
|
+
const hardCapHandle = setTimeout(() => {
|
|
2371
|
+
if (!completed && !agent.completedAt) {
|
|
2372
|
+
try {
|
|
2373
|
+
completed = true;
|
|
2374
|
+
clearInterval(pollInterval);
|
|
2375
|
+
// Pre-set stopped BEFORE aborting so the .then in
|
|
2376
|
+
// spawnBackgroundSession keeps the stopped state.
|
|
2377
|
+
updateAgentStatus(agent.id, 'stopped', `Timed out (${hardCapMs}ms hard cap)`);
|
|
2378
|
+
agent._sessionRef?.abort().catch(() => {
|
|
2379
|
+
// Abort may reject if the session is mid-dispose;
|
|
2380
|
+
// the status flip above is already recorded.
|
|
2381
|
+
});
|
|
2382
|
+
// Capture whatever final output exists in the session
|
|
2383
|
+
const hardCapSession = agent._sessionRef;
|
|
2384
|
+
const hardCapFinalOutput = hardCapSession ? extractFinalOutput(hardCapSession) : '';
|
|
2385
|
+
setAgentFinalOutput(agent.id, hardCapFinalOutput);
|
|
2386
|
+
// Gate the counter on the finalize claim: if the
|
|
2387
|
+
// stale sweep already finalized this entry, the
|
|
2388
|
+
// decrement happened there (PR #71 review).
|
|
2389
|
+
if (state.finalizeLiveSubagent(agent.id)) {
|
|
2390
|
+
state.activeSubagents--;
|
|
2391
|
+
if (state.activeSubagents < 0) state.activeSubagents = 0;
|
|
2392
|
+
}
|
|
2393
|
+
// m6: deadline abort is a stop, not a completion — mirror the
|
|
2394
|
+
// W3/poller stopped path (no completedSubagents increment).
|
|
2395
|
+
updateProgressStatus(state, ctx);
|
|
2396
|
+
} catch (err) {
|
|
2397
|
+
// Defensive: never let the hard-cap timer throw uncaught — that would
|
|
2398
|
+
// skip finalizeLiveSubagent, the counter decrement, and the notification.
|
|
2399
|
+
// Do all fallible work first (output capture), then mutate counters,
|
|
2400
|
+
// then notify — a throw mid-path can't double-fire mutations.
|
|
2401
|
+
completed = true;
|
|
2402
|
+
clearInterval(pollInterval);
|
|
2403
|
+
// m3: the try may have thrown BEFORE updateAgentStatus ran (e.g.
|
|
2404
|
+
// persistAgent fs failure) — leave the record terminal so
|
|
2405
|
+
// get_subagent_result doesn't report 'running' and the W3 timer
|
|
2406
|
+
// guard (!agent.completedAt) can't re-fire later.
|
|
2407
|
+
try {
|
|
2408
|
+
updateAgentStatus(agent.id, 'stopped', `Timed out (${hardCapMs}ms hard cap)`);
|
|
2409
|
+
} catch { /* ignore */ }
|
|
2410
|
+
try {
|
|
2411
|
+
setAgentFinalOutput(agent.id, extractAgentFinalOutput(agent));
|
|
2412
|
+
} catch { /* ignore */ }
|
|
2413
|
+
// Gate the counter on the finalize claim: if the
|
|
2414
|
+
// stale sweep already finalized this entry, the
|
|
2415
|
+
// decrement happened there (PR #71 review).
|
|
2416
|
+
if (state.finalizeLiveSubagent(agent.id)) {
|
|
2417
|
+
state.activeSubagents--;
|
|
2418
|
+
if (state.activeSubagents < 0) state.activeSubagents = 0;
|
|
2419
|
+
}
|
|
2420
|
+
state.failedSubagents++;
|
|
2421
|
+
updateProgressStatus(state, ctx);
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
}, hardCapMs);
|
|
2425
|
+
|
|
2426
|
+
log.info("Background agent spawned", { agentId: agent.id, task: spawn.task, model: agent.model });
|
|
2427
|
+
|
|
2428
|
+
return agent;
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
// -------------------------------------------------------------------
|
|
2432
|
+
// spawnBackgroundRun — Phase 6.5 background spawn, extracted verbatim
|
|
2433
|
+
// from execute() (#198 phase 1) so a later phase can fan out N runs.
|
|
2434
|
+
// -------------------------------------------------------------------
|
|
2435
|
+
|
|
2436
|
+
async function spawnBackgroundRun(
|
|
2437
|
+
pi: ExtensionAPI,
|
|
2438
|
+
ctx: ExtensionContext,
|
|
2439
|
+
params: Omit<Parameters<typeof resolveSubagentParams>[0], "task"> & {
|
|
2440
|
+
task?: string;
|
|
2441
|
+
model?: string;
|
|
2442
|
+
priority?: string;
|
|
2443
|
+
},
|
|
2444
|
+
singleTask: string,
|
|
2445
|
+
): Promise<ToolResult<undefined>> {
|
|
2446
|
+
// Phase 6.5: Background execution — spawn session and return ID immediately.
|
|
2447
|
+
// Resolve the preset and its model BEFORE the background branch so the
|
|
2448
|
+
// preset's model (and system prompt) are honored in background mode.
|
|
2449
|
+
const bgResolved = resolveSubagentParams({ ...params, task: singleTask }, state, ctx, log);
|
|
2450
|
+
const { resolvedPreset: bgResolvedPreset, autoRoutedPreset: bgAutoRoutedPreset } = bgResolved;
|
|
2451
|
+
const bgModelResult = resolveSubagentModel(ctx, bgResolvedPreset, params.model);
|
|
2452
|
+
const bgModel = bgModelResult.ok
|
|
2453
|
+
? `${bgModelResult.model.provider}/${bgModelResult.model.id}`
|
|
2454
|
+
: undefined;
|
|
2455
|
+
|
|
2456
|
+
try {
|
|
2457
|
+
// W5 (issue #28): approvalMode 'always' cannot work in background —
|
|
2458
|
+
// there is no interactive dialog to approve the diff. Reject loudly
|
|
2459
|
+
// instead of silently running unattended with write access.
|
|
2460
|
+
if (bgResolved.resolvedApprovalMode === 'always') {
|
|
2461
|
+
return {
|
|
2462
|
+
content: [{ type: "text" as const, text:
|
|
2463
|
+
`Cannot spawn background agent with approvalMode 'always': background agents run unattended ` +
|
|
2464
|
+
`and cannot present the approval dialog. Use approvalMode 'auto' (default) or 'writes'.`
|
|
2465
|
+
}],
|
|
2466
|
+
details: undefined,
|
|
2467
|
+
isError: true,
|
|
2468
|
+
};
|
|
2469
|
+
}
|
|
2470
|
+
// W5: 'writes' in background silently auto-approves — warn once so the
|
|
2471
|
+
// caller knows the diff will NOT be gated on approval.
|
|
2472
|
+
if (bgResolved.resolvedApprovalMode === 'writes') {
|
|
2473
|
+
log.warn("Background agent spawned with approvalMode 'writes' — auto-approving (no dialog in background)", {
|
|
2474
|
+
task: params.task,
|
|
2475
|
+
});
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
// W6 (issue #28): session cost limit must gate background spawns too —
|
|
2479
|
+
// the R5 check after the background branch never runs for them.
|
|
2480
|
+
// Same estimate/limit logic as single mode.
|
|
2481
|
+
const bgPerTaskEstimate = state.config.perTaskCostEstimate > 0
|
|
2482
|
+
? state.config.perTaskCostEstimate
|
|
2483
|
+
: 0.05;
|
|
2484
|
+
if (state.checkCostLimit(bgPerTaskEstimate, ctx)) {
|
|
2485
|
+
const bgLimit = state.config.sessionCostLimit;
|
|
2486
|
+
const bgCurrentTotal = state.getSessionTotalCost(ctx);
|
|
2487
|
+
log.warn("Background delegation rejected: session cost limit reached", {
|
|
2488
|
+
currentTotal: bgCurrentTotal,
|
|
2489
|
+
estimatedCost: bgPerTaskEstimate,
|
|
2490
|
+
limit: bgLimit,
|
|
2491
|
+
});
|
|
2492
|
+
return {
|
|
2493
|
+
content: [
|
|
2494
|
+
{
|
|
2495
|
+
type: "text" as const,
|
|
2496
|
+
text:
|
|
2497
|
+
`Cannot delegate: session cost limit reached ` +
|
|
2498
|
+
`($${bgCurrentTotal.toFixed(4)} spent of $${bgLimit.toFixed(2)} limit). ` +
|
|
2499
|
+
`Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
|
|
2500
|
+
},
|
|
2501
|
+
],
|
|
2502
|
+
details: undefined,
|
|
2503
|
+
isError: true,
|
|
2504
|
+
};
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
// F1: Validate cwd + outputFile the same way foreground single mode does —
|
|
2508
|
+
// an unvalidated outputFile would reach the prompt and could steer the
|
|
2509
|
+
// background agent's write tool outside the project root.
|
|
2510
|
+
const bgCwdResult = validateCwd(bgResolved.effectiveCwd, ctx.cwd);
|
|
2511
|
+
if (!bgCwdResult.ok) {
|
|
2512
|
+
return {
|
|
2513
|
+
content: [{ type: "text" as const, text: `Invalid cwd: ${bgCwdResult.error}` }],
|
|
2514
|
+
details: undefined,
|
|
2515
|
+
isError: true,
|
|
2516
|
+
};
|
|
2517
|
+
}
|
|
2518
|
+
let bgResolvedOutputFile: string | undefined;
|
|
2519
|
+
if (bgResolved.outputFile) {
|
|
2520
|
+
const ofResult = validateOutputFile(bgResolved.outputFile, bgCwdResult.value);
|
|
2521
|
+
if (!ofResult.ok) {
|
|
2522
|
+
return {
|
|
2523
|
+
content: [
|
|
2524
|
+
{ type: "text" as const, text: `Invalid outputFile: ${ofResult.error}` },
|
|
2525
|
+
],
|
|
2526
|
+
details: undefined,
|
|
2527
|
+
isError: true,
|
|
2528
|
+
};
|
|
2529
|
+
}
|
|
2530
|
+
bgResolvedOutputFile = ofResult.value;
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
// C: H1 validation for background mode — reject outputFile-vs-write
|
|
2534
|
+
// conflicts before spawning (loud failure, same as single mode).
|
|
2535
|
+
const bgValidation = validatePreTask({
|
|
2536
|
+
task: singleTask,
|
|
2537
|
+
toolOptions: bgResolved.toolOptions,
|
|
2538
|
+
thinkingLevel: bgResolved.thinkingLevel,
|
|
2539
|
+
gitMode: bgResolved.resolvedGitMode,
|
|
2540
|
+
outputFile: bgResolvedOutputFile,
|
|
2541
|
+
});
|
|
2542
|
+
if (bgValidation.warnings.length > 0) {
|
|
2543
|
+
log.warn("Background pre-task validation warnings", { warnings: bgValidation.warnings });
|
|
2544
|
+
}
|
|
2545
|
+
if (!bgValidation.valid) {
|
|
2546
|
+
const errText = bgValidation.errors.join("; ");
|
|
2547
|
+
log.warn("Background pre-task validation failed", { errors: bgValidation.errors });
|
|
2548
|
+
return {
|
|
2549
|
+
content: [{ type: "text" as const, text: errText }],
|
|
2550
|
+
details: undefined,
|
|
2551
|
+
isError: true,
|
|
2552
|
+
};
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
// Build the full prompt the same way foreground single mode does:
|
|
2556
|
+
// base prompt (optionally inherited) + custom prompt + preset guidance.
|
|
2557
|
+
const bgPrompt = buildSubagentPrompt(
|
|
2558
|
+
ctx.getSystemPrompt(),
|
|
2559
|
+
bgResolved.inheritSP,
|
|
2560
|
+
bgResolved.customSP,
|
|
2561
|
+
bgResolvedOutputFile,
|
|
2562
|
+
bgResolved.toolOptions?.tools,
|
|
2563
|
+
bgResolvedPreset?.promptGuideline,
|
|
2564
|
+
);
|
|
2565
|
+
|
|
2566
|
+
const agent = await startBackgroundAgent(pi, ctx, {
|
|
2567
|
+
task: singleTask,
|
|
2568
|
+
type: params.preset || 'general-purpose',
|
|
2569
|
+
description: params.label,
|
|
2570
|
+
model: bgModel,
|
|
2571
|
+
thinkingLevel: bgResolved.thinkingLevel,
|
|
2572
|
+
priority: params.priority,
|
|
2573
|
+
systemPrompt: bgPrompt,
|
|
2574
|
+
cwd: bgCwdResult.value,
|
|
2575
|
+
toolOptions: bgResolved.toolOptions,
|
|
2576
|
+
timeout: bgResolved.timeout,
|
|
2577
|
+
gitMode: bgResolved.resolvedGitMode,
|
|
2578
|
+
originalParams: snapshotOriginalParams(params),
|
|
2579
|
+
sanitizeCwd: bgResolved.effectiveCwd,
|
|
2580
|
+
});
|
|
2581
|
+
|
|
2582
|
+
// B2: Surface auto-route decisions in background spawn result too.
|
|
2583
|
+
// Derive the restriction from the RESOLVED toolOptions (which reflects
|
|
2584
|
+
// per-call overrides) rather than the preset's declared values.
|
|
2585
|
+
const bgAutoRouteNote = bgAutoRoutedPreset
|
|
2586
|
+
? `\n\n[auto-routed to preset '${bgAutoRoutedPreset.name}' — ${bgResolved.toolOptions ? formatToolRestriction(bgResolved.toolOptions) : formatPresetRestriction(bgAutoRoutedPreset)}]`
|
|
2587
|
+
: "";
|
|
2588
|
+
|
|
2589
|
+
return {
|
|
2590
|
+
content: [{
|
|
2591
|
+
type: "text" as const,
|
|
2592
|
+
text: `Background agent started: ${agent.id}\n\n` +
|
|
2593
|
+
`Description: ${agent.description}\n` +
|
|
2594
|
+
`Task: ${agent.task}\n` +
|
|
2595
|
+
`Status: ${agent.status}\n\n` +
|
|
2596
|
+
`You'll be woken with a completion message when it finishes; use get_subagent_result({ agent_id: "${agent.id}" }) for retrieval and stall checks.` +
|
|
2597
|
+
bgAutoRouteNote,
|
|
2598
|
+
}],
|
|
2599
|
+
details: undefined,
|
|
2600
|
+
};
|
|
2601
|
+
} catch (err) {
|
|
2602
|
+
const message = sanitizeErrorMessage(
|
|
2603
|
+
err instanceof Error ? err.message : String(err),
|
|
2604
|
+
bgResolved.effectiveCwd,
|
|
2605
|
+
);
|
|
2606
|
+
log.error("Failed to spawn background agent", { error: message });
|
|
2607
|
+
return {
|
|
2608
|
+
content: [{ type: "text" as const, text: `Failed to spawn background agent: ${message}` }],
|
|
2609
|
+
details: undefined,
|
|
2610
|
+
isError: true,
|
|
2611
|
+
};
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
|
|
1881
2615
|
// -------------------------------------------------------------------
|
|
1882
2616
|
// /brl-subagent command
|
|
1883
2617
|
// -------------------------------------------------------------------
|
|
@@ -1980,7 +2714,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1980
2714
|
buildTemplateGuideline(templateSummary),
|
|
1981
2715
|
"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.",
|
|
1982
2716
|
"Set retryOnTimeout: true to automatically retry a subagent that times out. Only retries once — the second timeout is treated as a final failure.",
|
|
1983
|
-
"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.",
|
|
2717
|
+
"Set background: true to run the subagent in the background without blocking. The tool returns immediately with an agent ID. With tasks, background fans out: every task starts as its own background agent, the call returns one ID per task in task order, and the conductor is woken once per agent as each finishes; chain and graph cannot be combined with background (rejected). 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.",
|
|
1984
2718
|
"",
|
|
1985
2719
|
"## Conductor Guardrails",
|
|
1986
2720
|
"",
|
|
@@ -2135,6 +2869,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2135
2869
|
"Run the subagent in the background without blocking the conductor. " +
|
|
2136
2870
|
"When true, the tool returns immediately with an agent ID. " +
|
|
2137
2871
|
"The conductor is woken with a completion message; use get_subagent_result for post-wake retrieval and stall checks. " +
|
|
2872
|
+
"Supports a single task or the tasks array (a fan-out that starts one background agent per task; with tasks the call returns one ID per task and the conductor is woken once per agent as each finishes); combining background with chain or graph is rejected. " +
|
|
2138
2873
|
"Default: false (blocking mode).",
|
|
2139
2874
|
}),
|
|
2140
2875
|
),
|
|
@@ -2396,7 +3131,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2396
3131
|
// resolving templates first keeps a combined template + retryRunId call
|
|
2397
3132
|
// from silently disabling template resolution (issue #175 review).
|
|
2398
3133
|
if (params.retryRunId) {
|
|
2399
|
-
const runEntry = state.
|
|
3134
|
+
const runEntry = state.findSpawnRunById(ctx, params.retryRunId);
|
|
2400
3135
|
if (runEntry) {
|
|
2401
3136
|
params = resolveRetryParams(params, runEntry);
|
|
2402
3137
|
} else {
|
|
@@ -2456,6 +3191,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
2456
3191
|
};
|
|
2457
3192
|
}
|
|
2458
3193
|
|
|
3194
|
+
// background: true with chain/graph was silently ignored — the batch ran
|
|
3195
|
+
// foreground (blocking until every unit finished) while the caller believed
|
|
3196
|
+
// it was background. Both remain rejected loudly at dispatch validation;
|
|
3197
|
+
// tasks (parallel) fans out into independent background agents instead.
|
|
3198
|
+
if (params.background && (isChain || isGraph)) {
|
|
3199
|
+
const batchMode = isChain ? "chain" : "graph";
|
|
3200
|
+
return {
|
|
3201
|
+
content: [
|
|
3202
|
+
{
|
|
3203
|
+
type: "text" as const,
|
|
3204
|
+
text: `background: true is not supported with ${batchMode} — batch modes run foreground and would block until every unit finishes. Remove background to run it foreground, or start each unit as its own single-task background call and sequence the dependencies yourself.`,
|
|
3205
|
+
},
|
|
3206
|
+
],
|
|
3207
|
+
details: undefined,
|
|
3208
|
+
isError: true,
|
|
3209
|
+
};
|
|
3210
|
+
}
|
|
3211
|
+
|
|
2459
3212
|
if (isChain) {
|
|
2460
3213
|
if (params.chain!.length > MAX_CHAIN_STEPS) {
|
|
2461
3214
|
return {
|
|
@@ -2485,6 +3238,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2485
3238
|
isError: true,
|
|
2486
3239
|
};
|
|
2487
3240
|
}
|
|
3241
|
+
if (params.background) return runBackgroundFanOut(pi, ctx, params, signal);
|
|
2488
3242
|
return runParallelMode(params, signal, onUpdate, ctx);
|
|
2489
3243
|
}
|
|
2490
3244
|
|
|
@@ -2497,391 +3251,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
2497
3251
|
// boolean-array filter — assert, mirroring params.chain!/params.tasks! above.
|
|
2498
3252
|
const singleTask = params.task!; // non-empty (sanitizer guarantees; isSingle confirms)
|
|
2499
3253
|
|
|
2500
|
-
// Phase 6.5: Background execution — spawn session and return ID immediately.
|
|
2501
|
-
// Resolve the preset and its model BEFORE the background branch so the
|
|
2502
|
-
// preset's model (and system prompt) are honored in background mode.
|
|
2503
|
-
const bgResolved = resolveSubagentParams({ ...params, task: singleTask }, state, ctx, log);
|
|
2504
|
-
const { resolvedPreset: bgResolvedPreset, autoRoutedPreset: bgAutoRoutedPreset } = bgResolved;
|
|
2505
|
-
const bgModelResult = resolveSubagentModel(ctx, bgResolvedPreset, params.model);
|
|
2506
|
-
const bgModel = bgModelResult.ok
|
|
2507
|
-
? `${bgModelResult.model.provider}/${bgModelResult.model.id}`
|
|
2508
|
-
: undefined;
|
|
2509
|
-
|
|
2510
3254
|
if (params.background) {
|
|
2511
|
-
|
|
2512
|
-
// Issue #31 (PR #76 review, DRY): extract final output from a
|
|
2513
|
-
// possibly-released ref — the poller may observe a terminal agent
|
|
2514
|
-
// whose ref was already nulled by the settlement path.
|
|
2515
|
-
const extractAgentFinalOutput = (a: BackgroundAgent): string =>
|
|
2516
|
-
extractFinalOutput(a._sessionRef ?? { messages: [] });
|
|
2517
|
-
|
|
2518
|
-
try {
|
|
2519
|
-
// W5 (issue #28): approvalMode 'always' cannot work in background —
|
|
2520
|
-
// there is no interactive dialog to approve the diff. Reject loudly
|
|
2521
|
-
// instead of silently running unattended with write access.
|
|
2522
|
-
if (bgResolved.resolvedApprovalMode === 'always') {
|
|
2523
|
-
return {
|
|
2524
|
-
content: [{ type: "text" as const, text:
|
|
2525
|
-
`Cannot spawn background agent with approvalMode 'always': background agents run unattended ` +
|
|
2526
|
-
`and cannot present the approval dialog. Use approvalMode 'auto' (default) or 'writes'.`
|
|
2527
|
-
}],
|
|
2528
|
-
details: undefined,
|
|
2529
|
-
isError: true,
|
|
2530
|
-
};
|
|
2531
|
-
}
|
|
2532
|
-
// W5: 'writes' in background silently auto-approves — warn once so the
|
|
2533
|
-
// caller knows the diff will NOT be gated on approval.
|
|
2534
|
-
if (bgResolved.resolvedApprovalMode === 'writes') {
|
|
2535
|
-
log.warn("Background agent spawned with approvalMode 'writes' — auto-approving (no dialog in background)", {
|
|
2536
|
-
task: params.task,
|
|
2537
|
-
});
|
|
2538
|
-
}
|
|
2539
|
-
|
|
2540
|
-
// W6 (issue #28): session cost limit must gate background spawns too —
|
|
2541
|
-
// the R5 check after the background branch never runs for them.
|
|
2542
|
-
// Same estimate/limit logic as single mode.
|
|
2543
|
-
const bgPerTaskEstimate = state.config.perTaskCostEstimate > 0
|
|
2544
|
-
? state.config.perTaskCostEstimate
|
|
2545
|
-
: 0.05;
|
|
2546
|
-
if (state.checkCostLimit(bgPerTaskEstimate, ctx)) {
|
|
2547
|
-
const bgLimit = state.config.sessionCostLimit;
|
|
2548
|
-
const bgCurrentTotal = state.getSessionTotalCost(ctx);
|
|
2549
|
-
log.warn("Background delegation rejected: session cost limit reached", {
|
|
2550
|
-
currentTotal: bgCurrentTotal,
|
|
2551
|
-
estimatedCost: bgPerTaskEstimate,
|
|
2552
|
-
limit: bgLimit,
|
|
2553
|
-
});
|
|
2554
|
-
return {
|
|
2555
|
-
content: [
|
|
2556
|
-
{
|
|
2557
|
-
type: "text" as const,
|
|
2558
|
-
text:
|
|
2559
|
-
`Cannot delegate: session cost limit reached ` +
|
|
2560
|
-
`($${bgCurrentTotal.toFixed(4)} spent of $${bgLimit.toFixed(2)} limit). ` +
|
|
2561
|
-
`Increase the limit via /brl-subagent costlimit or set to 0 for unlimited.`,
|
|
2562
|
-
},
|
|
2563
|
-
],
|
|
2564
|
-
details: undefined,
|
|
2565
|
-
isError: true,
|
|
2566
|
-
};
|
|
2567
|
-
}
|
|
2568
|
-
|
|
2569
|
-
// F1: Validate cwd + outputFile the same way foreground single mode does —
|
|
2570
|
-
// an unvalidated outputFile would reach the prompt and could steer the
|
|
2571
|
-
// background agent's write tool outside the project root.
|
|
2572
|
-
const bgCwdResult = validateCwd(bgResolved.effectiveCwd, ctx.cwd);
|
|
2573
|
-
if (!bgCwdResult.ok) {
|
|
2574
|
-
return {
|
|
2575
|
-
content: [{ type: "text" as const, text: `Invalid cwd: ${bgCwdResult.error}` }],
|
|
2576
|
-
details: undefined,
|
|
2577
|
-
isError: true,
|
|
2578
|
-
};
|
|
2579
|
-
}
|
|
2580
|
-
let bgResolvedOutputFile: string | undefined;
|
|
2581
|
-
if (bgResolved.outputFile) {
|
|
2582
|
-
const ofResult = validateOutputFile(bgResolved.outputFile, bgCwdResult.value);
|
|
2583
|
-
if (!ofResult.ok) {
|
|
2584
|
-
return {
|
|
2585
|
-
content: [
|
|
2586
|
-
{ type: "text" as const, text: `Invalid outputFile: ${ofResult.error}` },
|
|
2587
|
-
],
|
|
2588
|
-
details: undefined,
|
|
2589
|
-
isError: true,
|
|
2590
|
-
};
|
|
2591
|
-
}
|
|
2592
|
-
bgResolvedOutputFile = ofResult.value;
|
|
2593
|
-
}
|
|
2594
|
-
|
|
2595
|
-
// C: H1 validation for background mode — reject outputFile-vs-write
|
|
2596
|
-
// conflicts before spawning (loud failure, same as single mode).
|
|
2597
|
-
const bgValidation = validatePreTask({
|
|
2598
|
-
task: singleTask,
|
|
2599
|
-
toolOptions: bgResolved.toolOptions,
|
|
2600
|
-
thinkingLevel: bgResolved.thinkingLevel,
|
|
2601
|
-
gitMode: bgResolved.resolvedGitMode,
|
|
2602
|
-
outputFile: bgResolvedOutputFile,
|
|
2603
|
-
});
|
|
2604
|
-
if (bgValidation.warnings.length > 0) {
|
|
2605
|
-
log.warn("Background pre-task validation warnings", { warnings: bgValidation.warnings });
|
|
2606
|
-
}
|
|
2607
|
-
if (!bgValidation.valid) {
|
|
2608
|
-
const errText = bgValidation.errors.join("; ");
|
|
2609
|
-
log.warn("Background pre-task validation failed", { errors: bgValidation.errors });
|
|
2610
|
-
return {
|
|
2611
|
-
content: [{ type: "text" as const, text: errText }],
|
|
2612
|
-
details: undefined,
|
|
2613
|
-
isError: true,
|
|
2614
|
-
};
|
|
2615
|
-
}
|
|
2616
|
-
|
|
2617
|
-
// Build the full prompt the same way foreground single mode does:
|
|
2618
|
-
// base prompt (optionally inherited) + custom prompt + preset guidance.
|
|
2619
|
-
const bgPrompt = buildSubagentPrompt(
|
|
2620
|
-
ctx.getSystemPrompt(),
|
|
2621
|
-
bgResolved.inheritSP,
|
|
2622
|
-
bgResolved.customSP,
|
|
2623
|
-
bgResolvedOutputFile,
|
|
2624
|
-
bgResolved.toolOptions?.tools,
|
|
2625
|
-
bgResolvedPreset?.promptGuideline,
|
|
2626
|
-
);
|
|
2627
|
-
|
|
2628
|
-
const agent = await spawnBackgroundSession(pi, ctx, {
|
|
2629
|
-
task: singleTask,
|
|
2630
|
-
type: params.preset || 'general-purpose',
|
|
2631
|
-
description: params.label,
|
|
2632
|
-
model: bgModel,
|
|
2633
|
-
thinkingLevel: bgResolved.thinkingLevel,
|
|
2634
|
-
// Issue #114: per-unit priority rides the spawn so the background
|
|
2635
|
-
// run entry + drill-in header carry it like foreground runs.
|
|
2636
|
-
priority: params.priority,
|
|
2637
|
-
systemPrompt: bgPrompt,
|
|
2638
|
-
cwd: bgCwdResult.value,
|
|
2639
|
-
toolOptions: bgResolved.toolOptions,
|
|
2640
|
-
timeout: bgResolved.timeout,
|
|
2641
|
-
gitMode: bgResolved.resolvedGitMode,
|
|
2642
|
-
// Issue #98: snapshot the caller's raw params on the run entry so a
|
|
2643
|
-
// retry of this background run restores them — single source of truth
|
|
2644
|
-
// snapshotOriginalParams (issue #108: no more duplicated 11-field literal).
|
|
2645
|
-
originalParams: snapshotOriginalParams(params),
|
|
2646
|
-
});
|
|
2647
|
-
|
|
2648
|
-
// Register for live monitor
|
|
2649
|
-
state.registerLiveSubagent(agent.id, {
|
|
2650
|
-
id: agent.id,
|
|
2651
|
-
label: agent.description,
|
|
2652
|
-
task: agent.task,
|
|
2653
|
-
model: agent.model,
|
|
2654
|
-
thinkingLevel: agent.thinkingLevel,
|
|
2655
|
-
priority: agent.priority,
|
|
2656
|
-
startedAt: agent.startedAt,
|
|
2657
|
-
ctx,
|
|
2658
|
-
});
|
|
2659
|
-
|
|
2660
|
-
// Update footer counters
|
|
2661
|
-
state.activeSubagents++;
|
|
2662
|
-
updateProgressStatus(state, ctx);
|
|
2663
|
-
|
|
2664
|
-
let completed = false;
|
|
2665
|
-
|
|
2666
|
-
// Poll for live progress
|
|
2667
|
-
const pollInterval = setInterval(() => {
|
|
2668
|
-
try {
|
|
2669
|
-
if (completed) return;
|
|
2670
|
-
|
|
2671
|
-
const session = agent._sessionRef;
|
|
2672
|
-
// Issue #31: a nulled ref on a TERMINAL agent is expected (the
|
|
2673
|
-
// settlement path releases the ref) — fall through to finalize
|
|
2674
|
-
// below instead of treating it as a crash. A nulled ref on a
|
|
2675
|
-
// live agent is still a crash.
|
|
2676
|
-
if (!session && !agent.completedAt) {
|
|
2677
|
-
// Session ref not available — session may have crashed.
|
|
2678
|
-
// Defensive: the ref is assigned synchronously before the poller starts
|
|
2679
|
-
// and is never nulled, so this is mostly unreachable — but keep the
|
|
2680
|
-
// bookkeeping safe regardless.
|
|
2681
|
-
completed = true;
|
|
2682
|
-
clearInterval(pollInterval);
|
|
2683
|
-
clearTimeout(hardCapHandle);
|
|
2684
|
-
// Capture whatever output exists (may be empty — fine).
|
|
2685
|
-
setAgentFinalOutput(agent.id, state.subagentSessions.get(agent.id)?.liveOutput ?? '');
|
|
2686
|
-
// Gate the counter on the finalize claim: if the
|
|
2687
|
-
// stale sweep already finalized this entry, the
|
|
2688
|
-
// decrement happened there (PR #71 review).
|
|
2689
|
-
if (state.finalizeLiveSubagent(agent.id)) {
|
|
2690
|
-
state.activeSubagents--;
|
|
2691
|
-
if (state.activeSubagents < 0) state.activeSubagents = 0;
|
|
2692
|
-
}
|
|
2693
|
-
state.failedSubagents++;
|
|
2694
|
-
updateProgressStatus(state, ctx);
|
|
2695
|
-
pi.sendMessage({
|
|
2696
|
-
customType: "subagent-notification",
|
|
2697
|
-
content: `Background agent "${agent.description}" crashed.`,
|
|
2698
|
-
display: true,
|
|
2699
|
-
details: { agentId: agent.id }
|
|
2700
|
-
}, { deliverAs: "followUp" });
|
|
2701
|
-
return;
|
|
2702
|
-
}
|
|
2703
|
-
|
|
2704
|
-
// While running, update the live monitor.
|
|
2705
|
-
// Once completedAt is set, skip straight to finalize below.
|
|
2706
|
-
// Issue #31: past the guard above, the only nulled-ref path left is a
|
|
2707
|
-
// TERMINAL agent (settlement releases the ref), so a live agent always
|
|
2708
|
-
// has its session — the guard already returned on the live-nulled-ref
|
|
2709
|
-
// crash path. The compiler can't correlate the two checks, so assert it.
|
|
2710
|
-
if (!agent.completedAt) {
|
|
2711
|
-
const finalOutput = extractFinalOutput(session!);
|
|
2712
|
-
|
|
2713
|
-
try {
|
|
2714
|
-
const stats = session!.getSessionStats();
|
|
2715
|
-
state.updateLiveSubagent(agent.id, finalOutput, stats.tokens.input, stats.tokens.output);
|
|
2716
|
-
} catch {
|
|
2717
|
-
state.updateLiveSubagent(agent.id, finalOutput, 0, 0);
|
|
2718
|
-
}
|
|
2719
|
-
}
|
|
2720
|
-
|
|
2721
|
-
// Authoritative completion: the prompt promise settled — status and
|
|
2722
|
-
// completedAt are set by .then()/.catch() in spawnBackgroundSession.
|
|
2723
|
-
// Do NOT rely on !session.isStreaming: it is false during prompt()
|
|
2724
|
-
// preflight (auth check, model resolution) and for failed starts.
|
|
2725
|
-
if (agent.completedAt) {
|
|
2726
|
-
completed = true;
|
|
2727
|
-
clearInterval(pollInterval);
|
|
2728
|
-
clearTimeout(hardCapHandle);
|
|
2729
|
-
|
|
2730
|
-
// Issue #31: prefer the output already captured at settlement;
|
|
2731
|
-
// the ref may already be nulled on terminal agents, so never
|
|
2732
|
-
// overwrite with empty and stay null-safe.
|
|
2733
|
-
const finalOutput = agent.finalOutput ?? extractAgentFinalOutput(agent);
|
|
2734
|
-
setAgentFinalOutput(agent.id, finalOutput);
|
|
2735
|
-
|
|
2736
|
-
// Gate the counter on the finalize claim: if the
|
|
2737
|
-
// stale sweep already finalized this entry, the
|
|
2738
|
-
// decrement happened there (PR #71 review).
|
|
2739
|
-
if (state.finalizeLiveSubagent(agent.id)) {
|
|
2740
|
-
state.activeSubagents--;
|
|
2741
|
-
if (state.activeSubagents < 0) state.activeSubagents = 0;
|
|
2742
|
-
}
|
|
2743
|
-
|
|
2744
|
-
if (agent.status === 'failed') {
|
|
2745
|
-
state.failedSubagents++;
|
|
2746
|
-
updateProgressStatus(state, ctx);
|
|
2747
|
-
pi.sendMessage({
|
|
2748
|
-
customType: "subagent-notification",
|
|
2749
|
-
content: `Background agent "${agent.description}" failed.`,
|
|
2750
|
-
display: true,
|
|
2751
|
-
details: { agentId: agent.id }
|
|
2752
|
-
}, { deliverAs: "followUp" });
|
|
2753
|
-
} else if (agent.status === 'stopped') {
|
|
2754
|
-
// User-initiated stop (stop_subagent) or deadline abort
|
|
2755
|
-
// (timeout/hard cap) — not a failure.
|
|
2756
|
-
updateProgressStatus(state, ctx);
|
|
2757
|
-
} else {
|
|
2758
|
-
state.completedSubagents++;
|
|
2759
|
-
state.unseenSubagents++;
|
|
2760
|
-
updateProgressStatus(state, ctx);
|
|
2761
|
-
}
|
|
2762
|
-
}
|
|
2763
|
-
} catch (err) {
|
|
2764
|
-
if (!completed) {
|
|
2765
|
-
completed = true;
|
|
2766
|
-
clearInterval(pollInterval);
|
|
2767
|
-
clearTimeout(hardCapHandle);
|
|
2768
|
-
try {
|
|
2769
|
-
setAgentFinalOutput(agent.id, extractAgentFinalOutput(agent));
|
|
2770
|
-
} catch { /* ignore */ }
|
|
2771
|
-
// Gate the counter on the finalize claim: if the
|
|
2772
|
-
// stale sweep already finalized this entry, the
|
|
2773
|
-
// decrement happened there (PR #71 review).
|
|
2774
|
-
if (state.finalizeLiveSubagent(agent.id)) {
|
|
2775
|
-
state.activeSubagents--;
|
|
2776
|
-
if (state.activeSubagents < 0) state.activeSubagents = 0;
|
|
2777
|
-
}
|
|
2778
|
-
state.failedSubagents++;
|
|
2779
|
-
updateProgressStatus(state, ctx);
|
|
2780
|
-
pi.sendMessage({
|
|
2781
|
-
customType: "subagent-notification",
|
|
2782
|
-
content: `Background agent "${agent.description}" crashed: ${sanitizeErrorMessage((err as Error).message, bgResolved.effectiveCwd)}`,
|
|
2783
|
-
display: true,
|
|
2784
|
-
details: { agentId: agent.id }
|
|
2785
|
-
}, { deliverAs: "followUp" });
|
|
2786
|
-
}
|
|
2787
|
-
}
|
|
2788
|
-
}, 2000);
|
|
2789
|
-
|
|
2790
|
-
// Hard cap: stop polling AND abort the session after the deadline.
|
|
2791
|
-
// W2 (issue #28): previously this only stopped the poller — the pi
|
|
2792
|
-
// session kept running forever (orphaned). Now it pre-sets
|
|
2793
|
-
// 'stopped' (so the .then keeps it, per W1) and aborts the
|
|
2794
|
-
// session. The cap honors a shorter per-agent timeout.
|
|
2795
|
-
const hardCapMs = Math.min(bgResolved.timeout ?? 30 * 60 * 1000, 30 * 60 * 1000);
|
|
2796
|
-
const hardCapHandle = setTimeout(() => {
|
|
2797
|
-
if (!completed && !agent.completedAt) {
|
|
2798
|
-
try {
|
|
2799
|
-
completed = true;
|
|
2800
|
-
clearInterval(pollInterval);
|
|
2801
|
-
// Pre-set stopped BEFORE aborting so the .then in
|
|
2802
|
-
// spawnBackgroundSession keeps the stopped state.
|
|
2803
|
-
updateAgentStatus(agent.id, 'stopped', `Timed out (${hardCapMs}ms hard cap)`);
|
|
2804
|
-
agent._sessionRef?.abort().catch(() => {
|
|
2805
|
-
// Abort may reject if the session is mid-dispose;
|
|
2806
|
-
// the status flip above is already recorded.
|
|
2807
|
-
});
|
|
2808
|
-
// Capture whatever final output exists in the session
|
|
2809
|
-
const hardCapSession = agent._sessionRef;
|
|
2810
|
-
const hardCapFinalOutput = hardCapSession ? extractFinalOutput(hardCapSession) : '';
|
|
2811
|
-
setAgentFinalOutput(agent.id, hardCapFinalOutput);
|
|
2812
|
-
// Gate the counter on the finalize claim: if the
|
|
2813
|
-
// stale sweep already finalized this entry, the
|
|
2814
|
-
// decrement happened there (PR #71 review).
|
|
2815
|
-
if (state.finalizeLiveSubagent(agent.id)) {
|
|
2816
|
-
state.activeSubagents--;
|
|
2817
|
-
if (state.activeSubagents < 0) state.activeSubagents = 0;
|
|
2818
|
-
}
|
|
2819
|
-
// m6: deadline abort is a stop, not a completion — mirror the
|
|
2820
|
-
// W3/poller stopped path (no completedSubagents increment).
|
|
2821
|
-
updateProgressStatus(state, ctx);
|
|
2822
|
-
} catch (err) {
|
|
2823
|
-
// Defensive: never let the hard-cap timer throw uncaught — that would
|
|
2824
|
-
// skip finalizeLiveSubagent, the counter decrement, and the notification.
|
|
2825
|
-
// Do all fallible work first (output capture), then mutate counters,
|
|
2826
|
-
// then notify — a throw mid-path can't double-fire mutations.
|
|
2827
|
-
completed = true;
|
|
2828
|
-
clearInterval(pollInterval);
|
|
2829
|
-
// m3: the try may have thrown BEFORE updateAgentStatus ran (e.g.
|
|
2830
|
-
// persistAgent fs failure) — leave the record terminal so
|
|
2831
|
-
// get_subagent_result doesn't report 'running' and the W3 timer
|
|
2832
|
-
// guard (!agent.completedAt) can't re-fire later.
|
|
2833
|
-
try {
|
|
2834
|
-
updateAgentStatus(agent.id, 'stopped', `Timed out (${hardCapMs}ms hard cap)`);
|
|
2835
|
-
} catch { /* ignore */ }
|
|
2836
|
-
try {
|
|
2837
|
-
setAgentFinalOutput(agent.id, extractAgentFinalOutput(agent));
|
|
2838
|
-
} catch { /* ignore */ }
|
|
2839
|
-
// Gate the counter on the finalize claim: if the
|
|
2840
|
-
// stale sweep already finalized this entry, the
|
|
2841
|
-
// decrement happened there (PR #71 review).
|
|
2842
|
-
if (state.finalizeLiveSubagent(agent.id)) {
|
|
2843
|
-
state.activeSubagents--;
|
|
2844
|
-
if (state.activeSubagents < 0) state.activeSubagents = 0;
|
|
2845
|
-
}
|
|
2846
|
-
state.failedSubagents++;
|
|
2847
|
-
updateProgressStatus(state, ctx);
|
|
2848
|
-
}
|
|
2849
|
-
}
|
|
2850
|
-
}, hardCapMs);
|
|
2851
|
-
|
|
2852
|
-
log.info("Background agent spawned", { agentId: agent.id, task: params.task, model: agent.model });
|
|
2853
|
-
|
|
2854
|
-
// B2: Surface auto-route decisions in background spawn result too.
|
|
2855
|
-
// Derive the restriction from the RESOLVED toolOptions (which reflects
|
|
2856
|
-
// per-call overrides) rather than the preset's declared values.
|
|
2857
|
-
const bgAutoRouteNote = bgAutoRoutedPreset
|
|
2858
|
-
? `\n\n[auto-routed to preset '${bgAutoRoutedPreset.name}' — ${bgResolved.toolOptions ? formatToolRestriction(bgResolved.toolOptions) : formatPresetRestriction(bgAutoRoutedPreset)}]`
|
|
2859
|
-
: "";
|
|
2860
|
-
|
|
2861
|
-
return {
|
|
2862
|
-
content: [{
|
|
2863
|
-
type: "text" as const,
|
|
2864
|
-
text: `Background agent started: ${agent.id}\n\n` +
|
|
2865
|
-
`Description: ${agent.description}\n` +
|
|
2866
|
-
`Task: ${agent.task}\n` +
|
|
2867
|
-
`Status: ${agent.status}\n\n` +
|
|
2868
|
-
`You'll be woken with a completion message when it finishes; use get_subagent_result({ agent_id: "${agent.id}" }) for retrieval and stall checks.` +
|
|
2869
|
-
bgAutoRouteNote,
|
|
2870
|
-
}],
|
|
2871
|
-
details: undefined,
|
|
2872
|
-
};
|
|
2873
|
-
} catch (err) {
|
|
2874
|
-
const message = sanitizeErrorMessage(
|
|
2875
|
-
err instanceof Error ? err.message : String(err),
|
|
2876
|
-
bgResolved.effectiveCwd,
|
|
2877
|
-
);
|
|
2878
|
-
log.error("Failed to spawn background agent", { error: message });
|
|
2879
|
-
return {
|
|
2880
|
-
content: [{ type: "text" as const, text: `Failed to spawn background agent: ${message}` }],
|
|
2881
|
-
details: undefined,
|
|
2882
|
-
isError: true,
|
|
2883
|
-
};
|
|
2884
|
-
}
|
|
3255
|
+
return spawnBackgroundRun(pi, ctx, params, singleTask);
|
|
2885
3256
|
}
|
|
2886
3257
|
|
|
2887
3258
|
// R5: Check session cost limit before spawning
|