澄清信封在整条回执通道上不再按自由文本盲切

上一轮只堵了通道末端,父 run 仍然停在 needs-reconciliation:子 Agent 输出
518 字符的合法问询,在源头 last_response 处就被切成 501 字符,剥掉信封首行后
正好 1120 字节,父 run 解析时在字符串中途 EOF。

信封是结构化协议载荷,只是恰好借用了「子 Agent 自由回复」这条文本通道。定界
本身要保的三件事——状态快照与只增审计日志不被自由文本撑爆、子 Agent 文本不无
限量灌进父 Agent 上下文、摘要行只占一行——对信封都已由问询 schema 逐字段硬性
校验保住,再叠一层盲切只会砍断 JSON。

改为共有一条上限规则 static_delegate_result_detail_max_chars:字面以信封首行
开头时取 schema 推导的上限,否则原样走各自的 500/600/240。通道上四处定界全部
接上——last_response、terminal_detail、回执发布、复用既有终态时的摘要,其中
后者把「给人看的摘要」和「给解析的明细」拆成两个值,不再让一行摘要的长度决定
结构化载荷能不能被解析。last_response 与 terminal_detail 必须同源,两者逐字
相等是 finalization 幂等校验的不变式。

非信封文本走的分支与改动前完全一致,做游戏链路(含 codex)不产出该信封,行为
不变。

回归用例覆盖源头与通道两端,并钉住「普通自由回复仍被截到 500 字符」。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 09:55:56 +00:00
parent 276606fc70
commit 3bae4cdbb0
6 changed files with 202 additions and 25 deletions
@@ -2559,6 +2559,49 @@ fn game_chat_code_prototype_rejects_tampered_art_delivery_identity_before_claim(
drop(child_lane);
}
#[test]
fn completed_run_exempts_the_clarification_envelope_from_the_free_text_cap() {
let temporary = tempfile::tempdir().expect("create clarification cap root");
let root = temporary.path().join("project");
let (_main, child, _delegation_id, _lane) = game_chat_main_art_child_fixture_with_lane(
&root,
"art-director",
&["art-spec", "core-spritesheet"],
false,
);
// 源头:last_response 的定界。这里被盲切,后面每一处修复都补不回来——
// 现场就是子 Agent 输出 518 字符、切到 501 字符,父 run 在 1120 字节处 EOF。
let envelope = schema_max_clarification_envelope();
let completed =
prepare_game_creator_agent_runtime_completed_state(&root, child.clone(), &envelope)
.expect("prepare completed state carrying a clarification envelope");
assert_eq!(
completed.last_response.as_deref(),
Some(envelope.as_str()),
"澄清信封是结构化协议载荷,不能按自由文本盲切"
);
// terminal_detail 必须与 last_response 逐字相等,否则 finalization 幂等校验
// 会判「completed task 与 finalization 回复不匹配」。两处上限规则必须同源。
assert_eq!(
agent_runtime_terminal_detail(&completed),
completed.last_response
);
// 非信封的长自由文本仍然守着原来的 500 字符上限——这条豁免不是把定界取消掉。
let free_text = "普通自由回复。".repeat(200);
let bounded = prepare_game_creator_agent_runtime_completed_state(&root, child, &free_text)
.expect("prepare completed state carrying ordinary free text");
assert_eq!(
bounded
.last_response
.as_deref()
.map(|value| value.chars().count()),
Some(501),
"普通自由回复必须仍被截到 500 字符加省略号"
);
}
#[test]
fn game_chat_main_agent_allows_only_one_same_contract_safe_default_repair() {
let temporary = tempfile::tempdir().expect("create game-chat safe-default repair root");
@@ -554,7 +554,10 @@ pub(super) fn prepare_game_creator_agent_runtime_completed_state(
state.current_action = "等待下一轮输入".to_string();
state.waiting_on = "开发者下一轮输入".to_string();
state.next_step = "等待下一轮输入".to_string();
state.last_response = Some(sanitize_agent_runtime_text(response, 500));
state.last_response = Some(sanitize_agent_runtime_text(
response,
static_delegate_result_detail_max_chars(response, 500),
));
state.error = None;
complete_agent_runtime_remaining_plan_steps(&mut state, "本轮 Agent 已生成最终回复。");
state
@@ -3416,7 +3419,10 @@ pub(super) fn agent_runtime_terminal_detail(state: &AgentRuntimeState) -> Option
"cancelled" => Some(state.current_action.as_str()),
_ => None,
}?;
let detail = sanitize_agent_runtime_text(detail, 500);
// 必须与 last_response 用同一条上限规则:两者相等是 finalization 幂等校验的
// 不变式(见 finish_game_creator_agent_background_runtime_turn_idempotently_at)。
let detail =
sanitize_agent_runtime_text(detail, static_delegate_result_detail_max_chars(detail, 500));
(!detail.trim().is_empty()).then_some(detail)
}
@@ -1053,21 +1053,25 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked(
}
if let Some(terminal_status) = game_creator_agent_runtime_terminal_status(&existing)
{
let result_summary = existing
let result_detail = existing
.terminal_detail
.as_deref()
.or(existing.error.as_deref())
.unwrap_or(existing.current_action.as_str());
let result_summary = truncate_agent_runtime_text(
&redact_agent_runtime_project_paths(root, result_summary, 240),
140,
// 摘要给人看,明细给回执解析:两者不能共用一次截断,否则一行摘要
// 的长度会决定结构化载荷能不能被父 run 解析。
let result_detail = redact_agent_runtime_project_paths(
root,
result_detail,
static_delegate_result_detail_max_chars(result_detail, 240),
);
let result_summary = truncate_agent_runtime_text(&result_detail, 140);
let structured_result = match build_static_delegate_result_for_child_at(
root,
&delivery,
&existing,
terminal_status,
&result_summary,
&result_detail,
) {
Ok(result) => result,
Err(error) => {
@@ -1329,15 +1333,17 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked(
if let Some(terminal_status) =
game_creator_agent_runtime_terminal_status(&existing)
{
let result_summary = existing
let result_detail = existing
.terminal_detail
.as_deref()
.or(existing.error.as_deref())
.unwrap_or(error.as_str());
let result_summary = truncate_agent_runtime_text(
&redact_agent_runtime_project_paths(root, result_summary, 240),
140,
let result_detail = redact_agent_runtime_project_paths(
root,
result_detail,
static_delegate_result_detail_max_chars(result_detail, 240),
);
let result_summary = truncate_agent_runtime_text(&result_detail, 140);
let ready_result = (|| {
let _delivery_lock =
try_acquire_game_creator_agent_delegation_lock_with_wait(
@@ -1358,7 +1364,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked(
&delivery,
&existing,
terminal_status,
&result_summary,
&result_detail,
)?;
mark_static_delegate_delivery_ready_with_result_at(
root,
@@ -722,19 +722,12 @@ pub(crate) fn build_static_delegate_result_for_child_at(
.error
.as_deref()
.or((!result_detail.trim().is_empty()).then_some(result_detail));
// 澄清信封是结构化协议载荷,只是恰好走了 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)
redact_agent_runtime_error(
root,
value,
static_delegate_result_detail_max_chars(value, 500),
)
});
let mut result = build_static_delegate_structured_result_at(
root,
@@ -1502,7 +1495,11 @@ pub(crate) fn publish_game_creator_agent_delegate_result(
.or(child_task.terminal_detail.as_deref())
.or(child_task.error.as_deref())
.unwrap_or(child_task.current_action.as_str());
let result_detail = redact_agent_runtime_error(root, result_detail, 600);
let result_detail = redact_agent_runtime_error(
root,
result_detail,
static_delegate_result_detail_max_chars(result_detail, 600),
);
if uses_durable_delivery {
let existing_delivery = match read_static_delegate_delivery_at(root, delegation_id) {
Ok(Some(delivery)) => delivery,
@@ -19,6 +19,52 @@ pub(crate) const STATIC_DELEGATE_USER_INPUT_PREFIX: &str = "AGC_NEEDS_USER_INPUT
/// 允许的最大合法问询比 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;
/// 澄清信封是结构化协议载荷,只是恰好借用了「子 Agent 自由回复」这条文本通道。
/// 通道上每一处按普通自由文本盲切字符的定界都会把 JSON 拦腰砍断,父 run 认领回执
/// 时解析失败,整条委派链停在 needs-reconciliation。信封本身已由问询 schema 硬性
/// 定界(问题数、选项数、各字段字符上限逐项校验),不需要再叠一层盲切;凡是可能
/// 承载信封的定界点都从这里取上限,非信封文本仍走各自原有的上限。
pub(crate) fn static_delegate_result_detail_max_chars(
value: &str,
default_max_chars: usize,
) -> usize {
if value
.trim_start()
.starts_with(STATIC_DELEGATE_USER_INPUT_PREFIX)
{
STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS
} else {
default_max_chars
}
}
/// 构造一份贴着问询 schema 上限的合法澄清信封,供跨模块的通道用例复用。
/// 通道必须容得下 schema 允许的最大合法问询,而不只是「碰巧短」的那一条。
#[cfg(test)]
pub(crate) fn schema_max_clarification_envelope() -> String {
let questions = (0..3)
.map(|index| {
serde_json::json!({
"id": format!("decision_{index}"),
"header": "关键决定",
"question": "当前要定的规则。".repeat(40),
"options": (0..3)
.map(|option| {
serde_json::json!({
"label": format!("{} · 平行方案", char::from(b'A' + option as u8)),
"description": "该方案的边界与代价。".repeat(20),
})
})
.collect::<Vec<_>>(),
})
})
.collect::<Vec<_>>();
format!(
"{STATIC_DELEGATE_USER_INPUT_PREFIX}{}",
serde_json::json!({ "questions": questions })
)
}
// 澄清轮次上限按 source 区分:诉求只来自策划节点,game-chat 单主路径定位零打扰,
// 从未承诺给它 3 轮预算,因此取 1;其它 source(包括 Project Supervisor 常规协作)取 3。
const STATIC_DELEGATE_CLARIFICATION_ROUND_LIMIT_DEFAULT: u32 = 3;
@@ -2720,6 +2720,85 @@ fn claimed_needs_user_input_delivery_becomes_one_supervisor_wait() {
fs::remove_dir_all(root).ok();
}
#[test]
fn schema_max_clarification_envelope_survives_the_delegate_result_channel() {
let root = unique_project_path();
init_local_game_project_at(
&root,
"project-child-max-clarification",
"满格澄清信封通道测试",
)
.expect("project init");
let delivery = new_static_delegate_delivery_with_contract(
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"parent-session",
"parent-run",
"parent-action",
"child-max-clarification-delegation",
"design-director",
"child-session",
"child-run",
&["明确核心规则".to_string()],
&[],
None,
);
let response = schema_max_clarification_envelope();
// 通道上历史最紧的一处盲切是 500 字符;schema 允许的满格问询远超过它,
// 被切之后 JSON 只会在末尾 EOF,父 run 拿不到问题只能停在 needs-reconciliation。
assert!(
response.chars().count() > 600,
"满格信封必须超过通道上所有旧盲切上限:{}",
response.chars().count()
);
assert!(
response.chars().count() <= STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS,
"满格信封仍须落在 schema 推导的上限内:{}",
response.chars().count()
);
let child_task = AgentRuntimeTaskRecord {
schema_version: "game-creator-agent-runtime-task.v1".to_string(),
task_id: "child-task".to_string(),
agent_id: delivery.target_agent_id.clone(),
session_id: delivery.target_session_id.clone(),
run_id: delivery.target_run_id.clone(),
source: "agent-delegate".to_string(),
parent_agent_id: Some(delivery.parent_agent_id.clone()),
parent_run_id: Some(delivery.parent_run_id.clone()),
delegation_id: Some(delivery.delegation_id.clone()),
run_profile: default_agent_runtime_run_profile(),
run_profile_binding_fingerprint: String::new(),
goal_id: None,
goal_revision: 0,
goal_status: None,
task: "明确核心规则".to_string(),
status: "completed".to_string(),
phase: "completed".to_string(),
current_action: "已完成澄清请求".to_string(),
terminal_detail: Some(response.clone()),
error: None,
updated_at: unix_timestamp(),
};
let result = build_static_delegate_result_for_child_at(
&root,
&delivery,
&child_task,
"completed",
&response,
)
.expect("满格澄清信封必须能被父 run 解析成结构化回执");
assert_eq!(
result.contract_status,
StaticDelegateContractStatus::NeedsUserInput
);
assert_eq!(result.user_input_questions.len(), 3);
assert!(result
.user_input_questions
.iter()
.all(|question| question.options.len() == 3));
fs::remove_dir_all(root).ok();
}
#[test]
fn completed_child_final_response_becomes_needs_user_input_delivery_result() {
let root = unique_project_path();