diff --git a/Cargo.lock b/Cargo.lock index b5b3fc8..e4a00e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1462,6 +1462,7 @@ dependencies = [ "tokio-stream", "tracing", "urlencoding", + "uuid", "which", ] diff --git a/crates/cowork-core/src/agents/mod.rs b/crates/cowork-core/src/agents/mod.rs index f974ea7..49c675f 100644 --- a/crates/cowork-core/src/agents/mod.rs +++ b/crates/cowork-core/src/agents/mod.rs @@ -545,7 +545,7 @@ pub fn create_project_manager_agent(model: Arc, iteration_id: String) - let agent = LlmAgentBuilder::new("project_manager_agent") .instruction(&instruction) .model(model) - .tool(Arc::new(PMGotoStageTool)) + .tool(Arc::new(PMGotoStageTool::new(iteration_id.clone()))) .tool(Arc::new(PMCreateIterationTool::new(iteration_id.clone()))) .tool(Arc::new(PMRespondTool)) .tool(Arc::new(PMSaveDecisionTool::new(iteration_id.clone()))) @@ -877,9 +877,31 @@ pub async fn execute_pm_agent_message_streaming( agent_message = "处理完成".to_string(); } + // Deduplicate actions - keep only the first occurrence of each unique action + let mut seen_stages: std::collections::HashSet = std::collections::HashSet::new(); + let mut seen_iterations: std::collections::HashSet = std::collections::HashSet::new(); + let mut unique_actions: Vec = Vec::new(); + + for action in detected_actions { + match &action { + PMAgentAction::GotoStage { target_stage, .. } => { + if !seen_stages.contains(target_stage) { + seen_stages.insert(target_stage.clone()); + unique_actions.push(action); + } + } + PMAgentAction::CreateIteration { iteration_id, .. } => { + if !seen_iterations.contains(iteration_id) { + seen_iterations.insert(iteration_id.clone()); + unique_actions.push(action); + } + } + } + } + Ok(PMAgentResult { message: agent_message, - actions: detected_actions, + actions: unique_actions, parts: all_parts, }) } diff --git a/crates/cowork-core/src/domain/memory.rs b/crates/cowork-core/src/domain/memory.rs index 0afd190..05743af 100644 --- a/crates/cowork-core/src/domain/memory.rs +++ b/crates/cowork-core/src/domain/memory.rs @@ -1,5 +1,6 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use uuid::Uuid; /// Project-level memory (across iterations) #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -153,7 +154,7 @@ impl Decision { let now = Utc::now(); let iteration_id = iteration_id.into(); Self { - id: format!("dec-{}-{}", iteration_id, now.timestamp()), + id: format!("dec-{}-{}", iteration_id, Uuid::new_v4()), title: title.into(), context: context.into(), decision: decision.into(), @@ -186,7 +187,7 @@ impl Pattern { let now = Utc::now(); let iteration_id = iteration_id.into(); Self { - id: format!("pat-{}-{}", iteration_id, now.timestamp()), + id: format!("pat-{}-{}", iteration_id, Uuid::new_v4()), name: name.into(), description: description.into(), usage: Vec::new(), diff --git a/crates/cowork-core/src/tools/pm_tools.rs b/crates/cowork-core/src/tools/pm_tools.rs index 005ac10..4fa5db5 100644 --- a/crates/cowork-core/src/tools/pm_tools.rs +++ b/crates/cowork-core/src/tools/pm_tools.rs @@ -19,7 +19,15 @@ use super::get_optional_string_param; // PM Goto Stage Tool - Restart pipeline from a specific stage // ============================================================================ -pub struct PMGotoStageTool; +pub struct PMGotoStageTool { + current_iteration_id: String, +} + +impl PMGotoStageTool { + pub fn new(current_iteration_id: String) -> Self { + Self { current_iteration_id } + } +} #[async_trait] impl Tool for PMGotoStageTool { @@ -70,6 +78,9 @@ impl Tool for PMGotoStageTool { } }; + // Set iteration ID for storage operations BEFORE saving feedback + crate::storage::set_iteration_id(self.current_iteration_id.clone()); + // Save feedback for the target stage (not "pm_agent") // This allows the target stage to find its feedback when loading from storage let feedback = Feedback { diff --git a/crates/cowork-gui/src-tauri/Cargo.toml b/crates/cowork-gui/src-tauri/Cargo.toml index fbf385c..51ef085 100644 --- a/crates/cowork-gui/src-tauri/Cargo.toml +++ b/crates/cowork-gui/src-tauri/Cargo.toml @@ -22,6 +22,7 @@ tokio = { version = "1", features = ["sync", "full"] } tokio-stream = "0.1" async-trait = "0.1" chrono = "0.4" +uuid = { workspace = true } anyhow = "1" thiserror = "2" futures = "0.3" diff --git a/crates/cowork-gui/src-tauri/src/commands/memory.rs b/crates/cowork-gui/src-tauri/src/commands/memory.rs index defe8c9..892f5d7 100644 --- a/crates/cowork-gui/src-tauri/src/commands/memory.rs +++ b/crates/cowork-gui/src-tauri/src/commands/memory.rs @@ -1,4 +1,5 @@ use cowork_core::persistence::MemoryStore; +use uuid::Uuid; #[tauri::command] pub async fn query_memory_index( @@ -50,7 +51,8 @@ pub async fn query_memory_index( for i in &mem.insights { if matches_stage(Some(&i.stage)) { results.push(serde_json::json!({ - "id": format!("insight-{}", i.created_at.timestamp()), + "id": format!("insight-{}", Uuid::new_v4()), + "_ts": i.created_at.timestamp(), "title": format!("Insight from {} stage", i.stage), "summary": i.content.chars().take(200).collect::(), "category": "decision", @@ -68,7 +70,8 @@ pub async fn query_memory_index( for i in &mem.issues { if matches_stage(Some(&i.stage)) { results.push(serde_json::json!({ - "id": format!("issue-{}", i.created_at.timestamp()), + "id": format!("issue-{}", Uuid::new_v4()), + "_ts": i.created_at.timestamp(), "title": format!("Issue from {} stage", i.stage), "summary": i.content.chars().take(200).collect::(), "category": "experience", @@ -85,7 +88,8 @@ pub async fn query_memory_index( if matches_category("pattern") || category.is_none() || category.as_deref() == Some("all") { for l in &mem.learnings { results.push(serde_json::json!({ - "id": format!("learning-{}", l.created_at.timestamp()), + "id": format!("learning-{}", Uuid::new_v4()), + "_ts": l.created_at.timestamp(), "title": "Learning", "summary": l.content.chars().take(200).collect::(), "category": "pattern", @@ -153,6 +157,7 @@ pub async fn load_memory_detail( memory_id: String, file: Option, iteration_id: Option, + ts: Option, ) -> Result { let store = MemoryStore::new(); @@ -189,14 +194,14 @@ pub async fn load_memory_detail( } } - // Check iteration memory + // Check iteration memory using ts parameter for lookup if let Some(iter_id) = iteration_id { let iter_mem = store.load_iteration_memory(&iter_id).map_err(|e| e.to_string())?; - if memory_id.starts_with("insight-") { - if let Ok(ts) = memory_id.replace("insight-", "").parse::() { + if let Some(timestamp) = ts { + if memory_id.starts_with("insight-") { for i in &iter_mem.insights { - if i.created_at.timestamp() == ts { + if i.created_at.timestamp() == timestamp { return Ok(serde_json::json!({ "id": memory_id, "content": i.content, @@ -209,12 +214,10 @@ pub async fn load_memory_detail( } } } - } - if memory_id.starts_with("issue-") { - if let Ok(ts) = memory_id.replace("issue-", "").parse::() { + if memory_id.starts_with("issue-") { for i in &iter_mem.issues { - if i.created_at.timestamp() == ts { + if i.created_at.timestamp() == timestamp { return Ok(serde_json::json!({ "id": memory_id, "content": i.content, @@ -227,12 +230,10 @@ pub async fn load_memory_detail( } } } - } - if memory_id.starts_with("learning-") { - if let Ok(ts) = memory_id.replace("learning-", "").parse::() { + if memory_id.starts_with("learning-") { for l in &iter_mem.learnings { - if l.created_at.timestamp() == ts { + if l.created_at.timestamp() == timestamp { return Ok(serde_json::json!({ "id": memory_id, "content": l.content, @@ -296,14 +297,15 @@ pub async fn save_session_memory( pub async fn promote_to_project_memory( memory_id: String, iteration_id: String, + ts: Option, ) -> Result { let store = MemoryStore::new(); let iter_mem = store.load_iteration_memory(&iteration_id).map_err(|e| e.to_string())?; - if memory_id.starts_with("insight-") { - if let Ok(ts) = memory_id.replace("insight-", "").parse::() { + if let Some(timestamp) = ts { + if memory_id.starts_with("insight-") { for i in &iter_mem.insights { - if i.created_at.timestamp() == ts { + if i.created_at.timestamp() == timestamp { let dec = cowork_core::domain::Decision::new( "Insight", "", @@ -315,12 +317,10 @@ pub async fn promote_to_project_memory( } } } - } - if memory_id.starts_with("learning-") { - if let Ok(ts) = memory_id.replace("learning-", "").parse::() { + if memory_id.starts_with("learning-") { for l in &iter_mem.learnings { - if l.created_at.timestamp() == ts { + if l.created_at.timestamp() == timestamp { let pat = cowork_core::domain::Pattern::new( "Learning", &l.content, diff --git a/crates/cowork-gui/src/components/MemoryPanel.tsx b/crates/cowork-gui/src/components/MemoryPanel.tsx index 50ae63f..fdfe86b 100644 --- a/crates/cowork-gui/src/components/MemoryPanel.tsx +++ b/crates/cowork-gui/src/components/MemoryPanel.tsx @@ -21,6 +21,7 @@ interface Memory { impact?: string; tags?: string[]; file?: string; + _ts?: number; } interface MemoryDetail { @@ -81,7 +82,7 @@ const MemoryPanel: React.FC = ({ currentSession, refreshTrigge setDetailLoading(true); try { const detail = await invoke("load_memory_detail", { - memoryId: memory.id, file: memory.file, iterationId: currentSession || null, + memoryId: memory.id, file: memory.file, iterationId: currentSession || null, ts: memory._ts, }); setMemoryDetail(detail); } catch (error) {