澄清信封在整条回执通道上不再按自由文本盲切
上一轮只堵了通道末端,父 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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user