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:
@@ -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,
|
||||
¶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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user