fix(agent): preserve tool use after compaction

This commit is contained in:
matevip 2026-08-10 05:39:08 -04:00
parent 5e96dade9c
commit ce7f0847fb
4 changed files with 73 additions and 6 deletions

View File

@ -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

View File

@ -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;
}

View File

@ -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<Message> 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() {

View File

@ -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("调用工具"));
}
}