diff --git a/codex-rs/core/src/analytics_client.rs b/codex-rs/core/src/analytics_client.rs index 922a25aca..c8829eda0 100644 --- a/codex-rs/core/src/analytics_client.rs +++ b/codex-rs/core/src/analytics_client.rs @@ -34,16 +34,25 @@ pub(crate) fn build_track_events_context( } } +#[derive(Clone, Debug)] pub(crate) struct SkillInvocation { pub(crate) skill_name: String, pub(crate) skill_scope: SkillScope, pub(crate) skill_path: PathBuf, + pub(crate) invocation_type: InvocationType, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum InvocationType { + Explicit, + Implicit, } pub(crate) struct AppInvocation { pub(crate) connector_id: Option, pub(crate) app_name: Option, - pub(crate) invoke_type: Option, + pub(crate) invocation_type: Option, } #[derive(Clone)] @@ -197,7 +206,7 @@ struct SkillInvocationEventParams { skill_scope: Option, repo_url: Option, thread_id: Option, - invoke_type: Option, + invoke_type: Option, model_slug: Option, } @@ -208,7 +217,7 @@ struct CodexAppMetadata { turn_id: Option, app_name: Option, product_client_id: Option, - invoke_type: Option, + invoke_type: Option, model_slug: Option, } @@ -328,7 +337,7 @@ async fn send_track_skill_invocations(auth_manager: &AuthManager, job: TrackSkil skill_name: invocation.skill_name.clone(), event_params: SkillInvocationEventParams { thread_id: Some(tracking.thread_id.clone()), - invoke_type: Some("explicit".to_string()), + invoke_type: Some(invocation.invocation_type), model_slug: Some(tracking.model_slug.clone()), product_client_id: Some(crate::default_client::originator().value), repo_url, @@ -383,7 +392,7 @@ fn codex_app_metadata(tracking: &TrackEventsContext, app: AppInvocation) -> Code turn_id: Some(tracking.turn_id.clone()), app_name: app.app_name, product_client_id: Some(crate::default_client::originator().value), - invoke_type: app.invoke_type, + invoke_type: app.invocation_type, model_slug: Some(tracking.model_slug.clone()), } } @@ -437,7 +446,7 @@ async fn send_track_events( } } -fn skill_id_for_local_skill( +pub(crate) fn skill_id_for_local_skill( repo_url: Option<&str>, repo_root: Option<&Path>, skill_path: &Path, @@ -485,6 +494,7 @@ mod tests { use super::AppInvocation; use super::CodexAppMentionedEventRequest; use super::CodexAppUsedEventRequest; + use super::InvocationType; use super::TrackEventRequest; use super::TrackEventsContext; use super::codex_app_metadata; @@ -567,7 +577,7 @@ mod tests { AppInvocation { connector_id: Some("calendar".to_string()), app_name: Some("Calendar".to_string()), - invoke_type: Some("explicit".to_string()), + invocation_type: Some(InvocationType::Explicit), }, ), }); @@ -605,7 +615,7 @@ mod tests { AppInvocation { connector_id: Some("drive".to_string()), app_name: Some("Google Drive".to_string()), - invoke_type: Some("implicit".to_string()), + invocation_type: Some(InvocationType::Implicit), }, ), }); @@ -639,7 +649,7 @@ mod tests { let app = AppInvocation { connector_id: Some("calendar".to_string()), app_name: Some("Calendar".to_string()), - invoke_type: Some("implicit".to_string()), + invocation_type: Some(InvocationType::Implicit), }; let turn_1 = TrackEventsContext { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index fb614f12c..01d9851d5 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -14,6 +14,7 @@ use crate::agent::AgentStatus; use crate::agent::agent_status_from_event; use crate::analytics_client::AnalyticsEventsClient; use crate::analytics_client::AppInvocation; +use crate::analytics_client::InvocationType; use crate::analytics_client::build_track_events_context; use crate::apps::render_apps_section; use crate::commit_attribution::commit_message_trailer_instruction; @@ -542,6 +543,20 @@ pub(crate) struct Session { next_internal_sub_id: AtomicU64, } +#[derive(Clone, Debug)] +pub(crate) struct TurnSkillsContext { + pub(crate) outcome: Arc, + pub(crate) implicit_invocation_seen_skills: Arc>>, +} +impl TurnSkillsContext { + pub(crate) fn new(outcome: Arc) -> Self { + Self { + outcome, + implicit_invocation_seen_skills: Arc::new(Mutex::new(HashSet::new())), + } + } +} + /// The context needed for a single turn of the thread. #[derive(Debug)] pub(crate) struct TurnContext { @@ -578,6 +593,7 @@ pub(crate) struct TurnContext { pub(crate) js_repl: Arc, pub(crate) dynamic_tools: Vec, pub(crate) turn_metadata_state: Arc, + pub(crate) turn_skills: TurnSkillsContext, } impl TurnContext { pub(crate) fn model_context_window(&self) -> Option { @@ -660,6 +676,7 @@ impl TurnContext { js_repl: Arc::clone(&self.js_repl), dynamic_tools: self.dynamic_tools.clone(), turn_metadata_state: self.turn_metadata_state.clone(), + turn_skills: self.turn_skills.clone(), } } @@ -938,6 +955,7 @@ impl Session { network: Option, sub_id: String, js_repl: Arc, + skills_outcome: Arc, ) -> TurnContext { let reasoning_effort = session_configuration.collaboration_mode.reasoning_effort(); let reasoning_summary = session_configuration.model_reasoning_summary; @@ -1000,6 +1018,7 @@ impl Session { js_repl, dynamic_tools: session_configuration.dynamic_tools.clone(), turn_metadata_state, + turn_skills: TurnSkillsContext::new(skills_outcome), } } @@ -2084,6 +2103,12 @@ impl Session { &per_turn_config, ) .await; + let skills_outcome = Arc::new( + self.services + .skills_manager + .skills_for_cwd(&session_configuration.cwd, false) + .await, + ); let mut turn_context: TurnContext = Self::make_turn_context( Some(Arc::clone(&self.services.auth_manager)), &self.services.otel_manager, @@ -2097,6 +2122,7 @@ impl Session { .map(StartedNetworkProxy::proxy), sub_id, Arc::clone(&self.js_repl), + skills_outcome, ); if let Some(final_schema) = final_output_json_schema { @@ -4522,6 +4548,7 @@ async fn spawn_review_thread( dynamic_tools: parent_turn_context.dynamic_tools.clone(), truncation_policy: model_info.truncation_policy.into(), turn_metadata_state, + turn_skills: TurnSkillsContext::new(parent_turn_context.turn_skills.outcome.clone()), }; // Seed the child task with the review prompt as the initial user message. @@ -4646,6 +4673,8 @@ pub(crate) async fn run_turn( return None; } + let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); + let previous_model = sess.previous_model().await; sess.record_context_updates_and_set_reference_context_item( turn_context.as_ref(), @@ -4653,13 +4682,6 @@ pub(crate) async fn run_turn( ) .await; - let skills_outcome = Some( - sess.services - .skills_manager - .skills_for_cwd(&turn_context.cwd, false) - .await, - ); - let available_connectors = if turn_context.config.features.enabled(Feature::Apps) { let mcp_tools = match sess .services @@ -4751,7 +4773,7 @@ pub(crate) async fn run_turn( app_name: connector_names_by_id .get(connector_id.as_str()) .map(|name| (*name).to_string()), - invoke_type: Some("explicit".to_string()), + invocation_type: Some(InvocationType::Explicit), }) .collect::>(); sess.services @@ -4843,7 +4865,7 @@ pub(crate) async fn run_turn( turn_metadata_header.as_deref(), sampling_request_input, &explicitly_enabled_connectors, - skills_outcome.as_ref(), + skills_outcome, &mut server_model_warning_emitted_for_turn, cancellation_token.child_token(), ) @@ -5272,7 +5294,6 @@ async fn run_sampling_request( personality: turn_context.personality, output_schema: turn_context.final_output_json_schema.clone(), }; - let mut retries = 0; loop { let err = match try_run_sampling_request( @@ -8085,6 +8106,7 @@ mod tests { config.js_repl_node_module_dirs.clone(), )); + let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&per_turn_config)); let turn_context = Session::make_turn_context( Some(Arc::clone(&auth_manager)), &otel_manager, @@ -8095,6 +8117,7 @@ mod tests { None, "turn_id".to_string(), Arc::clone(&js_repl), + skills_outcome, ); let session = Session { @@ -8238,6 +8261,7 @@ mod tests { config.js_repl_node_module_dirs.clone(), )); + let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&per_turn_config)); let turn_context = Arc::new(Session::make_turn_context( Some(Arc::clone(&auth_manager)), &otel_manager, @@ -8248,6 +8272,7 @@ mod tests { None, "turn_id".to_string(), Arc::clone(&js_repl), + skills_outcome, )); let session = Arc::new(Session { diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index ce260773d..63f83bb3f 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -4,6 +4,7 @@ use std::time::Instant; use tracing::error; use crate::analytics_client::AppInvocation; +use crate::analytics_client::InvocationType; use crate::analytics_client::build_track_events_context; use crate::codex::Session; use crate::codex::TurnContext; @@ -276,15 +277,15 @@ async fn maybe_track_codex_app_used( let (connector_id, app_name) = metadata .map(|metadata| (metadata.connector_id, metadata.app_name)) .unwrap_or((None, None)); - let invoke_type = if let Some(connector_id) = connector_id.as_deref() { + let invocation_type = if let Some(connector_id) = connector_id.as_deref() { let mentioned_connector_ids = sess.get_connector_selection().await; if mentioned_connector_ids.contains(connector_id) { - "explicit" + InvocationType::Explicit } else { - "implicit" + InvocationType::Implicit } } else { - "implicit" + InvocationType::Implicit }; let tracking = build_track_events_context( @@ -297,7 +298,7 @@ async fn maybe_track_codex_app_used( AppInvocation { connector_id, app_name, - invoke_type: Some(invoke_type.to_string()), + invocation_type: Some(invocation_type), }, ); } diff --git a/codex-rs/core/src/skills/injection.rs b/codex-rs/core/src/skills/injection.rs index 4dec3fe39..475ea6c12 100644 --- a/codex-rs/core/src/skills/injection.rs +++ b/codex-rs/core/src/skills/injection.rs @@ -3,6 +3,7 @@ use std::collections::HashSet; use std::path::PathBuf; use crate::analytics_client::AnalyticsEventsClient; +use crate::analytics_client::InvocationType; use crate::analytics_client::SkillInvocation; use crate::analytics_client::TrackEventsContext; use crate::instructions::SkillInstructions; @@ -43,6 +44,7 @@ pub(crate) async fn build_skill_injections( skill_name: skill.name.clone(), skill_scope: skill.scope, skill_path: skill.path.clone(), + invocation_type: InvocationType::Explicit, }); result.items.push(ResponseItem::from(SkillInstructions { name: skill.name.clone(), diff --git a/codex-rs/core/src/skills/invocation_utils.rs b/codex-rs/core/src/skills/invocation_utils.rs new file mode 100644 index 000000000..761d2098a --- /dev/null +++ b/codex-rs/core/src/skills/invocation_utils.rs @@ -0,0 +1,385 @@ +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; + +use crate::analytics_client::InvocationType; +use crate::analytics_client::SkillInvocation; +use crate::analytics_client::build_track_events_context; +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::skills::SkillMetadata; + +#[derive(Clone, Debug)] +pub(crate) struct ImplicitSkillCandidate { + pub(crate) invocation: SkillInvocation, +} + +#[derive(Default, Debug)] +pub(crate) struct ImplicitSkillDetector { + pub(crate) by_scripts_dir: HashMap, + pub(crate) by_skill_doc_path: HashMap, +} + +#[derive(Debug)] +pub(crate) struct ImplicitInvocationContext { + pub(crate) detector: ImplicitSkillDetector, +} + +pub(crate) fn build_implicit_invocation_context( + skills: Vec, +) -> Option { + if skills.is_empty() { + return None; + } + + let mut detector = ImplicitSkillDetector::default(); + for skill in skills { + let invocation = SkillInvocation { + skill_name: skill.name, + skill_scope: skill.scope, + skill_path: skill.path, + invocation_type: InvocationType::Implicit, + }; + let candidate = ImplicitSkillCandidate { invocation }; + + let skill_doc_path = normalize_path(candidate.invocation.skill_path.as_path()); + detector + .by_skill_doc_path + .insert(skill_doc_path, candidate.clone()); + + if let Some(skill_dir) = candidate.invocation.skill_path.parent() { + let scripts_dir = normalize_path(&skill_dir.join("scripts")); + detector.by_scripts_dir.insert(scripts_dir, candidate); + } + } + + Some(ImplicitInvocationContext { detector }) +} + +fn detect_implicit_skill_invocation_for_command( + detector: &ImplicitSkillDetector, + turn_context: &TurnContext, + command: &str, + workdir: Option<&str>, +) -> Option { + let workdir = turn_context.resolve_path(workdir.map(str::to_owned)); + let workdir = normalize_path(workdir.as_path()); + let tokens = tokenize_command(command); + + if let Some(candidate) = detect_skill_script_run(detector, tokens.as_slice(), workdir.as_path()) + { + return Some(candidate); + } + + if let Some(candidate) = detect_skill_doc_read(detector, tokens.as_slice(), workdir.as_path()) { + return Some(candidate); + } + + None +} + +pub(crate) async fn maybe_emit_implicit_skill_invocation( + sess: &Session, + turn_context: &TurnContext, + command: &str, + workdir: Option<&str>, +) { + let Some(implicit) = turn_context + .turn_skills + .outcome + .implicit_invocation_context + .as_deref() + else { + return; + }; + let Some(candidate) = detect_implicit_skill_invocation_for_command( + &implicit.detector, + turn_context, + command, + workdir, + ) else { + return; + }; + let skill_scope = match candidate.invocation.skill_scope { + codex_protocol::protocol::SkillScope::User => "user", + codex_protocol::protocol::SkillScope::Repo => "repo", + codex_protocol::protocol::SkillScope::System => "system", + codex_protocol::protocol::SkillScope::Admin => "admin", + }; + let skill_path = candidate.invocation.skill_path.to_string_lossy(); + let skill_name = candidate.invocation.skill_name.clone(); + let seen_key = format!("{skill_scope}:{skill_path}:{skill_name}"); + let inserted = { + let mut seen_skills = turn_context + .turn_skills + .implicit_invocation_seen_skills + .lock() + .await; + seen_skills.insert(seen_key) + }; + if !inserted { + return; + } + + turn_context.otel_manager.counter( + "codex.skill.injected", + 1, + &[ + ("status", "ok"), + ("skill", skill_name.as_str()), + ("invoke_type", "implicit"), + ], + ); + sess.services + .analytics_events_client + .track_skill_invocations( + build_track_events_context( + turn_context.model_info.slug.clone(), + sess.conversation_id.to_string(), + turn_context.sub_id.clone(), + ), + vec![candidate.invocation], + ); +} + +fn tokenize_command(command: &str) -> Vec { + shlex::split(command).unwrap_or_else(|| { + command + .split_whitespace() + .map(std::string::ToString::to_string) + .collect() + }) +} + +fn script_run_token(tokens: &[String]) -> Option<&str> { + const RUNNERS: [&str; 10] = [ + "python", "python3", "bash", "zsh", "sh", "node", "deno", "ruby", "perl", "pwsh", + ]; + const SCRIPT_EXTENSIONS: [&str; 7] = [".py", ".sh", ".js", ".ts", ".rb", ".pl", ".ps1"]; + + let runner_token = tokens.first()?; + let runner = command_basename(runner_token).to_ascii_lowercase(); + let runner = runner.strip_suffix(".exe").unwrap_or(&runner); + if !RUNNERS.contains(&runner) { + return None; + } + + let mut script_token: Option<&str> = None; + for token in tokens.iter().skip(1) { + if token == "--" { + continue; + } + if token.starts_with('-') { + continue; + } + script_token = Some(token.as_str()); + break; + } + let script_token = script_token?; + if SCRIPT_EXTENSIONS + .iter() + .any(|extension| script_token.to_ascii_lowercase().ends_with(extension)) + { + return Some(script_token); + } + + None +} + +fn detect_skill_script_run( + detector: &ImplicitSkillDetector, + tokens: &[String], + workdir: &Path, +) -> Option { + let script_token = script_run_token(tokens)?; + let script_path = Path::new(script_token); + let script_path = if script_path.is_absolute() { + script_path.to_path_buf() + } else { + workdir.join(script_path) + }; + let script_path = normalize_path(script_path.as_path()); + + for ancestor in script_path.ancestors() { + if let Some(candidate) = detector.by_scripts_dir.get(ancestor) { + return Some(candidate.clone()); + } + } + + None +} + +fn detect_skill_doc_read( + detector: &ImplicitSkillDetector, + tokens: &[String], + workdir: &Path, +) -> Option { + if !command_reads_file(tokens) { + return None; + } + + for token in tokens.iter().skip(1) { + if token.starts_with('-') { + continue; + } + let path = Path::new(token); + let candidate_path = if path.is_absolute() { + normalize_path(path) + } else { + normalize_path(&workdir.join(path)) + }; + if let Some(candidate) = detector.by_skill_doc_path.get(&candidate_path) { + return Some(candidate.clone()); + } + } + + None +} + +fn command_reads_file(tokens: &[String]) -> bool { + const READERS: [&str; 8] = ["cat", "sed", "head", "tail", "less", "more", "bat", "awk"]; + let Some(program) = tokens.first() else { + return false; + }; + let program = command_basename(program).to_ascii_lowercase(); + READERS.contains(&program.as_str()) +} + +fn command_basename(command: &str) -> String { + Path::new(command) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(command) + .to_string() +} + +fn normalize_path(path: &Path) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +#[cfg(test)] +mod tests { + use super::ImplicitSkillCandidate; + use super::ImplicitSkillDetector; + use super::InvocationType; + use super::SkillInvocation; + use super::detect_skill_doc_read; + use super::detect_skill_script_run; + use super::normalize_path; + use super::script_run_token; + use pretty_assertions::assert_eq; + use std::collections::HashMap; + use std::path::Path; + use std::path::PathBuf; + + #[test] + fn script_run_detection_matches_runner_plus_extension() { + let tokens = vec![ + "python3".to_string(), + "-u".to_string(), + "scripts/fetch_comments.py".to_string(), + ]; + + assert_eq!(script_run_token(&tokens).is_some(), true); + } + + #[test] + fn script_run_detection_excludes_python_c() { + let tokens = vec![ + "python3".to_string(), + "-c".to_string(), + "print(1)".to_string(), + ]; + + assert_eq!(script_run_token(&tokens).is_some(), false); + } + + #[test] + fn skill_doc_read_detection_matches_absolute_path() { + let skill_doc_path = PathBuf::from("/tmp/skill-test/SKILL.md"); + let normalized_skill_doc_path = normalize_path(skill_doc_path.as_path()); + let invocation = SkillInvocation { + skill_name: "test-skill".to_string(), + skill_scope: codex_protocol::protocol::SkillScope::User, + skill_path: skill_doc_path, + invocation_type: InvocationType::Implicit, + }; + let candidate = ImplicitSkillCandidate { invocation }; + + let detector = ImplicitSkillDetector { + by_scripts_dir: HashMap::new(), + by_skill_doc_path: HashMap::from([(normalized_skill_doc_path, candidate)]), + }; + + let tokens = vec![ + "cat".to_string(), + "/tmp/skill-test/SKILL.md".to_string(), + "|".to_string(), + "head".to_string(), + ]; + let found = detect_skill_doc_read(&detector, &tokens, Path::new("/tmp")); + + assert_eq!( + found.map(|value| value.invocation.skill_name), + Some("test-skill".to_string()) + ); + } + + #[test] + fn skill_script_run_detection_matches_relative_path_from_skill_root() { + let skill_doc_path = PathBuf::from("/tmp/skill-test/SKILL.md"); + let scripts_dir = normalize_path(Path::new("/tmp/skill-test/scripts")); + let invocation = SkillInvocation { + skill_name: "test-skill".to_string(), + skill_scope: codex_protocol::protocol::SkillScope::User, + skill_path: skill_doc_path, + invocation_type: InvocationType::Implicit, + }; + let candidate = ImplicitSkillCandidate { invocation }; + + let detector = ImplicitSkillDetector { + by_scripts_dir: HashMap::from([(scripts_dir, candidate)]), + by_skill_doc_path: HashMap::new(), + }; + let tokens = vec![ + "python3".to_string(), + "scripts/fetch_comments.py".to_string(), + ]; + + let found = detect_skill_script_run(&detector, &tokens, Path::new("/tmp/skill-test")); + + assert_eq!( + found.map(|value| value.invocation.skill_name), + Some("test-skill".to_string()) + ); + } + + #[test] + fn skill_script_run_detection_matches_absolute_path_from_any_workdir() { + let skill_doc_path = PathBuf::from("/tmp/skill-test/SKILL.md"); + let scripts_dir = normalize_path(Path::new("/tmp/skill-test/scripts")); + let invocation = SkillInvocation { + skill_name: "test-skill".to_string(), + skill_scope: codex_protocol::protocol::SkillScope::User, + skill_path: skill_doc_path, + invocation_type: InvocationType::Implicit, + }; + let candidate = ImplicitSkillCandidate { invocation }; + + let detector = ImplicitSkillDetector { + by_scripts_dir: HashMap::from([(scripts_dir, candidate)]), + by_skill_doc_path: HashMap::new(), + }; + let tokens = vec![ + "python3".to_string(), + "/tmp/skill-test/scripts/fetch_comments.py".to_string(), + ]; + + let found = detect_skill_script_run(&detector, &tokens, Path::new("/tmp/other")); + + assert_eq!( + found.map(|value| value.invocation.skill_name), + Some("test-skill".to_string()) + ); + } +} diff --git a/codex-rs/core/src/skills/manager.rs b/codex-rs/core/src/skills/manager.rs index cc6193f76..6743d8bee 100644 --- a/codex-rs/core/src/skills/manager.rs +++ b/codex-rs/core/src/skills/manager.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::collections::HashSet; use std::path::Path; use std::path::PathBuf; +use std::sync::Arc; use std::sync::RwLock; use codex_protocol::protocol::SkillScope; @@ -16,6 +17,7 @@ use crate::config_loader::CloudRequirementsLoader; use crate::config_loader::LoaderOverrides; use crate::config_loader::load_config_layers_state; use crate::skills::SkillLoadOutcome; +use crate::skills::build_implicit_invocation_context; use crate::skills::loader::SkillRoot; use crate::skills::loader::load_skills_from_roots; use crate::skills::loader::skill_roots_from_layer_stack_with_agents; @@ -50,6 +52,9 @@ impl SkillsManager { skill_roots_from_layer_stack_with_agents(&config.config_layer_stack, &config.cwd); let mut outcome = load_skills_from_roots(roots); outcome.disabled_paths = disabled_paths_from_stack(&config.config_layer_stack); + outcome.implicit_invocation_context = + build_implicit_invocation_context(outcome.allowed_skills_for_implicit_invocation()) + .map(Arc::new); let mut cache = match self.cache_by_cwd.write() { Ok(cache) => cache, Err(err) => err.into_inner(), @@ -125,6 +130,9 @@ impl SkillsManager { ); let mut outcome = load_skills_from_roots(roots); outcome.disabled_paths = disabled_paths_from_stack(&config_layer_stack); + outcome.implicit_invocation_context = + build_implicit_invocation_context(outcome.allowed_skills_for_implicit_invocation()) + .map(Arc::new); let mut cache = match self.cache_by_cwd.write() { Ok(cache) => cache, Err(err) => err.into_inner(), diff --git a/codex-rs/core/src/skills/mod.rs b/codex-rs/core/src/skills/mod.rs index 18f7180f9..633e28e37 100644 --- a/codex-rs/core/src/skills/mod.rs +++ b/codex-rs/core/src/skills/mod.rs @@ -1,5 +1,6 @@ mod env_var_dependencies; pub mod injection; +pub(crate) mod invocation_utils; pub mod loader; pub mod manager; pub mod model; @@ -13,6 +14,8 @@ pub(crate) use env_var_dependencies::resolve_skill_dependencies_for_turn; pub(crate) use injection::SkillInjections; pub(crate) use injection::build_skill_injections; pub(crate) use injection::collect_explicit_skill_mentions; +pub(crate) use invocation_utils::build_implicit_invocation_context; +pub(crate) use invocation_utils::maybe_emit_implicit_skill_invocation; pub use loader::load_skills; pub use manager::SkillsManager; pub use model::SkillError; diff --git a/codex-rs/core/src/skills/model.rs b/codex-rs/core/src/skills/model.rs index ac642db43..512e83db8 100644 --- a/codex-rs/core/src/skills/model.rs +++ b/codex-rs/core/src/skills/model.rs @@ -1,7 +1,9 @@ use std::collections::HashSet; use std::path::PathBuf; +use std::sync::Arc; use crate::config::Permissions; +use crate::skills::invocation_utils::ImplicitInvocationContext; use codex_protocol::protocol::SkillScope; #[derive(Debug, Clone, PartialEq)] @@ -68,6 +70,7 @@ pub struct SkillLoadOutcome { pub skills: Vec, pub errors: Vec, pub disabled_paths: HashSet, + pub(crate) implicit_invocation_context: Option>, } impl SkillLoadOutcome { diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 46a90681d..229ef5a3d 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -13,6 +13,7 @@ use crate::function_tool::FunctionCallError; use crate::is_safe_command::is_known_safe_command; use crate::protocol::ExecCommandSource; use crate::shell::Shell; +use crate::skills::maybe_emit_implicit_skill_invocation; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; @@ -228,6 +229,13 @@ impl ToolHandler for ShellCommandHandler { }; let params: ShellCommandToolCallParams = parse_arguments(&arguments)?; + maybe_emit_implicit_skill_invocation( + session.as_ref(), + turn.as_ref(), + ¶ms.command, + params.workdir.as_deref(), + ) + .await; let prefix_rule = params.prefix_rule.clone(); let exec_params = Self::to_exec_params( ¶ms, diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 76dbade19..12451c51d 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -5,6 +5,7 @@ use crate::protocol::TerminalInteractionEvent; use crate::sandboxing::SandboxPermissions; use crate::shell::Shell; use crate::shell::get_shell_by_model_provided_path; +use crate::skills::maybe_emit_implicit_skill_invocation; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; @@ -131,6 +132,13 @@ impl ToolHandler for UnifiedExecHandler { let response = match tool_name.as_str() { "exec_command" => { let args: ExecCommandArgs = parse_arguments(&arguments)?; + maybe_emit_implicit_skill_invocation( + session.as_ref(), + turn.as_ref(), + &args.cmd, + args.workdir.as_deref(), + ) + .await; let process_id = manager.allocate_process_id().await; let command = get_command( &args,