澄清信封按括号配平定界,退化尾巴不再判死整条委派
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:
@@ -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":"用30~90分钟做可点击五岛地图与两种信件配置原型,让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":"制作 30–90 分钟微型原型,让 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!(
|
||||
|
||||
Reference in New Issue
Block a user