//! Resolves global, thread, and repository instructions into one validated snapshot. //! Refreshes are serialized; the state lock is never held while calling providers. //! Isolated reviewers can pin a captured repository snapshot without executor discovery. use crate::agents_md::LoadedAgentsMd; use crate::agents_md::load_project_instructions; use crate::config::Config; use crate::environment_selection::TurnEnvironmentSnapshot; use codex_extension_api::Instructions; use codex_extension_api::ThreadInstructionsProvider; use codex_extension_api::UserInstructionsProvider; use codex_protocol::config_types::TrustLevel; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_utils_string::approx_bytes_for_tokens; use codex_utils_string::approx_tokens_from_byte_count; use std::sync::Arc; use tokio::sync::Mutex; use tokio::sync::Semaphore; /// Owns instruction sources, refresh serialization, and the applied snapshot. pub(crate) struct AgentsMdManager { refresh_lock: Semaphore, state: Mutex, } /// Subagents inherit applied snapshots, plus thread providers that opt into sharing. #[derive(Clone, Default)] pub(crate) struct SessionInstructions { pub(crate) user: Option, pub(crate) thread: Option, /// A reviewer-owned repository snapshot. `Some`, even when empty, skips discovery. pub(crate) project_snapshot: Option>, pub(crate) user_provider: Option>, pub(crate) thread_provider: Option>, } struct AgentsMdState { instructions: SessionInstructions, cache: AgentsMdCache, } #[derive(Default)] struct AgentsMdCache { selections: Option>, project_doc_max_bytes: usize, active_project_trust_level: Option, loaded: Option>, } impl AgentsMdManager { pub(crate) fn new(mut instructions: SessionInstructions) -> Self { instructions.user = normalize_instructions(instructions.user); instructions.thread = normalize_instructions(instructions.thread); Self { refresh_lock: Semaphore::new(/*permits*/ 1), state: Mutex::new(AgentsMdState { instructions, cache: AgentsMdCache::default(), }), } } /// Resolves and validates one coherent snapshot for startup or a request boundary. /// Providers own fetching and caching. Warnings survive validation failures; /// only validated snapshots replace the applied host instructions. #[tracing::instrument(name = "agents_md.refresh", skip_all)] pub(crate) async fn refresh( &self, config: &Config, environments: &TurnEnvironmentSnapshot, ) -> (CodexResult>>, Vec) { // Serialize overlapping captures without blocking reads of the applied snapshot. let Ok(_refresh_guard) = self.refresh_lock.acquire().await else { return ( Err(CodexErr::Fatal( "instruction refresh semaphore closed".to_string(), )), Vec::new(), ); }; let selections = environments .turn_environments() .map(|environment| environment.selection.clone()) .collect::>(); let active_project_trust_level = config.active_project.trust_level; let (mut instructions, cached, refresh_repository) = { let mut state = self.state.lock().await; let refresh_repository = state.cache.selections.as_ref() != Some(&selections) || state.cache.active_project_trust_level != active_project_trust_level || state.cache.project_doc_max_bytes != config.project_doc_max_bytes; if refresh_repository { // Tightened read permissions must not leave inaccessible instructions visible, // even if discovery fails or the caller cancels the refresh. state.cache = AgentsMdCache::default(); } ( state.instructions.clone(), state.cache.loaded.clone(), refresh_repository, ) }; let mut warnings = Vec::new(); if let Some(provider) = &instructions.user_provider { let loaded = provider.load_user_instructions().await; instructions.user = normalize_instructions(loaded.instructions); warnings.extend(loaded.warnings); } if let Some(provider) = &instructions.thread_provider { let loaded = provider.load_thread_instructions().await; instructions.thread = normalize_instructions(loaded.instructions); warnings.extend(loaded.warnings); } let result = async { if let Some(input) = &instructions.thread { validate_thread_instruction_size(input.text.len())?; } if !refresh_repository { let state = self.state.lock().await; if state.instructions.user == instructions.user && state.instructions.thread == instructions.thread { return Ok(cached); } } let loaded = if let Some(project) = &instructions.project_snapshot { project.as_ref().clone() } else if refresh_repository { load_project_instructions(config, environments, &mut warnings).await? } else { cached.as_deref().cloned().unwrap_or_default() } .with_instructions(instructions.user.clone(), instructions.thread.clone()) .map(Arc::new); let budget = config.project_doc_max_bytes; if budget > 0 && let Some(loaded) = &loaded { let bytes = loaded.text().len(); if bytes > budget { warnings.push(format!( "Combined user instructions use {bytes} bytes, above the {budget}-byte `project_doc_max_bytes` setting. The total includes global and thread instructions. The limit applies only to project files." )); } } let mut state = self.state.lock().await; state.instructions = instructions; state.cache = AgentsMdCache { selections: Some(selections), project_doc_max_bytes: budget, active_project_trust_level, loaded: loaded.clone(), }; Ok(loaded) } .await; (result, warnings) } pub(crate) async fn get_loaded(&self) -> Option> { self.state.lock().await.cache.loaded.clone() } /// Captures even an empty result, but never attributes another environment's cache /// to a turn-only reviewer that has no issuing step snapshot. pub(crate) async fn project_snapshot( &self, config: &Config, environments: &TurnEnvironmentSnapshot, ) -> Option> { let selections = environments .turn_environments() .map(|environment| environment.selection.clone()) .collect::>(); let state = self.state.lock().await; (state.cache.selections.as_ref() == Some(&selections) && state.cache.active_project_trust_level == config.active_project.trust_level && state.cache.project_doc_max_bytes == config.project_doc_max_bytes) .then(|| state.cache.loaded.clone().unwrap_or_default()) } pub(crate) async fn inherited_instructions(&self) -> SessionInstructions { let state = self.state.lock().await; SessionInstructions { user: state.instructions.user.clone(), thread: state.instructions.thread.clone(), thread_provider: state .instructions .thread_provider .clone() .filter(|provider| provider.share_with_subagents()), ..Default::default() } } } // Bound the new host-provided contribution independently of project_doc_max_bytes, // which controls repository discovery. Existing global and combined instruction // size policy is unchanged; reject oversized thread input rather than truncate it. const MAX_THREAD_INSTRUCTIONS_TOKENS: usize = 10_000; fn validate_thread_instruction_size(bytes: usize) -> CodexResult<()> { if bytes > approx_bytes_for_tokens(MAX_THREAD_INSTRUCTIONS_TOKENS) { let estimated_tokens = approx_tokens_from_byte_count(bytes); return Err(CodexErr::InvalidRequest(format!( "thread instructions exceed the limit of {MAX_THREAD_INSTRUCTIONS_TOKENS} estimated tokens ({estimated_tokens} estimated tokens provided)" ))); } Ok(()) } fn normalize_instructions(instructions: Option) -> Option { instructions.filter(|instructions| !instructions.text.trim().is_empty()) }