test(agent): cover patchReasoningContent consumer

This commit is contained in:
matevip 2026-04-24 18:16:12 +08:00
parent 45bae0c8ab
commit 84370566de
5 changed files with 638 additions and 89 deletions

View File

@ -408,7 +408,14 @@ public class AgentGraphBuilder {
primaryModelConfig != null ? primaryModelConfig.getProvider() : null,
providerPool);
ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, toolGuardService, approvalService, streamTracker, toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry);
ReasoningNode reasoningNode = new ReasoningNode(chatModel, toolSet, reasoningEffort, streamingHelper, conversationWindowManager, streamTracker, 0, wikiContextService);
// PR-1.2 (RFC-049 L1-B): propagate the bound model's capability so ReasoningNode
// can gate the ThinkingLevelHolder override explicitly, rather than inferring
// capability from reasoningEffort == null.
boolean supportsReasoningEffort = primaryModelConfig != null
&& ModelFamily.detect(primaryModelConfig.getModelName()).supportsReasoningEffort();
ReasoningNode reasoningNode = new ReasoningNode(chatModel, toolSet, reasoningEffort,
supportsReasoningEffort,
streamingHelper, conversationWindowManager, streamTracker, 0, wikiContextService);
ActionNode actionNode = new ActionNode(executor, streamTracker);
ObservationProcessor observationProcessor = new ObservationProcessor(graphObservationProperties);
ObservationNode observationNode = new ObservationNode(observationProcessor, streamTracker);
@ -1037,7 +1044,8 @@ public class AgentGraphBuilder {
public org.springframework.http.ResponseEntity<OpenAiApi.ChatCompletion> chatCompletionEntity(
OpenAiApi.ChatCompletionRequest chatRequest,
MultiValueMap<String, String> additionalHttpHeader) {
chatRequest = patchReasoningContent(chatRequest);
chatRequest = sanitizeReasoningEffortForProvider(chatRequest, provider);
chatRequest = patchReasoningContent(chatRequest, provider);
chatRequest = stripReasoningEffortIfIncompatible(chatRequest);
chatRequest = patchVideoMediaContent(chatRequest);
if (kimiSearchEnabled) {
@ -1056,7 +1064,8 @@ public class AgentGraphBuilder {
public Flux<OpenAiApi.ChatCompletionChunk> chatCompletionStream(
OpenAiApi.ChatCompletionRequest chatRequest,
MultiValueMap<String, String> additionalHttpHeader) {
chatRequest = patchReasoningContent(chatRequest);
chatRequest = sanitizeReasoningEffortForProvider(chatRequest, provider);
chatRequest = patchReasoningContent(chatRequest, provider);
chatRequest = stripReasoningEffortIfIncompatible(chatRequest);
chatRequest = patchVideoMediaContent(chatRequest);
if (kimiSearchEnabled) {
@ -1108,13 +1117,28 @@ public class AgentGraphBuilder {
}
private String resolveReasoningEffort(String modelName, Map<String, Object> kwargs, ModelFamily family) {
// generateKwargs 显式覆盖始终优先
// PR-1.1 (RFC-049 L1-A): Only families that actually accept reasoning_effort may receive
// it. Previously only the default-inject branch checked capability; the generateKwargs
// override branch did not, so a provider-level `reasoningEffort: "high"` would leak to
// deepseek-chat / kimi-k2 / deepseek-reasoner etc., triggering the incident documented
// in RFC-049 (DeepSeek "reasoning_content missing" 400).
if (!family.supportsReasoningEffort()) {
Object overridden = findOptionValue(kwargs, "reasoningEffort");
if (overridden != null) {
log.warn("Dropping reasoningEffort='{}' from generateKwargs — model '{}' (family={}) "
+ "does not accept reasoning_effort. For DeepSeek thinking use "
+ "extra_body.thinking; for Kimi thinking the model activates it natively.",
overridden, modelName, family);
}
return null;
}
// generateKwargs 显式覆盖始终优先仅在白名单族内
Object value = findOptionValue(kwargs, "reasoningEffort");
if (value instanceof String text && StringUtils.hasText(text)) {
return text.trim();
}
// 仅支持 reasoning_effort 的模型族才自动注入默认值
if (family.isThinking() && family.supportsReasoningEffort()) {
if (family.isThinking()) {
return "medium";
}
return null;
@ -1376,62 +1400,135 @@ public class AgentGraphBuilder {
}
/**
* 修补 assistant 消息缺失的 reasoningContent 字段
* <p>
* Spring AI 1.1.3 在将 AssistantMessage 转回 ChatCompletionMessage 时不会设置 reasoningContent
* 导致某些启用 thinking 模式的 API Kimi K2.5在多轮对话中报错
* "thinking is enabled but reasoning_content is missing in assistant tool call message"
* <p>
* 触发条件放宽
* <ul>
* <li>条件 A请求明确设置了 reasoningEffort</li>
* <li>条件 B消息历史中已有 assistant 消息携带 reasoningContent说明模型天然启用了 thinking</li>
* </ul>
* 修复策略为缺失 reasoningContent assistant tool_call 消息注入空字符串 "" 以满足 API 校验
* 使用 record canonical constructor 重建 ChatCompletionRequest避免反射修改不可变字段
* Consume the {@link AssistantThinkingRelay} entry and rebuild the outbound
* {@link OpenAiApi.ChatCompletionRequest} so that assistant tool-call / thinking
* messages carry the correct {@code reasoning_content}.
*
* <p>PR-2 (RFC-049 §2.3.2): This is the consumer side of the relay.
* {@code NodeStreamingChatHelper.doStreamCall} stashes per-assistant thinking
* keyed on a token embedded in {@code request.user()}. Here we:
* <ol>
* <li>{@link AssistantThinkingRelay#take(String)} the entry and restore
* {@code request.user()} to {@code entry.originalUser()} (internal token
* never reaches the provider).</li>
* <li>Compute {@code lastUserIdx} (the boundary of the current user turn),
* symmetric to {@code stripThinkingFromPrompt}. Assistant messages at
* {@code i <= lastUserIdx} are prior-turn history: their
* {@code reasoning_content} must stay null. Only {@code i > lastUserIdx}
* messages are eligible for patching.</li>
* <li>Select a {@link FallbackPolicy} by {@code providerId}. When relay has
* a real value, we use it; when empty, the policy decides whether to
* inject {@code " "} (legacy tolerance: KIMI/OPENAI/DEFAULT) or leave
* {@code null} to surface an explicit provider error (DEEPSEEK).</li>
* </ol>
*
* <p>The relay iterator advances for every assistant message (including
* prior-turn ones) to stay positionally aligned with the producer's extraction
* in {@code NodeStreamingChatHelper.extractAssistantThinkings}.
*/
private static OpenAiApi.ChatCompletionRequest patchReasoningContent(OpenAiApi.ChatCompletionRequest request) {
static OpenAiApi.ChatCompletionRequest patchReasoningContent(
OpenAiApi.ChatCompletionRequest request, ModelProviderEntity provider) {
if (request.messages() == null || request.messages().isEmpty()) {
return request;
}
// 判断是否处于 thinking 模式
boolean thinkingMode = request.reasoningEffort() != null;
// 1. Consume relay (if any) and compute the sanitized user field.
AssistantThinkingRelay.RelayEntry entry = AssistantThinkingRelay.take(request.user());
String sanitizedUser = (entry != null)
? entry.originalUser()
: (AssistantThinkingRelay.isToken(request.user()) ? null : request.user());
// 2. Detect thinking mode unchanged from the prior design except that relay
// presence is also a trigger.
boolean thinkingMode = request.reasoningEffort() != null
|| requiresReasoningContentPatch(request.model())
|| request.messages().stream().anyMatch(m ->
m.role() == OpenAiApi.ChatCompletionMessage.Role.ASSISTANT
&& m.reasoningContent() != null)
|| entry != null;
if (!thinkingMode) {
thinkingMode = requiresReasoningContentPatch(request.model());
}
if (!thinkingMode) {
thinkingMode = request.messages().stream().anyMatch(msg ->
msg.role() == OpenAiApi.ChatCompletionMessage.Role.ASSISTANT
&& msg.reasoningContent() != null);
}
if (!thinkingMode) {
return request;
// Nothing to patch but we may still need to strip a leaked relay token from user.
return request.user() != null && !request.user().equals(sanitizedUser)
? rebuildWithUser(request, sanitizedUser)
: request;
}
// 检查是否有需要补丁的消息
boolean needsPatch = request.messages().stream().anyMatch(msg ->
msg.role() == OpenAiApi.ChatCompletionMessage.Role.ASSISTANT
&& msg.toolCalls() != null && !msg.toolCalls().isEmpty()
&& msg.reasoningContent() == null);
if (!needsPatch) {
return request;
}
// 重建消息列表为缺失 reasoningContent assistant tool call 消息注入 ""
List<OpenAiApi.ChatCompletionMessage> patched = request.messages().stream().map(msg -> {
if (msg.role() == OpenAiApi.ChatCompletionMessage.Role.ASSISTANT
&& msg.toolCalls() != null && !msg.toolCalls().isEmpty()
&& msg.reasoningContent() == null) {
return new OpenAiApi.ChatCompletionMessage(
msg.rawContent(), msg.role(), msg.name(), msg.toolCallId(),
msg.toolCalls(), msg.refusal(), msg.audioOutput(),
msg.annotations(), " ");
// 3. Find lastUserIdx so we can skip cross-turn assistants.
int lastUserIdx = -1;
for (int i = request.messages().size() - 1; i >= 0; i--) {
if (request.messages().get(i).role() == OpenAiApi.ChatCompletionMessage.Role.USER) {
lastUserIdx = i;
break;
}
return msg;
}).toList();
}
// record canonical constructor 重建 request不用反射
FallbackPolicy policy = FallbackPolicy.forProvider(provider);
java.util.Iterator<String> it = (entry != null)
? entry.thinkings().iterator()
: java.util.Collections.emptyIterator();
// 4. Walk messages, patching only in-turn assistants; always advance iterator
// for all assistants so producer/consumer positions stay aligned.
boolean anyPatched = false;
List<OpenAiApi.ChatCompletionMessage> patched = new ArrayList<>(request.messages().size());
for (int i = 0; i < request.messages().size(); i++) {
OpenAiApi.ChatCompletionMessage msg = request.messages().get(i);
if (msg.role() != OpenAiApi.ChatCompletionMessage.Role.ASSISTANT) {
patched.add(msg);
continue;
}
String next = it.hasNext() ? it.next() : null;
// Already has a real value: leave alone
if (msg.reasoningContent() != null && !msg.reasoningContent().isBlank()) {
patched.add(msg);
continue;
}
// Cross-turn assistant: never patch (symmetric with stripThinkingFromPrompt)
if (i <= lastUserIdx) {
patched.add(msg);
continue;
}
boolean hasToolCalls = msg.toolCalls() != null && !msg.toolCalls().isEmpty();
if (!hasToolCalls && !policy.patchNonToolCall) {
patched.add(msg);
continue;
}
String injected;
if (next != null && !next.isEmpty()) {
injected = next;
} else {
injected = policy.emptyFallback;
if (injected == null && policy.warnOnMissingReal) {
log.warn("[patchReasoningContent] provider={} requires real reasoning_content "
+ "but relay has no value for assistant message at index {}; "
+ "leaving null so provider returns explicit error.",
providerIdOrUnknown(provider), i);
}
}
if (injected == null && msg.reasoningContent() == null) {
// No change keep original
patched.add(msg);
continue;
}
patched.add(new OpenAiApi.ChatCompletionMessage(
msg.rawContent(), msg.role(), msg.name(), msg.toolCallId(),
msg.toolCalls(), msg.refusal(), msg.audioOutput(),
msg.annotations(), injected));
anyPatched = true;
}
boolean userChanged = request.user() != null && !request.user().equals(sanitizedUser)
|| (request.user() == null && sanitizedUser != null);
if (!anyPatched && !userChanged) {
return request;
}
// 5. Rebuild with patched messages + sanitized user.
return new OpenAiApi.ChatCompletionRequest(
patched,
request.model(),
@ -1458,7 +1555,7 @@ public class AgentGraphBuilder {
request.tools(),
request.toolChoice(),
request.parallelToolCalls(),
request.user(),
sanitizedUser,
request.reasoningEffort(),
request.webSearchOptions(),
request.verbosity(),
@ -1468,6 +1565,211 @@ public class AgentGraphBuilder {
);
}
/**
* PR-2 (RFC-049 §2.3.2): Provider-keyed policy for how {@code patchReasoningContent}
* should behave when the relay has no real thinking for an in-turn assistant message.
*
* <ul>
* <li>{@code emptyFallback}: value to inject when relay has no real value
* {@code null} means leave {@code reasoning_content} null (DeepSeek);
* {@code " "} preserves Spring AI 1.1.4 legacy tolerance (Kimi/OpenAI/unknown).</li>
* <li>{@code warnOnMissingReal}: emit WARN when {@code emptyFallback==null} fires
* only DeepSeek wants this, because there a missing value means we have a bug.</li>
* <li>{@code patchNonToolCall}: whether to patch assistant messages without tool_calls
* DeepSeek's contract applies to all in-turn assistant messages, others only
* to tool_call messages (historical behavior).</li>
* </ul>
*
* {@code DEFAULT} intentionally keeps the legacy {@code " "} tolerance rather than
* going no-op: an unrecognized provider (self-hosted DeepSeek-like backend, custom
* OpenAI-compatible gateway) might still require the patch noop would regress
* those into new 400s.
*/
private enum FallbackPolicy {
DEEPSEEK(null, true, true),
KIMI (" ", false, false),
OPENAI (" ", false, false),
DEFAULT (" ", false, false);
final String emptyFallback;
final boolean warnOnMissingReal;
final boolean patchNonToolCall;
FallbackPolicy(String emptyFallback, boolean warnOnMissingReal, boolean patchNonToolCall) {
this.emptyFallback = emptyFallback;
this.warnOnMissingReal = warnOnMissingReal;
this.patchNonToolCall = patchNonToolCall;
}
static FallbackPolicy forProvider(ModelProviderEntity provider) {
if (provider == null || provider.getProviderId() == null) {
return DEFAULT;
}
String id = provider.getProviderId().toLowerCase();
return switch (id) {
case "deepseek" -> DEEPSEEK;
case "kimi-cn", "kimi-intl", "kimi-code" -> KIMI;
case "openai", "azure-openai" -> OPENAI;
default -> DEFAULT;
};
}
}
/**
* Rebuild a {@link OpenAiApi.ChatCompletionRequest} with only the {@code user} field
* replaced. Used when {@code patchReasoningContent} has no assistant-message changes
* but must strip a relay token from the outbound {@code user} field.
*/
private static OpenAiApi.ChatCompletionRequest rebuildWithUser(
OpenAiApi.ChatCompletionRequest request, String newUser) {
return new OpenAiApi.ChatCompletionRequest(
request.messages(),
request.model(),
request.store(),
request.metadata(),
request.frequencyPenalty(),
request.logitBias(),
request.logprobs(),
request.topLogprobs(),
request.maxTokens(),
request.maxCompletionTokens(),
request.n(),
request.outputModalities(),
request.audioParameters(),
request.presencePenalty(),
request.responseFormat(),
request.seed(),
request.serviceTier(),
request.stop(),
request.stream(),
request.streamOptions(),
request.temperature(),
request.topP(),
request.tools(),
request.toolChoice(),
request.parallelToolCalls(),
newUser,
request.reasoningEffort(),
request.webSearchOptions(),
request.verbosity(),
request.promptCacheKey(),
request.safetyIdentifier(),
request.extraBody()
);
}
/**
* PR-1.3 (RFC-049 L1-C): Provider-first sanitization of {@code reasoning_effort}.
*
* <p>Authoritative judgement uses the target {@code provider.getProviderId()} as a
* whitelist (default-deny). Only OpenAI official providers are allowed to carry
* {@code reasoning_effort}; everything else known non-supporters (DeepSeek / Kimi /
* DashScope / Ollama / ) and any unrecognized providerId (self-hosted gateways,
* OpenRouter / Together / aggregators) is stripped unconditionally.
*
* <p>The reason we intentionally distrust {@code request.model()} here: MateClaw's
* failover chain (RFC-009) can reuse the same {@code Prompt} and {@code OpenAiChatOptions}
* across providers, and {@code OpenAiChatOptions.model} was set to the primary's model
* name (e.g. {@code gpt-5}). If the sanitizer only checked {@code ModelFamily.detect(
* request.model())}, a failover hop from GPT-5 DeepSeek would see model name
* "gpt-5" OPENAI_REASONING {@code supportsReasoningEffort == true} and quietly
* forward the primary's {@code reasoning_effort} to DeepSeek, re-triggering the
* incident this RFC exists to fix.
*
* <p>Only when the provider is on the whitelist do we fall through to the
* {@link ModelFamily} check (e.g. within OpenAI, {@code gpt-4} still wouldn't support
* reasoning_effort). Outside the whitelist, no runtime check on model is trusted.
*
* <p>Adding a new provider to the whitelist must be an explicit PR with a sanitizer
* test do not add a catch-all default-allow branch.
*/
static OpenAiApi.ChatCompletionRequest sanitizeReasoningEffortForProvider(
OpenAiApi.ChatCompletionRequest request, ModelProviderEntity provider) {
if (request == null || request.reasoningEffort() == null) {
return request;
}
if (!isReasoningEffortWhitelistedProvider(provider)) {
log.warn("[reasoning_effort sanitizer] provider={} is not on the reasoning_effort "
+ "whitelist (only openai/azure-openai are); stripping value='{}' "
+ "(request.model()='{}' may be leaked from failover primary).",
providerIdOrUnknown(provider), request.reasoningEffort(), request.model());
return rebuildWithReasoningEffort(request, null);
}
ModelFamily targetFamily = ModelFamily.detect(request.model());
if (!targetFamily.supportsReasoningEffort()) {
log.warn("[reasoning_effort sanitizer] provider={} model={} family={} does not "
+ "support reasoning_effort; stripping value='{}'.",
provider.getProviderId(), request.model(), targetFamily, request.reasoningEffort());
return rebuildWithReasoningEffort(request, null);
}
return request;
}
/**
* Whitelist of providers known to accept {@code reasoning_effort} on
* {@code /v1/chat/completions} (or {@code /v1/responses}). Anything else is denied.
* Adding a provider here must come with a corresponding sanitizer test case.
*/
static boolean isReasoningEffortWhitelistedProvider(ModelProviderEntity provider) {
if (provider == null || provider.getProviderId() == null) {
return false;
}
String id = provider.getProviderId().toLowerCase();
return switch (id) {
case "openai", "azure-openai" -> true;
default -> false;
};
}
private static String providerIdOrUnknown(ModelProviderEntity p) {
return (p == null || p.getProviderId() == null) ? "<unknown>" : p.getProviderId();
}
/**
* Rebuild a {@link OpenAiApi.ChatCompletionRequest} with a new {@code reasoningEffort}
* value (typically {@code null} to strip). Mirrors the record canonical-constructor
* pattern used by {@link #stripReasoningEffortIfIncompatible}.
*/
private static OpenAiApi.ChatCompletionRequest rebuildWithReasoningEffort(
OpenAiApi.ChatCompletionRequest request, String newReasoningEffort) {
return new OpenAiApi.ChatCompletionRequest(
request.messages(),
request.model(),
request.store(),
request.metadata(),
request.frequencyPenalty(),
request.logitBias(),
request.logprobs(),
request.topLogprobs(),
request.maxTokens(),
request.maxCompletionTokens(),
request.n(),
request.outputModalities(),
request.audioParameters(),
request.presencePenalty(),
request.responseFormat(),
request.seed(),
request.serviceTier(),
request.stop(),
request.stream(),
request.streamOptions(),
request.temperature(),
request.topP(),
request.tools(),
request.toolChoice(),
request.parallelToolCalls(),
request.user(),
newReasoningEffort,
request.webSearchOptions(),
request.verbosity(),
request.promptCacheKey(),
request.safetyIdentifier(),
request.extraBody()
);
}
/**
* GPT-5 兼容性 /v1/chat/completions 路径下tools reasoning_effort 不可同时存在
* <p>

View File

@ -0,0 +1,101 @@
package vip.mate.agent;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Relays per-request assistant {@code reasoning_content} from the producer
* ({@code NodeStreamingChatHelper}, which sees {@code AssistantMessage.metadata})
* to the consumer ({@code AgentGraphBuilder.patchReasoningContent}, which rebuilds
* the outbound {@code ChatCompletionRequest}).
*
* <p>Why not {@link ThreadLocal}: {@code OpenAiChatModel.stream()} hops to
* {@code boundedElastic} via {@code subscribeOn}, so a {@code ThreadLocal} on the
* caller does not propagate across the producer/consumer boundary. The relay
* token travels inside the request object itself
* ({@code OpenAiApi.ChatCompletionRequest.user}), which survives scheduler hops
* without needing Reactor context propagation config.
*
* <p>The {@link RelayEntry} carries both the per-assistant thinking list and the
* caller's <em>original</em> {@code user} field the producer overwrites
* {@code OpenAiChatOptions.user} with the relay token before handing the
* {@code Prompt} to Spring AI, so by the time the consumer runs,
* {@code request.user()} only contains the token. The consumer restores the
* caller's original value from the entry when rebuilding the outbound request.
* The internal token is never sent to the provider.
*
* <p>Ownership: the producer is responsible for calling {@link #discard(String)}
* in a {@code finally} block as a belt-and-suspenders cleanup. The consumer's
* {@link #take(String)} already removes the entry on the happy path, so
* {@code discard} is a no-op in that case; it becomes the only cleanup when the
* consumer never runs (e.g., a Reactor error before the request is dispatched).
*
* @author MateClaw Team
*/
public final class AssistantThinkingRelay {
/**
* Per-request relay payload.
*
* @param thinkings per-assistant {@code reasoning_content} in message order;
* empty string means "this assistant had no thinking"
* @param originalUser the caller's original {@code OpenAiChatOptions.user} value
* before the producer overwrote it with the relay token;
* may be {@code null}
*/
public record RelayEntry(List<String> thinkings, String originalUser) {
public RelayEntry {
thinkings = List.copyOf(thinkings);
}
}
private static final ConcurrentHashMap<String, RelayEntry> MAP = new ConcurrentHashMap<>();
/** Prefix must be distinctive enough that a caller-provided {@code user} value
* can never collide with a relay token. */
public static final String TOKEN_PREFIX = "__mc_thinking_";
private AssistantThinkingRelay() {}
/**
* Stash per-assistant thinking (in message order) plus the caller's original
* {@code user} field. Returns the token to embed in
* {@code OpenAiChatOptions.user}.
*/
public static String stash(List<String> thinkingsInOrder, String originalUser) {
String token = TOKEN_PREFIX + UUID.randomUUID();
MAP.put(token, new RelayEntry(thinkingsInOrder, originalUser));
return token;
}
/** Consume and remove entry. Returns {@code null} if {@code user} is not a
* relay token or the entry was already taken. */
public static RelayEntry take(String user) {
if (!isToken(user)) return null;
return MAP.remove(user);
}
/** Whether the given {@code user} field value is a relay token produced by
* {@link #stash(List, String)}. */
public static boolean isToken(String user) {
return user != null && user.startsWith(TOKEN_PREFIX);
}
/** Defensive cleanup; idempotent — safe to call even after {@link #take}. */
public static void discard(String token) {
if (token != null) MAP.remove(token);
}
// ---------- test hooks ----------
/** Visible for tests: current map size. Production code must not use. */
static int size() {
return MAP.size();
}
/** Visible for tests: clear all entries. Production code must not use. */
static void clearAll() {
MAP.clear();
}
}

View File

@ -3,10 +3,12 @@ package vip.mate.agent.graph;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import vip.mate.agent.AssistantThinkingRelay;
import vip.mate.channel.web.ChatStreamTracker;
import reactor.core.Disposable;
@ -567,6 +569,67 @@ public class NodeStreamingChatHelper {
private StreamResult doStreamCall(ChatModel chatModel, Prompt prompt,
String conversationId, String phase,
boolean broadcast, int attempt) {
// 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);
// PR-2 L3 (RFC-049 §2.3.2): producer-side relay stash. Extract per-assistant
// thinking from the normalized prompt (cross-turn positions are already "" due
// to strip), stash with the caller's original `user` field, and overwrite
// `options.user` with the relay token. The consumer in
// AgentGraphBuilder.patchReasoningContent restores the original user when
// rebuilding the outbound ChatCompletionRequest; the token never reaches the
// provider. We only activate relay on OpenAiChatOptions paths Anthropic has
// its own thinking mechanism (extended thinking via AnthropicChatOptions.thinking).
String relayToken = null;
String originalUser = null;
org.springframework.ai.openai.OpenAiChatOptions oaiOptsForRelay = null;
if (outbound.getOptions() instanceof org.springframework.ai.openai.OpenAiChatOptions oaiOpts) {
List<String> thinkings = extractAssistantThinkings(outbound);
if (thinkings.stream().anyMatch(s -> !s.isEmpty())) {
originalUser = oaiOpts.getUser();
relayToken = AssistantThinkingRelay.stash(thinkings, originalUser);
oaiOpts.setUser(relayToken);
oaiOptsForRelay = oaiOpts;
}
}
try {
return doStreamCallInner(chatModel, outbound, conversationId, phase, broadcast, attempt);
} finally {
// Idempotent: if consumer already took the entry, discard is a no-op.
if (relayToken != null) {
AssistantThinkingRelay.discard(relayToken);
if (oaiOptsForRelay != null) {
oaiOptsForRelay.setUser(originalUser);
}
}
}
}
/**
* PR-2: Extract per-assistant {@code reasoningContent} from a Prompt's messages in
* order. Non-assistant messages are skipped; assistants with no metadata or no
* reasoningContent yield {@code ""} so the returned list's positional index aligns
* with the assistant-message index as seen by the consumer.
*/
private static List<String> extractAssistantThinkings(Prompt prompt) {
List<String> out = new ArrayList<>();
for (Message m : prompt.getInstructions()) {
if (m instanceof AssistantMessage am) {
Map<String, Object> meta = am.getMetadata();
Object rc = meta != null ? meta.get("reasoningContent") : null;
out.add(rc instanceof String s ? s : "");
}
}
return out;
}
private StreamResult doStreamCallInner(ChatModel chatModel, Prompt prompt,
String conversationId, String phase,
boolean broadcast, int attempt) {
if (attempt > 0) {
long delay = Math.min(BACKOFF_BASE_MS * (1L << (attempt - 1)), BACKOFF_CAP_MS);
// 加入 jitter 防止雷群效应Hermes 风格
@ -852,9 +915,7 @@ public class NodeStreamingChatHelper {
}
}
AssistantMessage assembledMessage = !finalToolCalls.isEmpty()
? AssistantMessage.builder().content(fullContent).toolCalls(finalToolCalls).build()
: new AssistantMessage(fullContent);
AssistantMessage assembledMessage = buildAssistantMessageWithThinking(fullContent, fullThinking, finalToolCalls);
recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
return new StreamResult(fullContent, fullThinking, assembledMessage,
@ -883,15 +944,7 @@ public class NodeStreamingChatHelper {
}
}
AssistantMessage assembledMessage;
if (!finalToolCalls.isEmpty()) {
assembledMessage = AssistantMessage.builder()
.content(fullContent)
.toolCalls(finalToolCalls)
.build();
} else {
assembledMessage = new AssistantMessage(fullContent);
}
AssistantMessage assembledMessage = buildAssistantMessageWithThinking(fullContent, fullThinking, finalToolCalls);
recordCacheMetrics(phase, promptTok, completionTok, cacheReadTok, cacheWriteTok);
return new StreamResult(fullContent, fullThinking, assembledMessage,
@ -899,6 +952,33 @@ public class NodeStreamingChatHelper {
partial, errorMsg, ErrorType.NONE, false, cacheReadTok, cacheWriteTok);
}
/**
* PR-2 L2 (RFC-049): Build an {@link AssistantMessage} that persists the per-turn
* {@code fullThinking} into the message's properties under key {@code "reasoningContent"}.
*
* <p>This is the linchpin of the structural fix: without writing thinking back into
* the AssistantMessage that enters the next ReAct round's state, the outbound
* request's {@code reasoning_content} is lost (Spring AI 1.1.4's
* {@code OpenAiChatModel.lambda$createRequest$20} hardcodes {@code null} on the
* outbound conversion, so the relay in {@code AssistantThinkingRelay} is the only
* way back see RFC-049 §2.3 L3).
*
* <p>Note the Spring AI naming asymmetry: the builder method is
* {@code .properties(Map)} but the reader is {@code getMetadata()} (see
* {@link #stripThinkingFromPrompt} L937).
*/
private static AssistantMessage buildAssistantMessageWithThinking(
String fullContent, String fullThinking, List<AssistantMessage.ToolCall> finalToolCalls) {
AssistantMessage.Builder builder = AssistantMessage.builder().content(fullContent);
if (finalToolCalls != null && !finalToolCalls.isEmpty()) {
builder.toolCalls(finalToolCalls);
}
if (fullThinking != null && !fullThinking.isEmpty()) {
builder.properties(Map.of("reasoningContent", fullThinking));
}
return builder.build();
}
/**
* Record token / cache usage to the optional metrics aggregator.
* Called only from successful assembly paths ({@link #assembleResult}
@ -917,26 +997,47 @@ public class NodeStreamingChatHelper {
/** 构建纯错误 StreamResult无任何内容 */
/**
* Prompt 中剥离旧 AssistantMessage thinking/reasoningContent metadata
* 保留最新一条 AssistantMessage thinking可能是模型需要的签名
* Strip {@code reasoningContent} from AssistantMessages that belong to <em>prior</em>
* user turns, keeping thinking for messages within the <strong>current</strong> user
* turn intact.
*
* <p>PR-2 L4 (RFC-049 §2.4.1): The old semantics "keep only the last AssistantMessage's
* thinking" broke DeepSeek's contract for multi-round tool-calls within a single user
* turn (DeepSeek requires all in-turn assistant thinking to be passed back on subsequent
* rounds). Now the boundary is the most recent {@link UserMessage}: AssistantMessages at
* index {@code <= lastUserIdx} are prior-turn history (their thinking must be stripped
* per DeepSeek's "reset across user turns" rule); AssistantMessages at {@code > lastUserIdx}
* are in-turn (their thinking must be preserved).
*
* <p>PR-2 L4 (RFC-049 §2.4.2): This method is called as a normal pre-egress step from
* {@link #doStreamCall}, not only from the {@code THINKING_BLOCK_ERROR} retry path. The
* retry path still calls it too (idempotent), serving as defensive re-application.
*
* <p>Note: {@code Prompt.getOptions()} is preserved by reference into the returned
* {@code Prompt} (this is existing behavior). Callers rely on that mutations to
* {@code options.user} via {@link AssistantThinkingRelay} must stay visible after
* normalize.
*/
private Prompt stripThinkingFromPrompt(Prompt prompt) {
static Prompt stripThinkingFromPrompt(Prompt prompt) {
List<Message> messages = prompt.getInstructions();
// 找最后一个 AssistantMessage
int lastAssistantIdx = -1;
// Find most recent UserMessage boundary of the current user turn
int lastUserIdx = -1;
for (int i = messages.size() - 1; i >= 0; i--) {
if (messages.get(i) instanceof AssistantMessage) {
lastAssistantIdx = i;
if (messages.get(i) instanceof UserMessage) {
lastUserIdx = i;
break;
}
}
List<Message> cleaned = new ArrayList<>();
int strippedCount = 0;
List<Message> cleaned = new ArrayList<>(messages.size());
for (int i = 0; i < messages.size(); i++) {
Message msg = messages.get(i);
if (msg instanceof AssistantMessage am && i != lastAssistantIdx) {
// Only strip prior-turn assistant thinking (i <= lastUserIdx); in-turn (i > lastUserIdx) stays
if (msg instanceof AssistantMessage am && i <= lastUserIdx) {
Map<String, Object> meta = am.getMetadata();
if (meta != null && meta.containsKey("reasoningContent")) {
// builder 重建 AssistantMessage去掉 reasoningContent
Map<String, Object> cleanMeta = new java.util.HashMap<>(meta);
cleanMeta.remove("reasoningContent");
AssistantMessage.Builder builder = AssistantMessage.builder()
@ -949,13 +1050,17 @@ public class NodeStreamingChatHelper {
builder.media(am.getMedia());
}
cleaned.add(builder.build());
strippedCount++;
continue;
}
}
cleaned.add(msg);
}
log.info("[ThinkingRecovery] Stripped thinking blocks from {} messages, last assistant at index {}",
messages.size(), lastAssistantIdx);
if (strippedCount > 0) {
log.debug("[ThinkingRecovery] Stripped reasoningContent from {} prior-turn assistant messages "
+ "(lastUserIdx={}, total={})",
strippedCount, lastUserIdx, messages.size());
}
return new Prompt(cleaned, prompt.getOptions());
}

View File

@ -58,6 +58,13 @@ public class ReasoningNode implements NodeAction {
private final ChatModel chatModel;
private final List<ToolCallback> toolCallbacks;
private final String reasoningEffort;
/**
* PR-1.2 (RFC-049 L1-B): Whether the bound model's {@code ModelFamily} accepts
* {@code reasoning_effort}. Drives the capability gate in
* {@link #resolveEffectiveReasoningEffort()} so that a front-end {@code ThinkingLevelHolder}
* override is dropped on chat-type models that cannot honor it.
*/
private final boolean supportsReasoningEffort;
private final NodeStreamingChatHelper streamingHelper;
private final ConversationWindowManager conversationWindowManager;
private final ChatStreamTracker streamTracker;
@ -86,9 +93,31 @@ public class ReasoningNode implements NodeAction {
ConversationWindowManager conversationWindowManager,
ChatStreamTracker streamTracker, int maxOutputTokens,
vip.mate.wiki.service.WikiContextService wikiContextService) {
// Backward-compatible delegate. Callers that have not migrated to the explicit
// supportsReasoningEffort parameter inherit the pre-PR-1 behavior: treat the bound
// model as supporting reasoning_effort iff reasoningEffort was resolved to a non-null
// value at construction time. New callers (AgentGraphBuilder) should use the
// 9-arg constructor below.
this(chatModel, toolSet, reasoningEffort, reasoningEffort != null,
streamingHelper, conversationWindowManager, streamTracker,
maxOutputTokens, wikiContextService);
}
/**
* PR-1.2 (RFC-049): Primary constructor with explicit {@code supportsReasoningEffort}
* capability flag avoids inferring capability from {@code reasoningEffort == null},
* which fails for a future "supports but not auto-enabled" scenario.
*/
public ReasoningNode(ChatModel chatModel, AgentToolSet toolSet, String reasoningEffort,
boolean supportsReasoningEffort,
NodeStreamingChatHelper streamingHelper,
ConversationWindowManager conversationWindowManager,
ChatStreamTracker streamTracker, int maxOutputTokens,
vip.mate.wiki.service.WikiContextService wikiContextService) {
this.chatModel = chatModel;
this.toolCallbacks = toolSet.callbacks();
this.reasoningEffort = reasoningEffort;
this.supportsReasoningEffort = supportsReasoningEffort;
this.streamingHelper = streamingHelper;
this.conversationWindowManager = conversationWindowManager;
this.streamTracker = streamTracker;
@ -120,6 +149,7 @@ public class ReasoningNode implements NodeAction {
this.chatModel = chatModel;
this.toolCallbacks = toolCallbacks;
this.reasoningEffort = null;
this.supportsReasoningEffort = false;
this.streamingHelper = null;
this.conversationWindowManager = null;
this.streamTracker = null;
@ -520,6 +550,13 @@ public class ReasoningNode implements NodeAction {
* 解析有效的 reasoningEffort
* 优先级ThinkingLevelHolder请求级 > 构造时的 reasoningEffortAgent/模型默认
* "off" 会清除 reasoningEffort返回 null
*
* <p>PR-1.2 (RFC-049 L1-B): If the bound model's family does not support
* {@code reasoning_effort} (as declared via {@link #supportsReasoningEffort} at
* construction time), the front-end thinking-level override is ignored.
* Chat-type models like {@code deepseek-chat} must not be forced into thinking mode
* just because the user ticked "deep thinking" in the UI this is a product
* contract, not a runtime option.
*/
private String resolveEffectiveReasoningEffort() {
String requestLevel = ThinkingLevelHolder.get();
@ -527,6 +564,11 @@ public class ReasoningNode implements NodeAction {
if ("off".equalsIgnoreCase(requestLevel)) {
return null;
}
if (!this.supportsReasoningEffort) {
log.debug("[ReasoningNode] Ignoring thinkingLevel='{}' — bound model family does not support reasoning_effort",
requestLevel);
return null;
}
// thinkingLevel reasoningEffort 映射
return switch (requestLevel.toLowerCase()) {
case "low" -> "low";

View File

@ -11,7 +11,6 @@ import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.util.StringUtils;
import com.fasterxml.jackson.databind.JsonNode;
@ -119,20 +118,20 @@ public class StepExecutionNode implements NodeAction {
try {
while (toolCallCount < MAX_TOOL_CALLS_PER_STEP) {
ChatOptions options;
// PR-2 (RFC-049 §2.3.4): always use OpenAiChatOptions so the relay
// producer in NodeStreamingChatHelper.doStreamCall can attach the
// user-token. Using ToolCallingChatOptions when reasoningEffort is
// null (e.g. DeepSeek-Reasoner whose thinking is model-inherent,
// or Kimi-K2.5) would bypass the relay and multi-round tool-calls
// would 400 again.
OpenAiChatOptions oaiOpts = OpenAiChatOptions.builder()
.toolCallbacks(toolSet.callbacks())
.build();
if (StringUtils.hasText(reasoningEffort)) {
OpenAiChatOptions oaiOpts = OpenAiChatOptions.builder()
.toolCallbacks(toolSet.callbacks())
.reasoningEffort(reasoningEffort)
.build();
oaiOpts.setInternalToolExecutionEnabled(false);
options = oaiOpts;
} else {
options = ToolCallingChatOptions.builder()
.toolCallbacks(toolSet.callbacks())
.internalToolExecutionEnabled(false)
.build();
oaiOpts.setReasoningEffort(reasoningEffort);
}
oaiOpts.setInternalToolExecutionEnabled(false);
ChatOptions options = oaiOpts;
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCall(
chatModel, new Prompt(messages, options), conversationId,