@d3ara1n/pi-subagent 3.0.0 → 3.2.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 -12
- 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 +34 -13
- package/src/inheritance.test.ts +217 -0
- package/src/inheritance.ts +188 -0
- package/src/render-async.ts +21 -2
- package/src/render.test.ts +63 -0
- package/src/render.ts +25 -3
- package/src/roles.ts +9 -2
- 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 +17 -0
- package/src/utils.test.ts +13 -0
- package/src/utils.ts +6 -0
- package/src/view.ts +15 -3
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic, text-only serialization of an active pi session branch for
|
|
3
|
+
* optional subagent conversation inheritance.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
const OMISSION_MARKER = "[Earlier inherited conversation omitted for length.]";
|
|
9
|
+
const MESSAGE_OMISSION_MARKER = "[Earlier text in this message omitted.]";
|
|
10
|
+
|
|
11
|
+
type EntryLike = {
|
|
12
|
+
type?: unknown;
|
|
13
|
+
summary?: unknown;
|
|
14
|
+
message?: unknown;
|
|
15
|
+
retainedTail?: unknown;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type MessageLike = {
|
|
19
|
+
role?: unknown;
|
|
20
|
+
content?: unknown;
|
|
21
|
+
summary?: unknown;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type Chunk = {
|
|
25
|
+
kind: "summary" | "dialogue";
|
|
26
|
+
text: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export interface InheritedConversationSnapshot {
|
|
30
|
+
/** Delimiter-safe text delivered to the child. */
|
|
31
|
+
text: string;
|
|
32
|
+
/** True when eligible inherited content was omitted to satisfy maxChars. */
|
|
33
|
+
truncated: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Keep inherited text from being interpreted as one of the surrounding prompt tags. */
|
|
37
|
+
function escapePromptText(text: string): string {
|
|
38
|
+
return text.replaceAll("&", "&").replaceAll("<", "<");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function textContent(content: unknown): string {
|
|
42
|
+
if (typeof content === "string") return escapePromptText(content);
|
|
43
|
+
if (!Array.isArray(content)) return "";
|
|
44
|
+
return content
|
|
45
|
+
.flatMap((part) => {
|
|
46
|
+
if (!part || typeof part !== "object") return [];
|
|
47
|
+
const block = part as { type?: unknown; text?: unknown };
|
|
48
|
+
return block.type === "text" && typeof block.text === "string"
|
|
49
|
+
? [escapePromptText(block.text)]
|
|
50
|
+
: [];
|
|
51
|
+
})
|
|
52
|
+
.join("");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function serializeMessage(message: unknown): Chunk | undefined {
|
|
56
|
+
if (!message || typeof message !== "object") return undefined;
|
|
57
|
+
const { role, content, summary } = message as MessageLike;
|
|
58
|
+
if (role === "user" || role === "assistant") {
|
|
59
|
+
const text = textContent(content);
|
|
60
|
+
return text ? { kind: "dialogue", text: `[${role}]\n${text}` } : undefined;
|
|
61
|
+
}
|
|
62
|
+
if (role === "compactionSummary" && typeof summary === "string" && summary) {
|
|
63
|
+
return {
|
|
64
|
+
kind: "summary",
|
|
65
|
+
text: `[Compaction summary]\n${escapePromptText(summary)}`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (role === "branchSummary" && typeof summary === "string" && summary) {
|
|
69
|
+
return { kind: "summary", text: `[Branch summary]\n${escapePromptText(summary)}` };
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function truncateDialogueChunk(text: string, maxChars: number): string {
|
|
75
|
+
if (text.length <= maxChars) return text;
|
|
76
|
+
const newline = text.indexOf("\n");
|
|
77
|
+
const label = newline >= 0 ? text.slice(0, newline) : "[message]";
|
|
78
|
+
const prefix = `${label}\n${MESSAGE_OMISSION_MARKER}\n`;
|
|
79
|
+
if (prefix.length >= maxChars) return prefix.slice(0, maxChars);
|
|
80
|
+
return prefix + text.slice(text.length - (maxChars - prefix.length));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Select newest complete dialogue chunks, truncating only the newest chunk as a last resort. */
|
|
84
|
+
function newestDialogue(chunks: Chunk[], maxChars: number): string {
|
|
85
|
+
if (maxChars <= 0 || chunks.length === 0) return "";
|
|
86
|
+
const selected: string[] = [];
|
|
87
|
+
let used = 0;
|
|
88
|
+
for (let i = chunks.length - 1; i >= 0; i -= 1) {
|
|
89
|
+
const separator = selected.length > 0 ? 2 : 0;
|
|
90
|
+
const remaining = maxChars - used - separator;
|
|
91
|
+
if (remaining <= 0) break;
|
|
92
|
+
const text = chunks[i].text;
|
|
93
|
+
if (text.length <= remaining) {
|
|
94
|
+
selected.unshift(text);
|
|
95
|
+
used += separator + text.length;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
selected.unshift(truncateDialogueChunk(text, remaining));
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
return selected.join("\n\n");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Serialize the supplied active, compaction-aware session entries in order.
|
|
106
|
+
* Only compaction/branch summaries and user/assistant text are retained.
|
|
107
|
+
*/
|
|
108
|
+
export function serializeInheritedConversation(
|
|
109
|
+
entries: SessionEntry[],
|
|
110
|
+
maxChars: number,
|
|
111
|
+
): InheritedConversationSnapshot {
|
|
112
|
+
const limit = Number.isFinite(maxChars) ? Math.max(0, Math.floor(maxChars)) : 0;
|
|
113
|
+
if (limit === 0) return { text: "", truncated: false };
|
|
114
|
+
const chunks: Chunk[] = [];
|
|
115
|
+
|
|
116
|
+
const addMessage = (message: unknown) => {
|
|
117
|
+
const serialized = serializeMessage(message);
|
|
118
|
+
if (serialized) chunks.push(serialized);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
for (const rawEntry of entries) {
|
|
122
|
+
const entry = rawEntry as EntryLike;
|
|
123
|
+
if (entry.type === "compaction") {
|
|
124
|
+
if (typeof entry.summary === "string" && entry.summary) {
|
|
125
|
+
chunks.push({
|
|
126
|
+
kind: "summary",
|
|
127
|
+
text: `[Compaction summary]\n${escapePromptText(entry.summary)}`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
// Newer compaction entries materialize their kept context here. The
|
|
131
|
+
// installed firstKeptEntryId shape returns those entries separately.
|
|
132
|
+
if (Array.isArray(entry.retainedTail)) {
|
|
133
|
+
for (const message of entry.retainedTail) addMessage(message);
|
|
134
|
+
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (entry.type === "branch_summary") {
|
|
138
|
+
if (typeof entry.summary === "string" && entry.summary) {
|
|
139
|
+
chunks.push({
|
|
140
|
+
kind: "summary",
|
|
141
|
+
text: `[Branch summary]\n${escapePromptText(entry.summary)}`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (entry.type === "message") addMessage(entry.message);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const full = chunks.map((chunk) => chunk.text).join("\n\n");
|
|
150
|
+
if (full.length <= limit) return { text: full, truncated: false };
|
|
151
|
+
|
|
152
|
+
const summaryText = chunks
|
|
153
|
+
.filter((chunk) => chunk.kind === "summary")
|
|
154
|
+
.map((chunk) => chunk.text)
|
|
155
|
+
.join("\n\n");
|
|
156
|
+
const dialogueChunks = chunks.filter((chunk) => chunk.kind === "dialogue");
|
|
157
|
+
const dialogueText = dialogueChunks.map((chunk) => chunk.text).join("\n\n");
|
|
158
|
+
|
|
159
|
+
// Reserve an explicit marker, then retain summary context plus the newest
|
|
160
|
+
// dialogue. Start with a 40/60 split, but redistribute every unused char so
|
|
161
|
+
// a short side never wastes capacity. This is deterministic and model-free.
|
|
162
|
+
const hasSummary = summaryText.length > 0;
|
|
163
|
+
const hasDialogue = dialogueText.length > 0;
|
|
164
|
+
const separatorChars = (hasSummary ? 2 : 0) + (hasDialogue ? 2 : 0);
|
|
165
|
+
if (limit <= OMISSION_MARKER.length + separatorChars) {
|
|
166
|
+
return { text: OMISSION_MARKER.slice(0, limit), truncated: true };
|
|
167
|
+
}
|
|
168
|
+
const available = limit - OMISSION_MARKER.length - separatorChars;
|
|
169
|
+
let summaryBudget =
|
|
170
|
+
hasSummary && hasDialogue ? Math.floor(available * 0.4) : hasSummary ? available : 0;
|
|
171
|
+
let dialogueBudget = hasDialogue ? available - summaryBudget : 0;
|
|
172
|
+
|
|
173
|
+
if (summaryText.length < summaryBudget) {
|
|
174
|
+
dialogueBudget += summaryBudget - summaryText.length;
|
|
175
|
+
summaryBudget = summaryText.length;
|
|
176
|
+
}
|
|
177
|
+
if (dialogueText.length < dialogueBudget) {
|
|
178
|
+
summaryBudget += dialogueBudget - dialogueText.length;
|
|
179
|
+
dialogueBudget = dialogueText.length;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const selectedSummary = summaryBudget > 0 ? summaryText.slice(0, summaryBudget) : "";
|
|
183
|
+
const selectedDialogue = newestDialogue(dialogueChunks, dialogueBudget);
|
|
184
|
+
return {
|
|
185
|
+
text: [selectedSummary, OMISSION_MARKER, selectedDialogue].filter(Boolean).join("\n\n"),
|
|
186
|
+
truncated: true,
|
|
187
|
+
};
|
|
188
|
+
}
|
package/src/render-async.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
deriveRunState,
|
|
48
48
|
ensureElapsedTimer,
|
|
49
49
|
formatFallback,
|
|
50
|
+
formatInheritedConversationInput,
|
|
50
51
|
formatThinking,
|
|
51
52
|
formatTimePart,
|
|
52
53
|
formatToolCall,
|
|
@@ -283,10 +284,13 @@ export const renderCompletionNotice: MessageRenderer<CompletionNoticeDetails> =
|
|
|
283
284
|
|
|
284
285
|
export const renderBackgroundDelegateCall: RenderCallFn = (args, theme) => {
|
|
285
286
|
const roleName = (args as any).role || "...";
|
|
287
|
+
const mode = (args as any).inheritConversation
|
|
288
|
+
? " (background · inherits conversation)"
|
|
289
|
+
: " (background)";
|
|
286
290
|
const text =
|
|
287
291
|
theme.fg("toolTitle", theme.bold("subagent_delegate ")) +
|
|
288
292
|
theme.fg("accent", roleName) +
|
|
289
|
-
theme.fg("dim",
|
|
293
|
+
theme.fg("dim", mode);
|
|
290
294
|
return new Text(text, 0, 0);
|
|
291
295
|
};
|
|
292
296
|
|
|
@@ -302,7 +306,7 @@ export const renderBackgroundDelegateResult: RenderResultFn = (result, { expande
|
|
|
302
306
|
|
|
303
307
|
if (!expanded) return collapsedText(summaryLine);
|
|
304
308
|
|
|
305
|
-
// Expanded: full input — reference files, context
|
|
309
|
+
// Expanded: full input — reference files, context/inheritance metadata, task text.
|
|
306
310
|
const container = new Container();
|
|
307
311
|
container.addChild(new Text(summaryLine, 0, 0));
|
|
308
312
|
container.addChild(new Spacer(1));
|
|
@@ -314,6 +318,21 @@ export const renderBackgroundDelegateResult: RenderResultFn = (result, { expande
|
|
|
314
318
|
if (details.context) {
|
|
315
319
|
container.addChild(new Text(fg("dim", `ctx ${details.context.length} chars`), 0, 0));
|
|
316
320
|
}
|
|
321
|
+
if (details.inheritConversation) {
|
|
322
|
+
container.addChild(
|
|
323
|
+
new Text(
|
|
324
|
+
fg(
|
|
325
|
+
"dim",
|
|
326
|
+
formatInheritedConversationInput(
|
|
327
|
+
details.inheritedConversationChars ?? 0,
|
|
328
|
+
details.inheritedConversationTruncated === true,
|
|
329
|
+
),
|
|
330
|
+
),
|
|
331
|
+
0,
|
|
332
|
+
0,
|
|
333
|
+
),
|
|
334
|
+
);
|
|
335
|
+
}
|
|
317
336
|
container.addChild(new Text(fg("dim", details.task), 0, 0));
|
|
318
337
|
return container;
|
|
319
338
|
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** Tests for inherited-conversation TUI observability. */
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import { renderBackgroundDelegateCall, renderBackgroundDelegateResult } from "./render-async.ts";
|
|
6
|
+
import { renderDelegateCall } from "./render.ts";
|
|
7
|
+
|
|
8
|
+
const theme = {
|
|
9
|
+
fg: (_color: string, text: string) => text,
|
|
10
|
+
bold: (text: string) => text,
|
|
11
|
+
} as any;
|
|
12
|
+
|
|
13
|
+
function rendered(component: { render(width: number): string[] }): string {
|
|
14
|
+
return component
|
|
15
|
+
.render(200)
|
|
16
|
+
.map((line) => line.trimEnd())
|
|
17
|
+
.join("\n");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test("delegate call titles mark inherited conversation without changing isolated mode", () => {
|
|
21
|
+
const isolated = rendered(renderDelegateCall({ role: "worker" } as any, theme, {} as any));
|
|
22
|
+
const inherited = rendered(
|
|
23
|
+
renderDelegateCall({ role: "worker", inheritConversation: true } as any, theme, {} as any),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
assert.equal(isolated, "subagent_delegate worker");
|
|
27
|
+
assert.equal(inherited, "subagent_delegate worker (inherits conversation)");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("background call and expanded input expose only safe inheritance metadata", () => {
|
|
31
|
+
const call = rendered(
|
|
32
|
+
renderBackgroundDelegateCall(
|
|
33
|
+
{ role: "worker", background: true, inheritConversation: true } as any,
|
|
34
|
+
theme,
|
|
35
|
+
{} as any,
|
|
36
|
+
),
|
|
37
|
+
);
|
|
38
|
+
assert.equal(call, "subagent_delegate worker (background · inherits conversation)");
|
|
39
|
+
|
|
40
|
+
const result = rendered(
|
|
41
|
+
renderBackgroundDelegateResult(
|
|
42
|
+
{
|
|
43
|
+
content: [{ type: "text", text: "started" }],
|
|
44
|
+
details: {
|
|
45
|
+
id: "sub-1",
|
|
46
|
+
role: "worker",
|
|
47
|
+
task: "Implement the delta",
|
|
48
|
+
context: "explicit context",
|
|
49
|
+
inheritConversation: true,
|
|
50
|
+
inheritedConversationChars: 50_000,
|
|
51
|
+
inheritedConversationTruncated: true,
|
|
52
|
+
},
|
|
53
|
+
} as any,
|
|
54
|
+
{ expanded: true, isPartial: false },
|
|
55
|
+
theme,
|
|
56
|
+
{} as any,
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
assert.match(result, /ctx 16 chars/);
|
|
61
|
+
assert.match(result, /conversation 50000 chars · truncated/);
|
|
62
|
+
assert.ok(!result.includes("inherited_conversation"));
|
|
63
|
+
});
|
package/src/render.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
contentText,
|
|
16
16
|
ensureElapsedTimer,
|
|
17
17
|
formatFallback,
|
|
18
|
+
formatInheritedConversationInput,
|
|
18
19
|
formatThinking,
|
|
19
20
|
formatTimePart,
|
|
20
21
|
formatToolCall,
|
|
@@ -35,7 +36,13 @@ type RenderResultFn = NonNullable<ToolDefinition["renderResult"]>;
|
|
|
35
36
|
|
|
36
37
|
export const renderDelegateCall: RenderCallFn = (args, theme, _context) => {
|
|
37
38
|
const roleName = (args as any).role || "...";
|
|
38
|
-
const
|
|
39
|
+
const inheritance = (args as any).inheritConversation
|
|
40
|
+
? theme.fg("dim", " (inherits conversation)")
|
|
41
|
+
: "";
|
|
42
|
+
const text =
|
|
43
|
+
theme.fg("toolTitle", theme.bold("subagent_delegate ")) +
|
|
44
|
+
theme.fg("accent", roleName) +
|
|
45
|
+
inheritance;
|
|
39
46
|
return new Text(text, 0, 0);
|
|
40
47
|
};
|
|
41
48
|
|
|
@@ -104,8 +111,8 @@ export const renderDelegateResult: RenderResultFn = (result, { expanded }, theme
|
|
|
104
111
|
container.addChild(new Text(fallbackLine, 0, 0));
|
|
105
112
|
}
|
|
106
113
|
|
|
107
|
-
// Input block: reference files + context
|
|
108
|
-
// grouped without inner spacing (
|
|
114
|
+
// Input block: reference files + context/inherited-conversation metadata
|
|
115
|
+
// + task full text, grouped without inner spacing (all subagent input).
|
|
109
116
|
container.addChild(new Spacer(1));
|
|
110
117
|
if (r.files) {
|
|
111
118
|
for (const f of r.files) {
|
|
@@ -115,6 +122,21 @@ export const renderDelegateResult: RenderResultFn = (result, { expanded }, theme
|
|
|
115
122
|
if (r.context) {
|
|
116
123
|
container.addChild(new Text(theme.fg("dim", `ctx ${r.context.length} chars`), 0, 0));
|
|
117
124
|
}
|
|
125
|
+
if (r.inheritConversation) {
|
|
126
|
+
container.addChild(
|
|
127
|
+
new Text(
|
|
128
|
+
theme.fg(
|
|
129
|
+
"dim",
|
|
130
|
+
formatInheritedConversationInput(
|
|
131
|
+
r.inheritedConversationChars ?? 0,
|
|
132
|
+
r.inheritedConversationTruncated === true,
|
|
133
|
+
),
|
|
134
|
+
),
|
|
135
|
+
0,
|
|
136
|
+
0,
|
|
137
|
+
),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
118
140
|
container.addChild(new Text(theme.fg("dim", r.task), 0, 0));
|
|
119
141
|
|
|
120
142
|
// Activity stream (shown while running and after completion).
|
package/src/roles.ts
CHANGED
|
@@ -40,17 +40,24 @@ export const BUILTIN_ROLES: Record<string, SubagentRole> = {
|
|
|
40
40
|
fallbackRole: "default",
|
|
41
41
|
timeout: 3600,
|
|
42
42
|
description:
|
|
43
|
-
"READ-ONLY code review & analysis — audit code, assess architecture, review diffs, run tests for evidence. Reports findings and suggested fixes but never implements them.",
|
|
43
|
+
"READ-ONLY code review & analysis — audit code, assess architecture, review diffs, run tests for evidence. Reports findings and suggested fixes but never implements them. Can delegate to explorer/researcher.",
|
|
44
44
|
examples: [
|
|
45
45
|
"Review the error handling in src/api/ for security issues",
|
|
46
46
|
"Audit this PR diff for performance regressions",
|
|
47
47
|
],
|
|
48
48
|
decisionTrigger: "Task audits or reviews code quality?",
|
|
49
|
-
tools: ["read", "bash", "grep", "find"],
|
|
49
|
+
tools: ["read", "bash", "grep", "find", "subagent_delegate"],
|
|
50
|
+
subagentRoles: ["explorer", "researcher"],
|
|
50
51
|
systemPrompt: [
|
|
51
52
|
"Senior code reviewer. READ-ONLY — you must NOT modify any file.",
|
|
52
53
|
"If the task asks you to fix or implement, do NOT do it: report findings and suggested fixes, and state that implementation is out of scope for this role.",
|
|
53
54
|
"Run only read-only commands (git diff/log/show, test runs). Never use sed, tee, echo >, or any write command.",
|
|
55
|
+
"",
|
|
56
|
+
"## Delegation",
|
|
57
|
+
"You have a `subagent_delegate` tool — spend it to keep your review context focused:",
|
|
58
|
+
"- subagent_delegate(role=explorer) to map unfamiliar code touched by the change under review",
|
|
59
|
+
"- subagent_delegate(role=researcher) to verify third-party library APIs/versions against official docs",
|
|
60
|
+
"Don't delegate the review itself — reading and judging the code is your job.",
|
|
54
61
|
"Provide evidence-backed findings with file:line references.",
|
|
55
62
|
"",
|
|
56
63
|
"Output format (prioritize critical issues first):",
|
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);
|