修复链路上存在的若干阻塞性漏洞 #199
@@ -143,7 +143,12 @@ export const usage = `用法:
|
||||
--plan 走「做方案」立项策划入口,不做游戏,不做产物验收和试玩
|
||||
--timeout-minutes <分钟> 设置本次执行期限;自动任务默认 50 分钟,--plan 默认 6 分钟,手工模式默认不限时
|
||||
--dry-run 只检查目录发现和项目准备,不启动 LLM
|
||||
-h, --help 显示帮助`;
|
||||
-h, --help 显示帮助
|
||||
|
||||
环境变量:
|
||||
AGC_PLAN_GDD_DECISION 审批卡自动应答动作,默认 approve;revise/reject 必须
|
||||
同时用 AGC_PLAN_GDD_COMMENT 给出真实修改意见
|
||||
AGC_PLAN_GDD_COMMENT revise/reject 的意见原文`;
|
||||
|
||||
function readOptionValue(args, index, option) {
|
||||
const value = args[index + 1]?.trim();
|
||||
@@ -911,12 +916,15 @@ export function parseSettledSwarmTurnReport(output) {
|
||||
async function runCapturedCargo(
|
||||
cliArguments,
|
||||
setActiveChild,
|
||||
{ timeoutMs = null, label = 'Cargo 子命令' } = {},
|
||||
{ timeoutMs = null, label = 'Cargo 子命令', stdin = null } = {},
|
||||
) {
|
||||
const child = spawnChild(cargoCommand, buildCargoCliArguments(cliArguments), {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
stdio: [stdin === null ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
setActiveChild(child);
|
||||
if (stdin !== null) {
|
||||
child.stdin.end(stdin);
|
||||
}
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.setEncoding('utf8');
|
||||
@@ -1007,7 +1015,9 @@ async function runTaskCargo(
|
||||
let planGddApprovalPromise = null;
|
||||
const startPlanGddApproval = () => {
|
||||
planGddApprovalStarted = true;
|
||||
console.log('[自动审批] 检测到 Fast GDD 审批位,正在提交 approve');
|
||||
console.log(
|
||||
`[自动审批] 检测到 Fast GDD 审批位,正在提交 ${resolvePlanGddAutoDecision().action}`,
|
||||
);
|
||||
planGddApprovalPromise = onPlanGddApprovalWait()
|
||||
.then((value) => {
|
||||
planGddApproval = value;
|
||||
@@ -1842,13 +1852,30 @@ export function parsePlanGddDecisionOutput(output) {
|
||||
);
|
||||
}
|
||||
|
||||
// 审批卡是这条链路唯一的人类判据,所以自动应答只投 approve,且只在投影确实有一张
|
||||
// 待决定审批时出手。revise/reject 需要一段真实的修改意见,让机器编一段等于把判据
|
||||
// 换成噪声;要跑那两条分支就手工调 --plan-gdd-decide。
|
||||
// 审批卡是这条链路唯一的人类判据,所以自动应答默认只投 approve,且只在投影确实有
|
||||
// 一张待决定审批时出手。revise/reject 需要一段真实的修改意见,让机器编一段等于把
|
||||
// 判据换成噪声——所以那两条分支只在跑的人自己用 AGC_PLAN_GDD_COMMENT 给出意见时
|
||||
// 才走。手工调 --plan-gdd-decide 也能达到同样效果,但那要求 plan 根 run 仍然活着,
|
||||
// 而它恰好是本进程持有的 CLI 子进程。
|
||||
export function planGddAutoApprovalIsPending(state) {
|
||||
return Boolean(state?.pendingApproval);
|
||||
}
|
||||
|
||||
export function resolvePlanGddAutoDecision(env = process.env) {
|
||||
const action = (env.AGC_PLAN_GDD_DECISION ?? 'approve').trim();
|
||||
if (!['approve', 'revise', 'reject'].includes(action)) {
|
||||
throw new Error('AGC_PLAN_GDD_DECISION 只能是 approve / revise / reject');
|
||||
}
|
||||
const comment = (env.AGC_PLAN_GDD_COMMENT ?? '').trim();
|
||||
if (action === 'approve') return { action, comment: null };
|
||||
if (!comment) {
|
||||
throw new Error(
|
||||
`${action} 必须同时设 AGC_PLAN_GDD_COMMENT 提供真实修改意见`,
|
||||
);
|
||||
}
|
||||
return { action, comment };
|
||||
}
|
||||
|
||||
async function settlePlanGddApproval(
|
||||
projectPath,
|
||||
runtimeConfigPath,
|
||||
@@ -1875,18 +1902,21 @@ async function settlePlanGddApproval(
|
||||
if (!planGddAutoApprovalIsPending(before)) {
|
||||
return { decided: false, state: before };
|
||||
}
|
||||
const { action, comment } = resolvePlanGddAutoDecision();
|
||||
const decision = await runCapturedCargo(
|
||||
[
|
||||
'--config-dir',
|
||||
runtimeConfigPath,
|
||||
'--plan-gdd-decide',
|
||||
projectPath,
|
||||
'approve',
|
||||
action,
|
||||
...(comment === null ? [] : ['--stdin']),
|
||||
],
|
||||
setActiveChild,
|
||||
{
|
||||
timeoutMs: planGddApprovalTimeoutMs,
|
||||
label: 'Fast GDD 审批决定',
|
||||
stdin: comment,
|
||||
},
|
||||
);
|
||||
if (decision.code !== 0 || decision.signal) {
|
||||
@@ -1926,7 +1956,7 @@ async function reportPlanGddApproval(approval) {
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
` [已批准] outcome=${approval.receipt.outcome} v${approval.receipt.decisionRef.version} 投影状态=${state.state}`,
|
||||
` [已决定 ${approval.receipt.decisionRef.action}] outcome=${approval.receipt.outcome} v${approval.receipt.decisionRef.version} 投影状态=${state.state}`,
|
||||
);
|
||||
if (approval.recovered) {
|
||||
console.log(
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
3. 等待子 Agent 期间不得调用 `respond_to_user`。Runtime 会通过 delegate 完成屏障保持同一父 run,回执到达后再继续。
|
||||
4. 子 Agent 以问询信封退出时,决策卡由 Runtime 直接按信封原文呈现给用户,**不需要你调用任何工具**——你根本不会在那一刻被恢复。用户答完之后你才会拿到答案,届时为该原 delivery 创建且仅创建一次 continuation 委派,`continuationOfDelegationId` 与 `repairOfDelegationId` 都指向该原 delivery。`questionsSha256`、`answersSha256`、`acceptanceCriteria`、`expectedArtifacts` 四个全传 null——Runtime 会从该原 delivery 补齐权威指纹和原委派合同,你不要自己抄。子 Agent 在 continuation 里**再次**以信封退出时,对那条新 delivery 重复同一动作:「仅创建一次」约束的是单条 delivery,不是整条链,澄清预算未用尽时这个循环继续。Runtime 会在委派 task 末尾写明已用轮次与上限,不需要你自己数,也不要替它宣布预算已尽。
|
||||
5. 回执 contractStatus=evidence-ready 且 GDD 已提交时,用 `file.read` 从第 1 行读到 `game/fast_gdd.md` 末尾取证,每次都传 `maxLines: 240`(上限),尽量一页读完;确实需要第二页时从上一页的下一行开始,不要重复读同一段。每次 `file.read` 的 observation 末尾都带着 `sourceAgentId` / `sourceRunId` / `sourceActionId` 三个字段,把它们原样抄成 evidence 的 `{agentId, runId, actionId}`,用一次 `agent.acceptance_update` 一并提交即可——evidence 是按这三个字段整体查回执的,回忆错任何一个都会被判成"缺少持久动作回执"。不要为了取这些字段再去查动作历史。取证完成前审批卡不会出现。
|
||||
6. 用户在审批卡上选择修改或退回时,直接创建返工委派:`repairOfDelegationId` 指向原 delegationId,`runId`、`acceptanceCriteria`、`expectedArtifacts` 都传 null——Runtime 会从原 delivery 继承权威合同,不需要先 `agent.run_status` 去取再手抄。把用户原话完整附在 task 里;同一原委派只能返工一次。用户通过后只做一句简短收尾。
|
||||
6. 用户在审批卡上选择修改或退回时,直接创建返工委派:`repairOfDelegationId` 指向原 delegationId,`runId`、`acceptanceCriteria`、`expectedArtifacts` 都传 null——Runtime 会从原 delivery 继承权威合同,不需要先 `agent.run_status` 去取再手抄。把用户原话完整附在 task 里。「同一原委派只能返工一次」约束的是单条 delivery,不是整条链:用户看过新稿再点一次修改,就对那条新 delivery 重复同一动作,这个循环没有次数上限——`repair_depth` 防的是 runaway agent,而每一轮修订都由用户亲手触发,人本身就是循环边界。不要替 Runtime 宣布「这是最后一次修改机会」,也不要因此把多条意见攒到一轮里改完。用户通过后只做一句简短收尾。
|
||||
|
||||
【转达的规则】
|
||||
|
||||
- 把用户答案回灌给 `project-planning` 时,逐条列出全部已确认决定,每条格式为 `[已确认] 第N轮问的是:{question 原文} | 候选项:{option1.label} / {option2.label} / {option3.label} → 用户答:{原文}`。**问题原文和三个选项标签必须带上**:`{header}` 恒为「第N轮·关键决定」,不含任何信息量;子 Agent 每轮都是全新 run,除了这段正文什么都看不到,只给它 header 和答案,「类似B」「B · 沙盒里程碑成长」这类答案就无从解读,它只能把同一件事再问一遍。用户答案原文一字不改、不归纳、不拆分、不搬轮次;任务长度接近上限时压缩你自己的说明文字和选项描述,绝不压缩用户答案、问题原文和选项标签。
|
||||
- 把用户答案回灌给 `project-planning` 时,逐条列出全部已确认决定,每条格式为 `[已确认] 第N轮问的是:{question 原文} | 候选项:{option1.label} / {option2.label} / {option3.label} → 用户答:{原文}`。**问题原文和三个选项标签必须带上**:`{header}` 只写到「第N轮·当前要决定:{主题}」这一层,答案落在选项上;子 Agent 每轮都是全新 run,除了这段正文什么都看不到,只给它主题和答案,「类似B」「B · 沙盒里程碑成长」这类答案就无从解读,它只能把同一件事再问一遍。用户答案原文一字不改、不归纳、不拆分、不搬轮次;任务长度接近上限时压缩你自己的说明文字和选项描述,绝不压缩用户答案、问题原文和选项标签。
|
||||
- 策划链路的澄清信封**恰好一题**,不是通用静态委派协议里的 1-3 题:`project-planning` 每轮只提一个主要决定,Runtime 也只接受一题,多于一题会在出卡时被拒。委派 task 里不要写“1-3 个结构化问题”。
|
||||
- 上一条格式里的三个选项标签就是决策卡上的 A、B 和“需要原型验证”,必须原样转述、一个都不能省;B 是用户确认的 `confirmed/user_option`,不能转成默认建议。用户后续自由填写推翻了更早的决定时,你只负责把两轮答案的原文都原样带到,并说明后者更晚;怎么记进决定台账由 `project-planning` 判断,不要替它裁定哪条作废。
|
||||
|
||||
|
||||
@@ -3,26 +3,26 @@
|
||||
## 身份与边界
|
||||
|
||||
- 当前 run 固定为 `source=agent-delegate`、`profile=standard`,父 Agent 是 `project-supervisor`。不得伪造、改写或猜测这些 Runtime 身份。
|
||||
- 你不能委派或调度其他 Agent,不能创建 isolated child,不能调用命令、进程、预览、画布、素材生成、写入/补丁/删除工具,也不能改变项目版本或审批事实。
|
||||
- 你的原生工具目录只应包含 `file.read`、`file.list` 以及 Runtime 协议控制函数 `update_agent_plan`、`respond_to_user`;`user.input_request` 不属于你的工具目录。若需要用户决定,必须以终态信封首行 `AGC_NEEDS_USER_INPUT_V1` 退出本轮,下一行给出严格 JSON 信封 `{"questions":[{ ... }]}`,交由 Supervisor 转发。`questions` 恰好一个元素;元素字段只能是 `id`、`header`、`question`、`options` 四个,多写任何字段(例如 `answerFormat`)或省掉 `questions` 外壳都会被 Runtime 拒收,整条委派随即作废。`id` 是唯一 snake_case(小写字母开头,只含小写字母、数字、下划线);`header` 是决策卡标题,单行且不超过 12 字符;`question` 是决策卡正文,单行且不超过 400 字符;`options` 是 2~3 个 `{"label": ..., "description": ...}`,label 单行不超过 60 字符、description 单行不超过 240 字符。不要另起一行写答题说明或把选项复述进 `question`,作答方式由 Runtime 自己呈现。
|
||||
- 你不能委派或调度其他 Agent,不能创建 isolated child,不能调用 MCP、命令、进程、预览、画布、素材生成、写入/补丁/删除工具,也不能改变项目版本或审批事实。
|
||||
- 你的原生工具目录只应包含 `file.read`、`file.list` 以及 Runtime 协议控制函数 `update_agent_plan`、`respond_to_user`;`user.input_request` 不属于你的工具目录。若需要用户决定,必须以终态信封首行 `AGC_NEEDS_USER_INPUT_V1` 退出本轮,下一行给出严格 JSON 信封 `{"questions":[{ ... }]}`,交由 Supervisor 转发。`questions` 恰好一个元素;元素字段只能是 `id`、`header`、`question`、`options` 四个,多写任何字段(例如 `answerFormat`)或省掉 `questions` 外壳都会被 Runtime 拒收,整条委派随即作废。`id` 是唯一 snake_case(小写字母开头,只含小写字母、数字、下划线);`header` 是决策卡标题,写成 `第N轮·当前要决定:<主题>`,单行且不超过 60 字符;`question` 是决策卡正文,单行且不超过 400 字符;`options` 恰好 3 个 `{"label": ..., "description": ...}`,依次是 A、B、逐字“需要原型验证”(详见下文决策卡一段),label 单行不超过 60 字符、description 单行不超过 240 字符。不要另起一行写答题说明或把选项复述进 `question`,作答方式由 Runtime 自己呈现。
|
||||
- 只有 Runtime 广告并允许 `plan.submit_gdd` 时才可提交 GDD;不要假设未广告的工具存在,也不要把 GDD、审批或下游构建写进普通文本。
|
||||
|
||||
## 目标与轮次
|
||||
|
||||
- 最多进行 3 轮关键澄清;每轮是新 run、同一 session。你看得到自己的历史,但用户答案以 Supervisor 委派任务中的转述为准,缺失信息不能臆造。
|
||||
- **默认先澄清。** 出稿只有四个触发器,除此之外每轮都先做下面的字段差距检测再决定问不问:①任务正文出现“直接出稿”这四个字;②已完成第 3 轮澄清(任务正文写明的已用轮次已达上限);③剩余空白都能由默认建议覆盖,且不影响首个可玩闭环;④收到 Runtime 的活跃预算或超时提示。任务正文能改变流程的只有第 ① 条——它写的其它说明属于内容,不是出稿触发器。既定事实(用户答案、已确认决定)仍以任务正文为准。
|
||||
- 每轮提问前逐项对照 `plan-submit-gdd-input.v1` 的 `game` 字段做差距检测:用户明确提供的 = `confirmed`;有依据可推断的 = 按下面的默认建议填写并标 `default_pending`;无从判断**且影响首个可玩闭环**的 = 空白。提问名额只花在空白项上;有默认建议兜底的字段一律先用默认建议,不占轮次。`title`、`oneLiner`、`mvpSystems`、`creatorTips` 由你生成并标 `default_pending`,不作为提问对象;`platformFacts` 禁问。
|
||||
- **默认建议**(一律 `answerSource=default`、`round=0`;只用于缩短对话,不覆盖用户明确输入):`genre.fusion` 缺 → `null`,MVP 不做融合第二类型;`artStyle` 缺 → `visualType` 风格化、轮廓清楚,`keywords` 取自已确认的核心行为,`mvpArtBoundary` 写明 MVP 用占位资产、资产可复用;`targetUsers.sessionLength` 缺 → 10~20 分钟一局;`targetUsers.coreUsers` / `preferences` 缺 → 按已确认的类型与核心行为写典型玩家,不得编造人群规模、销量或市场数据;`targetUsers.referenceGames` 缺 → 空数组;`outOfScope` 缺 → 多人、商城、服务器、开放世界、赛季、复杂社交、完整剧情、全量内容。**`pillars` 与 `coreLoop` 没有默认建议**:它们就是首个可玩闭环本身,空白时属于该问的空白,不得用默认值填掉。
|
||||
- 每轮提问前逐项对照 `plan-submit-gdd-input.v1` 的 `game` 字段做差距检测:用户明确提供的 = `confirmed`;有依据可推断的 = 按下面的默认建议填写并标 `default_pending`;无从判断的 = 空白。提问名额只花在**空白或存疑、且影响首个可玩闭环**的决定上;有默认建议兜底的字段优先用默认建议而不是提问——「有默认」不等于「不能问」,那条默认明显可能是错的、且选错就做不出首个可玩闭环时,它就是一个该问的存疑项。`title`、`oneLiner`、`mvpSystems`、`creatorTips` 由你生成并标 `default_pending`,不作为提问对象;`platformFacts` 禁问。
|
||||
- **默认建议**(一律 `answerSource=default`、`round=0`;只用于缩短对话,不覆盖用户明确输入):`targetUsers.sessionLength` 缺 → 10~20 分钟一局;`artStyle` 缺 → `visualType` 风格化、轮廓清楚,`keywords` 取自已确认的核心行为,`mvpArtBoundary` 写明 MVP 用占位资产、资产可复用;缺成长时 → 1 条成长线和 2~3 个选择;缺探索时 → 1 条主路线加 1 个有意义的岔路;缺构建时 → 高风险输出和稳健防御两种方向。清单之外的字段没有默认值兜底——`genre.fusion`、`targetUsers.coreUsers` / `preferences` / `referenceGames`、`outOfScope` 缺失时都算空白,该不该花一轮问它们由上面的判据决定,不要自己拍一个值填掉就当它已经定了。**`pillars` 与 `coreLoop` 没有默认建议**:它们就是首个可玩闭环本身,空白时属于该问的空白,不得用默认值填掉。
|
||||
- 优先顺序:核心行为与本局目标 → 重玩动力 → 制作边界与 MVP。每轮最多问一个主要决定。**已确认决定关掉的那条轴不得重问。** 任务正文里每条 `[已确认]` 都带着当轮的问题原文和三个选项标签,先照它判断哪些轴已经关闭,本轮的问题必须落在另一条还没关闭的轴上。把已确认答案换个说法再问一遍——例如用户已经选定“自由经营、靠成就和攒钱升级推进”,你又拿“短周期经营目标 vs 沙盒里程碑成长”去问——是白烧一轮预算。所有轴都已关闭时按出稿触发器③直接出稿。
|
||||
- 决策卡的 header 固定为“第N轮·关键决定”,其中 N 是 Runtime 从委派谱系派生的当前轮号,必须精确相等,写错会被 Runtime 拒收:首轮恒为 1;之后每次续跑的任务正文都会写明已用轮次与上限,本轮该用的 N 就是“已用轮次 + 1”。正文以“当前要决定:”开头,只问尚未由平台事实或 MVP 规则排除的真实产品取舍,并说明为什么现在问;每张卡固定提供三个选项:A 是你的推荐方案(label 以 `A ·`、`A:`、`A:` 或 `A-` 开头并写明推荐、好处和代价),B 是形状不同且真实可行的平行备选(label 以 `B ·`、`B:`、`B:` 或 `B-` 开头并写明后果和代价),第三项逐字为“需要原型验证”,description 必须给出 30~90 分钟微型原型、试玩对象、观察信号和通过标准。自由输入按用户原话处理。
|
||||
- 决策卡的 header 写成“第N轮·当前要决定:<主题>”,最多 60 字符。N 是 Runtime 从委派谱系派生的当前轮号,写错会被 Runtime 拒收:首轮恒为 1;之后每次续跑的任务正文都会写明已用轮次与上限,本轮该用的 N 就是“已用轮次 + 1”。`<主题>` 是这一轮真正要定的那件事本身(例如“塔的构筑方式”“每局变化来源”),一句话说完、不带状态标记——它会原样落进决定台账的 `topic`,也是你下一轮辨认哪些轴已经关掉的唯一线索,写成“关键决定”这类空话等于把它作废。正文只问尚未由平台事实或 MVP 规则排除的真实产品取舍,并说明为什么现在问;每张卡固定提供三个选项:A 是你的推荐方案(label 以 `A ·`、`A:`、`A:` 或 `A-` 开头并写明推荐、好处和代价),B 是形状不同且真实可行的平行备选(label 以 `B ·`、`B:`、`B:` 或 `B-` 开头并写明后果和代价),第三项逐字为“需要原型验证”,description 必须给出 30~90 分钟微型原型、试玩对象、观察信号和通过标准。自由输入按用户原话处理。
|
||||
|
||||
## 低幻觉与 GDD 约束
|
||||
|
||||
- 用户描述玩法类型、机制或“像某款游戏”时,不代表授权复刻该游戏。游戏名称、世界观、角色与单位名、阵营、资源、界面术语和视觉语言必须原创;不得沿用、翻译或近似改写现有游戏的专有名称、Logo、标志性布局与受保护视觉语言,也不得把它们写进 GDD 正文、决定台账或原型验证项。用户提到的相似作品只能作为抽象品类参考,`targetUsers.referenceGames` 同样不得填入受保护名称。你的工具面窄,但内容红线不因此放宽——GDD 是整条产线的上游。
|
||||
- 决定台账里,**事实归 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` 里写明与被推翻决定的关系。降级只能往这个方向;用户已作出的决定不得整条丢弃。
|
||||
- 决定台账记录当前 GDD 的决定快照。澄清阶段的 A、B 或自由填写得到的用户决定标 `confirmed`,选择“需要原型验证”标 `prototype_pending`;未提问、由你按默认建议填写的字段标 `default_pending`、`answerSource=default`、`round=0`。审批阶段的用户修改意见是本轮最高优先级:由该意见新增或改写的决定使用 `answerSource=user_revision`、`round=0`,并按当前意见重新填写 `topic`、`state` 和 `answerSummary`。
|
||||
- 以当前 GDD 为基线,仅修改用户审批意见明确涉及的内容,以及为保持内部一致性所必需同步调整的派生内容。未被意见涉及的内容保持不变;如果意见与过去决定冲突,以最新意见为准。不要把用户未要求的其它方向自行扩展进本轮修订。提交时仍须提供完整 GDD 快照,但完整快照不代表可以任意重写未涉及内容。
|
||||
- `prototypeValidationItems` 是必填字段(没有就传空数组),与 `prototype_pending` 决定**一一对应**:每条 `prototype_pending` 决定必须有一个同 id 的验证项,每个验证项也必须对应一条 `prototype_pending` 决定,最多 3 项。除了用户亲选“需要原型验证”之外,你自己也可以主动标:手感、节奏、可读性、难度曲线这类你没问过、但选错就做不出首个可玩闭环的判断,标 `prototype_pending`(`answerSource=default`、`round=0`)比标 `default_pending` 诚实——那不是一个默认值,是一个没人验证过的假设。每项写清 30~90 分钟微型原型做什么、让谁试玩、观察什么信号、什么算通过。
|
||||
- 不得编造具体游戏的机制、数值、销量、人群规模、团队规模或来源。写 `targetUsers` 时按已确认的类型与核心行为描述典型玩家即可。
|
||||
- 只定义一个完整可玩闭环。MVP 不含多人、商城、服务器、开放世界、赛季、复杂社交、完整剧情或全量内容,除非用户明确改变范围。
|
||||
- GDD 至少覆盖:游戏名称与类型、一句话描述、2~4 条游戏支柱、核心循环、目标用户、美术方向、3~6 个最小 MVP 系统、先做/暂缓/验证/扩展条件、决定状态和审批请求。不要把 Runtime 注入的身份、时间、指纹、审批 receipt 或平台事实当作 Provider 输入字段。
|
||||
- 平台事实由 Runtime 固定注入为自包含 Web、desktop/mobile 双视口、keyboard/touch 双输入、本地 HTTP 预览;不得修改、删减或向用户询问。
|
||||
|
||||
@@ -872,13 +872,17 @@ mod tests {
|
||||
"brief 要点名这个真实踩过的坑"
|
||||
);
|
||||
for stated in [
|
||||
format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS} 字符"),
|
||||
// 策划卡的 header 走 `AGENT_RUNTIME_USER_INPUT_MAX_PLAN_HEADER_CHARS`:它装的是
|
||||
// 这一轮要定的主题本身,不是通用问询那 12 字的标题格。钉住的仍是「brief 与
|
||||
// 解析器同一把尺子」,只是尺子换成了策划链路实际生效的那一把。
|
||||
format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_PLAN_HEADER_CHARS} 字符"),
|
||||
format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_QUESTION_CHARS} 字符"),
|
||||
format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_OPTION_LABEL_CHARS} 字符"),
|
||||
format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_OPTION_DESCRIPTION_CHARS} 字符"),
|
||||
format!(
|
||||
"{AGENT_RUNTIME_USER_INPUT_MIN_OPTIONS}~{AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS} 个"
|
||||
),
|
||||
// 选项数同理:通用协议是 2-3 个,策划决策卡恒为 A / B /「需要原型验证」
|
||||
// 三项。brief 早先照通用常量写「2~3 个」,和它自己下文的「固定提供三个
|
||||
// 选项」以及 `planning_coordinator` 的硬校验三方打架。
|
||||
format!("恰好 {PLAN_CLARIFICATION_OPTION_COUNT} 个"),
|
||||
] {
|
||||
assert!(
|
||||
planning.contains(&stated),
|
||||
@@ -985,9 +989,17 @@ mod tests {
|
||||
/// 这条会跟着红。
|
||||
///
|
||||
/// 二、`pillars` / `coreLoop` 明确排除在清单外:它们就是首个可玩闭环本身,
|
||||
/// 给它们配默认值等于把最该花提问预算的那两项默认掉。原型那份清单里的
|
||||
/// 成长 / 探索 / 构建三条落到本仓库的 schema 上正好落在这两个字段上,照抄
|
||||
/// 会和「提问顺序:核心行为与本局目标 → 重玩动力」的前两顺位直接打架。
|
||||
/// 给它们配默认值等于把最该花提问预算的那两项默认掉。
|
||||
///
|
||||
/// 清单成员已按原型(`local-scripts/deisgn_agent/prompts.py:136`)那五条拉齐:
|
||||
/// 局长偏好、美术、成长、探索、构建。`genre.fusion` / `targetUsers.coreUsers`
|
||||
/// / `preferences` / `referenceGames` / `outOfScope` 从清单里摘掉了——它们
|
||||
/// 原型就没有默认值,进了清单就等于把第三顺位「制作边界与 MVP」整条轴默认
|
||||
/// 掉,出稿触发器③「剩余空白都能由默认建议覆盖」随之在第 3 轮恒真,3 轮预算
|
||||
/// 实际只花得出 2 轮。成长 / 探索 / 构建三条与上面那句不冲突:它们是维度级
|
||||
/// 缺省内容,不是 `pillars` / `coreLoop` 两个字段的缺省值,而且「优先用默认
|
||||
/// 建议而不是提问」是软优先级,不禁止提问——原型正是带着这三条默认,仍然把
|
||||
/// 第 1 轮花在 coreLoop、第 2 轮花在重玩动力上。
|
||||
///
|
||||
/// 三、出稿触发器是闭集。生产实测过 Supervisor 会把「若缺少会实质改变结果的
|
||||
/// 事实才提问,否则直接提交」写进委派 task,子 Agent 照办后 0 轮出稿;这里
|
||||
@@ -1003,15 +1015,27 @@ mod tests {
|
||||
planning.contains("**默认建议**"),
|
||||
"role brief 三处引用「默认建议」,清单本身必须在场"
|
||||
);
|
||||
for field in [
|
||||
"`genre.fusion`",
|
||||
"`artStyle`",
|
||||
"`targetUsers.sessionLength`",
|
||||
"`targetUsers.referenceGames`",
|
||||
"`outOfScope`",
|
||||
] {
|
||||
for field in ["`artStyle`", "`targetUsers.sessionLength`"] {
|
||||
assert!(planning.contains(field), "默认建议清单缺少字段 {field}");
|
||||
}
|
||||
for dimension in ["缺成长时", "缺探索时", "缺构建时"] {
|
||||
assert!(
|
||||
planning.contains(dimension),
|
||||
"默认建议清单缺少原型的维度级缺省 {dimension}"
|
||||
);
|
||||
}
|
||||
// 反向:这几个字段一旦回到默认清单,轴三就又被默认掉了。它们仍会在 brief 里
|
||||
// 出现(被点名为「没有默认值兜底」),所以只能钉「缺 → 」这个清单条目形状。
|
||||
for defaulted in [
|
||||
"`genre.fusion` 缺 →",
|
||||
"`targetUsers.referenceGames` 缺 →",
|
||||
"`outOfScope` 缺 →",
|
||||
] {
|
||||
assert!(
|
||||
!planning.contains(defaulted),
|
||||
"{defaulted} 不得回到默认建议清单:那会让出稿触发器③在第 3 轮恒真"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
planning.contains("**`pillars` 与 `coreLoop` 没有默认建议**"),
|
||||
"pillars / coreLoop 不得进默认建议清单"
|
||||
@@ -1528,12 +1552,15 @@ mod tests {
|
||||
|
||||
/// 澄清回灌必须带上问题原文和三个选项标签,两端都要钉住。
|
||||
///
|
||||
/// `header` 按信封契约恒为「第N轮·关键决定」,零信息量;而 `project-planning`
|
||||
/// 每轮都是全新 run(`observations: []`),除了委派任务正文什么都看不到。只回灌
|
||||
/// `{header} → 用户答:{原文}` 时,「类似B」「B · 沙盒里程碑成长」这类答案无从
|
||||
/// 解读——生产实测的农场经营项目里,第 1 轮问「季节订单冲刺 vs 自主农场成长」,
|
||||
/// 用户答了 B,第 2 轮又拿「短周期经营目标 vs 沙盒里程碑成长」问同一条轴,
|
||||
/// 而且 B 选项几乎是用户原话的复述。
|
||||
/// `header` 现在带主题(「第N轮·当前要决定:{主题}」),但只到主题这一层——用户
|
||||
/// 拍的板落在**选项**上。而 `project-planning` 每轮都是全新 run(`observations: []`),
|
||||
/// 除了委派任务正文什么都看不到。只回灌 `{header} → 用户答:{原文}` 时,
|
||||
/// 「类似B」「B · 沙盒里程碑成长」这类答案仍然无从解读——生产实测的农场经营项目里,
|
||||
/// 第 1 轮问「季节订单冲刺 vs 自主农场成长」,用户答了 B,第 2 轮又拿「短周期经营
|
||||
/// 目标 vs 沙盒里程碑成长」问同一条轴,而且 B 选项几乎是用户原话的复述。
|
||||
///
|
||||
/// 这条与 header 带不带主题正交:主题解决「问过哪些轴」,选项标签解决「答案指的是
|
||||
/// 哪一个」。两端都得钉。
|
||||
#[test]
|
||||
fn plan_clarification_relay_carries_the_question_and_option_labels() {
|
||||
let plan = required_runtime_prompt_section("planSupervisorPlaybook");
|
||||
|
||||
@@ -150,6 +150,6 @@ pub(crate) use tool_policy_snapshot::{
|
||||
agent_runtime_native_executable_tools, agent_runtime_plan_root_supervisor_tools,
|
||||
agent_runtime_plan_root_supervisor_tools_for_stage,
|
||||
agent_runtime_tool_policy_snapshot_for_run_at, plan_root_supervisor_stage_at,
|
||||
PlanRootSupervisorStage, AGENT_RUNTIME_CANVAS_ASSET_KINDS,
|
||||
AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS,
|
||||
plan_root_supervisor_stage_at_locked, PlanRootSupervisorStage,
|
||||
AGENT_RUNTIME_CANVAS_ASSET_KINDS, AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS,
|
||||
};
|
||||
|
||||
@@ -995,6 +995,25 @@ pub(in crate::agent) fn static_delegate_barrier_has_waiting_deliveries(detail: &
|
||||
waiting || user_revision_pending || unknown_contract_status
|
||||
}
|
||||
|
||||
/// `StaticDelegateCompletionBarrier::has_external_wait()` 的 detail 侧等价物。
|
||||
///
|
||||
/// 与 `static_delegate_barrier_has_waiting_deliveries` 的差别只有一项:不计
|
||||
/// `userRevisionPending`。park 决策必须用这个——用户修订没有任何外部事件可等,
|
||||
/// park 住就是等自己派出的委派,必然死锁。自动唤醒侧仍然用前者收手。
|
||||
pub(in crate::agent) fn static_delegate_barrier_has_external_wait(detail: &str) -> bool {
|
||||
let waiting = detail
|
||||
.split_whitespace()
|
||||
.find_map(|part| part.strip_prefix("waitingDelegations="))
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.is_some_and(|count| count > 0);
|
||||
let unknown_contract_status = detail
|
||||
.split_whitespace()
|
||||
.find_map(|part| part.strip_prefix("unknownContractStatus="))
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.is_some_and(|count| count > 0);
|
||||
waiting || unknown_contract_status
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn static_delegate_barrier_requires_repair(detail: &str) -> bool {
|
||||
detail
|
||||
.split_whitespace()
|
||||
@@ -1888,6 +1907,21 @@ mod static_delegate_barrier_detail_gate_tests {
|
||||
barrier.user_revision_pending_count > 0,
|
||||
"userRevisionPending 往返失真:{barrier:?}\ndetail={detail}"
|
||||
);
|
||||
assert_eq!(
|
||||
static_delegate_barrier_has_external_wait(&detail),
|
||||
barrier.has_external_wait(),
|
||||
"has_external_wait() 与 detail 解析必须等价:{barrier:?}\ndetail={detail}"
|
||||
);
|
||||
// 两个谓词只能在「仅 userRevisionPending」这一种情形上分叉,别的组合必须一致。
|
||||
// 分叉点写死在这里:park 决策用 has_external_wait,自动唤醒收手用 has_waiting,
|
||||
// 哪天有人把两者合并回一个,这条会先炸。
|
||||
assert_eq!(
|
||||
barrier.has_waiting() && !barrier.has_external_wait(),
|
||||
barrier.user_revision_pending_count > 0
|
||||
&& barrier.waiting_count == 0
|
||||
&& barrier.unknown_contract_status_count == 0,
|
||||
"两个等待谓词只应在「仅用户修订待办」时分叉:{barrier:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+69
-2
@@ -152,8 +152,9 @@ pub(in crate::agent) fn remove_autonomous_art_director_non_canvas_validation_too
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
|
||||
fn build_game_creator_agent_background_tool_plan_request_at(
|
||||
root: &Path,
|
||||
project_lock: Option<&ProjectWriteLock>,
|
||||
agent_id: &str,
|
||||
session_id: &str,
|
||||
run_id: &str,
|
||||
@@ -509,7 +510,12 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
|
||||
)?)
|
||||
.with_tool_choice(platform_llm::LlmToolChoice::Required);
|
||||
if plan_root {
|
||||
let stage = plan_root_supervisor_stage_at(root, agent_id, run_id)?;
|
||||
let stage = match project_lock {
|
||||
Some(project_lock) => {
|
||||
plan_root_supervisor_stage_at_locked(root, project_lock, agent_id, run_id)?
|
||||
}
|
||||
None => plan_root_supervisor_stage_at(root, agent_id, run_id)?,
|
||||
};
|
||||
retain_plan_root_supervisor_native_tools(&mut request.function_tools, stage)?;
|
||||
// 固定单节点 schema 只对还在广告 agent.goal_contract 的阶段有意义;收窄之后
|
||||
// 它已经不在目录里,此处再调只会撞上那道 fail-closed 的"缺少工具"守卫。
|
||||
@@ -643,6 +649,67 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
|
||||
))
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
session_id: &str,
|
||||
run_id: &str,
|
||||
task: &str,
|
||||
observations: &[AgentRuntimeToolObservation],
|
||||
loop_index: usize,
|
||||
) -> Result<
|
||||
(
|
||||
GameCreatorLlmConfig,
|
||||
String,
|
||||
LlmRunRequest,
|
||||
String,
|
||||
AgentRuntimeToolPlanRequestSnapshot,
|
||||
),
|
||||
String,
|
||||
> {
|
||||
build_game_creator_agent_background_tool_plan_request_at(
|
||||
root,
|
||||
None,
|
||||
agent_id,
|
||||
session_id,
|
||||
run_id,
|
||||
task,
|
||||
observations,
|
||||
loop_index,
|
||||
)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request_locked(
|
||||
root: &Path,
|
||||
project_lock: &ProjectWriteLock,
|
||||
agent_id: &str,
|
||||
session_id: &str,
|
||||
run_id: &str,
|
||||
task: &str,
|
||||
observations: &[AgentRuntimeToolObservation],
|
||||
loop_index: usize,
|
||||
) -> Result<
|
||||
(
|
||||
GameCreatorLlmConfig,
|
||||
String,
|
||||
LlmRunRequest,
|
||||
String,
|
||||
AgentRuntimeToolPlanRequestSnapshot,
|
||||
),
|
||||
String,
|
||||
> {
|
||||
build_game_creator_agent_background_tool_plan_request_at(
|
||||
root,
|
||||
Some(project_lock),
|
||||
agent_id,
|
||||
session_id,
|
||||
run_id,
|
||||
task,
|
||||
observations,
|
||||
loop_index,
|
||||
)
|
||||
}
|
||||
|
||||
pub(in crate::agent) fn build_game_creator_agent_background_final_reply_request(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
|
||||
+6
-3
@@ -239,8 +239,9 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
|
||||
== AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
|
||||
&& agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|
||||
&& autonomous_manifest_dag_in_progress_at(root)?;
|
||||
let request = build_game_creator_agent_background_tool_plan_request(
|
||||
let request = build_game_creator_agent_background_tool_plan_request_locked(
|
||||
root,
|
||||
&_lock,
|
||||
agent_id,
|
||||
session_id,
|
||||
run_id,
|
||||
@@ -303,8 +304,9 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
|
||||
== AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
|
||||
&& agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|
||||
&& autonomous_manifest_dag_in_progress_at(root)?;
|
||||
let request = build_game_creator_agent_background_tool_plan_request(
|
||||
let request = build_game_creator_agent_background_tool_plan_request_locked(
|
||||
root,
|
||||
&_lock,
|
||||
agent_id,
|
||||
session_id,
|
||||
run_id,
|
||||
@@ -356,8 +358,9 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
|
||||
// that lock so a session successor cannot be used to re-label an
|
||||
// object assembled from an older session.
|
||||
if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
|
||||
built_request = build_game_creator_agent_background_tool_plan_request(
|
||||
built_request = build_game_creator_agent_background_tool_plan_request_locked(
|
||||
root,
|
||||
&_lock,
|
||||
agent_id,
|
||||
session_id,
|
||||
run_id,
|
||||
|
||||
+65
@@ -1847,3 +1847,68 @@ fn finalization_v4_binds_response_request_slot_into_identity() {
|
||||
.expect_err("tampered v4 responseRequestSlot must break finalization identity");
|
||||
assert!(error.contains("幂等身份不匹配"));
|
||||
}
|
||||
|
||||
/// 澄清信封退出不受结构化计划完成度判据约束,普通交付收束仍然受。
|
||||
///
|
||||
/// 这两半是同一条不变量的两面,缺任何一面都是活锁:`respond_to_user` 是问询唯一
|
||||
/// 的出口,而计划里「按用户决定收敛」那一步在用户答之前不可能 completed——用完成
|
||||
/// 度拦信封,就等于问不出去、答不了、永远重试。实测一条生产 run 因此空转 65 轮。
|
||||
#[test]
|
||||
fn a_user_input_envelope_finalizes_while_an_incomplete_plan_still_blocks_delivery() {
|
||||
let (project, mut state, response_revision, _snapshot) =
|
||||
response_stream_fixture("finalization-user-input-envelope-run");
|
||||
let root = project.path();
|
||||
state.plan_revision = 1;
|
||||
state.plan_explanation = "先问清核心闭环再出稿。".to_string();
|
||||
state.plan = vec!["发起首轮澄清".to_string(), "按用户决定出稿".to_string()];
|
||||
state.plan_steps = vec![
|
||||
AgentRuntimePlanStep {
|
||||
index: 0,
|
||||
title: "发起首轮澄清".to_string(),
|
||||
status: AGENT_RUNTIME_PLAN_STATUS_IN_PROGRESS.to_string(),
|
||||
detail: None,
|
||||
updated_at: unix_timestamp(),
|
||||
},
|
||||
AgentRuntimePlanStep {
|
||||
index: 1,
|
||||
title: "按用户决定出稿".to_string(),
|
||||
status: AGENT_RUNTIME_PLAN_STATUS_PENDING.to_string(),
|
||||
detail: None,
|
||||
updated_at: unix_timestamp(),
|
||||
},
|
||||
];
|
||||
state.active_plan_step_index = Some(0);
|
||||
write_game_creator_agent_runtime_state(root, &state).expect("write incomplete plan state");
|
||||
|
||||
let delivery = "已完成本轮交付。";
|
||||
let blocked = finish_game_creator_agent_background_runtime_turn_at(
|
||||
root,
|
||||
state.clone(),
|
||||
delivery,
|
||||
response_revision,
|
||||
&[],
|
||||
)
|
||||
.expect("finalize plain delivery");
|
||||
match blocked {
|
||||
AgentBackgroundFinalizationOutcome::Stale(blocker) => {
|
||||
assert_eq!(blocker.tool, "runtime.plan_update");
|
||||
}
|
||||
other => panic!("计划未完成时普通交付收束必须被拦下,实际 {other:?}"),
|
||||
}
|
||||
|
||||
let envelope = format!(
|
||||
"{STATIC_DELEGATE_USER_INPUT_PREFIX}{{\"questions\":[{{\"id\":\"core_loop\",\"header\":\"第1轮·当前要决定:核心闭环\",\"question\":\"本局主要追求什么?\",\"options\":[{{\"label\":\"A · 推荐:抵达终点\",\"description\":\"沿路线避障抵达终点。\"}},{{\"label\":\"B · 计分生存\",\"description\":\"在加速路线里刷新分数。\"}},{{\"label\":\"需要原型验证\",\"description\":\"各做一个最小原型让目标玩家试玩。\"}}]}}]}}"
|
||||
);
|
||||
let finalized = finish_game_creator_agent_background_runtime_turn_at(
|
||||
root,
|
||||
state,
|
||||
&envelope,
|
||||
response_revision,
|
||||
&[],
|
||||
)
|
||||
.expect("finalize clarification envelope");
|
||||
assert!(
|
||||
!matches!(finalized, AgentBackgroundFinalizationOutcome::Stale(_)),
|
||||
"澄清信封是挂起等用户答,不能被计划完成度判据拦下"
|
||||
);
|
||||
}
|
||||
|
||||
+38
-6
@@ -159,6 +159,9 @@ pub(crate) enum PlanRootSupervisorStage {
|
||||
GoalContract,
|
||||
/// 合同已冻结但本根 run 还没有任何委派:唯一能推进的动作是派出策划子 Agent。
|
||||
Delegate,
|
||||
/// 最新 GDD 已提交但尚未完成当前根 Run 的 Acceptance Graph 取证:只能读取
|
||||
/// `game/fast_gdd.md`、更新验收图或重放状态,不能抢先创建重复策划 delivery。
|
||||
AwaitingAcceptanceEvidence,
|
||||
/// 已有委派:取证、返工与审批相关工具全部开放。
|
||||
Delegated,
|
||||
}
|
||||
@@ -169,6 +172,9 @@ pub(crate) fn agent_runtime_plan_root_supervisor_tools_for_stage(
|
||||
match stage {
|
||||
PlanRootSupervisorStage::GoalContract => &["agent.goal_contract"],
|
||||
PlanRootSupervisorStage::Delegate => &["agent.delegate"],
|
||||
PlanRootSupervisorStage::AwaitingAcceptanceEvidence => {
|
||||
&["file.read", "agent.acceptance_update", "agent.run_status"]
|
||||
}
|
||||
// 合同已冻结且不可重写,再广告 agent.goal_contract 只会诱导一次必被拒的调用。
|
||||
PlanRootSupervisorStage::Delegated => &[
|
||||
"file.read",
|
||||
@@ -195,6 +201,7 @@ mod plan_root_stage_tests {
|
||||
let union = [
|
||||
PlanRootSupervisorStage::GoalContract,
|
||||
PlanRootSupervisorStage::Delegate,
|
||||
PlanRootSupervisorStage::AwaitingAcceptanceEvidence,
|
||||
PlanRootSupervisorStage::Delegated,
|
||||
]
|
||||
.into_iter()
|
||||
@@ -224,22 +231,47 @@ mod plan_root_stage_tests {
|
||||
}
|
||||
|
||||
/// 只按 durable 事实判定阶段,不看 Provider 说了什么。
|
||||
pub(crate) fn plan_root_supervisor_stage_at(
|
||||
///
|
||||
/// 调用方必须已经持有当前项目写锁;需要自行取得锁的调用方使用下面的
|
||||
/// `plan_root_supervisor_stage_at` 包装入口。这样 Provider 请求构建路径可以复用外层
|
||||
/// 已有的项目锁,不会在阶段判定中再次获取同一把非重入锁。
|
||||
pub(crate) fn plan_root_supervisor_stage_at_locked(
|
||||
root: &Path,
|
||||
project_lock: &ProjectWriteLock,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
) -> Result<PlanRootSupervisorStage, String> {
|
||||
if !project_lock.guards_project_root(root)? {
|
||||
return Err("plan Supervisor 阶段判定缺少当前项目写锁".to_string());
|
||||
}
|
||||
if read_game_creator_agent_runtime_goal_contract_at(root, agent_id, run_id)?.is_none() {
|
||||
return Ok(PlanRootSupervisorStage::GoalContract);
|
||||
}
|
||||
let delegated = list_static_delegate_deliveries_at(root)?
|
||||
.into_iter()
|
||||
.any(|delivery| delivery.parent_agent_id == agent_id && delivery.parent_run_id == run_id);
|
||||
Ok(if delegated {
|
||||
PlanRootSupervisorStage::Delegated
|
||||
} else {
|
||||
PlanRootSupervisorStage::Delegate
|
||||
})
|
||||
if !delegated {
|
||||
return Ok(PlanRootSupervisorStage::Delegate);
|
||||
}
|
||||
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|
||||
&& plan_root_supervisor_acceptance_evidence_required_locked(root, project_lock, run_id)?
|
||||
{
|
||||
return Ok(PlanRootSupervisorStage::AwaitingAcceptanceEvidence);
|
||||
}
|
||||
Ok(PlanRootSupervisorStage::Delegated)
|
||||
}
|
||||
|
||||
/// 供未持有项目写锁的调用方使用的阶段判定入口。
|
||||
pub(crate) fn plan_root_supervisor_stage_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
run_id: &str,
|
||||
) -> Result<PlanRootSupervisorStage, String> {
|
||||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"planning.supervisor-stage",
|
||||
)?;
|
||||
plan_root_supervisor_stage_at_locked(root, &_lock, agent_id, run_id)
|
||||
}
|
||||
|
||||
pub(crate) fn agent_runtime_native_executable_tools() -> Vec<&'static str> {
|
||||
|
||||
@@ -393,7 +393,11 @@ pub(in crate::agent) fn resume_game_creator_agent_finalization_at(
|
||||
}
|
||||
if journal.status == AGENT_RUNTIME_FINALIZATION_STATUS_PREPARED && !assistant_exists {
|
||||
let current_revision = read_game_creator_agent_runtime_project_revision(root)?;
|
||||
let blocker = if let Some(blocker) = structured_plan_completion_blocker(&state) {
|
||||
// 与 `finish_game_creator_agent_background_runtime_turn_with_checkpoint_at`
|
||||
// 同一判据:澄清信封是挂起等用户答,不是交付收束,用计划完成度拦它会死锁。
|
||||
let blocker = if let Some(blocker) = structured_plan_completion_blocker(&state)
|
||||
.filter(|_| !response_is_static_delegate_user_input_envelope(&journal.response))
|
||||
{
|
||||
Some(blocker)
|
||||
} else if let Some(blocker) =
|
||||
plan_gdd_completion_blocker_at_locked(root, &journal.agent_id, &journal.run_id)
|
||||
|
||||
@@ -208,15 +208,10 @@ pub(super) fn game_creator_agent_final_reply_error_allows_fallback(error: &str)
|
||||
matches!(kind.as_str(), "empty-response" | "deserialize")
|
||||
}
|
||||
|
||||
/// `PLAN_SESSION_DECISIONS_MISMATCH` 与前两者同类:错的是本次 Provider input,
|
||||
/// durable 权威完好,把拒绝理由回灌给策划子 Agent 它就能改。真 CAS
|
||||
/// (`PLAN_SESSION_CAS_CONFLICT`)不在此列——那说明 session 已被推进或损坏,
|
||||
/// 重交同一份 input 不可能成功,必须 reconcile。
|
||||
/// 这些错误只描述本次 Provider input 或候选 GDD;真正的 session CAS 冲突不在
|
||||
/// 此列——那说明 durable session 已被推进或损坏,必须 reconcile。
|
||||
fn plan_submit_error_is_business_rejection(error: &PlanningStorageError) -> bool {
|
||||
matches!(
|
||||
error.code(),
|
||||
"PLAN_INVALID_REQUEST" | "PLAN_SIZE_LIMIT" | "PLAN_SESSION_DECISIONS_MISMATCH"
|
||||
)
|
||||
matches!(error.code(), "PLAN_INVALID_REQUEST" | "PLAN_SIZE_LIMIT")
|
||||
}
|
||||
|
||||
/// A malformed Fast GDD is useful feedback for the planning child, but it
|
||||
@@ -255,6 +250,39 @@ fn finish_plan_submit_business_rejection_limit_at(
|
||||
/// Runtime state 上,进程重启不能把一次活锁洗成新的无限 Provider 开销。
|
||||
const AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT: u32 = 4;
|
||||
|
||||
/// 最终回复被收束门禁拦下后 run 会原地续跑重试。多数 blocker 是模型自己能解的
|
||||
/// (补动作、补证据、重新规划),所以这里的额度比上面两个宽得多;它拦的是另一
|
||||
/// 类:模型根本无法满足的 blocker。那种情况下每一轮都是同一个请求换来同一个拒绝,
|
||||
/// 没有任何计数器会累加——空转闸只认裸 `update_agent_plan`,而这里模型每轮都在
|
||||
/// 认真调 `respond_to_user`。实测一条生产 run 因此空转 65 轮直到人工介入。
|
||||
const AGENT_RUNTIME_STALE_FINALIZATION_LIMIT: u32 = 32;
|
||||
|
||||
fn stale_finalization_limit_reached(rounds: u32) -> bool {
|
||||
rounds >= AGENT_RUNTIME_STALE_FINALIZATION_LIMIT
|
||||
}
|
||||
|
||||
fn next_stale_finalization_rounds(current: u32) -> (u32, bool) {
|
||||
let next = current.saturating_add(1);
|
||||
(next, stale_finalization_limit_reached(next))
|
||||
}
|
||||
|
||||
fn finish_stale_finalization_limit_at(
|
||||
root: &Path,
|
||||
runtime: &AgentRuntimeState,
|
||||
) -> Result<AgentBackgroundTaskOutcome, String> {
|
||||
let error = format!(
|
||||
"最终回复连续 {} 轮被收束门禁拦下,已停止自动续跑;请检查最后一次 blocker observation 后重新发起本轮任务。",
|
||||
AGENT_RUNTIME_STALE_FINALIZATION_LIMIT
|
||||
);
|
||||
let failed = fail_game_creator_agent_runtime_turn_at(root, runtime.clone(), &error)?;
|
||||
let _ = append_game_creator_agent_background_task_failed_audit(
|
||||
root,
|
||||
&failed,
|
||||
AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_STALE_FINALIZATION_LIMIT,
|
||||
);
|
||||
Ok(AgentBackgroundTaskOutcome::Finished)
|
||||
}
|
||||
|
||||
/// 纯只读工具不算「推进」。
|
||||
///
|
||||
/// 空转计数只在**裸 `update_agent_plan` 且步骤没有真实变化**时累加,早期实现却让
|
||||
@@ -350,6 +378,22 @@ mod plan_update_idle_guard_threshold_tests {
|
||||
assert!(first_repair_round < AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT);
|
||||
}
|
||||
|
||||
/// 最终回复重试额度是 runaway 兜底,不是主判据:它必须留在两道软闸之上,
|
||||
/// 让「摘掉 update_agent_plan 逼它调真动作」和空转闸先有机会自愈。调到软闸
|
||||
/// 以下,兜底就会抢在自愈之前把正常 run 打断。
|
||||
#[test]
|
||||
fn the_stale_finalization_backstop_sits_above_the_self_healing_guards() {
|
||||
assert!(AGENT_RUNTIME_STALE_FINALIZATION_LIMIT > AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT);
|
||||
assert!(AGENT_RUNTIME_STALE_FINALIZATION_LIMIT > PLAN_SUBMIT_GDD_BUSINESS_REJECTION_LIMIT);
|
||||
assert!(!stale_finalization_limit_reached(
|
||||
AGENT_RUNTIME_STALE_FINALIZATION_LIMIT - 1
|
||||
));
|
||||
assert_eq!(
|
||||
next_stale_finalization_rounds(AGENT_RUNTIME_STALE_FINALIZATION_LIMIT - 1),
|
||||
(AGENT_RUNTIME_STALE_FINALIZATION_LIMIT, true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_limit_is_reached_only_at_the_configured_round() {
|
||||
assert!(!plan_update_idle_limit_reached(0));
|
||||
@@ -459,6 +503,8 @@ const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_PLAN_SUBMIT_REJECTION_LIMIT: &str =
|
||||
"plan-submit-validation-retries-exhausted";
|
||||
const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_PLAN_UPDATE_IDLE_LIMIT: &str =
|
||||
"plan-update-idle-rounds-exhausted";
|
||||
const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_STALE_FINALIZATION_LIMIT: &str =
|
||||
"stale-finalization-rounds-exhausted";
|
||||
const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_BUDGET: &str = "loop-budget-exhausted";
|
||||
const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_FINAL_REPLY: &str = "final-reply-failed";
|
||||
const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_FINALIZATION: &str = "finalization-failed";
|
||||
@@ -531,6 +577,20 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
|
||||
};
|
||||
}
|
||||
|
||||
// 同上:计数随上一轮的 blocker 一起落盘,重启不能把第 N 次被拦洗成新一轮。
|
||||
if stale_finalization_limit_reached(runtime.stale_finalization_rounds) {
|
||||
return match finish_stale_finalization_limit_at(&root, &runtime) {
|
||||
Ok(outcome) => outcome,
|
||||
Err(error) => fail_game_creator_agent_background_context_at(
|
||||
&root,
|
||||
&agent_id,
|
||||
&session_id,
|
||||
runtime,
|
||||
&format!("收束已耗尽的最终回复重试失败:{error}"),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if continuation.applied_steer_cursor < runtime.applied_steer_cursor {
|
||||
return fail_game_creator_agent_background_context_at(
|
||||
&root,
|
||||
@@ -681,8 +741,11 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
|
||||
if let Some(blocker) =
|
||||
static_delegate_completion_blocker_at(&root, &agent_id, &runtime.run_id)
|
||||
{
|
||||
// park 只在真有外部事件可等时才对。用户修订待办不是外部事件——那条回执
|
||||
// 只能来自本 run 自己创建的修订委派,park 住就是等自己。下面 1700 行附近
|
||||
// 的 `user_revision_pending` 分支才是它该去的地方。
|
||||
let waits_for_delivery = blocker.detail.as_deref().is_some_and(|detail| {
|
||||
static_delegate_barrier_has_waiting_deliveries(detail)
|
||||
static_delegate_barrier_has_external_wait(detail)
|
||||
|| static_delegate_barrier_requires_user_input(detail)
|
||||
});
|
||||
if waits_for_delivery {
|
||||
@@ -1559,6 +1622,9 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
|
||||
{
|
||||
// 本轮至少有一个能推进 durable 状态的动作,计划没有空转。
|
||||
runtime.plan_update_idle_rounds = 0;
|
||||
// 同一个判据也给最终回复重试额度解锁:真实推进之后再被拦,是新的一
|
||||
// 轮尝试,不该继承上一段死循环的计数。
|
||||
runtime.stale_finalization_rounds = 0;
|
||||
}
|
||||
if plan.actions.is_empty() {
|
||||
// blocked 的 plan_gdd blocker 有三种截然不同的继续推进态,phase 与
|
||||
@@ -1776,7 +1842,7 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
|
||||
AgentBackgroundTaskOutcome::WaitingForIsolatedJoin,
|
||||
))
|
||||
} else if observation.tool == "runtime.delegate_receipts"
|
||||
&& (static_delegate_barrier_has_waiting_deliveries(detail)
|
||||
&& (static_delegate_barrier_has_external_wait(detail)
|
||||
|| static_delegate_barrier_requires_user_input(detail))
|
||||
{
|
||||
Some((
|
||||
@@ -3844,6 +3910,9 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
|
||||
AgentBackgroundTaskOutcome::Finished
|
||||
}
|
||||
Ok(AgentBackgroundFinalizationOutcome::Stale(blocker)) => {
|
||||
let (stale_rounds, exhausted) =
|
||||
next_stale_finalization_rounds(runtime.stale_finalization_rounds);
|
||||
runtime.stale_finalization_rounds = stale_rounds;
|
||||
if let Err(error) = provider_handoff::remove_at(&root, &agent_id, &runtime.run_id) {
|
||||
return fail_game_creator_agent_background_context_at(
|
||||
&root,
|
||||
@@ -3873,6 +3942,20 @@ async fn run_game_creator_agent_background_task_pass_without_deadline(
|
||||
);
|
||||
}
|
||||
};
|
||||
// 计数已随 blocker 一起落盘,这里才收束:让最后一次拒绝的 observation
|
||||
// 留在续跑上下文里,失败原因指得回具体 blocker 而不是一句「超限」。
|
||||
if exhausted {
|
||||
return match finish_stale_finalization_limit_at(&root, &runtime) {
|
||||
Ok(outcome) => outcome,
|
||||
Err(error) => fail_game_creator_agent_background_context_at(
|
||||
&root,
|
||||
&agent_id,
|
||||
&session_id,
|
||||
runtime,
|
||||
&format!("收束已耗尽的最终回复重试失败:{error}"),
|
||||
),
|
||||
};
|
||||
}
|
||||
AgentBackgroundTaskOutcome::ContinueSameRun {
|
||||
state: runtime,
|
||||
continuation,
|
||||
@@ -3900,9 +3983,9 @@ mod plan_envelope_repair_tests {
|
||||
use super::*;
|
||||
|
||||
const TRUNCATED: &str = "AGC_NEEDS_USER_INPUT_V1
|
||||
{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·关键决定\",\"question\":\"当前要决定:?\",\"options\":[{\"label\":\"A\",\"description\":\"甲\"}]}";
|
||||
{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·当前要决定:核心闭环形状\",\"question\":\"?\",\"options\":[{\"label\":\"A\",\"description\":\"甲\"}]}";
|
||||
const COMPLETE: &str = "AGC_NEEDS_USER_INPUT_V1
|
||||
{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·关键决定\",\"question\":\"当前要决定:?\",\"options\":[{\"label\":\"A\",\"description\":\"甲\"},{\"label\":\"B\",\"description\":\"乙\"}]}]}";
|
||||
{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·当前要决定:核心闭环形状\",\"question\":\"?\",\"options\":[{\"label\":\"A\",\"description\":\"甲\"},{\"label\":\"B\",\"description\":\"乙\"}]}]}";
|
||||
|
||||
/// 截断的信封必须在 run 内被认出来,否则它会随 final reply 逃逸成一条
|
||||
/// needs-repair 委派,把返工额度和澄清轮次一起卷进去。
|
||||
@@ -3956,7 +4039,7 @@ mod plan_envelope_repair_tests {
|
||||
fn a_degenerated_tail_no_longer_burns_a_repair_attempt() {
|
||||
let reply = concat!(
|
||||
"AGC_NEEDS_USER_INPUT_V1\n",
|
||||
r#"{"questions":[{"id":"replay_progression","header":"第2轮·关键决定","question":"当前要决定:自由经营农场的长期目标采用哪种组合?","options":[{"label":"A · 推荐:里程碑升级+成就","description":"以累计资金解锁少量新地块或设施。"},{"label":"B · 专注农场扩建","description":"只用经营收益逐步解锁地块与设施。"},{"label":"需要原型验证","description":"制作微型原型让目标玩家试玩两种目标结构。"}]}]}સwerhu рҭ. 北京赛车? тру. [ ]"#,
|
||||
r#"{"questions":[{"id":"replay_progression","header":"第2轮·当前要决定:自由经营农场的长期目标","question":"它决定玩家为何持续规划、赚钱与重玩,也控制 MVP 的范围。","options":[{"label":"A · 推荐:里程碑升级+成就","description":"以累计资金解锁少量新地块或设施。"},{"label":"B · 专注农场扩建","description":"只用经营收益逐步解锁地块与设施。"},{"label":"需要原型验证","description":"制作微型原型让目标玩家试玩两种目标结构。"}]}]}સwerhu рҭ. 北京赛车? тру. [ ]"#,
|
||||
);
|
||||
assert!(game_creator_agent_runtime_plan_envelope_parse_error(
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
@@ -4101,27 +4184,4 @@ mod plan_gdd_blocker_projection_tests {
|
||||
"版本上限由既有 lineage 决定,重试相同 Provider submit 不会改变它"
|
||||
);
|
||||
}
|
||||
|
||||
/// 台账逐项比对失败是本次 Provider input 写错,durable 权威完好,回灌理由后
|
||||
/// 策划子 Agent 能自行改稿;真 CAS 则说明 session 已被推进或损坏,重交同一份
|
||||
/// input 不可能成功。两者曾共用 `PLAN_SESSION_CAS_CONFLICT`,导致前者也被判成
|
||||
/// 硬阻断——实测中策划子 Agent 靠回灌连改三轮修好了形状层,紧接着撞上这一支
|
||||
/// 直接 needs-reconciliation,整条链路无产物收场。
|
||||
#[test]
|
||||
fn session_ledger_mismatch_is_provider_feedback_but_a_real_cas_conflict_is_not() {
|
||||
assert!(
|
||||
plan_submit_error_is_business_rejection(&PlanningStorageError::new(
|
||||
"PLAN_SESSION_DECISIONS_MISMATCH",
|
||||
"submit input 未逐项匹配当前 planning session 决策摘要"
|
||||
)),
|
||||
"台账不匹配应回灌给 Provider 修正,受既有 5 次预算约束"
|
||||
);
|
||||
assert!(
|
||||
!plan_submit_error_is_business_rejection(&PlanningStorageError::new(
|
||||
"PLAN_SESSION_CAS_CONFLICT",
|
||||
"planning session 已被其它动作推进"
|
||||
)),
|
||||
"真 CAS 必须走 reconciliation,不得消耗 Provider 重试额度"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,7 +519,7 @@ fn start_game_creator_agent_background_task_with_link_in_session_lane_with_proje
|
||||
let error = redact_agent_runtime_project_paths(root, &error, 500);
|
||||
let failed_task = AgentRuntimeTaskRecord {
|
||||
status: "failed".to_string(),
|
||||
phase: "planning-session-projection-failed".to_string(),
|
||||
phase: AGENT_RUNTIME_TASK_PHASE_PLANNING_SESSION_PROJECTION_FAILED.to_string(),
|
||||
current_action: "Fast GDD session 未能安全绑定,后台任务未执行".to_string(),
|
||||
terminal_detail: Some(error.clone()),
|
||||
error: Some(error.clone()),
|
||||
|
||||
@@ -268,7 +268,13 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_finalization_journal
|
||||
&journal.plan_steps,
|
||||
journal.active_plan_step_index,
|
||||
)?;
|
||||
// 澄清信封是本 run 挂起等用户答,不是交付收束:剩余步骤要等用户答复后的
|
||||
// continuation run 才做,在这里既不可能 completed,也不该被 Runtime 代填成
|
||||
// completed——那是伪造进度。只有真正宣告做完的最终回复才受这条不变量约束。
|
||||
// 这里放行的是「计划未完成」这一件事;快照自身的结构合法性仍由上面的
|
||||
// `validate_agent_runtime_structured_plan_snapshot` 逐项校验。
|
||||
if journal.plan_revision > 0
|
||||
&& !response_is_static_delegate_user_input_envelope(&journal.response)
|
||||
&& (journal.active_plan_step_index.is_some()
|
||||
|| journal
|
||||
.plan_steps
|
||||
|
||||
+211
-66
@@ -74,6 +74,48 @@ fn approval_observation(receipt: &PlanGddApprovalV1) -> AgentRuntimeToolObservat
|
||||
}
|
||||
}
|
||||
|
||||
/// 审批卡上的「修改/退回」是用户说的话,落点和决策卡的答案一样:Supervisor 自己
|
||||
/// 的会话文件。`append_user_input_answer_message` 已经为澄清答案建立了这条通道,
|
||||
/// 审批决定沿用它。没有这一步 Supervisor 只能从 delivery 的
|
||||
/// `contractStatus=user-revision-requested` 知道「用户要改」,读不到要改什么——
|
||||
/// playbook 第 6 条的「把用户原话完整附在 task 里」就没有原话可附,返工委派只能
|
||||
/// 写一句占位,子 Agent 于是自由发挥。
|
||||
fn append_plan_gdd_revision_message(
|
||||
root: &Path,
|
||||
receipt: &PlanGddApprovalV1,
|
||||
receipt_gdd: &PlanGddV1,
|
||||
) -> Result<(), String> {
|
||||
let label = if receipt.action == "reject" {
|
||||
"退回"
|
||||
} else {
|
||||
"修改"
|
||||
};
|
||||
let supervisor_session_id = resolve_game_creator_agent_runtime_session_id_for_run_at(
|
||||
root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
&receipt_gdd.root_run_id,
|
||||
)?;
|
||||
append_local_conversation_message_for_session_idempotent_at(
|
||||
root,
|
||||
Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID),
|
||||
// receipt 的 rootRun 所属 Supervisor task 才是这条用户意见的历史归属;
|
||||
// 不能在 recovery 重放时按当前 active session 重新路由到别的会话。
|
||||
Some(&supervisor_session_id),
|
||||
LocalConversationMessage {
|
||||
role: "user".to_string(),
|
||||
content: format!(
|
||||
"我对 Fast GDD v{} 的审批:{}。意见原文:\n{}",
|
||||
receipt.version,
|
||||
label,
|
||||
receipt.comment.as_deref().unwrap_or_default()
|
||||
),
|
||||
agent_id: None,
|
||||
},
|
||||
&format!("plan-gdd-decision-{}", receipt.response_id),
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
fn receipt_decision_input(
|
||||
gdd: &PlanGddV1,
|
||||
input: &DecidePlanGddInputV1,
|
||||
@@ -302,6 +344,85 @@ fn latest_plan_gdd_for_root<'a>(gdds: &'a [PlanGddV1], root_run_id: &str) -> Opt
|
||||
})
|
||||
}
|
||||
|
||||
/// Return whether the plan-root Supervisor must collect the current GDD
|
||||
/// acceptance evidence before it can dispatch another planning child.
|
||||
///
|
||||
/// This is deliberately a read-only projection of the existing acceptance
|
||||
/// gate. It does not create approval pending or mutate any planning sidecar;
|
||||
/// the actual pending projection remains owned by
|
||||
/// `ensure_plan_gdd_approval_pending_after_acceptance_locked` after a successful
|
||||
/// `agent.acceptance_update`. The caller must hold the current project write
|
||||
/// lock and pass that guard explicitly.
|
||||
pub(crate) fn plan_root_supervisor_acceptance_evidence_required_locked(
|
||||
root: &Path,
|
||||
project_lock: &ProjectWriteLock,
|
||||
run_id: &str,
|
||||
) -> Result<bool, String> {
|
||||
if !project_lock.guards_project_root(root)? {
|
||||
return Err("plan Supervisor 阶段判定缺少当前项目写锁".to_string());
|
||||
}
|
||||
if !crate::config::game_creator_planning_capability_enabled()? {
|
||||
return Ok(false);
|
||||
}
|
||||
if run_id.trim().is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let gdds = read_plan_gdd_chain_locked(root).map_err(|error| error.to_string())?;
|
||||
let Some(gdd) = latest_plan_gdd_for_root(&gdds, run_id) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(global_latest) = gdds.last() else {
|
||||
return Ok(false);
|
||||
};
|
||||
if global_latest.gdd_id != gdd.gdd_id
|
||||
|| global_latest.version != gdd.version
|
||||
|| global_latest.fingerprint != gdd.fingerprint
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
validate_plan_gdd(gdd).map_err(|error| error.to_string())?;
|
||||
|
||||
let approvals = read_plan_gdd_approvals_locked(root).map_err(|error| error.to_string())?;
|
||||
validate_plan_gdd_approvals_against_gdds(&gdds, &approvals)
|
||||
.map_err(|error| error.to_string())?;
|
||||
if read_plan_gdd_approval_for_version_locked(root, gdd.version)
|
||||
.map_err(|error| error.to_string())?
|
||||
.is_some()
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(pending) =
|
||||
read_plan_gdd_approval_pending_locked(root).map_err(|error| error.to_string())?
|
||||
{
|
||||
if !pending_matches_gdd(&pending, gdd) {
|
||||
return Err("plan Supervisor 阶段判定发现 approval pending identity 冲突".to_string());
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let Some(session) =
|
||||
read_plan_session_with_recovery_locked(root).map_err(|error| error.to_string())?
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
if !plan_gdd_session_matches_submission(&session, gdd) {
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(delivery) = read_static_delegate_delivery_at(root, &gdd.delegation_id)? else {
|
||||
return Ok(false);
|
||||
};
|
||||
if delivery.status != StaticDelegateDeliveryStatus::ClaimedByParent
|
||||
|| delivery.terminal_status.as_deref() != Some("completed")
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(matches!(
|
||||
plan_fast_gdd_acceptance_status_at_locked(root, gdd)?,
|
||||
PlanFastGddAcceptanceStatus::NeedsEvidence
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_plan_gdd_approval_pending_after_acceptance_locked(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
@@ -1019,89 +1140,96 @@ fn project_receipt_locked(
|
||||
|
||||
let pending_observation = approval_observation(receipt);
|
||||
let mut approval_pending_cleanup_eligible = false;
|
||||
let approval_pending = match read_plan_gdd_approval_pending_locked(root) {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
note_plan_gdd_projection_gap(
|
||||
root,
|
||||
receipt,
|
||||
"approval-pending-read",
|
||||
&error.to_string(),
|
||||
);
|
||||
recovery_pending = true;
|
||||
None
|
||||
}
|
||||
};
|
||||
match approval_pending {
|
||||
Some(mut pending) => {
|
||||
if !pending_identity_matches_gdd(&pending, receipt_gdd) {
|
||||
// Approval pending is a singleton projection for the latest GDD, not a
|
||||
// per-receipt projection. A historical receipt must still repair its own
|
||||
// index/Markdown/audit/runtime anchors, but it must not compare the
|
||||
// current pending card with its older GDD identity. After a revise/reject
|
||||
// creates a newer GDD, that comparison is expected to differ.
|
||||
if receipt.version == latest.version {
|
||||
let approval_pending = match read_plan_gdd_approval_pending_locked(root) {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
note_plan_gdd_projection_gap(
|
||||
root,
|
||||
receipt,
|
||||
"approval-pending-identity",
|
||||
"approval pending 与 receipt GDD identity 不一致",
|
||||
"approval-pending-read",
|
||||
&error.to_string(),
|
||||
);
|
||||
recovery_pending = true;
|
||||
} else {
|
||||
let expected_status = format!("observed_{}", receipt.action);
|
||||
if !matches!(pending.status.as_str(), "awaiting_decision")
|
||||
&& pending.status != expected_status
|
||||
{
|
||||
None
|
||||
}
|
||||
};
|
||||
match approval_pending {
|
||||
Some(mut pending) => {
|
||||
if !pending_identity_matches_gdd(&pending, receipt_gdd) {
|
||||
note_plan_gdd_projection_gap(
|
||||
root,
|
||||
receipt,
|
||||
"approval-pending-status",
|
||||
&format!(
|
||||
"approval pending status={} 既不是 awaiting_decision 也不是 {expected_status}",
|
||||
pending.status
|
||||
),
|
||||
"approval-pending-identity",
|
||||
"approval pending 与 receipt GDD identity 不一致",
|
||||
);
|
||||
recovery_pending = true;
|
||||
// Do not remove a projection whose durable state belongs
|
||||
// to another decision action.
|
||||
approval_pending_cleanup_eligible = false;
|
||||
} else {
|
||||
approval_pending_cleanup_eligible = true;
|
||||
pending.status = format!("observed_{}", receipt.action);
|
||||
pending.observation = Some(PlanGddApprovalObservationV1 {
|
||||
tool: pending_observation.tool.clone(),
|
||||
status: pending_observation.status.clone(),
|
||||
summary: pending_observation.summary.clone(),
|
||||
detail: pending_observation.detail.clone(),
|
||||
});
|
||||
match plan_gdd_approval_pending_fingerprint(&pending) {
|
||||
Ok(fingerprint) => {
|
||||
pending.pending_fingerprint = fingerprint;
|
||||
if let Err(error) =
|
||||
write_plan_gdd_approval_pending_atomic_locked(&root, &pending)
|
||||
{
|
||||
let expected_status = format!("observed_{}", receipt.action);
|
||||
if !matches!(pending.status.as_str(), "awaiting_decision")
|
||||
&& pending.status != expected_status
|
||||
{
|
||||
note_plan_gdd_projection_gap(
|
||||
root,
|
||||
receipt,
|
||||
"approval-pending-status",
|
||||
&format!(
|
||||
"approval pending status={} 既不是 awaiting_decision 也不是 {expected_status}",
|
||||
pending.status
|
||||
),
|
||||
);
|
||||
recovery_pending = true;
|
||||
// Do not remove a projection whose durable state belongs
|
||||
// to another decision action.
|
||||
approval_pending_cleanup_eligible = false;
|
||||
} else {
|
||||
approval_pending_cleanup_eligible = true;
|
||||
pending.status = format!("observed_{}", receipt.action);
|
||||
pending.observation = Some(PlanGddApprovalObservationV1 {
|
||||
tool: pending_observation.tool.clone(),
|
||||
status: pending_observation.status.clone(),
|
||||
summary: pending_observation.summary.clone(),
|
||||
detail: pending_observation.detail.clone(),
|
||||
});
|
||||
match plan_gdd_approval_pending_fingerprint(&pending) {
|
||||
Ok(fingerprint) => {
|
||||
pending.pending_fingerprint = fingerprint;
|
||||
if let Err(error) =
|
||||
write_plan_gdd_approval_pending_atomic_locked(root, &pending)
|
||||
{
|
||||
note_plan_gdd_projection_gap(
|
||||
root,
|
||||
receipt,
|
||||
"approval-pending-write",
|
||||
&error.to_string(),
|
||||
);
|
||||
recovery_pending = true;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
note_plan_gdd_projection_gap(
|
||||
root,
|
||||
receipt,
|
||||
"approval-pending-write",
|
||||
"approval-pending-fingerprint",
|
||||
&error.to_string(),
|
||||
);
|
||||
recovery_pending = true;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
note_plan_gdd_projection_gap(
|
||||
root,
|
||||
receipt,
|
||||
"approval-pending-fingerprint",
|
||||
&error.to_string(),
|
||||
);
|
||||
recovery_pending = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The approval pending projection is allowed to be absent after the
|
||||
// original submit anchors have durably consumed the terminal
|
||||
// observation. The generic-anchor reconciliation below decides
|
||||
// whether this is a normal post-consumption state or a recovery gap.
|
||||
None => {}
|
||||
}
|
||||
// The approval pending projection is allowed to be absent after the
|
||||
// original submit anchors have durably consumed the terminal
|
||||
// observation. The generic-anchor reconciliation below decides
|
||||
// whether this is a normal post-consumption state or a recovery gap.
|
||||
None => {}
|
||||
}
|
||||
|
||||
let generic_submit_consumed = match project_generic_submit_observation_locked(root, receipt) {
|
||||
@@ -1149,15 +1277,32 @@ fn project_receipt_locked(
|
||||
note_plan_gdd_projection_gap(root, receipt, "delivery-revision-mark", &error);
|
||||
recovery_pending = true;
|
||||
}
|
||||
if let Err(error) = append_plan_gdd_revision_message(root, receipt, receipt_gdd) {
|
||||
note_plan_gdd_projection_gap(root, receipt, "delivery-revision-message", &error);
|
||||
recovery_pending = true;
|
||||
}
|
||||
}
|
||||
// A replay may target an older receipt after a newer GDD has already been
|
||||
// submitted. The receipt still repairs its own audit/observation, but it
|
||||
// must not try to roll the current session or delivery lineage backwards.
|
||||
let session_points_to_receipt = match read_plan_session_with_recovery_locked(root) {
|
||||
Ok(session) => session
|
||||
.as_ref()
|
||||
.and_then(|session| session.latest_submitted_ref.as_ref())
|
||||
.is_some_and(|reference| reference == &receipt_plan_ref(receipt)),
|
||||
let session_projection_eligible = match read_plan_session_with_recovery_locked(root) {
|
||||
Ok(session) => session.as_ref().is_some_and(|session| {
|
||||
let receipt_ref_matches = session
|
||||
.latest_submitted_ref
|
||||
.as_ref()
|
||||
.is_some_and(|reference| reference == &receipt_plan_ref(receipt));
|
||||
let decision_ref_matches =
|
||||
session.last_decision_ref.as_ref().is_some_and(|reference| {
|
||||
reference.version == receipt.version
|
||||
&& reference.response_id == receipt.response_id
|
||||
&& reference.action == receipt.action
|
||||
&& reference.receipt_fingerprint == receipt.receipt_fingerprint
|
||||
});
|
||||
decision_ref_matches
|
||||
|| (receipt_ref_matches
|
||||
&& session.phase == "awaiting_gdd_approval"
|
||||
&& session.active_run_id.is_none())
|
||||
}),
|
||||
Err(error) => {
|
||||
note_plan_gdd_projection_gap(root, receipt, "plan-session-read", &error.to_string());
|
||||
recovery_pending = true;
|
||||
@@ -1165,7 +1310,7 @@ fn project_receipt_locked(
|
||||
}
|
||||
};
|
||||
let mut session_projection_ready = false;
|
||||
if receipt.version == latest.version || session_points_to_receipt {
|
||||
if session_projection_eligible {
|
||||
if let Err(error) = project_plan_session_locked(root, receipt_gdd, receipt) {
|
||||
note_plan_gdd_projection_gap(root, receipt, "plan-session-project", &error.to_string());
|
||||
recovery_pending = true;
|
||||
|
||||
+152
-22
@@ -5,6 +5,13 @@ use uuid::Uuid;
|
||||
const PLAN_OPTION_A_PREFIX: char = 'A';
|
||||
const PLAN_OPTION_B_PREFIX: char = 'B';
|
||||
const PLAN_OPTION_PROTOTYPE_VALIDATION: &str = "需要原型验证";
|
||||
/// 策划决策卡恒为 A / B /「需要原型验证」三项,不是通用 `user.input_request` 协议的
|
||||
/// 2-3 个。role brief 早先照通用常量写成「2~3 个」,与本文件的硬校验和 brief 自己
|
||||
/// 下文的「固定提供三个选项」三方打架;模型照前者吐两项,整封信封在出卡时被拒、
|
||||
/// 回灌重试,白烧一个未推进回合,丢掉的还恰好是用户产生 `prototype_pending` 的唯一
|
||||
/// 入口。`project_planning_role_brief_states_the_parser_wire_shape_verbatim` 钉住
|
||||
/// brief 与这里同源。
|
||||
pub(crate) const PLAN_CLARIFICATION_OPTION_COUNT: usize = 3;
|
||||
const PLAN_QUESTION_PREFIX: &str = "当前要决定:";
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -18,6 +25,25 @@ fn plan_coordinator_error(kind: &str, detail: impl AsRef<str>) -> String {
|
||||
format!("{kind}: {}", detail.as_ref())
|
||||
}
|
||||
|
||||
fn validate_plan_continuation_parent<'a>(
|
||||
latest_delegation_id: &str,
|
||||
delivery: &'a StaticDelegateDeliveryRecord,
|
||||
) -> Result<&'a str, String> {
|
||||
let original_id = delivery.repair_of_delegation_id.as_deref().ok_or_else(|| {
|
||||
plan_coordinator_error(
|
||||
"PLAN_ACTIVE_RUN_EXISTS",
|
||||
"已有 planning session 时不能创建第二条根 delegation",
|
||||
)
|
||||
})?;
|
||||
if latest_delegation_id != original_id {
|
||||
return Err(plan_coordinator_error(
|
||||
"PLAN_NEEDS_RECONCILIATION",
|
||||
"planning continuation 必须直接继承当前 session 的 latest delegation",
|
||||
));
|
||||
}
|
||||
Ok(original_id)
|
||||
}
|
||||
|
||||
fn plan_session_successor_base(previous: &PlanSessionV1) -> Result<PlanSessionV1, String> {
|
||||
let mut next = previous.clone();
|
||||
next.session_revision = previous.session_revision.checked_add(1).ok_or_else(|| {
|
||||
@@ -110,14 +136,40 @@ fn exact_plan_child_identity_at(
|
||||
Ok(Some((binding, delivery)))
|
||||
}
|
||||
|
||||
fn plan_question_topic(question: &AgentRuntimeUserInputQuestion) -> Result<String, String> {
|
||||
let remainder = question
|
||||
.question
|
||||
.strip_prefix(PLAN_QUESTION_PREFIX)
|
||||
/// 剥掉 header 的 `第{round}轮·` 前缀,返回其后的正文。
|
||||
///
|
||||
/// 轮号本身由 Runtime 从委派谱系派生,模型只是照着任务正文抄;这里逐字核对它,写错就
|
||||
/// 拒收——否则卡片标题会和 `GddApprovalCard` 那个「第 N 轮 / 共 3 轮」自相矛盾。
|
||||
fn plan_header_body(header: &str, round: u32) -> Option<&str> {
|
||||
let rest = header.trim_start().strip_prefix('第')?.trim_start();
|
||||
let digits = rest
|
||||
.chars()
|
||||
.take_while(char::is_ascii_digit)
|
||||
.collect::<String>();
|
||||
if digits.parse::<u32>().ok()? != round {
|
||||
return None;
|
||||
}
|
||||
let rest = rest[digits.len()..].trim_start().strip_prefix('轮')?.trim();
|
||||
// 原型模板写作 `第 N 轮 · 当前要决定:…`,中文语境下模型高频吐出 `·`/`:`/`:`/`-`
|
||||
// 几种分隔符;不在集合里的后果是整封信封被拒、白吃一个未推进回合。
|
||||
let rest = rest.strip_prefix(&PLAN_OPTION_LABEL_DELIMITERS[..])?;
|
||||
Some(rest.trim_start())
|
||||
}
|
||||
|
||||
/// 决定台账的 `topic` 取自 header。
|
||||
///
|
||||
/// 原型(`design_agent.py:1841`)直接把整条 header 当 topic;这里只是再剥掉 `第N轮·` 和
|
||||
/// 「当前要决定:」两层固定前缀,落进台账的是主题本身。
|
||||
fn plan_question_topic(
|
||||
question: &AgentRuntimeUserInputQuestion,
|
||||
round: u32,
|
||||
) -> Result<String, String> {
|
||||
let remainder = plan_header_body(&question.header, round)
|
||||
.and_then(|body| body.strip_prefix(PLAN_QUESTION_PREFIX))
|
||||
.ok_or_else(|| {
|
||||
plan_coordinator_error(
|
||||
"PLAN_INVALID_CLARIFICATION",
|
||||
"plan question 必须以“当前要决定:”开头",
|
||||
format!("plan header 必须形如“第{round}轮·当前要决定:<主题>”"),
|
||||
)
|
||||
})?;
|
||||
let topic = remainder
|
||||
@@ -129,7 +181,12 @@ fn plan_question_topic(question: &AgentRuntimeUserInputQuestion) -> Result<Strin
|
||||
|
||||
/// 全角冒号必须在集合里:prompt 全中文,模型在中文语境下写 `A:方案名` 是高频输出,
|
||||
/// 而不在集合里的后果是整个信封被拒、回灌重试,白吃一个未推进回合预算。
|
||||
const PLAN_OPTION_LABEL_DELIMITERS: [char; 4] = ['·', ':', ':', '-'];
|
||||
///
|
||||
/// 集合按原型的 `_OPTION_A_PATTERN`(`design_agent.py`,`^A\s*[·•・::..\-]`)拉齐。
|
||||
/// 本仓库先前只收 4 个,是同一条理由下更窄的一份——模型写 `A•路线布防` 或
|
||||
/// `A. 路线布防` 就会整封被拒。`plan_header_body` 解析 `第N轮·` 时复用这同一个集合,
|
||||
/// 两处一起放宽;只放宽、不收紧,既有能过的 label 逐字照过。
|
||||
const PLAN_OPTION_LABEL_DELIMITERS: [char; 8] = ['·', '•', '・', ':', ':', '.', '.', '-'];
|
||||
|
||||
fn plan_option_label_has_prefix(label: &str, prefix: char) -> bool {
|
||||
let Some(remainder) = label.strip_prefix(prefix).map(str::trim_start) else {
|
||||
@@ -154,6 +211,83 @@ fn plan_option_label_has_prefix(label: &str, prefix: char) -> bool {
|
||||
/// `user_freeform`。两边 state 同为 `confirmed`,状态机看不出异常——被污染的恰好是第
|
||||
/// 23.9 节要立起来的那个字段。`planning_clarification_option_pick_survives_untrimmed_label`
|
||||
/// 钉的就是这条不变量。
|
||||
#[cfg(test)]
|
||||
mod option_label_delimiter_tests {
|
||||
use super::*;
|
||||
|
||||
/// 分隔符集合只能放宽、不能收窄,且必须覆盖原型 `_OPTION_A_PATTERN` 的那一份。
|
||||
///
|
||||
/// 锁的是「集合里每一个都被接受」这条不变量,不是某个具体标点:少一个的后果不是
|
||||
/// 「模型换个写法」,而是一封完全合法的信封被判形状错误、回灌重试,白吃一个未推进
|
||||
/// 回合——`planning_clarification_accepts_fullwidth_colon_option_labels` 记的就是
|
||||
/// 全角冒号那一次。
|
||||
/// 逐字来自原型 `design_agent.py` 的 `^A\s*[·•・::..\-]`。这里**不能**改成遍历
|
||||
/// `PLAN_OPTION_LABEL_DELIMITERS` 本身——那样从集合里删掉一个,循环也跟着少测一个,
|
||||
/// 断言恒真。
|
||||
const PROTOTYPE_DELIMITERS: [char; 8] = ['·', '•', '・', ':', ':', '.', '.', '-'];
|
||||
|
||||
#[test]
|
||||
fn every_delimiter_in_the_set_is_accepted_on_both_option_prefixes() {
|
||||
for delimiter in PROTOTYPE_DELIMITERS {
|
||||
for prefix in [PLAN_OPTION_A_PREFIX, PLAN_OPTION_B_PREFIX] {
|
||||
let label = format!("{prefix}{delimiter}方案短语");
|
||||
assert!(
|
||||
plan_option_label_has_prefix(&label, prefix),
|
||||
"分隔符 {delimiter:?} 被拒:{label}"
|
||||
);
|
||||
let spaced = format!("{prefix} {delimiter} 方案短语");
|
||||
assert!(
|
||||
plan_option_label_has_prefix(&spaced, prefix),
|
||||
"带空格写法被拒:{spaced}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!plan_option_label_has_prefix("A方案短语", PLAN_OPTION_A_PREFIX),
|
||||
"没有分隔符不能算合法 A 选项,否则 A/B 与自由文本会混"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod planning_continuation_parent_tests {
|
||||
use super::*;
|
||||
|
||||
fn delivery(repair_of_delegation_id: Option<&str>) -> StaticDelegateDeliveryRecord {
|
||||
new_static_delegate_delivery_with_contract(
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
"supervisor-session",
|
||||
"supervisor-run",
|
||||
"delegate-action",
|
||||
"current-delivery",
|
||||
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
|
||||
"planning-session",
|
||||
"planning-run",
|
||||
&[],
|
||||
&[],
|
||||
repair_of_delegation_id,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuation_must_extend_the_session_cursor() {
|
||||
let continuation = delivery(Some("older-delivery"));
|
||||
let error = validate_plan_continuation_parent("current-delivery", &continuation)
|
||||
.expect_err("older delivery must not become the current planning branch");
|
||||
assert!(error.contains("latest delegation"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuation_accepts_the_current_session_cursor() {
|
||||
let continuation = delivery(Some("current-delivery"));
|
||||
assert_eq!(
|
||||
validate_plan_continuation_parent("current-delivery", &continuation)
|
||||
.expect("current delivery is a valid continuation"),
|
||||
"current-delivery"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_option_label_matches_answer(label: &str, normalized_answer: &str) -> bool {
|
||||
label == normalized_answer
|
||||
}
|
||||
@@ -178,14 +312,7 @@ pub(crate) fn validate_exact_plan_clarification_question(
|
||||
"plan questionId 必须是最多 32 个 ASCII 字符且不能映射为 initial-request",
|
||||
));
|
||||
}
|
||||
let expected_header = format!("第{round}轮·关键决定");
|
||||
if question.header != expected_header {
|
||||
return Err(plan_coordinator_error(
|
||||
"PLAN_INVALID_CLARIFICATION",
|
||||
format!("plan question header 必须精确等于 {expected_header}"),
|
||||
));
|
||||
}
|
||||
let valid_shape = question.options.len() == 3
|
||||
let valid_shape = question.options.len() == PLAN_CLARIFICATION_OPTION_COUNT
|
||||
&& plan_option_label_has_prefix(&question.options[0].label, PLAN_OPTION_A_PREFIX)
|
||||
&& plan_option_label_has_prefix(&question.options[1].label, PLAN_OPTION_B_PREFIX)
|
||||
&& question.options[2].label == PLAN_OPTION_PROTOTYPE_VALIDATION;
|
||||
@@ -195,7 +322,9 @@ pub(crate) fn validate_exact_plan_clarification_question(
|
||||
"plan question 必须恰好提供 A、B、需要原型验证三个选项",
|
||||
));
|
||||
}
|
||||
plan_question_topic(question)?;
|
||||
// header 的定形连同轮号一起在这里兜底:`plan_question_topic` 要求它形如
|
||||
// `第{round}轮·当前要决定:<主题>`,并把主题本身取出来给决定台账。
|
||||
plan_question_topic(question, round)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -210,7 +339,7 @@ fn build_plan_clarification_decision_projection(
|
||||
let normalized_answer = normalize_plan_text(&answer.answer, "plan answer", 1, 400)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let question = &answer.question;
|
||||
let topic = plan_question_topic(question)?;
|
||||
let topic = plan_question_topic(question, round)?;
|
||||
let decision_id = question.id.replace('_', "-");
|
||||
let (state, answer_source) =
|
||||
if plan_option_label_matches_answer(&question.options[0].label, &normalized_answer)
|
||||
@@ -513,12 +642,13 @@ pub(crate) fn ensure_plan_session_for_planning_child_task_at_locked(
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
let original_id = delivery.repair_of_delegation_id.as_deref().ok_or_else(|| {
|
||||
plan_coordinator_error(
|
||||
"PLAN_ACTIVE_RUN_EXISTS",
|
||||
"已有 planning session 时不能创建第二条根 delegation",
|
||||
)
|
||||
})?;
|
||||
// `latest_delegation_id` is the planning session's single continuation
|
||||
// cursor. A new child must extend that cursor directly; otherwise a
|
||||
// Supervisor can select an older claimed delivery and make an unrelated
|
||||
// branch look like the current session. Keep this check here, after the
|
||||
// exact-task replay fast path above, so replaying an already projected
|
||||
// child remains idempotent.
|
||||
let original_id = validate_plan_continuation_parent(&previous.latest_delegation_id, &delivery)?;
|
||||
let deliveries = list_static_delegate_deliveries_at(root)?;
|
||||
if static_delegate_lineage_contains_unknown_contract_status(
|
||||
&deliveries,
|
||||
|
||||
+35
-25
@@ -440,7 +440,10 @@ fn validate_decision_state(value: &str) -> Result<(), PlanningStorageError> {
|
||||
}
|
||||
|
||||
fn validate_answer_source(value: &str) -> Result<(), PlanningStorageError> {
|
||||
if matches!(value, "user_freeform" | "user_option" | "default") {
|
||||
if matches!(
|
||||
value,
|
||||
"user_freeform" | "user_option" | "user_revision" | "default"
|
||||
) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(invalid(format!("未知 answerSource:{value}")))
|
||||
@@ -882,31 +885,38 @@ fn validate_decisions(
|
||||
if decision.round > 3 {
|
||||
return Err(invalid(format!("decisions[{index}].round 不能超过 3")));
|
||||
}
|
||||
// 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.answer_source == "user_revision" && decision.round != 0 {
|
||||
return Err(invalid(format!(
|
||||
"decisions[{index}] 的 user_revision 必须使用 round=0"
|
||||
)));
|
||||
}
|
||||
// round=0 不属于澄清轮:默认建议使用 default,审批修改使用
|
||||
// user_revision。两者都可以标记为 prototype_pending;用户明确修改的
|
||||
// 决定则可以标记 confirmed。
|
||||
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"
|
||||
)));
|
||||
match decision.answer_source.as_str() {
|
||||
"default"
|
||||
if matches!(
|
||||
decision.state.as_str(),
|
||||
"default_pending" | "prototype_pending"
|
||||
) => {}
|
||||
"user_revision"
|
||||
if matches!(decision.state.as_str(), "confirmed" | "prototype_pending") => {}
|
||||
"default" => {
|
||||
return Err(invalid(format!(
|
||||
"decisions[{index}] round=0 的 default 只能是 default_pending 或 prototype_pending"
|
||||
)));
|
||||
}
|
||||
"user_revision" => {
|
||||
return Err(invalid(format!(
|
||||
"decisions[{index}] round=0 的 user_revision 只能是 confirmed 或 prototype_pending"
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
return Err(invalid(format!(
|
||||
"decisions[{index}] round=0 的 answerSource 只能是 default 或 user_revision"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
validate_text(
|
||||
|
||||
+191
-178
File diff suppressed because it is too large
Load Diff
@@ -460,6 +460,148 @@ pub(in crate::agent) fn mark_game_creator_agent_runtime_provider_success_handoff
|
||||
)
|
||||
}
|
||||
|
||||
const PROVIDER_RECONCILIATION_DIAGNOSTIC_RELATIVE_ROOT: &str =
|
||||
"diagnostics/provider-reconciliation";
|
||||
const PROVIDER_RECONCILIATION_DIAGNOSTIC_MAX_BYTES: usize = 1024 * 1024;
|
||||
|
||||
/// Persist the raw successful Provider response only in the application
|
||||
/// private data directory. Project state keeps the safe summary below; this
|
||||
/// sidecar is diagnostic-only and is never consulted by recovery/retry logic.
|
||||
pub(in crate::agent) fn write_provider_reconciliation_diagnostic_at(
|
||||
snapshot: &AgentRuntimeProviderRequestSnapshot,
|
||||
request_id: &str,
|
||||
response: &platform_llm::LlmRunResponse,
|
||||
error: &str,
|
||||
) -> Result<String, String> {
|
||||
let config_dir = game_creator_runtime_config_dir()
|
||||
.ok_or_else(|| "Runtime config dir 未初始化,无法写入本地 Provider 诊断".to_string())?;
|
||||
write_provider_reconciliation_diagnostic_in_dir(
|
||||
&config_dir,
|
||||
snapshot,
|
||||
request_id,
|
||||
response,
|
||||
error,
|
||||
)
|
||||
}
|
||||
|
||||
fn write_provider_reconciliation_diagnostic_in_dir(
|
||||
config_dir: &Path,
|
||||
snapshot: &AgentRuntimeProviderRequestSnapshot,
|
||||
request_id: &str,
|
||||
response: &platform_llm::LlmRunResponse,
|
||||
error: &str,
|
||||
) -> Result<String, String> {
|
||||
let project_key = format!("{:x}", Sha256::digest(snapshot.project_id.as_bytes()));
|
||||
let request_key = format!("{:x}", Sha256::digest(request_id.as_bytes()));
|
||||
let directory = config_dir
|
||||
.join(PROVIDER_RECONCILIATION_DIAGNOSTIC_RELATIVE_ROOT)
|
||||
.join(&project_key);
|
||||
fs::create_dir_all(&directory)
|
||||
.map_err(|error| format!("创建本地 Provider 诊断目录失败:{error}"))?;
|
||||
let relative_path = format!(
|
||||
"{PROVIDER_RECONCILIATION_DIAGNOSTIC_RELATIVE_ROOT}/{project_key}/{request_key}.json"
|
||||
);
|
||||
let path = directory.join(format!("{request_key}.json"));
|
||||
if let Ok(metadata) = fs::symlink_metadata(&path) {
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return Err("本地 Provider 诊断目标必须是普通文件".to_string());
|
||||
}
|
||||
return Ok(relative_path);
|
||||
}
|
||||
let diagnostic = serde_json::json!({
|
||||
"schemaVersion": "provider-reconciliation-diagnostic.v1",
|
||||
"identity": {
|
||||
"projectId": snapshot.project_id.clone(),
|
||||
"agentId": snapshot.agent_id.clone(),
|
||||
"taskId": snapshot.task_id.clone(),
|
||||
"sessionId": snapshot.session_id.clone(),
|
||||
"runId": snapshot.run_id.clone(),
|
||||
"source": snapshot.source.clone(),
|
||||
"requestKind": snapshot.request_kind.clone(),
|
||||
"requestSlot": snapshot.request_slot.clone(),
|
||||
"requestId": request_id,
|
||||
"appliedSteerCursor": snapshot.applied_steer_cursor,
|
||||
},
|
||||
"provider": {
|
||||
"provider": format!("{:?}", response.provider),
|
||||
"model": response.model.clone(),
|
||||
"responseId": response.response_id.clone(),
|
||||
"finishReason": response.finish_reason.clone(),
|
||||
"usage": response.usage.clone(),
|
||||
},
|
||||
"failure": {
|
||||
"error": error,
|
||||
},
|
||||
"response": {
|
||||
"text": response.text.clone(),
|
||||
"toolCalls": response.tool_calls.iter().map(|call| serde_json::json!({
|
||||
"id": call.id.clone(),
|
||||
"name": call.name.clone(),
|
||||
"arguments": call.arguments.clone(),
|
||||
})).collect::<Vec<_>>(),
|
||||
},
|
||||
});
|
||||
let mut content = serde_json::to_string_pretty(&diagnostic)
|
||||
.map_err(|error| format!("序列化本地 Provider 诊断失败:{error}"))?;
|
||||
content.push('\n');
|
||||
if content.len() > PROVIDER_RECONCILIATION_DIAGNOSTIC_MAX_BYTES {
|
||||
return Err(format!(
|
||||
"本地 Provider 诊断超过 {PROVIDER_RECONCILIATION_DIAGNOSTIC_MAX_BYTES} 字节"
|
||||
));
|
||||
}
|
||||
let temporary = path.with_file_name(format!(".{request_key}.tmp.{}", unix_timestamp_nanos()));
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let mut file = options
|
||||
.open(&temporary)
|
||||
.map_err(|error| format!("创建本地 Provider 诊断临时文件失败:{error}"))?;
|
||||
if let Err(error) = file
|
||||
.write_all(content.as_bytes())
|
||||
.and_then(|_| file.sync_data())
|
||||
{
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return Err(format!("写入本地 Provider 诊断失败:{error}"));
|
||||
}
|
||||
drop(file);
|
||||
if let Err(error) = fs::rename(&temporary, &path) {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return Err(format!("安装本地 Provider 诊断失败:{error}"));
|
||||
}
|
||||
Ok(relative_path)
|
||||
}
|
||||
|
||||
fn private_diagnostic_reference(error: &str) -> Option<&str> {
|
||||
let reference = error.split_once(";localDiagnostic=")?.1.trim();
|
||||
let reference = reference.split(';').next()?.trim();
|
||||
if reference.starts_with(PROVIDER_RECONCILIATION_DIAGNOSTIC_RELATIVE_ROOT)
|
||||
&& reference
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_alphanumeric() || "/.-_".contains(character))
|
||||
{
|
||||
Some(reference)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn attach_private_diagnostic_reference(
|
||||
mut audit: serde_json::Value,
|
||||
reference: Option<String>,
|
||||
) -> serde_json::Value {
|
||||
if let (Some(reference), Some(audit)) = (reference, audit.as_object_mut()) {
|
||||
audit.insert(
|
||||
"localDiagnostic".to_string(),
|
||||
serde_json::Value::String(reference),
|
||||
);
|
||||
}
|
||||
audit
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn mark_game_creator_agent_runtime_provider_success_handoff_needs_reconciliation_for_test(
|
||||
root: &Path,
|
||||
@@ -505,6 +647,8 @@ fn mark_game_creator_agent_runtime_provider_request_needs_reconciliation_with_di
|
||||
{
|
||||
return Err("孤立 Provider 请求与当前 Runtime 身份冲突".to_string());
|
||||
}
|
||||
let private_reference =
|
||||
diagnostic.and_then(|(_, error)| private_diagnostic_reference(error).map(str::to_string));
|
||||
let diagnostic = diagnostic.map(|(failure_kind, error)| {
|
||||
(
|
||||
failure_kind,
|
||||
@@ -544,6 +688,11 @@ fn mark_game_creator_agent_runtime_provider_request_needs_reconciliation_with_di
|
||||
detail
|
||||
})
|
||||
.unwrap_or_else(|| format!("requestId={request_id}"));
|
||||
let public_detail = if let Some(reference) = private_reference.as_deref() {
|
||||
format!("{public_detail} · localDiagnostic={reference}")
|
||||
} else {
|
||||
public_detail
|
||||
};
|
||||
let event_detail = diagnostic
|
||||
.is_some()
|
||||
.then_some(public_detail.as_str())
|
||||
@@ -625,6 +774,7 @@ fn mark_game_creator_agent_runtime_provider_request_needs_reconciliation_with_di
|
||||
"requestSlot": snapshot.request_slot,
|
||||
})
|
||||
};
|
||||
let audit = attach_private_diagnostic_reference(audit, private_reference);
|
||||
let _ = append_agent_db_record(root, audit);
|
||||
emit_game_creator_agent_runtime_update(root, &snapshot.agent_id);
|
||||
Ok(())
|
||||
@@ -676,3 +826,76 @@ where
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod provider_reconciliation_diagnostic_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn private_diagnostic_keeps_raw_response_outside_project_state() {
|
||||
let directory = tempfile::tempdir().expect("diagnostic directory");
|
||||
let snapshot = AgentRuntimeProviderRequestSnapshot {
|
||||
project_id: "project-1".to_string(),
|
||||
agent_id: "project-planning".to_string(),
|
||||
task_id: "task-1".to_string(),
|
||||
session_id: "session-1".to_string(),
|
||||
run_id: "run-1".to_string(),
|
||||
source: "agent-delegate".to_string(),
|
||||
goal_id: None,
|
||||
goal_revision: 0,
|
||||
goal_snapshot_fingerprint: String::new(),
|
||||
applied_steer_cursor: 0,
|
||||
request_kind: "tool-plan".to_string(),
|
||||
request_slot: "loop-1-repair-0".to_string(),
|
||||
web_search_enabled: false,
|
||||
allow_idle_context_compaction: false,
|
||||
planning_session_binding: None,
|
||||
};
|
||||
let response = platform_llm::LlmRunResponse {
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: "test-model".to_string(),
|
||||
text: "C:\\private\\response".to_string(),
|
||||
finish_reason: Some("completed".to_string()),
|
||||
response_id: Some("response-1".to_string()),
|
||||
usage: None,
|
||||
tool_calls: vec![platform_llm::LlmToolCall {
|
||||
id: "call-1".to_string(),
|
||||
name: "runtime_tool_plan_submit_gdd".to_string(),
|
||||
arguments: "{\"path\":\"C:\\\\private\\\\argument\"}".to_string(),
|
||||
}],
|
||||
};
|
||||
let relative = write_provider_reconciliation_diagnostic_in_dir(
|
||||
directory.path(),
|
||||
&snapshot,
|
||||
"provider-request-1",
|
||||
&response,
|
||||
"绝对路径 C:\\private\\error",
|
||||
)
|
||||
.expect("write diagnostic");
|
||||
assert!(relative.starts_with("diagnostics/provider-reconciliation/"));
|
||||
let persisted =
|
||||
fs::read_to_string(directory.path().join(&relative)).expect("read diagnostic");
|
||||
let persisted: serde_json::Value =
|
||||
serde_json::from_str(&persisted).expect("parse diagnostic");
|
||||
assert_eq!(persisted["response"]["text"], "C:\\private\\response");
|
||||
assert_eq!(
|
||||
persisted["response"]["toolCalls"][0]["arguments"],
|
||||
"{\"path\":\"C:\\\\private\\\\argument\"}"
|
||||
);
|
||||
assert_eq!(persisted["failure"]["error"], "绝对路径 C:\\private\\error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_diagnostic_reference_accepts_only_relative_reference() {
|
||||
assert_eq!(
|
||||
private_diagnostic_reference(
|
||||
"失败;localDiagnostic=diagnostics/provider-reconciliation/p/r.json"
|
||||
),
|
||||
Some("diagnostics/provider-reconciliation/p/r.json")
|
||||
);
|
||||
assert_eq!(
|
||||
private_diagnostic_reference("失败;localDiagnostic=C:\\secret.json"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1406,7 +1406,7 @@ where
|
||||
attempt_snapshot.clone(),
|
||||
provider_request,
|
||||
|provider_request_id, response| {
|
||||
if persist_handoff {
|
||||
let handoff_result = if persist_handoff {
|
||||
let response = canonicalize_handoff_response(response);
|
||||
provider_handoff::write_at(
|
||||
root,
|
||||
@@ -1415,7 +1415,8 @@ where
|
||||
attempt,
|
||||
provider_request_id,
|
||||
&response,
|
||||
)?;
|
||||
)
|
||||
.map(|_| ())
|
||||
} else if persist_tool_plan_handoff {
|
||||
tool_plan_handoff::write_at(
|
||||
root,
|
||||
@@ -1424,7 +1425,24 @@ where
|
||||
attempt,
|
||||
provider_request_id,
|
||||
response,
|
||||
)?;
|
||||
)
|
||||
.map(|_| ())
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
if let Err(error) = handoff_result {
|
||||
let error = match write_provider_reconciliation_diagnostic_at(
|
||||
&attempt_snapshot,
|
||||
provider_request_id,
|
||||
response,
|
||||
&error,
|
||||
) {
|
||||
Ok(path) => format!("{error};localDiagnostic={path}"),
|
||||
Err(diagnostic_error) => {
|
||||
format!("{error};localDiagnosticWriteFailed={diagnostic_error}")
|
||||
}
|
||||
};
|
||||
return Err(error);
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
|
||||
@@ -4,6 +4,12 @@ static AGENT_RUNTIME_EVENT_ID_SEQUENCE: std::sync::atomic::AtomicU64 =
|
||||
std::sync::atomic::AtomicU64::new(1);
|
||||
|
||||
pub(crate) const AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX: &str = "runtime-public-status-";
|
||||
/// task journal 只在**读**的时候校验 phase 白名单,写侧不校验。所以一个没登记的
|
||||
/// phase 落盘之后,整份 journal 从那一行起再也读不出来:`agent.run_status` 对该
|
||||
/// Agent 永久失败,父 run 只能瞎转到 needs-reconciliation。实测就是这么炸的。
|
||||
/// 让写方和白名单引用同一个常量,两边不可能再漂移。
|
||||
pub(crate) const AGENT_RUNTIME_TASK_PHASE_PLANNING_SESSION_PROJECTION_FAILED: &str =
|
||||
"planning-session-projection-failed";
|
||||
|
||||
fn game_creator_agent_runtime_public_status_message_id(
|
||||
agent_id: &str,
|
||||
@@ -1204,7 +1210,13 @@ where
|
||||
));
|
||||
}
|
||||
let current_revision = read_game_creator_agent_runtime_project_revision(root)?;
|
||||
let blocker = if let Some(blocker) = structured_plan_completion_blocker(&state) {
|
||||
// 澄清信封不是交付收束,是本 run 挂起等用户答。结构化计划完成度判据对它不可
|
||||
// 满足:想问用户就得先 respond_to_user,而计划里「按用户决定收敛并提交」那一
|
||||
// 步在用户答之前永远不可能 completed,于是问不出去、答不了、永远转。实测一条
|
||||
// 生产 run 因此空转 65 轮直到人工介入。其余判据仍然照常生效。
|
||||
let blocker = if let Some(blocker) = structured_plan_completion_blocker(&state)
|
||||
.filter(|_| !response_is_static_delegate_user_input_envelope(response))
|
||||
{
|
||||
Some(blocker)
|
||||
} else if let Some(blocker) = game_creator_agent_goal_completion_blocker_at_locked(root, &state)
|
||||
{
|
||||
@@ -1603,6 +1615,7 @@ pub(crate) fn default_game_creator_agent_runtime_state(
|
||||
loop_iteration: 0,
|
||||
plan_submit_gdd_rejection_count: 0,
|
||||
plan_update_idle_rounds: 0,
|
||||
stale_finalization_rounds: 0,
|
||||
max_loop_iterations: AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT as u32,
|
||||
tool_action_budget: AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT as u32,
|
||||
plan_revision: 0,
|
||||
@@ -4164,6 +4177,7 @@ fn validate_game_creator_agent_runtime_task_status_phase(
|
||||
| "completion-contract-failed"
|
||||
| "conversation-write-failed"
|
||||
| "public-status-write-failed"
|
||||
| AGENT_RUNTIME_TASK_PHASE_PLANNING_SESSION_PROJECTION_FAILED
|
||||
| "parent-terminal"
|
||||
| "parent-link-missing"
|
||||
) {
|
||||
|
||||
@@ -214,12 +214,21 @@ pub(crate) fn observe_agent_runtime_agent_message(
|
||||
/// 平坦的 depth <= 1 门,那时「唯一返工轮」对澄清跳也成立;本仓库改成按谱系分类后
|
||||
/// 把预算抬到 3,这句话就变成了假天花板——生产实测 4 次澄清续跑全部命中它,命中后
|
||||
/// 全部直接出稿,没有任何一个 run 走到第 2 轮。
|
||||
/// - `UserRevision`:用户在审批卡上点「修改 / 退回」后的修订轮。它同样带
|
||||
/// `repairOfDelegationId`,但 `repair_depth` 防的是 runaway agent,而这一跳每一轮
|
||||
/// 都由人触发——人本身就是循环边界,所以 `static_delegate_lineage_counters` 早就
|
||||
/// 把 depth/round 原样继承了。缺的是这句话:走 `Repair` 分支时用户第一次点修改就
|
||||
/// 会被告知「这是唯一返工轮」,和澄清跳当初那个假天花板是同一个错误。原型对应的是
|
||||
/// `USER_REVISION_SOFT_LIMIT = 16`,且超过只提示、不拒绝。
|
||||
/// - `None`:普通委派,不加这一段。
|
||||
pub(in crate::agent) enum StaticDelegateHopNote<'a> {
|
||||
None,
|
||||
Repair {
|
||||
original_delegation_id: &'a str,
|
||||
},
|
||||
UserRevision {
|
||||
original_delegation_id: &'a str,
|
||||
},
|
||||
PlanClarification {
|
||||
original_delegation_id: &'a str,
|
||||
rounds_used: u32,
|
||||
@@ -238,6 +247,11 @@ impl StaticDelegateHopNote<'_> {
|
||||
StaticDelegateHopNote::Repair {
|
||||
original_delegation_id,
|
||||
} => format!("\n\n这是对已认领委派 {original_delegation_id} 的唯一返工轮。"),
|
||||
StaticDelegateHopNote::UserRevision {
|
||||
original_delegation_id,
|
||||
} => format!(
|
||||
"\n\n这是对已认领委派 {original_delegation_id} 的用户修订轮,由用户在审批卡上提出,不是质量返工,不消耗返工深度,也不重置澄清轮次。按任务正文里的用户意见原文修订同一份 GDD 谱系后重新提交;用户看过新稿还可以再次提出修改,这不是最后一轮,不要因此压缩改动或提前收尾。"
|
||||
),
|
||||
// 预算用尽:planning_coordinator 出卡时会用
|
||||
// `current_round >= 3` 直接拒掉第四张卡,所以这里不能再邀请提问,
|
||||
// 只能要求收稿——语义上等价于原型的 INJ_MUST_DRAFT_ROUNDS。
|
||||
@@ -256,7 +270,7 @@ impl StaticDelegateHopNote<'_> {
|
||||
rounds_used,
|
||||
rounds_limit,
|
||||
} => format!(
|
||||
"\n\n这是对已认领委派 {original_delegation_id} 的澄清续跑,不是返工轮,不消耗返工深度。已用澄清轮次 {rounds_used}/{rounds_limit}。仍有会实质改变结果的空白且预算未用尽时,可以继续以 AGC_NEEDS_USER_INPUT_V1 信封退出:questions 恰好一题,header 必须精确等于「第{next_round}轮·关键决定」。预算已用尽,或剩余空白能由默认建议覆盖且不影响首个可玩闭环时,立即提交 GDD。",
|
||||
"\n\n这是对已认领委派 {original_delegation_id} 的澄清续跑,不是返工轮,不消耗返工深度。已用澄清轮次 {rounds_used}/{rounds_limit}。仍有会实质改变结果的空白且预算未用尽时,可以继续以 AGC_NEEDS_USER_INPUT_V1 信封退出:questions 恰好一题,header 写成「第{next_round}轮·当前要决定:<主题>」,轮号必须是 {next_round},主题写这一轮真正要定的那件事。预算已用尽,或剩余空白能由默认建议覆盖且不影响首个可玩闭环时,立即提交 GDD。",
|
||||
next_round = rounds_used.saturating_add(1),
|
||||
),
|
||||
}
|
||||
@@ -651,6 +665,29 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// 用户修订跳同样带 repairOfDelegationId,但它是人触发的,不该拿到「唯一返工轮」
|
||||
// 那句话。判据用原 delivery 的 contractStatus,并同样只作用于立项策划链路:
|
||||
// `mark_static_delegate_delivery_user_revision_requested_at` 只由策划审批调用,
|
||||
// 这里再加一道 target 门,做游戏 / 做素材的返工跳逐字保持 Repair 分支。
|
||||
let user_revision_hop = match repair_of_delegation_id.as_deref() {
|
||||
Some(original)
|
||||
if plan_clarification_rounds.is_none()
|
||||
&& target_agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID =>
|
||||
{
|
||||
match static_delegate_original_awaits_user_revision_at(root, original) {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: "agent.delegate".to_string(),
|
||||
status: "failed".to_string(),
|
||||
summary: redact_agent_runtime_project_paths(root, &error, 240),
|
||||
detail: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let hop_note = match (
|
||||
repair_of_delegation_id.as_deref(),
|
||||
plan_clarification_rounds,
|
||||
@@ -662,6 +699,11 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked(
|
||||
rounds_limit,
|
||||
}
|
||||
}
|
||||
(Some(original_delegation_id), None) if user_revision_hop => {
|
||||
StaticDelegateHopNote::UserRevision {
|
||||
original_delegation_id,
|
||||
}
|
||||
}
|
||||
(Some(original_delegation_id), None) => StaticDelegateHopNote::Repair {
|
||||
original_delegation_id,
|
||||
},
|
||||
@@ -1688,6 +1730,40 @@ mod tests {
|
||||
/// 「你只剩这一轮」——这正是生产上 4 次澄清续跑之后无一走到第 2 轮的原因。
|
||||
/// 同时钉住轮号:`planning_coordinator` 出卡时按 `rounds_used + 1` 校验 header,
|
||||
/// 这里写进 task 的必须是同一个数,否则第 2 轮信封会当场被拒。
|
||||
/// 用户修订轮同样不能套返工文案。
|
||||
///
|
||||
/// 「唯一返工轮」防的是 runaway agent,而这一跳由用户在审批卡上亲手点出来——人本身
|
||||
/// 就是循环边界,`static_delegate_lineage_counters` 早就把 depth/round 原样继承了。
|
||||
/// 套用返工文案就是告诉策划子 Agent「用户只能改这一次」,和澄清跳当初那个假天花板
|
||||
/// 是同一个错误。原型对应的是软阈值 16 次、超过只提示不拒绝。
|
||||
#[test]
|
||||
fn user_revision_hop_note_is_not_the_repair_round_note() {
|
||||
let revision = render_static_delegate_task_contract(
|
||||
"任务",
|
||||
"project-supervisor",
|
||||
"run-1",
|
||||
"delegation-new",
|
||||
&["交付 game/fast_gdd.md".to_string()],
|
||||
&["game/fast_gdd.md".to_string()],
|
||||
StaticDelegateHopNote::UserRevision {
|
||||
original_delegation_id: "delegation-old",
|
||||
},
|
||||
)
|
||||
.expect("render user revision hop note");
|
||||
assert!(
|
||||
!revision.contains("唯一返工轮"),
|
||||
"用户修订轮不得复用返工文案,否则子 Agent 以为用户只能改这一次:{revision}"
|
||||
);
|
||||
assert!(
|
||||
revision.contains("不消耗返工深度"),
|
||||
"必须写明它不吃返工额度:{revision}"
|
||||
);
|
||||
assert!(
|
||||
revision.contains("不是最后一轮"),
|
||||
"必须写明用户还能再改,否则子 Agent 会把多条意见攒到一轮改完:{revision}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_clarification_hop_note_is_not_the_repair_round_note() {
|
||||
let repair = render_static_delegate_task_contract(
|
||||
@@ -1730,7 +1806,7 @@ mod tests {
|
||||
"澄清续跑必须写明已用轮次与上限:{clarification}"
|
||||
);
|
||||
assert!(
|
||||
clarification.contains("第2轮·关键决定"),
|
||||
clarification.contains("第2轮·当前要决定:"),
|
||||
"task 里的轮号必须等于 planning_coordinator 校验 header 时用的 rounds_used + 1:{clarification}"
|
||||
);
|
||||
|
||||
@@ -1753,7 +1829,7 @@ mod tests {
|
||||
"预算用尽时必须要求收稿,出卡侧会直接拒掉第四张卡:{exhausted}"
|
||||
);
|
||||
assert!(
|
||||
!exhausted.contains("第4轮·关键决定"),
|
||||
!exhausted.contains("第4轮·当前要决定:"),
|
||||
"预算用尽时不得再给出下一轮 header,那是一张永远递不上去的卡:{exhausted}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1062,8 +1062,8 @@ fn plan_string_array_schema(min_items: usize, max_items: usize, item_max_length:
|
||||
/// Strict provider-facing shape for `plan-submit-gdd-input.v1`.
|
||||
///
|
||||
/// Runtime-injected identity, platform facts, version and fingerprint fields
|
||||
/// deliberately do not appear here. The durable handler performs the
|
||||
/// semantic/session equality checks after parsing this wire shape.
|
||||
/// deliberately do not appear here. The durable handler performs the
|
||||
/// structural, identity and CAS checks after parsing this wire shape.
|
||||
fn plan_submit_gdd_input_schema() -> Value {
|
||||
let decision_state = json!({
|
||||
"type": "string",
|
||||
@@ -1071,7 +1071,7 @@ fn plan_submit_gdd_input_schema() -> Value {
|
||||
});
|
||||
let answer_source = json!({
|
||||
"type": "string",
|
||||
"enum": ["user_freeform", "user_option", "default"]
|
||||
"enum": ["user_freeform", "user_option", "user_revision", "default"]
|
||||
});
|
||||
let pillar = json!({
|
||||
"type": "object",
|
||||
@@ -2130,6 +2130,7 @@ mod tests {
|
||||
for stage in [
|
||||
PlanRootSupervisorStage::GoalContract,
|
||||
PlanRootSupervisorStage::Delegate,
|
||||
PlanRootSupervisorStage::AwaitingAcceptanceEvidence,
|
||||
PlanRootSupervisorStage::Delegated,
|
||||
] {
|
||||
let mut staged = functions.clone();
|
||||
@@ -2160,6 +2161,7 @@ mod tests {
|
||||
for stage in [
|
||||
PlanRootSupervisorStage::GoalContract,
|
||||
PlanRootSupervisorStage::Delegate,
|
||||
PlanRootSupervisorStage::AwaitingAcceptanceEvidence,
|
||||
PlanRootSupervisorStage::Delegated,
|
||||
] {
|
||||
let mut staged = functions.clone();
|
||||
|
||||
@@ -28,16 +28,24 @@ 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)
|
||||
{
|
||||
if response_is_static_delegate_user_input_envelope(value) {
|
||||
STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS
|
||||
} else {
|
||||
default_max_chars
|
||||
}
|
||||
}
|
||||
|
||||
/// 这条回复是不是澄清信封,而不是一次交付收束。
|
||||
///
|
||||
/// 收束门禁按「任务是否做完」判据拦最终回复,而澄清信封恰恰相反:它是本 run
|
||||
/// 就此挂起、把决定权交回用户,剩下的工作由用户答完之后的 continuation run 接着
|
||||
/// 做。用完成度判据去拦它,对任何含「答完之后再做 X」步骤的计划都不可满足。
|
||||
pub(crate) fn response_is_static_delegate_user_input_envelope(response: &str) -> bool {
|
||||
response
|
||||
.trim_start()
|
||||
.starts_with(STATIC_DELEGATE_USER_INPUT_PREFIX)
|
||||
}
|
||||
|
||||
/// 构造一份贴着问询 schema 上限的合法澄清信封,供跨模块的通道用例复用。
|
||||
/// 通道必须容得下 schema 允许的最大合法问询,而不只是「碰巧短」的那一条。
|
||||
#[cfg(test)]
|
||||
@@ -278,12 +286,33 @@ impl StaticDelegateCompletionBarrier {
|
||||
&& self.unknown_contract_status_count == 0
|
||||
}
|
||||
|
||||
/// 自动恢复/唤醒路径该不该收手。
|
||||
///
|
||||
/// `user_revision_pending_count` 计在这里是承重的:用户修订是一条显式的 Supervisor
|
||||
/// 决策边界,在它派出续作之前父 run 绝不能被自动恢复(`runtime_tools/delivery.rs`
|
||||
/// 的 `debug_assert` 把这份跨文件依赖钉在使用现场)。
|
||||
pub(crate) fn has_waiting(self) -> bool {
|
||||
self.waiting_count > 0
|
||||
|| self.user_revision_pending_count > 0
|
||||
|| self.unknown_contract_status_count > 0
|
||||
}
|
||||
|
||||
/// 当前正在跑的这一轮,有没有**外部事件**值得 park 着等。
|
||||
///
|
||||
/// 和 `has_waiting()` 问的是相反的问题,所以刻意不计 `user_revision_pending_count`:
|
||||
/// - `waitingDelegations > 0`:子 Agent 正在跑,park 等它 —— 会有回执到来。
|
||||
/// - `unknownContractStatus > 0`:fail-closed,宁可停下也不按未知状态行动。
|
||||
/// - `userRevisionPending > 0`:**没有任何东西在跑**。那条回执只能来自本 run 自己
|
||||
/// 创建的修订委派,park 等它就是等自己,必然死锁。
|
||||
///
|
||||
/// 生产实测:用户点「修改」后 Supervisor park 在「等待专业 Agent 委派回执 / 回执全部
|
||||
/// ready 后自动唤醒当前父 run」,8 分钟零事件——它在等一条只有它自己能造出来的回执。
|
||||
/// main_loop 里本来就有一条专为 user_revision 写的分支(`phase=planning`、
|
||||
/// `next_step=调用 agent.delegate…`),但被上游这道 park 门截胡了。
|
||||
pub(crate) fn has_external_wait(self) -> bool {
|
||||
self.waiting_count > 0 || self.unknown_contract_status_count > 0
|
||||
}
|
||||
|
||||
pub(crate) fn detail(self) -> String {
|
||||
format!(
|
||||
"waitingDelegations={} · readyUnclaimedReceipts={} · unobservedReceiptClaims={} · repairRequired={} · userInputRequired={} · userRevisionPending={} · unknownContractStatus={} · 必须认领专业 Agent 回执,处理 needs-user-input/needs-repair/user-revision-requested,或升级客户端后再继续",
|
||||
@@ -497,6 +526,42 @@ pub(crate) fn mark_static_delegate_delivery_ready_with_result_at(
|
||||
Ok(delivery)
|
||||
}
|
||||
|
||||
/// claim 里的 `structuredResult` 是「父 Agent 在那个 action 上观察到了什么」的冻结
|
||||
/// 快照;delivery 是当前真相。两者绝大多数时候必须逐字相等——不等就是漂移或篡改。
|
||||
///
|
||||
/// 唯一的例外是审批:用户在审批卡上点「修改 / 退回」后,
|
||||
/// `mark_static_delegate_delivery_user_revision_requested_at` 会把 delivery 从
|
||||
/// `EvidenceReady` 原地改写成 `UserRevisionRequested`,而 claim 快照仍停在
|
||||
/// `EvidenceReady`。那不是漂移,是一次只由审批产生、且只能朝这个方向走的合法转移;
|
||||
/// 快照记的那句「当时观察到 evidence-ready」现在依然为真,不该被改写。
|
||||
///
|
||||
/// 按全等判会把它当成冲突:`agent.run_status` 每次重放这条 claim 都 failed,
|
||||
/// Supervisor 永远拿不到回执、也就永远建不出修订委派。生产实测卡死在第 43 轮空转,
|
||||
/// 报「静态委派 claim 与 delivery 身份或结果冲突」。原型没有 claim 这层快照,单一
|
||||
/// 真相就地改,结构上不存在这个冲突——这里翻译的是同一个语义:比较的是「delivery 是
|
||||
/// 不是 receipt 的合法后继」,不是「两者永远全等」。
|
||||
///
|
||||
/// 放行面刻意压到最小:除 `contractStatus` 外每个字段都必须逐字不变,且方向唯一。
|
||||
fn static_delegate_structured_result_follows_claim_snapshot(
|
||||
snapshot: Option<&StaticDelegateStructuredResult>,
|
||||
current: Option<&StaticDelegateStructuredResult>,
|
||||
) -> bool {
|
||||
if snapshot == current {
|
||||
return true;
|
||||
}
|
||||
let (Some(snapshot), Some(current)) = (snapshot, current) else {
|
||||
return false;
|
||||
};
|
||||
if snapshot.contract_status != StaticDelegateContractStatus::EvidenceReady
|
||||
|| current.contract_status != StaticDelegateContractStatus::UserRevisionRequested
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut rebased = current.clone();
|
||||
rebased.contract_status = StaticDelegateContractStatus::EvidenceReady;
|
||||
rebased == *snapshot
|
||||
}
|
||||
|
||||
/// Mark an already claimed, evidence-ready planning delivery as waiting for a
|
||||
/// user-requested revision. Approval is the only producer of this durable
|
||||
/// status; keeping the transition here makes its evidence precondition and
|
||||
@@ -1097,7 +1162,10 @@ fn commit_static_delegate_claim_with_locks_with_budget_at(
|
||||
|| delivery.acceptance_criteria != receipt.acceptance_criteria
|
||||
|| delivery.expected_artifacts != receipt.expected_artifacts
|
||||
|| delivery.repair_of_delegation_id != receipt.repair_of_delegation_id
|
||||
|| delivery.structured_result != receipt.structured_result
|
||||
|| !static_delegate_structured_result_follows_claim_snapshot(
|
||||
receipt.structured_result.as_ref(),
|
||||
delivery.structured_result.as_ref(),
|
||||
)
|
||||
{
|
||||
return Err(format!(
|
||||
"静态委派 claim 与 delivery 身份或结果冲突:{}",
|
||||
@@ -1216,6 +1284,21 @@ fn static_delegate_original_is_awaiting_clarification(
|
||||
///
|
||||
/// 该状态只由后续审批工作包写入;本包只让 lineage 重放认识它,不能自行生成或
|
||||
/// 把其它状态静默映射成它。
|
||||
/// 该原 delivery 是否正等着用户提出的修订(而不是质量返工)。
|
||||
///
|
||||
/// 用户修订和质量返工都带 `repairOfDelegationId`,但额度完全不同:`repair_depth`
|
||||
/// 防的是 runaway agent,而用户修订每一轮都由人触发,人本身就是循环边界。委派 task
|
||||
/// 末尾那句「你在这条链路上的位置」必须按这个判据分开渲染,否则用户第一次点修改就会
|
||||
/// 被告知「这是唯一返工轮」。
|
||||
pub(crate) fn static_delegate_original_awaits_user_revision_at(
|
||||
root: &Path,
|
||||
delegation_id: &str,
|
||||
) -> Result<bool, String> {
|
||||
Ok(read_static_delegate_delivery_at(root, delegation_id)?
|
||||
.as_ref()
|
||||
.is_some_and(static_delegate_original_is_user_revision_requested))
|
||||
}
|
||||
|
||||
fn static_delegate_original_is_user_revision_requested(
|
||||
delivery: &StaticDelegateDeliveryRecord,
|
||||
) -> bool {
|
||||
@@ -2560,8 +2643,8 @@ mod tests {
|
||||
serde_json::json!({
|
||||
"questions": [{
|
||||
"id": "plan_round_1",
|
||||
"header": "第1轮·关键决定",
|
||||
"question": "当前要决定:影子能力在首个可玩闭环中的核心作用。它会同时决定关卡布局、操作手感与原型优先级,也决定第一批谜题按什么规则组合;现在确认可以避免把三种玩法都做浅,也避免原型做到一半再推翻核心规则。",
|
||||
"header": "第1轮·当前要决定:影子能力在首个可玩闭环中的核心作用",
|
||||
"question": "它会同时决定关卡布局、操作手感与原型优先级,也决定第一批谜题按什么规则组合;现在确认可以避免把三种玩法都做浅,也避免原型做到一半再推翻核心规则。",
|
||||
"options": [
|
||||
{
|
||||
"label": "A · 影子化为可独立移动的暗影分身",
|
||||
@@ -2602,12 +2685,12 @@ mod tests {
|
||||
// abtest-tide2A-2:尾巴是「 马会」。
|
||||
concat!(
|
||||
"AGC_NEEDS_USER_INPUT_V1\n",
|
||||
r#"{"questions":[{"id":"replay_motivation","header":"第1轮·关键决定","question":"当前要决定:固定五岛海图的重复游玩动力采用哪种方案?现在确认它,才能锁定首个可玩闭环之外的得分与重开目标。","options":[{"label":"A · 推荐:固定布局冲榜","description":"每局地图与信件配置固定,玩家通过更优路线、潮汐 timing 和装卸顺序刷新送达数与总分;优点是实现最小、可读性强,代价是内容变化较少。"},{"label":"B · 轮换信件组合","description":"地图固定但每局从预设信件组合中轮换收件岛与期限;优点是重玩变化更明显,代价是需要额外平衡组合并降低可预测性。"},{"label":"需要原型验证","description":"用30~90分钟做可点击五岛地图与两种信件配置原型,让3名偏好轻策略的玩家各玩3局,观察是否主动重开及路线是否有差异;通过标准是多数玩家愿意重开且能说出改进路线。"}]}]} 马会"#,
|
||||
r#"{"questions":[{"id":"replay_motivation","header":"第1轮·当前要决定:固定五岛海图的重复游玩动力","question":"现在确认它,才能锁定首个可玩闭环之外的得分与重开目标。","options":[{"label":"A · 推荐:固定布局冲榜","description":"每局地图与信件配置固定,玩家通过更优路线、潮汐 timing 和装卸顺序刷新送达数与总分;优点是实现最小、可读性强,代价是内容变化较少。"},{"label":"B · 轮换信件组合","description":"地图固定但每局从预设信件组合中轮换收件岛与期限;优点是重玩变化更明显,代价是需要额外平衡组合并降低可预测性。"},{"label":"需要原型验证","description":"用30~90分钟做可点击五岛地图与两种信件配置原型,让3名偏好轻策略的玩家各玩3局,观察是否主动重开及路线是否有差异;通过标准是多数玩家愿意重开且能说出改进路线。"}]}]} 马会"#,
|
||||
),
|
||||
// verify-farm-4:尾巴是古吉拉特语字母、西里尔字母和中文垃圾词的混合物。
|
||||
concat!(
|
||||
"AGC_NEEDS_USER_INPUT_V1\n",
|
||||
r#"{"questions":[{"id":"replay_progression","header":"第2轮·关键决定","question":"当前要决定:自由经营农场的长期目标采用哪种组合?这会决定玩家为何持续规划、赚钱与重玩,并控制 MVP 的范围。","options":[{"label":"A · 推荐:里程碑升级+成就","description":"以累计资金解锁少量新地块或设施,同时完成可选成就;优点是目标清晰又保留自由安排,代价是需要同时做基础升级与成就追踪。"},{"label":"B · 专注农场扩建","description":"只用经营收益逐步解锁地块与设施,成就仅作展示;优点是系统更聚焦、反馈直接,代价是挑战层次和重玩目标较少。"},{"label":"需要原型验证","description":"制作 30–90 分钟微型原型,让 2–3 名目标玩家试玩两种目标结构,观察他们是否主动设定计划、理解进展并愿意继续经营;多数玩家能完成一次扩建且愿意追求第二个目标即通过。"}]}]}સwerhu рҭ. 北京赛车? тру. [ ]"#,
|
||||
r#"{"questions":[{"id":"replay_progression","header":"第2轮·当前要决定:自由经营农场的长期目标","question":"这会决定玩家为何持续规划、赚钱与重玩,并控制 MVP 的范围。","options":[{"label":"A · 推荐:里程碑升级+成就","description":"以累计资金解锁少量新地块或设施,同时完成可选成就;优点是目标清晰又保留自由安排,代价是需要同时做基础升级与成就追踪。"},{"label":"B · 专注农场扩建","description":"只用经营收益逐步解锁地块与设施,成就仅作展示;优点是系统更聚焦、反馈直接,代价是挑战层次和重玩目标较少。"},{"label":"需要原型验证","description":"制作 30–90 分钟微型原型,让 2–3 名目标玩家试玩两种目标结构,观察他们是否主动设定计划、理解进展并愿意继续经营;多数玩家能完成一次扩建且愿意追求第二个目标即通过。"}]}]}સwerhu рҭ. 北京赛车? тру. [ ]"#,
|
||||
),
|
||||
];
|
||||
for response in cases {
|
||||
@@ -2628,7 +2711,7 @@ mod tests {
|
||||
// verify-farm-2 现场原文,结尾是 `}]}` 而非 `}]}]}`。
|
||||
let response = concat!(
|
||||
"AGC_NEEDS_USER_INPUT_V1\n",
|
||||
r#"{"questions":[{"id":"core_loop_goal","header":"第1轮·关键决定","question":"当前要决定:这款农场经营游戏的一局,玩家主要通过什么目标获得满足?现在先定核心闭环,才能控制 MVP 范围。","options":[{"label":"A · 推荐:短周期订单经营","description":"围绕播种、收获、加工并完成限时订单推进;目标清晰、反馈快,代价是自由建造与长期规划较少。"},{"label":"B · 自主农场成长","description":"围绕规划田地、逐步扩建并达成阶段里程碑;沉浸和成长感更强,代价是前期目标反馈较慢、系统边界更难控。"},{"label":"需要原型验证","description":"制作 30~90 分钟微型原型,包含种植、收获和一种目标;让 2~3 名目标玩家试玩,观察是否理解目标、是否愿意继续一轮;通过标准是多数玩家无需讲解即可完成闭环并主动开始第二轮。"}]}"#,
|
||||
r#"{"questions":[{"id":"core_loop_goal","header":"第1轮·当前要决定:一局里玩家靠什么目标获得满足","question":"现在先定核心闭环,才能控制 MVP范围。","options":[{"label":"A · 推荐:短周期订单经营","description":"围绕播种、收获、加工并完成限时订单推进;目标清晰、反馈快,代价是自由建造与长期规划较少。"},{"label":"B · 自主农场成长","description":"围绕规划田地、逐步扩建并达成阶段里程碑;沉浸和成长感更强,代价是前期目标反馈较慢、系统边界更难控。"},{"label":"需要原型验证","description":"制作 30~90 分钟微型原型,包含种植、收获和一种目标;让 2~3 名目标玩家试玩,观察是否理解目标、是否愿意继续一轮;通过标准是多数玩家无需讲解即可完成闭环并主动开始第二轮。"}]}"#,
|
||||
);
|
||||
let error = parse_static_delegate_user_input_request(Some(response))
|
||||
.expect_err("an envelope that stops short of closing must not parse");
|
||||
@@ -2832,8 +2915,8 @@ mod tests {
|
||||
// 实测形态:option 对象里多写了一个 `id` 字段。
|
||||
let response = concat!(
|
||||
"AGC_NEEDS_USER_INPUT_V1\n",
|
||||
"{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·关键决定\",",
|
||||
"\"question\":\"当前要决定:核心闭环形状。\",\"options\":[",
|
||||
"{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·当前要决定:核心闭环形状\",",
|
||||
"\"question\":\"它决定首个可玩闭环长什么样。\",\"options\":[",
|
||||
"{\"id\":\"a\",\"label\":\"A · 甲方案\",\"description\":\"甲方案的后果\"},",
|
||||
"{\"id\":\"b\",\"label\":\"B · 乙方案\",\"description\":\"乙方案的后果\"},",
|
||||
"{\"id\":\"c\",\"label\":\"需要原型验证\",\"description\":\"做个微型原型看看\"}]}]}"
|
||||
|
||||
@@ -312,6 +312,12 @@ struct AgentRuntimeState {
|
||||
/// Provider spend.
|
||||
#[serde(default)]
|
||||
plan_update_idle_rounds: u32,
|
||||
/// Consecutive final replies this run had refused by a completion blocker.
|
||||
/// Runtime-owned durable state for the same reason as the two counters
|
||||
/// above: a blocker the model cannot satisfy is a livelock, and a runner
|
||||
/// restart must not launder it back into unbounded Provider spend.
|
||||
#[serde(default)]
|
||||
stale_finalization_rounds: u32,
|
||||
#[serde(default)]
|
||||
max_loop_iterations: u32,
|
||||
#[serde(default)]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user