refactor: rename task terminology to agent throughout codebase

- Renamed task_tool.rs to agent_tool.rs
- Updated all structs: Task → Agent, TaskManager → AgentManager
- Updated all types: TaskStatus → AgentStatus
- Renamed all functions: run_task → run_agent, check_task_status → check_agent_status, etc.
- Updated parameter structs: RunTaskParams → RunAgentParams, etc.
- Updated slash command prompts (/plan, /solve, /code) to use agent terminology
- Added UI status indicators showing running agents count
- Enhanced list_agents with status count summary
- Fixed tokio imports (kept as task::, not agent::)
- Maintained backward compatibility with event system

This provides clearer semantics when working with multiple external LLMs in parallel.

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

Co-Authored-By: Claude <[email protected]>
This commit is contained in:
James Peter
2025-08-11 07:36:19 +10:00
co-authored by Claude
parent cf42457553
commit cd94cebb0f
10 changed files with 1450 additions and 136 deletions
+1
View File
@@ -684,6 +684,7 @@ dependencies = [
"fs2",
"futures",
"landlock",
"lazy_static",
"libc",
"maplit",
"mcp-types",
+1
View File
@@ -24,6 +24,7 @@ env-flags = "0.1.1"
eventsource-stream = "0.2.3"
fs2 = "0.4.3"
futures = "0.3"
lazy_static = "1.5"
libc = "0.2.174"
mcp-types = { path = "../mcp-types" }
mime_guess = "2.0"
+645 -61
View File
@@ -1,85 +1,416 @@
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use std::path::{Path, PathBuf};
use tokio::process::Command;
use tokio::sync::RwLock;
use tokio::task::JoinHandle;
use uuid::Uuid;
use chrono::{DateTime, Utc, Duration};
use crate::openai_tools::{JsonSchema, OpenAiTool, ResponsesApiTool};
// Agent status enum
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum AgentStatus {
Pending,
Running,
Completed,
Failed,
Cancelled,
}
// Agent information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentCallParams {
pub struct Agent {
pub id: String,
pub batch_id: Option<String>,
pub model: String,
pub prompt: String,
pub agents: Vec<String>,
pub context: Option<String>,
pub output_goal: Option<String>,
pub files: Vec<String>,
pub read_only: bool,
pub status: AgentStatus,
pub result: Option<String>,
pub error: Option<String>,
pub created_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
pub progress: Vec<String>,
pub worktree_path: Option<String>,
pub branch_name: Option<String>,
}
pub fn create_agent_tool() -> OpenAiTool {
let mut properties = BTreeMap::new();
properties.insert(
"prompt".to_string(),
JsonSchema::String {
description: Some("The prompt to send to the external LLM agents".to_string()),
},
);
properties.insert(
"agents".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String {
description: Some("Agent name: 'claude', 'gemini', or 'codex'".to_string()),
}),
description: Some("List of agents to call (claude, gemini, codex)".to_string()),
},
);
OpenAiTool::Function(ResponsesApiTool {
name: "agent".to_string(),
description: "Call external LLM agents (claude, gemini, codex) via command line to get their responses for a given prompt".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["prompt".to_string(), "agents".to_string()]),
additional_properties: Some(false),
},
})
// Global agent manager
lazy_static::lazy_static! {
pub static ref AGENT_MANAGER: Arc<RwLock<AgentManager>> = Arc::new(RwLock::new(AgentManager::new()));
}
pub async fn execute_agent_call(params: AgentCallParams) -> Result<String, String> {
let mut results = Vec::new();
for agent in params.agents {
let result = match agent.to_lowercase().as_str() {
"claude" => {
execute_command("claude", vec!["-p", &params.prompt]).await
}
"gemini" => {
execute_command("gemini", vec!["-p", &params.prompt]).await
}
"codex" => {
execute_command("codex", vec!["exec", &params.prompt]).await
}
_ => {
Err(format!("Unknown agent: {}", agent))
}
pub struct AgentManager {
agents: HashMap<String, Agent>,
handles: HashMap<String, JoinHandle<()>>,
}
impl AgentManager {
pub fn new() -> Self {
Self {
agents: HashMap::new(),
handles: HashMap::new(),
}
}
pub async fn create_agent(
&mut self,
model: String,
prompt: String,
context: Option<String>,
output_goal: Option<String>,
files: Vec<String>,
read_only: bool,
batch_id: Option<String>,
) -> String {
let agent_id = Uuid::new_v4().to_string();
let agent = Agent {
id: agent_id.clone(),
batch_id,
model,
prompt,
context,
output_goal,
files,
read_only,
status: AgentStatus::Pending,
result: None,
error: None,
created_at: Utc::now(),
started_at: None,
completed_at: None,
progress: Vec::new(),
worktree_path: None,
branch_name: None,
};
match result {
Ok(output) => {
results.push(format!("=== {} response ===\n{}", agent, output));
self.agents.insert(agent_id.clone(), agent.clone());
// Spawn async agent
let agent_id_clone = agent_id.clone();
let handle = tokio::spawn(async move {
execute_agent(agent_id_clone).await;
});
self.handles.insert(agent_id.clone(), handle);
agent_id
}
pub fn get_agent(&self, agent_id: &str) -> Option<Agent> {
self.agents.get(agent_id).cloned()
}
pub fn list_agents(
&self,
status_filter: Option<AgentStatus>,
batch_id: Option<String>,
recent_only: bool,
) -> Vec<Agent> {
let cutoff = if recent_only {
Some(Utc::now() - Duration::hours(2))
} else {
None
};
self.agents
.values()
.filter(|agent| {
if let Some(ref filter) = status_filter {
if agent.status != *filter {
return false;
}
}
if let Some(ref batch) = batch_id {
if agent.batch_id.as_ref() != Some(batch) {
return false;
}
}
if let Some(cutoff) = cutoff {
if agent.created_at < cutoff {
return false;
}
}
true
})
.cloned()
.collect()
}
pub async fn cancel_agent(&mut self, agent_id: &str) -> bool {
if let Some(handle) = self.handles.remove(agent_id) {
handle.abort();
if let Some(agent) = self.agents.get_mut(agent_id) {
agent.status = AgentStatus::Cancelled;
agent.completed_at = Some(Utc::now());
}
Err(e) => {
results.push(format!("=== {} error ===\n{}", agent, e));
true
} else {
false
}
}
pub async fn cancel_batch(&mut self, batch_id: &str) -> usize {
let agent_ids: Vec<String> = self.agents
.values()
.filter(|agent| agent.batch_id.as_ref() == Some(&batch_id.to_string()))
.map(|agent| agent.id.clone())
.collect();
let mut count = 0;
for agent_id in agent_ids {
if self.cancel_agent(&agent_id).await {
count += 1;
}
}
count
}
pub async fn update_agent_status(&mut self, agent_id: &str, status: AgentStatus) {
if let Some(agent) = self.agents.get_mut(agent_id) {
agent.status = status;
if agent.status == AgentStatus::Running && agent.started_at.is_none() {
agent.started_at = Some(Utc::now());
}
if matches!(agent.status, AgentStatus::Completed | AgentStatus::Failed | AgentStatus::Cancelled) {
agent.completed_at = Some(Utc::now());
}
}
}
Ok(results.join("\n\n"))
pub async fn update_agent_result(&mut self, agent_id: &str, result: Result<String, String>) {
if let Some(agent) = self.agents.get_mut(agent_id) {
match result {
Ok(output) => {
agent.result = Some(output);
agent.status = AgentStatus::Completed;
}
Err(error) => {
agent.error = Some(error);
agent.status = AgentStatus::Failed;
}
}
agent.completed_at = Some(Utc::now());
}
}
pub async fn add_progress(&mut self, agent_id: &str, message: String) {
if let Some(agent) = self.agents.get_mut(agent_id) {
agent.progress.push(format!("{}: {}", Utc::now().format("%H:%M:%S"), message));
}
}
pub async fn update_worktree_info(&mut self, agent_id: &str, worktree_path: String, branch_name: String) {
if let Some(agent) = self.agents.get_mut(agent_id) {
agent.worktree_path = Some(worktree_path);
agent.branch_name = Some(branch_name);
}
}
}
async fn execute_command(command: &str, args: Vec<&str>) -> Result<String, String> {
let output = Command::new(command)
.args(&args)
async fn get_git_root() -> Result<PathBuf, String> {
let output = Command::new("git")
.args(&["rev-parse", "--show-toplevel"])
.output()
.await
.map_err(|e| format!("Failed to execute {}: {}", command, e))?;
.map_err(|e| format!("Git not installed or not in a git repository: {}", e))?;
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(PathBuf::from(path))
} else {
Err("Not in a git repository".to_string())
}
}
fn generate_branch_id(model: &str, agent: &str) -> String {
// Extract first few meaningful words from agent for the branch name
let words: Vec<&str> = agent
.split_whitespace()
.filter(|w| w.len() > 2 && !["the", "and", "for", "with", "from", "into"].contains(w))
.take(3)
.collect();
let agent_suffix = if words.is_empty() {
Uuid::new_v4().to_string().split('-').next().unwrap_or("agent").to_string()
} else {
words.join("-").to_lowercase()
};
format!("coder-{}-{}", model, agent_suffix)
}
async fn setup_worktree(git_root: &Path, branch_id: &str) -> Result<PathBuf, String> {
// Create .coder/branches directory if it doesn't exist
let coder_dir = git_root.join(".coder").join("branches");
tokio::fs::create_dir_all(&coder_dir)
.await
.map_err(|e| format!("Failed to create .coder/branches directory: {}", e))?;
// Path for this model's worktree
let worktree_path = coder_dir.join(branch_id);
// Remove existing worktree if it exists (cleanup from previous runs)
if worktree_path.exists() {
Command::new("git")
.args(&["worktree", "remove", worktree_path.to_str().unwrap(), "--force"])
.output()
.await
.ok(); // Ignore errors, it might not be a worktree
}
// Create new worktree
let output = Command::new("git")
.current_dir(git_root)
.args(&[
"worktree",
"add",
"-b",
branch_id,
worktree_path.to_str().unwrap()
])
.output()
.await
.map_err(|e| format!("Failed to create git worktree: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to create worktree: {}", stderr));
}
Ok(worktree_path)
}
async fn execute_agent(agent_id: String) {
let mut manager = AGENT_MANAGER.write().await;
// Get agent details
let agent = match manager.get_agent(&agent_id) {
Some(t) => t,
None => return,
};
// Update status to running
manager.update_agent_status(&agent_id, AgentStatus::Running).await;
manager.add_progress(&agent_id, format!("Starting agent with model: {}", agent.model)).await;
let model = agent.model.clone();
let prompt = agent.prompt.clone();
let read_only = agent.read_only;
let context = agent.context.clone();
let output_goal = agent.output_goal.clone();
let files = agent.files.clone();
drop(manager); // Release the lock before executing
// Build the full prompt with context
let mut full_prompt = prompt.clone();
if let Some(context) = &context {
full_prompt = format!("Context: {}\n\nAgent: {}", context, full_prompt);
}
if let Some(output_goal) = &output_goal {
full_prompt = format!("{}\n\nDesired output: {}", full_prompt, output_goal);
}
if !files.is_empty() {
full_prompt = format!("{}\n\nFiles to consider: {}", full_prompt, files.join(", "));
}
// Setup working directory and execute
let result = if !read_only {
// Check git and setup worktree for non-read-only mode
match get_git_root().await {
Ok(git_root) => {
let branch_id = generate_branch_id(&model, &prompt);
let mut manager = AGENT_MANAGER.write().await;
manager.add_progress(&agent_id, format!("Creating git worktree: {}", branch_id)).await;
drop(manager);
match setup_worktree(&git_root, &branch_id).await {
Ok(worktree_path) => {
let mut manager = AGENT_MANAGER.write().await;
manager.add_progress(&agent_id, format!("Executing in worktree: {}", worktree_path.display())).await;
manager.update_worktree_info(&agent_id, worktree_path.display().to_string(), branch_id.clone()).await;
drop(manager);
// Execute with full permissions in the worktree
execute_model_with_permissions(&model, &full_prompt, false, Some(worktree_path)).await
}
Err(e) => Err(format!("Failed to setup worktree: {}", e))
}
}
Err(e) => Err(format!("Git is required for non-read-only agents: {}", e))
}
} 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
};
// Update result
let mut manager = AGENT_MANAGER.write().await;
manager.update_agent_result(&agent_id, result).await;
}
async fn execute_model_with_permissions(
model: &str,
prompt: &str,
read_only: bool,
working_dir: Option<PathBuf>
) -> Result<String, String> {
let mut cmd = Command::new(model.to_lowercase());
// Set working directory if provided
if let Some(dir) = working_dir {
cmd.current_dir(dir);
}
// Build command based on model and permissions
match model.to_lowercase().as_str() {
"claude" => {
if read_only {
cmd.args(&[
"--allowedTools",
"Bash(ls:*), Bash(cat:*), Bash(grep:*), Bash(git status:*), Bash(git log:*), Bash(find:*), Read, Grep, Glob, LS, WebFetch, TodoRead, TodoWrite, WebSearch",
"-p",
prompt
]);
} else {
cmd.args(&["--dangerously-skip-permissions", "-p", prompt]);
}
}
"gemini" => {
if read_only {
cmd.args(&["-p", prompt]);
} else {
cmd.args(&["-y", "-p", prompt]);
}
}
"codex" => {
if read_only {
cmd.args(&["-s", "read-only", "-a", "never", "exec", prompt]);
} else {
cmd.args(&["-s", "workspace-write", "-a", "never", "exec", prompt]);
}
}
_ => {
return Err(format!("Unknown model: {}", model));
}
}
let output = cmd
.output()
.await
.map_err(|e| format!("Failed to execute {}: {}", model, e))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
@@ -87,4 +418,257 @@ async fn execute_command(command: &str, args: Vec<&str>) -> Result<String, Strin
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!("Command failed: {}", stderr))
}
}
// Tool creation functions
pub fn create_run_agent_tool() -> OpenAiTool {
let mut properties = BTreeMap::new();
properties.insert(
"agent".to_string(),
JsonSchema::String {
description: Some("The agent prompt - what to perform (required)".to_string()),
},
);
properties.insert(
"model".to_string(),
JsonSchema::String {
description: Some("Model: 'claude', 'gemini', or 'codex' (or array of models for batch execution)".to_string()),
},
);
properties.insert(
"context".to_string(),
JsonSchema::String {
description: Some("Optional: Background context for the agent".to_string()),
},
);
properties.insert(
"output".to_string(),
JsonSchema::String {
description: Some("Optional: The desired output/success state".to_string()),
},
);
properties.insert(
"files".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: Some("Optional: Array of file paths to include in the agent context".to_string()),
},
);
properties.insert(
"read_only".to_string(),
JsonSchema::Boolean {
description: Some("Optional: When true, agent runs in read-only mode (default: false)".to_string()),
},
);
OpenAiTool::Function(ResponsesApiTool {
name: "run_agent".to_string(),
description: "Start a complex AI agent asynchronously. Returns a agent ID immediately to check status and retrieve results.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["agent".to_string()]),
additional_properties: Some(false),
},
})
}
pub fn create_check_agent_status_tool() -> OpenAiTool {
let mut properties = BTreeMap::new();
properties.insert(
"agent_id".to_string(),
JsonSchema::String {
description: Some("The agent ID returned from run_agent".to_string()),
},
);
OpenAiTool::Function(ResponsesApiTool {
name: "check_agent_status".to_string(),
description: "Check the status of a running agent. Returns current status, progress, and partial results if available.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["agent_id".to_string()]),
additional_properties: Some(false),
},
})
}
pub fn create_get_agent_result_tool() -> OpenAiTool {
let mut properties = BTreeMap::new();
properties.insert(
"agent_id".to_string(),
JsonSchema::String {
description: Some("The agent ID returned from run_agent".to_string()),
},
);
OpenAiTool::Function(ResponsesApiTool {
name: "get_agent_result".to_string(),
description: "Get the final result of a completed agent.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["agent_id".to_string()]),
additional_properties: Some(false),
},
})
}
pub fn create_cancel_agent_tool() -> OpenAiTool {
let mut properties = BTreeMap::new();
properties.insert(
"agent_id".to_string(),
JsonSchema::String {
description: Some("The agent ID to cancel (required if batch_id not provided)".to_string()),
},
);
properties.insert(
"batch_id".to_string(),
JsonSchema::String {
description: Some("Cancel all agents with this batch ID (required if agent_id not provided)".to_string()),
},
);
OpenAiTool::Function(ResponsesApiTool {
name: "cancel_agent".to_string(),
description: "Cancel a pending or running agent, or all agents in a batch.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec![]),
additional_properties: Some(false),
},
})
}
pub fn create_wait_for_agent_tool() -> OpenAiTool {
let mut properties = BTreeMap::new();
properties.insert(
"agent_id".to_string(),
JsonSchema::String {
description: Some("Wait for this specific agent to complete (required if batch_id not provided)".to_string()),
},
);
properties.insert(
"batch_id".to_string(),
JsonSchema::String {
description: Some("Wait for any agent in this batch to complete (required if agent_id not provided)".to_string()),
},
);
properties.insert(
"timeout_seconds".to_string(),
JsonSchema::Number {
description: Some("Maximum seconds to wait before timing out (default: 300, max: 600)".to_string()),
},
);
properties.insert(
"return_all".to_string(),
JsonSchema::Boolean {
description: Some("For batch_id: return all completed agents instead of just the first one (default: false)".to_string()),
},
);
OpenAiTool::Function(ResponsesApiTool {
name: "wait_for_agent".to_string(),
description: "Wait for a agent or any agent in a batch to complete, fail, or be cancelled.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec![]),
additional_properties: Some(false),
},
})
}
pub fn create_list_agents_tool() -> OpenAiTool {
let mut properties = BTreeMap::new();
properties.insert(
"status_filter".to_string(),
JsonSchema::String {
description: Some("Optional: Filter agents by status (pending, running, completed, failed, cancelled)".to_string()),
},
);
properties.insert(
"batch_id".to_string(),
JsonSchema::String {
description: Some("Optional: Filter agents by batch ID".to_string()),
},
);
properties.insert(
"recent_only".to_string(),
JsonSchema::Boolean {
description: Some("Optional: Only show agents from the last 2 hours (default: false)".to_string()),
},
);
OpenAiTool::Function(ResponsesApiTool {
name: "list_agents".to_string(),
description: "List all agents with their current status.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec![]),
additional_properties: Some(false),
},
})
}
// Parameter structs for handlers
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunAgentParams {
pub agent: String,
pub model: Option<serde_json::Value>, // Can be string or array
pub context: Option<String>,
pub output: Option<String>,
pub files: Option<Vec<String>>,
pub read_only: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckAgentStatusParams {
pub agent_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetAgentResultParams {
pub agent_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CancelAgentParams {
pub agent_id: Option<String>,
pub batch_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WaitForAgentParams {
pub agent_id: Option<String>,
pub batch_id: Option<String>,
pub timeout_seconds: Option<u64>,
pub return_all: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListAgentsParams {
pub status_filter: Option<String>,
pub batch_id: Option<String>,
pub recent_only: Option<bool>,
}
+461 -62
View File
@@ -36,7 +36,11 @@ use crate::apply_patch::InternalApplyPatchInvocation;
use crate::apply_patch::convert_apply_patch_to_protocol;
use crate::apply_patch::get_writable_roots;
use crate::apply_patch::{self};
use crate::agent_tool::{AgentCallParams, execute_agent_call};
use crate::agent_tool::{
RunAgentParams, CheckAgentStatusParams, GetAgentResultParams,
CancelAgentParams, WaitForAgentParams, ListAgentsParams,
AgentStatus, AGENT_MANAGER,
};
use crate::client::ModelClient;
use crate::client_common::EnvironmentContext;
use crate::client_common::Prompt;
@@ -204,7 +208,7 @@ impl Codex {
/// Context for an initialized model agent
///
/// A session has at most 1 running task at a time, and can be interrupted by user input.
/// A session has at most 1 running agent at a time, and can be interrupted by user input.
pub(crate) struct Session {
client: ModelClient,
pub(crate) tx_event: Sender<Event>,
@@ -251,26 +255,26 @@ impl Session {
#[derive(Default)]
struct State {
approved_commands: HashSet<Vec<String>>,
current_task: Option<AgentTask>,
current_agent: Option<AgentAgent>,
pending_approvals: HashMap<String, oneshot::Sender<ReviewDecision>>,
pending_input: Vec<ResponseInputItem>,
history: ConversationHistory,
}
impl Session {
pub fn set_task(&self, task: AgentTask) {
pub fn set_agent(&self, agent: AgentAgent) {
let mut state = self.state.lock().unwrap();
if let Some(current_task) = state.current_task.take() {
current_task.abort();
if let Some(current_agent) = state.current_agent.take() {
current_agent.abort();
}
state.current_task = Some(task);
state.current_agent = Some(agent);
}
pub fn remove_task(&self, sub_id: &str) {
pub fn remove_agent(&self, sub_id: &str) {
let mut state = self.state.lock().unwrap();
if let Some(task) = &state.current_task {
if task.sub_id == sub_id {
state.current_task.take();
if let Some(agent) = &state.current_agent {
if agent.sub_id == sub_id {
state.current_agent.take();
}
}
}
@@ -541,10 +545,10 @@ impl Session {
[self.state.lock().unwrap().history.contents(), extra].concat()
}
/// Returns the input if there was no task running to inject into
/// Returns the input if there was no agent running to inject into
pub fn inject_input(&self, input: Vec<InputItem>) -> Result<(), Vec<InputItem>> {
let mut state = self.state.lock().unwrap();
if state.current_task.is_some() {
if state.current_agent.is_some() {
state.pending_input.push(input.into());
Ok(())
} else {
@@ -580,8 +584,8 @@ impl Session {
let mut state = self.state.lock().unwrap();
state.pending_approvals.clear();
state.pending_input.clear();
if let Some(task) = state.current_task.take() {
task.abort();
if let Some(agent) = state.current_agent.take() {
agent.abort();
}
}
@@ -647,16 +651,16 @@ pub(crate) struct ApplyPatchCommandContext {
}
/// A series of Turns in response to user input.
pub(crate) struct AgentTask {
pub(crate) struct AgentAgent {
sess: Arc<Session>,
sub_id: String,
handle: AbortHandle,
}
impl AgentTask {
impl AgentAgent {
fn spawn(sess: Arc<Session>, sub_id: String, input: Vec<InputItem>) -> Self {
let handle =
tokio::spawn(run_task(Arc::clone(&sess), sub_id.clone(), input)).abort_handle();
tokio::spawn(run_agent(Arc::clone(&sess), sub_id.clone(), input)).abort_handle();
Self {
sess,
sub_id,
@@ -669,7 +673,7 @@ impl AgentTask {
input: Vec<InputItem>,
compact_instructions: String,
) -> Self {
let handle = tokio::spawn(run_compact_task(
let handle = tokio::spawn(run_compact_agent(
Arc::clone(&sess),
sub_id.clone(),
input,
@@ -930,11 +934,11 @@ async fn submission_loop(
}
};
// attempt to inject input into current task
// attempt to inject input into current agent
if let Err(items) = sess.inject_input(items) {
// no current task, spawn a new one
let task = AgentTask::spawn(Arc::clone(sess), sub.id, items);
sess.set_task(task);
// no current agent, spawn a new one
let agent = AgentAgent::spawn(Arc::clone(sess), sub.id, items);
sess.set_agent(agent);
}
}
Op::ExecApproval { id, decision } => {
@@ -1021,17 +1025,17 @@ async fn submission_loop(
// Create a summarization request as user input
const SUMMARIZATION_PROMPT: &str = include_str!("prompt_for_compact_command.md");
// Attempt to inject input into current task
// Attempt to inject input into current agent
if let Err(items) = sess.inject_input(vec![InputItem::Text {
text: "Start Summarization".to_string(),
}]) {
let task = AgentTask::compact(
let agent = AgentAgent::compact(
sess.clone(),
sub.id,
items,
SUMMARIZATION_PROMPT.to_string(),
);
sess.set_task(task);
sess.set_agent(agent);
}
}
Op::Shutdown => {
@@ -1082,8 +1086,8 @@ async fn submission_loop(
/// - If the model requests a function call, we execute it and send the output
/// back to the model in the next turn.
/// - If the model sends only an assistant message, we record it in the
/// conversation history and consider the task complete.
async fn run_task(sess: Arc<Session>, sub_id: String, input: Vec<InputItem>) {
/// conversation history and consider the agent complete.
async fn run_agent(sess: Arc<Session>, sub_id: String, input: Vec<InputItem>) {
if input.is_empty() {
return;
}
@@ -1099,8 +1103,8 @@ async fn run_task(sess: Arc<Session>, sub_id: String, input: Vec<InputItem>) {
sess.record_conversation_items(&[initial_input_for_turn.clone().into()])
.await;
let mut last_agent_message: Option<String> = None;
// Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains
let mut last_task_message: Option<String> = None;
// Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Agent which contains
// many turns, from the perspective of the user, it is a single turn.
let mut turn_diff_tracker = TurnDiffTracker::new();
@@ -1229,13 +1233,13 @@ async fn run_task(sess: Arc<Session>, sub_id: String, input: Vec<InputItem>) {
if responses.is_empty() {
debug!("Turn completed");
last_agent_message = get_last_assistant_message_from_turn(
last_task_message = get_last_assistant_message_from_turn(
&items_to_record_in_conversation_history,
);
sess.maybe_notify(UserNotification::AgentTurnComplete {
turn_id: sub_id.clone(),
input_messages: turn_input_messages,
last_assistant_message: last_agent_message.clone(),
last_assistant_message: last_task_message.clone(),
});
break;
}
@@ -1254,10 +1258,10 @@ async fn run_task(sess: Arc<Session>, sub_id: String, input: Vec<InputItem>) {
}
}
}
sess.remove_task(&sub_id);
sess.remove_agent(&sub_id);
let event = Event {
id: sub_id,
msg: EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }),
msg: EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message: last_task_message }),
};
sess.tx_event.send(event).await.ok();
}
@@ -1490,7 +1494,7 @@ async fn try_run_turn(
}
}
async fn run_compact_task(
async fn run_compact_agent(
sess: Arc<Session>,
sub_id: String,
input: Vec<InputItem>,
@@ -1553,11 +1557,11 @@ async fn run_compact_task(
}
}
sess.remove_task(&sub_id);
sess.remove_agent(&sub_id);
let event = Event {
id: sub_id.clone(),
msg: EventMsg::AgentMessage(AgentMessageEvent {
message: "Compact task completed".to_string(),
message: "Compact agent completed".to_string(),
}),
};
sess.send_event(event).await;
@@ -1716,7 +1720,12 @@ async fn handle_function_call(
.await
}
"update_plan" => handle_update_plan(sess, arguments, sub_id, call_id).await,
"agent" => handle_agent_call(sess, arguments, sub_id, call_id).await,
"run_agent" => handle_run_agent(sess, arguments, sub_id, call_id).await,
"check_agent_status" => handle_check_agent_status(sess, arguments, sub_id, call_id).await,
"get_agent_result" => handle_get_agent_result(sess, arguments, sub_id, call_id).await,
"cancel_agent" => handle_cancel_agent(sess, arguments, sub_id, call_id).await,
"wait_for_agent" => handle_wait_for_agent(sess, arguments, sub_id, call_id).await,
"list_agents" => handle_list_agents(sess, arguments, sub_id, call_id).await,
_ => {
match sess.mcp_connection_manager.parse_tool_name(&name) {
Some((server, tool_name)) => {
@@ -1796,43 +1805,433 @@ fn maybe_run_with_user_profile(params: ExecParams, sess: &Session) -> ExecParams
params
}
async fn handle_agent_call(
async fn handle_run_agent(
sess: &Session,
arguments: String,
_sub_id: String,
call_id: String,
) -> ResponseInputItem {
match serde_json::from_str::<RunAgentParams>(&arguments) {
Ok(params) => {
let mut manager = AGENT_MANAGER.write().await;
// Handle model parameter (can be string or array)
let models = match params.model {
Some(serde_json::Value::String(model)) => vec![model],
Some(serde_json::Value::Array(models)) => {
models.into_iter()
.filter_map(|m| m.as_str().map(String::from))
.collect()
}
_ => vec!["codex".to_string()], // Default model
};
let batch_id = if models.len() > 1 {
Some(Uuid::new_v4().to_string())
} else {
None
};
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);
}
// Send status update showing agents are running
if agent_ids.len() > 0 {
let status_msg = format!("🤖 {} agent{} running", agent_ids.len(), if agent_ids.len() > 1 { "s" } else { "" });
let event = Event {
id: "agent-status".to_string(),
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
message: status_msg,
}),
};
let _ = sess.tx_event.send(event).await;
}
let response = if let Some(batch_id) = batch_id {
serde_json::json!({
"batch_id": batch_id,
"agent_ids": agent_ids,
"status": "started",
"message": format!("Started {} agents", agent_ids.len())
})
} else {
serde_json::json!({
"agent_id": agent_ids[0],
"status": "started",
"message": "Agent started successfully"
})
};
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: response.to_string(),
success: Some(true),
},
}
}
Err(e) => {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("Invalid run_agent arguments: {}", e),
success: None,
},
}
}
}
}
async fn handle_check_agent_status(
_sess: &Session,
arguments: String,
_sub_id: String,
call_id: String,
) -> ResponseInputItem {
// Parse the arguments
match serde_json::from_str::<AgentCallParams>(&arguments) {
match serde_json::from_str::<CheckAgentStatusParams>(&arguments) {
Ok(params) => {
// Execute the agent calls
match execute_agent_call(params).await {
Ok(output) => {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: output,
success: Some(true),
},
}
let manager = AGENT_MANAGER.read().await;
if let Some(agent) = manager.get_agent(&params.agent_id) {
let response = serde_json::json!({
"agent_id": agent.id,
"status": agent.status,
"model": agent.model,
"created_at": agent.created_at,
"started_at": agent.started_at,
"completed_at": agent.completed_at,
"progress": agent.progress,
"error": agent.error,
"worktree_path": agent.worktree_path,
"branch_name": agent.branch_name,
});
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: response.to_string(),
success: Some(true),
},
}
Err(e) => {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("Error calling agents: {}", e),
success: Some(false),
},
}
} else {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("Agent not found: {}", params.agent_id),
success: Some(false),
},
}
}
}
Err(e) => {
// Allow model to re-sample
ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(),
call_id,
output: FunctionCallOutputPayload {
content: format!("Invalid agent call arguments: {}", e),
content: format!("Invalid check_agent_status arguments: {}", e),
success: None,
},
}
}
}
}
async fn handle_get_agent_result(
_sess: &Session,
arguments: String,
_sub_id: String,
call_id: String,
) -> ResponseInputItem {
match serde_json::from_str::<GetAgentResultParams>(&arguments) {
Ok(params) => {
let manager = AGENT_MANAGER.read().await;
if let Some(agent) = manager.get_agent(&params.agent_id) {
if agent.status == AgentStatus::Completed {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: agent.result.unwrap_or_else(|| "No result available".to_string()),
success: Some(true),
},
}
} else if agent.status == AgentStatus::Failed {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("Agent failed: {}", agent.error.unwrap_or_else(|| "Unknown error".to_string())),
success: Some(false),
},
}
} else {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("Agent is still {}: cannot get result yet", serde_json::to_string(&agent.status).unwrap_or_else(|_| "running".to_string())),
success: Some(false),
},
}
}
} else {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("Agent not found: {}", params.agent_id),
success: Some(false),
},
}
}
}
Err(e) => {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("Invalid get_agent_result arguments: {}", e),
success: None,
},
}
}
}
}
async fn handle_cancel_agent(
_sess: &Session,
arguments: String,
_sub_id: String,
call_id: String,
) -> ResponseInputItem {
match serde_json::from_str::<CancelAgentParams>(&arguments) {
Ok(params) => {
let mut manager = AGENT_MANAGER.write().await;
if let Some(agent_id) = params.agent_id {
if manager.cancel_agent(&agent_id).await {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("Agent {} cancelled", agent_id),
success: Some(true),
},
}
} else {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("Failed to cancel agent {}", agent_id),
success: Some(false),
},
}
}
} else if let Some(batch_id) = params.batch_id {
let count = manager.cancel_batch(&batch_id).await;
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("Cancelled {} agents in batch {}", count, batch_id),
success: Some(true),
},
}
} else {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: "Either agent_id or batch_id must be provided".to_string(),
success: Some(false),
},
}
}
}
Err(e) => {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("Invalid cancel_agent arguments: {}", e),
success: None,
},
}
}
}
}
async fn handle_wait_for_agent(
_sess: &Session,
arguments: String,
_sub_id: String,
call_id: String,
) -> ResponseInputItem {
match serde_json::from_str::<WaitForAgentParams>(&arguments) {
Ok(params) => {
let timeout = std::time::Duration::from_secs(params.timeout_seconds.unwrap_or(300).min(600));
let start = std::time::Instant::now();
loop {
if start.elapsed() > timeout {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: "Timeout waiting for agent completion".to_string(),
success: Some(false),
},
};
}
let manager = AGENT_MANAGER.read().await;
if let Some(agent_id) = &params.agent_id {
if let Some(agent) = manager.get_agent(agent_id) {
if matches!(agent.status, AgentStatus::Completed | AgentStatus::Failed | AgentStatus::Cancelled) {
let response = serde_json::json!({
"agent_id": agent.id,
"status": agent.status,
"wait_time_seconds": start.elapsed().as_secs(),
});
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: response.to_string(),
success: Some(true),
},
};
}
}
} else if let Some(batch_id) = &params.batch_id {
let agents = manager.list_agents(None, Some(batch_id.clone()), false);
let completed_agents: Vec<_> = agents.into_iter()
.filter(|t| matches!(t.status, AgentStatus::Completed | AgentStatus::Failed | AgentStatus::Cancelled))
.collect();
if !completed_agents.is_empty() {
let response = if params.return_all.unwrap_or(false) {
serde_json::json!({
"batch_id": batch_id,
"completed_agents": completed_agents.iter().map(|t| t.id.clone()).collect::<Vec<_>>(),
"wait_time_seconds": start.elapsed().as_secs(),
})
} else {
serde_json::json!({
"agent_id": completed_agents[0].id,
"status": completed_agents[0].status,
"wait_time_seconds": start.elapsed().as_secs(),
})
};
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: response.to_string(),
success: Some(true),
},
};
}
}
drop(manager);
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
Err(e) => {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("Invalid wait_for_agent arguments: {}", e),
success: None,
},
}
}
}
}
async fn handle_list_agents(
sess: &Session,
arguments: String,
_sub_id: String,
call_id: String,
) -> ResponseInputItem {
match serde_json::from_str::<ListAgentsParams>(&arguments) {
Ok(params) => {
let manager = AGENT_MANAGER.read().await;
let status_filter = params.status_filter.and_then(|s| {
match s.to_lowercase().as_str() {
"pending" => Some(AgentStatus::Pending),
"running" => Some(AgentStatus::Running),
"completed" => Some(AgentStatus::Completed),
"failed" => Some(AgentStatus::Failed),
"cancelled" => Some(AgentStatus::Cancelled),
_ => None,
}
});
let agents = manager.list_agents(
status_filter,
params.batch_id,
params.recent_only.unwrap_or(false),
);
// Count running agents for status update
let running_count = agents.iter().filter(|a| a.status == AgentStatus::Running).count();
if running_count > 0 {
let status_msg = format!("🤖 {} agent{} currently running", running_count, if running_count != 1 { "s" } else { "" });
let event = Event {
id: "agent-status".to_string(),
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
message: status_msg,
}),
};
let _ = sess.tx_event.send(event).await;
}
// Add status counts to summary
let pending_count = agents.iter().filter(|a| a.status == AgentStatus::Pending).count();
let running_count = agents.iter().filter(|a| a.status == AgentStatus::Running).count();
let completed_count = agents.iter().filter(|a| a.status == AgentStatus::Completed).count();
let failed_count = agents.iter().filter(|a| a.status == AgentStatus::Failed).count();
let cancelled_count = agents.iter().filter(|a| a.status == AgentStatus::Cancelled).count();
let summary = serde_json::json!({
"total_agents": agents.len(),
"status_counts": {
"pending": pending_count,
"running": running_count,
"completed": completed_count,
"failed": failed_count,
"cancelled": cancelled_count,
},
"agents": agents.iter().map(|t| {
serde_json::json!({
"id": t.id,
"model": t.model,
"status": t.status,
"created_at": t.created_at,
"batch_id": t.batch_id,
"worktree_path": t.worktree_path,
"branch_name": t.branch_name,
})
}).collect::<Vec<_>>(),
});
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: summary.to_string(),
success: Some(true),
},
}
}
Err(e) => {
ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("Invalid list_agents arguments: {}", e),
success: None,
},
}
+1
View File
@@ -5,6 +5,7 @@
// the TUI or the tracing stack).
#![deny(clippy::print_stdout, clippy::print_stderr)]
pub mod slash_commands;
mod agent_tool;
mod apply_patch;
mod bash;
+11 -3
View File
@@ -4,7 +4,10 @@ use serde_json::json;
use std::collections::BTreeMap;
use std::collections::HashMap;
use crate::agent_tool::create_agent_tool;
use crate::agent_tool::{
create_run_agent_tool, create_check_agent_status_tool, create_get_agent_result_tool,
create_cancel_agent_tool, create_wait_for_agent_tool, create_list_agents_tool,
};
use crate::model_family::ModelFamily;
use crate::plan_tool::PLAN_TOOL;
use crate::protocol::AskForApproval;
@@ -333,8 +336,13 @@ pub(crate) fn get_openai_tools(
tools.push(PLAN_TOOL.clone());
}
// Add the agent tool for calling external LLMs
tools.push(create_agent_tool());
// Add agent management tools for calling external LLMs asynchronously
tools.push(create_run_agent_tool());
tools.push(create_check_agent_status_tool());
tools.push(create_get_agent_result_tool());
tools.push(create_cancel_agent_tool());
tools.push(create_wait_for_agent_tool());
tools.push(create_list_agents_tool());
if let Some(mcp_tools) = mcp_tools {
for (name, tool) in mcp_tools {
+191
View File
@@ -0,0 +1,191 @@
// Default models to use for slash commands
const DEFAULT_MODELS: &[&str] = &["claude", "gemini", "codex"];
/// 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());
let models_str = models_list.iter().map(|m| format!("\"{}\"", m)).collect::<Vec<_>>().join(", ");
format!(
r#"Create a comprehensive plan by leveraging multiple state-of-the-art LLMs working in parallel.
Use the agent tool to start a batch of agents using run_agent with:
- models: an array containing [{}]
- read_only: true (planning mode - no file modifications)
Provide a comprehensive description of the task and context. You should research the code base first and provide a general directory structure to give the models a head start of where to look. You can include one or two key files but also allow the models to look up the files they need themselves.
This will start all agents at once and return a batch_id.
IMPORTANT: Use wait_for_agent with the batch_id and return_all: true to wait for ALL agents to complete. This ensures you get all perspectives before formulating the final plan. If an agent fails or times out, you can ignore it and continue with the other results.
Once all models have completed:
1. Analyze all the different plans and recommendations
2. Identify common themes and best practices from each model
3. Synthesize the best elements from each plan
4. Create a final, comprehensive plan that incorporates the strongest recommendations from all models
5. Present the final plan with clear steps and rationale
Task to plan:
{}"#, models_str, task)
}
/// 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());
let models_str = models_list.iter().map(|m| format!("\"{}\"", m)).collect::<Vec<_>>().join(", ");
format!(
r#"Solve a complicated problem by starting multiple agents with state of the art LLMs.
Use the agent tool to start a batch of agents using a SINGLE run_agent with:
- models: an array containing [{}]
- read_only: true (so agents don't edit files but can read them)
Provide an extremely comprehensive description of the task and context. You should research the background information thoroughly and include any relevant details that could help the models understand the problem better. Include ALL relevant files you find.
This will start all agents at once and return a batch_id.
To monitor progress, you have two options:
1. Use wait_for_agent with the batch_id to block until the next agent completes (efficient, ignores already-completed agents)
2. Use list_agents with the batch_id to poll and check status manually
As soon as one completes you can try implementing the solution it proposes. IMPORTANT: You must thoroughly test and verify that the solution works correctly before considering the problem solved. This includes:
- Running any relevant tests
- Verifying the output is correct
- Checking for edge cases
- Ensuring no errors occur
If the solution does not work or only partially works, start a new agent with the same model/class and explain the problem, its suggested solution and why it didn't work. Check for any other completed agents and if they have a different solution try that. Keep multiple agents running in the background to explore different approaches.
**CRITICAL: Only cancel remaining agents AFTER you have:**
1. Implemented a complete solution
2. Thoroughly tested it works correctly
3. Verified the problem is 100% solved
4. Confirmed there are no errors or issues
**NEVER cancel agents if:**
- The solution is incomplete or untested
- You encounter errors during implementation
- The problem is only partially solved
- You haven't verified the solution works
If you're unsure whether the problem is fully solved, keep the agents running and continue working on the solution. It's better to have agents running in the background than to cancel them prematurely.
Problem to solve:
{}
Remember: DO NOT cancel any running agents until you have 100% confirmed the problem is completely solved with a working, tested solution."#, models_str, task)
}
/// 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());
let models_str = models_list.iter().map(|m| format!("\"{}\"", m)).collect::<Vec<_>>().join(", ");
format!(
r#"Perform a coding task with multiple LLMs and compare the results.
Use the agent tool to start a batch of agents using run_agent with:
- models: an array containing [{}]
- read_only: false (allow file modifications and code execution)
Provide a comprehensive description of the task and context. You should research the code base first to give the model a head start of where to look. You can include one or two key files but also allow the models to look up the files they need themselves.
The agents in separate worktrees will execute with full permissions to:
- Read and analyze existing code
- Create new files
- Modify existing files
- Execute commands
- Run tests
- Install dependencies
IMPORTANT: When agents complete, the response will include the worktree_path and branch_name for each agent. This shows you exactly where each model's work is located:
- worktree_path: The full path to the git worktree where the model made its changes
- branch_name: The git branch name created for that model's work
Monitor the agent progress using check_agent_status and wait for completion with wait_for_agent.
Once the agents are complete:
1. Check the worktree paths returned for each model
2. View their implementations in their respective worktrees
3. Compare their different approaches and solutions
4. Use git to examine the changes each model made
5. Bring the best parts of each solution into your own final implementation
Coding task to perform:
{}"#, models_str, task)
}
/// Parse a slash command and return the formatted prompt
pub fn handle_slash_command(input: &str) -> Option<String> {
let input = input.trim();
// Check if it starts with a slash
if !input.starts_with('/') {
return None;
}
// Parse the command and arguments
let parts: Vec<&str> = input.splitn(2, ' ').collect();
let command = parts[0];
let args = parts.get(1).map(|s| s.to_string()).unwrap_or_default();
match command {
"/plan" => {
if args.is_empty() {
Some("Error: /plan requires a task description. Usage: /plan <task>".to_string())
} else {
Some(format_plan_command(&args, None))
}
}
"/solve" => {
if args.is_empty() {
Some("Error: /solve requires a problem description. Usage: /solve <problem>".to_string())
} else {
Some(format_solve_command(&args, None))
}
}
"/code" => {
if args.is_empty() {
Some("Error: /code requires a task description. Usage: /code <task>".to_string())
} else {
Some(format_code_command(&args, None))
}
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_slash_command_parsing() {
// Test /plan command
let result = handle_slash_command("/plan implement a new feature");
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");
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");
assert!(result.is_some());
assert!(result.unwrap().contains("Perform a coding task"));
// Test invalid command
let result = handle_slash_command("/invalid test");
assert!(result.is_none());
// Test non-slash command
let result = handle_slash_command("regular message");
assert!(result.is_none());
// Test empty arguments
let result = handle_slash_command("/plan");
assert!(result.is_some());
assert!(result.unwrap().contains("Error"));
}
}
+11 -1
View File
@@ -11,7 +11,6 @@ use crate::slash_command::SlashCommand;
use crate::tui;
use codex_core::config::Config;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::Op;
use color_eyre::eyre::Result;
use crossterm::SynchronizedUpdate;
@@ -381,6 +380,17 @@ impl App<'_> {
widget.add_prompts_output();
}
}
// Prompt-expanding commands should have been handled in submit_user_message
// but add a fallback just in case
SlashCommand::Plan | SlashCommand::Solve | SlashCommand::Code => {
// These should have been expanded already, but handle them anyway
if let AppState::Chat { widget } = &mut self.app_state {
let expanded = command.expand_prompt(&command_text);
if let Some(prompt) = expanded {
widget.submit_text_message(prompt);
}
}
}
#[cfg(debug_assertions)]
SlashCommand::TestApproval => {
use std::collections::HashMap;
+36 -9
View File
@@ -415,10 +415,37 @@ impl ChatWidget<'_> {
fn submit_user_message(&mut self, user_message: UserMessage) {
let UserMessage { text, image_paths } = user_message;
// Keep the original text for display purposes
let original_text = text.clone();
let mut actual_text = text;
// Process slash commands and expand them if needed
let processed = crate::slash_command::process_slash_command_message(&actual_text);
match processed {
crate::slash_command::ProcessedCommand::ExpandedPrompt(expanded) => {
// Replace the slash command with the expanded prompt for the LLM
actual_text = expanded;
}
crate::slash_command::ProcessedCommand::RegularCommand(cmd, _args) => {
// This is a regular slash command, dispatch it normally
self.app_event_tx.send(AppEvent::DispatchCommand(cmd, actual_text.clone()));
return;
}
crate::slash_command::ProcessedCommand::Error(error_msg) => {
// Show error in history
self.add_to_history(HistoryCell::new_error_event(error_msg));
return;
}
crate::slash_command::ProcessedCommand::NotCommand(_) => {
// Not a slash command, process normally
}
}
let mut items: Vec<InputItem> = Vec::new();
if !text.is_empty() {
items.push(InputItem::Text { text: text.clone() });
if !actual_text.is_empty() {
items.push(InputItem::Text { text: actual_text.clone() });
}
for path in image_paths {
@@ -435,18 +462,18 @@ impl ChatWidget<'_> {
tracing::error!("failed to send message: {e}");
});
// Persist the text to cross-session message history.
if !text.is_empty() {
// Persist the original text to cross-session message history.
if !original_text.is_empty() {
self.codex_op_tx
.send(Op::AddToHistory { text: text.clone() })
.send(Op::AddToHistory { text: original_text.clone() })
.unwrap_or_else(|e| {
tracing::error!("failed to send AddHistory op: {e}");
});
}
// Only show text portion in conversation history for now.
if !text.is_empty() {
self.add_to_history(HistoryCell::new_user_prompt(text.clone()));
// Show the original text (slash command) in conversation history, not the expanded version
if !original_text.is_empty() {
self.add_to_history(HistoryCell::new_user_prompt(original_text));
}
}
@@ -833,7 +860,7 @@ impl ChatWidget<'_> {
/// Programmatically submit a user text message as if typed in the
/// composer. The text will be added to conversation history and sent to
/// the agent.
/// the agent. This also handles slash command expansion.
pub(crate) fn submit_text_message(&mut self, text: String) {
if text.is_empty() {
return;
+92
View File
@@ -19,6 +19,10 @@ pub enum SlashCommand {
Status,
Reasoning,
Prompts,
// Prompt-expanding commands
Plan,
Solve,
Code,
Logout,
Quit,
#[cfg(debug_assertions)]
@@ -37,6 +41,9 @@ impl SlashCommand {
SlashCommand::Status => "show current session configuration and token usage",
SlashCommand::Reasoning => "change reasoning effort (low/medium/high/none)",
SlashCommand::Prompts => "show example prompts",
SlashCommand::Plan => "create a comprehensive plan using multiple LLMs",
SlashCommand::Solve => "solve a complex problem with multiple LLMs",
SlashCommand::Code => "perform a coding task with multiple LLMs",
SlashCommand::Logout => "log out of Codex",
#[cfg(debug_assertions)]
SlashCommand::TestApproval => "test approval request",
@@ -48,9 +55,94 @@ impl SlashCommand {
pub fn command(self) -> &'static str {
self.into()
}
/// Returns true if this command should expand into a prompt for the LLM.
pub fn is_prompt_expanding(self) -> bool {
matches!(self, SlashCommand::Plan | SlashCommand::Solve | SlashCommand::Code)
}
/// Returns true if this command requires additional arguments after the command.
pub fn requires_arguments(self) -> bool {
matches!(self, SlashCommand::Plan | SlashCommand::Solve | SlashCommand::Code)
}
/// Expands a prompt-expanding command into a full prompt for the LLM.
/// Returns None if the command is not a prompt-expanding command.
pub fn expand_prompt(self, args: &str) -> Option<String> {
if !self.is_prompt_expanding() {
return None;
}
// Use the slash_commands module from core to generate the prompts
match self {
SlashCommand::Plan => {
Some(codex_core::slash_commands::format_plan_command(args, None))
}
SlashCommand::Solve => {
Some(codex_core::slash_commands::format_solve_command(args, None))
}
SlashCommand::Code => {
Some(codex_core::slash_commands::format_code_command(args, None))
}
_ => None,
}
}
}
/// Return all built-in commands in a Vec paired with their command string.
pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> {
SlashCommand::iter().map(|c| (c.command(), c)).collect()
}
/// Process a message that might contain a slash command.
/// Returns either the expanded prompt (for prompt-expanding commands) or the original message.
pub fn process_slash_command_message(message: &str) -> ProcessedCommand {
let trimmed = message.trim();
// Check if it starts with a slash
if !trimmed.starts_with('/') {
return ProcessedCommand::NotCommand(message.to_string());
}
// Parse the command and arguments
let parts: Vec<&str> = trimmed.splitn(2, ' ').collect();
let command_str = &parts[0][1..]; // Remove the leading '/'
let args = parts.get(1).map(|s| s.to_string()).unwrap_or_default();
// Try to parse the command
if let Ok(command) = command_str.parse::<SlashCommand>() {
// Check if it's a prompt-expanding command
if command.is_prompt_expanding() {
if args.is_empty() && command.requires_arguments() {
return ProcessedCommand::Error(format!(
"Error: /{} requires a task description. Usage: /{} <task>",
command.command(),
command.command()
));
}
if let Some(expanded) = command.expand_prompt(&args) {
return ProcessedCommand::ExpandedPrompt(expanded);
}
}
// It's a regular command, return it as-is
ProcessedCommand::RegularCommand(command, args)
} else {
// Unknown command
ProcessedCommand::NotCommand(message.to_string())
}
}
#[derive(Debug, Clone)]
pub enum ProcessedCommand {
/// The message was expanded from a prompt-expanding slash command
ExpandedPrompt(String),
/// A regular slash command that should be handled by the TUI
RegularCommand(SlashCommand, String),
/// Not a slash command, just a regular message
#[allow(dead_code)]
NotCommand(String),
/// Error processing the command
Error(String),
}