@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.
- package/package.json +4 -4
- 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.
|
|
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.
|
|
39
|
-
"@oh-my-pi/pi-natives": "14.7.
|
|
40
|
-
"@oh-my-pi/pi-utils": "14.7.
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
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) }],
|