#![forbid(unsafe_code)] mod agent; pub mod auth; mod builtin; pub mod catalog; pub mod config; mod handoff; mod hints; mod llm; mod mcp; pub mod types; mod wire; pub use catalog::{discover_databricks_models, ModelEntry, DATABRICKS_V2_KNOWN_MODELS}; pub use config::Provider; pub use types::AgentError; /// Environment keys the Windows Git Bash resolver may inspect. `spawn_one()` /// forwards every key in this list into its otherwise-cleared MCP child; Doctor /// uses the same contract so a ready agent can always start its shell tool. #[cfg(windows)] pub const WINDOWS_SHELL_RESOLUTION_ENV: &[&str] = &[ "PATH", "BUZZ_SHELL", "GIT_BASH", "SystemRoot", "ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA", ]; use std::collections::HashMap; use std::path::Path; use std::sync::Arc; use serde_json::{json, Value}; use tokio::io::BufReader; use tokio::sync::{mpsc, watch, Mutex}; use crate::agent::RunCtx; use crate::config::{Config, MAX_SYSTEM_PROMPT_BYTES, PROTOCOL_VERSION}; use crate::hints::SkillEntry; use crate::llm::Llm; use crate::mcp::McpRegistry; use crate::types::{ContentBlock, HistoryItem}; use crate::wire::{ classify, goose_session_update, Inbound, InitializeParams, SessionCancelParams, SessionNewParams, SessionPromptParams, SessionSetModelParams, SessionSteerParams, WireMsg, WireSender, INVALID_PARAMS, METHOD_NOT_FOUND, PARSE_ERROR, }; struct App { cfg: Config, llm: Arc, sessions: Mutex>, /// Cached model catalog for Databricks providers. Populated lazily on the /// first successful `session/new` discovery call. When discovery fails (e.g. /// auth missing or a transient network error) the cell is intentionally left /// empty so the next `session/new` call retries — a transient failure never /// pins the degraded fallback catalog for the process lifetime. models_cache: tokio::sync::OnceCell>, } struct Session { id: String, mcp: Arc, /// Skills discovered at session creation; used by the built-in `load_skill` tool. skills: Vec, history: Vec, cancel_tx: watch::Sender, busy: bool, /// Run id of the in-flight prompt, set when a prompt starts and cleared /// when it ends. `None` means no active run — a steer request targeting /// this session is rejected. Steer-capable clients learn this value from /// the `params.update._meta.goose.activeRunId` field on `session/update`. active_run_id: Option, /// Sender for mid-turn steer messages. Created fresh per prompt (like /// `cancel_tx`); the running prompt loop holds the matching receiver and /// drains queued steers at round boundaries. `None` when no prompt is in /// flight. steer_tx: Option>>, original_task: Option, handoff_count: usize, /// Cache-summed input tokens the provider reported for this session's most /// recent request, or `None` before the first response (or after a handoff /// resets the context). Drives the token-based handoff gate; see /// [`RunCtx::should_handoff`]. last_request_input_tokens: Option, /// History byte size when `last_request_input_tokens` was measured, paired /// with it so the gate can account for history appended since. last_request_history_bytes: Option, effective_system_prompt: Arc, /// Per-session model override set by `session/set_model`. When `Some`, /// overrides `App::cfg.model` for all LLM calls on this session. Persists /// across `session/prompt` calls until changed. effective_model: Option, /// Session-cumulative input tokens across all turns. Sent in the /// `_goose/unstable/session/update` usage notification so buzz-acp's /// `UsageTracker` can compute per-turn deltas symmetrically with goose. accumulated_input_tokens: u64, /// Session-cumulative output tokens across all turns. accumulated_output_tokens: u64, /// Session-cumulative cache-served input tokens across all turns — a subset /// of `accumulated_input_tokens`, not an addition to it. Emitted alongside /// it so a consumer can price the cached slice at the provider's discounted /// rate instead of assuming every input token cost full price. accumulated_cached_input_tokens: u64, /// Session-cumulative total-token state across all turns. /// /// Mirrors the per-turn `TurnTotalState` tri-state: starts `Unseen`, /// becomes `Exact(n)` as turns with genuine provider totals complete, /// transitions permanently to `Unknown` when any turn lacks a total or /// when the cumulative would otherwise decrease. Only emitted in the /// `usage_update` notification when `Exact`. accumulated_total_state: crate::types::TurnTotalState, } fn die(msg: String) -> ! { tracing::error!("{msg}"); std::process::exit(2); } pub fn run() -> Result<(), Box> { let args: Vec = std::env::args().collect(); if matches!(args.get(1).map(String::as_str), Some("auth")) { return tokio::runtime::Builder::new_multi_thread() .enable_all() .build()? .block_on(auth_subcommand(&args[2..])); } tokio::runtime::Builder::new_multi_thread() .enable_all() .build()? .block_on(async_main()); Ok(()) } /// `buzz-agent auth ` — run the interactive auth flow for a /// provider and persist the result, then exit. Today this supports Databricks /// OAuth 2.0 PKCE. Reads `DATABRICKS_HOST` from env; needs a browser on the /// machine. async fn auth_subcommand(args: &[String]) -> Result<(), Box> { let provider = args.first().map(String::as_str); match provider { Some("databricks" | "databricks_v2" | "databricks-v2") => { let host = std::env::var("DATABRICKS_HOST") .map_err(|_| "auth databricks: DATABRICKS_HOST required")?; let pkce = auth::PkceOAuthConfig { discovery_url: format!( "{}/oidc/.well-known/oauth-authorization-server", host.trim_end_matches('/') ), client_id: "databricks-cli".into(), scopes: vec!["all-apis".into(), "offline_access".into()], cache_namespace: "databricks".into(), cache_dir_override: None, }; let src = auth::PkceOAuthTokenSource::new(pkce)?; src.interactive_login().await?; eprintln!("Authenticated. Token cached under ~/.config/buzz-agent/oauth/databricks/."); Ok(()) } Some(other) => Err(format!("auth: unknown provider {other:?}").into()), None => Err("auth: provider required (try: buzz-agent auth databricks)".into()), } } async fn async_main() { tracing_subscriber::fmt() .with_writer(std::io::stderr) .with_ansi(false) .init(); let cfg = Config::from_env().unwrap_or_else(|e| die(e)); let llm = Arc::new(Llm::new(&cfg).unwrap_or_else(|e| die(e.to_string()))); let max_line = cfg.max_line_bytes; let app = Arc::new(App { cfg, llm, sessions: Mutex::new(HashMap::new()), models_cache: tokio::sync::OnceCell::new(), }); let (wire_tx, wire_rx) = mpsc::channel::(64); let writer = tokio::spawn(wire::writer_task(wire_rx)); if let Err(e) = read_loop( BufReader::new(tokio::io::stdin()), app.clone(), wire_tx, max_line, ) .await { tracing::error!("io: reader: {e}"); } for session in app.sessions.lock().await.values() { let _ = session.cancel_tx.send(true); } let _ = writer.await; } async fn read_loop( mut stdin: R, app: Arc, wire_tx: WireSender, max_line: usize, ) -> std::io::Result<()> { while let Some(line) = wire::read_bounded_line(&mut stdin, max_line).await? { if line.trim().is_empty() { continue; } match serde_json::from_str::(&line) { Ok(msg) => dispatch(&app, msg, &wire_tx).await, Err(e) => { wire::send( &wire_tx, wire::err(Value::Null, PARSE_ERROR, &format!("jsonrpc: parse: {e}")), ) .await; } } } Ok(()) } async fn dispatch(app: &Arc, msg: Value, wire_tx: &WireSender) { match classify(&msg) { Inbound::Request { id, method, params } => { handle_request(app, id, method, params, wire_tx).await } Inbound::Notification { method, params } => handle_notification(app, &method, params).await, Inbound::Ignored => {} Inbound::Invalid { id, code, message } => { wire::send(wire_tx, wire::err(id, code, &message)).await } } } async fn handle_request( app: &Arc, id: Value, method: String, params: Value, wire_tx: &WireSender, ) { match method.as_str() { "initialize" => initialize(id, params, wire_tx).await, "session/new" => { let app = app.clone(); let wire_tx = wire_tx.clone(); tokio::spawn(async move { session_new(&app, id, params, &wire_tx).await }); } "session/prompt" => spawn_prompt(app.clone(), id, params, wire_tx.clone()), "session/set_model" => { set_model_session(app, id, params, wire_tx).await; } "session/cancel" => { cancel_session(app, params).await; wire::send(wire_tx, wire::ok(id, Value::Null)).await; } // goose-compatible non-standard extension: inject user input into the // currently active prompt without starting a new one. Mirrors goose's // `_goose/unstable/session/steer` wire contract so a single client-side // delivery path serves both agents. "_goose/unstable/session/steer" => { steer_session(app, id, params, wire_tx).await; } _ => { wire::send( wire_tx, wire::err( id, METHOD_NOT_FOUND, &format!("jsonrpc: method not found: {method}"), ), ) .await } } } async fn handle_notification(app: &Arc, method: &str, params: Value) { if method == "session/cancel" { cancel_session(app, params).await; } } async fn initialize(id: Value, params: Value, wire_tx: &WireSender) { let p: InitializeParams = match decode(params, "initialize") { Ok(p) => p, Err(m) => return reject(wire_tx, id, INVALID_PARAMS, &m).await, }; // Honest negotiation: respond with the minimum of what the client // requested and what we support. // NOTE: gating `[Base]` injection on `protocol_version < 2` is a deliberate // temporary measure — we are squatting on ACP v2 ahead of the upstream ACP // RFD. Revisit when that RFD merges; otherwise a genuine upstream-v2 agent // would silently lose `[Base]`. let negotiated_version = p.protocol_version.min(PROTOCOL_VERSION); wire::send( wire_tx, wire::ok( id, json!({ "protocolVersion": negotiated_version, "agentCapabilities": { "loadSession": false, "promptCapabilities": { "image": false, "audio": false, "embeddedContext": false }, "mcpCapabilities": { "http": false, "sse": false }, }, "agentInfo": { "name": "buzz-agent", "version": env!("CARGO_PKG_VERSION") }, }), ), ) .await; } /// Resolve the Databricks model catalog for one `session/new` call. /// /// Tries to use a previously-cached successful discovery result. If the cache is empty, /// runs `discover` and — on success — populates the cache for future calls. On failure /// the cell is intentionally left empty so the next session retries; the provider-aware /// fallback is returned for the immediate response only. /// /// Extracted from `session_new` so that tests can drive this path with an injected /// discovery future without requiring a full `App` / transport stack. async fn resolve_models_catalog( cache: &tokio::sync::OnceCell>, provider: crate::config::Provider, model: &str, discover: impl std::future::Future, AgentError>>, ) -> Vec { match cache.get_or_try_init(|| discover).await { Ok(cached) => cached.clone(), Err(e) => { tracing::warn!( "model catalog discovery failed: {e}; using fallback (will retry next session)" ); crate::catalog::discovery_failure_fallback(provider, model) } } } async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { let p: SessionNewParams = match decode(params, "session/new") { Ok(p) => p, Err(m) => return reject(wire_tx, id, INVALID_PARAMS, &m).await, }; if p.cwd.is_empty() || !Path::new(&p.cwd).is_absolute() { return reject( wire_tx, id, INVALID_PARAMS, "session/new: cwd must be an absolute path", ) .await; } // Check cap without holding lock across MCP spawn (which may be slow). { let sessions = app.sessions.lock().await; if sessions.len() >= app.cfg.max_sessions { return reject( wire_tx, id, INVALID_PARAMS, "session/new: max sessions reached", ) .await; } } let (hints_text, skills) = if app.cfg.hints_enabled { hints::build_hints_section(std::path::Path::new(&p.cwd)) } else { (String::new(), Vec::new()) }; let effective_system_prompt: Arc = { // When the harness provides a systemPrompt (base_prompt + persona), use // it as the primary content and suppress the default. The default is only // a fallback for legacy harnesses that don't send systemPrompt. let base = match p.system_prompt.as_deref() { Some(client_prompt) if !client_prompt.trim().is_empty() => client_prompt.to_owned(), _ => app.cfg.system_prompt.clone(), }; let prompt = if hints_text.is_empty() { base } else { format!("{base}\n\n{hints_text}") }; // Reject combined prompts exceeding 512KB. if prompt.len() > MAX_SYSTEM_PROMPT_BYTES { return reject( wire_tx, id, INVALID_PARAMS, &format!( "session/new: combined system prompt exceeds {}KB limit ({} bytes)", MAX_SYSTEM_PROMPT_BYTES / 1024, prompt.len() ), ) .await; } Arc::from(prompt) }; let mcp = match McpRegistry::spawn_all(&app.cfg, &p.mcp_servers, &p.cwd).await { Ok(m) => Arc::new(m), Err(e) => return reject(wire_tx, id, e.json_rpc_code(), &e.to_string()).await, }; let session_id = match session_token() { Ok(t) => format!("ses_{t}"), Err(e) => return reject(wire_tx, id, -32000, &e).await, }; let (cancel_tx, _) = watch::channel(false); let mut sessions = app.sessions.lock().await; // Re-check cap (another session may have been created while we spawned MCP). if sessions.len() >= app.cfg.max_sessions { return reject( wire_tx, id, INVALID_PARAMS, "session/new: max sessions reached", ) .await; } sessions.insert( session_id.clone(), Session { id: session_id.clone(), mcp, skills, history: Vec::new(), cancel_tx, busy: false, active_run_id: None, steer_tx: None, original_task: None, handoff_count: 0, last_request_input_tokens: None, last_request_history_bytes: None, effective_system_prompt, effective_model: None, accumulated_input_tokens: 0, accumulated_output_tokens: 0, accumulated_cached_input_tokens: 0, accumulated_total_state: crate::types::TurnTotalState::Unseen, }, ); drop(sessions); // Build a models catalog for the `session/new` response. For Databricks // providers this advertises available models so the desktop ModelPicker and // pool can resolve `session/set_model` switches. For Anthropic/OpenAI we // report only the configured model — live switching on those providers // effectively requires respawn. // // `models_cache` caches only a successful discovery result (`get_or_try_init` // leaves the cell empty on error so the next `session/new` call retries). On // discovery failure the fallback is used for the immediate response without // being written to the cell. let available_models: Vec = { use crate::config::Provider; match app.cfg.provider { Provider::Databricks | Provider::DatabricksV2 => { let models = resolve_models_catalog( &app.models_cache, app.cfg.provider, &app.cfg.model, discover_databricks_models(&app.cfg), ) .await; models .iter() .map(|m| json!({ "modelId": m.id, "name": m.name })) .collect() } _ => vec![json!({ "modelId": app.cfg.model, "name": app.cfg.model })], } }; wire::send( wire_tx, wire::ok( id, json!({ "sessionId": session_id, "models": { "currentModelId": app.cfg.model, "availableModels": available_models, }, }), ), ) .await; } fn decode(params: Value, stage: &str) -> Result { serde_json::from_value(params).map_err(|e| format!("{stage}: {e}")) } async fn reject(wire_tx: &WireSender, id: Value, code: i32, message: &str) { wire::send(wire_tx, wire::err(id, code, message)).await; } async fn cancel_session(app: &Arc, params: Value) { if let Ok(p) = serde_json::from_value::(params) { if let Some(s) = app.sessions.lock().await.get(&p.session_id) { let _ = s.cancel_tx.send(true); } } } /// Handle `session/set_model`: apply a per-session model override immediately. /// /// Validation: /// - Unknown `sessionId` → `invalid_params`. /// - Empty `modelId` → `invalid_params`. /// /// On success: stores `model_id` on the session and responds `{ sessionId, modelId }`. /// The override is picked up by the next `session/prompt` call on this session. async fn set_model_session(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { let p: SessionSetModelParams = match decode(params, "session/set_model") { Ok(p) => p, Err(m) => return reject(wire_tx, id, INVALID_PARAMS, &m).await, }; if p.model_id.trim().is_empty() { return reject( wire_tx, id, INVALID_PARAMS, "session/set_model: modelId must not be empty", ) .await; } let mut sessions = app.sessions.lock().await; let Some(s) = sessions.get_mut(&p.session_id) else { return reject( wire_tx, id, INVALID_PARAMS, "session/set_model: unknown session", ) .await; }; s.effective_model = Some(p.model_id.clone()); tracing::info!( session_id = %p.session_id, model_id = %p.model_id, "session/set_model: model overridden" ); drop(sessions); wire::send( wire_tx, wire::ok( id, json!({ "sessionId": p.session_id, "modelId": p.model_id }), ), ) .await; } /// Handle `_goose/unstable/session/steer`: queue user input into the in-flight /// prompt. Validation mirrors goose's `on_steer_session`: /// - empty prompt → `invalid_params` /// - no active run (no prompt in flight) → `invalid_params` /// - `expectedRunId` mismatch → `invalid_params` (caller is steering a turn /// that already ended or rotated; it must fall back to cancel+merge) /// /// On success the message is queued for pickup at the next round boundary and /// we reply `{ runId, messageId }`, then emit a `queuedSteer` session/update so /// the client can correlate the accepted steer with its eventual pickup. async fn steer_session(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { let p: SessionSteerParams = match decode(params, "_goose/unstable/session/steer") { Ok(p) => p, Err(m) => return reject(wire_tx, id, INVALID_PARAMS, &m).await, }; if p.prompt.is_empty() { return reject( wire_tx, id, INVALID_PARAMS, "steer: prompt must not be empty", ) .await; } if p.expected_run_id.is_empty() { return reject( wire_tx, id, INVALID_PARAMS, "steer: expectedRunId must not be empty", ) .await; } let message_id = format!("steer_{}", session_token().unwrap_or_else(|_| "x".into())); let run_id = { let sessions = app.sessions.lock().await; let Some(s) = sessions.get(&p.session_id) else { return reject(wire_tx, id, INVALID_PARAMS, "steer: unknown session").await; }; let Some(active) = s.active_run_id.as_deref() else { return reject(wire_tx, id, INVALID_PARAMS, "steer: no active run to steer").await; }; if active != p.expected_run_id { return reject( wire_tx, id, INVALID_PARAMS, &format!( "steer: expected active run id `{}` but found `{active}`", p.expected_run_id ), ) .await; } // A live run always has a steer_tx; if the channel is gone the run is // tearing down — treat as no active run rather than queue into the void. match &s.steer_tx { Some(tx) if tx.send(p.prompt).is_ok() => active.to_owned(), _ => return reject(wire_tx, id, INVALID_PARAMS, "steer: no active run to steer").await, } }; wire::send( wire_tx, wire::ok(id, json!({ "runId": run_id, "messageId": message_id })), ) .await; // Best-effort correlation hint for the client; mirrors goose's // `send_queued_steer_update`. Not load-bearing for delivery. wire::send( wire_tx, wire::session_update_with_goose_meta( &p.session_id, json!({ "sessionUpdate": "session_info_update" }), json!({ "queuedSteer": { "messageId": message_id, "runId": run_id } }), ), ) .await; } fn spawn_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender) { tokio::spawn(async move { run_prompt(app, id, params, wire_tx).await }); } async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender) { let p: SessionPromptParams = match decode(params, "session/prompt") { Ok(p) => p, Err(m) => return reject(&wire_tx, id, INVALID_PARAMS, &m).await, }; let ( sid, mcp, skills, mut history, mut original_task, mut handoff_count, mut last_request_input_tokens, mut last_request_history_bytes, mut cancel_rx, effective_system_prompt, effective_model_override, run_id, mut steer_rx, ) = match acquire_session(&app, &p.session_id).await { Ok(v) => v, Err(reason) => { return reject( &wire_tx, id, INVALID_PARAMS, &format!("session/prompt: {reason}"), ) .await } }; // Advertise the active run id so steer-capable clients can target this turn // via `expectedRunId`. Mirrors goose's `send_active_run_update`. wire::send( &wire_tx, wire::session_update_with_goose_meta( &sid, json!({ "sessionUpdate": "session_info_update" }), json!({ "activeRunId": run_id }), ), ) .await; // Resolve effective model: session override wins over config default. let effective_model_str = effective_model_override .as_deref() .unwrap_or(&app.cfg.model); let mut turn_input_tokens: Option = None; let mut turn_output_tokens: Option = None; let mut turn_cached_input_tokens: Option = None; let mut turn_total_state = crate::types::TurnTotalState::Unseen; let mut ctx = RunCtx { cfg: &app.cfg, effective_model: effective_model_str, session_id: &sid, system_prompt: &effective_system_prompt, llm: &app.llm, mcp: &mcp, skills: &skills, wire: &wire_tx, cancel: &mut cancel_rx, steer: &mut steer_rx, history: &mut history, original_task: &mut original_task, handoff_count: &mut handoff_count, last_request_input_tokens: &mut last_request_input_tokens, last_request_history_bytes: &mut last_request_history_bytes, turn_input_tokens: &mut turn_input_tokens, turn_output_tokens: &mut turn_output_tokens, turn_cached_input_tokens: &mut turn_cached_input_tokens, turn_total_state: &mut turn_total_state, }; let result = ctx.run(p.prompt).await; if let Some(s) = app.sessions.lock().await.get_mut(&sid) { s.busy = false; // Clear run state so a late steer can't queue into a finished turn. s.active_run_id = None; s.steer_tx = None; s.history = history; s.original_task = original_task; s.handoff_count = handoff_count; s.last_request_input_tokens = last_request_input_tokens; s.last_request_history_bytes = last_request_history_bytes; } // Update session-cumulative token counters and emit the usage notification // BEFORE sending the session/prompt response. buzz-acp's UsageTracker // processes the notification while the turn is still in-flight (i.e. before // the response triggers take_turn_usage()), which is required for the // begin_turn gate to recognise it as publishable. // // Only emit when at least one token count was observed — a turn with no // provider response (validation failure, pre-response cancellation) carries // no information and must not produce a kind 44200 record per NIP-AM. if turn_input_tokens.is_some() || turn_output_tokens.is_some() { let accumulated = { let mut sessions = app.sessions.lock().await; if let Some(s) = sessions.get_mut(&sid) { s.accumulated_input_tokens = s .accumulated_input_tokens .saturating_add(turn_input_tokens.unwrap_or(0)); s.accumulated_output_tokens = s .accumulated_output_tokens .saturating_add(turn_output_tokens.unwrap_or(0)); s.accumulated_cached_input_tokens = s .accumulated_cached_input_tokens .saturating_add(turn_cached_input_tokens.unwrap_or(0)); // Fold the per-turn total state into the session cumulative. // Unknown poisons the session permanently; Exact adds to running sum; // Unseen (turn emitted no usage) leaves the cumulative unchanged. // Uses TurnTotalState::merge_session, which applies the same // checked-add / overflow-poisons contract as the per-response fold. s.accumulated_total_state = s.accumulated_total_state.merge_session(turn_total_state); Some(( s.accumulated_input_tokens, s.accumulated_output_tokens, s.accumulated_cached_input_tokens, s.accumulated_total_state, )) } else { // Session is gone — the accumulated baseline no longer exists, so // there is nothing correct to emit. Skip the usage notification. None } }; if let Some((accumulated_in, accumulated_out, accumulated_cached, accumulated_total)) = accumulated { // Build the usage_update payload. `accumulatedTotalTokens` is only // included when the cumulative is exactly known — never when Unseen // (no total ever observed) or Unknown (at least one turn lacked a // total). A goose consumer that doesn't recognise the field ignores it. let mut update = serde_json::json!({ "sessionUpdate": "usage_update", // used: total tokens as a context-usage proxy; // contextLimit: 0 (buzz-agent has no context limit tracking). "used": accumulated_in.saturating_add(accumulated_out), "contextLimit": 0u64, "accumulatedInputTokens": accumulated_in, "accumulatedOutputTokens": accumulated_out, // A subset of accumulatedInputTokens, not an addition to // it. Extends goose's usage_update shape; a consumer that // does not know the field ignores it and prices exactly as // it did before. "accumulatedCachedInputTokens": accumulated_cached, "model": effective_model_str, }); if let crate::types::TurnTotalState::Exact(total) = accumulated_total { update["accumulatedTotalTokens"] = serde_json::json!(total); } wire::send(&wire_tx, goose_session_update(&sid, update)).await; } } match result { Ok(stop) => { wire::send( &wire_tx, wire::ok(id, json!({ "stopReason": stop.as_wire() })), ) .await } Err(e) => wire::send(&wire_tx, wire::err(id, e.json_rpc_code(), &e.to_string())).await, } } async fn acquire_session( app: &Arc, session_id: &str, ) -> Result< ( String, Arc, Vec, Vec, Option, usize, Option, Option, watch::Receiver, Arc, Option, String, mpsc::UnboundedReceiver>, ), &'static str, > { let mut sessions = app.sessions.lock().await; let s = sessions.get_mut(session_id).ok_or("unknown session")?; if s.busy { return Err("prompt already in flight"); } s.busy = true; let (tx, rx) = watch::channel(false); s.cancel_tx = tx; // Skills are read-only after session creation; clone the Vec so RunCtx // can hold a reference without holding the sessions lock. let skills = s.skills.clone(); // Fresh run id + steer channel for this turn. The run id lets steer-capable // clients target *this* turn (rejecting steers aimed at a turn that already // ended); the channel carries mid-turn injections to the run loop. let run_id = format!("run_{}", session_token().unwrap_or_else(|_| "x".into())); s.active_run_id = Some(run_id.clone()); let (steer_tx, steer_rx) = mpsc::unbounded_channel(); s.steer_tx = Some(steer_tx); let effective_model = s.effective_model.clone(); Ok(( s.id.clone(), s.mcp.clone(), skills, std::mem::take(&mut s.history), s.original_task.take(), s.handoff_count, s.last_request_input_tokens, s.last_request_history_bytes, rx, Arc::clone(&s.effective_system_prompt), effective_model, run_id, steer_rx, )) } fn session_token() -> Result { let mut b = [0u8; 8]; getrandom::fill(&mut b).map_err(|e| format!("rng: getrandom failed: {e}"))?; Ok(b.iter().map(|x| format!("{x:02x}")).collect()) } #[cfg(test)] mod tests { use crate::catalog::{discovery_failure_fallback, ModelEntry, DATABRICKS_V2_KNOWN_MODELS}; use crate::config::Provider; use crate::types::AgentError; /// Regression: a discovery error must not pin the models_cache for the process lifetime. /// /// `resolve_models_catalog` uses `get_or_try_init` so an `Err` leaves the `OnceCell` /// empty and the next `session/new` retries discovery. This test calls /// `resolve_models_catalog` directly — the same function `session_new` calls — so /// reverting `session_new` to `get_or_init` (or any other cache-on-error variant) would /// break this test, not just the standalone `OnceCell` semantics. #[tokio::test] async fn models_cache_does_not_pin_on_discovery_error() { let cache: tokio::sync::OnceCell> = tokio::sync::OnceCell::new(); let provider = Provider::DatabricksV2; let model = "my-configured-model"; // First call — discovery fails. Cell must remain empty; fallback returned. let first = crate::resolve_models_catalog(&cache, provider, model, async { Err::, AgentError>(AgentError::LlmAuth("transient failure".into())) }) .await; assert!( cache.get().is_none(), "cell must be empty after a discovery error — next session must retry" ); let expected_fallback = discovery_failure_fallback(provider, model); assert_eq!( first, expected_fallback, "error path must return the provider-aware fallback" ); // Second call — discovery succeeds. Cell is now populated and returned. let discovered = vec![ModelEntry { id: "databricks-meta-llama-3-1-70b-instruct".into(), name: "databricks-meta-llama-3-1-70b-instruct".into(), }]; let discovered_clone = discovered.clone(); let second = crate::resolve_models_catalog(&cache, provider, model, async move { Ok::, AgentError>(discovered_clone) }) .await; assert_eq!( second, discovered, "second call must return the discovered catalog" ); assert!( cache.get().is_some(), "cell must be populated after successful discovery" ); assert_eq!( cache.get().unwrap(), &discovered, "cache must hold the successful discovery result" ); } /// Regression: legacy `Provider::Databricks` must not advertise v2 AI Gateway model IDs /// on discovery failure (Wes W1). This test calls `discovery_failure_fallback` directly — /// the same helper used by `session_new` — and verifies the split behavior. It FAILS if /// the arm is un-split (i.e., if both providers return the v2 catalog on failure). #[test] fn databricks_discovery_failure_fallback_legacy_returns_configured_model_only() { let configured = "my-serving-endpoint"; let result = discovery_failure_fallback(Provider::Databricks, configured); // Legacy Databricks must advertise exactly the configured model — nothing more. assert_eq!( result.len(), 1, "legacy Databricks fallback must contain exactly one entry, got: {result:?}" ); assert_eq!( result[0].id, configured, "legacy Databricks fallback must be the configured model" ); // Crucially: must NOT contain any DATABRICKS_V2_KNOWN_MODELS entry. let v2_ids: Vec<&str> = DATABRICKS_V2_KNOWN_MODELS.to_vec(); for id in &result { assert!( !v2_ids.contains(&id.id.as_str()), "legacy Databricks fallback must not include v2 ID '{}' — that endpoint \ may not be served by /serving-endpoints/{{model}}/invocations", id.id ); } } #[test] fn databricks_discovery_failure_fallback_v2_returns_known_models_catalog() { let configured = "my-configured-model"; let result = discovery_failure_fallback(Provider::DatabricksV2, configured); // DatabricksV2 must return the full DATABRICKS_V2_KNOWN_MODELS list, // plus the configured model so the picker can still represent the model // the agent is actually running. assert_eq!( result.len(), DATABRICKS_V2_KNOWN_MODELS.len() + 1, "DatabricksV2 fallback must return all known models plus the configured model" ); let result_ids: Vec<&str> = result.iter().map(|m| m.id.as_str()).collect(); for known_id in DATABRICKS_V2_KNOWN_MODELS { assert!( result_ids.contains(known_id), "DatabricksV2 fallback must include known model '{known_id}'" ); } assert!( result_ids.contains(&configured), "DatabricksV2 fallback must include the configured model" ); } #[test] fn databricks_discovery_failure_fallback_split_verified() { // This test FAILS if the v1/v2 arms are merged back into one — it directly verifies // that the two providers' error-path behavior diverges (Wes W1 protection). let v1 = discovery_failure_fallback(Provider::Databricks, "my-endpoint"); let v2 = discovery_failure_fallback(Provider::DatabricksV2, "my-endpoint"); let v1_ids: Vec<&str> = v1.iter().map(|m| m.id.as_str()).collect(); let v2_ids: Vec<&str> = v2.iter().map(|m| m.id.as_str()).collect(); assert_ne!( v1_ids, v2_ids, "Provider::Databricks and Provider::DatabricksV2 must return different \ fallback catalogs — if they are equal, the W1 arm split has been reverted" ); } }