补齐单Agent代码验证闭环

新增受控 project.verify 与 headless agent-task 入口
补强工具计划协议修复、瞬时重试和验证后收束门禁
完善进程清理、写锁恢复、审计关联及覆盖测试
同步共享契约、开发文档和验证脚本
This commit is contained in:
AIGameCreator App
2026-07-12 00:59:05 +08:00
parent ffbc990f5d
commit 44ac45cd5b
15 changed files with 2267 additions and 97 deletions
+1
View File
@@ -9,6 +9,7 @@
"dev-stack": "node scripts/start-dev-stack.mjs",
"build": "npm --prefix ../.. exec tauri -- build",
"llm-status": "node scripts/run-cli-with-config.mjs --llm-status",
"agent-task": "node scripts/run-cli-with-config.mjs --agent-task",
"agent-run": "node scripts/run-cli-with-config.mjs --agent-run",
"agent-run:smoke": "node scripts/smoke-agent-run-local-provider.mjs",
"typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --noEmit && node scripts/check-config.mjs"
@@ -374,6 +374,15 @@ if (
);
}
if (
packageConfig.scripts?.['agent-task'] !==
'node scripts/run-cli-with-config.mjs --agent-task'
) {
throw new Error(
'AI game creator shell agent-task must use client config before starting the single Agent runtime',
);
}
if (tauriConfig.productName !== 'Genarrative AI Game Creator') {
throw new Error('AI game creator shell productName drifted');
}
@@ -677,6 +686,7 @@ for (const script of [
'ai-game-creator-shell:dev',
'ai-game-creator-shell:dev-server',
'ai-game-creator-shell:build',
'ai-game-creator-shell:agent-task',
'ai-game-creator-shell:typecheck',
'ai-game-creator-shell:agent-run:smoke',
'ai-game-creator-shell:check',
+1
View File
@@ -1309,6 +1309,7 @@ dependencies = [
name = "genarrative-ai-game-creator-shell"
version = "0.1.0"
dependencies = [
"libc",
"platform-agent",
"platform-llm",
"reqwest 0.12.28",
@@ -18,5 +18,8 @@ shared-contracts = { path = "../../../server-rs/crates/shared-contracts", defaul
tauri = { version = "2.11.2", features = [] }
tauri-plugin-dialog = "2.7.1"
tauri-plugin-opener = "2.5.4"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tokio = { version = "1", features = ["io-util", "macros", "process", "rt-multi-thread", "time"] }
zip = { version = "2", default-features = false, features = ["deflate"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
File diff suppressed because one or more lines are too long
@@ -8,6 +8,12 @@ pub(crate) enum CliCommand {
agent_id: String,
prompt: String,
},
AgentTask {
project_path: PathBuf,
agent_id: String,
task: String,
initialize: bool,
},
AgentRun {
project_path: PathBuf,
prompt: String,
@@ -89,6 +95,37 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result<Option<CliCommand>, S
prompt: prompt.to_string(),
}));
}
if args.first().map(String::as_str) == Some("--agent-task") {
let mut rest = args[1..].to_vec();
let initialize = if let Some(index) = rest.iter().position(|arg| arg == "--init") {
rest.remove(index);
true
} else {
false
};
let project_path = rest.first().map(String::as_str).ok_or_else(|| {
"用法:--agent-task [--init] <本地项目绝对路径> <agentId> <任务>".to_string()
})?;
let agent_id = rest.get(1).map(String::as_str).ok_or_else(|| {
"用法:--agent-task [--init] <本地项目绝对路径> <agentId> <任务>".to_string()
})?;
if rest.len() < 3 {
return Err(
"用法:--agent-task [--init] <本地项目绝对路径> <agentId> <任务>".to_string(),
);
}
let task = rest[2..].join(" ");
let task = task.trim();
if task.is_empty() {
return Err("Agent 任务不能为空".to_string());
}
return Ok(Some(CliCommand::AgentTask {
project_path: PathBuf::from(project_path),
agent_id: agent_id.trim().to_string(),
task: task.to_string(),
initialize,
}));
}
if args.first().map(String::as_str) != Some("--agent-run") {
return Ok(None);
}
@@ -151,6 +188,89 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> {
println!("replyText={}", reply.reply_text);
Ok(())
}
CliCommand::AgentTask {
project_path,
agent_id,
task,
initialize,
} => {
if initialize && !project_path.join(".agent/manifest.json").is_file() {
let project_name = project_path
.file_name()
.and_then(|value| value.to_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("CLI Agent 项目");
init_local_game_project_at(
&project_path,
&format!("cli-agent-{}", unix_millis()),
project_name,
)?;
}
if !project_path.join(".agent/manifest.json").is_file() {
return Err("项目尚未初始化;请先在 App 中创建项目,或显式传入 --init".to_string());
}
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|error| format!("创建 CLI runtime 失败:{error}"))?;
let run_id = format!("cli-{agent_id}-{}", unix_millis());
let terminal = runtime.block_on(async {
let started = start_game_creator_agent_background_task_at(
&project_path,
&agent_id,
&task,
&run_id,
)?;
let canonical_run_id = started.state.run_id.clone();
let deadline = std::time::Instant::now() + Duration::from_secs(600);
loop {
let current = read_game_creator_agent_runtime_at(&project_path, &agent_id)?;
if current.state.run_id == canonical_run_id
&& (current.state.status == "idle"
|| current.state.status == "failed"
|| current.state.status == "waiting-for-confirmation")
{
break Ok::<AgentRuntimeState, String>(current.state);
}
if std::time::Instant::now() >= deadline {
break Err(format!(
"等待单 Agent 任务超时:agentId={agent_id} runId={canonical_run_id}"
));
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
})?;
println!("agent.task.terminal");
println!("projectPath={}", project_path.display());
println!("agentId={agent_id}");
println!("runId={}", terminal.run_id);
println!("status={}", terminal.status);
println!("phase={}", terminal.phase);
if let Some(reply) = terminal.last_response.as_deref() {
println!("replyText={reply}");
}
if let Some(pending) = terminal.pending_tool_action.as_ref() {
println!("pendingActionId={}", pending.action_id);
println!("pendingTool={}", pending.tool);
if let Some(summary) = pending.input_summary.as_deref() {
println!("pendingInput={summary}");
}
}
if let Some(error) = terminal.error.as_deref() {
println!("error={error}");
}
if terminal.status == "idle" && terminal.phase == "completed" {
Ok(())
} else if terminal.status == "waiting-for-confirmation" {
Err("单 Agent 任务正在等待开发者确认,请在开发窗口继续".to_string())
} else {
Err(format!(
"单 Agent 任务未完成:{} / {}",
terminal.status, terminal.phase
))
}
}
CliCommand::AgentRun {
project_path,
prompt,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -23,10 +23,14 @@
- 2026-07-10 补充:后台 Runtime 每次追加 `.agent/runtime/events/<agentId>.jsonl` 后会通过 Tauri `game-creator-agent-runtime-update` 事件广播当前 `AgentRuntimeResult`;开发单 Agent 聊天页、项目内 Agent 对话弹窗和主窗口 Agent 状态列表都只把该事件作为实时 UI 通知并复用前端 runtime 归一化合并,事实源仍是 `.agent/runtime/agents``events``tasks` 文件。
- 2026-07-11 补充:开发单 Agent 聊天页保留整页纵向滚动,聊天消息区固定响应式高度并在内部滚动;Runtime 恢复确认区使用独立布局行,避免与 Runtime 详情或聊天内容重叠。Runtime 面板详情可折叠且折叠时不渲染详情 DOM,但状态标题与任务控制按钮继续保留;等待 LLM 时在消息区显示动态状态,连续流式 delta 合并到动画帧更新并跳过重复 Runtime state。OpenAI Chat SSE 会收集 usage-only 尾包、保留 finish reason 与上游 error message,收到 `[DONE]` 后立即结束;持久事件订阅失败时显示非致命错误,聊天事件监听不可用或首个文本片段前流式失败时降级普通回复并继续落盘。
- 2026-07-11 补充:为缩小单 Agent 与 Codex CLI 在代码任务上的差距,Runtime 工具箱新增 `project.search``file.patch`,并扩展 `file.read` 的按行分页。`project.search` 在项目内执行有界字面量检索,默认忽略大小写,返回相对路径、行号和匹配行,跳过 `.agent`、敏感配置、依赖和构建目录;权限继承 `file.read``file.read` 接受 `startLine / maxLines`,返回带行号的最多 240 行、8,000 字符上下文,允许 Agent 继续分页而不是只看到文件开头约 900 字符。`file.patch` 只做 `oldText -> newText` 精确替换,必须声明预期匹配数,匹配数不符时不写入;它继承 `file.write` 权限,复用项目写锁和 Runtime 动作账本,并追加不含代码正文的 `agent.runtime.file.patch` 审计记录。三者组成“搜索定位 -> 分段读取 -> 局部修改 -> 再次读取验证”的最小代码工作闭环,不开放任意 shell。
- 2026-07-11 补充:后台 Agent 的工具规划和最终回复请求对 `LlmError::EmptyResponse` 最多自动重试 3 次(含首次共 4 次请求),与既有 Generator 对上游 HTTP 成功但空 content 的恢复策略一致;连接、协议、鉴权、解析等其他错误不在此处重试,仍按原错误路径失败并落 Runtime 事件。该重试不会重复执行工具动作,只会原样重发尚未得到有效文本的 LLM 请求
- 2026-07-11 补充:单 Agent 代码闭环新增开发专用 `project.verify`,用于在修改后执行项目根 `package.json` 已定义的 `check / typecheck / test / lint / build` 之一;当前只支持 npm,不接受自由命令、参数或工作目录。Agent 必须先读取 `package.json`,再把脚本名、完整原始脚本文本 `expectedCommand` 和 1-300 秒超时一起提交;Runtime 在真正执行前重新解析 JSON 并做精确一致性校验,脚本漂移时拒绝执行。该工具使用独立、默认 `confirm``project.verify` 权限,不再与 `command.run_limited` / `game.static_smoke` 共用授权;确认指纹覆盖脚本正文和超时。执行时不经过 App 自行拼接的 `bash -c`,而由 npm 执行已确认的项目脚本,并附加 `--ignore-scripts` 阻止 `pre/post` 生命周期旁路;继承环境被清理到 PATH 与必要平台变量,HOME/TMP/npm cache 隔离,stdin 关闭,输出保留有界头尾。Unix 下验证根进程正常结束或超时都会清理同进程组残留后代;项目写锁会按持有 PID 回收崩溃遗留锁,并拒绝 `.agent` 符号链接逃逸。进入进程执行后的成功、非零退出、启动失败和超时会写 `.agent/logs/command.log`、manifest command run 与 `agent.runtime.project.verify` 审计;输入预检拒绝则只进入 Runtime observation / error 事件。输出先过滤敏感内容再进入 observation。只要最新 `project.verify` 未通过,或通过后又发生 `file.write / file.patch / project.restore`Runtime 就拒绝模型用空 actions 假完成,继续要求修复和重新验证;耗尽 loop 仍未通过时保持失败。该能力会执行用户项目自身脚本,环境隔离不等同于 OS 沙箱,不能把不可信项目脚本视为安全代码;它不是自由 shell 代理,也不进入普通用户命令入口
- 2026-07-11 补充:开发侧新增 headless 单 Agent Runtime 入口 `npm run ai-game-creator-shell:agent-task -- [--init] <projectPath> <agentId> <task>`。该入口不实现第二套 Agent,只复用 Tauri App 的持久任务队列、per-agent 锁、LLM 路由、权限策略、工具 action / observation loop、对话和审计文件,并轮询到 `completed / failed / waiting-for-confirmation` 后用稳定键值行退出;`--init` 只在显式传入且 manifest 不存在时初始化项目。遇到待确认动作时 CLI 返回非零并打印 actionId、tool 和脱敏摘要,后续仍由开发窗口完成确认,不提供静默 `--yes` 绕过。
- 2026-07-11 补充:后台 Agent 的工具规划和最终回复请求对 `LlmError::EmptyResponse` 最多自动重试 3 次(含首次共 4 次请求),与既有 Generator 对上游 HTTP 成功但空 content 的恢复策略一致;对 `Timeout / Connectivity / Transport` 及上游 `408 / 429 / 5xx` 最多额外自动重试 2 次并做线性退避,配置、请求、流能力、反序列化错误及其他 `4xx` 仍立即失败。重试发生在工具计划被解析和执行前,或最终回复尚未落盘时,不会重复执行已经落盘的工具动作。
- 2026-07-11 调整:后台单 Agent 的 planning loop 上限从 3 轮提升到 6 轮,每轮工具动作上限仍为 3;真实代码任务已证明“搜索定位、分段读取、等待写入确认、写后复读”可能在第 3 轮才进入待确认状态,原上限会让确认后的同 run 没有继续验证余量。`maxLoopIterations` 随新上限写入 Runtime,跨重启待确认动作按已完成轮次继续使用剩余轮次;6 轮后 actions 仍未收束时继续进入 `failed / budget-exhausted`,不会伪装完成。该调整只作用于后台单 Agent Runtime,不改变游戏草案 Generator/Evaluator 的 3 轮上限;本文件和旧实施摘要中“后台 3 轮后整理最终回复”的历史描述由本条取代。
- 2026-07-11 调整:开发者投递的后台任务从队列记录、Runtime `currentTask/currentGoal` 到待确认动作私有账本统一保留最多 4,000 字符,不再在入队时截成 180 字符。180 字符只用于 UI、事件和审计预览;LLM planning、失败重试、确认续跑和重启恢复必须使用完整任务字段,避免位于长需求末尾的验收条件、禁止项或输出格式在真正执行前丢失。
- 2026-07-11 调整:后台结构化 planning 使用独立的 4,000 输出 token 上限,最终回复使用 2,400;两者显式请求 low reasoning effort 和 low text verbosity。`platform-llm` 会把 reasoning effort 同时映射到 OpenAI Responses 的 `reasoning.effort` 与 Chat Completions 的 `reasoning_effort`,未设置时不新增字段。真实 gpt-5.5 Chat 响应曾连续消耗约 1,000-1,400 completion tokens 却不返回 message content,低推理强度、较大的可见输出余量和 EmptyResponse 重试共同构成恢复策略。
- 2026-07-11 补充:后台单 Agent 的工具计划响应只接受可反序列化为计划 schema 的 JSON object。解析器提取模型输出中的首个完整对象并允许对象后带普通说明;未找到完整 JSON 对象,或提取对象无法反序列化为工具计划时,Runtime 最多追加 2 次自动格式修复请求。每次修复只携带限长、脱敏后的上一次无效输出,并写入 `agent.runtime.tool_plan.repair` 审计。两次修复后仍无有效对象则按工具规划失败处理;工具规划阶段的普通文本不得转换为默认的空 actions + response,也不得据此把任务标记为完成。
- 2026-07-11 调整:工具计划顶层 `thinkingSummary / plan / actions / response` 四个字段必须同时存在,未知顶层字段、空 thinkingSummary 和空 tool 均属于协议错误并进入同一格式修复预算,`{}` 或前置无关 JSON 对象不能再触发空计划收束。空 actions 表示 planning 收束;response 非空时直接采用,response 为空时进入独立的最终回复生成。`agent.runtime.project.verify` 审计同时保存 `runId / actionId / actionFingerprint`,使并行 Agent 的失败与通过记录能够精确归属到发起动作。
- 2026-07-10 补充:后台 Agent Runtime 的白名单工具继续扩到 `preview.start`,让 Agent 在完成写盘或静态自检后能按策略自行启动当前项目的 `127.0.0.1` 本地 HTTP 预览。该工具复用 `preview.start` 权限策略、项目写锁、共享 `PreviewRegistry`、manifest 预览状态、`.agent/logs/preview.log` 和 run trace 追加逻辑;写入 `.agent/agent.db` 的审计类型为 `agent.runtime.preview.start`。发给 LLM 的 observation 只包含 localhost URL 和端口,不包含用户项目绝对路径。
- 2026-07-10 补充:后台 Agent Runtime 的白名单工具继续扩到 `canvas.asset_generate`,让美术类 Agent 可在 loop 中自行请求生成首版美术素材。该工具读取 AppData / Tauri 配置中的 `editorApi`,复用 `canvas.asset_generate` 权限策略、项目写锁、External Editor API 生成和下载链路、manifest 资产登记以及 `canvas.asset_generate` 本地索引记录;另写 `agent.runtime.canvas.asset_generate` 记录到 `.agent/agent.db`,标明触发的 agent 与本地素材路径。API Key 不进入 prompt observation、manifest、agent.db 或日志;策略要求确认或拒绝时不会调用外部 API。
- 补充:规范 Agent ID 统一使用 manifest taskId,例如 `art-asset-plan``code-prototype`;历史前端曾使用的 `group-role` 别名只在 Tauri command 层兼容并映射到规范 taskId。主窗口 Agent 状态列表通过 `read_game_creator_agent_runtimes` 批量读取 `.agent/runtime/agents/<taskId>.json` 和最近任务,把每个 Agent 的 Runtime 状态、当前动作和最近 task 直接显示在状态卡片和 `/agents` 汇总里。
@@ -57,6 +57,14 @@ AI 游戏创作独立客户端常用短命令:
npm run agc
```
开发侧需要无 UI 验收某个单 Agent 的完整 Runtime 时使用:
```bash
npm run ai-game-creator-shell:agent-task -- --init /absolute/project code-prototype "修复失败测试并完成验证"
```
省略 `--init` 时项目必须已经由客户端初始化;遇到权限确认会返回非零并保留待确认动作,继续操作应回到开发窗口,不能用 CLI 静默绕过。
`npm run agc` 会启动 Tauri 开发客户端;其 `beforeDevCommand` 通过 `npm run agc:serve` 先完成壳 typecheck,再启动或复用配套 SpacetimeDB、`api-server` 和固定 `127.0.0.1:3080` Vite。只需要浏览器预览同一客户端时可用 `npm run agc:serve`;只启动配套后端和数据库时可用 `npm run agc:backend -- --database <name>`
Linux 多用户共享同一台机器开发时,本地 dev 脚本会为当前 Linux 用户分配一个固定端口段并写入系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json`,自动分配从 `10000-10099` 开始,每段 100 个端口,四个 dev 服务依次使用 `start``start + 3`。可用 `GENARRATIVE_DEV_PORT_RANGE``npm run dev -- --port-range` 手动指定端口段用于特殊场景;注册表会阻止不同用户使用相同或重叠段,并让同一用户后续启动继续复用自己已占用的固定段。该机制只在 Linux 生效,Windows 仍沿用原有端口探测与漂移逻辑。
File diff suppressed because one or more lines are too long
+1
View File
@@ -133,6 +133,7 @@
"ai-game-creator-shell:dev-server": "npm --prefix apps/ai-game-creator-shell run dev-server",
"ai-game-creator-shell:build": "npm --prefix apps/ai-game-creator-shell run build --",
"ai-game-creator-shell:llm-status": "npm --prefix apps/ai-game-creator-shell run llm-status --",
"ai-game-creator-shell:agent-task": "npm --prefix apps/ai-game-creator-shell run agent-task --",
"ai-game-creator-shell:agent-run": "npm --prefix apps/ai-game-creator-shell run agent-run --",
"ai-game-creator-shell:agent-run:smoke": "npm --prefix apps/ai-game-creator-shell run agent-run:smoke",
"ai-game-creator-shell:typecheck": "npm --prefix apps/ai-game-creator-shell run typecheck",
@@ -26,6 +26,11 @@ describe('AI 游戏创作 App 共享契约', () => {
(command) => command.id === 'command.run_limited',
)?.permission,
).toBe('confirm');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'project.verify',
)?.permission,
).toBe('confirm');
expect(
GAME_CREATION_APP_COMMANDS.find(
(command) => command.id === 'game.generate_draft',
@@ -20,6 +20,7 @@ export const GAME_CREATION_APP_COMMANDS = [
{ id: 'project.checkpoint', permission: 'confirm' },
{ id: 'project.diff', permission: 'auto' },
{ id: 'project.restore', permission: 'confirm' },
{ id: 'project.verify', permission: 'confirm' },
{ id: 'project.export_package', permission: 'confirm' },
{ id: 'project.export_list', permission: 'auto' },
{ id: 'project.policy_read', permission: 'auto' },
@@ -21,7 +21,7 @@ pub struct GameCreationAppCommandDescriptor {
pub permission: GameCreationAppPermission,
}
pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 48] = [
pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 49] = [
command("help.show", GameCreationAppPermission::Auto),
command("project.create", GameCreationAppPermission::Confirm),
command("project.status", GameCreationAppPermission::Auto),
@@ -29,6 +29,7 @@ pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 48] = [
command("project.checkpoint", GameCreationAppPermission::Confirm),
command("project.diff", GameCreationAppPermission::Auto),
command("project.restore", GameCreationAppPermission::Confirm),
command("project.verify", GameCreationAppPermission::Confirm),
command("project.export_package", GameCreationAppPermission::Confirm),
command("project.export_list", GameCreationAppPermission::Auto),
command("project.policy_read", GameCreationAppPermission::Auto),
@@ -632,6 +633,15 @@ mod tests {
.expect("command should exist");
assert_eq!(command.permission, GameCreationAppPermission::Confirm);
let project_verify = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "project.verify")
.expect("project.verify command should exist");
assert_eq!(
project_verify.permission,
GameCreationAppPermission::Confirm
);
let generate = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "game.generate_draft")