GameAgent 工具调用:采集、独立文件持久化、事件字段与回读命令

- 新增 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
This commit is contained in:
2026-09-14 15:00:32 +08:00
parent 7adabea656
commit 6ba86f5061
7 changed files with 878 additions and 7 deletions
@@ -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::*;
@@ -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<String> = 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,
&params,
);
// 工具调用卡片: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(&params);
@@ -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<Mutex<Vec<DirectToolCall>>>;
fn lock_direct_tool_call_collector(
collector: &DirectToolCallCollector,
) -> std::sync::MutexGuard<'_, Vec<DirectToolCall>> {
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<String, DirectCodexTurnFailure> {
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<String> = 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");
File diff suppressed because it is too large Load Diff
@@ -49,6 +49,7 @@ impl DirectGameCreatorTurnUpdateEmitter {
status: &'static str,
activity: Option<&'static str>,
accumulated_text: Option<String>,
tool_calls: Option<Vec<crate::DirectToolCall>>,
) {
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,
},
);
@@ -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<Vec<DirectToolCall>, 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,
@@ -973,6 +973,10 @@ struct GameCreatorDirectTurnUpdateEvent {
status: String,
activity: Option<String>,
accumulated_text: Option<String>,
/// 本回合内发生变化的结构化工具调用集合(只有变化时才带,老事件没有这个字段)。
/// `skip_serializing_if`:字段缺席时前端拿到 `undefined`,行为与改造前一致。
#[serde(skip_serializing_if = "Option::is_none")]
tool_calls: Option<Vec<crate::DirectToolCall>>,
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,