-
Notifications
You must be signed in to change notification settings - Fork 12.7k
feat: implement (multi) subagent orchestration system #3655
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from 12 commits
Commits
Show all changes
28 commits
Select commit
Hold shift + click to select a range
46b6cd3
feat: implement multi-agent orchestration system
metaphorics 9582502
Merge branch 'openai:main' into main
metaphorics 160774d
chore: apply clippy lints
metaphorics f3d900f
Merge branch 'main' into main
metaphorics 26975dc
Merge branch 'main' into main
metaphorics ae07076
feat: update subagents ux experiences
metaphorics f3f14bd
docs: update documents to meet the new subagents features accordingly
metaphorics 2c2768a
Merge branch 'main' into main
metaphorics 458e3d1
feat: implement path validation for agent prompt files
metaphorics 2c322f2
refactor: enhance agent context handling and task extraction
metaphorics 117f524
chore: update tests accordingly (added agent tool)
metaphorics cfd8248
Merge branch 'main' into main
metaphorics 54b6acd
refactor: improve agent ID generation and context management
metaphorics 23d04b1
refactor: improve agent context management and logging
metaphorics cd24d73
Merge branch 'main' into main
metaphorics 275f92e
refactor: enhance agent event handling and context management
metaphorics d7f89b1
Merge branch 'main' into main
metaphorics 784464e
Implement validation and prompt loading for AgentConfig
metaphorics 374dcf7
Refactor agent tool call handling for parallel execution
metaphorics 1ad8bae
Merge branch 'main' into main
metaphorics 7199be9
fix: Refactor agent execution for true parallelism
metaphorics 6a36f32
refactor: Improve mutex handling and state management
metaphorics 96aaa91
refactor: Improve tool call execution strategy for concurrency
metaphorics 0af39e8
Merge branch 'main' into main
metaphorics 9f4d05d
fixr: Update agent message structure for input handling
metaphorics 8d526a3
refactor: Enhance agent tool handling for improved parallel execution
metaphorics 086a049
refactor: Implement agent context management to prevent recursion
metaphorics 9f1e3c0
Merge branch 'main' into main
metaphorics File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,300 @@ | ||
| //! Multi-agent orchestration system with customizable system prompts | ||
| //! | ||
| //! This module provides a lightweight agent system where agents are primarily | ||
| //! specialized through custom system prompts while inheriting tools and permissions | ||
| //! from the current workspace context. | ||
|
|
||
| use crate::error::Result; | ||
| use serde::Deserialize; | ||
| use serde::Serialize; | ||
| use std::collections::HashMap; | ||
| use std::path::Path; | ||
| use std::path::PathBuf; | ||
|
|
||
| /// Configuration for a single agent | ||
| #[derive(Debug, Clone, Serialize, Deserialize)] | ||
| pub struct AgentConfig { | ||
| /// The system prompt that defines the agent's behavior | ||
| pub prompt: String, | ||
|
|
||
| /// Optional: Load prompt from file instead of inline | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| pub prompt_file: Option<String>, | ||
|
|
||
| /// Optional: Override tools (usually inherits from context) | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| pub tools: Option<Vec<String>>, | ||
|
|
||
| /// Optional: Override permissions (usually inherits from context) | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| pub permissions: Option<String>, | ||
| } | ||
|
|
||
| /// Registry of available agents and their configurations | ||
| pub struct AgentRegistry { | ||
| agents: HashMap<String, AgentConfig>, | ||
| #[allow(dead_code)] | ||
| agents_dir: Option<PathBuf>, | ||
| } | ||
|
|
||
| impl AgentRegistry { | ||
| /// Validate that a prompt file path doesn't escape allowed directories | ||
| fn validate_prompt_path(base_dir: &Path, prompt_file: &str) -> anyhow::Result<PathBuf> { | ||
| let path = if prompt_file.starts_with('/') { | ||
| PathBuf::from(prompt_file) | ||
| } else { | ||
| base_dir.join(prompt_file) | ||
| }; | ||
|
|
||
| // Canonicalize to resolve ../ and symlinks | ||
| let canonical = path | ||
| .canonicalize() | ||
| .map_err(|e| anyhow::anyhow!("Cannot access prompt file: {}", e))?; | ||
|
|
||
| // Get the home/.codex directory | ||
| let home_codex = dirs::home_dir() | ||
| .ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))? | ||
| .join(".codex"); | ||
|
|
||
| // Security check: path must be within ~/.codex or the base directory | ||
| if !canonical.starts_with(&home_codex) && !canonical.starts_with(base_dir) { | ||
| return Err(anyhow::anyhow!( | ||
| "Security error: Prompt file must be within ~/.codex directory" | ||
| )); | ||
| } | ||
|
|
||
| Ok(canonical) | ||
| } | ||
|
|
||
| /// Create a new agent registry, loading user configurations if available | ||
| pub fn new() -> Result<Self> { | ||
| let mut agents = HashMap::new(); | ||
|
|
||
| // Add the single default "general" agent | ||
| agents.insert( | ||
| "general".to_string(), | ||
| AgentConfig { | ||
| prompt: "You are a helpful AI assistant. Complete the given task efficiently and accurately.".to_string(), | ||
| prompt_file: None, | ||
| tools: None, | ||
| permissions: None, | ||
| } | ||
| ); | ||
|
|
||
| // Try to load user agents from ~/.codex/agents.toml | ||
| let agents_dir = Self::get_agents_directory(); | ||
| if let Some(ref dir) = agents_dir { | ||
| let config_path = dir.join("agents.toml"); | ||
| if config_path.exists() { | ||
| match std::fs::read_to_string(&config_path) { | ||
| Ok(content) => { | ||
| match toml::from_str::<HashMap<String, AgentConfig>>(&content) { | ||
| Ok(user_agents) => { | ||
| // Process each agent config | ||
| for (name, mut config) in user_agents { | ||
| // If prompt_file is specified, load the prompt from file | ||
| if let Some(ref prompt_file) = config.prompt_file { | ||
| // Validate the path to prevent traversal attacks | ||
| match Self::validate_prompt_path(dir, prompt_file) { | ||
| Ok(safe_path) => { | ||
| match std::fs::read_to_string(&safe_path) { | ||
| Ok(prompt_content) => { | ||
| config.prompt = prompt_content; | ||
| tracing::debug!( | ||
| "Loaded prompt file for agent '{}'", | ||
| name | ||
| ); | ||
| } | ||
| Err(e) => { | ||
| tracing::error!( | ||
| "Cannot read prompt file '{}' for agent '{}': {}", | ||
| prompt_file, | ||
| name, | ||
| e | ||
| ); | ||
| // Skip this agent but continue loading others | ||
| continue; | ||
| } | ||
| } | ||
| } | ||
| Err(e) => { | ||
| tracing::error!( | ||
| "Agent '{}' configuration error: {}", | ||
| name, | ||
| e | ||
| ); | ||
| // Skip this agent but continue loading others | ||
| continue; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| agents.insert(name, config); | ||
| } | ||
| tracing::info!("Loaded {} user-defined agents", agents.len() - 1); | ||
| } | ||
| Err(e) => { | ||
| tracing::warn!("Failed to parse agents.toml: {}", e); | ||
| } | ||
| } | ||
| } | ||
| Err(e) => { | ||
| tracing::debug!("Could not read agents.toml: {}", e); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Ok(Self { agents, agents_dir }) | ||
| } | ||
|
|
||
| /// Get the agents directory path (~/.codex/agents) | ||
| fn get_agents_directory() -> Option<PathBuf> { | ||
| std::env::var("HOME") | ||
| .or_else(|_| std::env::var("USERPROFILE")) | ||
| .ok() | ||
| .map(|home| PathBuf::from(home).join(".codex")) | ||
| } | ||
|
|
||
| /// Get an agent configuration by name | ||
| #[allow(dead_code)] | ||
| pub fn get_agent(&self, name: &str) -> Option<&AgentConfig> { | ||
| self.agents.get(name) | ||
| } | ||
|
|
||
| /// Get the system prompt for an agent (falls back to "general" if not found) | ||
| pub fn get_system_prompt(&self, agent_name: &str) -> String { | ||
| self.agents | ||
| .get(agent_name) | ||
| .or_else(|| self.agents.get("general")) | ||
| .map(|config| config.prompt.clone()) | ||
| .unwrap_or_else(|| "You are a helpful AI assistant.".to_string()) | ||
| } | ||
|
|
||
| /// List all available agents | ||
| #[allow(dead_code)] | ||
| pub fn list_agents(&self) -> Vec<String> { | ||
| self.agents.keys().cloned().collect() | ||
| } | ||
|
|
||
| /// Get detailed information about all agents | ||
| pub fn list_agent_details(&self) -> Vec<crate::protocol::AgentInfo> { | ||
| let mut agents = Vec::new(); | ||
|
|
||
| for (name, config) in &self.agents { | ||
| let description = self.extract_description(&config.prompt); | ||
| agents.push(crate::protocol::AgentInfo { | ||
| name: name.clone(), | ||
| description, | ||
| is_builtin: name == "general", | ||
| }); | ||
| } | ||
|
|
||
| agents.sort_by(|a, b| { | ||
| // Built-in agents first, then alphabetical | ||
| match (a.is_builtin, b.is_builtin) { | ||
| (true, false) => std::cmp::Ordering::Less, | ||
| (false, true) => std::cmp::Ordering::Greater, | ||
| _ => a.name.cmp(&b.name), | ||
| } | ||
| }); | ||
|
|
||
| agents | ||
| } | ||
|
|
||
| /// Extract brief description from prompt | ||
| fn extract_description(&self, prompt: &str) -> String { | ||
| // Take first line or first sentence as description | ||
| let first_line = prompt.lines().next().unwrap_or(""); | ||
| let desc = if let Some(pos) = first_line.find('.') { | ||
| &first_line[..=pos] | ||
| } else { | ||
| first_line | ||
| }; | ||
|
|
||
| // Clean up common prefixes | ||
| desc.trim_start_matches("You are a ") | ||
| .trim_start_matches("You are an ") | ||
| .trim_start_matches("You are ") | ||
| .trim() | ||
| .to_string() | ||
| } | ||
|
|
||
| /// Check if agents can spawn other agents (always false to prevent recursion) | ||
| #[allow(dead_code)] | ||
| pub fn can_spawn_agents(metadata: &HashMap<String, String>) -> bool { | ||
| !metadata.contains_key("is_agent") | ||
| } | ||
|
|
||
| /// Mark a context as being an agent context | ||
| #[allow(dead_code)] | ||
| pub fn mark_as_agent_context(metadata: &mut HashMap<String, String>) { | ||
| metadata.insert("is_agent".to_string(), "true".to_string()); | ||
| } | ||
| } | ||
|
|
||
| /// Execute an agent with a specific task | ||
| #[allow(dead_code)] | ||
| pub async fn execute_agent_task( | ||
| agent_name: &str, | ||
| task: String, | ||
| registry: &AgentRegistry, | ||
| ) -> Result<String> { | ||
| // Get the agent's system prompt | ||
| let system_prompt = registry.get_system_prompt(agent_name); | ||
|
|
||
| // Build the specialized prompt for this agent | ||
| let full_prompt = format!("{system_prompt}\n\nTask: {task}"); | ||
|
|
||
| // Note: The actual execution will be handled by the parent context | ||
| // using the existing conversation infrastructure | ||
| Ok(full_prompt) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
| use std::fs; | ||
| use tempfile::TempDir; | ||
|
|
||
| #[test] | ||
| fn test_default_agent_exists() { | ||
| let registry = AgentRegistry::new().unwrap(); | ||
| assert!(registry.get_agent("general").is_some()); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_agent_recursion_prevention() { | ||
| let mut metadata = HashMap::new(); | ||
| assert!(AgentRegistry::can_spawn_agents(&metadata)); | ||
|
|
||
| AgentRegistry::mark_as_agent_context(&mut metadata); | ||
| assert!(!AgentRegistry::can_spawn_agents(&metadata)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_path_traversal_prevention() { | ||
| // Create a temporary directory structure | ||
| let temp_dir = TempDir::new().unwrap(); | ||
| let base_dir = temp_dir.path(); | ||
|
|
||
| // Create a safe file | ||
| let safe_dir = base_dir.join("prompts"); | ||
| fs::create_dir(&safe_dir).unwrap(); | ||
| let safe_file = safe_dir.join("test.txt"); | ||
| fs::write(&safe_file, "safe content").unwrap(); | ||
|
|
||
| // Test that normal paths work | ||
| let result = AgentRegistry::validate_prompt_path(base_dir, "prompts/test.txt"); | ||
| assert!(result.is_ok()); | ||
|
|
||
| // Test that path traversal is blocked | ||
| let result = AgentRegistry::validate_prompt_path(base_dir, "../../../etc/passwd"); | ||
| assert!(result.is_err()); | ||
| assert!(result.unwrap_err().to_string().contains("Security error")); | ||
|
|
||
| // Test that absolute paths outside allowed dirs are blocked | ||
| let result = AgentRegistry::validate_prompt_path(base_dir, "/etc/passwd"); | ||
| assert!(result.is_err()); | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.