@ian-pascoe/pi-minimal-subagents 0.17.1 → 0.19.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 +26 -4
- package/package.json +2 -2
- package/skills/pi-minimal-subagents/SKILL.md +1 -1
- package/src/minimal-subagents-coordinator.ts +19 -6
- package/src/minimal-subagents-render-contract.ts +2 -0
- package/src/minimal-subagents-rendering.ts +8 -3
- package/src/minimal-subagents-result-text.ts +408 -0
- package/src/minimal-subagents-sessions.ts +35 -0
- package/src/minimal-subagents-status-panel.ts +52 -0
- package/src/minimal-subagents-tool-schemas.ts +6 -0
- package/src/minimal-subagents-tools.ts +69 -70
- package/src/minimal-subagents-types.ts +8 -0
package/README.md
CHANGED
|
@@ -42,7 +42,9 @@ From this package checkout, load the source directly with
|
|
|
42
42
|
response arrives automatically as a steer message. Without `turn_id`, a wait
|
|
43
43
|
skips turns whose result you already claimed or received automatically, and
|
|
44
44
|
never repeats an automatically delivered result: it reports
|
|
45
|
-
`already_delivered: true` instead of the output.
|
|
45
|
+
`already_delivered: true` instead of the output. If the result is handed to Pi but
|
|
46
|
+
not yet in your conversation, it also sets `delivery_pending: true` and the
|
|
47
|
+
result arrives as a separate message; no reread needed.
|
|
46
48
|
3. `agent_message` continues an idle child: the result reports
|
|
47
49
|
`disposition: "started-turn"` with the new `turn_id`. A plain
|
|
48
50
|
`subagent_wait({ agent_id })` targets that new turn, not the first result;
|
|
@@ -288,11 +290,22 @@ assistant message but omits image data. Timeout Wait Events carry only a compact
|
|
|
288
290
|
progress snapshot. Children target only direct children; the Root Agent
|
|
289
291
|
may inspect any descendant. Model-facing status reports a `child_count` but no
|
|
290
292
|
nested `children` summaries. Reported `usage` always includes `cacheWrite1h`
|
|
291
|
-
and `reasoning`, as `0` when the provider reports none.
|
|
292
|
-
|
|
293
|
+
and `reasoning`, as `0` when the provider reports none. `structuredContent`
|
|
294
|
+
rounds every `usage.cost` field to six decimal places of USD;
|
|
293
295
|
exact values stay in session data. `subagent_cancel`
|
|
294
296
|
lists in `affected_agent_ids` only agents whose active turns it cancelled.
|
|
295
297
|
|
|
298
|
+
Coordinator Tools give the model compact text, not JSON: a spawn is one line
|
|
299
|
+
naming the tool preset and count rather than the tool list; a settled wait is
|
|
300
|
+
status, elapsed time, one token and cost total, then the output; cancel and
|
|
301
|
+
delete are one line each, and a cancel that found nothing running says so.
|
|
302
|
+
Targeted `subagent_status` opens with a summary line, lists active tools once,
|
|
303
|
+
describes the Launch Contract grant and capability ceiling relative to them,
|
|
304
|
+
and previews the task, latest output, and last three Recent Activity items;
|
|
305
|
+
`verbose: true` adds the full task, output, ceiling, usage breakdown, recent
|
|
306
|
+
messages, and Recent Activity. Codemode scripts receive the complete record in
|
|
307
|
+
`structuredContent`, and the transcript renderer reads `details`.
|
|
308
|
+
|
|
296
309
|
`session_context` defaults to `omit`: the child starts with only its system
|
|
297
310
|
prompt and task, so the task should be a self-contained brief covering the goal,
|
|
298
311
|
target files and non-goals, the change or question, and acceptance criteria.
|
|
@@ -352,7 +365,9 @@ remaining observable turn, falling back to the active, then latest, turn. When
|
|
|
352
365
|
that fallback reaches a result already delivered to you automatically, whether
|
|
353
366
|
still queued or settled, the wait returns `event: "turn"` with
|
|
354
367
|
`already_delivered: true`, the turn identity, and status, but no output; it does
|
|
355
|
-
not claim the turn, and `turn_id` rereads the full result. A
|
|
368
|
+
not claim the turn, and `turn_id` rereads the full result. A result handed but
|
|
369
|
+
not yet recorded in your branch also carries `delivery_pending: true`: it will
|
|
370
|
+
arrive as a separate message (redelivered if Pi discards the queued steer), so no reread is needed. A
|
|
356
371
|
caller may have only one outstanding wait for the same source turn; a
|
|
357
372
|
concurrent duplicate is rejected instead of competing for one Wait Event.
|
|
358
373
|
When `timeout_ms` expires, the wait returns an observational `event: "timeout"`
|
|
@@ -418,6 +433,13 @@ process-local fork handoff is lost, recovery reads only the destination's
|
|
|
418
433
|
selected branch and proceeds only when its canonical `parentSession` proves the
|
|
419
434
|
source file; it never substitutes the source session's newer head.
|
|
420
435
|
|
|
436
|
+
Every child session also carries an Agent Lineage Record, defined by
|
|
437
|
+
`@ian-pascoe/pi-utils/agent-lineage`, naming its own session, its Root Agent's
|
|
438
|
+
session, its agent ID, and its parent agent. Other extensions read it to find a
|
|
439
|
+
Child Agent's ancestors and descendants without depending on this package; it
|
|
440
|
+
grants them nothing. A fork clone writes its own record when its destination
|
|
441
|
+
adopts it. Children created before the record existed have none.
|
|
442
|
+
|
|
421
443
|
## Tool annotations
|
|
422
444
|
|
|
423
445
|
The coordinator tools declare MCP-style `annotations`, which Pi reports through `pi.getAllTools()` for permission extensions and does not send to model providers. `subagent` is destructive and open-world because a Child Agent can use any tool it is granted. `agent_message` is not read-only but is non-destructive and closed-world. `subagent_cancel` is non-destructive and idempotent; `subagent_delete` is destructive and idempotent. `subagent_status` and `subagent_wait` are read-only (`subagent_wait` consumes queued deliveries, so it is not idempotent).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ian-pascoe/pi-minimal-subagents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Persistent nested subagents with bounded delegation for Pi",
|
|
6
6
|
"keywords": [
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"provenance": true
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ian-pascoe/pi-utils": "^0.
|
|
34
|
+
"@ian-pascoe/pi-utils": "^0.7.0",
|
|
35
35
|
"minimatch": "^10.2.6"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
@@ -9,7 +9,7 @@ disable-model-invocation: true
|
|
|
9
9
|
|
|
10
10
|
1. Read the relevant access, spawn, capability, delivery, or recovery section of [`../../README.md`](../../README.md).
|
|
11
11
|
2. For Subagent Access or missing Root Agent Coordinator Tools, run `/subagents status` first and record the effective source plus actual active-tool count.
|
|
12
|
-
3. From the direct parent, use `subagent_status` to capture the Child Agent's Launch Contract, effective tools, Runtime Profile, dependencies, result, and Recent Activity.
|
|
12
|
+
3. From the direct parent, use `subagent_status` with `verbose: true` to capture the Child Agent's Launch Contract, effective tools, Runtime Profile, dependencies, result, and Recent Activity.
|
|
13
13
|
4. Classify the fault as Subagent Access, launch resolution, adjacency, ordinary-tool ceiling, delivery state, or Registry recovery.
|
|
14
14
|
5. For access changes, use the narrowest `/subagents enable|disable|reset` scope. For other configuration, identify the effective layer, make one scoped edit, validate JSON, and reload Pi.
|
|
15
15
|
6. Repeat the same spawn or coordination operation. Finish when it succeeds or one named access, launch, adjacency, capability, delivery, or recovery boundary is evidenced.
|
|
@@ -70,6 +70,7 @@ import type {
|
|
|
70
70
|
TurnResult,
|
|
71
71
|
WaitMessageResult,
|
|
72
72
|
WaitTimeoutResult,
|
|
73
|
+
AutomaticDelivery,
|
|
73
74
|
WaitDeliveredTurnResult,
|
|
74
75
|
WaitResult,
|
|
75
76
|
} from "./minimal-subagents-types.js";
|
|
@@ -155,6 +156,7 @@ function terminalWaitResult(result: TurnResult, messages: WaitMessageResult[] =
|
|
|
155
156
|
function alreadyDeliveredWaitResult(
|
|
156
157
|
result: TurnResult,
|
|
157
158
|
messages: WaitMessageResult[],
|
|
159
|
+
delivery: AutomaticDelivery,
|
|
158
160
|
): WaitDeliveredTurnResult {
|
|
159
161
|
const delivered: WaitDeliveredTurnResult = {
|
|
160
162
|
event: "turn",
|
|
@@ -163,6 +165,7 @@ function alreadyDeliveredWaitResult(
|
|
|
163
165
|
status: result.status,
|
|
164
166
|
already_delivered: true,
|
|
165
167
|
};
|
|
168
|
+
if (delivery === "handed") delivered.delivery_pending = true;
|
|
166
169
|
if (messages.length > 0) delivered.messages = structuredClone(messages);
|
|
167
170
|
return delivered;
|
|
168
171
|
}
|
|
@@ -515,8 +518,12 @@ export class MinimalSubagentsCoordinator {
|
|
|
515
518
|
const messages = this.drainPendingParentMessages(callerId, agentId, turnId);
|
|
516
519
|
// A default wait falls back to the latest turn, whose result may already have been delivered
|
|
517
520
|
// automatically; repeating its output would deliver it twice. An explicit turn_id rereads it.
|
|
518
|
-
|
|
519
|
-
|
|
521
|
+
const delivery =
|
|
522
|
+
requestedTurnId === undefined
|
|
523
|
+
? this.automaticDelivery(callerId, retainedResult)
|
|
524
|
+
: undefined;
|
|
525
|
+
if (delivery) {
|
|
526
|
+
return Promise.resolve(alreadyDeliveredWaitResult(retainedResult, messages, delivery));
|
|
520
527
|
}
|
|
521
528
|
this.claimTerminalDelivery(callerId, retainedResult);
|
|
522
529
|
return Promise.resolve(terminalWaitResult(retainedResult, messages));
|
|
@@ -1772,17 +1779,23 @@ export class MinimalSubagentsCoordinator {
|
|
|
1772
1779
|
/**
|
|
1773
1780
|
* Whether this result was already delivered to the caller automatically: it is handed, or the
|
|
1774
1781
|
* caller's branch holds its automatic result message. A wait's own tool result does not count.
|
|
1782
|
+
* "recorded" means the branch already holds it; "handed" means it is queued to Pi but not yet
|
|
1783
|
+
* recorded, so it still arrives as a separate message (redelivered if Pi discarded the queued steer).
|
|
1775
1784
|
*/
|
|
1776
|
-
private
|
|
1777
|
-
|
|
1778
|
-
this.handedTerminalKeys.has(deliveryTurnKey(result.agent_id, result.turn_id)) ||
|
|
1785
|
+
private automaticDelivery(callerId: string, result: TurnResult): AutomaticDelivery | undefined {
|
|
1786
|
+
if (
|
|
1779
1787
|
this.hasRecipientDeliveryEvidence(
|
|
1780
1788
|
callerId,
|
|
1781
1789
|
result.agent_id,
|
|
1782
1790
|
result.turn_id,
|
|
1783
1791
|
automaticResultMessageId(result.agent_id, result.turn_id),
|
|
1784
1792
|
)
|
|
1785
|
-
)
|
|
1793
|
+
) {
|
|
1794
|
+
return "recorded";
|
|
1795
|
+
}
|
|
1796
|
+
return this.handedTerminalKeys.has(deliveryTurnKey(result.agent_id, result.turn_id))
|
|
1797
|
+
? "handed"
|
|
1798
|
+
: undefined;
|
|
1786
1799
|
}
|
|
1787
1800
|
|
|
1788
1801
|
private hasDeliveryEvidence(delivery: PersistedDelivery): boolean {
|
|
@@ -147,6 +147,7 @@ const WaitTurnDetailsSchema = Type.Object({
|
|
|
147
147
|
elapsed_ms: Type.Optional(Type.Number()),
|
|
148
148
|
usage: Type.Optional(RenderUsageSchema),
|
|
149
149
|
already_delivered: Type.Optional(Type.Boolean()),
|
|
150
|
+
delivery_pending: Type.Optional(Type.Boolean()),
|
|
150
151
|
messages: Type.Optional(Type.Array(WaitMessageDetailsSchema)),
|
|
151
152
|
});
|
|
152
153
|
const WaitTimeoutDetailsSchema = Type.Object({
|
|
@@ -314,6 +315,7 @@ export const CoordinatorToolOutputSchemas = {
|
|
|
314
315
|
...Type.Pick(CoordinatorTurnOutputSchema, ["agent_id", "turn_id", "status"]).properties,
|
|
315
316
|
event: Type.Literal("turn"),
|
|
316
317
|
already_delivered: Type.Literal(true),
|
|
318
|
+
delivery_pending: Type.Optional(Type.Literal(true)),
|
|
317
319
|
messages: Type.Optional(Type.Array(CoordinatorWaitMessageOutputSchema)),
|
|
318
320
|
...CoordinatorWaitSourceOutputSchema,
|
|
319
321
|
}),
|
|
@@ -379,7 +379,7 @@ function latestProgressActivity(activity: readonly RenderRecentActivity[]): Rend
|
|
|
379
379
|
}
|
|
380
380
|
|
|
381
381
|
/** Format a turn's cost for summary rows; sub-cent costs keep enough precision to be non-zero. */
|
|
382
|
-
function formatSubagentCost(usage: Usage | undefined): string | undefined {
|
|
382
|
+
export function formatSubagentCost(usage: Usage | undefined): string | undefined {
|
|
383
383
|
const total = usage?.cost.total;
|
|
384
384
|
if (total === undefined || !(total > 0)) return undefined;
|
|
385
385
|
return `$${total.toFixed(total < 0.01 ? 4 : 2)}`;
|
|
@@ -591,6 +591,11 @@ function renderWaitProgress(
|
|
|
591
591
|
|
|
592
592
|
/** Shown instead of the output of a result already delivered to the parent automatically. */
|
|
593
593
|
const ALREADY_DELIVERED_TEXT = "Already delivered automatically; wait with turn_id to reread it.";
|
|
594
|
+
const DELIVERY_PENDING_TEXT = "Handed to you automatically; it arrives as a separate message.";
|
|
595
|
+
|
|
596
|
+
function alreadyDeliveredText(details: { delivery_pending?: boolean }): string {
|
|
597
|
+
return details.delivery_pending ? DELIVERY_PENDING_TEXT : ALREADY_DELIVERED_TEXT;
|
|
598
|
+
}
|
|
594
599
|
|
|
595
600
|
/** The activity labels of a timeout result, including pre-compact results that carried the full status. */
|
|
596
601
|
function timeoutActivityLabels(
|
|
@@ -616,7 +621,7 @@ function collapsedWaitBody(
|
|
|
616
621
|
: undefined;
|
|
617
622
|
}
|
|
618
623
|
if (details.already_delivered) {
|
|
619
|
-
return collapsedText(theme, theme.fg("muted",
|
|
624
|
+
return collapsedText(theme, theme.fg("muted", alreadyDeliveredText(details)));
|
|
620
625
|
}
|
|
621
626
|
const output = details.output ?? "";
|
|
622
627
|
if (status === "completed") {
|
|
@@ -691,7 +696,7 @@ function renderWaitResult(
|
|
|
691
696
|
}
|
|
692
697
|
const output = details.output ?? "";
|
|
693
698
|
if (details.already_delivered) {
|
|
694
|
-
appendTextSection(container, theme, "Output",
|
|
699
|
+
appendTextSection(container, theme, "Output", alreadyDeliveredText(details));
|
|
695
700
|
} else if (status === "completed") {
|
|
696
701
|
if (output.length > 0) {
|
|
697
702
|
appendComponentSection(
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import type { Usage } from "@earendil-works/pi-ai";
|
|
2
|
+
import {
|
|
3
|
+
formatSubagentCost,
|
|
4
|
+
formatSubagentDuration,
|
|
5
|
+
formatSubagentPreview,
|
|
6
|
+
formatSubagentTokenCount,
|
|
7
|
+
formatSubagentUsage,
|
|
8
|
+
} from "./minimal-subagents-rendering.js";
|
|
9
|
+
import type {
|
|
10
|
+
AgentDetail,
|
|
11
|
+
AgentMessageResult,
|
|
12
|
+
AgentSummary,
|
|
13
|
+
CancelResult,
|
|
14
|
+
DeleteResult,
|
|
15
|
+
SpawnResult,
|
|
16
|
+
StatusResult,
|
|
17
|
+
ToolSelection,
|
|
18
|
+
WaitDeliveredTurnResult,
|
|
19
|
+
WaitMessageResult,
|
|
20
|
+
WaitResult,
|
|
21
|
+
WaitTimeoutResult,
|
|
22
|
+
WaitTurnResult,
|
|
23
|
+
} from "./minimal-subagents-types.js";
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
* Model-facing text for Coordinator Tool results. Each renderer states the facts a model acts on
|
|
27
|
+
* in a few lines; `details` and `structuredContent` keep the complete record, so nothing dropped
|
|
28
|
+
* here is lost to codemode scripts or the transcript renderer.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** Width of the one-line task preview in summaries. */
|
|
32
|
+
const TASK_PREVIEW_WIDTH = 120;
|
|
33
|
+
/** Width of the one-line latest-result preview in a non-verbose status. */
|
|
34
|
+
const RESULT_PREVIEW_WIDTH = 200;
|
|
35
|
+
/** Width of each one-line Recent Activity preview in a non-verbose status. */
|
|
36
|
+
const ACTIVITY_PREVIEW_WIDTH = 120;
|
|
37
|
+
/** Recent Activity items a non-verbose status previews, newest last. */
|
|
38
|
+
const STATUS_ACTIVITY_PREVIEW_COUNT = 3;
|
|
39
|
+
|
|
40
|
+
function joinParts(parts: readonly (string | undefined)[]): string {
|
|
41
|
+
return parts.filter((part): part is string => Boolean(part)).join(" · ");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function profile(model: string, thinkingLevel: string): string {
|
|
45
|
+
return `${model} (${thinkingLevel})`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One `tokens · cost` total, or undefined when usage is unknown. */
|
|
49
|
+
function usageTotal(usage: Usage | undefined): string | undefined {
|
|
50
|
+
if (!usage) return undefined;
|
|
51
|
+
return joinParts([
|
|
52
|
+
`${formatSubagentTokenCount(usage.totalTokens) ?? "0"} tokens`,
|
|
53
|
+
formatSubagentCost(usage),
|
|
54
|
+
]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function plural(count: number, singular: string, pluralForm = `${singular}s`): string {
|
|
58
|
+
return `${count} ${count === 1 ? singular : pluralForm}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function listOrNone(values: readonly string[]): string {
|
|
62
|
+
return values.length === 0 ? "none" : values.join(", ");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function without(values: readonly string[], excluded: readonly string[]): string[] {
|
|
66
|
+
const excludedSet = new Set(excluded);
|
|
67
|
+
return values.filter((value) => !excludedSet.has(value));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Describe the granted tools relative to the requested selection, without repeating a preset's list. */
|
|
71
|
+
function spawnToolsSummary(
|
|
72
|
+
granted: readonly string[],
|
|
73
|
+
requested: ToolSelection | undefined,
|
|
74
|
+
): string {
|
|
75
|
+
const count = plural(granted.length, "tool");
|
|
76
|
+
if (requested === undefined) return `tools: inherited, ${count}`;
|
|
77
|
+
if (!Array.isArray(requested)) return `tools: ${requested} preset, ${count}`;
|
|
78
|
+
const added = without(granted, requested);
|
|
79
|
+
const missing = without(requested, granted);
|
|
80
|
+
const diff = [
|
|
81
|
+
added.length > 0 ? `+${added.join(", +")}` : undefined,
|
|
82
|
+
missing.length > 0 ? `-${missing.join(", -")}` : undefined,
|
|
83
|
+
].filter(Boolean);
|
|
84
|
+
return `tools: your list, ${count}${diff.length > 0 ? ` (${diff.join("; ")})` : ""}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** One launch line plus any tool-resolution warnings. */
|
|
88
|
+
export function formatSpawnResultText(
|
|
89
|
+
result: SpawnResult,
|
|
90
|
+
requestedTools: ToolSelection | undefined,
|
|
91
|
+
): string {
|
|
92
|
+
const lines = [
|
|
93
|
+
joinParts([
|
|
94
|
+
`Spawned ${result.agent_id} (turn ${result.turn_id}, ${result.status})`,
|
|
95
|
+
profile(result.model, result.thinking_level),
|
|
96
|
+
spawnToolsSummary(result.tools, requestedTools),
|
|
97
|
+
`delegation ${result.delegation}`,
|
|
98
|
+
]),
|
|
99
|
+
];
|
|
100
|
+
for (const warning of result.warnings ?? []) lines.push(`warning: ${warning}`);
|
|
101
|
+
return lines.join("\n");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One line naming how the message was delivered, or why it failed. */
|
|
105
|
+
export function formatAgentMessageResultText(result: AgentMessageResult): string {
|
|
106
|
+
switch (result.disposition) {
|
|
107
|
+
case "delivered-via-wait":
|
|
108
|
+
return `Message delivered to ${result.agent_id} through its active wait.`;
|
|
109
|
+
case "queued":
|
|
110
|
+
return `Message queued into ${result.agent_id}'s active turn.`;
|
|
111
|
+
case "started-turn":
|
|
112
|
+
return `${result.agent_id} was idle; the message started turn ${result.turn_id ?? "(unknown)"}. Wait on it with subagent_wait.`;
|
|
113
|
+
case "failed":
|
|
114
|
+
return `Message to ${result.agent_id} failed: ${result.error ?? "unknown error"}`;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function coordinationMessagesText(
|
|
119
|
+
agentId: string,
|
|
120
|
+
messages: readonly WaitMessageResult[] | undefined,
|
|
121
|
+
): string | undefined {
|
|
122
|
+
if (!messages || messages.length === 0) return undefined;
|
|
123
|
+
return messages.map((message) => `Message from ${agentId}: ${message.message}`).join("\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* One line saying an already-delivered result is not repeated and how to reread it, or that a
|
|
128
|
+
* handed result is still to arrive as a separate message, followed by any Coordination Messages
|
|
129
|
+
* the wait drained, which the parent has not seen yet.
|
|
130
|
+
*/
|
|
131
|
+
function formatWaitDeliveredText(result: WaitDeliveredTurnResult): string {
|
|
132
|
+
const subject = `Result of ${result.agent_id} turn ${result.turn_id} (${result.status})`;
|
|
133
|
+
const notice = result.delivery_pending
|
|
134
|
+
? `${subject} was handed to you automatically and arrives as a separate message; no reread is needed.`
|
|
135
|
+
: `${subject} was already delivered automatically; call subagent_wait with turn_id "${result.turn_id}" to reread it.`;
|
|
136
|
+
const messages = coordinationMessagesText(result.agent_id, result.messages);
|
|
137
|
+
return messages ? `${notice}\n${messages}` : notice;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A Coordination Message returned before its source turn settled. */
|
|
141
|
+
function formatWaitMessageText(result: WaitMessageResult): string {
|
|
142
|
+
return `Message from ${result.agent_id} (turn ${result.turn_id} still running):\n${result.message}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Status and elapsed time, one token/cost total, drained messages, then the turn's output. */
|
|
146
|
+
function formatWaitTurnText(result: WaitTurnResult): string {
|
|
147
|
+
const header = joinParts([
|
|
148
|
+
`${result.agent_id} turn ${result.turn_id} ${result.status}`,
|
|
149
|
+
formatSubagentDuration(result.elapsed_ms),
|
|
150
|
+
]);
|
|
151
|
+
const total = usageTotal(result.usage);
|
|
152
|
+
const lines = [header];
|
|
153
|
+
if (total) lines.push(`usage: ${total}`);
|
|
154
|
+
if (result.error) lines.push(`error: ${result.error}`);
|
|
155
|
+
const messages = coordinationMessagesText(result.agent_id, result.messages);
|
|
156
|
+
if (messages) lines.push(messages);
|
|
157
|
+
return `${lines.join("\n")}\n\n${result.output || "(no output)"}`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The observational timeout snapshot; the wait never cancels the child. */
|
|
161
|
+
function formatWaitTimeoutText(result: WaitTimeoutResult): string {
|
|
162
|
+
const lines = [
|
|
163
|
+
`Wait timed out after ${formatSubagentDuration(result.timeout_ms) ?? `${result.timeout_ms}ms`}; ${result.agent_id} is ${result.state} on turn ${result.turn_id} and was not cancelled.`,
|
|
164
|
+
];
|
|
165
|
+
const progress = joinParts([
|
|
166
|
+
result.elapsed_ms === undefined
|
|
167
|
+
? undefined
|
|
168
|
+
: `elapsed ${formatSubagentDuration(result.elapsed_ms)}`,
|
|
169
|
+
result.latest_activity_at ? `last activity ${result.latest_activity_at}` : undefined,
|
|
170
|
+
result.total_tokens === undefined
|
|
171
|
+
? undefined
|
|
172
|
+
: `${formatSubagentTokenCount(result.total_tokens)} tokens`,
|
|
173
|
+
]);
|
|
174
|
+
if (progress) lines.push(progress);
|
|
175
|
+
if (result.recent_activity_labels.length > 0) {
|
|
176
|
+
lines.push(`recent activity: ${result.recent_activity_labels.join(", ")}`);
|
|
177
|
+
}
|
|
178
|
+
return lines.join("\n");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Any Wait Event: a message, a settled or already-delivered turn, or a timeout. */
|
|
182
|
+
export function formatWaitResultText(result: WaitResult): string {
|
|
183
|
+
if ("already_delivered" in result) return formatWaitDeliveredText(result);
|
|
184
|
+
switch (result.event) {
|
|
185
|
+
case "message":
|
|
186
|
+
return formatWaitMessageText(result);
|
|
187
|
+
case "turn":
|
|
188
|
+
return formatWaitTurnText(result);
|
|
189
|
+
case "timeout":
|
|
190
|
+
return formatWaitTimeoutText(result);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function turnSummary(agent: AgentSummary): string {
|
|
195
|
+
if (agent.active_turn_id) return `turn ${agent.active_turn_id}`;
|
|
196
|
+
if (agent.latest_turn)
|
|
197
|
+
return `latest turn ${agent.latest_turn.turn_id} ${agent.latest_turn.status}`;
|
|
198
|
+
return "no turns";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function stateSummary(agent: AgentSummary): string {
|
|
202
|
+
return agent.availability === "unavailable" ? `${agent.state}, unavailable` : agent.state;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** One line per direct child; nested summaries and tool lists stay in `details`. */
|
|
206
|
+
function formatStatusListText(parentId: string, agents: readonly AgentSummary[]): string {
|
|
207
|
+
if (agents.length === 0) return `${parentId} has no children.`;
|
|
208
|
+
const lines = [`${plural(agents.length, "child", "children")} of ${parentId}:`];
|
|
209
|
+
for (const agent of agents) {
|
|
210
|
+
lines.push(
|
|
211
|
+
`- ${joinParts([
|
|
212
|
+
`${agent.agent_id} ${stateSummary(agent)}`,
|
|
213
|
+
turnSummary(agent),
|
|
214
|
+
profile(agent.model, agent.thinking_level),
|
|
215
|
+
formatSubagentDuration(agent.elapsed_ms),
|
|
216
|
+
agent.child_count > 0 ? plural(agent.child_count, "child", "children") : undefined,
|
|
217
|
+
agent.task ? `task: ${formatSubagentPreview(agent.task, TASK_PREVIEW_WIDTH)}` : undefined,
|
|
218
|
+
])}`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
return lines.join("\n");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Active tools, the Launch Contract grant, and the capability ceiling, each listed at most once:
|
|
226
|
+
* the grant and ceiling are described relative to the list before them.
|
|
227
|
+
*/
|
|
228
|
+
function toolLines(agent: AgentDetail, verbose: boolean): string[] {
|
|
229
|
+
const active = agent.tools;
|
|
230
|
+
const granted = agent.launch_contract.ordinary_tools;
|
|
231
|
+
const ceiling = agent.capability_ceiling;
|
|
232
|
+
const lines = [`active tools (${active.length}): ${listOrNone(active)}`];
|
|
233
|
+
|
|
234
|
+
const inactiveGrants = without(granted, active);
|
|
235
|
+
const adapterTools = without(active, granted);
|
|
236
|
+
if (inactiveGrants.length === 0 && adapterTools.length === 0) {
|
|
237
|
+
lines.push("granted tools: same as active");
|
|
238
|
+
} else {
|
|
239
|
+
const differences = [
|
|
240
|
+
inactiveGrants.length > 0
|
|
241
|
+
? `plus ${inactiveGrants.join(", ")} (granted but not active: codemode-only or undeclared)`
|
|
242
|
+
: undefined,
|
|
243
|
+
adapterTools.length > 0
|
|
244
|
+
? `minus ${adapterTools.join(", ")} (active from a runtime adapter, not granted)`
|
|
245
|
+
: undefined,
|
|
246
|
+
].filter(Boolean);
|
|
247
|
+
lines.push(`granted tools (${granted.length}): active ${differences.join("; ")}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const beyondGrant = without(ceiling, granted);
|
|
251
|
+
if (beyondGrant.length === 0) {
|
|
252
|
+
lines.push("capability ceiling: same as granted");
|
|
253
|
+
} else if (verbose) {
|
|
254
|
+
lines.push(`capability ceiling (${ceiling.length}): granted plus ${beyondGrant.join(", ")}`);
|
|
255
|
+
} else {
|
|
256
|
+
lines.push(
|
|
257
|
+
`capability ceiling: ${ceiling.length} tools, ${beyondGrant.length} beyond the grant`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
return lines;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function launchLine(agent: AgentDetail): string {
|
|
264
|
+
const contract = agent.launch_contract;
|
|
265
|
+
const tools =
|
|
266
|
+
contract.tools === undefined
|
|
267
|
+
? "inherited tools"
|
|
268
|
+
: Array.isArray(contract.tools)
|
|
269
|
+
? "explicit tool list"
|
|
270
|
+
: `${contract.tools} preset`;
|
|
271
|
+
const launchedProfile =
|
|
272
|
+
contract.model !== agent.model || contract.thinking_level !== agent.thinking_level
|
|
273
|
+
? `launched as ${profile(contract.model, contract.thinking_level)}`
|
|
274
|
+
: undefined;
|
|
275
|
+
return `launch: ${joinParts([
|
|
276
|
+
contract.role ? `role ${contract.role}` : undefined,
|
|
277
|
+
launchedProfile,
|
|
278
|
+
tools,
|
|
279
|
+
`session context ${contract.session_context}`,
|
|
280
|
+
`project context ${contract.project_context}`,
|
|
281
|
+
`delegation ${contract.delegation ?? "none"}`,
|
|
282
|
+
])}`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function indent(text: string): string {
|
|
286
|
+
return text
|
|
287
|
+
.split("\n")
|
|
288
|
+
.map((line) => ` ${line}`)
|
|
289
|
+
.join("\n");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* A summary line, then the Launch Contract, tools, latest result, and Recent Activity. Without
|
|
294
|
+
* `verbose` the task, result, and activity are one-line previews and the ceiling is a count.
|
|
295
|
+
*/
|
|
296
|
+
function formatStatusDetailText(agent: AgentDetail, verbose: boolean): string {
|
|
297
|
+
const lines = [
|
|
298
|
+
joinParts([
|
|
299
|
+
`${agent.agent_id} ${stateSummary(agent)}`,
|
|
300
|
+
turnSummary(agent),
|
|
301
|
+
profile(agent.model, agent.thinking_level),
|
|
302
|
+
agent.elapsed_ms === undefined
|
|
303
|
+
? undefined
|
|
304
|
+
: `elapsed ${formatSubagentDuration(agent.elapsed_ms)}`,
|
|
305
|
+
agent.latest_activity_at ? `last activity ${agent.latest_activity_at}` : undefined,
|
|
306
|
+
usageTotal(agent.usage),
|
|
307
|
+
]),
|
|
308
|
+
joinParts([`parent ${agent.parent_id}`, plural(agent.child_count, "child", "children")]),
|
|
309
|
+
];
|
|
310
|
+
if (agent.unavailable_reason) lines.push(`unavailable: ${agent.unavailable_reason}`);
|
|
311
|
+
if (agent.missing_dependencies.length > 0) {
|
|
312
|
+
lines.push(`missing dependencies: ${agent.missing_dependencies.join(", ")}`);
|
|
313
|
+
}
|
|
314
|
+
if (agent.task) {
|
|
315
|
+
lines.push(
|
|
316
|
+
verbose
|
|
317
|
+
? `task:\n${indent(agent.task)}`
|
|
318
|
+
: `task: ${formatSubagentPreview(agent.task, TASK_PREVIEW_WIDTH)}`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
lines.push(launchLine(agent), ...toolLines(agent, verbose));
|
|
322
|
+
|
|
323
|
+
const latest = agent.latest_result;
|
|
324
|
+
if (latest) {
|
|
325
|
+
// The summary line already names an idle child's latest turn.
|
|
326
|
+
const heading = joinParts([
|
|
327
|
+
agent.active_turn_id
|
|
328
|
+
? `latest result: turn ${latest.turn_id} ${latest.status}`
|
|
329
|
+
: "latest result",
|
|
330
|
+
formatSubagentDuration(latest.elapsed_ms),
|
|
331
|
+
]);
|
|
332
|
+
if (latest.error) lines.push(`${heading}\n error: ${latest.error}`);
|
|
333
|
+
else lines.push(heading);
|
|
334
|
+
if (latest.output) {
|
|
335
|
+
lines.push(
|
|
336
|
+
verbose
|
|
337
|
+
? indent(latest.output)
|
|
338
|
+
: ` ${formatSubagentPreview(latest.output, RESULT_PREVIEW_WIDTH)}`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (verbose) {
|
|
344
|
+
const usage = formatSubagentUsage(agent.usage);
|
|
345
|
+
if (usage) lines.push(`usage: ${usage}`);
|
|
346
|
+
if (agent.session_file) lines.push(`session file: ${agent.session_file}`);
|
|
347
|
+
lines.push(`spawn entry: ${agent.spawn_entry_id}`);
|
|
348
|
+
if (agent.recent_messages.length > 0) {
|
|
349
|
+
lines.push(`recent messages (${agent.recent_messages.length}):`);
|
|
350
|
+
for (const message of agent.recent_messages) {
|
|
351
|
+
lines.push(
|
|
352
|
+
`- ${message.source_agent_id} (${message.turn_id}):\n${indent(message.content)}`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (agent.recent_activity.length > 0) {
|
|
357
|
+
lines.push(`recent activity (${agent.recent_activity.length}):`);
|
|
358
|
+
for (const activity of agent.recent_activity) {
|
|
359
|
+
const label = `${activity.label}${activity.truncated ? " (truncated)" : ""}`;
|
|
360
|
+
lines.push(`- ${label}:\n${indent(activity.content)}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return lines.join("\n");
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const activity = agent.recent_activity.slice(-STATUS_ACTIVITY_PREVIEW_COUNT);
|
|
367
|
+
if (activity.length > 0) {
|
|
368
|
+
lines.push(
|
|
369
|
+
`recent activity (last ${activity.length} of ${agent.recent_activity.length}):`,
|
|
370
|
+
...activity.map(
|
|
371
|
+
(item) => `- ${item.label}: ${formatSubagentPreview(item.content, ACTIVITY_PREVIEW_WIDTH)}`,
|
|
372
|
+
),
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
lines.push(
|
|
376
|
+
"Pass verbose: true for the full task, latest output, capability ceiling, recent messages, and recent activity.",
|
|
377
|
+
);
|
|
378
|
+
return lines.join("\n");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** A direct-children list or one child's detail. */
|
|
382
|
+
export function formatStatusResultText(result: StatusResult, verbose = false): string {
|
|
383
|
+
return "agent" in result
|
|
384
|
+
? formatStatusDetailText(result.agent, verbose)
|
|
385
|
+
: formatStatusListText(result.parent_id, result.agents);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** One line naming the cancelled turns, or that no turn was running. */
|
|
389
|
+
export function formatCancelResultText(result: CancelResult): string {
|
|
390
|
+
if (result.cancelled_turn_ids.length === 0) {
|
|
391
|
+
return `Nothing cancelled: ${result.agent_id} had no active turn${result.recursive ? ", nor did any descendant" : ""}.`;
|
|
392
|
+
}
|
|
393
|
+
return `Cancelled ${plural(result.cancelled_turn_ids.length, "active turn")}: ${result.cancelled_turn_ids.join(", ")}; sessions are kept.`;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** One line naming the deleted agents, plus one line per failure. */
|
|
397
|
+
export function formatDeleteResultText(result: DeleteResult): string {
|
|
398
|
+
const lines =
|
|
399
|
+
result.deleted_agent_ids.length === 0
|
|
400
|
+
? [`Deleted no agents under ${result.agent_id}.`]
|
|
401
|
+
: [
|
|
402
|
+
`Deleted ${result.deleted_agent_ids.join(", ")}; ${plural(result.trashed_session_files.length, "session file")} moved to trash.`,
|
|
403
|
+
];
|
|
404
|
+
for (const failure of result.failures) {
|
|
405
|
+
lines.push(`failed: ${failure.agent_id}: ${failure.error}`);
|
|
406
|
+
}
|
|
407
|
+
return lines.join("\n");
|
|
408
|
+
}
|
|
@@ -21,6 +21,12 @@ import {
|
|
|
21
21
|
type SessionEntry,
|
|
22
22
|
type ToolDefinition,
|
|
23
23
|
} from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import {
|
|
25
|
+
AGENT_LINEAGE_ENTRY_TYPE,
|
|
26
|
+
agentLineageRecord,
|
|
27
|
+
readAgentLineage,
|
|
28
|
+
type AgentLineage,
|
|
29
|
+
} from "@ian-pascoe/pi-utils/agent-lineage";
|
|
24
30
|
import type { Static, TSchema } from "typebox";
|
|
25
31
|
import { Value } from "typebox/value";
|
|
26
32
|
import {
|
|
@@ -284,6 +290,20 @@ function appendImportedMessage(sessionManager: SessionManager, message: AgentMes
|
|
|
284
290
|
sessionManager.appendMessage(message as Parameters<SessionManager["appendMessage"]>[0]);
|
|
285
291
|
}
|
|
286
292
|
|
|
293
|
+
/** The shared Agent Lineage of a child session, readable by packages that do not import this one. */
|
|
294
|
+
function agentLineage(
|
|
295
|
+
agent: PersistedAgent,
|
|
296
|
+
rootSessionId: string,
|
|
297
|
+
sessionId: string,
|
|
298
|
+
): AgentLineage {
|
|
299
|
+
return {
|
|
300
|
+
sessionId,
|
|
301
|
+
rootSessionId,
|
|
302
|
+
agentId: agent.agent_id,
|
|
303
|
+
parentAgentId: agent.parent_id === "root" ? null : agent.parent_id,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
287
307
|
/** Create and force-flush a writable child JSONL identity before its first model response. */
|
|
288
308
|
export function createPersistentChildIdentity(
|
|
289
309
|
options: PersistentIdentityOptions,
|
|
@@ -296,6 +316,12 @@ export function createPersistentChildIdentity(
|
|
|
296
316
|
direct_parent_id: options.agent.parent_id,
|
|
297
317
|
created_at: options.agent.created_at,
|
|
298
318
|
});
|
|
319
|
+
sessionManager.appendCustomEntry(
|
|
320
|
+
AGENT_LINEAGE_ENTRY_TYPE,
|
|
321
|
+
agentLineageRecord(
|
|
322
|
+
agentLineage(options.agent, options.rootSessionId, sessionManager.getSessionId()),
|
|
323
|
+
),
|
|
324
|
+
);
|
|
299
325
|
sessionManager.appendSessionInfo(`[subagent] ${options.agent.agent_id}`);
|
|
300
326
|
sessionManager.appendModelChange(
|
|
301
327
|
options.agent.launch_contract.model.slice(0, options.agent.launch_contract.model.indexOf("/")),
|
|
@@ -1043,6 +1069,15 @@ export class PiAgentSessionFactory implements AgentSessionFactory {
|
|
|
1043
1069
|
direct_parent_id: agent.parent_id,
|
|
1044
1070
|
});
|
|
1045
1071
|
}
|
|
1072
|
+
// A clone inherits its source's record, which names the source session; record this one's own.
|
|
1073
|
+
const lineage = agentLineage(agent, this.options.rootSessionId, sessionManager.getSessionId());
|
|
1074
|
+
const recorded = readAgentLineage(lineage.sessionId, sessionManager.getBranch());
|
|
1075
|
+
if (
|
|
1076
|
+
recorded?.rootSessionId !== lineage.rootSessionId ||
|
|
1077
|
+
recorded.agentId !== lineage.agentId ||
|
|
1078
|
+
recorded.parentAgentId !== lineage.parentAgentId
|
|
1079
|
+
)
|
|
1080
|
+
sessionManager.appendCustomEntry(AGENT_LINEAGE_ENTRY_TYPE, agentLineageRecord(lineage));
|
|
1046
1081
|
return {
|
|
1047
1082
|
sessionFile,
|
|
1048
1083
|
sessionId: sessionManager.getSessionId(),
|
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
type Component,
|
|
14
14
|
type OverlayHandle,
|
|
15
15
|
type TUI,
|
|
16
|
+
type TuiMouseEvent,
|
|
17
|
+
type TuiMouseEventResult,
|
|
16
18
|
} from "@earendil-works/pi-tui";
|
|
17
19
|
import { hintLine, joinInline, noticeText } from "@ian-pascoe/pi-utils/ui";
|
|
18
20
|
import type { MinimalSubagentsCoordinator } from "./minimal-subagents-coordinator.js";
|
|
@@ -143,6 +145,9 @@ export class MinimalSubagentsStatusPanelComponent implements Component {
|
|
|
143
145
|
private transcriptLayout?: TranscriptLayout;
|
|
144
146
|
private readonly transcriptCache = createTranscriptRenderCache();
|
|
145
147
|
private bodyHeight = 1;
|
|
148
|
+
/** Panel row of the first body line in the last render; undefined when no body was drawn. */
|
|
149
|
+
private bodyTop?: number;
|
|
150
|
+
private mousePressedAgentId?: string;
|
|
146
151
|
private ensureSelectionVisible = true;
|
|
147
152
|
private toolOutputExpanded = false;
|
|
148
153
|
private disposed = false;
|
|
@@ -204,6 +209,50 @@ export class MinimalSubagentsStatusPanelComponent implements Component {
|
|
|
204
209
|
this.tui.requestRender();
|
|
205
210
|
}
|
|
206
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Handle the mouse like Pi's SelectList. The wheel mirrors the arrow keys: one row of selection
|
|
214
|
+
* per event in the tree, the wheel's logical lines in the transcript. In the tree, pressing a
|
|
215
|
+
* row selects it and clicking it opens its transcript. Other events stay unhandled so Pi's
|
|
216
|
+
* screen text selection still copies transcript text.
|
|
217
|
+
*/
|
|
218
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
219
|
+
if (event.type === "wheel") {
|
|
220
|
+
if (!event.wheelDelta) return undefined;
|
|
221
|
+
this.moveSelection(
|
|
222
|
+
this.view === "transcript" ? event.wheelDelta : Math.sign(event.wheelDelta),
|
|
223
|
+
);
|
|
224
|
+
return { handled: true };
|
|
225
|
+
}
|
|
226
|
+
if (this.view !== "tree" || event.button !== "left") return undefined;
|
|
227
|
+
if (event.type === "press") {
|
|
228
|
+
const agentId = this.agentIdAtRow(event.y);
|
|
229
|
+
this.mousePressedAgentId = agentId;
|
|
230
|
+
if (!agentId) return undefined;
|
|
231
|
+
this.selectedAgentId = agentId;
|
|
232
|
+
this.ensureSelectionVisible = true;
|
|
233
|
+
return { handled: true, focus: true };
|
|
234
|
+
}
|
|
235
|
+
if (event.type === "click") {
|
|
236
|
+
const agentId = this.mousePressedAgentId ?? this.agentIdAtRow(event.y);
|
|
237
|
+
this.mousePressedAgentId = undefined;
|
|
238
|
+
if (!agentId || !this.flattened.some(({ agent }) => agent.agent_id === agentId)) {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
this.selectedAgentId = agentId;
|
|
242
|
+
this.openSelectedTranscript();
|
|
243
|
+
return { handled: true };
|
|
244
|
+
}
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The Child Agent drawn on a panel row in the tree, if any. */
|
|
249
|
+
private agentIdAtRow(row: number): string | undefined {
|
|
250
|
+
if (this.bodyTop === undefined) return undefined;
|
|
251
|
+
const bodyRow = row - this.bodyTop;
|
|
252
|
+
if (bodyRow < 0 || bodyRow >= this.bodyHeight) return undefined;
|
|
253
|
+
return this.flattened[this.scrollOffset + bodyRow]?.agent.agent_id;
|
|
254
|
+
}
|
|
255
|
+
|
|
207
256
|
/** Render the tree or Child Session Transcript in Pi's selector frame, within the terminal. */
|
|
208
257
|
render(width: number): string[] {
|
|
209
258
|
if (width <= 0) return [];
|
|
@@ -214,6 +263,7 @@ export class MinimalSubagentsStatusPanelComponent implements Component {
|
|
|
214
263
|
this.tui.terminal.rows - 2 * STATUS_PANEL_MARGIN,
|
|
215
264
|
),
|
|
216
265
|
);
|
|
266
|
+
this.bodyTop = undefined;
|
|
217
267
|
if (width < 6 || height < 5) {
|
|
218
268
|
const cancelKey = this.keybindings.getKeys("tui.select.cancel").join("/");
|
|
219
269
|
return new Text(this.theme.fg("muted", `${cancelKey} back · Enlarge terminal`), 0, 0)
|
|
@@ -235,6 +285,8 @@ export class MinimalSubagentsStatusPanelComponent implements Component {
|
|
|
235
285
|
].slice(0, Math.min(3, height - 4));
|
|
236
286
|
const visibleHeader = header.slice(0, Math.max(1, height - help.length - 3));
|
|
237
287
|
this.bodyHeight = Math.max(1, height - 2 - visibleHeader.length - help.length);
|
|
288
|
+
// The top border precedes the header.
|
|
289
|
+
this.bodyTop = 1 + visibleHeader.length;
|
|
238
290
|
let body: string[];
|
|
239
291
|
if (transcriptView) {
|
|
240
292
|
const layout = renderTranscriptSnapshot(
|
|
@@ -87,6 +87,12 @@ export function createCoordinatorToolSchemas(modelIds: readonly string[]) {
|
|
|
87
87
|
agent_id: Type.Optional(
|
|
88
88
|
canonicalAgentIdSchema("Direct child canonical agent ID; the root may name any descendant"),
|
|
89
89
|
),
|
|
90
|
+
verbose: Type.Optional(
|
|
91
|
+
Type.Boolean({
|
|
92
|
+
description:
|
|
93
|
+
"With agent_id: include the full task, latest output, capability ceiling, usage breakdown, recent messages, and all recent activity",
|
|
94
|
+
}),
|
|
95
|
+
),
|
|
90
96
|
}),
|
|
91
97
|
subagent_cancel: Type.Object({
|
|
92
98
|
agent_id: canonicalAgentIdSchema("Direct child canonical agent ID"),
|
|
@@ -16,6 +16,14 @@ import type { MinimalSubagentsCoordinator } from "./minimal-subagents-coordinato
|
|
|
16
16
|
import { withTroubleshootingHint } from "./troubleshooting-skill.js";
|
|
17
17
|
import type { MinimalSubagentsModelRole } from "./minimal-subagents-config.js";
|
|
18
18
|
import { roundMinimalSubagentsUsageCosts } from "./minimal-subagents-usage.js";
|
|
19
|
+
import {
|
|
20
|
+
formatAgentMessageResultText,
|
|
21
|
+
formatCancelResultText,
|
|
22
|
+
formatDeleteResultText,
|
|
23
|
+
formatSpawnResultText,
|
|
24
|
+
formatStatusResultText,
|
|
25
|
+
formatWaitResultText,
|
|
26
|
+
} from "./minimal-subagents-result-text.js";
|
|
19
27
|
import {
|
|
20
28
|
renderCoordinatorToolCall,
|
|
21
29
|
renderCoordinatorToolResult,
|
|
@@ -41,7 +49,6 @@ import type {
|
|
|
41
49
|
DeleteResult,
|
|
42
50
|
SpawnResult,
|
|
43
51
|
StatusResult,
|
|
44
|
-
WaitDeliveredTurnResult,
|
|
45
52
|
WaitResult,
|
|
46
53
|
} from "./minimal-subagents-types.js";
|
|
47
54
|
|
|
@@ -203,49 +210,40 @@ function createCoordinatorToolRendering(
|
|
|
203
210
|
* Serialize a tool result with every nested `usage` cost rounded for presentation. Exact values
|
|
204
211
|
* stay in `details`, the Registry, and session data, which never pass through here.
|
|
205
212
|
*/
|
|
206
|
-
function stringifyWithRoundedUsageCosts(
|
|
207
|
-
result: CoordinatorToolResultDetails,
|
|
208
|
-
space?: number,
|
|
209
|
-
): string {
|
|
213
|
+
function stringifyWithRoundedUsageCosts(result: CoordinatorToolResultDetails): string {
|
|
210
214
|
// `JSON.stringify` types replacer values as `any`; a usage is recognized by key and `cost` object.
|
|
211
|
-
return JSON.stringify(
|
|
212
|
-
|
|
213
|
-
(key, value) =>
|
|
214
|
-
key === "usage" && value?.cost ? roundMinimalSubagentsUsageCosts(value) : value,
|
|
215
|
-
space,
|
|
215
|
+
return JSON.stringify(result, (key, value) =>
|
|
216
|
+
key === "usage" && value?.cost ? roundMinimalSubagentsUsageCosts(value) : value,
|
|
216
217
|
);
|
|
217
218
|
}
|
|
218
219
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
// Presentation only: `details` keeps the exact values the session persists.
|
|
223
|
-
const json = stringifyWithRoundedUsageCosts(result, 2);
|
|
224
|
-
const truncated = truncateHead(json, {
|
|
220
|
+
/** Model-facing text, truncated like Pi's built-in tool output. */
|
|
221
|
+
function textContent(text: string): AgentToolResult<unknown>["content"] {
|
|
222
|
+
const truncated = truncateHead(text, {
|
|
225
223
|
maxBytes: DEFAULT_MAX_BYTES,
|
|
226
224
|
maxLines: DEFAULT_MAX_LINES,
|
|
227
225
|
});
|
|
226
|
+
return [{ type: "text" as const, text: truncated.content }];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The model reads compact `text`; codemode scripts and the transcript renderer keep the complete
|
|
231
|
+
* record in `structuredContent` (costs rounded) and `details` (exact).
|
|
232
|
+
*/
|
|
233
|
+
function structuredToolResult<TDetails extends CoordinatorToolResultDetails>(
|
|
234
|
+
result: TDetails,
|
|
235
|
+
text: string,
|
|
236
|
+
): AgentToolResult<TDetails> {
|
|
228
237
|
return {
|
|
229
|
-
content:
|
|
238
|
+
content: textContent(text),
|
|
230
239
|
details: result,
|
|
231
|
-
structuredContent: JSON.parse(
|
|
240
|
+
structuredContent: JSON.parse(stringifyWithRoundedUsageCosts(result)),
|
|
232
241
|
};
|
|
233
242
|
}
|
|
234
243
|
|
|
235
|
-
/**
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
*/
|
|
239
|
-
function alreadyDeliveredContent(
|
|
240
|
-
result: WaitDeliveredTurnResult,
|
|
241
|
-
): AgentToolResult<WaitResult>["content"] {
|
|
242
|
-
const notice = `Result of ${result.agent_id} turn ${result.turn_id} (${result.status}) was already delivered automatically; call subagent_wait with turn_id "${result.turn_id}" to reread it.`;
|
|
243
|
-
if (!result.messages) return [{ type: "text", text: notice }];
|
|
244
|
-
const messages = truncateHead(JSON.stringify({ messages: result.messages }, null, 2), {
|
|
245
|
-
maxBytes: DEFAULT_MAX_BYTES,
|
|
246
|
-
maxLines: DEFAULT_MAX_LINES,
|
|
247
|
-
});
|
|
248
|
-
return [{ type: "text", text: `${notice}\n${messages.content}` }];
|
|
244
|
+
/** One wait result as model-facing text. */
|
|
245
|
+
function waitResultText(result: WaitResult): AgentToolResult<WaitResult>["content"] {
|
|
246
|
+
return textContent(formatWaitResultText(result));
|
|
249
247
|
}
|
|
250
248
|
|
|
251
249
|
/**
|
|
@@ -255,26 +253,19 @@ function alreadyDeliveredContent(
|
|
|
255
253
|
function failedStructuredToolResult<TDetails extends CoordinatorToolResultDetails>(
|
|
256
254
|
prefix: string,
|
|
257
255
|
result: TDetails,
|
|
256
|
+
resultText: string,
|
|
258
257
|
options: { troubleshootingHint: boolean },
|
|
259
258
|
): AgentToolResult<TDetails> & { isError: true } {
|
|
260
|
-
const
|
|
261
|
-
const text = `${prefix}:\n${textOf(success)}`;
|
|
259
|
+
const text = `${prefix}:\n${resultText}`;
|
|
262
260
|
return {
|
|
263
|
-
...
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
text: options.troubleshootingHint ? withTroubleshootingHint(text) : text,
|
|
268
|
-
},
|
|
269
|
-
],
|
|
261
|
+
...structuredToolResult(
|
|
262
|
+
result,
|
|
263
|
+
options.troubleshootingHint ? withTroubleshootingHint(text) : text,
|
|
264
|
+
),
|
|
270
265
|
isError: true,
|
|
271
266
|
};
|
|
272
267
|
}
|
|
273
268
|
|
|
274
|
-
function textOf(result: AgentToolResult<unknown>): string {
|
|
275
|
-
return result.content.map((part) => (part.type === "text" ? part.text : "")).join("\n");
|
|
276
|
-
}
|
|
277
|
-
|
|
278
269
|
function callerSourceTurnId(
|
|
279
270
|
coordinator: CoordinatorToolOperations,
|
|
280
271
|
callerId: string,
|
|
@@ -320,7 +311,10 @@ export function createCoordinatorToolDefinitions(
|
|
|
320
311
|
const status = options.coordinator.inspectStatus(result.agent_id);
|
|
321
312
|
const details: SpawnResultDetails = { ...result };
|
|
322
313
|
if ("agent" in status) details.agent = status.agent;
|
|
323
|
-
return {
|
|
314
|
+
return {
|
|
315
|
+
...structuredToolResult(result, formatSpawnResultText(result, parameters.tools)),
|
|
316
|
+
details,
|
|
317
|
+
};
|
|
324
318
|
});
|
|
325
319
|
},
|
|
326
320
|
...createCoordinatorToolRendering(options, "subagent"),
|
|
@@ -349,12 +343,13 @@ export function createCoordinatorToolDefinitions(
|
|
|
349
343
|
},
|
|
350
344
|
callerSourceTurnId(options.coordinator, options.callerId, toolCallId),
|
|
351
345
|
);
|
|
346
|
+
const text = formatAgentMessageResultText(result);
|
|
352
347
|
return result.disposition === "failed"
|
|
353
|
-
? failedStructuredToolResult("Minimal subagents message delivery failed", result, {
|
|
348
|
+
? failedStructuredToolResult("Minimal subagents message delivery failed", result, text, {
|
|
354
349
|
// The coordinator already appends the hint to `error`.
|
|
355
350
|
troubleshootingHint: false,
|
|
356
351
|
})
|
|
357
|
-
: structuredToolResult(result);
|
|
352
|
+
: structuredToolResult(result, text);
|
|
358
353
|
});
|
|
359
354
|
},
|
|
360
355
|
...createCoordinatorToolRendering(options, "agent_message"),
|
|
@@ -364,7 +359,7 @@ export function createCoordinatorToolDefinitions(
|
|
|
364
359
|
name: "subagent_wait",
|
|
365
360
|
label: "Subagent Wait",
|
|
366
361
|
description:
|
|
367
|
-
"Wait for one direct child's oldest observable turn whose result you have neither claimed nor received, or select an exact retained turn_id. An active child may first return event=message; later unconsumed items still fall back automatically. A settled turn returns event=turn, with any queued messages in messages; waiting again returns a result you claimed in full. Without turn_id, a result already delivered to you automatically returns event=turn with already_delivered=true and no output; pass its turn_id to reread it. Timeout returns a compact event=timeout (state, elapsed_ms, latest_activity_at, total_tokens, last few activity labels; use subagent_status for detail) and never cancels the child.",
|
|
362
|
+
"Wait for one direct child's oldest observable turn whose result you have neither claimed nor received, or select an exact retained turn_id. An active child may first return event=message; later unconsumed items still fall back automatically. A settled turn returns event=turn, with any queued messages in messages; waiting again returns a result you claimed in full. Without turn_id, a result already delivered to you automatically returns event=turn with already_delivered=true and no output; pass its turn_id to reread it. If it was handed to you but is not yet in your conversation, delivery_pending=true and it arrives as a separate message; do not reread. Timeout returns a compact event=timeout (state, elapsed_ms, latest_activity_at, total_tokens, last few activity labels; use subagent_status for detail) and never cancels the child.",
|
|
368
363
|
promptSnippet: "Wait for one direct child's exact turn",
|
|
369
364
|
parameters: options.schemas.subagent_wait,
|
|
370
365
|
annotations: {
|
|
@@ -409,10 +404,7 @@ export function createCoordinatorToolDefinitions(
|
|
|
409
404
|
source_turn_id: result.turn_id,
|
|
410
405
|
};
|
|
411
406
|
return {
|
|
412
|
-
content:
|
|
413
|
-
"already_delivered" in result
|
|
414
|
-
? alreadyDeliveredContent(result)
|
|
415
|
-
: structuredToolResult(result).content,
|
|
407
|
+
content: waitResultText(result),
|
|
416
408
|
details,
|
|
417
409
|
structuredContent: JSON.parse(stringifyWithRoundedUsageCosts(details)),
|
|
418
410
|
};
|
|
@@ -428,7 +420,7 @@ export function createCoordinatorToolDefinitions(
|
|
|
428
420
|
name: "subagent_status",
|
|
429
421
|
label: "Subagent Status",
|
|
430
422
|
description:
|
|
431
|
-
"List direct children when agent_id is omitted, or inspect one direct child's launch contract, result, usage, dependencies, and bounded recent activity including message text and reasoning. The root may inspect any descendant.",
|
|
423
|
+
"List direct children when agent_id is omitted, or inspect one direct child's launch contract, result, usage, dependencies, and bounded recent activity including message text and reasoning. Without verbose, the task, latest output, and recent activity are short previews. The root may inspect any descendant.",
|
|
432
424
|
promptSnippet: "Inspect direct child state",
|
|
433
425
|
parameters: options.schemas.subagent_status,
|
|
434
426
|
annotations: {
|
|
@@ -438,9 +430,13 @@ export function createCoordinatorToolDefinitions(
|
|
|
438
430
|
openWorldHint: false,
|
|
439
431
|
},
|
|
440
432
|
async execute(_toolCallId, parameters) {
|
|
441
|
-
return runCoordinatorToolActivity(options, () =>
|
|
442
|
-
|
|
443
|
-
|
|
433
|
+
return runCoordinatorToolActivity(options, () => {
|
|
434
|
+
const result = options.coordinator.status(options.callerId, parameters.agent_id);
|
|
435
|
+
return structuredToolResult(
|
|
436
|
+
result,
|
|
437
|
+
formatStatusResultText(result, parameters.verbose ?? false),
|
|
438
|
+
);
|
|
439
|
+
});
|
|
444
440
|
},
|
|
445
441
|
...createCoordinatorToolRendering(options, "subagent_status"),
|
|
446
442
|
});
|
|
@@ -459,15 +455,14 @@ export function createCoordinatorToolDefinitions(
|
|
|
459
455
|
openWorldHint: false,
|
|
460
456
|
},
|
|
461
457
|
async execute(_toolCallId, parameters) {
|
|
462
|
-
return runCoordinatorToolActivity(options, async () =>
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
);
|
|
458
|
+
return runCoordinatorToolActivity(options, async () => {
|
|
459
|
+
const result = await options.coordinator.cancel(
|
|
460
|
+
options.callerId,
|
|
461
|
+
parameters.agent_id,
|
|
462
|
+
parameters.recursive ?? true,
|
|
463
|
+
);
|
|
464
|
+
return structuredToolResult(result, formatCancelResultText(result));
|
|
465
|
+
});
|
|
471
466
|
},
|
|
472
467
|
...createCoordinatorToolRendering(options, "subagent_cancel"),
|
|
473
468
|
});
|
|
@@ -492,13 +487,17 @@ export function createCoordinatorToolDefinitions(
|
|
|
492
487
|
parameters.agent_id,
|
|
493
488
|
parameters.recursive ?? true,
|
|
494
489
|
);
|
|
490
|
+
const text = formatDeleteResultText(result);
|
|
495
491
|
if (result.failures.length > 0) {
|
|
496
492
|
options.onAttention?.(`Deletion partially failed for ${parameters.agent_id}`);
|
|
497
|
-
return failedStructuredToolResult(
|
|
498
|
-
|
|
499
|
-
|
|
493
|
+
return failedStructuredToolResult(
|
|
494
|
+
"Minimal subagents deletion partially failed",
|
|
495
|
+
result,
|
|
496
|
+
text,
|
|
497
|
+
{ troubleshootingHint: true },
|
|
498
|
+
);
|
|
500
499
|
}
|
|
501
|
-
return structuredToolResult(result);
|
|
500
|
+
return structuredToolResult(result, text);
|
|
502
501
|
});
|
|
503
502
|
},
|
|
504
503
|
...createCoordinatorToolRendering(options, "subagent_delete"),
|
|
@@ -98,6 +98,9 @@ export interface WaitTurnResult extends TurnResult {
|
|
|
98
98
|
messages?: WaitMessageResult[];
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/** How a result reached the caller automatically: handed to Pi but not yet in its branch, or recorded there. */
|
|
102
|
+
export type AutomaticDelivery = "handed" | "recorded";
|
|
103
|
+
|
|
101
104
|
/**
|
|
102
105
|
* Reports that a default wait selected a terminal turn whose result was already delivered
|
|
103
106
|
* automatically, handed or settled, so the output is not repeated; an explicit `turn_id` rereads it.
|
|
@@ -108,6 +111,11 @@ export interface WaitDeliveredTurnResult extends Pick<
|
|
|
108
111
|
> {
|
|
109
112
|
event: "turn";
|
|
110
113
|
already_delivered: true;
|
|
114
|
+
/**
|
|
115
|
+
* Set when the result is handed to Pi but not yet recorded in the caller's branch, so it will
|
|
116
|
+
* arrive as a separate message and needs no reread.
|
|
117
|
+
*/
|
|
118
|
+
delivery_pending?: true;
|
|
111
119
|
messages?: WaitMessageResult[];
|
|
112
120
|
}
|
|
113
121
|
|