@jopqior/pi-subagents 1.0.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/CHANGELOG.md +2705 -0
- package/LICENSE +21 -0
- package/README.md +503 -0
- package/dist/public.d.ts +331 -0
- package/dist/settings.d.ts +82 -0
- package/docs/architecture/architecture.md +1566 -0
- package/docs/architecture/client-server-opportunities.md +127 -0
- package/docs/architecture/history/phase-1-api-boundary.md +8 -0
- package/docs/architecture/history/phase-10-structural-decomposition.md +141 -0
- package/docs/architecture/history/phase-11-closure-to-class.md +100 -0
- package/docs/architecture/history/phase-12-complexity-test-fixtures.md +55 -0
- package/docs/architecture/history/phase-13-remaining-smells.md +88 -0
- package/docs/architecture/history/phase-14-strip-policy.md +49 -0
- package/docs/architecture/history/phase-15-domain-model-evolution.md +73 -0
- package/docs/architecture/history/phase-16-invert-dependencies.md +144 -0
- package/docs/architecture/history/phase-17-core-consolidation.md +214 -0
- package/docs/architecture/history/phase-18-reconsider-ui.md +166 -0
- package/docs/architecture/history/phase-19-implement-ui-decisions.md +282 -0
- package/docs/architecture/history/phase-2-remove-scheduling.md +9 -0
- package/docs/architecture/history/phase-20-result-delivery.md +245 -0
- package/docs/architecture/history/phase-21-classification-model-boundary.md +107 -0
- package/docs/architecture/history/phase-3-remove-rpc-groupjoin.md +11 -0
- package/docs/architecture/history/phase-4-implement-service.md +8 -0
- package/docs/architecture/history/phase-5-decompose-index.md +42 -0
- package/docs/architecture/history/phase-7-encapsulation.md +173 -0
- package/docs/architecture/history/phase-8-testability.md +103 -0
- package/docs/architecture/history/phase-9-observation-ctx.md +122 -0
- package/docs/comparison-with-upstream.md +77 -0
- package/docs/configuration.md +364 -0
- package/docs/decisions/0001-deferred-patches.md +80 -0
- package/docs/decisions/0002-extensions-on-a-minimal-core.md +125 -0
- package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
- package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
- package/docs/decisions/0005-subagent-record-admission-policy.md +106 -0
- package/docs/decisions/0006-inherited-prompt-is-identity-only.md +104 -0
- package/docs/decisions/0007-transcript-viewer-is-not-an-overlay.md +228 -0
- package/docs/decisions/0008-inherited-region-is-shared-parts.md +81 -0
- package/docs/decisions/0009-portable-inheritance-is-provider-scoped.md +116 -0
- package/package.json +91 -0
- package/src/config/agent-types.ts +135 -0
- package/src/config/custom-agents.ts +151 -0
- package/src/config/default-agents.ts +121 -0
- package/src/config/invocation-config.ts +167 -0
- package/src/config/thinking-level.ts +58 -0
- package/src/debug.ts +14 -0
- package/src/handlers/index.ts +3 -0
- package/src/handlers/interrupt.ts +58 -0
- package/src/handlers/lifecycle.ts +71 -0
- package/src/handlers/widget-events.ts +49 -0
- package/src/index.ts +292 -0
- package/src/layered-settings.ts +105 -0
- package/src/lifecycle/child-lifecycle.ts +115 -0
- package/src/lifecycle/child-shutdown.ts +105 -0
- package/src/lifecycle/concurrency-limiter.ts +55 -0
- package/src/lifecycle/create-subagent-session.ts +335 -0
- package/src/lifecycle/parent-snapshot.ts +119 -0
- package/src/lifecycle/run-listeners.ts +37 -0
- package/src/lifecycle/selection-scope.ts +116 -0
- package/src/lifecycle/spawn-selection.ts +259 -0
- package/src/lifecycle/subagent-manager.ts +546 -0
- package/src/lifecycle/subagent-session.ts +347 -0
- package/src/lifecycle/subagent-state.ts +404 -0
- package/src/lifecycle/subagent.ts +885 -0
- package/src/lifecycle/turn-limits.ts +13 -0
- package/src/lifecycle/usage.ts +60 -0
- package/src/lifecycle/workspace-bracket.ts +76 -0
- package/src/lifecycle/workspace.ts +46 -0
- package/src/observation/composite-subagent-observer.ts +74 -0
- package/src/observation/notification.ts +430 -0
- package/src/observation/outcome-delivery.ts +239 -0
- package/src/observation/record-observer.ts +78 -0
- package/src/observation/renderer.ts +161 -0
- package/src/observation/subagent-events-observer.ts +148 -0
- package/src/runtime.ts +137 -0
- package/src/service/service-adapter.ts +201 -0
- package/src/service/service.ts +246 -0
- package/src/session/ask-parent-tool.ts +69 -0
- package/src/session/content-items.ts +53 -0
- package/src/session/context.ts +80 -0
- package/src/session/conversation.ts +49 -0
- package/src/session/env.ts +40 -0
- package/src/session/model-resolver.ts +126 -0
- package/src/session/notify-parent-tool.ts +83 -0
- package/src/session/package-exclusions.ts +75 -0
- package/src/session/prompts.ts +231 -0
- package/src/session/provider-inheritance.ts +56 -0
- package/src/session/selection-catalogue.ts +143 -0
- package/src/session/session-config.ts +202 -0
- package/src/session/session-dir.ts +38 -0
- package/src/settings.ts +447 -0
- package/src/tools/agent-tool.ts +305 -0
- package/src/tools/background-spawner.ts +83 -0
- package/src/tools/foreground-runner.ts +159 -0
- package/src/tools/get-result-renderer.ts +119 -0
- package/src/tools/get-result-report.ts +84 -0
- package/src/tools/get-result-tool.ts +192 -0
- package/src/tools/helpers.ts +118 -0
- package/src/tools/result-renderer.ts +153 -0
- package/src/tools/spawn-config.ts +192 -0
- package/src/tools/steer-tool.ts +109 -0
- package/src/types.ts +143 -0
- package/src/ui/agent-widget.ts +333 -0
- package/src/ui/bounded-lines.ts +45 -0
- package/src/ui/display.ts +180 -0
- package/src/ui/glyphs.ts +62 -0
- package/src/ui/session-navigation.ts +150 -0
- package/src/ui/session-navigator.ts +255 -0
- package/src/ui/subagents-settings.ts +179 -0
- package/src/ui/transcript-content.ts +374 -0
- package/src/ui/widget-renderer.ts +301 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { debugLog } from "#src/debug";
|
|
2
|
+
import type { SubagentStatus } from "#src/lifecycle/subagent-state";
|
|
3
|
+
import { getLifetimeTotal } from "#src/lifecycle/usage";
|
|
4
|
+
import {
|
|
5
|
+
renderQuestionAffordance,
|
|
6
|
+
renderRunUpdates,
|
|
7
|
+
renderStatusLabel,
|
|
8
|
+
renderWorkspaceNotice,
|
|
9
|
+
} from "#src/observation/outcome-delivery";
|
|
10
|
+
import type { Subagent } from "#src/types";
|
|
11
|
+
|
|
12
|
+
/** Details attached to custom notification messages for visual rendering. */
|
|
13
|
+
export interface NotificationDetails {
|
|
14
|
+
id: string;
|
|
15
|
+
description: string;
|
|
16
|
+
status: SubagentStatus;
|
|
17
|
+
toolUses: number;
|
|
18
|
+
turnCount: number;
|
|
19
|
+
maxTurns?: number;
|
|
20
|
+
totalTokens: number;
|
|
21
|
+
durationMs: number;
|
|
22
|
+
outputFile?: string;
|
|
23
|
+
error?: string;
|
|
24
|
+
resultPreview: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ---- Pure helpers (exported for unit testing) ----
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Escape XML special characters to prevent injection in structured
|
|
31
|
+
* notifications. Quotes are escaped too, so values stay safe if they are ever
|
|
32
|
+
* placed in attribute position, not only element content.
|
|
33
|
+
*/
|
|
34
|
+
export function escapeXml(s: string): string {
|
|
35
|
+
return s
|
|
36
|
+
.replace(/&/g, "&")
|
|
37
|
+
.replace(/</g, "<")
|
|
38
|
+
.replace(/>/g, ">")
|
|
39
|
+
.replace(/"/g, """)
|
|
40
|
+
.replace(/'/g, "'");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Format a structured <task-notification> XML block for the parent agent to parse. */
|
|
44
|
+
export function formatTaskNotification(record: Subagent, resultMaxLen: number): string {
|
|
45
|
+
if (record.stoppedWhileQueued) return formatNeverStartedNotification(record);
|
|
46
|
+
|
|
47
|
+
const status = renderStatusLabel(record.status, record.error);
|
|
48
|
+
const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
|
|
49
|
+
const totalTokens = getLifetimeTotal(record.lifetimeUsage);
|
|
50
|
+
const contextPercent = record.getContextPercent();
|
|
51
|
+
const ctxXml = contextPercent !== null ? `<context_percent>${Math.round(contextPercent)}</context_percent>` : "";
|
|
52
|
+
const compactXml = record.compactionCount ? `<compactions>${record.compactionCount}</compactions>` : "";
|
|
53
|
+
|
|
54
|
+
const resultPreview = record.result
|
|
55
|
+
? record.result.length > resultMaxLen
|
|
56
|
+
? record.result.slice(0, resultMaxLen) + "\n...(truncated, use get_subagent_result for full output)"
|
|
57
|
+
: record.result
|
|
58
|
+
: "No output.";
|
|
59
|
+
|
|
60
|
+
const toolCallId = record.toolCallId;
|
|
61
|
+
const outputFile = record.outputFile;
|
|
62
|
+
return joinNotificationLines([
|
|
63
|
+
"<task-notification>",
|
|
64
|
+
`<task-id>${record.id}</task-id>`,
|
|
65
|
+
toolCallId ? `<tool-use-id>${escapeXml(toolCallId)}</tool-use-id>` : null,
|
|
66
|
+
outputFile ? `<output-file>${escapeXml(outputFile)}</output-file>` : null,
|
|
67
|
+
`<status>${escapeXml(status)}</status>`,
|
|
68
|
+
`<summary>Subagent "${escapeXml(record.description)}" ${record.status}</summary>`,
|
|
69
|
+
`<result>${escapeXml(resultPreview)}</result>`,
|
|
70
|
+
`<usage><total_tokens>${totalTokens}</total_tokens><tool_uses>${record.toolUses}</tool_uses>${ctxXml}${compactXml}<duration_ms>${durationMs}</duration_ms></usage>`,
|
|
71
|
+
"</task-notification>",
|
|
72
|
+
]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Format a `<workspace-notice>` block for what a teardown reported after the
|
|
77
|
+
* child's result had already been delivered.
|
|
78
|
+
*
|
|
79
|
+
* A distinct element from `<task-notification>`: the outcome was reported long
|
|
80
|
+
* ago and has not changed. This says only where the child's work ended up, so
|
|
81
|
+
* the parent's next action is to act on that artifact, never to collect a
|
|
82
|
+
* result it already has.
|
|
83
|
+
*/
|
|
84
|
+
export function formatWorkspaceNotice(record: Subagent, notice: string): string {
|
|
85
|
+
return joinNotificationLines([
|
|
86
|
+
"<workspace-notice>",
|
|
87
|
+
`<task-id>${record.id}</task-id>`,
|
|
88
|
+
`<summary>Subagent "${escapeXml(record.description)}" left work behind when its workspace was torn down</summary>`,
|
|
89
|
+
`<notice>${escapeXml(notice)}</notice>`,
|
|
90
|
+
"</workspace-notice>",
|
|
91
|
+
]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Format a `<subagent-update>` block for a message a still-running child sent.
|
|
96
|
+
*
|
|
97
|
+
* A distinct element from `<task-notification>`: the child has not finished, so
|
|
98
|
+
* the parent's next action is to steer it or leave it alone, never to collect a
|
|
99
|
+
* result that does not exist yet.
|
|
100
|
+
*/
|
|
101
|
+
export function formatUpdateNotification(record: Subagent, message: string): string {
|
|
102
|
+
return joinNotificationLines([
|
|
103
|
+
"<subagent-update>",
|
|
104
|
+
`<task-id>${record.id}</task-id>`,
|
|
105
|
+
`<summary>Subagent "${escapeXml(record.description)}" sent an update</summary>`,
|
|
106
|
+
`<message>${escapeXml(message)}</message>`,
|
|
107
|
+
"</subagent-update>",
|
|
108
|
+
`The agent is still running. Steer it with steer_subagent("${record.id}", "...") to redirect it, or let it continue.`,
|
|
109
|
+
]);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Format the block for an agent stopped before the limiter admitted it. Such an
|
|
114
|
+
* agent never ran, so it has no result and no usage — reporting either (even as
|
|
115
|
+
* zeroes) would point the parent at work that does not exist.
|
|
116
|
+
*/
|
|
117
|
+
function formatNeverStartedNotification(record: Subagent): string {
|
|
118
|
+
const toolCallId = record.toolCallId;
|
|
119
|
+
return joinNotificationLines([
|
|
120
|
+
"<task-notification>",
|
|
121
|
+
`<task-id>${record.id}</task-id>`,
|
|
122
|
+
toolCallId ? `<tool-use-id>${escapeXml(toolCallId)}</tool-use-id>` : null,
|
|
123
|
+
"<status>Stopped before starting</status>",
|
|
124
|
+
`<summary>Subagent "${escapeXml(record.description)}" was stopped while queued and never started</summary>`,
|
|
125
|
+
"</task-notification>",
|
|
126
|
+
]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Join notification lines, dropping the ones a conditional element omitted. */
|
|
130
|
+
function joinNotificationLines(lines: (string | null)[]): string {
|
|
131
|
+
return lines.filter(Boolean).join("\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Build notification details for the custom message renderer. */
|
|
135
|
+
export function buildNotificationDetails(
|
|
136
|
+
record: Subagent,
|
|
137
|
+
resultMaxLen: number,
|
|
138
|
+
): NotificationDetails {
|
|
139
|
+
const totalTokens = getLifetimeTotal(record.lifetimeUsage);
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
id: record.id,
|
|
143
|
+
description: record.description,
|
|
144
|
+
status: record.status,
|
|
145
|
+
toolUses: record.toolUses,
|
|
146
|
+
turnCount: record.turnCount,
|
|
147
|
+
maxTurns: record.maxTurns,
|
|
148
|
+
totalTokens,
|
|
149
|
+
durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
|
|
150
|
+
outputFile: record.outputFile,
|
|
151
|
+
error: record.error,
|
|
152
|
+
resultPreview: buildResultPreview(record, resultMaxLen),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The renderer's preview text: the (truncated) result, or why there is none. */
|
|
157
|
+
function buildResultPreview(record: Subagent, resultMaxLen: number): string {
|
|
158
|
+
if (record.stoppedWhileQueued) return "Never started — stopped while queued.";
|
|
159
|
+
if (!record.result) return "No output.";
|
|
160
|
+
return record.result.length > resultMaxLen
|
|
161
|
+
? record.result.slice(0, resultMaxLen) + "…"
|
|
162
|
+
: record.result;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Build event data for lifecycle events from a Subagent. */
|
|
166
|
+
export function buildEventData(record: Subagent) {
|
|
167
|
+
const durationMs = record.completedAt ? record.completedAt - record.startedAt : Date.now() - record.startedAt;
|
|
168
|
+
const u = record.lifetimeUsage;
|
|
169
|
+
const total = getLifetimeTotal(u);
|
|
170
|
+
const tokens =
|
|
171
|
+
total > 0
|
|
172
|
+
? { input: u.input, output: u.output, total }
|
|
173
|
+
: undefined;
|
|
174
|
+
return {
|
|
175
|
+
id: record.id,
|
|
176
|
+
type: record.type,
|
|
177
|
+
description: record.description,
|
|
178
|
+
result: record.result,
|
|
179
|
+
error: record.error,
|
|
180
|
+
status: record.status,
|
|
181
|
+
toolUses: record.toolUses,
|
|
182
|
+
durationMs,
|
|
183
|
+
tokens,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---- Notification system factory ----
|
|
188
|
+
|
|
189
|
+
export interface NotificationSystem {
|
|
190
|
+
sendCompletion: (record: Subagent) => void;
|
|
191
|
+
sendUpdate: (record: Subagent, message: string) => void;
|
|
192
|
+
sendWorkspaceNotice: (record: Subagent, notice: string) => void;
|
|
193
|
+
dispose: () => void;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Details the update renderer reads. */
|
|
197
|
+
export interface UpdateDetails {
|
|
198
|
+
id: string;
|
|
199
|
+
description: string;
|
|
200
|
+
message: string;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Details the workspace-notice renderer reads. */
|
|
204
|
+
export interface WorkspaceNoticeDetails {
|
|
205
|
+
id: string;
|
|
206
|
+
description: string;
|
|
207
|
+
notice: string;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* One announcement withheld for the parent's current run.
|
|
212
|
+
*
|
|
213
|
+
* A completion for an agent supersedes an earlier one for that agent; two
|
|
214
|
+
* updates are two distinct facts and both survive.
|
|
215
|
+
*/
|
|
216
|
+
type PendingAnnouncement =
|
|
217
|
+
| { kind: "completion"; record: Subagent }
|
|
218
|
+
| { kind: "update"; record: Subagent; message: string };
|
|
219
|
+
|
|
220
|
+
export class NotificationManager implements NotificationSystem {
|
|
221
|
+
// pi.sendMessage is fire-and-forget: while the parent's agent run is active,
|
|
222
|
+
// a followUp is handed to a queue the extension cannot recall, yet it is only
|
|
223
|
+
// delivered when the run drains that queue at turn end. A parent that pulls
|
|
224
|
+
// the result in between would receive it twice. So nudges that arrive mid-run
|
|
225
|
+
// are withheld here — where record.consumed is still consultable — and
|
|
226
|
+
// flushed once the run settles.
|
|
227
|
+
// Ordered rather than record-keyed: a completion for an agent supersedes an
|
|
228
|
+
// earlier one for that agent, but announcements from different agents are
|
|
229
|
+
// distinct facts, and arrival order is the only order the parent can make
|
|
230
|
+
// sense of.
|
|
231
|
+
private pending: PendingAnnouncement[] = [];
|
|
232
|
+
private parentRunActive = false;
|
|
233
|
+
private disposed = false;
|
|
234
|
+
|
|
235
|
+
constructor(
|
|
236
|
+
private sendMessage: (
|
|
237
|
+
msg: { customType: string; content: string; display: boolean; details?: unknown },
|
|
238
|
+
opts?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
|
|
239
|
+
) => void,
|
|
240
|
+
) {}
|
|
241
|
+
|
|
242
|
+
sendCompletion(record: Subagent): void {
|
|
243
|
+
// The session is gone. It is the aborts fired during shutdown that reach
|
|
244
|
+
// here, and with no parent run active a nudge would go straight out as an
|
|
245
|
+
// unrecallable followUp.
|
|
246
|
+
if (this.disposed) return;
|
|
247
|
+
// A carrier has committed to delivering this outcome, so announcing it would
|
|
248
|
+
// duplicate a delivery the parent is already getting. Structural and decided
|
|
249
|
+
// at the parent's request, so unlike the consumption check below it cannot
|
|
250
|
+
// race the turn.
|
|
251
|
+
if (record.claimed) return;
|
|
252
|
+
// Consumption is domain state on the record; the nudge is a pure
|
|
253
|
+
// announcement. Skip if the parent already pulled the result (enqueue-time
|
|
254
|
+
// guard); emitIndividualNudge re-reads record.consumed when the nudge is
|
|
255
|
+
// actually emitted, which is what makes the flush a fresh re-check.
|
|
256
|
+
if (record.consumed) return;
|
|
257
|
+
if (this.parentRunActive) {
|
|
258
|
+
this.withholdCompletion(record);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
this.emitIndividualNudge(record);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Queue a completion for the flush, superseding an earlier one for the same
|
|
266
|
+
* agent in the position that one already holds — a re-completion is the same
|
|
267
|
+
* fact told again, not a later one.
|
|
268
|
+
*/
|
|
269
|
+
private withholdCompletion(record: Subagent): void {
|
|
270
|
+
const entry: PendingAnnouncement = { kind: "completion", record };
|
|
271
|
+
const existing = this.pending.findIndex(
|
|
272
|
+
(queued) => queued.kind === "completion" && queued.record.id === record.id,
|
|
273
|
+
);
|
|
274
|
+
if (existing === -1) this.pending.push(entry);
|
|
275
|
+
else this.pending[existing] = entry;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Announce a message a still-running child sent its parent.
|
|
280
|
+
*
|
|
281
|
+
* Consumption is not consulted: it records that the child's *outcome* was
|
|
282
|
+
* collected, and an update is a new fact rather than that outcome told again.
|
|
283
|
+
* What is consulted is whether an announcement is still the right channel at
|
|
284
|
+
* all — see `canAnnounceUpdate`. The disposal latch and the parent-run
|
|
285
|
+
* withhold apply as they do to any announcement.
|
|
286
|
+
*/
|
|
287
|
+
sendUpdate(record: Subagent, message: string): void {
|
|
288
|
+
if (this.disposed) return;
|
|
289
|
+
if (!this.canAnnounceUpdate(record)) return;
|
|
290
|
+
if (this.parentRunActive) {
|
|
291
|
+
this.pending.push({ kind: "update", record, message });
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
this.emitUpdate(record, message);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Whether an announcement is still the right channel for this run's updates.
|
|
299
|
+
*
|
|
300
|
+
* A claimed outcome is one a blocked carrier is already delivering, so an
|
|
301
|
+
* announcement could only arrive after its return. A terminated run has an
|
|
302
|
+
* outcome, and every carrier of an outcome renders what the run still owes —
|
|
303
|
+
* including the completion nudge — so the message arrives with it rather than
|
|
304
|
+
* as a live update the child can no longer act on.
|
|
305
|
+
*
|
|
306
|
+
* Consulted at enqueue and again at emit, because a run withheld for the
|
|
307
|
+
* parent's turn can terminate or be claimed in between. That re-read is what
|
|
308
|
+
* keeps a `<subagent-update>` block's steering affordance true.
|
|
309
|
+
*/
|
|
310
|
+
private canAnnounceUpdate(record: Subagent): boolean {
|
|
311
|
+
return !record.claimed && record.isActive();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Announce where a teardown left a child's work, after its result was already
|
|
316
|
+
* delivered.
|
|
317
|
+
*
|
|
318
|
+
* Consults neither the carrier claim nor consumption: both record that the
|
|
319
|
+
* child's *outcome* has an owner, and this is a fact about the workspace
|
|
320
|
+
* rather than that outcome told again. It is not withheld for the parent's
|
|
321
|
+
* run either — the withheld queue exists for the unrecallable `followUp` a
|
|
322
|
+
* mid-run send becomes, and this delivery mode never reaches it. Pi appends
|
|
323
|
+
* it once the current turn ends, or immediately when there is none.
|
|
324
|
+
*/
|
|
325
|
+
sendWorkspaceNotice(record: Subagent, notice: string): void {
|
|
326
|
+
if (this.disposed) return;
|
|
327
|
+
const details: WorkspaceNoticeDetails = {
|
|
328
|
+
id: record.id,
|
|
329
|
+
description: record.description,
|
|
330
|
+
notice,
|
|
331
|
+
};
|
|
332
|
+
this.sendMessage(
|
|
333
|
+
{
|
|
334
|
+
customType: "subagent-workspace-notice",
|
|
335
|
+
content: formatWorkspaceNotice(record, notice),
|
|
336
|
+
display: true,
|
|
337
|
+
details,
|
|
338
|
+
},
|
|
339
|
+
{ triggerTurn: false },
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** The parent's agent run became active; nudges are withheld until it settles. */
|
|
344
|
+
onParentAgentStart(): void {
|
|
345
|
+
this.parentRunActive = true;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The parent's agent run settled. Flush the nudges withheld during it, each
|
|
350
|
+
* re-checking consumption, so a result the parent pulled mid-run is dropped
|
|
351
|
+
* rather than announced a second time.
|
|
352
|
+
*/
|
|
353
|
+
onParentAgentSettled(): void {
|
|
354
|
+
this.parentRunActive = false;
|
|
355
|
+
const withheld = this.pending.splice(0);
|
|
356
|
+
for (const entry of withheld) {
|
|
357
|
+
try {
|
|
358
|
+
if (entry.kind === "update") this.emitUpdate(entry.record, entry.message);
|
|
359
|
+
else this.emitIndividualNudge(entry.record);
|
|
360
|
+
} catch (err) {
|
|
361
|
+
debugLog("notification render", err);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Terminal: the manager stops announcing anything, now and afterwards. */
|
|
367
|
+
dispose(): void {
|
|
368
|
+
this.disposed = true;
|
|
369
|
+
this.pending.length = 0;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private emitUpdate(record: Subagent, message: string): void {
|
|
373
|
+
if (!this.canAnnounceUpdate(record)) return;
|
|
374
|
+
// This channel is delivering the message, so no outcome carrier may repeat
|
|
375
|
+
// it — the record renders only what is still owed.
|
|
376
|
+
record.markUpdateAnnounced(message);
|
|
377
|
+
const details: UpdateDetails = {
|
|
378
|
+
id: record.id,
|
|
379
|
+
description: record.description,
|
|
380
|
+
message,
|
|
381
|
+
};
|
|
382
|
+
this.sendMessage(
|
|
383
|
+
{
|
|
384
|
+
customType: "subagent-update",
|
|
385
|
+
content: formatUpdateNotification(record, message),
|
|
386
|
+
display: true,
|
|
387
|
+
details,
|
|
388
|
+
},
|
|
389
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private emitIndividualNudge(record: Subagent): void {
|
|
394
|
+
if (record.claimed) return;
|
|
395
|
+
if (record.consumed) return;
|
|
396
|
+
|
|
397
|
+
const notification = formatTaskNotification(record, 500);
|
|
398
|
+
// A never-started agent has no transcript and nothing to collect.
|
|
399
|
+
const pointerLines = record.stoppedWhileQueued ? "" : this.buildPointerLines(record);
|
|
400
|
+
|
|
401
|
+
this.sendMessage(
|
|
402
|
+
{
|
|
403
|
+
customType: "subagent-notification",
|
|
404
|
+
content: notification + pointerLines,
|
|
405
|
+
display: true,
|
|
406
|
+
details: buildNotificationDetails(record, 500),
|
|
407
|
+
},
|
|
408
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* The trailing pointer lines: where the transcript lives, and how to collect
|
|
414
|
+
* the result. The nudge only announces; the parent must pull to collect (and
|
|
415
|
+
* consume).
|
|
416
|
+
*/
|
|
417
|
+
private buildPointerLines(record: Subagent): string {
|
|
418
|
+
const outputFile = record.outputFile;
|
|
419
|
+
const transcriptLine = outputFile ? `\nFull transcript available at: ${outputFile}` : "";
|
|
420
|
+
return (
|
|
421
|
+
// What the child flagged along the way leads — for a run nothing else
|
|
422
|
+
// collected, this nudge is the carrier those updates ride. Then where the
|
|
423
|
+
// work went, so the parent reads it before the pointers.
|
|
424
|
+
renderRunUpdates(record.runUpdates) +
|
|
425
|
+
renderWorkspaceNotice(record.workspaceNotice) +
|
|
426
|
+
`${transcriptLine}\nCall get_subagent_result("${record.id}") to collect the full result.` +
|
|
427
|
+
renderQuestionAffordance(record.id, record.pendingQuestion, record.resumeRefusal)
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* outcome-delivery.ts — Shared rendering for a subagent outcome.
|
|
3
|
+
*
|
|
4
|
+
* A terminated child's outcome reaches the parent model through exactly one
|
|
5
|
+
* carrier: the foreground tool return, the resume tool return, the
|
|
6
|
+
* `get_subagent_result` report, or the completion nudge. Each carrier owns its
|
|
7
|
+
* own framing (spawn notes, XML envelope, report header) and its own
|
|
8
|
+
* truncation — a nudge is a preview, a pull is the full text — but the body
|
|
9
|
+
* they wrap is the same fact, so it is rendered here rather than four times.
|
|
10
|
+
*
|
|
11
|
+
* A workspace notice rides the same four carriers without being part of the
|
|
12
|
+
* outcome: it reports where a teardown put the child's work, which the parent
|
|
13
|
+
* needs whether the run succeeded or failed. A notice produced after the
|
|
14
|
+
* result was already delivered reaches none of them and is announced on its
|
|
15
|
+
* own instead.
|
|
16
|
+
*
|
|
17
|
+
* The updates a child sent mid-run ride the carriers too, for the runs whose
|
|
18
|
+
* outcome a blocked carrier holds: the announcement channel would deliver them
|
|
19
|
+
* after that carrier's own return, so it renders them itself instead.
|
|
20
|
+
*
|
|
21
|
+
* The updates, the notice, and the ask-back affordance are appended in one
|
|
22
|
+
* fixed order by `renderOutcomeAddenda`, so the three carriers that compose
|
|
23
|
+
* them cannot disagree about what follows the body. The affordance is the one
|
|
24
|
+
* addendum that asks the parent to call back, so it is also the one that has
|
|
25
|
+
* to know whether that call would be accepted.
|
|
26
|
+
*
|
|
27
|
+
* Pure functions only: no SDK types, no record types, no side effects.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { ResumeRefusal } from "#src/lifecycle/subagent";
|
|
31
|
+
import type { SubagentStatus } from "#src/lifecycle/subagent-state";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What a terminal status means, independent of how a carrier renders it.
|
|
35
|
+
*
|
|
36
|
+
* One source of truth for the facts, because the carriers disagreed on them:
|
|
37
|
+
* the nudge reported an abort as "max turns exceeded" while the foreground
|
|
38
|
+
* result added "output may be incomplete", and the pull and resume carriers
|
|
39
|
+
* reported nothing at all. Presentation still differs — see the two renderers
|
|
40
|
+
* below — because a standalone label and a mid-sentence parenthetical are
|
|
41
|
+
* different grammar, not different facts.
|
|
42
|
+
*/
|
|
43
|
+
interface StatusMeaning {
|
|
44
|
+
/** Sentence-initial label, e.g. "Wrapped up". */
|
|
45
|
+
label: string;
|
|
46
|
+
/** Why, without terminal punctuation, e.g. "reached turn limit". */
|
|
47
|
+
detail: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const STATUS_MEANINGS: Partial<Record<SubagentStatus, StatusMeaning>> = {
|
|
51
|
+
aborted: { label: "Aborted", detail: "max turns exceeded, output may be incomplete" },
|
|
52
|
+
steered: { label: "Wrapped up", detail: "reached turn limit" },
|
|
53
|
+
// "user request" rather than "stopped by user": the detail must stand on its
|
|
54
|
+
// own after the label, which both presentations already supply.
|
|
55
|
+
stopped: { label: "Stopped", detail: "user request" },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Why a resume is unavailable *for good*, worded as a subordinate clause.
|
|
60
|
+
*
|
|
61
|
+
* The resume door words the same three facts as standalone refusals; a clause
|
|
62
|
+
* that continues someone else's sentence is different grammar, not a different
|
|
63
|
+
* fact — the split `STATUS_MEANINGS` makes between `label` and `detail`.
|
|
64
|
+
*
|
|
65
|
+
* `still-running` is excluded by type rather than by omission: a refusal that
|
|
66
|
+
* lifts when the run settles asks the parent to wait, where these three ask it
|
|
67
|
+
* to give up, so it gets its own sentence in the renderer below.
|
|
68
|
+
*/
|
|
69
|
+
const RESUME_REFUSAL_CLAUSES: Record<Exclude<ResumeRefusal, "still-running">, string> = {
|
|
70
|
+
// Deliberately not "...no session to resume": the clause is followed by a
|
|
71
|
+
// colon, and "resume:" is the exact token the parent must not see here.
|
|
72
|
+
"no-session": "it has no active session",
|
|
73
|
+
"session-released": "its session was released after its retention window",
|
|
74
|
+
"workspace-disposed": "it ran in an isolated workspace that has since been removed",
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Standalone label form, e.g. "Wrapped up (reached turn limit)".
|
|
79
|
+
*
|
|
80
|
+
* An error reports its message instead: the status alone does not say what
|
|
81
|
+
* went wrong.
|
|
82
|
+
*/
|
|
83
|
+
export function renderStatusLabel(status: SubagentStatus, error?: string): string {
|
|
84
|
+
if (status === "error") return `Error: ${error ?? "unknown"}`;
|
|
85
|
+
const meaning = STATUS_MEANINGS[status];
|
|
86
|
+
return meaning ? `${meaning.label} (${meaning.detail})` : "Done";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Parenthetical suffix form, e.g. " (wrapped up — reached turn limit)", for a
|
|
91
|
+
* carrier appending to its own sentence. Empty when the status is unremarkable
|
|
92
|
+
* or when the body already carries the explanation, as an error's does.
|
|
93
|
+
*/
|
|
94
|
+
export function renderStatusNote(status: SubagentStatus): string {
|
|
95
|
+
const meaning = STATUS_MEANINGS[status];
|
|
96
|
+
if (!meaning) return "";
|
|
97
|
+
return ` (${meaning.label.toLowerCase()} \u2014 ${meaning.detail})`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Only what the body formatter reads. Narrower than any record type so a
|
|
102
|
+
* caller cannot come to depend on fields this module does not use.
|
|
103
|
+
*/
|
|
104
|
+
export interface OutcomeBody {
|
|
105
|
+
status: SubagentStatus;
|
|
106
|
+
result: string | undefined;
|
|
107
|
+
error: string | undefined;
|
|
108
|
+
/** Whether the agent was stopped before the limiter ever admitted it. */
|
|
109
|
+
stoppedWhileQueued: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The trailing affordance for a child that ended its turn with a question.
|
|
114
|
+
* Empty when the child asked nothing.
|
|
115
|
+
*
|
|
116
|
+
* Names the exact call that answers it when a resume would be accepted, why it
|
|
117
|
+
* cannot be answered when one would be refused for good, and what to wait for
|
|
118
|
+
* when the child is simply not finished — the parent is never told to make a
|
|
119
|
+
* call this extension declines.
|
|
120
|
+
*
|
|
121
|
+
* Takes the id, question, and refusal rather than a record: the three facts it
|
|
122
|
+
* needs, so a carrier holding any shape can call it.
|
|
123
|
+
*/
|
|
124
|
+
export function renderQuestionAffordance(
|
|
125
|
+
agentId: string,
|
|
126
|
+
question: string | undefined,
|
|
127
|
+
refusal: ResumeRefusal | undefined,
|
|
128
|
+
): string {
|
|
129
|
+
if (!question) return "";
|
|
130
|
+
const quoted = question
|
|
131
|
+
.split("\n")
|
|
132
|
+
.map((line) => ` ${line}`)
|
|
133
|
+
.join("\n");
|
|
134
|
+
if (refusal === "still-running") {
|
|
135
|
+
return (
|
|
136
|
+
"\n\nThis agent asked a question before it finished running, so it cannot be " +
|
|
137
|
+
`resumed yet:\n\n${quoted}\n\n` +
|
|
138
|
+
"Wait for it to settle \u2014 get_subagent_result with wait: true returns when it " +
|
|
139
|
+
"does \u2014 then answer it."
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (refusal) {
|
|
143
|
+
return (
|
|
144
|
+
"\n\nThis agent ended its run with a question that can no longer be answered \u2014 " +
|
|
145
|
+
`${RESUME_REFUSAL_CLAUSES[refusal]}:\n\n${quoted}\n\n` +
|
|
146
|
+
"Spawn a new agent with the context it needs; this one cannot be resumed."
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
return (
|
|
150
|
+
`\n\nThis agent is waiting on an answer:\n\n${quoted}\n\n` +
|
|
151
|
+
`Answer by calling subagent with resume: "${agentId}" and your answer as the prompt.`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The provider's own wording for where a teardown left the child's work, for a
|
|
157
|
+
* carrier appending it after the outcome body. Empty when no teardown reported
|
|
158
|
+
* anything — including every run whose addendum rode the result text instead.
|
|
159
|
+
*
|
|
160
|
+
* Takes the notice rather than a record, so a carrier holding any shape can
|
|
161
|
+
* call it, and so the framing has one home if a carrier ever needs its own.
|
|
162
|
+
*/
|
|
163
|
+
export function renderWorkspaceNotice(notice: string | undefined): string {
|
|
164
|
+
return notice ?? "";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* What a carrier appends after the outcome body: where the work went, then the
|
|
169
|
+
* call to action that follows it.
|
|
170
|
+
*
|
|
171
|
+
* The fields rather than a record, so a carrier holding any shape can call it
|
|
172
|
+
* — both `Subagent` and `AgentReport` satisfy this structurally.
|
|
173
|
+
*/
|
|
174
|
+
export interface OutcomeAddenda {
|
|
175
|
+
id: string;
|
|
176
|
+
runUpdates?: readonly string[];
|
|
177
|
+
workspaceNotice?: string;
|
|
178
|
+
pendingQuestion?: string;
|
|
179
|
+
/**
|
|
180
|
+
* Why a resume would be refused; undefined when one would be accepted.
|
|
181
|
+
*
|
|
182
|
+
* Required rather than optional so a carrier that forgets it fails to
|
|
183
|
+
* compile. An omitted optional would silently mean "resumable", which is the
|
|
184
|
+
* fail-open this field exists to close.
|
|
185
|
+
*/
|
|
186
|
+
resumeRefusal: ResumeRefusal | undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The addenda tail every outcome carrier appends, in one order. */
|
|
190
|
+
export function renderOutcomeAddenda(outcome: OutcomeAddenda): string {
|
|
191
|
+
return (
|
|
192
|
+
// What the agent flagged along the way, then where the work went, then the
|
|
193
|
+
// call to action that follows both.
|
|
194
|
+
renderRunUpdates(outcome.runUpdates) +
|
|
195
|
+
renderWorkspaceNotice(outcome.workspaceNotice) +
|
|
196
|
+
renderQuestionAffordance(outcome.id, outcome.pendingQuestion, outcome.resumeRefusal)
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The updates a child sent while a carrier held this run's outcome, quoted the
|
|
202
|
+
* way the ask-back affordance quotes the child's question. Empty for a run the
|
|
203
|
+
* child said nothing during — which is every run whose updates the parent was
|
|
204
|
+
* free to receive as they happened.
|
|
205
|
+
*/
|
|
206
|
+
export function renderRunUpdates(updates: readonly string[] | undefined): string {
|
|
207
|
+
if (!updates?.length) return "";
|
|
208
|
+
const quoted = updates
|
|
209
|
+
.map((update) =>
|
|
210
|
+
update
|
|
211
|
+
.split("\n")
|
|
212
|
+
.map((line) => ` ${line}`)
|
|
213
|
+
.join("\n"),
|
|
214
|
+
)
|
|
215
|
+
.join("\n\n");
|
|
216
|
+
return `\n\nUpdates this agent sent while it worked:\n\n${quoted}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The outcome body every carrier reports: a running note, an error line, a
|
|
221
|
+
* never-started note, or the trimmed result.
|
|
222
|
+
*
|
|
223
|
+
* The empty-result fallback tests truthiness rather than nullishness. A `??`
|
|
224
|
+
* here passed an empty string through, which is exactly what a run whose
|
|
225
|
+
* provider errored carries — so the parent received a completion header
|
|
226
|
+
* followed by nothing and confabulated the child's work. The two renderers in
|
|
227
|
+
* `notification.ts` always guarded this by truthiness; this is the third
|
|
228
|
+
* agreeing with them (#889).
|
|
229
|
+
*/
|
|
230
|
+
export function renderOutcomeBody(outcome: OutcomeBody): string {
|
|
231
|
+
if (outcome.status === "running")
|
|
232
|
+
return "Agent is still running. Use wait: true or check back later.";
|
|
233
|
+
if (outcome.status === "error") return `Error: ${outcome.error}`;
|
|
234
|
+
if (outcome.stoppedWhileQueued)
|
|
235
|
+
return "Agent was stopped while queued and never started. No work was performed.";
|
|
236
|
+
const trimmed = outcome.result?.trim();
|
|
237
|
+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- || intentional: converts falsy values to fallback
|
|
238
|
+
return trimmed || "No output.";
|
|
239
|
+
}
|