@oh-my-pi/pi-agent-core 18.11.2 → 18.12.0

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.12.0] - 2026-10-17
6
+
7
+ ### Fixed
8
+
9
+ - Fixed Cursor turns failing with `PROVIDER_ERROR` when a follow-up (a finished background job, a late diagnostic, a reminder) arrived while the previous turn was still streaming ([#15821](https://github.com/can1357/oh-my-pi/pull/15821) by [@robfeldmann](https://github.com/robfeldmann))
10
+
11
+ ## [18.11.3] - 2026-10-16
12
+
13
+ ### Added
14
+
15
+ - `prepareQueuedMessages` receives which queue (`steering` or `followUp`) a batch came from ([#16026](https://github.com/can1357/oh-my-pi/pull/16026))
16
+
5
17
  ## [18.11.2] - 2026-10-16
6
18
 
7
19
  ### Added
@@ -58,11 +58,16 @@ export declare function truncateToolResultForSummary(text: string): string;
58
58
  export declare function escapeSummaryBoundaryTags(text: string): string;
59
59
  /**
60
60
  * Serialize LLM messages as plain summary input without provider control tokens.
61
+ * `dropThinking` is the summarizer's resolved `classifier-cot-extraction` policy.
61
62
  */
62
- export declare function serializeConversationForSummary(messages: Message[], dialect?: Dialect): string;
63
+ export declare function serializeConversationForSummary(messages: Message[], dialect?: Dialect, dropThinking?: boolean): string;
63
64
  /**
64
65
  * Serialize LLM messages to transcript text.
65
66
  * Call convertToLlm() first to handle custom message types.
67
+ * `dropThinking` omits assistant reasoning from dialect transcripts: set it from
68
+ * the reader's resolved `classifier-cot-extraction` catalog policy (Claude's
69
+ * classifier refuses inputs that reproduce the model's own reasoning as text,
70
+ * "reasoning_extraction"). Reasoning is ephemeral and low-signal for a summary.
66
71
  */
67
- export declare function serializeConversation(messages: Message[], dialect?: Dialect): string;
72
+ export declare function serializeConversation(messages: Message[], dialect?: Dialect, dropThinking?: boolean): string;
68
73
  export declare const SUMMARIZATION_SYSTEM_PROMPT: string;
@@ -12,8 +12,10 @@ export interface QueuedMessagePreparation {
12
12
  /** Append context after the originals; undefined stops this attempt, retaining originals unless explicitly removed. */
13
13
  commit(): readonly AgentMessage[] | undefined;
14
14
  }
15
+ /** Which agent queue a batch came from: steering amends the running task, follow-ups start the next one. */
16
+ export type QueuedMessageQueue = "steering" | "followUp";
15
17
  /** Prepare an exclusively claimed batch. Undefined delivers unchanged; the signal also aborts when the claim is cancelled. */
16
- export type PrepareQueuedMessages = (messages: readonly AgentMessage[], signal: AbortSignal) => QueuedMessagePreparation | undefined | Promise<QueuedMessagePreparation | undefined>;
18
+ export type PrepareQueuedMessages = (messages: readonly AgentMessage[], signal: AbortSignal, queue: QueuedMessageQueue) => QueuedMessagePreparation | undefined | Promise<QueuedMessagePreparation | undefined>;
17
19
  /** Called once an aside has been inserted into the agent's live context. */
18
20
  export declare const ASIDE_MESSAGE_COMMIT: unique symbol;
19
21
  /** Symbol-keyed handoff for one finalized, tool-owned stream speculation session. */
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.2",
4
+ "version": "18.12.0",
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.2",
42
- "@oh-my-pi/pi-catalog": "18.11.2",
43
- "@oh-my-pi/pi-natives": "18.11.2",
44
- "@oh-my-pi/pi-utils": "18.11.2",
45
- "@oh-my-pi/pi-wire": "18.11.2",
46
- "@oh-my-pi/snapcompact": "18.11.2",
41
+ "@oh-my-pi/pi-ai": "18.12.0",
42
+ "@oh-my-pi/pi-catalog": "18.12.0",
43
+ "@oh-my-pi/pi-natives": "18.12.0",
44
+ "@oh-my-pi/pi-utils": "18.12.0",
45
+ "@oh-my-pi/pi-wire": "18.12.0",
46
+ "@oh-my-pi/snapcompact": "18.12.0",
47
47
  "@opentelemetry/api": "^1.9.1"
48
48
  },
49
49
  "devDependencies": {
50
- "@oh-my-pi/omptype": "18.11.2",
50
+ "@oh-my-pi/omptype": "18.12.0",
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-loop.ts CHANGED
@@ -76,7 +76,13 @@ import {
76
76
  startInvokeAgentSpan,
77
77
  } from "./telemetry";
78
78
  import { validateAgentToolArguments } from "./tool-arguments";
79
- import { createAdditionalContextMessage, isNonBlankContext, joinAdditionalContext } from "./tool-context";
79
+ import {
80
+ createAdditionalContextMessage,
81
+ isNonBlankContext,
82
+ joinAdditionalContext,
83
+ TOOL_RESULT_ADDITIONAL_CONTEXT,
84
+ type ToolResultWithAdditionalContext,
85
+ } from "./tool-context";
80
86
  import dsmlToolCallLeakPrompt from "./prompts/dsml-tool-call-leak.md" with { type: "text" };
81
87
  import type {
82
88
  AgentContext,
@@ -897,7 +903,11 @@ export function normalizeMessagesForProvider(
897
903
  messages: Context["messages"],
898
904
  model: AgentLoopConfig["model"],
899
905
  ): Context["messages"] {
900
- if (model.provider !== "cerebras") {
906
+ // The anthropic and ollama transports apply `stripThinkingHistory` with their
907
+ // own turn-shape rules; chat completions has no wire-level handling, so the
908
+ // history is stripped here.
909
+ const compat = model.api === "openai-completions" ? model.compat : undefined;
910
+ if (!compat || !("stripThinkingHistory" in compat) || compat.stripThinkingHistory !== true) {
901
911
  return messages;
902
912
  }
903
913
 
@@ -2082,6 +2092,50 @@ async function streamAssistantResponse(
2082
2092
  return userOnResponse?.(response, modelInfo);
2083
2093
  };
2084
2094
 
2095
+ // Cursor executes its tools server-side during the stream and hands each
2096
+ // result to `cursorOnToolResult`; the host persists them after the assistant
2097
+ // message, so this loop never runs them and never sees them. A follow-up the
2098
+ // loop picks up after this turn needs them in its own context, or the next
2099
+ // request rebuilds this turn with every call unpaired. The host follows the
2100
+ // results with one message carrying their passive context, so that is
2101
+ // mirrored too. Entries are reserved synchronously so concurrent transforms
2102
+ // keep their arrival order. When the server re-sends a call it already ran
2103
+ // (issue #15749), the newer result replaces the earlier one, matching the
2104
+ // single result the host persists.
2105
+ const cursorToolResults: {
2106
+ result: ToolResultMessage;
2107
+ additionalContext: string | undefined;
2108
+ /** The host transform, which the provider does not always await. */
2109
+ pending: Promise<void>;
2110
+ }[] = [];
2111
+ const userCursorOnToolResult = config.cursorOnToolResult;
2112
+ const collectCursorToolResult: AgentLoopConfig["cursorOnToolResult"] = userCursorOnToolResult
2113
+ ? async result => {
2114
+ const superseded = cursorToolResults.findIndex(entry => entry.result.toolCallId === result.toolCallId);
2115
+ if (superseded >= 0) cursorToolResults.splice(superseded, 1);
2116
+ // Read before the transform, which may return a message without it.
2117
+ const additionalContext = (result as ToolResultWithAdditionalContext)[TOOL_RESULT_ADDITIONAL_CONTEXT];
2118
+ const { promise: pending, resolve: settle } = Promise.withResolvers<void>();
2119
+ const entry = { result, additionalContext, pending };
2120
+ cursorToolResults.push(entry);
2121
+ try {
2122
+ const settled = await userCursorOnToolResult(result);
2123
+ if (settled) entry.result = settled;
2124
+ return settled;
2125
+ } finally {
2126
+ settle();
2127
+ }
2128
+ }
2129
+ : undefined;
2130
+ // Like the host before it persists, wait for transforms still running so the
2131
+ // next request carries the result the transcript stores.
2132
+ const appendCursorToolResults = async (): Promise<void> => {
2133
+ await Promise.all(cursorToolResults.map(entry => entry.pending));
2134
+ for (const { result } of cursorToolResults) context.messages.push(result);
2135
+ const additionalContext = joinAdditionalContext(cursorToolResults.map(entry => entry.additionalContext));
2136
+ if (additionalContext !== undefined) context.messages.push(createAdditionalContextMessage(additionalContext));
2137
+ };
2138
+
2085
2139
  const finishChat = async (message: AssistantMessage): Promise<void> => {
2086
2140
  await finishChatSpan(telemetry, chatSpan, message, {
2087
2141
  stepNumber: chatStepNumber,
@@ -2106,6 +2160,7 @@ async function streamAssistantResponse(
2106
2160
  cwd: effectiveCwd,
2107
2161
  signal: finalRequestSignal,
2108
2162
  onResponse: captureOnResponse,
2163
+ cursorOnToolResult: collectCursorToolResult,
2109
2164
  liveSteering: providerCall.liveSteering,
2110
2165
  });
2111
2166
  if (promptToolWireTools && ownedDialect) {
@@ -2294,6 +2349,7 @@ async function streamAssistantResponse(
2294
2349
  } else {
2295
2350
  context.messages.push(finalMessage);
2296
2351
  }
2352
+ await appendCursorToolResults();
2297
2353
  if (!addedPartial) {
2298
2354
  stream.push({ type: "message_start", message: snapshotAssistantMessage(finalMessage) });
2299
2355
  }
@@ -2574,6 +2630,7 @@ async function streamAssistantResponse(
2574
2630
  context.messages.push(trailing);
2575
2631
  stream.push({ type: "message_start", message: snapshotAssistantMessage(trailing) });
2576
2632
  }
2633
+ await appendCursorToolResults();
2577
2634
  stream.push({ type: "message_end", message: snapshotAssistantMessage(trailing) });
2578
2635
  await finishChat(trailing);
2579
2636
  speculationSettled = true;
package/src/agent.ts CHANGED
@@ -60,6 +60,7 @@ import type {
60
60
  AgentTurnEndContext,
61
61
  AsideMessage,
62
62
  PrepareQueuedMessages,
63
+ QueuedMessageQueue,
63
64
  SpeculativeToolExecutionConfig,
64
65
  StreamFn,
65
66
  ToolCallContext,
@@ -385,8 +386,6 @@ interface CursorToolResultEntry {
385
386
  additionalContext?: string;
386
387
  }
387
388
 
388
- type QueuedMessageQueue = "steering" | "followUp";
389
-
390
389
  interface LiveSteeredEntry {
391
390
  message: AgentMessage;
392
391
  controller: AbortController | undefined;
@@ -1009,7 +1008,7 @@ export class Agent {
1009
1008
  this.#queuedMessageClaims[queue] = claim;
1010
1009
  const preparationSignal = AbortSignal.any([signal, claim.controller.signal]);
1011
1010
  try {
1012
- const preparation = await prepare(messages, preparationSignal);
1011
+ const preparation = await prepare(messages, preparationSignal, queue);
1013
1012
  signal.throwIfAborted();
1014
1013
  if (preparationSignal.aborted || this.#queuedMessageClaims[queue] !== claim) return [];
1015
1014
  const additional = preparation?.commit();
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { Api, ApiKey, AssistantMessage, Context, Model, SimpleStreamOptions } from "@oh-my-pi/pi-ai";
9
+ import { resolveCatalogPolicy } from "@oh-my-pi/pi-catalog/compat/catalog-policy";
9
10
  import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
10
11
  import { prompt } from "@oh-my-pi/pi-utils";
11
12
  import * as snapcompact from "@oh-my-pi/snapcompact";
@@ -346,7 +347,11 @@ export async function generateBranchSummary(
346
347
  // Transform to LLM-compatible messages, then serialize to text
347
348
  // Serialization prevents the model from treating it as a conversation to continue
348
349
  const llmMessages = (options.convertToLlm ?? defaultConvertToLlm)(messages);
349
- const conversationText = serializeConversationForSummary(llmMessages, preferredDialect(model.id));
350
+ const conversationText = serializeConversationForSummary(
351
+ llmMessages,
352
+ preferredDialect(model.id),
353
+ resolveCatalogPolicy(model).classifierCotExtraction === true,
354
+ );
350
355
 
351
356
  // Build prompt
352
357
  const instructions = customInstructions || BRANCH_SUMMARY_PROMPT;
@@ -36,6 +36,7 @@ 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
37
  import { isSurvivingAssistant } from "@oh-my-pi/pi-ai/providers/transform-messages";
38
38
  import { stripOpenAIResponsesOutputOnlyStatusesForReplay } from "@oh-my-pi/pi-ai/utils";
39
+ import { resolveCatalogPolicy } from "@oh-my-pi/pi-catalog/compat/catalog-policy";
39
40
  import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
40
41
  import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking";
41
42
  import { isRecord, logger, prompt } from "@oh-my-pi/pi-utils";
@@ -870,13 +871,14 @@ function planSummaryWindows(
870
871
  messages: Message[],
871
872
  tokenizer: Tokenizer,
872
873
  dialect: Dialect | undefined,
874
+ dropThinking: boolean,
873
875
  budgetTokens: number,
874
876
  ): Message[][] {
875
877
  const windows: Message[][] = [];
876
878
  let current: Message[] = [];
877
879
  let currentTokens = 0;
878
880
  for (const message of messages) {
879
- const tokens = tokenizer.countTokens(serializeConversationForSummary([message], dialect));
881
+ const tokens = tokenizer.countTokens(serializeConversationForSummary([message], dialect, dropThinking));
880
882
  if (currentTokens > 0 && currentTokens + tokens > budgetTokens) {
881
883
  windows.push(current);
882
884
  current = [];
@@ -905,8 +907,9 @@ export async function generateSummary(
905
907
  // Convert to LLM messages first (handles custom app messages when caller provides a transformer).
906
908
  const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(currentMessages);
907
909
  const dialect = preferredDialect(model.id);
910
+ const dropThinking = resolveCatalogPolicy(model).classifierCotExtraction === true;
908
911
  const tokenizer = new Tokenizer(model);
909
- const wholeConversation = serializeConversationForSummary(llmMessages, dialect);
912
+ const wholeConversation = serializeConversationForSummary(llmMessages, dialect, dropThinking);
910
913
  const budgetTokens = summaryInputBudgetTokens(model, maxTokens);
911
914
  // A span that outgrew the summarizer's window is summarized as a fold: each
912
915
  // window updates the summary carried out of the previous one, which is the
@@ -917,12 +920,15 @@ export async function generateSummary(
917
920
  // common case and costs exactly the one call it always did.
918
921
  const pending: SummaryWindow[] = tokenizer.checkTokenBudget(wholeConversation, budgetTokens).fits
919
922
  ? [{ messages: llmMessages, budgetTokens, text: wholeConversation }]
920
- : planSummaryWindows(llmMessages, tokenizer, dialect, budgetTokens).map(messages => ({ messages, budgetTokens }));
923
+ : planSummaryWindows(llmMessages, tokenizer, dialect, dropThinking, budgetTokens).map(messages => ({
924
+ messages,
925
+ budgetTokens,
926
+ }));
921
927
 
922
928
  let carriedSummary = previousSummary;
923
929
  while (pending.length > 0) {
924
930
  const window = pending[0];
925
- const text = window.text ?? serializeConversationForSummary(window.messages, dialect);
931
+ const text = window.text ?? serializeConversationForSummary(window.messages, dialect, dropThinking);
926
932
  // A budget probe, not a raw count: a window whose bytes already fit needs
927
933
  // neither an exact count nor the clamp, and the bust path hands back the
928
934
  // exact count the proportional clamp needs as its denominator.
@@ -960,7 +966,7 @@ export async function generateSummary(
960
966
  pending.splice(
961
967
  0,
962
968
  1,
963
- ...planSummaryWindows(window.messages, tokenizer, dialect, halved).map(messages => ({
969
+ ...planSummaryWindows(window.messages, tokenizer, dialect, dropThinking, halved).map(messages => ({
964
970
  messages,
965
971
  budgetTokens: halved,
966
972
  })),
@@ -1210,7 +1216,11 @@ async function generateShortSummary(
1210
1216
  ): Promise<string> {
1211
1217
  const maxTokens = Math.min(512, Math.floor(0.2 * reserveTokens));
1212
1218
  const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(recentMessages);
1213
- const conversationText = serializeConversationForSummary(llmMessages, preferredDialect(model.id));
1219
+ const conversationText = serializeConversationForSummary(
1220
+ llmMessages,
1221
+ preferredDialect(model.id),
1222
+ resolveCatalogPolicy(model).classifierCotExtraction === true,
1223
+ );
1214
1224
 
1215
1225
  let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
1216
1226
  if (historySummary) {
@@ -2280,7 +2290,11 @@ async function generateTurnPrefixSummary(
2280
2290
  const maxTokens = Math.min(Math.floor(0.5 * reserveTokens), MAX_SUMMARY_TOKENS); // Smaller budget for turn prefix
2281
2291
 
2282
2292
  const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(messages);
2283
- const conversationText = serializeConversationForSummary(llmMessages, preferredDialect(model.id));
2293
+ const conversationText = serializeConversationForSummary(
2294
+ llmMessages,
2295
+ preferredDialect(model.id),
2296
+ resolveCatalogPolicy(model).classifierCotExtraction === true,
2297
+ );
2284
2298
  const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
2285
2299
  const summarizationMessages = [
2286
2300
  {
@@ -226,9 +226,10 @@ export function escapeSummaryBoundaryTags(text: string): string {
226
226
 
227
227
  /**
228
228
  * Serialize LLM messages as plain summary input without provider control tokens.
229
+ * `dropThinking` is the summarizer's resolved `classifier-cot-extraction` policy.
229
230
  */
230
- export function serializeConversationForSummary(messages: Message[], dialect?: Dialect): string {
231
- const conversation = serializeConversation(messages, dialect);
231
+ export function serializeConversationForSummary(messages: Message[], dialect?: Dialect, dropThinking = false): string {
232
+ const conversation = serializeConversation(messages, dialect, dropThinking);
232
233
  const escaped = dialect === "harmony" ? escapeHarmonyControlTokens(conversation) : conversation;
233
234
  return escapeSummaryBoundaryTags(escaped);
234
235
  }
@@ -236,8 +237,12 @@ export function serializeConversationForSummary(messages: Message[], dialect?: D
236
237
  /**
237
238
  * Serialize LLM messages to transcript text.
238
239
  * Call convertToLlm() first to handle custom message types.
240
+ * `dropThinking` omits assistant reasoning from dialect transcripts: set it from
241
+ * the reader's resolved `classifier-cot-extraction` catalog policy (Claude's
242
+ * classifier refuses inputs that reproduce the model's own reasoning as text,
243
+ * "reasoning_extraction"). Reasoning is ephemeral and low-signal for a summary.
239
244
  */
240
- export function serializeConversation(messages: Message[], dialect?: Dialect): string {
245
+ export function serializeConversation(messages: Message[], dialect?: Dialect, dropThinking = false): string {
241
246
  // Tool results flagged contextually useless (and their paired calls) are
242
247
  // dropped from the serialized text: the source region is discarded after
243
248
  // summarization anyway, so excluding them costs nothing and keeps garbage
@@ -249,13 +254,6 @@ export function serializeConversation(messages: Message[], dialect?: Dialect): s
249
254
  }
250
255
  }
251
256
  if (dialect) {
252
- // Claude's classifier refuses inputs that reproduce the model's own
253
- // reasoning as text ("reasoning_extraction"), and the anthropic dialect
254
- // otherwise renders thinking verbatim inside <thinking> tags. Reasoning is
255
- // ephemeral and low-signal for a summary, so drop it from Anthropic-target
256
- // summary input. Other dialects (e.g. Harmony) carry reasoning natively in
257
- // their transcript format and keep it.
258
- const dropThinking = dialect === "anthropic";
259
257
  const processed: Message[] = [];
260
258
  for (const msg of messages) {
261
259
  if (msg.role === "assistant") {
package/src/types.ts CHANGED
@@ -38,10 +38,14 @@ export interface QueuedMessagePreparation {
38
38
  commit(): readonly AgentMessage[] | undefined;
39
39
  }
40
40
 
41
+ /** Which agent queue a batch came from: steering amends the running task, follow-ups start the next one. */
42
+ export type QueuedMessageQueue = "steering" | "followUp";
43
+
41
44
  /** Prepare an exclusively claimed batch. Undefined delivers unchanged; the signal also aborts when the claim is cancelled. */
42
45
  export type PrepareQueuedMessages = (
43
46
  messages: readonly AgentMessage[],
44
47
  signal: AbortSignal,
48
+ queue: QueuedMessageQueue,
45
49
  ) => QueuedMessagePreparation | undefined | Promise<QueuedMessagePreparation | undefined>;
46
50
 
47
51
  /** Called once an aside has been inserted into the agent's live context. */