fix(agent): collapse SystemMessages at egress to fix LM Studio 400 (#218)

Some OpenAI-compatible providers (LM Studio's built-in server, certain
strict-mode vLLM / SGLang deployments) reject 400 "System message must
be at the beginning" when SystemMessages appear after user / assistant
/ tool messages. The reasoning loop currently emits four SystemMessage
segments — main prompt at index 0, skill catalog inserted at index 1,
progress-ledger snapshot and stale-reminder appended at the end of
nonHistoryPrefix after the runtime-context UserMessage. The latter two
violate the strict shape, so conversations on LM Studio 400 on the
first turn (reported in #218).

Add MessageNormalizer: collects every SystemMessage in the outbound
prompt regardless of position, joins their text with a blank-line
separator, and emits a single SystemMessage at index 0. Non-system
messages keep their relative order, so AssistantMessage(tool_calls) ↔
ToolResponseMessage adjacency is preserved verbatim (required by strict
pair validators).

Wire it into doStreamCall as the first pre-egress step so every node
(reasoning, step-execution, summarizing, plan-generation, limit-exceeded)
inherits the fix without per-node changes, and any future node that
emits multiple SystemMessages stays compliant.

The transformation is semantically equivalent on permissive providers
(OpenAI, DashScope, Ollama, DeepSeek, Kimi, Doubao, GLM) — the merged
token sequence matches what they would have seen across N SystemMessages
— and safe on non-OpenAI protocols (Anthropic, Vertex / Gemini), whose
adapters already extract SystemMessages into a top-level system field
and receive an identical payload.

Kill switch: -Dmateclaw.llm.message-normalizer.enabled=false reverts to
the prior behavior for emergency rollback.

Tests: 11 unit tests on MessageNormalizer cover empty / no-system /
canonical / mid-list / tail / blanks / tool-pair preservation / Prompt
option-reference preservation / kill switch. 1 wiring test pins the
call site in doStreamCall. Full vip.mate.agent.** suite (504 tests)
stays green.

Closes #218.
This commit is contained in:
matevip 2026-05-25 17:57:03 +08:00
parent a37074a9a6
commit 07eb625d11
4 changed files with 545 additions and 1 deletions

View File

@ -0,0 +1,165 @@
package vip.mate.agent.graph;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.prompt.Prompt;
import java.util.ArrayList;
import java.util.List;
/**
* Pre-egress message-list normalizer.
*
* <p>Some OpenAI-compatible providers (notably LM Studio's built-in server,
* and certain strict-mode vLLM / SGLang deployments) enforce that exactly
* one {@link SystemMessage} must appear at index 0 of the messages array.
* Multiple consecutive SystemMessages, or any SystemMessage following a
* user / assistant / tool message, returns {@code 400 BAD_REQUEST:
* "System message must be at the beginning."}.
*
* <p>Permissive providers (OpenAI, DashScope, Ollama, DeepSeek, Kimi, Doubao,
* GLM) accept the relaxed shape, so the runtime historically composed
* prompts with multiple SystemMessages sprinkled through the non-history
* prefix (main system prompt + skill catalog + progress-ledger snapshot,
* each as its own SystemMessage). To stay portable across both strict and
* permissive backends, this normalizer collects every SystemMessage found
* anywhere in the input list, concatenates their text with a blank-line
* separator, and emits the result as a single SystemMessage at index 0.
* The relative order of non-system messages (user / assistant /
* tool_response) is preserved verbatim so {@code tool_call_id} pairings
* are unaffected.
*
* <p>Blank / whitespace-only SystemMessages are dropped from the merge. If
* every SystemMessage in the input is blank, the result is the same list
* with all SystemMessages removed (no synthetic empty SystemMessage is
* emitted). If the input contains zero SystemMessages, the input list
* reference is returned unchanged.
*
* <p>The transformation is semantically equivalent on permissive providers
* the merged SystemMessage produces the same token sequence the model
* would have seen across N separate SystemMessages and converts the
* strict-provider 400 into a success. It is also safe for non-OpenAI
* protocols: the Spring AI Anthropic and Vertex / Gemini adapters already
* extract SystemMessages out of the messages list into a top-level
* {@code system} / {@code systemInstruction} request field, so they receive
* an identical outbound payload whether handed one merged SystemMessage
* or several.
*
* <p>A kill switch is exposed via the JVM system property
* {@code mateclaw.llm.message-normalizer.enabled=false}, which makes
* {@link #normalize} a no-op for emergency rollback without code changes.
*/
public final class MessageNormalizer {
/** Separator inserted between merged SystemMessage segments. */
static final String SEPARATOR = "\n\n";
/**
* Kill-switch property name. Set to {@code false} (case-insensitive) on
* the JVM command line to disable normalization without a code change.
*/
public static final String ENABLED_PROPERTY = "mateclaw.llm.message-normalizer.enabled";
private static volatile boolean enabled = !"false".equalsIgnoreCase(
System.getProperty(ENABLED_PROPERTY, "true"));
private MessageNormalizer() {
}
/** Read the current kill-switch state. */
public static boolean isEnabled() {
return enabled;
}
/**
* Override the kill-switch at runtime (primarily for tests). Production
* code should not need to call this set the JVM property at startup
* instead.
*/
public static void setEnabledForTesting(boolean value) {
enabled = value;
}
/**
* Return a copy of {@code prompt} with every SystemMessage merged into a
* single SystemMessage at index 0. Returns the input prompt reference
* unchanged when no normalization is necessary (kill switch off, zero
* SystemMessages, or already a single non-blank SystemMessage at index 0).
*/
public static Prompt normalize(Prompt prompt) {
if (prompt == null || !enabled) {
return prompt;
}
List<Message> in = prompt.getInstructions();
List<Message> out = normalize(in);
if (out == in) {
return prompt;
}
return new Prompt(out, prompt.getOptions());
}
/**
* List-level normalization, used by {@link #normalize(Prompt)} and by
* unit tests that want to assert on the raw message shape without
* constructing a {@link Prompt}. Returns the input list reference
* unchanged when no normalization is necessary.
*/
public static List<Message> normalize(List<Message> messages) {
if (!enabled || messages == null || messages.isEmpty()) {
return messages;
}
int systemCount = 0;
int firstSystemIdx = -1;
for (int i = 0; i < messages.size(); i++) {
if (messages.get(i) instanceof SystemMessage) {
if (firstSystemIdx < 0) firstSystemIdx = i;
systemCount++;
}
}
// Fast-path 1: no SystemMessages nothing to do.
if (systemCount == 0) {
return messages;
}
// Fast-path 2: exactly one SystemMessage and it sits at index 0 with
// non-blank text. Already canonical skip the rebuild.
if (systemCount == 1 && firstSystemIdx == 0) {
SystemMessage sm = (SystemMessage) messages.get(0);
String text = sm.getText();
if (text != null && !text.isBlank()) {
return messages;
}
// Single blank SystemMessage at [0] fall through to the rebuild,
// which will drop it.
}
StringBuilder merged = new StringBuilder();
List<Message> rest = new ArrayList<>(messages.size());
for (Message m : messages) {
if (m instanceof SystemMessage sm) {
String text = sm.getText();
if (text == null || text.isBlank()) {
continue;
}
if (merged.length() > 0) {
merged.append(SEPARATOR);
}
merged.append(text);
} else {
rest.add(m);
}
}
if (merged.length() == 0) {
// Every SystemMessage in the input was blank return just the
// non-system tail. No synthetic empty SystemMessage.
return rest;
}
List<Message> out = new ArrayList<>(rest.size() + 1);
out.add(new SystemMessage(merged.toString()));
out.addAll(rest);
return out;
}
}

View File

@ -716,11 +716,25 @@ public class NodeStreamingChatHelper {
private StreamResult doStreamCall(ChatModel chatModel, Prompt prompt,
String conversationId, String phase,
boolean broadcast, int attempt) {
// Collapse every SystemMessage in the prompt into a single SystemMessage
// at index 0. Some OpenAI-compatible providers (LM Studio's built-in
// server, certain strict vLLM / SGLang deployments) reject 400
// "System message must be at the beginning" when SystemMessages appear
// after user / assistant / tool messages the runtime composes the
// non-history prefix from several SystemMessage segments (main prompt,
// skill catalog, progress-ledger snapshot) and some of them land mid-
// list. Permissive providers see an equivalent token sequence either
// way; non-OpenAI protocols (Anthropic, Vertex) extract the merged
// system into their top-level system field exactly as before.
// Preserves the input's options reference so downstream relay logic
// (options.user = relay token) keeps working.
Prompt outbound = MessageNormalizer.normalize(prompt);
// PR-2 L4 (RFC-049 §2.4.2): normalize as a pre-egress step (not only on retry).
// Strip reasoning_content from prior-turn AssistantMessages (i <= lastUserIdx),
// preserving in-turn thinking (i > lastUserIdx) so DeepSeek's contract holds.
// The returned Prompt shares `options` by reference with the input prompt.
Prompt outbound = stripThinkingFromPrompt(prompt);
outbound = stripThinkingFromPrompt(outbound);
// RFC-049 follow-up (2026-04-27): trim trailing AssistantMessage from the
// outbound prompt. Triggered in practice by the summarizingreasoning

View File

@ -0,0 +1,267 @@
package vip.mate.agent.graph;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
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 org.springframework.ai.chat.prompt.Prompt;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Contract tests for {@link MessageNormalizer}.
*
* <p>Pins the invariants that make the normalizer safe to apply unconditionally
* before every LLM call:
* <ul>
* <li>All SystemMessages end up merged into a single SystemMessage at index 0.</li>
* <li>Non-system messages keep their relative order so {@code AssistantMessage(tool_calls)}
* and its matching {@code ToolResponseMessage} are never reordered.</li>
* <li>Blank / whitespace-only SystemMessages are dropped from the merge.</li>
* <li>Already-canonical inputs (zero systems, or one non-blank system at index 0) are
* returned by reference no allocation overhead on the happy path.</li>
* <li>The kill switch (JVM property {@value MessageNormalizer#ENABLED_PROPERTY}) makes
* {@link MessageNormalizer#normalize} a no-op.</li>
* </ul>
*/
class MessageNormalizerTest {
@BeforeEach
void enableNormalizer() {
MessageNormalizer.setEnabledForTesting(true);
}
@AfterEach
void resetNormalizer() {
MessageNormalizer.setEnabledForTesting(true);
}
// ---------- Fast paths ----------
@Test
@DisplayName("null and empty inputs pass through unchanged")
void nullAndEmpty_passThrough() {
assertThat(MessageNormalizer.normalize((Prompt) null)).isNull();
assertThat(MessageNormalizer.normalize((List<Message>) null)).isNull();
List<Message> empty = List.of();
assertThat(MessageNormalizer.normalize(empty)).isSameAs(empty);
}
@Test
@DisplayName("no SystemMessage at all → input list returned by reference")
void noSystem_passThrough() {
List<Message> in = List.of(
new UserMessage("hello"),
AssistantMessage.builder().content("hi").build(),
new UserMessage("follow-up")
);
assertThat(MessageNormalizer.normalize(in)).isSameAs(in);
}
@Test
@DisplayName("single non-blank SystemMessage at index 0 → input returned by reference")
void canonicalShape_passThrough() {
List<Message> in = List.of(
new SystemMessage("you are a helpful assistant"),
new UserMessage("hi")
);
assertThat(MessageNormalizer.normalize(in)).isSameAs(in);
}
// ---------- The bug case: SystemMessage after UserMessage ----------
@Test
@DisplayName("SystemMessage at tail (ReasoningNode ledger-snapshot pattern) → merged to head")
void systemAtTail_movedToHead() {
// Mirrors the exact shape ReasoningNode produces today:
// [system(main), system(skillCatalog), user(runtime), user(wiki),
// system(ledger snapshot), system(stale reminder),
// ...history user/assistant messages...]
List<Message> in = new ArrayList<>(List.of(
new SystemMessage("MAIN_PROMPT"),
new SystemMessage("SKILL_CATALOG"),
new UserMessage("RUNTIME_CTX"),
new UserMessage("WIKI_SNIPPET"),
new SystemMessage("LEDGER_SNAPSHOT"),
new SystemMessage("STALE_REMINDER"),
new UserMessage("user question"),
AssistantMessage.builder().content("answer").build()
));
List<Message> out = MessageNormalizer.normalize(in);
// Exactly one SystemMessage, at index 0, containing all four segments
// joined by the canonical separator and in original encounter order.
assertThat(out.stream().filter(m -> m instanceof SystemMessage)).hasSize(1);
assertThat(out.get(0)).isInstanceOf(SystemMessage.class);
assertThat(out.get(0).getText()).isEqualTo(
"MAIN_PROMPT" + MessageNormalizer.SEPARATOR
+ "SKILL_CATALOG" + MessageNormalizer.SEPARATOR
+ "LEDGER_SNAPSHOT" + MessageNormalizer.SEPARATOR
+ "STALE_REMINDER");
// Non-system messages preserve their original relative order.
assertThat(out.subList(1, out.size()))
.extracting(Message::getText)
.containsExactly("RUNTIME_CTX", "WIKI_SNIPPET", "user question", "answer");
}
// ---------- Blanks ----------
@Test
@DisplayName("blank SystemMessages are skipped during merge")
void blankSystemsDropped() {
List<Message> in = List.of(
new SystemMessage("MAIN"),
new SystemMessage(" "),
new SystemMessage(""),
new UserMessage("hi"),
new SystemMessage("\n\t \n")
);
List<Message> out = MessageNormalizer.normalize(in);
assertThat(out).hasSize(2);
assertThat(out.get(0)).isInstanceOf(SystemMessage.class);
assertThat(out.get(0).getText()).isEqualTo("MAIN");
assertThat(out.get(1)).isInstanceOf(UserMessage.class);
}
@Test
@DisplayName("all SystemMessages blank → SystemMessage dropped entirely")
void allBlankSystems_allDropped() {
List<Message> in = List.of(
new SystemMessage(""),
new SystemMessage(" "),
new UserMessage("hi")
);
List<Message> out = MessageNormalizer.normalize(in);
assertThat(out).hasSize(1);
assertThat(out.get(0)).isInstanceOf(UserMessage.class);
}
@Test
@DisplayName("single blank SystemMessage at index 0 → dropped (not preserved by fast path)")
void singleBlankAtHead_dropped() {
// Fast-path guard: a single SystemMessage at [0] is canonical only when
// it has text. A blank one at [0] should still be dropped so we don't
// send providers an empty system slot.
List<Message> in = List.of(
new SystemMessage(" "),
new UserMessage("hi")
);
List<Message> out = MessageNormalizer.normalize(in);
assertThat(out).hasSize(1);
assertThat(out.get(0)).isInstanceOf(UserMessage.class);
}
// ---------- Tool-call pairing preservation ----------
@Test
@DisplayName("tool_call ↔ tool_response pairing survives normalization")
void toolCallPairingPreserved() {
// Build a realistic ReAct history fragment with a system mid-stream
// (the bug case) and a tool-call/tool-response pair that must stay
// adjacent and in-order.
AssistantMessage assistantWithToolCall = AssistantMessage.builder()
.content("")
.toolCalls(List.of(new AssistantMessage.ToolCall(
"call-abc", "function", "read_file", "{\"path\":\"x\"}")))
.build();
ToolResponseMessage toolResponse = ToolResponseMessage.builder()
.responses(List.of(new ToolResponseMessage.ToolResponse(
"call-abc", "read_file", "file body")))
.build();
List<Message> in = List.of(
new SystemMessage("MAIN"),
new UserMessage("first turn"),
new SystemMessage("LATE_SYSTEM"),
assistantWithToolCall,
toolResponse,
new UserMessage("second turn")
);
List<Message> out = MessageNormalizer.normalize(in);
// System at [0] only; everything else in original order.
assertThat(out.get(0)).isInstanceOf(SystemMessage.class);
assertThat(out.get(0).getText()).isEqualTo("MAIN" + MessageNormalizer.SEPARATOR + "LATE_SYSTEM");
assertThat(out.get(1)).isInstanceOf(UserMessage.class);
assertThat(out.get(2)).isSameAs(assistantWithToolCall);
assertThat(out.get(3)).isSameAs(toolResponse);
assertThat(out.get(4)).isInstanceOf(UserMessage.class);
// The AssistantMessage(tool_calls) ToolResponseMessage adjacency is
// critical: providers that strictly validate pairing (kimi-code, some
// OpenAI-compat layers) reject a 400 if these are reordered or split
// by another message.
int assistantIdx = out.indexOf(assistantWithToolCall);
int responseIdx = out.indexOf(toolResponse);
assertThat(responseIdx).isEqualTo(assistantIdx + 1);
}
// ---------- Prompt overload ----------
@Test
@DisplayName("Prompt overload preserves options by reference")
void promptOverloadPreservesOptions() {
Prompt in = new Prompt(List.of(
new SystemMessage("a"),
new UserMessage("u"),
new SystemMessage("b")
));
Prompt out = MessageNormalizer.normalize(in);
// Different Prompt instance (because messages changed)
assertThat(out).isNotSameAs(in);
// but the options reference is preserved verbatim, which matters
// because doStreamCall mutates options.user via AssistantThinkingRelay
// and we cannot break that chain.
assertThat(out.getOptions()).isSameAs(in.getOptions());
assertThat(out.getInstructions()).hasSize(2);
assertThat(out.getInstructions().get(0).getText())
.isEqualTo("a" + MessageNormalizer.SEPARATOR + "b");
}
@Test
@DisplayName("Prompt overload returns same reference on canonical input")
void promptOverloadFastPath() {
Prompt in = new Prompt(List.of(
new SystemMessage("only one"),
new UserMessage("u")
));
assertThat(MessageNormalizer.normalize(in)).isSameAs(in);
}
// ---------- Kill switch ----------
@Test
@DisplayName("kill switch off → normalize is a no-op (input returned by reference)")
void killSwitchOff_isNoOp() {
MessageNormalizer.setEnabledForTesting(false);
try {
List<Message> in = List.of(
new SystemMessage("MAIN"),
new UserMessage("u"),
new SystemMessage("LATE_SYSTEM") // would normally be merged
);
assertThat(MessageNormalizer.normalize(in)).isSameAs(in);
Prompt p = new Prompt(in);
assertThat(MessageNormalizer.normalize(p)).isSameAs(p);
} finally {
MessageNormalizer.setEnabledForTesting(true);
}
}
}

View File

@ -0,0 +1,98 @@
package vip.mate.agent.graph;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
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.UserMessage;
import org.springframework.ai.chat.metadata.ChatGenerationMetadata;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import reactor.core.publisher.Flux;
import vip.mate.channel.web.ChatStreamTracker;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Pins the wiring: {@link NodeStreamingChatHelper#streamCall} must apply
* {@link MessageNormalizer#normalize} before handing the prompt off to
* {@link ChatModel#stream}.
*
* <p>The pure {@code MessageNormalizer} contract is covered by
* {@code MessageNormalizerTest}. This test guards against a refactor that
* accidentally drops the call site at the top of {@code doStreamCall}
* which is the only thing standing between MateClaw and the LM Studio
* {@code 400 "System message must be at the beginning"} regression.
*/
class NodeStreamingChatHelperNormalizerWiringTest {
private ChatStreamTracker streamTracker;
@BeforeEach
void setUp() {
streamTracker = mock(ChatStreamTracker.class);
when(streamTracker.isStopRequested(any())).thenReturn(false);
}
/** Mock that emits one successful chunk with the given text. */
private static ChatModel successModel(String text) {
ChatModel m = mock(ChatModel.class);
Generation gen = new Generation(new AssistantMessage(text), ChatGenerationMetadata.NULL);
ChatResponse resp = mock(ChatResponse.class);
when(resp.getResults()).thenReturn(List.of(gen));
when(resp.getResult()).thenReturn(gen);
when(resp.getMetadata()).thenReturn(null);
when(m.stream(any(Prompt.class))).thenReturn(Flux.just(resp));
return m;
}
@Test
@DisplayName("streamCall normalizes SystemMessages before invoking ChatModel.stream")
void streamCallNormalizes() {
ChatModel chatModel = successModel("hi");
NodeStreamingChatHelper helper = new NodeStreamingChatHelper(streamTracker);
// Build the exact problematic shape ReasoningNode produces today:
// SystemMessages sprinkled around UserMessages would 400 on LM Studio.
Prompt prompt = new Prompt(List.of(
new SystemMessage("MAIN_PROMPT"),
new SystemMessage("SKILL_CATALOG"),
new UserMessage("RUNTIME_CTX"),
new SystemMessage("LEDGER_SNAPSHOT"),
new UserMessage("user question")
));
helper.streamCall(chatModel, prompt, "conv-1", "reasoning");
// Capture the Prompt that actually reached ChatModel.stream.
ArgumentCaptor<Prompt> captor = ArgumentCaptor.forClass(Prompt.class);
verify(chatModel).stream(captor.capture());
Prompt outbound = captor.getValue();
List<Message> sent = outbound.getInstructions();
// Exactly one SystemMessage, at index 0, containing all three system
// segments in encounter order.
assertThat(sent.stream().filter(m -> m instanceof SystemMessage)).hasSize(1);
assertThat(sent.get(0)).isInstanceOf(SystemMessage.class);
assertThat(sent.get(0).getText()).isEqualTo(
"MAIN_PROMPT" + MessageNormalizer.SEPARATOR
+ "SKILL_CATALOG" + MessageNormalizer.SEPARATOR
+ "LEDGER_SNAPSHOT");
// Non-system relative order preserved.
assertThat(sent.subList(1, sent.size()))
.extracting(Message::getText)
.containsExactly("RUNTIME_CTX", "user question");
}
}