mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-22 06:49:52 +00:00
fix(sdk-java): normalize lone surrogates before the digest and the insert, and keep protocol messages' field paths (#13803)
Two digest-honesty findings from the real stack and the reviews: - The canonical encoder escapes an unpaired surrogate in the admission digest, while no store column keeps the character; re-encoding the read-back input then never matches the payloadDigest, and the Turn retries forever (payloadDigest does not match promptContent). Input admission now trades unpaired surrogates for U+FFFD before the payload digest, the replay digest and the insert, on the API path and on the child-creation path, so all three describe one representation. - Four DaemonProtocolException messages that named only the context lost their field path in this PR's digest work; they name the field again, pinned by a witness that reads all four texts. Witnesses: the child-creation and user-text admissions normalize before the captured digest and insert, with valid pairs and non-strings left untouched; the helper-rejection tests pass unchanged. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
b8f921f105
commit
52958aade4
4 changed files with 167 additions and 9 deletions
|
|
@ -53,6 +53,7 @@ import java.nio.charset.StandardCharsets;
|
|||
import java.time.Clock;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
|
@ -307,6 +308,7 @@ public class ManagedAgentService {
|
|||
private CommandAdmission insertChildSession(String tenantId,
|
||||
String parentSessionId, String childRunId, String title,
|
||||
List<Map<String, Object>> input, boolean isolated) {
|
||||
List<Map<String, Object>> normalizedInput = normalizeInput(input);
|
||||
SessionRecord parent = store.requireSession(tenantId, parentSessionId);
|
||||
if (parent.workspace() == null || !"ACTIVE".equals(parent.status())) {
|
||||
throw new ApiException(HttpStatus.CONFLICT,
|
||||
|
|
@ -314,7 +316,7 @@ public class ManagedAgentService {
|
|||
"The parent Session cannot admit a child.");
|
||||
}
|
||||
String creationKey = childCreationKey(parentSessionId, childRunId);
|
||||
if (!input.isEmpty() && !harness.isWorkspaceFilesAvailable()) {
|
||||
if (!normalizedInput.isEmpty() && !harness.isWorkspaceFilesAvailable()) {
|
||||
throw new ApiException(HttpStatus.CONFLICT,
|
||||
"workspace_unavailable",
|
||||
"Hosted Workspace execution is not available.");
|
||||
|
|
@ -340,7 +342,7 @@ public class ManagedAgentService {
|
|||
Map<String, Object> semantic = new LinkedHashMap<>();
|
||||
semantic.put("child", childRunId);
|
||||
semantic.put("title", title);
|
||||
semantic.put("input", input);
|
||||
semantic.put("input", normalizedInput);
|
||||
if (childCwd != null) {
|
||||
semantic.put("workspace", childCwd);
|
||||
}
|
||||
|
|
@ -350,11 +352,13 @@ public class ManagedAgentService {
|
|||
admission = childCwd == null
|
||||
? store.insertChildSessionCommand(tenantId,
|
||||
parentSessionId, creationKey, requestDigest, title,
|
||||
input, SubmitHarnessTurn.computePayloadDigest(input),
|
||||
normalizedInput,
|
||||
SubmitHarnessTurn.computePayloadDigest(normalizedInput),
|
||||
lineage)
|
||||
: store.insertChildSessionCommand(tenantId,
|
||||
parentSessionId, creationKey, requestDigest, title,
|
||||
input, SubmitHarnessTurn.computePayloadDigest(input),
|
||||
normalizedInput,
|
||||
SubmitHarnessTurn.computePayloadDigest(normalizedInput),
|
||||
lineage, childCwd);
|
||||
} catch (DuplicateKeyException error) {
|
||||
admission = store.replayChildSessionCommand(tenantId,
|
||||
|
|
@ -1353,11 +1357,72 @@ public class ManagedAgentService {
|
|||
"unsupported_input",
|
||||
"Phase 1 accepts non-empty text input only.");
|
||||
}
|
||||
result.add(Map.of("type", "text", "text", block.text()));
|
||||
result.add(Map.of("type", "text", "text",
|
||||
replaceLoneSurrogates(block.text())));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
/** The input the digest and the column both describe: an unpaired
|
||||
* surrogate, which no store column keeps, trades for U+FFFD at
|
||||
* admission — before the payload digest, the replay digest and the
|
||||
* insert — so all three name one representation. Well-formed
|
||||
* surrogate pairs and every non-string value pass through. */
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<Map<String, Object>> normalizeInput(
|
||||
List<Map<String, Object>> input) {
|
||||
List<Map<String, Object>> result = new ArrayList<>(input.size());
|
||||
for (Map<String, Object> block : input)
|
||||
result.add((Map<String, Object>) normalizeValue(block));
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private static Object normalizeValue(Object value) {
|
||||
if (value instanceof String text) {
|
||||
return replaceLoneSurrogates(text);
|
||||
}
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
Map<String, Object> copy = new LinkedHashMap<>(map.size());
|
||||
for (Map.Entry<?, ?> entry : map.entrySet())
|
||||
copy.put(replaceLoneSurrogates((String) entry.getKey()),
|
||||
normalizeValue(entry.getValue()));
|
||||
return copy;
|
||||
}
|
||||
if (value instanceof List<?> list) {
|
||||
List<Object> copy = new ArrayList<>(list.size());
|
||||
for (Object item : list) copy.add(normalizeValue(item));
|
||||
return Collections.unmodifiableList(copy);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static String replaceLoneSurrogates(String text) {
|
||||
StringBuilder out = null;
|
||||
int written = 0;
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
char current = text.charAt(i);
|
||||
if (Character.isHighSurrogate(current)) {
|
||||
if (i + 1 < text.length()
|
||||
&& Character.isLowSurrogate(text.charAt(i + 1))) {
|
||||
i++; // a well-formed pair passes through unchanged
|
||||
continue;
|
||||
}
|
||||
} else if (!Character.isLowSurrogate(current)) {
|
||||
continue;
|
||||
}
|
||||
if (out == null) {
|
||||
out = new StringBuilder(text.length());
|
||||
}
|
||||
out.append(text, written, i).append('<27>');
|
||||
written = i + 1;
|
||||
}
|
||||
if (out == null) {
|
||||
return text;
|
||||
}
|
||||
out.append(text, written, text.length());
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
private static String metadataTitle(String explicitTitle,
|
||||
Map<String, Object> metadata) {
|
||||
if (metadata == null || metadata.isEmpty()) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import static org.mockito.Mockito.mock;
|
|||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.alibaba.qwen.code.daemon.SubmitHarnessTurn;
|
||||
import com.alibaba.qwen.code.managedagent.api.ApiModels;
|
||||
import com.alibaba.qwen.code.managedagent.harness.HarnessConnector;
|
||||
import com.alibaba.qwen.code.managedagent.store.AgentStateStore;
|
||||
import com.alibaba.qwen.code.managedagent.store.StoreModels;
|
||||
|
|
@ -114,4 +116,65 @@ class ManagedAgentServiceWorkflowChildTest {
|
|||
.isEqualTo(ManagedAgentService.childCreationKey(PARENT, RUN));
|
||||
assertThat(replayed.getValue()).isEqualTo(attempted.getValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loneSurrogatesTradeForReplacementBeforeTheDigestAndTheInsert() {
|
||||
String raw = "x surrogate " + (char) 0xD800 + " here";
|
||||
assertThat(service.createWorkflowChildSession(TENANT, PARENT, RUN,
|
||||
"workflow/audit", 1L, DIGEST, raw, Map.of("note", raw))
|
||||
.sessionId()).isEqualTo("workflow-child");
|
||||
|
||||
ArgumentCaptor<List<Map<String, Object>>> input =
|
||||
ArgumentCaptor.forClass(List.class);
|
||||
ArgumentCaptor<String> digest = ArgumentCaptor.forClass(String.class);
|
||||
verify(store).insertChildSessionCommand(eq(TENANT), eq(PARENT),
|
||||
anyString(), anyString(), anyString(), input.capture(),
|
||||
digest.capture(), any(StoreModels.SessionLineage.class));
|
||||
Map<String, Object> block = input.getValue().get(0);
|
||||
String normalized = raw.replace((char) 0xD800, '<27>');
|
||||
assertThat(block.get("script")).isEqualTo(normalized);
|
||||
assertThat(block.get("args")).isEqualTo(Map.of("note", normalized));
|
||||
// The digest names the representation the column keeps: recomputing
|
||||
// it from the persisted input round-trips instead of wedging the Turn.
|
||||
assertThat(digest.getValue()).isEqualTo(
|
||||
SubmitHarnessTurn.computePayloadDigest(input.getValue()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void userTextWithALoneSurrogateIsNormalizedTheSameWay() {
|
||||
when(harness.isAvailable()).thenReturn(true);
|
||||
when(store.insertSessionCommand(anyString(), anyString(), anyString(),
|
||||
anyString(), anyString(), any(), any(), any(), anyList(),
|
||||
anyString())).thenReturn(new StoreModels.Admission(
|
||||
"session-new", null, false, true));
|
||||
String raw = "x surrogate " + (char) 0xD800 + " here";
|
||||
|
||||
service.createSession(TENANT, "actor", "key-1", null, null, null,
|
||||
null, List.of(new ApiModels.InputBlock("text", raw)));
|
||||
|
||||
ArgumentCaptor<List<Map<String, Object>>> input =
|
||||
ArgumentCaptor.forClass(List.class);
|
||||
ArgumentCaptor<String> digest = ArgumentCaptor.forClass(String.class);
|
||||
verify(store).insertSessionCommand(anyString(), anyString(),
|
||||
anyString(), anyString(), anyString(), any(), any(), any(),
|
||||
input.capture(), digest.capture());
|
||||
List<Map<String, Object>> inserted = input.getValue();
|
||||
String normalized = raw.replace((char) 0xD800, '<27>');
|
||||
assertThat(inserted).containsExactly(
|
||||
Map.of("type", "text", "text", normalized));
|
||||
assertThat(digest.getValue()).isEqualTo(
|
||||
SubmitHarnessTurn.computePayloadDigest(inserted));
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaceLoneSurrogatesTradesOnlyTheUnpaired() {
|
||||
assertThat(ManagedAgentService.replaceLoneSurrogates(
|
||||
"x" + (char) 0xD800)).isEqualTo("x" + '<27>');
|
||||
assertThat(ManagedAgentService.replaceLoneSurrogates(
|
||||
"x" + (char) 0xDFFF + "y")).isEqualTo("x" + '<27>' + "y");
|
||||
assertThat(ManagedAgentService.replaceLoneSurrogates("café 中 😀"))
|
||||
.isEqualTo("café 中 😀");
|
||||
assertThat(ManagedAgentService.replaceLoneSurrogates("plain"))
|
||||
.isEqualTo("plain");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -370,7 +370,8 @@ final class JsonSupport {
|
|||
Object value = object.get(field);
|
||||
long result = exactLong(value, context + "." + field);
|
||||
if (result < 0) {
|
||||
throw new DaemonProtocolException(context + " must be non-negative");
|
||||
throw new DaemonProtocolException(context + "." + field
|
||||
+ " must be non-negative");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -383,7 +384,8 @@ final class JsonSupport {
|
|||
}
|
||||
long result = exactLong(value, context + "." + field);
|
||||
if (result <= 0) {
|
||||
throw new DaemonProtocolException(context + " must be positive");
|
||||
throw new DaemonProtocolException(context + "." + field
|
||||
+ " must be positive");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -392,7 +394,7 @@ final class JsonSupport {
|
|||
String context) {
|
||||
long value = exactLong(object.get(field), context + "." + field);
|
||||
if (value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) {
|
||||
throw new DaemonProtocolException(context
|
||||
throw new DaemonProtocolException(context + "." + field
|
||||
+ " is outside the integer range");
|
||||
}
|
||||
return (int) value;
|
||||
|
|
@ -402,7 +404,8 @@ final class JsonSupport {
|
|||
String field, String context) {
|
||||
Map<String, Object> value = optionalObject(object, field);
|
||||
if (value == null) {
|
||||
throw new DaemonProtocolException(field + " must be an object");
|
||||
throw new DaemonProtocolException(context + "." + field
|
||||
+ " must be an object");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -168,5 +168,32 @@ class JsonSupportTest {
|
|||
assertEquals("{\"a\":1,\"b\":[1,2,0.0001],\"c\":{\"x\":true,\"y\":{}},\"d\":null}",
|
||||
JsonSupport.encode(value));
|
||||
}
|
||||
|
||||
@Test
|
||||
void protocolHelperMessagesKeepTheirFieldPath() {
|
||||
Map<String, Object> block = new LinkedHashMap<>();
|
||||
block.put("count", -1L);
|
||||
assertEquals("ctx.count must be non-negative",
|
||||
assertThrows(DaemonProtocolException.class,
|
||||
() -> JsonSupport.requiredNonNegativeLong(block,
|
||||
"count", "ctx")).getMessage());
|
||||
|
||||
block.put("count", 0L);
|
||||
assertEquals("ctx.count must be positive",
|
||||
assertThrows(DaemonProtocolException.class,
|
||||
() -> JsonSupport.optionalPositiveLong(block, "count",
|
||||
"ctx")).getMessage());
|
||||
|
||||
block.put("count", (long) Integer.MAX_VALUE + 1L);
|
||||
assertEquals("ctx.count is outside the integer range",
|
||||
assertThrows(DaemonProtocolException.class,
|
||||
() -> JsonSupport.requiredInt(block, "count", "ctx"))
|
||||
.getMessage());
|
||||
|
||||
assertEquals("ctx.inner must be an object",
|
||||
assertThrows(DaemonProtocolException.class,
|
||||
() -> JsonSupport.requiredObject(Map.of(), "inner",
|
||||
"ctx")).getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue