Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions packages/core/src/parsers/normalize-thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ interface RawToolUse {
interface RawToolResult {
toolUseId: string | undefined;
content: TextContent[];
isError: boolean;
}

/**
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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<string, ToolCall>,
toolCallId: string | undefined,
content: TextContent[]
content: TextContent[],
isError: boolean
): void {
if (!toolCallId) {
return;
Expand All @@ -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. */
Expand Down
59 changes: 59 additions & 0 deletions packages/core/tests/parsers/deerflow-jsonl-thread-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
58 changes: 58 additions & 0 deletions packages/core/tests/parsers/json-thread-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
}
});
});