Skip to content

Commit ff5e735

Browse files
drvossvincentkoc
andauthored
fix(agents): drop partialJson streaming artifacts from session history repair (#93469)
Merged via squash. Prepared head SHA: 86fe9d5 Co-authored-by: drvoss <3031622+drvoss@users.noreply.github.com> Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com> Reviewed-by: @vincentkoc
1 parent b5648b1 commit ff5e735

2 files changed

Lines changed: 244 additions & 17 deletions

File tree

src/agents/session-transcript-repair.test.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,120 @@ describe("sanitizeToolCallInputs allowed-name filtering", () => {
784784
expect(ids).toEqual(expectedIds);
785785
});
786786

787+
it("keeps finalized OpenAI Responses calls and drops partialJson streaming artifacts", () => {
788+
const input = castAgentMessages([
789+
{
790+
role: "assistant",
791+
stopReason: "toolUse",
792+
content: [
793+
// complete tool call — kept as-is
794+
{ type: "toolCall", id: "call_ok", name: "read", arguments: { path: "/a" } },
795+
// Legacy generic Responses transport persisted finalized toolUse
796+
// turns with partialJson; repair strips the scratch field.
797+
{
798+
type: "toolCall",
799+
id: "call_partial|fc_123",
800+
name: "Bash",
801+
arguments: { command: "ls" },
802+
partialJson: '{"command": "ls"}',
803+
},
804+
{
805+
type: "toolCall",
806+
id: "call_empty|fc_789",
807+
name: "session_status",
808+
arguments: {},
809+
partialJson: "",
810+
},
811+
// Anthropic can persist initialized tool calls with arguments: {}
812+
// plus partialJson if the stream aborts before content_block_stop.
813+
// Those incomplete artifacts must be dropped.
814+
{
815+
type: "toolCall",
816+
id: "toolu_123",
817+
name: "Bash",
818+
arguments: {},
819+
partialJson: '{"command":',
820+
},
821+
// An OpenAI-shaped id and parsed partial arguments do not prove that
822+
// response.output_item.done arrived.
823+
{
824+
type: "toolCall",
825+
id: "call_truncated|fc_456",
826+
name: "Bash",
827+
arguments: { command: "ls" },
828+
partialJson: '{"command":"ls"',
829+
},
830+
// Missing required input is also an interrupted artifact and should drop.
831+
{
832+
type: "toolUse",
833+
id: "call_partial2",
834+
name: "read",
835+
input: null,
836+
partialJson: '{"path":',
837+
},
838+
],
839+
},
840+
{ role: "user", content: "retry" },
841+
]);
842+
const out = sanitizeToolCallInputs(input);
843+
const toolCalls = getAssistantToolCallBlocks(out);
844+
const ids = toolCalls.map((t) => (t as { id?: unknown }).id);
845+
expect(ids).toEqual(["call_ok", "call_partial|fc_123", "call_empty|fc_789"]);
846+
expect(toolCalls[1]).not.toHaveProperty("partialJson");
847+
expect(toolCalls[2]).not.toHaveProperty("partialJson");
848+
});
849+
850+
it("strips finalized partialJson without rewriting sessions_spawn arguments", () => {
851+
const input = castAgentMessages([
852+
{
853+
role: "assistant",
854+
stopReason: "toolUse",
855+
content: [
856+
{
857+
type: "toolCall",
858+
id: "call_spawn|fc_456",
859+
name: "sessions_spawn",
860+
arguments: { attachments: [{ content: "secret data" }] },
861+
partialJson: '{"attachments":[{"content":"secret data"}]}',
862+
},
863+
],
864+
},
865+
]);
866+
867+
const out = sanitizeToolCallInputs(input);
868+
const toolCalls = getAssistantToolCallBlocks(out);
869+
expect(toolCalls).toHaveLength(1);
870+
expect(toolCalls[0]).not.toHaveProperty("partialJson");
871+
expect((toolCalls[0] as { arguments?: unknown }).arguments).toEqual({
872+
attachments: [{ content: "secret data" }],
873+
});
874+
});
875+
876+
it.each(["stop", "aborted", "error", "length"] as const)(
877+
"drops OpenAI Responses partialJson blocks on %s assistant turns",
878+
(stopReason) => {
879+
const input = castAgentMessages([
880+
{
881+
role: "assistant",
882+
stopReason,
883+
content: [
884+
{
885+
type: "toolCall",
886+
id: "call_partial|fc_123",
887+
name: "Bash",
888+
arguments: { command: "ls" },
889+
partialJson: '{"command":"ls"}',
890+
},
891+
],
892+
},
893+
{ role: "user", content: "retry" },
894+
]);
895+
896+
const out = sanitizeToolCallInputs(input);
897+
expect(getAssistantToolCallBlocks(out)).toHaveLength(0);
898+
},
899+
);
900+
787901
it("keeps valid tool calls and preserves text blocks", () => {
788902
const input = castAgentMessages([
789903
{
@@ -835,6 +949,36 @@ describe("sanitizeToolCallInputs allowed-name filtering", () => {
835949
expect(out).toStrictEqual([]);
836950
});
837951

952+
it("drops signed-thinking assistant turns with partialJson tool calls", () => {
953+
const input = castAgentMessages([
954+
{
955+
role: "assistant",
956+
stopReason: "toolUse",
957+
content: [
958+
{
959+
type: "thinking",
960+
thinking: "Let me run a command.",
961+
thinkingSignature: "sig_partial",
962+
},
963+
{
964+
type: "toolCall",
965+
id: "call_partial|fc_123",
966+
name: "exec",
967+
arguments: {},
968+
partialJson: '{"command":"ls"}',
969+
},
970+
],
971+
},
972+
]);
973+
974+
const out = sanitizeToolCallInputs(input, {
975+
allowedToolNames: ["exec"],
976+
allowProviderOwnedThinkingReplay: true,
977+
});
978+
979+
expect(out).toStrictEqual([]);
980+
});
981+
838982
it("drops signed-thinking assistant turns when sibling tool calls reuse an id", () => {
839983
const input = castAgentMessages([
840984
{

src/agents/session-transcript-repair.ts

Lines changed: 100 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66
import {
77
hasNonEmptyString as hasNonEmptyStringField,
8+
normalizeLowercaseStringOrEmpty,
89
normalizeOptionalString,
910
readStringValue,
1011
} from "@openclaw/normalization-core/string-coerce";
@@ -27,6 +28,7 @@ type RawToolCallBlock = {
2728
name?: unknown;
2829
input?: unknown;
2930
arguments?: unknown;
31+
partialJson?: unknown;
3032
};
3133

3234
const RAW_TOOL_CALL_BLOCK_TYPES = new Set([
@@ -72,6 +74,45 @@ function hasToolCallId(block: RawToolCallBlock): boolean {
7274
);
7375
}
7476

77+
function hasPartialJson(
78+
block: RawToolCallBlock,
79+
): block is RawToolCallBlock & { partialJson: string } {
80+
return typeof block.partialJson === "string";
81+
}
82+
83+
function isCompleteJsonObject(value: string): boolean {
84+
try {
85+
const parsed: unknown = JSON.parse(value);
86+
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed);
87+
} catch {
88+
return false;
89+
}
90+
}
91+
92+
function isFinalizedOpenAIResponsesToolCall(
93+
message: AgentMessage,
94+
block: RawToolCallBlock,
95+
): boolean {
96+
if (
97+
message.role !== "assistant" ||
98+
!("stopReason" in message) ||
99+
message.stopReason !== "toolUse" ||
100+
!hasPartialJson(block) ||
101+
typeof block.id !== "string" ||
102+
"input" in block ||
103+
!block.arguments ||
104+
typeof block.arguments !== "object" ||
105+
Array.isArray(block.arguments) ||
106+
(!isCompleteJsonObject(block.partialJson) &&
107+
(block.partialJson.trim() !== "" || Object.keys(block.arguments).length > 0))
108+
) {
109+
return false;
110+
}
111+
112+
const separator = block.id.indexOf("|");
113+
return separator > 0 && separator < block.id.length - 1;
114+
}
115+
75116
function sanitizeToolCallBlock(block: RawToolCallBlock): RawToolCallBlock {
76117
// This repair path normalizes replay shape only. Tool payloads are local
77118
// trusted-operator transcript state per SECURITY.md, so do not redact or
@@ -116,6 +157,7 @@ function isReplaySafeThinkingAssistantTurn(
116157
const toolCallId = typeof block.id === "string" ? block.id.trim() : "";
117158
if (
118159
!hasToolCallInput(block) ||
160+
hasPartialJson(block) ||
119161
!toolCallId ||
120162
seenToolCallIds.has(toolCallId) ||
121163
!isAllowedToolCallName(block.name, allowedToolNames)
@@ -382,31 +424,72 @@ function repairToolCallInputs(
382424
let messageChanged = false;
383425

384426
for (const block of msg.content) {
385-
if (
386-
isRawToolCallBlock(block) &&
387-
(!hasToolCallInput(block) ||
427+
if (isRawToolCallBlock(block)) {
428+
// Drop genuinely incomplete streaming artifacts (missing required fields).
429+
if (
430+
!hasToolCallInput(block) ||
388431
!hasToolCallId(block) ||
389-
!isAllowedToolCallName((block as RawToolCallBlock).name, allowedToolNames))
390-
) {
391-
droppedToolCalls += 1;
392-
droppedInMessage += 1;
432+
!isAllowedToolCallName((block as RawToolCallBlock).name, allowedToolNames)
433+
) {
434+
droppedToolCalls += 1;
435+
droppedInMessage += 1;
436+
changed = true;
437+
messageChanged = true;
438+
continue;
439+
}
440+
}
441+
let workBlock = block;
442+
if (isRawToolCallBlock(block) && hasPartialJson(block)) {
443+
if (!isFinalizedOpenAIResponsesToolCall(msg, block)) {
444+
droppedToolCalls += 1;
445+
droppedInMessage += 1;
446+
changed = true;
447+
messageChanged = true;
448+
continue;
449+
}
450+
451+
// Legacy generic Responses transport persisted successful toolUse turns
452+
// with the scratch buffer intact. Strip it only when terminal state and
453+
// the provider-specific finalized shape both prove completion.
454+
const stripped = { ...block };
455+
delete (stripped as RawToolCallBlock & { partialJson?: unknown }).partialJson;
456+
workBlock = stripped;
393457
changed = true;
394458
messageChanged = true;
395-
continue;
396459
}
397-
if (isRawToolCallBlock(block)) {
398-
if (RAW_TOOL_CALL_BLOCK_TYPES.has((block as { type?: string }).type ?? "")) {
399-
const sanitized = sanitizeToolCallBlock(block);
400-
if (sanitized !== block) {
401-
changed = true;
402-
messageChanged = true;
460+
if (isRawToolCallBlock(workBlock)) {
461+
if (RAW_TOOL_CALL_BLOCK_TYPES.has((workBlock as { type?: string }).type ?? "")) {
462+
// Only sanitize (redact) sessions_spawn blocks; all others are passed through
463+
// unchanged to preserve provider-specific shapes (e.g. toolUse.input for Anthropic).
464+
const blockName =
465+
typeof (workBlock as { name?: unknown }).name === "string"
466+
? (workBlock as { name: string }).name.trim()
467+
: undefined;
468+
if (normalizeLowercaseStringOrEmpty(blockName) === "sessions_spawn") {
469+
const sanitized = sanitizeToolCallBlock(workBlock);
470+
if (sanitized !== workBlock) {
471+
changed = true;
472+
messageChanged = true;
473+
}
474+
nextContent.push(sanitized as typeof block);
475+
} else if (typeof (workBlock as { name?: unknown }).name === "string") {
476+
const rawName = (workBlock as { name: string }).name;
477+
const trimmedName = rawName.trim();
478+
if (rawName !== trimmedName && trimmedName) {
479+
const renamed = { ...(workBlock as object), name: trimmedName } as typeof block;
480+
nextContent.push(renamed);
481+
changed = true;
482+
messageChanged = true;
483+
} else {
484+
nextContent.push(workBlock);
485+
}
486+
} else {
487+
nextContent.push(workBlock);
403488
}
404-
nextContent.push(sanitized as typeof block);
405489
continue;
406490
}
407-
} else {
408-
nextContent.push(block);
409491
}
492+
nextContent.push(workBlock);
410493
}
411494

412495
if (droppedInMessage > 0) {

0 commit comments

Comments
 (0)