brl-subagent 2.3.9 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT.md +5 -0
- package/README.md +20 -8
- package/package.json +11 -9
- package/src/concurrency.ts +1 -0
- package/src/diff.ts +1 -0
- package/src/event-bus.ts +1 -0
- package/src/git.ts +1 -0
- package/src/history.ts +1 -0
- package/src/index.ts +60 -374
- package/src/logging.ts +21 -1
- package/src/messaging.ts +1 -0
- package/src/metrics.ts +1 -0
- package/src/model-availability.ts +1 -0
- package/src/notify-completion.ts +1 -0
- package/src/params.ts +1 -0
- package/src/paths.ts +1 -0
- package/src/preflight.ts +1 -0
- package/src/prelude.ts +1 -0
- package/src/presets.ts +1 -0
- package/src/prompt.ts +1 -0
- package/src/reports.ts +64 -8
- package/src/router.ts +1 -0
- package/src/runner.ts +1 -0
- package/src/sanitize.ts +1 -0
- package/src/scheduler.ts +1 -0
- package/src/schema.ts +254 -0
- package/src/session-manager.ts +44 -96
- package/src/state.ts +1 -0
- package/src/templates.ts +1 -0
- package/src/transcript-tail.ts +1 -0
- package/src/transcript.ts +1 -0
- package/src/tui-format.ts +1 -0
- package/src/tui.ts +3 -208
- package/src/types.ts +23 -2
- package/src/unit-run.ts +1 -0
- package/src/validate.ts +16 -2
package/src/notify-completion.ts
CHANGED
package/src/params.ts
CHANGED
package/src/paths.ts
CHANGED
package/src/preflight.ts
CHANGED
package/src/prelude.ts
CHANGED
package/src/presets.ts
CHANGED
package/src/prompt.ts
CHANGED
package/src/reports.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// Purpose: Compliance reporting: file-access records and secrets-exposure detection.
|
|
1
2
|
/**
|
|
2
3
|
* brl-subagent — Compliance Reports (E5)
|
|
3
4
|
*
|
|
@@ -128,6 +129,11 @@ function extractFilesFromOutputSummary(outputSummary: string): string[] {
|
|
|
128
129
|
|
|
129
130
|
/**
|
|
130
131
|
* Check if a file path matches any sensitive file pattern.
|
|
132
|
+
*
|
|
133
|
+
* This is the PATH-based detector for the secrets exposure report: it answers
|
|
134
|
+
* "did this run touch a sensitive FILE?" rather than "did the output text
|
|
135
|
+
* happen to mention one?". It was previously unreachable dead code; keep it in
|
|
136
|
+
* sync with SENSITIVE_FILE_PATTERNS / FILE_SEVERITY_MAP above.
|
|
131
137
|
*/
|
|
132
138
|
function isSensitiveFile(filePath: string): { match: boolean; pattern?: string; severity?: "high" | "medium" | "low" } {
|
|
133
139
|
const normalizedPath = filePath.toLowerCase();
|
|
@@ -221,20 +227,40 @@ export function buildFileAccessReport(runs: SubagentRun[]): FileAccessReport {
|
|
|
221
227
|
*/
|
|
222
228
|
export function buildSecretsExposureReport(runs: SubagentRun[]): SecretsExposureReport {
|
|
223
229
|
const exposures: SecretsExposureEntry[] = [];
|
|
230
|
+
// Dedupe key set: one exposure per (file, runId) regardless of whether the
|
|
231
|
+
// finding came from the text scan or the path scan.
|
|
232
|
+
const seen = new Set<string>();
|
|
224
233
|
|
|
225
234
|
for (const run of runs) {
|
|
226
235
|
// Check task description for sensitive keywords
|
|
227
236
|
const taskCheck = hasSensitiveTaskKeywords(run.task);
|
|
228
237
|
if (taskCheck.match && taskCheck.keyword) {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
238
|
+
const file = `task:${taskCheck.keyword}`;
|
|
239
|
+
const key = `${file}\x00${run.id}`;
|
|
240
|
+
if (!seen.has(key)) {
|
|
241
|
+
seen.add(key);
|
|
242
|
+
exposures.push({
|
|
243
|
+
file,
|
|
244
|
+
runId: run.id,
|
|
245
|
+
runLabel: run.label,
|
|
246
|
+
severity: "medium",
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Files this run touched (mirrors buildFileAccessReport). Computed BEFORE
|
|
252
|
+
// the text scan so a text hit that is only a FRAGMENT of a touched path
|
|
253
|
+
// (e.g. ".env" inside "config/prod/.env") is not double-reported — the
|
|
254
|
+
// path scan below reports the real, full path instead.
|
|
255
|
+
const touched: string[] = [];
|
|
256
|
+
if (run.fullOutput && run.fullOutput.includes("diff --git")) {
|
|
257
|
+
touched.push(...extractFilesFromGitDiff(run.fullOutput));
|
|
258
|
+
}
|
|
259
|
+
if (touched.length === 0 && run.outputSummary) {
|
|
260
|
+
touched.push(...extractFilesFromOutputSummary(run.outputSummary));
|
|
235
261
|
}
|
|
236
262
|
|
|
237
|
-
// Check output for sensitive file
|
|
263
|
+
// Check output text for sensitive file mentions.
|
|
238
264
|
const outputToCheck = [
|
|
239
265
|
run.outputSummary,
|
|
240
266
|
run.fullOutput,
|
|
@@ -254,8 +280,18 @@ export function buildSecretsExposureReport(runs: SubagentRun[]): SecretsExposure
|
|
|
254
280
|
}
|
|
255
281
|
}
|
|
256
282
|
|
|
283
|
+
// Report the text that actually matched, not the regex
|
|
284
|
+
// literal — the `file` field is what a reader acts on.
|
|
285
|
+
const matched = outputToCheck.match(pattern)?.[0] ?? patternStr;
|
|
286
|
+
// Fragment of a touched file? The path scan below reports the full
|
|
287
|
+
// path — skip this fragment so one file yields ONE entry.
|
|
288
|
+
if (touched.some((p) => p.toLowerCase().includes(matched.toLowerCase()))) continue;
|
|
289
|
+
const dedupeKey = `${matched}\x00${run.id}`;
|
|
290
|
+
if (seen.has(dedupeKey)) continue;
|
|
291
|
+
seen.add(dedupeKey);
|
|
292
|
+
|
|
257
293
|
exposures.push({
|
|
258
|
-
file:
|
|
294
|
+
file: matched,
|
|
259
295
|
runId: run.id,
|
|
260
296
|
runLabel: run.label,
|
|
261
297
|
severity,
|
|
@@ -263,6 +299,26 @@ export function buildSecretsExposureReport(runs: SubagentRun[]): SecretsExposure
|
|
|
263
299
|
}
|
|
264
300
|
}
|
|
265
301
|
}
|
|
302
|
+
|
|
303
|
+
// Path-based detection: flag the sensitive FILES this run actually
|
|
304
|
+
// touched — the full path, even when the output text never names it (and
|
|
305
|
+
// even when it names it in a different case: the path is normalized
|
|
306
|
+
// before matching).
|
|
307
|
+
for (const file of touched) {
|
|
308
|
+
const check = isSensitiveFile(file);
|
|
309
|
+
if (!check.match) continue;
|
|
310
|
+
|
|
311
|
+
const dedupeKey = `${file}\x00${run.id}`;
|
|
312
|
+
if (seen.has(dedupeKey)) continue;
|
|
313
|
+
seen.add(dedupeKey);
|
|
314
|
+
|
|
315
|
+
exposures.push({
|
|
316
|
+
file,
|
|
317
|
+
runId: run.id,
|
|
318
|
+
runLabel: run.label,
|
|
319
|
+
severity: check.severity ?? "high",
|
|
320
|
+
});
|
|
321
|
+
}
|
|
266
322
|
}
|
|
267
323
|
|
|
268
324
|
return { exposures };
|
package/src/router.ts
CHANGED
package/src/runner.ts
CHANGED
package/src/sanitize.ts
CHANGED
package/src/scheduler.ts
CHANGED
package/src/schema.ts
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// Purpose: The registered `delegate_task` parameter schema — the single source of truth for its types.
|
|
2
|
+
/**
|
|
3
|
+
* delegate_task parameter schema — the SINGLE source of truth (issue #239).
|
|
4
|
+
* index.ts registers this object; DelegateTaskParams (types.ts) is
|
|
5
|
+
* Static<typeof delegateTaskParamsSchema>, so schema and types cannot drift.
|
|
6
|
+
*/
|
|
7
|
+
import { Type } from "typebox";
|
|
8
|
+
import { MAX_CHAIN_STEPS, MAX_PARALLEL_TASKS, MAX_GRAPH_TASKS } from "./types";
|
|
9
|
+
|
|
10
|
+
export const delegateTaskParamsSchema = Type.Object({
|
|
11
|
+
task: Type.Optional(Type.String({
|
|
12
|
+
description: "Detailed description of the task for the subagent to complete (required for single mode, optional for chain/tasks/graph)",
|
|
13
|
+
})),
|
|
14
|
+
systemPrompt: Type.Optional(
|
|
15
|
+
Type.String({
|
|
16
|
+
description:
|
|
17
|
+
"Custom system prompt or additional instructions for the subagent. " +
|
|
18
|
+
"When inheritSystemPrompt is true (default), this is appended after the inherited prompt. " +
|
|
19
|
+
"When inheritSystemPrompt is false, this replaces the inherited prompt entirely.",
|
|
20
|
+
}),
|
|
21
|
+
),
|
|
22
|
+
inheritSystemPrompt: Type.Optional(
|
|
23
|
+
Type.Boolean({
|
|
24
|
+
description:
|
|
25
|
+
"Whether to inherit the main agent's system prompt. " +
|
|
26
|
+
"Default: true. Set to false to use only your custom systemPrompt, " +
|
|
27
|
+
"or to avoid passing a large inherited prompt to the subagent.",
|
|
28
|
+
}),
|
|
29
|
+
),
|
|
30
|
+
thinkingLevel: Type.Optional(
|
|
31
|
+
Type.String({
|
|
32
|
+
description:
|
|
33
|
+
"Requested thinking level for this subagent call. " +
|
|
34
|
+
"One of: off, minimal, low, medium, high, xhigh. " +
|
|
35
|
+
"Capped at the user's configured maximum. If omitted, the user's configured level is used.",
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
outputFile: Type.Optional(
|
|
39
|
+
Type.String({
|
|
40
|
+
description:
|
|
41
|
+
"Project-relative path where the subagent should write its full findings. " +
|
|
42
|
+
"When provided, the subagent is instructed to write complete output to this file " +
|
|
43
|
+
"and return only a structured summary.",
|
|
44
|
+
}),
|
|
45
|
+
),
|
|
46
|
+
label: Type.Optional(
|
|
47
|
+
Type.String({
|
|
48
|
+
description:
|
|
49
|
+
"Human-readable label for this subagent (e.g., 'security-audit', 'docs-review'). " +
|
|
50
|
+
"Appears in the status bar and tool call display. " +
|
|
51
|
+
"Omit to use the default anonymous counter.",
|
|
52
|
+
}),
|
|
53
|
+
),
|
|
54
|
+
model: Type.Optional(Type.String({ description: "Model override (provider/model-id). Defaults to the global subagent model." })),
|
|
55
|
+
timeout: Type.Optional(
|
|
56
|
+
Type.Number({
|
|
57
|
+
description:
|
|
58
|
+
"Maximum time in milliseconds the subagent is allowed to run. " +
|
|
59
|
+
"If exceeded, the subagent is killed and an error is returned. " +
|
|
60
|
+
"Background runs default to a 30-minute deadline when no timeout is given; an explicit timeout is honored as given (30 minutes is a default, not a ceiling). A raw value ≥ 2^31-1 normalizes to the default.",
|
|
61
|
+
}),
|
|
62
|
+
),
|
|
63
|
+
cwd: Type.Optional(
|
|
64
|
+
Type.String({
|
|
65
|
+
description:
|
|
66
|
+
"Working directory for the subagent. Must be an existing directory. " +
|
|
67
|
+
"Defaults to the conductor's current working directory.",
|
|
68
|
+
}),
|
|
69
|
+
),
|
|
70
|
+
tools: Type.Optional(
|
|
71
|
+
Type.Array(Type.String(), {
|
|
72
|
+
description:
|
|
73
|
+
"Explicit allowlist of tool names for the subagent. Maps to pi's --tools flag.",
|
|
74
|
+
}),
|
|
75
|
+
),
|
|
76
|
+
excludeTools: Type.Optional(
|
|
77
|
+
Type.Array(Type.String(), {
|
|
78
|
+
description:
|
|
79
|
+
"Tool names to disable for the subagent. Maps to pi's --exclude-tools flag.",
|
|
80
|
+
}),
|
|
81
|
+
),
|
|
82
|
+
noBuiltinTools: Type.Optional(
|
|
83
|
+
Type.Boolean({
|
|
84
|
+
description:
|
|
85
|
+
"Disable all built-in tools for the subagent. Maps to pi's --no-builtin-tools flag.",
|
|
86
|
+
}),
|
|
87
|
+
),
|
|
88
|
+
preset: Type.Optional(
|
|
89
|
+
Type.String({
|
|
90
|
+
description:
|
|
91
|
+
"Name of a saved delegation preset (created via /brl-subagent preset). " +
|
|
92
|
+
"Preset values are used as defaults; explicit parameters on this call override them.",
|
|
93
|
+
}),
|
|
94
|
+
),
|
|
95
|
+
template: Type.Optional(
|
|
96
|
+
Type.String({
|
|
97
|
+
description:
|
|
98
|
+
"Name of a saved task template. Use with params to fill template slots. " +
|
|
99
|
+
"Templates are file-backed: 9 builtin templates ship with the extension " +
|
|
100
|
+
"(browse via /brl-subagent templates); override or add via .md files in " +
|
|
101
|
+
"~/.pi/agent/brl-subagent/templates/ or .pi/brl-subagent/templates/ " +
|
|
102
|
+
"(project-local wins over user-global over builtin).",
|
|
103
|
+
}),
|
|
104
|
+
),
|
|
105
|
+
params: Type.Optional(
|
|
106
|
+
Type.Record(Type.String(), Type.String(), {
|
|
107
|
+
description:
|
|
108
|
+
"Parameter values for template ${param} slots. " +
|
|
109
|
+
"Keys are param names, values are the substitution text.",
|
|
110
|
+
}),
|
|
111
|
+
),
|
|
112
|
+
retryRunId: Type.Optional(
|
|
113
|
+
Type.String({
|
|
114
|
+
description:
|
|
115
|
+
"ID of a previously failed subagent run to retry. " +
|
|
116
|
+
"The retry rebuilds the parameter object from a fixed field set, falling back to the " +
|
|
117
|
+
"original run's recorded values, with explicit values on this call winning: task, label, " +
|
|
118
|
+
"model, preset, systemPrompt, inheritSystemPrompt, thinkingLevel, priority, outputFile, " +
|
|
119
|
+
"timeout, cwd, tools, excludeTools, noBuiltinTools, background, gitMode, approvalMode, force. " +
|
|
120
|
+
"The execution-shape fields ARE restored: a retried background run stays background (with its " +
|
|
121
|
+
"completion wake), and a retried branch-mode run keeps its work branch, its approvalMode " +
|
|
122
|
+
"gating and its force override. Qualify that by the effective gitMode - when neither the record " +
|
|
123
|
+
"nor this call carries one, the configured default applies, which may still be 'branch'. " +
|
|
124
|
+
"Not restored: chain/tasks/graph and params, so a retried multi-step run silently degrades to a " +
|
|
125
|
+
"single task - re-issue it fresh. retryOnTimeout is explicit-only: never restored, honoured when passed. " +
|
|
126
|
+
"template is asymmetric: the original's template is NOT restored, but a template passed on the " +
|
|
127
|
+
"retry call DOES take effect (it is resolved before the retry merge). " +
|
|
128
|
+
"Fan-out units (parallel/chain/graph origin) record no background, so retrying one is a single " +
|
|
129
|
+
"foreground run - pass background: true for a background retry. " +
|
|
130
|
+
"Only works with runs that ended in failure (exitCode != 0, timeout, error, or abort).",
|
|
131
|
+
}),
|
|
132
|
+
),
|
|
133
|
+
gitMode: Type.Optional(
|
|
134
|
+
Type.String({
|
|
135
|
+
description:
|
|
136
|
+
"Git integration mode for this subagent call. " +
|
|
137
|
+
"'branch' creates a work branch, captures the diff, and switches back. " +
|
|
138
|
+
"'none' (default) does nothing. Falls back to the configured default.",
|
|
139
|
+
}),
|
|
140
|
+
),
|
|
141
|
+
retryOnTimeout: Type.Optional(
|
|
142
|
+
Type.Boolean({
|
|
143
|
+
description:
|
|
144
|
+
"If true and a FOREGROUND subagent times out, automatically retry with the same parameters. " +
|
|
145
|
+
"Only retries once — the second timeout is treated as a final failure. " +
|
|
146
|
+
"Background runs are not auto-retried; re-dispatch with retryRunId.",
|
|
147
|
+
}),
|
|
148
|
+
),
|
|
149
|
+
approvalMode: Type.Optional(
|
|
150
|
+
Type.String({
|
|
151
|
+
description:
|
|
152
|
+
"Change approval mode: auto (never ask), writes (ask when files changed), " +
|
|
153
|
+
"always (ask every time). Default is user config (/brl-subagent approval).",
|
|
154
|
+
}),
|
|
155
|
+
),
|
|
156
|
+
force: Type.Optional(
|
|
157
|
+
Type.Boolean({
|
|
158
|
+
description:
|
|
159
|
+
"Override for capability pre-flight errors (default false). A dispatch is blocked when the task clearly needs a capability the resolved toolset lacks — " +
|
|
160
|
+
"a run/execute/test/compile/benchmark task with no bash, or an exploration task (search/grep/find/list/locate/glob) with none of find/ls/grep/bash. " +
|
|
161
|
+
"Set force: true to dispatch anyway: the mismatch is then delivered as a warning in the result instead of rejecting the call. " +
|
|
162
|
+
"Warnings are surfaced either way. force never suppresses outputFile-without-write conflicts.",
|
|
163
|
+
}),
|
|
164
|
+
),
|
|
165
|
+
background: Type.Optional(
|
|
166
|
+
Type.Boolean({
|
|
167
|
+
description:
|
|
168
|
+
"Run the subagent in the background without blocking the conductor. " +
|
|
169
|
+
"When true, the tool returns immediately with an agent ID. " +
|
|
170
|
+
"The conductor is woken with a completion message; use get_subagent_result for post-wake retrieval and stall checks. " +
|
|
171
|
+
"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. " +
|
|
172
|
+
"Default: false (blocking mode).",
|
|
173
|
+
}),
|
|
174
|
+
),
|
|
175
|
+
priority: Type.Optional(
|
|
176
|
+
Type.Union([
|
|
177
|
+
Type.Literal("critical"),
|
|
178
|
+
Type.Literal("high"),
|
|
179
|
+
Type.Literal("normal"),
|
|
180
|
+
Type.Literal("low"),
|
|
181
|
+
], {
|
|
182
|
+
description: "Concurrency priority for this delegation: critical, high, normal, or low. Higher-priority delegations queue ahead. Defaults to normal.",
|
|
183
|
+
})
|
|
184
|
+
),
|
|
185
|
+
// Issue #114: NO per-step `priority` on chain[] — chain steps never
|
|
186
|
+
// compete for concurrency slots (the chain holds ONE slot for its whole
|
|
187
|
+
// duration); array order IS the priority.
|
|
188
|
+
chain: Type.Optional(Type.Array(Type.Object({
|
|
189
|
+
task: Type.String({ description: "Task description. Use {previous} to reference the previous step output." }),
|
|
190
|
+
label: Type.Optional(Type.String({})),
|
|
191
|
+
model: Type.Optional(Type.String({ description: "Model override for this step (provider/model-id). Defaults to the global subagent model." })),
|
|
192
|
+
thinkingLevel: Type.Optional(Type.String({})),
|
|
193
|
+
cwd: Type.Optional(Type.String({})),
|
|
194
|
+
timeout: Type.Optional(Type.Number({})),
|
|
195
|
+
outputFile: Type.Optional(Type.String({})),
|
|
196
|
+
tools: Type.Optional(Type.Array(Type.String({}))),
|
|
197
|
+
excludeTools: Type.Optional(Type.Array(Type.String({}))),
|
|
198
|
+
noBuiltinTools: Type.Optional(Type.Boolean({})),
|
|
199
|
+
systemPrompt: Type.Optional(Type.String({})),
|
|
200
|
+
inheritSystemPrompt: Type.Optional(Type.Boolean({})),
|
|
201
|
+
}), {
|
|
202
|
+
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."
|
|
203
|
+
})),
|
|
204
|
+
tasks: Type.Optional(Type.Array(Type.Object({
|
|
205
|
+
task: Type.String({ description: "Task description for this parallel subtask" }),
|
|
206
|
+
label: Type.Optional(Type.String({})),
|
|
207
|
+
model: Type.Optional(Type.String({ description: "Model override for this step (provider/model-id). Defaults to the global subagent model." })),
|
|
208
|
+
thinkingLevel: Type.Optional(Type.String({})),
|
|
209
|
+
priority: Type.Optional(
|
|
210
|
+
Type.Union([
|
|
211
|
+
Type.Literal("critical"),
|
|
212
|
+
Type.Literal("high"),
|
|
213
|
+
Type.Literal("normal"),
|
|
214
|
+
Type.Literal("low"),
|
|
215
|
+
], { description: "Concurrency priority for this subtask (overrides the call-level default)." })
|
|
216
|
+
),
|
|
217
|
+
cwd: Type.Optional(Type.String({})),
|
|
218
|
+
timeout: Type.Optional(Type.Number({})),
|
|
219
|
+
outputFile: Type.Optional(Type.String({})),
|
|
220
|
+
tools: Type.Optional(Type.Array(Type.String({}))),
|
|
221
|
+
excludeTools: Type.Optional(Type.Array(Type.String({}))),
|
|
222
|
+
noBuiltinTools: Type.Optional(Type.Boolean({})),
|
|
223
|
+
systemPrompt: Type.Optional(Type.String({})),
|
|
224
|
+
inheritSystemPrompt: Type.Optional(Type.Boolean({})),
|
|
225
|
+
}), {
|
|
226
|
+
description: "Parallel tasks to execute concurrently. All tasks run regardless of individual failures. Max " + MAX_PARALLEL_TASKS + " tasks."
|
|
227
|
+
})),
|
|
228
|
+
graph: Type.Optional(Type.Array(Type.Object({
|
|
229
|
+
id: Type.String({ description: "Unique identifier for this task node" }),
|
|
230
|
+
task: Type.String({ description: "Task description. Use {<nodeId>} to reference output from another task (the referenced node's own id, which must be a word-character id - letters, digits, underscore; ids like 'step-1' or 'node.a' are not matched and the braces are left as literal text)." }),
|
|
231
|
+
label: Type.Optional(Type.String({})),
|
|
232
|
+
model: Type.Optional(Type.String({ description: "Model override for this step (provider/model-id). Defaults to the global subagent model." })),
|
|
233
|
+
dependsOn: Type.Optional(Type.Array(Type.String({}), { description: "IDs of tasks that must complete before this one starts" })),
|
|
234
|
+
thinkingLevel: Type.Optional(Type.String({})),
|
|
235
|
+
priority: Type.Optional(
|
|
236
|
+
Type.Union([
|
|
237
|
+
Type.Literal("critical"),
|
|
238
|
+
Type.Literal("high"),
|
|
239
|
+
Type.Literal("normal"),
|
|
240
|
+
Type.Literal("low"),
|
|
241
|
+
], { description: "Concurrency priority for this subtask (overrides the call-level default)." })
|
|
242
|
+
),
|
|
243
|
+
cwd: Type.Optional(Type.String({})),
|
|
244
|
+
timeout: Type.Optional(Type.Number({})),
|
|
245
|
+
outputFile: Type.Optional(Type.String({})),
|
|
246
|
+
tools: Type.Optional(Type.Array(Type.String({}))),
|
|
247
|
+
excludeTools: Type.Optional(Type.Array(Type.String({}))),
|
|
248
|
+
noBuiltinTools: Type.Optional(Type.Boolean({})),
|
|
249
|
+
systemPrompt: Type.Optional(Type.String({})),
|
|
250
|
+
inheritSystemPrompt: Type.Optional(Type.Boolean({})),
|
|
251
|
+
}), {
|
|
252
|
+
description: "Declare tasks with dependencies. The scheduler parallelizes independent tasks and sequences dependent ones. Max " + MAX_GRAPH_TASKS + " tasks."
|
|
253
|
+
})),
|
|
254
|
+
});
|
package/src/session-manager.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
// Purpose: Background execution: SDK sessions (`createAgentSession`), agent records, timeouts, steering, and settle paths.
|
|
1
2
|
import { randomUUID } from 'crypto';
|
|
2
3
|
import { join } from 'path';
|
|
3
|
-
import { mkdirSync, writeFileSync, readFileSync, existsSync
|
|
4
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs';
|
|
4
5
|
import type { BackgroundAgent, AgentStatus, GitMode, SubagentResult, SubagentRun, ThinkingLevel, SubagentToolOptions, UsageStats, ErrorCategory } from './types';
|
|
5
|
-
import { EMPTY_USAGE, CUSTOM_ENTRY_TYPES, classifyError, classifyTerminalOutcome,
|
|
6
|
+
import { EMPTY_USAGE, CUSTOM_ENTRY_TYPES, classifyError, classifyTerminalOutcome, isProviderError, coherentFailureReason, SUBAGENT_ABORTED_MESSAGE } from './types';
|
|
6
7
|
import { accumulateUsage } from './runner';
|
|
7
8
|
import * as eventBus from './event-bus';
|
|
8
9
|
import * as transcript from './transcript';
|
|
@@ -11,6 +12,7 @@ import { assertSafeAgentId, sanitizeErrorMessage } from './sanitize';
|
|
|
11
12
|
import { wrapTask } from './prompt';
|
|
12
13
|
import { createLogger } from './logging';
|
|
13
14
|
import { getCurrentBranch, createWorkBranch, captureDiff, switchToBranch, deleteBranch, hasUncommittedChanges, getRepoRoot, commitAll, captureWorkingDiff } from './git';
|
|
15
|
+
import { normalizeTimeout, DEFAULT_BACKGROUND_DEADLINE_MS } from './validate';
|
|
14
16
|
|
|
15
17
|
const log = createLogger('brl-subagent');
|
|
16
18
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
@@ -117,49 +119,6 @@ function loadAgent(id: string): BackgroundAgent | null {
|
|
|
117
119
|
}
|
|
118
120
|
}
|
|
119
121
|
|
|
120
|
-
/**
|
|
121
|
-
* Create a new background agent session
|
|
122
|
-
*
|
|
123
|
-
* NOTE: In v2.0.3, this creates a record but does NOT actually spawn a pi session.
|
|
124
|
-
* The actual session spawning will be implemented when pi's ExtensionAPI supports it.
|
|
125
|
-
* For now, this is a placeholder that creates the agent record.
|
|
126
|
-
*/
|
|
127
|
-
export function createSession(params: {
|
|
128
|
-
task: string;
|
|
129
|
-
type?: string;
|
|
130
|
-
description?: string;
|
|
131
|
-
model?: string;
|
|
132
|
-
thinkingLevel?: ThinkingLevel;
|
|
133
|
-
systemPrompt?: string;
|
|
134
|
-
}): BackgroundAgent {
|
|
135
|
-
const id = generateUUID();
|
|
136
|
-
const agent: BackgroundAgent = {
|
|
137
|
-
id,
|
|
138
|
-
sessionId: `session-${id}`,
|
|
139
|
-
type: params.type || 'general-purpose',
|
|
140
|
-
description: params.description || params.task.slice(0, 50),
|
|
141
|
-
status: 'pending',
|
|
142
|
-
startedAt: Date.now(),
|
|
143
|
-
task: params.task,
|
|
144
|
-
model: params.model || 'unknown',
|
|
145
|
-
thinkingLevel: params.thinkingLevel || 'medium',
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
agents.set(id, agent);
|
|
149
|
-
persistAgent(agent);
|
|
150
|
-
|
|
151
|
-
// Start transcript for this agent
|
|
152
|
-
transcript.startTranscript(agent.id, params.task);
|
|
153
|
-
|
|
154
|
-
eventBus.emit(eventBus.createEvent('subagent:created', agent.id, {
|
|
155
|
-
type: agent.type,
|
|
156
|
-
description: agent.description,
|
|
157
|
-
task: agent.task,
|
|
158
|
-
}));
|
|
159
|
-
|
|
160
|
-
return agent;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
122
|
/**
|
|
164
123
|
* Get agent record by ID
|
|
165
124
|
*/
|
|
@@ -177,23 +136,6 @@ export function getAgent(id: string): BackgroundAgent | null {
|
|
|
177
136
|
return agents.get(id) || loadAgent(id);
|
|
178
137
|
}
|
|
179
138
|
|
|
180
|
-
/**
|
|
181
|
-
* List all background agents
|
|
182
|
-
*/
|
|
183
|
-
export function listAgents(): BackgroundAgent[] {
|
|
184
|
-
ensureStorageDir();
|
|
185
|
-
const files = readdirSync(STORAGE_DIR).filter(f => f.endsWith('.json'));
|
|
186
|
-
const result: BackgroundAgent[] = [];
|
|
187
|
-
|
|
188
|
-
for (const file of files) {
|
|
189
|
-
const id = file.replace('.json', '');
|
|
190
|
-
const agent = agents.get(id) || loadAgent(id);
|
|
191
|
-
if (agent) result.push(agent);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
return result.sort((a, b) => b.startedAt - a.startedAt);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
139
|
/**
|
|
198
140
|
* Update agent status
|
|
199
141
|
*/
|
|
@@ -225,25 +167,6 @@ export function updateAgentStatus(id: string, status: AgentStatus, error?: strin
|
|
|
225
167
|
return agent;
|
|
226
168
|
}
|
|
227
169
|
|
|
228
|
-
/**
|
|
229
|
-
* Set agent result
|
|
230
|
-
*/
|
|
231
|
-
export function setAgentResult(id: string, result: SubagentResult): BackgroundAgent | null {
|
|
232
|
-
const agent = getAgent(id);
|
|
233
|
-
if (!agent) return null;
|
|
234
|
-
|
|
235
|
-
agent.result = result;
|
|
236
|
-
// Issue #179 (D1): honor the full terminal reason (stopReason error/aborted/
|
|
237
|
-
// length/incomplete), not exitCode alone — a mid-run provider death resolves
|
|
238
|
-
// with exitCode 0 and was previously recorded as 'completed'.
|
|
239
|
-
agent.status = isSubagentError(result) ? 'failed' : 'completed';
|
|
240
|
-
agent.completedAt = Date.now();
|
|
241
|
-
|
|
242
|
-
agents.set(id, agent);
|
|
243
|
-
persistAgent(agent);
|
|
244
|
-
return agent;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
170
|
/**
|
|
248
171
|
* Issue #179 (D4): when a run ends in a failure, the prose extracted by
|
|
249
172
|
* extractFinalOutput may come from an EARLIER turn — the failing turn carries
|
|
@@ -281,7 +204,7 @@ function defaultTerminalMessage(stopReason: string | undefined): string {
|
|
|
281
204
|
* Extract the final assistant text from a session's messages.
|
|
282
205
|
* Falls back to the LAST assistant message with non-empty text content — the
|
|
283
206
|
* final message is often a tool-call-only turn (agent stopped mid-turn, failed,
|
|
284
|
-
* or hit the
|
|
207
|
+
* or hit the deadline), and that turn carries no text of its own.
|
|
285
208
|
*/
|
|
286
209
|
export function extractFinalOutput(session: { messages: Array<{ role: string; content?: Array<{ type: string; text?: string }> | string | null }> }): string {
|
|
287
210
|
const assistants = [...session.messages].reverse().filter(m => m.role === 'assistant');
|
|
@@ -351,24 +274,45 @@ export async function stopAgent(id: string): Promise<BackgroundAgent | null> {
|
|
|
351
274
|
}
|
|
352
275
|
|
|
353
276
|
/**
|
|
354
|
-
* Steer a running agent by injecting a message
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
277
|
+
* Steer a running agent by injecting a message into its live session.
|
|
278
|
+
*
|
|
279
|
+
* Issue #241: steering DELIVERS via the SDK's `AgentSession.steer()` — the
|
|
280
|
+
* message is queued while the agent is running and delivered after the current
|
|
281
|
+
* assistant turn finishes its tool calls, before the next model call.
|
|
282
|
+
*
|
|
283
|
+
* Steering is NOT a state transition: the agent stays 'running' (no status
|
|
284
|
+
* flip, no persistAgent) so REPEAT steers keep working. Delivery failures
|
|
285
|
+
* propagate to the caller — a false success would tell the conductor a steer
|
|
286
|
+
* landed when the agent never saw it. The transcript line is an audit record
|
|
287
|
+
* written AFTER successful delivery, best-effort: a landed steer must never be
|
|
288
|
+
* reported as failed because the audit write failed.
|
|
358
289
|
*/
|
|
359
|
-
export function steerAgent(id: string, message: string): BackgroundAgent | null {
|
|
290
|
+
export async function steerAgent(id: string, message: string): Promise<BackgroundAgent | null> {
|
|
360
291
|
const agent = getAgent(id);
|
|
361
292
|
if (!agent) return null;
|
|
362
293
|
if (agent.status !== 'running') {
|
|
363
294
|
throw new Error(`Cannot steer agent ${id}: status is ${agent.status}, not running`);
|
|
364
295
|
}
|
|
365
296
|
|
|
366
|
-
|
|
367
|
-
|
|
297
|
+
const session = agent._sessionRef;
|
|
298
|
+
if (!session) {
|
|
299
|
+
throw new Error(`Cannot steer agent ${id}: no live session (the run may be settling)`);
|
|
300
|
+
}
|
|
301
|
+
if (typeof session.steer !== 'function') {
|
|
302
|
+
throw new Error('installed pi SDK has no AgentSession.steer — steering unsupported');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Deliver FIRST — a rejection here must surface as a real tool error, never
|
|
306
|
+
// a recorded-but-undelivered steer.
|
|
307
|
+
await session.steer(message);
|
|
368
308
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
309
|
+
// Audit line — best-effort. The steer already landed; an append failure is
|
|
310
|
+
// logged, not propagated.
|
|
311
|
+
try {
|
|
312
|
+
transcript.appendEntry(id, 'user', `Steering: ${message}`);
|
|
313
|
+
} catch (err) {
|
|
314
|
+
log.warn(`steerAgent: transcript append failed for ${id}`, { error: (err as Error).message });
|
|
315
|
+
}
|
|
372
316
|
|
|
373
317
|
eventBus.emit(eventBus.createEvent('subagent:steered', agent.id, { message }));
|
|
374
318
|
|
|
@@ -944,9 +888,13 @@ export async function spawnBackgroundSession(
|
|
|
944
888
|
// Armed immediately after the prompt call so in-prompt preflight (auth
|
|
945
889
|
// check, model resolution) counts toward the deadline; the abort lands in
|
|
946
890
|
// the .then below as an aborted run (probe contract). We pre-set the status
|
|
947
|
-
// so the .then keeps 'stopped' with the timeout reason recorded. The
|
|
948
|
-
// is normalized
|
|
949
|
-
//
|
|
891
|
+
// so the .then keeps 'stopped' with the timeout reason recorded. The raw
|
|
892
|
+
// value is normalized here (issue #240): a raw >=2^31 or Infinity would make
|
|
893
|
+
// Node fire the timer at ~1ms (instant kill), so normalizeTimeout maps it to
|
|
894
|
+
// undefined. An explicit timeout is HONORED verbatim (no ceiling); only the
|
|
895
|
+
// no-timeout fallback uses the 30m background default. The `params.timeout`
|
|
896
|
+
// guard below keeps this timer unarmed on the no-timeout path, where the
|
|
897
|
+
// index.ts deadline timer owns the no-timeout default (no redundant timer).
|
|
950
898
|
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
951
899
|
if (params.timeout && params.timeout > 0) {
|
|
952
900
|
timeoutHandle = setTimeout(() => {
|
|
@@ -966,7 +914,7 @@ export async function spawnBackgroundSession(
|
|
|
966
914
|
error: (err as Error).message,
|
|
967
915
|
});
|
|
968
916
|
}
|
|
969
|
-
},
|
|
917
|
+
}, normalizeTimeout(params.timeout) ?? DEFAULT_BACKGROUND_DEADLINE_MS);
|
|
970
918
|
}
|
|
971
919
|
|
|
972
920
|
runPromise.then(() => {
|
package/src/state.ts
CHANGED
package/src/templates.ts
CHANGED
package/src/transcript-tail.ts
CHANGED
package/src/transcript.ts
CHANGED