@bermudi/pi-delegate 0.1.1 → 0.1.3
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 +3 -2
- package/agents.ts +163 -17
- package/concurrency.ts +90 -10
- package/config.ts +61 -0
- package/delegate.ts +14 -0
- package/dispatch.ts +126 -21
- package/extension.ts +309 -62
- package/file-tracking.ts +27 -5
- package/format.ts +46 -7
- package/leaf.ts +48 -0
- package/lifecycle.ts +129 -35
- package/manual.ts +38 -10
- package/package.json +4 -1
- package/patches/@marcfargas%2Fpi-test-harness@0.6.1.patch +13 -0
- package/render-branches.ts +31 -13
- package/render-result.ts +12 -0
- package/runner.ts +237 -42
- package/schema.ts +204 -47
- package/status.ts +68 -2
- package/task-resolution.ts +101 -65
- package/telemetry.ts +738 -0
- package/tickets.ts +196 -61
- package/tools.ts +16 -15
- package/types.ts +71 -13
- package/usage.ts +19 -0
package/tickets.ts
CHANGED
|
@@ -11,22 +11,31 @@ import {
|
|
|
11
11
|
fmtDuration,
|
|
12
12
|
fmtTokens,
|
|
13
13
|
formatCompletedTask,
|
|
14
|
+
formatTaskId,
|
|
14
15
|
shortenPath,
|
|
15
16
|
trunc,
|
|
16
17
|
getActivityAge,
|
|
17
18
|
formatActivityLabel,
|
|
18
19
|
taskMetaBase,
|
|
19
20
|
relativeTouchedSummary,
|
|
21
|
+
findTouchedOverlaps,
|
|
22
|
+
formatTouchedOverlapWarning,
|
|
20
23
|
} from "./format.ts";
|
|
24
|
+
import { isCrossLeafTicket } from "./leaf.ts";
|
|
21
25
|
import { renderOutputForPoll } from "./spill.ts";
|
|
22
26
|
import { scheduleDeadline } from "./timer.ts";
|
|
27
|
+
import { aggregateTaskResults, emptyUsage } from "./usage.ts";
|
|
28
|
+
import { recordCall } from "./telemetry.ts";
|
|
23
29
|
import type {
|
|
24
30
|
AsyncTicket,
|
|
25
31
|
DelegateDetails,
|
|
32
|
+
ResolvedTask,
|
|
26
33
|
TaskResult,
|
|
27
34
|
TicketWaiter,
|
|
28
35
|
} from "./types.ts";
|
|
29
36
|
|
|
37
|
+
const PENDING_RESULT_ERROR = "PENDING — result not available";
|
|
38
|
+
|
|
30
39
|
const busyTicketIdsBySession = new Map<string, Set<string>>();
|
|
31
40
|
const busySessionsByTicket = new Map<string, Set<string>>();
|
|
32
41
|
|
|
@@ -117,6 +126,30 @@ export function cancelTicketForShutdown(ticket: AsyncTicket): void {
|
|
|
117
126
|
ticket.completedAt = Date.now();
|
|
118
127
|
syncTicketBusyIndex(ticket);
|
|
119
128
|
settleTicketWaiters(ticket);
|
|
129
|
+
if (ticket.callRecord) {
|
|
130
|
+
const { totalTokens, totalCost } = aggregateTaskResults(ticket.results);
|
|
131
|
+
recordCall(
|
|
132
|
+
{
|
|
133
|
+
...ticket.callRecord,
|
|
134
|
+
status: "cancelled",
|
|
135
|
+
wall_ms: ticket.completedAt - (ticket.callStartedAt ?? ticket.created),
|
|
136
|
+
total_tokens: totalTokens,
|
|
137
|
+
total_cost: totalCost,
|
|
138
|
+
},
|
|
139
|
+
ticket.telemetryGeneration,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Request cooperative cancellation of a live ticket: abort the workers and
|
|
145
|
+
* move to "cancelling" so they settle and report what actually ran. Unlike
|
|
146
|
+
* `cancelTicketForShutdown` this leaves the ticket deliverable — the runtime
|
|
147
|
+
* is still alive, so the final "cancelled" result still reaches the user. */
|
|
148
|
+
export function requestTicketCancel(ticket: AsyncTicket): void {
|
|
149
|
+
if (ticket.status !== "running") return;
|
|
150
|
+
ticket.controller.abort();
|
|
151
|
+
ticket.status = "cancelling";
|
|
152
|
+
syncTicketBusyIndex(ticket);
|
|
120
153
|
}
|
|
121
154
|
|
|
122
155
|
/** Check if any running async ticket holds a given sessionId.
|
|
@@ -187,13 +220,23 @@ export function formatCompletedTicket(
|
|
|
187
220
|
const r = ticket.results[i];
|
|
188
221
|
const t = ticket.resolved[i]!;
|
|
189
222
|
if (!r) {
|
|
190
|
-
parts.push(
|
|
223
|
+
parts.push(
|
|
224
|
+
`=== ${t.agentName}${formatTaskId(t.id)}: ${trunc(t.prompt || "", 80)} ===`,
|
|
225
|
+
);
|
|
191
226
|
parts.push(`[${pendingLabelFor(i)}]`);
|
|
192
227
|
continue;
|
|
193
228
|
}
|
|
194
229
|
parts.push(...formatCompletedTask(t, r));
|
|
195
230
|
}
|
|
196
231
|
|
|
232
|
+
const completedResults = ticket.results.filter(
|
|
233
|
+
(r): r is TaskResult => r !== undefined && "touchedFiles" in r,
|
|
234
|
+
);
|
|
235
|
+
const overlapWarning = formatTouchedOverlapWarning(
|
|
236
|
+
findTouchedOverlaps(completedResults),
|
|
237
|
+
);
|
|
238
|
+
if (overlapWarning) parts.push("", overlapWarning);
|
|
239
|
+
|
|
197
240
|
if (ticket.status === "cancelled") {
|
|
198
241
|
parts.push(
|
|
199
242
|
"",
|
|
@@ -206,7 +249,11 @@ export function formatCompletedTicket(
|
|
|
206
249
|
details: {
|
|
207
250
|
tasks: ticket.tasks,
|
|
208
251
|
results: [...ticket.results].map(
|
|
209
|
-
(r, index) =>
|
|
252
|
+
(r, index) =>
|
|
253
|
+
r ?? {
|
|
254
|
+
...pendingResultPlaceholder(ticket.resolved[index]),
|
|
255
|
+
error: pendingLabelFor(index),
|
|
256
|
+
},
|
|
210
257
|
),
|
|
211
258
|
progress: [...ticket.progress],
|
|
212
259
|
parentModel: ticket.parentModelId,
|
|
@@ -214,22 +261,49 @@ export function formatCompletedTicket(
|
|
|
214
261
|
// the human sees which ticket they polled, even in the rich tree path.
|
|
215
262
|
ticketId: ticket.id,
|
|
216
263
|
status: ticket.status,
|
|
264
|
+
overlapWarning: overlapWarning || undefined,
|
|
217
265
|
},
|
|
218
266
|
};
|
|
219
267
|
}
|
|
220
268
|
|
|
221
269
|
// ── Waiter helpers ─────────────────────────────────────────────────────────
|
|
222
270
|
|
|
271
|
+
function pendingResultPlaceholder(task: ResolvedTask | undefined): TaskResult {
|
|
272
|
+
return {
|
|
273
|
+
id: task?.id,
|
|
274
|
+
agent: task?.agentName ?? "unknown",
|
|
275
|
+
output: "",
|
|
276
|
+
durationMs: 0,
|
|
277
|
+
tokens: 0,
|
|
278
|
+
usage: emptyUsage(),
|
|
279
|
+
touchedFiles: [],
|
|
280
|
+
attributedFiles: [],
|
|
281
|
+
// Machine-visible and human-readable marker so structured consumers do not
|
|
282
|
+
// mistake a pending placeholder for a successful result.
|
|
283
|
+
error: PENDING_RESULT_ERROR,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
223
287
|
function buildWaitDetails(ticket: AsyncTicket): DelegateDetails {
|
|
288
|
+
const completedResults = ticket.results.filter(
|
|
289
|
+
(r): r is TaskResult => r !== undefined && "touchedFiles" in r,
|
|
290
|
+
);
|
|
291
|
+
const overlapWarning = formatTouchedOverlapWarning(
|
|
292
|
+
findTouchedOverlaps(completedResults),
|
|
293
|
+
);
|
|
294
|
+
const results: TaskResult[] = [];
|
|
295
|
+
for (let i = 0; i < ticket.resolved.length; i++) {
|
|
296
|
+
const r = ticket.results[i];
|
|
297
|
+
results.push(r ?? pendingResultPlaceholder(ticket.resolved[i]));
|
|
298
|
+
}
|
|
224
299
|
return {
|
|
225
300
|
tasks: ticket.tasks,
|
|
226
|
-
results
|
|
227
|
-
(r) => r ?? { error: "PENDING — result not available" },
|
|
228
|
-
),
|
|
301
|
+
results,
|
|
229
302
|
progress: [...ticket.progress],
|
|
230
303
|
parentModel: ticket.parentModelId,
|
|
231
304
|
ticketId: ticket.id,
|
|
232
305
|
status: ticket.status,
|
|
306
|
+
overlapWarning: overlapWarning || undefined,
|
|
233
307
|
};
|
|
234
308
|
}
|
|
235
309
|
|
|
@@ -251,9 +325,13 @@ function buildWaitRunningUpdate(
|
|
|
251
325
|
if (failed > 0) parts.push(`${failed} failed`);
|
|
252
326
|
if (pending > 0) parts.push(`${pending} queued`);
|
|
253
327
|
|
|
328
|
+
const details = buildWaitDetails(ticket);
|
|
329
|
+
const text =
|
|
330
|
+
parts.join(" · ") +
|
|
331
|
+
(details.overlapWarning ? `\n\n${details.overlapWarning}` : "");
|
|
254
332
|
return {
|
|
255
|
-
content: [{ type: "text", text
|
|
256
|
-
details
|
|
333
|
+
content: [{ type: "text", text }],
|
|
334
|
+
details,
|
|
257
335
|
};
|
|
258
336
|
}
|
|
259
337
|
|
|
@@ -261,28 +339,38 @@ function buildWaitTimeoutResult(
|
|
|
261
339
|
ticket: AsyncTicket,
|
|
262
340
|
timeoutMs: number,
|
|
263
341
|
): AgentToolResult<DelegateDetails> {
|
|
342
|
+
// A timeout must not be an information cliff. Reuse the same rich snapshot
|
|
343
|
+
// as poll so the caller can see activity and consume any completed outputs
|
|
344
|
+
// without making a second tool call.
|
|
345
|
+
const snapshot = handlePoll({ ticket: ticket.id }, {} as ExtensionContext);
|
|
346
|
+
const snapshotText = snapshot.content
|
|
347
|
+
.filter((item) => item.type === "text")
|
|
348
|
+
.map((item) => item.text)
|
|
349
|
+
.join("\n");
|
|
350
|
+
const base = `Ticket ${ticket.id} still ${ticket.status} after ${fmtDuration(timeoutMs)} · wait timed out (ticket continues in background)`;
|
|
351
|
+
const guidance =
|
|
352
|
+
"If you need the final result in this turn, call wait once with timeoutMs omitted; do not poll after a timeout. Otherwise stop calling ticket controls and let the final result auto-deliver.";
|
|
264
353
|
return {
|
|
265
354
|
content: [
|
|
266
355
|
{
|
|
267
356
|
type: "text",
|
|
268
|
-
text:
|
|
357
|
+
text: `${base}\n\n${snapshotText}\n\n${guidance}`,
|
|
269
358
|
},
|
|
270
359
|
],
|
|
271
|
-
details:
|
|
360
|
+
details: snapshot.details,
|
|
272
361
|
};
|
|
273
362
|
}
|
|
274
363
|
|
|
275
364
|
function buildWaitAbortResult(
|
|
276
365
|
ticket: AsyncTicket,
|
|
277
366
|
): AgentToolResult<DelegateDetails> {
|
|
367
|
+
const details = buildWaitDetails(ticket);
|
|
368
|
+
const base = `Wait for ticket ${ticket.id} aborted · ticket continues ${ticket.status} in the background`;
|
|
369
|
+
const text =
|
|
370
|
+
base + (details.overlapWarning ? `\n\n${details.overlapWarning}` : "");
|
|
278
371
|
return {
|
|
279
|
-
content: [
|
|
280
|
-
|
|
281
|
-
type: "text",
|
|
282
|
-
text: `Wait for ticket ${ticket.id} aborted · ticket continues ${ticket.status} in the background`,
|
|
283
|
-
},
|
|
284
|
-
],
|
|
285
|
-
details: buildWaitDetails(ticket),
|
|
372
|
+
content: [{ type: "text", text }],
|
|
373
|
+
details,
|
|
286
374
|
};
|
|
287
375
|
}
|
|
288
376
|
|
|
@@ -366,23 +454,57 @@ export function notifyWaiters(ticket: AsyncTicket): void {
|
|
|
366
454
|
continue;
|
|
367
455
|
}
|
|
368
456
|
active.push(w);
|
|
369
|
-
if (w.onUpdate)
|
|
457
|
+
if (w.onUpdate) {
|
|
458
|
+
try {
|
|
459
|
+
w.onUpdate(buildWaitRunningUpdate(ticket));
|
|
460
|
+
} catch (error) {
|
|
461
|
+
// Progress delivery is an observer boundary. A host callback must not
|
|
462
|
+
// be able to fail the worker that reported the update or reject the
|
|
463
|
+
// wait promise; keep the waiter attached for the terminal result and
|
|
464
|
+
// leave the failure visible for diagnosis.
|
|
465
|
+
console.error(
|
|
466
|
+
`[delegate] wait progress callback for ticket '${ticket.id}' threw; continuing`,
|
|
467
|
+
error,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
370
471
|
}
|
|
371
472
|
ticket.waiters = active.length ? active : undefined;
|
|
372
473
|
}
|
|
373
474
|
|
|
475
|
+
/** Prefix for a result whose spawn leaf is no longer the active one. The model
|
|
476
|
+
* would otherwise read a foreign branch's work as current-turn context. */
|
|
477
|
+
const CROSS_LEAF_NOTICE =
|
|
478
|
+
"NOTE: this async delegate ticket was spawned on a different branch of the " +
|
|
479
|
+
"session tree; the conversation has since navigated elsewhere (/tree). These " +
|
|
480
|
+
"results may not relate to the current line of work — verify relevance before " +
|
|
481
|
+
"acting on them.";
|
|
482
|
+
|
|
483
|
+
/** How a completed ticket was handed back. `deferred` means the result was
|
|
484
|
+
* queued without waking the agent because the session navigated away from
|
|
485
|
+
* the spawn leaf; callers surface that to the human (see status.ts). */
|
|
486
|
+
export type TicketDelivery = "none" | "waiters" | "steer" | "deferred";
|
|
487
|
+
|
|
374
488
|
/** Push results into parent session via sendMessage when background ticket completes.
|
|
375
489
|
* If there are active blocking waiters, resolve them directly and suppress the
|
|
376
|
-
* automatic follow-up so completion is delivered exactly once.
|
|
490
|
+
* automatic follow-up so completion is delivered exactly once.
|
|
491
|
+
*
|
|
492
|
+
* Delivery mode depends on leaf affinity (see leaf.ts). Same leaf: `steer` +
|
|
493
|
+
* `triggerTurn`, the agent picks the result up immediately. Different leaf:
|
|
494
|
+
* `nextTurn`, which explicitly "does not interrupt or trigger anything" — the
|
|
495
|
+
* result waits for the human's next prompt instead of waking the agent on a
|
|
496
|
+
* branch the task was never part of. The ticket stays pollable either way. */
|
|
377
497
|
export function deliverTicketResults(
|
|
378
498
|
pi: ExtensionAPI,
|
|
379
499
|
ticket: AsyncTicket,
|
|
380
|
-
):
|
|
381
|
-
if (!ticket.completedAt) return;
|
|
500
|
+
): TicketDelivery {
|
|
501
|
+
if (!ticket.completedAt) return "none";
|
|
382
502
|
|
|
383
503
|
// Resolve active blocking waiters directly. Stale/aborted waiters are
|
|
384
504
|
// cleaned but not resolved here (their abort handlers already returned).
|
|
385
|
-
|
|
505
|
+
// A waiter is a tool call on the live leaf by construction, so leaf
|
|
506
|
+
// affinity does not apply to it.
|
|
507
|
+
if (settleTicketWaiters(ticket)) return "waiters";
|
|
386
508
|
|
|
387
509
|
const formatted = formatCompletedTicket(ticket);
|
|
388
510
|
const text = formatted.content
|
|
@@ -393,10 +515,12 @@ export function deliverTicketResults(
|
|
|
393
515
|
.map((c) => c.text)
|
|
394
516
|
.join("\n");
|
|
395
517
|
|
|
518
|
+
const crossLeaf = isCrossLeafTicket(ticket);
|
|
519
|
+
|
|
396
520
|
pi.sendMessage(
|
|
397
521
|
{
|
|
398
522
|
customType: "async_delegate_result",
|
|
399
|
-
content: text,
|
|
523
|
+
content: crossLeaf ? `${CROSS_LEAF_NOTICE}\n\n${text}` : text,
|
|
400
524
|
display: true,
|
|
401
525
|
details: {
|
|
402
526
|
...formatted.details,
|
|
@@ -404,11 +528,11 @@ export function deliverTicketResults(
|
|
|
404
528
|
status: ticket.status,
|
|
405
529
|
},
|
|
406
530
|
},
|
|
407
|
-
|
|
408
|
-
deliverAs: "
|
|
409
|
-
triggerTurn: true,
|
|
410
|
-
},
|
|
531
|
+
crossLeaf
|
|
532
|
+
? { deliverAs: "nextTurn" }
|
|
533
|
+
: { deliverAs: "steer", triggerTurn: true },
|
|
411
534
|
);
|
|
535
|
+
return crossLeaf ? "deferred" : "steer";
|
|
412
536
|
}
|
|
413
537
|
|
|
414
538
|
/** Return a snapshot of one async ticket or the complete ticket roster. */
|
|
@@ -434,7 +558,7 @@ export function handlePoll(
|
|
|
434
558
|
"No async tickets.",
|
|
435
559
|
"",
|
|
436
560
|
"To spawn a subagent: delegate({ tasks: [{ agent, prompt }] }).",
|
|
437
|
-
"For the full manual and agent list, call delegate({ tasks: [] }) with no top-level `
|
|
561
|
+
"For the full manual and agent list, call delegate({ tasks: [] }) with no top-level `ticketAction`.",
|
|
438
562
|
].join("\n"),
|
|
439
563
|
},
|
|
440
564
|
],
|
|
@@ -467,9 +591,9 @@ export function handlePoll(
|
|
|
467
591
|
// Copy-pasteable controls for running/cancelling tickets — a human can grab these
|
|
468
592
|
// straight out of the TUI without retyping the ticket id.
|
|
469
593
|
if (t.status === "running" || t.status === "cancelling") {
|
|
470
|
-
line += `\n poll: delegate({
|
|
594
|
+
line += `\n poll: delegate({ ticketAction: "poll", ticket: "${t.id}" })`;
|
|
471
595
|
if (t.status === "running") {
|
|
472
|
-
line += `\n cancel: delegate({
|
|
596
|
+
line += `\n cancel: delegate({ ticketAction: "cancel", ticket: "${t.id}", force: true })`;
|
|
473
597
|
}
|
|
474
598
|
}
|
|
475
599
|
return line;
|
|
@@ -537,9 +661,9 @@ export function handlePoll(
|
|
|
537
661
|
if (r.touchedFiles.length > 0) {
|
|
538
662
|
const t = ticket.resolved[i]!;
|
|
539
663
|
const touched = relativeTouchedSummary(r.touchedFiles, t.cwd);
|
|
540
|
-
if (touched) meta.push(`touched: ${touched}`);
|
|
664
|
+
if (touched) meta.push(`touched (best-effort): ${touched}`);
|
|
541
665
|
}
|
|
542
|
-
lines.push(`✓ ${r.agent} · ${meta.join(" · ")}`);
|
|
666
|
+
lines.push(`✓ ${r.agent}${formatTaskId(r.id)} · ${meta.join(" · ")}`);
|
|
543
667
|
if (r.output && r.output !== "(no output)") {
|
|
544
668
|
lines.push(renderOutputForPoll(r.output));
|
|
545
669
|
}
|
|
@@ -549,10 +673,12 @@ export function handlePoll(
|
|
|
549
673
|
if (r.touchedFiles.length > 0) {
|
|
550
674
|
const t = ticket.resolved[i]!;
|
|
551
675
|
const touched = relativeTouchedSummary(r.touchedFiles, t.cwd);
|
|
552
|
-
if (touched) meta.push(`touched: ${touched}`);
|
|
676
|
+
if (touched) meta.push(`touched (best-effort): ${touched}`);
|
|
553
677
|
}
|
|
554
678
|
const errorText = r.error ?? "unknown error";
|
|
555
|
-
lines.push(
|
|
679
|
+
lines.push(
|
|
680
|
+
`✗ ${r.agent}${formatTaskId(r.id)} · ${errorText} · ${meta.join(" · ")}`,
|
|
681
|
+
);
|
|
556
682
|
if (r.sessionFile)
|
|
557
683
|
lines.push(` session: ${shortenPath(r.sessionFile)}`);
|
|
558
684
|
if (r.output && r.output !== "(no output)")
|
|
@@ -565,12 +691,19 @@ export function handlePoll(
|
|
|
565
691
|
if (p.tokens > 0) parts.push(`${fmtTokens(p.tokens)} tokens`);
|
|
566
692
|
const age = getActivityAge(p.lastActivityAt);
|
|
567
693
|
if (age) parts.push(age);
|
|
568
|
-
lines.push(`⏳ ${p.agent} · ${parts.join(" · ")}`);
|
|
694
|
+
lines.push(`⏳ ${p.agent}${formatTaskId(p.id)} · ${parts.join(" · ")}`);
|
|
569
695
|
} else {
|
|
570
|
-
lines.push(`○ ${p.agent} · waiting…`);
|
|
696
|
+
lines.push(`○ ${p.agent}${formatTaskId(p.id)} · waiting…`);
|
|
571
697
|
}
|
|
572
698
|
}
|
|
573
699
|
|
|
700
|
+
const completedForOverlap = completedResults.filter(
|
|
701
|
+
(r): r is TaskResult => r !== undefined,
|
|
702
|
+
);
|
|
703
|
+
const overlapWarning = formatTouchedOverlapWarning(
|
|
704
|
+
findTouchedOverlaps(completedForOverlap),
|
|
705
|
+
);
|
|
706
|
+
|
|
574
707
|
const headerStatus =
|
|
575
708
|
ticket.status === "cancelling" ? "CANCELLING" : "RUNNING";
|
|
576
709
|
const headerParts: string[] = [
|
|
@@ -586,24 +719,22 @@ export function handlePoll(
|
|
|
586
719
|
const header = headerParts.join(" · ");
|
|
587
720
|
const guidance =
|
|
588
721
|
ticket.status === "cancelling"
|
|
589
|
-
? "Cancellation requested. Active subagents are aborting and returning partial results
|
|
722
|
+
? "Cancellation requested. Active subagents are aborting and returning partial results. Wait without timeoutMs for final status; do not repeatedly poll."
|
|
590
723
|
: settledCount === totalCount
|
|
591
724
|
? ""
|
|
592
|
-
:
|
|
593
|
-
? "Tasks are progressing. Do other work while remaining tasks finish — results will be delivered automatically when all complete."
|
|
594
|
-
: "Tasks are still running. Do other work while you wait — polling again immediately will not speed them up. Results are delivered automatically when all tasks complete.";
|
|
725
|
+
: "If you need the final result in this turn, call wait once with timeoutMs omitted. Otherwise stop calling ticket controls and let the final result auto-deliver after this turn; repeated polling will not speed it up.";
|
|
595
726
|
|
|
596
727
|
return {
|
|
597
728
|
content: [
|
|
598
729
|
{
|
|
599
730
|
type: "text",
|
|
600
|
-
text: `${header}\n${lines.join("\n")}${guidance ? `\n\n${guidance}` : ""}`,
|
|
731
|
+
text: `${header}\n${lines.join("\n")}${guidance ? `\n\n${guidance}` : ""}${overlapWarning ? `\n\n${overlapWarning}` : ""}`,
|
|
601
732
|
},
|
|
602
733
|
],
|
|
603
734
|
details: {
|
|
604
735
|
tasks: ticket.tasks,
|
|
605
736
|
results: completedResults.map(
|
|
606
|
-
(r) => r ??
|
|
737
|
+
(r, i) => r ?? pendingResultPlaceholder(ticket.resolved[i]),
|
|
607
738
|
),
|
|
608
739
|
progress: [...ticket.progress],
|
|
609
740
|
parentModel: ticket.parentModelId,
|
|
@@ -611,6 +742,7 @@ export function handlePoll(
|
|
|
611
742
|
// (friction #2). The LLM-facing content still names the ticket id too.
|
|
612
743
|
ticketId: ticket.id,
|
|
613
744
|
status: ticket.status,
|
|
745
|
+
overlapWarning: overlapWarning || undefined,
|
|
614
746
|
},
|
|
615
747
|
};
|
|
616
748
|
}
|
|
@@ -633,9 +765,9 @@ function buildCancelPreview(ticket: AsyncTicket): string {
|
|
|
633
765
|
for (let i = 0; i < ticket.progress.length; i++) {
|
|
634
766
|
const p = ticket.progress[i]!;
|
|
635
767
|
if (p.status === "done") {
|
|
636
|
-
lines.push(`✓ ${p.agent} · completed`);
|
|
768
|
+
lines.push(`✓ ${p.agent}${formatTaskId(p.id)} · completed`);
|
|
637
769
|
} else if (p.status === "failed") {
|
|
638
|
-
lines.push(`✗ ${p.agent} · ${p.error ?? "failed"}`);
|
|
770
|
+
lines.push(`✗ ${p.agent}${formatTaskId(p.id)} · ${p.error ?? "failed"}`);
|
|
639
771
|
} else if (p.status === "running") {
|
|
640
772
|
const parts: string[] = [formatActivityLabel(p)];
|
|
641
773
|
if (p.toolUses > 0)
|
|
@@ -643,16 +775,16 @@ function buildCancelPreview(ticket: AsyncTicket): string {
|
|
|
643
775
|
if (p.tokens > 0) parts.push(`${fmtTokens(p.tokens)} tokens`);
|
|
644
776
|
const age = getActivityAge(p.lastActivityAt);
|
|
645
777
|
if (age) parts.push(age);
|
|
646
|
-
lines.push(`⏳ ${p.agent} · ${parts.join(" · ")}`);
|
|
778
|
+
lines.push(`⏳ ${p.agent}${formatTaskId(p.id)} · ${parts.join(" · ")}`);
|
|
647
779
|
} else {
|
|
648
|
-
lines.push(`○ ${p.agent} · waiting…`);
|
|
780
|
+
lines.push(`○ ${p.agent}${formatTaskId(p.id)} · waiting…`);
|
|
649
781
|
}
|
|
650
782
|
}
|
|
651
783
|
|
|
652
784
|
lines.push(
|
|
653
785
|
"",
|
|
654
786
|
"WARNING: Cancelling now will abort active subagents. Files already written or shell commands already executed are NOT rolled back.",
|
|
655
|
-
`To proceed, call delegate({
|
|
787
|
+
`To proceed, call delegate({ ticketAction: "cancel", ticket: "${ticket.id}", force: true }).`,
|
|
656
788
|
);
|
|
657
789
|
return lines.join("\n");
|
|
658
790
|
}
|
|
@@ -668,7 +800,7 @@ export function handleCancel(params: {
|
|
|
668
800
|
if (!ticketId) {
|
|
669
801
|
return {
|
|
670
802
|
content: [
|
|
671
|
-
{ type: "text", text: "
|
|
803
|
+
{ type: "text", text: "ticketAction='cancel' requires a ticket ID." },
|
|
672
804
|
],
|
|
673
805
|
details: { tasks: [], results: [], progress: [] },
|
|
674
806
|
};
|
|
@@ -692,29 +824,30 @@ export function handleCancel(params: {
|
|
|
692
824
|
};
|
|
693
825
|
}
|
|
694
826
|
if (!params.force) {
|
|
827
|
+
const details = buildWaitDetails(ticket);
|
|
828
|
+
const text =
|
|
829
|
+
buildCancelPreview(ticket) +
|
|
830
|
+
(details.overlapWarning ? `\n\n${details.overlapWarning}` : "");
|
|
695
831
|
return {
|
|
696
|
-
content: [{ type: "text", text
|
|
697
|
-
details
|
|
832
|
+
content: [{ type: "text", text }],
|
|
833
|
+
details,
|
|
698
834
|
};
|
|
699
835
|
}
|
|
700
|
-
ticket
|
|
701
|
-
|
|
702
|
-
|
|
836
|
+
requestTicketCancel(ticket);
|
|
837
|
+
const details = buildWaitDetails(ticket);
|
|
838
|
+
const base = `Ticket '${ticketId}' is cancelling; workers are settling. Poll for final status.`;
|
|
839
|
+
const text =
|
|
840
|
+
base + (details.overlapWarning ? `\n\n${details.overlapWarning}` : "");
|
|
703
841
|
return {
|
|
704
|
-
content: [
|
|
705
|
-
|
|
706
|
-
type: "text",
|
|
707
|
-
text: `Ticket '${ticketId}' is cancelling; workers are settling. Poll for final status.`,
|
|
708
|
-
},
|
|
709
|
-
],
|
|
710
|
-
details: buildWaitDetails(ticket),
|
|
842
|
+
content: [{ type: "text", text }],
|
|
843
|
+
details,
|
|
711
844
|
};
|
|
712
845
|
}
|
|
713
846
|
|
|
714
847
|
/** Block until a ticket reaches a terminal state or `timeoutMs` expires.
|
|
715
848
|
* Progress is streamed through `onUpdate` without consuming model turns.
|
|
716
849
|
* Parent-tool abort or timeout detaches the waiter and leaves the ticket
|
|
717
|
-
* running; cancellation remains explicit (`
|
|
850
|
+
* running; cancellation remains explicit (`ticketAction: "cancel"`). */
|
|
718
851
|
export function handleWait(
|
|
719
852
|
params: { ticket?: string; timeoutMs?: number },
|
|
720
853
|
signal: AbortSignal | undefined,
|
|
@@ -727,7 +860,9 @@ export function handleWait(
|
|
|
727
860
|
const ticketId = params.ticket;
|
|
728
861
|
if (!ticketId) {
|
|
729
862
|
return Promise.resolve({
|
|
730
|
-
content: [
|
|
863
|
+
content: [
|
|
864
|
+
{ type: "text", text: "ticketAction='wait' requires a ticket ID." },
|
|
865
|
+
],
|
|
731
866
|
details: {
|
|
732
867
|
tasks: [],
|
|
733
868
|
results: [],
|
package/tools.ts
CHANGED
|
@@ -12,20 +12,22 @@ import { DEFAULT_TOOLS, READONLY_TOOLS } from "./constants.ts";
|
|
|
12
12
|
|
|
13
13
|
/** Shorthand → concrete tool list. `*` = full agent (bash subsumes search);
|
|
14
14
|
* `ro` = read-only scout (search without shell). */
|
|
15
|
-
const TOOL_GROUPS: Record<string, string[]> =
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
const TOOL_GROUPS: Record<string, string[]> = Object.create(null) as Record<
|
|
16
|
+
string,
|
|
17
|
+
string[]
|
|
18
|
+
>;
|
|
19
|
+
TOOL_GROUPS["*"] = DEFAULT_TOOLS;
|
|
20
|
+
TOOL_GROUPS.ro = READONLY_TOOLS;
|
|
19
21
|
|
|
20
|
-
export const TOOL_FACTORIES: Record<string, (cwd: string) => AgentTool<any>> =
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
export const TOOL_FACTORIES: Record<string, (cwd: string) => AgentTool<any>> =
|
|
23
|
+
Object.create(null) as Record<string, (cwd: string) => AgentTool<any>>;
|
|
24
|
+
TOOL_FACTORIES.read = createReadTool;
|
|
25
|
+
TOOL_FACTORIES.write = createWriteTool;
|
|
26
|
+
TOOL_FACTORIES.edit = createEditTool;
|
|
27
|
+
TOOL_FACTORIES.bash = createBashTool;
|
|
28
|
+
TOOL_FACTORIES.grep = createGrepTool;
|
|
29
|
+
TOOL_FACTORIES.find = createFindTool;
|
|
30
|
+
TOOL_FACTORIES.ls = createLsTool;
|
|
29
31
|
|
|
30
32
|
/** Expand tool-group shorthands (`*`, `ro`) into concrete tool lists.
|
|
31
33
|
* Unknown names pass through unchanged for the caller to validate.
|
|
@@ -33,8 +35,7 @@ export const TOOL_FACTORIES: Record<string, (cwd: string) => AgentTool<any>> = {
|
|
|
33
35
|
export function resolveToolGroups(tools: string[]): string[] {
|
|
34
36
|
const resolved: string[] = [];
|
|
35
37
|
for (const t of tools) {
|
|
36
|
-
|
|
37
|
-
if (group) resolved.push(...group);
|
|
38
|
+
if (Object.hasOwn(TOOL_GROUPS, t)) resolved.push(...TOOL_GROUPS[t]);
|
|
38
39
|
else resolved.push(t);
|
|
39
40
|
}
|
|
40
41
|
return [...new Set(resolved)];
|