@bermudi/pi-delegate 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -0
- package/agents.ts +347 -0
- package/concurrency.ts +126 -0
- package/config.ts +358 -0
- package/constants.ts +41 -0
- package/delegate.ts +115 -0
- package/dispatch.ts +362 -0
- package/extension.ts +126 -0
- package/file-tracking.ts +57 -0
- package/format.ts +506 -0
- package/host-compat.ts +73 -0
- package/host.ts +814 -0
- package/lifecycle.ts +704 -0
- package/manual.ts +184 -0
- package/model.ts +81 -0
- package/package.json +43 -0
- package/parent-context.ts +42 -0
- package/pool.ts +420 -0
- package/render-branches.ts +380 -0
- package/render-result.ts +182 -0
- package/runner.ts +686 -0
- package/schema.ts +289 -0
- package/sessions.ts +102 -0
- package/settings.ts +78 -0
- package/spill.ts +161 -0
- package/task-resolution.ts +321 -0
- package/tickets.ts +795 -0
- package/timer.ts +46 -0
- package/tools.ts +41 -0
- package/types.ts +243 -0
- package/usage.ts +121 -0
- package/utils.ts +131 -0
package/runner.ts
ADDED
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type {
|
|
3
|
+
AgentSession,
|
|
4
|
+
AgentSessionEvent,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import {
|
|
7
|
+
getGitChangedFiles,
|
|
8
|
+
extractTouchedFromActivities,
|
|
9
|
+
} from "./file-tracking.ts";
|
|
10
|
+
import { extractTextFromPartialResult, extractOutput } from "./utils.ts";
|
|
11
|
+
import { snapshotSessionUsage, usageDelta, emptyUsage } from "./usage.ts";
|
|
12
|
+
import { getStallTimeoutMs } from "./config.ts";
|
|
13
|
+
import { fmtDuration } from "./format.ts";
|
|
14
|
+
import { scheduleDeadline } from "./timer.ts";
|
|
15
|
+
import type { Usage } from "@earendil-works/pi-ai";
|
|
16
|
+
import type {
|
|
17
|
+
AgentProgressUpdate,
|
|
18
|
+
TaskFailureKind,
|
|
19
|
+
ToolActivity,
|
|
20
|
+
} from "./types.ts";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Run a single prompt against a live `AgentSession` and report progress.
|
|
24
|
+
*
|
|
25
|
+
* This replaces the old `createAgent` / `runAgentOnce` / `runAgent` stack.
|
|
26
|
+
* The `AgentSession` owns the model-call loop, per-message auto-retry
|
|
27
|
+
* (strip-bad-message + `agent.continue()`), compaction, overflow recovery, and
|
|
28
|
+
* session persistence — so this function only:
|
|
29
|
+
* 1. subscribes to the session's event stream and maps it to the renderer's
|
|
30
|
+
* `AgentProgressUpdate` / `ToolActivity` shapes,
|
|
31
|
+
* 2. wires the parent abort signal to `session.abort()`,
|
|
32
|
+
* 3. waits for extension-started post-run compaction/continuations to become
|
|
33
|
+
* quiescent before returning ownership to lifecycle,
|
|
34
|
+
* 4. snapshots usage before/after the prompt for token delta accounting, and
|
|
35
|
+
* 5. computes touched files from activity + git diff.
|
|
36
|
+
*
|
|
37
|
+
* Output is captured from AgentSession events so compaction cannot erase it
|
|
38
|
+
* before collection; `session.messages` is only a guarded fallback for
|
|
39
|
+
* providers that fail before emitting message_end. AgentSession's internal
|
|
40
|
+
* retry means a transient mid-loop error no longer hard-fails a task whose
|
|
41
|
+
* work is already done.
|
|
42
|
+
*/
|
|
43
|
+
export async function runAgentSession(
|
|
44
|
+
session: AgentSession,
|
|
45
|
+
prompt: string,
|
|
46
|
+
config: { cwd: string },
|
|
47
|
+
signal: AbortSignal | undefined,
|
|
48
|
+
onProgress: ((update: AgentProgressUpdate) => void) | undefined,
|
|
49
|
+
gitBaseline: Set<string>,
|
|
50
|
+
start: number,
|
|
51
|
+
): Promise<{
|
|
52
|
+
output: string;
|
|
53
|
+
error?: string;
|
|
54
|
+
durationMs: number;
|
|
55
|
+
tokens: number;
|
|
56
|
+
usage: Usage;
|
|
57
|
+
touchedFiles: string[];
|
|
58
|
+
failureKind?: TaskFailureKind;
|
|
59
|
+
}> {
|
|
60
|
+
const startTime = start ?? Date.now();
|
|
61
|
+
const stallTimeoutMs = getStallTimeoutMs();
|
|
62
|
+
let toolUses = 0;
|
|
63
|
+
let lastActivityAt: number | undefined = startTime;
|
|
64
|
+
let phase = "starting agent";
|
|
65
|
+
let stalled = false;
|
|
66
|
+
let stalledPhase: string | undefined;
|
|
67
|
+
let clearStallDeadline: (() => void) | undefined;
|
|
68
|
+
const activities: ToolActivity[] = [];
|
|
69
|
+
const pendingById = new Map<string, ToolActivity>();
|
|
70
|
+
let sessionEventGeneration = 0;
|
|
71
|
+
let wakeSessionEvent: (() => void) | undefined;
|
|
72
|
+
|
|
73
|
+
// AgentSession.prompt() can return while an agent_settled extension callback
|
|
74
|
+
// is still running fire-and-forget work through ctx.compact(). Keep a small
|
|
75
|
+
// internal event seam so the runner can wait without polling throughout a
|
|
76
|
+
// potentially long remote compaction. The generation closes the race between
|
|
77
|
+
// checking session state and installing the next waiter.
|
|
78
|
+
let abortRequestedGeneration = -1;
|
|
79
|
+
const noteSessionEvent = () => {
|
|
80
|
+
sessionEventGeneration++;
|
|
81
|
+
const wake = wakeSessionEvent;
|
|
82
|
+
wakeSessionEvent = undefined;
|
|
83
|
+
wake?.();
|
|
84
|
+
};
|
|
85
|
+
const waitForSessionEventAfter = async (
|
|
86
|
+
generation: number,
|
|
87
|
+
): Promise<void> => {
|
|
88
|
+
if (sessionEventGeneration !== generation) return;
|
|
89
|
+
await new Promise<void>((resolve) => {
|
|
90
|
+
let settled = false;
|
|
91
|
+
const probe = setTimeout(() => finish(), 250);
|
|
92
|
+
const finish = () => {
|
|
93
|
+
if (settled) return;
|
|
94
|
+
settled = true;
|
|
95
|
+
clearTimeout(probe);
|
|
96
|
+
if (wakeSessionEvent === finish) wakeSessionEvent = undefined;
|
|
97
|
+
resolve();
|
|
98
|
+
};
|
|
99
|
+
if (sessionEventGeneration !== generation) {
|
|
100
|
+
finish();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// AgentSession normally emits every transition we care about. The slow
|
|
104
|
+
// probe is a liveness fallback for host versions that clear an internal
|
|
105
|
+
// busy flag without a corresponding public event.
|
|
106
|
+
wakeSessionEvent = finish;
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
const nextEventLoopTurn = () =>
|
|
110
|
+
new Promise<void>((resolve) => setImmediate(resolve));
|
|
111
|
+
|
|
112
|
+
const requestSessionCancellation = (
|
|
113
|
+
source: "parent-aborted" | "stalled",
|
|
114
|
+
): void => {
|
|
115
|
+
const logFailure = (operation: string, error: unknown) => {
|
|
116
|
+
console.error(`[delegate] ${source} subagent ${operation} failed`, error);
|
|
117
|
+
};
|
|
118
|
+
try {
|
|
119
|
+
session.abortCompaction();
|
|
120
|
+
} catch (error) {
|
|
121
|
+
logFailure("compaction cancellation", error);
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
session.abortBranchSummary();
|
|
125
|
+
} catch (error) {
|
|
126
|
+
logFailure("branch-summary cancellation", error);
|
|
127
|
+
}
|
|
128
|
+
noteSessionEvent();
|
|
129
|
+
// Fire the abort before recording the generation. session.abort() may
|
|
130
|
+
// synchronously emit events (e.g. a final message_update as the stream
|
|
131
|
+
// unwinds) that increment the generation. Recording after the call
|
|
132
|
+
// ensures those abort-caused events are accounted for, so the quiescence
|
|
133
|
+
// barrier's re-abort check doesn't loop on the abort's own events.
|
|
134
|
+
void session.abort().catch((error: unknown) => {
|
|
135
|
+
logFailure("agent cancellation", error);
|
|
136
|
+
});
|
|
137
|
+
// Record the generation after abort is dispatched. If new session events
|
|
138
|
+
// fire after this point (e.g. a continuation prompt started by an
|
|
139
|
+
// extension's onComplete callback delayed by async auth), the quiescence
|
|
140
|
+
// barrier re-aborts to cancel that continuation rather than letting it run
|
|
141
|
+
// — and potentially mutate files — after the task is considered cancelled.
|
|
142
|
+
abortRequestedGeneration = sessionEventGeneration;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Wait until the session is idle and non-compacting for two unchanged event
|
|
147
|
+
* loop turns. The quiet turns are significant: AgentSession emits
|
|
148
|
+
* compaction_end before ctx.compact's onComplete/onError callback runs, and a
|
|
149
|
+
* successful callback may immediately start a continuation prompt.
|
|
150
|
+
*
|
|
151
|
+
* Cancellation re-abort: if the task has been cancelled (parent abort or
|
|
152
|
+
* stall), any session event after the last cancellation request means new
|
|
153
|
+
* work started — typically a continuation from an extension's onComplete
|
|
154
|
+
* callback, possibly delayed by async auth. The barrier re-aborts to cancel
|
|
155
|
+
* it. This check runs *before* the idle check so a fast continuation that
|
|
156
|
+
* already completed between samples is still caught: the generation changed
|
|
157
|
+
* even though the session is idle again, and re-abort resets the tracker to
|
|
158
|
+
* catch any further continuations.
|
|
159
|
+
*
|
|
160
|
+
* Cancelled grace period: when cancelled and idle, a single 50 ms wait is
|
|
161
|
+
* required before quiet turns can accumulate. Without it, a continuation
|
|
162
|
+
* delayed by async auth or another extension handler can start after the
|
|
163
|
+
* runner returns — the two event-loop turns pass in microseconds, far faster
|
|
164
|
+
* than any realistic async-auth gap. This is a **mitigation, not a
|
|
165
|
+
* deterministic fix**: a deterministic solution would require the host to
|
|
166
|
+
* expose pending extension work (e.g. `AgentSession.hasPendingExtensionWork()`)
|
|
167
|
+
* so the barrier could wait on it explicitly. The grace period adds at most
|
|
168
|
+
* one 50 ms wait per cancellation/re-abort cycle — re-aborts reset the
|
|
169
|
+
* `graceWaited` flag, so a sequence of delayed continuations can cause
|
|
170
|
+
* multiple grace waits.
|
|
171
|
+
*/
|
|
172
|
+
const waitForSessionQuiescence = async (): Promise<void> => {
|
|
173
|
+
const cancelledGraceMs = 50;
|
|
174
|
+
const cancellationRequested = () => signal?.aborted || stalled;
|
|
175
|
+
const cancellationSource = (): "parent-aborted" | "stalled" =>
|
|
176
|
+
signal?.aborted ? "parent-aborted" : "stalled";
|
|
177
|
+
let quietTurns = 0;
|
|
178
|
+
let graceWaited = false;
|
|
179
|
+
while (quietTurns < 2) {
|
|
180
|
+
const generation = sessionEventGeneration;
|
|
181
|
+
await nextEventLoopTurn();
|
|
182
|
+
|
|
183
|
+
// Re-abort if new activity started after the last cancellation request.
|
|
184
|
+
// This runs before the idle check so a fast continuation that completed
|
|
185
|
+
// between samples (generation changed, session idle again) is still
|
|
186
|
+
// caught. After re-aborting, restart the loop to recompute isIdle/
|
|
187
|
+
// isCompacting rather than falling through to the 250 ms event probe.
|
|
188
|
+
if (
|
|
189
|
+
cancellationRequested() &&
|
|
190
|
+
abortRequestedGeneration >= 0 &&
|
|
191
|
+
sessionEventGeneration !== abortRequestedGeneration
|
|
192
|
+
) {
|
|
193
|
+
requestSessionCancellation(cancellationSource());
|
|
194
|
+
quietTurns = 0;
|
|
195
|
+
graceWaited = false;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const idle = session.isIdle && !session.isCompacting;
|
|
200
|
+
if (idle && sessionEventGeneration === generation) {
|
|
201
|
+
if (cancellationRequested() && !graceWaited) {
|
|
202
|
+
// Wait once for a grace period before accepting quiet turns. A
|
|
203
|
+
// continuation delayed by async auth can start after the immediate
|
|
204
|
+
// microtask batch settles. This is a single setTimeout, not a
|
|
205
|
+
// busy-spin — the event loop is free to process the continuation's
|
|
206
|
+
// events during the wait.
|
|
207
|
+
graceWaited = true;
|
|
208
|
+
await new Promise<void>((resolve) =>
|
|
209
|
+
setTimeout(resolve, cancelledGraceMs),
|
|
210
|
+
);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
quietTurns++;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
quietTurns = 0;
|
|
218
|
+
graceWaited = false;
|
|
219
|
+
if (!idle) {
|
|
220
|
+
await waitForSessionEventAfter(sessionEventGeneration);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// Snapshot cumulative usage before the prompt so we can report only the
|
|
226
|
+
// tokens consumed by this call (not cumulative history on a pooled/resumed
|
|
227
|
+
// session). Cumulative session stats (including compacted-away history) are the only
|
|
228
|
+
// stable accounting boundary for pooled/resumed sessions. The transcript can
|
|
229
|
+
// be replaced during compaction, so a message-array length/usage delta is not
|
|
230
|
+
// a valid per-call counter.
|
|
231
|
+
const statsBefore = snapshotSessionUsage(session);
|
|
232
|
+
const currentUsage = () =>
|
|
233
|
+
usageDelta(statsBefore, snapshotSessionUsage(session));
|
|
234
|
+
|
|
235
|
+
const fireProgress = () => {
|
|
236
|
+
if (!onProgress) return;
|
|
237
|
+
const delta = currentUsage().totalTokens;
|
|
238
|
+
onProgress({
|
|
239
|
+
tokens: delta,
|
|
240
|
+
toolUses,
|
|
241
|
+
durationMs: Date.now() - startTime,
|
|
242
|
+
lastActivityAt,
|
|
243
|
+
activities: [...activities],
|
|
244
|
+
failureKind: stalled ? "stalled" : undefined,
|
|
245
|
+
});
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const stallError = () =>
|
|
249
|
+
`Stalled: no AgentSession activity for ${fmtDuration(stallTimeoutMs)} while ${stalledPhase ?? phase}; task aborted.`;
|
|
250
|
+
const clearStallWatchdog = () => {
|
|
251
|
+
clearStallDeadline?.();
|
|
252
|
+
clearStallDeadline = undefined;
|
|
253
|
+
};
|
|
254
|
+
const abortForStall = () => {
|
|
255
|
+
if (stalled || signal?.aborted) return;
|
|
256
|
+
stalled = true;
|
|
257
|
+
stalledPhase = phase;
|
|
258
|
+
clearStallWatchdog();
|
|
259
|
+
console.warn(
|
|
260
|
+
`[delegate] stalled subagent detected after ${fmtDuration(stallTimeoutMs)} while ${phase}; requesting cooperative cancellation`,
|
|
261
|
+
);
|
|
262
|
+
// AgentSession.abort() only covers the agent loop. A post-settle manual
|
|
263
|
+
// compaction is idle by that definition, so cancel the other session work
|
|
264
|
+
// explicitly before waiting for the prompt/quiescence barrier to settle.
|
|
265
|
+
requestSessionCancellation("stalled");
|
|
266
|
+
// Surface the transition immediately. Cancellation is cooperative, so the
|
|
267
|
+
// final TaskResult may not arrive until the provider/tool becomes idle.
|
|
268
|
+
fireProgress();
|
|
269
|
+
};
|
|
270
|
+
const armStallWatchdog = (graceMs = 0) => {
|
|
271
|
+
clearStallWatchdog();
|
|
272
|
+
if (!stallTimeoutMs || stalled || signal?.aborted) return;
|
|
273
|
+
|
|
274
|
+
const grace = Number.isFinite(graceMs) && graceMs > 0 ? graceMs : 0;
|
|
275
|
+
const deadline = Date.now() + stallTimeoutMs + grace;
|
|
276
|
+
clearStallDeadline = scheduleDeadline(deadline, abortForStall);
|
|
277
|
+
};
|
|
278
|
+
const noteActivity = (nextPhase: string, graceMs = 0) => {
|
|
279
|
+
lastActivityAt = Date.now();
|
|
280
|
+
phase = nextPhase;
|
|
281
|
+
armStallWatchdog(graceMs);
|
|
282
|
+
fireProgress();
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// AgentSession can replace `session.messages` during compaction. Capture
|
|
286
|
+
// finalized assistant messages from the event stream instead of slicing the
|
|
287
|
+
// mutable transcript after prompt() returns. Keep each low-level attempt as
|
|
288
|
+
// structured data: overflow compaction reports its retry disposition only
|
|
289
|
+
// *after* agent_end, so output cannot be settled permanently at agent_end.
|
|
290
|
+
//
|
|
291
|
+
// The initial array and its prefix are also retained for the narrow fallback
|
|
292
|
+
// below. A Set of initial message objects is not enough: compaction can
|
|
293
|
+
// rebuild historical messages as fresh objects, making them look like new
|
|
294
|
+
// output. New pre-message_end output is still recoverable when the host has
|
|
295
|
+
// appended it to the unchanged transcript.
|
|
296
|
+
const initialMessages = session.messages;
|
|
297
|
+
const initialMessageCount = initialMessages.length;
|
|
298
|
+
const initialMessageSnapshot = initialMessages.slice();
|
|
299
|
+
let transcriptMayHaveBeenReplaced = false;
|
|
300
|
+
const assistantMessagesForAttempt: AgentMessage[] = [];
|
|
301
|
+
let partialAssistantMessage: AgentMessage | undefined;
|
|
302
|
+
type AttemptCapture = {
|
|
303
|
+
eventMessages: AgentMessage[];
|
|
304
|
+
capturedAssistants: AgentMessage[];
|
|
305
|
+
partialAssistant?: AgentMessage;
|
|
306
|
+
/** Set by a retrying agent_end or a later overflow compaction_end. */
|
|
307
|
+
omitFinalAssistant: boolean;
|
|
308
|
+
};
|
|
309
|
+
const settledAttempts: AttemptCapture[] = [];
|
|
310
|
+
let pendingCompactionAttempt: AttemptCapture | undefined;
|
|
311
|
+
|
|
312
|
+
const countAssistants = (messages: readonly AgentMessage[]): number =>
|
|
313
|
+
messages.reduce(
|
|
314
|
+
(count, message) => count + (message.role === "assistant" ? 1 : 0),
|
|
315
|
+
0,
|
|
316
|
+
);
|
|
317
|
+
const removeAssistantAt = (
|
|
318
|
+
messages: readonly AgentMessage[],
|
|
319
|
+
assistantOrdinal: number,
|
|
320
|
+
): AgentMessage[] => {
|
|
321
|
+
if (assistantOrdinal < 0) return [...messages];
|
|
322
|
+
let currentOrdinal = 0;
|
|
323
|
+
for (let index = 0; index < messages.length; index++) {
|
|
324
|
+
if (messages[index]?.role !== "assistant") continue;
|
|
325
|
+
if (currentOrdinal === assistantOrdinal) {
|
|
326
|
+
return messages.filter((_, candidate) => candidate !== index);
|
|
327
|
+
}
|
|
328
|
+
currentOrdinal++;
|
|
329
|
+
}
|
|
330
|
+
return [...messages];
|
|
331
|
+
};
|
|
332
|
+
const renderAttempt = (attempt: AttemptCapture): string => {
|
|
333
|
+
// `agent_end.messages` is authoritative for the low-level attempt. When a
|
|
334
|
+
// retry is requested, remove the final assistant by position. The
|
|
335
|
+
// message_end collection is filtered by the same assistant ordinal rather
|
|
336
|
+
// than object identity: hosts/extensions may clone event payloads.
|
|
337
|
+
const eventAssistantCount = countAssistants(attempt.eventMessages);
|
|
338
|
+
const finalAssistantOrdinal = eventAssistantCount - 1;
|
|
339
|
+
const eventMessages = attempt.omitFinalAssistant
|
|
340
|
+
? removeAssistantAt(attempt.eventMessages, finalAssistantOrdinal)
|
|
341
|
+
: [...attempt.eventMessages];
|
|
342
|
+
const capturedAssistantCount = countAssistants(attempt.capturedAssistants);
|
|
343
|
+
const capturedOrdinal = attempt.omitFinalAssistant
|
|
344
|
+
? finalAssistantOrdinal >= 0
|
|
345
|
+
? finalAssistantOrdinal
|
|
346
|
+
: capturedAssistantCount - 1
|
|
347
|
+
: -1;
|
|
348
|
+
const capturedAssistants = attempt.omitFinalAssistant
|
|
349
|
+
? removeAssistantAt(attempt.capturedAssistants, capturedOrdinal)
|
|
350
|
+
: [...attempt.capturedAssistants];
|
|
351
|
+
const eventText = extractOutput(eventMessages);
|
|
352
|
+
const capturedText = extractOutput(capturedAssistants);
|
|
353
|
+
const attemptTextWithoutPartial = eventText || capturedText;
|
|
354
|
+
const attemptParts = attemptTextWithoutPartial
|
|
355
|
+
? [attemptTextWithoutPartial]
|
|
356
|
+
: [];
|
|
357
|
+
|
|
358
|
+
// A failed stream can contain useful text that never receives a
|
|
359
|
+
// message_end. If this agent run also contains an earlier successful turn,
|
|
360
|
+
// that turn must not win the `||` chain and hide the newer partial output.
|
|
361
|
+
// Do not retain partial text for a response that will be retried, including
|
|
362
|
+
// the overflow case whose retry disposition arrives at compaction_end.
|
|
363
|
+
const partialText =
|
|
364
|
+
attempt.omitFinalAssistant || !attempt.partialAssistant
|
|
365
|
+
? ""
|
|
366
|
+
: extractOutput([attempt.partialAssistant]);
|
|
367
|
+
// Avoid duplicating partial output already represented by the authoritative
|
|
368
|
+
// event selection (including when the host copied the message object).
|
|
369
|
+
if (
|
|
370
|
+
partialText &&
|
|
371
|
+
!attemptParts.some(
|
|
372
|
+
(text) => text === partialText || text.includes(partialText),
|
|
373
|
+
)
|
|
374
|
+
) {
|
|
375
|
+
attemptParts.push(partialText);
|
|
376
|
+
}
|
|
377
|
+
return attemptParts.join("\n\n");
|
|
378
|
+
};
|
|
379
|
+
const rememberPartialAssistant = (message: AgentMessage): void => {
|
|
380
|
+
// AgentSession emits an empty synthetic failure message after a provider
|
|
381
|
+
// throws mid-stream. Do not let that empty message erase useful text from
|
|
382
|
+
// the real, pre-message_end partial response.
|
|
383
|
+
const existingText = partialAssistantMessage
|
|
384
|
+
? extractOutput([partialAssistantMessage])
|
|
385
|
+
: "";
|
|
386
|
+
const nextText = extractOutput([message]);
|
|
387
|
+
if (nextText || !existingText) partialAssistantMessage = message;
|
|
388
|
+
};
|
|
389
|
+
const finishAttempt = (
|
|
390
|
+
messages: readonly AgentMessage[],
|
|
391
|
+
willRetry: boolean,
|
|
392
|
+
): void => {
|
|
393
|
+
const attempt: AttemptCapture = {
|
|
394
|
+
eventMessages: [...messages],
|
|
395
|
+
capturedAssistants: [...assistantMessagesForAttempt],
|
|
396
|
+
partialAssistant: partialAssistantMessage,
|
|
397
|
+
omitFinalAssistant: willRetry,
|
|
398
|
+
};
|
|
399
|
+
settledAttempts.push(attempt);
|
|
400
|
+
pendingCompactionAttempt = attempt;
|
|
401
|
+
assistantMessagesForAttempt.length = 0;
|
|
402
|
+
partialAssistantMessage = undefined;
|
|
403
|
+
};
|
|
404
|
+
const capturedOutput = (): string => {
|
|
405
|
+
const parts = settledAttempts.map(renderAttempt).filter(Boolean);
|
|
406
|
+
const currentAttempt = renderAttempt({
|
|
407
|
+
eventMessages: [],
|
|
408
|
+
capturedAssistants: assistantMessagesForAttempt,
|
|
409
|
+
partialAssistant: partialAssistantMessage,
|
|
410
|
+
omitFinalAssistant: false,
|
|
411
|
+
});
|
|
412
|
+
if (currentAttempt) parts.push(currentAttempt);
|
|
413
|
+
const eventOutput = parts.join("\n\n");
|
|
414
|
+
if (eventOutput) return eventOutput;
|
|
415
|
+
// Once an event boundary has been observed, an empty rendered result is
|
|
416
|
+
// meaningful (for example, a sole retrying response was intentionally
|
|
417
|
+
// removed). Do not let the mutable transcript re-introduce that response.
|
|
418
|
+
if (
|
|
419
|
+
settledAttempts.length > 0 ||
|
|
420
|
+
assistantMessagesForAttempt.length > 0 ||
|
|
421
|
+
partialAssistantMessage
|
|
422
|
+
) {
|
|
423
|
+
return "";
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Keep an append-only fallback for providers/fakes that reject before
|
|
427
|
+
// emitting message_end. It is deliberately used only when event capture
|
|
428
|
+
// is empty; event capture is authoritative across compaction and retries.
|
|
429
|
+
// Requiring the original array and unchanged historical prefix prevents a
|
|
430
|
+
// replacement/compaction transcript from leaking old assistant output.
|
|
431
|
+
const currentMessages = session.messages;
|
|
432
|
+
if (
|
|
433
|
+
transcriptMayHaveBeenReplaced ||
|
|
434
|
+
currentMessages !== initialMessages ||
|
|
435
|
+
currentMessages.length < initialMessageCount
|
|
436
|
+
) {
|
|
437
|
+
return "";
|
|
438
|
+
}
|
|
439
|
+
for (let i = 0; i < initialMessageCount; i++) {
|
|
440
|
+
if (currentMessages[i] !== initialMessageSnapshot[i]) return "";
|
|
441
|
+
}
|
|
442
|
+
return extractOutput(currentMessages.slice(initialMessageCount));
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
// Map the AgentSession event union to the renderer's ToolActivity model.
|
|
446
|
+
// Field names line up 1:1 (toolCallId, toolName, args, partialResult,
|
|
447
|
+
// result, isError) — AgentSession forwards the underlying agent events
|
|
448
|
+
// verbatim. Retry and compaction events are handled below; queue/bookkeeping
|
|
449
|
+
// events and thinking changes are intentionally ignored.
|
|
450
|
+
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
|
|
451
|
+
noteSessionEvent();
|
|
452
|
+
switch (event.type) {
|
|
453
|
+
case "tool_execution_start": {
|
|
454
|
+
const now = Date.now();
|
|
455
|
+
const activity: ToolActivity = {
|
|
456
|
+
id: event.toolCallId,
|
|
457
|
+
name: event.toolName,
|
|
458
|
+
args: event.args,
|
|
459
|
+
startTime: now,
|
|
460
|
+
};
|
|
461
|
+
pendingById.set(event.toolCallId, activity);
|
|
462
|
+
activities.push(activity);
|
|
463
|
+
noteActivity(`executing tool '${event.toolName}'`);
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
case "tool_execution_update": {
|
|
467
|
+
const activity = pendingById.get(event.toolCallId);
|
|
468
|
+
if (activity) {
|
|
469
|
+
const text = extractTextFromPartialResult(event.partialResult);
|
|
470
|
+
if (text !== undefined) activity.liveOutput = text;
|
|
471
|
+
}
|
|
472
|
+
noteActivity(`executing tool '${event.toolName}'`);
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
case "tool_execution_end": {
|
|
476
|
+
const now = Date.now();
|
|
477
|
+
const activity = pendingById.get(event.toolCallId);
|
|
478
|
+
if (activity) {
|
|
479
|
+
activity.result = {
|
|
480
|
+
content: event.result?.content ?? [],
|
|
481
|
+
isError: event.isError,
|
|
482
|
+
};
|
|
483
|
+
activity.endTime = now;
|
|
484
|
+
pendingById.delete(event.toolCallId);
|
|
485
|
+
}
|
|
486
|
+
toolUses++;
|
|
487
|
+
noteActivity("waiting for the next agent turn");
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
case "message_start":
|
|
491
|
+
case "message_update":
|
|
492
|
+
if (event.message?.role === "assistant") {
|
|
493
|
+
rememberPartialAssistant(event.message);
|
|
494
|
+
}
|
|
495
|
+
noteActivity("streaming model output");
|
|
496
|
+
break;
|
|
497
|
+
case "message_end":
|
|
498
|
+
if (event.message.role === "assistant") {
|
|
499
|
+
assistantMessagesForAttempt.push(event.message);
|
|
500
|
+
// Keep a text-bearing partial if the host follows a provider
|
|
501
|
+
// exception with an empty synthetic failure message. A real
|
|
502
|
+
// finalized text response supersedes the partial.
|
|
503
|
+
if (extractOutput([event.message])) {
|
|
504
|
+
partialAssistantMessage = undefined;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
noteActivity("waiting for the next agent turn");
|
|
508
|
+
break;
|
|
509
|
+
case "turn_end":
|
|
510
|
+
noteActivity("waiting for the next agent turn");
|
|
511
|
+
break;
|
|
512
|
+
case "agent_start":
|
|
513
|
+
case "turn_start":
|
|
514
|
+
noteActivity("waiting for model output");
|
|
515
|
+
break;
|
|
516
|
+
case "agent_end":
|
|
517
|
+
finishAttempt(event.messages, event.willRetry);
|
|
518
|
+
noteActivity("finishing agent run");
|
|
519
|
+
break;
|
|
520
|
+
case "agent_settled":
|
|
521
|
+
noteActivity("finishing agent run");
|
|
522
|
+
break;
|
|
523
|
+
case "auto_retry_start":
|
|
524
|
+
// A declared retry delay is intentional silence, not a wedge. Add the
|
|
525
|
+
// delay before ordinary inactivity detection resumes.
|
|
526
|
+
noteActivity("waiting to retry", event.delayMs);
|
|
527
|
+
break;
|
|
528
|
+
case "auto_retry_end":
|
|
529
|
+
noteActivity("waiting for model output");
|
|
530
|
+
break;
|
|
531
|
+
case "compaction_start":
|
|
532
|
+
// Even if a host mutates the transcript in place rather than replacing
|
|
533
|
+
// its array, historical messages are no longer a safe fallback source.
|
|
534
|
+
transcriptMayHaveBeenReplaced = true;
|
|
535
|
+
noteActivity("compacting context");
|
|
536
|
+
break;
|
|
537
|
+
case "compaction_end":
|
|
538
|
+
transcriptMayHaveBeenReplaced = true;
|
|
539
|
+
// Context-overflow agent_end is intentionally emitted with
|
|
540
|
+
// willRetry=false because Pi's retry decision belongs to compaction.
|
|
541
|
+
// Only an overflow compaction that actually retries may retract that
|
|
542
|
+
// preceding response. Failed compaction retains its error evidence;
|
|
543
|
+
// threshold compaction never discards a successful answer.
|
|
544
|
+
if (
|
|
545
|
+
event.reason === "overflow" &&
|
|
546
|
+
event.willRetry &&
|
|
547
|
+
pendingCompactionAttempt
|
|
548
|
+
) {
|
|
549
|
+
pendingCompactionAttempt.omitFinalAssistant = true;
|
|
550
|
+
}
|
|
551
|
+
pendingCompactionAttempt = undefined;
|
|
552
|
+
noteActivity("waiting for model output");
|
|
553
|
+
break;
|
|
554
|
+
default: {
|
|
555
|
+
// Pi 0.83 added summarization-retry and direct-bash progress events.
|
|
556
|
+
// The extension still typechecks against its oldest supported Pi, so
|
|
557
|
+
// recognize this forward-compatible event subset at runtime.
|
|
558
|
+
const compatEvent = event as unknown as {
|
|
559
|
+
type: string;
|
|
560
|
+
delayMs?: unknown;
|
|
561
|
+
};
|
|
562
|
+
if (compatEvent.type === "summarization_retry_scheduled") {
|
|
563
|
+
const delayMs =
|
|
564
|
+
typeof compatEvent.delayMs === "number" ? compatEvent.delayMs : 0;
|
|
565
|
+
noteActivity("waiting to retry summarization", delayMs);
|
|
566
|
+
} else if (
|
|
567
|
+
compatEvent.type === "summarization_retry_attempt_start" ||
|
|
568
|
+
compatEvent.type === "summarization_retry_finished"
|
|
569
|
+
) {
|
|
570
|
+
noteActivity("summarizing context");
|
|
571
|
+
} else if (compatEvent.type === "bash_execution_update") {
|
|
572
|
+
noteActivity("running bash command");
|
|
573
|
+
}
|
|
574
|
+
// queue_update, entry_appended, session_info_changed, and thinking
|
|
575
|
+
// changes are local bookkeeping, not evidence a blocked operation lives.
|
|
576
|
+
break;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
// Wire the parent abort signal to the session. AgentSession.abort() cancels
|
|
582
|
+
// the in-flight model call, any retry backoff, and waits for idle.
|
|
583
|
+
let abortHandler: (() => void) | undefined;
|
|
584
|
+
if (signal) {
|
|
585
|
+
abortHandler = () => {
|
|
586
|
+
clearStallWatchdog();
|
|
587
|
+
// Fire-and-forget: prompt()/the quiescence barrier observe cancellation
|
|
588
|
+
// and then return the partial evidence that this runner reports.
|
|
589
|
+
requestSessionCancellation("parent-aborted");
|
|
590
|
+
};
|
|
591
|
+
signal.addEventListener("abort", abortHandler, { once: true });
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// The signal may have fired *between* lifecycle's pre-acquire abort check
|
|
595
|
+
// and this listener registration (e.g. during getHostDeps/createAgentSession/
|
|
596
|
+
// git baseline). addEventListener("abort", …, { once }) does NOT fire for an
|
|
597
|
+
// already-aborted signal, so without this re-check a cancelled async ticket
|
|
598
|
+
// can still start a subagent that writes files and gets pooled.
|
|
599
|
+
if (signal?.aborted) {
|
|
600
|
+
abortHandler?.();
|
|
601
|
+
// The early return skips the try/finally below, so clean up the
|
|
602
|
+
// subscription and abort listener here — otherwise they leak on every
|
|
603
|
+
// already-aborted call, which is especially harmful for pooled sessions
|
|
604
|
+
// whose subscription would outlive the task.
|
|
605
|
+
if (signal && abortHandler)
|
|
606
|
+
signal.removeEventListener("abort", abortHandler);
|
|
607
|
+
unsubscribe();
|
|
608
|
+
return {
|
|
609
|
+
output: "",
|
|
610
|
+
error: "Aborted",
|
|
611
|
+
durationMs: Date.now() - startTime,
|
|
612
|
+
tokens: 0,
|
|
613
|
+
usage: emptyUsage(),
|
|
614
|
+
touchedFiles: [],
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Start detection only once the session is ready to receive its prompt;
|
|
619
|
+
// queued delegate tasks never enter this runner and therefore never time out.
|
|
620
|
+
armStallWatchdog();
|
|
621
|
+
fireProgress();
|
|
622
|
+
|
|
623
|
+
try {
|
|
624
|
+
await session.prompt(prompt);
|
|
625
|
+
await waitForSessionQuiescence();
|
|
626
|
+
clearStallWatchdog();
|
|
627
|
+
|
|
628
|
+
const state = session.state as { errorMessage?: string };
|
|
629
|
+
const output = capturedOutput();
|
|
630
|
+
const usage = currentUsage();
|
|
631
|
+
|
|
632
|
+
// Compute touched files: union of activity-based (edit/write) and git diff
|
|
633
|
+
// against the pre-prompt baseline. Independent of the runner's event model.
|
|
634
|
+
const fromActivities = extractTouchedFromActivities(activities, config.cwd);
|
|
635
|
+
const gitAfter = await getGitChangedFiles(config.cwd);
|
|
636
|
+
const fromGit = [...gitAfter].filter((f) => !gitBaseline.has(f));
|
|
637
|
+
const touchedFiles = [...new Set([...fromActivities, ...fromGit])];
|
|
638
|
+
const errorMessage = stalled
|
|
639
|
+
? stallError()
|
|
640
|
+
: signal?.aborted
|
|
641
|
+
? "Aborted"
|
|
642
|
+
: state.errorMessage;
|
|
643
|
+
|
|
644
|
+
return {
|
|
645
|
+
output: output || "(no output)",
|
|
646
|
+
error: errorMessage,
|
|
647
|
+
durationMs: Date.now() - startTime,
|
|
648
|
+
tokens: usage.totalTokens,
|
|
649
|
+
usage,
|
|
650
|
+
touchedFiles,
|
|
651
|
+
failureKind: stalled ? "stalled" : undefined,
|
|
652
|
+
};
|
|
653
|
+
} catch (err) {
|
|
654
|
+
// Preserve partial-work evidence: whatever assistant output, token spend,
|
|
655
|
+
// and touched files accumulated before the failure/abort.
|
|
656
|
+
const partialOutput = capturedOutput();
|
|
657
|
+
const usage = currentUsage();
|
|
658
|
+
|
|
659
|
+
const fromActivities = extractTouchedFromActivities(activities, config.cwd);
|
|
660
|
+
const gitAfter = await getGitChangedFiles(config.cwd);
|
|
661
|
+
const fromGit = [...gitAfter].filter((f) => !gitBaseline.has(f));
|
|
662
|
+
const touchedFiles = [...new Set([...fromActivities, ...fromGit])];
|
|
663
|
+
|
|
664
|
+
const msg = stalled
|
|
665
|
+
? stallError()
|
|
666
|
+
: signal?.aborted
|
|
667
|
+
? "Aborted"
|
|
668
|
+
: err instanceof Error
|
|
669
|
+
? err.message
|
|
670
|
+
: String(err);
|
|
671
|
+
return {
|
|
672
|
+
output: partialOutput || "(no output)",
|
|
673
|
+
error: msg,
|
|
674
|
+
durationMs: Date.now() - startTime,
|
|
675
|
+
tokens: usage.totalTokens,
|
|
676
|
+
usage,
|
|
677
|
+
touchedFiles,
|
|
678
|
+
failureKind: stalled ? "stalled" : undefined,
|
|
679
|
+
};
|
|
680
|
+
} finally {
|
|
681
|
+
clearStallWatchdog();
|
|
682
|
+
if (signal && abortHandler)
|
|
683
|
+
signal.removeEventListener("abort", abortHandler);
|
|
684
|
+
unsubscribe();
|
|
685
|
+
}
|
|
686
|
+
}
|