@martian-engineering/lossless-claw 0.2.3 → 0.2.5

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/README.md CHANGED
@@ -22,7 +22,7 @@ Nothing is lost. Raw messages stay in the database. Summaries link back to their
22
22
 
23
23
  ### Prerequisites
24
24
 
25
- - OpenClaw with context engine support (josh/context-engine branch or equivalent)
25
+ - OpenClaw with plugin context engine support
26
26
  - Node.js 22+
27
27
  - An LLM provider configured in OpenClaw (used for summarization)
28
28
 
@@ -243,7 +243,12 @@ Add a `lossless-claw` entry under `plugins.entries` in your OpenClaw config:
243
243
  "plugins": {
244
244
  "entries": {
245
245
  "lossless-claw": {
246
- "enabled": true
246
+ "enabled": true,
247
+ "config": {
248
+ "freshTailCount": 32,
249
+ "contextThreshold": 0.75,
250
+ "incrementalMaxDepth": -1
251
+ }
247
252
  }
248
253
  }
249
254
  }
@@ -267,9 +272,12 @@ Add a `lossless-claw` entry under `plugins.entries` in your OpenClaw config:
267
272
  | `LCM_CONDENSED_TARGET_TOKENS` | `2000` | Target token count for condensed summaries |
268
273
  | `LCM_MAX_EXPAND_TOKENS` | `4000` | Token cap for sub-agent expansion queries |
269
274
  | `LCM_LARGE_FILE_TOKEN_THRESHOLD` | `25000` | File blocks above this size are intercepted and stored separately |
275
+ | `LCM_LARGE_FILE_SUMMARY_PROVIDER` | `""` | Provider override for large-file summarization |
276
+ | `LCM_LARGE_FILE_SUMMARY_MODEL` | `""` | Model override for large-file summarization |
270
277
  | `LCM_SUMMARY_MODEL` | *(from OpenClaw)* | Model for summarization (e.g. `anthropic/claude-sonnet-4-20250514`) |
271
278
  | `LCM_SUMMARY_PROVIDER` | *(from OpenClaw)* | Provider override for summarization |
272
- | `LCM_INCREMENTAL_MAX_DEPTH` | `0` | Depth limit for incremental condensation after leaf passes (-1 = unlimited) |
279
+ | `LCM_AUTOCOMPACT_DISABLED` | `false` | Disable automatic compaction after turns |
280
+ | `LCM_PRUNE_HEARTBEAT_OK` | `false` | Retroactively delete `HEARTBEAT_OK` turn cycles from LCM storage |
273
281
 
274
282
  ### Recommended starting configuration
275
283
 
@@ -283,6 +291,47 @@ LCM_CONTEXT_THRESHOLD=0.75
283
291
  - **incrementalMaxDepth=-1** enables unlimited automatic condensation after each compaction pass — the DAG cascades as deep as needed. Set to `0` (default) for leaf-only, or a positive integer for a specific depth cap.
284
292
  - **contextThreshold=0.75** triggers compaction when context reaches 75% of the model's window, leaving headroom for the model's response.
285
293
 
294
+ ### OpenClaw session reset settings
295
+
296
+ LCM preserves history through compaction, but it does **not** change OpenClaw's core session reset policy. If sessions are resetting sooner than you want, increase OpenClaw's `session.reset.idleMinutes` or use a channel/type-specific override.
297
+
298
+ ```json
299
+ {
300
+ "session": {
301
+ "reset": {
302
+ "mode": "idle",
303
+ "idleMinutes": 10080
304
+ }
305
+ }
306
+ }
307
+ ```
308
+
309
+ - `session.reset.mode: "idle"` keeps a session alive until the idle window expires.
310
+ - `session.reset.idleMinutes` is the actual reset interval in minutes.
311
+ - OpenClaw does **not** currently enforce a maximum `idleMinutes`; in source it is validated only as a positive integer.
312
+ - If you also use daily reset mode, `idleMinutes` acts as a secondary guard and the session resets when **either** the daily boundary or the idle window is reached first.
313
+ - Legacy `session.idleMinutes` still works, but OpenClaw prefers `session.reset.idleMinutes`.
314
+
315
+ Useful values:
316
+
317
+ - `1440` = 1 day
318
+ - `10080` = 7 days
319
+ - `43200` = 30 days
320
+ - `525600` = 365 days
321
+
322
+ For most long-lived LCM setups, a good starting point is:
323
+
324
+ ```json
325
+ {
326
+ "session": {
327
+ "reset": {
328
+ "mode": "idle",
329
+ "idleMinutes": 10080
330
+ }
331
+ }
332
+ }
333
+ ```
334
+
286
335
  ## How it works
287
336
 
288
337
  See [docs/architecture.md](docs/architecture.md) for the full technical deep-dive. Here's the summary:
package/index.ts CHANGED
@@ -43,6 +43,7 @@ type PluginEnvSnapshot = {
43
43
  lcmSummaryModel: string;
44
44
  lcmSummaryProvider: string;
45
45
  openclawProvider: string;
46
+ openclawDefaultModel: string;
46
47
  agentDir: string;
47
48
  home: string;
48
49
  };
@@ -62,11 +63,27 @@ function snapshotPluginEnv(env: NodeJS.ProcessEnv = process.env): PluginEnvSnaps
62
63
  lcmSummaryModel: env.LCM_SUMMARY_MODEL?.trim() ?? "",
63
64
  lcmSummaryProvider: env.LCM_SUMMARY_PROVIDER?.trim() ?? "",
64
65
  openclawProvider: env.OPENCLAW_PROVIDER?.trim() ?? "",
66
+ openclawDefaultModel: "",
65
67
  agentDir: env.OPENCLAW_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim() || "",
66
68
  home: env.HOME?.trim() ?? "",
67
69
  };
68
70
  }
69
71
 
72
+ /** Read OpenClaw's configured default model from the validated runtime config. */
73
+ function readDefaultModelFromConfig(config: unknown): string {
74
+ if (!config || typeof config !== "object") {
75
+ return "";
76
+ }
77
+
78
+ const model = (config as { agents?: { defaults?: { model?: unknown } } }).agents?.defaults?.model;
79
+ if (typeof model === "string") {
80
+ return model.trim();
81
+ }
82
+
83
+ const primary = (model as { primary?: unknown } | undefined)?.primary;
84
+ return typeof primary === "string" ? primary.trim() : "";
85
+ }
86
+
70
87
  /** Resolve common provider API keys from environment. */
71
88
  function resolveApiKey(provider: string, readEnv: ReadEnvFn): string | undefined {
72
89
  const keyMap: Record<string, string[]> = {
@@ -596,6 +613,7 @@ function readLatestAssistantReply(messages: unknown[]): string | undefined {
596
613
  /** Construct LCM dependencies from plugin API/runtime surfaces. */
597
614
  function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
598
615
  const envSnapshot = snapshotPluginEnv();
616
+ envSnapshot.openclawDefaultModel = readDefaultModelFromConfig(api.config);
599
617
  const readEnv: ReadEnvFn = (key) => process.env[key];
600
618
  const pluginConfig =
601
619
  api.pluginConfig && typeof api.pluginConfig === "object" && !Array.isArray(api.pluginConfig)
@@ -789,7 +807,8 @@ function createLcmDependencies(api: OpenClawPluginApi): LcmDependencies {
789
807
  }
790
808
  },
791
809
  resolveModel: (modelRef, providerHint) => {
792
- const raw = (modelRef ?? envSnapshot.lcmSummaryModel).trim();
810
+ const raw =
811
+ (modelRef?.trim() || envSnapshot.lcmSummaryModel || envSnapshot.openclawDefaultModel).trim();
793
812
  if (!raw) {
794
813
  throw new Error("No model configured for LCM summarization.");
795
814
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@martian-engineering/lossless-claw",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Lossless Context Management plugin for OpenClaw — DAG-based conversation summarization with incremental compaction",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/src/assembler.ts CHANGED
@@ -144,6 +144,137 @@ function getOriginalRole(parts: MessagePartRecord[]): string | null {
144
144
  return null;
145
145
  }
146
146
 
147
+ function getPartMetadata(part: MessagePartRecord): {
148
+ originalRole?: string;
149
+ rawType?: string;
150
+ raw?: unknown;
151
+ } {
152
+ const decoded = parseJson(part.metadata);
153
+ if (!decoded || typeof decoded !== "object") {
154
+ return {};
155
+ }
156
+
157
+ const record = decoded as {
158
+ originalRole?: unknown;
159
+ rawType?: unknown;
160
+ raw?: unknown;
161
+ };
162
+ return {
163
+ originalRole:
164
+ typeof record.originalRole === "string" && record.originalRole.length > 0
165
+ ? record.originalRole
166
+ : undefined,
167
+ rawType:
168
+ typeof record.rawType === "string" && record.rawType.length > 0
169
+ ? record.rawType
170
+ : undefined,
171
+ raw: record.raw,
172
+ };
173
+ }
174
+
175
+ function parseStoredValue(value: string | null): unknown {
176
+ if (typeof value !== "string" || value.length === 0) {
177
+ return undefined;
178
+ }
179
+ const parsed = parseJson(value);
180
+ return parsed !== undefined ? parsed : value;
181
+ }
182
+
183
+ function reasoningBlockFromPart(part: MessagePartRecord, rawType?: string): unknown {
184
+ const type = rawType === "thinking" ? "thinking" : "reasoning";
185
+ if (typeof part.textContent === "string" && part.textContent.length > 0) {
186
+ return type === "thinking"
187
+ ? { type, thinking: part.textContent }
188
+ : { type, text: part.textContent };
189
+ }
190
+ return { type };
191
+ }
192
+
193
+ /**
194
+ * Detect if a raw block is an OpenClaw-normalised OpenAI reasoning item.
195
+ * OpenClaw converts OpenAI `{type:"reasoning", id:"rs_…", encrypted_content:"…"}`
196
+ * into `{type:"thinking", thinking:"", thinkingSignature:"{…}"}`.
197
+ * When we reassemble for the OpenAI provider we need the original back.
198
+ */
199
+ function tryRestoreOpenAIReasoning(raw: Record<string, unknown>): Record<string, unknown> | null {
200
+ if (raw.type !== "thinking") return null;
201
+ const sig = raw.thinkingSignature;
202
+ if (typeof sig !== "string" || !sig.startsWith("{")) return null;
203
+ try {
204
+ const parsed = JSON.parse(sig) as Record<string, unknown>;
205
+ if (parsed.type === "reasoning" && typeof parsed.id === "string") {
206
+ return parsed;
207
+ }
208
+ } catch {
209
+ // not valid JSON — leave as-is
210
+ }
211
+ return null;
212
+ }
213
+
214
+ function toolCallBlockFromPart(part: MessagePartRecord, rawType?: string): unknown {
215
+ const type =
216
+ rawType === "function_call" ||
217
+ rawType === "functionCall" ||
218
+ rawType === "tool_use" ||
219
+ rawType === "tool-use" ||
220
+ rawType === "toolUse" ||
221
+ rawType === "toolCall"
222
+ ? rawType
223
+ : "toolCall";
224
+ const input = parseStoredValue(part.toolInput);
225
+ const block: Record<string, unknown> = { type };
226
+
227
+ if (type === "function_call") {
228
+ if (typeof part.toolCallId === "string" && part.toolCallId.length > 0) {
229
+ block.call_id = part.toolCallId;
230
+ }
231
+ if (typeof part.toolName === "string" && part.toolName.length > 0) {
232
+ block.name = part.toolName;
233
+ }
234
+ if (input !== undefined) {
235
+ block.arguments = input;
236
+ }
237
+ return block;
238
+ }
239
+
240
+ if (typeof part.toolCallId === "string" && part.toolCallId.length > 0) {
241
+ block.id = part.toolCallId;
242
+ }
243
+ if (typeof part.toolName === "string" && part.toolName.length > 0) {
244
+ block.name = part.toolName;
245
+ }
246
+
247
+ if (input !== undefined) {
248
+ if (type === "functionCall") {
249
+ block.arguments = input;
250
+ } else {
251
+ block.input = input;
252
+ }
253
+ }
254
+ return block;
255
+ }
256
+
257
+ function toolResultBlockFromPart(part: MessagePartRecord, rawType?: string): unknown {
258
+ const type =
259
+ rawType === "function_call_output" || rawType === "toolResult" || rawType === "tool_result"
260
+ ? rawType
261
+ : "tool_result";
262
+ const output = parseStoredValue(part.toolOutput) ?? part.textContent ?? "";
263
+ const block: Record<string, unknown> = { type, output };
264
+
265
+ if (type === "function_call_output") {
266
+ if (typeof part.toolCallId === "string" && part.toolCallId.length > 0) {
267
+ block.call_id = part.toolCallId;
268
+ }
269
+ return block;
270
+ }
271
+
272
+ if (typeof part.toolCallId === "string" && part.toolCallId.length > 0) {
273
+ block.tool_use_id = part.toolCallId;
274
+ }
275
+ return block;
276
+ }
277
+
147
278
  function toRuntimeRole(
148
279
  dbRole: MessageRole,
149
280
  parts: MessagePartRecord[],
@@ -173,26 +304,43 @@ function toRuntimeRole(
173
304
  }
174
305
 
175
306
  function blockFromPart(part: MessagePartRecord): unknown {
176
- const decoded = parseJson(part.metadata);
177
- if (decoded && typeof decoded === "object") {
178
- const raw = (decoded as { raw?: unknown }).raw;
179
- if (raw && typeof raw === "object") {
180
- return raw;
181
- }
307
+ const metadata = getPartMetadata(part);
308
+ if (metadata.raw && typeof metadata.raw === "object") {
309
+ // If this is an OpenClaw-normalised OpenAI reasoning block, restore the original
310
+ // OpenAI format so the Responses API gets the {type:"reasoning", id:"rs_…"} it expects.
311
+ const restored = tryRestoreOpenAIReasoning(metadata.raw as Record<string, unknown>);
312
+ if (restored) return restored;
313
+ return metadata.raw;
182
314
  }
183
315
 
184
- if (part.partType === "text" || part.partType === "reasoning") {
185
- return { type: "text", text: part.textContent ?? "" };
316
+ if (part.partType === "reasoning") {
317
+ return reasoningBlockFromPart(part, metadata.rawType);
186
318
  }
187
319
  if (part.partType === "tool") {
188
- const toolOutput = parseJson(part.toolOutput);
189
- if (toolOutput !== undefined) {
190
- return toolOutput;
191
- }
192
- if (typeof part.textContent === "string") {
193
- return { type: "text", text: part.textContent };
320
+ if (metadata.originalRole === "toolResult" || metadata.rawType === "function_call_output") {
321
+ return toolResultBlockFromPart(part, metadata.rawType);
194
322
  }
195
- return { type: "text", text: part.toolOutput ?? part.toolInput ?? "" };
323
+ return toolCallBlockFromPart(part, metadata.rawType);
324
+ }
325
+ if (
326
+ metadata.rawType === "function_call" ||
327
+ metadata.rawType === "functionCall" ||
328
+ metadata.rawType === "tool_use" ||
329
+ metadata.rawType === "tool-use" ||
330
+ metadata.rawType === "toolUse" ||
331
+ metadata.rawType === "toolCall"
332
+ ) {
333
+ return toolCallBlockFromPart(part, metadata.rawType);
334
+ }
335
+ if (
336
+ metadata.rawType === "function_call_output" ||
337
+ metadata.rawType === "tool_result" ||
338
+ metadata.rawType === "toolResult"
339
+ ) {
340
+ return toolResultBlockFromPart(part, metadata.rawType);
341
+ }
342
+ if (part.partType === "text") {
343
+ return { type: "text", text: part.textContent ?? "" };
196
344
  }
197
345
 
198
346
  if (typeof part.textContent === "string" && part.textContent.length > 0) {
package/src/engine.ts CHANGED
@@ -60,6 +60,39 @@ function safeString(value: unknown): string | undefined {
60
60
  return typeof value === "string" ? value : undefined;
61
61
  }
62
62
 
63
+ function appendTextValue(value: unknown, out: string[]): void {
64
+ if (typeof value === "string") {
65
+ out.push(value);
66
+ return;
67
+ }
68
+ if (Array.isArray(value)) {
69
+ for (const entry of value) {
70
+ appendTextValue(entry, out);
71
+ }
72
+ return;
73
+ }
74
+ if (!value || typeof value !== "object") {
75
+ return;
76
+ }
77
+
78
+ const record = value as Record<string, unknown>;
79
+ appendTextValue(record.text, out);
80
+ appendTextValue(record.value, out);
81
+ }
82
+
83
+ function extractReasoningText(record: Record<string, unknown>): string | undefined {
84
+ const chunks: string[] = [];
85
+ appendTextValue(record.summary, chunks);
86
+ if (chunks.length === 0) {
87
+ return undefined;
88
+ }
89
+
90
+ const normalized = chunks
91
+ .map((chunk) => chunk.trim())
92
+ .filter((chunk, idx, arr) => chunk.length > 0 && arr.indexOf(chunk) === idx);
93
+ return normalized.length > 0 ? normalized.join("\n") : undefined;
94
+ }
95
+
63
96
  function normalizeUnknownBlock(value: unknown): {
64
97
  type: string;
65
98
  text?: string;
@@ -76,7 +109,12 @@ function normalizeUnknownBlock(value: unknown): {
76
109
  const rawType = safeString(record.type);
77
110
  return {
78
111
  type: rawType ?? "agent",
79
- text: safeString(record.text) ?? safeString(record.thinking),
112
+ text:
113
+ safeString(record.text) ??
114
+ safeString(record.thinking) ??
115
+ ((rawType === "reasoning" || rawType === "thinking")
116
+ ? extractReasoningText(record)
117
+ : undefined),
80
118
  metadata: { raw: record },
81
119
  };
82
120
  }
@@ -89,7 +127,12 @@ function toPartType(type: string): MessagePartType {
89
127
  case "reasoning":
90
128
  return "reasoning";
91
129
  case "tool_use":
130
+ case "toolUse":
92
131
  case "tool-use":
132
+ case "toolCall":
133
+ case "functionCall":
134
+ case "function_call":
135
+ case "function_call_output":
93
136
  case "tool_result":
94
137
  case "toolResult":
95
138
  case "tool":
@@ -215,7 +258,12 @@ function buildMessageParts(params: {
215
258
  const role = typeof message.role === "string" ? message.role : "unknown";
216
259
  const topLevel = message as unknown as Record<string, unknown>;
217
260
  const topLevelToolCallId =
218
- safeString(topLevel.toolCallId) ?? safeString(topLevel.tool_call_id) ?? safeString(topLevel.id);
261
+ safeString(topLevel.toolCallId) ??
262
+ safeString(topLevel.tool_call_id) ??
263
+ safeString(topLevel.toolUseId) ??
264
+ safeString(topLevel.tool_use_id) ??
265
+ safeString(topLevel.call_id) ??
266
+ safeString(topLevel.id);
219
267
 
220
268
  // BashExecutionMessage: preserve a synthetic text part so output is round-trippable.
221
269
  if (!("content" in message) && "command" in message && "output" in message) {
@@ -284,14 +332,19 @@ function buildMessageParts(params: {
284
332
  for (let ordinal = 0; ordinal < message.content.length; ordinal++) {
285
333
  const block = normalizeUnknownBlock(message.content[ordinal]);
286
334
  const metadataRecord = block.metadata.raw as Record<string, unknown> | undefined;
335
+ const partType = toPartType(block.type);
287
336
  const toolCallId =
288
337
  safeString(metadataRecord?.toolCallId) ??
289
338
  safeString(metadataRecord?.tool_call_id) ??
339
+ safeString(metadataRecord?.toolUseId) ??
340
+ safeString(metadataRecord?.tool_use_id) ??
341
+ safeString(metadataRecord?.call_id) ??
342
+ (partType === "tool" ? safeString(metadataRecord?.id) : undefined) ??
290
343
  topLevelToolCallId;
291
344
 
292
345
  parts.push({
293
346
  sessionId,
294
- partType: toPartType(block.type),
347
+ partType,
295
348
  ordinal,
296
349
  textContent: block.text ?? null,
297
350
  toolCallId,
@@ -302,6 +355,8 @@ function buildMessageParts(params: {
302
355
  toolInput:
303
356
  metadataRecord?.input !== undefined
304
357
  ? toJson(metadataRecord.input)
358
+ : metadataRecord?.arguments !== undefined
359
+ ? toJson(metadataRecord.arguments)
305
360
  : metadataRecord?.toolInput !== undefined
306
361
  ? toJson(metadataRecord.toolInput)
307
362
  : (safeString(metadataRecord?.tool_input) ?? null),
@@ -1454,6 +1509,10 @@ export class LcmContextEngine implements ContextEngine {
1454
1509
  observedTokens !== undefined
1455
1510
  ? await this.compaction.evaluate(conversationId, tokenBudget, observedTokens)
1456
1511
  : await this.compaction.evaluate(conversationId, tokenBudget);
1512
+ const targetTokens =
1513
+ params.compactionTarget === "threshold" ? decision.threshold : tokenBudget;
1514
+ const liveContextStillExceedsTarget =
1515
+ observedTokens !== undefined && observedTokens >= targetTokens;
1457
1516
 
1458
1517
  if (!forceCompaction && !decision.shouldCompact) {
1459
1518
  return {
@@ -1478,27 +1537,28 @@ export class LcmContextEngine implements ContextEngine {
1478
1537
  });
1479
1538
 
1480
1539
  return {
1481
- ok: true,
1540
+ ok: sweepResult.actionTaken || !liveContextStillExceedsTarget,
1482
1541
  compacted: sweepResult.actionTaken,
1483
1542
  reason: sweepResult.actionTaken
1484
1543
  ? "compacted"
1485
1544
  : manualCompactionRequested
1486
1545
  ? "nothing to compact"
1487
- : "already under target",
1546
+ : liveContextStillExceedsTarget
1547
+ ? "live context still exceeds target"
1548
+ : "already under target",
1488
1549
  result: {
1489
1550
  tokensBefore: decision.currentTokens,
1490
1551
  tokensAfter: sweepResult.tokensAfter,
1491
1552
  details: {
1492
1553
  rounds: sweepResult.actionTaken ? 1 : 0,
1493
- targetTokens:
1494
- params.compactionTarget === "threshold" ? decision.threshold : tokenBudget,
1554
+ targetTokens,
1495
1555
  },
1496
1556
  },
1497
1557
  };
1498
1558
  }
1499
1559
 
1500
1560
  // When forced, use the token budget as target
1501
- const targetTokens = forceCompaction
1561
+ const convergenceTargetTokens = forceCompaction
1502
1562
  ? tokenBudget
1503
1563
  : params.compactionTarget === "threshold"
1504
1564
  ? decision.threshold
@@ -1507,7 +1567,7 @@ export class LcmContextEngine implements ContextEngine {
1507
1567
  const compactResult = await this.compaction.compactUntilUnder({
1508
1568
  conversationId,
1509
1569
  tokenBudget,
1510
- targetTokens,
1570
+ targetTokens: convergenceTargetTokens,
1511
1571
  ...(observedTokens !== undefined ? { currentTokens: observedTokens } : {}),
1512
1572
  summarize,
1513
1573
  });
@@ -1526,7 +1586,7 @@ export class LcmContextEngine implements ContextEngine {
1526
1586
  tokensAfter: compactResult.finalTokens,
1527
1587
  details: {
1528
1588
  rounds: compactResult.rounds,
1529
- targetTokens,
1589
+ targetTokens: convergenceTargetTokens,
1530
1590
  },
1531
1591
  },
1532
1592
  };
@@ -27,7 +27,80 @@ type ToolCallLike = {
27
27
 
28
28
  // -- Extraction helpers (from tool-call-id.ts) --
29
29
 
30
- const TOOL_CALL_TYPES = new Set(["toolCall", "toolUse", "functionCall"]);
30
+ const TOOL_CALL_TYPES = new Set([
31
+ "toolCall",
32
+ "toolUse",
33
+ "tool_use",
34
+ "tool-use",
35
+ "functionCall",
36
+ "function_call",
37
+ ]);
38
+ const OPENAI_FUNCTION_CALL_TYPES = new Set(["functionCall", "function_call"]);
39
+
40
+ function extractToolCallId(block: { id?: unknown; call_id?: unknown }): string | null {
41
+ if (typeof block.id === "string" && block.id) {
42
+ return block.id;
43
+ }
44
+ if (typeof block.call_id === "string" && block.call_id) {
45
+ return block.call_id;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ function normalizeAssistantReasoningBlocks<T extends AgentMessageLike>(message: T): T {
51
+ if (!Array.isArray(message.content)) {
52
+ return message;
53
+ }
54
+
55
+ let sawToolCall = false;
56
+ let reasoningAfterToolCall = false;
57
+ let functionCallCount = 0;
58
+
59
+ for (const block of message.content) {
60
+ if (!block || typeof block !== "object") {
61
+ return message;
62
+ }
63
+
64
+ const type = (block as { type?: unknown }).type;
65
+ if (type === "reasoning" || type === "thinking") {
66
+ if (sawToolCall) {
67
+ reasoningAfterToolCall = true;
68
+ }
69
+ continue;
70
+ }
71
+
72
+ if (typeof type === "string" && TOOL_CALL_TYPES.has(type)) {
73
+ sawToolCall = true;
74
+ if (OPENAI_FUNCTION_CALL_TYPES.has(type)) {
75
+ functionCallCount += 1;
76
+ }
77
+ continue;
78
+ }
79
+
80
+ return message;
81
+ }
82
+
83
+ // Only repair the specific OpenAI shape we need: a single function call that
84
+ // has one or more reasoning blocks after it. Multi-call turns may use
85
+ // interleaved reasoning intentionally, so leave them untouched.
86
+ if (!reasoningAfterToolCall || functionCallCount !== 1) {
87
+ return message;
88
+ }
89
+
90
+ const reasoning = message.content.filter((block) => {
91
+ const type = (block as { type?: unknown }).type;
92
+ return type === "reasoning" || type === "thinking";
93
+ });
94
+ const toolCalls = message.content.filter((block) => {
95
+ const type = (block as { type?: unknown }).type;
96
+ return typeof type === "string" && TOOL_CALL_TYPES.has(type);
97
+ });
98
+
99
+ return {
100
+ ...message,
101
+ content: [...reasoning, ...toolCalls],
102
+ };
103
+ }
31
104
 
32
105
  function extractToolCallsFromAssistant(msg: AgentMessageLike): ToolCallLike[] {
33
106
  const content = msg.content;
@@ -40,13 +113,14 @@ function extractToolCallsFromAssistant(msg: AgentMessageLike): ToolCallLike[] {
40
113
  if (!block || typeof block !== "object") {
41
114
  continue;
42
115
  }
43
- const rec = block as { type?: unknown; id?: unknown; name?: unknown };
44
- if (typeof rec.id !== "string" || !rec.id) {
116
+ const rec = block as { type?: unknown; id?: unknown; call_id?: unknown; name?: unknown };
117
+ const id = extractToolCallId(rec);
118
+ if (!id) {
45
119
  continue;
46
120
  }
47
121
  if (typeof rec.type === "string" && TOOL_CALL_TYPES.has(rec.type)) {
48
122
  toolCalls.push({
49
- id: rec.id,
123
+ id,
50
124
  name: typeof rec.name === "string" ? rec.name : undefined,
51
125
  });
52
126
  }
@@ -134,18 +208,23 @@ export function sanitizeToolUseResultPairing<T extends AgentMessageLike>(message
134
208
  continue;
135
209
  }
136
210
 
211
+ const normalizedAssistant = normalizeAssistantReasoningBlocks(msg);
212
+ if (normalizedAssistant !== msg) {
213
+ changed = true;
214
+ }
215
+
137
216
  // Skip tool call extraction for aborted or errored assistant messages.
138
217
  // When stopReason is "error" or "aborted", the tool_use blocks may be incomplete
139
218
  // and should not have synthetic tool_results created.
140
- const stopReason = msg.stopReason;
219
+ const stopReason = normalizedAssistant.stopReason;
141
220
  if (stopReason === "error" || stopReason === "aborted") {
142
- out.push(msg);
221
+ out.push(normalizedAssistant as T);
143
222
  continue;
144
223
  }
145
224
 
146
- const toolCalls = extractToolCallsFromAssistant(msg);
225
+ const toolCalls = extractToolCallsFromAssistant(normalizedAssistant);
147
226
  if (toolCalls.length === 0) {
148
- out.push(msg);
227
+ out.push(normalizedAssistant as T);
149
228
  continue;
150
229
  }
151
230
 
@@ -190,7 +269,7 @@ export function sanitizeToolUseResultPairing<T extends AgentMessageLike>(message
190
269
  }
191
270
  }
192
271
 
193
- out.push(msg);
272
+ out.push(normalizedAssistant as T);
194
273
 
195
274
  if (spanResultsById.size > 0 && remainder.length > 0) {
196
275
  moved = true;