@oh-my-pi/pi-agent-core 16.3.0 → 16.3.3

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
+ ## [16.3.3] - 2026-07-02
6
+
7
+ ### Changed
8
+
9
+ - Enabled dynamic model resolution to support seamless mid-run model switching.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed an issue in the Cursor agent where assistant messages containing native tool calls could duplicate text blocks on replay.
14
+ - Fixed a bug where Cursor agent exec-channel tools (such as bash, write, and delete) were executed a second time after server-side execution.
15
+ - Improved error handling for tool calls interrupted by upstream provider stream errors, distinguishing transport/provider failures from local tool execution failures in the CLI, events, and messages.
16
+
5
17
  ## [16.3.0] - 2026-07-02
6
18
 
7
19
  ### Added
@@ -64,3 +64,26 @@ export declare function normalizeTools(tools: AgentContext["tools"], injectInten
64
64
  * `signal.reason` is the default `AbortError` `DOMException`) falls back to the
65
65
  * generic sentinel that downstream renderers treat as "no specific reason". */
66
66
  export declare function abortReasonText(signal: AbortSignal | undefined): string;
67
+ /**
68
+ * Discriminator embedded in {@link AgentToolResult.details} and
69
+ * {@link ToolResultMessage.details} for tool calls that were emitted by the
70
+ * assistant but never actually invoked locally.
71
+ *
72
+ * The synthetic result exists only to preserve the tool_use / tool_result
73
+ * pairing the provider API requires; no `tool.execute()` ran. UI, telemetry,
74
+ * and history consumers can key on `__synthetic === true` to render or
75
+ * classify these as "call emitted, not executed" instead of a real local
76
+ * tool failure — the mislabeling this discriminator was introduced to fix
77
+ * (#4321): a provider-side stream error after tool-call emission (e.g. Codex
78
+ * websocket close) was surfaced by the CLI as if the local tool had failed.
79
+ *
80
+ * `source` names the assistant-side termination state that prevented
81
+ * execution; `upstreamError` is the provider-reported message when the turn
82
+ * ended with `stopReason === "error"`.
83
+ */
84
+ export interface SyntheticToolResultDetails {
85
+ __synthetic: true;
86
+ source: "assistant_stop_aborted" | "assistant_stop_error" | "assistant_stop_skipped" | "assistant_stop_length";
87
+ executed: false;
88
+ upstreamError?: string;
89
+ }
@@ -274,6 +274,14 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
274
274
  * the next model call instead of waiting for the next prompt.
275
275
  */
276
276
  getReasoning?: () => Effort | undefined;
277
+ /**
278
+ * Dynamic model override, resolved once per LLM call. When set, each
279
+ * provider call re-reads the model (like {@link getReasoning}) so mid-run
280
+ * model switches — context promotion, retry fallback — apply on the next
281
+ * call instead of the run finishing on the stale model captured at
282
+ * run-loop start. Falls back to the static {@link model} when unset.
283
+ */
284
+ getModel?: () => Model;
277
285
  /**
278
286
  * Dynamic reasoning-disable override, resolved per LLM call. When set,
279
287
  * its return value overrides the static `disableReasoning` from
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": "16.3.0",
4
+ "version": "16.3.3",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -35,12 +35,12 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "16.3.0",
39
- "@oh-my-pi/pi-catalog": "16.3.0",
40
- "@oh-my-pi/pi-natives": "16.3.0",
41
- "@oh-my-pi/pi-utils": "16.3.0",
42
- "@oh-my-pi/pi-wire": "16.3.0",
43
- "@oh-my-pi/snapcompact": "16.3.0",
38
+ "@oh-my-pi/pi-ai": "16.3.3",
39
+ "@oh-my-pi/pi-catalog": "16.3.3",
40
+ "@oh-my-pi/pi-natives": "16.3.3",
41
+ "@oh-my-pi/pi-utils": "16.3.3",
42
+ "@oh-my-pi/pi-wire": "16.3.3",
43
+ "@oh-my-pi/snapcompact": "16.3.3",
44
44
  "@opentelemetry/api": "^1.9.1"
45
45
  },
46
46
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  wrapInbandToolStream,
27
27
  } from "@oh-my-pi/pi-ai/dialect";
28
28
  import * as AIError from "@oh-my-pi/pi-ai/error";
29
+ import { type CursorExecResolvedCarrier, kCursorExecResolved } from "@oh-my-pi/pi-ai/utils/block-symbols";
29
30
  import {
30
31
  createHarmonyAuditEvent,
31
32
  detectHarmonyLeakInAssistantMessage,
@@ -910,7 +911,13 @@ async function runLoopBody(
910
911
  // Create placeholder tool results for any tool calls in the aborted message
911
912
  // This maintains the tool_use/tool_result pairing that the API requires
912
913
  type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
913
- const toolCalls = message.content.filter((c): c is ToolCallContent => c.type === "toolCall");
914
+ // Cursor exec-resolved blocks already have their toolResult buffered
915
+ // for out-of-band emission; a placeholder aborted result here would
916
+ // pair a duplicate to the same toolCallId (issue #4348 codex review).
917
+ const toolCalls = message.content.filter(
918
+ (c): c is ToolCallContent =>
919
+ c.type === "toolCall" && (c as CursorExecResolvedCarrier)[kCursorExecResolved] !== true,
920
+ );
914
921
  const toolResults: ToolResultMessage[] = [];
915
922
  for (const toolCall of toolCalls) {
916
923
  const result = createAbortedToolResult(toolCall, stream, message.stopReason, message.errorMessage);
@@ -949,7 +956,15 @@ async function runLoopBody(
949
956
  // trailing tool_use may be truncated with incomplete arguments — those calls
950
957
  // are abandoned below. (`error`/`aborted` already returned above.)
951
958
  type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
952
- const toolCalls = message.content.filter((c): c is ToolCallContent => c.type === "toolCall");
959
+ // A Cursor exec-channel synthesized `toolCall` block carries
960
+ // `kCursorExecResolved` because Cursor already executed the tool
961
+ // server-side (via the bridge) and buffered the result for
962
+ // out-of-band emission — running it here again would duplicate the
963
+ // same side-effecting call (issue #4348 review by @chatgpt-codex-connector).
964
+ const toolCalls = message.content.filter(
965
+ (c): c is ToolCallContent =>
966
+ c.type === "toolCall" && (c as CursorExecResolvedCarrier)[kCursorExecResolved] !== true,
967
+ );
953
968
  const runnableStop = message.stopReason === "toolUse" || message.stopReason === "stop";
954
969
  hasMoreToolCalls = runnableStop && toolCalls.length > 0;
955
970
 
@@ -1131,7 +1146,7 @@ async function emitHarmonyAudit(
1131
1146
  createHarmonyAuditEvent({
1132
1147
  action,
1133
1148
  detection: interruption.detection,
1134
- model: config.model,
1149
+ model: config.getModel?.() ?? config.model,
1135
1150
  retryN,
1136
1151
  removed: interruption.removed,
1137
1152
  }),
@@ -1155,6 +1170,11 @@ async function streamAssistantResponse(
1155
1170
  hostToolChoice?: ToolChoice,
1156
1171
  forcedToolChoice?: ToolChoice,
1157
1172
  ): Promise<AssistantMessage> {
1173
+ // Re-resolve the model per provider call (like `getReasoning`): mid-run
1174
+ // model switches — context promotion, retry fallback — must apply on the
1175
+ // next call instead of the run silently finishing on the stale model
1176
+ // captured at run start.
1177
+ const model = config.getModel?.() ?? config.model;
1158
1178
  // Apply context transform if configured (AgentMessage[] → AgentMessage[])
1159
1179
  let messages = context.messages;
1160
1180
  if (config.transformContext) {
@@ -1163,10 +1183,10 @@ async function streamAssistantResponse(
1163
1183
 
1164
1184
  // Convert to LLM-compatible messages (AgentMessage[] → Message[])
1165
1185
  const llmMessages = await config.convertToLlm(messages);
1166
- const normalizedMessages = normalizeMessagesForProvider(llmMessages, config.model);
1186
+ const normalizedMessages = normalizeMessagesForProvider(llmMessages, model);
1167
1187
 
1168
1188
  const ownedDialect: Dialect | undefined = config.dialect ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
1169
- const exampleDialect = ownedDialect ?? preferredDialect(config.model.id);
1189
+ const exampleDialect = ownedDialect ?? preferredDialect(model.id);
1170
1190
  // Owned/in-band dialects carry the catalog in the prompt as text and send no
1171
1191
  // native `tools`, so description pruning only applies to native tool calling.
1172
1192
  const pruneToolDescriptions = !!config.pruneToolDescriptions && !ownedDialect;
@@ -1188,7 +1208,7 @@ async function streamAssistantResponse(
1188
1208
  };
1189
1209
  }
1190
1210
  if (config.transformProviderContext) {
1191
- llmContext = await config.transformProviderContext(llmContext, config.model);
1211
+ llmContext = await config.transformProviderContext(llmContext, model);
1192
1212
  }
1193
1213
 
1194
1214
  // Owned tool calling: take tool calls away from the provider and run them
@@ -1212,8 +1232,8 @@ async function streamAssistantResponse(
1212
1232
  // `getServiceTier` is authoritative when present (replaces the static tier
1213
1233
  // for both the wire request and telemetry), so callers can scope priority
1214
1234
  // per model without touching the shared session `serviceTier`.
1215
- const effectiveServiceTier = config.getServiceTier ? config.getServiceTier(config.model) : config.serviceTier;
1216
- const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(config.model);
1235
+ const effectiveServiceTier = config.getServiceTier ? config.getServiceTier(model) : config.serviceTier;
1236
+ const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(model);
1217
1237
  const harmonyAbortController = harmonyMitigationEnabled ? new AbortController() : undefined;
1218
1238
  const requestSignal = harmonyAbortController
1219
1239
  ? signal
@@ -1235,13 +1255,13 @@ async function streamAssistantResponse(
1235
1255
  : providerAbortSignals.length === 1
1236
1256
  ? providerAbortSignals[0]!
1237
1257
  : AbortSignal.any(providerAbortSignals);
1238
- const requestApiKey = (config.getApiKey ? await config.getApiKey(config.model) : undefined) ?? config.apiKey;
1258
+ const requestApiKey = (config.getApiKey ? await config.getApiKey(model) : undefined) ?? config.apiKey;
1239
1259
  const resolvedApiKey = await resolveApiKeyOnce(requestApiKey, finalRequestSignal);
1240
1260
  const apiKey = isApiKeyResolver(requestApiKey) ? seedApiKeyResolver(resolvedApiKey, requestApiKey) : requestApiKey;
1241
1261
 
1242
1262
  // Re-resolve metadata after credential selection so the per-request value
1243
1263
  // reflects the credential actually used, not the snapshot from AgentLoopConfig construction.
1244
- const resolvedMetadata = config.metadataResolver ? config.metadataResolver(config.model.provider) : config.metadata;
1264
+ const resolvedMetadata = config.metadataResolver ? config.metadataResolver(model.provider) : config.metadata;
1245
1265
  const effectiveTemperature =
1246
1266
  harmonyRetryAttempt > 0 && config.temperature !== undefined ? config.temperature + 0.05 : config.temperature;
1247
1267
  // Owned tool calling sends no native tools, so any tool_choice would error.
@@ -1254,7 +1274,7 @@ async function streamAssistantResponse(
1254
1274
 
1255
1275
  const chatStepNumber = stepCounter.count;
1256
1276
  stepCounter.count += 1;
1257
- const chatSpan = startChatSpan(telemetry, config.model, {
1277
+ const chatSpan = startChatSpan(telemetry, model, {
1258
1278
  parent: invokeAgentSpan,
1259
1279
  stepNumber: chatStepNumber,
1260
1280
  request: {
@@ -1287,13 +1307,13 @@ async function streamAssistantResponse(
1287
1307
  stepNumber: chatStepNumber,
1288
1308
  serviceTier: effectiveServiceTier,
1289
1309
  responseHeaders: capturedHeaders,
1290
- baseUrl: config.model.baseUrl,
1310
+ baseUrl: model.baseUrl,
1291
1311
  });
1292
1312
  };
1293
1313
 
1294
1314
  try {
1295
1315
  return await runInActiveSpan(chatSpan, async () => {
1296
- let response = await streamFunction(config.model, llmContext, {
1316
+ let response = await streamFunction(model, llmContext, {
1297
1317
  ...config,
1298
1318
  apiKey,
1299
1319
  metadata: resolvedMetadata,
@@ -1513,7 +1533,7 @@ async function streamAssistantResponse(
1513
1533
  failChatSpan(telemetry, chatSpan, {
1514
1534
  errorObject: err,
1515
1535
  responseHeaders: capturedHeaders,
1516
- baseUrl: config.model.baseUrl,
1536
+ baseUrl: model.baseUrl,
1517
1537
  });
1518
1538
  throw err;
1519
1539
  }
@@ -1614,6 +1634,7 @@ function emitAbortedAssistantMessage(
1614
1634
  stream: EventStream<AgentEvent, AgentMessage[]>,
1615
1635
  requestSignal: AbortSignal | undefined,
1616
1636
  ): AssistantMessage {
1637
+ const model = config.getModel?.() ?? config.model;
1617
1638
  const errorMessage = abortReasonText(requestSignal);
1618
1639
  const errorId =
1619
1640
  errorMessage === "Request was aborted"
@@ -1624,9 +1645,9 @@ function emitAbortedAssistantMessage(
1624
1645
  : {
1625
1646
  role: "assistant",
1626
1647
  content: [],
1627
- api: config.model.api,
1628
- provider: config.model.provider,
1629
- model: config.model.id,
1648
+ api: model.api,
1649
+ provider: model.provider,
1650
+ model: model.id,
1630
1651
  usage: {
1631
1652
  input: 0,
1632
1653
  output: 0,
@@ -1679,7 +1700,13 @@ async function executeToolCalls(
1679
1700
  afterToolCall,
1680
1701
  } = config;
1681
1702
  type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
1682
- const toolCalls = assistantMessage.content.filter((c): c is ToolCallContent => c.type === "toolCall");
1703
+ // Defensive: the outer loop already filters exec-resolved blocks before
1704
+ // deciding to invoke `executeToolCalls`, but skip them here too so the
1705
+ // guarantee lives with the code that would re-run the tool.
1706
+ const toolCalls = assistantMessage.content.filter(
1707
+ (c): c is ToolCallContent =>
1708
+ c.type === "toolCall" && (c as CursorExecResolvedCarrier)[kCursorExecResolved] !== true,
1709
+ );
1683
1710
  const emittedToolResults: ToolResultMessage[] = [];
1684
1711
  const toolCallInfos = toolCalls.map(call => ({ id: call.id, name: call.name }));
1685
1712
  const batchId = `${assistantMessage.timestamp ?? Date.now()}_${toolCalls[0]?.id ?? "batch"}`;
@@ -2113,8 +2140,55 @@ async function executeToolCalls(
2113
2140
  }
2114
2141
 
2115
2142
  /**
2116
- * Create a tool result for a tool call that was aborted or errored before execution.
2117
- * Maintains the tool_use/tool_result pairing required by the API.
2143
+ * Discriminator embedded in {@link AgentToolResult.details} and
2144
+ * {@link ToolResultMessage.details} for tool calls that were emitted by the
2145
+ * assistant but never actually invoked locally.
2146
+ *
2147
+ * The synthetic result exists only to preserve the tool_use / tool_result
2148
+ * pairing the provider API requires; no `tool.execute()` ran. UI, telemetry,
2149
+ * and history consumers can key on `__synthetic === true` to render or
2150
+ * classify these as "call emitted, not executed" instead of a real local
2151
+ * tool failure — the mislabeling this discriminator was introduced to fix
2152
+ * (#4321): a provider-side stream error after tool-call emission (e.g. Codex
2153
+ * websocket close) was surfaced by the CLI as if the local tool had failed.
2154
+ *
2155
+ * `source` names the assistant-side termination state that prevented
2156
+ * execution; `upstreamError` is the provider-reported message when the turn
2157
+ * ended with `stopReason === "error"`.
2158
+ */
2159
+ export interface SyntheticToolResultDetails {
2160
+ __synthetic: true;
2161
+ source: "assistant_stop_aborted" | "assistant_stop_error" | "assistant_stop_skipped" | "assistant_stop_length";
2162
+ executed: false;
2163
+ upstreamError?: string;
2164
+ }
2165
+
2166
+ function syntheticDetailsFor(
2167
+ reason: "aborted" | "error" | "skipped" | "length",
2168
+ errorMessage: string | undefined,
2169
+ ): SyntheticToolResultDetails {
2170
+ const source: SyntheticToolResultDetails["source"] =
2171
+ reason === "aborted"
2172
+ ? "assistant_stop_aborted"
2173
+ : reason === "error"
2174
+ ? "assistant_stop_error"
2175
+ : reason === "length"
2176
+ ? "assistant_stop_length"
2177
+ : "assistant_stop_skipped";
2178
+ return {
2179
+ __synthetic: true,
2180
+ source,
2181
+ executed: false,
2182
+ ...(reason === "error" && errorMessage ? { upstreamError: errorMessage } : {}),
2183
+ };
2184
+ }
2185
+
2186
+ /**
2187
+ * Create a tool result for a tool call that was emitted by the assistant but
2188
+ * never invoked locally. Maintains the tool_use / tool_result pairing the
2189
+ * provider API requires, and tags {@link SyntheticToolResultDetails} so
2190
+ * consumers can distinguish this from a real local tool failure without
2191
+ * string-matching the content (#4321).
2118
2192
  */
2119
2193
  function createAbortedToolResult(
2120
2194
  toolCall: Extract<AssistantMessage["content"][number], { type: "toolCall" }>,
@@ -2129,10 +2203,11 @@ function createAbortedToolResult(
2129
2203
  ? "Tool call was not executed because the assistant hit its output token limit (stop_reason: length) before the arguments could complete; the recorded arguments are truncated and unsafe to run. Do NOT retry by re-emitting the same large payload — split the work into several smaller tool calls (e.g. for `write`/`edit`, write the first chunk then append the rest with subsequent `edit` insert ops, or break the file into multiple `write` targets)"
2130
2204
  : reason === "skipped"
2131
2205
  ? "Tool call was not executed because the assistant ended its turn"
2132
- : "Tool execution failed due to an error";
2133
- const result: AgentToolResult<any> = {
2206
+ : "Tool call was not executed because the provider stream ended with an error before the tool could run";
2207
+ const details = syntheticDetailsFor(reason, errorMessage);
2208
+ const result: AgentToolResult<SyntheticToolResultDetails> = {
2134
2209
  content: [{ type: "text", text: errorMessage ? `${message}: ${errorMessage}` : `${message}.` }],
2135
- details: {},
2210
+ details,
2136
2211
  };
2137
2212
 
2138
2213
  stream.push({
@@ -2150,12 +2225,12 @@ function createAbortedToolResult(
2150
2225
  isError: true,
2151
2226
  });
2152
2227
 
2153
- const toolResultMessage: ToolResultMessage = {
2228
+ const toolResultMessage: ToolResultMessage<SyntheticToolResultDetails> = {
2154
2229
  role: "toolResult",
2155
2230
  toolCallId: toolCall.id,
2156
2231
  toolName: toolCall.name,
2157
2232
  content: result.content,
2158
- details: {},
2233
+ details,
2159
2234
  isError: true,
2160
2235
  timestamp: Date.now(),
2161
2236
  };
package/src/agent.ts CHANGED
@@ -320,10 +320,9 @@ export interface AgentPromptOptions {
320
320
  toolChoice?: ToolChoice;
321
321
  }
322
322
 
323
- /** Buffered Cursor tool result with text position at time of call */
323
+ /** Buffered Cursor exec-channel tool result waiting to be emitted after the assistant message. */
324
324
  interface CursorToolResultEntry {
325
325
  toolResult: ToolResultMessage;
326
- textLengthAtCall: number;
327
326
  }
328
327
 
329
328
  export class Agent {
@@ -1097,12 +1096,11 @@ export class Agent {
1097
1096
  }
1098
1097
  } catch {}
1099
1098
  }
1100
- // Buffer tool result with current text length for correct ordering later.
1101
- // Cursor executes tools server-side during streaming, so the assistant message
1102
- // already incorporates results. We buffer here and emit in correct order
1103
- // when the assistant message ends.
1104
- const textLength = this.#getAssistantTextLength(this.#state.streamMessage);
1105
- this.#cursorToolResultBuffer.push({ toolResult: finalMessage, textLengthAtCall: textLength });
1099
+ // Cursor executes tools server-side during streaming. We buffer
1100
+ // each toolResult and emit them right after the assistant message
1101
+ // closes (see `#emitCursorSplitAssistantMessage`), so replay
1102
+ // receives (assistant with interleaved toolCall blocks) → results.
1103
+ this.#cursorToolResultBuffer.push({ toolResult: finalMessage });
1106
1104
  return finalMessage;
1107
1105
  }
1108
1106
  : undefined;
@@ -1175,6 +1173,7 @@ export class Agent {
1175
1173
  onHarmonyLeak: this.#onHarmonyLeak,
1176
1174
  onTurnEnd: (messages, signal, context) => this.#onTurnEnd?.(messages, signal, context),
1177
1175
  getToolChoice,
1176
+ getModel: () => this.#state.model ?? model,
1178
1177
  getReasoning: () => this.#state.thinkingLevel,
1179
1178
  getDisableReasoning: () => this.#state.disableReasoning,
1180
1179
  getServiceTier: this.#serviceTierResolver,
@@ -1343,115 +1342,33 @@ export class Agent {
1343
1342
  }
1344
1343
  }
1345
1344
 
1346
- /** Calculate total text length from an assistant message's content blocks */
1347
- #getAssistantTextLength(message: AgentMessage | null): number {
1348
- if (message?.role !== "assistant" || !Array.isArray(message.content)) {
1349
- return 0;
1350
- }
1351
- let length = 0;
1352
- for (const block of message.content) {
1353
- if (block.type === "text") {
1354
- length += (block as TextContent).text.length;
1355
- }
1356
- }
1357
- return length;
1358
- }
1359
-
1360
1345
  /**
1361
- * Emit a Cursor assistant message split around tool results.
1362
- * This fixes the ordering issue where tool results appear after the full explanation.
1346
+ * Emit a Cursor assistant message with buffered exec-channel toolResults.
1347
+ *
1348
+ * Since the Cursor provider now synthesizes `toolCall` content blocks at the
1349
+ * point each exec tool starts (issue #4348), the assistant message content
1350
+ * already interleaves text/thinking with toolCall blocks in execution order.
1351
+ * We emit the message as-is and let the buffered toolResults follow — the
1352
+ * transcript rebuild in `renderSessionContext` pairs them by `toolCallId`.
1363
1353
  *
1364
- * Output order: Assistant(preamble) -> ToolResults -> Assistant(continuation)
1354
+ * Historical note: this used to split the assistant message at
1355
+ * `textLengthAtCall` to interpose toolResults between preamble and
1356
+ * continuation. That workaround existed because native cursor tools had no
1357
+ * toolCall blocks; it also copied `preambleText` into every text block on
1358
+ * multi-text turns, producing duplicated text on replay.
1365
1359
  */
1366
1360
  #emitCursorSplitAssistantMessage(assistantMessage: AssistantMessage): void {
1367
1361
  const buffer = this.#cursorToolResultBuffer;
1368
1362
  this.#cursorToolResultBuffer = [];
1369
1363
 
1370
- if (buffer.length === 0) {
1371
- // No tool results, emit normally
1372
- this.#state.streamMessage = null;
1373
- this.appendMessage(assistantMessage);
1374
- this.#emit({ type: "message_end", message: assistantMessage });
1375
- return;
1376
- }
1377
-
1378
- // Find the split point: minimum text length at first tool call
1379
- const splitPoint = Math.min(...buffer.map(r => r.textLengthAtCall));
1380
-
1381
- // Extract text content from assistant message
1382
- const content = assistantMessage.content;
1383
- let fullText = "";
1384
- for (const block of content) {
1385
- if (block.type === "text") {
1386
- fullText += block.text;
1387
- }
1388
- }
1389
-
1390
- // If no text or split point is 0 or at/past end, don't split
1391
- if (fullText.length === 0 || splitPoint <= 0 || splitPoint >= fullText.length) {
1392
- // Emit assistant message first, then tool results (original behavior but with buffered results)
1393
- this.#state.streamMessage = null;
1394
- this.appendMessage(assistantMessage);
1395
- this.#emit({ type: "message_end", message: assistantMessage });
1396
-
1397
- // Emit buffered tool results
1398
- for (const { toolResult } of buffer) {
1399
- this.#emit({ type: "message_start", message: toolResult });
1400
- this.appendMessage(toolResult);
1401
- this.#emit({ type: "message_end", message: toolResult });
1402
- }
1403
- return;
1404
- }
1405
-
1406
- // Split the text
1407
- const preambleText = fullText.slice(0, splitPoint);
1408
- const continuationText = fullText.slice(splitPoint);
1409
-
1410
- // Create preamble message (text before tools)
1411
- const preambleContent = content.map(block => {
1412
- if (block.type === "text") {
1413
- return { ...block, text: preambleText };
1414
- }
1415
- return block;
1416
- });
1417
- const preambleMessage: AssistantMessage = {
1418
- ...assistantMessage,
1419
- content: preambleContent,
1420
- };
1421
-
1422
- // Emit preamble
1423
1364
  this.#state.streamMessage = null;
1424
- this.appendMessage(preambleMessage);
1425
- this.#emit({ type: "message_end", message: preambleMessage });
1365
+ this.appendMessage(assistantMessage);
1366
+ this.#emit({ type: "message_end", message: assistantMessage });
1426
1367
 
1427
- // Emit buffered tool results
1428
1368
  for (const { toolResult } of buffer) {
1429
1369
  this.#emit({ type: "message_start", message: toolResult });
1430
1370
  this.appendMessage(toolResult);
1431
1371
  this.#emit({ type: "message_end", message: toolResult });
1432
1372
  }
1433
-
1434
- // Emit continuation message (text after tools) if non-empty
1435
- const trimmedContinuation = continuationText.trim();
1436
- if (trimmedContinuation.length > 0) {
1437
- // Create continuation message with only text content (no thinking/toolCalls)
1438
- const continuationContent: TextContent[] = [{ type: "text", text: continuationText }];
1439
- const continuationMessage: AssistantMessage = {
1440
- ...assistantMessage,
1441
- content: continuationContent,
1442
- // Zero out usage for continuation since it's part of same response
1443
- usage: {
1444
- input: 0,
1445
- output: 0,
1446
- cacheRead: 0,
1447
- cacheWrite: 0,
1448
- totalTokens: 0,
1449
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
1450
- },
1451
- };
1452
- this.#emit({ type: "message_start", message: continuationMessage });
1453
- this.appendMessage(continuationMessage);
1454
- this.#emit({ type: "message_end", message: continuationMessage });
1455
- }
1456
1373
  }
1457
1374
  }
package/src/types.ts CHANGED
@@ -324,6 +324,14 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
324
324
  * the next model call instead of waiting for the next prompt.
325
325
  */
326
326
  getReasoning?: () => Effort | undefined;
327
+ /**
328
+ * Dynamic model override, resolved once per LLM call. When set, each
329
+ * provider call re-reads the model (like {@link getReasoning}) so mid-run
330
+ * model switches — context promotion, retry fallback — apply on the next
331
+ * call instead of the run finishing on the stale model captured at
332
+ * run-loop start. Falls back to the static {@link model} when unset.
333
+ */
334
+ getModel?: () => Model;
327
335
 
328
336
  /**
329
337
  * Dynamic reasoning-disable override, resolved per LLM call. When set,