Skip to content
Merged
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
34 changes: 33 additions & 1 deletion src/agent/branch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ 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::tools::{MemoryPersistenceContractState, MemoryPersistenceTerminalOutcome};
use crate::{AgentDeps, BranchId, ChannelId, ProcessEvent, ProcessId, ProcessType};
use rig::agent::AgentBuilder;
use rig::completion::CompletionModel;
Expand Down Expand Up @@ -160,6 +160,16 @@ impl Branch {
tracing::warn!(branch_id = %self.id, "branch hit max turns, returning partial result");
break partial;
}
Err(rig::completion::PromptError::PromptCancelled { reason, .. })
if SpacebotHook::is_memory_persistence_complete_reason(&reason) =>
{
self.hook.set_completion_contract_request_active(false);
tracing::info!(
branch_id = %self.id,
"memory persistence branch reached terminal outcome"
);
break self.memory_persistence_conclusion();
}
Err(rig::completion::PromptError::PromptCancelled { reason, .. })
if enforce_memory_contract
&& SpacebotHook::is_memory_persistence_contract_reason(&reason) =>
Expand Down Expand Up @@ -279,6 +289,28 @@ impl Branch {
Ok(conclusion)
}

/// Describe the recorded terminal outcome for the branch log. Memory
/// persistence branches complete silently, so this text is only ever
/// surfaced to operators.
fn memory_persistence_conclusion(&self) -> String {
match self
.memory_persistence_contract
.as_ref()
.and_then(|contract| contract.terminal_outcome())
{
Some(MemoryPersistenceTerminalOutcome::Saved { saved_memory_ids }) => {
format!(
"Memory persistence complete: {} memories saved.",
saved_memory_ids.len()
)
}
Some(MemoryPersistenceTerminalOutcome::NoMemories { reason }) => {
format!("Memory persistence complete: no memories saved ({reason}).")
}
None => "Memory persistence complete.".to_string(),
}
}

/// Compact history down to what the context window has left once this
/// branch's own preamble and prompt are accounted for, dropping the oldest
/// half of the messages at a time until it fits.
Expand Down
12 changes: 12 additions & 0 deletions src/agent/ingestion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,18 @@ fn classify_chunk_prompt_result(
);
Ok(())
}
// The chunk signalled its terminal outcome, so the loop stopped before
// the trailing LLM call. The caller verifies the outcome was recorded.
Err(PromptError::PromptCancelled { reason, .. })
if SpacebotHook::is_memory_persistence_complete_reason(&reason) =>
{
tracing::debug!(
file = %filename,
chunk = %format!("{chunk_number}/{total_chunks}"),
"chunk processed"
);
Ok(())
}
Err(PromptError::MaxTurnsError { .. }) => {
tracing::warn!(
file = %filename,
Expand Down
17 changes: 13 additions & 4 deletions src/agent/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -607,10 +607,13 @@ impl Worker {
.tool_server_handle(worker_tool_server)
.build();

// If this is a resumed worker, load the prior history into `history`
// (not `compacted_history`) so the LLM sees it as conversation context
// on the next follow-up call.
let resuming = self.prior_history.is_some();
// Seed `history` (not `compacted_history`) so the LLM sees prior
// messages as conversation context. Two distinct cases populate it: a
// resumed worker carrying its own transcript, and a fresh worker
// forking the channel's history. Only the former has already relayed a
// result, so resumption is keyed off the state the worker was built
// with, not off the presence of history.
let resuming = self.state == WorkerState::WaitingForInput;
let mut history = self.prior_history.take().unwrap_or_default();
let mut compacted_history = Vec::new();

Expand All @@ -622,6 +625,12 @@ impl Worker {
);
self.hook.send_status("resumed — waiting for input");
self.hook.send_worker_idle();
} else if !history.is_empty() {
tracing::info!(
worker_id = %self.id,
forked_messages = history.len(),
"worker seeded with forked channel history"
);
}

// Run the initial task in segments with compaction checkpoints
Expand Down
8 changes: 7 additions & 1 deletion src/conversation/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1028,7 +1028,13 @@ impl ProcessRunLogger {
.try_get::<chrono::DateTime<chrono::Utc>, _>("completed_at")
.ok()
.map(|t| t.to_rfc3339()),
transcript_blob: row.try_get("transcript").ok(),
// A NULL blob decodes to an empty vec rather than erroring, so
// read it as nullable and drop empties — callers treat `None` as
// "no persisted transcript" and fall back to the live cache.
transcript_blob: row
.try_get::<Option<Vec<u8>>, _>("transcript")
.unwrap_or(None)
.filter(|blob| !blob.is_empty()),
tool_calls: row.try_get::<i64, _>("tool_calls").unwrap_or(0),
opencode_session_id: row.try_get("opencode_session_id").ok(),
opencode_port: row.try_get::<i32, _>("opencode_port").ok(),
Expand Down
66 changes: 66 additions & 0 deletions src/hooks/spacebot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ impl SpacebotHook {
/// PromptCancelled reason used for memory-persistence contract retries.
pub const MEMORY_PERSISTENCE_CONTRACT_REASON: &str =
"spacebot_memory_persistence_contract_retry";
/// PromptCancelled reason used once a memory-persistence run has recorded
/// its terminal outcome and the loop should stop.
pub const MEMORY_PERSISTENCE_COMPLETE_REASON: &str = "spacebot_memory_persistence_complete";
/// Maximum nudge retries per prompt request.
pub const TOOL_NUDGE_MAX_RETRIES: usize = 2;
/// Maximum completion-contract retries per prompt request.
Expand Down Expand Up @@ -259,6 +262,12 @@ impl SpacebotHook {
reason == Self::MEMORY_PERSISTENCE_CONTRACT_REASON
}

/// Return true if a PromptCancelled reason indicates the memory-persistence
/// run recorded its terminal outcome and stopped deliberately.
pub fn is_memory_persistence_complete_reason(reason: &str) -> bool {
reason == Self::MEMORY_PERSISTENCE_COMPLETE_REASON
}

/// Drain and return all buffered injected messages.
pub fn take_injected_messages(&self) -> Vec<String> {
self.injected_messages
Expand Down Expand Up @@ -1366,12 +1375,20 @@ where
);
}

// The terminal completion tool ends a memory-persistence run. Stopping
// here skips the trailing LLM call that has nothing left to say —
// providers answer it with an empty message, which the response parser
// rejects and which surfaced as a failed branch even though every
// memory had already been persisted.
if !is_tool_error
&& tool_name == "memory_persistence_complete"
&& let Some(contract_state) = &self.memory_persistence_contract
&& let Some(outcome) = Self::parse_memory_persistence_terminal_outcome(result)
{
contract_state.set_terminal_outcome(outcome);
return HookAction::Terminate {
reason: Self::MEMORY_PERSISTENCE_COMPLETE_REASON.into(),
};
}

// A successful tool call proves the worker is still productive.
Expand Down Expand Up @@ -2449,4 +2466,53 @@ mod tests {

assert!(matches!(action, HookAction::Continue));
}

#[tokio::test]
async fn memory_persistence_terminal_outcome_stops_the_loop() {
for result in [
"{\"success\":true,\"outcome\":\"no_memories\",\"saved_memory_ids\":[],\"reason\":\"No durable facts found\"}",
"{\"success\":true,\"outcome\":\"saved\",\"saved_memory_ids\":[\"mem_real_1\"]}",
] {
let (hook, contract_state) = make_memory_persistence_hook();

let action = <SpacebotHook as PromptHook<SpacebotModel>>::on_tool_result(
&hook,
"memory_persistence_complete",
None,
"internal_1",
"{}",
result,
)
.await;

assert!(contract_state.has_terminal_outcome());
match action {
HookAction::Terminate { reason } => assert!(
SpacebotHook::is_memory_persistence_complete_reason(&reason),
"unexpected terminate reason: {reason}"
),
HookAction::Continue => {
panic!("terminal outcome should stop the loop before the trailing LLM call")
}
}
}
}

#[tokio::test]
async fn memory_persistence_tool_error_does_not_stop_the_loop() {
let (hook, contract_state) = make_memory_persistence_hook();

let action = <SpacebotHook as PromptHook<SpacebotModel>>::on_tool_result(
&hook,
"memory_persistence_complete",
None,
"internal_1",
"{}",
"Toolset error: memory_persistence_complete failed: saved_memory_ids mismatch",
)
.await;

assert!(!contract_state.has_terminal_outcome());
assert!(matches!(action, HookAction::Continue));
}
}
Loading