feat: add configurable agent support via config.toml

- Add AgentConfig struct to define agent settings (name, command, args, read-only, enabled, env)
- Update Config and Session to include agent configurations
- Modify agent_tool.rs to use configured agents with their settings
- Update slash commands to dynamically use enabled agents from config
- Create comprehensive config.toml.example with agent examples
- Support both configured and unconfigured agents for backward compatibility
- Allow per-agent read-only override and environment variables
- Pass agent configuration through Session for proper access control

This allows users to:
- Define which AI agents are available
- Set custom commands and arguments for each agent
- Control read-only/write permissions per agent
- Enable/disable specific agents
- Configure environment variables for agents

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
This commit is contained in:
James Peter
2025-08-11 08:00:09 +10:00
co-authored by Claude
parent cd94cebb0f
commit cab2392423
7 changed files with 372 additions and 38 deletions
+87 -7
View File
@@ -8,6 +8,7 @@ use tokio::task::JoinHandle;
use uuid::Uuid;
use chrono::{DateTime, Utc, Duration};
use crate::config_types::AgentConfig;
use crate::openai_tools::{JsonSchema, OpenAiTool, ResponsesApiTool};
// Agent status enum
@@ -41,6 +42,8 @@ pub struct Agent {
pub progress: Vec<String>,
pub worktree_path: Option<String>,
pub branch_name: Option<String>,
#[serde(skip)]
pub config: Option<AgentConfig>,
}
// Global agent manager
@@ -70,6 +73,52 @@ impl AgentManager {
files: Vec<String>,
read_only: bool,
batch_id: Option<String>,
) -> String {
self.create_agent_internal(
model,
prompt,
context,
output_goal,
files,
read_only,
batch_id,
None,
).await
}
pub async fn create_agent_with_config(
&mut self,
model: String,
prompt: String,
context: Option<String>,
output_goal: Option<String>,
files: Vec<String>,
read_only: bool,
batch_id: Option<String>,
config: AgentConfig,
) -> String {
self.create_agent_internal(
model,
prompt,
context,
output_goal,
files,
read_only,
batch_id,
Some(config),
).await
}
async fn create_agent_internal(
&mut self,
model: String,
prompt: String,
context: Option<String>,
output_goal: Option<String>,
files: Vec<String>,
read_only: bool,
batch_id: Option<String>,
config: Option<AgentConfig>,
) -> String {
let agent_id = Uuid::new_v4().to_string();
@@ -91,6 +140,7 @@ impl AgentManager {
progress: Vec::new(),
worktree_path: None,
branch_name: None,
config: config.clone(),
};
self.agents.insert(agent_id.clone(), agent.clone());
@@ -98,7 +148,7 @@ impl AgentManager {
// Spawn async agent
let agent_id_clone = agent_id.clone();
let handle = tokio::spawn(async move {
execute_agent(agent_id_clone).await;
execute_agent(agent_id_clone, config).await;
});
self.handles.insert(agent_id.clone(), handle);
@@ -290,7 +340,7 @@ async fn setup_worktree(git_root: &Path, branch_id: &str) -> Result<PathBuf, Str
Ok(worktree_path)
}
async fn execute_agent(agent_id: String) {
async fn execute_agent(agent_id: String, config: Option<AgentConfig>) {
let mut manager = AGENT_MANAGER.write().await;
// Get agent details
@@ -343,7 +393,7 @@ async fn execute_agent(agent_id: String) {
drop(manager);
// Execute with full permissions in the worktree
execute_model_with_permissions(&model, &full_prompt, false, Some(worktree_path)).await
execute_model_with_permissions(&model, &full_prompt, false, Some(worktree_path), config.clone()).await
}
Err(e) => Err(format!("Failed to setup worktree: {}", e))
}
@@ -353,7 +403,7 @@ async fn execute_agent(agent_id: String) {
} else {
// Execute in read-only mode
full_prompt = format!("{}\n\n[Running in read-only mode - no modifications allowed]", full_prompt);
execute_model_with_permissions(&model, &full_prompt, true, None).await
execute_model_with_permissions(&model, &full_prompt, true, None, config).await
};
// Update result
@@ -365,17 +415,47 @@ async fn execute_model_with_permissions(
model: &str,
prompt: &str,
read_only: bool,
working_dir: Option<PathBuf>
working_dir: Option<PathBuf>,
config: Option<AgentConfig>
) -> Result<String, String> {
let mut cmd = Command::new(model.to_lowercase());
// Use config command if provided, otherwise use model name
let command = if let Some(ref cfg) = config {
cfg.command.clone()
} else {
model.to_lowercase()
};
let mut cmd = Command::new(command.clone());
// Set working directory if provided
if let Some(dir) = working_dir {
cmd.current_dir(dir);
}
// Add environment variables from config if provided
if let Some(ref cfg) = config {
if let Some(ref env) = cfg.env {
for (key, value) in env {
cmd.env(key, value);
}
}
// Add any configured args first
for arg in &cfg.args {
cmd.arg(arg);
}
}
// Build command based on model and permissions
match model.to_lowercase().as_str() {
// Use command instead of model for matching if config provided
let model_lower = model.to_lowercase();
let model_name = if config.is_some() {
command.as_str()
} else {
model_lower.as_str()
};
match model_name {
"claude" => {
if read_only {
cmd.args(&[
+42 -10
View File
@@ -230,6 +230,9 @@ pub(crate) struct Session {
/// Manager for external MCP servers/tools.
mcp_connection_manager: McpConnectionManager,
/// Configuration for available agent models
agents: Vec<crate::config_types::AgentConfig>,
/// External notifier command (will be passed as args to exec()). When
/// `None` this feature is disabled.
notify: Option<Vec<String>>,
@@ -887,6 +890,7 @@ async fn submission_loop(
cwd,
writable_roots,
mcp_connection_manager,
agents: config.agents.clone(),
notify,
state: Mutex::new(state),
rollout: Mutex::new(rollout_recorder),
@@ -1834,16 +1838,44 @@ async fn handle_run_agent(
let mut agent_ids = Vec::new();
for model in models {
let agent_id = manager.create_agent(
model,
params.agent.clone(),
params.context.clone(),
params.output.clone(),
params.files.clone().unwrap_or_default(),
params.read_only.unwrap_or(false),
batch_id.clone(),
).await;
agent_ids.push(agent_id);
// Check if this model is configured and enabled
let agent_config = sess.agents.iter().find(|a|
a.name.to_lowercase() == model.to_lowercase() ||
a.command.to_lowercase() == model.to_lowercase()
);
if let Some(config) = agent_config {
if !config.enabled {
continue; // Skip disabled agents
}
// Override read_only if agent is configured as read-only
let read_only = config.read_only || params.read_only.unwrap_or(false);
let agent_id = manager.create_agent_with_config(
model,
params.agent.clone(),
params.context.clone(),
params.output.clone(),
params.files.clone().unwrap_or_default(),
read_only,
batch_id.clone(),
config.clone(),
).await;
agent_ids.push(agent_id);
} else {
// Use default configuration for unknown agents
let agent_id = manager.create_agent(
model,
params.agent.clone(),
params.context.clone(),
params.output.clone(),
params.files.clone().unwrap_or_default(),
params.read_only.unwrap_or(false),
batch_id.clone(),
).await;
agent_ids.push(agent_id);
}
}
// Send status update showing agents are running
+9
View File
@@ -1,4 +1,5 @@
use crate::config_profile::ConfigProfile;
use crate::config_types::AgentConfig;
use crate::config_types::History;
use crate::config_types::McpServerConfig;
use crate::config_types::ReasoningEffort;
@@ -111,6 +112,9 @@ pub struct Config {
/// Definition for MCP servers that Codex can reach out to for tool calls.
pub mcp_servers: HashMap<String, McpServerConfig>,
/// Configuration for available agent models
pub agents: Vec<AgentConfig>,
/// Combined provider map (defaults merged with user-defined overrides).
pub model_providers: HashMap<String, ModelProviderInfo>,
@@ -352,6 +356,10 @@ pub struct ConfigToml {
#[serde(default)]
pub mcp_servers: HashMap<String, McpServerConfig>,
/// Configuration for available agent models
#[serde(default)]
pub agents: Vec<AgentConfig>,
/// User-defined provider entries that extend/override the built-in list.
#[serde(default)]
pub model_providers: HashMap<String, ModelProviderInfo>,
@@ -630,6 +638,7 @@ impl Config {
user_instructions,
base_instructions,
mcp_servers: cfg.mcp_servers,
agents: cfg.agents,
model_providers,
project_doc_max_bytes: cfg.project_doc_max_bytes.unwrap_or(PROJECT_DOC_MAX_BYTES),
codex_home,
+35
View File
@@ -11,6 +11,41 @@ use wildmatch::WildMatchPattern;
use serde::Deserialize;
use serde::Serialize;
/// Configuration for external agent models
#[derive(Deserialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub struct AgentConfig {
/// Name of the agent (e.g., "claude", "gemini", "gpt-4")
pub name: String,
/// Command to execute the agent (e.g., "claude", "gemini")
pub command: String,
/// Optional arguments to pass to the agent command
#[serde(default)]
pub args: Vec<String>,
/// Whether this agent can only run in read-only mode
#[serde(default)]
pub read_only: bool,
/// Whether this agent is enabled
#[serde(default = "default_true")]
pub enabled: bool,
/// Optional description of the agent
#[serde(default)]
pub description: Option<String>,
/// Optional environment variables for the agent
#[serde(default)]
pub env: Option<HashMap<String, String>>,
}
fn default_true() -> bool {
true
}
#[derive(Deserialize, Debug, Clone, PartialEq)]
pub struct McpServerConfig {
pub command: String,
+96 -18
View File
@@ -1,9 +1,33 @@
// Default models to use for slash commands
const DEFAULT_MODELS: &[&str] = &["claude", "gemini", "codex"];
use crate::config_types::AgentConfig;
/// Get the list of enabled agent names from the configuration
pub fn get_enabled_agents(agents: &[AgentConfig]) -> Vec<String> {
agents
.iter()
.filter(|agent| agent.enabled)
.map(|agent| agent.name.clone())
.collect()
}
/// Get default models if no agents are configured
fn get_default_models() -> Vec<String> {
vec!["claude".to_string(), "gemini".to_string(), "codex".to_string()]
}
/// Format the /plan command into a prompt for the LLM
pub fn format_plan_command(task: &str, models: Option<Vec<String>>) -> String {
let models_list = models.unwrap_or_else(|| DEFAULT_MODELS.iter().map(|s| s.to_string()).collect());
pub fn format_plan_command(task: &str, models: Option<Vec<String>>, agents: Option<&[AgentConfig]>) -> String {
let models_list = models.unwrap_or_else(|| {
if let Some(agents) = agents {
let enabled = get_enabled_agents(agents);
if !enabled.is_empty() {
enabled
} else {
get_default_models()
}
} else {
get_default_models()
}
});
let models_str = models_list.iter().map(|m| format!("\"{}\"", m)).collect::<Vec<_>>().join(", ");
format!(
@@ -30,8 +54,19 @@ Task to plan:
}
/// Format the /solve command into a prompt for the LLM
pub fn format_solve_command(task: &str, models: Option<Vec<String>>) -> String {
let models_list = models.unwrap_or_else(|| DEFAULT_MODELS.iter().map(|s| s.to_string()).collect());
pub fn format_solve_command(task: &str, models: Option<Vec<String>>, agents: Option<&[AgentConfig]>) -> String {
let models_list = models.unwrap_or_else(|| {
if let Some(agents) = agents {
let enabled = get_enabled_agents(agents);
if !enabled.is_empty() {
enabled
} else {
get_default_models()
}
} else {
get_default_models()
}
});
let models_str = models_list.iter().map(|m| format!("\"{}\"", m)).collect::<Vec<_>>().join(", ");
format!(
@@ -77,8 +112,19 @@ Remember: DO NOT cancel any running agents until you have 100% confirmed the pro
}
/// Format the /code command into a prompt for the LLM
pub fn format_code_command(task: &str, models: Option<Vec<String>>) -> String {
let models_list = models.unwrap_or_else(|| DEFAULT_MODELS.iter().map(|s| s.to_string()).collect());
pub fn format_code_command(task: &str, models: Option<Vec<String>>, agents: Option<&[AgentConfig]>) -> String {
let models_list = models.unwrap_or_else(|| {
if let Some(agents) = agents {
let enabled = get_enabled_agents(agents);
if !enabled.is_empty() {
enabled
} else {
get_default_models()
}
} else {
get_default_models()
}
});
let models_str = models_list.iter().map(|m| format!("\"{}\"", m)).collect::<Vec<_>>().join(", ");
format!(
@@ -115,7 +161,7 @@ Coding task to perform:
}
/// Parse a slash command and return the formatted prompt
pub fn handle_slash_command(input: &str) -> Option<String> {
pub fn handle_slash_command(input: &str, agents: Option<&[AgentConfig]>) -> Option<String> {
let input = input.trim();
// Check if it starts with a slash
@@ -133,21 +179,21 @@ pub fn handle_slash_command(input: &str) -> Option<String> {
if args.is_empty() {
Some("Error: /plan requires a task description. Usage: /plan <task>".to_string())
} else {
Some(format_plan_command(&args, None))
Some(format_plan_command(&args, None, agents))
}
}
"/solve" => {
if args.is_empty() {
Some("Error: /solve requires a problem description. Usage: /solve <problem>".to_string())
} else {
Some(format_solve_command(&args, None))
Some(format_solve_command(&args, None, agents))
}
}
"/code" => {
if args.is_empty() {
Some("Error: /code requires a task description. Usage: /code <task>".to_string())
} else {
Some(format_code_command(&args, None))
Some(format_code_command(&args, None, agents))
}
}
_ => None,
@@ -161,31 +207,63 @@ mod tests {
#[test]
fn test_slash_command_parsing() {
// Test /plan command
let result = handle_slash_command("/plan implement a new feature");
let result = handle_slash_command("/plan implement a new feature", None);
assert!(result.is_some());
assert!(result.unwrap().contains("Create a comprehensive plan"));
// Test /solve command
let result = handle_slash_command("/solve fix the bug in authentication");
let result = handle_slash_command("/solve fix the bug in authentication", None);
assert!(result.is_some());
assert!(result.unwrap().contains("Solve a complicated problem"));
// Test /code command
let result = handle_slash_command("/code refactor the database module");
let result = handle_slash_command("/code refactor the database module", None);
assert!(result.is_some());
assert!(result.unwrap().contains("Perform a coding task"));
// Test invalid command
let result = handle_slash_command("/invalid test");
let result = handle_slash_command("/invalid test", None);
assert!(result.is_none());
// Test non-slash command
let result = handle_slash_command("regular message");
let result = handle_slash_command("regular message", None);
assert!(result.is_none());
// Test empty arguments
let result = handle_slash_command("/plan");
let result = handle_slash_command("/plan", None);
assert!(result.is_some());
assert!(result.unwrap().contains("Error"));
}
#[test]
fn test_slash_commands_with_agents() {
// Create test agent configurations
let agents = vec![
AgentConfig {
name: "test-claude".to_string(),
command: "claude".to_string(),
args: vec![],
read_only: false,
enabled: true,
description: None,
env: None,
},
AgentConfig {
name: "test-gemini".to_string(),
command: "gemini".to_string(),
args: vec![],
read_only: false,
enabled: false, // disabled
description: None,
env: None,
},
];
// Test that only enabled agents are included
let result = handle_slash_command("/plan test task", Some(&agents));
assert!(result.is_some());
let prompt = result.unwrap();
assert!(prompt.contains("test-claude"));
assert!(!prompt.contains("test-gemini")); // Should not include disabled agent
}
}
+5 -3
View File
@@ -74,15 +74,17 @@ impl SlashCommand {
}
// Use the slash_commands module from core to generate the prompts
// Note: We pass None for agents here as the TUI doesn't have access to the session config
// The actual agents will be determined when the agent tool is invoked
match self {
SlashCommand::Plan => {
Some(codex_core::slash_commands::format_plan_command(args, None))
Some(codex_core::slash_commands::format_plan_command(args, None, None))
}
SlashCommand::Solve => {
Some(codex_core::slash_commands::format_solve_command(args, None))
Some(codex_core::slash_commands::format_solve_command(args, None, None))
}
SlashCommand::Code => {
Some(codex_core::slash_commands::format_code_command(args, None))
Some(codex_core::slash_commands::format_code_command(args, None, None))
}
_ => None,
}
+98
View File
@@ -0,0 +1,98 @@
# Codex Configuration Example
# Copy this file to config.toml and customize as needed
# Agent Configuration
# Configure which external AI models are available and their settings
[[agents]]
name = "claude"
command = "claude"
enabled = true
read-only = false
description = "Claude AI assistant with full capabilities"
args = ["--dangerously-skip-permissions"]
[[agents]]
name = "claude-safe"
command = "claude"
enabled = true
read-only = true
description = "Claude AI assistant in read-only mode"
args = []
[[agents]]
name = "gemini"
command = "gemini"
enabled = true
read-only = false
description = "Google Gemini AI assistant"
args = ["-y"]
[[agents]]
name = "gemini-safe"
command = "gemini"
enabled = true
read-only = true
description = "Google Gemini AI assistant in read-only mode"
args = []
[[agents]]
name = "codex"
command = "codex"
enabled = true
read-only = false
description = "Codex AI assistant (self-referential)"
args = ["-s", "workspace-write", "-a", "never"]
[[agents]]
name = "gpt-4"
command = "gpt"
enabled = false
read-only = false
description = "OpenAI GPT-4 assistant"
args = ["--model", "gpt-4"]
# Optional environment variables for the agent
# env = { OPENAI_API_KEY = "your-key-here" }
# Custom agent example
[[agents]]
name = "custom-llm"
command = "/usr/local/bin/custom-llm"
enabled = false
read-only = false
description = "Custom LLM implementation"
args = ["--config", "/path/to/config.json"]
env = { MODEL_PATH = "/models/custom.bin", TEMP = "0.7" }
# Sandbox Configuration
# Controls file system access and permissions
sandbox-mode = "workspace-write"
[sandbox-workspace-write]
writable-roots = ["."]
network-access = true
exclude-tmpdir-env-var = false
exclude-slash-tmp = false
# Shell Environment Configuration
[shell-environment-policy]
inherit = "all"
ignore-default-excludes = false
# Exclude sensitive environment variables
exclude = ["*SECRET*", "*PASSWORD*", "*CREDENTIAL*"]
# Set custom environment variables
set = { EDITOR = "vim", PAGER = "less" }
# History Configuration
[history]
persistence = "save-all"
max-bytes = 10485760 # 10MB
# MCP Server Configuration
# Example MCP server configurations
[mcp-servers.example-server]
command = "npx"
args = ["-y", "@example/mcp-server"]
env = { API_KEY = "your-api-key" }
# File Opener Configuration
file-opener = "vscode" # Options: vscode, vscode-insiders, windsurf, cursor, none