@d3ara1n/pi-subagent 2.2.0 → 3.1.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/README.md +45 -14
- package/package.json +1 -1
- package/src/config.test.ts +19 -1
- package/src/config.ts +13 -0
- package/src/history.ts +3 -0
- package/src/index.ts +93 -110
- package/src/inheritance.test.ts +217 -0
- package/src/inheritance.ts +188 -0
- package/src/reminder.test.ts +101 -52
- package/src/reminder.ts +15 -9
- package/src/render-async.ts +70 -2
- package/src/render.test.ts +63 -0
- package/src/render.ts +25 -3
- package/src/run.test.ts +51 -1
- package/src/run.ts +28 -6
- package/src/spawn.test.ts +34 -10
- package/src/spawn.ts +44 -10
- package/src/types.ts +28 -9
- package/src/utils.test.ts +51 -0
- package/src/utils.ts +48 -5
- package/src/view.ts +15 -3
package/src/run.test.ts
CHANGED
|
@@ -167,7 +167,9 @@ test("spawned runs persist to history on every terminal path; pre-run failures d
|
|
|
167
167
|
spawnImpl: async (_m, _t, options) => {
|
|
168
168
|
options.onProgress?.({
|
|
169
169
|
output: "partial",
|
|
170
|
-
activityLog: [
|
|
170
|
+
activityLog: [
|
|
171
|
+
{ kind: "toolCall", id: "t1", status: "running", toolName: "bash", args: {} },
|
|
172
|
+
],
|
|
171
173
|
});
|
|
172
174
|
throw new Error("Subagent was aborted");
|
|
173
175
|
},
|
|
@@ -229,6 +231,54 @@ test("provider error on first attempt retries on the fallback role", async () =>
|
|
|
229
231
|
assert.strictEqual(result.fallbackFrom.model, "test/model-fast");
|
|
230
232
|
});
|
|
231
233
|
|
|
234
|
+
test("forwards an immutable inherited conversation to first and fallback spawns", async () => {
|
|
235
|
+
const received: Array<{
|
|
236
|
+
model: string;
|
|
237
|
+
inheritConversation?: boolean;
|
|
238
|
+
inheritedConversation?: string;
|
|
239
|
+
}> = [];
|
|
240
|
+
const spawnImpl: SpawnImpl = async (model, _task, options) => {
|
|
241
|
+
received.push({
|
|
242
|
+
model,
|
|
243
|
+
inheritConversation: options.inheritConversation,
|
|
244
|
+
inheritedConversation: options.inheritedConversation,
|
|
245
|
+
});
|
|
246
|
+
return received.length === 1
|
|
247
|
+
? makeResult({ exitCode: 1, errorMessage: "429 quota exceeded", stderr: "HTTP 429" })
|
|
248
|
+
: makeResult({ output: "fallback ok" });
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const run = startSubagentRun(
|
|
252
|
+
makeDeps({
|
|
253
|
+
roleDef: { ...roleDef, fallbackRole: "default" },
|
|
254
|
+
inheritConversation: true,
|
|
255
|
+
inheritedConversation: "[user]\\nParent requirement",
|
|
256
|
+
inheritedConversationTruncated: true,
|
|
257
|
+
spawnImpl,
|
|
258
|
+
}),
|
|
259
|
+
);
|
|
260
|
+
const result = await run.promise;
|
|
261
|
+
|
|
262
|
+
assert.deepEqual(received, [
|
|
263
|
+
{
|
|
264
|
+
model: "test/model-fast",
|
|
265
|
+
inheritConversation: true,
|
|
266
|
+
inheritedConversation: "[user]\\nParent requirement",
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
model: "test/model-default",
|
|
270
|
+
inheritConversation: true,
|
|
271
|
+
inheritedConversation: "[user]\\nParent requirement",
|
|
272
|
+
},
|
|
273
|
+
]);
|
|
274
|
+
assert.equal(run.inheritConversation, true);
|
|
275
|
+
assert.equal(run.inheritedConversationChars, "[user]\\nParent requirement".length);
|
|
276
|
+
assert.equal(run.inheritedConversationTruncated, true);
|
|
277
|
+
assert.equal(result.inheritConversation, true);
|
|
278
|
+
assert.equal(result.inheritedConversationChars, "[user]\\nParent requirement".length);
|
|
279
|
+
assert.equal(result.inheritedConversationTruncated, true);
|
|
280
|
+
});
|
|
281
|
+
|
|
232
282
|
test("prerun failure (roles api unavailable) becomes a failed run, not a throw", async () => {
|
|
233
283
|
const run = startSubagentRun(
|
|
234
284
|
makeDeps({
|
package/src/run.ts
CHANGED
|
@@ -48,6 +48,9 @@ export interface RunHandle {
|
|
|
48
48
|
readonly task: string;
|
|
49
49
|
readonly context?: string;
|
|
50
50
|
readonly files?: string[];
|
|
51
|
+
readonly inheritConversation?: boolean;
|
|
52
|
+
readonly inheritedConversationChars?: number;
|
|
53
|
+
readonly inheritedConversationTruncated?: boolean;
|
|
51
54
|
/** Lifecycle state, kept in sync with the latest snapshot frame. */
|
|
52
55
|
readonly state: RunState;
|
|
53
56
|
/** Latest frame: queued placeholder, live progress, or terminal result. */
|
|
@@ -76,6 +79,12 @@ export interface StartRunOptions {
|
|
|
76
79
|
task: string;
|
|
77
80
|
context?: string;
|
|
78
81
|
files?: string[];
|
|
82
|
+
/** Opt in to a text-only snapshot of the parent's active conversation. */
|
|
83
|
+
inheritConversation?: boolean;
|
|
84
|
+
/** Immutable serialized parent-conversation body; never persisted to history. */
|
|
85
|
+
inheritedConversation?: string;
|
|
86
|
+
/** Whether maxChars shortened the serialized parent conversation. */
|
|
87
|
+
inheritedConversationTruncated?: boolean;
|
|
79
88
|
cwd: string;
|
|
80
89
|
/** Nesting depth for the child (CURRENT_DEPTH + 1). */
|
|
81
90
|
depth: number;
|
|
@@ -98,6 +107,13 @@ export interface StartRunOptions {
|
|
|
98
107
|
export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
99
108
|
const spawn = opts.spawnImpl ?? spawnSubagent;
|
|
100
109
|
const listeners = new Set<() => void>();
|
|
110
|
+
const inheritanceMetadata = opts.inheritConversation
|
|
111
|
+
? {
|
|
112
|
+
inheritConversation: true as const,
|
|
113
|
+
inheritedConversationChars: opts.inheritedConversation?.length ?? 0,
|
|
114
|
+
inheritedConversationTruncated: opts.inheritedConversationTruncated ?? false,
|
|
115
|
+
}
|
|
116
|
+
: {};
|
|
101
117
|
|
|
102
118
|
const inputFrame = (exitCode: number, queued: boolean): SubagentResult => ({
|
|
103
119
|
role: opts.role,
|
|
@@ -110,6 +126,7 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
|
110
126
|
activityLog: [],
|
|
111
127
|
files: opts.files,
|
|
112
128
|
context: opts.context,
|
|
129
|
+
...inheritanceMetadata,
|
|
113
130
|
});
|
|
114
131
|
|
|
115
132
|
let currentState: RunState = "queued";
|
|
@@ -163,6 +180,7 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
|
163
180
|
task: opts.task,
|
|
164
181
|
context: opts.context,
|
|
165
182
|
files: opts.files,
|
|
183
|
+
...inheritanceMetadata,
|
|
166
184
|
get state() {
|
|
167
185
|
return currentState;
|
|
168
186
|
},
|
|
@@ -198,8 +216,7 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
|
198
216
|
try {
|
|
199
217
|
await opts.gate.acquire(controller.signal);
|
|
200
218
|
} catch {
|
|
201
|
-
const msg =
|
|
202
|
-
"still queued for a concurrency slot" + (abortReason ? ` (${abortReason})` : "");
|
|
219
|
+
const msg = "still queued for a concurrency slot" + (abortReason ? ` (${abortReason})` : "");
|
|
203
220
|
finish(
|
|
204
221
|
{ ...inputFrame(1, false), stopReason: "cancelled", errorMessage: msg },
|
|
205
222
|
new Error(msg),
|
|
@@ -279,9 +296,11 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
|
279
296
|
pauseStart: partial.pauseStart,
|
|
280
297
|
files: opts.files,
|
|
281
298
|
context: opts.context,
|
|
299
|
+
...inheritanceMetadata,
|
|
282
300
|
fallbackFrom: activeFallbackFrom,
|
|
283
301
|
});
|
|
284
|
-
const emitProgress = (partial: Partial<SubagentResult>) =>
|
|
302
|
+
const emitProgress = (partial: Partial<SubagentResult>) =>
|
|
303
|
+
setFrame(liveFrame(partial), "running");
|
|
285
304
|
|
|
286
305
|
// Running placeholder now that we hold a slot.
|
|
287
306
|
setFrame(liveFrame({}), "running");
|
|
@@ -294,6 +313,8 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
|
294
313
|
systemPrompt: opts.roleDef.systemPrompt,
|
|
295
314
|
context: opts.context,
|
|
296
315
|
contextFiles: opts.files,
|
|
316
|
+
inheritConversation: opts.inheritConversation,
|
|
317
|
+
inheritedConversation: opts.inheritedConversation,
|
|
297
318
|
subagentRoles: opts.roleDef.subagentRoles,
|
|
298
319
|
timeoutMs: timeoutBudgetMs,
|
|
299
320
|
maxTurns,
|
|
@@ -330,6 +351,8 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
|
330
351
|
systemPrompt: opts.roleDef.systemPrompt,
|
|
331
352
|
context: opts.context,
|
|
332
353
|
contextFiles: opts.files,
|
|
354
|
+
inheritConversation: opts.inheritConversation,
|
|
355
|
+
inheritedConversation: opts.inheritedConversation,
|
|
333
356
|
subagentRoles: opts.roleDef.subagentRoles,
|
|
334
357
|
timeoutMs: timeoutBudgetMs,
|
|
335
358
|
maxTurns,
|
|
@@ -351,6 +374,7 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
|
351
374
|
runResult.role = opts.role;
|
|
352
375
|
runResult.files = opts.files;
|
|
353
376
|
runResult.context = opts.context;
|
|
377
|
+
Object.assign(runResult, inheritanceMetadata);
|
|
354
378
|
runResult.elapsedMs = Date.now() - startTime;
|
|
355
379
|
|
|
356
380
|
// Compress/truncate oversized output before it reaches the main model or TUI.
|
|
@@ -398,9 +422,7 @@ export function startSubagentRun(opts: StartRunOptions): RunHandle {
|
|
|
398
422
|
activityLog: partial.activityLog,
|
|
399
423
|
budgetMs: partial.budgetMs,
|
|
400
424
|
elapsedMs: partial.startTime ? Date.now() - partial.startTime : undefined,
|
|
401
|
-
errorMessage: wasCancelled
|
|
402
|
-
? abortReason || "cancelled"
|
|
403
|
-
: err?.message || String(err),
|
|
425
|
+
errorMessage: wasCancelled ? abortReason || "cancelled" : err?.message || String(err),
|
|
404
426
|
};
|
|
405
427
|
// The run spawned before throwing — audit it like any terminal state.
|
|
406
428
|
// The partial output is raw (compression never ran on it).
|
package/src/spawn.test.ts
CHANGED
|
@@ -17,7 +17,7 @@ describe("composeInitialMessage", () => {
|
|
|
17
17
|
try {
|
|
18
18
|
const fileA = path.join(dir, "a.md");
|
|
19
19
|
fs.writeFileSync(fileA, "alpha content");
|
|
20
|
-
const message = await composeInitialMessage([fileA], "some ctx", "do the thing");
|
|
20
|
+
const message = await composeInitialMessage([fileA], undefined, "some ctx", "do the thing");
|
|
21
21
|
assert.equal(
|
|
22
22
|
message,
|
|
23
23
|
`<file name="${fileA}">\nalpha content\n</file>\n\n<context>\nsome ctx\n</context>\n\n<task>\ndo the thing\n</task>`,
|
|
@@ -28,24 +28,40 @@ describe("composeInitialMessage", () => {
|
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
test("omits absent channels and preserves relative block order", async () => {
|
|
31
|
-
const message = await composeInitialMessage(undefined, undefined, "only a task");
|
|
31
|
+
const message = await composeInitialMessage(undefined, undefined, undefined, "only a task");
|
|
32
32
|
assert.equal(message, "<task>\nonly a task\n</task>");
|
|
33
|
-
const ctxOnly = await composeInitialMessage(undefined, "ctx body", "");
|
|
33
|
+
const ctxOnly = await composeInitialMessage(undefined, undefined, "ctx body", "");
|
|
34
34
|
assert.equal(ctxOnly, "<context>\nctx body\n</context>");
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
test("unreadable files degrade to a placeholder instead of failing the run", async () => {
|
|
38
38
|
const missing = path.join(os.tmpdir(), "pi-sub-test-does-not-exist.md");
|
|
39
|
-
const message = await composeInitialMessage([missing], undefined, "t");
|
|
39
|
+
const message = await composeInitialMessage([missing], undefined, undefined, "t");
|
|
40
40
|
assert.match(message, /^\[?<file name="/);
|
|
41
41
|
assert.match(message, /failed to read file/);
|
|
42
42
|
assert.match(message, /\n\n<task>\nt\n<\/task>$/);
|
|
43
43
|
});
|
|
44
44
|
|
|
45
45
|
test("blank (whitespace-only) context is dropped", async () => {
|
|
46
|
-
const message = await composeInitialMessage(undefined, " \n\t", "t");
|
|
46
|
+
const message = await composeInitialMessage(undefined, undefined, " \n\t", "t");
|
|
47
47
|
assert.equal(message, "<task>\nt\n</task>");
|
|
48
48
|
});
|
|
49
|
+
|
|
50
|
+
test("puts inherited conversation after files and before context/task", async () => {
|
|
51
|
+
const message = await composeInitialMessage(
|
|
52
|
+
["missing.txt"],
|
|
53
|
+
"parent text",
|
|
54
|
+
"explicit ctx",
|
|
55
|
+
"delta task",
|
|
56
|
+
);
|
|
57
|
+
assert.ok(
|
|
58
|
+
message.indexOf('<file name="missing.txt">') < message.indexOf("<inherited_conversation>"),
|
|
59
|
+
);
|
|
60
|
+
assert.ok(message.indexOf("<inherited_conversation>") < message.indexOf("<context>"));
|
|
61
|
+
assert.ok(message.indexOf("<context>") < message.lastIndexOf("<task>"));
|
|
62
|
+
assert.match(message, /text-only background/i);
|
|
63
|
+
assert.match(message, /separate task block remains authoritative/);
|
|
64
|
+
});
|
|
49
65
|
});
|
|
50
66
|
|
|
51
67
|
describe("buildChildArgs", () => {
|
|
@@ -92,12 +108,20 @@ describe("buildChildArgs", () => {
|
|
|
92
108
|
assert.ok(!args.includes("--exclude-tools"));
|
|
93
109
|
});
|
|
94
110
|
|
|
111
|
+
test("policy is conditional on conversation inheritance", () => {
|
|
112
|
+
const isolatedArgs = buildChildArgs("m", {}, "/t");
|
|
113
|
+
assert.deepEqual(buildChildArgs("m", { inheritConversation: false }, "/t"), isolatedArgs);
|
|
114
|
+
const isolated = isolatedArgs.join("\n");
|
|
115
|
+
const inherited = buildChildArgs("m", { inheritConversation: true }, "/t").join("\n");
|
|
116
|
+
assert.match(isolated, /you have NO prior conversation/);
|
|
117
|
+
assert.ok(!isolated.includes("<inherited_conversation> block"));
|
|
118
|
+
assert.match(inherited, /<inherited_conversation> block/);
|
|
119
|
+
assert.match(inherited, /may be compacted or truncated/);
|
|
120
|
+
assert.ok(!inherited.includes("you have NO prior conversation"));
|
|
121
|
+
});
|
|
122
|
+
|
|
95
123
|
test("thinking and role system prompt are wrapped in their blocks", () => {
|
|
96
|
-
const args = buildChildArgs(
|
|
97
|
-
"m",
|
|
98
|
-
{ thinking: "high", systemPrompt: " Be brief. " },
|
|
99
|
-
"/t",
|
|
100
|
-
);
|
|
124
|
+
const args = buildChildArgs("m", { thinking: "high", systemPrompt: " Be brief. " }, "/t");
|
|
101
125
|
assert.equal(args[args.indexOf("--thinking") + 1], "high");
|
|
102
126
|
const roleIdx = args.findIndex((a) => a.startsWith("<subagent_role>"));
|
|
103
127
|
assert.ok(roleIdx > 0);
|
package/src/spawn.ts
CHANGED
|
@@ -149,6 +149,7 @@ export function buildChildArgs(
|
|
|
149
149
|
tools?: string[];
|
|
150
150
|
excludeTools?: string[];
|
|
151
151
|
systemPrompt?: string;
|
|
152
|
+
inheritConversation?: boolean;
|
|
152
153
|
},
|
|
153
154
|
tmpDir: string,
|
|
154
155
|
): string[] {
|
|
@@ -187,6 +188,17 @@ export function buildChildArgs(
|
|
|
187
188
|
// does; this shapes HOW any subagent behaves when the task exceeds its
|
|
188
189
|
// actual capabilities: report the gap and stop instead of improvising
|
|
189
190
|
// workarounds until timeout.
|
|
191
|
+
const conversationPolicy = options.inheritConversation
|
|
192
|
+
? [
|
|
193
|
+
"- You have an <inherited_conversation> block from the parent session.",
|
|
194
|
+
" It is text-only background and may be compacted or truncated. If needed",
|
|
195
|
+
" material is absent, report it as Missing — do not guess it.",
|
|
196
|
+
]
|
|
197
|
+
: [
|
|
198
|
+
"- The task may reference material as 'discussed above' or 'the requirements'",
|
|
199
|
+
" — you have NO prior conversation; only this prompt exists. If referenced",
|
|
200
|
+
" material is not in this prompt, report it as Missing — do not guess it.",
|
|
201
|
+
];
|
|
190
202
|
args.push(
|
|
191
203
|
"--append-system-prompt",
|
|
192
204
|
[
|
|
@@ -194,11 +206,9 @@ export function buildChildArgs(
|
|
|
194
206
|
"Before attempting the task, check it against your actual capabilities in this",
|
|
195
207
|
"session — the tool list here is definitive.",
|
|
196
208
|
"- If the task needs a capability you do not have (web access, bash, file",
|
|
197
|
-
" writes, ...) or material that is
|
|
198
|
-
"
|
|
199
|
-
|
|
200
|
-
" — you have NO prior conversation; only this prompt exists. If referenced",
|
|
201
|
-
" material is not in this prompt, report it as Missing — do not guess it.",
|
|
209
|
+
" writes, ...) or material that is absent from local files and the provided",
|
|
210
|
+
" prompt channels, it is out of scope for you. Do NOT improvise workarounds.",
|
|
211
|
+
...conversationPolicy,
|
|
202
212
|
'- "Cannot complete" means a capability or material gap — not "difficult" or',
|
|
203
213
|
' "uncertain". If it is merely hard, keep working within your tools.',
|
|
204
214
|
"- When you hit a genuine gap, stop early and return:",
|
|
@@ -218,14 +228,15 @@ export function buildChildArgs(
|
|
|
218
228
|
|
|
219
229
|
/**
|
|
220
230
|
* Compose the initial RPC prompt message: reference files as <file> blocks,
|
|
221
|
-
* then context and task as structured tags —
|
|
222
|
-
* json mode (@file arguments wrapped by pi's
|
|
223
|
-
* the inline message body).
|
|
231
|
+
* optional inherited conversation, then context and task as structured tags —
|
|
232
|
+
* the same shape the child saw in json mode (@file arguments wrapped by pi's
|
|
233
|
+
* processFileArguments, followed by the inline message body).
|
|
224
234
|
*
|
|
225
235
|
* @internal — exported for testing.
|
|
226
236
|
*/
|
|
227
237
|
export async function composeInitialMessage(
|
|
228
238
|
files: string[] | undefined,
|
|
239
|
+
inheritedConversation: string | undefined,
|
|
229
240
|
context: string | undefined,
|
|
230
241
|
task: string,
|
|
231
242
|
): Promise<string> {
|
|
@@ -241,6 +252,17 @@ export async function composeInitialMessage(
|
|
|
241
252
|
parts.push(`<file name="${f}">\n${content}\n</file>`);
|
|
242
253
|
}
|
|
243
254
|
}
|
|
255
|
+
if (inheritedConversation !== undefined) {
|
|
256
|
+
parts.push(
|
|
257
|
+
[
|
|
258
|
+
"<inherited_conversation>",
|
|
259
|
+
"Text-only background from the parent conversation; it may be compacted or truncated. The separate task block remains authoritative.",
|
|
260
|
+
"",
|
|
261
|
+
inheritedConversation,
|
|
262
|
+
"</inherited_conversation>",
|
|
263
|
+
].join("\n"),
|
|
264
|
+
);
|
|
265
|
+
}
|
|
244
266
|
if (context?.trim()) parts.push(`<context>\n${context}\n</context>`);
|
|
245
267
|
if (task.trim()) parts.push(`<task>\n${task}\n</task>`);
|
|
246
268
|
return parts.join("\n\n");
|
|
@@ -268,6 +290,10 @@ export async function spawnSubagent(
|
|
|
268
290
|
systemPrompt?: string;
|
|
269
291
|
/** Extra context delivered as a separate channel from the task. */
|
|
270
292
|
context?: string;
|
|
293
|
+
/** Enables the inherited-conversation policy variant. */
|
|
294
|
+
inheritConversation?: boolean;
|
|
295
|
+
/** Immutable serialized parent-conversation body injected independently from context/task. */
|
|
296
|
+
inheritedConversation?: string;
|
|
271
297
|
/** Reference files injected as <file> blocks in the initial prompt (child reads them directly). */
|
|
272
298
|
contextFiles?: string[];
|
|
273
299
|
subagentRoles?: string[];
|
|
@@ -332,7 +358,12 @@ export async function spawnSubagent(
|
|
|
332
358
|
// (processFileArguments). Content still never enters the parent model's
|
|
333
359
|
// context; this process reads the bytes off disk and pipes them straight
|
|
334
360
|
// to the child.
|
|
335
|
-
const initialMessage = await composeInitialMessage(
|
|
361
|
+
const initialMessage = await composeInitialMessage(
|
|
362
|
+
options.contextFiles,
|
|
363
|
+
options.inheritedConversation,
|
|
364
|
+
options.context,
|
|
365
|
+
task,
|
|
366
|
+
);
|
|
336
367
|
|
|
337
368
|
// Spawn process
|
|
338
369
|
const invocation = getPiInvocation(args);
|
|
@@ -384,7 +415,10 @@ export async function spawnSubagent(
|
|
|
384
415
|
// Called after each assistant message_end (usage already accumulated).
|
|
385
416
|
const checkBudget = () => {
|
|
386
417
|
if (budgetExceeded || wasTimeout) return;
|
|
387
|
-
if (
|
|
418
|
+
if (
|
|
419
|
+
(maxTurns > 0 && result.usage.turns >= maxTurns) ||
|
|
420
|
+
(maxCost > 0 && result.usage.cost >= maxCost)
|
|
421
|
+
) {
|
|
388
422
|
budgetExceeded = true;
|
|
389
423
|
killProc("budget");
|
|
390
424
|
}
|
package/src/types.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface SubagentConfig {
|
|
|
15
15
|
/** Persist every spawned delegate run (finished/failed/aborted alike) to ~/.pi/subagent/history/{sessionId}/{toolCallId}.json for auditing. Pre-run failures that never spawned are not recorded. */
|
|
16
16
|
history: SubagentHistoryConfig;
|
|
17
17
|
summary: SubagentSummaryConfig;
|
|
18
|
+
/** Limits optional serialized parent-conversation inheritance. */
|
|
19
|
+
inheritance: SubagentInheritanceConfig;
|
|
18
20
|
/**
|
|
19
21
|
* Per-role overrides from settings.json. Keyed by role name.
|
|
20
22
|
* - Override built-in roles: provide fields to merge.
|
|
@@ -32,6 +34,11 @@ export interface SubagentSummaryConfig {
|
|
|
32
34
|
enabled: boolean;
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
export interface SubagentInheritanceConfig {
|
|
38
|
+
/** Maximum characters in the inherited-conversation body. */
|
|
39
|
+
maxChars: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
35
42
|
export const DEFAULT_CONFIG: SubagentConfig = {
|
|
36
43
|
maxConcurrency: 4,
|
|
37
44
|
maxDepth: 3,
|
|
@@ -39,6 +46,7 @@ export const DEFAULT_CONFIG: SubagentConfig = {
|
|
|
39
46
|
maxCost: 0,
|
|
40
47
|
history: { enabled: true },
|
|
41
48
|
summary: { role: "utility", enabled: true },
|
|
49
|
+
inheritance: { maxChars: 50_000 },
|
|
42
50
|
agentOverrides: {},
|
|
43
51
|
};
|
|
44
52
|
|
|
@@ -181,6 +189,12 @@ export interface SubagentResult {
|
|
|
181
189
|
files?: string[];
|
|
182
190
|
/** Extra context passed to delegate (params.context); used by the expanded view. */
|
|
183
191
|
context?: string;
|
|
192
|
+
/** True when this run received a filtered parent-conversation snapshot. */
|
|
193
|
+
inheritConversation?: boolean;
|
|
194
|
+
/** Delivered inherited-conversation body size; safe metadata only, never the body itself. */
|
|
195
|
+
inheritedConversationChars?: number;
|
|
196
|
+
/** True when the inherited body was mechanically shortened to its configured limit. */
|
|
197
|
+
inheritedConversationTruncated?: boolean;
|
|
184
198
|
}
|
|
185
199
|
|
|
186
200
|
/** Snapshot of a failed first attempt that was retried on the fallback role. */
|
|
@@ -213,6 +227,9 @@ export interface BackgroundDelegateDetails {
|
|
|
213
227
|
task: string;
|
|
214
228
|
context?: string;
|
|
215
229
|
files?: string[];
|
|
230
|
+
inheritConversation?: boolean;
|
|
231
|
+
inheritedConversationChars?: number;
|
|
232
|
+
inheritedConversationTruncated?: boolean;
|
|
216
233
|
}
|
|
217
234
|
|
|
218
235
|
/** One watched run inside a wait/check view. */
|
|
@@ -230,15 +247,6 @@ export interface WaitDetails {
|
|
|
230
247
|
timedOut?: boolean;
|
|
231
248
|
}
|
|
232
249
|
|
|
233
|
-
/** Lightweight tombstone kept in the registry after a run's result was claimed via subagent_check — /subagent:status history without the full state machine. */
|
|
234
|
-
export interface CollectedRun {
|
|
235
|
-
id: string;
|
|
236
|
-
role: string;
|
|
237
|
-
/** First-line task preview (same 70-char cap as the inbox reminder). */
|
|
238
|
-
task: string;
|
|
239
|
-
state: "finished" | "failed";
|
|
240
|
-
}
|
|
241
|
-
|
|
242
250
|
/** Details for a check tool result — a frozen one-shot snapshot of a single run. */
|
|
243
251
|
export interface CheckDetails {
|
|
244
252
|
id: string;
|
|
@@ -253,6 +261,17 @@ export interface CheckDetails {
|
|
|
253
261
|
*/
|
|
254
262
|
export type CancelDetails = CheckDetails;
|
|
255
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Details for a steer tool result — the echoed correction. Collapsed shows
|
|
266
|
+
* icon + first line; expanded shows the full message plus the delivery hint
|
|
267
|
+
* (check is the result-fetcher for the effect, never this row).
|
|
268
|
+
*/
|
|
269
|
+
export interface SteerDetails {
|
|
270
|
+
id: string;
|
|
271
|
+
role: string;
|
|
272
|
+
message: string;
|
|
273
|
+
}
|
|
274
|
+
|
|
256
275
|
/**
|
|
257
276
|
* Details for the background-run completion notice (custom message
|
|
258
277
|
* `subagent-completion`). The renderer lays these out as a structured notice
|
package/src/utils.test.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
previewArgs,
|
|
22
22
|
truncateOutput,
|
|
23
23
|
formatTokens,
|
|
24
|
+
formatInheritedConversationInput,
|
|
24
25
|
formatUsageStats,
|
|
25
26
|
effectiveTimeout,
|
|
26
27
|
elapsedSeconds,
|
|
@@ -44,6 +45,7 @@ import {
|
|
|
44
45
|
completionNoticeLines,
|
|
45
46
|
formatToolCall,
|
|
46
47
|
briefFilesUsed,
|
|
48
|
+
collectDeliveredIds,
|
|
47
49
|
} from "./utils.ts";
|
|
48
50
|
import type { ActivityEntry, SubagentResult, SubagentRole } from "./types.ts";
|
|
49
51
|
|
|
@@ -324,6 +326,18 @@ describe("truncateOutput", () => {
|
|
|
324
326
|
});
|
|
325
327
|
});
|
|
326
328
|
|
|
329
|
+
// ── inherited-conversation input metadata ──
|
|
330
|
+
describe("formatInheritedConversationInput", () => {
|
|
331
|
+
test("formats delivered chars, truncation, and empty inheritance", () => {
|
|
332
|
+
assert.equal(formatInheritedConversationInput(42, false), "conversation 42 chars");
|
|
333
|
+
assert.equal(
|
|
334
|
+
formatInheritedConversationInput(50_000, true),
|
|
335
|
+
"conversation 50000 chars · truncated",
|
|
336
|
+
);
|
|
337
|
+
assert.equal(formatInheritedConversationInput(0, false), "conversation inherited · empty");
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
327
341
|
// ── formatTokens: boundary correctness ──
|
|
328
342
|
describe("formatTokens", () => {
|
|
329
343
|
test("under 1000 stays raw", () => {
|
|
@@ -853,3 +867,40 @@ describe("briefFilesUsed", () => {
|
|
|
853
867
|
assert.equal(used.get(F1), false);
|
|
854
868
|
});
|
|
855
869
|
});
|
|
870
|
+
|
|
871
|
+
describe("collectDeliveredIds", () => {
|
|
872
|
+
const checkEntry = (id: string) => ({
|
|
873
|
+
type: "message",
|
|
874
|
+
message: { role: "toolResult", toolName: "subagent_check", details: { id, role: "worker" } },
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
test("collects ids from subagent_check tool results only", () => {
|
|
878
|
+
const entries = [
|
|
879
|
+
{ type: "message", message: { role: "user", content: "hi" } },
|
|
880
|
+
{ type: "message", message: { role: "assistant", content: [] } },
|
|
881
|
+
{ type: "message", message: { role: "toolResult", toolName: "read", details: { id: "sub-9" } } },
|
|
882
|
+
{ type: "message", message: { role: "toolResult", toolName: "subagent_wait", details: { entries: [] } } },
|
|
883
|
+
checkEntry("sub-1"),
|
|
884
|
+
{ type: "message", message: { role: "custom", customType: "subagent-completion" } },
|
|
885
|
+
{ type: "compaction" },
|
|
886
|
+
];
|
|
887
|
+
assert.deepEqual(collectDeliveredIds(entries), new Set(["sub-1"]));
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
test("dedupes repeated checks of the same id", () => {
|
|
891
|
+
assert.deepEqual(collectDeliveredIds([checkEntry("sub-1"), checkEntry("sub-1")]), new Set(["sub-1"]));
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
test("empty path means nothing delivered (branch rewound past the check)", () => {
|
|
895
|
+
assert.equal(collectDeliveredIds([]).size, 0);
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
test("ignores malformed details", () => {
|
|
899
|
+
const entries = [
|
|
900
|
+
{ type: "message", message: { role: "toolResult", toolName: "subagent_check" } },
|
|
901
|
+
{ type: "message", message: { role: "toolResult", toolName: "subagent_check", details: {} } },
|
|
902
|
+
{ type: "message", message: { role: "toolResult", toolName: "subagent_check", details: { id: 42 } } },
|
|
903
|
+
];
|
|
904
|
+
assert.equal(collectDeliveredIds(entries).size, 0);
|
|
905
|
+
});
|
|
906
|
+
});
|
package/src/utils.ts
CHANGED
|
@@ -38,6 +38,12 @@ export function formatTokens(count: number): string {
|
|
|
38
38
|
return `${(count / 1000000).toFixed(1)}M`;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** Safe TUI label for inherited-conversation input; never includes transcript text. */
|
|
42
|
+
export function formatInheritedConversationInput(chars: number, truncated: boolean): string {
|
|
43
|
+
if (chars === 0) return "conversation inherited · empty";
|
|
44
|
+
return `conversation ${chars} chars${truncated ? " · truncated" : ""}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
41
47
|
/**
|
|
42
48
|
* Usage parts shared by the TUI stats line and the LLM usage footer.
|
|
43
49
|
* `withCache` adds the cache-read/write and peak-context figures (TUI only —
|
|
@@ -638,9 +644,9 @@ export function formatCheckText(id: string, role: string, r: SubagentResult): st
|
|
|
638
644
|
}
|
|
639
645
|
|
|
640
646
|
/**
|
|
641
|
-
* Cancel confirmation text: short, no output
|
|
642
|
-
* check's job to return
|
|
643
|
-
* the
|
|
647
|
+
* Cancel confirmation text for the /subagent:cancel command: short, no output
|
|
648
|
+
* dump — the partial output is check's job to return. Always points at check
|
|
649
|
+
* so the user knows where the partial output lives.
|
|
644
650
|
*/
|
|
645
651
|
/**
|
|
646
652
|
* Compact stop summary shared by the cancel tool text and its TUI row:
|
|
@@ -657,8 +663,8 @@ export function cancelStopSummary(r: SubagentResult): string {
|
|
|
657
663
|
|
|
658
664
|
/**
|
|
659
665
|
* Cancel confirmation text: short, no output dump — the partial output is
|
|
660
|
-
* check's job to return
|
|
661
|
-
*
|
|
666
|
+
* check's job to return. Always points at check so the model fetches the
|
|
667
|
+
* partial output it is entitled to.
|
|
662
668
|
*/
|
|
663
669
|
export function formatCancelText(id: string, role: string, r: SubagentResult): string {
|
|
664
670
|
const head = `${id} (${role})`;
|
|
@@ -819,3 +825,40 @@ export function truncateOutput(t: string): string {
|
|
|
819
825
|
const tail = t.slice(-(MAX_OUTPUT_CHARS - 30_050));
|
|
820
826
|
return `[Output truncated — ${t.length} chars total]\n\n${head}\n\n... [truncated] ...\n\n${tail}`;
|
|
821
827
|
}
|
|
828
|
+
|
|
829
|
+
// ── Session-tree delivery derivation ────────────────────────
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Minimal structural slice of a session entry — the only fields
|
|
833
|
+
* collectDeliveredIds reads. The real SessionEntry from
|
|
834
|
+
* ctx.sessionManager.buildContextEntries() satisfies this shape structurally;
|
|
835
|
+
* keeping it local preserves this module's zero pi-API-dependency rule and
|
|
836
|
+
* lets tests build plain fakes.
|
|
837
|
+
*/
|
|
838
|
+
interface SessionEntryLike {
|
|
839
|
+
type: string;
|
|
840
|
+
message?: {
|
|
841
|
+
role?: string;
|
|
842
|
+
toolName?: string;
|
|
843
|
+
details?: unknown;
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Derive the set of background-run ids already delivered by subagent_check
|
|
849
|
+
* on the given session entries. The session tree is the single source of
|
|
850
|
+
* truth for delivery state: it is append-only and branch navigation rebuilds
|
|
851
|
+
* the active path, so branching past a check entry un-delivers (the inbox
|
|
852
|
+
* re-arms) while branching back re-delivers — no mirrored state to sync.
|
|
853
|
+
*/
|
|
854
|
+
export function collectDeliveredIds(entries: Iterable<SessionEntryLike>): Set<string> {
|
|
855
|
+
const ids = new Set<string>();
|
|
856
|
+
for (const entry of entries) {
|
|
857
|
+
if (entry.type !== "message") continue;
|
|
858
|
+
const message = entry.message;
|
|
859
|
+
if (!message || message.role !== "toolResult" || message.toolName !== "subagent_check") continue;
|
|
860
|
+
const id = (message.details as { id?: unknown } | undefined)?.id;
|
|
861
|
+
if (typeof id === "string" && id) ids.add(id);
|
|
862
|
+
}
|
|
863
|
+
return ids;
|
|
864
|
+
}
|
package/src/view.ts
CHANGED
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
* (a "⋮ N earlier" marker appears), reaching the bottom again (or End)
|
|
16
16
|
* re-pins.
|
|
17
17
|
* - brief: the run's inputs and vitals — task and context verbatim (wrapped;
|
|
18
|
-
* head+tail elided when huge),
|
|
19
|
-
*
|
|
18
|
+
* head+tail elided when huge), safe inherited-conversation size/truncation
|
|
19
|
+
* metadata, the reference file list annotated with ✓/· for whether the
|
|
20
|
+
* child's tool calls touched each file, usage and time
|
|
20
21
|
* stats, the fallback trace, and a stderr tail on failures.
|
|
21
22
|
*
|
|
22
23
|
* Steer input is modal so keys never conflict with the editor: browse mode
|
|
@@ -51,6 +52,7 @@ import type { ActivityEntry } from "./types.ts";
|
|
|
51
52
|
import {
|
|
52
53
|
briefFilesUsed,
|
|
53
54
|
formatFallback,
|
|
55
|
+
formatInheritedConversationInput,
|
|
54
56
|
formatThinking,
|
|
55
57
|
formatTimePart,
|
|
56
58
|
formatToolCall,
|
|
@@ -339,7 +341,8 @@ export class SubagentViewPanel implements Component, Focusable {
|
|
|
339
341
|
}
|
|
340
342
|
|
|
341
343
|
/** Render the brief page's full content (pre-scroll): task/context verbatim,
|
|
342
|
-
* annotated
|
|
344
|
+
* inherited-conversation metadata, annotated files, stats, fallback trace,
|
|
345
|
+
* and failure stderr tail. */
|
|
343
346
|
private renderBriefLines(run: RunHandle, width: number, fg: Fg): string[] {
|
|
344
347
|
const snap = run.snapshot;
|
|
345
348
|
const lines: string[] = [];
|
|
@@ -361,6 +364,15 @@ export class SubagentViewPanel implements Component, Focusable {
|
|
|
361
364
|
body(run.context);
|
|
362
365
|
}
|
|
363
366
|
|
|
367
|
+
if (run.inheritConversation) {
|
|
368
|
+
section(
|
|
369
|
+
formatInheritedConversationInput(
|
|
370
|
+
run.inheritedConversationChars ?? 0,
|
|
371
|
+
run.inheritedConversationTruncated === true,
|
|
372
|
+
),
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
364
376
|
if (run.files && run.files.length > 0) {
|
|
365
377
|
section(`files · ${run.files.length}`);
|
|
366
378
|
const used = briefFilesUsed(run.files, snap.activityLog);
|