@martian-engineering/lossless-claw 0.2.3 → 0.2.4
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 +52 -3
- package/package.json +1 -1
- package/src/assembler.ts +163 -15
- package/src/engine.ts +58 -3
- package/src/transcript-repair.ts +88 -9
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
|
|
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
|
-
| `
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@martian-engineering/lossless-claw",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
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
|
|
177
|
-
if (
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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 === "
|
|
185
|
-
return
|
|
316
|
+
if (part.partType === "reasoning") {
|
|
317
|
+
return reasoningBlockFromPart(part, metadata.rawType);
|
|
186
318
|
}
|
|
187
319
|
if (part.partType === "tool") {
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
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:
|
|
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) ??
|
|
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
|
|
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),
|
package/src/transcript-repair.ts
CHANGED
|
@@ -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([
|
|
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
|
-
|
|
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
|
|
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 =
|
|
219
|
+
const stopReason = normalizedAssistant.stopReason;
|
|
141
220
|
if (stopReason === "error" || stopReason === "aborted") {
|
|
142
|
-
out.push(
|
|
221
|
+
out.push(normalizedAssistant as T);
|
|
143
222
|
continue;
|
|
144
223
|
}
|
|
145
224
|
|
|
146
|
-
const toolCalls = extractToolCallsFromAssistant(
|
|
225
|
+
const toolCalls = extractToolCallsFromAssistant(normalizedAssistant);
|
|
147
226
|
if (toolCalls.length === 0) {
|
|
148
|
-
out.push(
|
|
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(
|
|
272
|
+
out.push(normalizedAssistant as T);
|
|
194
273
|
|
|
195
274
|
if (spanResultsById.size > 0 && remainder.length > 0) {
|
|
196
275
|
moved = true;
|