diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs index 27e2a5660..a59d3af87 100644 --- a/src/agent/loop_.rs +++ b/src/agent/loop_.rs @@ -267,6 +267,12 @@ async fn build_context(mem: &dyn Memory, user_msg: &str, min_relevance_score: f6 if memory::is_assistant_autosave_key(&entry.key) { continue; } + // Skip entries containing tool_result blocks — they can leak + // stale tool output from previous heartbeat ticks into new + // sessions, presenting the LLM with orphan tool_result data. + if entry.content.contains(") -> Vec { normalized } +/// Remove `` blocks (and a leading `[Tool results]` +/// header, if present) from a conversation-history entry so that stale tool +/// output is never presented to the LLM without the corresponding ``. +fn strip_tool_result_content(text: &str) -> String { + static TOOL_RESULT_RE: std::sync::LazyLock = std::sync::LazyLock::new(|| { + regex::Regex::new(r"(?s)]*>.*?").unwrap() + }); + + let cleaned = TOOL_RESULT_RE.replace_all(text, ""); + let cleaned = cleaned.trim(); + + // If the only remaining content is the header, drop it entirely. + if cleaned == "[Tool results]" || cleaned.is_empty() { + return String::new(); + } + + cleaned.to_string() +} + fn supports_runtime_model_switch(channel_name: &str) -> bool { matches!(channel_name, "telegram" | "discord" | "matrix") } @@ -956,6 +975,14 @@ fn should_skip_memory_context_entry(key: &str, content: &str) -> bool { return true; } + // Skip entries containing tool_result blocks. After a daemon restart + // these can be recalled from SQLite and injected as memory context, + // presenting the LLM with a `` without a preceding + // `` and triggering hallucinated output. + if content.contains(" MEMORY_CONTEXT_MAX_CHARS } @@ -1762,6 +1789,15 @@ async fn process_channel_message( .unwrap_or_default(); let mut prior_turns = normalize_cached_channel_turns(prior_turns_raw); + // Strip stale tool_result blocks from cached turns so the LLM never + // sees a `` without a preceding ``, which + // causes hallucinated output on subsequent heartbeat ticks or sessions. + for turn in &mut prior_turns { + if turn.content.contains("Mon Feb 20"# + )); + assert!(!should_skip_memory_context_entry( + "telegram_user_msg_201", + "plain text without tool results" + )); + } + + #[test] + fn strip_tool_result_content_removes_blocks_and_header() { + let input = r#"[Tool results] +Mon Feb 20 +{"status":200}"#; + assert_eq!(strip_tool_result_content(input), ""); + + let mixed = "Some context\nok\nMore text"; + let cleaned = strip_tool_result_content(mixed); + assert!(cleaned.contains("Some context")); + assert!(cleaned.contains("More text")); + assert!(!cleaned.contains("tool_result")); + + assert_eq!( + strip_tool_result_content("no tool results here"), + "no tool results here" + ); + assert_eq!(strip_tool_result_content(""), ""); } #[test]