@openviking/openclaw-plugin 2026.5.21 → 2026.5.26
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/INSTALL-AGENT.md +355 -355
- package/INSTALL-ZH.md +345 -345
- package/INSTALL.md +362 -362
- package/README.md +338 -338
- package/auto-recall.ts +257 -257
- package/client.ts +939 -939
- package/commands/setup.ts +1002 -1002
- package/config.ts +476 -476
- package/context-engine.ts +1737 -1737
- package/dist/index.js +18 -18
- package/index.ts +1985 -1985
- package/install-manifest.json +47 -47
- package/memory-ranking.ts +267 -267
- package/openclaw.plugin.json +335 -335
- package/package.json +76 -76
- package/process-manager.ts +45 -45
- package/runtime-utils.ts +5 -5
- package/session-transcript-repair.ts +530 -530
- package/skills/install-openviking-memory/SKILL.md +665 -665
- package/text-utils.ts +507 -507
- package/tool-call-id.ts +331 -331
|
@@ -1,530 +1,530 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tool use/result pairing repair for assembled context.
|
|
3
|
-
*
|
|
4
|
-
* Copied from openclaw core (src/agents/session-transcript-repair.ts).
|
|
5
|
-
* Only change: replaced `import type { AgentMessage } from "@mariozechner/pi-agent-core"`
|
|
6
|
-
* with import from local tool-call-id.ts to avoid the external dependency.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type { AgentMessage } from "./tool-call-id.js";
|
|
10
|
-
import { extractToolCallsFromAssistant, extractToolResultId } from "./tool-call-id.js";
|
|
11
|
-
|
|
12
|
-
const TOOL_CALL_NAME_MAX_CHARS = 64;
|
|
13
|
-
const TOOL_CALL_NAME_RE = /^[A-Za-z0-9_-]+$/;
|
|
14
|
-
|
|
15
|
-
type RawToolCallBlock = {
|
|
16
|
-
type?: unknown;
|
|
17
|
-
id?: unknown;
|
|
18
|
-
name?: unknown;
|
|
19
|
-
input?: unknown;
|
|
20
|
-
arguments?: unknown;
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
function isRawToolCallBlock(block: unknown): block is RawToolCallBlock {
|
|
24
|
-
if (!block || typeof block !== "object") {
|
|
25
|
-
return false;
|
|
26
|
-
}
|
|
27
|
-
const type = (block as { type?: unknown }).type;
|
|
28
|
-
return (
|
|
29
|
-
typeof type === "string" &&
|
|
30
|
-
(type === "toolCall" || type === "toolUse" || type === "functionCall")
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function hasToolCallInput(block: RawToolCallBlock): boolean {
|
|
35
|
-
const hasInput = "input" in block ? block.input !== undefined && block.input !== null : false;
|
|
36
|
-
const hasArguments =
|
|
37
|
-
"arguments" in block ? block.arguments !== undefined && block.arguments !== null : false;
|
|
38
|
-
return hasInput || hasArguments;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function hasNonEmptyStringField(value: unknown): boolean {
|
|
42
|
-
return typeof value === "string" && value.trim().length > 0;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function hasToolCallId(block: RawToolCallBlock): boolean {
|
|
46
|
-
return hasNonEmptyStringField(block.id);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function normalizeAllowedToolNames(allowedToolNames?: Iterable<string>): Set<string> | null {
|
|
50
|
-
if (!allowedToolNames) {
|
|
51
|
-
return null;
|
|
52
|
-
}
|
|
53
|
-
const normalized = new Set<string>();
|
|
54
|
-
for (const name of allowedToolNames) {
|
|
55
|
-
if (typeof name !== "string") {
|
|
56
|
-
continue;
|
|
57
|
-
}
|
|
58
|
-
const trimmed = name.trim();
|
|
59
|
-
if (trimmed) {
|
|
60
|
-
normalized.add(trimmed.toLowerCase());
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
return normalized.size > 0 ? normalized : null;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function hasToolCallName(block: RawToolCallBlock, allowedToolNames: Set<string> | null): boolean {
|
|
67
|
-
if (typeof block.name !== "string") {
|
|
68
|
-
return false;
|
|
69
|
-
}
|
|
70
|
-
const trimmed = block.name.trim();
|
|
71
|
-
if (!trimmed) {
|
|
72
|
-
return false;
|
|
73
|
-
}
|
|
74
|
-
if (trimmed.length > TOOL_CALL_NAME_MAX_CHARS || !TOOL_CALL_NAME_RE.test(trimmed)) {
|
|
75
|
-
return false;
|
|
76
|
-
}
|
|
77
|
-
if (!allowedToolNames) {
|
|
78
|
-
return true;
|
|
79
|
-
}
|
|
80
|
-
return allowedToolNames.has(trimmed.toLowerCase());
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function redactSessionsSpawnAttachmentsArgs(value: unknown): unknown {
|
|
84
|
-
if (!value || typeof value !== "object") {
|
|
85
|
-
return value;
|
|
86
|
-
}
|
|
87
|
-
const rec = value as Record<string, unknown>;
|
|
88
|
-
const raw = rec.attachments;
|
|
89
|
-
if (!Array.isArray(raw)) {
|
|
90
|
-
return value;
|
|
91
|
-
}
|
|
92
|
-
const next = raw.map((item) => {
|
|
93
|
-
if (!item || typeof item !== "object") {
|
|
94
|
-
return item;
|
|
95
|
-
}
|
|
96
|
-
const a = item as Record<string, unknown>;
|
|
97
|
-
if (!Object.hasOwn(a, "content")) {
|
|
98
|
-
return item;
|
|
99
|
-
}
|
|
100
|
-
const { content: _content, ...rest } = a;
|
|
101
|
-
return { ...rest, content: "__OPENCLAW_REDACTED__" };
|
|
102
|
-
});
|
|
103
|
-
return { ...rec, attachments: next };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function sanitizeToolCallBlock(block: RawToolCallBlock): RawToolCallBlock {
|
|
107
|
-
const rawName = typeof block.name === "string" ? block.name : undefined;
|
|
108
|
-
const trimmedName = rawName?.trim();
|
|
109
|
-
const hasTrimmedName = typeof trimmedName === "string" && trimmedName.length > 0;
|
|
110
|
-
const normalizedName = hasTrimmedName ? trimmedName : undefined;
|
|
111
|
-
const nameChanged = hasTrimmedName && rawName !== trimmedName;
|
|
112
|
-
|
|
113
|
-
const isSessionsSpawn = normalizedName?.toLowerCase() === "sessions_spawn";
|
|
114
|
-
|
|
115
|
-
if (!isSessionsSpawn) {
|
|
116
|
-
if (!nameChanged) {
|
|
117
|
-
return block;
|
|
118
|
-
}
|
|
119
|
-
return { ...(block as Record<string, unknown>), name: normalizedName } as RawToolCallBlock;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Redact large/sensitive inline attachment content from persisted transcripts.
|
|
123
|
-
// Apply redaction to both `.arguments` and `.input` properties since block structures can vary
|
|
124
|
-
const nextArgs = redactSessionsSpawnAttachmentsArgs(block.arguments);
|
|
125
|
-
const nextInput = redactSessionsSpawnAttachmentsArgs(block.input);
|
|
126
|
-
if (nextArgs === block.arguments && nextInput === block.input && !nameChanged) {
|
|
127
|
-
return block;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const next = { ...(block as Record<string, unknown>) };
|
|
131
|
-
if (nameChanged && normalizedName) {
|
|
132
|
-
next.name = normalizedName;
|
|
133
|
-
}
|
|
134
|
-
if (nextArgs !== block.arguments || Object.hasOwn(block, "arguments")) {
|
|
135
|
-
next.arguments = nextArgs;
|
|
136
|
-
}
|
|
137
|
-
if (nextInput !== block.input || Object.hasOwn(block, "input")) {
|
|
138
|
-
next.input = nextInput;
|
|
139
|
-
}
|
|
140
|
-
return next as RawToolCallBlock;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function makeMissingToolResult(params: {
|
|
144
|
-
toolCallId: string;
|
|
145
|
-
toolName?: string;
|
|
146
|
-
}): Extract<AgentMessage, { role: "toolResult" }> {
|
|
147
|
-
return {
|
|
148
|
-
role: "toolResult",
|
|
149
|
-
toolCallId: params.toolCallId,
|
|
150
|
-
toolName: params.toolName ?? "unknown",
|
|
151
|
-
content: [
|
|
152
|
-
{
|
|
153
|
-
type: "text",
|
|
154
|
-
text: "[openclaw] missing tool result in session history; inserted synthetic error result for transcript repair.",
|
|
155
|
-
},
|
|
156
|
-
],
|
|
157
|
-
isError: true,
|
|
158
|
-
timestamp: Date.now(),
|
|
159
|
-
} as Extract<AgentMessage, { role: "toolResult" }>;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function trimNonEmptyString(value: unknown): string | undefined {
|
|
163
|
-
if (typeof value !== "string") {
|
|
164
|
-
return undefined;
|
|
165
|
-
}
|
|
166
|
-
const trimmed = value.trim();
|
|
167
|
-
return trimmed || undefined;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function normalizeToolResultName(
|
|
171
|
-
message: Extract<AgentMessage, { role: "toolResult" }>,
|
|
172
|
-
fallbackName?: string,
|
|
173
|
-
): Extract<AgentMessage, { role: "toolResult" }> {
|
|
174
|
-
const rawToolName = (message as { toolName?: unknown }).toolName;
|
|
175
|
-
const normalizedToolName = trimNonEmptyString(rawToolName);
|
|
176
|
-
if (normalizedToolName) {
|
|
177
|
-
if (rawToolName === normalizedToolName) {
|
|
178
|
-
return message;
|
|
179
|
-
}
|
|
180
|
-
return { ...message, toolName: normalizedToolName };
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
const normalizedFallback = trimNonEmptyString(fallbackName);
|
|
184
|
-
if (normalizedFallback) {
|
|
185
|
-
return { ...message, toolName: normalizedFallback };
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
if (typeof rawToolName === "string") {
|
|
189
|
-
return { ...message, toolName: "unknown" };
|
|
190
|
-
}
|
|
191
|
-
return message;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
export { makeMissingToolResult };
|
|
195
|
-
|
|
196
|
-
export type ToolCallInputRepairReport = {
|
|
197
|
-
messages: AgentMessage[];
|
|
198
|
-
droppedToolCalls: number;
|
|
199
|
-
droppedAssistantMessages: number;
|
|
200
|
-
};
|
|
201
|
-
|
|
202
|
-
export type ToolCallInputRepairOptions = {
|
|
203
|
-
allowedToolNames?: Iterable<string>;
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
export type ToolUseResultPairingOptions = {
|
|
207
|
-
preserveErroredAssistantResults?: boolean;
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
export function stripToolResultDetails(messages: AgentMessage[]): AgentMessage[] {
|
|
211
|
-
let touched = false;
|
|
212
|
-
const out: AgentMessage[] = [];
|
|
213
|
-
for (const msg of messages) {
|
|
214
|
-
if (!msg || typeof msg !== "object" || (msg as { role?: unknown }).role !== "toolResult") {
|
|
215
|
-
out.push(msg);
|
|
216
|
-
continue;
|
|
217
|
-
}
|
|
218
|
-
if (!("details" in msg)) {
|
|
219
|
-
out.push(msg);
|
|
220
|
-
continue;
|
|
221
|
-
}
|
|
222
|
-
const sanitized = { ...(msg as object) } as { details?: unknown };
|
|
223
|
-
delete sanitized.details;
|
|
224
|
-
touched = true;
|
|
225
|
-
out.push(sanitized as unknown as AgentMessage);
|
|
226
|
-
}
|
|
227
|
-
return touched ? out : messages;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
export function repairToolCallInputs(
|
|
231
|
-
messages: AgentMessage[],
|
|
232
|
-
options?: ToolCallInputRepairOptions,
|
|
233
|
-
): ToolCallInputRepairReport {
|
|
234
|
-
let droppedToolCalls = 0;
|
|
235
|
-
let droppedAssistantMessages = 0;
|
|
236
|
-
let changed = false;
|
|
237
|
-
const out: AgentMessage[] = [];
|
|
238
|
-
const allowedToolNames = normalizeAllowedToolNames(options?.allowedToolNames);
|
|
239
|
-
|
|
240
|
-
for (const msg of messages) {
|
|
241
|
-
if (!msg || typeof msg !== "object") {
|
|
242
|
-
out.push(msg);
|
|
243
|
-
continue;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
|
|
247
|
-
out.push(msg);
|
|
248
|
-
continue;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const nextContent: typeof msg.content = [];
|
|
252
|
-
let droppedInMessage = 0;
|
|
253
|
-
let messageChanged = false;
|
|
254
|
-
|
|
255
|
-
for (const block of msg.content) {
|
|
256
|
-
if (
|
|
257
|
-
isRawToolCallBlock(block) &&
|
|
258
|
-
(!hasToolCallInput(block) ||
|
|
259
|
-
!hasToolCallId(block) ||
|
|
260
|
-
!hasToolCallName(block, allowedToolNames))
|
|
261
|
-
) {
|
|
262
|
-
droppedToolCalls += 1;
|
|
263
|
-
droppedInMessage += 1;
|
|
264
|
-
changed = true;
|
|
265
|
-
messageChanged = true;
|
|
266
|
-
continue;
|
|
267
|
-
}
|
|
268
|
-
if (isRawToolCallBlock(block)) {
|
|
269
|
-
if (
|
|
270
|
-
(block as { type?: unknown }).type === "toolCall" ||
|
|
271
|
-
(block as { type?: unknown }).type === "toolUse" ||
|
|
272
|
-
(block as { type?: unknown }).type === "functionCall"
|
|
273
|
-
) {
|
|
274
|
-
// Only sanitize (redact) sessions_spawn blocks; all others are passed through
|
|
275
|
-
// unchanged to preserve provider-specific shapes (e.g. toolUse.input for Anthropic).
|
|
276
|
-
const blockName =
|
|
277
|
-
typeof (block as { name?: unknown }).name === "string"
|
|
278
|
-
? (block as { name: string }).name.trim()
|
|
279
|
-
: undefined;
|
|
280
|
-
if (blockName?.toLowerCase() === "sessions_spawn") {
|
|
281
|
-
const sanitized = sanitizeToolCallBlock(block);
|
|
282
|
-
if (sanitized !== block) {
|
|
283
|
-
changed = true;
|
|
284
|
-
messageChanged = true;
|
|
285
|
-
}
|
|
286
|
-
nextContent.push(sanitized as typeof block);
|
|
287
|
-
} else {
|
|
288
|
-
if (typeof (block as { name?: unknown }).name === "string") {
|
|
289
|
-
const rawName = (block as { name: string }).name;
|
|
290
|
-
const trimmedName = rawName.trim();
|
|
291
|
-
if (rawName !== trimmedName && trimmedName) {
|
|
292
|
-
const renamed = { ...(block as object), name: trimmedName } as typeof block;
|
|
293
|
-
nextContent.push(renamed);
|
|
294
|
-
changed = true;
|
|
295
|
-
messageChanged = true;
|
|
296
|
-
} else {
|
|
297
|
-
nextContent.push(block);
|
|
298
|
-
}
|
|
299
|
-
} else {
|
|
300
|
-
nextContent.push(block);
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
continue;
|
|
304
|
-
}
|
|
305
|
-
} else {
|
|
306
|
-
nextContent.push(block);
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
if (droppedInMessage > 0) {
|
|
311
|
-
if (nextContent.length === 0) {
|
|
312
|
-
droppedAssistantMessages += 1;
|
|
313
|
-
changed = true;
|
|
314
|
-
continue;
|
|
315
|
-
}
|
|
316
|
-
out.push({ ...msg, content: nextContent });
|
|
317
|
-
continue;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
if (messageChanged) {
|
|
321
|
-
out.push({ ...msg, content: nextContent });
|
|
322
|
-
continue;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
out.push(msg);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
return {
|
|
329
|
-
messages: changed ? out : messages,
|
|
330
|
-
droppedToolCalls,
|
|
331
|
-
droppedAssistantMessages,
|
|
332
|
-
};
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
export function sanitizeToolCallInputs(
|
|
336
|
-
messages: AgentMessage[],
|
|
337
|
-
options?: ToolCallInputRepairOptions,
|
|
338
|
-
): AgentMessage[] {
|
|
339
|
-
return repairToolCallInputs(messages, options).messages;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
export function sanitizeToolUseResultPairing(
|
|
343
|
-
messages: AgentMessage[],
|
|
344
|
-
options?: ToolUseResultPairingOptions,
|
|
345
|
-
): AgentMessage[] {
|
|
346
|
-
return repairToolUseResultPairing(messages, options).messages;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
export type ToolUseRepairReport = {
|
|
350
|
-
messages: AgentMessage[];
|
|
351
|
-
added: Array<Extract<AgentMessage, { role: "toolResult" }>>;
|
|
352
|
-
droppedDuplicateCount: number;
|
|
353
|
-
droppedOrphanCount: number;
|
|
354
|
-
moved: boolean;
|
|
355
|
-
};
|
|
356
|
-
|
|
357
|
-
export function repairToolUseResultPairing(
|
|
358
|
-
messages: AgentMessage[],
|
|
359
|
-
options?: ToolUseResultPairingOptions,
|
|
360
|
-
): ToolUseRepairReport {
|
|
361
|
-
// Anthropic (and Cloud Code Assist) reject transcripts where assistant tool calls are not
|
|
362
|
-
// immediately followed by matching tool results. Session files can end up with results
|
|
363
|
-
// displaced (e.g. after user turns) or duplicated. Repair by:
|
|
364
|
-
// - moving matching toolResult messages directly after their assistant toolCall turn
|
|
365
|
-
// - inserting synthetic error toolResults for missing ids
|
|
366
|
-
// - dropping duplicate toolResults for the same id (anywhere in the transcript)
|
|
367
|
-
const out: AgentMessage[] = [];
|
|
368
|
-
const added: Array<Extract<AgentMessage, { role: "toolResult" }>> = [];
|
|
369
|
-
const seenToolResultIds = new Set<string>();
|
|
370
|
-
let droppedDuplicateCount = 0;
|
|
371
|
-
let droppedOrphanCount = 0;
|
|
372
|
-
let moved = false;
|
|
373
|
-
let changed = false;
|
|
374
|
-
|
|
375
|
-
const pushToolResult = (msg: Extract<AgentMessage, { role: "toolResult" }>) => {
|
|
376
|
-
const id = extractToolResultId(msg);
|
|
377
|
-
if (id && seenToolResultIds.has(id)) {
|
|
378
|
-
droppedDuplicateCount += 1;
|
|
379
|
-
changed = true;
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
|
-
if (id) {
|
|
383
|
-
seenToolResultIds.add(id);
|
|
384
|
-
}
|
|
385
|
-
out.push(msg);
|
|
386
|
-
};
|
|
387
|
-
|
|
388
|
-
for (let i = 0; i < messages.length; i += 1) {
|
|
389
|
-
const msg = messages[i];
|
|
390
|
-
if (!msg || typeof msg !== "object") {
|
|
391
|
-
out.push(msg);
|
|
392
|
-
continue;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
const role = (msg as { role?: unknown }).role;
|
|
396
|
-
if (role !== "assistant") {
|
|
397
|
-
// Tool results must only appear directly after the matching assistant tool call turn.
|
|
398
|
-
// Any "free-floating" toolResult entries in session history can make strict providers
|
|
399
|
-
// (Anthropic-compatible APIs, MiniMax, Cloud Code Assist) reject the entire request.
|
|
400
|
-
if (role !== "toolResult") {
|
|
401
|
-
out.push(msg);
|
|
402
|
-
} else {
|
|
403
|
-
droppedOrphanCount += 1;
|
|
404
|
-
changed = true;
|
|
405
|
-
}
|
|
406
|
-
continue;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
const assistant = msg as Extract<AgentMessage, { role: "assistant" }>;
|
|
410
|
-
|
|
411
|
-
const toolCalls = extractToolCallsFromAssistant(assistant);
|
|
412
|
-
if (toolCalls.length === 0) {
|
|
413
|
-
out.push(msg);
|
|
414
|
-
continue;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
const toolCallIds = new Set(toolCalls.map((t) => t.id));
|
|
418
|
-
const toolCallNamesById = new Map(toolCalls.map((t) => [t.id, t.name] as const));
|
|
419
|
-
|
|
420
|
-
const spanResultsById = new Map<string, Extract<AgentMessage, { role: "toolResult" }>>();
|
|
421
|
-
const remainder: AgentMessage[] = [];
|
|
422
|
-
|
|
423
|
-
let j = i + 1;
|
|
424
|
-
for (; j < messages.length; j += 1) {
|
|
425
|
-
const next = messages[j];
|
|
426
|
-
if (!next || typeof next !== "object") {
|
|
427
|
-
remainder.push(next);
|
|
428
|
-
continue;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
const nextRole = (next as { role?: unknown }).role;
|
|
432
|
-
if (nextRole === "assistant") {
|
|
433
|
-
break;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
if (nextRole === "toolResult") {
|
|
437
|
-
const toolResult = next as Extract<AgentMessage, { role: "toolResult" }>;
|
|
438
|
-
const id = extractToolResultId(toolResult);
|
|
439
|
-
if (id && toolCallIds.has(id)) {
|
|
440
|
-
if (seenToolResultIds.has(id)) {
|
|
441
|
-
droppedDuplicateCount += 1;
|
|
442
|
-
changed = true;
|
|
443
|
-
continue;
|
|
444
|
-
}
|
|
445
|
-
const normalizedToolResult = normalizeToolResultName(
|
|
446
|
-
toolResult,
|
|
447
|
-
toolCallNamesById.get(id),
|
|
448
|
-
);
|
|
449
|
-
if (normalizedToolResult !== toolResult) {
|
|
450
|
-
changed = true;
|
|
451
|
-
}
|
|
452
|
-
if (!spanResultsById.has(id)) {
|
|
453
|
-
spanResultsById.set(id, normalizedToolResult);
|
|
454
|
-
}
|
|
455
|
-
continue;
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
// Drop tool results that don't match the current assistant tool calls.
|
|
460
|
-
if (nextRole !== "toolResult") {
|
|
461
|
-
remainder.push(next);
|
|
462
|
-
} else {
|
|
463
|
-
droppedOrphanCount += 1;
|
|
464
|
-
changed = true;
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
// Aborted/errored assistant turns should never synthesize missing tool results, but
|
|
469
|
-
// the replay sanitizer can still legitimately retain real tool results for surviving
|
|
470
|
-
// tool calls in the same turn after malformed siblings are dropped.
|
|
471
|
-
const stopReason = (assistant as { stopReason?: string }).stopReason;
|
|
472
|
-
if (stopReason === "error" || stopReason === "aborted") {
|
|
473
|
-
out.push(msg);
|
|
474
|
-
if (options?.preserveErroredAssistantResults) {
|
|
475
|
-
for (const toolCall of toolCalls) {
|
|
476
|
-
const result = spanResultsById.get(toolCall.id);
|
|
477
|
-
if (!result) {
|
|
478
|
-
continue;
|
|
479
|
-
}
|
|
480
|
-
pushToolResult(result);
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
for (const rem of remainder) {
|
|
484
|
-
out.push(rem);
|
|
485
|
-
}
|
|
486
|
-
i = j - 1;
|
|
487
|
-
continue;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
out.push(msg);
|
|
491
|
-
|
|
492
|
-
if (spanResultsById.size > 0 && remainder.length > 0) {
|
|
493
|
-
moved = true;
|
|
494
|
-
changed = true;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
for (const call of toolCalls) {
|
|
498
|
-
const existing = spanResultsById.get(call.id);
|
|
499
|
-
if (existing) {
|
|
500
|
-
pushToolResult(existing);
|
|
501
|
-
} else {
|
|
502
|
-
const missing = makeMissingToolResult({
|
|
503
|
-
toolCallId: call.id,
|
|
504
|
-
toolName: call.name,
|
|
505
|
-
});
|
|
506
|
-
added.push(missing);
|
|
507
|
-
changed = true;
|
|
508
|
-
pushToolResult(missing);
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
for (const rem of remainder) {
|
|
513
|
-
if (!rem || typeof rem !== "object") {
|
|
514
|
-
out.push(rem);
|
|
515
|
-
continue;
|
|
516
|
-
}
|
|
517
|
-
out.push(rem);
|
|
518
|
-
}
|
|
519
|
-
i = j - 1;
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
const changedOrMoved = changed || moved;
|
|
523
|
-
return {
|
|
524
|
-
messages: changedOrMoved ? out : messages,
|
|
525
|
-
added,
|
|
526
|
-
droppedDuplicateCount,
|
|
527
|
-
droppedOrphanCount,
|
|
528
|
-
moved: changedOrMoved,
|
|
529
|
-
};
|
|
530
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Tool use/result pairing repair for assembled context.
|
|
3
|
+
*
|
|
4
|
+
* Copied from openclaw core (src/agents/session-transcript-repair.ts).
|
|
5
|
+
* Only change: replaced `import type { AgentMessage } from "@mariozechner/pi-agent-core"`
|
|
6
|
+
* with import from local tool-call-id.ts to avoid the external dependency.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { AgentMessage } from "./tool-call-id.js";
|
|
10
|
+
import { extractToolCallsFromAssistant, extractToolResultId } from "./tool-call-id.js";
|
|
11
|
+
|
|
12
|
+
const TOOL_CALL_NAME_MAX_CHARS = 64;
|
|
13
|
+
const TOOL_CALL_NAME_RE = /^[A-Za-z0-9_-]+$/;
|
|
14
|
+
|
|
15
|
+
type RawToolCallBlock = {
|
|
16
|
+
type?: unknown;
|
|
17
|
+
id?: unknown;
|
|
18
|
+
name?: unknown;
|
|
19
|
+
input?: unknown;
|
|
20
|
+
arguments?: unknown;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function isRawToolCallBlock(block: unknown): block is RawToolCallBlock {
|
|
24
|
+
if (!block || typeof block !== "object") {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
const type = (block as { type?: unknown }).type;
|
|
28
|
+
return (
|
|
29
|
+
typeof type === "string" &&
|
|
30
|
+
(type === "toolCall" || type === "toolUse" || type === "functionCall")
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hasToolCallInput(block: RawToolCallBlock): boolean {
|
|
35
|
+
const hasInput = "input" in block ? block.input !== undefined && block.input !== null : false;
|
|
36
|
+
const hasArguments =
|
|
37
|
+
"arguments" in block ? block.arguments !== undefined && block.arguments !== null : false;
|
|
38
|
+
return hasInput || hasArguments;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function hasNonEmptyStringField(value: unknown): boolean {
|
|
42
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function hasToolCallId(block: RawToolCallBlock): boolean {
|
|
46
|
+
return hasNonEmptyStringField(block.id);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeAllowedToolNames(allowedToolNames?: Iterable<string>): Set<string> | null {
|
|
50
|
+
if (!allowedToolNames) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const normalized = new Set<string>();
|
|
54
|
+
for (const name of allowedToolNames) {
|
|
55
|
+
if (typeof name !== "string") {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const trimmed = name.trim();
|
|
59
|
+
if (trimmed) {
|
|
60
|
+
normalized.add(trimmed.toLowerCase());
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return normalized.size > 0 ? normalized : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function hasToolCallName(block: RawToolCallBlock, allowedToolNames: Set<string> | null): boolean {
|
|
67
|
+
if (typeof block.name !== "string") {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
const trimmed = block.name.trim();
|
|
71
|
+
if (!trimmed) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
if (trimmed.length > TOOL_CALL_NAME_MAX_CHARS || !TOOL_CALL_NAME_RE.test(trimmed)) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (!allowedToolNames) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
return allowedToolNames.has(trimmed.toLowerCase());
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function redactSessionsSpawnAttachmentsArgs(value: unknown): unknown {
|
|
84
|
+
if (!value || typeof value !== "object") {
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
const rec = value as Record<string, unknown>;
|
|
88
|
+
const raw = rec.attachments;
|
|
89
|
+
if (!Array.isArray(raw)) {
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
const next = raw.map((item) => {
|
|
93
|
+
if (!item || typeof item !== "object") {
|
|
94
|
+
return item;
|
|
95
|
+
}
|
|
96
|
+
const a = item as Record<string, unknown>;
|
|
97
|
+
if (!Object.hasOwn(a, "content")) {
|
|
98
|
+
return item;
|
|
99
|
+
}
|
|
100
|
+
const { content: _content, ...rest } = a;
|
|
101
|
+
return { ...rest, content: "__OPENCLAW_REDACTED__" };
|
|
102
|
+
});
|
|
103
|
+
return { ...rec, attachments: next };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function sanitizeToolCallBlock(block: RawToolCallBlock): RawToolCallBlock {
|
|
107
|
+
const rawName = typeof block.name === "string" ? block.name : undefined;
|
|
108
|
+
const trimmedName = rawName?.trim();
|
|
109
|
+
const hasTrimmedName = typeof trimmedName === "string" && trimmedName.length > 0;
|
|
110
|
+
const normalizedName = hasTrimmedName ? trimmedName : undefined;
|
|
111
|
+
const nameChanged = hasTrimmedName && rawName !== trimmedName;
|
|
112
|
+
|
|
113
|
+
const isSessionsSpawn = normalizedName?.toLowerCase() === "sessions_spawn";
|
|
114
|
+
|
|
115
|
+
if (!isSessionsSpawn) {
|
|
116
|
+
if (!nameChanged) {
|
|
117
|
+
return block;
|
|
118
|
+
}
|
|
119
|
+
return { ...(block as Record<string, unknown>), name: normalizedName } as RawToolCallBlock;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Redact large/sensitive inline attachment content from persisted transcripts.
|
|
123
|
+
// Apply redaction to both `.arguments` and `.input` properties since block structures can vary
|
|
124
|
+
const nextArgs = redactSessionsSpawnAttachmentsArgs(block.arguments);
|
|
125
|
+
const nextInput = redactSessionsSpawnAttachmentsArgs(block.input);
|
|
126
|
+
if (nextArgs === block.arguments && nextInput === block.input && !nameChanged) {
|
|
127
|
+
return block;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const next = { ...(block as Record<string, unknown>) };
|
|
131
|
+
if (nameChanged && normalizedName) {
|
|
132
|
+
next.name = normalizedName;
|
|
133
|
+
}
|
|
134
|
+
if (nextArgs !== block.arguments || Object.hasOwn(block, "arguments")) {
|
|
135
|
+
next.arguments = nextArgs;
|
|
136
|
+
}
|
|
137
|
+
if (nextInput !== block.input || Object.hasOwn(block, "input")) {
|
|
138
|
+
next.input = nextInput;
|
|
139
|
+
}
|
|
140
|
+
return next as RawToolCallBlock;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function makeMissingToolResult(params: {
|
|
144
|
+
toolCallId: string;
|
|
145
|
+
toolName?: string;
|
|
146
|
+
}): Extract<AgentMessage, { role: "toolResult" }> {
|
|
147
|
+
return {
|
|
148
|
+
role: "toolResult",
|
|
149
|
+
toolCallId: params.toolCallId,
|
|
150
|
+
toolName: params.toolName ?? "unknown",
|
|
151
|
+
content: [
|
|
152
|
+
{
|
|
153
|
+
type: "text",
|
|
154
|
+
text: "[openclaw] missing tool result in session history; inserted synthetic error result for transcript repair.",
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
isError: true,
|
|
158
|
+
timestamp: Date.now(),
|
|
159
|
+
} as Extract<AgentMessage, { role: "toolResult" }>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function trimNonEmptyString(value: unknown): string | undefined {
|
|
163
|
+
if (typeof value !== "string") {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
const trimmed = value.trim();
|
|
167
|
+
return trimmed || undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function normalizeToolResultName(
|
|
171
|
+
message: Extract<AgentMessage, { role: "toolResult" }>,
|
|
172
|
+
fallbackName?: string,
|
|
173
|
+
): Extract<AgentMessage, { role: "toolResult" }> {
|
|
174
|
+
const rawToolName = (message as { toolName?: unknown }).toolName;
|
|
175
|
+
const normalizedToolName = trimNonEmptyString(rawToolName);
|
|
176
|
+
if (normalizedToolName) {
|
|
177
|
+
if (rawToolName === normalizedToolName) {
|
|
178
|
+
return message;
|
|
179
|
+
}
|
|
180
|
+
return { ...message, toolName: normalizedToolName };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const normalizedFallback = trimNonEmptyString(fallbackName);
|
|
184
|
+
if (normalizedFallback) {
|
|
185
|
+
return { ...message, toolName: normalizedFallback };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (typeof rawToolName === "string") {
|
|
189
|
+
return { ...message, toolName: "unknown" };
|
|
190
|
+
}
|
|
191
|
+
return message;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export { makeMissingToolResult };
|
|
195
|
+
|
|
196
|
+
export type ToolCallInputRepairReport = {
|
|
197
|
+
messages: AgentMessage[];
|
|
198
|
+
droppedToolCalls: number;
|
|
199
|
+
droppedAssistantMessages: number;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export type ToolCallInputRepairOptions = {
|
|
203
|
+
allowedToolNames?: Iterable<string>;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
export type ToolUseResultPairingOptions = {
|
|
207
|
+
preserveErroredAssistantResults?: boolean;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
export function stripToolResultDetails(messages: AgentMessage[]): AgentMessage[] {
|
|
211
|
+
let touched = false;
|
|
212
|
+
const out: AgentMessage[] = [];
|
|
213
|
+
for (const msg of messages) {
|
|
214
|
+
if (!msg || typeof msg !== "object" || (msg as { role?: unknown }).role !== "toolResult") {
|
|
215
|
+
out.push(msg);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (!("details" in msg)) {
|
|
219
|
+
out.push(msg);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const sanitized = { ...(msg as object) } as { details?: unknown };
|
|
223
|
+
delete sanitized.details;
|
|
224
|
+
touched = true;
|
|
225
|
+
out.push(sanitized as unknown as AgentMessage);
|
|
226
|
+
}
|
|
227
|
+
return touched ? out : messages;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function repairToolCallInputs(
|
|
231
|
+
messages: AgentMessage[],
|
|
232
|
+
options?: ToolCallInputRepairOptions,
|
|
233
|
+
): ToolCallInputRepairReport {
|
|
234
|
+
let droppedToolCalls = 0;
|
|
235
|
+
let droppedAssistantMessages = 0;
|
|
236
|
+
let changed = false;
|
|
237
|
+
const out: AgentMessage[] = [];
|
|
238
|
+
const allowedToolNames = normalizeAllowedToolNames(options?.allowedToolNames);
|
|
239
|
+
|
|
240
|
+
for (const msg of messages) {
|
|
241
|
+
if (!msg || typeof msg !== "object") {
|
|
242
|
+
out.push(msg);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
|
|
247
|
+
out.push(msg);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const nextContent: typeof msg.content = [];
|
|
252
|
+
let droppedInMessage = 0;
|
|
253
|
+
let messageChanged = false;
|
|
254
|
+
|
|
255
|
+
for (const block of msg.content) {
|
|
256
|
+
if (
|
|
257
|
+
isRawToolCallBlock(block) &&
|
|
258
|
+
(!hasToolCallInput(block) ||
|
|
259
|
+
!hasToolCallId(block) ||
|
|
260
|
+
!hasToolCallName(block, allowedToolNames))
|
|
261
|
+
) {
|
|
262
|
+
droppedToolCalls += 1;
|
|
263
|
+
droppedInMessage += 1;
|
|
264
|
+
changed = true;
|
|
265
|
+
messageChanged = true;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (isRawToolCallBlock(block)) {
|
|
269
|
+
if (
|
|
270
|
+
(block as { type?: unknown }).type === "toolCall" ||
|
|
271
|
+
(block as { type?: unknown }).type === "toolUse" ||
|
|
272
|
+
(block as { type?: unknown }).type === "functionCall"
|
|
273
|
+
) {
|
|
274
|
+
// Only sanitize (redact) sessions_spawn blocks; all others are passed through
|
|
275
|
+
// unchanged to preserve provider-specific shapes (e.g. toolUse.input for Anthropic).
|
|
276
|
+
const blockName =
|
|
277
|
+
typeof (block as { name?: unknown }).name === "string"
|
|
278
|
+
? (block as { name: string }).name.trim()
|
|
279
|
+
: undefined;
|
|
280
|
+
if (blockName?.toLowerCase() === "sessions_spawn") {
|
|
281
|
+
const sanitized = sanitizeToolCallBlock(block);
|
|
282
|
+
if (sanitized !== block) {
|
|
283
|
+
changed = true;
|
|
284
|
+
messageChanged = true;
|
|
285
|
+
}
|
|
286
|
+
nextContent.push(sanitized as typeof block);
|
|
287
|
+
} else {
|
|
288
|
+
if (typeof (block as { name?: unknown }).name === "string") {
|
|
289
|
+
const rawName = (block as { name: string }).name;
|
|
290
|
+
const trimmedName = rawName.trim();
|
|
291
|
+
if (rawName !== trimmedName && trimmedName) {
|
|
292
|
+
const renamed = { ...(block as object), name: trimmedName } as typeof block;
|
|
293
|
+
nextContent.push(renamed);
|
|
294
|
+
changed = true;
|
|
295
|
+
messageChanged = true;
|
|
296
|
+
} else {
|
|
297
|
+
nextContent.push(block);
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
nextContent.push(block);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
} else {
|
|
306
|
+
nextContent.push(block);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (droppedInMessage > 0) {
|
|
311
|
+
if (nextContent.length === 0) {
|
|
312
|
+
droppedAssistantMessages += 1;
|
|
313
|
+
changed = true;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
out.push({ ...msg, content: nextContent });
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (messageChanged) {
|
|
321
|
+
out.push({ ...msg, content: nextContent });
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
out.push(msg);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
messages: changed ? out : messages,
|
|
330
|
+
droppedToolCalls,
|
|
331
|
+
droppedAssistantMessages,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function sanitizeToolCallInputs(
|
|
336
|
+
messages: AgentMessage[],
|
|
337
|
+
options?: ToolCallInputRepairOptions,
|
|
338
|
+
): AgentMessage[] {
|
|
339
|
+
return repairToolCallInputs(messages, options).messages;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function sanitizeToolUseResultPairing(
|
|
343
|
+
messages: AgentMessage[],
|
|
344
|
+
options?: ToolUseResultPairingOptions,
|
|
345
|
+
): AgentMessage[] {
|
|
346
|
+
return repairToolUseResultPairing(messages, options).messages;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export type ToolUseRepairReport = {
|
|
350
|
+
messages: AgentMessage[];
|
|
351
|
+
added: Array<Extract<AgentMessage, { role: "toolResult" }>>;
|
|
352
|
+
droppedDuplicateCount: number;
|
|
353
|
+
droppedOrphanCount: number;
|
|
354
|
+
moved: boolean;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
export function repairToolUseResultPairing(
|
|
358
|
+
messages: AgentMessage[],
|
|
359
|
+
options?: ToolUseResultPairingOptions,
|
|
360
|
+
): ToolUseRepairReport {
|
|
361
|
+
// Anthropic (and Cloud Code Assist) reject transcripts where assistant tool calls are not
|
|
362
|
+
// immediately followed by matching tool results. Session files can end up with results
|
|
363
|
+
// displaced (e.g. after user turns) or duplicated. Repair by:
|
|
364
|
+
// - moving matching toolResult messages directly after their assistant toolCall turn
|
|
365
|
+
// - inserting synthetic error toolResults for missing ids
|
|
366
|
+
// - dropping duplicate toolResults for the same id (anywhere in the transcript)
|
|
367
|
+
const out: AgentMessage[] = [];
|
|
368
|
+
const added: Array<Extract<AgentMessage, { role: "toolResult" }>> = [];
|
|
369
|
+
const seenToolResultIds = new Set<string>();
|
|
370
|
+
let droppedDuplicateCount = 0;
|
|
371
|
+
let droppedOrphanCount = 0;
|
|
372
|
+
let moved = false;
|
|
373
|
+
let changed = false;
|
|
374
|
+
|
|
375
|
+
const pushToolResult = (msg: Extract<AgentMessage, { role: "toolResult" }>) => {
|
|
376
|
+
const id = extractToolResultId(msg);
|
|
377
|
+
if (id && seenToolResultIds.has(id)) {
|
|
378
|
+
droppedDuplicateCount += 1;
|
|
379
|
+
changed = true;
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (id) {
|
|
383
|
+
seenToolResultIds.add(id);
|
|
384
|
+
}
|
|
385
|
+
out.push(msg);
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
for (let i = 0; i < messages.length; i += 1) {
|
|
389
|
+
const msg = messages[i];
|
|
390
|
+
if (!msg || typeof msg !== "object") {
|
|
391
|
+
out.push(msg);
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const role = (msg as { role?: unknown }).role;
|
|
396
|
+
if (role !== "assistant") {
|
|
397
|
+
// Tool results must only appear directly after the matching assistant tool call turn.
|
|
398
|
+
// Any "free-floating" toolResult entries in session history can make strict providers
|
|
399
|
+
// (Anthropic-compatible APIs, MiniMax, Cloud Code Assist) reject the entire request.
|
|
400
|
+
if (role !== "toolResult") {
|
|
401
|
+
out.push(msg);
|
|
402
|
+
} else {
|
|
403
|
+
droppedOrphanCount += 1;
|
|
404
|
+
changed = true;
|
|
405
|
+
}
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const assistant = msg as Extract<AgentMessage, { role: "assistant" }>;
|
|
410
|
+
|
|
411
|
+
const toolCalls = extractToolCallsFromAssistant(assistant);
|
|
412
|
+
if (toolCalls.length === 0) {
|
|
413
|
+
out.push(msg);
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const toolCallIds = new Set(toolCalls.map((t) => t.id));
|
|
418
|
+
const toolCallNamesById = new Map(toolCalls.map((t) => [t.id, t.name] as const));
|
|
419
|
+
|
|
420
|
+
const spanResultsById = new Map<string, Extract<AgentMessage, { role: "toolResult" }>>();
|
|
421
|
+
const remainder: AgentMessage[] = [];
|
|
422
|
+
|
|
423
|
+
let j = i + 1;
|
|
424
|
+
for (; j < messages.length; j += 1) {
|
|
425
|
+
const next = messages[j];
|
|
426
|
+
if (!next || typeof next !== "object") {
|
|
427
|
+
remainder.push(next);
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const nextRole = (next as { role?: unknown }).role;
|
|
432
|
+
if (nextRole === "assistant") {
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (nextRole === "toolResult") {
|
|
437
|
+
const toolResult = next as Extract<AgentMessage, { role: "toolResult" }>;
|
|
438
|
+
const id = extractToolResultId(toolResult);
|
|
439
|
+
if (id && toolCallIds.has(id)) {
|
|
440
|
+
if (seenToolResultIds.has(id)) {
|
|
441
|
+
droppedDuplicateCount += 1;
|
|
442
|
+
changed = true;
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
const normalizedToolResult = normalizeToolResultName(
|
|
446
|
+
toolResult,
|
|
447
|
+
toolCallNamesById.get(id),
|
|
448
|
+
);
|
|
449
|
+
if (normalizedToolResult !== toolResult) {
|
|
450
|
+
changed = true;
|
|
451
|
+
}
|
|
452
|
+
if (!spanResultsById.has(id)) {
|
|
453
|
+
spanResultsById.set(id, normalizedToolResult);
|
|
454
|
+
}
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Drop tool results that don't match the current assistant tool calls.
|
|
460
|
+
if (nextRole !== "toolResult") {
|
|
461
|
+
remainder.push(next);
|
|
462
|
+
} else {
|
|
463
|
+
droppedOrphanCount += 1;
|
|
464
|
+
changed = true;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Aborted/errored assistant turns should never synthesize missing tool results, but
|
|
469
|
+
// the replay sanitizer can still legitimately retain real tool results for surviving
|
|
470
|
+
// tool calls in the same turn after malformed siblings are dropped.
|
|
471
|
+
const stopReason = (assistant as { stopReason?: string }).stopReason;
|
|
472
|
+
if (stopReason === "error" || stopReason === "aborted") {
|
|
473
|
+
out.push(msg);
|
|
474
|
+
if (options?.preserveErroredAssistantResults) {
|
|
475
|
+
for (const toolCall of toolCalls) {
|
|
476
|
+
const result = spanResultsById.get(toolCall.id);
|
|
477
|
+
if (!result) {
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
pushToolResult(result);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
for (const rem of remainder) {
|
|
484
|
+
out.push(rem);
|
|
485
|
+
}
|
|
486
|
+
i = j - 1;
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
out.push(msg);
|
|
491
|
+
|
|
492
|
+
if (spanResultsById.size > 0 && remainder.length > 0) {
|
|
493
|
+
moved = true;
|
|
494
|
+
changed = true;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
for (const call of toolCalls) {
|
|
498
|
+
const existing = spanResultsById.get(call.id);
|
|
499
|
+
if (existing) {
|
|
500
|
+
pushToolResult(existing);
|
|
501
|
+
} else {
|
|
502
|
+
const missing = makeMissingToolResult({
|
|
503
|
+
toolCallId: call.id,
|
|
504
|
+
toolName: call.name,
|
|
505
|
+
});
|
|
506
|
+
added.push(missing);
|
|
507
|
+
changed = true;
|
|
508
|
+
pushToolResult(missing);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
for (const rem of remainder) {
|
|
513
|
+
if (!rem || typeof rem !== "object") {
|
|
514
|
+
out.push(rem);
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
out.push(rem);
|
|
518
|
+
}
|
|
519
|
+
i = j - 1;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const changedOrMoved = changed || moved;
|
|
523
|
+
return {
|
|
524
|
+
messages: changedOrMoved ? out : messages,
|
|
525
|
+
added,
|
|
526
|
+
droppedDuplicateCount,
|
|
527
|
+
droppedOrphanCount,
|
|
528
|
+
moved: changedOrMoved,
|
|
529
|
+
};
|
|
530
|
+
}
|