fix(agent): make head-orphan repair order-sensitive — a later same-id assistant does not redeem an earlier orphan

This commit is contained in:
matevip 2026-05-13 09:03:16 +08:00
parent bff57924d6
commit 1ea90c25cb
2 changed files with 91 additions and 34 deletions

View File

@ -357,10 +357,28 @@ public abstract class BaseAgent {
} }
/** /**
* Drop leading {@link ToolResponseMessage}s whose response ids cannot be * Drop leading {@link ToolResponseMessage}s whose owning
* matched against any {@link AssistantMessage} tool_call id in the list. * {@link AssistantMessage} sits <em>before</em> them in this list. Provider
* Leaves any leading {@link SystemMessage}s (boundary rows, system prompts) * validity is order-sensitive: a tool response must follow the assistant
* intact and continues scanning past them. * that issued the tool_call_id; an unrelated later AssistantMessage that
* happens to carry the same id does not redeem an earlier orphan.
*
* <p>Algorithm: forward scan with a {@code seenIssuedIds} set. Leading
* {@link SystemMessage}s (boundary rows, system prompts) pass through
* untouched but contribute no ids. The first {@link AssistantMessage} or
* {@link UserMessage} we hit stops the repair walk by that point we're
* out of head-orphan territory. Every {@link ToolResponseMessage} we
* encounter before that stop is checked against {@code seenIssuedIds};
* if every response id is unseen, the message is dropped and the scan
* re-examines the new head. A response whose ids are all in the seen
* set (e.g. {@code [system, assistant(X), toolResponse(X), ...]} when
* the assistant fell at index 1 of the slice) is left in place.
*
* <p>Mixed responses (some ids matched, some not) inside a single
* leading {@code ToolResponseMessage} are dropped wholesale rather than
* surgically rewritten the provider would reject partially-broken
* sequences anyway, and the mixed case implies an upstream invariant
* violation that surfaces in logs.
* *
* <p>Package-private + static so unit tests can drive it without standing * <p>Package-private + static so unit tests can drive it without standing
* up a full BaseAgent subclass. * up a full BaseAgent subclass.
@ -368,17 +386,11 @@ public abstract class BaseAgent {
static int stripHeadOrphanToolResponses(List<Message> messages, String agentName) { static int stripHeadOrphanToolResponses(List<Message> messages, String agentName) {
if (messages.isEmpty()) return 0; if (messages.isEmpty()) return 0;
// Collect every tool_call id issued by any AssistantMessage in scope. // Built up as we walk; only assistants we've already passed count
Set<String> issuedIds = new HashSet<>(); // toward "preceding". An assistant that sits behind a head orphan is
for (Message m : messages) { // irrelevant: provider order-validity asks "was this tool_call id
if (m instanceof AssistantMessage am && am.getToolCalls() != null) { // issued BEFORE this response?", not "anywhere in the prompt".
for (AssistantMessage.ToolCall tc : am.getToolCalls()) { Set<String> seenIssuedIds = new HashSet<>();
if (tc.id() != null && !tc.id().isEmpty()) {
issuedIds.add(tc.id());
}
}
}
}
int dropped = 0; int dropped = 0;
int i = 0; int i = 0;
@ -390,25 +402,44 @@ public abstract class BaseAgent {
i++; i++;
continue; continue;
} }
if (m instanceof AssistantMessage am) {
// Reached a preceding assistant head danger is over. The
// tool_call ids it issued are valid for any tool responses
// that follow, but we stop the repair walk here either way.
if (am.getToolCalls() != null) {
for (AssistantMessage.ToolCall tc : am.getToolCalls()) {
if (tc.id() != null && !tc.id().isEmpty()) {
seenIssuedIds.add(tc.id());
}
}
}
break;
}
if (m instanceof ToolResponseMessage trm) { if (m instanceof ToolResponseMessage trm) {
boolean allOrphan = trm.getResponses().stream() // Every response id must have been issued by a preceding
// assistant we already walked through. If any single id is
// missing from seenIssuedIds, the message is invalid in
// place. Empty / null ids don't count for or against.
boolean anyUnmatched = trm.getResponses().stream()
.map(ToolResponseMessage.ToolResponse::id) .map(ToolResponseMessage.ToolResponse::id)
.filter(id -> id != null && !id.isEmpty()) .filter(id -> id != null && !id.isEmpty())
.allMatch(id -> !issuedIds.contains(id)); .anyMatch(id -> !seenIssuedIds.contains(id));
if (allOrphan) { if (anyUnmatched) {
messages.remove(i); messages.remove(i);
dropped++; dropped++;
continue; // re-examine the new messages[i] continue; // re-examine the new messages[i]
} }
// All ids match a preceding assistant keep, and stop the
// repair walk. Anything past here is well-formed by
// construction (provider validates each subsequent pair as
// we go).
break;
} }
// First non-orphan, non-system message stop. Deeper orphans are // UserMessage (or anything else) past the head danger. Stop.
// upstream bugs (every other call path keeps pairs together);
// dropping aggressively from here on would mask those instead of
// surfacing them in logs.
break; break;
} }
if (dropped > 0) { if (dropped > 0) {
log.info("[{}] Stripped {} leading orphan ToolResponseMessage(s) — owning AssistantMessage was outside the recent window", log.info("[{}] Stripped {} leading orphan ToolResponseMessage(s) — no preceding AssistantMessage in scope",
agentName, dropped); agentName, dropped);
} }
return dropped; return dropped;

View File

@ -110,13 +110,38 @@ class BaseAgentHeadOrphanRepairTest {
} }
@Test @Test
void partialOrphanIsKeptToSurfaceUpstreamBug() { void laterAssistantWithSameIdDoesNotRedeemHeadOrphan() {
// A ToolResponseMessage with two responses one orphan, one matched // The classic order-sensitivity trap: a ToolResponseMessage sits at
// in scope. Real pipelines should never produce this (each // the head, and a LATER AssistantMessage happens to carry the same
// ToolResponseMessage closes ONE assistant turn) but if it happens, // tool_call_id. The provider's contract is "tool_call must precede
// dropping the whole message would also lose the matched response. // tool_response", not "tool_call exists somewhere in the prompt".
// Stop at the first non-orphan and let the upstream invariant // The leading response is therefore still orphan and must be dropped.
// violation surface in logs. List<Message> messages = new ArrayList<>(List.of(
new SystemMessage("[boundary]"),
toolResponse("call-X"),
new UserMessage("hi"),
assistantWithToolCalls("call-X"), // same id, but AFTER the response
toolResponse("call-X")
));
int dropped = BaseAgent.stripHeadOrphanToolResponses(messages, "test");
assertEquals(1, dropped,
"the leading response is orphan regardless of whether a later assistant "
+ "happens to carry the same id — provider validity is order-sensitive");
assertInstanceOf(SystemMessage.class, messages.get(0));
assertInstanceOf(UserMessage.class, messages.get(1),
"the orphan that sat between the boundary and the user turn is gone");
}
@Test
void partialOrphanInLeadingResponseIsDropped() {
// A ToolResponseMessage with two responses one whose id has no
// preceding assistant, one whose id has none either (since we
// haven't walked any assistants yet). Provider order-validity
// doesn't allow partial pairs; dropping wholesale is the safer
// call. We lose matched-response content but never emit a request
// the provider would 400.
ToolResponseMessage mixed = ToolResponseMessage.builder().responses(List.of( ToolResponseMessage mixed = ToolResponseMessage.builder().responses(List.of(
new ToolResponseMessage.ToolResponse("call-orphan", "tool_x", "x"), new ToolResponseMessage.ToolResponse("call-orphan", "tool_x", "x"),
new ToolResponseMessage.ToolResponse("call-known", "tool_y", "y") new ToolResponseMessage.ToolResponse("call-known", "tool_y", "y")
@ -129,10 +154,11 @@ class BaseAgentHeadOrphanRepairTest {
int dropped = BaseAgent.stripHeadOrphanToolResponses(messages, "test"); int dropped = BaseAgent.stripHeadOrphanToolResponses(messages, "test");
assertEquals(0, dropped, assertEquals(1, dropped,
"mixed orphan/matched responses in one message are NOT dropped — " "no preceding assistant has been walked yet, so even a partially-matched "
+ "the partial-match case is an upstream bug we want to see in logs"); + "leading response is dropped wholesale");
assertSame(mixed, messages.getFirst()); assertInstanceOf(AssistantMessage.class, messages.getFirst(),
"the mixed head is gone; the assistant that would have owned call-known is now first");
} }
@Test @Test