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
6 changes: 5 additions & 1 deletion prompts/en/memory_persistence.md.j2
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,17 @@ This is an automatic process triggered periodically during conversation. You are
- `"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
- `"deadline_set"` when the conversation establishes a concrete due date, deadline, or scheduled milestone
- `"blocked_on"` when progress is waiting on an approval, dependency, missing input, or external action
- `"constraint"` when the user or system states a hard requirement, limitation, or non-negotiable boundary
- `"outcome"` when a task, branch, or delegated step reaches a clear terminal result worth retaining in temporal memory
- `"error"` for failures or problems
- `"system"` for other notable events
- `summary`: one-line description of what happened
- 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)
- `importance`: 0.0-1.0 score (`decision`, `user_correction`, `decision_revised`, `blocked_on`, and `outcome` 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 Down
155 changes: 145 additions & 10 deletions src/agent/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,63 @@ fn should_flush_coalesce_buffer_for_event(event: &ProcessEvent) -> bool {
)
}

fn classify_conversational_event_summary(
summary: &str,
default_event_type: crate::memory::WorkingMemoryEventType,
) -> (crate::memory::WorkingMemoryEventType, String) {
let trimmed = summary.trim();
if trimmed.is_empty() {
return (default_event_type, String::new());
}

if let Some((prefix, rest)) = trimmed.split_once(':') {
let rest_trimmed = rest.trim();
let prefix = prefix.trim();
if prefix.eq_ignore_ascii_case("outcome") {
return (
crate::memory::WorkingMemoryEventType::Outcome,
rest_trimmed.to_string(),
);
}
if prefix.eq_ignore_ascii_case("blocked_on") {
return (
crate::memory::WorkingMemoryEventType::BlockedOn,
rest_trimmed.to_string(),
);
}
if prefix.eq_ignore_ascii_case("constraint") {
return (
crate::memory::WorkingMemoryEventType::Constraint,
rest_trimmed.to_string(),
);
}
}

(default_event_type, trimmed.to_string())
}

fn format_conversational_event_summary(
event_type: crate::memory::WorkingMemoryEventType,
source: &str,
event_summary: &str,
) -> String {
let label = match event_type {
crate::memory::WorkingMemoryEventType::Outcome => "outcome",
crate::memory::WorkingMemoryEventType::BlockedOn => "blocked on",
crate::memory::WorkingMemoryEventType::Constraint => "constraint",
crate::memory::WorkingMemoryEventType::Error => "failed",
crate::memory::WorkingMemoryEventType::BranchCompleted
| crate::memory::WorkingMemoryEventType::WorkerCompleted => "completed",
_ => "concluded",
};

if event_summary.is_empty() {
format!("{source} {label}")
} else {
format!("{source} {label}: {event_summary}")
}
}

/// Shared state that channel tools need to act on the channel.
///
/// Wrapped in Arc and passed to tools (branch, spawn_worker, route, cancel)
Expand Down Expand Up @@ -2954,11 +3011,19 @@ impl Channel {
} else {
conclusion.clone()
};
let (event_type, event_summary) = classify_conversational_event_summary(
&summary,
crate::memory::WorkingMemoryEventType::BranchCompleted,
);
self.deps
.working_memory
.emit(
crate::memory::WorkingMemoryEventType::BranchCompleted,
format!("Branch concluded: {summary}"),
event_type,
format_conversational_event_summary(
event_type,
"Branch",
&event_summary,
),
)
.channel(self.id.to_string())
.importance(0.7)
Expand Down Expand Up @@ -3027,20 +3092,18 @@ impl Channel {
} else {
result.clone()
};
let event_type = if *success {
let default_event_type = if *success {
crate::memory::WorkingMemoryEventType::WorkerCompleted
} else {
crate::memory::WorkingMemoryEventType::Error
};
let (event_type, event_summary) =
classify_conversational_event_summary(&worker_summary, default_event_type);
self.deps
.working_memory
.emit(
event_type,
if *success {
format!("Worker completed: {worker_summary}")
} else {
format!("Worker failed: {worker_summary}")
},
format_conversational_event_summary(event_type, "Worker", &event_summary),
)
.channel(self.id.to_string())
.importance(if *success { 0.6 } else { 0.8 })
Expand Down Expand Up @@ -3617,11 +3680,12 @@ fn is_dm_conversation_id(conv_id: &str) -> bool {
#[cfg(test)]
mod tests {
use super::{
QuietModeFallbackState, compute_listen_mode_invocation, is_dm_conversation_id,
QuietModeFallbackState, classify_conversational_event_summary,
compute_listen_mode_invocation, format_conversational_event_summary, is_dm_conversation_id,
recv_channel_event, should_process_event_for_channel,
should_send_discord_quiet_mode_ping_ack, should_send_quiet_mode_fallback,
};
use crate::memory::MemoryType;
use crate::memory::{MemoryType, WorkingMemoryEventType};
use crate::{AgentId, ChannelId, InboundMessage, MessageContent, ProcessEvent, ProcessId};
use std::collections::HashMap;
use std::sync::Arc;
Expand Down Expand Up @@ -3790,6 +3854,77 @@ mod tests {
assert!(!should_process_event_for_channel(&event, &channel_id));
}

#[test]
fn conversational_event_summary_extracts_outcome_prefix() {
let (event_type, summary) = classify_conversational_event_summary(
"outcome: implemented the migration safety check",
WorkingMemoryEventType::WorkerCompleted,
);
assert_eq!(event_type, WorkingMemoryEventType::Outcome);
assert_eq!(summary, "implemented the migration safety check");
}

#[test]
fn conversational_event_summary_extracts_blocked_on_prefix() {
let (event_type, summary) = classify_conversational_event_summary(
"blocked_on: waiting for review from infra",
WorkingMemoryEventType::Error,
);
assert_eq!(event_type, WorkingMemoryEventType::BlockedOn);
assert_eq!(summary, "waiting for review from infra");
}

#[test]
fn conversational_event_summary_falls_back_to_default_type() {
let (event_type, summary) = classify_conversational_event_summary(
"completed with no blockers",
WorkingMemoryEventType::WorkerCompleted,
);
assert_eq!(event_type, WorkingMemoryEventType::WorkerCompleted);
assert_eq!(summary, "completed with no blockers");
}

#[test]
fn conversational_event_summary_extracts_constraint_prefix_case_insensitively() {
let (event_type, summary) = classify_conversational_event_summary(
"CoNsTrAiNt: must keep migrations immutable",
WorkingMemoryEventType::WorkerCompleted,
);
assert_eq!(event_type, WorkingMemoryEventType::Constraint);
assert_eq!(summary, "must keep migrations immutable");
}

#[test]
fn conversational_event_summary_is_case_insensitive_across_prefixes() {
let (event_type, summary) = classify_conversational_event_summary(
"OUTCOME: implemented the follow-up",
WorkingMemoryEventType::WorkerCompleted,
);
assert_eq!(event_type, WorkingMemoryEventType::Outcome);
assert_eq!(summary, "implemented the follow-up");

let (event_type, summary) = classify_conversational_event_summary(
"Blocked_On: waiting on reviewer signoff",
WorkingMemoryEventType::WorkerCompleted,
);
assert_eq!(event_type, WorkingMemoryEventType::BlockedOn);
assert_eq!(summary, "waiting on reviewer signoff");
}

#[test]
fn conversational_event_summary_treats_empty_prefixed_content_as_empty_summary() {
let (event_type, summary) = classify_conversational_event_summary(
"outcome: ",
WorkingMemoryEventType::WorkerCompleted,
);
assert_eq!(event_type, WorkingMemoryEventType::Outcome);
assert!(summary.is_empty());
assert_eq!(
format_conversational_event_summary(event_type, "Worker", &summary),
"Worker outcome"
);
}

#[test]
fn quiet_mode_invocation_uses_discord_mention_and_reply_metadata() {
let message = inbound_message(
Expand Down
32 changes: 29 additions & 3 deletions src/memory/working.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ pub enum WorkingMemoryEventType {
UserCorrection,
/// A prior decision was revised.
DecisionRevised,
/// A concrete deadline or due date was set.
DeadlineSet,
/// Progress is currently blocked on an external dependency or prerequisite.
BlockedOn,
/// An explicit constraint was stated.
Constraint,
/// A task or branch reached a terminal result.
Outcome,
/// An error or failure occurred.
Error,
/// A task was created or updated.
Expand All @@ -68,6 +76,10 @@ impl WorkingMemoryEventType {
Self::Decision => "decision",
Self::UserCorrection => "user_correction",
Self::DecisionRevised => "decision_revised",
Self::DeadlineSet => "deadline_set",
Self::BlockedOn => "blocked_on",
Self::Constraint => "constraint",
Self::Outcome => "outcome",
Self::Error => "error",
Self::TaskUpdate => "task_update",
Self::AgentMessage => "agent_message",
Expand All @@ -87,6 +99,10 @@ impl WorkingMemoryEventType {
"decision" => Some(Self::Decision),
"user_correction" => Some(Self::UserCorrection),
"decision_revised" => Some(Self::DecisionRevised),
"deadline_set" => Some(Self::DeadlineSet),
"blocked_on" => Some(Self::BlockedOn),
"constraint" => Some(Self::Constraint),
"outcome" => Some(Self::Outcome),
"error" => Some(Self::Error),
"task_update" => Some(Self::TaskUpdate),
"agent_message" => Some(Self::AgentMessage),
Expand Down Expand Up @@ -765,6 +781,10 @@ fn format_event_line(event: &WorkingMemoryEvent, current_channel_id: &str) -> St
WorkingMemoryEventType::Decision => "Decision",
WorkingMemoryEventType::UserCorrection => "User correction",
WorkingMemoryEventType::DecisionRevised => "Decision revised",
WorkingMemoryEventType::DeadlineSet => "Deadline set",
WorkingMemoryEventType::BlockedOn => "Blocked on",
WorkingMemoryEventType::Constraint => "Constraint",
WorkingMemoryEventType::Outcome => "Outcome",
WorkingMemoryEventType::Error => "Error",
WorkingMemoryEventType::TaskUpdate => "Task update",
WorkingMemoryEventType::AgentMessage => "Agent message",
Expand Down Expand Up @@ -1060,7 +1080,7 @@ mod tests {
let store = setup_test_store().await;
let today = store.today();

for event_type in [
let inserted = [
WorkingMemoryEventType::BranchCompleted,
WorkingMemoryEventType::WorkerSpawned,
WorkingMemoryEventType::WorkerCompleted,
Expand All @@ -1069,13 +1089,19 @@ mod tests {
WorkingMemoryEventType::Decision,
WorkingMemoryEventType::UserCorrection,
WorkingMemoryEventType::DecisionRevised,
WorkingMemoryEventType::DeadlineSet,
WorkingMemoryEventType::BlockedOn,
WorkingMemoryEventType::Constraint,
WorkingMemoryEventType::Outcome,
WorkingMemoryEventType::Error,
WorkingMemoryEventType::TaskUpdate,
WorkingMemoryEventType::AgentMessage,
WorkingMemoryEventType::System,
WorkingMemoryEventType::MemoryPromoted,
WorkingMemoryEventType::MemoryDemoted,
] {
];

for event_type in inserted {
let event = WorkingMemoryEvent {
id: Uuid::new_v4().to_string(),
event_type,
Expand All @@ -1091,7 +1117,7 @@ mod tests {
}

let events = store.get_events_for_day(&today).await.unwrap();
assert_eq!(events.len(), 14);
assert_eq!(events.len(), inserted.len());

// Verify all types survived the roundtrip.
let types: Vec<WorkingMemoryEventType> = events.iter().map(|e| e.event_type).collect();
Expand Down
Loading