@oh-my-pi/pi-agent-core 18.0.8 → 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 CHANGED
@@ -2,6 +2,18 @@
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
+
11
+ ## [18.0.9] - 2026-08-28
12
+
13
+ ### Fixed
14
+
15
+ - Fixed `/shake elide` handling of mixed tool results so images are preserved and token savings are reported accurately.
16
+
5
17
  ## [18.0.7] - 2026-08-26
6
18
 
7
19
  ### 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`. If it doesn't, the LLM provider will reject the request.
47
- * This cannot be validated here since `convertToLlm` is only called once per turn.
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
  /**
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Context-reducing surgical compaction ("shake").
3
3
  *
4
- * `shake` drops heavy content out of the live context mechanically: whole
5
- * tool-call results and large fenced/XML blocks are replaced with short
6
- * placeholders. This module is the pure layer — region detection and in-place
4
+ * `shake` drops heavy content out of the live context mechanically:
5
+ * tool-result text and large fenced/XML blocks are replaced with short
6
+ * placeholders while non-text tool-result content is preserved. This module
7
+ * is the pure layer — region detection and in-place
7
8
  * mutation only. Artifact offload, persistence, and provider-session teardown
8
9
  * are orchestrated by the caller (`AgentSession.shake`).
9
10
  *
@@ -45,6 +46,7 @@ export declare const RESCUE_SHAKE_CONFIG: ShakeConfig;
45
46
  export interface ToolResultShakeRegion {
46
47
  kind: "toolResult";
47
48
  entry: SessionMessageEntry;
49
+ /** Estimated tokens in the removable text only; retained images are excluded. */
48
50
  tokens: number;
49
51
  originalText: string;
50
52
  /** Human label for the offload doc (tool name). */
@@ -68,10 +70,11 @@ export type ShakeRegion = ToolResultShakeRegion | BlockShakeRegion;
68
70
  * Pure detection: locate every eligible shake region on a branch.
69
71
  *
70
72
  * Walks the protect-recent window (most recent `protectTokens` of context is
71
- * kept intact), collects whole tool-result messages (honoring `protectedTools`
72
- * and skipping already-pruned results) and large fenced/XML blocks inside
73
- * user/developer/assistant/custom messages. Tool results flagged contextually
74
- * useless by their tool bypass the protect window — there is nothing recent
73
+ * kept intact), collects the text from eligible tool-result messages
74
+ * (honoring `protectedTools` and skipping already-pruned results) and large
75
+ * fenced/XML blocks inside user/developer/assistant/custom messages.
76
+ * Contextually useless tool results bypass the protect window — there is
77
+ * nothing recent
75
78
  * worth keeping in them. Returns regions in document order.
76
79
  *
77
80
  * `toolCall` blocks are never touched (tool-call/result pairing is preserved)
@@ -82,8 +85,9 @@ export declare function collectShakeRegions(entries: SessionEntry[], tokenizer:
82
85
  /**
83
86
  * Pure mutation: replace a single region's content in place.
84
87
  *
85
- * Tool-result: replaces the message content with the placeholder text and
86
- * stamps `prunedAt`. Block: splices `replacement` over `[start, end)` of the
88
+ * Tool-result: replaces its first non-empty text block with the placeholder,
89
+ * removes its other text blocks, preserves every non-text block, and stamps
90
+ * `prunedAt`. Block: splices `replacement` over `[start, end)` of the
87
91
  * target text block. When several block regions share one text block they MUST
88
92
  * be applied highest-start-first so earlier offsets stay valid — use
89
93
  * {@link applyShakeRegions}, which orders them correctly.
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.8",
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.8",
39
- "@oh-my-pi/pi-catalog": "18.0.8",
40
- "@oh-my-pi/pi-natives": "18.0.8",
41
- "@oh-my-pi/pi-utils": "18.0.8",
42
- "@oh-my-pi/pi-wire": "18.0.8",
43
- "@oh-my-pi/snapcompact": "18.0.8",
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.8",
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`. If it doesn't, the LLM provider will reject the request.
563
- * This cannot be validated here since `convertToLlm` is only called once per turn.
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);
@@ -85,7 +85,16 @@ function getPrunedToolResultContent(message: ToolResultMessage): (TextContent |
85
85
  }
86
86
  const textBlocks = message.content.filter((content): content is TextContent => content.type === "text");
87
87
  const text = textBlocks.map(block => block.text).join("") || "[Output truncated]";
88
- return [{ type: "text", text }];
88
+ const firstTextIndex = message.content.findIndex(content => content.type === "text");
89
+ if (firstTextIndex < 0) return [{ type: "text", text }, ...message.content];
90
+
91
+ const content: (TextContent | ImageContent)[] = [];
92
+ for (let index = 0; index < message.content.length; index++) {
93
+ const block = message.content[index];
94
+ if (block.type !== "text") content.push(block);
95
+ else if (index === firstTextIndex) content.push({ type: "text", text });
96
+ }
97
+ return content;
89
98
  }
90
99
 
91
100
  export function renderBranchSummaryContext(summary: string): string {
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Context-reducing surgical compaction ("shake").
3
3
  *
4
- * `shake` drops heavy content out of the live context mechanically: whole
5
- * tool-call results and large fenced/XML blocks are replaced with short
6
- * placeholders. This module is the pure layer — region detection and in-place
4
+ * `shake` drops heavy content out of the live context mechanically:
5
+ * tool-result text and large fenced/XML blocks are replaced with short
6
+ * placeholders while non-text tool-result content is preserved. This module
7
+ * is the pure layer — region detection and in-place
7
8
  * mutation only. Artifact offload, persistence, and provider-session teardown
8
9
  * are orchestrated by the caller (`AgentSession.shake`).
9
10
  *
@@ -80,6 +81,7 @@ const PLACEHOLDER_TOKEN_ESTIMATE = 16;
80
81
  export interface ToolResultShakeRegion {
81
82
  kind: "toolResult";
82
83
  entry: SessionMessageEntry;
84
+ /** Estimated tokens in the removable text only; retained images are excluded. */
83
85
  tokens: number;
84
86
  originalText: string;
85
87
  /** Human label for the offload doc (tool name). */
@@ -114,11 +116,19 @@ function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefine
114
116
  return message as ToolResultMessage;
115
117
  }
116
118
 
117
- function toolResultText(message: ToolResultMessage): string {
118
- return message.content
119
- .filter((block): block is TextContent => block.type === "text")
120
- .map(block => block.text)
121
- .join("\n");
119
+ function toolResultText(
120
+ message: ToolResultMessage,
121
+ tokenizer: Tokenizer,
122
+ ): { originalText: string; tokens: number } | undefined {
123
+ const fragments: string[] = [];
124
+ for (const block of message.content) {
125
+ if (block.type === "text" && block.text.length > 0) fragments.push(block.text);
126
+ }
127
+ if (fragments.length === 0) return undefined;
128
+ return {
129
+ originalText: fragments.join("\n"),
130
+ tokens: tokenizer.countTokens(fragments),
131
+ };
122
132
  }
123
133
 
124
134
  /** Estimate the token contribution of an entry for the protect-recent window. */
@@ -292,10 +302,11 @@ function scanContentBlocks(
292
302
  * Pure detection: locate every eligible shake region on a branch.
293
303
  *
294
304
  * Walks the protect-recent window (most recent `protectTokens` of context is
295
- * kept intact), collects whole tool-result messages (honoring `protectedTools`
296
- * and skipping already-pruned results) and large fenced/XML blocks inside
297
- * user/developer/assistant/custom messages. Tool results flagged contextually
298
- * useless by their tool bypass the protect window — there is nothing recent
305
+ * kept intact), collects the text from eligible tool-result messages
306
+ * (honoring `protectedTools` and skipping already-pruned results) and large
307
+ * fenced/XML blocks inside user/developer/assistant/custom messages.
308
+ * Contextually useless tool results bypass the protect window — there is
309
+ * nothing recent
299
310
  * worth keeping in them. Returns regions in document order.
300
311
  *
301
312
  * `toolCall` blocks are never touched (tool-call/result pairing is preserved)
@@ -339,13 +350,13 @@ export function collectShakeRegions(entries: SessionEntry[], tokenizer: Tokenize
339
350
  if (toolResult.prunedAt !== undefined) continue;
340
351
  if (isProtectedToolResult(toolResult, toolCallsById.get(toolResult.toolCallId), config.protectedTools))
341
352
  continue;
342
- const text = toolResultText(toolResult);
343
- if (text.length === 0) continue;
353
+ const text = toolResultText(toolResult, tokenizer);
354
+ if (!text) continue;
344
355
  regions.push({
345
356
  kind: "toolResult",
346
357
  entry: entry as SessionMessageEntry,
347
- tokens: tokenizer.countMessage(toolResult as AgentMessage),
348
- originalText: text,
358
+ tokens: text.tokens,
359
+ originalText: text.originalText,
349
360
  label: toolResult.toolName,
350
361
  });
351
362
  continue;
@@ -414,8 +425,9 @@ function getBlockTextSlot(entry: SessionMessageEntry | CustomMessageEntry, block
414
425
  /**
415
426
  * Pure mutation: replace a single region's content in place.
416
427
  *
417
- * Tool-result: replaces the message content with the placeholder text and
418
- * stamps `prunedAt`. Block: splices `replacement` over `[start, end)` of the
428
+ * Tool-result: replaces its first non-empty text block with the placeholder,
429
+ * removes its other text blocks, preserves every non-text block, and stamps
430
+ * `prunedAt`. Block: splices `replacement` over `[start, end)` of the
419
431
  * target text block. When several block regions share one text block they MUST
420
432
  * be applied highest-start-first so earlier offsets stay valid — use
421
433
  * {@link applyShakeRegions}, which orders them correctly.
@@ -423,7 +435,15 @@ function getBlockTextSlot(entry: SessionMessageEntry | CustomMessageEntry, block
423
435
  export function applyShakeRegion(region: ShakeRegion, replacement: string): void {
424
436
  if (region.kind === "toolResult") {
425
437
  const message = region.entry.message as ToolResultMessage;
426
- message.content = [{ type: "text", text: replacement }];
438
+ const replacementIndex = message.content.findIndex(block => block.type === "text" && block.text.length > 0);
439
+ if (replacementIndex < 0) return;
440
+ const kept: typeof message.content = [];
441
+ for (let index = 0; index < message.content.length; index++) {
442
+ const block = message.content[index];
443
+ if (block.type !== "text") kept.push(block);
444
+ else if (index === replacementIndex) kept.push({ type: "text", text: replacement });
445
+ }
446
+ message.content = kept;
427
447
  message.prunedAt = Date.now();
428
448
  invalidateMessageCache(message as AgentMessage);
429
449
  return;