mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-23 08:25:57 +00:00
fix(frontend): exclude hidden, reasoning, and tool payloads from chat export
`formatThreadAsMarkdown` / `formatThreadAsJSON` iterated raw messages without running the UI-level `isHiddenFromUIMessage` filter. Exported transcripts therefore included `hide_from_ui` system reminders, memory injections, provider `reasoning_content`, tool calls, and tool result messages — content that is intentionally hidden in the chat view. Filter the export to the user-visible transcript by default and gate reasoning / tool calls / tool messages / hidden messages behind explicit `ExportOptions` flags so a future debug export can opt back in without forking the formatter. Refs: bytedance/deer-flow#3107 (BUG-006)
This commit is contained in:
@@ -5,12 +5,45 @@ import {
|
|||||||
extractReasoningContentFromMessage,
|
extractReasoningContentFromMessage,
|
||||||
hasContent,
|
hasContent,
|
||||||
hasToolCalls,
|
hasToolCalls,
|
||||||
|
isHiddenFromUIMessage,
|
||||||
stripUploadedFilesTag,
|
stripUploadedFilesTag,
|
||||||
} from "../messages/utils";
|
} from "../messages/utils";
|
||||||
|
|
||||||
import type { AgentThread } from "./types";
|
import type { AgentThread } from "./types";
|
||||||
import { titleOfThread } from "./utils";
|
import { titleOfThread } from "./utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional debug switches for advanced exports.
|
||||||
|
*
|
||||||
|
* Bytedance/deer-flow issue #3107 BUG-006: by default, the user-facing chat
|
||||||
|
* export must include only the visible transcript. Internal payloads —
|
||||||
|
* `hide_from_ui` messages, reasoning content, tool calls, and tool result
|
||||||
|
* messages — stay out unless the caller explicitly opts in. There is no UI
|
||||||
|
* surface for this today; the flags exist so a future "debug export" can
|
||||||
|
* reuse the same formatter instead of forking it.
|
||||||
|
*/
|
||||||
|
export interface ExportOptions {
|
||||||
|
includeReasoning?: boolean;
|
||||||
|
includeToolCalls?: boolean;
|
||||||
|
includeToolMessages?: boolean;
|
||||||
|
includeHidden?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleMessages(
|
||||||
|
messages: Message[],
|
||||||
|
options: ExportOptions,
|
||||||
|
): Message[] {
|
||||||
|
return messages.filter((message) => {
|
||||||
|
if (!options.includeHidden && isHiddenFromUIMessage(message)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!options.includeToolMessages && message.type === "tool") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function formatMessageContent(message: Message): string {
|
function formatMessageContent(message: Message): string {
|
||||||
const text = extractContentFromMessage(message);
|
const text = extractContentFromMessage(message);
|
||||||
if (!text) return "";
|
if (!text) return "";
|
||||||
@@ -26,6 +59,7 @@ function formatToolCalls(message: Message): string {
|
|||||||
export function formatThreadAsMarkdown(
|
export function formatThreadAsMarkdown(
|
||||||
thread: AgentThread,
|
thread: AgentThread,
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
|
options: ExportOptions = {},
|
||||||
): string {
|
): string {
|
||||||
const title = titleOfThread(thread);
|
const title = titleOfThread(thread);
|
||||||
const createdAt = thread.created_at
|
const createdAt = thread.created_at
|
||||||
@@ -41,16 +75,20 @@ export function formatThreadAsMarkdown(
|
|||||||
"",
|
"",
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const message of messages) {
|
for (const message of visibleMessages(messages, options)) {
|
||||||
if (message.type === "human") {
|
if (message.type === "human") {
|
||||||
const content = formatMessageContent(message);
|
const content = formatMessageContent(message);
|
||||||
if (content) {
|
if (content) {
|
||||||
lines.push(`## 🧑 User`, "", content, "", "---", "");
|
lines.push(`## 🧑 User`, "", content, "", "---", "");
|
||||||
}
|
}
|
||||||
} else if (message.type === "ai") {
|
} else if (message.type === "ai") {
|
||||||
const reasoning = extractReasoningContentFromMessage(message);
|
const reasoning = options.includeReasoning
|
||||||
|
? extractReasoningContentFromMessage(message)
|
||||||
|
: undefined;
|
||||||
const content = formatMessageContent(message);
|
const content = formatMessageContent(message);
|
||||||
const toolCalls = formatToolCalls(message);
|
const toolCalls = options.includeToolCalls
|
||||||
|
? formatToolCalls(message)
|
||||||
|
: "";
|
||||||
|
|
||||||
if (!content && !toolCalls && !reasoning) continue;
|
if (!content && !toolCalls && !reasoning) continue;
|
||||||
|
|
||||||
@@ -86,17 +124,20 @@ export function formatThreadAsMarkdown(
|
|||||||
export function formatThreadAsJSON(
|
export function formatThreadAsJSON(
|
||||||
thread: AgentThread,
|
thread: AgentThread,
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
|
options: ExportOptions = {},
|
||||||
): string {
|
): string {
|
||||||
const exportData = {
|
const exportData = {
|
||||||
title: titleOfThread(thread),
|
title: titleOfThread(thread),
|
||||||
thread_id: thread.thread_id,
|
thread_id: thread.thread_id,
|
||||||
created_at: thread.created_at,
|
created_at: thread.created_at,
|
||||||
exported_at: new Date().toISOString(),
|
exported_at: new Date().toISOString(),
|
||||||
messages: messages.map((msg) => ({
|
messages: visibleMessages(messages, options).map((msg) => ({
|
||||||
type: msg.type,
|
type: msg.type,
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
content: typeof msg.content === "string" ? msg.content : msg.content,
|
content: typeof msg.content === "string" ? msg.content : msg.content,
|
||||||
...(msg.type === "ai" && msg.tool_calls?.length
|
...(options.includeToolCalls &&
|
||||||
|
msg.type === "ai" &&
|
||||||
|
msg.tool_calls?.length
|
||||||
? { tool_calls: msg.tool_calls }
|
? { tool_calls: msg.tool_calls }
|
||||||
: {}),
|
: {}),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import type { Message } from "@langchain/langgraph-sdk";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatThreadAsJSON,
|
||||||
|
formatThreadAsMarkdown,
|
||||||
|
} from "@/core/threads/export";
|
||||||
|
import type { AgentThread } from "@/core/threads/types";
|
||||||
|
|
||||||
|
// Bytedance/deer-flow issue #3107 BUG-006: the chat export path bypasses the
|
||||||
|
// UI-level hidden-message filter and emits reasoning content, tool calls, and
|
||||||
|
// any other "internal" payload as if it were part of the user transcript.
|
||||||
|
|
||||||
|
function makeThread(): AgentThread {
|
||||||
|
return {
|
||||||
|
thread_id: "thread-1",
|
||||||
|
created_at: "2026-05-21T00:00:00Z",
|
||||||
|
updated_at: "2026-05-21T00:00:00Z",
|
||||||
|
metadata: { title: "Demo thread" },
|
||||||
|
status: "idle",
|
||||||
|
values: { messages: [] },
|
||||||
|
} as unknown as AgentThread;
|
||||||
|
}
|
||||||
|
|
||||||
|
function human(content: string, extra: Partial<Message> = {}): Message {
|
||||||
|
return {
|
||||||
|
id: `h-${content}`,
|
||||||
|
type: "human",
|
||||||
|
content,
|
||||||
|
...extra,
|
||||||
|
} as Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ai(
|
||||||
|
content: string,
|
||||||
|
extra: Partial<Message> & { tool_calls?: unknown } = {},
|
||||||
|
): Message {
|
||||||
|
return {
|
||||||
|
id: `a-${content}`,
|
||||||
|
type: "ai",
|
||||||
|
content,
|
||||||
|
...extra,
|
||||||
|
} as Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolMsg(content: string): Message {
|
||||||
|
return {
|
||||||
|
id: `t-${content}`,
|
||||||
|
type: "tool",
|
||||||
|
content,
|
||||||
|
name: "task",
|
||||||
|
tool_call_id: "call-1",
|
||||||
|
} as unknown as Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("formatThreadAsMarkdown", () => {
|
||||||
|
it("includes plain user and assistant text", () => {
|
||||||
|
const md = formatThreadAsMarkdown(makeThread(), [
|
||||||
|
human("hello"),
|
||||||
|
ai("hi there"),
|
||||||
|
]);
|
||||||
|
expect(md).toContain("hello");
|
||||||
|
expect(md).toContain("hi there");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops messages marked hide_from_ui", () => {
|
||||||
|
const hidden = human("internal system reminder", {
|
||||||
|
additional_kwargs: { hide_from_ui: true },
|
||||||
|
} as Partial<Message>);
|
||||||
|
const md = formatThreadAsMarkdown(makeThread(), [
|
||||||
|
hidden,
|
||||||
|
ai("public answer"),
|
||||||
|
]);
|
||||||
|
expect(md).not.toContain("internal system reminder");
|
||||||
|
expect(md).toContain("public answer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not emit reasoning_content by default", () => {
|
||||||
|
const message = ai("final answer", {
|
||||||
|
additional_kwargs: {
|
||||||
|
reasoning_content: "secret chain of thought",
|
||||||
|
},
|
||||||
|
} as Partial<Message>);
|
||||||
|
const md = formatThreadAsMarkdown(makeThread(), [message]);
|
||||||
|
expect(md).not.toContain("secret chain of thought");
|
||||||
|
expect(md).not.toContain("Thinking");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not emit tool calls by default", () => {
|
||||||
|
const message = ai("ok", {
|
||||||
|
tool_calls: [{ id: "1", name: "task", args: { description: "do work" } }],
|
||||||
|
} as Partial<Message>);
|
||||||
|
const md = formatThreadAsMarkdown(makeThread(), [message]);
|
||||||
|
expect(md).not.toContain("**Tool:**");
|
||||||
|
expect(md).not.toContain("`task`");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops tool result messages", () => {
|
||||||
|
const md = formatThreadAsMarkdown(makeThread(), [
|
||||||
|
ai("delegating"),
|
||||||
|
toolMsg("Task Succeeded. Result: confidential"),
|
||||||
|
]);
|
||||||
|
expect(md).not.toContain("confidential");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatThreadAsJSON", () => {
|
||||||
|
it("strips hidden messages, tool messages, reasoning, and tool calls", () => {
|
||||||
|
const messages = [
|
||||||
|
human("hello"),
|
||||||
|
human("secret reminder", {
|
||||||
|
additional_kwargs: { hide_from_ui: true },
|
||||||
|
} as Partial<Message>),
|
||||||
|
ai("answer", {
|
||||||
|
additional_kwargs: {
|
||||||
|
reasoning_content: "secret reasoning",
|
||||||
|
},
|
||||||
|
tool_calls: [{ id: "1", name: "task", args: {} }],
|
||||||
|
} as Partial<Message>),
|
||||||
|
toolMsg("internal trace"),
|
||||||
|
];
|
||||||
|
const raw = formatThreadAsJSON(makeThread(), messages);
|
||||||
|
const parsed = JSON.parse(raw) as {
|
||||||
|
messages: { type: string; tool_calls?: unknown[] }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(parsed.messages).toHaveLength(2);
|
||||||
|
expect(parsed.messages.every((m) => m.type !== "tool")).toBe(true);
|
||||||
|
expect(raw).not.toContain("secret reminder");
|
||||||
|
expect(raw).not.toContain("secret reasoning");
|
||||||
|
expect(raw).not.toContain("internal trace");
|
||||||
|
expect(raw).not.toContain("tool_calls");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user