From 626b079fa3e5c4c6199e5138e71698da6c2e108e Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 11 Aug 2026 18:23:53 +1000 Subject: [PATCH] fix(buzz-agent): keep agent conversations out of the user's goose session DB goose's SessionManager::instance() resolves to Paths::data_dir(), so buzz-agent was persisting every turn into the same sessions.db as the user's own goose CLI history. On this machine that file is already 132 MB and 892 sessions, with desktop agent turns from minutes ago interleaved into it. Not a correctness bug -- SQLite handles concurrent writers -- but agent conversations do not belong in a human's personal session history, and buzz-agent should not grow a shared file it does not own and cannot prune. Resolves its own directory instead: BUZZ_AGENT_SESSION_DIR when absolute, else /buzz-agent, else goose's singleton. A relative env value is ignored rather than resolved against the agent's CWD, which is the user's workspace. Deliberately not GOOSE_PATH_ROOT: that would move goose's config and keyring lookups too, and provider configuration should keep resolving where goose puts it. Resolved once behind a OnceLock -- SessionManager::new builds its own SQLite pool, so a per-session call would open a pool per session against the same file. Verified: with BUZZ_AGENT_SESSION_DIR set, the turn's session lands in that directory and the shared DB is untouched. Co-authored-by: Michael Neale Signed-off-by: Michael Neale --- crates/buzz-agent/src/lib.rs | 5 +- crates/buzz-agent/src/loop_drive.rs | 2 +- crates/buzz-agent/src/session_store.rs | 107 +++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 crates/buzz-agent/src/session_store.rs diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index 073ce8c7f7..ea2981f34d 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -29,6 +29,7 @@ pub mod hooks; pub mod loop_drive; pub mod mesh; pub mod prompt; +pub mod session_store; pub mod steer; pub mod tools; pub mod types; @@ -49,7 +50,7 @@ use tokio_util::sync::CancellationToken; use goose::agents::{Agent, AgentConfig, ExtensionConfig, GoosePlatform}; use goose::config::PermissionManager; -use goose::session::session_manager::{SessionManager, SessionType}; +use goose::session::session_manager::SessionType; use config::{Config, MAX_PROMPT_BYTES, MAX_SYSTEM_PROMPT_BYTES, PROTOCOL_VERSION}; use types::McpServerStdio; @@ -121,7 +122,7 @@ async fn build_agent( ), AgentError, > { - let session_manager = Arc::new(SessionManager::instance()); + let session_manager = crate::session_store::session_manager(); let permission_manager = PermissionManager::instance(); let agent = Agent::with_config(AgentConfig::new( diff --git a/crates/buzz-agent/src/loop_drive.rs b/crates/buzz-agent/src/loop_drive.rs index 9e066fd6b2..e81242cdaf 100644 --- a/crates/buzz-agent/src/loop_drive.rs +++ b/crates/buzz-agent/src/loop_drive.rs @@ -115,7 +115,7 @@ pub async fn run_turn( prompt: Message, ) -> (Result, super::agent::TurnTokens) { let mut tokens = super::agent::TurnTokens::default(); - let sessions = SessionManager::instance(); + let sessions = crate::session_store::session_manager(); // The user's prompt is persisted before the first inference so a resumed // or inspected session sees the same history the model saw. diff --git a/crates/buzz-agent/src/session_store.rs b/crates/buzz-agent/src/session_store.rs new file mode 100644 index 0000000000..e9f72dd97d --- /dev/null +++ b/crates/buzz-agent/src/session_store.rs @@ -0,0 +1,107 @@ +//! Where buzz-agent's conversations are persisted. +//! +//! goose's [`SessionManager`] is the conversation store the loop reads and +//! writes each round. Its `instance()` singleton resolves to +//! `Paths::data_dir()` — for a normal install, the *user's own* goose data +//! directory. A buzz-agent using it would write its turns into the same +//! `sessions.db` as the person's `goose` CLI history: not a correctness bug +//! (SQLite handles concurrent writers), but wrong on two counts. Agent +//! conversations get interleaved with a human's personal sessions, and +//! buzz-agent grows an ever-larger file it does not own and cannot prune. +//! +//! So buzz-agent picks its own directory instead. `BUZZ_AGENT_SESSION_DIR` +//! (absolute) is the explicit knob; otherwise we derive a per-user default +//! under the OS data directory. Only if neither is available do we fall back +//! to goose's singleton, because a degraded shared store still beats refusing +//! to start. +//! +//! Note this is deliberately *not* `GOOSE_PATH_ROOT`: that would relocate +//! goose's config and keyring lookups too, and buzz-agent wants goose's +//! provider configuration to keep resolving exactly where goose puts it. + +use std::path::PathBuf; +use std::sync::Arc; + +use goose::session::session_manager::SessionManager; + +/// Absolute path to a directory buzz-agent should keep its session store in. +pub const SESSION_DIR_ENV: &str = "BUZZ_AGENT_SESSION_DIR"; + +/// Resolve the session-store directory, or `None` to use goose's default. +/// +/// Split out from [`session_manager`] so the precedence is testable without +/// touching the filesystem or building a SQLite pool. +fn resolve_dir(env_value: Option<&str>, data_dir: Option) -> Option { + // An explicit relative path is a misconfiguration we should not silently + // resolve against an arbitrary CWD -- the agent's CWD is the user's + // workspace, and a stray sessions.db there would be surprising. + if let Some(path) = env_value.map(PathBuf::from).filter(|p| p.is_absolute()) { + return Some(path); + } + data_dir.map(|dir| dir.join("buzz-agent")) +} + +/// The session store this process should use. +/// +/// Resolved once and shared: `SessionManager::new` builds its own SQLite pool, +/// so calling it per session or per round would open a new pool each time +/// against the same file. Every call site must get this one. +/// +/// Falls back to goose's shared singleton only when no private directory can +/// be determined at all. +pub fn session_manager() -> Arc { + static STORE: std::sync::OnceLock> = std::sync::OnceLock::new(); + STORE + .get_or_init(|| { + Arc::new( + match resolve_dir( + std::env::var(SESSION_DIR_ENV).ok().as_deref(), + dirs::data_dir(), + ) { + Some(dir) => { + tracing::debug!(dir = %dir.display(), "session store"); + SessionManager::new(dir) + } + None => { + tracing::warn!( + "no data dir available; falling back to goose's shared session store" + ); + SessionManager::instance() + } + }, + ) + }) + .clone() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_absolute_env_path_wins() { + let dir = resolve_dir(Some("/tmp/buzz-sessions"), Some(PathBuf::from("/data"))); + assert_eq!(dir, Some(PathBuf::from("/tmp/buzz-sessions"))); + } + + #[test] + fn a_relative_env_path_is_ignored() { + // Resolving this against the agent's CWD would drop a sessions.db in + // whatever workspace the user happened to open. + let dir = resolve_dir(Some("relative/path"), Some(PathBuf::from("/data"))); + assert_eq!(dir, Some(PathBuf::from("/data/buzz-agent"))); + } + + #[test] + fn the_default_is_private_to_buzz_agent() { + // The point of the whole module: never the bare goose data dir, which + // is where the user's own CLI history lives. + let dir = resolve_dir(None, Some(PathBuf::from("/data"))); + assert_eq!(dir, Some(PathBuf::from("/data/buzz-agent"))); + } + + #[test] + fn without_a_data_dir_we_defer_to_goose() { + assert_eq!(resolve_dir(None, None), None); + } +}