fix(agent): repair head-side orphan tool responses on the pagination cut

This commit is contained in:
matevip 2026-05-13 09:03:10 +08:00
parent af56763156
commit bff57924d6
2 changed files with 281 additions and 0 deletions

View File

@ -15,6 +15,7 @@ import vip.mate.llm.routing.MediaCaptionService;
import vip.mate.llm.routing.MultimodalRouter;
import vip.mate.llm.routing.model.MultimodalRoutingDecision;
import vip.mate.llm.service.ModelCapabilityService;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.MessageContentPart;
import vip.mate.workspace.conversation.model.MessageEntity;
@ -24,6 +25,7 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;
@ -328,9 +330,90 @@ public abstract class BaseAgent {
while (!messages.isEmpty() && messages.get(messages.size() - 1) instanceof UserMessage) {
messages.remove(messages.size() - 1);
}
// Head guard orphan tool-response strip.
//
// Independent of the compaction pair-safe boundary in
// ConversationWindowManager: that one protects the *compaction* cut,
// this one protects the *pagination* cut. listRecentMessages returns
// the last N rows verbatim, and the first row of that page can be a
// ToolResponseMessage whose owning AssistantMessage sat one row
// earlier i.e. outside the page. Sending such a sequence to any
// OpenAI-compatible provider returns 400 because every tool response
// must be preceded by an assistant message issuing that tool_call_id.
//
// The boundary prepend earlier inserts a SystemMessage at the head;
// the orphan, if present, sits at index 1 in that case. Skip leading
// SystemMessages and drop any ToolResponseMessage whose response ids
// are all unmatched by the AssistantMessages still in scope.
//
// Dropping is correct rather than expanding backward to fetch the
// missing assistant: if the AssistantMessage is outside the window,
// its content is already lost to the model anyway, and the boundary
// summary (if any) covers it. Keeping the orphan would just trade a
// dropped row for a 400.
stripHeadOrphanToolResponses(messages, agentName);
return messages;
}
/**
* Drop leading {@link ToolResponseMessage}s whose response ids cannot be
* matched against any {@link AssistantMessage} tool_call id in the list.
* Leaves any leading {@link SystemMessage}s (boundary rows, system prompts)
* intact and continues scanning past them.
*
* <p>Package-private + static so unit tests can drive it without standing
* up a full BaseAgent subclass.
*/
static int stripHeadOrphanToolResponses(List<Message> messages, String agentName) {
if (messages.isEmpty()) return 0;
// Collect every tool_call id issued by any AssistantMessage in scope.
Set<String> issuedIds = new HashSet<>();
for (Message m : messages) {
if (m instanceof AssistantMessage am && am.getToolCalls() != null) {
for (AssistantMessage.ToolCall tc : am.getToolCalls()) {
if (tc.id() != null && !tc.id().isEmpty()) {
issuedIds.add(tc.id());
}
}
}
}
int dropped = 0;
int i = 0;
while (i < messages.size()) {
Message m = messages.get(i);
if (m instanceof SystemMessage) {
// Boundary rows / system prompts pass through; advance and
// keep looking for orphan tool responses that sit behind them.
i++;
continue;
}
if (m instanceof ToolResponseMessage trm) {
boolean allOrphan = trm.getResponses().stream()
.map(ToolResponseMessage.ToolResponse::id)
.filter(id -> id != null && !id.isEmpty())
.allMatch(id -> !issuedIds.contains(id));
if (allOrphan) {
messages.remove(i);
dropped++;
continue; // re-examine the new messages[i]
}
}
// First non-orphan, non-system message stop. Deeper orphans are
// upstream bugs (every other call path keeps pairs together);
// dropping aggressively from here on would mask those instead of
// surfacing them in logs.
break;
}
if (dropped > 0) {
log.info("[{}] Stripped {} leading orphan ToolResponseMessage(s) — owning AssistantMessage was outside the recent window",
agentName, dropped);
}
return dropped;
}
/**
* History sanitization entry point. Encapsulates *all* steps applied to a
* persisted message before it reaches an LLM prompt. Returns {@code null}

View File

@ -0,0 +1,198 @@
package vip.mate.agent;
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 java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Head-side pair repair on the recent-message pagination cut.
*
* <p>{@code listRecentMessages(conversationId, windowSize)} returns the last N
* rows verbatim. The first row of that page can be a {@link ToolResponseMessage}
* whose owning {@link AssistantMessage} (carrying the matching tool_call_id)
* sat one row earlier i.e. outside the page. Sending such a sequence to any
* OpenAI-compatible provider returns 400 because every tool response must be
* preceded by an assistant message issuing that tool_call_id.
*
* <p>{@link BaseAgent#stripHeadOrphanToolResponses} drops leading
* {@code ToolResponseMessage}s whose response ids are unmatched by every
* AssistantMessage still in scope. {@link SystemMessage}s (boundary rows,
* system prompts) at the head are skipped over, not removed.
*/
class BaseAgentHeadOrphanRepairTest {
@Test
void orphanToolResponseAtHeadIsDropped() {
// Window starts with a TOOL response (orphan: no AssistantMessage in this list issued call-X).
List<Message> messages = new ArrayList<>(List.of(
toolResponse("call-X"),
new UserMessage("next user turn"),
assistantWithToolCalls("call-Y"),
toolResponse("call-Y")
));
int dropped = BaseAgent.stripHeadOrphanToolResponses(messages, "test");
assertEquals(1, dropped, "leading orphan should be dropped");
assertInstanceOf(UserMessage.class, messages.getFirst(),
"head is now the user turn, not the orphan tool response");
}
@Test
void multipleConsecutiveOrphansAtHeadAllDropped() {
// A single AssistantMessage outside the window may have produced
// several tool calls whose responses landed in two separate
// ToolResponseMessages. Both should be removed.
List<Message> messages = new ArrayList<>(List.of(
toolResponse("call-A"),
toolResponse("call-B"),
new UserMessage("here we go"),
assistantWithToolCalls("call-C"),
toolResponse("call-C")
));
int dropped = BaseAgent.stripHeadOrphanToolResponses(messages, "test");
assertEquals(2, dropped);
assertInstanceOf(UserMessage.class, messages.getFirst());
}
@Test
void systemBoundaryAtHeadIsSkippedAndOrphanBehindItIsDropped() {
// After findLatestCompressionBoundary prepends a SystemMessage, the
// orphan tool response now sits at index 1. The repair must skip the
// system row and still drop the orphan.
SystemMessage boundary = new SystemMessage("[compression boundary placeholder]");
List<Message> messages = new ArrayList<>(List.of(
boundary,
toolResponse("call-X"),
new UserMessage("after orphan"),
assistantWithToolCalls("call-Y"),
toolResponse("call-Y")
));
int dropped = BaseAgent.stripHeadOrphanToolResponses(messages, "test");
assertEquals(1, dropped);
assertSame(boundary, messages.getFirst(),
"the system boundary stays in place");
assertInstanceOf(UserMessage.class, messages.get(1),
"the orphan that sat behind the boundary is gone");
}
@Test
void matchedHeadToolResponseIsKept() {
// The window happens to start with both the AssistantMessage and its
// tool response perfectly aligned, nothing to drop.
List<Message> messages = new ArrayList<>(List.of(
assistantWithToolCalls("call-A"),
toolResponse("call-A"),
new UserMessage("next")
));
List<Message> snapshot = new ArrayList<>(messages);
int dropped = BaseAgent.stripHeadOrphanToolResponses(messages, "test");
assertEquals(0, dropped);
assertEquals(snapshot, messages, "no drops, list unchanged");
}
@Test
void partialOrphanIsKeptToSurfaceUpstreamBug() {
// A ToolResponseMessage with two responses one orphan, one matched
// in scope. Real pipelines should never produce this (each
// ToolResponseMessage closes ONE assistant turn) but if it happens,
// dropping the whole message would also lose the matched response.
// Stop at the first non-orphan and let the upstream invariant
// violation surface in logs.
ToolResponseMessage mixed = ToolResponseMessage.builder().responses(List.of(
new ToolResponseMessage.ToolResponse("call-orphan", "tool_x", "x"),
new ToolResponseMessage.ToolResponse("call-known", "tool_y", "y")
)).build();
List<Message> messages = new ArrayList<>(List.of(
mixed,
assistantWithToolCalls("call-known"),
toolResponse("call-known")
));
int dropped = BaseAgent.stripHeadOrphanToolResponses(messages, "test");
assertEquals(0, dropped,
"mixed orphan/matched responses in one message are NOT dropped — "
+ "the partial-match case is an upstream bug we want to see in logs");
assertSame(mixed, messages.getFirst());
}
@Test
void emptyListIsNoOp() {
List<Message> messages = new ArrayList<>();
assertEquals(0, BaseAgent.stripHeadOrphanToolResponses(messages, "test"));
assertTrue(messages.isEmpty());
}
@Test
void purelyUserAssistantHistoryUntouched() {
// No tool responses at all repair is a no-op.
List<Message> messages = new ArrayList<>(List.of(
new UserMessage("hi"),
new AssistantMessage("hello"),
new UserMessage("how are you?"),
new AssistantMessage("good")
));
List<Message> snapshot = new ArrayList<>(messages);
int dropped = BaseAgent.stripHeadOrphanToolResponses(messages, "test");
assertEquals(0, dropped);
assertEquals(snapshot, messages);
}
@Test
void stopsAtFirstNonOrphanNonSystem() {
// Once we hit a non-system, non-orphan message, repair stops we do
// NOT keep walking and look for orphans deeper in the history.
// Deeper orphans imply an upstream bug; this guard is only here to
// protect the pagination cut.
List<Message> messages = new ArrayList<>(List.of(
toolResponse("call-A"), // orphan at head will be dropped
new UserMessage("user"), // stops the scan
toolResponse("call-B"), // orphan but we do NOT touch it
new AssistantMessage("late")
));
int dropped = BaseAgent.stripHeadOrphanToolResponses(messages, "test");
assertEquals(1, dropped);
assertInstanceOf(UserMessage.class, messages.getFirst());
assertFalse(messages.stream().noneMatch(m -> m instanceof ToolResponseMessage),
"the deeper orphan stays in place — it surfaces as an upstream bug elsewhere");
}
// ------------------------------------------------------------------ helpers
private static AssistantMessage assistantWithToolCalls(String... callIds) {
List<AssistantMessage.ToolCall> calls = new ArrayList<>();
for (String id : callIds) {
calls.add(new AssistantMessage.ToolCall(id, "function", "tool_" + id, "{}"));
}
return AssistantMessage.builder().content("").toolCalls(calls).build();
}
private static ToolResponseMessage toolResponse(String callId) {
return ToolResponseMessage.builder().responses(List.of(
new ToolResponseMessage.ToolResponse(callId, "tool_" + callId, "ok")
)).build();
}
}