fix(copilot): 对齐图片限制并修复 Doubao 模型与思考回放

This commit is contained in:
ageerle
2026-10-13 03:08:34 +08:00
parent 432fbb6ea4
commit 8ab3638012
7 changed files with 114 additions and 7 deletions

View File

@@ -89,6 +89,8 @@ public class DoubaoStreamingChatModel implements StreamingChatModel {
/** AiMessage.attributes() 中存储 Doubao 思考加密原文的键。 */
public static final String ENCRYPTED_CONTENT_ATTRIBUTE = "doubao_encrypted_content";
/** A completed provider response may omit both fields; preserve that fact, never invent them. */
public static final String REASONING_ABSENT_ATTRIBUTE = "doubao_reasoning_absent";
/** 与 attributes 对应的请求 JSON 字段名。 */
public static final String ENCRYPTED_CONTENT_FIELD = "encrypted_content";
private static final String REASONING_CONTENT_FIELD = "reasoning_content";
@@ -707,6 +709,10 @@ public class DoubaoStreamingChatModel implements StreamingChatModel {
if (encryptedContent.length() > 0) {
attributes.put(ENCRYPTED_CONTENT_ATTRIBUTE, encryptedContent.toString());
}
if (thinkingEnabled && !requests.isEmpty()
&& reasoning.length() == 0 && encryptedContent.length() == 0) {
attributes.put(REASONING_ABSENT_ATTRIBUTE, true);
}
messageBuilder.toolExecutionRequests(requests);
if (!attributes.isEmpty()) {
messageBuilder.attributes(attributes);

View File

@@ -95,7 +95,7 @@ public class CodingHarnessApplicationService {
private HarnessArtifactRepository artifactRepository;
private static final int MAX_IMAGES_PER_RUN = 5;
private static final long MAX_IMAGE_BYTES_PER_RUN = 8L * 1024 * 1024;
private static final long MAX_IMAGE_BYTES_PER_RUN = 50L * 1024 * 1024;
private static final Map<String, byte[]> IMAGE_SIGNATURES = Map.of(
"image/jpeg", new byte[]{(byte) 0xff, (byte) 0xd8, (byte) 0xff},
"image/png", new byte[]{(byte) 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a},
@@ -950,8 +950,8 @@ public class CodingHarnessApplicationService {
throw new IllegalArgumentException("Image content does not match " + mediaType);
}
total += bytes.length;
if (bytes.length == 0 || total > MAX_IMAGE_BYTES_PER_RUN) {
throw new IllegalArgumentException("Image payload exceeds the 8 MiB run limit");
if (bytes.length == 0 || bytes.length > 10L * 1024 * 1024 || total > MAX_IMAGE_BYTES_PER_RUN) {
throw new IllegalArgumentException("单张图片不能超过 10 MB,合计不能超过 50 MB");
}
result.add(new DecodedImage(mediaType, input.detail(), bytes, sha256(bytes)));
}

View File

@@ -4150,6 +4150,7 @@ public class DurableHarnessRunProcessor implements HarnessRunProcessor {
message.metadata().get("thinkingEnabled")))
.anyMatch(message -> !message.toolCalls().isEmpty()
&& !doubaoReplaySatisfied(message)
&& !doubaoProviderOmittedReasoning(run, message)
&& (message.thinking() == null || message.thinking().isBlank()));
return missing
? "Thinking-enabled provider tool calls are missing replayable reasoning content"
@@ -4167,6 +4168,19 @@ public class DurableHarnessRunProcessor implements HarnessRunProcessor {
return encrypted instanceof String text && !text.isBlank();
}
/**
* Distinguish provider omission from losing a field during ledger/context projection.
* Only the Doubao SSE adapter, after a complete response, can attest to this omission.
* Keep the warning and replay the actual response unchanged. Other providers and old
* messages without this provenance still fail closed if their reasoning was lost.
*/
static boolean doubaoProviderOmittedReasoning(HarnessRunState run, HarnessMessage message) {
return run != null && run.modelRoute() != null
&& org.ruoyi.service.coding.harness.modelruntime.HarnessModelPolicy
.isDoubao(run.modelRoute().selectedModel())
&& Boolean.TRUE.equals(message.metadata().get("doubaoReasoningAbsent"));
}
/** Exact suffix that cannot be compacted before a later provider assistant consumes it. */
static List<HarnessMessage> latestUnconsumedToolProtocolTail(
List<HarnessMessage> messages) {

View File

@@ -48,6 +48,12 @@ public final class HarnessAssistantMessageMapper {
put(metadata, "modelName", response.modelName());
Map<String, Object> attributes = message.attributes();
if (attributes != null) {
if (Boolean.TRUE.equals(attributes.get(
org.ruoyi.service.chat.impl.provider.doubao.DoubaoStreamingChatModel
.REASONING_ABSENT_ATTRIBUTE))) {
metadata.put("doubaoReasoningAbsent", true);
metadata.put("protocolWarning", "Provider completed a tool response without reasoning_content or encrypted_content; neither field was synthesized.");
}
Object encrypted = attributes.get(
org.ruoyi.service.chat.impl.provider.doubao.DoubaoStreamingChatModel
.ENCRYPTED_CONTENT_ATTRIBUTE);

View File

@@ -19,6 +19,9 @@ public final class HarnessModelPolicy {
public static final String DOUBAO_SEED_EVOLVING = "doubao-seed-evolving";
public static final String DOUBAO_SEED_EVOLVING_QUALIFIED =
"bytedance/doubao-seed-evolving";
public static final String DOUBAO_SEED_21_PRO = "doubao-seed-2.1-pro-260628";
public static final String DOUBAO_SEED_21_PRO_QUALIFIED =
"bytedance/doubao-seed-2.1-pro-260628";
private static final List<String> AUTOMATIC_CANDIDATES = List.of(FLASH, PRO, VISION);
@@ -45,14 +48,15 @@ public final class HarnessModelPolicy {
}
/**
* 是否为字节 Doubao-Seed-Evolving 模型。精确支持 doubao-seed-evolving 与
* bytedance/doubao-seed-evolving 两个 Model ID;Doubao 会话固定模型,永不
* 回退到 DeepSeek 自动路由。
* 精确识别已适配的 Doubao Model ID,包括固定版本 Seed 2.1 Pro。
* 保持所选模型及思考配置,动作恢复轮不得回退到 DeepSeek。
*/
public static boolean isDoubao(String model) {
String normalized = normalizeModel(model);
return DOUBAO_SEED_EVOLVING.equals(normalized)
|| DOUBAO_SEED_EVOLVING_QUALIFIED.equals(normalized);
|| DOUBAO_SEED_EVOLVING_QUALIFIED.equals(normalized)
|| DOUBAO_SEED_21_PRO.equals(normalized)
|| DOUBAO_SEED_21_PRO_QUALIFIED.equals(normalized);
}
/** 模型是否支持图片输入(Doubao-Seed-Evolving 原生支持多模态图片理解)。 */

View File

@@ -0,0 +1,58 @@
package org.ruoyi.service.coding.harness.loop;
import dev.langchain4j.agent.tool.ToolExecutionRequest;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.model.chat.response.ChatResponse;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.ruoyi.service.chat.impl.provider.doubao.DoubaoStreamingChatModel;
import org.ruoyi.service.coding.harness.model.*;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@Tag("dev")
class DoubaoReasoningProvenanceTest {
private HarnessRunState run(String model) {
HarnessRunState run = mock(HarnessRunState.class);
HarnessModelRoute route = mock(HarnessModelRoute.class);
when(run.modelRoute()).thenReturn(route);
when(route.thinkingEnabled()).thenReturn(true);
when(route.selectedModel()).thenReturn(model);
when(run.sessionId()).thenReturn("session");
when(run.runId()).thenReturn("run");
return run;
}
private HarnessMessage map(HarnessRunState run, Map<String,Object> attributes) {
AiMessage ai = AiMessage.builder().attributes(attributes).toolExecutionRequests(List.of(
ToolExecutionRequest.builder().id("call1").name("read_file").arguments("{}").build())).build();
return new HarnessAssistantMessageMapper().map(ChatResponse.builder().aiMessage(ai)
.finishReason(dev.langchain4j.model.output.FinishReason.TOOL_EXECUTION).build(),
run,"effect",1,true,1);
}
@Test void providerOmissionIsPreservedWithWarningAndWithoutFabricatedReasoning() {
HarnessRunState run = run("bytedance/doubao-seed-2.1-pro-260628");
HarnessMessage message = map(run,Map.of(DoubaoStreamingChatModel.REASONING_ABSENT_ATTRIBUTE,true));
assertNull(message.thinking());
assertEquals(true,message.metadata().get("doubaoReasoningAbsent"));
assertNotNull(message.metadata().get("protocolWarning"));
assertFalse(message.metadata().containsKey("doubaoEncryptedContent"));
assertNull(DurableHarnessRunProcessor.thinkingReplayViolation(run,List.of(message)));
}
@Test void missingProvenanceStillRejectsDroppedReasoning() {
HarnessRunState run = run("bytedance/doubao-seed-2.1-pro-260628");
assertNotNull(DurableHarnessRunProcessor.thinkingReplayViolation(run,List.of(map(run,Map.of()))));
}
@Test void omissionMarkerDoesNotRelaxOtherProviders() {
HarnessRunState run = run("deepseek-v4-flash");
assertNotNull(DurableHarnessRunProcessor.thinkingReplayViolation(run,List.of(map(run,
Map.of(DoubaoStreamingChatModel.REASONING_ABSENT_ATTRIBUTE,true)))));
}
@Test void encryptedPayloadSurvivesExactly() {
HarnessRunState run = run("bytedance/doubao-seed-2.1-pro-260628");
HarnessMessage message = map(run,Map.of(DoubaoStreamingChatModel.ENCRYPTED_CONTENT_ATTRIBUTE,"opaque-unmodified"));
assertEquals("opaque-unmodified",message.metadata().get("doubaoEncryptedContent"));
assertNull(DurableHarnessRunProcessor.thinkingReplayViolation(run,List.of(message)));
}
}

View File

@@ -0,0 +1,19 @@
package org.ruoyi.service.coding.harness.modelruntime;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Tag;
import static org.junit.jupiter.api.Assertions.*;
@Tag("dev")
class HarnessModelPolicyTest {
@Test
void fixedSeed21UsesDoubaoRoutingWithoutFuzzyAliases() {
assertTrue(HarnessModelPolicy.isDoubao("bytedance/doubao-seed-2.1-pro-260628"));
assertTrue(HarnessModelPolicy.isDoubao("doubao-seed-2.1-pro-260628"));
assertFalse(HarnessModelPolicy.isAutomatic("bytedance/doubao-seed-2.1-pro-260628"));
assertTrue(HarnessModelPolicy.supportsThinkingLevel("bytedance/doubao-seed-2.1-pro-260628"));
assertFalse(HarnessModelPolicy.isDoubao("bytedance/doubao-seed-2.1-pro-unknown"));
assertFalse(HarnessModelPolicy.isDoubao("deepseek-v4-flash"));
assertTrue(HarnessModelPolicy.isDoubao("bytedance/doubao-seed-evolving"));
}
}