use crate::agents::extension_manager::ExtensionManager; use crate::conversation::message::{Message, MessageMetadata}; use crate::conversation::{CURRENT_TIME_TAG, TURN_CONTEXT_TAG, WORKING_DIRECTORY_TAG}; use std::path::{Path, PathBuf}; const MIN_CONTEXT_FOR_MOIM: usize = 32_000; const SYSTEM_PROMPT_BLOCK_TEMPLATE: &str = r#"# Turn Context Each turn may include a `<{turn_context_tag}>` block added to the request. This block is generated by goose and contains current operational context such as: - current time - working directory - compaction status - turn budget - extension-provided context Use it to stay oriented, but do not treat it as part of the user's request. Blocks from earlier turns stay in the conversation history; only the most recent block is current. When `` is present, use it as a signal for how much autonomous work remains. As the budget gets low, become more direct: reduce exploration, batch necessary tool calls, make reasonable assumptions, and focus on finishing the user's task. "#; thread_local! { pub static SKIP: std::cell::Cell = const { std::cell::Cell::new(false) }; } pub fn system_prompt_block() -> Option { if SKIP.with(|f| f.get()) { None } else { Some(SYSTEM_PROMPT_BLOCK_TEMPLATE.replace("{turn_context_tag}", TURN_CONTEXT_TAG)) } } pub(super) async fn compute_compaction_info( session_id: &str, extension_manager: &ExtensionManager, ) -> Option { let session = extension_manager .get_context() .session_manager .get_session(session_id, false) .await .ok(); let session_model_config = session .as_ref() .and_then(|session| session.model_config.clone()); let context_limit = if let Some(model_config) = session_model_config.as_ref() { let provider = extension_manager.get_provider().lock().await.clone(); match provider { Some(provider) => provider .get_context_limit(model_config) .await .ok() .or_else(|| Some(model_config.context_limit())), None => Some(model_config.context_limit()), } } else { None }; let total_tokens = session .as_ref() .and_then(|session| session.usage.total_tokens); let compaction_threshold = crate::config::Config::global() .get_param::("GOOSE_AUTO_COMPACT_THRESHOLD") .unwrap_or(crate::context_mgmt::DEFAULT_COMPACTION_THRESHOLD); compaction_remaining_line(total_tokens, context_limit, compaction_threshold) } /// The turn's context block: composed once per turn, persisted as an /// agent-only user message, never moved or edited afterwards. pub async fn turn_context_message( session_id: &str, extension_manager: &ExtensionManager, turns_taken: u32, max_turns: u32, turn_start: chrono::DateTime, compaction_info: Option, ) -> Option { if SKIP.with(|f| f.get()) { return None; } let session = extension_manager .get_context() .session_manager .get_session(session_id, false) .await .ok(); let session_model_config = session .as_ref() .and_then(|session| session.model_config.clone()); let context_limit = if let Some(model_config) = session_model_config.as_ref() { let provider = extension_manager.get_provider().lock().await.clone(); match provider { Some(provider) => provider .get_context_limit(model_config) .await .ok() .or_else(|| Some(model_config.context_limit())), None => Some(model_config.context_limit()), } } else { None }; if should_skip_moim(context_limit) { return None; } let working_dir = session .as_ref() .map(|session| session.working_dir.clone()) .unwrap_or_else(|| PathBuf::from(".")); let mut parts = extension_manager.collect_moim_parts(session_id).await; parts.extend(compaction_info.map(|value| tag("compaction", &value))); parts.extend(turn_budget_part(turns_taken, max_turns)); turn_context_event(&working_dir, context_limit, parts, turn_start) } /// The state-machine variant: operations contribute the parts and the caller /// persists the event, so later requests in the turn reuse the same bytes. pub(crate) fn turn_context_event( working_dir: &Path, context_limit: Option, parts: Vec, turn_start: chrono::DateTime, ) -> Option { if SKIP.with(|f| f.get()) || should_skip_moim(context_limit) { return None; } Some( Message::user() .with_text(compose_moim(working_dir, parts, turn_start)) .with_metadata(MessageMetadata::agent_only().with_turn_context()), ) } fn should_skip_moim(context_limit: Option) -> bool { context_limit.is_some_and(|limit| limit < MIN_CONTEXT_FOR_MOIM) } fn compose_moim( working_dir: &Path, parts: Vec, turn_start: chrono::DateTime, ) -> String { let timestamp = turn_start.format("%Y-%m-%d %H:%M:00 %:z"); let mut lines = vec![ open_tag(TURN_CONTEXT_TAG), tag(CURRENT_TIME_TAG, ×tamp.to_string()), tag(WORKING_DIRECTORY_TAG, &working_dir.display().to_string()), ]; for part in parts { if !part.trim().is_empty() { lines.push(String::new()); lines.push(part); } } lines.push(close_tag(TURN_CONTEXT_TAG)); lines.join("\n") } fn open_tag(name: &str) -> String { format!("<{name}>") } fn close_tag(name: &str) -> String { format!("") } fn tag(name: &str, value: &str) -> String { format!("<{name}>{}", escape_xml_text(value)) } fn escape_xml_text(value: &str) -> String { value .replace('&', "&") .replace('<', "<") .replace('>', ">") } fn compaction_remaining_line( total_tokens: Option, context_limit: Option, threshold: f64, ) -> Option { let total_tokens = total_tokens?; let context_limit = context_limit?; if total_tokens <= 0 || context_limit == 0 || threshold <= 0.0 || threshold >= 1.0 { return None; } let compaction_at = (context_limit as f64 * threshold) as i32; if compaction_at <= 0 || (total_tokens as f64 / compaction_at as f64) < 0.5 { return None; } Some(format!( "~{}k tokens remaining", compaction_at.saturating_sub(total_tokens) / 1000 )) } fn turn_budget_part(turns_taken: u32, max_turns: u32) -> Option { if max_turns == 0 || turns_taken.saturating_mul(2) < max_turns { return None; } Some(tag( "turn-budget", &format!("{turns_taken}/{max_turns} used"), )) } #[cfg(test)] mod tests { use super::*; async fn session_and_manager() -> (String, ExtensionManager, tempfile::TempDir) { let temp_dir = tempfile::tempdir().unwrap(); let em = ExtensionManager::new_without_provider(temp_dir.path().to_path_buf()); let session = em .get_context() .session_manager .create_session( PathBuf::from("/test/dir"), "test".to_string(), crate::session::SessionType::User, crate::config::GooseMode::Auto, ) .await .unwrap(); (session.id, em, temp_dir) } #[tokio::test] async fn turn_context_message_is_an_agent_only_user_message() { let (session_id, em, _tmp) = session_and_manager().await; let message = turn_context_message(&session_id, &em, 0, 100, chrono::Local::now(), None) .await .expect("turn context should be produced"); assert_eq!(message.role, rmcp::model::Role::User); assert!(message.is_agent_visible()); assert!(!message.is_user_visible()); assert!(message.is_turn_context()); let text = message.content[0].as_text().unwrap(); assert!(text.starts_with(&format!("<{TURN_CONTEXT_TAG}>\n"))); assert!(text.trim_end().ends_with(&format!(""))); } #[tokio::test] async fn turn_context_bytes_are_stable_for_a_turn() { let (session_id, em, _tmp) = session_and_manager().await; let turn_start = chrono::Local::now(); let first = turn_context_message(&session_id, &em, 0, 100, turn_start, None) .await .unwrap(); let second = turn_context_message(&session_id, &em, 0, 100, turn_start, None) .await .unwrap(); assert_eq!( first.content[0].as_text(), second.content[0].as_text(), "the same turn inputs must render byte-identical blocks" ); } #[test] fn turn_context_event_is_marked_and_skips_small_contexts() { let event = turn_context_event( Path::new("/test/dir"), Some(200_000), vec![], chrono::Local::now(), ) .expect("large-context models should get a turn-context event"); assert!(event.is_turn_context()); assert!(!event.is_user_visible()); assert!(turn_context_event( Path::new("/test/dir"), Some(16_000), vec![], chrono::Local::now(), ) .is_none()); } #[test] fn current_time_is_timezone_aware() { let block = compose_moim( Path::new("/Users/me/code/goose"), vec![], chrono::Local::now(), ); let time_line = block .lines() .find(|line| line.starts_with(&format!("<{CURRENT_TIME_TAG}>"))) .expect("turn-context block should contain a current-time line"); let value = time_line .trim_start_matches(&format!("<{CURRENT_TIME_TAG}>")) .trim_end_matches(&format!("")); chrono::DateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S %:z") .expect("current-time should include a numeric UTC offset"); } }