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
68 changes: 68 additions & 0 deletions src/agent/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1021,6 +1021,14 @@ impl Channel {
tracing::error!(%error, channel_id = %self.id, "error flushing coalesce buffer on shutdown");
}

// Persist any unsaved conversation context to memory before the channel
// closes. Without this, short-lived conversations (common on Discord and
// Telegram) that never reach the message_interval threshold would lose
// their context entirely.
if self.message_count > 0 {
self.force_memory_persistence().await;
}

tracing::info!(channel_id = %self.id, "channel stopped");
Ok(())
}
Expand Down Expand Up @@ -3069,6 +3077,36 @@ impl Channel {
}
}

/// Spawn a memory persistence branch unconditionally (ignoring the
/// message_interval threshold). Used on channel shutdown to flush any
/// unsaved conversation context that hasn't reached the periodic trigger.
async fn force_memory_persistence(&mut self) {
let config = **self.deps.runtime_config.memory_persistence.load();
if !config.enabled {
return;
}

self.message_count = 0;

match spawn_memory_persistence_branch(&self.state, &self.deps).await {
Ok(branch_id) => {
self.memory_persistence_branches.insert(branch_id);
Comment on lines +3089 to +3093
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If spawn_memory_persistence_branch fails, clearing message_count here drops the signal that there was unsaved context. Consider only zeroing the counter after a successful spawn.

Suggested change
self.message_count = 0;
match spawn_memory_persistence_branch(&self.state, &self.deps).await {
Ok(branch_id) => {
self.memory_persistence_branches.insert(branch_id);
match spawn_memory_persistence_branch(&self.state, &self.deps).await {
Ok(branch_id) => {
self.message_count = 0;
self.memory_persistence_branches.insert(branch_id);

tracing::info!(
channel_id = %self.id,
branch_id = %branch_id,
"memory persistence branch spawned on channel shutdown"
);
}
Err(error) => {
tracing::warn!(
channel_id = %self.id,
%error,
"failed to spawn memory persistence branch on channel shutdown"
);
}
}
}

/// If prompt capture is enabled for this channel, snapshot the current
/// system prompt sections and conversation history. The save is
/// fire-and-forget so it never blocks the agentic loop.
Expand Down Expand Up @@ -3282,4 +3320,34 @@ mod tests {

assert!(!should_process_event_for_channel(&event, &channel_id));
}

#[test]
fn memory_persistence_config_defaults_to_enabled() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These new tests are only asserting config fields (or even just config.enabled) and don’t exercise the shutdown flush behavior added above. I’d either drop them (to avoid a false sense of coverage) or replace with a regression that drives the channel shutdown path and asserts a memory-persistence branch is spawned when message_count > 0 and memory_persistence.enabled.

let config = crate::config::MemoryPersistenceConfig::default();
assert!(config.enabled);
assert_eq!(config.message_interval, 50);
}

#[test]
fn memory_persistence_disabled_config_gates_off() {
let config = crate::config::MemoryPersistenceConfig {
enabled: false,
message_interval: 10,
};
// force_memory_persistence returns early when disabled.
// check_memory_persistence returns early when disabled or interval is 0.
assert!(!config.enabled);
}

#[test]
fn memory_persistence_zero_interval_gates_off() {
let config = crate::config::MemoryPersistenceConfig {
enabled: true,
message_interval: 0,
};
// check_memory_persistence returns early when interval is 0.
// force_memory_persistence ignores interval (only checks enabled).
assert!(config.enabled);
assert_eq!(config.message_interval, 0);
}
}
54 changes: 25 additions & 29 deletions src/tools/send_message_to_another_channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,14 +150,13 @@ impl Tool for SendMessageTool {
// If explicit prefix returned default "signal" adapter but we're in a named
// Signal adapter conversation (e.g., signal:gvoice1), use the current adapter
// to ensure the message goes through the correct account.
if target.adapter == "signal" {
if let Some(current_adapter) = self
if target.adapter == "signal"
&& let Some(current_adapter) = self
.current_adapter
.as_ref()
.filter(|adapter| adapter.starts_with("signal:"))
{
target.adapter = current_adapter.clone();
}
{
target.adapter = current_adapter.clone();
}

self.messaging_manager
Expand Down Expand Up @@ -189,31 +188,28 @@ impl Tool for SendMessageTool {
.current_adapter
.as_ref()
.filter(|adapter| adapter.starts_with("signal"))
&& let Some(target) = parse_implicit_signal_shorthand(&args.target, current_adapter)
{
if let Some(target) = parse_implicit_signal_shorthand(&args.target, current_adapter) {
self.messaging_manager
.broadcast(
&target.adapter,
&target.target,
crate::OutboundResponse::Text(args.message),
)
.await
.map_err(|error| {
SendMessageError(format!("failed to send message: {error}"))
})?;

tracing::info!(
adapter = %target.adapter,
broadcast_target = %"[REDACTED]",
"message sent via implicit Signal shorthand"
);

return Ok(SendMessageOutput {
success: true,
target: target.target,
platform: target.adapter,
});
}
self.messaging_manager
.broadcast(
&target.adapter,
&target.target,
crate::OutboundResponse::Text(args.message),
)
.await
.map_err(|error| SendMessageError(format!("failed to send message: {error}")))?;

tracing::info!(
adapter = %target.adapter,
broadcast_target = %"[REDACTED]",
"message sent via implicit Signal shorthand"
);

return Ok(SendMessageOutput {
success: true,
target: target.target,
platform: target.adapter,
});
}

// Check for explicit email target
Expand Down
Loading