From b0f9f59cc7f408ddf85fa9b936a23303a0069d0a Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:07:04 +0900 Subject: [PATCH] fix(core): keep tool result errors when importing threads Importing an Anthropic Messages dump dropped `is_error` from `tool_result` blocks, and importing a DeerFlow run-event file dropped `status: "error"` from LangChain tool messages. Failed tool calls then showed as successful and were replayed to the model as successful results. Carry the flag through as `output.isError`; successful results are unchanged. Co-Authored-By: Claude Opus 5 --- packages/core/src/parsers/normalize-thread.ts | 28 +++++++-- .../deerflow-jsonl-thread-parser.test.ts | 59 +++++++++++++++++++ .../tests/parsers/json-thread-parser.test.ts | 58 ++++++++++++++++++ 3 files changed, 140 insertions(+), 5 deletions(-) diff --git a/packages/core/src/parsers/normalize-thread.ts b/packages/core/src/parsers/normalize-thread.ts index 1ae4962c..1e5607d9 100644 --- a/packages/core/src/parsers/normalize-thread.ts +++ b/packages/core/src/parsers/normalize-thread.ts @@ -44,6 +44,7 @@ interface RawToolUse { interface RawToolResult { toolUseId: string | undefined; content: TextContent[]; + isError: boolean; } /** @@ -96,7 +97,12 @@ export function normalizeToThread( // Anthropic carries tool results inside a `user` message; they belong // on the matching assistant tool call, not in the user content. for (const result of resolved.toolResults) { - _attachToolResult(toolCallsById, result.toolUseId, result.content); + _attachToolResult( + toolCallsById, + result.toolUseId, + result.content, + result.isError + ); } const content: UserMessageContent[] = [ ...resolved.text, @@ -122,7 +128,14 @@ export function normalizeToThread( const toolCallId = typeof m.tool_call_id === "string" ? m.tool_call_id : undefined; const content = _resolveContent(m.content).text; - _attachToolResult(toolCallsById, toolCallId, content); + // LangChain `ToolMessage` dumps (such as DeerFlow's) mark failures + // with `status: "error"`. + _attachToolResult( + toolCallsById, + toolCallId, + content, + m.status === "error" + ); break; } @@ -318,6 +331,7 @@ function _resolveContent(content: unknown): ResolvedContent { toolUseId: typeof b.tool_use_id === "string" ? b.tool_use_id : undefined, content: _resolveContent(b.content).text, + isError: b.is_error === true, }); break; } @@ -534,11 +548,15 @@ function _imageContent( return { type: "image", mimeType, data }; } -/** Set a tool call's output, matching by id. Unmatched results are dropped. */ +/** + * Set a tool call's output, matching by id, and keep a failed result marked as + * an error. Unmatched results are dropped. + */ function _attachToolResult( toolCallsById: Map, toolCallId: string | undefined, - content: TextContent[] + content: TextContent[], + isError: boolean ): void { if (!toolCallId) { return; @@ -547,7 +565,7 @@ function _attachToolResult( if (!toolCall) { return; } - toolCall.output = { content }; + toolCall.output = isError ? { content, isError: true } : { content }; } /** A {@link TextContent} from a value, or `undefined` for empty/non-string. */ diff --git a/packages/core/tests/parsers/deerflow-jsonl-thread-parser.test.ts b/packages/core/tests/parsers/deerflow-jsonl-thread-parser.test.ts index bc58bbb6..595101bb 100644 --- a/packages/core/tests/parsers/deerflow-jsonl-thread-parser.test.ts +++ b/packages/core/tests/parsers/deerflow-jsonl-thread-parser.test.ts @@ -93,6 +93,65 @@ describe("DeerFlowJsonlThreadParser", () => { } }); + test("keeps the error status of tool results", async () => { + const result = await new DeerFlowJsonlThreadParser().parseDetailed( + _jsonl( + { + event_type: "llm.human.input", + category: "message", + content: { type: "human", content: "Read both files" }, + }, + { + event_type: "llm.ai.response", + category: "message", + content: { + type: "ai", + content: "", + tool_calls: [ + { id: "call-ok", name: "read_file", args: { path: "a.txt" } }, + { id: "call-error", name: "read_file", args: { path: "b.txt" } }, + ], + }, + }, + { + event_type: "llm.tool.result", + category: "message", + content: { + type: "tool", + tool_call_id: "call-ok", + content: "alpha", + status: "success", + }, + }, + { + event_type: "llm.tool.result", + category: "message", + content: { + type: "tool", + tool_call_id: "call-error", + content: "Error: file not found", + status: "error", + }, + } + ) + ); + + expect(result.status).toBe("parsed"); + if (result.status === "parsed") { + const assistant = result.thread.context?.messages?.[1]; + expect(assistant?.role).toBe("assistant"); + if (assistant?.role === "assistant") { + expect(assistant.toolCalls?.[0]?.output).toEqual({ + content: [{ type: "text", text: "alpha" }], + }); + expect(assistant.toolCalls?.[1]?.output).toEqual({ + content: [{ type: "text", text: "Error: file not found" }], + isError: true, + }); + } + } + }); + test("skips internal DeerFlow messages", async () => { const result = await new DeerFlowJsonlThreadParser().parseDetailed( _jsonl( diff --git a/packages/core/tests/parsers/json-thread-parser.test.ts b/packages/core/tests/parsers/json-thread-parser.test.ts index cbb63362..6eb06b3d 100644 --- a/packages/core/tests/parsers/json-thread-parser.test.ts +++ b/packages/core/tests/parsers/json-thread-parser.test.ts @@ -47,4 +47,62 @@ describe("JsonThreadParser", () => { }); } }); + + test("keeps the error flag of imported Anthropic tool results", async () => { + const result = await new JsonThreadParser().parseDetailed( + JSON.stringify({ + messages: [ + { role: "user", content: "Read both files" }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_ok", + name: "read", + input: { path: "notes.txt" }, + }, + { + type: "tool_use", + id: "toolu_error", + name: "read", + input: { path: "missing.txt" }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_ok", + content: "hello", + }, + { + type: "tool_result", + tool_use_id: "toolu_error", + content: "ENOENT: no such file", + is_error: true, + }, + ], + }, + ], + }) + ); + + expect(result.status).toBe("parsed"); + if (result.status === "parsed") { + const assistant = result.thread.context?.messages?.[1]; + expect(assistant?.role).toBe("assistant"); + if (assistant?.role === "assistant") { + expect(assistant.toolCalls?.[0]?.output).toEqual({ + content: [{ type: "text", text: "hello" }], + }); + expect(assistant.toolCalls?.[1]?.output).toEqual({ + content: [{ type: "text", text: "ENOENT: no such file" }], + isError: true, + }); + } + } + }); });