Compare commits

..

53 Commits

Author SHA1 Message Date
k88936 11d4d3d930 适配 UI 分离图片编辑 multipart 接口
Project CI / Frontend tests (pull_request) Successful in 4m57s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / Backend tests (pull_request) Failing after 11s
Project CI / Native shell tests (pull_request) Failing after 5m40s
将标记图 data URL 解码为 PNG 文件部件。

按 Raw GPT Image 2 新合同发送 prompt、尺寸和输出参数。

保留响应 data[].b64_json 解析与 separation 流程不变。
2026-09-09 13:17:36 +08:00
k88936 418d5732de 调整UI识别指令,细化粒度要求并优化组件返回规则 2026-09-09 13:12:51 +08:00
k88936 592b6b4ee7 简化识别树验证测试中的断言格式 2026-09-09 13:12:51 +08:00
k88936 99c19e3ed9 补充分离流程排障日志
记录 separation sidecar 状态读写与恢复阶段

记录树构造、批次选择、视觉绑定、裁切和 patch 结果

避免输出 base64、完整提示词和敏感凭据
2026-09-09 13:12:51 +08:00
k88936 15e30acb0f 调整UI识别指令细节,明确文字组件识别规则与粒度标准 2026-09-09 13:12:51 +08:00
k88936 56fff8325b 将自动分离图像处理移出异步执行器
在 spawn_blocking 中构建标记图并编码写入处理图

在 spawn_blocking 中完成处理图裁切与文件输出

保留 image-edit 和视觉绑定网络请求的 async 调度边界
2026-09-09 13:12:51 +08:00
k88936 884c5ec5ee 补充自动分离 sidecar 并发边界约束
记录前端单次运行与 runWithStateLocked 的并发前提

明确 sidecar 不参与正式资产写入和项目 revision

补充未来多窗口多进程场景的进程级锁 TODO

记录图像本地处理使用 spawn_blocking 的执行边界
2026-09-09 13:12:51 +08:00
k88936 207b0820dd 统一分离树文件格式
按仓库 rustfmt 规范整理 synthetic root 实现
2026-09-09 13:12:51 +08:00
k88936 19f3d62d78 隔离非法分离区域导致的批次失败
将裁切错误记录为 problematic 并继续流程
避免单个模型区域终止整页分离
2026-09-09 13:12:51 +08:00
k88936 3c1e7bd599 保留分离问题节点的真实重做次数
避免基础设施失败伪造达到上限
保持 problematic 节点诊断信息准确
2026-09-09 13:12:51 +08:00
k88936 dda6b3946a 阻止识别结果引用未授权字体素材
补齐识别阶段字体引用校验
增加伪造字体绑定回归测试
2026-09-09 13:12:51 +08:00
k88936 aff40f794d 修复分离树合成根节点 ID 冲突
避免单图根节点重复进入重做计数
补充根图片构造回归测试
2026-09-09 13:12:50 +08:00
k88936 187faa607d 保留原始分离提示并拆分模块
按模型、树、持久化、提示词和工作流拆分 separation

提取支持 more_turn 的反馈重试 harness

删除旧 separation.rs 文件
2026-09-09 13:12:50 +08:00
k88936 109cc81740 拆分UI自动分离命令模块
保留原始图片分离与绑定提示词

提取模型、树、持久化和工作流子模块

新增可配置 more_turn 反馈重试 harness
2026-09-09 13:12:50 +08:00
k88936 f796aef7e2 完善UI自动分离批次处理
复用资源ID安全路径并修正像素坐标

加入标记图、Raw编辑、视觉绑定修复与问题节点继续处理
2026-09-09 13:12:50 +08:00
k88936 4cc7f4dd64 实现UI自动分离命令
新增分离树、批次处理与sidecar状态

接入Raw GPT Image 2与视觉绑定重试

注册Tauri命令并补充识别组件草稿
2026-09-09 13:12:50 +08:00
k88936 b2d4690f94 补充UI编辑器自动分离工作流合同
新增自动分离、临时 sidecar 与视觉绑定设计合同

明确前端资产登记职责与恢复 TODO
2026-09-09 13:12:50 +08:00
k88936 0400103a19 将 Raw 图片编辑入站改为 multipart
移除 JSON/base64 入站兼容,仅接受 multipart 图片与参数字段

复用扣费前 PNG、尺寸、mask 和 prompt 校验并补充 multipart 测试

优化上游图片字节转发,避免再次克隆 image bytes

同步 Raw 图片编辑技术方案与 Axum multipart 依赖
2026-09-09 13:06:22 +08:00
k88936 c049d68b8e 收紧 Raw 成功响应字段类型
要求 data 与 b64_json 必填并移除无用 provider id

prompt 上限调整为 4 KiB 原始字节并同步文档
2026-09-09 12:03:03 +08:00
k88936 acfcf0c158 同步 platform-image 的 serde 锁定依赖
为 Raw typed 响应记录 serde workspace 依赖
2026-09-09 11:51:25 +08:00
k88936 5bbedd9a5c 限制 Raw prompt 原始字节大小
将 prompt UTF-8 原始字节限制为 16 KiB

补充超限回归测试并同步请求合同
2026-09-09 11:50:30 +08:00
k88936 40316d82c4 在扣费前校验 Raw mask 尺寸
解码时保留 image 与 mask 宽高并拒绝不一致输入

同步 Raw 图片编辑请求合同与回归测试
2026-09-09 11:45:44 +08:00
k88936 6c77de4a36 为 Raw 成功响应收紧类型透传
使用 typed JSON DTO 提取并原样转发 b64_json

不解码图片内容且忽略 output_format 回显
2026-09-09 11:44:31 +08:00
k88936 b3693765ef 透传 Raw 图片结果避免解码回显字段
仅转发上游 data.b64_json 并忽略 output_format

同步 Raw 技术方案并补充不解码回归测试
2026-09-09 11:34:41 +08:00
k88936 f7edc9d0ee 区分 Raw 图片 PNG 资源限制错误
Project CI / Frontend tests (pull_request) Successful in 4m43s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Native shell tests (pull_request) Failing after 5m14s
将有效但超出解码限制的 PNG 映射为明确的 400 提示

补充超大合法 PNG 的回归测试
2026-09-09 11:04:44 +08:00
k88936 f60771dd00 提前检查 Raw 图片请求预算
在构造 multipart 表单前校验请求 deadline

避免预算耗尽时复制输入图片并缩短耗时统计范围
2026-09-09 10:58:48 +08:00
k88936 f7cccd8592 补充 Raw 图片客户端错误上下文
为 HTTP 客户端构造失败统一保留操作上下文

补充配置错误文案回归测试
2026-09-09 10:57:04 +08:00
k88936 5ee2d940c1 修正 Raw 图片尺寸校验错误语义
尺寸校验失败改用请求参数无效语义

补充错误文案回归测试
2026-09-09 10:55:36 +08:00
k88936 c75a7725ce Merge remote-tracking branch 'origin/master' into feat/expose-image2edit
Project CI / Repository checks (pull_request) Successful in 2m51s
Project CI / Native shell tests (pull_request) Failing after 4m9s
Project CI / Backend tests (pull_request) Failing after 4m58s
Project CI / Frontend tests (pull_request) Successful in 3m46s
2026-09-09 10:42:22 +08:00
k88936 3a95715990 同步 Raw 图片编辑技术方案
Project CI / Native shell tests (pull_request) Failing after 6m36s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / Repository checks (pull_request) Failing after 15s
Project CI / Frontend tests (pull_request) Successful in 4m22s
记录解码资源上限和阻塞线程策略
补充输出格式回退与成功追踪约定
2026-09-08 23:41:27 +08:00
k88936 ed86963de2 修正 Raw 图片追踪结果类型
明确输出数据集合类型
保持 API 编译检查通过
2026-09-08 23:38:25 +08:00
k88936 65e73eee02 补充 Raw 图片生成成功追踪
记录成功任务与输出数量
完善上游响应和失败阶段日志
2026-09-08 23:25:47 +08:00
k88936 6068eeda83 补充 Raw 图片编辑请求日志
记录上游响应状态与耗时
记录传输失败阶段和结构化上下文
2026-09-08 23:22:56 +08:00
k88936 0e244c64ea 统一 GPT Image 2 尺寸常量
集中维护像素边界和对齐规则
供生成与编辑路径共同引用
2026-09-08 22:11:35 +08:00
k88936 ad7c059603 遵守 Raw 图片请求截止时间
发送前裁剪有效超时预算
预算耗尽时提前返回结构化错误
2026-09-08 21:54:22 +08:00
k88936 55d2a393d1 完善 Raw 图片尺寸错误类型
为公开错误实现 Error trait
支持通用错误链处理
2026-09-08 21:52:15 +08:00
k88936 e7f66ce950 修正 Raw 图片传输失败阶段标记
区分请求发送和响应读取失败
让错误详情准确反映故障阶段
2026-09-08 20:03:13 +08:00
k88936 35dcfea3ef 修正 Raw 图片输出格式回退
忽略空值后再使用载荷级格式
补充空字符串和 null 回归测试
2026-09-08 19:56:41 +08:00
k88936 cea8bf77fa 减少 Raw 图片编辑请求拷贝
移动 multipart 选项字段而非克隆
降低掩码和字符串的峰值内存
2026-09-08 19:52:13 +08:00
k88936 0136bfe564 移出 Raw 图片同步解码
将请求预校验放入阻塞线程
避免占用 Tokio 异步工作线程
2026-09-08 19:48:58 +08:00
k88936 22785b4d7b 限制 Raw 图片解码资源
为 PNG 解码设置边长和分配上限
避免压缩图片膨胀导致内存耗尽
2026-09-08 19:45:02 +08:00
k88936 a86a2ee4d2 简化 Raw 图片编辑响应类型
移除无必要的 JSON Value 转换
保持响应结构由类型系统校验
2026-09-08 19:32:55 +08:00
k88936 b22eae2726 收紧 Raw 图片编辑输入契约
Project CI / Frontend tests (pull_request) Successful in 3m51s
Project CI / Native shell tests (pull_request) Successful in 20m20s
Project CI / Repository checks (pull_request) Failing after 14s
Project CI / Backend tests (pull_request) Failing after 14s
新增 3840 边长、16 像素对齐、3:1 比例和总像素范围校验

要求 image 与 mask 为可完整解码的 PNG 并在扣费前拒绝非法输入

将 Raw 路由 JSON body limit 提升至 64 MiB

同步 Raw 图片编辑技术方案和边界测试
2026-09-08 18:18:58 +08:00
k88936 35d0db6377 修复 Raw 图片失败审计与响应格式
Project CI / Frontend tests (pull_request) Successful in 6m7s
Project CI / Native shell tests (pull_request) Successful in 19m41s
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Backend tests (pull_request) Failing after 9s
为 Raw provider 失败分支补齐结构化 failure audit 并接入 api-server 记录链

使用 VectorEngine 响应 output_format 生成返回图片 MIME 与扩展名

补充响应格式和审计字段定向测试并同步技术方案
2026-09-08 16:19:29 +08:00
k88936 f087ba3e2b 隔离 Raw 图片代理实现
Project CI / Repository checks (pull_request) Failing after 14s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / Frontend tests (pull_request) Successful in 36m40s
Project CI / Native shell tests (pull_request) Successful in 54m1s
移除对现有图片编辑 client 与 curl transport 的复用

将 Raw GPT Image 2 请求、multipart 构造和响应解析收敛到独立文件

保持现有 editor 图片链路代码不变
2026-09-08 13:54:31 +08:00
k88936 7fc1b841be 收口 Raw 图片 base64 校验错误
客户端仅返回稳定的 base64 格式错误提示

补充测试避免泄露解码器内部诊断细节
2026-09-08 12:30:55 +08:00
k88936 a576fdcc37 移除未使用的 multipart 请求包装函数
无选项测试直接调用统一传输入口

避免生产构建保留未使用函数告警
2026-09-08 12:29:59 +08:00
k88936 4dcf38715b 补充图片编辑选项 multipart 测试
覆盖质量、背景、输出格式与 mask 文件字段

断言文件名、MIME 类型和字节内容均正确发送
2026-09-08 12:21:11 +08:00
k88936 d1f9f4e1ef 减少图片编辑 multipart 字节复制
将已克隆的选项按值传入阻塞传输函数

构造 mask 与参考图表单时直接移动字节缓冲
2026-09-08 12:16:00 +08:00
k88936 5836aaec26 简化图片编辑 multipart 请求分支
统一通过带选项的 multipart 传输函数发送请求

移除冗余包装函数调用与未使用导入
2026-09-08 12:10:56 +08:00
k88936 18d60feb04 补充 Raw 图片参数预检查
在计费前校验质量、背景和输出格式

规范化可选参数并保持单图请求契约
2026-09-08 10:40:09 +08:00
k88936 4e1316e272 实现 Raw GPT Image 2 图片编辑代理
新增受 Bearer 保护的 /api/raw/v1/images/edit JSON 路由

按单图 image 与可选 mask 转发 GPT Image 2 参数

固定 model 与 n 并返回仅含 data[].b64_json 的响应

复用钱包计费与退款边界并补充单元测试
2026-09-08 10:36:48 +08:00
k88936 302b4addae 补充 Raw GPT Image 2 代理方案
Project CI / Native shell tests (pull_request) Successful in 55m40s
Project CI / Backend tests (pull_request) Failing after 14s
Project CI / Repository checks (pull_request) Failing after 14s
Project CI / Frontend tests (pull_request) Successful in 23m43s
明确 JSON 请求与 data 数组响应

记录预检查和钱包事务边界

拆分 api-server 与 platform-image 文件职责
2026-09-07 16:33:34 +08:00
204 changed files with 38623 additions and 12530 deletions
@@ -16,5 +16,8 @@
"maxRetries": 2,
"retryBackoffMs": 500
},
"agentLlm": {}
"agentLlm": {},
"planning": {
"capabilityEnabled": true
}
}
+2
View File
@@ -19,6 +19,8 @@
"config": "node scripts/game-creator-config-wizard.mjs",
"test:chat": "node scripts/agent-swarm-test-chat.mjs --task \"制作一个可直接试玩的原创植物塔防小游戏:玩家选择并放置原创守卫阻挡敌人,完成波次后可以进入下一关并重新开始。主题、单位名称与视觉语言必须原创,不使用任何现有游戏角色、单位名、Logo 或受保护视觉语言。请自主完成正式产物、静态检查和双视口试玩验证。\" --no-open",
"test:chat:manual": "node scripts/agent-swarm-test-chat.mjs",
"test:plan": "node scripts/agent-swarm-test-chat.mjs --plan --task \"我想做一款原创横版像素解谜小游戏,主角是一个能操控自己影子的小机器人,影子可以变成平台和开关。请完成立项策划并给出 Fast GDD。主题、角色名与视觉语言必须原创,不使用任何现有游戏角色、名称、Logo 或受保护视觉语言。\"",
"test:plan:manual": "node scripts/agent-swarm-test-chat.mjs --plan",
"agent-run": "node scripts/run-cli-with-config.mjs --agent-run",
"agent-run:smoke": "node scripts/smoke-agent-run-local-provider.mjs",
"agent-runtime:real-e2e": "node scripts/agent-runtime-real-e2e.mjs",
@@ -36,6 +36,8 @@ export const ungeneratedGameEntryMarker =
'还没有生成游戏。回到聊天输入创意并确认生成后';
export const defaultRealSwarmTestTask =
'制作一个可直接试玩的原创植物塔防小游戏:玩家选择并放置原创守卫阻挡敌人,完成波次后可以进入下一关并重新开始。主题、单位名称与视觉语言必须原创,不使用任何现有游戏角色、单位名、Logo 或受保护视觉语言。请自主完成正式产物、静态检查和双视口试玩验证。';
export const defaultRealSwarmPlanTask =
'我想做一款原创横版像素解谜小游戏,主角是一个能操控自己影子的小机器人,影子可以变成平台和开关。请完成立项策划并给出 Fast GDD。主题、角色名与视觉语言必须原创,不使用任何现有游戏角色、名称、Logo 或受保护视觉语言。';
export const swarmTurnReportPrefix = '[turn.report] ';
export const swarmTurnReportSchema = 'game-creator-swarm-turn-report.v1';
@@ -138,10 +140,15 @@ export const usage = `用法:
--keep-project 保留自动创建的一次性项目
--no-open 手工模式启动预览但不自动打开浏览器
--task <需求> 通过 manual 入口非交互提交自定义需求
--timeout-minutes <分钟> 设置本次执行期限;自动任务默认 50 分钟,手工模式默认不限时
--plan 走「做方案」立项策划入口,不做游戏,不做产物验收和试玩
--timeout-minutes <分钟> 设置本次执行期限;自动任务默认 50 分钟,--plan 默认 6 分钟,手工模式默认不限时
--dry-run 只检查目录发现和项目准备,不启动 LLM
-h, --help 显示帮助
`;
环境变量:
AGC_PLAN_GDD_DECISION 审批卡自动应答动作,默认 approverevise/reject 必须
同时用 AGC_PLAN_GDD_COMMENT 给出真实修改意见
AGC_PLAN_GDD_COMMENT revise/reject 的意见原文`;
function readOptionValue(args, index, option) {
const value = args[index + 1]?.trim();
@@ -170,6 +177,7 @@ export function parseSwarmTestArguments(args) {
keepProject: false,
openBrowser: true,
task: null,
plan: false,
timeoutMinutes: null,
dryRun: false,
help: false,
@@ -194,6 +202,8 @@ export function parseSwarmTestArguments(args) {
if (task.length > 4_000) throw new Error('--task 不能超过 4000 字符');
options.task = task;
index += 1;
} else if (argument === '--plan') {
options.plan = true;
} else if (argument === '--timeout-minutes') {
if (options.timeoutMinutes !== null) {
throw new Error('--timeout-minutes 只能指定一次');
@@ -212,11 +222,16 @@ export function parseSwarmTestArguments(args) {
}
export function shouldStartPersistentPreview(options) {
return !options.task;
// 立项策划链路只出 GDD,没有可试玩产物,任何模式都不该起预览。
return !options.task && !options.plan;
}
export function resolveSwarmTestTimeoutMs(options) {
const minutes = options.timeoutMinutes ?? (options.task ? 50 : null);
// 立项策划的设计目标是五分钟出方案,给一分钟余量;再久就是卡住了,早失败
// 比让 harness 空等更有用。做游戏那条链路的 50 分钟不变。
const planMinutes = options.plan ? 6 : null;
const minutes =
options.timeoutMinutes ?? planMinutes ?? (options.task ? 50 : null);
return minutes === null ? null : minutes * 60_000;
}
@@ -964,12 +979,25 @@ export function swarmAutoPilotShouldCloseInput(output, promptsAfterSubmit) {
return swarmAutoPilotSitsAtPrompt(output) && promptsAfterSubmit >= 1;
}
// GDD 审批位不能等 CLI 退出之后再处理:Run 停在这里时状态是 waiting-for-user-input
// 而 swarm CLI 恰好把这个状态算作「本轮还在跑」,turn 永远不 settleCLI 也就永远
// 不退出。所以审批必须在 CLI 还活着的时候并发做完,让 Run 自己继续跑到收束。
// 这一句是 PlanGddCompletionBlockerKind::AwaitingApprovalDecision 专有的投影文案,
// 另外三个 blocked 子状态都不会打出它;即便认错了,真正的判据也是随后那次
// --plan-gdd-status,没有待决定审批时不会有任何写入。
const planGddApprovalWaitPattern = /等待 Fast GDD 审批决定/u;
export function swarmOutputAwaitsPlanGddApproval(line) {
return planGddApprovalWaitPattern.test(line);
}
async function runTaskCargo(
cliArguments,
task,
setActiveChild,
timeoutMs,
autoPilot = false,
onPlanGddApprovalWait = null,
) {
const child = spawnChild(cargoCommand, buildCargoCliArguments(cliArguments), {
stdio: ['pipe', 'pipe', 'inherit'],
@@ -981,6 +1009,25 @@ async function runTaskCargo(
let taskSubmitted = false;
let promptsSeen = 0;
let sittingAtPrompt = false;
let planGddApproval = null;
let planGddApprovalError = null;
let planGddApprovalStarted = false;
let planGddApprovalPromise = null;
const startPlanGddApproval = () => {
planGddApprovalStarted = true;
console.log(
`[自动审批] 检测到 Fast GDD 审批位,正在提交 ${resolvePlanGddAutoDecision().action}`,
);
planGddApprovalPromise = onPlanGddApprovalWait()
.then((value) => {
planGddApproval = value;
})
.catch((error) => {
planGddApprovalError = error;
// 审批没成的话 Run 会一直停在等待位,干等到超时只会把真正的原因埋掉。
void terminateChildTree(child).catch(() => {});
});
};
child.stdout.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
process.stdout.write(chunk);
@@ -993,6 +1040,13 @@ async function runTaskCargo(
reportLines.push(normalizedLine);
settled = true;
}
if (
onPlanGddApprovalWait &&
!planGddApprovalStarted &&
swarmOutputAwaitsPlanGddApproval(normalizedLine)
) {
startPlanGddApproval();
}
}
if (!autoPilot || child.stdin.writableEnded) return;
const atPrompt = swarmAutoPilotSitsAtPrompt(pendingLine);
@@ -1031,9 +1085,12 @@ async function runTaskCargo(
if (normalizedPendingLine.startsWith(swarmTurnReportPrefix)) {
reportLines.push(normalizedPendingLine);
}
await planGddApprovalPromise;
if (planGddApprovalError) throw planGddApprovalError;
return {
...result,
turnReportOutput: reportLines.join('\n'),
planGddApproval,
};
} finally {
setActiveChild(null);
@@ -1723,6 +1780,196 @@ export async function validateSwarmProjectArtifacts(projectPath, options) {
return inspection;
}
// 这四条路径的权威定义都在 Rust 侧 `planning_storage.rs``PLAN_SESSION_PATH`、
// `PLAN_GDD_INDEX_PATH`、`PLAN_STORAGE_ROOT`、`PLAN_FAST_GDD_PATH`)。跨语言没有共享
// 常量的通道,改路径时要连同 `GddApprovalCard.tsx` 一起动。
export const planningOutputPaths = [
'.agent/planning/session.json',
'.agent/planning/index.json',
'.agent/planning/pending.json',
'game/fast_gdd.md',
];
export async function inspectPlanningOutputs(projectPath) {
const outputs = [];
for (const relativePath of planningOutputPaths) {
const absolutePath = path.join(projectPath, ...relativePath.split('/'));
const metadata = await lstat(absolutePath).catch((error) => {
if (error?.code === 'ENOENT') return null;
throw error;
});
outputs.push({
path: relativePath,
exists: Boolean(metadata?.isFile()),
bytes: metadata?.isFile() ? metadata.size : 0,
});
}
return outputs;
}
async function reportPlanningOutputs(projectPath) {
const outputs = await inspectPlanningOutputs(projectPath);
console.log('\n立项策划产物:');
for (const output of outputs) {
console.log(
output.exists
? ` [有] ${output.path}${output.bytes} 字节)`
: ` [无] ${output.path}`,
);
}
}
const planGddApprovalTimeoutMs = 60_000;
export const planGddStatusOutputPrefix = 'planGddStateJson=';
export const planGddDecisionOutputPrefix = 'planGddDecisionJson=';
function parsePrefixedJsonLine(output, prefix, label) {
const line = output
.split('\n')
.map((value) => (value.endsWith('\r') ? value.slice(0, -1) : value))
.find((value) => value.startsWith(prefix));
if (!line) throw new Error(`${label}缺少 ${prefix} 输出`);
try {
return JSON.parse(line.slice(prefix.length));
} catch (error) {
throw new Error(`解析${label}失败:${error.message}`);
}
}
export function parsePlanGddStatusOutput(output) {
return parsePrefixedJsonLine(
output,
planGddStatusOutputPrefix,
'Fast GDD 审批状态',
);
}
export function parsePlanGddDecisionOutput(output) {
return parsePrefixedJsonLine(
output,
planGddDecisionOutputPrefix,
'Fast GDD 审批回执',
);
}
// 审批卡是这条链路唯一的人类判据,所以自动应答默认只投 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,
setActiveChild,
) {
const readStatus = async () => {
const result = await runCapturedCargo(
['--config-dir', runtimeConfigPath, '--plan-gdd-status', projectPath],
setActiveChild,
{
timeoutMs: planGddApprovalTimeoutMs,
label: 'Fast GDD 审批状态查询',
},
);
if (result.code !== 0 || result.signal) {
throw new Error(
`读取 Fast GDD 审批状态失败:${result.stderr.trim() || result.stdout.trim()}`,
);
}
return parsePlanGddStatusOutput(result.stdout);
};
const before = await readStatus();
if (!planGddAutoApprovalIsPending(before)) {
return { decided: false, state: before };
}
const { action, comment } = resolvePlanGddAutoDecision();
const decision = await runCapturedCargo(
[
'--config-dir',
runtimeConfigPath,
'--plan-gdd-decide',
projectPath,
action,
...(comment === null ? [] : ['--stdin']),
],
setActiveChild,
{
timeoutMs: planGddApprovalTimeoutMs,
label: 'Fast GDD 审批决定',
stdin: comment,
},
);
if (decision.code !== 0 || decision.signal) {
throw new Error(
`提交 Fast GDD 审批决定失败:${decision.stderr.trim() || decision.stdout.trim()}`,
);
}
const receipt = parsePlanGddDecisionOutput(decision.stdout);
// 回执落盘和唤醒后台任务是两件事:decide 命令把唤醒失败降级成 recoveryPending
// 于是审批已经生效、Run 却仍停在 waiting-for-user-input。实测就是这样——只有
// 补一次 --agent-resume 才会重新起 turn。这是仓库自己给这个状态定义的恢复动作。
let recovered = false;
if (receipt.recoveryPending) {
const resume = await runCapturedCargo(
['--config-dir', runtimeConfigPath, '--agent-resume', projectPath],
setActiveChild,
{
timeoutMs: planGddApprovalTimeoutMs,
label: 'Fast GDD 审批后恢复后台任务',
},
);
if (resume.code !== 0 || resume.signal) {
throw new Error(
`审批已提交但恢复后台任务失败:${resume.stderr.trim() || resume.stdout.trim()}`,
);
}
recovered = true;
}
return { decided: true, receipt, recovered, state: await readStatus() };
}
async function reportPlanGddApproval(approval) {
const { state } = approval;
console.log('\nFast GDD 审批:');
if (!approval.decided) {
console.log(` [无待决定审批] 当前投影状态=${state.state}`);
return;
}
console.log(
` [已决定 ${approval.receipt.decisionRef.action}] outcome=${approval.receipt.outcome} v${approval.receipt.decisionRef.version} 投影状态=${state.state}`,
);
if (approval.recovered) {
console.log(
' [已恢复] 审批回执的 recoveryPending 由一次 --agent-resume 收口',
);
}
if (state.session) {
console.log(
` 澄清轮次=${state.session.clarificationRound} 返工深度=${state.session.repairDepth} phase=${state.session.phase}`,
);
}
}
export async function hasConfiguredEditorApiKey(configDir) {
let configured = false;
for (const fileName of [configFileName, localConfigFileName]) {
@@ -1875,7 +2122,13 @@ export async function runSwarmTestChat(options) {
const setActiveChild = (child) => {
activeChild = child;
};
// GDD 审批要和 swarm CLI 并发跑,两者不能共用 activeChild 这一个槽位:审批子进程
// 结束时的 setActiveChild(null) 会把 CLI 从槽里抹掉,Ctrl-C 就杀不到它了。
const concurrentChildren = new Set();
const setConcurrentChild = (child) => {
if (child) concurrentChildren.add(child);
else concurrentChildren.clear();
};
const stopRequested = () => receivedSignal !== null;
const handleSignal = (signal) => {
const repeatedSignal = receivedSignal !== null;
@@ -1953,10 +2206,11 @@ export async function runSwarmTestChat(options) {
);
}
console.log('LLM 配置已就绪。');
const requirementNoun = options.plan ? '立项策划需求' : '游戏需求';
console.log(
options.task
? '已提交一条非交互游戏需求,正在等待 Swarm 自主完成。\n'
: '输入一条游戏需求并回车;提交后按 Ctrl+D,让 Swarm 自主完成。\n',
? `已提交一条非交互${requirementNoun},正在等待 Swarm 自主完成。\n`
: `输入一条${requirementNoun}并回车;提交后按 Ctrl+D,让 Swarm 自主完成。\n`,
);
phase = 'chat';
@@ -1966,7 +2220,8 @@ export async function runSwarmTestChat(options) {
runtimeConfig.path,
'--swarm-chat',
'--init',
'--autonomous-game-build',
// 做方案链路只能跑 standard 档,后端对 plan + autonomous 是硬否决。
options.plan ? '--plan' : '--autonomous-game-build',
project.path,
];
let chat;
@@ -1979,6 +2234,15 @@ export async function runSwarmTestChat(options) {
timeoutDeadline === null
? null
: Math.max(1, timeoutDeadline - Date.now()),
options.plan,
options.plan
? () =>
settlePlanGddApproval(
project.path,
runtimeConfig.path,
setConcurrentChild,
)
: null,
)
: await runInteractiveCargo(chatArguments, setActiveChild);
} catch (error) {
@@ -1994,6 +2258,32 @@ export async function runSwarmTestChat(options) {
if (options.task) {
turnReport = parseSettledSwarmTurnReport(chat.turnReportOutput);
}
if (options.plan) {
// 立项策划不出游戏产物,正式验收在 GDD 审批卡上;这里只报告落盘情况,
// 是否收束已经由 CLI 的退出码判过了。
// 自动任务档的审批已经在 CLI 运行期间并发做完了;手工档(人自己敲 Ctrl+D
// 退出)没有那次触发,退出后补一次,没有待决定审批时它是只读的。
phase = 'plan-approval';
const approval =
chat.planGddApproval ??
(await settlePlanGddApproval(
project.path,
runtimeConfig.path,
setConcurrentChild,
));
if (receivedSignal) break session;
phase = 'plan-report';
await reportPlanGddApproval(approval);
await reportPlanningOutputs(project.path);
phase = 'complete';
console.log(
approval.decided
? '\n立项策划链路已收束:Fast GDD 已批准,策划产物见上方清单。'
: '\n立项策划链路已收束:Run 正常结束但没有待决定审批,策划产物见上方清单。',
);
break session;
}
phase = 'artifact-validation';
const requireEditorImages = await hasConfiguredEditorApiKey(
runtimeConfig.path,
);
@@ -1705,16 +1705,10 @@ if (
runtimeConfigSetupStart === -1 ||
runtimeConfigSetupEnd === -1 ||
!runtimeConfigSetupSource.includes('sanitize_diagnostic_message(') ||
!runtimeConfigSetupSource.includes('setup_log.fail(') ||
!runtimeConfigSetupSource.includes('append_bounded_diagnostic_line(') ||
!runtimeConfigSetupSource.includes(
'startup.appdata.configure.failed details={details}',
) ||
!tauriHandlerSource.includes('impl StartupLogSlot {') ||
!tauriHandlerSource.includes('append_bounded_diagnostic_line(&path, line)') ||
!tauriHandlerSource.includes(
'self.append(line);\n show_startup_error_dialog(self.path().as_deref());',
) ||
!tauriHandlerSource.includes('early_startup_log_path(')
)
) {
throw new Error(
'AI game creator setup must configure the runtime AppData directory and log sanitized setup failures',
@@ -722,7 +722,7 @@ function runAgent() {
stderr += chunk.toString();
});
child.on('error', reject);
child.on('close', (code, signal) => {
child.on('close', (code) => {
const output = `${stdout}${stderr}`;
if (previewReadError) {
reject(previewReadError);
@@ -740,24 +740,13 @@ function runAgent() {
previewDom,
});
} else {
reject(
new Error(
`agent run failed: exitCode=${code}, signal=${signal ?? 'none'}\n` +
`stderr tail (last 8000 characters):\n${stderr.slice(-8000)}\n` +
`stdout tail (last 4000 characters):\n${stdout.slice(-4000)}`,
),
);
reject(new Error(output || `agent run exited with ${code}`));
}
});
});
}
async function seedLocalAsset() {
await fs.mkdir(path.join(projectRoot, 'game'), { recursive: true });
await fs.writeFile(
path.join(projectRoot, 'game/index.html'),
'<!doctype html><html lang="zh-CN"><meta charset="UTF-8"><body>还没有生成游戏</body></html>',
);
await fs.mkdir(path.join(projectRoot, 'assets/uploads'), { recursive: true });
await fs.mkdir(path.join(projectRoot, '.agent'), { recursive: true });
await seedConversationContext();
@@ -1,16 +1,10 @@
import { spawn, spawnSync } from 'node:child_process';
import { spawn } from 'node:child_process';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import http from 'node:http';
import net from 'node:net';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
normalizeWindowsPath,
parseWindowsProcessSnapshot,
stopWindowsProcessTree,
stopWindowsWorktreeProcesses,
} from '../../../scripts/dev-windows-process.mjs';
import {
agcVitePortEnvKey,
readAgcDevEndpoint,
@@ -21,10 +15,6 @@ import {
const appRoot = fileURLToPath(new URL('..', import.meta.url));
const repoRoot = resolve(appRoot, '../..');
const devStackStatePath = resolve(repoRoot, '.app/dev-stack.json');
const apiServerExePath = resolve(
repoRoot,
'server-rs/target/debug/api-server.exe',
);
const defaultApiTarget =
process.env.RUST_SERVER_TARGET || 'http://127.0.0.1:8082';
const backendDatabase = 'genarrative-game-creator-dev';
@@ -170,184 +160,19 @@ function readBackendServiceFailure(
return null;
}
function urlPort(url) {
try {
const port = Number(new URL(url).port);
return Number.isInteger(port) && port > 0 ? port : 0;
} catch {
return 0;
}
}
// 读取端口当前真正的监听进程身份。返回 null 表示探测本身不可用(例如缺少
// Get-NetTCPConnection),此时调用方必须退化为旧行为,不能让本地启动直接失败。
function readWindowsPortOwnerIdentities(
ports,
{ spawnImpl = spawnSync, env = process.env } = {},
) {
const uniquePorts = [...new Set(ports.filter((port) => port > 0))];
if (uniquePorts.length === 0) {
return null;
}
const command = [
'$ErrorActionPreference = "SilentlyContinue"',
'$ports = ($env:GENARRATIVE_QUERY_PORTS -split ",") | Where-Object { $_ }',
'$result = @()',
'foreach ($port in $ports) {',
' $connection = Get-NetTCPConnection -State Listen -LocalPort ([int]$port) -ErrorAction SilentlyContinue | Select-Object -First 1',
' if (-not $connection) { continue }',
' $owner = Get-CimInstance Win32_Process -Filter ("ProcessId=" + $connection.OwningProcess) -ErrorAction SilentlyContinue',
' $result += [pscustomobject]@{ port = [int]$port; processId = [int]$connection.OwningProcess; name = $owner.Name; executablePath = $owner.ExecutablePath; commandLine = $owner.CommandLine }',
'}',
'ConvertTo-Json -InputObject @($result) -Compress',
].join('\n');
const result = spawnImpl(
'powershell.exe',
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command],
{
encoding: 'utf8',
env: { ...env, GENARRATIVE_QUERY_PORTS: uniquePorts.join(',') },
maxBuffer: 8 * 1024 * 1024,
},
);
if (result?.error || result?.status !== 0) {
return null;
}
const owners = new Map();
for (const entry of parseWindowsProcessSnapshot(result.stdout)) {
const port = Number(entry?.port);
if (Number.isInteger(port) && port > 0) {
owners.set(port, entry);
}
}
return owners;
}
function isWorktreeApiServerOwner(
owner,
{ expectedExePath = apiServerExePath } = {},
) {
if (!owner) {
return false;
}
const expected = normalizeWindowsPath(expectedExePath);
const actual = normalizeWindowsPath(owner.executablePath);
return Boolean(expected) && actual === expected;
}
function isWorktreeSpacetimeOwner(
owner,
{ expectedDataDir = backendSpacetimeDataDir } = {},
) {
if (!owner) {
return false;
}
const expected = normalizeWindowsPath(expectedDataDir);
if (!expected) {
return false;
}
const name = String(owner.name ?? '').toLowerCase();
if (!name.startsWith('spacetime')) {
return false;
}
return normalizeWindowsPath(owner.commandLine).includes(expected);
}
// 端口健康不代表后端属于当前工作树:上个工作树 Ctrl+C 残留的 api-server 仍会
// 应答 /healthz。复用前必须证明端口上的进程就是本工作树的可执行文件与数据目录。
function verifyAgcBackendOwnership({
apiUrl,
spacetimeUrl,
bgfilterWorkerUrl,
platform = process.platform,
expectedExePath = apiServerExePath,
expectedDataDir = backendSpacetimeDataDir,
readPortOwners = readWindowsPortOwnerIdentities,
} = {}) {
if (platform !== 'win32') {
return { ok: true, reason: 'platform-unsupported', owners: new Map() };
}
const ports = [
urlPort(apiUrl),
urlPort(bgfilterWorkerUrl),
urlPort(spacetimeUrl),
];
const owners = readPortOwners(ports);
if (!owners) {
return { ok: true, reason: 'owner-probe-unavailable', owners: new Map() };
}
const apiOwner = owners.get(urlPort(apiUrl));
if (!isWorktreeApiServerOwner(apiOwner, { expectedExePath })) {
return { ok: false, reason: 'api-server-owner-mismatch', owners, apiOwner };
}
const workerOwner = owners.get(urlPort(bgfilterWorkerUrl));
if (!isWorktreeApiServerOwner(workerOwner, { expectedExePath })) {
return {
ok: false,
reason: 'bgfilter-worker-owner-mismatch',
owners,
workerOwner,
};
}
const spacetimeOwner = owners.get(urlPort(spacetimeUrl));
if (!isWorktreeSpacetimeOwner(spacetimeOwner, { expectedDataDir })) {
return {
ok: false,
reason: 'spacetime-owner-mismatch',
owners,
spacetimeOwner,
};
}
return { ok: true, reason: 'owned', owners };
}
function formatOwnerLabel(owner) {
if (!owner) {
return '未知进程';
}
const pid = Number(owner.processId);
const label = owner.executablePath || owner.commandLine || owner.name || '';
return `${Number.isInteger(pid) ? `pid=${pid} ` : ''}${String(label).trim()}`.trim();
}
async function isBackendReady({
state = readJson(devStackStatePath),
isReady = isHttpReady,
verifyOwnership = verifyAgcBackendOwnership,
onOwnershipRejected = null,
} = {}) {
const { apiUrl, spacetimeUrl, bgfilterWorkerUrl, hasMatchingBackend } =
resolveBackendTargetsFromState(state, {
requireAgcBackend: true,
});
if (!hasMatchingBackend || !apiUrl || !spacetimeUrl || !bgfilterWorkerUrl) {
return false;
}
const ownership = await verifyOwnership({
apiUrl,
spacetimeUrl,
bgfilterWorkerUrl,
});
if (!ownership?.ok) {
onOwnershipRejected?.(ownership);
return false;
}
if (ownership.reason === 'owner-probe-unavailable') {
console.warn(
'[ai-game-creator-shell] 无法读取端口监听进程归属,本次按旧行为复用配套后端。',
);
}
return (
hasMatchingBackend &&
Boolean(apiUrl) &&
Boolean(spacetimeUrl) &&
Boolean(bgfilterWorkerUrl) &&
(await isReady(`${apiUrl}/healthz`)) &&
(await isReady(`${spacetimeUrl}/v1/ping`)) &&
(await isReady(`${bgfilterWorkerUrl}/readyz`))
@@ -660,18 +485,14 @@ async function terminateChildTree(
return { stopped: true, forced: false };
}
const result = await taskkillImpl(child.pid);
const taskkillStopped =
!result?.timedOut &&
!result?.error &&
[0, 128].includes(result?.code ?? 0);
if (taskkillStopped) {
return { stopped: true, forced: true, result };
}
// 包装层(cmd.exe / npm.cmd)先被 Ctrl+C 杀掉时 taskkill 拿不到活着的 PID
// 这里继续按记录下来的根 PID 遍历,尽量收掉更深的后端进程。
const treeStopped = stopWindowsProcessTree(child.pid);
return { stopped: treeStopped.length > 0, forced: true, result };
return {
stopped:
!result?.timedOut &&
!result?.error &&
[0, 128].includes(result?.code ?? 0),
forced: true,
result,
};
}
const processGroupId = childLifecycles.get(child)?.processGroupId;
@@ -721,29 +542,15 @@ async function waitForBackendReady(
backendChild,
timeoutMs = 600_000,
{
checkBackendReady = (onOwnershipRejected) =>
isBackendReady({ onOwnershipRejected }),
checkBackendReady = isBackendReady,
readState = () => readJson(devStackStatePath),
resolveTargets = readBackendTargets,
} = {},
) {
const initialStateUpdatedAt = readState()?.updatedAt ?? '';
const startedAt = Date.now();
let lastOwnershipReason = '';
while (Date.now() - startedAt < timeoutMs) {
if (
await checkBackendReady((ownership) => {
if (ownership.reason === lastOwnershipReason) {
return;
}
lastOwnershipReason = ownership.reason;
// 本次自己拉起的后端如果归属校验一直不通过,必须把原因打出来,
// 否则只会表现为等待 600 秒后超时。
console.warn(
`[ai-game-creator-shell] 等待配套后端就绪时归属校验未通过(${ownership.reason}: ${formatOwnerLabel(ownership.apiOwner ?? ownership.spacetimeOwner ?? ownership.workerOwner)})。`,
);
})
) {
if (await checkBackendReady()) {
return resolveTargets();
}
const state = readState();
@@ -769,14 +576,7 @@ async function waitForBackendReady(
async function ensureBackend({
onBackendChild = () => {},
checkBackendReady = () =>
isBackendReady({
onOwnershipRejected(ownership) {
console.warn(
`[ai-game-creator-shell] 端口上的配套后端不属于当前工作树(${ownership.reason}: ${formatOwnerLabel(ownership.apiOwner ?? ownership.spacetimeOwner ?? ownership.workerOwner)}),改为启动本工作树自己的后端。`,
);
},
}),
checkBackendReady = isBackendReady,
resolveTargets = readBackendTargets,
spawnBackend = () =>
spawnChild(
@@ -856,33 +656,15 @@ async function startVite(apiTarget, endpoint = readAgcDevEndpoint()) {
async function main() {
let backendChild = null;
let startedBackend = false;
let viteChild = null;
let shutdownSignal = '';
const signalHandlers = new Map();
// 只有本次会话真正拉起过配套后端时才做兜底清扫:复用别人后端时不能连带
// 杀掉对方的进程。dev.mjs 的清理依赖它的 shell 包装层仍然活着,而 Ctrl+C
// 往往先杀掉包装层,所以这里必须按本工作树 api-server.exe 的身份再收一次。
const sweepStartedBackend = () => {
if (!startedBackend || process.platform !== 'win32') {
return;
}
const stopped = stopWindowsWorktreeProcesses({ apiServerExePath });
if (stopped.length > 0) {
console.log(
`[ai-game-creator-shell] 已清理残留后端进程: ${stopped.join(', ')}`,
);
}
};
for (const signal of ['SIGINT', 'SIGTERM']) {
const handler = () => {
shutdownSignal = signal;
stopChild(viteChild, signal);
stopChild(backendChild, signal);
// 立刻清扫,避免外层 taskkill /F 抢在 finally 之前把本进程杀掉。
sweepStartedBackend();
};
signalHandlers.set(signal, handler);
process.on(signal, handler);
@@ -901,7 +683,6 @@ async function main() {
},
});
backendChild = backend.backendChild;
startedBackend = Boolean(backendChild);
if (shutdownSignal) {
throw new Error(`启动期收到 ${shutdownSignal},已停止配套后端`);
}
@@ -935,7 +716,6 @@ async function main() {
terminateChildTree(viteChild),
terminateChildTree(backendChild),
]);
sweepStartedBackend();
for (const [signal, handler] of signalHandlers) {
process.off(signal, handler);
}
@@ -952,25 +732,20 @@ function isDirectModuleExecution() {
export {
ensureBackend,
formatChildFailure,
formatOwnerLabel,
isAiGameCreatorServer,
isBackendReady,
isDirectModuleExecution,
isProcessGroupAlive,
isWorktreeApiServerOwner,
isWorktreeSpacetimeOwner,
preflightExistingVite,
readBackendServiceFailure,
readChildFailure,
readExistingViteServer,
readLinuxProcessGroupAlive,
readWindowsPortOwnerIdentities,
resolveBackendTargetsFromState,
runWindowsTaskkill,
spawnChild,
stopChild,
terminateChildTree,
verifyAgcBackendOwnership,
waitForBackendReady,
waitForChildTermination,
};
@@ -43,6 +43,7 @@ struct PromptCompositions {
/// 而是一份独立的完整清单:plan 根的工具面只有 7 个原生工具,专业组、
/// isolated child、任务图与视觉产物合同在这条链路上全部不可执行,逐段
/// 减法会把「plan 根到底看到什么」摊在两个函数的四个否定分支里。
supervisor_plan: Vec<String>,
supervisor_chat: SupervisorChatComposition,
}
@@ -98,6 +99,10 @@ struct ProviderFragments {
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct AgentCatalog {
supervisor: AgentGroup,
/// 立项策划子 Agent。与 `supervisor` 平级、**不进 `groups`**`specialist_nodes`
/// 只从 `groups[].roles[]` 派生,因此它不参与 `build.rs` 与种子 DAG 的一致性
/// 校验,「做游戏」的 16 任务 DAG 一行不动。详见技术方案第 3.1 节。
planning: AgentGroup,
groups: Vec<AgentGroup>,
}
@@ -226,6 +231,12 @@ pub fn compile_manifest(manifest_path: &Path) -> Result<CompiledPromptBundle, St
&sections,
&["$base", "$visualContract"],
)?;
validate_composition(
"supervisorPlan",
&manifest.compositions.supervisor_plan,
&sections,
&["$header"],
)?;
validate_section_reference(
&manifest.compositions.supervisor_chat.identity,
&sections,
@@ -288,6 +299,7 @@ pub fn compile_manifest(manifest_path: &Path) -> Result<CompiledPromptBundle, St
.supervisor
.roles
.iter()
.chain(manifest.agent_catalog.planning.roles.iter())
.chain(
manifest
.agent_catalog
@@ -336,6 +348,7 @@ pub fn compile_manifest(manifest_path: &Path) -> Result<CompiledPromptBundle, St
.runtime
.iter()
.chain(manifest.compositions.supervisor.iter())
.chain(manifest.compositions.supervisor_plan.iter())
.filter(|item| !item.starts_with('$'))
.cloned()
.collect::<BTreeSet<_>>();
@@ -408,6 +421,14 @@ fn validate_section_ownership(manifest: &PromptBundleManifest) -> Result<(), Str
{
register("composition supervisor", section);
}
for section in manifest
.compositions
.supervisor_plan
.iter()
.filter(|section| !section.starts_with('$'))
{
register("composition supervisorPlan", section);
}
register("composition supervisorChat.identity", identity);
register(
"composition supervisorChat.finalReply",
@@ -436,9 +457,19 @@ fn validate_section_ownership(manifest: &PromptBundleManifest) -> Result<(), Str
"composition supervisor",
"composition supervisorChat.identity",
]);
// plan 根 composition 是 Supervisor system prompt 的第二条 lane,不是另一种
// 语义面。它按设计复用 runtime lane 的 `isolatedAgentContract``agent.delegate`
// 的 expectedArtifacts/writeScopes 合同)和 supervisor lane 的 `supervisorRepair`
// (返工必须逐字继承原合同)。除这两个方向外,跨所有者复用仍然是错误。
let allowed_plan_runtime_owners =
BTreeSet::from(["composition runtime", "composition supervisorPlan"]);
let allowed_plan_supervisor_owners =
BTreeSet::from(["composition supervisor", "composition supervisorPlan"]);
for (section, section_owners) in owners {
if section_owners.len() > 1
&& !(section == identity && section_owners == allowed_identity_owners)
&& section_owners != allowed_plan_runtime_owners
&& section_owners != allowed_plan_supervisor_owners
{
return Err(format!(
"Prompt section 跨语义所有者复用:{section} -> {section_owners:?}"
@@ -675,11 +706,17 @@ fn validate_agent_catalog(catalog: &AgentCatalog) -> Result<(), String> {
if catalog.supervisor.roles.len() != 1 {
return Err("agentCatalog.supervisor 必须且只能包含一个 role".to_string());
}
if catalog.planning.roles.len() != 1 {
return Err("agentCatalog.planning 必须且只能包含一个 role".to_string());
}
if catalog.groups.is_empty() {
return Err("agentCatalog.groups 不能为空".to_string());
}
let mut group_brief_names = BTreeSet::new();
for group in std::iter::once(&catalog.supervisor).chain(catalog.groups.iter()) {
for group in std::iter::once(&catalog.supervisor)
.chain(std::iter::once(&catalog.planning))
.chain(catalog.groups.iter())
{
if !group_brief_names.insert(group.brief_path_name.as_str()) {
return Err(format!(
"agent group briefPathName 重复:{}",
@@ -687,7 +724,10 @@ fn validate_agent_catalog(catalog: &AgentCatalog) -> Result<(), String> {
));
}
}
let mut generated_names = BTreeSet::from(["PROJECT_SUPERVISOR".to_string()]);
let mut generated_names = BTreeSet::from([
"PROJECT_SUPERVISOR".to_string(),
"PROJECT_PLANNING".to_string(),
]);
for group in &catalog.groups {
let generated = rust_identifier(&group.id);
if !generated
@@ -713,6 +753,12 @@ fn validate_agent_catalog(catalog: &AgentCatalog) -> Result<(), String> {
&mut task_ids,
&mut tool_ids,
)?;
validate_agent_group(
&catalog.planning,
&mut group_ids,
&mut task_ids,
&mut tool_ids,
)?;
for group in &catalog.groups {
validate_agent_group(group, &mut group_ids, &mut task_ids, &mut tool_ids)?;
}
@@ -874,6 +920,10 @@ fn render_rust(manifest: &PromptBundleManifest, sections: &BTreeMap<String, Stri
"RUNTIME_PROMPT_SUPERVISOR_COMPOSITION",
&manifest.compositions.supervisor,
));
output.push_str(&render_string_slice_const(
"RUNTIME_PROMPT_SUPERVISOR_PLAN_COMPOSITION",
&manifest.compositions.supervisor_plan,
));
output.push_str(&format!(
"pub(crate) const RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION: &[&str] = &[{}, {}];\n",
rust_literal(&manifest.compositions.supervisor_chat.identity),
@@ -962,6 +1012,26 @@ fn render_agent_catalog(catalog: &AgentCatalog) -> String {
"static PROJECT_SUPERVISOR_AGENT_DEFINITION: AgentGroupDefinition = {};\n",
render_group_value(&catalog.supervisor, "&PROJECT_SUPERVISOR_AGENT_ROLES")
));
let planning_role = &catalog.planning.roles[0];
output.push_str(&format!(
"pub(crate) const GAME_CREATOR_PROJECT_PLANNING_AGENT_ID: &str = {};\n",
rust_literal(&planning_role.task_id)
));
output.push_str(&format!(
"pub(crate) const GAME_CREATOR_PROJECT_PLANNING_MEMORY_PATH: &str = {};\n",
rust_literal(&format!(
"memory/agents/{}",
catalog.planning.brief_path_name
))
));
output.push_str(&render_role_array(
"PROJECT_PLANNING_AGENT_ROLES",
&catalog.planning.roles,
));
output.push_str(&format!(
"static PROJECT_PLANNING_AGENT_DEFINITION: AgentGroupDefinition = {};\n",
render_group_value(&catalog.planning, "&PROJECT_PLANNING_AGENT_ROLES")
));
for group in &catalog.groups {
let roles_name = format!("{}_AGENT_ROLES", rust_identifier(&group.id));
output.push_str(&render_role_array(&roles_name, &group.roles));
@@ -23,7 +23,11 @@
"supervisorVisualWithEditor": "supervisor/visual-contract-with-editor.md",
"supervisorPlaybook": "supervisor/playbook.md",
"supervisorClaimGate": "supervisor/claim-gate.md",
"supervisorRepair": "supervisor/repair.md"
"supervisorRepair": "supervisor/repair.md",
"projectPlanningRoleBrief": "roles/project-planning.md",
"planCommon": "plan/common.md",
"planSupervisorIdentity": "plan/supervisor-identity.md",
"planSupervisorPlaybook": "plan/supervisor-playbook.md"
},
"compositions": {
"runtime": [
@@ -43,6 +47,14 @@
"supervisorClaimGate",
"supervisorRepair"
],
"supervisorPlan": [
"$header",
"planCommon",
"isolatedAgentContract",
"planSupervisorIdentity",
"planSupervisorPlaybook",
"supervisorRepair"
],
"supervisorChat": {
"identity": "supervisorIdentityContract",
"finalReply": "supervisorFinalReplyContract"
@@ -58,7 +70,12 @@
"editorUnavailable": "supervisorVisualWithoutEditor"
}
},
"roleOverlays": [],
"roleOverlays": [
{
"agentId": "project-planning",
"sections": ["projectPlanningRoleBrief"]
}
],
"providerFragments": {
"isolatedToolContract": "providerIsolatedToolContract",
"autonomousRunProfile": "providerAutonomousRunProfile",
@@ -85,6 +102,21 @@
}
]
},
"planning": {
"id": "planning",
"label": "立项策划",
"role": "Project Planning",
"briefPathName": "project-planning.md",
"roles": [
{
"id": "project-planning",
"role": "Project Planning",
"taskId": "project-planning",
"toolId": "agent.runtime.project-planning",
"briefPathName": "project-planning.md"
}
]
},
"groups": [
{
"id": "design",
@@ -0,0 +1,7 @@
用户只描述玩法类型、机制或相似体验时,不代表授权复刻现有游戏。所有专业 Agent 必须创建原创标题、阵营、资源、单位名称、角色造型、界面术语和视觉语言;禁止沿用、翻译或近似改写现有游戏的专有角色、单位名、Logo、贴图、标志性布局与受保护视觉语言。除非用户明确提供有权使用的项目内素材,否则不得把 Sunflower、Peashooter、向日葵、豌豆射手、僵尸等知名塔防元素写入策划、记忆、代码、图片提示或正式产物。
静态委派协议:新 agent.delegate 必须提交 1-8 条 acceptanceCriteria、0-16 个精确项目内非私有 expectedArtifacts,以及 nullable repairOfDelegationId/runId/continuationOfDelegationId/questionsSha256/answersSha256,普通委派后三项传 null。专业 Agent 收到的 task 会携带完整合同。Supervisor 认领回执后必须区分 evidence-ready、needs-user-input 与 needs-repair;前者仍需语义验收,needs-repair 不能作为成功。专业 Agent 若缺少会实质改变结果的用户事实,不能调用 user.input_request,必须以最终回复首行 `AGC_NEEDS_USER_INPUT_V1`,下一行短 JSON `{"questions":[...]}` 返回 1-3 个结构化问题;Runtime 会把它作为内部回执交给 Supervisor。Supervisor 对每个原 delivery 逐一用现有 user.input_request 提问,收齐对应答案后最多创建一次 continuation 委派,并同时提交 continuationOfDelegationId、questionsSha256、answersSha256Runtime 会自动派生稳定 continuation identity,不得把多个 delivery 的问题或答案混入同一 continuation。
每 6 轮只是一次进度 checkpoint 与停滞检测,不是上下文压缩或 run 的终止上限;只要 observation 出现新的独立进展,就在同一 run 继续下一窗口,只有窗口没有新进展时才按停滞处理。真正的上下文压缩仅由 token 阈值或显式 compact 触发。
必须直接调用与当前请求广告的工具一一对应的动作函数,或在本阶段确实无事可做时调用 respond_to_user。本 run 不维护结构化计划,也没有 update_agent_plan 可调;工具结果会由 Runtime 作为 observation 返回,不要假装工具已执行,不要把动作或回复放进普通文本,不要 markdown,不要泄露密钥。
@@ -0,0 +1,7 @@
你是 Genarrative AI 游戏创作桌面 App 的 Project Supervisor。当前 run 是立项策划根 run(`source=project-supervisor-plan`),你是用户在本条链路里唯一的对话对象。
你不生产策划内容。本链路的全部策划工作——提问、取舍、撰写 GDD——都由 `project-planning` 子 Agent 完成。你只有四件事:冻结目标合同;发起与续跑对 `project-planning` 的委派;代子 Agent 向用户提问并把答案原样转达回去;在子 Agent 提交 GDD 后完成取证,把审批交给用户。
你不做的事:不自己提策划问题(`user.input_request` 只能用于转达子 Agent 的问题信封);不自己撰写、补写或改写 GDD 正文、决定台账与原型验证项;不替用户做产品决定;不写文件、不跑命令、不做预览、不生成素材、不查询任务图、不调度 ready 任务;不委派 `project-planning` 以外的任何 Agent,也不创建 isolated child 或启动构建。
`project-planning` 的消息和回执只是原目标的证据,不能替换原目标。contractStatus=evidence-ready 只代表客观证据齐全,你仍须按 acceptanceCriteria 逐条完成语义验收;needs-repair 不得忽略,同一原委派最多发起一轮显式返工。GDD 最终是否通过由用户在审批卡上决定,不由你代答。
@@ -0,0 +1,22 @@
【固定动作顺序,不得跳步】
1. 本 run 第一轮只调用一次 `agent.goal_contract` 冻结目标合同:outcome 概括用户原话意图,`preferences` 必须传空数组,`acceptanceNodes` 提交 Runtime 指定的固定单节点。这一轮不做任何其它调用。
2. 冻结后立即用一次 `agent.delegate` 把任务委派给 `project-planning``expectedArtifacts``game/fast_gdd.md``repairOfDelegationId``runId``continuationOfDelegationId``questionsSha256``answersSha256` 全传 null。已有委派尚未收束时不要重复委派。
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 里。「同一原委派只能返工一次」约束的是单条 delivery,不是整条链:用户看过新稿再点一次修改,就对那条新 delivery 重复同一动作,这个循环没有次数上限——`repair_depth` 防的是 runaway agent,而每一轮修订都由用户亲手触发,人本身就是循环边界。不要替 Runtime 宣布「这是最后一次修改机会」,也不要因此把多条意见攒到一轮里改完。用户通过后只做一句简短收尾。
【转达的规则】
- 把用户答案回灌给 `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` 判断,不要替它裁定哪条作废。
【委派合同的边界】
委派 `project-planning` 时,acceptanceCriteria 只写产物形状、覆盖范围与红线(例如必须交付 `game/fast_gdd.md`、必须原创、必须只定义一个 MVP 闭环),**不得替用户预先裁定产品取舍**。用户没有指定的玩法规则、数值、关卡量级、美术方向和目标人群,一律留给策划子 Agent 按其 3 轮问询预算决定是提问还是按默认建议填写;不要写“未指定的标注为立项假设”“自行假设后继续”这类指令,那会把问询预算作废。平台事实(自包含 Web、desktop/mobile 双视口、keyboard/touch 双输入、本地 HTTP 预览)由 Runtime 固定注入,属于已定事实,不得要求标为待定、建议或开放项。
**本轮指令三选一。** 委派任务正文里,除了用户原始意图和已确认答案原文,你只能再写一句“本轮该做什么”,且必须是下面三个之一:**继续澄清**(默认,不附加任何前置条件)、**直接出稿**(仅当用户明确要求跳过问询)、**按意见修订**(仅审批返回修改或退回时)。不要自己描述“什么情况下才该提问”“若缺少会实质改变结果的事实则……”“否则直接提交完整 GDD”——那不在这三项里。提问预算怎么花,由 `project-planning` 按 Runtime 注入的判据决定。
不要向用户暴露内部 task/event、工具计划、动态 child ID 或调试状态。
@@ -0,0 +1,33 @@
你是“立项策划 Agent”(`agentId=project-planning`),由 Project Supervisor 通过静态 `agent.delegate` 委派。你的工作是把一句用户需求收敛成可审批的 MVP Fast GDD;你只负责玩法澄清、原型验证建议和最小 GDD,不负责完整游戏构建。
## 身份与边界
- 当前 run 固定为 `source=agent-delegate``profile=standard`,父 Agent 是 `project-supervisor`。不得伪造、改写或猜测这些 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`;只用于缩短对话,不覆盖用户明确输入):`targetUsers.sessionLength` 缺 → 1020 分钟一局;`artStyle` 缺 → `visualType` 风格化、轮廓清楚,`keywords` 取自已确认的核心行为,`mvpArtBoundary` 写明 MVP 用占位资产、资产可复用;缺成长时 → 1 条成长线和 2~3 个选择;缺探索时 → 1 条主路线加 1 个有意义的岔路;缺构建时 → 高风险输出和稳健防御两种方向。清单之外的字段没有默认值兜底——`genre.fusion``targetUsers.coreUsers` / `preferences` / `referenceGames``outOfScope` 缺失时都算空白,该不该花一轮问它们由上面的判据决定,不要自己拍一个值填掉就当它已经定了。**`pillars``coreLoop` 没有默认建议**:它们就是首个可玩闭环本身,空白时属于该问的空白,不得用默认值填掉。
- 优先顺序:核心行为与本局目标 → 重玩动力 → 制作边界与 MVP。每轮最多问一个主要决定。**已确认决定关掉的那条轴不得重问。** 任务正文里每条 `[已确认]` 都带着当轮的问题原文和三个选项标签,先照它判断哪些轴已经关闭,本轮的问题必须落在另一条还没关闭的轴上。把已确认答案换个说法再问一遍——例如用户已经选定“自由经营、靠成就和攒钱升级推进”,你又拿“短周期经营目标 vs 沙盒里程碑成长”去问——是白烧一轮预算。所有轴都已关闭时按出稿触发器③直接出稿。
- 决策卡的 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 是整条产线的上游。
- 决定台账记录当前 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 预览;不得修改、删减或向用户询问。
## 输出纪律
- 澄清模式只返回 `AGC_NEEDS_USER_INPUT_V1` 终态信封,不再调用其他函数;成稿模式只在 `plan.submit_gdd` 被广告时调用它并等待 Runtime 校验;收到 revise/reject observation 后按同一 GDD 谱系修订,收到 approve 后只做简短收尾。
- 必须直接调用当前请求广告的原生函数;不要输出 JSON、代码围栏或内部思考过程,不要假装已经写入文件、完成审批或启动构建。
@@ -3777,6 +3777,8 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
request_slot: "direct-chat".to_string(),
web_search_enabled: config.llm.web_search_enabled,
allow_idle_context_compaction: false,
// direct-codex 不是立项策划链路,没有 planning session 可绑定。
planning_session_binding: None,
};
let api_kind =
parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| error.to_string())?;
@@ -3843,6 +3845,8 @@ pub(crate) async fn direct_game_creator_home_codex_chat(
request_slot: "direct-home-chat".to_string(),
web_search_enabled: config.llm.web_search_enabled,
allow_idle_context_compaction: false,
// 直连 Codex 的首页对话不属于任何立项策划 session。
planning_session_binding: None,
};
let api_kind =
parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| error.to_string())?;
@@ -4176,6 +4180,7 @@ mod tests {
request_slot: "slot-1".to_string(),
web_search_enabled: false,
allow_idle_context_compaction: false,
planning_session_binding: None,
}
}
@@ -1776,7 +1776,7 @@ fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &st
if normalized.contains("permission-denied") || normalized.contains("http 403") {
return "当前陶泥儿账号可能没有访问该资源的权限,请检查账号后重试";
}
if error.contains(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) {
if error.contains("项目正在被其他写操作占用") {
return "当前项目仍有写入正在结束,请稍后再次发送该需求";
}
if error.contains("身份不唯一")
@@ -4710,10 +4710,10 @@ mod tests {
assert!(!prompt.contains("你是 Codex"));
assert!(prompt.contains("不要等待 Supervisor"));
assert!(prompt.contains("提示词与技能"));
assert!(prompt.contains("AGC 工程合同:当前 cwd 是用户选择的项目目录"));
assert!(prompt.contains("AGC 工程合同(仅说明项目边界,不是流程门槛)"));
assert!(prompt.contains("客户端扩展列表中用户已启用的第三方 MCP"));
assert!(prompt.contains("用户明确指定第三方 MCP Server 或工具时"));
assert!(prompt.contains("先读取当前 cwd 下适用的 AGENTS.md、README 或项目说明"));
assert!(prompt.contains("按需读取当前 cwd 下适用的 `AGENTS.md`"));
assert!(prompt.contains("agc_write_file"));
assert!(prompt.contains("content 必须是目标文件的完整原始 UTF-8 正文"));
assert!(prompt.contains("不得把 command.exec 的 Exit code、Wall time、Output 包装"));
@@ -1473,14 +1473,7 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value {
return Err("工具参数 content 不能包含 NUL".to_string());
}
reject_command_output_wrapper(content)?;
// Direct 写入原本用零等待取锁:任何重叠都在 24-42ms 内直接被判成"别人正在写",
// 而 `file.write / file.patch / file.delete` 等写入口用的是约 10 秒有界等待。
// 这是用户直接触发、失败即整轮无法落盘的项目写入通道,必须和其它写入口同语义:
// 短暂重叠排队等成功,只有预算耗尽才报出带持锁方身份的错误。
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"direct-codex.file.write",
)?;
let _lock = acquire_project_write_lock(root, "direct-codex.file.write")?;
let written = write_local_project_file_at(root, &path, content)?;
let revision = advance_agent_runtime_project_revision_locked(root)?;
Ok::<_, String>(json!({
@@ -1500,27 +1493,6 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value {
}
}
/// 项目写锁的有界等待是同步轮询(2_000 × 5ms,最多约 10 秒)。handler 是 async
/// 直接在 handler 里走完整条写路径会占住一个 tokio worker:争用窗口内同一轮并行写多个
/// 文件时会有多个 worker 被占,而这条 bridge 与只读端点、UI 命令共享同一个 runtime,
/// 正是 Issue #318 现场"只读工具全部正常"这条诊断特征会被破坏的情形。
/// 因此整条写路径挪进阻塞线程池,等待语义与错误文案都不变。
async fn bridge_write_file_in_blocking_pool(root: PathBuf, arguments: Value) -> Value {
let task_root = root.clone();
match tokio::task::spawn_blocking(move || bridge_write_file(&task_root, &arguments)).await {
Ok(result) => result,
Err(error) => bridge_tool_result(
redact_agent_runtime_error(
&root,
&format!("agc_write_file 阻塞任务未返回:{error}"),
480,
),
Vec::new(),
true,
),
}
}
fn bridge_safe_account_asset_projection(asset: &Value) -> Option<Value> {
let asset_id = asset.get("assetId").and_then(Value::as_str)?;
if asset_id.trim().is_empty() {
@@ -2335,9 +2307,7 @@ async fn handle_direct_tool_bridge(
bridge_list_registered_assets(&state.root, &request.arguments)
}
"agc_list_project_files" => bridge_list_project_files(&state.root, &request.arguments),
"agc_write_file" => {
bridge_write_file_in_blocking_pool(state.root.clone(), request.arguments).await
}
"agc_write_file" => bridge_write_file(&state.root, &request.arguments),
"agc_list_account_assets" => bridge_list_account_assets(&state, &request.arguments).await,
"agc_import_account_assets" => {
bridge_import_account_assets(&state, &request.arguments).await
@@ -2742,185 +2712,6 @@ mod tests {
);
}
/// Issue #318 第 1 条验收:同一轮里并行的多个文件写必须排队成功,
/// 而不是互相报"项目正在被其他写操作占用"。
#[test]
fn bridge_write_file_serializes_parallel_writes_in_one_round() {
let temporary = tempfile::tempdir().expect("create parallel direct write root");
init_local_game_project_at(temporary.path(), "direct-parallel", "Direct 并行写入测试")
.expect("initialize parallel direct write root");
let root = temporary.path().to_path_buf();
let paths = (0..4)
.map(|index| format!("game/parallel-{index}.js"))
.collect::<Vec<_>>();
let results = std::thread::scope(|scope| {
let handles = paths
.iter()
.map(|path| {
let root = root.clone();
let path = path.clone();
scope.spawn(move || {
let result = bridge_write_file(
&root,
&json!({ "path": path, "content": format!("// {path}\n") }),
);
(path, result)
})
})
.collect::<Vec<_>>();
handles
.into_iter()
.map(|handle| handle.join().expect("parallel direct write must not panic"))
.collect::<Vec<_>>()
});
for (path, result) in &results {
assert_eq!(
result.get("isError").and_then(Value::as_bool),
Some(false),
"parallel direct write of {path} must succeed: {result}"
);
assert_eq!(
fs::read_to_string(root.join(path)).expect("read parallel direct write"),
format!("// {path}\n")
);
}
}
/// Issue #318 第 1 条验收:App 自己另一条写通道正在写该项目时,
/// Direct 写入必须等待后成功,而不是在 24-42ms 内被判成"别人正在写"。
#[test]
fn bridge_write_file_waits_for_a_short_same_process_project_writer() {
let temporary = tempfile::tempdir().expect("create contended direct write root");
init_local_game_project_at(temporary.path(), "direct-contended", "Direct 写入等待测试")
.expect("initialize contended direct write root");
let root = temporary.path().to_path_buf();
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
let holder_barrier = std::sync::Arc::clone(&barrier);
let holder_root = root.clone();
let holder = std::thread::spawn(move || {
let lock = acquire_project_write_lock(&holder_root, "concurrent-writer")
.expect("acquire a short-lived project writer");
holder_barrier.wait();
std::thread::sleep(std::time::Duration::from_millis(300));
drop(lock);
});
barrier.wait();
let result = bridge_write_file(
&root,
&json!({ "path": "game/waited.js", "content": "// waited\n" }),
);
holder.join().expect("join the short-lived project writer");
assert_eq!(
result.get("isError").and_then(Value::as_bool),
Some(false),
"the direct write must wait out a short same-process writer: {result}"
);
assert_eq!(
fs::read_to_string(root.join("game/waited.js")).expect("read waited direct write"),
"// waited\n"
);
}
/// 有界等待是同步轮询(最多约 10 秒),而 handler 是 async:等待必须挪到阻塞线程池,
/// 否则会占住 runtime worker。本用例用默认的 current_thread runtime——handler 一旦同步
/// 阻塞,同一 runtime 上的心跳任务就完全停摆,因此在写入等待期间检查心跳即可区分。
#[tokio::test]
async fn bridge_write_file_waits_on_the_blocking_pool_instead_of_a_runtime_worker() {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
let temporary = tempfile::tempdir().expect("create blocking pool write root");
init_local_game_project_at(temporary.path(), "direct-pool", "Direct 阻塞池测试")
.expect("initialize blocking pool write root");
let state = direct_tool_bridge_state(temporary.path().to_path_buf());
// 另一条写通道由 OS 线程持有项目写锁,不受本 runtime 影响。
let holder_root = temporary.path().to_path_buf();
let lock = acquire_project_write_lock(&holder_root, "concurrent-writer")
.expect("acquire the concurrent project writer");
let holder = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(250));
drop(lock);
});
// 心跳任务:只有 handler 让出 worker,它才可能在写入等待期间推进。
let heartbeat = Arc::new(AtomicBool::new(false));
let heartbeat_writer = Arc::clone(&heartbeat);
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
heartbeat_writer.store(true, Ordering::SeqCst);
});
let response = handle_direct_tool_bridge(
axum::extract::State(state),
axum::Json(DirectToolBridgeRequest {
tool: "agc_write_file".to_string(),
arguments: json!({ "path": "game/blocking-pool.js", "content": "// pooled\n" }),
}),
)
.await
.0;
holder.join().expect("join the concurrent project writer");
assert!(
heartbeat.load(Ordering::SeqCst),
"写入等待期间同一 runtime 的心跳任务停摆了:等待必须走阻塞线程池,不能占住 worker"
);
assert_eq!(
response.get("isError").and_then(Value::as_bool),
Some(false),
"the pooled direct write must still wait out the holder: {response}"
);
assert_eq!(
fs::read_to_string(temporary.path().join("game/blocking-pool.js"))
.expect("read pooled direct write"),
"// pooled\n"
);
}
/// Issue #318 第 3 条验收:权限拒绝不得被投影成"被其他写操作占用"。
#[cfg(unix)]
#[test]
fn bridge_write_file_does_not_project_permission_denial_as_contention() {
use std::os::unix::fs::PermissionsExt;
let temporary = tempfile::tempdir().expect("create acl direct write root");
init_local_game_project_at(temporary.path(), "direct-acl", "Direct ACL 测试")
.expect("initialize acl direct write root");
let agent_directory = temporary.path().join(".agent");
let original = fs::metadata(&agent_directory)
.expect("read control directory metadata")
.permissions();
fs::set_permissions(&agent_directory, fs::Permissions::from_mode(0o500))
.expect("drop write permission on the control directory");
let result = bridge_write_file(
temporary.path(),
&json!({ "path": "game/acl-denied.js", "content": "// denied\n" }),
);
fs::set_permissions(&agent_directory, original)
.expect("restore control directory permission");
if result.get("isError").and_then(Value::as_bool) != Some(true) {
// 以 root 运行(或文件系统忽略权限位)时 0o500 不构成拒绝,本用例不成立。
return;
}
let text = result
.pointer("/content/0/text")
.and_then(Value::as_str)
.unwrap_or_default();
assert!(
!text.contains("项目正在被其他写操作占用"),
"a permission denial must not be projected as lock contention: {text}"
);
assert!(!temporary.path().join("game/acl-denied.js").exists());
}
#[test]
fn resource_request_uuid_is_stable_v4_and_domain_separated() {
let operation = direct_resource_request_uuid("turn-1", "operation", "abc");
@@ -27,9 +27,8 @@ pub(in crate::agent) use canvas_generation::{
commit_prepared_platform_art_asset_at, commit_prepared_platform_art_asset_strict_slices_at,
generate_platform_art_asset_with_retained_runtime_options_at,
generate_platform_art_asset_with_runtime_options_at,
platform_art_generation_error_result_unknown, recover_persisted_visual_generation_options,
register_existing_platform_art_slices_at, request_platform_art_asset_with_runtime_options_at,
restore_platform_art_asset_bytes_at,
platform_art_generation_error_result_unknown, register_existing_platform_art_slices_at,
request_platform_art_asset_with_runtime_options_at, restore_platform_art_asset_bytes_at,
retained_platform_art_generation_runtime_spritesheet_identity_at,
retained_platform_art_generation_runtime_state_matches_direct_stage_at,
validate_platform_art_png_bytes_with_limits,
@@ -429,87 +429,6 @@ impl Default for PlatformArtAssetGenerationOptions {
}
}
pub(in crate::agent) fn recover_persisted_visual_generation_options(
root: &Path,
pending: &AgentRuntimePendingToolAction,
prompt: &str,
requested: &PlatformArtAssetGenerationOptions,
) -> Result<Option<PlatformArtAssetGenerationOptions>, String> {
let context = platform_art_generation_runtime_context_from_pending(pending);
let Some(state) = read_platform_art_generation_runtime_state(root, &context)? else {
return Ok(None);
};
let (path, ratio, size, kind, label, generation_kind) = match context.agent_id.as_str() {
"art-director" => (
"assets/art-spec.png",
"1:1",
"1K",
"icon-spec",
"游戏统一视觉规范图",
"spec",
),
"design-foundation" => (
"assets/ui-prototype.png",
"16:9",
"2K",
"ui-prototype",
"游戏横屏界面原型图",
"ui-design",
),
"art-asset-plan" => (
"assets/art-spritesheet.png",
"1:1",
"1K",
"art-spritesheet",
"游戏首版核心美术素材",
"icon-spritesheet",
),
_ => return Ok(None),
};
let mut options = requested.clone();
let label = if context.agent_id == "design-foundation"
&& options
.output_path
.as_deref()
.is_some_and(design_foundation_ui_page_output_path_is_valid)
{
"游戏功能页面设计图"
} else {
label
};
if options.output_path.is_none() {
options.output_path = Some(path.to_string());
}
for (value, default) in [
(&mut options.aspect_ratio, ratio),
(&mut options.image_size, size),
(&mut options.asset_kind, kind),
(&mut options.asset_label, label),
] {
if value.is_empty() {
*value = default.to_string();
}
}
let snapshot = platform_art_generation_runtime_request_snapshot(&state)?;
// 只恢复经过身份校验的已有请求;显式改参和新请求仍走当前合同。
if snapshot.generation_kind == generation_kind
&& snapshot.generation_prompt == build_platform_art_asset_prompt(prompt, &[], &options)
&& options.asset_kind == kind
&& options.aspect_ratio == ratio
&& options.image_size == size
&& (options.output_path.as_deref() == Some(path)
|| (context.agent_id == "design-foundation"
&& options
.output_path
.as_deref()
.is_some_and(design_foundation_ui_page_output_path_is_valid)))
{
Ok(Some(options))
} else {
Ok(None)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct ExistingPlatformArtAssetFingerprint {
local_path: String,
@@ -8976,13 +8895,13 @@ mod canvas_generation_tests {
}
#[test]
fn strict_spritesheet_contract_accepts_variable_slices_without_fixed_layout() {
fn strict_spritesheet_contract_requires_the_grid_2x2_provider_receipt() {
let canvas_context = ExternalCanvasGenerationContext {
project_id: "canvas-project".to_string(),
asset_folder_id: "asset-folder".to_string(),
canvas_name: "contract-test".to_string(),
};
let slices = (0..3)
let slices = (0..4)
.map(|index| {
let download = rgba_test_png(100 + index);
let validated = validate_platform_art_png_bytes_with_limits(
@@ -9008,7 +8927,7 @@ mod canvas_generation_tests {
}
})
.collect::<Vec<_>>();
validate_strict_platform_art_spritesheet_contract(
let error = validate_strict_platform_art_spritesheet_contract(
&slices,
&canvas_context,
Some("canvas-project"),
@@ -9022,7 +8941,9 @@ mod canvas_generation_tests {
true,
true,
)
.expect("valid slice identities and pixel evidence do not require a fixed layout");
.expect_err("missing fixed-layout proof must not be accepted");
assert!(error.contains("grid-2x2"));
}
#[test]
@@ -10247,8 +10168,7 @@ mod canvas_generation_tests {
}
#[tokio::test]
async fn accepted_runtime_generation_checks_running_operation_before_rejecting_changed_intent()
{
async fn accepted_runtime_generation_rejects_changed_intent_before_operation_get() {
let temporary = tempfile::tempdir().expect("create changed-intent recovery project");
let root = temporary.path();
init_local_game_project_at(root, "changed-intent", "旧异步生成意图隔离")
@@ -10268,31 +10188,6 @@ mod canvas_generation_tests {
.set_nonblocking(true)
.expect("set changed intent fixture nonblocking");
let base_url = format!("http://{}", listener.local_addr().expect("fixture address"));
let (request_sender, request_receiver) = std::sync::mpsc::channel();
let (stop_sender, stop_receiver) = std::sync::mpsc::channel();
let server = std::thread::spawn(move || loop {
if stop_receiver.try_recv().is_ok() {
break;
}
let (mut stream, _) = match listener.accept() {
Ok(connection) => connection,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(5));
continue;
}
Err(error) => panic!("accept changed intent request: {error}"),
};
request_sender
.send(read_test_http_request(&mut stream))
.expect("capture status request");
write_test_json_response(
&mut stream,
"200 OK",
&serde_json::json!({
"data": {"operationId": "old-operation", "status": "running"}
}),
);
});
let runtime_context = PlatformArtGenerationRuntimeContext {
agent_id: "art-director".to_string(),
task_id: "art-director".to_string(),
@@ -10358,15 +10253,10 @@ mod canvas_generation_tests {
error.contains("当前生成意图与已持久化请求快照不一致"),
"{error}"
);
stop_sender.send(()).expect("stop changed intent fixture");
server.join().expect("join changed intent fixture");
let requests = request_receiver.try_iter().collect::<Vec<_>>();
assert_eq!(
requests.len(),
1,
"only the existing operation may be queried"
);
assert!(requests[0].starts_with("GET /api/external/v1/generations/old-operation "));
assert!(matches!(
listener.accept(),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock
));
assert!(game_creator_agent_runtime_external_generation_exists(
root,
&runtime_context.agent_id,
@@ -10375,8 +10265,7 @@ mod canvas_generation_tests {
}
#[tokio::test]
async fn accepted_derived_generation_checks_running_operation_before_rejecting_changed_art_spec(
) {
async fn accepted_derived_generation_rejects_changed_art_spec_before_operation_get() {
let temporary = tempfile::tempdir().expect("create changed reference project");
let root = temporary.path();
init_local_game_project_at(root, "changed-reference", "规范图身份隔离")
@@ -10421,31 +10310,6 @@ mod canvas_generation_tests {
.set_nonblocking(true)
.expect("set changed reference fixture nonblocking");
let base_url = format!("http://{}", listener.local_addr().expect("fixture address"));
let (request_sender, request_receiver) = std::sync::mpsc::channel();
let (stop_sender, stop_receiver) = std::sync::mpsc::channel();
let server = std::thread::spawn(move || loop {
if stop_receiver.try_recv().is_ok() {
break;
}
let (mut stream, _) = match listener.accept() {
Ok(connection) => connection,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(5));
continue;
}
Err(error) => panic!("accept changed reference request: {error}"),
};
request_sender
.send(read_test_http_request(&mut stream))
.expect("capture status request");
write_test_json_response(
&mut stream,
"200 OK",
&serde_json::json!({
"data": {"operationId": "old-background-operation", "status": "running"}
}),
);
});
let runtime_context = PlatformArtGenerationRuntimeContext {
agent_id: "direct-codex-art".to_string(),
task_id: "direct-codex-art-game-background".to_string(),
@@ -10548,19 +10412,10 @@ mod canvas_generation_tests {
"{error}"
);
assert!(error.contains("当前规范图身份"), "{error}");
stop_sender
.send(())
.expect("stop changed reference fixture");
server.join().expect("join changed reference fixture");
let requests = request_receiver.try_iter().collect::<Vec<_>>();
assert_eq!(
requests.len(),
1,
"only the existing operation may be queried"
);
assert!(
requests[0].starts_with("GET /api/external/v1/generations/old-background-operation ")
);
assert!(matches!(
listener.accept(),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock
));
assert!(game_creator_agent_runtime_external_generation_exists(
root,
&runtime_context.agent_id,
@@ -10954,38 +10809,6 @@ mod canvas_generation_tests {
mark_platform_art_generation_runtime_accepted(root, state, "test-operation-id", 0)
.expect("accept current generation fixture");
let sparse_options = PlatformArtAssetGenerationOptions {
output_path: Some(AGENT_RUNTIME_ART_SPEC_PATH.to_string()),
aspect_ratio: String::new(),
image_size: String::new(),
asset_kind: String::new(),
asset_label: String::new(),
..Default::default()
};
assert_eq!(
recover_persisted_visual_generation_options(root, &pending, prompt, &sparse_options)
.expect("recover persisted defaults"),
Some(options.clone())
);
assert!(recover_persisted_visual_generation_options(
root,
&pending,
"显式改变生成意图",
&sparse_options,
)
.unwrap()
.is_none());
let mut explicit_options = sparse_options.clone();
explicit_options.asset_kind = "game-background".to_string();
assert!(recover_persisted_visual_generation_options(
root,
&pending,
prompt,
&explicit_options,
)
.unwrap()
.is_none());
resume_game_creator_agent_background_tasks_at(root)
.expect("resume accepted generation through recovery scan");
let first = request_receiver
@@ -11240,18 +11063,19 @@ mod canvas_generation_tests {
}
#[test]
fn canonical_art_spritesheet_request_preserves_project_requirements() {
fn canonical_art_spritesheet_request_has_exactly_four_ordered_categories() {
let descriptions = canonical_art_spritesheet_icon_descriptions("原创收集玩法");
assert_eq!(descriptions.len(), 1);
assert!(descriptions[0].contains("原创收集玩法"));
assert!(descriptions[0].contains("数量、类别、排列和切片方式由本次需求决定"));
assert_eq!(descriptions.len(), 4);
for (index, description) in descriptions.iter().enumerate() {
assert!(description.starts_with(&format!("{}", index + 1)));
}
}
#[test]
fn canonical_art_spritesheet_descriptions_obey_external_editor_item_limit() {
let descriptions = canonical_art_spritesheet_icon_descriptions(&"原创玩法需求".repeat(128));
assert!(!descriptions.is_empty());
assert_eq!(descriptions.len(), 4);
assert!(descriptions
.iter()
.all(|description| description.chars().count() <= 200));
@@ -11642,7 +11466,7 @@ mod canvas_generation_tests {
}
#[test]
fn strict_slice_commit_rejects_empty_slice_results_before_replacing_the_sheet() {
fn strict_slice_commit_rejects_non_four_slice_results_before_replacing_the_sheet() {
let temporary = tempfile::tempdir().expect("create strict slice project");
let root = temporary.path();
init_local_game_project_at(root, "strict-slices", "严格切片测试")
@@ -11657,9 +11481,9 @@ mod canvas_generation_tests {
&replacement_options(),
|_| Ok(()),
)
.expect_err("strict spritesheet commit must require at least one slice");
.expect_err("strict spritesheet commit must require exactly four slices");
assert!(error.contains("至少需要一个独立切片"));
assert!(error.contains("恰好包含 4 个独立切片"));
assert_eq!(fs::read(path).expect("read preserved sheet"), b"old-image");
assert!(!root
.join("assets/art-spritesheet-slices/manifest.json")
@@ -337,6 +337,9 @@ pub(crate) fn agent_role_memory_relative_path_for_task(task_id: &str) -> Result<
if task_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
return Ok(GAME_CREATOR_PROJECT_SUPERVISOR_MEMORY_PATH.to_string());
}
if task_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
return Ok(GAME_CREATOR_PROJECT_PLANNING_MEMORY_PATH.to_string());
}
for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS {
for role in group.roles {
if role.task_id == task_id {
File diff suppressed because it is too large Load Diff
@@ -44,6 +44,7 @@ pub(in crate::agent) use run_status_observation::*;
pub(in crate::agent) use structured_plan::*;
pub(in crate::agent) use tool_plan_protocol::*;
pub(crate) use crate::agent::runtime_protocol::plan_gdd_completion_blocker_at_locked;
#[cfg(test)]
pub(crate) use action_audit::agent_runtime_action_receipt_public_safe_detail_for_test;
#[cfg(test)]
@@ -73,6 +74,7 @@ pub(crate) use context_compaction::compact_game_creator_agent_runtime_session_at
pub(crate) use parallel_ledger::{
agent_runtime_confirmation_path_component, agent_runtime_parallel_read_batch_len,
agent_runtime_tool_allowed_for_agent, agent_runtime_tool_is_parallel_safe_read,
agent_runtime_tool_rejected_by_agent_identity,
game_creator_agent_runtime_parallel_read_batch_path,
game_creator_agent_runtime_pending_tool_action_path,
game_creator_agent_runtime_provider_action_batch_path,
@@ -104,7 +106,6 @@ pub(crate) use project_gates::{
prepare_agent_runtime_project_mutation_locked, process_session_completion_blocker_at,
project_verification_completion_blocker, project_verification_completion_blocker_at,
static_delegate_completion_blocker_at, structured_plan_completion_blocker,
try_acquire_game_creator_agent_runtime_project_write_lock,
validate_agent_runtime_pending_verification_gate_before,
};
#[cfg(test)]
@@ -115,6 +116,7 @@ pub(crate) use project_gates::{
};
pub(crate) use provider_action_batch::{
prepare_game_creator_agent_runtime_provider_action_batch,
prepare_game_creator_agent_runtime_provider_action_batch_with_planning_binding,
update_game_creator_agent_runtime_provider_batch_member, AgentRuntimePendingToolAction,
AgentRuntimeProviderActionBatch,
};
@@ -145,6 +147,9 @@ pub(crate) use tool_plan_protocol::parse_game_creator_agent_tool_plan_response;
pub(crate) use tool_policy_snapshot::{
agent_runtime_acceptance_evidence_tools,
agent_runtime_autonomous_design_foundation_command_is_allowed, agent_runtime_executable_tools,
agent_runtime_native_executable_tools, agent_runtime_tool_policy_snapshot_for_run_at,
AGENT_RUNTIME_CANVAS_ASSET_KINDS,
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,
plan_root_supervisor_stage_at_locked, PlanRootSupervisorStage,
AGENT_RUNTIME_CANVAS_ASSET_KINDS, AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS,
};
@@ -38,6 +38,29 @@ pub(crate) async fn execute_game_creator_agent_runtime_tool_action_with_pending_
) -> AgentRuntimeToolObservation {
let tool = action.tool.trim();
let relaxed_autonomous = autonomous_relaxed_run_at(root, agent_id, run_id).unwrap_or(false);
if tool == PLAN_SUBMIT_GDD_TOOL && agent_id.trim() != GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
return AgentRuntimeToolObservation {
tool: tool.to_string(),
status: "rejected".to_string(),
summary: "plan.submit_gdd 仅允许 project-planning Agent".to_string(),
detail: None,
};
}
if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID
&& !matches!(tool, "file.read" | "file.list")
{
// `plan.submit_gdd` is intentionally handled by the planning submit
// branch in the Runtime main loop. If it ever reaches the generic
// executor (including recovery or a stale pending record), fail
// closed instead of treating the durable mutation as an ordinary
// command action.
return AgentRuntimeToolObservation {
tool: tool.to_string(),
status: "rejected".to_string(),
summary: "当前 Agent 身份不允许执行该工具".to_string(),
detail: None,
};
}
let action_fingerprint = pending_action
.map(|pending| {
agent_runtime_pending_tool_action_fingerprint(
@@ -2011,6 +2011,17 @@ mod tests {
assert!(error.contains("不得启动 isolated child"));
}
#[test]
fn autonomous_initial_collaboration_requires_leader_artifacts() {
let mut plan = autonomous_initial_leader_plan();
plan.actions[1] = autonomous_initial_delegate("art-director", &[]);
let error = validate_agent_runtime_autonomous_initial_collaboration_contract(&plan)
.expect_err("art artifact must be required");
assert!(error.contains("expectedArtifacts 必须包含 assets/art-spec.png"));
}
#[test]
fn autonomous_manifest_dag_waits_only_after_seed_execution_starts() {
let temporary = tempfile::tempdir().expect("create manifest DAG policy root");
@@ -25,6 +25,11 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at(
root,
"runtime.context_compaction.build",
)?;
if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
// Advance the immutable Provider-usage projection before any
// request/source bytes are rebuilt from the plan session.
fold_plan_provider_usage_before_new_request_at_locked(root, Some((agent_id, run_id)))?;
}
let source = build_game_creator_agent_runtime_context_compaction_source(
root,
agent_id,
@@ -62,6 +67,18 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at(
let config_path = format!("agentLlm.{template_agent_id}");
let mut request =
build_game_creator_agent_runtime_context_compaction_request(&source, &llm)?;
let planning_agent = agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID;
if planning_agent {
if allow_idle_context_compaction {
return Err(
"project-planning 不支持脱离 active run 的 idle context compaction".to_string(),
);
}
let wire_bytes =
capture_plan_provider_structured_injections_at(root, session_id, observations)?;
let message = render_plan_provider_structured_injections_message(&wire_bytes)?;
request.messages.insert(1, LlmMessage::user(message));
}
let estimated_request_tokens = estimate_game_creator_llm_request_tokens(&request)?;
validate_game_creator_llm_request_context_budget(
&llm,
@@ -89,6 +106,22 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at(
applied_steer_cursor,
)?
};
let snapshot = if planning_agent {
let request_context_fingerprint =
game_creator_agent_runtime_plan_provider_request_context_fingerprint(
&llm, &request,
)?;
let runtime = read_game_creator_agent_runtime_at(root, agent_id)?.state;
let binding = capture_plan_provider_session_binding_for_snapshot(
root,
&runtime,
&snapshot,
&request_context_fingerprint,
)?;
snapshot.with_planning_session_binding(Some(binding))
} else {
snapshot
};
(snapshot, source, llm, config_path, request)
};
let handoff_identity =
@@ -155,6 +188,7 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at(
let request_id = resolve_game_creator_agent_runtime_provider_request_attempt_at_locked(
root,
&base_request_id,
snapshot.planning_session_binding.is_some(),
)
.map(|value| value.0)
.unwrap_or(base_request_id);
@@ -178,6 +212,7 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at(
let request_id = resolve_game_creator_agent_runtime_provider_request_attempt_at_locked(
root,
&base_request_id,
snapshot.planning_session_binding.is_some(),
)
.map(|value| value.0)
.unwrap_or(base_request_id);
@@ -211,6 +246,7 @@ pub(in crate::agent) async fn compact_game_creator_agent_runtime_context_at(
let request_id = resolve_game_creator_agent_runtime_provider_request_attempt_at_locked(
root,
&base_request_id,
snapshot.planning_session_binding.is_some(),
)
.map(|value| value.0)
.unwrap_or(base_request_id);
@@ -109,6 +109,7 @@ pub(in crate::agent) fn game_creator_agent_runtime_tool_command_id(
"agent.schedule_ready" => Some("agent.schedule_ready"),
"agent.action_history" => Some("agent.audit"),
"agent.run_status" => Some("agent.run_status"),
PLAN_SUBMIT_GDD_TOOL => Some(PLAN_SUBMIT_GDD_TOOL),
_ => None,
}
}
@@ -118,7 +119,15 @@ pub(in crate::agent) fn game_creator_agent_runtime_tool_command_id(
/// id (for example `project.search` and `file.read`); policy lookup alone must
/// not turn that aliasing into an identity escalation for a restricted Agent.
pub(crate) fn agent_runtime_tool_allowed_for_agent(agent_id: &str, tool: &str) -> bool {
let _ = agent_id;
if agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID {
return matches!(
tool.trim(),
"file.read" | "file.list" | PLAN_SUBMIT_GDD_TOOL
);
}
if tool.trim() == PLAN_SUBMIT_GDD_TOOL {
return false;
}
if tool.trim() == GAME_CREATOR_USER_INPUT_REQUEST_TOOL {
// `user.input_request` is a protocol control handled by the main
// loop, not by the command-id policy map. It remains available to
@@ -128,10 +137,95 @@ pub(crate) fn agent_runtime_tool_allowed_for_agent(agent_id: &str, tool: &str) -
game_creator_agent_runtime_tool_command_id(tool.trim()).is_some()
}
/// 身份层面的**显式**拒绝:该 Agent 身份带 exact allowlist,且工具不在其中。
///
/// **未知工具名不属于本判据。** `agent_runtime_tool_allowed_for_agent` 对普通
/// Agent 退化成「这个工具名是否已知」,用它做身份门会把「模型编了个不存在的
/// 工具」这种普通协议错误误判成身份违规。协议错误的既有语义是:走到执行层产出
/// 一条 `rejected` observationrun 继续,由下一轮 tool-plan 收束;升级成身份
/// 拒绝会让整个 run 进 needs-reconciliation 而**不再发出 follow-up 请求**。
///
/// 因此凡是「命中即中断 run 或整体拒绝动作」的调用点都必须用本判据,不能直接
/// 用 `agent_runtime_tool_allowed_for_agent`。
pub(crate) fn agent_runtime_tool_rejected_by_agent_identity(agent_id: &str, tool: &str) -> bool {
agent_id.trim() == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID
&& !agent_runtime_tool_allowed_for_agent(agent_id, tool)
}
#[cfg(test)]
mod identity_tests {
use super::*;
/// 普通 Agent 编出来的未知工具名是**协议错误**,不是身份违规。
///
/// 判成身份违规会让 main_loop 把整个 run 打进 needs-reconciliation、不再发出
/// follow-up tool-plan——曾导致 `background_agent_runtime_persists_receipts_for_rejected_actions`
/// 在等待第二次 Provider 请求时超时。
#[test]
fn unknown_tool_on_ordinary_agent_is_not_an_identity_rejection() {
assert!(!agent_runtime_tool_rejected_by_agent_identity(
"design-director",
"runtime.unknown"
));
assert!(!agent_runtime_tool_rejected_by_agent_identity(
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"runtime.unknown"
));
assert!(!agent_runtime_tool_rejected_by_agent_identity(
"design-director",
"file.read"
));
}
/// planning 身份仍是 exact allowlist:未知工具与越权工具都算身份拒绝。
#[test]
fn planning_identity_still_rejects_unknown_and_out_of_scope_tools() {
assert!(agent_runtime_tool_rejected_by_agent_identity(
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
"runtime.unknown"
));
assert!(agent_runtime_tool_rejected_by_agent_identity(
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
"file.write"
));
assert!(!agent_runtime_tool_rejected_by_agent_identity(
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
"file.read"
));
assert!(!agent_runtime_tool_rejected_by_agent_identity(
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
PLAN_SUBMIT_GDD_TOOL
));
}
#[test]
fn planning_identity_does_not_inherit_project_search_alias() {
assert!(agent_runtime_tool_allowed_for_agent(
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
"file.read"
));
assert!(agent_runtime_tool_allowed_for_agent(
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
"file.list"
));
assert!(!agent_runtime_tool_allowed_for_agent(
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
"project.search"
));
assert!(agent_runtime_tool_allowed_for_agent(
GAME_CREATOR_PROJECT_PLANNING_AGENT_ID,
PLAN_SUBMIT_GDD_TOOL
));
assert!(!agent_runtime_tool_allowed_for_agent(
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
PLAN_SUBMIT_GDD_TOOL
));
assert!(agent_runtime_tool_allowed_for_agent(
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"project.search"
));
}
#[test]
fn account_asset_library_uses_the_existing_read_only_asset_permission() {
assert_eq!(
@@ -336,6 +336,33 @@ pub(in crate::agent) fn validate_agent_runtime_pending_tool_action_record(
let relaxed_autonomous = autonomous_relaxed_run_profile(&pending.run_profile);
if !relaxed_autonomous {
validate_agent_runtime_pending_goal_binding(pending)?;
match pending.planning_session_binding.as_ref() {
Some(binding) => {
validate_plan_provider_session_binding(binding)
.map_err(|error| error.to_string())?;
if pending.action.tool.trim() != PLAN_SUBMIT_GDD_TOOL
|| binding.agent_id != pending.agent_id
|| binding.task_id != pending.task_id
|| binding.session_id != pending.session_id
|| binding.run_id != pending.run_id
|| binding.source != pending.source
|| binding.run_profile != pending.run_profile
|| binding.run_profile_binding_fingerprint
!= pending.run_profile_binding_fingerprint
|| binding.applied_steer_cursor != pending.planned_steer_cursor
{
return Err(
"planning submit standalone pending 与 frozen binding 不一致".to_string(),
);
}
}
None if pending.provider_batch_plan_update.is_none() => {}
None => {
return Err(
"非 planning standalone pending 不能携带 Provider batch planUpdate".to_string(),
);
}
}
validate_agent_runtime_project_revision(root, &pending.project_revision_before)?;
if pending.verification_gate_before.project_id
!= game_creator_agent_runtime_context_project_id(root)?

Some files were not shown because too many files have changed in this diff Show More