Skip to content
2 changes: 1 addition & 1 deletion prompts/en/fragments/system/memory_persistence.md.j2
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Review the recent conversation and persist any important information as memories. Start by recalling existing memories related to the topics discussed, then save new or updated memories with appropriate associations.
Review the recent conversation and persist important information as memories. Required order: use memory_recall first, then memory_save when appropriate, then finish by calling memory_persistence_complete. Never invent memory IDs.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
You must finish this memory-persistence run by calling memory_persistence_complete. First recall relevant memories, then save real memories if needed, then call memory_persistence_complete with either outcome="saved" and exact saved_memory_ids from successful memory_save calls in this run, or outcome="no_memories" with a short reason and no saved IDs. Do not invent memory IDs.
7 changes: 6 additions & 1 deletion prompts/en/memory_persistence.md.j2
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,15 @@ This is an automatic process triggered periodically during conversation. You are
- Use `related_to` for topical connections
- Use `part_of` when a detail belongs to a larger concept already in memory

4. **Finish with the terminal tool.** You must call `memory_persistence_complete` before finishing:
- Use `outcome: "saved"` with `saved_memory_ids` that exactly match IDs returned by successful `memory_save` calls in this run.
- Use `outcome: "no_memories"` when nothing is worth saving, with a short `reason` and no saved IDs.

## Rules

1. Do NOT save trivial information — greetings, small talk, tool call details, intermediate reasoning.
2. Do NOT duplicate memories. If you recall an existing memory that covers the same fact, either skip it or save an update with an `updates` association.
3. Be concise in memory content. Memories are structured data, not conversation transcripts.
4. Focus on the most recent portion of the conversation — older content has likely already been captured by previous persistence runs.
5. Return a brief summary of what you saved (for logging only — the channel will not see this).
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`.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Finish a memory persistence run with a terminal outcome. Use outcome="saved" only with exact memory IDs returned by successful memory_save calls in this run, or outcome="no_memories" with a short reason and no IDs.
83 changes: 80 additions & 3 deletions src/agent/branch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@ use crate::error::Result;
use crate::hooks::SpacebotHook;
use crate::llm::SpacebotModel;
use crate::llm::routing::is_context_overflow_error;
use crate::tools::MemoryPersistenceContractState;
use crate::{AgentDeps, BranchId, ChannelId, ProcessEvent, ProcessId, ProcessType};
use rig::agent::AgentBuilder;
use rig::completion::CompletionModel;
use rig::tool::server::ToolServerHandle;
use std::sync::Arc;
use uuid::Uuid;

/// Max consecutive context overflow recoveries before giving up.
const MAX_OVERFLOW_RETRIES: usize = 2;
/// Max retries when a memory persistence branch misses terminal completion contract.
const MAX_MEMORY_CONTRACT_RETRIES: usize = 2;

/// A branch is a fork of a channel's context for thinking.
pub struct Branch {
Expand All @@ -29,6 +33,14 @@ pub struct Branch {
pub tool_server: ToolServerHandle,
/// Maximum LLM turns before the branch is forced to conclude.
pub max_turns: usize,
/// Optional completion contract state used only by silent memory-persistence branches.
pub memory_persistence_contract: Option<Arc<MemoryPersistenceContractState>>,
}

#[derive(Debug, Clone)]
pub struct BranchExecutionConfig {
pub max_turns: usize,
pub memory_persistence_contract: Option<Arc<MemoryPersistenceContractState>>,
}

impl Branch {
Expand All @@ -40,17 +52,20 @@ impl Branch {
system_prompt: impl Into<String>,
history: Vec<rig::message::Message>,
tool_server: ToolServerHandle,
max_turns: usize,
execution_config: BranchExecutionConfig,
) -> Self {
let id = Uuid::new_v4();
let process_id = ProcessId::Branch(id);
let hook = SpacebotHook::new(
let mut hook = SpacebotHook::new(
deps.agent_id.clone(),
process_id,
ProcessType::Branch,
Some(channel_id.clone()),
deps.event_tx.clone(),
);
if let Some(contract_state) = &execution_config.memory_persistence_contract {
hook = hook.with_memory_persistence_contract(contract_state.clone());
}

Self {
id,
Expand All @@ -61,7 +76,8 @@ impl Branch {
system_prompt: system_prompt.into(),
history,
tool_server,
max_turns,
max_turns: execution_config.max_turns,
memory_persistence_contract: execution_config.memory_persistence_contract,
}
}

Expand Down Expand Up @@ -102,27 +118,81 @@ impl Branch {

let mut current_prompt = prompt;
let mut overflow_retries = 0;
let mut memory_contract_retries = 0;
let enforce_memory_contract = self.memory_persistence_contract.is_some();

let conclusion = loop {
if enforce_memory_contract {
self.hook.set_completion_contract_request_active(true);
}
match self
.hook
.prompt_once(&agent, &mut self.history, &current_prompt)
.await
{
Ok(response) => break response,
Err(rig::completion::PromptError::MaxTurnsError { .. }) => {
self.hook.set_completion_contract_request_active(false);
if enforce_memory_contract {
tracing::warn!(
branch_id = %self.id,
"memory persistence branch exceeded turn limit without completing contract"
);
break "Memory persistence branch exceeded turn limit without completing the memory persistence contract."
.to_string();
}
let partial = crate::agent::extract_last_assistant_text(&self.history)
.unwrap_or_else(|| {
"Branch exhausted its turns without a final conclusion.".into()
});
tracing::warn!(branch_id = %self.id, "branch hit max turns, returning partial result");
break partial;
}
Err(rig::completion::PromptError::PromptCancelled { reason, .. })
if enforce_memory_contract
&& SpacebotHook::is_memory_persistence_contract_reason(&reason) =>
{
self.hook.set_completion_contract_request_active(false);
if matches!(
self.history.last(),
Some(rig::message::Message::Assistant { .. })
) {
self.history.pop();
}
memory_contract_retries += 1;
Comment thread
tomasmach marked this conversation as resolved.
if memory_contract_retries > MAX_MEMORY_CONTRACT_RETRIES {
tracing::warn!(
branch_id = %self.id,
retries = MAX_MEMORY_CONTRACT_RETRIES,
"memory persistence completion contract retries exhausted"
);
break "Memory persistence branch failed to produce a terminal completion outcome."
.to_string();
}

tracing::warn!(
branch_id = %self.id,
attempt = memory_contract_retries,
"memory persistence branch missing terminal completion outcome, retrying"
);
let prompt_engine = self.deps.runtime_config.prompts.load();
current_prompt = prompt_engine
.render_system_memory_persistence_contract_retry()
.unwrap_or_else(|_| {
SpacebotHook::MEMORY_PERSISTENCE_CONTRACT_PROMPT.to_string()
});
}
Comment thread
tomasmach marked this conversation as resolved.
Err(rig::completion::PromptError::PromptCancelled { reason, .. }) => {
if enforce_memory_contract {
self.hook.set_completion_contract_request_active(false);
}
tracing::info!(branch_id = %self.id, %reason, "branch cancelled");
break format!("Branch was cancelled: {reason}");
}
Err(error) if is_context_overflow_error(&error.to_string()) => {
if enforce_memory_contract {
self.hook.set_completion_contract_request_active(false);
}
overflow_retries += 1;
if overflow_retries > MAX_OVERFLOW_RETRIES {
tracing::error!(
Expand All @@ -146,12 +216,19 @@ impl Branch {
"Continue where you left off. Older context has been compacted.".into();
}
Err(error) => {
if enforce_memory_contract {
self.hook.set_completion_contract_request_active(false);
}
tracing::error!(branch_id = %self.id, %error, "branch LLM call failed");
return Err(crate::error::AgentError::Other(error.into()).into());
}
}
};

if enforce_memory_contract {
self.hook.set_completion_contract_request_active(false);
}

// Scrub tool secret values from the conclusion before sending to the
// channel. Branches can spawn workers whose output may contain secrets.
// Layer 1: exact-match redaction of known secrets from the store.
Expand Down
29 changes: 27 additions & 2 deletions src/agent/channel_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@
//! background processes: `spawn_branch_from_state`, `spawn_worker_from_state`,
//! and `spawn_opencode_worker_from_state`.

use crate::agent::branch::Branch;
use crate::agent::branch::{Branch, BranchExecutionConfig};
use crate::agent::channel::ChannelState;
use crate::agent::channel_prompt::TemporalContext;
use crate::agent::worker::Worker;
use crate::error::{AgentError, Error as SpacebotError};
use crate::tools::{BranchToolProfile, MemoryPersistenceContractState};
use crate::{AgentDeps, BranchId, ChannelId, ProcessEvent, WorkerId};
use futures::FutureExt as _;
use std::sync::Arc;
Expand Down Expand Up @@ -112,6 +113,11 @@ fn build_worker_status_text(
Some(system_info.render_for_worker(&current_time_line))
}

#[derive(Debug, Clone)]
struct BranchSpawnOptions {
profile: BranchToolProfile,
}

/// Spawn a branch from a ChannelState. Used by the BranchTool.
pub async fn spawn_branch_from_state(
state: &ChannelState,
Expand All @@ -134,6 +140,9 @@ pub async fn spawn_branch_from_state(
&system_prompt,
&description,
"branch",
BranchSpawnOptions {
profile: BranchToolProfile::Default,
},
)
.await
}
Expand All @@ -147,6 +156,8 @@ pub(crate) async fn spawn_memory_persistence_branch(
state: &ChannelState,
deps: &AgentDeps,
) -> std::result::Result<BranchId, AgentError> {
let contract_state = Arc::new(MemoryPersistenceContractState::default());

let prompt_engine = deps.runtime_config.prompts.load();
let system_prompt = prompt_engine
.render_static("memory_persistence")
Expand All @@ -162,6 +173,9 @@ pub(crate) async fn spawn_memory_persistence_branch(
&system_prompt,
"persisting memories...",
"memory_persistence_branch",
BranchSpawnOptions {
profile: BranchToolProfile::MemoryPersistence { contract_state },
},
)
.await
}
Expand Down Expand Up @@ -215,7 +229,14 @@ async fn spawn_branch(
system_prompt: &str,
status_label: &str,
dispatch_type: &'static str,
branch_options: BranchSpawnOptions,
) -> std::result::Result<BranchId, AgentError> {
let BranchSpawnOptions { profile } = branch_options;
let memory_persistence_contract = match &profile {
BranchToolProfile::MemoryPersistence { contract_state } => Some(contract_state.clone()),
BranchToolProfile::Default => None,
};

let max_branches = **state.deps.runtime_config.max_concurrent_branches.load();
{
let branches = state.active_branches.read().await;
Expand Down Expand Up @@ -243,6 +264,7 @@ async fn spawn_branch(
state.conversation_logger.clone(),
state.channel_store.clone(),
crate::conversation::ProcessRunLogger::new(state.deps.sqlite_pool.clone()),
profile,
);
let branch_max_turns = **state.deps.runtime_config.branch_max_turns.load();

Expand All @@ -253,7 +275,10 @@ async fn spawn_branch(
system_prompt,
history,
tool_server,
branch_max_turns,
BranchExecutionConfig {
max_turns: branch_max_turns,
memory_persistence_contract,
},
);

let branch_id = branch.id;
Expand Down
14 changes: 14 additions & 0 deletions src/agent/ingestion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use crate::ProcessType;
use crate::config::IngestionConfig;
use crate::hooks::SpacebotHook;
use crate::llm::SpacebotModel;
use crate::tools::MemoryPersistenceContractState;

use anyhow::Context as _;
use rig::agent::AgentBuilder;
Expand All @@ -24,6 +25,7 @@ use sqlx::SqlitePool;

use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use uuid::Uuid;

Expand Down Expand Up @@ -484,6 +486,7 @@ async fn process_chunk(
let conversation_logger =
crate::conversation::history::ConversationLogger::new(deps.sqlite_pool.clone());
let channel_store = crate::conversation::ChannelStore::new(deps.sqlite_pool.clone());
let contract_state = Arc::new(MemoryPersistenceContractState::default());
let tool_server: ToolServerHandle = crate::tools::create_branch_tool_server(
None,
deps.agent_id.clone(),
Expand All @@ -494,6 +497,9 @@ async fn process_chunk(
conversation_logger,
channel_store,
crate::conversation::ProcessRunLogger::new(deps.sqlite_pool.clone()),
crate::tools::BranchToolProfile::MemoryPersistence {
contract_state: contract_state.clone(),
},
);

let agent = AgentBuilder::new(model)
Expand All @@ -517,6 +523,14 @@ async fn process_chunk(
let result = hook.prompt_once(&agent, &mut history, &user_prompt).await;
classify_chunk_prompt_result(result, filename, chunk_number, total_chunks)?;

if !contract_state.has_terminal_outcome() {
tracing::warn!(
file = %filename,
chunk = %format!("{chunk_number}/{total_chunks}"),
"ingestion chunk completed without memory_persistence_complete signal"
);
}

Ok(())
}

Expand Down
42 changes: 16 additions & 26 deletions src/agent/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
use crate::agent::compactor::estimate_history_tokens;
use crate::config::BrowserConfig;
use crate::error::Result;
use crate::hooks::{SpacebotHook, ToolNudgePolicy};
use crate::hooks::SpacebotHook;
use crate::llm::SpacebotModel;
use crate::llm::routing::{is_context_overflow_error, is_retriable_error};
use crate::{AgentDeps, ChannelId, ProcessId, ProcessType, WorkerId};
Expand Down Expand Up @@ -546,38 +546,28 @@ impl Worker {
let mut follow_up_prompt = follow_up.clone();
let mut follow_up_overflow_retries = 0;
let mut follow_up_transient_retries = 0u32;
let follow_up_hook = self
.hook
.clone()
.with_tool_nudge_policy(ToolNudgePolicy::Disabled);

let follow_up_result: std::result::Result<String, String> = loop {
match follow_up_hook
.prompt_once(&agent, &mut history, &follow_up_prompt)
match self
.hook
.prompt_with_tool_nudge_retry(&agent, &mut history, &follow_up_prompt)
.await
{
Ok(response) => break Ok(response),
Err(rig::completion::PromptError::PromptCancelled {
ref reason, ..
}) if SpacebotHook::is_context_injection_reason(reason) => {
// Context injection during a follow-up: drain
// buffered messages, append to history, and
// re-prompt — same as the main task loop.
let injected = follow_up_hook.take_injected_messages();
for message in &injected {
tracing::info!(
worker_id = %self.id,
"injecting context into worker follow-up history"
);
history.push(rig::message::Message::user(format!(
"[Context update from the user]: {message}"
)));
}
follow_up_prompt = "New context has been provided above. \
Incorporate this information and continue working \
on your task. Do not repeat completed work."
.to_string();
continue;
}) if SpacebotHook::is_tool_nudge_reason(reason) => {
let failure_reason = format!(
"follow-up ended without terminal outcome after {} nudge retries",
SpacebotHook::TOOL_NUDGE_MAX_RETRIES
);
self.write_failure_log(&history, &failure_reason);
tracing::warn!(
worker_id = %self.id,
%reason,
"follow-up completion contract retries exhausted"
);
break Err(failure_reason);
}
Err(error) if is_context_overflow_error(&error.to_string()) => {
follow_up_overflow_retries += 1;
Expand Down
Loading
Loading