Files
dev/code-rs/core/src/agent_tool.rs
T

1831 lines
61 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use chrono::DateTime;
use chrono::Duration;
use chrono::Utc;
use serde::Deserialize;
use serde::Serialize;
use uuid::Uuid;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use tokio::process::Command;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::runtime::Builder as TokioRuntimeBuilder;
use tokio::sync::RwLock;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tokio::time::Duration as TokioDuration;
use std::thread;
use crate::agent_defaults::{agent_model_spec, default_params_for};
use shlex::split as shlex_split;
use crate::config_types::AgentConfig;
use crate::openai_tools::JsonSchema;
use crate::openai_tools::OpenAiTool;
use crate::openai_tools::ResponsesApiTool;
use crate::protocol::AgentInfo;
fn current_code_binary_path() -> Result<std::path::PathBuf, String> {
if let Ok(path) = std::env::var("CODE_BINARY_PATH") {
return Ok(std::path::PathBuf::from(path));
}
std::env::current_exe().map_err(|e| format!("Failed to resolve current executable: {}", e))
}
// 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 Agent {
pub id: String,
pub batch_id: Option<String>,
pub model: String,
#[serde(default)]
pub name: Option<String>,
pub prompt: 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>,
#[serde(skip)]
#[allow(dead_code)]
pub config: Option<AgentConfig>,
pub reasoning_effort: code_protocol::config_types::ReasoningEffort,
}
// Global agent manager
lazy_static::lazy_static! {
pub static ref AGENT_MANAGER: Arc<RwLock<AgentManager>> = Arc::new(RwLock::new(AgentManager::new()));
}
pub struct AgentManager {
agents: HashMap<String, Agent>,
handles: HashMap<String, JoinHandle<()>>,
event_sender: Option<mpsc::UnboundedSender<AgentStatusUpdatePayload>>,
}
#[derive(Debug, Clone)]
pub struct AgentStatusUpdatePayload {
pub agents: Vec<AgentInfo>,
pub context: Option<String>,
pub task: Option<String>,
}
impl AgentManager {
pub fn new() -> Self {
Self {
agents: HashMap::new(),
handles: HashMap::new(),
event_sender: None,
}
}
pub fn set_event_sender(&mut self, sender: mpsc::UnboundedSender<AgentStatusUpdatePayload>) {
self.event_sender = Some(sender);
}
async fn send_agent_status_update(&self) {
if let Some(ref sender) = self.event_sender {
let now = Utc::now();
let agents: Vec<AgentInfo> = self
.agents
.values()
.map(|agent| {
// Just show the model name - status provides the useful info
let name = agent
.name
.as_ref()
.map(|value| value.clone())
.unwrap_or_else(|| agent.model.clone());
let start = agent.started_at.unwrap_or(agent.created_at);
let end = agent.completed_at.unwrap_or(now);
let elapsed_ms = match end.signed_duration_since(start).num_milliseconds() {
value if value >= 0 => Some(value as u64),
_ => None,
};
AgentInfo {
id: agent.id.clone(),
name,
status: format!("{:?}", agent.status).to_lowercase(),
batch_id: agent.batch_id.clone(),
model: Some(agent.model.clone()),
last_progress: agent.progress.last().cloned(),
result: agent.result.clone(),
error: agent.error.clone(),
elapsed_ms,
token_count: None,
}
})
.collect();
// Get context and task from the first agent (they're all the same)
let (context, task) = self
.agents
.values()
.next()
.map(|agent| {
let context = agent
.context
.as_ref()
.and_then(|value| if value.trim().is_empty() {
None
} else {
Some(value.clone())
});
let task = if agent.prompt.trim().is_empty() {
None
} else {
Some(agent.prompt.clone())
};
(context, task)
})
.unwrap_or((None, None));
let payload = AgentStatusUpdatePayload { agents, context, task };
let _ = sender.send(payload);
}
}
pub async fn create_agent(
&mut self,
model: String,
name: Option<String>,
prompt: String,
context: Option<String>,
output_goal: Option<String>,
files: Vec<String>,
read_only: bool,
batch_id: Option<String>,
reasoning_effort: code_protocol::config_types::ReasoningEffort,
) -> String {
self.create_agent_internal(
model,
name,
prompt,
context,
output_goal,
files,
read_only,
batch_id,
None,
reasoning_effort,
)
.await
}
pub async fn create_agent_with_config(
&mut self,
model: String,
name: Option<String>,
prompt: String,
context: Option<String>,
output_goal: Option<String>,
files: Vec<String>,
read_only: bool,
batch_id: Option<String>,
config: AgentConfig,
reasoning_effort: code_protocol::config_types::ReasoningEffort,
) -> String {
self.create_agent_internal(
model,
name,
prompt,
context,
output_goal,
files,
read_only,
batch_id,
Some(config),
reasoning_effort,
)
.await
}
async fn create_agent_internal(
&mut self,
model: String,
name: Option<String>,
prompt: String,
context: Option<String>,
output_goal: Option<String>,
files: Vec<String>,
read_only: bool,
batch_id: Option<String>,
config: Option<AgentConfig>,
reasoning_effort: code_protocol::config_types::ReasoningEffort,
) -> String {
let agent_id = Uuid::new_v4().to_string();
let agent = Agent {
id: agent_id.clone(),
batch_id,
model,
name: normalize_agent_name(name),
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,
config: config.clone(),
reasoning_effort,
};
self.agents.insert(agent_id.clone(), agent.clone());
// Send initial status update
self.send_agent_status_update().await;
// Spawn async agent
let agent_id_clone = agent_id.clone();
let handle = tokio::spawn(async move {
execute_agent(agent_id_clone, config).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 get_all_agents(&self) -> impl Iterator<Item = &Agent> {
self.agents.values()
}
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 fn has_active_agents(&self) -> bool {
self.agents
.values()
.any(|agent| matches!(agent.status, AgentStatus::Pending | AgentStatus::Running))
}
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());
}
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());
}
// Send status update event
self.send_agent_status_update().await;
}
}
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());
// Send status update event
self.send_agent_status_update().await;
}
}
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));
// Send updated agent status with the latest progress
self.send_agent_status_update().await;
}
}
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 get_git_root() -> Result<PathBuf, String> {
let output = Command::new("git")
.args(&["rev-parse", "--show-toplevel"])
.output()
.await
.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())
}
}
use crate::git_worktree::sanitize_ref_component;
fn generate_branch_id(model: &str, agent: &str) -> String {
// Extract first few meaningful words from agent for the branch name
let stop = ["the", "and", "for", "with", "from", "into", "goal"]; // skip boilerplate
let words: Vec<&str> = agent
.split_whitespace()
.filter(|w| w.len() > 2 && !stop.contains(&w.to_ascii_lowercase().as_str()))
.take(3)
.collect();
let raw_suffix = if words.is_empty() {
Uuid::new_v4()
.to_string()
.split('-')
.next()
.unwrap_or("agent")
.to_string()
} else {
words.join("-")
};
// Sanitize both model and suffix for safety
let model_s = sanitize_ref_component(model);
let mut suffix_s = sanitize_ref_component(&raw_suffix);
// Constrain length to keep branch names readable
if suffix_s.len() > 40 {
suffix_s.truncate(40);
suffix_s = suffix_s.trim_matches('-').to_string();
if suffix_s.is_empty() {
suffix_s = "agent".to_string();
}
}
format!("code-{}-{}", model_s, suffix_s)
}
use crate::git_worktree::setup_worktree;
async fn execute_agent(agent_id: String, config: Option<AgentConfig>) {
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 model_spec = agent_model_spec(&model);
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();
let reasoning_effort = agent.reasoning_effort;
drop(manager); // Release the lock before executing
// Build the full prompt with context
let mut full_prompt = prompt.clone();
// Prepend any per-agent instructions from config when available
if let Some(cfg) = config.as_ref() {
if let Some(instr) = cfg.instructions.as_ref() {
if !instr.trim().is_empty() {
full_prompt = format!("{}\n\n{}", instr.trim(), full_prompt);
}
}
}
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 gating_error_message = |spec: &crate::agent_defaults::AgentModelSpec| {
if let Some(flag) = spec.gating_env {
format!(
"agent model '{}' is disabled; set {}=1 to enable it",
spec.slug, flag
)
} else {
format!("agent model '{}' is disabled", spec.slug)
}
};
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, used_branch)) => {
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(),
used_branch.clone(),
)
.await;
drop(manager);
// Execute with full permissions in the worktree
let use_built_in_cloud = config.is_none()
&& model_spec
.map(|spec| spec.cli.eq_ignore_ascii_case("cloud"))
.unwrap_or_else(|| model.eq_ignore_ascii_case("cloud"));
if use_built_in_cloud {
if let Some(spec) = model_spec {
if !spec.is_enabled() {
Err(gating_error_message(spec))
} else {
execute_cloud_built_in_streaming(
&agent_id,
&full_prompt,
Some(worktree_path),
config.clone(),
spec.slug,
)
.await
}
} else {
execute_cloud_built_in_streaming(
&agent_id,
&full_prompt,
Some(worktree_path),
config.clone(),
model.as_str(),
)
.await
}
} else {
execute_model_with_permissions(
&model,
&full_prompt,
false,
Some(worktree_path),
config.clone(),
reasoning_effort,
)
.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
);
let use_built_in_cloud = config.is_none()
&& model_spec
.map(|spec| spec.cli.eq_ignore_ascii_case("cloud"))
.unwrap_or_else(|| model.eq_ignore_ascii_case("cloud"));
if use_built_in_cloud {
if let Some(spec) = model_spec {
if !spec.is_enabled() {
Err(gating_error_message(spec))
} else {
execute_cloud_built_in_streaming(&agent_id, &full_prompt, None, config, spec.slug).await
}
} else {
execute_cloud_built_in_streaming(&agent_id, &full_prompt, None, config, model.as_str()).await
}
} else {
execute_model_with_permissions(
&model,
&full_prompt,
true,
None,
config,
reasoning_effort,
)
.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>,
config: Option<AgentConfig>,
reasoning_effort: code_protocol::config_types::ReasoningEffort,
) -> Result<String, String> {
// Helper: cross‑platform check whether an executable is available in PATH
// and is directly spawnable by std::process::Command (no shell wrappers).
fn command_exists(cmd: &str) -> bool {
// Absolute/relative path with separators: check directly (files only).
if cmd.contains(std::path::MAIN_SEPARATOR) || cmd.contains('/') || cmd.contains('\\') {
return std::fs::metadata(cmd).map(|m| m.is_file()).unwrap_or(false);
}
#[cfg(target_os = "windows")]
{
// On Windows, ensure we only accept spawnable extensions. PowerShell
// scripts like .ps1 are not directly spawnable via Command::new.
if let Ok(p) = which::which(cmd) {
if !p.is_file() { return false; }
match p.extension().and_then(|e| e.to_str()).map(|s| s.to_ascii_lowercase()) {
Some(ext) if matches!(ext.as_str(), "exe" | "com" | "cmd" | "bat") => true,
_ => false,
}
} else {
false
}
}
#[cfg(not(target_os = "windows"))]
{
use std::os::unix::fs::PermissionsExt;
let Some(path_os) = std::env::var_os("PATH") else { return false; };
for dir in std::env::split_paths(&path_os) {
if dir.as_os_str().is_empty() { continue; }
let candidate = dir.join(cmd);
if let Ok(meta) = std::fs::metadata(&candidate) {
if meta.is_file() {
let mode = meta.permissions().mode();
if mode & 0o111 != 0 { return true; }
}
}
}
false
}
}
let spec_opt = agent_model_spec(model)
.or_else(|| config.as_ref().and_then(|cfg| agent_model_spec(&cfg.name)))
.or_else(|| config.as_ref().and_then(|cfg| agent_model_spec(&cfg.command)));
if let Some(spec) = spec_opt {
if !spec.is_enabled() {
if let Some(flag) = spec.gating_env {
return Err(format!(
"agent model '{}' is disabled; set {}=1 to enable it",
spec.slug, flag
));
}
return Err(format!("agent model '{}' is disabled", spec.slug));
}
}
// Use config command if provided, otherwise fall back to the spec CLI (or the
// lowercase model string).
let command = if let Some(ref cfg) = config {
let cmd = cfg.command.trim();
if !cmd.is_empty() {
cfg.command.clone()
} else if let Some(spec) = spec_opt {
spec.cli.to_string()
} else {
cfg.name.clone()
}
} else if let Some(spec) = spec_opt {
spec.cli.to_string()
} else {
model.to_lowercase()
};
let (command_base, command_extra_args) = split_command_and_args(&command);
let command_for_spawn = if command_base.is_empty() {
command.clone()
} else {
command_base.clone()
};
// Special case: for the built‑in Codex agent, prefer invoking the currently
// running executable with the `exec` subcommand rather than relying on a
// `codex` binary to be present on PATH. This improves portability,
// especially on Windows where global shims may be missing.
let model_lower = model.to_lowercase();
let command_lower = command_for_spawn.to_ascii_lowercase();
fn is_known_family(s: &str) -> bool {
matches!(s, "claude" | "gemini" | "qwen" | "codex" | "code" | "cloud")
}
let slug_for_defaults = spec_opt.map(|spec| spec.slug).unwrap_or(model);
let spec_family = spec_opt.map(|spec| spec.family);
let family = if let Some(spec_family) = spec_family {
spec_family
} else if is_known_family(model_lower.as_str()) {
model_lower.as_str()
} else if is_known_family(command_lower.as_str()) {
command_lower.as_str()
} else {
model_lower.as_str()
};
let mut use_current_exe = false;
if matches!(family, "code" | "codex" | "cloud") {
if config.is_none() {
if !command_exists(&command_for_spawn) {
use_current_exe = true;
}
} else if let Some(ref cfg) = config {
if cfg.command.trim().is_empty() {
use_current_exe = true;
}
}
}
let mut cmd = if use_current_exe {
match current_code_binary_path() {
Ok(path) => Command::new(path),
Err(e) => return Err(e),
}
} else {
Command::new(command_for_spawn.clone())
};
// Set working directory if provided
if let Some(dir) = working_dir.clone() {
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);
}
}
}
let mut final_args: Vec<String> = command_extra_args;
if let Some(ref cfg) = config {
if read_only {
if let Some(ro) = cfg.args_read_only.as_ref() {
final_args.extend(ro.iter().cloned());
} else {
final_args.extend(cfg.args.iter().cloned());
}
} else if let Some(w) = cfg.args_write.as_ref() {
final_args.extend(w.iter().cloned());
} else {
final_args.extend(cfg.args.iter().cloned());
}
}
strip_model_flags(&mut final_args);
let spec_model_args: Vec<String> = if let Some(spec) = spec_opt {
if matches!(spec.family, "code" | "codex" | "cloud") && use_current_exe {
Vec::new()
} else {
spec.model_args.iter().map(|arg| (*arg).to_string()).collect()
}
} else {
Vec::new()
};
let built_in_cloud = family == "cloud" && config.is_none();
// Clamp reasoning effort to what the target model supports.
let clamped_effort = match reasoning_effort {
code_protocol::config_types::ReasoningEffort::XHigh => {
let lower = slug_for_defaults.to_ascii_lowercase();
if lower.contains("max") {
reasoning_effort
} else {
code_protocol::config_types::ReasoningEffort::High
}
}
other => other,
};
// Configuration overrides for Codex CLI families. External CLIs (claude,
// gemini, qwen) do not understand our config flags, so only attach these
// when launching Codex binaries.
let effort_override = format!(
"model_reasoning_effort={}",
clamped_effort.to_string().to_ascii_lowercase()
);
let auto_effort_override = format!(
"auto_drive.model_reasoning_effort={}",
clamped_effort.to_string().to_ascii_lowercase()
);
match family {
"claude" | "gemini" | "qwen" => {
let mut defaults = default_params_for(slug_for_defaults, read_only);
strip_model_flags(&mut defaults);
final_args.extend(defaults);
final_args.extend(spec_model_args.iter().cloned());
final_args.push("-p".into());
final_args.push(prompt.to_string());
}
"codex" | "code" => {
let have_mode_args = config
.as_ref()
.map(|c| if read_only { c.args_read_only.is_some() } else { c.args_write.is_some() })
.unwrap_or(false);
if !have_mode_args {
let mut defaults = default_params_for(slug_for_defaults, read_only);
strip_model_flags(&mut defaults);
final_args.extend(defaults);
}
final_args.extend(spec_model_args.iter().cloned());
final_args.push("-c".into());
final_args.push(effort_override.clone());
final_args.push("-c".into());
final_args.push(auto_effort_override.clone());
final_args.push(prompt.to_string());
}
"cloud" => {
if built_in_cloud {
final_args.extend(["cloud", "submit", "--wait"].map(String::from));
}
let have_mode_args = config
.as_ref()
.map(|c| if read_only { c.args_read_only.is_some() } else { c.args_write.is_some() })
.unwrap_or(false);
if !have_mode_args {
let mut defaults = default_params_for(slug_for_defaults, read_only);
strip_model_flags(&mut defaults);
final_args.extend(defaults);
}
final_args.extend(spec_model_args.iter().cloned());
final_args.push("-c".into());
final_args.push(effort_override.clone());
final_args.push("-c".into());
final_args.push(auto_effort_override);
final_args.push(prompt.to_string());
}
_ => {
final_args.extend(spec_model_args.iter().cloned());
final_args.push(prompt.to_string());
}
}
// Proactively check for presence of external command before spawn when not
// using the current executable fallback. This avoids confusing OS errors
// like "program not found" and lets us surface a cleaner message.
if !(family == "codex" || family == "code" || (family == "cloud" && config.is_none()))
&& !command_exists(&command_for_spawn)
{
return Err(format!("Required agent '{}' is not installed or not in PATH", command));
}
// Agents: run without OS sandboxing; rely on per-branch worktrees for isolation.
use crate::protocol::SandboxPolicy;
use crate::spawn::StdioPolicy;
let output = if !read_only {
// Build env from current process then overlay any config-provided vars.
let mut env: std::collections::HashMap<String, String> = std::env::vars().collect();
let orig_home: Option<String> = env.get("HOME").cloned();
if let Some(ref cfg) = config {
if let Some(ref e) = cfg.env { for (k, v) in e { env.insert(k.clone(), v.clone()); } }
}
// Convenience: map common key names so external CLIs "just work".
if let Some(google_key) = env.get("GOOGLE_API_KEY").cloned() {
env.entry("GEMINI_API_KEY".to_string()).or_insert(google_key);
}
if let Some(claude_key) = env.get("CLAUDE_API_KEY").cloned() {
env.entry("ANTHROPIC_API_KEY".to_string()).or_insert(claude_key);
}
if let Some(anthropic_key) = env.get("ANTHROPIC_API_KEY").cloned() {
env.entry("CLAUDE_API_KEY".to_string()).or_insert(anthropic_key);
}
if let Some(anthropic_base) = env.get("ANTHROPIC_BASE_URL").cloned() {
env.entry("CLAUDE_BASE_URL".to_string()).or_insert(anthropic_base);
}
// Qwen/DashScope convenience: mirror API keys and base URLs both ways so
// either variable name works across tools.
if let Some(qwen_key) = env.get("QWEN_API_KEY").cloned() {
env.entry("DASHSCOPE_API_KEY".to_string()).or_insert(qwen_key);
}
if let Some(dashscope_key) = env.get("DASHSCOPE_API_KEY").cloned() {
env.entry("QWEN_API_KEY".to_string()).or_insert(dashscope_key);
}
if let Some(qwen_base) = env.get("QWEN_BASE_URL").cloned() {
env.entry("DASHSCOPE_BASE_URL".to_string()).or_insert(qwen_base);
}
if let Some(ds_base) = env.get("DASHSCOPE_BASE_URL").cloned() {
env.entry("QWEN_BASE_URL".to_string()).or_insert(ds_base);
}
if family == "qwen" {
env.insert("OPENAI_API_KEY".to_string(), String::new());
}
// Reduce startup overhead for Claude CLI: disable auto-updater/telemetry.
env.entry("DISABLE_AUTOUPDATER".to_string()).or_insert("1".to_string());
env.entry("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC".to_string()).or_insert("1".to_string());
env.entry("DISABLE_ERROR_REPORTING".to_string()).or_insert("1".to_string());
// Prefer explicit Claude config dir to avoid touching $HOME/.claude.json.
// Do not force CLAUDE_CONFIG_DIR here; leave CLI free to use its default
// (including Keychain) unless we explicitly redirect HOME below.
// If GEMINI_API_KEY not provided, try pointing to host config for read‑only
// discovery (Gemini CLI supports GEMINI_CONFIG_DIR). We keep HOME as-is so
// CLIs that require ~/.gemini and ~/.claude continue to work with your
// existing config.
if env.get("GEMINI_API_KEY").is_none() {
if let Some(h) = orig_home.clone() {
let host_gem_cfg = std::path::PathBuf::from(&h).join(".gemini");
if host_gem_cfg.is_dir() {
env.insert(
"GEMINI_CONFIG_DIR".to_string(),
host_gem_cfg.to_string_lossy().to_string(),
);
}
}
}
// No OS sandbox.
// Resolve the command and args we prepared above into Vec<String> for spawn helpers.
let program = if ((model_lower == "code" || model_lower == "codex") || model_lower == "cloud") && config.is_none() {
// Use current exe path (or overridden path) to avoid JS bootstrap on Windows.
current_code_binary_path()?
} else {
// Use program name; PATH resolution will be handled by spawn helper with provided env.
std::path::PathBuf::from(&command_for_spawn)
};
let args = final_args.clone();
// Always run agents without OS sandboxing.
let sandbox_type = crate::exec::SandboxType::None;
// Spawn via helpers and capture output
let child_result: std::io::Result<tokio::process::Child> = match sandbox_type {
crate::exec::SandboxType::None | crate::exec::SandboxType::MacosSeatbelt | crate::exec::SandboxType::LinuxSeccomp => {
crate::spawn::spawn_child_async(
program.clone(),
args.clone(),
Some(program.to_string_lossy().as_ref()),
working_dir.clone().unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))),
&SandboxPolicy::DangerFullAccess,
StdioPolicy::RedirectForShellTool,
env.clone(),
)
.await
}
};
match child_result {
Ok(child) => child
.wait_with_output()
.await
.map_err(|e| format!("Failed to read output: {}", e))?,
Err(e) => {
if e.kind() == std::io::ErrorKind::NotFound {
return Err(format!(
"Required agent '{}' is not installed or not in PATH",
command
));
}
return Err(format!("Failed to spawn sandboxed agent: {}", e));
}
}
} else {
// Read-only path: use prior behavior
cmd.args(final_args.clone());
match cmd.output().await {
Ok(o) => o,
Err(e) => {
// Only fall back for external CLIs (not the built-in code/codex path)
if family == "codex" || family == "code" {
return Err(format!("Failed to execute {}: {}", model, e));
}
let mut fb = match current_code_binary_path() {
Ok(p) => Command::new(p),
Err(e2) => {
return Err(format!(
"Failed to execute {} and could not resolve built-in fallback: {} / {}",
model, e, e2
))
}
};
fb.args(final_args.clone());
fb.output().await.map_err(|e2| {
format!(
"Failed to execute {} ({}). Built-in fallback also failed: {}",
model, e, e2
)
})?
}
}
};
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let combined = if stderr.trim().is_empty() {
stdout.trim().to_string()
} else if stdout.trim().is_empty() {
stderr.trim().to_string()
} else {
format!("{}\n{}", stderr.trim(), stdout.trim())
};
Err(format!("Command failed: {}", combined))
}
}
fn strip_model_flags(args: &mut Vec<String>) {
let mut i = 0;
while i < args.len() {
let lowered = args[i].to_ascii_lowercase();
if lowered == "--model" || lowered == "-m" {
args.remove(i);
if i < args.len() {
args.remove(i);
}
continue;
}
if lowered.starts_with("--model=") || lowered.starts_with("-m=") {
args.remove(i);
continue;
}
i += 1;
}
}
pub fn split_command_and_args(command: &str) -> (String, Vec<String>) {
let trimmed = command.trim();
if trimmed.is_empty() {
return (String::new(), Vec::new());
}
if let Some(tokens) = shlex_split(trimmed) {
if let Some((first, rest)) = tokens.split_first() {
return (first.clone(), rest.to_vec());
}
}
let tokens: Vec<String> = trimmed.split_whitespace().map(|s| s.to_string()).collect();
if tokens.is_empty() {
(String::new(), Vec::new())
} else {
let head = tokens[0].clone();
(head, tokens[1..].to_vec())
}
}
const AGENT_SMOKE_TEST_PROMPT: &str = "Reply only with the string \"ok\". Do not include any other words.";
const AGENT_SMOKE_TEST_EXPECTED: &str = "ok";
const AGENT_SMOKE_TEST_TIMEOUT: TokioDuration = TokioDuration::from_secs(20);
fn should_validate_in_read_only(_cfg: &AgentConfig) -> bool { true }
async fn run_agent_smoke_test(cfg: AgentConfig) -> Result<String, String> {
let model_name = cfg.name.clone();
let read_only = should_validate_in_read_only(&cfg);
let mut task = tokio::spawn(async move {
execute_model_with_permissions(
&model_name,
AGENT_SMOKE_TEST_PROMPT,
read_only,
None,
Some(cfg),
code_protocol::config_types::ReasoningEffort::High,
)
.await
});
let timer = tokio::time::sleep(AGENT_SMOKE_TEST_TIMEOUT);
tokio::pin!(timer);
tokio::select! {
res = &mut task => {
res.map_err(|e| format!("agent validation task failed: {e}"))?
}
_ = timer.as_mut() => {
task.abort();
let _ = task.await;
return Err(format!(
"agent validation timed out after {}s",
AGENT_SMOKE_TEST_TIMEOUT.as_secs()
));
}
}
}
fn summarize_agent_output(output: &str) -> String {
let trimmed = output.trim();
if trimmed.is_empty() {
return "<empty response>".to_string();
}
const MAX_LEN: usize = 240;
if trimmed.len() <= MAX_LEN {
trimmed.to_string()
} else {
let mut cutoff = MAX_LEN.min(trimmed.len());
while cutoff > 0 && !trimmed.is_char_boundary(cutoff) {
cutoff -= 1;
}
if cutoff == 0 {
// Fallback: take first char to avoid empty slice
let mut chars = trimmed.chars();
if let Some(first) = chars.next() {
format!("{}…", first)
} else {
"…".to_string()
}
} else {
format!("{}…", &trimmed[..cutoff])
}
}
}
pub async fn smoke_test_agent(cfg: AgentConfig) -> Result<(), String> {
let output = run_agent_smoke_test(cfg).await?;
let normalized = output.trim().to_ascii_lowercase();
if normalized == AGENT_SMOKE_TEST_EXPECTED {
Ok(())
} else {
Err(format!(
"agent response missing \"ok\": {}",
summarize_agent_output(&output)
))
}
}
fn run_smoke_test_with_new_runtime(cfg: AgentConfig) -> Result<(), String> {
TokioRuntimeBuilder::new_current_thread()
.enable_all()
.build()
.map_err(|e| format!("failed to build validation runtime: {}", e))?
.block_on(smoke_test_agent(cfg))
}
pub fn smoke_test_agent_blocking(cfg: AgentConfig) -> Result<(), String> {
if tokio::runtime::Handle::try_current().is_ok() {
thread::Builder::new()
.name("agent-smoke-test".into())
.spawn(move || run_smoke_test_with_new_runtime(cfg))
.map_err(|e| format!("failed to spawn agent validation thread: {}", e))?
.join()
.map_err(|_| "agent validation thread panicked".to_string())?
} else {
run_smoke_test_with_new_runtime(cfg)
}
}
/// Execute the built-in cloud agent via the current `code` binary, streaming
/// stderr lines into the HUD as progress and returning final stdout. Applies a
/// modest truncation cap to very large outputs to keep UI responsive.
async fn execute_cloud_built_in_streaming(
agent_id: &str,
prompt: &str,
working_dir: Option<std::path::PathBuf>,
_config: Option<AgentConfig>,
model_slug: &str,
) -> Result<String, String> {
// Program and argv
let program = current_code_binary_path()?;
let mut args: Vec<String> = vec!["cloud".into(), "submit".into(), "--wait".into()];
if let Some(spec) = agent_model_spec(model_slug) {
args.extend(spec.model_args.iter().map(|arg| (*arg).to_string()));
}
args.push(prompt.into());
// Baseline env mirrors behavior in execute_model_with_permissions
let env: std::collections::HashMap<String, String> = std::env::vars().collect();
use crate::protocol::SandboxPolicy;
use crate::spawn::StdioPolicy;
let mut child = crate::spawn::spawn_child_async(
program.clone(),
args.clone(),
Some(program.to_string_lossy().as_ref()),
working_dir.clone().unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))),
&SandboxPolicy::DangerFullAccess,
StdioPolicy::RedirectForShellTool,
env,
)
.await
.map_err(|e| format!("Failed to spawn cloud submit: {}", e))?;
// Stream stderr to HUD
let stderr_task = if let Some(stderr) = child.stderr.take() {
let agent = agent_id.to_string();
Some(tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
let msg = line.trim();
if msg.is_empty() { continue; }
let mut mgr = AGENT_MANAGER.write().await;
mgr.add_progress(&agent, msg.to_string()).await;
}
}))
} else { None };
// Collect stdout fully (final result)
let mut stdout_buf = String::new();
if let Some(stdout) = child.stdout.take() {
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
stdout_buf.push_str(&line);
stdout_buf.push('\n');
}
}
let status = child.wait().await.map_err(|e| format!("Failed to wait: {}", e))?;
if let Some(t) = stderr_task { let _ = t.await; }
if !status.success() {
return Err(format!("cloud submit exited with status {}", status));
}
if let Some(dir) = working_dir.as_ref() {
let diff_text_opt = if stdout_buf.starts_with("diff --git ") {
Some(stdout_buf.trim())
} else {
stdout_buf
.find("\ndiff --git ")
.map(|idx| stdout_buf[idx + 1..].trim())
};
if let Some(diff_text) = diff_text_opt {
if !diff_text.is_empty() {
let mut apply = Command::new("git");
apply.arg("apply").arg("--whitespace=nowarn");
apply.current_dir(dir);
apply.stdin(Stdio::piped());
let mut child = apply
.spawn()
.map_err(|e| format!("Failed to spawn git apply: {}", e))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(diff_text.as_bytes())
.await
.map_err(|e| format!("Failed to write diff to git apply: {}", e))?;
}
let status = child
.wait()
.await
.map_err(|e| format!("Failed to wait for git apply: {}", e))?;
if !status.success() {
return Err(format!(
"git apply exited with status {} while applying cloud diff",
status
));
}
}
}
}
// Truncate large outputs
const MAX_BYTES: usize = 500_000; // ~500 KB
if stdout_buf.len() > MAX_BYTES {
let omitted = stdout_buf.len() - MAX_BYTES;
let mut truncated = String::with_capacity(MAX_BYTES + 128);
truncated.push_str(&stdout_buf[..MAX_BYTES]);
truncated.push_str(&format!("\n… [truncated: {} bytes omitted]", omitted));
Ok(truncated)
} else {
Ok(stdout_buf)
}
}
// Tool creation functions
pub fn create_agent_tool(allowed_models: &[String]) -> OpenAiTool {
let mut properties = BTreeMap::new();
properties.insert(
"action".to_string(),
JsonSchema::String {
description: Some(
"Required: choose one of ['create','status','wait','result','cancel','list']".to_string(),
),
allowed_values: Some(
["create", "status", "wait", "result", "cancel", "list"]
.into_iter()
.map(|value| value.to_string())
.collect(),
),
},
);
let mut create_properties = BTreeMap::new();
create_properties.insert(
"name".to_string(),
JsonSchema::String {
description: Some("Display name shown in the UI (e.g., \"Plan TUI Refactor\")".to_string()),
allowed_values: None,
},
);
create_properties.insert(
"task".to_string(),
JsonSchema::String {
description: Some("Task prompt to execute".to_string()),
allowed_values: None,
},
);
create_properties.insert(
"context".to_string(),
JsonSchema::String {
description: Some("Optional background context".to_string()),
allowed_values: None,
},
);
create_properties.insert(
"models".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String {
description: None,
allowed_values: if allowed_models.is_empty() {
None
} else {
Some(allowed_models.iter().cloned().collect())
},
}),
description: Some(
"Optional array of model names (e.g., ['claude-sonnet-4.5','code-gpt-5.1-codex-max','code-gpt-5.1-codex-mini','gemini-3-pro'])".to_string(),
),
},
);
create_properties.insert(
"files".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String {
description: None,
allowed_values: None,
}),
description: Some(
"Optional array of file paths to include in context".to_string(),
),
},
);
create_properties.insert(
"output".to_string(),
JsonSchema::String {
description: Some("Optional desired output description".to_string()),
allowed_values: None,
},
);
create_properties.insert(
"write".to_string(),
JsonSchema::Boolean {
description: Some(
"Enable isolated write worktrees for each agent (default: true). Set false to keep the agent read-only.".to_string(),
),
},
);
create_properties.insert(
"read_only".to_string(),
JsonSchema::Boolean {
description: Some(
"Deprecated: inverse of `write`. Prefer setting `write` instead.".to_string(),
),
},
);
properties.insert(
"create".to_string(),
JsonSchema::Object {
properties: create_properties,
required: Some(vec!["task".to_string()]),
additional_properties: Some(false.into()),
},
);
let mut status_properties = BTreeMap::new();
status_properties.insert(
"agent_id".to_string(),
JsonSchema::String {
description: Some("Agent identifier to inspect".to_string()),
allowed_values: None,
},
);
properties.insert(
"status".to_string(),
JsonSchema::Object {
properties: status_properties,
required: Some(vec!["agent_id".to_string()]),
additional_properties: Some(false.into()),
},
);
let mut result_properties = BTreeMap::new();
result_properties.insert(
"agent_id".to_string(),
JsonSchema::String {
description: Some("Agent identifier whose result should be fetched".to_string()),
allowed_values: None,
},
);
properties.insert(
"result".to_string(),
JsonSchema::Object {
properties: result_properties,
required: Some(vec!["agent_id".to_string()]),
additional_properties: Some(false.into()),
},
);
let mut cancel_properties = BTreeMap::new();
cancel_properties.insert(
"agent_id".to_string(),
JsonSchema::String {
description: Some("Cancel a specific agent".to_string()),
allowed_values: None,
},
);
cancel_properties.insert(
"batch_id".to_string(),
JsonSchema::String {
description: Some("Cancel all agents in the batch".to_string()),
allowed_values: None,
},
);
properties.insert(
"cancel".to_string(),
JsonSchema::Object {
properties: cancel_properties,
required: Some(Vec::new()),
additional_properties: Some(false.into()),
},
);
let mut wait_properties = BTreeMap::new();
wait_properties.insert(
"agent_id".to_string(),
JsonSchema::String {
description: Some("Wait for a specific agent".to_string()),
allowed_values: None,
},
);
wait_properties.insert(
"batch_id".to_string(),
JsonSchema::String {
description: Some("Wait for any agent in the batch".to_string()),
allowed_values: None,
},
);
wait_properties.insert(
"timeout_seconds".to_string(),
JsonSchema::Number {
description: Some(
"Optional timeout before giving up (default 300, max 600)".to_string(),
),
},
);
wait_properties.insert(
"return_all".to_string(),
JsonSchema::Boolean {
description: Some(
"When waiting on a batch, return all completed agents instead of the first".to_string(),
),
},
);
properties.insert(
"wait".to_string(),
JsonSchema::Object {
properties: wait_properties,
required: Some(Vec::new()),
additional_properties: Some(false.into()),
},
);
let mut list_properties = BTreeMap::new();
list_properties.insert(
"status_filter".to_string(),
JsonSchema::String {
description: Some(
"Optional status filter (pending, running, completed, failed, cancelled)".to_string(),
),
allowed_values: None,
},
);
list_properties.insert(
"batch_id".to_string(),
JsonSchema::String {
description: Some("Limit results to a batch".to_string()),
allowed_values: None,
},
);
list_properties.insert(
"recent_only".to_string(),
JsonSchema::Boolean {
description: Some(
"When true, only include agents from the last two hours".to_string(),
),
},
);
properties.insert(
"list".to_string(),
JsonSchema::Object {
properties: list_properties,
required: Some(Vec::new()),
additional_properties: Some(false.into()),
},
);
let required = Some(vec!["action".to_string()]);
OpenAiTool::Function(ResponsesApiTool {
name: "agent".to_string(),
description: "Unified agent manager for launching, monitoring, and collecting results from asynchronous agents.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required,
additional_properties: Some(false.into()),
},
})
}
// Parameter structs for handlers
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunAgentParams {
pub task: String,
#[serde(default, deserialize_with = "deserialize_models_field")]
pub models: Vec<String>,
pub context: Option<String>,
pub output: Option<String>,
pub files: Option<Vec<String>>,
#[serde(default)]
pub write: Option<bool>,
#[serde(default)]
pub read_only: Option<bool>,
pub name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentCreateOptions {
pub task: Option<String>,
#[serde(default, deserialize_with = "deserialize_models_field")]
pub models: Vec<String>,
pub context: Option<String>,
pub output: Option<String>,
pub files: Option<Vec<String>>,
#[serde(default)]
pub write: Option<bool>,
#[serde(default)]
pub read_only: Option<bool>,
pub name: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentIdentifierOptions {
pub agent_id: Option<String>,
pub batch_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentCancelOptions {
pub agent_id: Option<String>,
pub batch_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentWaitOptions {
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 AgentListOptions {
pub status_filter: Option<String>,
pub batch_id: Option<String>,
pub recent_only: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentToolRequest {
pub action: String,
pub create: Option<AgentCreateOptions>,
pub status: Option<AgentIdentifierOptions>,
pub result: Option<AgentIdentifierOptions>,
pub cancel: Option<AgentCancelOptions>,
pub wait: Option<AgentWaitOptions>,
pub list: Option<AgentListOptions>,
}
pub(crate) fn normalize_agent_name(name: Option<String>) -> Option<String> {
let Some(name) = name.map(|value| value.trim().to_string()) else {
return None;
};
if name.is_empty() {
return None;
}
let canonicalized = canonicalize_agent_word_boundaries(&name);
let words: Vec<&str> = canonicalized.split_whitespace().collect();
if words.is_empty() {
return None;
}
Some(
words
.into_iter()
.map(format_agent_word)
.collect::<Vec<_>>()
.join(" "),
)
}
fn canonicalize_agent_word_boundaries(input: &str) -> String {
let mut tokens: Vec<String> = Vec::new();
let mut current = String::new();
let mut chars = input.chars().peekable();
let mut prev_char: Option<char> = None;
let mut uppercase_run: usize = 0;
while let Some(ch) = chars.next() {
if ch.is_whitespace() || matches!(ch, '_' | '-' | '/' | ':' | '.') {
if !current.is_empty() {
tokens.push(std::mem::take(&mut current));
}
prev_char = None;
uppercase_run = 0;
continue;
}
let next_char = chars.peek().copied();
let mut split = false;
if !current.is_empty() {
if let Some(prev) = prev_char {
if prev.is_ascii_lowercase() && ch.is_ascii_uppercase() {
split = true;
} else if prev.is_ascii_uppercase()
&& ch.is_ascii_uppercase()
&& uppercase_run > 0
&& next_char.map_or(false, |c| c.is_ascii_lowercase())
{
split = true;
}
}
}
if split {
tokens.push(std::mem::take(&mut current));
uppercase_run = 0;
}
current.push(ch);
if ch.is_ascii_uppercase() {
uppercase_run += 1;
} else {
uppercase_run = 0;
}
prev_char = Some(ch);
}
if !current.is_empty() {
tokens.push(current);
}
tokens.join(" ")
}
const AGENT_NAME_ACRONYMS: &[&str] = &[
"AI", "API", "CLI", "CPU", "DB", "GPU", "HTTP", "HTTPS", "ID", "LLM", "SDK", "SQL", "TUI", "UI", "UX",
];
fn format_agent_word(word: &str) -> String {
if word.is_empty() {
return String::new();
}
let uppercase = word.to_ascii_uppercase();
if AGENT_NAME_ACRONYMS.contains(&uppercase.as_str()) {
return uppercase;
}
let mut chars = word.chars();
let Some(first) = chars.next() else {
return String::new();
};
let mut formatted = String::new();
formatted.extend(first.to_uppercase());
formatted.push_str(&chars.flat_map(char::to_lowercase).collect::<String>());
formatted
}
fn deserialize_models_field<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum ModelsInput {
Seq(Vec<String>),
One(String),
}
let parsed = Option::<ModelsInput>::deserialize(deserializer)?;
Ok(match parsed {
Some(ModelsInput::Seq(seq)) => seq,
Some(ModelsInput::One(single)) => vec![single],
None => Vec::new(),
})
}
#[cfg(test)]
mod tests {
use super::normalize_agent_name;
#[test]
fn drops_empty_names() {
assert_eq!(normalize_agent_name(None), None);
assert_eq!(normalize_agent_name(Some(" ".into())), None);
}
#[test]
fn title_cases_and_restores_separators() {
assert_eq!(
normalize_agent_name(Some("plan_tui_refactor".into())),
Some("Plan TUI Refactor".into())
);
assert_eq!(
normalize_agent_name(Some("run-ui-tests".into())),
Some("Run UI Tests".into())
);
}
#[test]
fn handles_camel_case_and_acronyms() {
assert_eq!(
normalize_agent_name(Some("shipCloudAPI".into())),
Some("Ship Cloud API".into())
);
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckAgentStatusParams {
pub agent_id: String,
pub batch_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetAgentResultParams {
pub agent_id: String,
pub batch_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>,
}