@oh-my-pi/pi-agent-core 14.7.2 → 14.7.4

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.
Files changed (2) hide show
  1. package/package.json +4 -4
  2. package/src/agent-loop.ts +125 -102
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-agent-core",
4
- "version": "14.7.2",
4
+ "version": "14.7.4",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -35,9 +35,9 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "14.7.2",
39
- "@oh-my-pi/pi-natives": "14.7.2",
40
- "@oh-my-pi/pi-utils": "14.7.2"
38
+ "@oh-my-pi/pi-ai": "14.7.4",
39
+ "@oh-my-pi/pi-natives": "14.7.4",
40
+ "@oh-my-pi/pi-utils": "14.7.4"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@sinclair/typebox": "^0.34.49",
package/src/agent-loop.ts CHANGED
@@ -22,6 +22,49 @@ import type {
22
22
  StreamFn,
23
23
  } from "./types";
24
24
 
25
+ /** Sentinel returned by the abort race in `streamAssistantResponse`. */
26
+ const ABORTED: unique symbol = Symbol("agent-loop-aborted");
27
+
28
+ /**
29
+ * Normalize a value coming back from `tool.execute()` (or its streaming partial-update callback)
30
+ * into a structurally valid {@link AgentToolResult}.
31
+ *
32
+ * The tool interface is typed, but third-party tools (MCP, extensions, user-authored AgentTools)
33
+ * can violate the contract at runtime. Persisting a malformed result corrupts the session file
34
+ * (missing `content` array → crash on reload). We coerce at the single boundary where untyped
35
+ * results enter the agent loop, so every downstream consumer can rely on the type.
36
+ */
37
+ function coerceToolResult(raw: unknown): { result: AgentToolResult<any>; malformed: boolean } {
38
+ const rawObj = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
39
+ const rawContent = rawObj?.content;
40
+ const details = rawObj && "details" in rawObj ? rawObj.details : {};
41
+
42
+ if (!Array.isArray(rawContent)) {
43
+ return {
44
+ result: {
45
+ content: [{ type: "text", text: "Tool returned an invalid result: missing content array." }],
46
+ details,
47
+ },
48
+ malformed: true,
49
+ };
50
+ }
51
+
52
+ const content: AgentToolResult["content"] = [];
53
+ for (const block of rawContent) {
54
+ if (!block || typeof block !== "object" || !("type" in block)) continue;
55
+ if (block.type === "text" && typeof (block as { text?: unknown }).text === "string") {
56
+ content.push({ type: "text", text: sanitizeText((block as { text: string }).text) });
57
+ } else if (
58
+ block.type === "image" &&
59
+ typeof (block as { data?: unknown }).data === "string" &&
60
+ typeof (block as { mimeType?: unknown }).mimeType === "string"
61
+ ) {
62
+ content.push(block as { type: "image"; data: string; mimeType: string });
63
+ }
64
+ }
65
+ return { result: { content, details }, malformed: false };
66
+ }
67
+
25
68
  /**
26
69
  * Start an agent loop with a new prompt message.
27
70
  * The prompt is added to the context and events are emitted for it.
@@ -360,115 +403,97 @@ async function streamAssistantResponse(
360
403
  let addedPartial = false;
361
404
 
362
405
  const responseIterator = response[Symbol.asyncIterator]();
363
- while (true) {
364
- const read = await readResponseEvent(responseIterator, signal);
365
- if (read.type === "aborted") {
406
+ // Set up a single abort race: register the abort listener once for the whole
407
+ // stream and reuse the same race promise for every iterator.next() instead of
408
+ // allocating Promise.withResolvers and add/removeEventListener per event.
409
+ let abortRacePromise: Promise<typeof ABORTED> | undefined;
410
+ let detachAbortListener: (() => void) | undefined;
411
+ if (signal) {
412
+ if (signal.aborted) {
366
413
  return emitAbortedAssistantMessage(partialMessage, addedPartial, context, config, stream);
367
414
  }
368
- if (read.type === "error") {
369
- throw read.error;
370
- }
371
- if (read.result.done) {
372
- break;
373
- }
415
+ const { promise, resolve } = Promise.withResolvers<typeof ABORTED>();
416
+ const onAbort = () => resolve(ABORTED);
417
+ signal.addEventListener("abort", onAbort, { once: true });
418
+ abortRacePromise = promise;
419
+ detachAbortListener = () => signal.removeEventListener("abort", onAbort);
420
+ }
374
421
 
375
- const event = read.result.value;
376
- // Check for abort signal before processing each event
377
- if (signal?.aborted) {
378
- return emitAbortedAssistantMessage(partialMessage, addedPartial, context, config, stream);
379
- }
422
+ try {
423
+ while (true) {
424
+ let next: IteratorResult<AssistantMessageEvent>;
425
+ if (abortRacePromise) {
426
+ const result = await Promise.race([responseIterator.next(), abortRacePromise]);
427
+ if (result === ABORTED) {
428
+ responseIterator.return?.()?.catch(() => {});
429
+ return emitAbortedAssistantMessage(partialMessage, addedPartial, context, config, stream);
430
+ }
431
+ next = result;
432
+ } else {
433
+ next = await responseIterator.next();
434
+ }
435
+ if (signal?.aborted) {
436
+ return emitAbortedAssistantMessage(partialMessage, addedPartial, context, config, stream);
437
+ }
438
+ if (next.done) break;
380
439
 
381
- switch (event.type) {
382
- case "start":
383
- partialMessage = event.partial;
384
- context.messages.push(partialMessage);
385
- addedPartial = true;
386
- stream.push({ type: "message_start", message: { ...partialMessage } });
387
- break;
388
-
389
- case "text_start":
390
- case "text_delta":
391
- case "text_end":
392
- case "thinking_start":
393
- case "thinking_delta":
394
- case "thinking_end":
395
- case "toolcall_start":
396
- case "toolcall_delta":
397
- case "toolcall_end":
398
- if (partialMessage) {
440
+ const event = next.value;
441
+
442
+ switch (event.type) {
443
+ case "start":
399
444
  partialMessage = event.partial;
400
- context.messages[context.messages.length - 1] = partialMessage;
401
- config.onAssistantMessageEvent?.(partialMessage, event);
402
- if (signal?.aborted) {
403
- continue;
445
+ context.messages.push(partialMessage);
446
+ addedPartial = true;
447
+ stream.push({ type: "message_start", message: { ...partialMessage } });
448
+ break;
449
+
450
+ case "text_start":
451
+ case "text_delta":
452
+ case "text_end":
453
+ case "thinking_start":
454
+ case "thinking_delta":
455
+ case "thinking_end":
456
+ case "toolcall_start":
457
+ case "toolcall_delta":
458
+ case "toolcall_end":
459
+ if (partialMessage) {
460
+ partialMessage = event.partial;
461
+ context.messages[context.messages.length - 1] = partialMessage;
462
+ config.onAssistantMessageEvent?.(partialMessage, event);
463
+ if (signal?.aborted) {
464
+ continue;
465
+ }
466
+ stream.push({
467
+ type: "message_update",
468
+ assistantMessageEvent: event,
469
+ message: { ...partialMessage },
470
+ });
404
471
  }
405
- stream.push({
406
- type: "message_update",
407
- assistantMessageEvent: event,
408
- message: { ...partialMessage },
409
- });
410
- }
411
- break;
412
-
413
- case "done":
414
- case "error": {
415
- const finalMessage = await response.result();
416
- if (addedPartial) {
417
- context.messages[context.messages.length - 1] = finalMessage;
418
- } else {
419
- context.messages.push(finalMessage);
420
- }
421
- if (!addedPartial) {
422
- stream.push({ type: "message_start", message: { ...finalMessage } });
472
+ break;
473
+
474
+ case "done":
475
+ case "error": {
476
+ const finalMessage = await response.result();
477
+ if (addedPartial) {
478
+ context.messages[context.messages.length - 1] = finalMessage;
479
+ } else {
480
+ context.messages.push(finalMessage);
481
+ }
482
+ if (!addedPartial) {
483
+ stream.push({ type: "message_start", message: { ...finalMessage } });
484
+ }
485
+ stream.push({ type: "message_end", message: finalMessage });
486
+ return finalMessage;
423
487
  }
424
- stream.push({ type: "message_end", message: finalMessage });
425
- return finalMessage;
426
488
  }
427
489
  }
490
+ } finally {
491
+ detachAbortListener?.();
428
492
  }
429
493
 
430
494
  return await response.result();
431
495
  }
432
496
 
433
- type ResponseEventRead =
434
- | { type: "event"; result: IteratorResult<AssistantMessageEvent> }
435
- | { type: "error"; error: unknown }
436
- | { type: "aborted" };
437
-
438
- async function readResponseEvent(
439
- iterator: AsyncIterator<AssistantMessageEvent>,
440
- signal: AbortSignal | undefined,
441
- ): Promise<ResponseEventRead> {
442
- if (!signal) {
443
- return { type: "event", result: await iterator.next() };
444
- }
445
- if (signal.aborted) {
446
- const returnPromise = iterator.return?.();
447
- if (returnPromise) void returnPromise.catch(() => {});
448
- return { type: "aborted" };
449
- }
450
-
451
- const { promise: abortPromise, resolve: resolveAbort } = Promise.withResolvers<ResponseEventRead>();
452
- const onAbort = () => resolveAbort({ type: "aborted" });
453
- signal.addEventListener("abort", onAbort, { once: true });
454
-
455
- const eventPromise = iterator.next().then(
456
- result => ({ type: "event" as const, result }),
457
- error => ({ type: "error" as const, error }),
458
- );
459
-
460
- try {
461
- const read = await Promise.race([eventPromise, abortPromise]);
462
- if (read.type === "aborted") {
463
- const returnPromise = iterator.return?.();
464
- if (returnPromise) void returnPromise.catch(() => {});
465
- }
466
- return read;
467
- } finally {
468
- signal.removeEventListener("abort", onAbort);
469
- }
470
- }
471
-
472
497
  function emitAbortedAssistantMessage(
473
498
  partialMessage: AssistantMessage | null,
474
499
  addedPartial: boolean,
@@ -671,7 +696,7 @@ async function executeToolCalls(
671
696
  toolCalls: toolCallInfos,
672
697
  })
673
698
  : undefined;
674
- result = await tool.execute(
699
+ const rawResult = await tool.execute(
675
700
  toolCall.id,
676
701
  transformToolCallArguments ? transformToolCallArguments(effectiveArgs, toolCall.name) : effectiveArgs,
677
702
  tool.nonAbortable ? undefined : toolSignal,
@@ -681,16 +706,14 @@ async function executeToolCalls(
681
706
  toolCallId: toolCall.id,
682
707
  toolName: toolCall.name,
683
708
  args: argsForExecution,
684
- partialResult: {
685
- ...partialResult,
686
- content: partialResult.content.map(c =>
687
- c.type === "text" ? { ...c, text: sanitizeText(c.text) } : c,
688
- ),
689
- },
709
+ partialResult: coerceToolResult(partialResult).result,
690
710
  });
691
711
  },
692
712
  toolContext,
693
713
  );
714
+ const coerced = coerceToolResult(rawResult);
715
+ result = coerced.result;
716
+ if (coerced.malformed) isError = true;
694
717
  } catch (e) {
695
718
  result = {
696
719
  content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],