@gotgenes/pi-subagents 6.8.3 → 6.9.1

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.
@@ -7,13 +7,11 @@ import { AgentTypeRegistry } from "../agent-types.js";
7
7
  import { resolveAgentInvocationConfig } from "../invocation-config.js";
8
8
  import { resolveInvocationModel } from "../model-resolver.js";
9
9
 
10
- import { NotificationState } from "../notification-state.js";
11
10
  import type { AgentInvocation, AgentRecord, SubagentType } from "../types.js";
12
11
  import { AgentActivityTracker } from "../ui/agent-activity-tracker.js";
13
12
  import {
14
13
  type AgentDetails,
15
14
  buildInvocationTags,
16
- describeActivity,
17
15
  formatMs,
18
16
  formatTurns,
19
17
  getDisplayName,
@@ -21,55 +19,9 @@ import {
21
19
  SPINNER,
22
20
  type UICtx,
23
21
  } from "../ui/agent-widget.js";
24
- import { subscribeUIObserver } from "../ui/ui-observer.js";
25
- import type { LifetimeUsage } from "../usage.js";
26
- import { formatLifetimeTokens, textResult } from "./helpers.js";
27
-
28
- // ---- Agent-tool-specific helpers ----
29
-
30
- /** Parenthetical status note for completed agent result text. */
31
- export function getStatusNote(status: string): string {
32
- switch (status) {
33
- case "aborted":
34
- return " (aborted — max turns exceeded, output may be incomplete)";
35
- case "steered":
36
- return " (wrapped up — reached turn limit)";
37
- case "stopped":
38
- return " (stopped by user)";
39
- default:
40
- return "";
41
- }
42
- }
43
-
44
- /** Build AgentDetails from a base + record-specific fields. */
45
- export function buildDetails(
46
- base: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "tags">,
47
- record: {
48
- toolUses: number;
49
- startedAt: number;
50
- completedAt?: number;
51
- status: string;
52
- error?: string;
53
- id?: string;
54
- session?: any;
55
- lifetimeUsage: LifetimeUsage;
56
- },
57
- activity?: AgentActivityTracker,
58
- overrides?: Partial<AgentDetails>,
59
- ): AgentDetails {
60
- return {
61
- ...base,
62
- toolUses: record.toolUses,
63
- tokens: formatLifetimeTokens(record),
64
- turnCount: activity?.turnCount,
65
- maxTurns: activity?.maxTurns,
66
- durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
67
- status: record.status as AgentDetails["status"],
68
- agentId: record.id,
69
- error: record.error,
70
- ...overrides,
71
- };
72
- }
22
+ import { spawnBackground } from "./background-spawner.js";
23
+ import { runForeground } from "./foreground-runner.js";
24
+ import { buildDetails, buildTypeListText, formatLifetimeTokens, getStatusNote, textResult } from "./helpers.js";
73
25
 
74
26
  // ---- Deps interface ----
75
27
 
@@ -80,7 +32,6 @@ export interface AgentToolManager {
80
32
  resume: (id: string, prompt: string, signal: AbortSignal) => Promise<AgentRecord | undefined>;
81
33
  getRecord: (id: string) => AgentRecord | undefined;
82
34
  getMaxConcurrent: () => number;
83
- listAgents: () => AgentRecord[];
84
35
  }
85
36
 
86
37
  /** Narrow widget interface — only the methods the Agent tool calls. */
@@ -91,14 +42,21 @@ export interface AgentToolWidget {
91
42
  markFinished: (id: string) => void;
92
43
  }
93
44
 
45
+ /**
46
+ * Narrow read/write interface for the agent-tool's agentActivity access.
47
+ * The full Map satisfies this structurally — no wrapper needed.
48
+ */
49
+ export interface AgentActivityAccess {
50
+ get(id: string): AgentActivityTracker | undefined;
51
+ set(id: string, tracker: AgentActivityTracker): void;
52
+ delete(id: string): void;
53
+ }
54
+
94
55
  export interface AgentToolDeps {
95
56
  manager: AgentToolManager;
96
57
  widget: AgentToolWidget;
97
- agentActivity: Map<string, AgentActivityTracker>;
98
- emitEvent: (name: string, data: unknown) => void;
58
+ agentActivity: AgentActivityAccess;
99
59
  registry: AgentTypeRegistry;
100
- typeListText: string;
101
- availableTypesText: string;
102
60
  agentDir: string;
103
61
  /** Narrow settings accessor — only the default max turns is needed here. */
104
62
  settings: { readonly defaultMaxTurns: number | undefined };
@@ -108,6 +66,8 @@ export interface AgentToolDeps {
108
66
 
109
67
  /** Create the Agent tool definition (without Pi SDK wrapper). */
110
68
  export function createAgentTool(deps: AgentToolDeps) {
69
+ const typeListText = buildTypeListText(deps.registry, deps.agentDir);
70
+ const availableTypesText = deps.registry.getAvailableTypes().join(", ");
111
71
  return {
112
72
  name: "Agent" as const,
113
73
  label: "Agent",
@@ -116,7 +76,7 @@ export function createAgentTool(deps: AgentToolDeps) {
116
76
  The Agent tool launches specialized agents that autonomously handle complex tasks. Each agent type has specific capabilities and tools available to it.
117
77
 
118
78
  Available agent types:
119
- ${deps.typeListText}
79
+ ${typeListText}
120
80
 
121
81
  Guidelines:
122
82
  - For parallel work, use run_in_background: true on each agent. Foreground calls run sequentially — only one executes at a time.
@@ -140,7 +100,7 @@ Guidelines:
140
100
  description: "A short (3-5 word) description of the task (shown in UI).",
141
101
  }),
142
102
  subagent_type: Type.String({
143
- description: `The type of specialized agent to use. Available types: ${deps.availableTypesText}. Custom agents from .pi/agents/<name>.md (project) or ${deps.agentDir}/agents/<name>.md (global) are also available.`,
103
+ description: `The type of specialized agent to use. Available types: ${availableTypesText}. Custom agents from .pi/agents/<name>.md (project) or ${deps.agentDir}/agents/<name>.md (global) are also available.`,
144
104
  }),
145
105
  model: Type.Optional(
146
106
  Type.String({
@@ -403,175 +363,48 @@ Guidelines:
403
363
 
404
364
  // Background execution
405
365
  if (runInBackground) {
406
- const bgState = new AgentActivityTracker(effectiveMaxTurns);
407
-
408
- let id: string;
409
-
410
- try {
411
- id = deps.manager.spawn(ctx, subagentType, params.prompt as string, {
412
- parentSessionFile: ctx.sessionManager.getSessionFile(),
413
- parentSessionId: ctx.sessionManager.getSessionId(),
366
+ return spawnBackground(
367
+ { manager: deps.manager, widget: deps.widget, agentActivity: deps.agentActivity },
368
+ {
369
+ ctx,
370
+ subagentType,
371
+ prompt: params.prompt as string,
414
372
  description: params.description as string,
373
+ displayName,
374
+ toolCallId,
375
+ detailBase,
415
376
  model,
416
- maxTurns: effectiveMaxTurns,
377
+ effectiveMaxTurns,
417
378
  isolated,
418
379
  inheritContext,
419
- thinkingLevel: thinking,
420
- isBackground: true,
380
+ thinking,
421
381
  isolation,
422
- invocation: agentInvocation,
423
- onSessionCreated: (session: any) => {
424
- bgState.setSession(session);
425
- subscribeUIObserver(session, bgState);
426
- },
427
- });
428
- } catch (err) {
429
- return textResult(err instanceof Error ? err.message : String(err));
430
- }
431
-
432
- const record = deps.manager.getRecord(id);
433
- if (record) {
434
- // Born complete: notification-state object owns toolCallId + resultConsumed.
435
- record.notification = new NotificationState(toolCallId);
436
- }
437
-
438
- deps.agentActivity.set(id, bgState);
439
- deps.widget.ensureTimer();
440
- deps.widget.update();
441
-
442
- // Emit created event
443
- deps.emitEvent("subagents:created", {
444
- id,
445
- type: subagentType,
446
- description: params.description,
447
- isBackground: true,
448
- });
449
-
450
- const isQueued = record?.status === "queued";
451
- return textResult(
452
- `Agent ${isQueued ? "queued" : "started"} in background.\n` +
453
- `Agent ID: ${id}\n` +
454
- `Type: ${displayName}\n` +
455
- `Description: ${params.description}\n` +
456
- (record?.execution?.outputFile ? `Output file: ${record.execution.outputFile}\n` : "") +
457
- (isQueued
458
- ? `Position: queued (max ${deps.manager.getMaxConcurrent()} concurrent)\n`
459
- : "") +
460
- `\nYou will be notified when this agent completes.\n` +
461
- `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
462
- `Do not duplicate this agent's work.`,
463
- {
464
- ...detailBase,
465
- toolUses: 0,
466
- tokens: "",
467
- durationMs: 0,
468
- status: "background" as const,
469
- agentId: id,
382
+ agentInvocation,
470
383
  },
471
384
  );
472
385
  }
473
386
 
474
387
  // Foreground (synchronous) execution — stream progress via onUpdate
475
- let spinnerFrame = 0;
476
- const startedAt = Date.now();
477
- let fgId: string | undefined;
478
-
479
- const fgState = new AgentActivityTracker(effectiveMaxTurns);
480
- let unsubUI: (() => void) | undefined;
481
-
482
- const streamUpdate = () => {
483
- const details: AgentDetails = {
484
- ...detailBase,
485
- toolUses: fgState.toolUses,
486
- tokens: formatLifetimeTokens(fgState),
487
- turnCount: fgState.turnCount,
488
- maxTurns: fgState.maxTurns,
489
- durationMs: Date.now() - startedAt,
490
- status: "running",
491
- activity: describeActivity(fgState.activeTools, fgState.responseText),
492
- spinnerFrame: spinnerFrame % SPINNER.length,
493
- };
494
- onUpdate?.({
495
- content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }],
496
- details: details as any,
497
- });
498
- };
499
-
500
- // Animate spinner at ~80ms (smooth rotation through 10 braille frames)
501
- const spinnerInterval = setInterval(() => {
502
- spinnerFrame++;
503
- streamUpdate();
504
- }, 80);
505
-
506
- streamUpdate();
507
-
508
- let record: AgentRecord;
509
- try {
510
- record = await deps.manager.spawnAndWait(
388
+ return runForeground(
389
+ { manager: deps.manager, widget: deps.widget, agentActivity: deps.agentActivity },
390
+ {
511
391
  ctx,
512
392
  subagentType,
513
- params.prompt as string,
514
- {
515
- description: params.description as string,
516
- model,
517
- maxTurns: effectiveMaxTurns,
518
- isolated,
519
- inheritContext,
520
- thinkingLevel: thinking,
521
- isolation,
522
- invocation: agentInvocation,
523
- signal,
524
- parentSessionFile: ctx.sessionManager.getSessionFile(),
525
- parentSessionId: ctx.sessionManager.getSessionId(),
526
- onSessionCreated: (session: any) => {
527
- fgState.setSession(session);
528
- unsubUI = subscribeUIObserver(session, fgState, streamUpdate);
529
- for (const a of deps.manager.listAgents()) {
530
- if (a.execution?.session === session) {
531
- fgId = a.id;
532
- deps.agentActivity.set(a.id, fgState);
533
- deps.widget.ensureTimer();
534
- break;
535
- }
536
- }
537
- },
538
- },
539
- );
540
- } catch (err) {
541
- clearInterval(spinnerInterval);
542
- unsubUI?.();
543
- return textResult(err instanceof Error ? err.message : String(err));
544
- }
545
-
546
- clearInterval(spinnerInterval);
547
- unsubUI?.();
548
-
549
- // Clean up foreground agent from widget
550
- if (fgId) {
551
- deps.agentActivity.delete(fgId);
552
- deps.widget.markFinished(fgId);
553
- }
554
-
555
- // Get final token count
556
- const tokenText = formatLifetimeTokens(fgState);
557
-
558
- const details = buildDetails(detailBase, record, fgState, { tokens: tokenText });
559
-
560
- const fallbackNote = fellBack
561
- ? `Note: Unknown agent type "${rawType}" — using general-purpose.\n\n`
562
- : "";
563
-
564
- if (record.status === "error") {
565
- return textResult(`${fallbackNote}Agent failed: ${record.error}`, details);
566
- }
567
-
568
- const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
569
- const statsParts = [`${record.toolUses} tool uses`];
570
- if (tokenText) statsParts.push(tokenText);
571
- return textResult(
572
- `${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getStatusNote(record.status)}.\n\n` +
573
- (record.result?.trim() || "No output."),
574
- details,
393
+ prompt: params.prompt as string,
394
+ description: params.description as string,
395
+ detailBase,
396
+ rawType,
397
+ fellBack,
398
+ model,
399
+ effectiveMaxTurns,
400
+ isolated,
401
+ inheritContext,
402
+ thinking,
403
+ isolation,
404
+ agentInvocation,
405
+ },
406
+ signal,
407
+ onUpdate,
575
408
  );
576
409
  },
577
410
  };
@@ -0,0 +1,116 @@
1
+ import type { Model } from "@earendil-works/pi-ai";
2
+ import type { AgentSpawnConfig } from "../agent-manager.js";
3
+ import type { AgentInvocation, AgentRecord, IsolationMode, ThinkingLevel } from "../types.js";
4
+ import { AgentActivityTracker } from "../ui/agent-activity-tracker.js";
5
+ import type { AgentDetails } from "../ui/agent-widget.js";
6
+ import { subscribeUIObserver } from "../ui/ui-observer.js";
7
+ import type { AgentActivityAccess } from "./agent-tool.js";
8
+ import { textResult } from "./helpers.js";
9
+
10
+ /** Narrow manager interface for the background spawner. */
11
+ export interface BackgroundManagerDeps {
12
+ spawn(ctx: any, type: string, prompt: string, opts: AgentSpawnConfig): string;
13
+ getRecord(id: string): AgentRecord | undefined;
14
+ getMaxConcurrent(): number;
15
+ }
16
+
17
+ /** Narrow widget interface for the background spawner. */
18
+ export interface BackgroundWidgetDeps {
19
+ ensureTimer(): void;
20
+ update(): void;
21
+ }
22
+
23
+ /** Injected collaborators for spawnBackground. */
24
+ export interface BackgroundDeps {
25
+ manager: BackgroundManagerDeps;
26
+ widget: BackgroundWidgetDeps;
27
+ agentActivity: AgentActivityAccess;
28
+ }
29
+
30
+ /** All values the background spawner needs, bundled from shared execute setup. */
31
+ export interface BackgroundParams {
32
+ ctx: {
33
+ sessionManager: {
34
+ getSessionFile(): string;
35
+ getSessionId(): string;
36
+ };
37
+ };
38
+ subagentType: string;
39
+ prompt: string;
40
+ description: string;
41
+ displayName: string;
42
+ toolCallId: string;
43
+ detailBase: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "tags">;
44
+ model: Model<any> | undefined;
45
+ effectiveMaxTurns: number | undefined;
46
+ isolated: boolean | undefined;
47
+ inheritContext: boolean | undefined;
48
+ thinking: ThinkingLevel | undefined;
49
+ isolation: IsolationMode | undefined;
50
+ agentInvocation: AgentInvocation;
51
+ }
52
+
53
+ /**
54
+ * Spawn a background agent and return the tool result immediately.
55
+ * Owns: activity tracker creation, UI observer subscription, activity map
56
+ * registration, widget update, and launch message formatting.
57
+ */
58
+ export function spawnBackground(
59
+ deps: BackgroundDeps,
60
+ params: BackgroundParams,
61
+ ) {
62
+ const bgState = new AgentActivityTracker(params.effectiveMaxTurns);
63
+
64
+ let id: string;
65
+ try {
66
+ id = deps.manager.spawn(params.ctx, params.subagentType, params.prompt, {
67
+ parentSessionFile: params.ctx.sessionManager.getSessionFile(),
68
+ parentSessionId: params.ctx.sessionManager.getSessionId(),
69
+ description: params.description,
70
+ model: params.model,
71
+ maxTurns: params.effectiveMaxTurns,
72
+ isolated: params.isolated,
73
+ inheritContext: params.inheritContext,
74
+ thinkingLevel: params.thinking,
75
+ isBackground: true,
76
+ isolation: params.isolation,
77
+ invocation: params.agentInvocation,
78
+ toolCallId: params.toolCallId,
79
+ onSessionCreated: (session) => {
80
+ bgState.setSession(session);
81
+ subscribeUIObserver(session, bgState);
82
+ },
83
+ });
84
+ } catch (err) {
85
+ return textResult(err instanceof Error ? err.message : String(err));
86
+ }
87
+
88
+ const record = deps.manager.getRecord(id);
89
+
90
+ deps.agentActivity.set(id, bgState);
91
+ deps.widget.ensureTimer();
92
+ deps.widget.update();
93
+
94
+ const isQueued = record?.status === "queued";
95
+ return textResult(
96
+ `Agent ${isQueued ? "queued" : "started"} in background.\n` +
97
+ `Agent ID: ${id}\n` +
98
+ `Type: ${params.displayName}\n` +
99
+ `Description: ${params.description}\n` +
100
+ (record?.execution?.outputFile ? `Output file: ${record.execution.outputFile}\n` : "") +
101
+ (isQueued
102
+ ? `Position: queued (max ${deps.manager.getMaxConcurrent()} concurrent)\n`
103
+ : "") +
104
+ `\nYou will be notified when this agent completes.\n` +
105
+ `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
106
+ `Do not duplicate this agent's work.`,
107
+ {
108
+ ...params.detailBase,
109
+ toolUses: 0,
110
+ tokens: "",
111
+ durationMs: 0,
112
+ status: "background" as const,
113
+ agentId: id,
114
+ },
115
+ );
116
+ }
@@ -0,0 +1,175 @@
1
+ import type { Model } from "@earendil-works/pi-ai";
2
+ import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
3
+ import type { AgentSpawnConfig } from "../agent-manager.js";
4
+ import type { AgentInvocation, AgentRecord, IsolationMode, ThinkingLevel } from "../types.js";
5
+ import { AgentActivityTracker } from "../ui/agent-activity-tracker.js";
6
+ import {
7
+ type AgentDetails,
8
+ describeActivity,
9
+ formatMs,
10
+ SPINNER,
11
+ } from "../ui/agent-widget.js";
12
+ import { subscribeUIObserver } from "../ui/ui-observer.js";
13
+ import type { AgentActivityAccess } from "./agent-tool.js";
14
+ import {
15
+ buildDetails,
16
+ formatLifetimeTokens,
17
+ getStatusNote,
18
+ textResult,
19
+ } from "./helpers.js";
20
+
21
+ /** Narrow manager interface for the foreground runner. */
22
+ export interface ForegroundManagerDeps {
23
+ spawnAndWait(
24
+ ctx: any,
25
+ type: string,
26
+ prompt: string,
27
+ opts: Omit<AgentSpawnConfig, "isBackground">,
28
+ ): Promise<AgentRecord>;
29
+ }
30
+
31
+ /** Narrow widget interface for the foreground runner. */
32
+ export interface ForegroundWidgetDeps {
33
+ ensureTimer(): void;
34
+ markFinished(id: string): void;
35
+ }
36
+
37
+ /** Injected collaborators for runForeground. */
38
+ export interface ForegroundDeps {
39
+ manager: ForegroundManagerDeps;
40
+ widget: ForegroundWidgetDeps;
41
+ agentActivity: AgentActivityAccess;
42
+ }
43
+
44
+ /** All values the foreground runner needs, bundled from shared execute setup. */
45
+ export interface ForegroundParams {
46
+ ctx: {
47
+ sessionManager: {
48
+ getSessionFile(): string;
49
+ getSessionId(): string;
50
+ };
51
+ };
52
+ subagentType: string;
53
+ prompt: string;
54
+ description: string;
55
+ detailBase: Pick<
56
+ AgentDetails,
57
+ "displayName" | "description" | "subagentType" | "modelName" | "tags"
58
+ >;
59
+ rawType: string;
60
+ fellBack: boolean;
61
+ model: Model<any> | undefined;
62
+ effectiveMaxTurns: number | undefined;
63
+ isolated: boolean | undefined;
64
+ inheritContext: boolean | undefined;
65
+ thinking: ThinkingLevel | undefined;
66
+ isolation: IsolationMode | undefined;
67
+ agentInvocation: AgentInvocation;
68
+ }
69
+
70
+ /**
71
+ * Run an agent synchronously in the foreground, streaming spinner updates.
72
+ * Owns: spinner interval, AgentActivityTracker creation, UI observer subscription,
73
+ * streaming onUpdate callbacks, cleanup, and result formatting.
74
+ */
75
+ export async function runForeground(
76
+ deps: ForegroundDeps,
77
+ params: ForegroundParams,
78
+ signal: AbortSignal | undefined,
79
+ onUpdate: ((update: AgentToolResult<any>) => void) | undefined,
80
+ ) {
81
+ let spinnerFrame = 0;
82
+ const startedAt = Date.now();
83
+ let fgId: string | undefined;
84
+
85
+ const fgState = new AgentActivityTracker(params.effectiveMaxTurns);
86
+ let unsubUI: (() => void) | undefined;
87
+
88
+ const streamUpdate = () => {
89
+ const details: AgentDetails = {
90
+ ...params.detailBase,
91
+ toolUses: fgState.toolUses,
92
+ tokens: formatLifetimeTokens(fgState),
93
+ turnCount: fgState.turnCount,
94
+ maxTurns: fgState.maxTurns,
95
+ durationMs: Date.now() - startedAt,
96
+ status: "running",
97
+ activity: describeActivity(fgState.activeTools, fgState.responseText),
98
+ spinnerFrame: spinnerFrame % SPINNER.length,
99
+ };
100
+ onUpdate?.({
101
+ content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }],
102
+ details: details as any,
103
+ });
104
+ };
105
+
106
+ // Animate spinner at ~80ms (smooth rotation through 10 braille frames)
107
+ const spinnerInterval = setInterval(() => {
108
+ spinnerFrame++;
109
+ streamUpdate();
110
+ }, 80);
111
+
112
+ streamUpdate();
113
+
114
+ let record: AgentRecord;
115
+ try {
116
+ record = await deps.manager.spawnAndWait(
117
+ params.ctx,
118
+ params.subagentType,
119
+ params.prompt,
120
+ {
121
+ description: params.description,
122
+ model: params.model,
123
+ maxTurns: params.effectiveMaxTurns,
124
+ isolated: params.isolated,
125
+ inheritContext: params.inheritContext,
126
+ thinkingLevel: params.thinking,
127
+ isolation: params.isolation,
128
+ invocation: params.agentInvocation,
129
+ signal,
130
+ parentSessionFile: params.ctx.sessionManager.getSessionFile(),
131
+ parentSessionId: params.ctx.sessionManager.getSessionId(),
132
+ onSessionCreated: (session, record) => {
133
+ fgState.setSession(session);
134
+ unsubUI = subscribeUIObserver(session, fgState, streamUpdate);
135
+ fgId = record.id;
136
+ deps.agentActivity.set(record.id, fgState);
137
+ deps.widget.ensureTimer();
138
+ },
139
+ },
140
+ );
141
+ } catch (err) {
142
+ clearInterval(spinnerInterval);
143
+ unsubUI?.();
144
+ return textResult(err instanceof Error ? err.message : String(err));
145
+ }
146
+
147
+ clearInterval(spinnerInterval);
148
+ unsubUI?.();
149
+
150
+ // Clean up foreground agent from widget
151
+ if (fgId) {
152
+ deps.agentActivity.delete(fgId);
153
+ deps.widget.markFinished(fgId);
154
+ }
155
+
156
+ const tokenText = formatLifetimeTokens(fgState);
157
+ const details = buildDetails(params.detailBase, record, fgState, { tokens: tokenText });
158
+
159
+ const fallbackNote = params.fellBack
160
+ ? `Note: Unknown agent type "${params.rawType}" \u2014 using general-purpose.\n\n`
161
+ : "";
162
+
163
+ if (record.status === "error") {
164
+ return textResult(`${fallbackNote}Agent failed: ${record.error}`, details);
165
+ }
166
+
167
+ const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
168
+ const statsParts = [`${record.toolUses} tool uses`];
169
+ if (tokenText) statsParts.push(tokenText);
170
+ return textResult(
171
+ `${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getStatusNote(record.status)}.\n\n` +
172
+ (record.result?.trim() || "No output."),
173
+ details,
174
+ );
175
+ }