修复澄清回执通道容不下一次正常问询
子 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:
@@ -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
|
||||
));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user