@narumitw/pi-subagents 0.15.1 → 0.17.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/src/runner.ts CHANGED
@@ -5,12 +5,7 @@ import * as path from "node:path";
5
5
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
6
6
  import type { Message } from "@earendil-works/pi-ai";
7
7
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
8
- import type {
9
- AgentConfig,
10
- AgentScope,
11
- AgentSource,
12
- SubagentThinkingLevel,
13
- } from "./agents.js";
8
+ import type { AgentConfig, AgentScope, AgentSource, SubagentThinkingLevel } from "./agents.js";
14
9
  import {
15
10
  appendBounded,
16
11
  DEFAULT_MAX_CONTEXT_BYTES,
@@ -32,6 +27,13 @@ export interface UsageStats {
32
27
  contextTokens: number;
33
28
  turns: number;
34
29
  }
30
+ export type RecentActivityItem =
31
+ | { type: "text"; text: string }
32
+ | { type: "toolCall"; name: string; args: Record<string, unknown> };
33
+
34
+ const MAX_RECENT_ACTIVITY_ITEMS = 10;
35
+ const MAX_RECENT_ACTIVITY_BYTES = 8 * 1024;
36
+ const MAX_RECENT_ACTIVITY_ARGUMENT_BYTES = 1024;
35
37
 
36
38
  export interface SingleResult {
37
39
  agent: string;
@@ -42,6 +44,10 @@ export interface SingleResult {
42
44
  stderr: string;
43
45
  usage: UsageStats;
44
46
  model?: string;
47
+ actualProvider?: string;
48
+ actualModel?: string;
49
+ recentActivity?: RecentActivityItem[];
50
+ recentActivityTotal?: number;
45
51
  thinkingLevel?: SubagentThinkingLevel;
46
52
  stopReason?: string;
47
53
  errorMessage?: string;
@@ -72,9 +78,11 @@ function getFinalOutput(messages: Message[]): string {
72
78
  for (let i = messages.length - 1; i >= 0; i--) {
73
79
  const msg = messages[i];
74
80
  if (msg.role === "assistant") {
75
- for (const part of msg.content) {
76
- if (part.type === "text") return part.text;
77
- }
81
+ const text = msg.content
82
+ .filter((part) => part.type === "text")
83
+ .map((part) => part.text)
84
+ .join("\n");
85
+ if (text) return text;
78
86
  }
79
87
  }
80
88
  return "";
@@ -84,36 +92,157 @@ export function getResultFinalOutput(result: SingleResult): string {
84
92
  return result.finalOutput ?? getFinalOutput(result.messages);
85
93
  }
86
94
 
87
- function boundMessageText(message: Message, maxBytes: number): { message: Message; bytes: number; truncated: boolean } {
88
- const serializedBytes = Buffer.byteLength(JSON.stringify(message), "utf8");
89
- if (serializedBytes <= maxBytes) return { message, bytes: serializedBytes, truncated: false };
90
- if (!Array.isArray(message.content)) return { message, bytes: Math.min(serializedBytes, maxBytes), truncated: true };
91
- let remaining = maxBytes;
92
- const content = message.content
93
- .filter((part) => part.type === "text")
94
- .map((part) => {
95
- const bounded = truncateUtf8(part.text, remaining);
96
- remaining = Math.max(0, remaining - Buffer.byteLength(bounded.text, "utf8"));
97
- return { ...part, text: bounded.text };
98
- });
99
- const boundedMessage = { ...message, content } as Message;
100
- return {
101
- message: boundedMessage,
102
- bytes: Math.min(Buffer.byteLength(JSON.stringify(boundedMessage), "utf8"), maxBytes),
103
- truncated: true,
95
+ export function isResultError(result: SingleResult): boolean {
96
+ return (
97
+ (result.exitCode !== 0 && result.exitCode !== -1) ||
98
+ result.timedOut === true ||
99
+ result.stopReason === "timeout" ||
100
+ result.stopReason === "error" ||
101
+ result.stopReason === "aborted"
102
+ );
103
+ }
104
+
105
+ export function formatResultFailure(result: SingleResult): string {
106
+ const error = result.errorMessage || result.stderr.trim();
107
+ const output = getResultFinalOutput(result);
108
+ const combined =
109
+ error && output ? `${error}\n\nPartial output:\n${output}` : error || output || "(no output)";
110
+ return truncateUtf8(combined, DEFAULT_MAX_CONTEXT_BYTES).text;
111
+ }
112
+
113
+ function boundMessageText(
114
+ message: Message,
115
+ maxBytes: number,
116
+ ): { message?: Message; bytes: number; truncated: boolean } {
117
+ const originalBytes = Buffer.byteLength(JSON.stringify(message), "utf8");
118
+ if (Number.isSafeInteger(maxBytes) && maxBytes >= 0 && originalBytes <= maxBytes) {
119
+ return { message, bytes: originalBytes, truncated: false };
120
+ }
121
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) return { bytes: 0, truncated: true };
122
+
123
+ const content: Array<
124
+ | { type: "text"; text: string }
125
+ | { type: "toolCall"; id: string; name: string; arguments: Record<string, unknown> }
126
+ > = [];
127
+ const bounded = () => ({ ...message, content }) as Message;
128
+ const fits = () => Buffer.byteLength(JSON.stringify(bounded()), "utf8") <= maxBytes;
129
+ const addText = (text: string, prepend = false) => {
130
+ if (!text.trim()) return;
131
+ const part = { type: "text" as const, text: "" };
132
+ if (prepend) content.unshift(part);
133
+ else content.push(part);
134
+ if (!fits()) {
135
+ content.splice(content.indexOf(part), 1);
136
+ return;
137
+ }
138
+ let low = 0;
139
+ let high = Buffer.byteLength(text, "utf8");
140
+ while (low < high) {
141
+ const middle = Math.ceil((low + high) / 2);
142
+ part.text = truncateUtf8(text, middle).text;
143
+ if (fits()) low = middle;
144
+ else high = middle - 1;
145
+ }
146
+ part.text = truncateUtf8(text, low).text;
147
+ if (!part.text.trim()) content.splice(content.indexOf(part), 1);
148
+ };
149
+ const addToolCall = (part: Extract<Message["content"][number], { type: "toolCall" }>) => {
150
+ const toolCall = {
151
+ type: "toolCall" as const,
152
+ id: part.id,
153
+ name: part.name,
154
+ arguments: part.arguments,
155
+ };
156
+ content.unshift(toolCall);
157
+ if (fits()) return;
158
+ const arguments_: Record<string, unknown> = {};
159
+ for (const key of ["command", "path", "file_path", "pattern", "url"]) {
160
+ const value = part.arguments[key];
161
+ if (typeof value === "string") arguments_[key] = truncateUtf8(value, 256).text;
162
+ }
163
+ toolCall.arguments = arguments_;
164
+ if (fits()) return;
165
+ toolCall.arguments = {};
166
+ if (!fits()) content.shift();
104
167
  };
168
+
169
+ if (message.role === "assistant") {
170
+ for (let index = message.content.length - 1; index >= 0; index--) {
171
+ const part = message.content[index];
172
+ if (part.type === "text") addText(part.text, true);
173
+ else if (part.type === "toolCall") addToolCall(part);
174
+ }
175
+ } else {
176
+ for (const part of message.content) {
177
+ if (typeof part === "object" && part && part.type === "text") addText(part.text);
178
+ }
179
+ }
180
+
181
+ if (content.length === 0) return { bytes: 0, truncated: true };
182
+ const result = bounded();
183
+ const bytes = Buffer.byteLength(JSON.stringify(result), "utf8");
184
+ return { message: result, bytes, truncated: true };
185
+ }
186
+ function compactRecentActivityArguments(args: Record<string, unknown>): Record<string, unknown> {
187
+ if (Buffer.byteLength(JSON.stringify(args), "utf8") <= MAX_RECENT_ACTIVITY_ARGUMENT_BYTES)
188
+ return args;
189
+ const compact: Record<string, unknown> = {};
190
+ for (const key of ["command", "path", "file_path", "pattern", "url", "selector"]) {
191
+ const value = args[key];
192
+ if (typeof value === "string") compact[key] = truncateUtf8(value, 256).text;
193
+ else if (typeof value === "number" || typeof value === "boolean") compact[key] = value;
194
+ }
195
+ return compact;
105
196
  }
106
197
 
107
- export function buildFanInContext(results: SingleResult[], maxBytes = DEFAULT_MAX_CONTEXT_BYTES): string {
198
+ function appendRecentActivity(result: SingleResult, message: Message): void {
199
+ if (message.role !== "assistant") return;
200
+ const append = (item: RecentActivityItem) => {
201
+ result.recentActivity ??= [];
202
+ result.recentActivityTotal = (result.recentActivityTotal ?? 0) + 1;
203
+ result.recentActivity.push(item);
204
+ if (result.recentActivity.length > MAX_RECENT_ACTIVITY_ITEMS) {
205
+ result.recentActivity.splice(0, result.recentActivity.length - MAX_RECENT_ACTIVITY_ITEMS);
206
+ }
207
+ while (
208
+ Buffer.byteLength(JSON.stringify(result.recentActivity), "utf8") > MAX_RECENT_ACTIVITY_BYTES
209
+ ) {
210
+ result.recentActivity.shift();
211
+ }
212
+ };
213
+ for (const part of message.content) {
214
+ if (part.type === "text") {
215
+ const text = part.text.trim();
216
+ if (text) append({ type: "text", text: truncateUtf8(text, 1024).text });
217
+ } else if (part.type === "toolCall") {
218
+ append({
219
+ type: "toolCall",
220
+ name: part.name,
221
+ args: compactRecentActivityArguments(part.arguments),
222
+ });
223
+ }
224
+ }
225
+ }
226
+
227
+ export function buildFanInContext(
228
+ results: SingleResult[],
229
+ maxBytes = DEFAULT_MAX_CONTEXT_BYTES,
230
+ ): string {
108
231
  const text = results
109
232
  .map((result, index) => {
110
- const status = result.exitCode === 0 ? "completed" : result.exitCode === -1 ? "running" : "failed";
233
+ const failed = isResultError(result);
234
+ const status = result.exitCode === -1 ? "running" : failed ? "failed" : "completed";
111
235
  const output = getResultFinalOutput(result);
112
236
  const error = result.errorMessage || result.stderr.trim();
237
+ const resultText = failed
238
+ ? `${error ? "Error" : output ? "Partial output" : "Error"}:\n${formatResultFailure(result)}`
239
+ : output
240
+ ? `Output:\n${output}`
241
+ : "Output: (no output)";
113
242
  return [
114
243
  `## Result ${index + 1}: ${result.agent} (${status})`,
115
244
  `Task: ${result.task}`,
116
- output ? `Output:\n${output}` : error ? `Error:\n${error}` : "Output: (no output)",
245
+ resultText,
117
246
  ].join("\n\n");
118
247
  })
119
248
  .join("\n\n---\n\n");
@@ -146,7 +275,10 @@ export async function mapWithConcurrencyLimit<TIn, TOut>(
146
275
  return results;
147
276
  }
148
277
 
149
- async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
278
+ async function writePromptToTempFile(
279
+ agentName: string,
280
+ prompt: string,
281
+ ): Promise<{ dir: string; filePath: string }> {
150
282
  const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
151
283
  const safeName = agentName.replace(/[^\w.-]+/g, "_");
152
284
  const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
@@ -207,8 +339,15 @@ function signalProcess(proc: ReturnType<typeof spawn>, signal: NodeJS.Signals):
207
339
  }
208
340
  }
209
341
 
210
- export function terminateProcess(proc: ReturnType<typeof spawn>, graceMs = KILL_GRACE_MS): () => void {
211
- let closed = proc.exitCode !== null || proc.signalCode !== null;
342
+ export function terminateProcess(
343
+ proc: ReturnType<typeof spawn>,
344
+ graceMs = KILL_GRACE_MS,
345
+ ): () => void {
346
+ const leaderExited = proc.exitCode !== null || proc.signalCode !== null;
347
+ const capturedOutputClosed = [proc.stdout, proc.stderr].every(
348
+ (stream) => !stream || stream.readableEnded || stream.destroyed,
349
+ );
350
+ let closed = leaderExited && capturedOutputClosed;
212
351
  const onClose = () => {
213
352
  closed = true;
214
353
  };
@@ -251,7 +390,15 @@ export async function runSingleAgent(
251
390
  exitCode: 1,
252
391
  messages: [],
253
392
  stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`,
254
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
393
+ usage: {
394
+ input: 0,
395
+ output: 0,
396
+ cacheRead: 0,
397
+ cacheWrite: 0,
398
+ cost: 0,
399
+ contextTokens: 0,
400
+ turns: 0,
401
+ },
255
402
  thinkingLevel,
256
403
  step,
257
404
  finalOutput: "",
@@ -261,6 +408,9 @@ export async function runSingleAgent(
261
408
  let tmpPromptDir: string | null = null;
262
409
  let tmpPromptPath: string | null = null;
263
410
 
411
+ let latestAssistantOutput = "";
412
+ let terminalAssistantOutput: string | undefined;
413
+
264
414
  const currentResult: SingleResult = {
265
415
  agent: agentName,
266
416
  agentSource: agent.source,
@@ -268,15 +418,35 @@ export async function runSingleAgent(
268
418
  exitCode: 0,
269
419
  messages: [],
270
420
  stderr: "",
271
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
421
+ usage: {
422
+ input: 0,
423
+ output: 0,
424
+ cacheRead: 0,
425
+ cacheWrite: 0,
426
+ cost: 0,
427
+ contextTokens: 0,
428
+ turns: 0,
429
+ },
272
430
  model: agent.model ?? undefined,
273
431
  thinkingLevel,
274
432
  step,
275
433
  timeoutMs,
276
434
  };
435
+ const selectedAssistantOutput = () =>
436
+ terminalAssistantOutput !== undefined
437
+ ? terminalAssistantOutput
438
+ : latestAssistantOutput || getFinalOutput(currentResult.messages);
439
+ const setErrorMessage = (message: string) => {
440
+ const bounded = truncateUtf8(message, DEFAULT_MAX_STDERR_BYTES);
441
+ currentResult.errorMessage = bounded.text;
442
+ currentResult.truncated ||= bounded.truncated;
443
+ return bounded.text;
444
+ };
277
445
 
278
446
  const emitUpdate = () => {
279
- currentResult.finalOutput = getFinalOutput(currentResult.messages);
447
+ const latest = truncateUtf8(selectedAssistantOutput(), DEFAULT_MAX_OUTPUT_BYTES);
448
+ currentResult.finalOutput = latest.text;
449
+ currentResult.truncated ||= latest.truncated;
280
450
  if (onUpdate) {
281
451
  onUpdate({
282
452
  content: [{ type: "text", text: currentResult.finalOutput || "(running...)" }],
@@ -293,8 +463,7 @@ export async function runSingleAgent(
293
463
  currentResult.exitCode = 1;
294
464
  currentResult.stopReason = "error";
295
465
  const reason = error instanceof Error ? error.message : String(error);
296
- currentResult.errorMessage = `Invalid subagent cwd: ${effectiveCwd} (${reason})`;
297
- currentResult.stderr = currentResult.errorMessage;
466
+ currentResult.stderr = setErrorMessage(`Invalid subagent cwd: ${effectiveCwd} (${reason})`);
298
467
  return currentResult;
299
468
  }
300
469
 
@@ -302,7 +471,7 @@ export async function runSingleAgent(
302
471
  currentResult.exitCode = 130;
303
472
  currentResult.aborted = true;
304
473
  currentResult.stopReason = "aborted";
305
- currentResult.errorMessage = "Subagent was aborted before start";
474
+ setErrorMessage("Subagent was aborted before start");
306
475
  return currentResult;
307
476
  }
308
477
 
@@ -356,22 +525,37 @@ export async function runSingleAgent(
356
525
  },
357
526
  });
358
527
  } catch (error) {
359
- currentResult.errorMessage = error instanceof Error ? error.message : String(error);
360
- currentResult.stderr = currentResult.errorMessage;
528
+ currentResult.stderr = setErrorMessage(
529
+ error instanceof Error ? error.message : String(error),
530
+ );
361
531
  finish(1);
362
532
  return;
363
533
  }
364
534
 
365
- let capturedMessageBytes = 0;
366
535
  const addMessage = (msg: Message) => {
367
- if (currentResult.messages.length >= DEFAULT_MAX_MESSAGES) {
536
+ const boundedMessage = boundMessageText(msg, DEFAULT_MAX_OUTPUT_BYTES - 2);
537
+ currentResult.truncated ||= boundedMessage.truncated;
538
+ if (!boundedMessage.message) return;
539
+ while (
540
+ currentResult.messages.length >= DEFAULT_MAX_MESSAGES ||
541
+ Buffer.byteLength(
542
+ JSON.stringify([...currentResult.messages, boundedMessage.message]),
543
+ "utf8",
544
+ ) > DEFAULT_MAX_OUTPUT_BYTES
545
+ ) {
546
+ const removed = currentResult.messages.shift();
547
+ if (!removed) break;
548
+ currentResult.truncated = true;
549
+ }
550
+ if (
551
+ Buffer.byteLength(
552
+ JSON.stringify([...currentResult.messages, boundedMessage.message]),
553
+ "utf8",
554
+ ) > DEFAULT_MAX_OUTPUT_BYTES
555
+ ) {
368
556
  currentResult.truncated = true;
369
557
  return;
370
558
  }
371
- const remaining = Math.max(0, DEFAULT_MAX_OUTPUT_BYTES - capturedMessageBytes);
372
- const boundedMessage = boundMessageText(msg, remaining);
373
- capturedMessageBytes += boundedMessage.bytes;
374
- currentResult.truncated ||= boundedMessage.truncated;
375
559
  currentResult.messages.push(boundedMessage.message);
376
560
  };
377
561
  const processEvent = (raw: unknown) => {
@@ -379,6 +563,15 @@ export async function runSingleAgent(
379
563
  const event = raw as { type?: string; message?: Message };
380
564
  if (event.type === "message_end" && event.message) {
381
565
  const msg = event.message;
566
+ if (msg.role === "assistant") {
567
+ const output = truncateUtf8(getFinalOutput([msg]), DEFAULT_MAX_OUTPUT_BYTES);
568
+ currentResult.truncated ||= output.truncated;
569
+ if (output.text) latestAssistantOutput = output.text;
570
+ if (msg.stopReason === "stop" || msg.stopReason === "length") {
571
+ terminalAssistantOutput = output.text;
572
+ }
573
+ }
574
+ appendRecentActivity(currentResult, msg);
382
575
  addMessage(msg);
383
576
  if (msg.role === "assistant") {
384
577
  currentResult.usage.turns++;
@@ -391,9 +584,11 @@ export async function runSingleAgent(
391
584
  currentResult.usage.cost += usage.cost?.total || 0;
392
585
  currentResult.usage.contextTokens = usage.totalTokens || 0;
393
586
  }
394
- if (!currentResult.model && msg.model) currentResult.model = msg.model;
587
+ if (msg.provider) currentResult.actualProvider = msg.provider;
588
+ if (msg.responseModel ?? msg.model)
589
+ currentResult.actualModel = msg.responseModel ?? msg.model;
395
590
  if (msg.stopReason) currentResult.stopReason = msg.stopReason;
396
- if (msg.errorMessage) currentResult.errorMessage = msg.errorMessage;
591
+ if (msg.errorMessage) setErrorMessage(msg.errorMessage);
397
592
  }
398
593
  emitUpdate();
399
594
  } else if (event.type === "tool_result_end" && event.message) {
@@ -415,7 +610,7 @@ export async function runSingleAgent(
415
610
  timedOut = true;
416
611
  currentResult.timedOut = true;
417
612
  currentResult.stopReason = "timeout";
418
- currentResult.errorMessage = `Subagent timed out after ${timeoutMs}ms`;
613
+ setErrorMessage(`Subagent timed out after ${timeoutMs}ms`);
419
614
  const bounded = appendBounded(
420
615
  currentResult.stderr,
421
616
  `\nSubagent timed out after ${timeoutMs}ms.`,
@@ -430,7 +625,11 @@ export async function runSingleAgent(
430
625
 
431
626
  proc.stdout?.on("data", (data) => decoder.push(data));
432
627
  proc.stderr?.on("data", (data) => {
433
- const bounded = appendBounded(currentResult.stderr, data.toString(), DEFAULT_MAX_STDERR_BYTES);
628
+ const bounded = appendBounded(
629
+ currentResult.stderr,
630
+ data.toString(),
631
+ DEFAULT_MAX_STDERR_BYTES,
632
+ );
434
633
  currentResult.stderr = bounded.text;
435
634
  currentResult.truncated ||= bounded.truncated;
436
635
  });
@@ -439,10 +638,10 @@ export async function runSingleAgent(
439
638
  finish(timedOut ? 124 : wasAborted ? 130 : (code ?? 0));
440
639
  });
441
640
  proc.on("error", (error) => {
442
- currentResult.errorMessage = error.message;
641
+ const message = setErrorMessage(error.message);
443
642
  const bounded = appendBounded(
444
643
  currentResult.stderr,
445
- `${currentResult.stderr ? "\n" : ""}${error.message}`,
644
+ `${currentResult.stderr ? "\n" : ""}${message}`,
446
645
  DEFAULT_MAX_STDERR_BYTES,
447
646
  );
448
647
  currentResult.stderr = bounded.text;
@@ -456,7 +655,7 @@ export async function runSingleAgent(
456
655
  wasAborted = true;
457
656
  currentResult.aborted = true;
458
657
  currentResult.stopReason = "aborted";
459
- currentResult.errorMessage = "Subagent was aborted";
658
+ setErrorMessage("Subagent was aborted");
460
659
  cleanupTermination = terminateProcess(proc);
461
660
  };
462
661
  if (signal.aborted) abortHandler();
@@ -465,9 +664,18 @@ export async function runSingleAgent(
465
664
  });
466
665
 
467
666
  currentResult.exitCode = exitCode;
468
- const final = truncateUtf8(getFinalOutput(currentResult.messages), DEFAULT_MAX_OUTPUT_BYTES);
667
+ const final = truncateUtf8(selectedAssistantOutput(), DEFAULT_MAX_OUTPUT_BYTES);
469
668
  currentResult.finalOutput = final.text;
470
669
  currentResult.truncated ||= final.truncated;
670
+ if (
671
+ currentResult.exitCode === 0 &&
672
+ currentResult.stopReason !== "error" &&
673
+ (currentResult.stopReason === "toolUse" || !currentResult.finalOutput.trim())
674
+ ) {
675
+ currentResult.exitCode = 1;
676
+ currentResult.stopReason = "error";
677
+ setErrorMessage("Subagent completed without final text");
678
+ }
471
679
  currentResult.policy = {
472
680
  inherited: ["environment"],
473
681
  overridden: [