@oh-my-pi/pi-agent-core 18.11.0 → 18.11.1

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.11.1] - 2026-10-15
6
+
7
+ ### Added
8
+
9
+ - `Tokenizer.countLatestTurn`: counts a message as the transcript's latest assistant turn, which an Anthropic Messages replay keeps byte-for-byte ([#15791](https://github.com/can1357/oh-my-pi/pull/15791) by [@will-bogusz](https://github.com/will-bogusz))
10
+ - `Agent.prioritizeSteering`: moves given pending steering messages ahead of older ones so one-at-a-time delivery takes them first ([#15642](https://github.com/can1357/oh-my-pi/pull/15642))
11
+
12
+ ### Fixed
13
+
14
+ - Fixed a Cursor tool call re-sent by the server persisting two results under one id; the re-run's result replaces the first ([#15749](https://github.com/can1357/oh-my-pi/issues/15749))
15
+ - Fixed compaction keeping more recent history than `keepRecentTokens` when the latest Claude turn on the Anthropic Messages API ended its tool calls on `stop` or `length`: the replay keeps that turn's signed reasoning, but the cut point counted only its visible blocks ([#15791](https://github.com/can1357/oh-my-pi/pull/15791) by [@will-bogusz](https://github.com/will-bogusz))
16
+
5
17
  ## [18.11.0] - 2026-10-14
6
18
 
7
19
  ### Breaking Changes
@@ -514,6 +514,13 @@ export declare class Agent {
514
514
  * Delivered after current tool execution, skips remaining tools.
515
515
  */
516
516
  steer(m: AgentMessage): void;
517
+ /**
518
+ * Move the given pending steering messages (by identity) ahead of the rest,
519
+ * keeping both groups' relative order, so one-at-a-time delivery takes them
520
+ * first. Messages no longer pending, including a claimed batch already
521
+ * being delivered, are ignored.
522
+ */
523
+ prioritizeSteering(messages: readonly AgentMessage[]): void;
517
524
  /**
518
525
  * Queue a follow-up message to be processed after the agent finishes.
519
526
  * Delivered only when agent has no more tool calls or steering messages.
@@ -137,6 +137,14 @@ export declare function shouldCompact(contextTokens: number, contextWindow: numb
137
137
  * takes the floor.)
138
138
  */
139
139
  export declare function compactionContextTokens(providerContextTokens: number, storedConversationEstimate: number): number;
140
+ /**
141
+ * The prompt budget below `contextWindow`: the window less
142
+ * {@link resolveBudgetReserveTokens}, capped at `contextWindow - 1` so it never
143
+ * reaches the whole window even when the reserve resolves to 0. Independent of
144
+ * any configured trigger; the default {@link resolveThresholdTokens} and
145
+ * length-stop recovery's "did the window run out" check both use it.
146
+ */
147
+ export declare function promptBudgetTokens(contextWindow: number, settings: CompactionSettings): number;
140
148
  export declare function resolveThresholdTokens(contextWindow: number, settings: CompactionSettings): number;
141
149
  /**
142
150
  * True for entries that begin a conversational turn (a user request, a
@@ -111,6 +111,16 @@ export declare class Tokenizer {
111
111
  * the native remote-compaction probe applies.
112
112
  */
113
113
  countMessage(message: AgentMessage, options?: MessageCountOptions): number;
114
+ /**
115
+ * {@link countMessage} for the transcript's latest surviving assistant turn
116
+ * (`isSurvivingAssistant`), which an Anthropic Messages replay keeps
117
+ * byte-for-byte (`transformMessages`): a turn whose tool calls ended without
118
+ * a tool-use stop keeps its signatures then, so its reasoning is charged. A
119
+ * count of one message cannot see its position; a caller walking back from
120
+ * the end of the transcript counts that turn here. Not memoized: it differs
121
+ * from {@link countMessage} only for that one turn.
122
+ */
123
+ countLatestTurn(message: AgentMessage, options?: MessageCountOptions): number;
114
124
  /** Sum of {@link countMessage} over `messages`. */
115
125
  countMessages(messages: readonly AgentMessage[], options?: MessageCountOptions): number;
116
126
  /**
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.11.0",
4
+ "version": "18.11.1",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -38,16 +38,16 @@
38
38
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
39
39
  },
40
40
  "dependencies": {
41
- "@oh-my-pi/pi-ai": "18.11.0",
42
- "@oh-my-pi/pi-catalog": "18.11.0",
43
- "@oh-my-pi/pi-natives": "18.11.0",
44
- "@oh-my-pi/pi-utils": "18.11.0",
45
- "@oh-my-pi/pi-wire": "18.11.0",
46
- "@oh-my-pi/snapcompact": "18.11.0",
41
+ "@oh-my-pi/pi-ai": "18.11.1",
42
+ "@oh-my-pi/pi-catalog": "18.11.1",
43
+ "@oh-my-pi/pi-natives": "18.11.1",
44
+ "@oh-my-pi/pi-utils": "18.11.1",
45
+ "@oh-my-pi/pi-wire": "18.11.1",
46
+ "@oh-my-pi/snapcompact": "18.11.1",
47
47
  "@opentelemetry/api": "^1.9.1"
48
48
  },
49
49
  "devDependencies": {
50
- "@oh-my-pi/omptype": "18.11.0",
50
+ "@oh-my-pi/omptype": "18.11.1",
51
51
  "@opentelemetry/context-async-hooks": "^2.9.0",
52
52
  "@opentelemetry/sdk-trace-base": "^2.9.0",
53
53
  "@types/bun": "^1.3.14"
package/src/agent.ts CHANGED
@@ -1390,6 +1390,20 @@ export class Agent {
1390
1390
  this.#emitQueueChanged();
1391
1391
  }
1392
1392
 
1393
+ /**
1394
+ * Move the given pending steering messages (by identity) ahead of the rest,
1395
+ * keeping both groups' relative order, so one-at-a-time delivery takes them
1396
+ * first. Messages no longer pending, including a claimed batch already
1397
+ * being delivered, are ignored.
1398
+ */
1399
+ prioritizeSteering(messages: readonly AgentMessage[]): void {
1400
+ const wanted = new Set(messages);
1401
+ const front = this.#steeringQueue.filter(message => wanted.has(message));
1402
+ if (front.every((message, index) => this.#steeringQueue[index] === message)) return;
1403
+ this.#steeringQueue = [...front, ...this.#steeringQueue.filter(message => !wanted.has(message))];
1404
+ this.#emitQueueChanged();
1405
+ }
1406
+
1393
1407
  /**
1394
1408
  * Queue a follow-up message to be processed after the agent finishes.
1395
1409
  * Delivered only when agent has no more tool calls or steering messages.
@@ -1808,10 +1822,19 @@ export class Agent {
1808
1822
  // that, a transformer resolving after the swap would patch a detached
1809
1823
  // object while the persisted result kept the original payload — the
1810
1824
  // rewrite silently lost.
1825
+ //
1826
+ // A result for a call already buffered is the server re-dispatching
1827
+ // that call; the provider keeps one block for it, so the re-run's
1828
+ // result replaces the first rather than pairing a second result with
1829
+ // the same id (issue #15749).
1811
1830
  const entry: CursorToolResultEntry = {
1812
1831
  toolResult: message,
1813
1832
  additionalContext: (message as ToolResultWithAdditionalContext)[TOOL_RESULT_ADDITIONAL_CONTEXT],
1814
1833
  };
1834
+ const superseded = this.#cursorToolResultBuffer.findIndex(
1835
+ ({ toolResult }) => toolResult.toolCallId === message.toolCallId,
1836
+ );
1837
+ if (superseded >= 0) this.#cursorToolResultBuffer.splice(superseded, 1);
1815
1838
  this.#cursorToolResultBuffer.push(entry);
1816
1839
  const transform = this.#cursorOnToolResult;
1817
1840
  if (transform) {
@@ -34,6 +34,7 @@ import {
34
34
  import type { InputItem as CodexInputItem } from "@oh-my-pi/pi-ai/providers/openai-codex/request-transformer";
35
35
  import { convertTools } from "@oh-my-pi/pi-ai/providers/openai-responses";
36
36
  import { buildResponsesInput, resolveOpenAICompatPolicy } from "@oh-my-pi/pi-ai/providers/openai-shared";
37
+ import { isSurvivingAssistant } from "@oh-my-pi/pi-ai/providers/transform-messages";
37
38
  import { stripOpenAIResponsesOutputOnlyStatusesForReplay } from "@oh-my-pi/pi-ai/utils";
38
39
  import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
39
40
  import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking";
@@ -375,9 +376,11 @@ export function compactionContextTokens(providerContextTokens: number, storedCon
375
376
  /**
376
377
  * The prompt budget below `contextWindow`: the window less
377
378
  * {@link resolveBudgetReserveTokens}, capped at `contextWindow - 1` so it never
378
- * reaches the whole window even when the reserve resolves to 0.
379
+ * reaches the whole window even when the reserve resolves to 0. Independent of
380
+ * any configured trigger; the default {@link resolveThresholdTokens} and
381
+ * length-stop recovery's "did the window run out" check both use it.
379
382
  */
380
- function promptBudgetTokens(contextWindow: number, settings: CompactionSettings): number {
383
+ export function promptBudgetTokens(contextWindow: number, settings: CompactionSettings): number {
381
384
  return Math.max(0, Math.min(contextWindow - 1, contextWindow - resolveBudgetReserveTokens(contextWindow, settings)));
382
385
  }
383
386
 
@@ -535,13 +538,23 @@ export function findCutPoint(
535
538
  // belonging to an assistant. Checking individual messages can either retain
536
539
  // the oversized older assistant or miss its boundary and retain all history.
537
540
  let accumulatedTokens = 0;
541
+ // The latest surviving assistant turn replays byte-for-byte, signatures of
542
+ // abandoned tool calls included, so it is counted as the latest.
543
+ let latestTurnIndex = -1;
544
+ for (let i = entries.length - 1; i >= 0 && latestTurnIndex < 0; i--) {
545
+ const message = getMessageFromEntry(entries[i]);
546
+ if (message?.role === "assistant" && isSurvivingAssistant(message)) latestTurnIndex = i;
547
+ }
538
548
  let cutPointIndex = cutPoints.length - 1;
539
549
  let cutIndex = cutPoints[cutPointIndex];
540
550
 
541
551
  for (let i = endIndex - 1; i >= startIndex; i--) {
542
552
  const entry = entries[i];
543
553
  const message = getMessageFromEntry(entry);
544
- if (message) accumulatedTokens += tokenizer.countMessage(message);
554
+ if (message) {
555
+ accumulatedTokens +=
556
+ i === latestTurnIndex ? tokenizer.countLatestTurn(message) : tokenizer.countMessage(message);
557
+ }
545
558
  if (i !== cutPoints[cutPointIndex]) continue;
546
559
  if (accumulatedTokens > keepRecentTokens) break;
547
560
  cutIndex = i;
package/src/tokenizer.ts CHANGED
@@ -295,7 +295,7 @@ export class Tokenizer {
295
295
  */
296
296
  countMessage(message: AgentMessage, options?: MessageCountOptions): number {
297
297
  const floored = options?.excludeEncryptedReasoning === true;
298
- if (!isEstimateCacheable(message)) return this.#measureMessage(message, floored);
298
+ if (!isEstimateCacheable(message)) return this.#measureMessage(message, floored, false);
299
299
  const version = messageEstimateVersion(message);
300
300
  let entry = this.#estimates.get(message);
301
301
  if (entry === undefined || entry.version !== version) {
@@ -304,12 +304,25 @@ export class Tokenizer {
304
304
  }
305
305
  const cached = floored ? entry.floored : entry.default;
306
306
  if (cached !== undefined) return cached;
307
- const result = this.#measureMessage(message, floored);
307
+ const result = this.#measureMessage(message, floored, false);
308
308
  if (floored) entry.floored = result;
309
309
  else entry.default = result;
310
310
  return result;
311
311
  }
312
312
 
313
+ /**
314
+ * {@link countMessage} for the transcript's latest surviving assistant turn
315
+ * (`isSurvivingAssistant`), which an Anthropic Messages replay keeps
316
+ * byte-for-byte (`transformMessages`): a turn whose tool calls ended without
317
+ * a tool-use stop keeps its signatures then, so its reasoning is charged. A
318
+ * count of one message cannot see its position; a caller walking back from
319
+ * the end of the transcript counts that turn here. Not memoized: it differs
320
+ * from {@link countMessage} only for that one turn.
321
+ */
322
+ countLatestTurn(message: AgentMessage, options?: MessageCountOptions): number {
323
+ return this.#measureMessage(message, options?.excludeEncryptedReasoning === true, true);
324
+ }
325
+
313
326
  /** Sum of {@link countMessage} over `messages`. */
314
327
  countMessages(messages: readonly AgentMessage[], options?: MessageCountOptions): number {
315
328
  let total = 0;
@@ -361,7 +374,7 @@ export class Tokenizer {
361
374
  return total;
362
375
  }
363
376
 
364
- #measureMessage(message: AgentMessage, excludeEncryptedReasoning: boolean): number {
377
+ #measureMessage(message: AgentMessage, excludeEncryptedReasoning: boolean, latestTurn: boolean): number {
365
378
  const fragments: string[] = [];
366
379
  let extra = 0;
367
380
  // Declaration-merged app roles (the coding-agent's bashExecution) are
@@ -407,14 +420,20 @@ export class Tokenizer {
407
420
  // The replay's signature rule (`transformMessages`): an aborted or
408
421
  // errored turn's final block can hold a partial signature, and a turn
409
422
  // whose tool calls ended without a tool-use stop is abandoned, so all
410
- // its signatures are stripped. Every other block completed. The latest
411
- // assistant turn keeps an abandoned turn's signatures byte-for-byte;
412
- // a per-message count cannot see position, so it follows the rule for
413
- // older turns, and that latest turn's own usage anchors transcript counts.
423
+ // its signatures are stripped. Every other block completed. On the
424
+ // Anthropic Messages API the latest assistant turn keeps an abandoned
425
+ // turn's signatures byte-for-byte (Bedrock's Converse replay strips
426
+ // them too); a per-message count cannot see position, so it follows
427
+ // the rule for older turns unless the caller says it is the latest
428
+ // (`countLatestTurn`).
414
429
  const content = message.content;
415
430
  const invalidStop = message.stopReason === "aborted" || message.stopReason === "error";
431
+ const keepsLatestSignatures = latestTurn && message.api === "anthropic-messages";
416
432
  const abandonedToolUse =
417
- !invalidStop && message.stopReason !== "toolUse" && content.some(block => block.type === "toolCall");
433
+ !keepsLatestSignatures &&
434
+ !invalidStop &&
435
+ message.stopReason !== "toolUse" &&
436
+ content.some(block => block.type === "toolCall");
418
437
  const untrustedIndex = invalidStop ? content.length - 1 : -1;
419
438
  let signedReasoning = false;
420
439
  for (let index = 0; index < content.length; index++) {