@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/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(`=== ${t.agentName}: ${trunc(t.prompt || "", 80)} ===`);
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) => r ?? { error: pendingLabelFor(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: ticket.results.map(
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: parts.join(" · ") }],
256
- details: buildWaitDetails(ticket),
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: `Ticket ${ticket.id} still ${ticket.status} after ${fmtDuration(timeoutMs)} · wait timed out (ticket continues in background)`,
357
+ text: `${base}\n\n${snapshotText}\n\n${guidance}`,
269
358
  },
270
359
  ],
271
- details: buildWaitDetails(ticket),
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) w.onUpdate(buildWaitRunningUpdate(ticket));
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
- ): void {
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
- if (settleTicketWaiters(ticket)) return;
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: "steer",
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 `action`.",
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({ action: "poll", ticket: "${t.id}" })`;
594
+ line += `\n poll: delegate({ ticketAction: "poll", ticket: "${t.id}" })`;
471
595
  if (t.status === "running") {
472
- line += `\n cancel: delegate({ action: "cancel", ticket: "${t.id}", force: true })`;
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(`✗ ${r.agent} · ${errorText} · ${meta.join(" · ")}`);
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; poll again for the final status."
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
- : settledCount > 0
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 ?? { error: "PENDING — result not available" },
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({ action: "cancel", ticket: "${ticket.id}", force: true }).`,
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: "action='cancel' requires a ticket ID." },
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: buildCancelPreview(ticket) }],
697
- details: buildWaitDetails(ticket),
832
+ content: [{ type: "text", text }],
833
+ details,
698
834
  };
699
835
  }
700
- ticket.controller.abort();
701
- ticket.status = "cancelling";
702
- syncTicketBusyIndex(ticket);
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 (`action: "cancel"`). */
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: [{ type: "text", text: "action='wait' requires a ticket ID." }],
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
- "*": DEFAULT_TOOLS,
17
- ro: READONLY_TOOLS,
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
- read: createReadTool,
22
- write: createWriteTool,
23
- edit: createEditTool,
24
- bash: createBashTool,
25
- grep: createGrepTool,
26
- find: createFindTool,
27
- ls: createLsTool,
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
- const group = TOOL_GROUPS[t];
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)];