Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions prompts/en/memory_persistence.md.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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`.
2 changes: 1 addition & 1 deletion prompts/en/tools/memory_save_description.md.j2
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Save a memory to long-term storage. Memories persist across conversations and can be recalled later via branches.
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.
11 changes: 10 additions & 1 deletion src/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down
23 changes: 12 additions & 11 deletions src/llm/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1064,19 +1064,20 @@ impl SpacebotModel {
return;
}
}
} else {
if let Ok(raw_event) = serde_json::from_str::<serde_json::Value>(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::<serde_json::Value>(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;
}
}
}
}
Expand Down
14 changes: 13 additions & 1 deletion src/memory/working.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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",
Expand All @@ -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),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -1057,6 +1067,8 @@ mod tests {
WorkingMemoryEventType::CronExecuted,
WorkingMemoryEventType::MemorySaved,
WorkingMemoryEventType::Decision,
WorkingMemoryEventType::UserCorrection,
WorkingMemoryEventType::DecisionRevised,
WorkingMemoryEventType::Error,
WorkingMemoryEventType::TaskUpdate,
WorkingMemoryEventType::AgentMessage,
Expand All @@ -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<WorkingMemoryEventType> = events.iter().map(|e| e.event_type).collect();
Expand Down
148 changes: 111 additions & 37 deletions src/tools/memory_persistence_complete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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": {
Expand All @@ -190,33 +196,7 @@ impl Tool for MemoryPersistenceCompleteTool {
async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
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(
Expand Down Expand Up @@ -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() {
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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
}));
}
}
Loading