澄清信封按括号配平定界,退化尾巴不再判死整条委派

serde 的 from_str 要求整段输入就是一个值,尾部多一个字节就整包拒收。
而模型在长嵌套 JSON 字符串的尾部会退化:27 个历史 run 的 68 条真实澄清
信封里,有 2 条把信封写完整之后继续在同一个字符串里吐垃圾——
「 马会」、「સwerhu рҭ. 北京赛车? тру. [ ]」。信封本体一个字节都没坏,
函数调用的 arguments JSON 也一次成型(repairAttempt 全为 0),却和真正
写坏的信封一样停在 needs-repair。

这不是截断:坏信封 445–520 字符,好信封 439–529 字符,分布完全重叠,
全场最长的那条解析正常。撞上 max_output_tokens 会把 arguments 本身切断,
那会留下格式修复痕迹,实测一次都没有。

改成从标记后第一个 `{` 起做括号配平扫描(跳过字符串与转义),只把配平的
那个对象喂给 serde,与原型 design_agent.py 的 parse_envelope 一致。信封是
终态协议载荷,配平对象之后不存在协议内容。长度上限随之改按切片计算:
退化尾巴既然不进解析器,也不该替一条合法信封把通道撑爆。

少写闭合符的那一类(同批 5 条,结尾 `}]}` 而非 `}]}]}`)仍然失败——
那是模型真没写完,补括号只是替它猜一个没表达出来的形状,交给 run 内重取。

三条新测试用的是现场原样抓来的载荷:两条退化尾巴必须解析成功,一条缺
闭合符必须继续失败,外加一条正文里含括号字符的信封验证定界只认字符串外的
括号。delegation::tests 19/19,user_input 过滤器 21/21。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 04:22:30 +00:00
parent bc6c1e57a0
commit 8e3dae3446
@@ -1812,6 +1812,51 @@ pub(crate) fn build_static_delegate_structured_result_at(
})
}
/// 从信封载荷里切出第一个括号配平的 JSON 对象。
///
/// serde 的 from_str 要求整段输入就是一个值,尾部多一个字节就整包拒收。而模型在长
/// 嵌套 JSON 字符串的尾部会退化:27 个历史 run 的 68 条真实澄清信封里,有 2 条把信封
/// 写完整之后继续吐垃圾(「 马会」「સwerhu рҭ. 北京赛车? тру. [ ]」),信封本身
/// 一个字节都没坏,却和真正写坏的信封一样被判死,整条委派链停在 needs-repair。
///
/// 信封是终态协议载荷,配平的那个对象之后不存在任何协议内容,按配平定界即可。
/// 反过来,少写闭合符的那一类(同一批里 5 条,结尾是 `}]}` 而非 `}]}]}`)在这里
/// 仍然失败:那是模型真的没把结构写完,补括号只是替它猜一个它没表达的形状。
fn static_delegate_user_input_balanced_object(payload: &str) -> Result<&str, String> {
if !payload.starts_with('{') {
return Err("信封标记后不是 JSON 对象".to_string());
}
let mut depth = 0usize;
let mut in_string = false;
let mut escaped = false;
for (index, character) in payload.char_indices() {
if in_string {
if escaped {
escaped = false;
} else if character == '\\' {
escaped = true;
} else if character == '"' {
in_string = false;
}
continue;
}
match character {
'"' => in_string = true,
'{' | '[' => depth += 1,
'}' | ']' => {
depth = depth
.checked_sub(1)
.ok_or_else(|| "信封 JSON 括号不闭合".to_string())?;
if depth == 0 {
return Ok(&payload[..index + character.len_utf8()]);
}
}
_ => {}
}
}
Err("信封 JSON 括号不闭合".to_string())
}
pub(crate) fn parse_static_delegate_user_input_request(
response: Option<&str>,
) -> Result<(Option<Vec<AgentRuntimeUserInputQuestion>>, Option<String>), String> {
@@ -1821,10 +1866,16 @@ pub(crate) fn parse_static_delegate_user_input_request(
if !response.starts_with(STATIC_DELEGATE_USER_INPUT_PREFIX) {
return Ok((None, None));
}
if response.chars().count() > STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS {
let payload = response[STATIC_DELEGATE_USER_INPUT_PREFIX.len()..].trim();
// 长度上限约束的是协议载荷本身,所以按配平后的切片算:退化尾巴既然不进解析器,
// 也就不该替一条合法信封把通道撑爆。
let payload = static_delegate_user_input_balanced_object(payload)
.map_err(|reason| format!("子 Agent 用户澄清请求 JSON 无效:{reason}"))?;
if STATIC_DELEGATE_USER_INPUT_PREFIX.chars().count() + payload.chars().count()
> STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS
{
return Err("子 Agent 用户澄清请求超过回执长度上限".to_string());
}
let payload = response[STATIC_DELEGATE_USER_INPUT_PREFIX.len()..].trim();
let value = serde_json::from_str::<serde_json::Value>(payload)
.map_err(|error| format!("子 Agent 用户澄清请求 JSON 无效:{error}"))?;
let questions = parse_game_creator_agent_user_input_questions(&value)?;
@@ -2737,6 +2788,74 @@ mod tests {
.expect("an ordinary one-question clarification must pass the relay channel");
}
/// 现场原样抓来的两条退化信封:模型把信封写完整之后,在同一个字符串里继续吐了
/// 一段垃圾。函数调用的 arguments JSON 一次成型(repairAttempt 全为 0),信封本体
/// 一个字节都没坏,坏的只是尾巴——这不是截断,是长嵌套 JSON 尾部的解码退化。
///
/// 27 个历史 run 的 68 条信封里这类占 2 条。修复前它们和真正写坏的信封同样被
/// serde 的 trailing characters 判死,整条委派链停在 needs-repair。
#[test]
fn a_degenerated_tail_after_a_complete_envelope_does_not_kill_the_envelope() {
let cases = [
// abtest-tide2A-2:尾巴是「 马会」。
concat!(
"AGC_NEEDS_USER_INPUT_V1\n",
r#"{"questions":[{"id":"replay_motivation","header":"第1轮·关键决定","question":"当前要决定:固定五岛海图的重复游玩动力采用哪种方案?现在确认它,才能锁定首个可玩闭环之外的得分与重开目标。","options":[{"label":"A · 推荐:固定布局冲榜","description":"每局地图与信件配置固定,玩家通过更优路线、潮汐 timing 和装卸顺序刷新送达数与总分;优点是实现最小、可读性强,代价是内容变化较少。"},{"label":"B · 轮换信件组合","description":"地图固定但每局从预设信件组合中轮换收件岛与期限;优点是重玩变化更明显,代价是需要额外平衡组合并降低可预测性。"},{"label":"需要原型验证","description":"用3090分钟做可点击五岛地图与两种信件配置原型,让3名偏好轻策略的玩家各玩3局,观察是否主动重开及路线是否有差异;通过标准是多数玩家愿意重开且能说出改进路线。"}]}]} 马会"#,
),
// verify-farm-4:尾巴是古吉拉特语字母、西里尔字母和中文垃圾词的混合物。
concat!(
"AGC_NEEDS_USER_INPUT_V1\n",
r#"{"questions":[{"id":"replay_progression","header":"第2轮·关键决定","question":"当前要决定:自由经营农场的长期目标采用哪种组合?这会决定玩家为何持续规划、赚钱与重玩,并控制 MVP 的范围。","options":[{"label":"A · 推荐:里程碑升级+成就","description":"以累计资金解锁少量新地块或设施,同时完成可选成就;优点是目标清晰又保留自由安排,代价是需要同时做基础升级与成就追踪。"},{"label":"B · 专注农场扩建","description":"只用经营收益逐步解锁地块与设施,成就仅作展示;优点是系统更聚焦、反馈直接,代价是挑战层次和重玩目标较少。"},{"label":"需要原型验证","description":"制作 3090 分钟微型原型,让 2–3 名目标玩家试玩两种目标结构,观察他们是否主动设定计划、理解进展并愿意继续经营;多数玩家能完成一次扩建且愿意追求第二个目标即通过。"}]}]}સwerhu рҭ. 北京赛车? тру. [ ]"#,
),
];
for response in cases {
let (questions, sha256) = parse_static_delegate_user_input_request(Some(response))
.expect("a complete envelope followed by garbage must still parse");
let questions = questions.expect("questions present");
assert_eq!(questions.len(), 1);
assert_eq!(questions[0].options.len(), 3);
assert_eq!(sha256.as_deref().map(str::len), Some(64));
}
}
/// 同一批里另外 5 条是另一种形态:结尾少了最外面的 `]}`,括号差 2 层。
/// 那是模型真的没把结构写完,不能靠补括号替它猜一个没表达出来的形状——
/// 这条必须继续失败,交给 run 内重取。
#[test]
fn an_envelope_missing_its_closing_brackets_still_fails() {
// verify-farm-2 现场原文,结尾是 `}]}` 而非 `}]}]}`。
let response = concat!(
"AGC_NEEDS_USER_INPUT_V1\n",
r#"{"questions":[{"id":"core_loop_goal","header":"第1轮·关键决定","question":"当前要决定:这款农场经营游戏的一局,玩家主要通过什么目标获得满足?现在先定核心闭环,才能控制 MVP 范围。","options":[{"label":"A · 推荐:短周期订单经营","description":"围绕播种、收获、加工并完成限时订单推进;目标清晰、反馈快,代价是自由建造与长期规划较少。"},{"label":"B · 自主农场成长","description":"围绕规划田地、逐步扩建并达成阶段里程碑;沉浸和成长感更强,代价是前期目标反馈较慢、系统边界更难控。"},{"label":"需要原型验证","description":"制作 30~90 分钟微型原型,包含种植、收获和一种目标;让 2~3 名目标玩家试玩,观察是否理解目标、是否愿意继续一轮;通过标准是多数玩家无需讲解即可完成闭环并主动开始第二轮。"}]}"#,
);
let error = parse_static_delegate_user_input_request(Some(response))
.expect_err("an envelope that stops short of closing must not parse");
assert!(error.contains("括号不闭合"), "unexpected error: {error}");
}
/// 定界只认字符串外的括号。信封正文里出现的括号字符必须被跳过,否则一条完全
/// 合法的信封会因为问题文案里写了 `}` 而被提前切断。
#[test]
fn balanced_object_scanning_ignores_brackets_inside_strings() {
let response = format!(
"{STATIC_DELEGATE_USER_INPUT_PREFIX}{}",
serde_json::json!({
"questions": [{
"id": "brace_heavy",
"header": "括号",
"question": "存档格式写成 {\"slot\": [1]} 还是二进制?",
"options": [
{"label": "A · JSON", "description": "形如 {\"slot\": [1]} 的文本存档,可读但体积大。"},
{"label": "B · 二进制", "description": "紧凑但要自己写工具才能看,形如 ]}]} 的字节序列。"}
]
}]
})
);
let (questions, _) = parse_static_delegate_user_input_request(Some(&response))
.expect("brackets inside strings must not terminate the scan");
assert_eq!(questions.expect("questions present").len(), 1);
}
#[test]
fn static_delegate_target_agent_ids_include_claimed_and_exclude_suppressed_or_repair() {
let root = std::env::temp_dir().join(format!(