Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e8622da
Add runtime skills discovery and validation
tibo-openai Nov 30, 2025
f3552dc
Add skills plan diagram
tibo-openai Nov 30, 2025
5be690a
Update skills plan diagram to mermaid
tibo-openai Nov 30, 2025
1705bf8
Add ASCII skills data flow diagram
tibo-openai Nov 30, 2025
e14249e
Update skills plan
tibo-openai Nov 30, 2025
ca4680e
Normalize Windows paths in skills tests
gverma-openai Dec 1, 2025
ac5b4ba
Merge branch 'main' into tibo/skills
gverma-openai Dec 1, 2025
f3122f6
fmt and lint fixes
gverma-openai Dec 1, 2025
100a8bb
Add skills documentation (experimental)
gverma-openai Dec 1, 2025
417168a
Delete design doc
gverma-openai Dec 1, 2025
3c215fe
prettier format new markdown file
gverma-openai Dec 1, 2025
2dfb2fb
Normalize path when extracting for Windows
gverma-openai Dec 1, 2025
e401362
Reorganize skills into folder
gverma-openai Dec 1, 2025
9f5fef6
Rename constant
gverma-openai Dec 1, 2025
95fc467
fmt fixes
gverma-openai Dec 1, 2025
75e30f5
Add CLI argument to feature gate loading of skills
gverma-openai Dec 2, 2025
e5fea7b
Fix import
gverma-openai Dec 2, 2025
f94d631
Add CLI arg feature flag to tests
gverma-openai Dec 2, 2025
f5c6184
Check whether target is symlink before following it
gverma-openai Dec 2, 2025
926fe0b
Use feature enum natively
gverma-openai Dec 2, 2025
1cee47c
Return error enum when parsing skills
gverma-openai Dec 2, 2025
9d354e5
Update test to match typed error
gverma-openai Dec 2, 2025
2e282f5
Join strings instead of quadruple-match
gverma-openai Dec 2, 2025
334e98c
Add test suite for loading skills
gverma-openai Dec 2, 2025
1a9a11d
Remove unused import
gverma-openai Dec 2, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions codex-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions codex-rs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ seccompiler = "0.5.0"
sentry = "0.34.0"
serde = "1"
serde_json = "1"
serde_yaml = "0.9"
serde_with = "3.16"
serial_test = "3.2.0"
sha1 = "0.10.6"
Expand Down
1 change: 1 addition & 0 deletions codex-rs/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ regex-lite = { workspace = true }
reqwest = { workspace = true, features = ["json", "stream"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_yaml = { workspace = true }
sha1 = { workspace = true }
sha2 = { workspace = true }
shlex = { workspace = true }
Expand Down
8 changes: 8 additions & 0 deletions codex-rs/core/src/features.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ pub enum Feature {
ShellTool,
/// Allow model to call multiple tools in parallel (only for models supporting it).
ParallelToolCalls,
/// Experimental skills injection (CLI flag-driven).
Skills,
}

impl Feature {
Expand Down Expand Up @@ -326,4 +328,10 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::Skills,
key: "skills",
stage: Stage::Experimental,
default_enabled: false,
},
];
1 change: 1 addition & 0 deletions codex-rs/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ mod rollout;
pub(crate) mod safety;
pub mod seatbelt;
pub mod shell;
pub mod skills;
pub mod spawn;
pub mod terminal;
mod tools;
Expand Down
118 changes: 109 additions & 9 deletions codex-rs/core/src/project_doc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
//! 3. We do **not** walk past the Git root.

use crate::config::Config;
use crate::features::Feature;
use crate::skills::load_skills;
use crate::skills::render_skills_section;
use dunce::canonicalize as normalize_path;
use std::path::PathBuf;
use tokio::io::AsyncReadExt;
Expand All @@ -31,18 +34,47 @@ const PROJECT_DOC_SEPARATOR: &str = "\n\n--- project-doc ---\n\n";
/// Combines `Config::instructions` and `AGENTS.md` (if present) into a single
/// string of instructions.
pub(crate) async fn get_user_instructions(config: &Config) -> Option<String> {
match read_project_docs(config).await {
Ok(Some(project_doc)) => match &config.user_instructions {
Some(original_instructions) => Some(format!(
"{original_instructions}{PROJECT_DOC_SEPARATOR}{project_doc}"
)),
None => Some(project_doc),
},
Ok(None) => config.user_instructions.clone(),
let skills_section = if config.features.enabled(Feature::Skills) {
let skills_outcome = load_skills(config);
for err in &skills_outcome.errors {
error!(
"failed to load skill {}: {}",
err.path.display(),
err.message
);
}
render_skills_section(&skills_outcome.skills)
} else {
None
};

let project_docs = match read_project_docs(config).await {
Ok(docs) => docs,
Err(e) => {
error!("error trying to find project doc: {e:#}");
config.user_instructions.clone()
return config.user_instructions.clone();
}
};

let combined_project_docs = merge_project_docs_with_skills(project_docs, skills_section);

let mut parts: Vec<String> = Vec::new();

if let Some(instructions) = config.user_instructions.clone() {
parts.push(instructions);
}

if let Some(project_doc) = combined_project_docs {
if !parts.is_empty() {
parts.push(PROJECT_DOC_SEPARATOR.to_string());
}
parts.push(project_doc);
}

if parts.is_empty() {
None
} else {
Some(parts.concat())
}
}

Expand Down Expand Up @@ -195,12 +227,25 @@ fn candidate_filenames<'a>(config: &'a Config) -> Vec<&'a str> {
names
}

fn merge_project_docs_with_skills(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we get rid of this as well?

project_doc: Option<String>,
skills_section: Option<String>,
) -> Option<String> {
match (project_doc, skills_section) {
(Some(doc), Some(skills)) => Some(format!("{doc}\n\n{skills}")),
(Some(doc), None) => Some(doc),
(None, Some(skills)) => Some(skills),
(None, None) => None,
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::config::ConfigOverrides;
use crate::config::ConfigToml;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;

/// Helper that returns a `Config` pointing at `root` and using `limit` as
Expand All @@ -219,6 +264,7 @@ mod tests {

config.cwd = root.path().to_path_buf();
config.project_doc_max_bytes = limit;
config.features.enable(Feature::Skills);

config.user_instructions = instructions.map(ToOwned::to_owned);
config
Expand Down Expand Up @@ -447,4 +493,58 @@ mod tests {
.eq(DEFAULT_PROJECT_DOC_FILENAME)
);
}

#[tokio::test]
async fn skills_are_appended_to_project_doc() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::write(tmp.path().join("AGENTS.md"), "base doc").unwrap();

let cfg = make_config(&tmp, 4096, None);
create_skill(
cfg.codex_home.clone(),
"pdf-processing",
"extract from pdfs",
);

let res = get_user_instructions(&cfg)
.await
.expect("instructions expected");
let expected_path = dunce::canonicalize(
cfg.codex_home
.join("skills/pdf-processing/SKILL.md")
.as_path(),
)
.unwrap_or_else(|_| cfg.codex_home.join("skills/pdf-processing/SKILL.md"));
let expected_path_str = expected_path.to_string_lossy().replace('\\', "/");
let expected = format!(
"base doc\n\n## Skills\nThese skills are discovered at startup from ~/.codex/skills; each entry shows name, description, and file path so you can open the source for full instructions. Content is not inlined to keep context lean.\n- pdf-processing: extract from pdfs (file: {expected_path_str})"
);
assert_eq!(res, expected);
}

#[tokio::test]
async fn skills_render_without_project_doc() {
let tmp = tempfile::tempdir().expect("tempdir");
let cfg = make_config(&tmp, 4096, None);
create_skill(cfg.codex_home.clone(), "linting", "run clippy");

let res = get_user_instructions(&cfg)
.await
.expect("instructions expected");
let expected_path =
dunce::canonicalize(cfg.codex_home.join("skills/linting/SKILL.md").as_path())
.unwrap_or_else(|_| cfg.codex_home.join("skills/linting/SKILL.md"));
let expected_path_str = expected_path.to_string_lossy().replace('\\', "/");
let expected = format!(
"## Skills\nThese skills are discovered at startup from ~/.codex/skills; each entry shows name, description, and file path so you can open the source for full instructions. Content is not inlined to keep context lean.\n- linting: run clippy (file: {expected_path_str})"
);
assert_eq!(res, expected);
}

fn create_skill(codex_home: PathBuf, name: &str, description: &str) {
let skill_dir = codex_home.join(format!("skills/{name}"));
fs::create_dir_all(&skill_dir).unwrap();
let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n");
fs::write(skill_dir.join("SKILL.md"), content).unwrap();
}
}
Loading
Loading