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
164 changes: 151 additions & 13 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 @@ -944,9 +1001,11 @@ impl Channel {
"/quiet" | "/observe" => {
self.set_response_mode(ResponseMode::Observe).await;
self.send_builtin_text(
"observe mode enabled. i'll learn from this conversation but won't respond.".to_string(),
"observe mode enabled. i'll learn from this conversation but won't respond."
.to_string(),
"observe",
).await;
)
.await;
return Ok(true);
}
"/active" => {
Expand Down Expand Up @@ -976,7 +1035,8 @@ impl Channel {
"- /tasks: ready task list".to_string(),
"- /digest: one-shot day digest (00:00 -> now)".to_string(),
"- /observe: learn from conversation, never respond".to_string(),
"- /mention-only: only respond when @mentioned, replied to, or given a command".to_string(),
"- /mention-only: only respond when @mentioned, replied to, or given a command"
.to_string(),
"- /active: normal reply mode".to_string(),
"- /agent-id: runtime agent id".to_string(),
];
Expand Down Expand Up @@ -3008,11 +3068,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 @@ -3081,20 +3149,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 @@ -3671,11 +3737,12 @@ fn is_dm_conversation_id(conv_id: &str) -> bool {
#[cfg(test)]
mod tests {
use super::{
ObserveModeFallbackState, compute_listen_mode_invocation, is_dm_conversation_id,
ObserveModeFallbackState, 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 @@ -3844,6 +3911,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
4 changes: 3 additions & 1 deletion src/config/load.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,9 @@ fn parse_response_mode(
// Backwards compat: listen_only_mode maps to response_mode
match listen_only_mode {
Some(true) => {
tracing::warn!("listen_only_mode is deprecated, use response_mode = \"observe\" instead");
tracing::warn!(
"listen_only_mode is deprecated, use response_mode = \"observe\" instead"
);
Some(ResponseMode::Observe)
}
Some(false) => Some(ResponseMode::Active),
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