修复澄清回执通道容不下一次正常问询

子 Agent 的澄清信封走的是 error 文本通道,两处把它当普通错误消息处理:

一是中转通道的字符上限写死 500,而它承载的问询 schema 允许 3 个问题、每题 400
字符、每题 2-3 个选项(label 60 + description 240)——单个 schema 合法的问题光内
容就有 1376 字符,通道连一个满格的合法问题都装不下。现场一问三选的正常中文问询
501 字符,正好被拒收。改成由 schema 自己的上限推导,推导常量放在 schema 所在的
user_input.rs;顺带把内联的 options.len() < 2 || > 3 换成具名常量,让推导有据。

二是父 run 认领回执时按错误消息截到 500 字符再补省略号。现场子 Agent 的真实输出
521 字符,截断后 501 字符,JSON 拦腰断在末尾,父 run 解析失败停在
needs-reconciliation。只放宽上限治不了这一处:载荷在到达解析器之前就已经被切了。
识别信封前缀时按信封通道上限放行,其余错误消息仍是 500。

两处都得对,少一处这条链路就断。上限是沿用 master 的(82957fe1d,2026-08-12),
本分支没改过;做游戏链路的专业 Agent 很少提带选项的澄清,所以此前没暴露,而 M1
策划把"先问清楚再出 GDD"做成了必经步骤。

放宽上限不会让原本通过的载荷失败,逐字段复核仍在
parse_game_creator_agent_user_input_questions。新增三条用例:schema 允许的最大合法
问询必须能过通道、现场那条普通中文问询必须能过、同一条信封被截断后必然解析失败。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 08:49:12 +00:00
parent 7ca7e9704b
commit 7df033fcde
3 changed files with 159 additions and 5 deletions
@@ -722,7 +722,20 @@ pub(crate) fn build_static_delegate_result_for_child_at(
.error
.as_deref()
.or((!result_detail.trim().is_empty()).then_some(result_detail));
let result_detail = result_detail.map(|value| redact_agent_runtime_error(root, value, 500));
// 澄清信封是结构化协议载荷,只是恰好走了 error 文本通道。按普通错误消息截到
// 500 字符会把 JSON 拦腰切断,父 run 随后解析失败并停在 needs-reconciliation
// 现场子 Agent 的真实输出 521 字符,截断后 501 字符,JSON 在末尾 EOF。
let result_detail = result_detail.map(|value| {
let max_chars = if value
.trim_start()
.starts_with(STATIC_DELEGATE_USER_INPUT_PREFIX)
{
STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS
} else {
500
};
redact_agent_runtime_error(root, value, max_chars)
});
let mut result = build_static_delegate_structured_result_at(
root,
terminal_status,
@@ -13,8 +13,12 @@ const STATIC_DELEGATE_ACCEPTANCE_CRITERION_MAX_CHARS: usize = 240;
const STATIC_DELEGATE_MAX_EXPECTED_ARTIFACTS: usize = 16;
const STATIC_DELEGATE_EXPECTED_ARTIFACT_MAX_CHARS: usize = 240;
const STATIC_DELEGATE_MAX_EVIDENCE: usize = 16;
const STATIC_DELEGATE_USER_INPUT_PREFIX: &str = "AGC_NEEDS_USER_INPUT_V1\n";
const STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS: usize = 500;
pub(crate) const STATIC_DELEGATE_USER_INPUT_PREFIX: &str = "AGC_NEEDS_USER_INPUT_V1\n";
/// 中转通道的上限必须由澄清问询 schema 推导。写死 500 时,一问三选的正常中文
/// 问询(501 字符)就会在父 run 认领回执时被拒收,整条委派链阻断;而 schema 本身
/// 允许的最大合法问询比 500 大一个数量级。
pub(crate) const STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS: usize =
STATIC_DELEGATE_USER_INPUT_PREFIX.len() + AGENT_RUNTIME_USER_INPUT_MAX_WIRE_CHARS;
// 澄清轮次上限按 source 区分:诉求只来自策划节点,game-chat 单主路径定位零打扰,
// 从未承诺给它 3 轮预算,因此取 1;其它 source(包括 Project Supervisor 常规协作)取 3。
const STATIC_DELEGATE_CLARIFICATION_ROUND_LIMIT_DEFAULT: u32 = 3;
@@ -2552,6 +2556,116 @@ fn validate_static_delegate_structured_result(
mod tests {
use super::*;
/// 中转通道和澄清问询 schema 之间隔着一个字符数上限,两边没有任何东西相连。
/// 通道窄于 schema 的代价不是「问询被截断」:子 Agent 提了一个完全合法的问题,
/// Runtime 会在父 run 认领回执时整包拒收,委派链就此阻断——现场实测一问三选的
/// 正常中文问询是 501 字符,而当时的上限恰好是 500。
///
/// 所以这里锁的是**包含关系**:schema 允许的最大合法问询,必须能过通道。
#[test]
fn user_input_relay_channel_admits_the_largest_schema_legal_request() {
let long_text = |count: usize| "".repeat(count);
let questions = (0..3)
.map(|index| {
serde_json::json!({
"id": format!("q{index}{}", "a".repeat(60)),
"header": long_text(12),
"question": long_text(400),
"options": (0..3)
.map(|option| serde_json::json!({
"label": format!("{option}{}", long_text(59)),
"description": long_text(240),
}))
.collect::<Vec<_>>(),
})
})
.collect::<Vec<_>>();
let response = format!(
"{STATIC_DELEGATE_USER_INPUT_PREFIX}{}",
serde_json::json!({ "questions": questions })
);
assert!(
response.chars().count() <= STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS,
"schema 允许的最大问询 {} 字符超过了中转通道上限 {}",
response.chars().count(),
STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS
);
let (parsed, sha256) = parse_static_delegate_user_input_request(Some(&response))
.expect("largest schema-legal clarification must pass the relay channel");
assert_eq!(parsed.expect("questions").len(), 3);
assert!(sha256.is_some_and(|value| value.len() == 64));
}
/// 澄清信封走的是 error 文本通道,会被按普通错误消息截断。现场子 Agent 的真实
/// 输出 521 字符,截到 500 再补一个省略号正好 501——JSON 拦腰断在末尾,父 run
/// 解析失败停在 needs-reconciliation。放宽拒收上限治不了这个:载荷在到达解析器
/// 之前就已经被切了。
#[test]
fn truncating_a_clarification_envelope_makes_it_unparseable() {
let response = format!(
"{STATIC_DELEGATE_USER_INPUT_PREFIX}{}",
serde_json::json!({
"questions": [{
"id": "core_loop",
"header": "第1轮·核心",
"question": "影子能力在首个可玩闭环里承担什么作用?这决定关卡布局与原型优先级,也决定第一批谜题按什么规则组合。",
"options": [
{ "label": "A · 暗影分身", "description": "影子沿地面或墙面独立移动,可压机关、挡感应光、穿窄缝;规则直观,代价是要处理可达范围与回收。" },
{ "label": "B · 暗影桥梁", "description": "调整光源与站位让影子延展成短暂平台或连接导电点;偏空间构图,代价是碰撞与落脚可读性更严格。" },
],
}]
})
);
// 完整信封能过通道。
parse_static_delegate_user_input_request(Some(&response)).expect("intact envelope parses");
// 同一条信封被截断后必然解析失败——这正是现场那条 needs-reconciliation。
let truncated: String = response
.chars()
.take(response.chars().count() - 20)
.collect();
let error = parse_static_delegate_user_input_request(Some(&truncated))
.expect_err("a truncated envelope must not parse");
assert!(error.contains("JSON 无效"), "unexpected error: {error}");
}
/// 现场那条 501 字符的真实问询:一个问题、三个选项,没有任何一项接近 schema 上限。
#[test]
fn user_input_relay_channel_admits_one_ordinary_chinese_question() {
let response = format!(
"{STATIC_DELEGATE_USER_INPUT_PREFIX}{}",
serde_json::json!({
"questions": [{
"id": "plan_round_1",
"header": "第1轮·关键决定",
"question": "当前要决定:影子能力在首个可玩闭环中的核心作用。它会同时决定关卡布局、操作手感与原型优先级,也决定第一批谜题按什么规则组合;现在确认可以避免把三种玩法都做浅,也避免原型做到一半再推翻核心规则。",
"options": [
{
"label": "A · 影子化为可独立移动的暗影分身",
"description": "机器人定位光源后,影子沿地面或墙面移动,可压住机关、挡住感应光或穿过窄缝;规则直观、谜题组合清晰,代价是要处理影子可达范围与回收。",
},
{
"label": "B · 影子作为可拉伸的暗影桥梁",
"description": "玩家调整光源与站位,让影子延展成短暂平台或连接导电点;更偏空间构图解谜,代价是碰撞、长度和落脚可读性需要更严格。",
},
{
"label": "需要原型验证",
"description": "先用三十到九十分钟做一个最小原型,把两种方案的操作手感、关卡搭建成本和可读性各跑一遍,再决定首个可玩闭环采用哪一种,避免一开始就压死方向。",
},
],
}]
})
);
// 修复前这个上限是写死的 500,而这条问询没有任何一个字段接近 schema 上限。
const PREVIOUS_HARDCODED_CAP: usize = 500;
assert!(
response.chars().count() > PREVIOUS_HARDCODED_CAP,
"现场问询 {} 字符,应当超过旧的写死上限",
response.chars().count()
);
parse_static_delegate_user_input_request(Some(&response))
.expect("an ordinary one-question clarification must pass the relay channel");
}
#[test]
fn static_delegate_target_agent_ids_include_claimed_and_exclude_suppressed_or_repair() {
let root = std::env::temp_dir().join(format!(
@@ -11,11 +11,36 @@ pub(crate) const AGENT_RUNTIME_USER_INPUT_STATUS_CANCELLED: &str = "cancelled";
const AGENT_RUNTIME_USER_INPUT_SIDECAR_MAX_BYTES: usize = 128 * 1024;
const AGENT_RUNTIME_USER_INPUT_MAX_QUESTIONS: usize = 3;
const AGENT_RUNTIME_USER_INPUT_MIN_OPTIONS: usize = 2;
const AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS: usize = 3;
const AGENT_RUNTIME_USER_INPUT_MAX_ID_CHARS: usize = 64;
const AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS: usize = 12;
const AGENT_RUNTIME_USER_INPUT_MAX_QUESTION_CHARS: usize = 400;
const AGENT_RUNTIME_USER_INPUT_MAX_OPTION_LABEL_CHARS: usize = 60;
const AGENT_RUNTIME_USER_INPUT_MAX_OPTION_DESCRIPTION_CHARS: usize = 240;
/// 一份 schema 合法的澄清问询在线上最多可能有多长(字符)。
///
/// 存在的意义是给中转通道一个由 schema 推导的上限,而不是让它自己拍一个数。
/// 通道比 schema 窄的后果不是「模型写短一点」——子 Agent 提了一个完全合法的
/// 问题,Runtime 会在父 run 认领回执时拒收,整条委派链就此阻断。真正的逐字段
/// 复核仍在 `parse_game_creator_agent_user_input_questions`,这个上限只是粗筛。
///
/// JSON 语法开销按每个标量字段一对引号加冒号逗号、每层括号若干字符宽估。
pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_WIRE_CHARS: usize = {
const OPTION_SYNTAX_CHARS: usize = 40;
const QUESTION_SYNTAX_CHARS: usize = 64;
const ENVELOPE_SYNTAX_CHARS: usize = 64;
let per_option = AGENT_RUNTIME_USER_INPUT_MAX_OPTION_LABEL_CHARS
+ AGENT_RUNTIME_USER_INPUT_MAX_OPTION_DESCRIPTION_CHARS
+ OPTION_SYNTAX_CHARS;
let per_question = AGENT_RUNTIME_USER_INPUT_MAX_ID_CHARS
+ AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS
+ AGENT_RUNTIME_USER_INPUT_MAX_QUESTION_CHARS
+ AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS * per_option
+ QUESTION_SYNTAX_CHARS;
AGENT_RUNTIME_USER_INPUT_MAX_QUESTIONS * per_question + ENVELOPE_SYNTAX_CHARS
};
const AGENT_RUNTIME_USER_INPUT_MAX_ANSWER_CHARS: usize = 4_000;
const AGENT_RUNTIME_USER_INPUT_MAX_TOTAL_ANSWER_CHARS: usize = 8_000;
const AGENT_RUNTIME_USER_INPUT_MAX_RESPONSE_ID_CHARS: usize = 160;
@@ -274,9 +299,11 @@ fn normalize_user_input_questions(
question_index + 1
),
)?;
if question.options.len() < 2 || question.options.len() > 3 {
if question.options.len() < AGENT_RUNTIME_USER_INPUT_MIN_OPTIONS
|| question.options.len() > AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS
{
return Err(format!(
"user.input_request question #{} 必须提供 2-3 个选项",
"user.input_request question #{} 必须提供 {AGENT_RUNTIME_USER_INPUT_MIN_OPTIONS}-{AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS} 个选项",
question_index + 1
));
}