Support plugin agent roles

This commit is contained in:
charlesgong-openai
2026-07-07 22:54:32 -04:00
parent aaa30f79c2
commit 63d51ea592
17 changed files with 626 additions and 4 deletions
+25
View File
@@ -57,6 +57,7 @@ use tracing::instrument;
use tracing::warn;
const DEFAULT_SKILLS_DIR_NAME: &str = "skills";
const DEFAULT_AGENTS_DIR_NAME: &str = "agents";
const DEFAULT_HOOKS_CONFIG_FILE: &str = "hooks/hooks.json";
const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json";
const DEFAULT_APP_CONFIG_FILE: &str = ".app.json";
@@ -764,6 +765,7 @@ async fn load_plugin(
manifest_description: None,
root,
enabled: plugin.enabled,
agent_roots: Vec::new(),
skill_roots: Vec::new(),
disabled_skill_paths: HashSet::new(),
has_enabled_skills: false,
@@ -812,6 +814,7 @@ async fn load_plugin(
} => {
loaded_plugin.manifest_name = Some(manifest.display_name().to_string());
loaded_plugin.manifest_description = manifest.description.clone();
loaded_plugin.agent_roots = plugin_agent_roots(&plugin_root, manifest_paths);
loaded_plugin.skill_roots = plugin_skill_roots(&plugin_root, manifest_paths);
let resolved_skills = load_plugin_skills(
&plugin_root,
@@ -972,6 +975,28 @@ fn plugin_skill_roots(
paths
}
fn plugin_agent_roots(
plugin_root: &AbsolutePathBuf,
manifest_paths: &PluginManifestPaths,
) -> Vec<AbsolutePathBuf> {
let mut paths = default_agent_roots(plugin_root);
if let Some(path) = &manifest_paths.agents {
paths.push(path.clone());
}
paths.sort_unstable();
paths.dedup();
paths
}
fn default_agent_roots(plugin_root: &AbsolutePathBuf) -> Vec<AbsolutePathBuf> {
let agents_dir = plugin_root.join(DEFAULT_AGENTS_DIR_NAME);
if agents_dir.is_dir() {
vec![agents_dir]
} else {
Vec::new()
}
}
fn default_skill_roots(plugin_root: &AbsolutePathBuf) -> Vec<AbsolutePathBuf> {
let skills_dir = plugin_root.join(DEFAULT_SKILLS_DIR_NAME);
if skills_dir.is_dir() {
+77
View File
@@ -6,6 +6,9 @@ use codex_config::ConfigLayerSource;
use codex_config::ConfigRequirements;
use codex_config::ConfigRequirementsToml;
use codex_plugin::PluginId;
use codex_plugin::PluginLoadOutcome;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_plugins::PluginAgentRoot;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
@@ -66,6 +69,80 @@ fn configured_plugins_from_stack_merges_user_layers() {
);
}
#[tokio::test]
async fn load_plugins_discovers_default_and_manifest_agent_roots() {
let temp_dir = TempDir::new().expect("tempdir");
let plugin_root = temp_dir.path().join("plugins/cache/test/agented/local");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"agented","agents":"./extra-agents"}"#,
);
write_file(
&plugin_root.join("agents/researcher.toml"),
r#"name = "researcher"
description = "Research role"
developer_instructions = "Research carefully"
"#,
);
write_file(
&plugin_root.join("extra-agents/reviewer.toml"),
r#"name = "reviewer"
description = "Review role"
developer_instructions = "Review carefully"
"#,
);
let stack = ConfigLayerStack::new(
vec![user_layer(
user_config_path(&temp_dir, "config.toml"),
r#"
[plugins."agented@test"]
enabled = true
"#,
)],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("valid config layer stack");
let store = PluginStore::new(temp_dir.path().to_path_buf());
let plugins = load_plugins_from_layer_stack(
&stack,
HashMap::new(),
&store,
Some(Product::Codex),
/*prefer_remote_curated_conflicts*/ false,
)
.await;
let plugin = plugins
.iter()
.find(|plugin| plugin.config_name == "agented@test")
.expect("agented plugin should load");
assert_eq!(
plugin.agent_roots,
vec![
plugin_root.join("agents").abs(),
plugin_root.join("extra-agents").abs(),
]
);
assert_eq!(
PluginLoadOutcome::from_plugins(plugins).effective_plugin_agent_roots(),
vec![
PluginAgentRoot {
path: plugin_root.join("agents").abs(),
plugin_id: "agented@test".to_string(),
plugin_root: plugin_root.abs(),
},
PluginAgentRoot {
path: plugin_root.join("extra-agents").abs(),
plugin_id: "agented@test".to_string(),
plugin_root: plugin_root.abs(),
},
]
);
}
#[tokio::test]
async fn hooks_only_scope_shares_plugin_resolution_without_loading_other_capabilities() {
let temp_dir = TempDir::new().expect("tempdir");
@@ -864,6 +864,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() {
),
root: AbsolutePathBuf::try_from(plugin_root.clone()).unwrap(),
enabled: true,
agent_roots: Vec::new(),
skill_roots: vec![plugin_root.join("skills").abs()],
disabled_skill_paths: HashSet::new(),
has_enabled_skills: true,
@@ -2145,6 +2146,7 @@ async fn load_plugins_preserves_disabled_plugins_without_effective_contributions
manifest_description: None,
root: AbsolutePathBuf::try_from(plugin_root).unwrap(),
enabled: false,
agent_roots: Vec::new(),
skill_roots: Vec::new(),
disabled_skill_paths: HashSet::new(),
has_enabled_skills: false,
@@ -2320,6 +2322,7 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() {
manifest_description: None,
root: AbsolutePathBuf::try_from(codex_home.path().join(dir_name)).unwrap(),
enabled: true,
agent_roots: Vec::new(),
skill_roots: Vec::new(),
disabled_skill_paths: HashSet::new(),
has_enabled_skills: false,
+4
View File
@@ -33,6 +33,8 @@ struct RawPluginManifest {
// Keep manifest paths as raw strings so we can validate the required `./...` syntax before
// resolving them under the plugin root.
#[serde(default)]
agents: Option<RawPluginManifestPaths>,
#[serde(default)]
skills: Option<RawPluginManifestPaths>,
#[serde(default)]
mcp_servers: Option<RawPluginManifestMcpServers>,
@@ -162,6 +164,7 @@ pub(crate) fn parse_plugin_manifest_uri(
version,
description,
keywords,
agents,
skills,
mcp_servers,
apps,
@@ -258,6 +261,7 @@ pub(crate) fn parse_plugin_manifest_uri(
description,
keywords,
paths: codex_plugin::manifest::PluginManifestPaths {
agents: resolve_manifest_paths(plugin_root, "agents", agents.as_ref()),
skills: resolve_manifest_paths(plugin_root, "skills", skills.as_ref()),
mcp_servers: resolve_manifest_mcp_servers(plugin_root, mcp_servers),
apps: resolve_manifest_path(plugin_root, "apps", apps.as_deref()),