feat: Compress skill paths with root aliases (#19098)

Add skill root tracking so model-visible skill lists can use short path
aliases when absolute paths would exceed the metadata budget.
This commit is contained in:
xl-openai
2026-04-24 15:49:07 -07:00
committed by GitHub
Unverified
parent 588f7a9fc4
commit 1e560f33e1
8 changed files with 935 additions and 103 deletions
@@ -1,4 +1,5 @@
use codex_core_skills::AvailableSkills;
use codex_core_skills::render_available_skills_body;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
@@ -6,12 +7,14 @@ use super::ContextualUserFragment;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AvailableSkillsInstructions {
skill_root_lines: Vec<String>,
skill_lines: Vec<String>,
}
impl From<AvailableSkills> for AvailableSkillsInstructions {
fn from(available_skills: AvailableSkills) -> Self {
Self {
skill_root_lines: available_skills.skill_root_lines,
skill_lines: available_skills.skill_lines,
}
}
@@ -23,34 +26,6 @@ impl ContextualUserFragment for AvailableSkillsInstructions {
const END_MARKER: &'static str = SKILLS_INSTRUCTIONS_CLOSE_TAG;
fn body(&self) -> String {
let mut lines: Vec<String> = Vec::new();
lines.push("## Skills".to_string());
lines.push("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.".to_string());
lines.push("### Available skills".to_string());
lines.extend(self.skill_lines.iter().cloned());
lines.push("### How to use skills".to_string());
lines.push(
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.
- How to use a skill (progressive disclosure):
1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.
2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the skill directory listed above first, and only consider other paths if needed.
3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.
4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.
5) If `assets/` or templates exist, reuse them instead of recreating from scratch.
- Coordination and sequencing:
- If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.
- Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.
- Context hygiene:
- Keep context small: summarize long sections instead of pasting them; only load extra files when needed.
- Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.
- 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."###
.to_string(),
);
format!("\n{}\n", lines.join("\n"))
render_available_skills_body(&self.skill_root_lines, &self.skill_lines)
}
}
+1 -5
View File
@@ -2627,12 +2627,8 @@ impl Session {
}
}
if turn_context.config.include_skill_instructions {
let implicit_skills = turn_context
.turn_skills
.outcome
.allowed_skills_for_implicit_invocation();
let available_skills = build_available_skills(
&implicit_skills,
&turn_context.turn_skills.outcome,
default_skill_metadata_budget(turn_context.model_info.context_window),
SkillRenderSideEffects::ThreadStart {
session_telemetry: &self.services.session_telemetry,
+15 -11
View File
@@ -5185,17 +5185,19 @@ async fn build_initial_context_trims_skill_metadata_from_context_window_budget()
#[test]
fn emit_thread_start_skill_metrics_records_enabled_kept_and_truncated_values() {
let session_telemetry = test_session_telemetry_without_metadata();
let mut outcome = SkillLoadOutcome::default();
outcome.skills = vec![SkillMetadata {
name: "repo-skill".to_string(),
description: "desc".to_string(),
short_description: None,
interface: None,
dependencies: None,
policy: None,
path_to_skills_md: test_path_buf("/tmp/repo-skill/SKILL.md").abs(),
scope: SkillScope::Repo,
}];
let rendered = build_available_skills(
&[SkillMetadata {
name: "repo-skill".to_string(),
description: "desc".to_string(),
short_description: None,
interface: None,
dependencies: None,
policy: None,
path_to_skills_md: test_path_buf("/tmp/repo-skill/SKILL.md").abs(),
scope: SkillScope::Repo,
}],
&outcome,
SkillMetadataBudget::Characters(1),
SkillRenderSideEffects::ThreadStart {
session_telemetry: &session_telemetry,
@@ -5255,9 +5257,11 @@ fn emit_thread_start_skill_metrics_records_description_truncated_chars_without_o
.count()
};
let minimum_budget = minimum_skill_line_cost(&alpha) + minimum_skill_line_cost(&beta);
let mut outcome = SkillLoadOutcome::default();
outcome.skills = vec![alpha, beta];
let rendered = build_available_skills(
&[alpha, beta],
&outcome,
SkillMetadataBudget::Characters(minimum_budget + 6),
SkillRenderSideEffects::ThreadStart {
session_telemetry: &session_telemetry,
+91
View File
@@ -1,3 +1,4 @@
use codex_config::ConfigLayerStack;
use codex_config::types::AuthCredentialsStoreMode;
use codex_core::ModelClient;
use codex_core::NewThread;
@@ -71,6 +72,7 @@ use std::io::Write;
use std::num::NonZeroU64;
use std::sync::Arc;
use tempfile::TempDir;
use toml::toml;
use uuid::Uuid;
use wiremock::Mock;
use wiremock::MockServer;
@@ -1493,6 +1495,95 @@ async fn skills_append_to_developer_message() {
let _codex_home_guard = codex_home;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn skills_use_aliases_in_developer_message_under_budget_pressure() {
skip_if_no_network!();
let server = MockServer::start().await;
let resp_mock = mount_sse_once(
&server,
sse(vec![ev_response_created("resp1"), ev_completed("resp1")]),
)
.await;
let codex_home_parent = TempDir::new().unwrap();
let long_home_parent = codex_home_parent
.path()
.join("codex-home-with-long-shared-prefix-for-skill-alias-budget-test");
std::fs::create_dir_all(&long_home_parent).expect("create long home parent");
let codex_home = Arc::new(TempDir::new_in(long_home_parent).unwrap());
let skill_root = codex_home.path().join("skills");
for index in 0..12 {
let skill_dir = skill_root.join(format!("s{index:02}"));
std::fs::create_dir_all(&skill_dir).expect("create skill dir");
std::fs::write(
skill_dir.join("SKILL.md"),
format!("---\nname: s{index:02}\ndescription: d\n---\n\n# body\n"),
)
.expect("write skill");
}
let codex_home_path = codex_home.path().to_path_buf();
let mut builder = test_codex()
.with_home(codex_home.clone())
.with_auth(CodexAuth::from_api_key("Test API Key"))
.with_config(move |config| {
config.cwd = codex_home_path.abs();
let user_config_path = codex_home_path.join("config.toml").abs();
config.config_layer_stack = ConfigLayerStack::default().with_user_config(
&user_config_path,
toml! { skills = { bundled = { enabled = false } } }.into(),
);
config.model_context_window = Some(12_000);
});
let codex = builder
.build(&server)
.await
.expect("create new conversation")
.codex;
codex
.submit(Op::UserInput {
environments: None,
items: vec![UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
})
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let request = resp_mock.single_request();
let developer_messages = request.message_input_texts("developer");
let developer_text = developer_messages.join("\n\n");
let expected_root = normalize_path(skill_root).unwrap();
let expected_root_str = expected_root.to_string_lossy().replace('\\', "/");
assert!(
developer_text.contains("### Skill roots"),
"expected aliased skills root section: {developer_messages:?}"
);
assert!(
developer_text.contains(&format!("- `r0` = `{expected_root_str}`")),
"expected root alias for {expected_root_str}: {developer_messages:?}"
);
assert!(
developer_text.contains("- s00: d (file: r0/s00/SKILL.md)"),
"expected skill path to use root alias: {developer_messages:?}"
);
assert!(
developer_text.contains(
"expand the listed short `path` with the matching alias from `### Skill roots`"
),
"expected alias-specific skill instructions: {developer_messages:?}"
);
let _codex_home_guard = codex_home;
let _codex_home_parent_guard = codex_home_parent;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn includes_configured_effort_in_request() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));