diff --git a/codex-rs/config/src/skills_config.rs b/codex-rs/config/src/skills_config.rs index 948b9060c48c..1ab0c41cd3eb 100644 --- a/codex-rs/config/src/skills_config.rs +++ b/codex-rs/config/src/skills_config.rs @@ -31,6 +31,10 @@ pub struct SkillsConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub include_instructions: Option, + /// Whether the automatic skills instructions include guidance to announce skill usage. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub announce_usage: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] pub config: Vec, } diff --git a/codex-rs/core-skills/src/render.rs b/codex-rs/core-skills/src/render.rs index 28617fb6c425..d260a8dc7a16 100644 --- a/codex-rs/core-skills/src/render.rs +++ b/codex-rs/core-skills/src/render.rs @@ -24,6 +24,7 @@ pub const SKILL_DESCRIPTIONS_REMOVED_WARNING_PREFIX: &str = "Exceeded skills context budget. All skill descriptions were removed and"; pub const SKILLS_INTRO_WITH_ABSOLUTE_PATHS: &str = "A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill."; pub const SKILLS_INTRO_WITH_ALIASES: &str = "A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and a short path that can be expanded into an absolute path using the skill roots table."; +pub const SKILLS_ANNOUNCEMENT_GUIDANCE_LINE: &str = " - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why."; pub const SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS: &str = r###"- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths. - Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned. - Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback. @@ -59,7 +60,11 @@ pub const SKILLS_HOW_TO_USE_WITH_ALIASES: &str = r###"- Discovery: The list abov - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice. - Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."###; -pub fn render_available_skills_body(skill_root_lines: &[String], skill_lines: &[String]) -> String { +pub fn render_available_skills_body( + skill_root_lines: &[String], + skill_lines: &[String], + announce_usage: bool, +) -> String { let mut lines: Vec = Vec::new(); lines.push("## Skills".to_string()); if skill_root_lines.is_empty() { @@ -78,11 +83,23 @@ pub fn render_available_skills_body(skill_root_lines: &[String], skill_lines: &[ } else { SKILLS_HOW_TO_USE_WITH_ALIASES }; - lines.push(how_to_use.to_string()); + lines.push(render_how_to_use(how_to_use, announce_usage)); format!("\n{}\n", lines.join("\n")) } +fn render_how_to_use(how_to_use: &str, announce_usage: bool) -> String { + if announce_usage { + return how_to_use.to_string(); + } + + how_to_use + .lines() + .filter(|line| *line != SKILLS_ANNOUNCEMENT_GUIDANCE_LINE) + .collect::>() + .join("\n") +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SkillMetadataBudget { Tokens(usize), @@ -786,8 +803,13 @@ fn aliased_metadata_overhead_cost( skill_root_lines: &[String], ) -> usize { let empty_skill_lines: &[String] = &[]; - let absolute_body = render_available_skills_body(&[], empty_skill_lines); - let aliased_body = render_available_skills_body(skill_root_lines, empty_skill_lines); + let absolute_body = + render_available_skills_body(&[], empty_skill_lines, /*announce_usage*/ true); + let aliased_body = render_available_skills_body( + skill_root_lines, + empty_skill_lines, + /*announce_usage*/ true, + ); budget .cost(&aliased_body) .saturating_sub(budget.cost(&absolute_body)) @@ -1006,6 +1028,42 @@ mod tests { ); } + #[test] + fn render_body_includes_announcement_guidance_when_enabled() { + let skill_lines = vec!["- example: test skill (file: /tmp/example/SKILL.md)".to_string()]; + + let absolute = + render_available_skills_body(&[], &skill_lines, /*announce_usage*/ true); + let aliased = render_available_skills_body( + &["- `r0` = `/tmp`".to_string()], + &skill_lines, + /*announce_usage*/ true, + ); + + assert!(absolute.contains(SKILLS_ANNOUNCEMENT_GUIDANCE_LINE)); + assert!(aliased.contains(SKILLS_ANNOUNCEMENT_GUIDANCE_LINE)); + assert!(absolute.contains(SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS)); + assert!(aliased.contains(SKILLS_HOW_TO_USE_WITH_ALIASES)); + } + + #[test] + fn render_body_omits_only_announcement_guidance_when_disabled() { + let skill_lines = vec!["- example: test skill (file: /tmp/example/SKILL.md)".to_string()]; + let skill_root_lines = vec!["- `r0` = `/tmp`".to_string()]; + + for roots in [&[][..], skill_root_lines.as_slice()] { + let with_guidance = + render_available_skills_body(roots, &skill_lines, /*announce_usage*/ true); + let without_guidance = + render_available_skills_body(roots, &skill_lines, /*announce_usage*/ false); + let expected = + with_guidance.replace(&format!("\n{SKILLS_ANNOUNCEMENT_GUIDANCE_LINE}"), ""); + + assert_eq!(without_guidance, expected); + assert!(!without_guidance.contains(SKILLS_ANNOUNCEMENT_GUIDANCE_LINE)); + } + } + #[test] fn budgeted_rendering_truncates_descriptions_equally_before_omitting_skills() { let alpha = make_skill_with_description("alpha-skill", SkillScope::Repo, "abcdef"); diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 00fc4931a494..57f79dbe8e30 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2483,6 +2483,10 @@ "SkillsConfig": { "additionalProperties": false, "properties": { + "announce_usage": { + "description": "Whether the automatic skills instructions include guidance to announce skill usage.", + "type": "boolean" + }, "bundled": { "$ref": "#/definitions/BundledSkillsConfig" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 2297314e06ae..d49399e2429b 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -368,6 +368,7 @@ fn parses_bundled_skills_config() { r#" [skills] include_instructions = false +announce_usage = false [skills.bundled] enabled = false @@ -380,6 +381,7 @@ enabled = false Some(SkillsConfig { bundled: Some(BundledSkillsConfig { enabled: false }), include_instructions: Some(false), + announce_usage: Some(false), config: Vec::new(), }) ); @@ -7805,6 +7807,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { include_apps_instructions: true, include_collaboration_mode_instructions: true, include_skill_instructions: true, + skill_announce_usage: true, include_environment_context: true, compact_prompt: None, forced_chatgpt_workspace_id: None, @@ -8259,6 +8262,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { include_apps_instructions: true, include_collaboration_mode_instructions: true, include_skill_instructions: true, + skill_announce_usage: true, include_environment_context: true, compact_prompt: None, forced_chatgpt_workspace_id: None, @@ -8427,6 +8431,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { include_apps_instructions: true, include_collaboration_mode_instructions: true, include_skill_instructions: true, + skill_announce_usage: true, include_environment_context: true, compact_prompt: None, forced_chatgpt_workspace_id: None, @@ -8580,6 +8585,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { include_apps_instructions: true, include_collaboration_mode_instructions: true, include_skill_instructions: true, + skill_announce_usage: true, include_environment_context: true, compact_prompt: None, forced_chatgpt_workspace_id: None, @@ -9936,6 +9942,41 @@ include_environment_context = true Ok(()) } +#[tokio::test] +async fn skill_announce_usage_defaults_to_enabled() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await?; + + assert!(config.skill_announce_usage); + Ok(()) +} + +#[tokio::test] +async fn skill_announce_usage_can_be_disabled() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[skills] +announce_usage = false +"#, + )?; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await?; + + assert!(config.include_skill_instructions); + assert!(!config.skill_announce_usage); + Ok(()) +} + #[tokio::test] async fn approvals_reviewer_stays_manual_only_when_guardian_feature_is_enabled() -> std::io::Result<()> { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 336d54b3828f..d951c5a68879 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -597,6 +597,9 @@ pub struct Config { /// Whether to inject the `` developer block. pub include_skill_instructions: bool, + /// Whether the automatic skills instructions include guidance to announce skill usage. + pub skill_announce_usage: bool, + /// Whether to inject the `` user block. pub include_environment_context: bool, @@ -3186,6 +3189,11 @@ impl Config { .as_ref() .and_then(|skills| skills.include_instructions) .unwrap_or(true); + let skill_announce_usage = cfg + .skills + .as_ref() + .and_then(|skills| skills.announce_usage) + .unwrap_or(true); let include_environment_context = config_profile .include_environment_context .or(cfg.include_environment_context) @@ -3411,6 +3419,7 @@ impl Config { include_apps_instructions, include_collaboration_mode_instructions, include_skill_instructions, + skill_announce_usage, include_environment_context, // The config.toml omits "_mode" because it's a config file. However, "_mode" // is important in code to differentiate the mode from the store implementation. diff --git a/codex-rs/core/src/context/available_skills_instructions.rs b/codex-rs/core/src/context/available_skills_instructions.rs index e63d637677a2..2a1097ff7394 100644 --- a/codex-rs/core/src/context/available_skills_instructions.rs +++ b/codex-rs/core/src/context/available_skills_instructions.rs @@ -9,13 +9,15 @@ use super::ContextualUserFragment; pub(crate) struct AvailableSkillsInstructions { skill_root_lines: Vec, skill_lines: Vec, + announce_usage: bool, } -impl From for AvailableSkillsInstructions { - fn from(available_skills: AvailableSkills) -> Self { +impl AvailableSkillsInstructions { + pub(crate) fn new(available_skills: AvailableSkills, announce_usage: bool) -> Self { Self { skill_root_lines: available_skills.skill_root_lines, skill_lines: available_skills.skill_lines, + announce_usage, } } } @@ -34,6 +36,10 @@ impl ContextualUserFragment for AvailableSkillsInstructions { } fn body(&self) -> String { - render_available_skills_body(&self.skill_root_lines, &self.skill_lines) + render_available_skills_body( + &self.skill_root_lines, + &self.skill_lines, + self.announce_usage, + ) } } diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index 85815f8533fd..36a0081be822 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -170,10 +170,9 @@ fn save_config_resolved_fields( agents.job_max_runtime_seconds = config.agent_job_max_runtime_seconds; agents.interrupt_message = Some(config.agent_interrupt_message_enabled); - lock_config - .skills - .get_or_insert_with(Default::default) - .include_instructions = Some(config.include_skill_instructions); + let skills = lock_config.skills.get_or_insert_with(Default::default); + skills.include_instructions = Some(config.include_skill_instructions); + skills.announce_usage = Some(config.skill_announce_usage); Ok(()) } @@ -224,6 +223,12 @@ mod tests { assert_eq!(lock.instructions, Some(sc.base_instructions.clone())); assert_eq!(lock.developer_instructions, sc.developer_instructions); assert_eq!(lock.compact_prompt, sc.compact_prompt); + assert_eq!( + lock.skills + .as_ref() + .and_then(|skills| skills.announce_usage), + Some(sc.original_config_do_not_use.skill_announce_usage) + ); assert_eq!(lock.model, Some(sc.collaboration_mode.model().to_string())); assert_eq!( lock.model_reasoning_effort, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 8f0d2f1c01f7..3f4bd1214ab8 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2772,7 +2772,10 @@ impl Session { ); if let Some(available_skills) = available_skills { let warning_message = available_skills.warning_message.clone(); - let skills_instructions = AvailableSkillsInstructions::from(available_skills); + let skills_instructions = AvailableSkillsInstructions::new( + available_skills, + turn_context.config.skill_announce_usage, + ); if let Some(warning_message) = warning_message { self.send_event_raw(Event { id: String::new(), diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 9fdd6db90fb1..4a5812f126dc 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -192,6 +192,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R include_apps_instructions: false, include_collaboration_mode_instructions: false, include_skill_instructions: false, + skill_announce_usage: false, include_environment_context: false, compact_prompt: None, notify: None,