0 轮直出可以标原型验证项;坏信封降级成可返工而不是判死委派
两条都是 run 17(571 字完整需求走生产链路)实测撞出来的,都在既有代码里, 都比刚修的台账约束更早触发。 N1 —— round=0 决定被钉死成 default_pending。用户一次把需求说全时全部决定都是 round=0,于是没有任何决定可能成为 prototype_pending;而 validate_decisions 的双射 又要求 prototypeValidationItems 逐项对应 prototype_pending 决定,结果首次 plan.submit_gdd 必被预检拒收,这份稿子也永远不可能带上原型验证项。实测子 Agent 读懂了拒绝理由,代价是把「触控手感」「30 回合是不是真的 8-12 分钟」这类没人验证过 的假设一律标成「默认,待确认」——那是在说谎,它们不是默认值。 round=0 真正要守的是「从未提问过的决定不得声称任何用户权威」,即 answerSource 必须是 default,而不是把状态钉死。改成允许 default_pending 与 prototype_pending 两种,answerSource 仍强制 default,confirmed 照旧拒。双射保持 严格不动——Agent 现在有合法途径同时给出决定和验证项。 N2 —— AGC_NEEDS_USER_INPUT_V1 的解析在 build_static_delegate_structured_result_at 里 `?` 往外传,而这个函数跑在投递回执时,子 run 已经 idle/completed,没有任何一轮 可以把解析错误回灌回去。实测一次 option 多写 `id` 字段就让整条委派 result_failed、 父 Supervisor 直接 needs-reconciliation 停下等人。不是预算设成 0,是结构上没地方 重试;对照原型,它在 run 循环内解析、坏了注入 INJ_ENVELOPE_REJECTED 重试 3 次。 信封格式属于「本次 Provider 输出写错」,与 plan.submit_gdd 的业务拒绝同类,不是 durable 权威损坏。降级成 needs-repair,并把解析失败在哪当返工理由带上(而不是把 那段无法解析的原文照抄回去,那对子 Agent 没有可操作信息)。Supervisor 用既有的一次 返工额度即可自愈。 role brief 补上 prototypeValidationItems:schema 把它列为 required,brief 此前 一个字没提它、也没提双射,模型只能靠撞。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
- 决定台账里,**事实归 Runtime、判断归你**。`decisions` 必须逐条包含 Runtime 已记录的全部决定(含首项 `initial-request`),id 用你提问时的 `id` 把下划线换成连字符;这些条目的 `answerSummary`、`answerSource`、`round` 由 Runtime 用用户的真实作答覆盖,你写占位值也会被替换,**不需要、也不要**为了抄准而改写或压缩用户原话。你真正决定的是 `topic` 和 `state`。
|
||||
- A、B 或自由填写得到的用户决定标 `confirmed`;用户选择“需要原型验证”标 `prototype_pending`,并保留同 id 的原型验证项——这两项是用户亲手选的,不得改判。只有未提问、由你按默认建议填写的字段才标 `default_pending`,其 `answerSource=default`、`round=0`。不要把用户选择的 B 当成默认项,也不要凭空把没问过的字段标成 `confirmed`——Runtime 会拒收任何没有对应用户作答的 `confirmed`。
|
||||
- 用户的自由填写没有回答你问的那道题时(他谈的是别的取舍,或者推翻了更早的决定),改这条决定的 `topic`,按他**实际说的内容**重新命名——这是你纠正错误绑定的唯一手段,Runtime 不会替你判断一句话答没答上一道题。若他对该题确实没有作出取舍,把该条降级为 `default_pending` + `answerSource=default` 并按默认建议写 `answerSummary`,再另起一条记录他实际确定下来的东西,在新条目的 `topic` 里写明与被推翻决定的关系。降级只能往这个方向;用户已作出的决定不得整条丢弃。
|
||||
- `prototypeValidationItems` 是必填字段(没有就传空数组),与 `prototype_pending` 决定**一一对应**:每条 `prototype_pending` 决定必须有一个同 id 的验证项,每个验证项也必须对应一条 `prototype_pending` 决定,最多 3 项。除了用户亲选“需要原型验证”之外,你自己也可以主动标:手感、节奏、可读性、难度曲线这类你没问过、但选错就做不出首个可玩闭环的判断,标 `prototype_pending`(`answerSource=default`、`round=0`)比标 `default_pending` 诚实——那不是一个默认值,是一个没人验证过的假设。每项写清 30~90 分钟微型原型做什么、让谁试玩、观察什么信号、什么算通过。
|
||||
- 只定义一个完整可玩闭环。MVP 不含多人、商城、服务器、开放世界、赛季、复杂社交、完整剧情或全量内容,除非用户明确改变范围。
|
||||
- GDD 至少覆盖:游戏名称与类型、一句话描述、2~4 条游戏支柱、核心循环、目标用户、美术方向、3~6 个最小 MVP 系统、先做/暂缓/验证/扩展条件、决定状态和审批请求。不要把 Runtime 注入的身份、时间、指纹、审批 receipt 或平台事实当作 Provider 输入字段。
|
||||
- 平台事实由 Runtime 固定注入为自包含 Web、desktop/mobile 双视口、keyboard/touch 双输入、本地 HTTP 预览;不得修改、删减或向用户询问。
|
||||
|
||||
+26
-7
@@ -880,13 +880,32 @@ fn validate_decisions(
|
||||
if decision.round > 3 {
|
||||
return Err(invalid(format!("decisions[{index}].round 不能超过 3")));
|
||||
}
|
||||
if decision.round == 0
|
||||
&& decision.id != "initial-request"
|
||||
&& !(decision.state == "default_pending" && decision.answer_source == "default")
|
||||
{
|
||||
return Err(invalid(
|
||||
"round=0 的非 initial-request 只能是 default_pending/default",
|
||||
));
|
||||
// round=0 表示这条决定从未向用户提问过,因此它不能声称任何用户权威:
|
||||
// answerSource 必须是 default。但它可以落在两种状态上——由 Agent 按默认
|
||||
// 建议填写(default_pending),或者 Agent 判定这项会实质影响首个可玩闭环、
|
||||
// 不该由它替用户拍板,需要一个 30~90 分钟微型原型来验证
|
||||
// (prototype_pending,并配同 id 的 prototypeValidationItems 项)。
|
||||
//
|
||||
// 早期实现把 round=0 钉死成 default_pending。于是用户一次把需求说全、
|
||||
// 走 0 轮直出时,全部决定都是 round=0,没有任何决定可能成为
|
||||
// prototype_pending;而下面的双射又要求验证项逐项对应 prototype_pending
|
||||
// 决定,结果是首次 plan.submit_gdd 必被预检拒收,且这份稿子永远不可能
|
||||
// 带上原型验证项。把一项未经验证的风险标成「默认,待确认」是在说谎:
|
||||
// 那不是一个默认值,那是一个没人验证过的假设。
|
||||
if decision.round == 0 && decision.id != "initial-request" {
|
||||
if decision.answer_source != "default" {
|
||||
return Err(invalid(format!(
|
||||
"decisions[{index}] round=0 未经提问,answerSource 只能是 default"
|
||||
)));
|
||||
}
|
||||
if !matches!(
|
||||
decision.state.as_str(),
|
||||
"default_pending" | "prototype_pending"
|
||||
) {
|
||||
return Err(invalid(format!(
|
||||
"decisions[{index}] round=0 只能是 default_pending 或 prototype_pending"
|
||||
)));
|
||||
}
|
||||
}
|
||||
validate_text(
|
||||
&decision.answer_summary,
|
||||
|
||||
@@ -3694,6 +3694,69 @@ mod tests {
|
||||
.expect_err("non-initial decisions keep the 400 scalar bound");
|
||||
}
|
||||
|
||||
/// N1:0 轮直出(用户一次把需求说全)时全部决定都是 round=0。Agent 必须能把
|
||||
/// 「我没问过、但选错会做不出首个可玩闭环」的判断标成 prototype_pending 并配
|
||||
/// 微型原型,而不是被迫标成 default_pending 假装那是个默认值。
|
||||
#[test]
|
||||
fn an_unasked_risk_may_be_flagged_for_prototype_validation_at_round_zero() {
|
||||
let mut input = valid_input();
|
||||
input.decisions.push(PlanSubmitDecision {
|
||||
id: "touch-readability".to_string(),
|
||||
topic: "触控放置手感与信息可读性".to_string(),
|
||||
state: "prototype_pending".to_string(),
|
||||
answer_source: "default".to_string(),
|
||||
round: 0,
|
||||
answer_summary: "没有向用户提问,选错会让首个可玩闭环立不住".to_string(),
|
||||
});
|
||||
input
|
||||
.prototype_validation_items
|
||||
.push(PlanPrototypeValidationItem {
|
||||
id: "touch-readability".to_string(),
|
||||
question: "玩家能否在不读说明的情况下完成一次放置".to_string(),
|
||||
micro_prototype: "用 30~90 分钟做一屏占位网格与三种占位单位".to_string(),
|
||||
observation: "记录首次放置耗时与误触次数".to_string(),
|
||||
pass_criterion: "3 名试玩者中 2 名在 30 秒内完成首次放置".to_string(),
|
||||
});
|
||||
validate_plan_submit_gdd_input(&input)
|
||||
.expect("an unasked risk may be flagged for prototype validation");
|
||||
|
||||
let (root, context, input) = submit_fixture_from(input);
|
||||
execute_plan_submit_gdd(&root, &context, &input)
|
||||
.expect("a zero-clarification draft may carry prototype validation items");
|
||||
let chain = read_plan_gdd_chain(&root).expect("read submitted chain");
|
||||
assert_eq!(chain[0].decisions[1].state, "prototype_pending");
|
||||
assert_eq!(chain[0].prototype_validation_items.len(), 1);
|
||||
cleanup_fixture(root);
|
||||
}
|
||||
|
||||
/// round=0 放开的只是状态,不是权威:从未提问过的决定仍然不许声称用户拍过板,
|
||||
/// 也不许挂上任何 user_* 来源。
|
||||
#[test]
|
||||
fn a_round_zero_decision_still_cannot_claim_any_user_authority() {
|
||||
let mut confirmed = valid_input();
|
||||
confirmed.decisions.push(PlanSubmitDecision {
|
||||
id: "invented".to_string(),
|
||||
topic: "没问过却声称已确认".to_string(),
|
||||
state: "confirmed".to_string(),
|
||||
answer_source: "default".to_string(),
|
||||
round: 0,
|
||||
answer_summary: "伪造".to_string(),
|
||||
});
|
||||
validate_plan_submit_gdd_input(&confirmed).expect_err("round=0 may not be confirmed");
|
||||
|
||||
let mut sourced = valid_input();
|
||||
sourced.decisions.push(PlanSubmitDecision {
|
||||
id: "invented".to_string(),
|
||||
topic: "没问过却挂上用户来源".to_string(),
|
||||
state: "prototype_pending".to_string(),
|
||||
answer_source: "user_option".to_string(),
|
||||
round: 0,
|
||||
answer_summary: "伪造".to_string(),
|
||||
});
|
||||
validate_plan_submit_gdd_input(&sourced)
|
||||
.expect_err("round=0 may not carry a user answer source");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_replay_reports_missing_session_without_guessing_or_allocating_version() {
|
||||
let (root, context, input) = submit_fixture();
|
||||
|
||||
@@ -1725,11 +1725,24 @@ pub(crate) fn build_static_delegate_structured_result_at(
|
||||
let verification_passed = !verification_required
|
||||
|| (verification_status == Some("passed") && verified_revision.is_some());
|
||||
let completed = terminal_status == "completed";
|
||||
let (user_input_questions, user_input_questions_sha256) =
|
||||
parse_static_delegate_user_input_request(error)?;
|
||||
// 终态信封在这里解析,而这里是**投递回执时**——子 run 此刻已经终止,没有任何
|
||||
// 一轮可以把解析错误回灌给它。早期实现在这一步直接 `?`,于是一次格式手滑
|
||||
// (实测:option 对象里多写了一个 `id` 字段)就把整条委派判成投递失败,父
|
||||
// Supervisor 直接进 needs-reconciliation 停下等人。
|
||||
//
|
||||
// 信封格式属于「本次 Provider 输出写错」,不是「durable 权威损坏」,和
|
||||
// plan.submit_gdd 的业务拒绝同类。降级成 needs-repair 并把解析错误当返工理由
|
||||
// 带上:Supervisor 用既有的一次返工额度就能让子 Agent 重写,不需要人工介入。
|
||||
let (user_input_questions, user_input_questions_sha256, user_input_parse_error) =
|
||||
match parse_static_delegate_user_input_request(error) {
|
||||
Ok((questions, sha256)) => (questions, sha256, None),
|
||||
Err(parse_error) => (None, None, Some(parse_error)),
|
||||
};
|
||||
let needs_user_input = completed && user_input_questions.is_some();
|
||||
let contract_status = if needs_user_input {
|
||||
StaticDelegateContractStatus::NeedsUserInput
|
||||
} else if user_input_parse_error.is_some() {
|
||||
StaticDelegateContractStatus::NeedsRepair
|
||||
} else if completed && missing_expected_artifacts.is_empty() && verification_passed {
|
||||
StaticDelegateContractStatus::EvidenceReady
|
||||
} else {
|
||||
@@ -1749,8 +1762,19 @@ pub(crate) fn build_static_delegate_structured_result_at(
|
||||
});
|
||||
}
|
||||
let derived_error = if contract_status == StaticDelegateContractStatus::NeedsRepair {
|
||||
error
|
||||
.map(|value| redact_agent_runtime_error(root, value, 500))
|
||||
// 坏信封的返工理由必须是**解析失败在哪**,而不是把那段无法解析的原文照抄
|
||||
// 回去——后者对子 Agent 没有任何可操作信息。
|
||||
user_input_parse_error
|
||||
.map(|parse_error| {
|
||||
redact_agent_runtime_error(
|
||||
root,
|
||||
&format!(
|
||||
"AGC_NEEDS_USER_INPUT_V1 信封无法解析:{parse_error}。请严格按信封契约重写问题(questions 恰好一题,元素只含 id/header/question/options,option 只含 label/description),或者直接完成交付收束。"
|
||||
),
|
||||
500,
|
||||
)
|
||||
})
|
||||
.or_else(|| error.map(|value| redact_agent_runtime_error(root, value, 500)))
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| {
|
||||
if !completed {
|
||||
@@ -2873,6 +2897,48 @@ mod tests {
|
||||
assert!(error.contains("user.input_request"));
|
||||
}
|
||||
|
||||
/// 坏信封是 Provider 输出质量问题,不是 durable 权威损坏。它必须变成一次可返工
|
||||
/// 的 needs-repair,而不是把整条委派判成投递失败、把父 Supervisor 推进
|
||||
/// needs-reconciliation——解析发生在子 run 终止之后,那条路上没有任何一轮能自愈。
|
||||
#[test]
|
||||
fn a_malformed_user_input_envelope_degrades_to_needs_repair_with_the_parse_reason() {
|
||||
// 实测形态:option 对象里多写了一个 `id` 字段。
|
||||
let response = concat!(
|
||||
"AGC_NEEDS_USER_INPUT_V1\n",
|
||||
"{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·关键决定\",",
|
||||
"\"question\":\"当前要决定:核心闭环形状。\",\"options\":[",
|
||||
"{\"id\":\"a\",\"label\":\"A · 甲方案\",\"description\":\"甲方案的后果\"},",
|
||||
"{\"id\":\"b\",\"label\":\"B · 乙方案\",\"description\":\"乙方案的后果\"},",
|
||||
"{\"id\":\"c\",\"label\":\"需要原型验证\",\"description\":\"做个微型原型看看\"}]}]}"
|
||||
);
|
||||
parse_static_delegate_user_input_request(Some(response))
|
||||
.expect_err("an option carrying an extra field is still a malformed envelope");
|
||||
|
||||
let result = build_static_delegate_structured_result_at(
|
||||
&std::env::temp_dir(),
|
||||
"completed",
|
||||
&[],
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(response),
|
||||
)
|
||||
.expect("a malformed envelope must not fail the whole delivery");
|
||||
assert_eq!(
|
||||
result.contract_status,
|
||||
StaticDelegateContractStatus::NeedsRepair
|
||||
);
|
||||
assert!(result.user_input_questions.is_empty());
|
||||
assert!(result.user_input_questions_sha256.is_none());
|
||||
let reason = result.error.as_deref().expect("a repair reason is derived");
|
||||
// 返工理由要说清错在哪,而不是把那段无法解析的原文照抄回去。
|
||||
assert!(reason.contains("AGC_NEEDS_USER_INPUT_V1 信封无法解析"));
|
||||
assert!(reason.contains("label"));
|
||||
validate_static_delegate_structured_result(&result, "completed", &[])
|
||||
.expect("the degraded result still validates");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_chat_safe_default_replacement_recovers_both_persisted_half_states() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
|
||||
Reference in New Issue
Block a user