@oh-my-pi/pi-agent-core 18.0.9 → 18.0.10
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/CHANGELOG.md +6 -0
- package/dist/types/agent-loop.d.ts +15 -2
- package/package.json +8 -8
- package/src/agent-loop.ts +63 -3
- package/src/agent.ts +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.0.10] - 2026-08-28
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added support for continuing interrupted agent runs with pending tool calls, allowing those calls to be retried before requesting the next model response.
|
|
10
|
+
|
|
5
11
|
## [18.0.9] - 2026-08-28
|
|
6
12
|
|
|
7
13
|
### Fixed
|
|
@@ -38,13 +38,26 @@ export declare function resolveOwnedDialectFromEnv(value: string | undefined): D
|
|
|
38
38
|
* The prompt is added to the context and events are emitted for it.
|
|
39
39
|
*/
|
|
40
40
|
export declare function agentLoop(prompts: AgentMessage[], context: AgentContext, config: AgentLoopConfig, signal?: AbortSignal, streamFn?: StreamFn): EventStream<AgentEvent, AgentMessage[]>;
|
|
41
|
+
/**
|
|
42
|
+
* Trailing assistant message whose runnable tool calls have no results yet.
|
|
43
|
+
*
|
|
44
|
+
* A harness that strips failed/aborted tool results in order to re-execute
|
|
45
|
+
* the calls (e.g. `AgentSession.retry`'s tool replay) leaves the transcript
|
|
46
|
+
* in this shape; {@link agentLoopContinue} resumes it by running those calls
|
|
47
|
+
* before the next model call. Cursor exec-resolved blocks are excluded — the
|
|
48
|
+
* provider already executed those server-side. `length`-truncated turns never
|
|
49
|
+
* qualify: their trailing call arguments may be incomplete.
|
|
50
|
+
*/
|
|
51
|
+
export declare function unpairedToolCallTail(messages: readonly AgentMessage[]): AssistantMessage | undefined;
|
|
41
52
|
/**
|
|
42
53
|
* Continue an agent loop from the current context without adding a new message.
|
|
43
54
|
* Used for retries - context already has user message or tool results.
|
|
44
55
|
*
|
|
45
56
|
* **Important:** The last message in context must convert to a `user` or `toolResult` message
|
|
46
|
-
* via `convertToLlm
|
|
47
|
-
*
|
|
57
|
+
* via `convertToLlm` — except for an assistant tail with unpaired runnable
|
|
58
|
+
* tool calls (see {@link unpairedToolCallTail}), which resumes by executing
|
|
59
|
+
* those calls first. Any other assistant tail is rejected here; other invalid
|
|
60
|
+
* tails cannot be validated since `convertToLlm` is only called once per turn.
|
|
48
61
|
*/
|
|
49
62
|
export declare function agentLoopContinue(context: AgentContext, config: AgentLoopConfig, signal?: AbortSignal, streamFn?: StreamFn): EventStream<AgentEvent, AgentMessage[]>;
|
|
50
63
|
/**
|
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": "18.0.
|
|
4
|
+
"version": "18.0.10",
|
|
5
5
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Stencil Labs, Inc.",
|
|
@@ -35,16 +35,16 @@
|
|
|
35
35
|
"fmt": "biome format --write ."
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@oh-my-pi/pi-ai": "18.0.
|
|
39
|
-
"@oh-my-pi/pi-catalog": "18.0.
|
|
40
|
-
"@oh-my-pi/pi-natives": "18.0.
|
|
41
|
-
"@oh-my-pi/pi-utils": "18.0.
|
|
42
|
-
"@oh-my-pi/pi-wire": "18.0.
|
|
43
|
-
"@oh-my-pi/snapcompact": "18.0.
|
|
38
|
+
"@oh-my-pi/pi-ai": "18.0.10",
|
|
39
|
+
"@oh-my-pi/pi-catalog": "18.0.10",
|
|
40
|
+
"@oh-my-pi/pi-natives": "18.0.10",
|
|
41
|
+
"@oh-my-pi/pi-utils": "18.0.10",
|
|
42
|
+
"@oh-my-pi/pi-wire": "18.0.10",
|
|
43
|
+
"@oh-my-pi/snapcompact": "18.0.10",
|
|
44
44
|
"@opentelemetry/api": "^1.9.1"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"@oh-my-pi/omptype": "18.0.
|
|
47
|
+
"@oh-my-pi/omptype": "18.0.10",
|
|
48
48
|
"@opentelemetry/context-async-hooks": "^2.9.0",
|
|
49
49
|
"@opentelemetry/sdk-trace-base": "^2.9.0",
|
|
50
50
|
"@types/bun": "^1.3.14"
|
package/src/agent-loop.ts
CHANGED
|
@@ -554,13 +554,36 @@ export function agentLoop(
|
|
|
554
554
|
return stream;
|
|
555
555
|
}
|
|
556
556
|
|
|
557
|
+
/**
|
|
558
|
+
* Trailing assistant message whose runnable tool calls have no results yet.
|
|
559
|
+
*
|
|
560
|
+
* A harness that strips failed/aborted tool results in order to re-execute
|
|
561
|
+
* the calls (e.g. `AgentSession.retry`'s tool replay) leaves the transcript
|
|
562
|
+
* in this shape; {@link agentLoopContinue} resumes it by running those calls
|
|
563
|
+
* before the next model call. Cursor exec-resolved blocks are excluded — the
|
|
564
|
+
* provider already executed those server-side. `length`-truncated turns never
|
|
565
|
+
* qualify: their trailing call arguments may be incomplete.
|
|
566
|
+
*/
|
|
567
|
+
export function unpairedToolCallTail(messages: readonly AgentMessage[]): AssistantMessage | undefined {
|
|
568
|
+
const tail = messages[messages.length - 1];
|
|
569
|
+
if (tail?.role !== "assistant") return undefined;
|
|
570
|
+
// Mirrors the loop's `runnableStop` rule for fresh turns.
|
|
571
|
+
if (tail.stopReason !== "toolUse" && tail.stopReason !== "stop") return undefined;
|
|
572
|
+
const hasRunnable = tail.content.some(
|
|
573
|
+
c => c.type === "toolCall" && (c as CursorExecResolvedCarrier)[kCursorExecResolved] !== true,
|
|
574
|
+
);
|
|
575
|
+
return hasRunnable ? tail : undefined;
|
|
576
|
+
}
|
|
577
|
+
|
|
557
578
|
/**
|
|
558
579
|
* Continue an agent loop from the current context without adding a new message.
|
|
559
580
|
* Used for retries - context already has user message or tool results.
|
|
560
581
|
*
|
|
561
582
|
* **Important:** The last message in context must convert to a `user` or `toolResult` message
|
|
562
|
-
* via `convertToLlm
|
|
563
|
-
*
|
|
583
|
+
* via `convertToLlm` — except for an assistant tail with unpaired runnable
|
|
584
|
+
* tool calls (see {@link unpairedToolCallTail}), which resumes by executing
|
|
585
|
+
* those calls first. Any other assistant tail is rejected here; other invalid
|
|
586
|
+
* tails cannot be validated since `convertToLlm` is only called once per turn.
|
|
564
587
|
*/
|
|
565
588
|
export function agentLoopContinue(
|
|
566
589
|
context: AgentContext,
|
|
@@ -572,7 +595,7 @@ export function agentLoopContinue(
|
|
|
572
595
|
throw new Error("Cannot continue: no messages in context");
|
|
573
596
|
}
|
|
574
597
|
|
|
575
|
-
if (context.messages[context.messages.length - 1].role === "assistant") {
|
|
598
|
+
if (context.messages[context.messages.length - 1].role === "assistant" && !unpairedToolCallTail(context.messages)) {
|
|
576
599
|
throw new Error("Cannot continue from message role: assistant");
|
|
577
600
|
}
|
|
578
601
|
|
|
@@ -1061,6 +1084,43 @@ async function runLoopBody(
|
|
|
1061
1084
|
let softSatisfies: SoftToolRequirement["satisfies"];
|
|
1062
1085
|
let directiveResolvedForTurn = false;
|
|
1063
1086
|
let turnOpen = false;
|
|
1087
|
+
// A trailing assistant message with unpaired tool calls: the harness
|
|
1088
|
+
// stripped the failed/aborted results so this run re-executes the calls
|
|
1089
|
+
// (AgentSession.retry's tool replay). Run them before the first model
|
|
1090
|
+
// call; the loop then continues normally on the fresh results. Queued
|
|
1091
|
+
// steering stays parked until after the batch — injecting a message
|
|
1092
|
+
// between the tool_use blocks and their results would break the
|
|
1093
|
+
// provider's pairing invariant.
|
|
1094
|
+
const resumeTail = unpairedToolCallTail(currentContext.messages);
|
|
1095
|
+
if (resumeTail) {
|
|
1096
|
+
stream.push({ type: "turn_start" });
|
|
1097
|
+
emitInputMessages(stream, messagesToEmit);
|
|
1098
|
+
messagesToEmit = [];
|
|
1099
|
+
turnOpen = true;
|
|
1100
|
+
const executionResult = await executeToolCalls(
|
|
1101
|
+
currentContext,
|
|
1102
|
+
resumeTail,
|
|
1103
|
+
signal,
|
|
1104
|
+
stream,
|
|
1105
|
+
config,
|
|
1106
|
+
telemetry,
|
|
1107
|
+
invokeAgentSpan,
|
|
1108
|
+
);
|
|
1109
|
+
for (const result of executionResult.toolResults) {
|
|
1110
|
+
currentContext.messages.push(result);
|
|
1111
|
+
newMessages.push(result);
|
|
1112
|
+
}
|
|
1113
|
+
await emitTurnEnd(stream, currentContext, resumeTail, executionResult.toolResults, config, signal, {
|
|
1114
|
+
willContinue: !isDeadlineExceeded(config.deadline),
|
|
1115
|
+
});
|
|
1116
|
+
turnOpen = false;
|
|
1117
|
+
// A tool hook may mark its completed result as terminal (e.g. subagent
|
|
1118
|
+
// yield) — same stop-before-next-model-call rule as the main loop.
|
|
1119
|
+
if (signal?.reason === TERMINAL_TOOL_RESULT_ABORT_REASON) {
|
|
1120
|
+
endAgentStream(stream, newMessages, telemetry, stepCounter.count);
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1064
1124
|
|
|
1065
1125
|
// Outer loop: continues when queued follow-up messages arrive after agent would stop
|
|
1066
1126
|
while (true) {
|
package/src/agent.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
normalizeMessagesForProvider,
|
|
35
35
|
normalizeTools,
|
|
36
36
|
resolveOwnedDialectFromEnv,
|
|
37
|
+
unpairedToolCallTail,
|
|
37
38
|
} from "./agent-loop";
|
|
38
39
|
import type { AppendOnlyContextManager } from "./append-only-context";
|
|
39
40
|
import { isProviderRefusalMessage } from "./replay-policy";
|
|
@@ -1249,6 +1250,15 @@ export class Agent {
|
|
|
1249
1250
|
throw new Error("No messages to continue from");
|
|
1250
1251
|
}
|
|
1251
1252
|
if (messages[messages.length - 1].role === "assistant") {
|
|
1253
|
+
// A tail with unpaired runnable tool calls resumes by re-executing
|
|
1254
|
+
// them (see `unpairedToolCallTail` in agent-loop). This must win over
|
|
1255
|
+
// queued-message delivery: injecting a message between the tool_use
|
|
1256
|
+
// blocks and their results would break the provider's pairing
|
|
1257
|
+
// invariant. Queued messages drain inside the resumed loop instead.
|
|
1258
|
+
if (unpairedToolCallTail(messages)) {
|
|
1259
|
+
await this.#runLoop(undefined, undefined, signal, true);
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1252
1262
|
const queuedSteering = await this.#dequeueSteeringMessagesAfterHooks(dequeueSignal);
|
|
1253
1263
|
if (queuedSteering.length > 0) {
|
|
1254
1264
|
await this.#runLoop(queuedSteering, { skipInitialSteeringPoll: true }, signal, true);
|