diff --git a/prompts/en/memory_persistence.md.j2 b/prompts/en/memory_persistence.md.j2 index 054d5fe5d..3a60a0bd5 100644 --- a/prompts/en/memory_persistence.md.j2 +++ b/prompts/en/memory_persistence.md.j2 @@ -28,9 +28,17 @@ This is an automatic process triggered periodically during conversation. You are - Use `part_of` when a detail belongs to a larger concept already in memory 4. **Extract events.** While reviewing the conversation, identify key decisions, important events, and errors. Include them in the `events` field of `memory_persistence_complete`: - - `event_type`: "decision" for commitments or choices made, "error" for failures or problems, "system" for other notable events + - `event_type`: + - `"decision"` for commitments or choices made + - `"user_correction"` when the user corrects a prior assumption, instruction, or framing + - `"decision_revised"` when a prior choice changes after feedback or new information + - `"error"` for failures or problems + - `"system"` for other notable events - `summary`: one-line description of what happened - - `importance`: 0.0-1.0 score (decisions and errors typically 0.6-0.8) + - Normalize relative time references to absolute dates/times with timezone + (for example `2026-03-31T14:20:00-04:00`) so downstream memory checks are + stable across sessions. + - `importance`: 0.0-1.0 score (`decision`, `user_correction`, and `decision_revised` are typically 0.6-0.8) - Events feed the agent's temporal working memory — they help the agent remember *what happened today*, not just facts. 5. **Finish with the terminal tool.** You must call `memory_persistence_complete` before finishing: @@ -46,3 +54,14 @@ This is an automatic process triggered periodically during conversation. You are 4. Focus on the most recent portion of the conversation — older content has likely already been captured by previous persistence runs. 5. Do not invent memory IDs. Every ID in `saved_memory_ids` must come from a real successful `memory_save` call in this run. 6. Do not return plain text as the terminal result. End the run by calling `memory_persistence_complete`. + +7. Exclude non-durable information: + - Raw repo-state or derivable state such as `ls`, `pwd`, environment output, + git status/history, or full file diffs unless it materially changes the + plan or decision. + - Ephemeral task chatter: retries, progress updates, temporary tool output, + or worker process chatter. + +8. Verify stale memory before relying on it. If a recalled memory conflicts with +the newer conversation context, treat the older item as stale, avoid propagating +it as truth, and capture the latest truth via `updates` or `contradicts`. diff --git a/prompts/en/tools/memory_save_description.md.j2 b/prompts/en/tools/memory_save_description.md.j2 index fe29c116b..e4054e8db 100644 --- a/prompts/en/tools/memory_save_description.md.j2 +++ b/prompts/en/tools/memory_save_description.md.j2 @@ -1 +1 @@ -Save a memory to long-term storage. Memories persist across conversations and can be recalled later via branches. \ No newline at end of file +Save a validated, durable memory to long-term storage. Use this only for information that should persist across conversations and be recalled later via branches. diff --git a/src/config/types.rs b/src/config/types.rs index 887b023a7..1a3b7ae47 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -643,11 +643,20 @@ pub struct CompactionConfig { /// Spawns a silent branch every N messages to recall existing memories and save /// new ones from the recent conversation. Runs without blocking the channel and /// the result is never injected into channel history. +/// +/// Legacy note: active working-memory persistence triggers are now configured in +/// `WorkingMemoryConfig` (`persistence_message_threshold`, +/// `persistence_time_threshold_secs`, `persistence_event_density_threshold`). +/// Keep these values in sync only if you intentionally preserve this legacy +/// branch cadence. #[derive(Debug, Clone, Copy)] pub struct MemoryPersistenceConfig { /// Whether auto memory persistence branches are enabled. pub enabled: bool, - /// Number of user messages between automatic memory persistence branches. + /// Legacy branch cadence in user messages. + /// + /// Runtime checks now use the working-memory thresholds in + /// `WorkingMemoryConfig`. pub message_interval: usize, } diff --git a/src/llm/model.rs b/src/llm/model.rs index 5d7d7bc1e..ac418e44a 100644 --- a/src/llm/model.rs +++ b/src/llm/model.rs @@ -1064,19 +1064,20 @@ impl SpacebotModel { return; } } - } else { - if let Ok(raw_event) = serde_json::from_str::(data) { - match process_openai_responses_stream_raw_event(&raw_event, &mut pending_tool_calls) { - Ok(events) => { - for event in events { - yield Ok(event); - } - } - Err(error) => { - yield Err(error); - return; + } else if let Ok(raw_event) = serde_json::from_str::(data) { + match process_openai_responses_stream_raw_event( + &raw_event, + &mut pending_tool_calls, + ) { + Ok(events) => { + for event in events { + yield Ok(event); } } + Err(error) => { + yield Err(error); + return; + } } } } diff --git a/src/memory/working.rs b/src/memory/working.rs index cad9a586f..7d1bff0d2 100644 --- a/src/memory/working.rs +++ b/src/memory/working.rs @@ -39,6 +39,10 @@ pub enum WorkingMemoryEventType { MemorySaved, /// A decision was made (extracted from conversation). Decision, + /// The user corrected prior instructions or assumptions. + UserCorrection, + /// A prior decision was revised. + DecisionRevised, /// An error or failure occurred. Error, /// A task was created or updated. @@ -62,6 +66,8 @@ impl WorkingMemoryEventType { Self::CronExecuted => "cron_executed", Self::MemorySaved => "memory_saved", Self::Decision => "decision", + Self::UserCorrection => "user_correction", + Self::DecisionRevised => "decision_revised", Self::Error => "error", Self::TaskUpdate => "task_update", Self::AgentMessage => "agent_message", @@ -79,6 +85,8 @@ impl WorkingMemoryEventType { "cron_executed" => Some(Self::CronExecuted), "memory_saved" => Some(Self::MemorySaved), "decision" => Some(Self::Decision), + "user_correction" => Some(Self::UserCorrection), + "decision_revised" => Some(Self::DecisionRevised), "error" => Some(Self::Error), "task_update" => Some(Self::TaskUpdate), "agent_message" => Some(Self::AgentMessage), @@ -755,6 +763,8 @@ fn format_event_line(event: &WorkingMemoryEvent, current_channel_id: &str) -> St WorkingMemoryEventType::CronExecuted => "Cron executed", WorkingMemoryEventType::MemorySaved => "Memory saved", WorkingMemoryEventType::Decision => "Decision", + WorkingMemoryEventType::UserCorrection => "User correction", + WorkingMemoryEventType::DecisionRevised => "Decision revised", WorkingMemoryEventType::Error => "Error", WorkingMemoryEventType::TaskUpdate => "Task update", WorkingMemoryEventType::AgentMessage => "Agent message", @@ -1057,6 +1067,8 @@ mod tests { WorkingMemoryEventType::CronExecuted, WorkingMemoryEventType::MemorySaved, WorkingMemoryEventType::Decision, + WorkingMemoryEventType::UserCorrection, + WorkingMemoryEventType::DecisionRevised, WorkingMemoryEventType::Error, WorkingMemoryEventType::TaskUpdate, WorkingMemoryEventType::AgentMessage, @@ -1079,7 +1091,7 @@ mod tests { } let events = store.get_events_for_day(&today).await.unwrap(); - assert_eq!(events.len(), 12); + assert_eq!(events.len(), 14); // Verify all types survived the roundtrip. let types: Vec = events.iter().map(|e| e.event_type).collect(); diff --git a/src/tools/memory_persistence_complete.rs b/src/tools/memory_persistence_complete.rs index f2a17ca5c..e7e9c8b8f 100644 --- a/src/tools/memory_persistence_complete.rs +++ b/src/tools/memory_persistence_complete.rs @@ -109,7 +109,7 @@ pub struct MemoryPersistenceCompleteArgs { /// A single event extracted by the persistence branch for the working memory log. #[derive(Debug, Clone, Deserialize, JsonSchema)] pub struct WorkingMemoryEventInput { - /// Event type: "decision", "error", or "system". + /// Event type: "decision", "user_correction", "decision_revised", "error", or "system". pub event_type: String, /// One-line summary of the event. pub summary: String, @@ -165,8 +165,14 @@ impl Tool for MemoryPersistenceCompleteTool { "type": "object", "properties": { "event_type": { - "type": "string", - "enum": ["decision", "error", "system"], + "type": "string", + "enum": [ + "decision", + "user_correction", + "decision_revised", + "error", + "system" + ], "description": "Type of event" }, "summary": { @@ -190,33 +196,7 @@ impl Tool for MemoryPersistenceCompleteTool { async fn call(&self, args: Self::Args) -> Result { let outcome = args.outcome.trim(); let recorded_ids = self.state.saved_memory_ids(); - - // Write any extracted events to working memory (fire-and-forget). - if let Some(working_memory) = &self.working_memory { - for event_input in &args.events { - let event_type = match event_input.event_type.as_str() { - "decision" => crate::memory::WorkingMemoryEventType::Decision, - "error" => crate::memory::WorkingMemoryEventType::Error, - _ => crate::memory::WorkingMemoryEventType::System, - }; - let importance = event_input.importance.clamp(0.0, 1.0); - let mut builder = working_memory - .emit(event_type, &event_input.summary) - .importance(importance); - if let Some(channel_id) = &self.channel_id { - builder = builder.channel(channel_id.clone()); - } - builder.record(); - } - if !args.events.is_empty() { - tracing::info!( - event_count = args.events.len(), - "persistence branch extracted events into working memory" - ); - } - } - - match outcome { + let output = match outcome { "saved" => { if args.saved_memory_ids.is_empty() { return Err(MemoryPersistenceCompleteError( @@ -246,12 +226,12 @@ impl Tool for MemoryPersistenceCompleteTool { ))); } - Ok(MemoryPersistenceCompleteOutput { + MemoryPersistenceCompleteOutput { success: true, outcome: "saved".to_string(), saved_memory_ids: provided_ids, reason: None, - }) + } } "no_memories" => { if !args.saved_memory_ids.is_empty() { @@ -273,17 +253,52 @@ impl Tool for MemoryPersistenceCompleteTool { )); } - Ok(MemoryPersistenceCompleteOutput { + MemoryPersistenceCompleteOutput { success: true, outcome: "no_memories".to_string(), saved_memory_ids: Vec::new(), reason: Some(reason.trim().to_string()), - }) + } + } + _ => { + return Err(MemoryPersistenceCompleteError(format!( + "invalid outcome '{outcome}'; expected 'saved' or 'no_memories'" + ))); + } + }; + + // Write extracted events to working memory only after validation succeeds. + if let Some(working_memory) = &self.working_memory { + for event_input in &args.events { + let event_type = + match crate::memory::WorkingMemoryEventType::parse(&event_input.event_type) { + Some(event_type) => event_type, + None => { + tracing::trace!( + raw_event_type = %event_input.event_type, + "unrecognized event_type, falling back to System" + ); + crate::memory::WorkingMemoryEventType::System + } + }; + let importance = event_input.importance.clamp(0.0, 1.0); + let mut builder = working_memory + .emit(event_type, &event_input.summary) + .importance(importance); + if let Some(channel_id) = &self.channel_id { + builder = builder.channel(channel_id.clone()); + } + builder.record(); + } + if !args.events.is_empty() { + tracing::info!( + event_count = args.events.len(), + "persistence branch extracted events into working memory" + ); } - _ => Err(MemoryPersistenceCompleteError(format!( - "invalid outcome '{outcome}'; expected 'saved' or 'no_memories'" - ))), } + + Ok(output) } } @@ -349,4 +364,63 @@ mod tests { assert!(output.success); assert_eq!(output.outcome, "no_memories"); } + + #[tokio::test] + async fn persists_conversational_events() { + let state = Arc::new(MemoryPersistenceContractState::default()); + + let pool = sqlx::SqlitePool::connect("sqlite::memory:") + .await + .expect("sqlite connect"); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("migrations"); + + let working_memory = crate::memory::WorkingMemoryStore::new(pool, chrono_tz::Tz::UTC); + let tool = MemoryPersistenceCompleteTool::new(state) + .with_working_memory(working_memory.clone(), Some("test-channel".to_string())); + + tool.call(MemoryPersistenceCompleteArgs { + outcome: "no_memories".to_string(), + saved_memory_ids: Vec::new(), + reason: Some("Nothing worth retaining".to_string()), + events: vec![ + WorkingMemoryEventInput { + event_type: "user_correction".to_string(), + summary: "User corrected a payment split assumption".to_string(), + importance: 0.8, + }, + WorkingMemoryEventInput { + event_type: "decision_revised".to_string(), + summary: "Decision changed after user feedback".to_string(), + importance: 0.8, + }, + ], + }) + .await + .expect("persistence complete should pass"); + + let events = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let events = working_memory + .get_events_for_channel("test-channel", 10) + .await + .expect("working memory query"); + if events.len() == 2 { + break events; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("timed out waiting for working memory events"); + assert_eq!(events.len(), 2); + assert!(events.iter().any(|event| { + event.event_type == crate::memory::WorkingMemoryEventType::UserCorrection + })); + assert!(events.iter().any(|event| { + event.event_type == crate::memory::WorkingMemoryEventType::DecisionRevised + })); + } }