From 6ba86f50618313809c35c5fc39d52bfcb8e960aa Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 14 Sep 2026 15:00:32 +0800 Subject: [PATCH] =?UTF-8?q?GameAgent=20=E5=B7=A5=E5=85=B7=E8=B0=83?= =?UTF-8?q?=E7=94=A8=EF=BC=9A=E9=87=87=E9=9B=86=E3=80=81=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=8C=81=E4=B9=85=E5=8C=96=E3=80=81=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E5=AD=97=E6=AE=B5=E4=B8=8E=E5=9B=9E=E8=AF=BB=E5=91=BD?= =?UTF-8?q?=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 agent/direct_tool_calls.rs:把 Codex item 投影成结构化 DirectToolCall(command / file_change / mcp_tool / web_search / context_compaction / other),标题按 kind 固定(`编辑 N 个文件` 按去重路径数) - 新增 agent/direct_tool_calls.rs:独立文件 `.agent/conversations/tool-calls.jsonl` 的幂等 upsert (同 id 只落一行、completed 覆盖 started、startedAt 取最早非零值)与 200 条上限裁剪 - 新增 agent/direct_tool_calls.rs:command/output 截断 4000 字符、summary 截断 120 字符, 先抹绝对路径再抹密钥(复用 redact_absolute_path_tokens / redact_secret_tokens) - 新增 agent/direct_tool_calls.rs:回读按时间正序、单行损坏跳过、文件缺失返回空数组 - 新增 agent/direct_tool_calls.rs 单测 8 条:幂等 upsert、非 0 退出码判 failed、截断、脱敏、 坏行跳过、上限裁剪与排序、file_change 标题与摘要、非工具 item 不产卡片 - agent/codex_app_server.rs:DirectCodexTurnObservation 增加 ToolCall 变体;item/started 与 item/completed 各采一次,turnId 沿用 AGC 客户端回合 id - agent/direct_runtime.rs:观察者采集到工具调用时增量下发,回合结束整批落盘(一次锁、一次重写) - agent/direct_runtime.rs:单项落盘走阻塞线程池,落盘失败不影响回合结果 - agent/runtime_driver/entrypoints.rs:DirectGameCreatorTurnUpdateEmitter::emit 增加可选 toolCalls 参数 - main.rs:GameCreatorDirectTurnUpdateEvent 增加可选 toolCalls 字段,skip_serializing_if 保证 老事件序列化结果不变 - commands.rs / main.rs:新增 read_direct_tool_calls(projectPath) 命令并注册到 invoke_handler --- .../src-tauri/src/agent.rs | 2 + .../src-tauri/src/agent/codex_app_server.rs | 26 + .../src-tauri/src/agent/direct_runtime.rs | 84 +- .../src-tauri/src/agent/direct_tool_calls.rs | 753 ++++++++++++++++++ .../src/agent/runtime_driver/entrypoints.rs | 2 + .../src-tauri/src/commands.rs | 13 + .../src-tauri/src/main.rs | 5 + 7 files changed, 878 insertions(+), 7 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 17b8a6700..62a2a0b0b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -21,6 +21,7 @@ mod direct_project_history; mod direct_project_turn_history; mod direct_runtime; mod direct_tool_bridge; +mod direct_tool_calls; mod direct_tools_mcp; mod generation; mod interaction; @@ -49,6 +50,7 @@ pub(crate) use direct_project_history::*; pub(crate) use direct_project_turn_history::*; pub(crate) use direct_runtime::*; pub(crate) use direct_tool_bridge::*; +pub(crate) use direct_tool_calls::*; pub(crate) use direct_tools_mcp::*; pub(crate) use generation::*; pub(crate) use interaction::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 1d0ecf233..e8d536ae1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -523,6 +523,8 @@ pub(crate) enum DirectCodexTurnObservation { AccumulatedText(String), IntermediateText(String), Activity(&'static str), + /// 一条结构化工具调用(`item/started` 与 `item/completed` 各采一次,按 id 幂等)。 + ToolCall(crate::DirectToolCall), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -2653,6 +2655,12 @@ impl CodexAppServerConnection { let _turn_guard = self.inner.turn_gate.lock().await; let mut request = request; let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path); + // 工具调用卡片的 turnId 用 AGC 客户端回合 id(与实时事件、落盘条目同一口径), + // 不用 Codex app-server 自己的 turnId——前端要按它把卡片挂回对应的那一轮。 + let direct_tool_call_turn_id: Option = direct_client_turn_id + .map(str::trim) + .filter(|turn_id| !turn_id.is_empty()) + .map(str::to_string); if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { let current_prompt = direct_codex_current_user_prompt(&request).trim(); if current_prompt.is_empty() { @@ -2908,6 +2916,24 @@ impl CodexAppServerConnection { completed, ¶ms, ); + // 工具调用卡片:item/started 与 item/completed 各采一次, + // 由下游按 id 幂等 upsert 成同一条。采集失败(拿不到 id / + // 非工具类 item)就静默跳过,不影响这一轮的其它投影。 + if let Some(turn_id) = direct_tool_call_turn_id.as_deref() { + if let Some(tool_call) = direct_tool_call_from_item( + history_root, + item, + turn_id, + completed, + direct_tool_call_now_ms(), + ) { + if let Some(observer) = direct_observer.as_deref_mut() { + observer(DirectCodexTurnObservation::ToolCall( + tool_call, + )); + } + } + } if completed { if let Some(audit) = audit.as_mut() { audit.observe_item(¶ms); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 0766ab0d3..8c94be8b2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -4078,7 +4078,7 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( direct_creation_type_system_context(creation_type)?; emit_direct_game_creator_progress(root, "request.accepted", "已发送消息,正在等待陶泥儿回复"); if let Some(emitter) = turn_emitter { - emitter.emit("accepted", Some("request-accepted"), None); + emitter.emit("accepted", Some("request-accepted"), None, None); } match run_direct_game_creator_turn_inner(root, prompt, creation_type, turn_emitter, audit).await { @@ -4086,13 +4086,49 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( Err(failure) => { let error = record_direct_codex_turn_failure(root, failure); if let Some(emitter) = turn_emitter { - emitter.emit("failed", Some("none"), None); + emitter.emit("failed", Some("none"), None, None); } Err(error) } } } +/// 本回合累积的工具调用条目(观察者写、回合末读)。 +type DirectToolCallCollector = std::sync::Arc>>; + +fn lock_direct_tool_call_collector( + collector: &DirectToolCallCollector, +) -> std::sync::MutexGuard<'_, Vec> { + collector + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// 单条工具调用落盘:走阻塞线程池(写文件要拿项目锁,不能在 async 运行时上直接跑)。 +/// 失败只返回错误交给调用方忽略,不打断回合。 +fn spawn_persist_direct_tool_call(root: &Path, call: &DirectToolCall) { + let root = root.to_path_buf(); + let call = call.clone(); + tauri::async_runtime::spawn_blocking(move || persist_direct_tool_call_at(&root, &call)); +} + +/// 回合结束整批落盘;失败时退回逐条 upsert,尽量把能写的写进去。 +fn persist_collected_direct_tool_calls(root: &Path, collector: &DirectToolCallCollector) { + let calls = { + let collected = lock_direct_tool_call_collector(collector); + collected.clone() + }; + if calls.is_empty() { + return; + } + if persist_direct_tool_calls_at(root, &calls).is_ok() { + return; + } + for call in &calls { + let _ = persist_direct_tool_call_at(root, call); + } +} + async fn run_direct_game_creator_turn_inner( root: &Path, prompt: &str, @@ -4102,8 +4138,11 @@ async fn run_direct_game_creator_turn_inner( ) -> Result { emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息"); if let Some(emitter) = turn_emitter { - emitter.emit("running", Some("preparing"), None); + emitter.emit("running", Some("preparing"), None, None); } + // 本回合累积的工具调用条目:观察者增量采集,回合结束时整批落盘(幂等 upsert)。 + // 实时下发与落盘共用同一份数据,避免两处各采集一次产生口径差。 + let tool_calls: DirectToolCallCollector = Arc::new(Mutex::new(Vec::new())); let stream_enabled = load_game_creator_app_config() .map(|config| config.llm.stream) .map_err(|error| { @@ -4117,6 +4156,9 @@ async fn run_direct_game_creator_turn_inner( let reply = if let Some(emitter) = turn_emitter { let client_turn_id = emitter.turn_id().to_string(); let emitter = emitter.clone(); + let turn_root = root.to_path_buf(); + let turn_tool_calls = Arc::clone(&tool_calls); + let mut emitted_tool_call_ids: BTreeSet = BTreeSet::new(); let mut observer = move |observation: DirectCodexTurnObservation| { let status = direct_codex_observation_status(&observation, stream_enabled); match observation { @@ -4126,7 +4168,7 @@ async fn run_direct_game_creator_turn_inner( if visible_text.is_none() { return; } - emitter.emit(status, None, visible_text); + emitter.emit(status, None, visible_text, None); } DirectCodexTurnObservation::IntermediateText(intermediate_text) => { let visible_text = if stream_enabled @@ -4137,11 +4179,33 @@ async fn run_direct_game_creator_turn_inner( None }; if let Some(visible_text) = visible_text { - emitter.emit(status, None, Some(visible_text)); + emitter.emit(status, None, Some(visible_text), None); } } DirectCodexTurnObservation::Activity(activity) => { - emitter.emit(status, Some(activity), None); + emitter.emit(status, Some(activity), None, None); + } + DirectCodexTurnObservation::ToolCall(tool_call) => { + // 每条工具调用只在采集到的那一个事件里下发一次(id 与 Codex item 一一对应), + // 这样既满足"集合变化才带",也避免每个 heartbeat 重发全量。 + if !emitted_tool_call_ids.insert(tool_call.id.clone()) { + return; + } + let previous = { + let mut collected = lock_direct_tool_call_collector(&turn_tool_calls); + let previous = collected + .iter() + .find(|existing| existing.id == tool_call.id) + .cloned(); + collected.retain(|existing| existing.id != tool_call.id); + collected.push(tool_call.clone()); + previous + }; + emitter.emit(status, None, None, Some(vec![tool_call])); + // `started` 一落盘卡片就能在刷新后立刻出现;`completed` 覆盖同一行。 + if let Some(previous) = previous { + spawn_persist_direct_tool_call(&turn_root, &previous); + } } } }; @@ -4166,6 +4230,9 @@ async fn run_direct_game_creator_turn_inner( .await } .map_err(|error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error))?; + // 回合结束:把本回合累积的工具调用整批落盘(一次锁、一次重写,幂等 upsert)。 + // 落盘失败只记日志,不能把已经成功的回合判成失败——工具调用卡片是展示数据。 + persist_collected_direct_tool_calls(root, &tool_calls); let visible_reply = project_direct_codex_visible_text(&reply).ok_or_else(|| { DirectCodexTurnFailure::new( DirectCodexFailureStage::CodeGeneration, @@ -4177,6 +4244,7 @@ async fn run_direct_game_creator_turn_inner( "finalizing", Some("response-finalization"), Some(visible_reply.clone()), + None, ); } if direct_codex_output_fingerprint(root) != previous_output_fingerprint { @@ -4190,6 +4258,7 @@ async fn run_direct_game_creator_turn_inner( "finalizing", Some("file-write"), Some(visible_reply.clone()), + None, ); } sync_direct_codex_project_file_projection_at(root, Some(&previous_output_fingerprint)) @@ -4538,7 +4607,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex( } }; audit.finish(true); - turn_emitter.emit("completed", Some("none"), Some(reply.clone())); + turn_emitter.emit("completed", Some("none"), Some(reply.clone()), None); Ok(reply) } @@ -4819,6 +4888,7 @@ mod tests { status: "streaming".to_string(), activity: None, accumulated_text: Some("partial".to_string()), + tool_calls: None, updated_at: 42, }) .expect("serialize direct update"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs new file mode 100644 index 000000000..cd83485d2 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs @@ -0,0 +1,753 @@ +//! GameAgent 对话「工具调用卡片」的采集、持久化与回读。 +//! +//! 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`: +//! Codex app-server 的 `item/started` / `item/completed` 里带着完整的命令 / 文件变更 +//! 信息,这里把它们投影成结构化的 `DirectToolCall`,落到**独立文件** +//! `/.agent/conversations/tool-calls.jsonl`。 +//! +//! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的 +//! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。 + +use crate::agent::redact_secret_tokens; +use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file}; +use crate::project::{enforce_project_permission_policy, project_append_lock_for}; +use crate::redact_absolute_path_tokens; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +/// 行信封类型,与既有历史文件同构(`{"type": …, "payload": {…}}`)。 +pub(crate) const DIRECT_TOOL_CALL_RECORD_TYPE: &str = "tool_call_item"; +/// 条目 schema 版本。 +pub(crate) const DIRECT_TOOL_CALL_SCHEMA_VERSION: &str = "agc-tool-call.v1"; +/// 回读上限:只保留最近这么多条(按 `updatedAt` / `startedAt` 取最新)。 +pub(crate) const DIRECT_TOOL_CALL_LIMIT: usize = 200; +/// `detail.command` / `detail.output` 的字符上限。 +const DIRECT_TOOL_CALL_DETAIL_MAX_CHARS: usize = 4000; +/// 折叠态摘要(`summary`)的字符上限。 +const DIRECT_TOOL_CALL_SUMMARY_MAX_CHARS: usize = 120; +/// 单条变更路径的字符上限。 +const DIRECT_TOOL_CALL_PATH_MAX_CHARS: usize = 300; + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectToolCallChange { + pub(crate) path: String, + /// `add` | `update` | `delete` + pub(crate) kind: String, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectToolCallDetail { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) command: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) output: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) changes: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectToolCall { + pub(crate) schema_version: String, + /// Codex item 的 id。同一 item 的 started/completed 共用它,落盘时按它幂等 upsert。 + pub(crate) id: String, + pub(crate) turn_id: String, + /// `command` | `file_change` | `mcp_tool` | `web_search` | `context_compaction` | `other` + pub(crate) kind: String, + /// 折叠态标题,按 kind 固定(`command` → `执行命令`、`file_change` → `编辑 N 个文件`)。 + pub(crate) title: String, + /// 折叠态标题后面的短摘要。 + pub(crate) summary: String, + /// `running` | `completed` | `failed` + pub(crate) status: String, + pub(crate) detail: DirectToolCallDetail, + pub(crate) started_at: u64, + pub(crate) updated_at: u64, +} + +impl DirectToolCall { + fn timestamp(&self) -> u64 { + if self.updated_at > 0 { + self.updated_at + } else { + self.started_at + } + } +} + +fn tool_calls_path(root: &Path) -> PathBuf { + root.join(".agent/conversations/tool-calls.jsonl") +} + +/// 脱敏:项目内相对路径保留,其余按「先抹绝对路径、再抹密钥」处理。 +/// +/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 TOKEN 换成占位符,但绝对路径里的 +/// 用户名目录仍然会留下;这里先归一化路径 token,再处理密钥。 +fn sanitize_detail_text(root: &Path, value: &str) -> String { + let without_absolute = redact_absolute_path_tokens(value); + let without_secret = redact_secret_tokens(&without_absolute); + let root = root.to_string_lossy(); + if root.is_empty() { + return without_secret; + } + without_secret.replace(root.as_ref(), "") +} + +/// 按字符数截断(不切坏 UTF-8),并在真正截断时补省略号。 +fn bounded_chars(value: &str, max_chars: usize) -> String { + if value.chars().count() <= max_chars { + return value.to_string(); + } + let mut truncated = value.chars().take(max_chars).collect::(); + truncated.push('…'); + truncated +} + +/// 取值的首行并按字符数截断(命令的摘要习惯)。 +fn first_line_bounded(value: &str, max_chars: usize) -> String { + let first_line = value.lines().next().unwrap_or_default().trim(); + bounded_chars(first_line, max_chars) +} + +fn optional_bounded_line(value: Option<&str>, max_chars: usize) -> Option { + let value = value?.trim(); + (!value.is_empty()).then(|| bounded_chars(value, max_chars)) +} + +fn tool_call_kind(item_type: &str) -> Option<&'static str> { + match item_type { + "commandExecution" => Some("command"), + "fileChange" => Some("file_change"), + "mcpToolCall" => Some("mcp_tool"), + "webSearch" => Some("web_search"), + "contextCompaction" => Some("context_compaction"), + // todoList / reasoning / plan / agentMessage 之类不属于「工具调用」,不落卡片。 + "todoList" | "reasoning" | "plan" | "agentMessage" | "message" | "userMessage" => None, + _ => Some("other"), + } +} + +fn direct_tool_call_changes(item: &Value) -> Vec { + item.get("changes") + .and_then(Value::as_array) + .map(|changes| { + changes + .iter() + .filter_map(|change| { + let path = change + .get("path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|path| !path.is_empty())?; + Some(DirectToolCallChange { + path: bounded_chars(path, DIRECT_TOOL_CALL_PATH_MAX_CHARS), + kind: change + .get("kind") + .and_then(Value::as_str) + .unwrap_or("update") + .to_string(), + }) + }) + .collect::>() + }) + .unwrap_or_default() +} + +fn direct_tool_call_title(kind: &str, changes: &[DirectToolCallChange]) -> String { + match kind { + "command" => "执行命令".to_string(), + "file_change" => { + let mut paths = changes + .iter() + .map(|change| change.path.as_str()) + .collect::>(); + paths.sort_unstable(); + paths.dedup(); + if paths.is_empty() { + "编辑文件".to_string() + } else { + format!("编辑 {} 个文件", paths.len()) + } + } + "mcp_tool" => "调用工具".to_string(), + "web_search" => "搜索资料".to_string(), + "context_compaction" => "整理上下文".to_string(), + _ => "调用工具".to_string(), + } +} + +fn direct_tool_call_status(item: &Value, completed: bool) -> &'static str { + // Codex 的退出码约定:非 0 即失败;缺席时按"已完成"处理。 + if let Some(exit_code) = item.get("exitCode").and_then(Value::as_i64) { + return if exit_code == 0 { + "completed" + } else { + "failed" + }; + } + if let Some(success) = item.get("success").and_then(Value::as_bool) { + return if success { "completed" } else { "failed" }; + } + if completed { + "completed" + } else { + "running" + } +} + +/// 把一条 Codex item 投影成工具调用条目。非工具类 item 返回 `None`。 +/// +/// `started_at` / `updated_at`:item 自己带的 `startedAtMs` / `completedAtMs` 优先, +/// 两处都没有时才用调用方给的回退值(本机毫秒时间戳)。 +pub(crate) fn direct_tool_call_from_item( + root: &Path, + item: &Value, + turn_id: &str, + completed: bool, + now_ms: u64, +) -> Option { + let item_type = item.get("type").and_then(Value::as_str)?; + let kind = tool_call_kind(item_type)?; + let id = item + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|id| !id.is_empty())?; + + let item_started_at = item + .get("startedAtMs") + .and_then(Value::as_u64) + .unwrap_or_default(); + let item_completed_at = item + .get("completedAtMs") + .and_then(Value::as_u64) + .unwrap_or_default(); + let started_at = if item_started_at > 0 { + item_started_at + } else if item_completed_at > 0 { + item_completed_at + } else { + now_ms + }; + let updated_at = if item_completed_at > 0 { + item_completed_at + } else { + started_at.max(now_ms) + }; + + let command = item + .get("command") + .and_then(Value::as_str) + .map(str::trim) + .filter(|command| !command.is_empty()) + .map(|command| sanitize_detail_text(root, command)) + .map(|command| bounded_chars(&command, DIRECT_TOOL_CALL_DETAIL_MAX_CHARS)); + let output = item + .get("aggregatedOutput") + .or_else(|| item.get("output")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|output| !output.is_empty()) + .map(|output| sanitize_detail_text(root, output)) + .map(|output| bounded_chars(&output, DIRECT_TOOL_CALL_DETAIL_MAX_CHARS)); + // `fileChange` 的路径先脱敏成"项目内相对路径":绝对路径会被抹成 ``, + // 相对路径原样保留(契约要求 detail.changes[].path 用项目相对路径)。 + let changes = direct_tool_call_changes(item) + .into_iter() + .map(|change| DirectToolCallChange { + path: sanitize_detail_text(root, &change.path), + kind: change.kind, + }) + .collect::>(); + + let summary_source = command + .as_deref() + .or_else(|| changes.first().map(|change| change.path.as_str())) + .or_else(|| { + item.get("tool") + .and_then(Value::as_str) + .map(str::trim) + .filter(|tool| !tool.is_empty()) + }) + .unwrap_or_default(); + let summary = first_line_bounded(summary_source, DIRECT_TOOL_CALL_SUMMARY_MAX_CHARS); + + Some(DirectToolCall { + schema_version: DIRECT_TOOL_CALL_SCHEMA_VERSION.to_string(), + id: id.to_string(), + turn_id: turn_id.trim().to_string(), + kind: kind.to_string(), + title: direct_tool_call_title(kind, &changes), + summary, + status: direct_tool_call_status(item, completed).to_string(), + detail: DirectToolCallDetail { + command, + output, + changes, + }, + started_at, + updated_at, + }) +} + +fn record_line(call: &DirectToolCall) -> Result { + serde_json::to_string(&serde_json::json!({ + "type": DIRECT_TOOL_CALL_RECORD_TYPE, + "payload": call, + })) + .map_err(|error| format!("序列化工具调用条目失败:{error}")) +} + +/// 解析一行信封;坏行 / 非本文件条目都返回 `None`(尽力而为的展示数据,不整体失败)。 +fn tool_call_from_line(line: &str) -> Option { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + let parsed: Value = serde_json::from_str(trimmed).ok()?; + if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_TOOL_CALL_RECORD_TYPE) { + return None; + } + let payload = parsed.get("payload")?; + let mut call: DirectToolCall = serde_json::from_value(payload.clone()).ok()?; + if call.id.trim().is_empty() { + return None; + } + if call.schema_version.trim().is_empty() { + call.schema_version = DIRECT_TOOL_CALL_SCHEMA_VERSION.to_string(); + } + Some(call) +} + +fn read_tool_call_lines(path: &Path) -> Vec { + let Ok(file) = File::open(path) else { + return Vec::new(); + }; + let mut calls = Vec::new(); + for line in BufReader::new(file).lines() { + let Ok(line) = line else { + break; + }; + if let Some(call) = tool_call_from_line(&line) { + calls.push(call); + } + } + calls +} + +/// 按 id 归并(后写覆盖先写),再按时间正序裁剪到最近 `DIRECT_TOOL_CALL_LIMIT` 条。 +fn normalize_tool_calls(calls: Vec) -> Vec { + let mut by_id: BTreeMap = BTreeMap::new(); + for call in calls { + by_id.insert(call.id.clone(), call); + } + let mut normalized = by_id.into_values().collect::>(); + normalized.sort_by(|left, right| { + left.timestamp() + .cmp(&right.timestamp()) + .then_with(|| left.id.cmp(&right.id)) + }); + if normalized.len() > DIRECT_TOOL_CALL_LIMIT { + normalized.drain(..normalized.len() - DIRECT_TOOL_CALL_LIMIT); + } + normalized +} + +/// 回读:文件缺失返回空数组;单行损坏跳过;按时间正序,最多最近 200 条。 +pub(crate) fn read_direct_tool_calls_at(root: &Path) -> Result, String> { + let path = tool_calls_path(root); + if !prepare_game_creator_private_path_for_read(&path, false, "工具调用历史")? { + return Ok(Vec::new()); + } + Ok(normalize_tool_calls(read_tool_call_lines(&path))) +} + +/// 同一 id 的 `startedAt` 取最早的非零值:`item/completed` 事件不一定带 +/// `startedAtMs`,不能让 completed 覆盖掉 started 记下的起点(卡片时间序依赖它)。 +fn preserve_started_at(incoming: &mut DirectToolCall, existing: Option<&DirectToolCall>) { + let Some(existing) = existing else { + return; + }; + if existing.started_at > 0 + && (incoming.started_at == 0 || existing.started_at < incoming.started_at) + { + incoming.started_at = existing.started_at; + } +} + +/// 幂等 upsert:同 id 只保留一行,`completed` 覆盖 `started`。 +/// +/// 单次尝试的顺序是「取项目锁 + append 锁 → 锁内读 → 整文件原子替换」。 +/// 工具调用是**追加 + 就地更新**混用的数据,没有纯追加的 JSONL 语义,所以只能整文件重写; +/// 文件规模由 200 条上限与 4000 字符截断兜住。 +fn upsert_direct_tool_call_once(root: &Path, call: &DirectToolCall) -> Result<(), String> { + let path = tool_calls_path(root); + let _project_lock = crate::acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "conversation.write", + )?; + let lock = project_append_lock_for(&path)?; + let _append_guard = lock.lock("工具调用历史写入")?; + let mut calls = read_tool_call_lines(&path); + let existing = calls + .iter() + .find(|existing| existing.id == call.id) + .cloned(); + let mut incoming = call.clone(); + preserve_started_at(&mut incoming, existing.as_ref()); + calls.retain(|existing| existing.id != call.id); + calls.push(incoming); + let normalized = normalize_tool_calls(calls); + let mut body = String::new(); + for existing in &normalized { + body.push_str(&record_line(existing)?); + body.push('\n'); + } + write_game_creator_private_file(&path, body.as_bytes(), "工具调用历史") +} + +/// 落盘入口。失败不抛给调用方以外的地方——工具调用是展示数据,不能因为它把整轮判失败。 +pub(crate) fn persist_direct_tool_call_at( + root: &Path, + call: &DirectToolCall, +) -> Result<(), String> { + enforce_project_permission_policy(root, "conversation.write")?; + upsert_direct_tool_call_once(root, call) +} + +/// 一轮结束时把本回合累积的工具调用整批落盘(一次锁、一次重写)。 +pub(crate) fn persist_direct_tool_calls_at( + root: &Path, + calls: &[DirectToolCall], +) -> Result<(), String> { + if calls.is_empty() { + return Ok(()); + } + enforce_project_permission_policy(root, "conversation.write")?; + let path = tool_calls_path(root); + let _project_lock = crate::acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "conversation.write", + )?; + let lock = project_append_lock_for(&path)?; + let _append_guard = lock.lock("工具调用历史写入")?; + let mut existing = read_tool_call_lines(&path); + let mut incoming = calls.to_vec(); + for call in incoming.iter_mut() { + let previous = existing + .iter() + .find(|existing| existing.id == call.id) + .cloned(); + preserve_started_at(call, previous.as_ref()); + } + let ids = incoming + .iter() + .map(|call| call.id.as_str()) + .collect::>(); + existing.retain(|call| !ids.contains(&call.id.as_str())); + existing.extend(incoming); + let normalized = normalize_tool_calls(existing); + let mut body = String::new(); + for call in &normalized { + body.push_str(&record_line(call)?); + body.push('\n'); + } + write_game_creator_private_file(&path, body.as_bytes(), "工具调用历史") +} + +/// 本机毫秒时间戳(item 没带时间时用)。 +pub(crate) fn direct_tool_call_now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u64::MAX as u128) as u64 +} + +#[cfg(test)] +mod tests { + use super::{ + direct_tool_call_from_item, direct_tool_call_now_ms, persist_direct_tool_call_at, + persist_direct_tool_calls_at, read_direct_tool_calls_at, tool_calls_path, + DIRECT_TOOL_CALL_LIMIT, + }; + use serde_json::json; + + fn init_tool_call_project(name: &str) -> tempfile::TempDir { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), name, "工具调用卡片测试") + .expect("init project"); + root + } + + fn command_item(id: &str, command: &str) -> serde_json::Value { + json!({ + "id": id, + "type": "commandExecution", + "command": command, + "status": "inProgress", + "startedAtMs": 1000, + }) + } + + /// 判据:同一 item 的 started 与 completed 只落一行,completed 覆盖 status。 + #[test] + fn tool_call_upsert_is_idempotent_per_item_id() { + let root = init_tool_call_project("tool-call-upsert"); + let started = direct_tool_call_from_item( + root.path(), + &command_item("item-1", "npm run build"), + "turn-1", + false, + 1000, + ) + .expect("started tool call"); + persist_direct_tool_call_at(root.path(), &started).expect("persist started"); + + let completed = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-1", + "type": "commandExecution", + "command": "npm run build", + "exitCode": 0, + "completedAtMs": 2000, + }), + "turn-1", + true, + 2000, + ) + .expect("completed tool call"); + persist_direct_tool_call_at(root.path(), &completed).expect("persist completed"); + + let calls = read_direct_tool_calls_at(root.path()).expect("read tool calls"); + assert_eq!(calls.len(), 1, "同一 id 只能有一行"); + assert_eq!(calls[0].status, "completed"); + assert_eq!(calls[0].started_at, 1000, "startedAt 不被 completed 覆盖"); + assert_eq!(calls[0].updated_at, 2000); + } + + /// 判据:非 0 退出码判 failed。 + #[test] + fn tool_call_marks_failed_on_non_zero_exit_code() { + let root = init_tool_call_project("tool-call-failed"); + let call = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-failed", + "type": "commandExecution", + "command": "npm test", + "exitCode": 1, + "completedAtMs": 3000, + }), + "turn-1", + true, + 3000, + ) + .expect("failed tool call"); + assert_eq!(call.status, "failed"); + } + + /// 判据:command / output 截断到 4000 字符,summary 截断到 120 字符。 + #[test] + fn tool_call_truncates_command_output_and_summary() { + let root = init_tool_call_project("tool-call-truncate"); + let long_command = "a".repeat(5000); + let long_output = "b".repeat(5000); + let call = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-long", + "type": "commandExecution", + "command": long_command, + "aggregatedOutput": long_output, + "startedAtMs": 1000, + }), + "turn-1", + false, + 1000, + ) + .expect("long tool call"); + let command = call.detail.command.expect("bounded command"); + let output = call.detail.output.expect("bounded output"); + assert_eq!(command.chars().count(), 4001, "4000 字符 + 省略号"); + assert_eq!(output.chars().count(), 4001, "4000 字符 + 省略号"); + assert_eq!(call.summary.chars().count(), 121, "120 字符 + 省略号"); + } + + /// 判据:脱敏后不出现 API Key / Token / 绝对用户目录。 + #[test] + fn tool_call_redacts_secrets_and_absolute_paths() { + let root = init_tool_call_project("tool-call-redact"); + let call = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-secret", + "type": "commandExecution", + "command": "curl -H 'Authorization: Bearer sk-abcdefghijklmnop' https://example.com", + "aggregatedOutput": "OPENAI_API_KEY=tnr_sk_abcdefghijklmnop /home/someuser/private/notes.txt", + "startedAtMs": 1000, + }), + "turn-1", + false, + 1000, + ) + .expect("redacted tool call"); + let command = call.detail.command.as_deref().expect("command"); + let output = call.detail.output.as_deref().expect("output"); + assert!( + !command.contains("sk-abcdefghijklmnop"), + "命令里的 API Key 必须脱敏:{command}" + ); + assert!( + !output.contains("tnr_sk_abcdefghijklmnop"), + "输出里的 Token 必须脱敏:{output}" + ); + assert!( + !output.contains("/home/someuser"), + "输出里的绝对用户目录必须脱敏:{output}" + ); + + persist_direct_tool_call_at(root.path(), &call).expect("persist redacted call"); + let raw = std::fs::read_to_string(tool_calls_path(root.path())).expect("read raw file"); + assert!( + !raw.contains("sk-abcdefghijklmnop"), + "落盘文件里不得出现 API Key" + ); + assert!( + !raw.contains("/home/someuser"), + "落盘文件里不得出现绝对用户目录" + ); + } + + /// 判据:单行损坏只跳过该行,不整体失败;缺文件返回空数组。 + #[test] + fn tool_call_read_skips_corrupted_lines() { + let root = init_tool_call_project("tool-call-corrupt"); + let path = tool_calls_path(root.path()); + std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); + let good = serde_json::to_string(&json!({ + "type": "tool_call_item", + "payload": { + "schemaVersion": "agc-tool-call.v1", + "id": "item-good", + "turnId": "turn-1", + "kind": "command", + "title": "执行命令", + "summary": "npm run build", + "status": "completed", + "detail": {"command": "npm run build"}, + "startedAt": 1, + "updatedAt": 2 + } + })) + .expect("serialize good row"); + std::fs::write( + &path, + format!("{good}\n{{ not json\n{{\"type\":\"other\",\"payload\":{{}}}}\n{good}\n"), + ) + .expect("write fixture"); + + let missing = tempfile::tempdir().expect("missing dir"); + assert!( + read_direct_tool_calls_at(missing.path()) + .expect("missing file is empty") + .is_empty(), + "历史文件缺失必须返回空数组" + ); + + let calls = read_direct_tool_calls_at(root.path()).expect("read with corrupted lines"); + assert_eq!(calls.len(), 1, "坏行被跳过,同 id 归并成一条"); + assert_eq!(calls[0].id, "item-good"); + } + + /// 判据:回读按时间正序,且超出上限时保留最新。 + #[test] + fn tool_call_read_is_ordered_and_capped() { + let root = init_tool_call_project("tool-call-cap"); + let total = DIRECT_TOOL_CALL_LIMIT + 5; + let calls = (0..total) + .map(|index| { + direct_tool_call_from_item( + root.path(), + &json!({ + "id": format!("item-{index:04}"), + "type": "commandExecution", + "command": format!("run {index}"), + "startedAtMs": 1000 + index as u64, + }), + "turn-1", + false, + 1000 + index as u64, + ) + .expect("tool call") + }) + .collect::>(); + persist_direct_tool_calls_at(root.path(), &calls).expect("persist batch"); + + let read = read_direct_tool_calls_at(root.path()).expect("read capped"); + assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "超出上限保留最新 N 条"); + assert_eq!( + read.first().expect("first").id, + format!("item-{:04}", total - DIRECT_TOOL_CALL_LIMIT), + "最早被裁掉的是最旧的条目" + ); + assert!( + read.windows(2) + .all(|pair| pair[0].timestamp() <= pair[1].timestamp()), + "回读必须按时间正序" + ); + } + + /// 判据:fileChange 的标题按去重后的变更数量,摘要取首个变更路径。 + #[test] + fn tool_call_file_change_title_counts_unique_paths() { + let root = init_tool_call_project("tool-call-file-change"); + let call = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-files", + "type": "fileChange", + "changes": [ + {"path": "game/src/a.ts", "kind": "update"}, + {"path": "game/src/a.ts", "kind": "update"}, + {"path": "game/src/b.ts", "kind": "add"} + ], + "startedAtMs": 1000, + }), + "turn-1", + false, + 1000, + ) + .expect("file change tool call"); + assert_eq!(call.kind, "file_change"); + assert_eq!(call.title, "编辑 2 个文件"); + assert_eq!(call.summary, "game/src/a.ts"); + assert_eq!(call.detail.changes.len(), 3); + } + + /// 判据:非工具类 item 不产卡片。 + #[test] + fn tool_call_skips_non_tool_items() { + let root = init_tool_call_project("tool-call-skip"); + for item_type in ["reasoning", "agentMessage", "todoList", "plan"] { + assert!( + direct_tool_call_from_item( + root.path(), + &json!({"id": "item-x", "type": item_type}), + "turn-1", + false, + direct_tool_call_now_ms(), + ) + .is_none(), + "{item_type} 不应产出工具调用卡片" + ); + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index 2a1b77488..2c6867fb3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -49,6 +49,7 @@ impl DirectGameCreatorTurnUpdateEmitter { status: &'static str, activity: Option<&'static str>, accumulated_text: Option, + tool_calls: Option>, ) { let status_is_allowed = matches!( status, @@ -92,6 +93,7 @@ impl DirectGameCreatorTurnUpdateEmitter { status: status.to_string(), activity: activity.map(str::to_string), accumulated_text, + tool_calls, updated_at, }, ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index fbfd893e2..4e28e1de7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5237,6 +5237,19 @@ pub(crate) async fn read_direct_project_conversation( .map_err(|error| format!("读取 DirectProject 历史后台任务失败:{error}"))? } +#[tauri::command] +pub(crate) async fn read_direct_tool_calls( + project_path: String, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + read_direct_tool_calls_at(root) + }) + .await + .map_err(|error| format!("读取工具调用历史后台任务失败:{error}"))? +} + #[tauri::command] pub(crate) fn append_local_conversation_message( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 3813a0eb6..af5c97c77 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -973,6 +973,10 @@ struct GameCreatorDirectTurnUpdateEvent { status: String, activity: Option, accumulated_text: Option, + /// 本回合内发生变化的结构化工具调用集合(只有变化时才带,老事件没有这个字段)。 + /// `skip_serializing_if`:字段缺席时前端拿到 `undefined`,行为与改造前一致。 + #[serde(skip_serializing_if = "Option::is_none")] + tool_calls: Option>, updated_at: u64, } @@ -2724,6 +2728,7 @@ fn main() { archive_game_creator_agent_session, read_local_conversation, read_direct_project_conversation, + read_direct_tool_calls, append_local_conversation_message, append_direct_project_conversation_message, build_local_project_index,