feat(agents): validate new configs with smoke test

This commit is contained in:
James Peter
2025-11-12 22:09:46 +10:00
parent e53995b9ff
commit a7355ba93b
3 changed files with 129 additions and 31 deletions
+58
View File
@@ -11,9 +11,11 @@ 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::{timeout, Duration as TokioDuration};
use crate::agent_defaults::{agent_model_spec, default_params_for};
use crate::config_types::AgentConfig;
@@ -1011,6 +1013,62 @@ fn strip_model_flags(args: &mut Vec<String>) {
}
}
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);
async fn run_agent_smoke_test(cfg: AgentConfig) -> Result<String, String> {
let model_name = cfg.name.clone();
let response = timeout(
AGENT_SMOKE_TEST_TIMEOUT,
execute_model_with_permissions(&model_name, AGENT_SMOKE_TEST_PROMPT, true, None, Some(cfg)),
)
.await
.map_err(|_| {
format!(
"agent validation timed out after {}s",
AGENT_SMOKE_TEST_TIMEOUT.as_secs()
)
})??;
Ok(response)
}
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 {
format!("{}…", &trimmed[..MAX_LEN])
}
}
pub async fn smoke_test_agent(cfg: AgentConfig) -> Result<(), String> {
let output = run_agent_smoke_test(cfg).await?;
if output
.to_ascii_lowercase()
.contains(AGENT_SMOKE_TEST_EXPECTED)
{
Ok(())
} else {
Err(format!(
"agent response missing \"ok\": {}",
summarize_agent_output(&output)
))
}
}
pub fn smoke_test_agent_blocking(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))
}
/// 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.
+1
View File
@@ -115,6 +115,7 @@ pub mod util;
pub use apply_patch::CODEX_APPLY_PATCH_ARG1;
pub use command_safety::is_safe_command;
pub use agent_tool::smoke_test_agent_blocking;
pub use safety::get_platform_sandbox;
pub use housekeeping::run_housekeeping_if_due;
pub use housekeeping::CleanupOutcome;
+70 -31
View File
@@ -31,6 +31,7 @@ use code_common::model_presets::ModelPreset;
use code_common::model_presets::builtin_model_presets;
use code_core::ConversationManager;
use code_core::agent_defaults::{agent_model_spec, enabled_agent_model_specs};
use code_core::smoke_test_agent_blocking;
use code_core::config::Config;
use code_core::git_info::CommitLogEntry;
use code_core::config_types::AgentConfig;
@@ -17519,42 +17520,58 @@ Have we met every part of this goal and is there no further work to do?"#
description: Option<String>,
command: String,
) {
let mut updated_existing = false;
let provided_command = if command.trim().is_empty() { None } else { Some(command.as_str()) };
let mut command_to_persist: Option<String> = None;
if let Some(slot) = self
let existing_index = self
.config
.agents
.iter_mut()
.find(|a| a.name.eq_ignore_ascii_case(name))
{
slot.enabled = enabled;
slot.args_read_only = args_ro.clone();
slot.args_write = args_wr.clone();
slot.instructions = instr.clone();
slot.description = description.clone();
let resolved = Self::resolve_agent_command(name, provided_command, Some(slot.command.as_str()));
slot.command = resolved.clone();
command_to_persist = Some(resolved);
updated_existing = true;
.iter()
.position(|a| a.name.eq_ignore_ascii_case(name));
let existing_command = existing_index
.and_then(|idx| self.config.agents.get(idx))
.map(|cfg| cfg.command.clone());
let resolved = Self::resolve_agent_command(
name,
provided_command,
existing_command.as_deref(),
);
let env_clone = existing_index
.and_then(|idx| self.config.agents.get(idx))
.and_then(|cfg| cfg.env.clone());
let candidate_cfg = AgentConfig {
name: name.to_string(),
command: resolved.clone(),
args: Vec::new(),
read_only: false,
enabled,
description: description.clone(),
env: env_clone,
args_read_only: args_ro.clone(),
args_write: args_wr.clone(),
instructions: instr.clone(),
};
let requires_validation = !self.test_mode && existing_index.is_none();
if requires_validation && !self.validate_agent_candidate(&candidate_cfg) {
return;
}
if !updated_existing {
let resolved = Self::resolve_agent_command(name, provided_command, None);
let new_cfg = AgentConfig {
name: name.to_string(),
command: resolved.clone(),
args: Vec::new(),
read_only: false,
enabled,
description: description.clone(),
env: None,
args_read_only: args_ro.clone(),
args_write: args_wr.clone(),
instructions: instr.clone(),
};
self.config.agents.push(new_cfg);
command_to_persist = Some(resolved);
let mut command_to_persist: Option<String> = None;
if let Some(idx) = existing_index {
if let Some(slot) = self.config.agents.get_mut(idx) {
slot.enabled = enabled;
slot.args_read_only = args_ro.clone();
slot.args_write = args_wr.clone();
slot.instructions = instr.clone();
slot.description = description.clone();
slot.command = resolved.clone();
command_to_persist = Some(resolved.clone());
}
} else {
self.config.agents.push(candidate_cfg);
command_to_persist = Some(resolved.clone());
}
// Persist asynchronously
if let Ok(home) = code_core::config::find_code_home() {
@@ -17580,6 +17597,28 @@ Have we met every part of this goal and is there no further work to do?"#
self.refresh_settings_overview_rows();
}
fn validate_agent_candidate(&mut self, cfg: &AgentConfig) -> bool {
let start_message = format!("🧪 Testing agent `{}` (expecting \"ok\")…", cfg.name);
self.push_background_tail(start_message);
match smoke_test_agent_blocking(cfg.clone()) {
Ok(()) => {
self.push_background_tail(format!(
"✅ Agent `{}` responded with \"ok\".",
cfg.name
));
true
}
Err(err) => {
self.history_push_plain_state(history_cell::new_error_event(format!(
"❌ Agent `{}` validation failed: {}",
cfg.name, err
)));
self.request_redraw();
false
}
}
}
fn resolve_agent_command(
name: &str,
provided: Option<&str>,