From cec971c438772205524dd26d487f69ae1068fdeb Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 16 Sep 2026 19:53:23 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B1=80=E9=83=A8=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=E5=B7=A5=E5=85=B7=E7=9A=84=E7=BC=BA=E9=99=B7=E5=92=8C?= =?UTF-8?q?=E5=81=B6=E5=8F=91=E9=94=81=E9=97=AE=E9=A2=98=20(#386)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed-on: http://genarrative-station/git/GenarrativeAI/Genarrative/pulls/386 Co-authored-by: Linghong Co-committed-by: Linghong --- .../src-tauri/design-agent/tools.json | 2 +- .../src-tauri/src/agent/design_runtime.rs | 135 +++++++++++++++++- .../src-tauri/src/agent/design_tools.rs | 116 ++++++++++++++- .../shared-memory/decision-log.md | 6 + ...】策划Agent生产迁移与工作区浏览-2026-09-10.md | 4 +- 5 files changed, 249 insertions(+), 14 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json b/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json index c9c1bd6cf..ce086275f 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json @@ -2,7 +2,7 @@ {"type":"function","function":{"name":"get_workflow_status","description":"读取当前策划工作流状态,只返回阶段列表、当前阶段、已批准阶段和待审批阶段;不推进阶段、不提交审批、不修改文件。","parameters":{"type":"object","properties":{},"additionalProperties":false}}}, {"type":"function","function":{"name":"list_resources","description":"列出固定资源的逻辑目录、资源 ID、标题和简介。资源是只读的随包文档;不要猜测物理路径。","parameters":{"type":"object","properties":{},"additionalProperties":false}}}, {"type":"function","function":{"name":"read_resource","description":"读取一份固定资源文档全文。每次读取一个 resource_id;资源只读。读到未实现占位文档时由你自行判断和处理。","parameters":{"type":"object","properties":{"resource_id":{"type":"string"}},"required":["resource_id"],"additionalProperties":false}}}, - {"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一,匹配失败、重复或范围重叠时不修改文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}}, + {"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一。所有 edit 会一次性校验;任何失败都不修改文件,错误会列出各失败项及可唯一匹配的其余项。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}}, {"type":"function","function":{"name":"delete_path","description":"谨慎使用;永久删除工作区内的文件或目录;目录会连同全部内容递归删除,不备份。先确认目标及删除范围。path 使用相对路径,不能删除工作区根目录,也不能经过链接。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}}, {"type":"function","function":{"name":"list_dir","description":"列出工作目录内的文件和目录。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}}, {"type":"function","function":{"name":"read_file","description":"读取工作目录内的 UTF-8 文本文件。path 使用相对路径。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}}, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs index ebe6152b8..1a5a6e486 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs @@ -527,10 +527,6 @@ fn process_design_batch( let result = if uncertain { Err("进程在工具执行期间中断,执行结果未保存。未重复执行;请读取实际工作区确认结果后再决定下一步。".to_string()) } else { - let _write = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "design.tool", - )?; execute_design_tool(root, resources, session, &call) }; let error = result @@ -1026,6 +1022,15 @@ pub(crate) async fn continue_design_agent_at( finish_design_command(root, resources, session, active, run, emit).await } +async fn recover_uncertain_design_batch( + root: &Path, + resources: &DesignResources, + session: DesignSession, + active: File, +) -> Result { + finish_design_command(root, resources, session, active, true, |_| {}).await +} + pub(crate) async fn decide_design_phase_at( root: &Path, resources: &DesignResources, @@ -1058,7 +1063,8 @@ fn ensure_design_runtime_active(root: &Path) -> Result<(), String> { } #[tauri::command] -pub(crate) fn hydrate_design_agent_session( +pub(crate) async fn hydrate_design_agent_session( + app: tauri::AppHandle, project_path: String, ) -> Result, String> { let root = Path::new(project_path.trim()); @@ -1084,8 +1090,33 @@ pub(crate) fn hydrate_design_agent_session( if session.project_id != project_id { return Err("策划会话与当前项目不匹配".into()); } - let active = try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)?; - Ok(Some(design_view(&session, active.is_none()))) + let Some(active) = + try_open_game_creator_agent_runtime_task_lock_file(root, DESIGN_ACTIVE_LOCK)? + else { + return Ok(Some(design_view(&session, true))); + }; + if design_session_has_uncertain_batch(&session) { + let resources = DesignResources::new(resolve_design_resources_root(&app)?)?; + let view = recover_uncertain_design_batch(root, &resources, session, active).await?; + return Ok(Some(view)); + } + drop(active); + Ok(Some(design_view(&session, false))) +} + +fn design_session_has_uncertain_batch(session: &DesignSession) -> bool { + let Some(batch) = session.pending_batch.as_ref() else { + return false; + }; + if !batch.executing || batch.cursor >= batch.calls.len() { + return false; + } + let call_id = batch.calls[batch.cursor].id.as_str(); + session.turn.as_ref().is_some_and(|turn| turn.pending) + && !session.history.iter().any(|item| { + item.get("type").and_then(Value::as_str) == Some("function_call_output") + && item.get("call_id").and_then(Value::as_str) == Some(call_id) + }) } fn design_session_error_is_recoverable(error: &str) -> bool { @@ -1958,4 +1989,94 @@ mod tests { .any(|message| message.text.contains("重试后继续"))); assert!(next.session.last_error.is_none()); } + + #[tokio::test(flavor = "current_thread")] + async fn uncertain_batch_hydrate_continues_the_original_turn_without_replaying_file_tools() { + let (_temp, root, resources) = init_design_project(); + execute_design_file_tool( + &root, + "write_file", + &json!({"path":"project/00_concept/design.md","content":"概念"}), + ) + .expect("write concept"); + let mut session = new_design_session("design-fake", "quality"); + let call = platform_llm::LlmToolCall { + id: "interrupted-call".into(), + name: "patch_file".into(), + arguments: json!({ + "path":"project/00_concept/design.md", + "old_text":"概念", + "new_text":"概念设计" + }) + .to_string(), + }; + session.history.push(json!({ + "type":"function_call", + "call_id":call.id, + "name":call.name, + "arguments":call.arguments, + })); + session.messages = vec![DesignMessage { + id: "turn:user".into(), + role: "user".into(), + text: "继续".into(), + }]; + session.turn = Some(DesignTurn { + id: "turn-recovery".into(), + pending: true, + request_index: 0, + attempt: 0, + }); + session.pending_batch = Some(DesignToolBatch { + calls: vec![call], + cursor: 0, + executing: true, + }); + assert!(design_session_has_uncertain_batch(&session)); + write_design_session(&root, &session).expect("write interrupted session"); + + let _fake = fake_provider::install( + vec![Ok(fake_response( + "recovered-after-uncertain-tool", + "已读取文件并确认。", + Vec::new(), + ))], + 0, + ); + let view = recover_uncertain_design_batch(&root, &resources, session, { + try_open_game_creator_agent_runtime_task_lock_file( + &root, + ".agent/design-agent/active.lock", + ) + .expect("open active lock") + .expect("active lock is free") + }) + .await + .expect("recover uncertain batch"); + + assert!(!view.running); + assert!(view.session.last_error.is_none()); + let restored = read_design_session(&root) + .expect("read restored") + .expect("session"); + assert!(restored.pending_batch.is_none()); + assert!(!restored.turn.expect("turn").pending); + assert!(restored.history.iter().any(|item| { + item.get("type").and_then(Value::as_str) == Some("function_call_output") + && item.get("call_id").and_then(Value::as_str) == Some("interrupted-call") + && item + .get("output") + .and_then(Value::as_str) + .is_some_and(|output| output.contains("执行结果未保存")) + })); + assert!(restored.history.iter().any(|item| { + item.get("role").and_then(Value::as_str) == Some("assistant") + && item.get("content").is_some() + })); + assert!( + fs::read_to_string(root.join("design_artifacts/project/00_concept/design.md")) + .expect("read target") + == "概念" + ); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs index 5221030b1..730d68004 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs @@ -321,15 +321,31 @@ pub(crate) fn execute_design_file_tool( }) .collect::>(); let mut matches = Vec::new(); + let mut edit_errors = Vec::new(); + let mut valid_edits = 0; for (index, (old, new)) in normalized.iter().enumerate() { + if old == new { + edit_errors.push(format!( + "edits[{index}] new_text 与 old_text 相同,不会产生修改" + )); + continue; + } let count = content.matches(old).count(); if count == 0 { - return Err(format!("edits[{index}] 原文未找到:{display}")); + edit_errors.push(format!( + "edits[{index}] 原文未找到:{}{}", + display, + design_patch_location_hint(&content, old) + )); + continue; } if count != 1 { - return Err(format!( - "edits[{index}] 原文匹配 {count} 处,必须唯一:{display}" + let start = content.find(old).expect("count checked"); + let line = design_patch_line_number(&content, start); + edit_errors.push(format!( + "edits[{index}] 原文匹配 {count} 处,必须唯一;首次位于第 {line} 行" )); + continue; } let start = content.find(old).expect("count checked"); let end = start + old.len(); @@ -337,13 +353,33 @@ pub(crate) fn execute_design_file_tool( .iter() .find(|(_, other_start, other_end)| start < *other_end && *other_start < end) { - return Err(format!( - "edits[{index}] 与 edits[{other_index}] 修改范围重叠:{display}" + edit_errors.push(format!( + "edits[{index}] 与 edits[{other_index}] 修改范围重叠;请合并为一个 edit 或缩短 old_text" )); + continue; } matches.push((index, start, end)); + valid_edits += 1; let _ = new; } + if !edit_errors.is_empty() { + let shown = edit_errors.len().min(4); + let mut details = edit_errors[..shown].to_vec(); + if shown < edit_errors.len() { + details.push(format!( + "另有 {} 个 edit 校验失败(详情省略)", + edit_errors.len() - shown + )); + } + if valid_edits > 0 { + details.push(format!( + "其余 {valid_edits} 个 edit 当前可唯一匹配;本次未写入文件" + )); + } else { + details.push("本次未写入文件".to_string()); + } + return Err(details.join("\n")); + } let mut updated = content.clone(); for (index, start, end) in matches.into_iter().rev() { let (_, new) = &normalized[index]; @@ -396,6 +432,60 @@ pub(crate) fn execute_design_file_tool( } } +fn design_patch_line_number(content: &str, start: usize) -> usize { + 1 + content[..start] + .bytes() + .filter(|byte| *byte == b'\n') + .count() +} + +fn design_patch_visible_line(line: &str) -> String { + line.replace('\t', "\\t").chars().take(180).collect() +} + +fn design_patch_location_hint(content: &str, old: &str) -> String { + let Some(anchor) = old.lines().map(str::trim).find(|line| !line.is_empty()) else { + return String::new(); + }; + + let mut candidates = content + .lines() + .enumerate() + .filter(|(_, line)| line.trim() == anchor) + .map(|(index, line)| (index + 1, line)) + .collect::>(); + if candidates.is_empty() { + let token = anchor.split_whitespace().find(|token| token.len() >= 3); + if let Some(token) = token { + candidates = content + .lines() + .enumerate() + .filter(|(_, line)| line.trim().contains(token)) + .map(|(index, line)| (index + 1, line)) + .collect(); + } + } + if candidates.is_empty() { + return format!( + ";未找到与 old_text 首个非空行相似的行(当前文件约 {} 行)", + content.lines().count() + ); + } + + let details = candidates + .iter() + .take(2) + .map(|(line, text)| format!("第 {line} 行:{}", design_patch_visible_line(text))) + .collect::>() + .join(";"); + let suffix = if candidates.len() > 2 { + format!("等 {} 处", candidates.len()) + } else { + String::new() + }; + format!(";old_text 首个非空行可能对应 {details}{suffix}(tab 显示为 \\t)") +} + pub(crate) fn list_design_workspace_files( root: &Path, ) -> Result, String> { @@ -693,6 +783,22 @@ mod tests { ) .expect_err("escape"); assert!(escaped.contains("路径")); + let mismatch = execute_design_file_tool( + root, + "patch_file", + &json!({ + "path":"notes/design.md", + "edits":[ + {"old_text":" 游戏设计","new_text":"游戏概念"}, + {"old_text":"设计","new_text":"方案"} + ] + }), + ) + .expect_err("report all patch failures"); + assert!(mismatch.contains("edits[0] 原文未找到")); + assert!(mismatch.contains("第 1 行:游戏设计")); + assert!(mismatch.contains("其余 1 个 edit 当前可唯一匹配")); + assert!(mismatch.contains("本次未写入文件")); let patched = execute_design_file_tool( root, "patch_file", diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 0ba04d1c5..ccd59c705 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2,6 +2,12 @@ > 用途:记录已经确认、会影响后续开发的长期技术/产品/协作决策。短期讨论不要写在这里。 > 当前口径:历史条目的旧路径、旧版本和已退役对象只用于追溯,不构成现行实现依据;如与当前代码或 `docs/README.md` 冲突,以当前代码和最新专题文档为准。 +## 2026-09-16 策划 Agent 工具执行退出项目级写锁并自动接续中断批次 + +- 背景:策划 Agent 每个 `read_file` / `write_file` / `patch_file` 工具都在执行前竞争全局项目写锁,但同一会话已由 `.agent/design-agent/active.lock` 串行化,工具目标又限定在 `design_artifacts`;项目锁既不覆盖「工具 + 会话 checkpoint」事务,还把进程中断时的 `executing=true` 不确定窗口扩大到等锁与工具执行全程。真机项目出现 `pendingBatch.executing=true`、`function_call` 无配对 output、UI 只显示工作中且无错误的状态。 +- 决策:单次策划工具不再竞争项目级写锁,只保留策划命令锁与既有原子写入;GameAgent / DirectProject 的公共项目锁实现与调用不变。重开项目 hydrate 时,若命令锁可获取且当前批次处于 `executing=true`、当前 call 无 output,则自动续跑原回合:为该 call 补写「执行结果未保存」的工具错误、跳过剩余调用并交回 Provider 自愈;不得重放文件副作用,也不要求用户手动重试。 +- 验证:新增定向用例证明中断批次自动补齐工具 output、收到后续 assistant 回复、清空 pendingBatch 并结束原 turn,同时目标文件保持未修改(未重放 `patch_file`);策划 Runtime 定向 14 条、策划工具 3 条通过,`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。 +- 关联文档:[策划 Agent 生产迁移与工作区浏览](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md)。 ## 2026-09-16 AGC 同 AppData 多窗口共享 Agent Runner diff --git a/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md b/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md index ce891e8fc..36b5d19a4 100644 --- a/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md +++ b/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md @@ -132,6 +132,8 @@ Runtime 不维护文档版本号,不解析文档版本,不提供版本回退 一轮有多个工具调用时沿用正常工具执行循环。澄清或审批进入等待后,不继续请求 Provider,也不执行同批剩余文件操作;未执行调用明确记录为因等待用户而未执行,不伪造成功结果。恢复历史必须保持工具调用与结果配对,避免出现缺少 tool output 的协议错误。这属于协议与暂停处理,不引入同轮调用次数门禁。 +单次策划工具不再竞争项目级写锁;策划命令锁与会话原子写入已保证同一会话内工具按批次顺序执行。若进程在工具执行标记与结果落盘之间中断,重开项目时的只读 hydrate 必须在拿到策划命令锁后自动续跑原回合,为不确定调用补写“执行结果未保存”的工具错误、跳过剩余调用,并把错误交回 Provider 自愈;不得重放文件副作用,也不要求用户手动恢复。 + 迁移工具集合: ```text @@ -150,7 +152,7 @@ get_workflow_status 工具使用相对工作区路径。工具执行结果继续通过 Runtime 统一记录和展示,但不向 Agent 暴露宿主绝对路径。 -`patch_file` 保留原型按唯一原文匹配修改的语义、换行归一化和缺文件错误。正常工作区写入与删除不逐次请求用户审批;阶段审批不能被复用为文件操作许可。 +`patch_file` 保留原型按唯一原文匹配、范围不重叠、全部通过才原子写入的语义、换行归一化和缺文件错误。批量 edits 会一次性完成全部校验,并把未找到、多处匹配、重叠等失败项汇总返回;未找到时同时给出候选行号和可见化缩进提示,帮助 Provider 基于当前文件修正锚点。正常工作区写入与删除不逐次请求用户审批;阶段审批不能被复用为文件操作许可。 `list_resources` 一次返回完整逻辑分类、资源 ID、标题和简介;`read_resource` 按一个资源 ID 读取一个文件。资源描述不增加 `required=true/false` 分类,也不增加引导同轮多次调用的说明。