@ian-pascoe/pi-minimal-subagents 0.12.0 → 0.13.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 +58 -4
- package/package.json +1 -1
- package/src/minimal-subagents-capabilities.ts +12 -1
- package/src/minimal-subagents-context.ts +180 -4
- package/src/minimal-subagents-coordinator.ts +115 -24
- package/src/minimal-subagents-extension.ts +3 -0
- package/src/minimal-subagents-registry.ts +3 -1
- package/src/minimal-subagents-render-contract.ts +30 -2
- package/src/minimal-subagents-rendering.ts +318 -52
- package/src/minimal-subagents-status-panel.ts +26 -203
- package/src/minimal-subagents-tool-schemas.ts +12 -3
- package/src/minimal-subagents-tools.ts +104 -33
- package/src/minimal-subagents-transcript.ts +296 -0
- package/src/minimal-subagents-types.ts +22 -5
- package/src/minimal-subagents-ui.ts +1 -1
- package/src/minimal-subagents-usage.ts +13 -2
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
3
|
+
import { contentText } from "@earendil-works/pi-ai";
|
|
4
|
+
import {
|
|
5
|
+
AssistantMessageComponent,
|
|
6
|
+
BashExecutionComponent,
|
|
7
|
+
BranchSummaryMessageComponent,
|
|
8
|
+
CompactionSummaryMessageComponent,
|
|
9
|
+
CustomMessageComponent,
|
|
10
|
+
ToolExecutionComponent,
|
|
11
|
+
UserMessageComponent,
|
|
12
|
+
type TruncationResult,
|
|
13
|
+
} from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { Container, Text, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
15
|
+
import {
|
|
16
|
+
ACTIVITY_RAIL_WIDTH,
|
|
17
|
+
drawActivityRail,
|
|
18
|
+
renderMinimalSubagentsMessage,
|
|
19
|
+
renderMinimalSubagentsResult,
|
|
20
|
+
type MinimalSubagentsRenderTheme,
|
|
21
|
+
} from "./minimal-subagents-rendering.js";
|
|
22
|
+
import type { ChildAgentTranscriptSnapshot } from "./minimal-subagents-types.js";
|
|
23
|
+
|
|
24
|
+
interface CachedTranscriptMessage {
|
|
25
|
+
container: Container;
|
|
26
|
+
tools: Map<string, ToolExecutionComponent>;
|
|
27
|
+
expanded: boolean;
|
|
28
|
+
streaming: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Rendered transcript lines and the first line of each message. */
|
|
32
|
+
export interface TranscriptLayout {
|
|
33
|
+
lines: string[];
|
|
34
|
+
messageStarts: number[];
|
|
35
|
+
/** First line of each native component, such as one assistant reply or one tool execution. */
|
|
36
|
+
itemStarts: number[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Native components reused across renders while their source messages are unchanged. */
|
|
40
|
+
export interface TranscriptRenderCache {
|
|
41
|
+
messages: WeakMap<AgentMessage, CachedTranscriptMessage>;
|
|
42
|
+
results: WeakMap<ToolExecutionComponent, AgentMessage>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Render a Child Session Transcript with Pi's native message and tool components. */
|
|
46
|
+
export function renderTranscriptSnapshot(
|
|
47
|
+
snapshot: ChildAgentTranscriptSnapshot,
|
|
48
|
+
tui: TUI,
|
|
49
|
+
cwd: string,
|
|
50
|
+
expanded: boolean,
|
|
51
|
+
width: number,
|
|
52
|
+
cache: TranscriptRenderCache,
|
|
53
|
+
): TranscriptLayout {
|
|
54
|
+
if (snapshot.messages.length === 0) {
|
|
55
|
+
return {
|
|
56
|
+
lines: new Text(snapshot.fallback || "No conversation messages yet.", 3, 0).render(width),
|
|
57
|
+
messageStarts: [0],
|
|
58
|
+
itemStarts: [0],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const blocks: Container[] = [];
|
|
62
|
+
const tools = new Map(
|
|
63
|
+
snapshot.toolDefinitions.map((definition) => [definition.name, definition]),
|
|
64
|
+
);
|
|
65
|
+
const pendingTools = new Map<string, ToolExecutionComponent>();
|
|
66
|
+
const currentMessages = new Set(snapshot.messages);
|
|
67
|
+
|
|
68
|
+
for (const [messageIndex, message] of snapshot.messages.entries()) {
|
|
69
|
+
if (message.role === "toolResult") {
|
|
70
|
+
const paired = pendingTools.get(message.toolCallId);
|
|
71
|
+
if (paired) {
|
|
72
|
+
if (cache.results.get(paired) !== message) {
|
|
73
|
+
paired.updateResult(message);
|
|
74
|
+
cache.results.set(paired, message);
|
|
75
|
+
}
|
|
76
|
+
pendingTools.delete(message.toolCallId);
|
|
77
|
+
blocks.push(new Container());
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const streaming = messageIndex === snapshot.streamingAssistantIndex;
|
|
82
|
+
const cached = cache.messages.get(message);
|
|
83
|
+
const staleResult =
|
|
84
|
+
cached &&
|
|
85
|
+
[...cached.tools.values()].some((tool) => {
|
|
86
|
+
const result = cache.results.get(tool);
|
|
87
|
+
return result !== undefined && !currentMessages.has(result);
|
|
88
|
+
});
|
|
89
|
+
if (cached && !staleResult && cached.expanded === expanded && cached.streaming === streaming) {
|
|
90
|
+
blocks.push(cached.container);
|
|
91
|
+
for (const [id, tool] of cached.tools) pendingTools.set(id, tool);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const container = new Container();
|
|
95
|
+
const messageTools = new Map<string, ToolExecutionComponent>();
|
|
96
|
+
switch (message.role) {
|
|
97
|
+
case "user": {
|
|
98
|
+
const text = contentText(message.content, "\n\n");
|
|
99
|
+
if (text) container.addChild(new UserMessageComponent(text));
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
case "assistant": {
|
|
103
|
+
const assistant = new AssistantMessageComponent(message);
|
|
104
|
+
assistant.updateContent(message, messageIndex === snapshot.streamingAssistantIndex);
|
|
105
|
+
container.addChild(assistant);
|
|
106
|
+
for (const content of message.content) {
|
|
107
|
+
if (content.type !== "toolCall") continue;
|
|
108
|
+
const tool = new ToolExecutionComponent(
|
|
109
|
+
content.name,
|
|
110
|
+
content.id,
|
|
111
|
+
content.arguments,
|
|
112
|
+
{ showImages: false },
|
|
113
|
+
tools.get(content.name) ?? {},
|
|
114
|
+
tui,
|
|
115
|
+
cwd,
|
|
116
|
+
);
|
|
117
|
+
tool.setExpanded(expanded);
|
|
118
|
+
container.addChild(tool);
|
|
119
|
+
if (message.stopReason === "aborted" || message.stopReason === "error") {
|
|
120
|
+
tool.updateResult({
|
|
121
|
+
content: [
|
|
122
|
+
{
|
|
123
|
+
type: "text",
|
|
124
|
+
text:
|
|
125
|
+
message.stopReason === "aborted"
|
|
126
|
+
? "Operation aborted"
|
|
127
|
+
: (message.errorMessage ?? "Error"),
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
isError: true,
|
|
131
|
+
});
|
|
132
|
+
} else {
|
|
133
|
+
pendingTools.set(content.id, tool);
|
|
134
|
+
messageTools.set(content.id, tool);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
case "toolResult": {
|
|
140
|
+
const inherited = new ToolExecutionComponent(
|
|
141
|
+
message.toolName,
|
|
142
|
+
message.toolCallId,
|
|
143
|
+
{},
|
|
144
|
+
{ showImages: false },
|
|
145
|
+
{},
|
|
146
|
+
tui,
|
|
147
|
+
cwd,
|
|
148
|
+
);
|
|
149
|
+
inherited.setExpanded(expanded);
|
|
150
|
+
inherited.updateResult(message);
|
|
151
|
+
container.addChild(inherited);
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
case "custom": {
|
|
155
|
+
if (!message.display) break;
|
|
156
|
+
const renderer =
|
|
157
|
+
message.customType === "minimal-subagents.message"
|
|
158
|
+
? renderMinimalSubagentsMessage
|
|
159
|
+
: message.customType === "minimal-subagents.result"
|
|
160
|
+
? renderMinimalSubagentsResult
|
|
161
|
+
: undefined;
|
|
162
|
+
const custom = new CustomMessageComponent(message, renderer);
|
|
163
|
+
custom.setExpanded(expanded);
|
|
164
|
+
container.addChild(custom);
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
case "bashExecution": {
|
|
168
|
+
const bash = new BashExecutionComponent(message.command, tui, message.excludeFromContext);
|
|
169
|
+
if (message.output) bash.appendOutput(message.output);
|
|
170
|
+
bash.setComplete(
|
|
171
|
+
message.exitCode,
|
|
172
|
+
message.cancelled,
|
|
173
|
+
// SAFETY: Persisted bash messages retain only the truncation flag; BashExecutionComponent reads that flag here.
|
|
174
|
+
message.truncated ? ({ truncated: true } as TruncationResult) : undefined,
|
|
175
|
+
message.fullOutputPath,
|
|
176
|
+
);
|
|
177
|
+
bash.setExpanded(expanded);
|
|
178
|
+
container.addChild(bash);
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
case "branchSummary": {
|
|
182
|
+
const summary = new BranchSummaryMessageComponent(message);
|
|
183
|
+
summary.setExpanded(expanded);
|
|
184
|
+
container.addChild(summary);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
case "compactionSummary": {
|
|
188
|
+
const summary = new CompactionSummaryMessageComponent(message);
|
|
189
|
+
summary.setExpanded(expanded);
|
|
190
|
+
container.addChild(summary);
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
cache.messages.set(message, { container, tools: messageTools, expanded, streaming });
|
|
195
|
+
blocks.push(container);
|
|
196
|
+
}
|
|
197
|
+
let length = 0;
|
|
198
|
+
const messageStarts: number[] = [];
|
|
199
|
+
const itemStarts: number[] = [];
|
|
200
|
+
// Containers concatenate their children, so rendering children one by one yields the same lines.
|
|
201
|
+
const lines = blocks.flatMap((block) => {
|
|
202
|
+
messageStarts.push(length);
|
|
203
|
+
return block.children.flatMap((child) => {
|
|
204
|
+
itemStarts.push(length);
|
|
205
|
+
// Native user-message prompt zones belong to the main terminal, not an embedded overlay.
|
|
206
|
+
const rendered = child
|
|
207
|
+
.render(width)
|
|
208
|
+
.map((line) =>
|
|
209
|
+
line
|
|
210
|
+
.replaceAll("\x1b]133;A\x07", "")
|
|
211
|
+
.replaceAll("\x1b]133;B\x07", "")
|
|
212
|
+
.replaceAll("\x1b]133;C\x07", ""),
|
|
213
|
+
);
|
|
214
|
+
length += rendered.length;
|
|
215
|
+
return rendered;
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
return { lines, messageStarts, itemStarts };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Create an empty cache for {@link renderTranscriptSnapshot}. */
|
|
222
|
+
export function createTranscriptRenderCache(): TranscriptRenderCache {
|
|
223
|
+
return { messages: new WeakMap(), results: new WeakMap() };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Collapsed live views keep at most this many lines of the child's recent work. */
|
|
227
|
+
const COLLAPSED_TRANSCRIPT_RAIL_LINES = 20;
|
|
228
|
+
|
|
229
|
+
function isSpacerLine(line: string): boolean {
|
|
230
|
+
// Includes a tool box's coloured padding rows, so each rail connector meets visible content.
|
|
231
|
+
return stripVTControlCharacters(line).trim().length === 0;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function withoutSpacerEdges(lines: string[]): string[] {
|
|
235
|
+
const start = lines.findIndex((line) => !isSpacerLine(line));
|
|
236
|
+
if (start < 0) return [];
|
|
237
|
+
const end = lines.findLastIndex((line) => !isSpacerLine(line));
|
|
238
|
+
return lines.slice(start, end + 1);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* A child's recent transcript drawn with Pi's native message and tool components, one rail item
|
|
243
|
+
* per component. Collapsed, it keeps the latest items that fit the line budget.
|
|
244
|
+
*/
|
|
245
|
+
export class TranscriptRail implements Component {
|
|
246
|
+
constructor(
|
|
247
|
+
private readonly snapshot: ChildAgentTranscriptSnapshot,
|
|
248
|
+
private readonly tui: TUI,
|
|
249
|
+
private readonly cwd: string,
|
|
250
|
+
private readonly expanded: boolean,
|
|
251
|
+
private readonly cache: TranscriptRenderCache,
|
|
252
|
+
private readonly theme: MinimalSubagentsRenderTheme,
|
|
253
|
+
) {}
|
|
254
|
+
|
|
255
|
+
render(width: number): string[] {
|
|
256
|
+
const layout = renderTranscriptSnapshot(
|
|
257
|
+
this.snapshot,
|
|
258
|
+
this.tui,
|
|
259
|
+
this.cwd,
|
|
260
|
+
this.expanded,
|
|
261
|
+
Math.max(1, width - ACTIVITY_RAIL_WIDTH),
|
|
262
|
+
this.cache,
|
|
263
|
+
);
|
|
264
|
+
const items = layout.itemStarts
|
|
265
|
+
.map((start, index) =>
|
|
266
|
+
withoutSpacerEdges(layout.lines.slice(start, layout.itemStarts[index + 1])),
|
|
267
|
+
)
|
|
268
|
+
.filter((lines) => lines.length > 0);
|
|
269
|
+
if (this.expanded) return drawActivityRail(items, this.theme);
|
|
270
|
+
let shown = 0;
|
|
271
|
+
let lineCount = 0;
|
|
272
|
+
for (const item of items.toReversed()) {
|
|
273
|
+
if (shown > 0 && lineCount + item.length > COLLAPSED_TRANSCRIPT_RAIL_LINES) break;
|
|
274
|
+
shown++;
|
|
275
|
+
lineCount += item.length;
|
|
276
|
+
}
|
|
277
|
+
const kept = items
|
|
278
|
+
.slice(items.length - shown)
|
|
279
|
+
.map((lines) => lines.slice(0, COLLAPSED_TRANSCRIPT_RAIL_LINES));
|
|
280
|
+
const earlier = items.length - shown;
|
|
281
|
+
return drawActivityRail(
|
|
282
|
+
earlier > 0
|
|
283
|
+
? [
|
|
284
|
+
[this.theme.fg("dim", `… ${earlier} earlier ${earlier === 1 ? "step" : "steps"}`)],
|
|
285
|
+
...kept,
|
|
286
|
+
]
|
|
287
|
+
: kept,
|
|
288
|
+
this.theme,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
invalidate(): void {
|
|
293
|
+
this.cache.messages = new WeakMap();
|
|
294
|
+
this.cache.results = new WeakMap();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
@@ -41,11 +41,16 @@ export interface SpawnParameters {
|
|
|
41
41
|
delegation?: DelegationMode;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/** Returns persistent agent and turn identities immediately after launch scheduling. */
|
|
45
|
-
export interface SpawnResult {
|
|
44
|
+
/** Returns persistent agent and turn identities plus the resolved grant immediately after launch scheduling. */
|
|
45
|
+
export interface SpawnResult extends RuntimeProfile {
|
|
46
46
|
agent_id: string;
|
|
47
47
|
turn_id: string;
|
|
48
48
|
status: "running";
|
|
49
|
+
/** Concrete ordinary tools granted by the Launch Contract. */
|
|
50
|
+
tools: string[];
|
|
51
|
+
delegation: DelegationMode;
|
|
52
|
+
/** Non-fatal tool-resolution warnings, such as skipped configured tools. */
|
|
53
|
+
warnings?: string[];
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
/** Retains one keyed terminal turn result for waits and durable delivery recovery. */
|
|
@@ -59,13 +64,18 @@ export interface TurnResult {
|
|
|
59
64
|
elapsed_ms?: number;
|
|
60
65
|
}
|
|
61
66
|
|
|
62
|
-
/**
|
|
63
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Reports whether one direct message was handed to a wait, queued into an active turn, started a
|
|
69
|
+
* new turn on an idle recipient, or failed.
|
|
70
|
+
*/
|
|
71
|
+
export type AgentMessageDisposition = "delivered-via-wait" | "queued" | "started-turn" | "failed";
|
|
64
72
|
|
|
65
73
|
export interface AgentMessageResult {
|
|
66
74
|
agent_id: string;
|
|
67
75
|
message_id: string;
|
|
68
76
|
disposition: AgentMessageDisposition;
|
|
77
|
+
/** Turn started on an idle recipient; present only for `started-turn`. */
|
|
78
|
+
turn_id?: string;
|
|
69
79
|
error?: string;
|
|
70
80
|
}
|
|
71
81
|
|
|
@@ -112,7 +122,8 @@ export interface AgentSummary extends RuntimeProfile {
|
|
|
112
122
|
latest_activity_at?: string;
|
|
113
123
|
task?: string;
|
|
114
124
|
child_count: number;
|
|
115
|
-
|
|
125
|
+
/** Nested summaries; omitted from caller-scoped status, which reports only `child_count`. */
|
|
126
|
+
children?: AgentSummary[];
|
|
116
127
|
}
|
|
117
128
|
|
|
118
129
|
/** Provides bounded recent child conversation text for detailed status. */
|
|
@@ -129,6 +140,12 @@ export interface RecentAgentActivity {
|
|
|
129
140
|
truncated: boolean;
|
|
130
141
|
}
|
|
131
142
|
|
|
143
|
+
/** Live progress of a Child Agent's running turn, shown while its parent waits. */
|
|
144
|
+
export interface ActiveTurnProgress {
|
|
145
|
+
turn_id: string;
|
|
146
|
+
tool_calls: number;
|
|
147
|
+
}
|
|
148
|
+
|
|
132
149
|
/** Holds the complete selected-branch Child Session Transcript for trusted status UI. */
|
|
133
150
|
export interface ChildAgentTranscriptSnapshot {
|
|
134
151
|
messages: AgentMessage[];
|
|
@@ -59,7 +59,7 @@ function flattenAgentHierarchy(agents: readonly AgentSummary[]): FlattenedAgentS
|
|
|
59
59
|
const flattened: FlattenedAgentSummary[] = [];
|
|
60
60
|
const visit = (agent: AgentSummary, depth: number, parentId?: string) => {
|
|
61
61
|
flattened.push({ agent, depth, parentId, order: flattened.length });
|
|
62
|
-
for (const child of agent.children) visit(child, depth + 1, agent.agent_id);
|
|
62
|
+
for (const child of agent.children ?? []) visit(child, depth + 1, agent.agent_id);
|
|
63
63
|
};
|
|
64
64
|
for (const agent of agents) visit(agent, 0);
|
|
65
65
|
return flattened;
|
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
import type { Usage } from "@earendil-works/pi-ai";
|
|
2
2
|
|
|
3
|
+
/** Return a clone with optional counters present, so every reported usage has one shape. */
|
|
4
|
+
export function normalizeMinimalSubagentsUsage(usage: Usage): Usage {
|
|
5
|
+
return {
|
|
6
|
+
...structuredClone(usage),
|
|
7
|
+
cacheWrite1h: usage.cacheWrite1h ?? 0,
|
|
8
|
+
reasoning: usage.reasoning ?? 0,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
3
12
|
/** Add two optional Pi usage totals without retaining mutable references to either input. */
|
|
4
13
|
export function addMinimalSubagentsUsage(
|
|
5
14
|
left: Usage | undefined,
|
|
6
15
|
right: Usage | undefined,
|
|
7
16
|
): Usage | undefined {
|
|
8
|
-
if (!left) return right ?
|
|
9
|
-
if (!right) return
|
|
17
|
+
if (!left) return right ? normalizeMinimalSubagentsUsage(right) : undefined;
|
|
18
|
+
if (!right) return normalizeMinimalSubagentsUsage(left);
|
|
10
19
|
return {
|
|
11
20
|
input: left.input + right.input,
|
|
12
21
|
output: left.output + right.output,
|
|
13
22
|
cacheRead: left.cacheRead + right.cacheRead,
|
|
14
23
|
cacheWrite: left.cacheWrite + right.cacheWrite,
|
|
24
|
+
cacheWrite1h: (left.cacheWrite1h ?? 0) + (right.cacheWrite1h ?? 0),
|
|
25
|
+
reasoning: (left.reasoning ?? 0) + (right.reasoning ?? 0),
|
|
15
26
|
totalTokens: left.totalTokens + right.totalTokens,
|
|
16
27
|
cost: {
|
|
17
28
|
input: left.cost.input + right.cost.input,
|