Commit fb38d14f by Archer Committed by GitHub

fix: reason (#6963)

parent 05254ce0
......@@ -142,9 +142,12 @@ export const mergeAssistantFieldMessages = (messages: ChatCompletionMessageParam
): message is AssistantToolCallMessage => {
if (message?.role !== ChatCompletionRequestMessageRoleEnum.Assistant) return false;
const hasNoAssistantText =
message.content === undefined || message.content === null || message.content === '';
return (
hasAssistantToolCalls(message) &&
typeof message.content !== 'string' &&
hasNoAssistantText &&
typeof message.reasoning_content !== 'string'
);
};
......
......@@ -353,6 +353,56 @@ describe('mergeAssistantFieldMessages', () => {
]);
});
it('should treat empty assistant content on tool call messages as no answer text', () => {
const messages: ChatCompletionMessageParam[] = [
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'Need to call a tool.'
},
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
content: '',
tool_calls: [
{
id: 'call_search',
type: 'function',
function: {
name: 'search_web',
arguments: '{}'
}
}
]
},
{
role: ChatCompletionRequestMessageRoleEnum.Tool,
tool_call_id: 'call_search',
content: 'compressed result'
}
];
expect(mergeAssistantFieldMessages(messages)).toEqual([
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'Need to call a tool.',
tool_calls: [
{
id: 'call_search',
type: 'function',
function: {
name: 'search_web',
arguments: '{}'
}
}
]
},
{
role: ChatCompletionRequestMessageRoleEnum.Tool,
tool_call_id: 'call_search',
content: 'compressed result'
}
]);
});
it('should merge consecutive tool call groups into one assistant message', () => {
const messages: ChatCompletionMessageParam[] = [
{
......@@ -1171,6 +1221,61 @@ describe('chats2GPTMessages', () => {
]);
});
it('should not skip tool calls when the same value also has empty text', () => {
const messages: ChatItemMiniType[] = [
{
obj: ChatRoleEnum.AI,
value: [
{
text: {
content: ''
},
tools: [
{
id: 'call_search',
toolName: 'Search',
toolAvatar: '',
functionName: 'search_web',
params: '{}',
response: 'compressed result'
}
]
},
{
text: {
content: 'Final answer'
}
}
]
}
];
expect(chats2GPTMessages({ messages, reserveId: false, reserveTool: true })).toEqual([
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
tool_calls: [
{
id: 'call_search',
type: 'function',
function: {
name: 'search_web',
arguments: '{}'
}
}
]
},
{
role: ChatCompletionRequestMessageRoleEnum.Tool,
tool_call_id: 'call_search',
content: 'compressed result'
},
{
role: ChatCompletionRequestMessageRoleEnum.Assistant,
content: 'Final answer'
}
]);
});
it('should skip empty AI text values when there are multiple values', () => {
const messages: ChatItemMiniType[] = [
{
......
......@@ -192,10 +192,16 @@ export const createWorkflowAgentLoopEventMapper = ({
assistantText?: string;
reasoningText?: string;
}) => {
// 没有可见 assistantText 时,不在 request end 阶段回填 reasoning 到已有 tool。
// reasoning 的归属需要按流式顺序附着到后续 content/tool,避免误挂到上一轮工具。
if (!assistantText) return;
/*
* 只处理“本轮 LLM 结束于工具调用”的 assistant 内容归属。
*
* 例子:
* request 1: reasoningText="需要先查时间", assistantText=undefined, toolCalls=[call_time]
* request 2: reasoningText="工具返回了时间", assistantText="现在是 10 点"
*
* request 1 没有可见回答,但 reasoning 仍然属于 call_time 前的 assistant turn。
* 如果不挂到对应 tools value 上,刷新后/下一轮上下文会只剩 tool,丢失第一段思考。
*/
const runtimeToolCalls = toolCalls.filter((call) => {
const functionName = call.function.name;
return (
......@@ -211,6 +217,22 @@ export const createWorkflowAgentLoopEventMapper = ({
const existingIndex = assistantResponses.findIndex((item) =>
item.tools?.some((tool) => runtimeToolCallIds.has(tool.id))
);
if (!assistantText) {
// reason -> tool:没有 answerText 可单独插入时,把 reasoning 按 callId 挂到已创建的工具卡。
if (!reasoningText || existingIndex < 0) return;
const currentValue = assistantResponses[existingIndex];
assistantResponses[existingIndex] = {
...currentValue,
reasoning: {
content: [currentValue.reasoning?.content, reasoningText].filter(Boolean).join('\n\n')
},
...(!showReasoning ? { hideReason: true } : {})
};
return;
}
const insertIndex = existingIndex >= 0 ? existingIndex : assistantResponses.length;
const assistantValue: AIChatItemValueItemType = {
......
......@@ -36,6 +36,22 @@ const toolResponse = ({ id, name, response }: { id: string; name: string; respon
seconds: 0.1
}) as const;
const createToolCall = ({ id, name, args = '{}' }: { id: string; name: string; args?: string }) =>
({
id,
type: 'function',
function: {
name,
arguments: args
}
}) as const;
const toolCall = (params: { id: string; name: string; args?: string }) =>
({
type: 'tool_call',
call: createToolCall(params)
}) as const;
describe('createWorkflowAgentLoopEventMapper', () => {
it('streams main answer deltas', () => {
const workflowStreamResponse = vi.fn();
......@@ -172,6 +188,293 @@ describe('createWorkflowAgentLoopEventMapper', () => {
]);
});
it('persists the first reasoning text on reasoning-only tool requests', () => {
const workflowStreamResponse = vi.fn();
const mapper = createWorkflowAgentLoopEventMapper({
workflowStreamResponse,
getSubAppInfo: (id) => ({
name: id,
avatar: '',
toolDescription: ''
}),
internalToolNames: new Set()
});
mapper.emitEvent({
type: 'reasoning_delta',
text: 'first reasoning'
});
mapper.emitEvent(toolCall({ id: 'call_time', name: 'get_time' }));
mapper.emitEvent({
type: 'llm_request_end',
requestIndex: 1,
modelName: 'GPT-4',
requestId: 'req_tool',
finishReason: 'tool_calls',
reasoningText: 'first reasoning',
toolCalls: [createToolCall({ id: 'call_time', name: 'get_time' })]
});
mapper.emitEvent({
...toolResponse({
id: 'call_time',
name: 'get_time',
response: '2026-05-22 10:00:00'
})
});
// Final answer values are appended by the agent dispatcher after the loop finishes.
mapper.assistantResponses.push({
text: {
content: '现在是 10 点。'
},
reasoning: {
content: 'second reasoning'
}
});
expect(mapper.assistantResponses).toEqual([
{
id: 'call_time',
reasoning: {
content: 'first reasoning'
},
tools: [
{
id: 'call_time',
toolName: 'get_time',
toolAvatar: '',
functionName: 'get_time',
params: '{}',
response: '2026-05-22 10:00:00'
}
]
},
{
text: {
content: '现在是 10 点。'
},
reasoning: {
content: 'second reasoning'
}
}
]);
const restoredMessages = chats2GPTMessages({
messages: [
{
obj: ChatRoleEnum.AI,
value: mapper.assistantResponses
}
],
reserveId: false,
reserveTool: true
});
expect(restoredMessages).toEqual([
{
dataId: undefined,
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'first reasoning',
tool_calls: [
{
id: 'call_time',
type: 'function',
function: {
name: 'get_time',
arguments: '{}'
}
}
]
},
{
role: ChatCompletionRequestMessageRoleEnum.Tool,
tool_call_id: 'call_time',
content: '2026-05-22 10:00:00'
},
{
dataId: undefined,
role: ChatCompletionRequestMessageRoleEnum.Assistant,
content: '现在是 10 点。',
reasoning_content: 'second reasoning'
}
]);
});
it('persists hidden reasoning on reasoning-only tool requests', () => {
const workflowStreamResponse = vi.fn();
const mapper = createWorkflowAgentLoopEventMapper({
workflowStreamResponse,
getSubAppInfo: (id) => ({
name: id,
avatar: '',
toolDescription: ''
}),
internalToolNames: new Set(),
showReasoning: false
});
mapper.emitEvent({
type: 'reasoning_delta',
text: 'hidden first reasoning'
});
mapper.emitEvent(toolCall({ id: 'call_time', name: 'get_time' }));
mapper.emitEvent({
type: 'llm_request_end',
requestIndex: 1,
modelName: 'GPT-4',
requestId: 'req_tool',
finishReason: 'tool_calls',
reasoningText: 'hidden first reasoning',
toolCalls: [createToolCall({ id: 'call_time', name: 'get_time' })]
});
expect(workflowStreamResponse).toHaveBeenCalledTimes(1);
expect(workflowStreamResponse).toHaveBeenCalledWith({
id: 'call_time',
event: SseResponseEventEnum.toolCall,
data: {
tool: {
id: 'call_time',
toolName: 'get_time',
toolAvatar: '',
functionName: 'get_time',
params: '{}'
}
}
});
expect(mapper.assistantResponses).toEqual([
{
id: 'call_time',
reasoning: {
content: 'hidden first reasoning'
},
hideReason: true,
tools: [
{
id: 'call_time',
toolName: 'get_time',
toolAvatar: '',
functionName: 'get_time',
params: '{}'
}
]
}
]);
});
it('keeps reasoning-only parallel tool calls continuous when restoring messages', () => {
const mapper = createWorkflowAgentLoopEventMapper({
getSubAppInfo: (id) => ({
name: id,
avatar: '',
toolDescription: ''
}),
internalToolNames: new Set()
});
mapper.emitEvent(toolCall({ id: 'call_weather', name: 'weather', args: '{"city":"Beijing"}' }));
mapper.emitEvent(toolCall({ id: 'call_time', name: 'time' }));
mapper.emitEvent({
type: 'llm_request_end',
requestIndex: 1,
modelName: 'GPT-4',
requestId: 'req_parallel',
finishReason: 'tool_calls',
reasoningText: 'Need weather and time.',
toolCalls: [
createToolCall({ id: 'call_weather', name: 'weather', args: '{"city":"Beijing"}' }),
createToolCall({ id: 'call_time', name: 'time' })
]
});
mapper.emitEvent({
...toolResponse({
id: 'call_weather',
name: 'weather',
response: 'sunny'
})
});
mapper.emitEvent({
...toolResponse({
id: 'call_time',
name: 'time',
response: '10:00'
})
});
expect(mapper.assistantResponses).toEqual([
{
id: 'call_weather',
reasoning: {
content: 'Need weather and time.'
},
tools: [
expect.objectContaining({
id: 'call_weather',
functionName: 'weather',
response: 'sunny'
})
]
},
{
id: 'call_time',
tools: [
expect.objectContaining({
id: 'call_time',
functionName: 'time',
response: '10:00'
})
]
}
]);
const restoredMessages = chats2GPTMessages({
messages: [
{
obj: ChatRoleEnum.AI,
value: mapper.assistantResponses
}
],
reserveId: false,
reserveTool: true
});
expect(restoredMessages).toEqual([
{
dataId: undefined,
role: ChatCompletionRequestMessageRoleEnum.Assistant,
reasoning_content: 'Need weather and time.',
tool_calls: [
{
id: 'call_weather',
type: 'function',
function: {
name: 'weather',
arguments: '{"city":"Beijing"}'
}
},
{
id: 'call_time',
type: 'function',
function: {
name: 'time',
arguments: '{}'
}
}
]
},
{
role: ChatCompletionRequestMessageRoleEnum.Tool,
tool_call_id: 'call_weather',
content: 'sunny'
},
{
role: ChatCompletionRequestMessageRoleEnum.Tool,
tool_call_id: 'call_time',
content: '10:00'
}
]);
});
it('filters internal tool calls and streams runtime tool lifecycle events', () => {
const workflowStreamResponse = vi.fn();
const mapper = createWorkflowAgentLoopEventMapper({
......
......@@ -31,7 +31,7 @@
"date-fns": "catalog:",
"dayjs": "catalog:",
"i18next": "catalog:",
"js-cookie": "^3.0.5",
"js-cookie": "^3.0.7",
"lexical": "0.12.6",
"lodash": "catalog:",
"next": "catalog:",
......@@ -52,7 +52,7 @@
"zustand": "^4.3.5"
},
"devDependencies": {
"@types/js-cookie": "^3.0.5",
"@types/js-cookie": "^3.0.6",
"@types/lodash": "catalog:",
"@types/papaparse": "^5.3.7",
"@types/react": "catalog:",
......
......@@ -36,6 +36,25 @@ import { useMemoEnhance } from '@fastgpt/web/hooks/useMemoEnhance';
const ResponseTags = dynamic(() => import('./ResponseTags'));
const shouldFilterAiValue = (item: AIChatItemValueItemType) => {
if (item.hideInUI) return true;
if (item.text?.content?.trim() || item.reasoning?.content?.trim()) return false;
if (!item.text && !item.reasoning) return false;
return !(
item.tools?.length ||
item.tool ||
item.skills?.length ||
item.interactive ||
item.plan ||
item.planStatus ||
item.agentPlanUpdate ||
item.agentAsk ||
item.agentStopGate ||
item.contextCheckpoint
);
};
const colorMap = {
[ChatStatusEnum.loading]: {
bg: 'myGray.100',
......@@ -217,18 +236,7 @@ const ChatItem = (props: Props) => {
if (chat.obj === ChatRoleEnum.AI) {
// Remove empty text node
const filterList = chat.value.filter((item, i) => {
if (item.hideInUI) {
return false;
}
if (item.text && !item.text.content?.trim()) {
return false;
}
if (item.reasoning && !item.reasoning.content?.trim()) {
return false;
}
return item;
});
const filterList = chat.value.filter((item) => !shouldFilterAiValue(item));
const groupedValues: AIChatItemValueItemType[][] = [];
let currentGroup: AIChatItemValueItemType[] = [];
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment