fix: 统一适配 DeepSeek 工具与思考历史回传

- 结构化持久化 assistant/tool 历史,恢复真实消息链回放

- 为 DeepSeek 显式开启 thinking 协议配置,并补齐 public-api 与工具英文名兜底

- 增加聊天历史回放、tool 名称与请求参数解析相关测试
This commit is contained in:
2026-05-11 21:24:20 +08:00
parent c1590b0d8a
commit e27834ee0c
18 changed files with 1441 additions and 42 deletions

View File

@@ -5,55 +5,136 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
/**
* 负责聚合单轮聊天中的 assistant thinking、tool 调用和最终回答,
* 并产出可用于历史回放的结构化 payload。
*/
public class ChatAssistantAccumulator { public class ChatAssistantAccumulator {
private final StringBuilder content = new StringBuilder(); private final StringBuilder content = new StringBuilder();
private final StringBuilder reasoning = new StringBuilder(); private final StringBuilder reasoning = new StringBuilder();
private final StringBuilder displayReasoning = new StringBuilder();
private final List<Map<String, Object>> chains = new ArrayList<>(); private final List<Map<String, Object>> chains = new ArrayList<>();
private final List<Map<String, Object>> messageChain = new ArrayList<>();
private final List<Map<String, Object>> toolMessages = new ArrayList<>();
private Map<String, Object> latestToolCallAssistant;
private boolean toolCallBatchOpen;
/**
* 追加当前 assistant 片段的文本内容。
*
* @param delta 内容增量
*/
public void appendContent(String delta) { public void appendContent(String delta) {
if (delta != null && !delta.isEmpty()) { if (delta != null && !delta.isEmpty()) {
content.append(delta); content.append(delta);
} }
} }
/**
* 追加当前 assistant 片段的 reasoning 内容。
*
* @param delta reasoning 增量
*/
public void appendReasoning(String delta) { public void appendReasoning(String delta) {
if (delta != null && !delta.isEmpty()) { if (delta != null && !delta.isEmpty()) {
reasoning.append(delta); reasoning.append(delta);
displayReasoning.append(delta);
} }
} }
/**
* 记录 tool call同时把当前 assistant 片段固化为一条结构化 assistant 消息。
*
* @param id tool call id
* @param name tool 名称
* @param arguments tool 参数
*/
public void appendToolCall(String id, String name, Object arguments) { public void appendToolCall(String id, String name, Object arguments) {
Map<String, Object> chain = findToolChain(id, name); Map<String, Object> chain = findToolChain(id, name);
chain.put("status", "TOOL_CALL"); chain.put("status", "TOOL_CALL");
chain.put("result", arguments); chain.put("arguments", arguments);
Map<String, Object> assistantMessage = ensureToolCallAssistantMessage();
@SuppressWarnings("unchecked")
List<Map<String, Object>> toolCalls = (List<Map<String, Object>>) assistantMessage.computeIfAbsent("toolCalls",
key -> new ArrayList<Map<String, Object>>());
Map<String, Object> toolCall = new LinkedHashMap<>();
toolCall.put("id", id);
toolCall.put("name", name);
toolCall.put("arguments", arguments == null ? null : String.valueOf(arguments));
toolCalls.add(toolCall);
} }
/**
* 记录 tool result并附加到结构化消息链中。
*
* @param id tool call id
* @param name tool 名称
* @param result tool 结果
*/
public void appendToolResult(String id, String name, Object result) { public void appendToolResult(String id, String name, Object result) {
Map<String, Object> chain = findToolChain(id, name); Map<String, Object> chain = findToolChain(id, name);
chain.put("status", "TOOL_RESULT"); chain.put("status", "TOOL_RESULT");
chain.put("result", result); chain.put("result", result);
Map<String, Object> toolMessage = ChatRuntimeHistoryPayloadHelper.toolMessage(
id,
result == null ? null : String.valueOf(result)
);
toolMessages.add(toolMessage);
messageChain.add(ChatRuntimeHistoryPayloadHelper.deepCopyMap(toolMessage));
toolCallBatchOpen = false;
} }
/**
* 获取当前 assistant 片段的文本内容。
*
* @return 文本内容
*/
public String getContent() { public String getContent() {
return content.toString(); return content.toString();
} }
public Map<String, Object> buildPayload() { /**
Map<String, Object> payload = new LinkedHashMap<>(); * 获取最近一次 tool-call assistant 的 reasoning 内容,供实时内存消息回写复用。
*
* @return reasoning 内容
*/
public String getLatestToolCallReasoning() {
return latestToolCallAssistant == null ? null : stringValue(latestToolCallAssistant.get("reasoningContent"));
}
/**
* 获取最近一次 tool-call assistant 的内容,供实时内存消息回写复用。
*
* @return 内容
*/
public String getLatestToolCallContent() {
return latestToolCallAssistant == null ? null : stringValue(latestToolCallAssistant.get("content"));
}
/**
* 产出结构化 payload。
*
* @param finalContent 最终 assistant 文本
* @return payload
*/
public Map<String, Object> buildPayload(String finalContent) {
List<Map<String, Object>> payloadChains = new ArrayList<>(); List<Map<String, Object>> payloadChains = new ArrayList<>();
if (reasoning.length() > 0) { if (displayReasoning.length() > 0) {
Map<String, Object> think = new LinkedHashMap<>(); Map<String, Object> think = new LinkedHashMap<>();
think.put("reasoning_content", reasoning.toString()); think.put("reasoning_content", displayReasoning.toString());
think.put("thinkingStatus", "end"); think.put("thinkingStatus", "end");
think.put("thinlCollapse", Boolean.TRUE); think.put("thinlCollapse", Boolean.TRUE);
payloadChains.add(think); payloadChains.add(think);
} }
payloadChains.addAll(chains); payloadChains.addAll(chains);
if (!payloadChains.isEmpty()) { List<Map<String, Object>> payloadMessageChain = ChatRuntimeHistoryPayloadHelper.deepCopyList(messageChain);
payload.put("chains", payloadChains); Map<String, Object> finalAssistantMessage = buildFinalAssistantMessage(finalContent);
if (!finalAssistantMessage.isEmpty()) {
payloadMessageChain.add(finalAssistantMessage);
} }
return payload; return ChatRuntimeHistoryPayloadHelper.buildPayload(payloadMessageChain, toolMessages, payloadChains);
} }
private Map<String, Object> findToolChain(String id, String name) { private Map<String, Object> findToolChain(String id, String name) {
@@ -71,4 +152,43 @@ public class ChatAssistantAccumulator {
chains.add(chain); chains.add(chain);
return chain; return chain;
} }
private Map<String, Object> ensureToolCallAssistantMessage() {
if (toolCallBatchOpen && latestToolCallAssistant != null && !hasPendingAssistantContent()) {
return latestToolCallAssistant;
}
latestToolCallAssistant = ChatRuntimeHistoryPayloadHelper.assistantMessage(
content.length() == 0 ? null : content.toString(),
reasoning.length() == 0 ? null : reasoning.toString(),
null
);
messageChain.add(latestToolCallAssistant);
content.setLength(0);
reasoning.setLength(0);
toolCallBatchOpen = true;
return latestToolCallAssistant;
}
private Map<String, Object> buildFinalAssistantMessage(String finalContent) {
String assistantContent = finalContent;
if ((assistantContent == null || assistantContent.isEmpty()) && content.length() > 0) {
assistantContent = content.toString();
}
if ((assistantContent == null || assistantContent.isEmpty()) && reasoning.length() == 0) {
return new LinkedHashMap<>();
}
return ChatRuntimeHistoryPayloadHelper.assistantMessage(
assistantContent,
reasoning.length() == 0 ? null : reasoning.toString(),
null
);
}
private boolean hasPendingAssistantContent() {
return content.length() > 0 || reasoning.length() > 0;
}
private String stringValue(Object value) {
return value == null ? null : String.valueOf(value);
}
} }

View File

@@ -0,0 +1,272 @@
package tech.easyflow.core.runtime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 聊天运行时历史 payload 的统一读写工具。
* <p>
* 该工具只处理 {@link Map} / {@link List} 结构,避免把 easy-agents 类型泄漏到通用协议模块。
*/
public final class ChatRuntimeHistoryPayloadHelper {
public static final String KEY_MESSAGE_CHAIN = "messageChain";
public static final String KEY_ASSISTANT_MESSAGE = "assistantMessage";
public static final String KEY_FINAL_ASSISTANT_MESSAGE = "finalAssistantMessage";
public static final String KEY_TOOL_MESSAGES = "toolMessages";
public static final String KEY_DISPLAY_CHAINS = "displayChains";
public static final String KEY_CHAINS = "chains";
private ChatRuntimeHistoryPayloadHelper() {
}
/**
* 构造 assistant 历史消息结构。
*
* @param content assistant 内容
* @param reasoningContent assistant reasoning 内容
* @param toolCalls assistant tool calls
* @return assistant 历史消息结构
*/
public static Map<String, Object> assistantMessage(String content,
String reasoningContent,
List<Map<String, Object>> toolCalls) {
Map<String, Object> message = new LinkedHashMap<>();
message.put("role", "assistant");
if (content != null) {
message.put("content", content);
}
if (reasoningContent != null && !reasoningContent.isEmpty()) {
message.put("reasoningContent", reasoningContent);
}
if (toolCalls != null && !toolCalls.isEmpty()) {
message.put("toolCalls", deepCopyList(toolCalls));
}
return message;
}
/**
* 构造 tool 历史消息结构。
*
* @param toolCallId tool call id
* @param content tool 结果内容
* @return tool 历史消息结构
*/
public static Map<String, Object> toolMessage(String toolCallId, String content) {
Map<String, Object> message = new LinkedHashMap<>();
message.put("role", "tool");
message.put("toolCallId", toolCallId);
if (content != null) {
message.put("content", content);
}
return message;
}
/**
* 构造统一的历史 payload。
*
* @param messageChain 完整 assistant/tool 历史链
* @param toolMessages tool 历史列表
* @param displayChains 前端展示链
* @return payload
*/
public static Map<String, Object> buildPayload(List<Map<String, Object>> messageChain,
List<Map<String, Object>> toolMessages,
List<Map<String, Object>> displayChains) {
Map<String, Object> payload = new LinkedHashMap<>();
List<Map<String, Object>> safeMessageChain = deepCopyList(messageChain);
List<Map<String, Object>> safeToolMessages = deepCopyList(toolMessages);
List<Map<String, Object>> safeDisplayChains = deepCopyList(displayChains);
if (!safeMessageChain.isEmpty()) {
payload.put(KEY_MESSAGE_CHAIN, safeMessageChain);
Map<String, Object> firstAssistant = findAssistant(safeMessageChain, false);
if (!firstAssistant.isEmpty()) {
payload.put(KEY_ASSISTANT_MESSAGE, firstAssistant);
}
Map<String, Object> lastAssistant = findAssistant(safeMessageChain, true);
if (!lastAssistant.isEmpty() && !lastAssistant.equals(firstAssistant)) {
payload.put(KEY_FINAL_ASSISTANT_MESSAGE, lastAssistant);
}
}
if (!safeToolMessages.isEmpty()) {
payload.put(KEY_TOOL_MESSAGES, safeToolMessages);
}
if (!safeDisplayChains.isEmpty()) {
payload.put(KEY_DISPLAY_CHAINS, safeDisplayChains);
payload.put(KEY_CHAINS, deepCopyList(safeDisplayChains));
}
return payload;
}
/**
* 读取结构化消息链。
*
* @param payload contentPayload
* @return 结构化消息链
*/
public static List<Map<String, Object>> getMessageChain(Map<String, Object> payload) {
return getMapList(payload == null ? null : payload.get(KEY_MESSAGE_CHAIN));
}
/**
* 读取 assistant 历史消息。
*
* @param payload contentPayload
* @return assistant 历史消息
*/
public static Map<String, Object> getAssistantMessage(Map<String, Object> payload) {
return getMap(payload == null ? null : payload.get(KEY_ASSISTANT_MESSAGE));
}
/**
* 读取最终 assistant 历史消息。
*
* @param payload contentPayload
* @return 最终 assistant 历史消息
*/
public static Map<String, Object> getFinalAssistantMessage(Map<String, Object> payload) {
return getMap(payload == null ? null : payload.get(KEY_FINAL_ASSISTANT_MESSAGE));
}
/**
* 读取 tool 历史消息列表。
*
* @param payload contentPayload
* @return tool 历史消息列表
*/
public static List<Map<String, Object>> getToolMessages(Map<String, Object> payload) {
return getMapList(payload == null ? null : payload.get(KEY_TOOL_MESSAGES));
}
/**
* 判断 payload 是否已经包含新结构化历史。
*
* @param payload contentPayload
* @return true 表示包含新结构
*/
public static boolean hasStructuredHistory(Map<String, Object> payload) {
return !getMessageChain(payload).isEmpty()
|| !getAssistantMessage(payload).isEmpty()
|| !getToolMessages(payload).isEmpty();
}
/**
* 读取对象为 Map。
*
* @param value 值
* @return Map 视图
*/
@SuppressWarnings("unchecked")
public static Map<String, Object> getMap(Object value) {
if (!(value instanceof Map<?, ?> source)) {
return Collections.emptyMap();
}
Map<String, Object> result = new LinkedHashMap<>();
for (Map.Entry<?, ?> entry : source.entrySet()) {
if (entry.getKey() != null) {
result.put(String.valueOf(entry.getKey()), entry.getValue());
}
}
return result;
}
/**
* 读取对象为 Map 列表。
*
* @param value 值
* @return Map 列表
*/
public static List<Map<String, Object>> getMapList(Object value) {
if (!(value instanceof List<?> source)) {
return Collections.emptyList();
}
List<Map<String, Object>> result = new ArrayList<>(source.size());
for (Object item : source) {
Map<String, Object> map = getMap(item);
if (!map.isEmpty()) {
result.add(map);
}
}
return result;
}
/**
* 深拷贝 Map 列表。
*
* @param source 原始列表
* @return 深拷贝后的列表
*/
public static List<Map<String, Object>> deepCopyList(List<Map<String, Object>> source) {
if (source == null || source.isEmpty()) {
return new ArrayList<>();
}
List<Map<String, Object>> copy = new ArrayList<>(source.size());
for (Map<String, Object> item : source) {
copy.add(deepCopyMap(item));
}
return copy;
}
/**
* 深拷贝 Map。
*
* @param source 原始 Map
* @return 深拷贝后的 Map
*/
@SuppressWarnings("unchecked")
public static Map<String, Object> deepCopyMap(Map<String, Object> source) {
if (source == null || source.isEmpty()) {
return new LinkedHashMap<>();
}
Map<String, Object> copy = new LinkedHashMap<>();
for (Map.Entry<String, Object> entry : source.entrySet()) {
Object value = entry.getValue();
if (value instanceof Map<?, ?> mapValue) {
copy.put(entry.getKey(), deepCopyMap((Map<String, Object>) mapValue));
} else if (value instanceof List<?> listValue) {
copy.put(entry.getKey(), deepCopyValueList((List<Object>) listValue));
} else {
copy.put(entry.getKey(), value);
}
}
return copy;
}
private static List<Object> deepCopyValueList(List<Object> source) {
List<Object> copy = new ArrayList<>(source.size());
for (Object item : source) {
if (item instanceof Map<?, ?> mapItem) {
copy.add(deepCopyMap((Map<String, Object>) mapItem));
} else if (item instanceof List<?> listItem) {
copy.add(deepCopyValueList((List<Object>) listItem));
} else {
copy.add(item);
}
}
return copy;
}
private static Map<String, Object> findAssistant(List<Map<String, Object>> messageChain, boolean reverse) {
if (messageChain == null || messageChain.isEmpty()) {
return Collections.emptyMap();
}
if (reverse) {
for (int i = messageChain.size() - 1; i >= 0; i--) {
Map<String, Object> item = messageChain.get(i);
if ("assistant".equalsIgnoreCase(String.valueOf(item.get("role")))) {
return deepCopyMap(item);
}
}
return Collections.emptyMap();
}
for (Map<String, Object> item : messageChain) {
if ("assistant".equalsIgnoreCase(String.valueOf(item.get("role")))) {
return deepCopyMap(item);
}
}
return Collections.emptyMap();
}
}

View File

@@ -87,6 +87,7 @@ public class ChatStreamListener implements StreamResponseListener {
sendToolCallEnvelope(toolCall); sendToolCallEnvelope(toolCall);
} }
} }
applyToolCallHistorySnapshot(aiMessage);
aiMessage.setContent(null); aiMessage.setContent(null);
memoryPrompt.addMessage(aiMessage); memoryPrompt.addMessage(aiMessage);
List<ToolMessage> toolMessages = aiMessageResponse.executeToolCallsAndGetToolMessages(); List<ToolMessage> toolMessages = aiMessageResponse.executeToolCallsAndGetToolMessages();
@@ -275,11 +276,29 @@ public class ChatStreamListener implements StreamResponseListener {
message.setContentType("TEXT"); message.setContentType("TEXT");
String fullContent = context != null && context.getFullMessage() != null ? context.getFullMessage().getContent() : null; String fullContent = context != null && context.getFullMessage() != null ? context.getFullMessage().getContent() : null;
message.setContentText(StringUtil.hasText(fullContent) ? fullContent : assistantAccumulator.getContent()); message.setContentText(StringUtil.hasText(fullContent) ? fullContent : assistantAccumulator.getContent());
message.setContentPayload(assistantAccumulator.buildPayload()); message.setContentPayload(assistantAccumulator.buildPayload(message.getContentText()));
message.setCreatedAt(new Date()); message.setCreatedAt(new Date());
message.setSenderId(runtimeContext.getAssistantId()); message.setSenderId(runtimeContext.getAssistantId());
message.setSenderName(runtimeContext.getAssistantName()); message.setSenderName(runtimeContext.getAssistantName());
return message; return message;
} }
/**
* 在 tool call assistant 写入临时 memory 前,把 reasoning/content 快照回填到消息对象中,
* 以便前端 history 透传和 DeepSeek 下一轮请求都能拿到完整链路。
*
* @param aiMessage tool call assistant 消息
*/
private void applyToolCallHistorySnapshot(AiMessage aiMessage) {
if (aiMessage == null) {
return;
}
if (!StringUtil.hasText(aiMessage.getReasoningContent())) {
aiMessage.setReasoningContent(assistantAccumulator.getLatestToolCallReasoning());
}
if (!StringUtil.hasText(aiMessage.getTextContent())) {
aiMessage.setFullContent(assistantAccumulator.getLatestToolCallContent());
}
}
} }

View File

@@ -0,0 +1,145 @@
package tech.easyflow.ai.easyagents.memory;
import com.easyagents.core.message.AiMessage;
import com.easyagents.core.message.Message;
import com.easyagents.core.message.ToolCall;
import com.easyagents.core.message.ToolMessage;
import tech.easyflow.core.runtime.ChatRuntimeHistoryPayloadHelper;
import tech.easyflow.core.runtime.ChatRuntimeMessage;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* 负责把聊天运行时持久化 payload 恢复为 easy-agents 消息链。
*/
public final class ChatRuntimeHistoryMessageMapper {
private ChatRuntimeHistoryMessageMapper() {
}
/**
* 从运行时消息中恢复 assistant / tool 历史消息链。
*
* @param runtimeMessage 运行时消息
* @return 恢复后的消息列表
*/
public static List<Message> toStructuredMessages(ChatRuntimeMessage runtimeMessage) {
if (runtimeMessage == null) {
return Collections.emptyList();
}
Map<String, Object> payload = runtimeMessage.getContentPayload();
List<Map<String, Object>> messageChain = ChatRuntimeHistoryPayloadHelper.getMessageChain(payload);
if (!messageChain.isEmpty()) {
return toMessages(messageChain);
}
if (!ChatRuntimeHistoryPayloadHelper.hasStructuredHistory(payload)) {
return Collections.emptyList();
}
List<Message> messages = new ArrayList<>();
Map<String, Object> assistantMessage = ChatRuntimeHistoryPayloadHelper.getAssistantMessage(payload);
if (!assistantMessage.isEmpty()) {
AiMessage aiMessage = toAiMessage(assistantMessage);
if (aiMessage != null) {
messages.add(aiMessage);
}
}
List<Map<String, Object>> toolMessages = ChatRuntimeHistoryPayloadHelper.getToolMessages(payload);
messages.addAll(toMessages(toolMessages));
Map<String, Object> finalAssistantMessage = ChatRuntimeHistoryPayloadHelper.getFinalAssistantMessage(payload);
if (!finalAssistantMessage.isEmpty()) {
AiMessage finalAiMessage = toAiMessage(finalAssistantMessage);
if (finalAiMessage != null) {
messages.add(finalAiMessage);
}
} else if (!messages.isEmpty()
&& (runtimeMessage.getContentText() != null && !runtimeMessage.getContentText().isBlank())
&& assistantMessage.containsKey("toolCalls")) {
messages.add(new AiMessage(runtimeMessage.getContentText()));
}
return messages;
}
private static List<Message> toMessages(List<Map<String, Object>> messageChain) {
List<Message> messages = new ArrayList<>();
for (Map<String, Object> item : messageChain) {
String role = String.valueOf(item.get("role"));
if ("assistant".equalsIgnoreCase(role)) {
AiMessage aiMessage = toAiMessage(item);
if (aiMessage != null) {
messages.add(aiMessage);
}
} else if ("tool".equalsIgnoreCase(role)) {
ToolMessage toolMessage = toToolMessage(item);
if (toolMessage != null) {
messages.add(toolMessage);
}
}
}
return messages;
}
private static AiMessage toAiMessage(Map<String, Object> item) {
if (item == null || item.isEmpty()) {
return null;
}
String content = stringValue(item.get("content"));
String reasoningContent = stringValue(firstNonNull(item.get("reasoningContent"), item.get("reasoning_content")));
List<ToolCall> toolCalls = toToolCalls(ChatRuntimeHistoryPayloadHelper.getMapList(firstNonNull(item.get("toolCalls"), item.get("tool_calls"))));
boolean hasContent = content != null && !content.isBlank();
boolean hasReasoning = reasoningContent != null && !reasoningContent.isBlank();
boolean hasToolCalls = toolCalls != null && !toolCalls.isEmpty();
if (!hasContent && !hasReasoning && !hasToolCalls) {
return null;
}
AiMessage aiMessage = new AiMessage(hasContent ? content : null);
if (hasReasoning) {
aiMessage.setReasoningContent(reasoningContent);
aiMessage.setFullReasoningContent(reasoningContent);
}
if (hasToolCalls) {
aiMessage.setToolCalls(toolCalls);
}
return aiMessage;
}
private static ToolMessage toToolMessage(Map<String, Object> item) {
if (item == null || item.isEmpty()) {
return null;
}
String content = stringValue(item.get("content"));
String toolCallId = stringValue(firstNonNull(item.get("toolCallId"), item.get("tool_call_id")));
if ((content == null || content.isBlank()) && (toolCallId == null || toolCallId.isBlank())) {
return null;
}
ToolMessage toolMessage = new ToolMessage();
toolMessage.setContent(content);
toolMessage.setToolCallId(toolCallId);
return toolMessage;
}
private static List<ToolCall> toToolCalls(List<Map<String, Object>> rawToolCalls) {
if (rawToolCalls == null || rawToolCalls.isEmpty()) {
return null;
}
List<ToolCall> toolCalls = new ArrayList<>(rawToolCalls.size());
for (Map<String, Object> rawToolCall : rawToolCalls) {
ToolCall toolCall = new ToolCall();
toolCall.setId(stringValue(rawToolCall.get("id")));
toolCall.setName(stringValue(firstNonNull(rawToolCall.get("name"), rawToolCall.get("toolName"))));
toolCall.setArguments(stringValue(rawToolCall.get("arguments")));
toolCalls.add(toolCall);
}
return toolCalls;
}
private static Object firstNonNull(Object first, Object second) {
return first != null ? first : second;
}
private static String stringValue(Object value) {
return value == null ? null : String.valueOf(value);
}
}

View File

@@ -10,6 +10,9 @@ import tech.easyflow.core.runtime.ChatRuntimeMessage;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
/**
* 从聊天运行时消息恢复 easy-agents 历史消息。
*/
public class RuntimeChatMemory implements ChatMemory { public class RuntimeChatMemory implements ChatMemory {
private final Object id; private final Object id;
@@ -19,10 +22,7 @@ public class RuntimeChatMemory implements ChatMemory {
this.id = id; this.id = id;
if (runtimeMessages != null) { if (runtimeMessages != null) {
for (ChatRuntimeMessage runtimeMessage : runtimeMessages) { for (ChatRuntimeMessage runtimeMessage : runtimeMessages) {
Message message = toMessage(runtimeMessage); this.messages.addAll(toMessages(runtimeMessage));
if (message != null) {
this.messages.add(message);
}
} }
} }
} }
@@ -55,20 +55,40 @@ public class RuntimeChatMemory implements ChatMemory {
return id; return id;
} }
private Message toMessage(ChatRuntimeMessage runtimeMessage) { private List<Message> toMessages(ChatRuntimeMessage runtimeMessage) {
if (runtimeMessage == null || runtimeMessage.getContentText() == null || runtimeMessage.getContentText().isBlank()) { if (runtimeMessage == null) {
return null; return List.of();
} }
String role = runtimeMessage.getRole(); String role = runtimeMessage.getRole();
if ("assistant".equalsIgnoreCase(role)) { if ("assistant".equalsIgnoreCase(role)) {
return new AiMessage(runtimeMessage.getContentText()); List<Message> structuredMessages = ChatRuntimeHistoryMessageMapper.toStructuredMessages(runtimeMessage);
if (!structuredMessages.isEmpty()) {
return structuredMessages;
}
if (runtimeMessage.getContentText() == null || runtimeMessage.getContentText().isBlank()) {
return List.of();
}
return List.of(new AiMessage(runtimeMessage.getContentText()));
} }
if ("system".equalsIgnoreCase(role)) { if ("system".equalsIgnoreCase(role)) {
return new SystemMessage(runtimeMessage.getContentText()); if (runtimeMessage.getContentText() == null || runtimeMessage.getContentText().isBlank()) {
return List.of();
}
return List.of(new SystemMessage(runtimeMessage.getContentText()));
} }
if ("tool".equalsIgnoreCase(role)) { if ("tool".equalsIgnoreCase(role)) {
return new SystemMessage(runtimeMessage.getContentText()); List<Message> structuredMessages = ChatRuntimeHistoryMessageMapper.toStructuredMessages(runtimeMessage);
if (!structuredMessages.isEmpty()) {
return structuredMessages;
}
if (runtimeMessage.getContentText() == null || runtimeMessage.getContentText().isBlank()) {
return List.of();
}
return List.of(new SystemMessage(runtimeMessage.getContentText()));
} }
return new UserMessage(runtimeMessage.getContentText()); if (runtimeMessage.getContentText() == null || runtimeMessage.getContentText().isBlank()) {
return List.of();
}
return List.of(new UserMessage(runtimeMessage.getContentText()));
} }
} }

View File

@@ -0,0 +1,53 @@
package tech.easyflow.ai.easyagents.tool;
import org.springframework.util.StringUtils;
import java.math.BigInteger;
import java.util.regex.Pattern;
/**
* 聊天工具名称辅助类。
*
* <p>聊天工具名称最终会作为 OpenAI-compatible 协议里的 function.name 发给上游模型,
* 因此在启用英文名称时需要确保名称稳定且满足 ASCII 约束。</p>
*
* @author Codex
* @since 2026-05-11
*/
public final class ChatToolNameHelper {
private static final Pattern SAFE_TOOL_NAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_-]+$");
private ChatToolNameHelper() {
}
/**
* 解析聊天工具名称。
*
* @param needEnglishName 是否优先使用英文名称
* @param englishName 英文名称
* @param displayName 展示名称
* @param fallbackPrefix 安全兜底名前缀
* @param resourceId 资源 ID
* @return 最终工具名称
*/
public static String resolveToolName(boolean needEnglishName,
String englishName,
String displayName,
String fallbackPrefix,
BigInteger resourceId) {
if (!needEnglishName) {
return StringUtils.hasText(displayName) ? displayName : buildFallbackName(fallbackPrefix, resourceId);
}
if (StringUtils.hasText(englishName) && SAFE_TOOL_NAME_PATTERN.matcher(englishName).matches()) {
return englishName;
}
return buildFallbackName(fallbackPrefix, resourceId);
}
private static String buildFallbackName(String fallbackPrefix, BigInteger resourceId) {
String prefix = StringUtils.hasText(fallbackPrefix) ? fallbackPrefix : "tool";
String suffix = resourceId == null ? "unknown" : resourceId.toString();
return prefix + "_" + suffix;
}
}

View File

@@ -67,11 +67,13 @@ public class DocumentCollectionTool extends BaseTool {
this.knowledgeId = documentCollection.getId(); this.knowledgeId = documentCollection.getId();
this.retrievalMode = retrievalMode == null ? RetrievalMode.HYBRID : retrievalMode; this.retrievalMode = retrievalMode == null ? RetrievalMode.HYBRID : retrievalMode;
this.chatTimeContext = chatTimeContext; this.chatTimeContext = chatTimeContext;
if (needEnglishName) { this.name = ChatToolNameHelper.resolveToolName(
this.name = documentCollection.getEnglishName(); needEnglishName,
} else { documentCollection.getEnglishName(),
this.name = documentCollection.getTitle(); documentCollection.getTitle(),
} "knowledge",
documentCollection.getId()
);
this.description = documentCollection.getDescription(); this.description = documentCollection.getDescription();
this.parameters = getDefaultParameters(); this.parameters = getDefaultParameters();
} }

View File

@@ -26,11 +26,13 @@ public class WorkflowTool extends BaseTool {
public WorkflowTool(Workflow workflow, boolean needEnglishName, String definitionId) { public WorkflowTool(Workflow workflow, boolean needEnglishName, String definitionId) {
this.workflowId = workflow.getId(); this.workflowId = workflow.getId();
this.definitionId = definitionId; this.definitionId = definitionId;
if (needEnglishName) { this.name = ChatToolNameHelper.resolveToolName(
this.name = workflow.getEnglishName(); needEnglishName,
} else { workflow.getEnglishName(),
this.name = workflow.getTitle(); workflow.getTitle(),
} "workflow",
workflow.getId()
);
this.description = workflow.getDescription(); this.description = workflow.getDescription();
this.parameters = toParameters(workflow, definitionId); this.parameters = toParameters(workflow, definitionId);
} }

View File

@@ -1,8 +1,11 @@
package tech.easyflow.ai.entity; package tech.easyflow.ai.entity;
import com.easyagents.core.message.*; import com.easyagents.core.message.AiMessage;
import com.alibaba.fastjson.JSON; import com.easyagents.core.message.Message;
import com.alibaba.fastjson2.JSONArray; import com.easyagents.core.message.SystemMessage;
import com.easyagents.core.message.ToolCall;
import com.easyagents.core.message.ToolMessage;
import com.easyagents.core.message.UserMessage;
import com.alibaba.fastjson2.JSONObject; import com.alibaba.fastjson2.JSONObject;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
@@ -11,6 +14,9 @@ import java.math.BigInteger;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
/**
* Public API 聊天请求参数。
*/
public class ChatRequestParams implements Serializable { public class ChatRequestParams implements Serializable {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
@@ -24,7 +30,7 @@ public class ChatRequestParams implements Serializable {
@JsonProperty("messages") @JsonProperty("messages")
public void setMessagesFromJson(List<Object> rawMessages) { public void setMessagesFromJson(List<Object> rawMessages) {
if (rawMessages == null) { if (rawMessages == null) {
this.messages = null; this.messages = new ArrayList<>();
return; return;
} }
@@ -57,8 +63,8 @@ public class ChatRequestParams implements Serializable {
return switch (role) { return switch (role) {
case "user" -> jsonObj.toJavaObject(UserMessage.class); case "user" -> jsonObj.toJavaObject(UserMessage.class);
case "system" -> jsonObj.toJavaObject(SystemMessage.class); case "system" -> jsonObj.toJavaObject(SystemMessage.class);
case "assistant" -> jsonObj.toJavaObject(AiMessage.class); case "assistant" -> toAiMessage(jsonObj);
case "tool" -> jsonObj.toJavaObject(ToolMessage.class); case "tool" -> toToolMessage(jsonObj);
default -> { default -> {
UserMessage defaultMsg = new UserMessage(); UserMessage defaultMsg = new UserMessage();
defaultMsg.setContent(content); defaultMsg.setContent(content);
@@ -67,6 +73,85 @@ public class ChatRequestParams implements Serializable {
}; };
} }
/**
* 把 JSON 结构恢复为 assistant 消息,兼容 `reasoningContent` / `reasoning_content`
* 以及 `toolCalls` / `tool_calls` 两种写法。
*
* @param jsonObj 原始 JSON
* @return assistant 消息
*/
private AiMessage toAiMessage(JSONObject jsonObj) {
AiMessage aiMessage = jsonObj.toJavaObject(AiMessage.class);
String reasoningContent = jsonObj.getString("reasoningContent");
if (reasoningContent == null || reasoningContent.isBlank()) {
reasoningContent = jsonObj.getString("reasoning_content");
}
if (reasoningContent != null && !reasoningContent.isBlank()) {
aiMessage.setReasoningContent(reasoningContent);
aiMessage.setFullReasoningContent(reasoningContent);
}
List<ToolCall> toolCalls = parseToolCalls(jsonObj);
if (!toolCalls.isEmpty()) {
aiMessage.setToolCalls(toolCalls);
}
return aiMessage;
}
/**
* 把 JSON 结构恢复为 tool 消息,兼容 `toolCallId` / `tool_call_id` 两种写法。
*
* @param jsonObj 原始 JSON
* @return tool 消息
*/
private ToolMessage toToolMessage(JSONObject jsonObj) {
ToolMessage toolMessage = jsonObj.toJavaObject(ToolMessage.class);
if (toolMessage.getToolCallId() == null || toolMessage.getToolCallId().isBlank()) {
toolMessage.setToolCallId(jsonObj.getString("tool_call_id"));
}
return toolMessage;
}
/**
* 解析 assistant 上的 tool calls。
*
* @param jsonObj 原始 JSON
* @return tool call 列表
*/
private List<ToolCall> parseToolCalls(JSONObject jsonObj) {
Object rawToolCalls = jsonObj.get("toolCalls");
if (rawToolCalls == null) {
rawToolCalls = jsonObj.get("tool_calls");
}
List<ToolCall> toolCalls = new ArrayList<>();
if (!(rawToolCalls instanceof List<?> rawList)) {
return toolCalls;
}
for (Object rawToolCall : rawList) {
JSONObject toolCallJson = JSONObject.from(rawToolCall);
if (toolCallJson == null) {
continue;
}
ToolCall toolCall = new ToolCall();
toolCall.setId(toolCallJson.getString("id"));
String toolName = toolCallJson.getString("name");
if (toolName == null || toolName.isBlank()) {
JSONObject functionJson = toolCallJson.getJSONObject("function");
if (functionJson != null) {
toolName = functionJson.getString("name");
if (toolCallJson.getString("arguments") == null || toolCallJson.getString("arguments").isBlank()) {
toolCall.setArguments(functionJson.getString("arguments"));
}
}
}
toolCall.setName(toolName);
if (toolCall.getArguments() == null || toolCall.getArguments().isBlank()) {
toolCall.setArguments(toolCallJson.getString("arguments"));
}
toolCalls.add(toolCall);
}
return toolCalls;
}
public List<Message> getMessages() { public List<Message> getMessages() {
return messages; return messages;
} }
@@ -83,7 +168,11 @@ public class ChatRequestParams implements Serializable {
this.botId = botId; this.botId = botId;
} }
public String getConversationId() {return conversationId;} public String getConversationId() {
return conversationId;
}
public void setConversationId(String conversationId) {this.conversationId = conversationId;} public void setConversationId(String conversationId) {
} this.conversationId = conversationId;
}
}

View File

@@ -75,6 +75,12 @@ public class Model extends ModelBase {
deepseekConfig.setApiKey(checkAndGetApiKey()); deepseekConfig.setApiKey(checkAndGetApiKey());
deepseekConfig.setModel(checkAndGetModelName()); deepseekConfig.setModel(checkAndGetModelName());
deepseekConfig.setRequestPath(checkAndGetRequestPath()); deepseekConfig.setRequestPath(checkAndGetRequestPath());
deepseekConfig.setSupportThinking(Boolean.TRUE);
deepseekConfig.setThinkingProtocol("deepseek");
deepseekConfig.setNeedReasoningContentForToolMessage(Boolean.TRUE);
if (getSupportToolMessage() != null) {
deepseekConfig.setSupportToolMessage(getSupportToolMessage());
}
return new DeepseekChatModel(deepseekConfig); return new DeepseekChatModel(deepseekConfig);
default: default:
OpenAIChatConfig openAIChatConfig = new OpenAIChatConfig(); OpenAIChatConfig openAIChatConfig = new OpenAIChatConfig();

View File

@@ -298,7 +298,7 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
} }
UserMessage userMessage = new UserMessage(prompt); UserMessage userMessage = new UserMessage(prompt);
userMessage.addTools(buildFunctionList(Maps.of("botId", botId) userMessage.addTools(buildFunctionList(Maps.of("botId", botId)
.set("needEnglishName", false) .set("needEnglishName", true)
.set("bot", chatCheckResult.getAiBot()) .set("bot", chatCheckResult.getAiBot())
.set("chatTimeContext", chatTimeContext) .set("chatTimeContext", chatTimeContext)
.set("publishedOnly", chatCheckResult.isPublishedAccess()))); .set("publishedOnly", chatCheckResult.isPublishedAccess())));
@@ -350,13 +350,15 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
BotServiceImpl.ChatCheckResult chatCheckResult, ChatRuntimeContext runtimeContext) { BotServiceImpl.ChatCheckResult chatCheckResult, ChatRuntimeContext runtimeContext) {
Map<String, Object> modelOptions = chatCheckResult.getModelOptions(); Map<String, Object> modelOptions = chatCheckResult.getModelOptions();
ChatOptions chatOptions = getChatOptions(modelOptions); ChatOptions chatOptions = getChatOptions(modelOptions);
Boolean enableDeepThinking = MapUtil.getBoolean(modelOptions, Bot.KEY_ENABLE_DEEP_THINKING, false);
chatOptions.setThinkingEnabled(enableDeepThinking);
ChatModel chatModel = chatCheckResult.getChatModel(); ChatModel chatModel = chatCheckResult.getChatModel();
String systemPrompt = buildSystemPromptWithFaqImageRule( String systemPrompt = buildSystemPromptWithFaqImageRule(
MapUtil.getString(modelOptions, Bot.KEY_SYSTEM_PROMPT) MapUtil.getString(modelOptions, Bot.KEY_SYSTEM_PROMPT)
); );
UserMessage userMessage = new UserMessage(prompt); UserMessage userMessage = new UserMessage(prompt);
userMessage.addTools(buildFunctionList(Maps.of("botId", botId) userMessage.addTools(buildFunctionList(Maps.of("botId", botId)
.set("needEnglishName", false) .set("needEnglishName", true)
.set("needAccountId", false) .set("needAccountId", false)
.set("bot", chatCheckResult.getAiBot()) .set("bot", chatCheckResult.getAiBot())
.set("publishedOnly", chatCheckResult.isPublishedAccess()) .set("publishedOnly", chatCheckResult.isPublishedAccess())

View File

@@ -0,0 +1,142 @@
package tech.easyflow.ai.easyagents.memory;
import com.easyagents.core.message.AiMessage;
import com.easyagents.core.message.Message;
import com.easyagents.core.message.ToolCall;
import com.easyagents.core.message.ToolMessage;
import com.easyagents.core.model.client.OpenAIChatMessageSerializer;
import com.easyagents.llm.deepseek.DeepseekConfig;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.core.runtime.ChatAssistantAccumulator;
import tech.easyflow.core.runtime.ChatRuntimeMessage;
import java.util.List;
/**
* {@link RuntimeChatMemory} 测试。
*/
public class RuntimeChatMemoryTest {
/**
* 应当从结构化 payload 中恢复完整的 assistant/tool 消息链。
*/
@Test
public void shouldRestoreStructuredAssistantAndToolHistory() {
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
accumulator.appendReasoning("思考-1");
accumulator.appendToolCall("call-1", "kb_search", "{\"query\":\"java\"}");
accumulator.appendToolResult("call-1", "kb_search", "{\"hits\":1}");
accumulator.appendReasoning("思考-2");
accumulator.appendContent("最终回答");
ChatRuntimeMessage runtimeMessage = new ChatRuntimeMessage();
runtimeMessage.setRole("assistant");
runtimeMessage.setContentText("最终回答");
runtimeMessage.setContentPayload(accumulator.buildPayload("最终回答"));
RuntimeChatMemory memory = new RuntimeChatMemory("c1", List.of(runtimeMessage));
List<Message> messages = memory.getMessages(10);
Assert.assertEquals(3, messages.size());
Assert.assertTrue(messages.get(0) instanceof AiMessage);
Assert.assertTrue(messages.get(1) instanceof ToolMessage);
Assert.assertTrue(messages.get(2) instanceof AiMessage);
AiMessage toolAssistant = (AiMessage) messages.get(0);
Assert.assertEquals("思考-1", toolAssistant.getReasoningContent());
Assert.assertEquals(1, toolAssistant.getToolCalls().size());
Assert.assertEquals("call-1", toolAssistant.getToolCalls().get(0).getId());
Assert.assertEquals("{\"query\":\"java\"}", toolAssistant.getToolCalls().get(0).getArguments());
ToolMessage toolMessage = (ToolMessage) messages.get(1);
Assert.assertEquals("call-1", toolMessage.getToolCallId());
Assert.assertEquals("{\"hits\":1}", toolMessage.getContent());
AiMessage finalAssistant = (AiMessage) messages.get(2);
Assert.assertEquals("最终回答", finalAssistant.getTextContent());
Assert.assertEquals("思考-2", finalAssistant.getReasoningContent());
}
/**
* 旧结构历史应保持纯文本兼容。
*/
@Test
public void shouldFallbackToPlainAssistantMessageForLegacyPayload() {
ChatRuntimeMessage runtimeMessage = new ChatRuntimeMessage();
runtimeMessage.setRole("assistant");
runtimeMessage.setContentText("旧版回答");
RuntimeChatMemory memory = new RuntimeChatMemory("c2", List.of(runtimeMessage));
List<Message> messages = memory.getMessages(10);
Assert.assertEquals(1, messages.size());
Assert.assertTrue(messages.get(0) instanceof AiMessage);
Assert.assertEquals("旧版回答", ((AiMessage) messages.get(0)).getTextContent());
}
/**
* 内部聊天结构化历史回放后DeepSeek 序列化应继续带上 reasoning_content 与 tool 链。
*/
@Test
public void shouldSerializeRestoredRuntimeHistoryForDeepseekFollowUp() {
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
accumulator.appendReasoning("先思考");
accumulator.appendToolCall("call-1", "kb_search", "{\"query\":\"java\"}");
accumulator.appendToolResult("call-1", "kb_search", "{\"hits\":1}");
ChatRuntimeMessage runtimeMessage = new ChatRuntimeMessage();
runtimeMessage.setRole("assistant");
runtimeMessage.setContentPayload(accumulator.buildPayload(null));
RuntimeChatMemory memory = new RuntimeChatMemory("c3", List.of(runtimeMessage));
List<Message> restoredMessages = memory.getMessages(10);
DeepseekConfig config = new DeepseekConfig();
config.setSupportThinking(Boolean.TRUE);
config.setThinkingProtocol("deepseek");
config.setNeedReasoningContentForToolMessage(Boolean.TRUE);
List<java.util.Map<String, Object>> serialized = new OpenAIChatMessageSerializer()
.serializeMessages(restoredMessages, config);
Assert.assertEquals(2, serialized.size());
Assert.assertEquals("assistant", serialized.get(0).get("role"));
Assert.assertEquals("先思考", serialized.get(0).get("reasoning_content"));
Assert.assertTrue(serialized.get(0).containsKey("tool_calls"));
Assert.assertEquals("tool", serialized.get(1).get("role"));
Assert.assertEquals("call-1", serialized.get(1).get("tool_call_id"));
}
/**
* 连续多轮 tool-only assistant 回放后仍应保持独立顺序。
*/
@Test
public void shouldRestoreSeparatedToolOnlyAssistantSegments() {
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
accumulator.appendToolCall("call-1", "tool_one", "{\"step\":1}");
accumulator.appendToolResult("call-1", "tool_one", "{\"ok\":1}");
accumulator.appendToolCall("call-2", "tool_two", "{\"step\":2}");
accumulator.appendToolResult("call-2", "tool_two", "{\"ok\":2}");
ChatRuntimeMessage runtimeMessage = new ChatRuntimeMessage();
runtimeMessage.setRole("assistant");
runtimeMessage.setContentPayload(accumulator.buildPayload(null));
RuntimeChatMemory memory = new RuntimeChatMemory("c4", List.of(runtimeMessage));
List<Message> messages = memory.getMessages(10);
Assert.assertEquals(4, messages.size());
Assert.assertTrue(messages.get(0) instanceof AiMessage);
Assert.assertTrue(messages.get(1) instanceof ToolMessage);
Assert.assertTrue(messages.get(2) instanceof AiMessage);
Assert.assertTrue(messages.get(3) instanceof ToolMessage);
AiMessage firstAssistant = (AiMessage) messages.get(0);
AiMessage secondAssistant = (AiMessage) messages.get(2);
Assert.assertEquals(1, firstAssistant.getToolCalls().size());
Assert.assertEquals("call-1", firstAssistant.getToolCalls().get(0).getId());
Assert.assertEquals(1, secondAssistant.getToolCalls().size());
Assert.assertEquals("call-2", secondAssistant.getToolCalls().get(0).getId());
}
}

View File

@@ -0,0 +1,79 @@
package tech.easyflow.ai.easyagents.tool;
import org.junit.Assert;
import org.junit.Test;
import java.math.BigInteger;
/**
* {@link ChatToolNameHelper} 单元测试。
*
* @author Codex
* @since 2026-05-11
*/
public class ChatToolNameHelperTest {
/**
* 启用英文名时应优先返回合法英文名称。
*/
@Test
public void resolveToolNameShouldPreferValidEnglishName() {
String name = ChatToolNameHelper.resolveToolName(
true,
"knowledge_search",
"知识库检索",
"knowledge",
BigInteger.valueOf(101)
);
Assert.assertEquals("knowledge_search", name);
}
/**
* 英文名缺失时应回退为稳定安全名称。
*/
@Test
public void resolveToolNameShouldFallbackWhenEnglishNameMissing() {
String name = ChatToolNameHelper.resolveToolName(
true,
null,
"知识库检索",
"knowledge",
BigInteger.valueOf(101)
);
Assert.assertEquals("knowledge_101", name);
}
/**
* 英文名不满足协议约束时应回退为稳定安全名称。
*/
@Test
public void resolveToolNameShouldFallbackWhenEnglishNameInvalid() {
String name = ChatToolNameHelper.resolveToolName(
true,
"工作流-A",
"工作流检索",
"workflow",
BigInteger.valueOf(202)
);
Assert.assertEquals("workflow_202", name);
}
/**
* 未启用英文名时应保留展示名称。
*/
@Test
public void resolveToolNameShouldKeepDisplayNameWhenEnglishNameDisabled() {
String name = ChatToolNameHelper.resolveToolName(
false,
"workflow_search",
"工作流检索",
"workflow",
BigInteger.valueOf(202)
);
Assert.assertEquals("工作流检索", name);
}
}

View File

@@ -0,0 +1,179 @@
package tech.easyflow.ai.easyagents.tool;
import com.easyagents.flow.core.chain.ChainDefinition;
import com.easyagents.flow.core.chain.repository.ChainDefinitionRepository;
import com.easyagents.flow.core.chain.repository.InMemoryChainStateRepository;
import com.easyagents.flow.core.chain.repository.InMemoryNodeStateRepository;
import com.easyagents.flow.core.chain.runtime.ChainExecutor;
import org.junit.Assert;
import org.junit.Test;
import org.springframework.context.ApplicationContext;
import tech.easyflow.ai.entity.Workflow;
import java.lang.reflect.Field;
import java.lang.reflect.Proxy;
import java.math.BigInteger;
/**
* {@link WorkflowTool} 单元测试。
*
* @author Codex
* @since 2026-05-11
*/
public class WorkflowToolTest {
/**
* 启用英文名且字段合法时,应直接使用英文名。
*
* @throws Exception 反射注入异常
*/
@Test
public void shouldUseValidEnglishNameWhenEnabled() throws Exception {
ApplicationContext previousContext = getStaticField("applicationContext");
Object previousBeanFactory = getStaticField("beanFactory");
try {
setStaticField("beanFactory", null);
setStaticField("applicationContext", mockApplicationContext(buildChainExecutor("workflow-1")));
WorkflowTool tool = new WorkflowTool(buildWorkflow(101, "workflow_search", "工作流检索"), true, "workflow-1");
Assert.assertEquals("workflow_search", tool.getName());
} finally {
setStaticField("applicationContext", previousContext);
setStaticField("beanFactory", previousBeanFactory);
}
}
/**
* 启用英文名但字段缺失时,应回退为稳定安全名称。
*
* @throws Exception 反射注入异常
*/
@Test
public void shouldFallbackToSafeNameWhenEnglishNameMissing() throws Exception {
ApplicationContext previousContext = getStaticField("applicationContext");
Object previousBeanFactory = getStaticField("beanFactory");
try {
setStaticField("beanFactory", null);
setStaticField("applicationContext", mockApplicationContext(buildChainExecutor("workflow-2")));
WorkflowTool tool = new WorkflowTool(buildWorkflow(202, null, "工作流检索"), true, "workflow-2");
Assert.assertEquals("workflow_202", tool.getName());
} finally {
setStaticField("applicationContext", previousContext);
setStaticField("beanFactory", previousBeanFactory);
}
}
/**
* 启用英文名但字段非法时,应回退为稳定安全名称。
*
* @throws Exception 反射注入异常
*/
@Test
public void shouldFallbackToSafeNameWhenEnglishNameInvalid() throws Exception {
ApplicationContext previousContext = getStaticField("applicationContext");
Object previousBeanFactory = getStaticField("beanFactory");
try {
setStaticField("beanFactory", null);
setStaticField("applicationContext", mockApplicationContext(buildChainExecutor("workflow-3")));
WorkflowTool tool = new WorkflowTool(buildWorkflow(303, "工作流-A", "工作流检索"), true, "workflow-3");
Assert.assertEquals("workflow_303", tool.getName());
} finally {
setStaticField("applicationContext", previousContext);
setStaticField("beanFactory", previousBeanFactory);
}
}
private Workflow buildWorkflow(long id, String englishName, String title) {
Workflow workflow = new Workflow();
workflow.setId(BigInteger.valueOf(id));
workflow.setEnglishName(englishName);
workflow.setTitle(title);
workflow.setDescription("desc-" + id);
return workflow;
}
private ChainExecutor buildChainExecutor(String definitionId) {
ChainDefinitionRepository definitionRepository = id -> {
ChainDefinition definition = new ChainDefinition();
definition.setId(definitionId);
return definition;
};
return new ChainExecutor(
definitionRepository,
new InMemoryChainStateRepository(),
new InMemoryNodeStateRepository()
);
}
private ApplicationContext mockApplicationContext(ChainExecutor chainExecutor) {
return (ApplicationContext) Proxy.newProxyInstance(
ApplicationContext.class.getClassLoader(),
new Class[]{ApplicationContext.class},
(proxy, method, args) -> {
if ("getBean".equals(method.getName()) && args != null && args.length == 1 && args[0] == ChainExecutor.class) {
return chainExecutor;
}
if ("equals".equals(method.getName())) {
return proxy == args[0];
}
if ("hashCode".equals(method.getName())) {
return System.identityHashCode(proxy);
}
return defaultValue(method.getReturnType());
}
);
}
private static <T> T getStaticField(String name) throws Exception {
Field field = Class.forName("tech.easyflow.common.util.SpringContextUtil").getDeclaredField(name);
field.setAccessible(true);
@SuppressWarnings("unchecked")
T value = (T) field.get(null);
return value;
}
private static void setStaticField(String name, Object value) throws Exception {
Field field = Class.forName("tech.easyflow.common.util.SpringContextUtil").getDeclaredField(name);
field.setAccessible(true);
field.set(null, value);
}
private Object defaultValue(Class<?> returnType) {
if (returnType == null || returnType == Void.TYPE) {
return null;
}
if (!returnType.isPrimitive()) {
return null;
}
if (returnType == Boolean.TYPE) {
return false;
}
if (returnType == Character.TYPE) {
return '\0';
}
if (returnType == Byte.TYPE) {
return (byte) 0;
}
if (returnType == Short.TYPE) {
return (short) 0;
}
if (returnType == Integer.TYPE) {
return 0;
}
if (returnType == Long.TYPE) {
return 0L;
}
if (returnType == Float.TYPE) {
return 0F;
}
if (returnType == Double.TYPE) {
return 0D;
}
return null;
}
}

View File

@@ -0,0 +1,120 @@
package tech.easyflow.ai.entity;
import com.easyagents.core.message.AiMessage;
import com.easyagents.core.message.Message;
import com.easyagents.core.message.ToolMessage;
import com.easyagents.core.model.client.OpenAIChatMessageSerializer;
import com.easyagents.llm.deepseek.DeepseekConfig;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.ai.easyagents.memory.PublicBotMessageMemory;
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
import java.util.List;
import java.util.Map;
/**
* {@link ChatRequestParams} 测试。
*/
public class ChatRequestParamsTest {
/**
* 应兼容解析 assistant 的 reasoning 与 OpenAI 风格 tool_calls。
*/
@Test
public void shouldParseAssistantReasoningAndToolCalls() {
ChatRequestParams params = new ChatRequestParams();
params.setMessagesFromJson(List.of(
Map.of(
"role", "assistant",
"content", "",
"reasoning_content", "先思考",
"tool_calls", List.of(
Map.of(
"id", "call-1",
"type", "function",
"function", Map.of(
"name", "kb_search",
"arguments", "{\"query\":\"test\"}"
)
)
)
)
));
Message message = params.getMessages().get(0);
Assert.assertTrue(message instanceof AiMessage);
AiMessage aiMessage = (AiMessage) message;
Assert.assertEquals("先思考", aiMessage.getReasoningContent());
Assert.assertEquals(1, aiMessage.getToolCalls().size());
Assert.assertEquals("kb_search", aiMessage.getToolCalls().get(0).getName());
Assert.assertEquals("{\"query\":\"test\"}", aiMessage.getToolCalls().get(0).getArguments());
}
/**
* 应兼容解析 tool 消息的下划线字段。
*/
@Test
public void shouldParseToolMessageWithSnakeCaseToolCallId() {
ChatRequestParams params = new ChatRequestParams();
params.setMessagesFromJson(List.of(
Map.of(
"role", "tool",
"content", "{\"hits\":1}",
"tool_call_id", "call-2"
)
));
Message message = params.getMessages().get(0);
Assert.assertTrue(message instanceof ToolMessage);
ToolMessage toolMessage = (ToolMessage) message;
Assert.assertEquals("call-2", toolMessage.getToolCallId());
Assert.assertEquals("{\"hits\":1}", toolMessage.getContent());
}
/**
* public-api 多轮 assistant/tool 历史透传后DeepSeek 后续轮次序列化不应丢字段。
*/
@Test
public void shouldKeepPublicApiAssistantAndToolHistoryForDeepseekFollowUp() {
ChatRequestParams params = new ChatRequestParams();
params.setMessagesFromJson(List.of(
Map.of(
"role", "assistant",
"content", "",
"reasoning_content", "先思考",
"tool_calls", List.of(
Map.of(
"id", "call-1",
"type", "function",
"function", Map.of(
"name", "kb_search",
"arguments", "{\"query\":\"test\"}"
)
)
)
),
Map.of(
"role", "tool",
"content", "{\"hits\":1}",
"tool_call_id", "call-1"
)
));
PublicBotMessageMemory memory = new PublicBotMessageMemory(new ChatSseEmitter(), params.getMessages());
DeepseekConfig config = new DeepseekConfig();
config.setSupportThinking(Boolean.TRUE);
config.setThinkingProtocol("deepseek");
config.setNeedReasoningContentForToolMessage(Boolean.TRUE);
List<Map<String, Object>> serialized = new OpenAIChatMessageSerializer()
.serializeMessages(memory.getMessages(10), config);
Assert.assertEquals(2, serialized.size());
Assert.assertEquals("assistant", serialized.get(0).get("role"));
Assert.assertEquals("先思考", serialized.get(0).get("reasoning_content"));
Assert.assertTrue(serialized.get(0).containsKey("tool_calls"));
Assert.assertEquals("tool", serialized.get(1).get("role"));
Assert.assertEquals("call-1", serialized.get(1).get("tool_call_id"));
}
}

View File

@@ -0,0 +1,74 @@
package tech.easyflow.ai.entity;
import com.easyagents.core.message.AiMessage;
import com.easyagents.core.message.ToolCall;
import com.easyagents.core.message.ToolMessage;
import com.easyagents.core.model.client.OpenAIChatMessageSerializer;
import com.easyagents.llm.deepseek.DeepseekChatModel;
import com.easyagents.llm.deepseek.DeepseekConfig;
import org.junit.Assert;
import org.junit.Test;
import java.util.List;
import java.util.Map;
/**
* DeepSeek thinking/tool 配置测试。
*/
public class ModelDeepseekConfigTest {
/**
* DeepSeek 聊天模型应显式开启 thinking/tool 相关协议开关。
*/
@Test
public void shouldEnableDeepseekThinkingProtocolFlags() {
ModelProvider provider = new ModelProvider();
provider.setProviderType("deepseek");
provider.setProviderName("deepseek");
Model model = new Model();
model.setModelProvider(provider);
model.setEndpoint("https://api.deepseek.com");
model.setApiKey("sk-test");
model.setModelName("deepseek-chat");
model.setRequestPath("/chat/completions");
DeepseekChatModel chatModel = (DeepseekChatModel) model.toChatModel();
DeepseekConfig config = chatModel.getConfig();
Assert.assertTrue(config.isSupportThinking());
Assert.assertEquals("deepseek", config.getThinkingProtocol());
Assert.assertTrue(config.isNeedReasoningContentForToolMessage());
}
/**
* DeepSeek 在 tool call assistant 历史上应序列化 reasoning_content。
*/
@Test
public void shouldSerializeReasoningContentForToolAssistantMessage() {
DeepseekConfig config = new DeepseekConfig();
config.setNeedReasoningContentForToolMessage(Boolean.TRUE);
config.setThinkingProtocol("deepseek");
config.setSupportThinking(Boolean.TRUE);
AiMessage assistant = new AiMessage(null);
assistant.setReasoningContent("先推理");
assistant.setToolCalls(List.of(new ToolCall("call-1", "kb_search", "{\"query\":\"java\"}")));
ToolMessage toolMessage = new ToolMessage();
toolMessage.setToolCallId("call-1");
toolMessage.setContent("{\"hits\":1}");
List<Map<String, Object>> serialized = new OpenAIChatMessageSerializer().serializeMessages(
List.of(assistant, toolMessage),
config
);
Assert.assertEquals(2, serialized.size());
Assert.assertEquals("assistant", serialized.get(0).get("role"));
Assert.assertEquals("先推理", serialized.get(0).get("reasoning_content"));
Assert.assertTrue(serialized.get(0).containsKey("tool_calls"));
Assert.assertEquals("tool", serialized.get(1).get("role"));
Assert.assertEquals("call-1", serialized.get(1).get("tool_call_id"));
}
}

View File

@@ -0,0 +1,73 @@
package tech.easyflow.core.runtime;
import org.junit.Assert;
import org.junit.Test;
import java.util.List;
import java.util.Map;
/**
* {@link ChatAssistantAccumulator} 测试。
*/
public class ChatAssistantAccumulatorTest {
/**
* 应同时保留展示链和可回放的结构化消息链。
*/
@Test
@SuppressWarnings("unchecked")
public void shouldBuildStructuredPayloadWithDisplayChains() {
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
accumulator.appendReasoning("思考-1");
accumulator.appendToolCall("call-1", "kb_search", "{\"query\":\"java\"}");
accumulator.appendToolResult("call-1", "kb_search", "{\"hits\":1}");
accumulator.appendReasoning("思考-2");
accumulator.appendContent("最终回答");
Map<String, Object> payload = accumulator.buildPayload("最终回答");
List<Map<String, Object>> messageChain = (List<Map<String, Object>>) payload.get("messageChain");
List<Map<String, Object>> chains = (List<Map<String, Object>>) payload.get("chains");
Assert.assertEquals(3, messageChain.size());
Assert.assertEquals("assistant", messageChain.get(0).get("role"));
Assert.assertEquals("思考-1", messageChain.get(0).get("reasoningContent"));
Assert.assertEquals("tool", messageChain.get(1).get("role"));
Assert.assertEquals("assistant", messageChain.get(2).get("role"));
Assert.assertEquals("最终回答", messageChain.get(2).get("content"));
Assert.assertEquals("思考-2", messageChain.get(2).get("reasoningContent"));
Assert.assertFalse(chains.isEmpty());
Assert.assertEquals("思考-1思考-2", chains.get(0).get("reasoning_content"));
Assert.assertEquals("{\"query\":\"java\"}", chains.get(1).get("arguments"));
Assert.assertEquals("{\"hits\":1}", chains.get(1).get("result"));
}
/**
* 连续多轮 tool-only assistant 不应被错误合并到同一条 assistant 历史。
*/
@Test
@SuppressWarnings("unchecked")
public void shouldKeepToolOnlyAssistantSegmentsSeparatedAcrossRecursiveRounds() {
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
accumulator.appendToolCall("call-1", "tool_one", "{\"step\":1}");
accumulator.appendToolResult("call-1", "tool_one", "{\"ok\":1}");
accumulator.appendToolCall("call-2", "tool_two", "{\"step\":2}");
accumulator.appendToolResult("call-2", "tool_two", "{\"ok\":2}");
Map<String, Object> payload = accumulator.buildPayload(null);
List<Map<String, Object>> messageChain = (List<Map<String, Object>>) payload.get("messageChain");
Assert.assertEquals(4, messageChain.size());
Assert.assertEquals("assistant", messageChain.get(0).get("role"));
Assert.assertEquals("tool", messageChain.get(1).get("role"));
Assert.assertEquals("assistant", messageChain.get(2).get("role"));
Assert.assertEquals("tool", messageChain.get(3).get("role"));
List<Map<String, Object>> firstToolCalls = (List<Map<String, Object>>) messageChain.get(0).get("toolCalls");
List<Map<String, Object>> secondToolCalls = (List<Map<String, Object>>) messageChain.get(2).get("toolCalls");
Assert.assertEquals(1, firstToolCalls.size());
Assert.assertEquals("call-1", firstToolCalls.get(0).get("id"));
Assert.assertEquals(1, secondToolCalls.size());
Assert.assertEquals("call-2", secondToolCalls.get(0).get("id"));
}
}

View File

@@ -9,6 +9,7 @@ import tech.easyflow.chatlog.domain.dto.ChatMessageRecord;
import tech.easyflow.chatlog.service.ChatPersistDispatcher; import tech.easyflow.chatlog.service.ChatPersistDispatcher;
import tech.easyflow.chatlog.service.ChatSessionQueryService; import tech.easyflow.chatlog.service.ChatSessionQueryService;
import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.core.runtime.ChatRuntimeHistoryPayloadHelper;
import tech.easyflow.core.runtime.ChatRuntimeContext; import tech.easyflow.core.runtime.ChatRuntimeContext;
import tech.easyflow.core.runtime.ChatRuntimeListener; import tech.easyflow.core.runtime.ChatRuntimeListener;
import tech.easyflow.core.runtime.ChatRuntimeMessage; import tech.easyflow.core.runtime.ChatRuntimeMessage;
@@ -81,7 +82,8 @@ public class ChatlogRuntimeListener implements ChatRuntimeListener {
Collections.reverse(records); Collections.reverse(records);
List<ChatRuntimeMessage> messages = new ArrayList<>(records.size()); List<ChatRuntimeMessage> messages = new ArrayList<>(records.size());
for (ChatMessageRecord record : records) { for (ChatMessageRecord record : records) {
if (record.getContentText() == null || record.getContentText().isBlank()) { if ((record.getContentText() == null || record.getContentText().isBlank())
&& !ChatRuntimeHistoryPayloadHelper.hasStructuredHistory(record.getContentPayload())) {
continue; continue;
} }
ChatRuntimeMessage message = new ChatRuntimeMessage(); ChatRuntimeMessage message = new ChatRuntimeMessage();