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 3da206001..f579a08f0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -136,6 +136,33 @@ pub(crate) async fn chat_with_game_creator_role_agent_at( Ok(GameCreatorChatAgentReply { reply_text }) } +pub(crate) async fn chat_with_game_creator_role_agent_runtime_at( + root: &Path, + agent_id: &str, + prompt: &str, + run_id: &str, +) -> Result<(GameCreatorChatAgentReply, AgentRuntimeState), String> { + let mut runtime = start_game_creator_agent_runtime_turn_at(root, agent_id, prompt, run_id)?; + runtime = advance_game_creator_agent_runtime_turn_at( + root, + runtime, + "llm", + "请求 Agent LLM", + "已读取项目上下文,正在让 Agent 独立推理。", + )?; + match chat_with_game_creator_role_agent_at(root, agent_id, prompt).await { + Ok(reply) => { + let runtime = + finish_game_creator_agent_runtime_turn_at(root, runtime, &reply.reply_text)?; + Ok((reply, runtime)) + } + Err(error) => { + let _ = fail_game_creator_agent_runtime_turn_at(root, runtime, &error); + Err(error) + } + } +} + pub(crate) async fn chat_with_game_creator_role_agent_stream_at( root: &Path, agent_id: &str, @@ -160,6 +187,488 @@ where Ok(GameCreatorChatAgentReply { reply_text }) } +pub(crate) fn read_game_creator_agent_runtime_at( + root: &Path, + agent_id: &str, +) -> Result { + let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; + validate_project_root(root)?; + let session_path = game_creator_agent_runtime_session_path(root, agent_id); + let event_path = game_creator_agent_runtime_event_path(root, agent_id); + let mut state = match fs::read_to_string(&session_path) { + Ok(content) => serde_json::from_str::(&content).map_err(|error| { + format!( + "解析 Agent Runtime 状态失败:{}: {error}", + session_path.display() + ) + })?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + default_game_creator_agent_runtime_state(agent_id, "") + } + Err(error) => { + return Err(format!( + "读取 Agent Runtime 状态失败:{}: {error}", + session_path.display() + )); + } + }; + normalize_game_creator_agent_runtime_state(&mut state, agent_id); + let recent_events = read_recent_game_creator_agent_runtime_events(&event_path)?; + Ok(AgentRuntimeResult { + state, + session_path: session_path.to_string_lossy().into_owned(), + event_path: event_path.to_string_lossy().into_owned(), + recent_events, + }) +} + +pub(crate) fn start_game_creator_agent_runtime_turn_at( + root: &Path, + agent_id: &str, + prompt: &str, + run_id: &str, +) -> Result { + start_game_creator_agent_runtime_task_at( + root, + agent_id, + prompt, + run_id, + "agent-chat", + "读取项目上下文", + vec![ + "读取项目记忆、黑板、本 Agent 私有记忆和最近对话".to_string(), + "按当前 Agent 职责独立推理本轮建议".to_string(), + "流式回复开发者并把本轮 runtime 状态落盘".to_string(), + ], + ) +} + +pub(crate) fn start_game_creator_agent_runtime_task_at( + root: &Path, + agent_id: &str, + task: &str, + run_id: &str, + source: &str, + current_action: &str, + plan: Vec, +) -> Result { + let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?; + validate_project_root(root)?; + let run_id = normalize_game_creator_agent_runtime_run_id(agent_id, run_id); + let task = task.trim(); + if task.is_empty() { + return Err("Agent Runtime 任务不能为空".to_string()); + } + let runtime_task = sanitize_agent_runtime_text(task, 180); + let mut state = default_game_creator_agent_runtime_state(agent_id, &run_id); + state.source = source.trim().to_string(); + state.status = "running".to_string(); + state.phase = "planning".to_string(); + state.current_task = runtime_task.clone(); + state.current_action = current_action.trim().to_string(); + state.plan = plan; + state.observations = vec!["已创建本轮 Agent Runtime run。".to_string()]; + state.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_state(root, &state)?; + append_game_creator_agent_runtime_event( + root, + &state, + "turn.started", + "running", + "planning", + "Agent Runtime 开始处理本轮输入。", + Some(&runtime_task), + )?; + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.turn", + "agentId": state.agent_id, + "taskId": state.task_id, + "sessionId": state.session_id, + "runId": state.run_id, + "source": state.source, + "status": state.status, + "phase": state.phase, + "task": state.current_task, + }), + )?; + Ok(state) +} + +pub(crate) fn advance_game_creator_agent_runtime_turn_at( + root: &Path, + mut state: AgentRuntimeState, + phase: &str, + action: &str, + observation: &str, +) -> Result { + state.phase = phase.trim().to_string(); + state.current_action = action.trim().to_string(); + if !observation.trim().is_empty() { + state.observations.push(observation.trim().to_string()); + } + state.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_state(root, &state)?; + append_game_creator_agent_runtime_event( + root, + &state, + "turn.progress", + state.status.as_str(), + state.phase.as_str(), + state.current_action.as_str(), + Some(observation), + )?; + Ok(state) +} + +pub(crate) fn finish_game_creator_agent_runtime_turn_at( + root: &Path, + mut state: AgentRuntimeState, + response: &str, +) -> Result { + state.status = "idle".to_string(); + state.phase = "completed".to_string(); + state.current_action = "等待下一轮输入".to_string(); + state.last_response = Some(sanitize_agent_runtime_text(response, 500)); + state.error = None; + state + .observations + .push("Agent 已完成回复,assistant 消息等待或已经由前端落盘。".to_string()); + state.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_state(root, &state)?; + append_game_creator_agent_runtime_event( + root, + &state, + "turn.completed", + "idle", + "completed", + "Agent Runtime 完成本轮处理。", + state.last_response.as_deref(), + )?; + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.completed", + "agentId": state.agent_id, + "taskId": state.task_id, + "sessionId": state.session_id, + "runId": state.run_id, + "source": state.source, + "responsePreview": state.last_response, + }), + )?; + Ok(state) +} + +pub(crate) fn fail_game_creator_agent_runtime_turn_at( + root: &Path, + mut state: AgentRuntimeState, + error: &str, +) -> Result { + state.status = "failed".to_string(); + state.phase = "failed".to_string(); + state.current_action = "等待开发者处理失败".to_string(); + state.error = Some(sanitize_agent_runtime_text(error, 500)); + state.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_state(root, &state)?; + append_game_creator_agent_runtime_event( + root, + &state, + "turn.failed", + "failed", + "failed", + "Agent Runtime 本轮处理失败。", + state.error.as_deref(), + )?; + Ok(state) +} + +fn normalize_game_creator_runtime_agent_id(agent_id: &str) -> Result<&str, String> { + let agent_id = agent_id.trim(); + if agent_id.is_empty() { + return Err("Agent ID 不能为空".to_string()); + } + game_creator_agent_role_definition(agent_id) + .ok_or_else(|| format!("未知 Agent:{agent_id}"))?; + Ok(agent_id) +} + +fn normalize_game_creator_agent_runtime_run_id(agent_id: &str, run_id: &str) -> String { + let run_id = run_id.trim(); + if run_id.is_empty() { + format!("agent-runtime-{agent_id}-{}", unix_timestamp()) + } else { + truncate_agent_runtime_text(run_id, 160) + } +} + +fn default_game_creator_agent_runtime_state(agent_id: &str, run_id: &str) -> AgentRuntimeState { + AgentRuntimeState { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: agent_id.to_string(), + task_id: agent_id.to_string(), + session_id: format!("agent-session-{agent_id}"), + run_id: run_id.to_string(), + source: "agent-chat".to_string(), + status: "idle".to_string(), + phase: "idle".to_string(), + current_task: String::new(), + current_action: "等待输入".to_string(), + plan: vec![ + "读取项目上下文".to_string(), + "按角色职责推理".to_string(), + "回复并记录 runtime 事件".to_string(), + ], + observations: Vec::new(), + allowed_tools: vec![ + "conversation.read".to_string(), + "conversation.write".to_string(), + "memory.read".to_string(), + "asset.list".to_string(), + "file.read".to_string(), + "agent.run_status".to_string(), + ], + last_response: None, + error: None, + updated_at: unix_timestamp(), + } +} + +fn normalize_game_creator_agent_runtime_state(state: &mut AgentRuntimeState, agent_id: &str) { + if state.schema_version.trim().is_empty() { + state.schema_version = AGENT_RUNTIME_SCHEMA_VERSION.to_string(); + } + if state.agent_id.trim().is_empty() { + state.agent_id = agent_id.to_string(); + } + if state.task_id.trim().is_empty() { + state.task_id = agent_id.to_string(); + } + if state.session_id.trim().is_empty() { + state.session_id = format!("agent-session-{agent_id}"); + } + if state.source.trim().is_empty() { + state.source = "agent-chat".to_string(); + } + if state.status.trim().is_empty() { + state.status = "idle".to_string(); + } + if state.phase.trim().is_empty() { + state.phase = "idle".to_string(); + } + if state.current_action.trim().is_empty() { + state.current_action = "等待输入".to_string(); + } + if state.plan.is_empty() { + state.plan = vec![ + "读取项目上下文".to_string(), + "按角色职责推理".to_string(), + "回复并记录 runtime 事件".to_string(), + ]; + } + if state.allowed_tools.is_empty() { + state.allowed_tools = vec![ + "conversation.read".to_string(), + "conversation.write".to_string(), + "memory.read".to_string(), + "asset.list".to_string(), + "file.read".to_string(), + "agent.run_status".to_string(), + ]; + } + if state.updated_at == 0 { + state.updated_at = unix_timestamp(); + } +} + +fn normalize_game_creator_agent_runtime_event(event: &mut AgentRuntimeEvent) { + if event.schema_version.trim().is_empty() { + event.schema_version = AGENT_RUNTIME_SCHEMA_VERSION.to_string(); + } + if event.agent_id.trim().is_empty() { + event.agent_id = event.task_id.clone(); + } + if event.task_id.trim().is_empty() { + event.task_id = event.agent_id.clone(); + } + if event.session_id.trim().is_empty() { + event.session_id = format!("agent-session-{}", event.agent_id); + } + if event.source.trim().is_empty() { + event.source = "agent-chat".to_string(); + } + if event.event_type.trim().is_empty() { + event.event_type = "turn.event".to_string(); + } + if event.status.trim().is_empty() { + event.status = "idle".to_string(); + } + if event.phase.trim().is_empty() { + event.phase = "idle".to_string(); + } + if event.updated_at == 0 { + event.updated_at = unix_timestamp(); + } +} + +fn game_creator_agent_runtime_session_path(root: &Path, agent_id: &str) -> PathBuf { + root.join(".agent") + .join("runtime") + .join("agents") + .join(format!("{agent_id}.json")) +} + +fn game_creator_agent_runtime_event_path(root: &Path, agent_id: &str) -> PathBuf { + root.join(".agent") + .join("runtime") + .join("events") + .join(format!("{agent_id}.jsonl")) +} + +fn write_game_creator_agent_runtime_state( + root: &Path, + state: &AgentRuntimeState, +) -> Result<(), String> { + let path = game_creator_agent_runtime_session_path(root, &state.agent_id); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "创建 Agent Runtime 状态目录失败:{}: {error}", + parent.display() + ) + })?; + } + let content = serde_json::to_string_pretty(state) + .map_err(|error| format!("序列化 Agent Runtime 状态失败:{error}"))?; + let temp_path = path.with_file_name(format!( + ".{}.tmp.{}.{}", + path.file_name() + .and_then(|value| value.to_str()) + .unwrap_or("runtime.json"), + std::process::id(), + unix_timestamp_nanos() + )); + fs::write(&temp_path, format!("{content}\n")).map_err(|error| { + format!( + "写入 Agent Runtime 临时状态失败:{}: {error}", + temp_path.display() + ) + })?; + fs::rename(&temp_path, &path).map_err(|error| { + let _ = fs::remove_file(&temp_path); + format!( + "替换 Agent Runtime 状态失败:{} -> {}: {error}", + temp_path.display(), + path.display() + ) + }) +} + +fn append_game_creator_agent_runtime_event( + root: &Path, + state: &AgentRuntimeState, + event_type: &str, + status: &str, + phase: &str, + summary: &str, + detail: Option<&str>, +) -> Result<(), String> { + let path = game_creator_agent_runtime_event_path(root, &state.agent_id); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "创建 Agent Runtime 事件目录失败:{}: {error}", + parent.display() + ) + })?; + } + let event = AgentRuntimeEvent { + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: state.agent_id.clone(), + task_id: state.task_id.clone(), + session_id: state.session_id.clone(), + run_id: state.run_id.clone(), + source: state.source.clone(), + event_type: event_type.to_string(), + status: status.to_string(), + phase: phase.to_string(), + summary: summary.to_string(), + detail: detail.map(|value| sanitize_agent_runtime_text(value, 500)), + updated_at: unix_timestamp(), + }; + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .map_err(|error| format!("打开 Agent Runtime 事件失败:{}: {error}", path.display()))?; + serde_json::to_writer(&mut file, &event) + .map_err(|error| format!("序列化 Agent Runtime 事件失败:{error}"))?; + file.write_all(b"\n") + .map_err(|error| format!("写入 Agent Runtime 事件失败:{}: {error}", path.display())) +} + +fn read_recent_game_creator_agent_runtime_events( + path: &Path, +) -> Result, String> { + let mut events = Vec::new(); + match File::open(path) { + Ok(file) => { + for line in BufReader::new(file).lines() { + let line = line.map_err(|error| { + format!("读取 Agent Runtime 事件失败:{}: {error}", path.display()) + })?; + let line = line.trim(); + if line.is_empty() { + continue; + } + match serde_json::from_str::(line) { + Ok(mut event) => { + normalize_game_creator_agent_runtime_event(&mut event); + events.push(event); + } + Err(_) => continue, + } + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "读取 Agent Runtime 事件失败:{}: {error}", + path.display() + )); + } + } + if events.len() > AGENT_RUNTIME_RECENT_EVENT_LIMIT { + Ok(events[events.len() - AGENT_RUNTIME_RECENT_EVENT_LIMIT..].to_vec()) + } else { + Ok(events) + } +} + +fn truncate_agent_runtime_text(value: &str, max_chars: usize) -> String { + let value = value.trim(); + let mut output = String::new(); + for character in value.chars().take(max_chars) { + output.push(character); + } + if value.chars().count() > max_chars { + output.push('…'); + } + output +} + +fn sanitize_agent_runtime_text(value: &str, max_chars: usize) -> String { + truncate_agent_runtime_text(sanitize_prompt_context(value).trim(), max_chars) +} + +fn unix_timestamp_nanos() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() +} + pub(crate) fn build_game_creator_role_agent_chat_request( root: &Path, agent_id: &str, @@ -557,6 +1066,7 @@ pub(crate) async fn run_game_creator_agent_loop_at( &spec_markdown, &findings_markdown, &agenda, + &run_id, pass, ) .await @@ -911,6 +1421,7 @@ pub(crate) async fn request_agent_group_briefs_with_client( spec_markdown: &str, findings_markdown: &str, agenda: &AgentPassAgenda, + run_id: &str, pass: u8, ) -> Result, String> { let mut role_briefs_by_task = BTreeMap::::new(); @@ -958,6 +1469,22 @@ pub(crate) async fn request_agent_group_briefs_with_client( if let Some((source_path, source_markdown)) = read_previous_agent_role_brief(root, pass, *definition, *role_definition)? { + let runtime = start_game_creator_agent_runtime_task_at( + root, + role_definition.task_id, + &format!( + "pass {pass} 沿用 {} / {} brief", + definition.label, role_definition.role + ), + run_id, + "generate-draft", + "沿用上一轮角色 brief", + vec![ + "读取上一轮角色 brief".to_string(), + "写入本轮 carry-over brief".to_string(), + "把 carry-over 状态同步到 Agent Runtime".to_string(), + ], + )?; let markdown = render_carryover_role_brief( *definition, *role_definition, @@ -987,6 +1514,11 @@ pub(crate) async fn request_agent_group_briefs_with_client( definition.label, role_definition.role ), }; + let _ = finish_game_creator_agent_runtime_turn_at( + root, + runtime, + &role_brief.summary, + )?; completed_role_context.push_str(&render_agent_role_brief_context(&role_brief)); role_briefs_by_task.insert(role_definition.task_id.to_string(), role_brief); continue; @@ -1003,6 +1535,7 @@ pub(crate) async fn request_agent_group_briefs_with_client( let agenda_markdown = agenda_markdown.clone(); let completed_group_context = completed_group_context.clone(); let completed_role_context = completed_role_context.clone(); + let run_id = run_id.to_string(); role_brief_jobs.spawn(async move { build_agent_role_brief_draft( root, @@ -1018,6 +1551,7 @@ pub(crate) async fn request_agent_group_briefs_with_client( agenda_markdown, completed_group_context, completed_role_context, + run_id, pass, ) .await @@ -1139,64 +1673,105 @@ async fn build_agent_role_brief_draft( agenda_markdown: String, completed_group_context: String, completed_role_context: String, + run_id: String, pass: u8, ) -> Result { - let agent_memory_relative_path = agent_role_memory_relative_path(definition, role_definition); - let agent_memory = read_optional_text(&root.join(&agent_memory_relative_path))?; - let agent_conversation_context = - render_local_conversation_prompt_context(&root, Some(role_definition.task_id))?; - let role_short_memory = append_prompt_context(&agent_conversation_context, &short_memory); - let local_markdown = render_local_agent_role_brief( - definition, - role_definition, - &prompt, - &role_short_memory, - &long_memory, - &project_blackboard, - &agent_memory, - &spec_markdown, - &findings_markdown, - &agenda_markdown, - &completed_group_context, - &completed_role_context, - pass, - ); - let (markdown, tool_id, summary) = - if has_game_creator_agent_llm_override(&app_config, role_definition.task_id) { - let markdown = request_agent_role_brief_with_config( - &app_config, - role_definition.task_id, - &local_markdown, - ) - .await?; - ( - markdown, - format!("llm.chat.{}", role_definition.task_id), - format!( - "{} / {} 使用 agentLlm.{} 生成 brief", - definition.label, role_definition.role, role_definition.task_id - ), - ) - } else { - ( - local_markdown, - role_definition.tool_id.to_string(), - format!( - "本地编排生成 {} / {} brief", - definition.label, role_definition.role - ), - ) + let runtime = start_game_creator_agent_runtime_task_at( + &root, + role_definition.task_id, + &format!( + "pass {pass} 生成 {} / {} brief", + definition.label, role_definition.role + ), + &run_id, + "generate-draft", + "读取角色上下文", + vec![ + "读取项目记忆、黑板、Agent 私有记忆和最近对话".to_string(), + "根据 Orchestrator agenda 生成本角色 brief".to_string(), + "把角色 brief 写入本轮 pass 产物并同步 runtime 状态".to_string(), + ], + )?; + let result: Result = async { + let agent_memory_relative_path = + agent_role_memory_relative_path(definition, role_definition); + let agent_memory = read_optional_text(&root.join(&agent_memory_relative_path))?; + let agent_conversation_context = + render_local_conversation_prompt_context(&root, Some(role_definition.task_id))?; + let role_short_memory = append_prompt_context(&agent_conversation_context, &short_memory); + let local_markdown = render_local_agent_role_brief( + definition, + role_definition, + &prompt, + &role_short_memory, + &long_memory, + &project_blackboard, + &agent_memory, + &spec_markdown, + &findings_markdown, + &agenda_markdown, + &completed_group_context, + &completed_role_context, + pass, + ); + let runtime = advance_game_creator_agent_runtime_turn_at( + &root, + runtime, + "brief", + if has_game_creator_agent_llm_override(&app_config, role_definition.task_id) { + "调用角色专属 LLM 生成 brief" + } else { + "使用本地编排生成 brief" + }, + "角色上下文已读取。", + )?; + let (markdown, tool_id, summary) = + if has_game_creator_agent_llm_override(&app_config, role_definition.task_id) { + let markdown = request_agent_role_brief_with_config( + &app_config, + role_definition.task_id, + &local_markdown, + ) + .await?; + ( + markdown, + format!("llm.chat.{}", role_definition.task_id), + format!( + "{} / {} 使用 agentLlm.{} 生成 brief", + definition.label, role_definition.role, role_definition.task_id + ), + ) + } else { + ( + local_markdown, + role_definition.tool_id.to_string(), + format!( + "本地编排生成 {} / {} brief", + definition.label, role_definition.role + ), + ) + }; + let draft = AgentRoleBriefDraft { + group_definition: definition, + role_definition, + markdown, + memory_relative_path: agent_memory_relative_path, + status: "completed".to_string(), + tool_id, + summary, }; - - Ok(AgentRoleBriefDraft { - group_definition: definition, - role_definition, - markdown, - memory_relative_path: agent_memory_relative_path, - status: "completed".to_string(), - tool_id, - summary, - }) + finish_game_creator_agent_runtime_turn_at(&root, runtime, &draft.summary)?; + Ok::(draft) + } + .await; + if let Err(error) = &result { + let failed_state = read_game_creator_agent_runtime_at(&root, role_definition.task_id) + .map(|result| result.state); + if let Ok(state) = failed_state { + let _ = fail_game_creator_agent_runtime_turn_at(&root, state, error); + } + } + result } pub(crate) fn has_game_creator_agent_llm_override( 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 30aff269b..efa0c2947 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -245,7 +245,11 @@ pub(crate) async fn chat_with_game_creator_role_agent( ) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; - chat_with_game_creator_role_agent_at(root, agent_id.trim(), prompt.trim()).await + enforce_project_permission_policy(root, "conversation.write")?; + let _lock = acquire_project_write_lock(root, "conversation.write")?; + chat_with_game_creator_role_agent_runtime_at(root, agent_id.trim(), prompt.trim(), "") + .await + .map(|(reply, _runtime)| reply) } #[tauri::command] @@ -261,10 +265,23 @@ pub(crate) async fn chat_with_game_creator_role_agent_stream( let run_id = run_id.trim().to_string(); let root = Path::new(project_path.as_str()); enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + let _lock = acquire_project_write_lock(root, "conversation.write")?; let emit_app = app.clone(); let event_project_path = project_path.clone(); let event_agent_id = agent_id.clone(); let event_run_id = run_id.clone(); + let mut runtime_state = + start_game_creator_agent_runtime_turn_at(root, agent_id.as_str(), prompt.trim(), &run_id)?; + runtime_state = advance_game_creator_agent_runtime_turn_at( + root, + runtime_state, + "llm", + "请求 Agent LLM", + "已读取项目上下文,正在让 Agent 独立推理。", + )?; + let mut streaming_runtime_state = runtime_state.clone(); + streaming_runtime_state.current_action = "正在接收 Agent 回复".to_string(); let _ = app.emit( "game-creator-role-agent-chat-stream", GameCreatorRoleAgentChatStreamEvent { @@ -275,6 +292,11 @@ pub(crate) async fn chat_with_game_creator_role_agent_stream( delta_text: String::new(), accumulated_text: String::new(), finish_reason: None, + session_id: Some(runtime_state.session_id.clone()), + runtime_status: Some(runtime_state.status.clone()), + runtime_phase: Some(runtime_state.phase.clone()), + runtime_summary: Some(runtime_state.current_action.clone()), + runtime_state: Some(runtime_state.clone()), }, ); let result = chat_with_game_creator_role_agent_stream_at( @@ -292,6 +314,11 @@ pub(crate) async fn chat_with_game_creator_role_agent_stream( delta_text: delta.delta_text.clone(), accumulated_text: delta.accumulated_text.clone(), finish_reason: delta.finish_reason.clone(), + session_id: None, + runtime_status: Some("running".to_string()), + runtime_phase: Some("llm".to_string()), + runtime_summary: Some("正在接收 Agent 回复".to_string()), + runtime_state: Some(streaming_runtime_state.clone()), }, ); }, @@ -299,6 +326,8 @@ pub(crate) async fn chat_with_game_creator_role_agent_stream( .await; match result { Ok(reply) => { + let completed_runtime = + finish_game_creator_agent_runtime_turn_at(root, runtime_state, &reply.reply_text)?; let _ = app.emit( "game-creator-role-agent-chat-stream", GameCreatorRoleAgentChatStreamEvent { @@ -309,11 +338,18 @@ pub(crate) async fn chat_with_game_creator_role_agent_stream( delta_text: String::new(), accumulated_text: reply.reply_text.clone(), finish_reason: None, + session_id: Some(completed_runtime.session_id.clone()), + runtime_status: Some(completed_runtime.status.clone()), + runtime_phase: Some(completed_runtime.phase.clone()), + runtime_summary: Some(completed_runtime.current_action.clone()), + runtime_state: Some(completed_runtime), }, ); Ok(reply) } Err(error) => { + let failed_runtime = + fail_game_creator_agent_runtime_turn_at(root, runtime_state, &error).ok(); let _ = app.emit( "game-creator-role-agent-chat-stream", GameCreatorRoleAgentChatStreamEvent { @@ -324,6 +360,17 @@ pub(crate) async fn chat_with_game_creator_role_agent_stream( delta_text: String::new(), accumulated_text: String::new(), finish_reason: None, + session_id: failed_runtime + .as_ref() + .map(|runtime| runtime.session_id.clone()), + runtime_status: failed_runtime + .as_ref() + .map(|runtime| runtime.status.clone()), + runtime_phase: failed_runtime.as_ref().map(|runtime| runtime.phase.clone()), + runtime_summary: failed_runtime + .as_ref() + .map(|runtime| runtime.current_action.clone()), + runtime_state: failed_runtime, }, ); Err(error) @@ -331,6 +378,16 @@ pub(crate) async fn chat_with_game_creator_role_agent_stream( } } +#[tauri::command] +pub(crate) fn read_game_creator_agent_runtime( + project_path: String, + agent_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + read_game_creator_agent_runtime_at(root, agent_id.trim()) +} + #[tauri::command] pub(crate) fn check_game_creator_llm_config() -> GameCreatorLlmConfigStatus { check_game_creator_llm_config_from_config() 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 28920a780..d7a36dabf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -125,6 +125,86 @@ struct GameCreatorRoleAgentChatStreamEvent { delta_text: String, accumulated_text: String, finish_reason: Option, + session_id: Option, + runtime_status: Option, + runtime_phase: Option, + runtime_summary: Option, + runtime_state: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct AgentRuntimeState { + #[serde(default)] + schema_version: String, + #[serde(default)] + agent_id: String, + #[serde(default)] + task_id: String, + #[serde(default)] + session_id: String, + #[serde(default)] + run_id: String, + #[serde(default)] + source: String, + #[serde(default)] + status: String, + #[serde(default)] + phase: String, + #[serde(default)] + current_task: String, + #[serde(default)] + current_action: String, + #[serde(default)] + plan: Vec, + #[serde(default)] + observations: Vec, + #[serde(default)] + allowed_tools: Vec, + #[serde(default)] + last_response: Option, + #[serde(default)] + error: Option, + #[serde(default)] + updated_at: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct AgentRuntimeEvent { + #[serde(default)] + schema_version: String, + #[serde(default)] + agent_id: String, + #[serde(default)] + task_id: String, + #[serde(default)] + session_id: String, + #[serde(default)] + run_id: String, + #[serde(default)] + source: String, + #[serde(default)] + event_type: String, + #[serde(default)] + status: String, + #[serde(default)] + phase: String, + #[serde(default)] + summary: String, + #[serde(default)] + detail: Option, + #[serde(default)] + updated_at: u64, +} + +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct AgentRuntimeResult { + state: AgentRuntimeState, + session_path: String, + event_path: String, + recent_events: Vec, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -531,6 +611,8 @@ const PROJECT_PERMISSION_POLICY_PATH: &str = ".agent/policy.json"; const PROJECT_INDEX_PATH: &str = ".agent/project.index.json"; const PROJECT_WRITE_LOCK_PATH: &str = ".agent/project.lock"; const LOCAL_CONVERSATION_SCHEMA_VERSION: &str = "game-creator-conversation.v1"; +const AGENT_RUNTIME_SCHEMA_VERSION: &str = "game-creator-agent-runtime.v1"; +const AGENT_RUNTIME_RECENT_EVENT_LIMIT: usize = 20; const GAME_CREATOR_CONVERSATION_CONTEXT_MAX_MESSAGES: usize = 12; const MAX_CANVAS_EXPORT_FILES: usize = 500; const MAX_CANVAS_EXPORT_BYTES: u64 = 512 * 1024 * 1024; @@ -941,6 +1023,7 @@ fn main() { chat_with_game_creator_agent, chat_with_game_creator_role_agent, chat_with_game_creator_role_agent_stream, + read_game_creator_agent_runtime, check_game_creator_llm_config, read_game_creator_app_config, write_game_creator_app_config, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index edbba1b82..6f162835c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -1315,6 +1315,7 @@ pub(crate) fn should_skip_project_index_path(relative_path: &str) -> bool { relative_path == PROJECT_WRITE_LOCK_PATH || relative_path == PROJECT_INDEX_PATH || relative_path.starts_with(".agent/checkpoints/") + || relative_path.starts_with(".agent/runtime/") } pub(crate) fn should_skip_project_restore_path(relative_path: &str) -> bool { @@ -1324,6 +1325,7 @@ pub(crate) fn should_skip_project_restore_path(relative_path: &str) -> bool { || relative_path == PROJECT_INDEX_PATH || relative_path.starts_with(".agent/logs/") || relative_path.starts_with(".agent/conversations/") + || relative_path.starts_with(".agent/runtime/") } pub(crate) fn normalize_checkpoint_id(checkpoint_id: &str) -> Result { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tests.rs index bf021b1e5..700820d30 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -1150,6 +1150,112 @@ async fn chat_with_game_creator_role_agent_stream_emits_deltas() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn role_agent_runtime_turn_persists_session_events_and_index() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let base_url = spawn_mock_llm_server("先确认角色规范,再推进首张主角图。".to_string()); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "art-director": {{ + "apiKey": "art-key", + "baseUrl": {base_url:?}, + "model": "art-runtime-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + + let (reply, runtime) = chat_with_game_creator_role_agent_runtime_at( + &root, + "art-director", + "我要生成主角图", + "runtime-test-run", + ) + .await + .expect("runtime role chat"); + + assert_eq!(reply.reply_text, "先确认角色规范,再推进首张主角图。"); + assert_eq!(runtime.status, "idle"); + assert_eq!(runtime.phase, "completed"); + assert_eq!(runtime.run_id, "runtime-test-run"); + assert_eq!(runtime.session_id, "agent-session-art-director"); + assert_eq!(runtime.task_id, "art-director"); + assert_eq!(runtime.source, "agent-chat"); + assert!(runtime + .allowed_tools + .contains(&"conversation.read".to_string())); + let result = + read_game_creator_agent_runtime_at(&root, "art-director").expect("read runtime state"); + assert!(result + .session_path + .ends_with(".agent/runtime/agents/art-director.json")); + assert!(result + .event_path + .ends_with(".agent/runtime/events/art-director.jsonl")); + assert_eq!( + result.state.last_response.as_deref(), + Some("先确认角色规范,再推进首张主角图。") + ); + let event_types = result + .recent_events + .iter() + .map(|event| event.event_type.as_str()) + .collect::>(); + assert!(event_types.contains(&"turn.started")); + assert!(event_types.contains(&"turn.progress")); + assert!(event_types.contains(&"turn.completed")); + fs::OpenOptions::new() + .append(true) + .open(root.join(".agent/runtime/events/art-director.jsonl")) + .expect("open runtime events") + .write_all(b"{broken runtime event\n") + .expect("append broken runtime event"); + let result_with_broken_event = + read_game_creator_agent_runtime_at(&root, "art-director").expect("read runtime state"); + assert!(result_with_broken_event + .recent_events + .iter() + .any(|event| event.event_type == "turn.completed")); + let sensitive_runtime = start_game_creator_agent_runtime_task_at( + &root, + "art-director", + "token sk-runtime-secret\nAuthorization: Bearer secret-token", + "runtime-sensitive-run", + "agent-chat", + "读取敏感输入", + vec!["测试 runtime 脱敏".to_string()], + ) + .expect("sensitive runtime start"); + finish_game_creator_agent_runtime_turn_at( + &root, + sensitive_runtime, + "回复里包含 sk-runtime-reply 和 Authorization: Bearer reply-token", + ) + .expect("sensitive runtime finish"); + let sensitive_state = + read_game_creator_agent_runtime_at(&root, "art-director").expect("read sensitive runtime"); + let serialized_state = + serde_json::to_string(&sensitive_state.state).expect("runtime state json"); + assert!(!serialized_state.contains("sk-runtime-secret")); + assert!(!serialized_state.contains("secret-token")); + assert!(!serialized_state.contains("sk-runtime-reply")); + assert!(!serialized_state.contains("reply-token")); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + assert!(agent_db.contains("\"recordType\":\"agent.runtime.turn\"")); + assert!(agent_db.contains("\"recordType\":\"agent.runtime.completed\"")); + assert!(agent_db.contains("\"taskId\":\"art-director\"")); + assert!(!agent_db.contains("art-key")); + assert!(!agent_db.contains("sk-runtime-secret")); + assert!(!agent_db.contains("secret-token")); + assert!(!agent_db.contains("sk-runtime-reply")); + assert!(!agent_db.contains("reply-token")); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn agent_loop_uses_per_agent_llm_overrides() { let root = unique_project_path(); @@ -1312,6 +1418,7 @@ async fn agent_role_briefs_run_same_wave_llm_agents_in_parallel() { "Planner 规格", "Evaluator 反馈", &agenda, + "role-brief-test-run", 1, ) .await @@ -1328,6 +1435,19 @@ async fn agent_role_briefs_run_same_wave_llm_agents_in_parallel() { assert!(captured_requests .iter() .any(|request| request.contains("design-foundation-model"))); + let director_runtime = + read_game_creator_agent_runtime_at(&root, "design-director").expect("director runtime"); + assert_eq!(director_runtime.state.status, "idle"); + assert_eq!(director_runtime.state.phase, "completed"); + assert_eq!(director_runtime.state.source, "generate-draft"); + assert_eq!(director_runtime.state.run_id, "role-brief-test-run"); + assert!(director_runtime + .recent_events + .iter() + .any(|event| event.event_type == "turn.completed")); + let foundation_runtime = + read_game_creator_agent_runtime_at(&root, "design-foundation").expect("foundation runtime"); + assert_eq!(foundation_runtime.state.source, "generate-draft"); let design_group = briefs .iter() .find(|brief| brief.definition.id == "design") @@ -2137,8 +2257,12 @@ async fn generate_local_game_draft_fails_after_max_passes_without_final_artifact .lines() .map(|line| serde_json::from_str::(line).expect("agent db record")) .collect::>(); - assert_eq!(records.len(), 1); - assert_eq!(records[0]["recordType"], "project.init"); + assert!(records + .iter() + .any(|record| record["recordType"] == "project.init")); + assert!(!records + .iter() + .any(|record| record["recordType"] == "game.generate_draft")); fs::remove_dir_all(root).ok(); } @@ -3842,6 +3966,12 @@ fn local_project_checkpoint_diff_restore_and_index_are_recorded() { write_local_project_file_at(&root, "game/notes.txt", "v2").expect("change notes"); write_local_project_file_at(&root, "game/extra.txt", "new").expect("add file"); + fs::create_dir_all(root.join(".agent/runtime/agents")).expect("runtime agent dir"); + fs::write( + root.join(".agent/runtime/agents/design-director.json"), + r#"{"agentId":"design-director","status":"running"}"#, + ) + .expect("runtime state"); delete_local_project_file_at(&root, "exports/README.md").expect("delete tracked file"); let diff = diff_local_project_checkpoint_at(&root, &checkpoint.checkpoint_id).expect("diff"); @@ -3857,9 +3987,17 @@ fn local_project_checkpoint_diff_restore_and_index_are_recorded() { .deleted .iter() .any(|entry| entry.path == "exports/README.md")); + assert!(!diff + .added + .iter() + .any(|entry| entry.path.starts_with(".agent/runtime/"))); let index = build_local_project_index_at(&root).expect("project index"); assert!(index.file_count > 0); + assert!(!index + .files + .iter() + .any(|file| file.path.starts_with(".agent/runtime/"))); assert!(root.join(PROJECT_INDEX_PATH).exists()); let restored = @@ -3871,6 +4009,9 @@ fn local_project_checkpoint_diff_restore_and_index_are_recorded() { ); assert!(root.join("exports/README.md").exists()); assert!(!root.join("game/extra.txt").exists()); + assert!(root + .join(".agent/runtime/agents/design-director.json") + .exists()); assert_eq!(restored.deleted_count, 1); let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index e42f5e595..8a9f5897f 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -220,6 +220,52 @@ interface GameCreatorRoleAgentChatStreamEvent { deltaText: string; accumulatedText: string; finishReason: string | null; + sessionId?: string | null; + runtimeStatus?: string | null; + runtimePhase?: string | null; + runtimeSummary?: string | null; + runtimeState?: AgentRuntimeState | null; +} + +interface AgentRuntimeState { + schemaVersion: string; + agentId: string; + taskId: string; + sessionId: string; + runId: string; + source: string; + status: string; + phase: string; + currentTask: string; + currentAction: string; + plan: string[]; + observations: string[]; + allowedTools: string[]; + lastResponse: string | null; + error: string | null; + updatedAt: number; +} + +interface AgentRuntimeEventRecord { + schemaVersion: string; + agentId: string; + taskId: string; + sessionId: string; + runId: string; + source: string; + eventType: string; + status: string; + phase: string; + summary: string; + detail: string | null; + updatedAt: number; +} + +interface AgentRuntimeResult { + state: AgentRuntimeState; + sessionPath: string; + eventPath: string; + recentEvents: AgentRuntimeEventRecord[]; } interface GameCreatorLlmConfigStatus { @@ -428,6 +474,58 @@ function createAgentChatRunId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } +function AgentRuntimeStatusPanel({ + runtime, + error, +}: { + runtime: AgentRuntimeState | null; + error?: string | null; +}) { + if (!runtime && error) { + return ( +
+
+ Runtime 状态读取失败 +
+

{error}

+
+ ); + } + if (!runtime) { + return null; + } + const planItems = runtime.plan.slice(0, 3); + const observations = runtime.observations.slice(-2); + return ( +
+
+ {`${runtime.status} / ${runtime.phase}`} + {runtime.sessionId} +
+ {`task: ${runtime.taskId} · ${runtime.source}`} + {runtime.runId ? {`run: ${runtime.runId}`} : null} + {runtime.currentTask ?

{runtime.currentTask}

: null} + {runtime.currentAction} + {planItems.length > 0 ? ( +
    + {planItems.map((item, index) => ( +
  1. {item}
  2. + ))} +
+ ) : null} + {observations.length > 0 ? ( +
+ {observations.map((item, index) => ( + {item} + ))} +
+ ) : null} + {runtime.error ?

{runtime.error}

: null} + {error ?

{error}

: null} +
+ ); +} + interface ProjectPermissionPolicy { deniedCommands: string[]; confirmCommands: string[]; @@ -2220,6 +2318,9 @@ export function WorkspaceLauncher({ useState(null); const [agentChatLlmStatus, setAgentChatLlmStatus] = useState('尚未检查 LLM 配置'); + const [agentChatRuntime, setAgentChatRuntime] = + useState(null); + const [agentChatRuntimeError, setAgentChatRuntimeError] = useState(''); const agentChatLoadVersionRef = useRef(0); useEffect(() => { @@ -2921,6 +3022,8 @@ export function WorkspaceLauncher({ const loadVersion = agentChatLoadVersionRef.current + 1; agentChatLoadVersionRef.current = loadVersion; setAgentChatBusy(true); + setAgentChatRuntime(null); + setAgentChatRuntimeError(''); setAgentChatStatus('正在读取'); try { const result = await invoke( @@ -2934,6 +3037,26 @@ export function WorkspaceLauncher({ return; } setAgentChatMessages(result.messages); + try { + const runtime = await invoke( + 'read_game_creator_agent_runtime', + { + projectPath: projectPathForChat, + agentId: agent.id, + }, + ); + if (agentChatLoadVersionRef.current === loadVersion) { + setAgentChatRuntime(runtime.state); + setAgentChatRuntimeError(''); + } + } catch (error) { + if (agentChatLoadVersionRef.current === loadVersion) { + setAgentChatRuntime(null); + setAgentChatRuntimeError( + error instanceof Error ? error.message : String(error), + ); + } + } setAgentChatStatus(`已读取 ${result.messages.length} 条:${result.path}`); } catch (error) { if (agentChatLoadVersionRef.current !== loadVersion) { @@ -3009,8 +3132,14 @@ export function WorkspaceLauncher({ ) { return; } + if (payload.runtimeState) { + setAgentChatRuntime(payload.runtimeState); + setAgentChatRuntimeError(''); + } if (payload.status === 'started') { - setAgentChatStatus('Agent 已连接,正在等待回复'); + setAgentChatStatus( + payload.runtimeSummary ?? 'Agent 已连接,正在等待回复', + ); return; } if (payload.status === 'delta') { @@ -3029,11 +3158,13 @@ export function WorkspaceLauncher({ return; } if (payload.status === 'completed') { - setAgentChatStatus('Agent 回复完成,正在保存'); + setAgentChatStatus(payload.runtimeSummary ?? 'Agent 回复完成,正在保存'); return; } if (payload.status === 'failed') { - setAgentChatStatus('Agent 流式回复失败,正在记录错误'); + setAgentChatStatus( + payload.runtimeSummary ?? 'Agent 流式回复失败,正在记录错误', + ); } }, ); @@ -3063,6 +3194,25 @@ export function WorkspaceLauncher({ if (agentChatLoadVersionRef.current !== saveVersion) { return; } + try { + const runtime = await invoke( + 'read_game_creator_agent_runtime', + { + projectPath: projectPathForChat, + agentId: agent.id, + }, + ); + if (agentChatLoadVersionRef.current === saveVersion) { + setAgentChatRuntime(runtime.state); + setAgentChatRuntimeError(''); + } + } catch (error) { + if (agentChatLoadVersionRef.current === saveVersion) { + setAgentChatRuntimeError( + error instanceof Error ? error.message : String(error), + ); + } + } setAgentChatStatus('正在保存 Agent 回复'); setAgentChatMessages([ ...savedUserMessages, @@ -3790,17 +3940,23 @@ export function WorkspaceLauncher({ : '请选择 Agent'} - {currentAgentChatLlmWarning ? ( -
- {currentAgentChatLlmWarning} - -
- ) : null} +
+ {currentAgentChatLlmWarning ? ( +
+ {currentAgentChatLlmWarning} + +
+ ) : null} + +
{agentChatMessages.length > 0 ? ( agentChatMessages.map((message, index) => ( @@ -11157,6 +11313,10 @@ export function App() { const [agentConversationMessages, setAgentConversationMessages] = useState< LocalConversationMessageRecord[] >([]); + const [agentConversationRuntime, setAgentConversationRuntime] = + useState(null); + const [agentConversationRuntimeError, setAgentConversationRuntimeError] = + useState(''); const [agentConversationVisibleCount, setAgentConversationVisibleCount] = useState(CONVERSATION_INITIAL_VISIBLE_COUNT); const [agentConversationSaving, setAgentConversationSaving] = useState(false); @@ -12042,6 +12202,8 @@ export function App() { setSelectedAgent(null); setAgentConversationInput(''); setAgentConversationMessages([]); + setAgentConversationRuntime(null); + setAgentConversationRuntimeError(''); setAgentConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); setAgentConversationStatus('未选择 agent'); setAgentConversationSaving(false); @@ -12131,6 +12293,8 @@ export function App() { setSelectedAgent(agent); setAgentConversationInput(''); setAgentConversationMessages([]); + setAgentConversationRuntime(null); + setAgentConversationRuntimeError(''); setAgentConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); setAgentMemoryContent(''); const invoke = resolveTauriInvoke(); @@ -12187,6 +12351,26 @@ export function App() { return; } setAgentConversationMessages(result.messages); + try { + const runtime = await invoke( + 'read_game_creator_agent_runtime', + { + projectPath: nextProjectPath, + agentId: agent.id, + }, + ); + if (agentConversationLoadVersionRef.current === loadVersion) { + setAgentConversationRuntime(runtime.state); + setAgentConversationRuntimeError(''); + } + } catch (error) { + if (agentConversationLoadVersionRef.current === loadVersion) { + setAgentConversationRuntime(null); + setAgentConversationRuntimeError( + error instanceof Error ? error.message : String(error), + ); + } + } setAgentConversationStatus( `已读取 ${result.messages.length} 条:${result.path}`, ); @@ -12257,6 +12441,7 @@ export function App() { setSelectedAgent(null); setAgentConversationInput(''); setAgentConversationMessages([]); + setAgentConversationRuntime(null); setAgentConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); setAgentConversationStatus('未选择 agent'); setAgentConversationSaving(false); @@ -12417,8 +12602,14 @@ export function App() { ) { return; } + if (payload.runtimeState) { + setAgentConversationRuntime(payload.runtimeState); + setAgentConversationRuntimeError(''); + } if (payload.status === 'started') { - setAgentConversationStatus('Agent 已连接,正在等待回复'); + setAgentConversationStatus( + payload.runtimeSummary ?? 'Agent 已连接,正在等待回复', + ); return; } if (payload.status === 'delta') { @@ -12437,11 +12628,15 @@ export function App() { return; } if (payload.status === 'completed') { - setAgentConversationStatus('Agent 回复完成,正在保存'); + setAgentConversationStatus( + payload.runtimeSummary ?? 'Agent 回复完成,正在保存', + ); return; } if (payload.status === 'failed') { - setAgentConversationStatus('Agent 流式回复失败,正在记录错误'); + setAgentConversationStatus( + payload.runtimeSummary ?? 'Agent 流式回复失败,正在记录错误', + ); } }, ); @@ -12471,6 +12666,25 @@ export function App() { if (agentConversationLoadVersionRef.current !== saveVersion) { return; } + try { + const runtime = await invoke( + 'read_game_creator_agent_runtime', + { + projectPath: nextProjectPath, + agentId: agent.id, + }, + ); + if (agentConversationLoadVersionRef.current === saveVersion) { + setAgentConversationRuntime(runtime.state); + setAgentConversationRuntimeError(''); + } + } catch (error) { + if (agentConversationLoadVersionRef.current === saveVersion) { + setAgentConversationRuntimeError( + error instanceof Error ? error.message : String(error), + ); + } + } setAgentConversationStatus('正在保存 Agent 回复'); setAgentConversationMessages([ ...savedUserMessages, @@ -19643,6 +19857,10 @@ export function App() {
) : null} +
.agent-runtime-status { + margin: 0; +} + +.agent-runtime-status header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.agent-runtime-status strong { + font-size: 13px; +} + +.agent-runtime-status p, +.agent-runtime-status ol { + margin: 0; +} + +.agent-runtime-status p, +.agent-runtime-status li, +.agent-runtime-status small { + color: #647084; + font-size: 12px; + overflow-wrap: anywhere; +} + +.agent-runtime-status ol { + display: grid; + gap: 3px; + padding-left: 18px; +} + +.agent-runtime-status div { + display: grid; + gap: 3px; +} + .launcher-project-development { padding-top: 92px; } diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index 15f10de1a..d619cd3c4 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -1069,6 +1069,36 @@ describe('AI 游戏创作 App 界面边界', () => { let releaseStream: (() => void) | null = null; let streamHandler: ((event: { payload: Record }) => void) | null = null; + const startedRuntimeState = { + schemaVersion: 'game-creator-agent-runtime.v1', + agentId: 'design-director', + taskId: 'design-director', + sessionId: 'agent-session-design-director', + runId: 'launcher-agent-chat-test', + source: 'agent-chat', + status: 'running', + phase: 'llm', + currentTask: '请流式回答', + currentAction: '请求 Agent LLM', + plan: ['读取项目上下文', '按角色职责推理', '流式回复'], + observations: ['已创建本轮 Agent Runtime run。'], + allowedTools: ['conversation.read', 'conversation.write'], + lastResponse: null, + error: null, + updatedAt: 3000, + }; + const completedRuntimeState = { + ...startedRuntimeState, + status: 'idle', + phase: 'completed', + currentAction: '等待下一轮输入', + lastResponse: '专业 Agent 已流式完成。', + observations: [ + '已创建本轮 Agent Runtime run。', + 'Agent 已完成回复,assistant 消息等待或已经由前端落盘。', + ], + updatedAt: 3001, + }; const listen = vi.fn( async ( eventName: string, @@ -1120,6 +1150,16 @@ describe('AI 游戏创作 App 界面边界', () => { })), }; } + if (command === 'read_game_creator_agent_runtime') { + return { + state: completedRuntimeState, + sessionPath: + '/tmp/authorized-game/.agent/runtime/agents/design-director.json', + eventPath: + '/tmp/authorized-game/.agent/runtime/events/design-director.jsonl', + recentEvents: [], + }; + } if (command === 'chat_with_game_creator_role_agent_stream') { const payloadBase = { projectPath: args?.projectPath, @@ -1133,6 +1173,8 @@ describe('AI 游戏创作 App 界面边界', () => { status: 'started', deltaText: '', accumulatedText: '', + runtimeSummary: '请求 Agent LLM', + runtimeState: startedRuntimeState, }, }); streamHandler?.({ @@ -1160,6 +1202,8 @@ describe('AI 游戏创作 App 界面边界', () => { status: 'completed', deltaText: '', accumulatedText: '专业 Agent 已流式完成。', + runtimeSummary: '等待下一轮输入', + runtimeState: completedRuntimeState, }, }); return { @@ -1201,6 +1245,10 @@ describe('AI 游戏创作 App 界面边界', () => { fireEvent.click(screen.getByRole('button', { name: '发送' })); expect(await screen.findByText('专业 Agent')).not.toBeNull(); + expect(await screen.findByLabelText('Agent Runtime 状态')).not.toBeNull(); + expect(screen.getByText('running / llm')).not.toBeNull(); + expect(screen.getByText('agent-session-design-director')).not.toBeNull(); + expect(screen.getAllByText('请求 Agent LLM').length).toBeGreaterThan(0); expect(screen.getByText('正在接收 Agent 回复')).not.toBeNull(); expect(invoke).toHaveBeenCalledWith( 'chat_with_game_creator_role_agent_stream', @@ -1219,6 +1267,8 @@ describe('AI 游戏创作 App 界面边界', () => { }); expect(await screen.findByText('专业 Agent 已流式完成。')).not.toBeNull(); + expect(await screen.findByText('idle / completed')).not.toBeNull(); + expect(screen.getAllByText('等待下一轮输入').length).toBeGreaterThan(0); expect(await screen.findByText(/已保存 2 条/)).not.toBeNull(); expect(persistedMessages).toEqual([ { @@ -1234,6 +1284,59 @@ describe('AI 游戏创作 App 界面边界', () => { ]); }); + it('shows developer agent runtime read failures', async () => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'check_game_creator_llm_config') { + return { + configured: true, + apiKeyPresent: true, + baseUrl: 'https://llm.example.test/v1', + model: 'gpt-5.5', + apiKind: 'openai_chat', + stream: true, + error: null, + agents: [ + { + agentId: 'design-director', + label: '拆解创作方向', + configured: true, + apiKeyPresent: true, + baseUrl: 'https://llm.example.test/v1', + model: 'gpt-5.5', + apiKind: 'openai_chat', + stream: true, + error: null, + }, + ], + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl', + agentId: args?.agentId, + messages: [], + }; + } + if (command === 'read_game_creator_agent_runtime') { + throw new Error('runtime json broken'); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAgentChatAt('/?agent-chat'); + + fireEvent.change(screen.getByLabelText('Agent 聊天项目目录'), { + target: { value: '/tmp/authorized-game' }, + }); + fireEvent.click(screen.getByRole('button', { name: '读取历史' })); + + expect(await screen.findByText(/已读取 0 条/)).not.toBeNull(); + expect(await screen.findByText('Runtime 状态读取失败')).not.toBeNull(); + expect(screen.getByText('runtime json broken')).not.toBeNull(); + }); + it('persists developer agent chat reply failures after saving the user message', async () => { const persistedMessages: Array<{ role: 'user' | 'assistant'; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 329f4f606..95da984e3 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4041,6 +4041,7 @@ - 2026-06-24 调整:同一本地项目多次 `game.generate_draft` 必须追加 `memory/session.md` 与 `memory/project.md`,不得覆盖历史对话和创作目标记录。 - 2026-07-01 调整:AI 游戏创作 App 在 `memory/session.md` 与 `memory/project.md` 之外新增项目级黑板 `memory/blackboard.md`,只记录重要跨 agent 决策、依赖和风险摘要;每个角色 agent 拥有私有记忆 `memory/agents//.md`。角色 brief 必须读取自己的私有记忆和项目黑板;`game.generate_draft` 通过 Evaluator 与 `game.static_smoke` 后,追加项目黑板摘要和各角色成功产出摘要,不得覆盖既有记忆。失败 run 仍只保留 trace 和 pass 快照,不写最终记忆摘要。 - 2026-07-06 调整:AI 游戏创作 App 主聊天普通文本改为进入主聊天 Agent,而不是直接排队 `game.generate_draft`;主聊天 Agent 读取短期记忆、长期记忆、项目黑板、最近项目对话和本地资产摘要作为背景,支持 `agentLlm.chat` 单独 provider 配置,但只做自然语言交互、澄清和 slash 命令建议,不写项目、不运行工具、不伪装生成结果。显式 `/generate <创作想法>` 或 `/draft <创作想法>` 才进入 `game.generate_draft` 待确认流。 +- 2026-07-09 调整:AI 游戏创作 App 新增 Agent Runtime V1 最小可观测状态。单 Agent 对话和生成 loop 中的角色 brief 必须写 `.agent/runtime/agents/.json` 与 `.agent/runtime/events/.jsonl`,记录 `agentId`、`taskId`、`sessionId`、`runId`、`source`、`status`、`phase`、当前任务 / 动作、计划、观测、允许工具、最近回复和错误;单 Agent 流式聊天事件要回传最新 `runtimeState`,开发单 Agent 聊天页和项目内单 Agent 对话弹窗只读展示该状态,读取失败必须可见提示,不得静默伪装为空状态。`source=agent-chat` 表示开发者单 Agent 对话,`source=generate-draft` 表示生成 loop 角色 brief;carry-over brief 只记录继承和完成,不伪装成重新调用 LLM。Runtime state 写入使用临时文件替换,event JSONL 读取跳过坏行,用户 prompt / 回复摘要进入 runtime 与 `agent.db` 前复用敏感上下文过滤;`.agent/runtime/` 是运行观测状态,不进入项目索引、checkpoint diff 或 restore 删除范围。该层仍是本地 JSONL 状态与事件,不引入 SQLite、常驻独立进程、远程 runner 或可中断上游 LLM 的承诺。 - 2026-07-01 调整:AI 游戏创作 App 借鉴 Godcoder 的本地工程护栏,但只收敛到五项本地机制:`ArtifactWriter` 写入前 checkpoint、写入后 diff、用户确认 restore;进入 LLM 前过滤密钥和本机配置痕迹;`.agent/agent.db` 继续作为轻量 JSONL 项目索引,`/index` 额外刷新 `.agent/project.index.json`;同一项目写入通过 `.agent/project.lock` 串行化;`.agent/policy.json` 记录项目级命令拒绝 / 确认策略。v1 不引入通用 IDE 插件、云工作区、SQLite 或任意 shell 代理。 - 2026-07-03 调整:主窗口最近 checkpoint 列表必须直接展示 checkpoint id、文件数、大小和创建时间,并提供直接对比、填入 `/diff`、确认回滚和填入 `/restore` 的轻量操作;回滚仍走 `project.restore` 确认卡,不在列表按钮中直接写项目文件。 - 2026-06-24 调整:普通用户通过聊天输入 `/help` 发现可用内置命令;命令发现必须留在聊天消息里,不得因此暴露开发面板。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 4041844d9..a5699d776 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -31,7 +31,7 @@ Agent Runtime 负责: - 用户能力:聊天入口、上传文件;正式用户窗口不展示任务、文件、预览、日志、能力清单或开发专用单 Agent 聊天入口。 - 开发窗口能力:debug 构建额外打开 `developer` 窗口,走 `index.html?agent-chat`;开发者可选择 Agent、授权本地项目路径,并通过 `read_local_conversation` / `append_local_conversation_message` 读写 `.agent/conversations/agents/.jsonl`,通过 `agentLlm.` 调用该 Agent 的独立 LLM 路由做真实对话,用于单独调试某个 Agent 的长期对话上下文。 - 命令能力:内置命令调用、权限 gate、执行日志;v1 只允许白名单受限命令,不执行任意 shell。 -- 编排能力:任务拆分、任务图依赖、专业组调度、多智能体协作。 +- 编排能力:任务拆分、任务图依赖、专业组调度、多智能体协作;Runtime V1 会为单 Agent 对话和生成 loop 中的角色 brief 写入独立 runtime state / event,先解决“每个 Agent 正在做什么、跑到哪一步、最近一次 task/run 是什么”的可观测性。 - 任务图能力:每轮 Orchestrator agenda、ready / active task 选择、Evaluator 结构化返工路由、返工轮 carry-over。 - 记忆能力:短期记忆 `memory/session.md`、长期记忆 `memory/project.md`、项目级黑板 `memory/blackboard.md` 和角色私有记忆 `memory/agents//.md`;黑板用于共享重要跨 agent 记忆,角色私有记忆只给对应角色 brief 读取和追加。最近 project / agent conversation 会作为短期 prompt 上下文读取,不替代正式 memory 文件。 - 对话能力:结构化对话记录统一落在 `.agent/conversations/` 的 append-only JSONL;普通聊天写 `.agent/conversations/project.jsonl`,进入单个 agent 后只写对应 `.agent/conversations/agents/.jsonl`,不把原始对话混进项目黑板或角色私有记忆。 @@ -66,6 +66,11 @@ game-project/ project.jsonl agents/ .jsonl + runtime/ + agents/ + .json + events/ + .jsonl activity.jsonl output.jsonl context.bundle.json @@ -264,10 +269,11 @@ game-project/ - loop 每次运行会写 `.agent/run.latest.json` 和 `.agent/runs/.json`,记录 `Planner` / `Orchestrator` agenda / 16 个组内角色 brief 或 carry-over / 6 个 `GroupCoordinator` 汇总 / `Generator` / 6 个专业组交接 / `Evaluator` 质量评审 / `ArtifactWriter` / `Playtest` step、每步 `toolCalls`、输入文件、输出文件、状态、轮次、maxPasses、toolCallCount、maxToolCalls、stopReason、nextStep 和错误摘要;Planner、角色 agent 和 Generator 的 `inputPaths` 必须包含对应记忆文件、`.agent/conversations/project.jsonl`、`.agent/conversations/agents/`、`.agent/manifest.json` 和 agenda 等上下文来源,其中角色 agent 必须包含自己的 `memory/agents//.md` 和 `memory/blackboard.md`;conversation 输入只取最近少量 project / agent 对话摘要,不读取全量历史;每个 step 必须带 phase、taskId、group 和 role,`.agent/run.latest.json.taskGraph` 必须记录 goal、readyTaskIds、activeTaskIds、carriedTaskIds、repairFocus、repairRoutes 和当前任务状态;`.agent/run.latest.json.passPlans` 必须按轮记录 mode、summary、activeTaskIds、carriedTaskIds、dependencyWaves、repairFocus 和 repairRoutes,作为 `/trace` 与开发窗口判断编排 loop 是否真实发生的直接证据;`run.latest.json` 是当前指针,`.agent/runs/` 保留历史 run trace;开发窗口读取 latest 展示编排过程,并复用 `file.list/read` 按文件修改时间先载入最近 20 个历史 run,滚动时再按批次读取剩余历史,普通用户窗口不展示。 - `.agent/run.latest.json` 的 `artifacts` 使用结构化记录,包含相对路径、字节数和 `fnv1a64:` checksum;除最终本地产物外,也会收集 `.agent/passes/pass-N/` 快照,便于确认返工前后的产物差异。 - 通过 Evaluator 和 `game.static_smoke` 后,Agent loop 会把本次 runId、状态、轮次、下一步、active / carry-over 任务和最终本地产物摘要追加到 `memory/session.md` 与 `memory/project.md`,把重要跨 agent 决策 / 依赖 / 风险摘要追加到 `memory/blackboard.md`,并把各角色本轮成功产出的角色摘要追加到 `memory/agents//.md`;下一次 Planner、组内角色和 Generator 会通过记忆输入自然读取上一轮稳定原型状态,而不只依赖开发窗口 trace。 -- 单 agent 对话入口读取对应 agent conversation;用户提交后先追加用户消息,再调用 `chat_with_game_creator_role_agent` 让对应 `agentLlm.` 结合项目上下文、Agent 私有记忆和本 Agent 历史对话生成回复,随后把回复写入对应 `.agent/conversations/agents/.jsonl`。单 agent 面板可把当前输入手动追加到对应 `memory/agents//.md`,写入前复用 `memory.write` 项目策略和本地项目锁;最近对话可作为本次生成 prompt 上下文读取,但只有经过显式总结、用户显式手动沉淀或生成 loop 成功沉淀的稳定结论,才追加到 `memory/blackboard.md` 或 `memory/agents//.md`。 +- 单 agent 对话入口读取对应 agent conversation;用户提交后先追加用户消息,再调用 `chat_with_game_creator_role_agent` / `chat_with_game_creator_role_agent_stream` 让对应 `agentLlm.` 结合项目上下文、Agent 私有记忆和本 Agent 历史对话生成回复,随后把回复写入对应 `.agent/conversations/agents/.jsonl`。每轮对话会同步写 `.agent/runtime/agents/.json` 和 `.agent/runtime/events/.jsonl`,字段包含 `agentId`、`taskId`、`sessionId`、`runId`、`source`、`status`、`phase`、`currentTask`、`currentAction`、`plan`、`observations`、`allowedTools`、`lastResponse` 和 `error`;流式事件会把最新 `runtimeState` 回传给界面。Runtime state 写入使用临时文件替换,event JSONL 读取会跳过坏行;`currentTask`、event detail、`lastResponse` 和 `agent.db` 摘要复用敏感上下文过滤,不保存明显 API Key / Bearer / Cookie 片段。单 agent 面板可把当前输入手动追加到对应 `memory/agents//.md`,写入前复用 `memory.write` 项目策略和本地项目锁;最近对话可作为本次生成 prompt 上下文读取,但只有经过显式总结、用户显式手动沉淀或生成 loop 成功沉淀的稳定结论,才追加到 `memory/blackboard.md` 或 `memory/agents//.md`。 +- 生成 loop 中的角色 brief 也写同一套 Agent Runtime state / event:active 角色用 `source=generate-draft` 和当前 `runId` 标记正在读取上下文、调用角色专属 LLM 或本地编排、生成 brief、完成或失败;carry-over 角色同样写入开始 / 完成事件,但不会伪装成重新调用 LLM。开发单 Agent 聊天页和项目内单 Agent 对话弹窗只读展示当前 Agent 的 runtime 状态、最近 task/run、阶段、动作、计划和观测;这只是 V1 可观测性,不代表已经有独立后台常驻进程或可中断任意上游 LLM 请求。 - `.agent/agent.db` 当前作为最小本地项目索引文件使用 JSONL:初始化写入 `project.init`,每次 `game.generate_draft` 追加目标、标题、本地产物路径、checkpoint 和 diff 摘要,上传 / 登记 / 画板导入资产时追加 `asset.register` 或 `asset.update`;v1 不引入 SQLite 依赖。 - `game.generate_draft`、资产登记 / 导入、记忆写入、预览状态写入、checkpoint / restore、agent 生命周期控制、画板资源回流 / 生成和 policy 写入会先按 `.agent/policy.json` 判断本次命令是否被项目策略拒绝,再拿项目级 `.agent/project.lock` 串行化;锁只保护同一本地项目,v1 不做后台锁管理。`confirmCommands` 可把索引、状态读取、资产登记、checkpoint、预览、agent 生命周期、画板资源回流 / 生成、memory 读写删除和 conversation 读写等命令转成项目策略确认,命中时用户确认后才执行;用户可用 `/policy-confirm 命令` 加入确认列表,用 `/policy-auto 命令` 移除确认项。 -- `ArtifactWriter` 写入最终产物前把当前项目文件保存到 `.agent/checkpoints//`,写入后把新增、修改、删除计数记录到 `.agent/agent.db`;聊天命令 `/checkpoint`、`/checkpoints`、`/diff checkpoint-id` 和 `/restore checkpoint-id` 允许用户手动保存、列出最近 checkpoint、对比和确认回滚到 checkpoint,回滚时会删除 checkpoint 后新增的受跟踪项目文件。 +- `ArtifactWriter` 写入最终产物前把当前项目文件保存到 `.agent/checkpoints//`,写入后把新增、修改、删除计数记录到 `.agent/agent.db`;聊天命令 `/checkpoint`、`/checkpoints`、`/diff checkpoint-id` 和 `/restore checkpoint-id` 允许用户手动保存、列出最近 checkpoint、对比和确认回滚到 checkpoint,回滚时会删除 checkpoint 后新增的受跟踪项目文件。`.agent/runtime/` 属于运行观测状态,不进入项目索引、checkpoint diff 或 restore 删除范围。 - Planner、组内角色和 Generator 读取上下文前会先做安全过滤:拒绝 `.env*`、`game-creator.config*`、Authorization / Cookie / API Key / Token / Bearer 等密钥样式内容,并清理 `sk-*` / `tnr_sk_*` token;memory、资产摘要和 conversation JSONL 中被过滤的内容不进入 LLM prompt。 - `.agent/run.latest.json` 的 schema 固定为共享契约 `GAME_CREATION_AGENT_RUN_SCHEMA_VERSION = game-creator-agent-run.v1`;TS 与 Rust 都从共享契约读取 run trace 类型,避免开发窗口和 Tauri 写入结构漂移。 - `.agent/run.latest.json` 增加可选 `lifecycleStatus`,把一次生成 run 映射到本地最小生命周期:`scheduled / running / waiting / pending / done / failed / killed`。聊天命令 `/agent-status` 读取最近 run,`/agent-kill` 标记为 `killed`,`/agent-retry` 与 `/agent-resume [说明]` 标记为 `pending`,并写入 `.agent/activity.jsonl`、`.agent/output.jsonl` 和 `.agent/context.bundle.json`;状态 / 控制结果消息可一键填入 `/read .agent/output.jsonl` 草稿继续查看 run 输出,Agent 状态栏也可填入 output / activity / context bundle 的 `/read` 草稿。v1 只做本地状态控制,不承诺真正中断已在上游执行中的 LLM 请求;后续引入独立 runner 后再把 `pending` 接入 claim。