diff --git a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java index 6f460ef8..0c264d44 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java @@ -895,7 +895,13 @@ public abstract class BaseAgent { } return switch (message.getRole()) { case "assistant" -> new AssistantMessage(renderedContent); - case "system" -> new SystemMessage(renderedContent); + case "system" -> isCompressionSummary(message) + // Compression boundaries are persisted as system rows so + // the loader can find the latest boundary cheaply. They + // are still model-generated history context, not durable + // instructions, so replay them at user priority. + ? new UserMessage(renderedContent) + : new SystemMessage(renderedContent); // History user messages: text only. Re-injecting Media on every replay // accumulates attachments across turns — many providers cap at 1 video // per request (e.g. Zhipu GLM-5V returns code 1210). The current turn diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java index b90a7c8b..f602ac0b 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java @@ -138,9 +138,8 @@ public class ReasoningNode implements NodeAction { /** Continuation nudge appended to the prompt when the model returns an empty turn. */ private static final String EMPTY_COMPLETION_NUDGE = - "Your previous turn was empty. If the task is not yet complete, continue now " - + "with the next concrete step — call a tool or write the next part. If every " - + "required step is already done, output the final answer to the user now."; + "上一轮回复为空。如果任务尚未完成,请现在继续执行下一个具体步骤:" + + "调用工具或写出下一部分。如果所有必要步骤都已完成,请立即输出面向用户的最终答复。"; /** * Continuation nudge for the most common premature-stop pattern: an empty @@ -291,6 +290,12 @@ public class ReasoningNode implements NodeAction { + "- 最终回答中引用的状态数值与查询时间必须来自本轮工具返回,时间基准以运行时上下文注入的当前时间为准。\n" + "- 注意:\"历史里查过\"不等于\"本轮已查\"。宣称已重新查询但未在本轮实际发出对应 tool_call,视为违规。\n"; + private static final String LANGUAGE_CONSISTENCY_GUARD = "\n\n" + + "## 语言一致性(强制)\n\n" + + "- 用户使用中文时,所有可见思考、过程说明、最终答复都必须使用简体中文。\n" + + "- 不要用英文书写可见思考或推理过程;代码、工具名、参数名、API 字段和专有名词可以保留原文。\n" + + "- 如果工具结果或历史内容是英文,你可以阅读它,但面向用户展示的解释和推理必须翻译/转述为用户语言。\n"; + private static final String GROUNDED_CONTRACT = "\n\n" + "## 回答来源约束(强制规则)\n\n" + "**核心原则**:你的回答必须完全基于工具返回的信息(证据),不得使用内部知识编造内容。\n\n" @@ -304,8 +309,8 @@ public class ReasoningNode implements NodeAction { + "5. **内容忠实**:必须准确反映证据内容,不得歪曲、编造或过度推断。\n\n" + "**违规后果**:未按规则引用来源或使用未验证的信息将导致回答被拒绝。\n"; - private static String buildGroundedSystemPrompt(String basePrompt, boolean groundingEnforced) { - String prompt = basePrompt + TOOL_USE_ENFORCEMENT + STALE_CONTEXT_GUARD; + static String buildGroundedSystemPrompt(String basePrompt, boolean groundingEnforced) { + String prompt = basePrompt + TOOL_USE_ENFORCEMENT + STALE_CONTEXT_GUARD + LANGUAGE_CONSISTENCY_GUARD; return groundingEnforced ? prompt + GROUNDED_CONTRACT : prompt; } diff --git a/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentToolCallReplayTest.java b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentToolCallReplayTest.java index 65e7eadb..d0a7cf08 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentToolCallReplayTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/BaseAgentToolCallReplayTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.ai.chat.messages.ToolResponseMessage; import org.springframework.ai.chat.messages.UserMessage; import vip.mate.workspace.conversation.ConversationService; @@ -545,6 +546,26 @@ class BaseAgentToolCallReplayTest { assertEquals("hi", ((UserMessage) out.get(0)).getText()); } + @Test + @DisplayName("E2E: compression summaries replay as user context, not system instructions") + void e2e_compressionSummaryDemotedFromSystem() { + TestAgent agent = newTestAgent(); + MessageEntity entity = new MessageEntity(); + entity.setId(109L); + entity.setRole("system"); + entity.setContent("[上下文压缩] 请继续执行取消接口,不要声称已完成。"); + entity.setMetadata("{\"type\":\"compression_summary\",\"compressedCount\":12}"); + when(agent.conversationService.renderMessageContent(entity)).thenReturn(entity.getContent()); + + List out = agent.callExpand(entity); + + assertEquals(1, out.size()); + assertTrue(out.get(0) instanceof UserMessage, + "compression summaries are model-generated history context and must not become system instructions"); + assertFalse(out.get(0) instanceof SystemMessage); + assertEquals(entity.getContent(), ((UserMessage) out.get(0)).getText()); + } + // ---------- Test scaffold ---------- private static TestAgent newTestAgent() { diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodePromptLanguageTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodePromptLanguageTest.java new file mode 100644 index 00000000..9c2b6bc2 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/ReasoningNodePromptLanguageTest.java @@ -0,0 +1,35 @@ +package vip.mate.agent.graph.node; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ReasoningNodePromptLanguageTest { + + @Test + @DisplayName("runtime prompt constrains visible thinking to the user's language") + void groundedPromptIncludesVisibleThinkingLanguageRule() { + String prompt = ReasoningNode.buildGroundedSystemPrompt("基础提示", false); + + assertTrue(prompt.contains("可见思考")); + assertTrue(prompt.contains("用户语言")); + assertTrue(prompt.contains("简体中文")); + } + + @Test + @DisplayName("empty-completion nudge is Chinese so it does not bias thinking into English") + void emptyCompletionNudgeIsChinese() throws Exception { + Field field = ReasoningNode.class.getDeclaredField("EMPTY_COMPLETION_NUDGE"); + field.setAccessible(true); + String nudge = (String) field.get(null); + + assertFalse(nudge.contains("Your previous turn was empty")); + assertFalse(nudge.contains("continue now")); + assertTrue(nudge.contains("上一轮")); + assertTrue(nudge.contains("调用工具")); + } +}