617 lines
24 KiB
Rust
617 lines
24 KiB
Rust
//! Project configuration — parses `project.toml` for agents, components, and server settings.
|
|
use crate::slog;
|
|
use serde::Deserialize;
|
|
use std::collections::HashSet;
|
|
use std::path::Path;
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct ProjectConfig {
|
|
#[serde(default)]
|
|
pub component: Vec<ComponentConfig>,
|
|
#[serde(default)]
|
|
pub agent: Vec<AgentConfig>,
|
|
#[serde(default)]
|
|
pub watcher: WatcherConfig,
|
|
/// Project-wide default QA mode: "server", "agent", or "human".
|
|
/// Per-story `qa` front matter overrides this. Default: "server".
|
|
#[serde(default = "default_qa")]
|
|
pub default_qa: String,
|
|
/// Default model for coder-stage agents (e.g. "sonnet").
|
|
/// When set, `find_free_agent_for_stage` only considers coder agents whose
|
|
/// model matches this value, so opus agents are only used when explicitly
|
|
/// requested via story front matter `agent:` field.
|
|
#[serde(default)]
|
|
pub default_coder_model: Option<String>,
|
|
/// Maximum number of concurrent coder-stage agents.
|
|
/// When set, `auto_assign_available_work` will not start more than this many
|
|
/// coder agents at once. Stories wait in `2_current/` until a slot frees up.
|
|
#[serde(default)]
|
|
pub max_coders: Option<usize>,
|
|
/// Maximum number of retries per story per pipeline stage before marking as blocked.
|
|
/// Default: 2. Set to 0 to disable retry limits.
|
|
#[serde(default = "default_max_retries")]
|
|
pub max_retries: u32,
|
|
/// Optional base branch name (e.g. "main", "master", "develop").
|
|
/// When set, overrides the auto-detection logic (`detect_base_branch`) for all
|
|
/// worktree creation, merge operations, and agent prompt `{{base_branch}}` substitution.
|
|
/// When not set, the system falls back to `detect_base_branch` (reads current HEAD).
|
|
#[serde(default)]
|
|
pub base_branch: Option<String>,
|
|
/// Whether to send `RateLimitWarning` chat notifications.
|
|
/// Set to `false` to suppress noisy soft rate-limit warnings while still
|
|
/// receiving `RateLimitHardBlock` and `StoryBlocked` notifications.
|
|
/// Default: `true`.
|
|
#[serde(default = "default_rate_limit_notifications")]
|
|
pub rate_limit_notifications: bool,
|
|
/// Whether the web UI WebSocket consumer subscribes to the status broadcaster.
|
|
/// Set to `false` to disable status event forwarding to the web UI without
|
|
/// affecting other consumers (chat transports, agent context).
|
|
/// Default: `true`.
|
|
#[serde(default = "default_web_ui_status_consumer")]
|
|
pub web_ui_status_consumer: bool,
|
|
/// Whether the Matrix bot subscribes to the status broadcaster and forwards
|
|
/// pipeline events to its configured rooms.
|
|
/// Set to `false` to silence Matrix status notifications without affecting
|
|
/// other consumers (web UI, Slack, Discord, WhatsApp, agent context).
|
|
/// Default: `true`.
|
|
#[serde(default = "default_matrix_status_consumer")]
|
|
pub matrix_status_consumer: bool,
|
|
/// Whether the Slack bot subscribes to the status broadcaster and forwards
|
|
/// pipeline events to its configured channels.
|
|
/// Set to `false` to silence Slack status notifications without affecting
|
|
/// other consumers (web UI, Matrix, Discord, WhatsApp, agent context).
|
|
/// Default: `true`.
|
|
#[serde(default = "default_slack_status_consumer")]
|
|
pub slack_status_consumer: bool,
|
|
/// Whether the Discord bot subscribes to the status broadcaster and forwards
|
|
/// pipeline events to its configured channels.
|
|
/// Set to `false` to silence Discord status notifications without affecting
|
|
/// other consumers (web UI, Matrix, Slack, WhatsApp, agent context).
|
|
/// Default: `true`.
|
|
#[serde(default = "default_discord_status_consumer")]
|
|
pub discord_status_consumer: bool,
|
|
/// Whether the WhatsApp bot subscribes to the status broadcaster and forwards
|
|
/// pipeline events to all active senders.
|
|
/// Set to `false` to silence WhatsApp status notifications without affecting
|
|
/// other consumers (web UI, Matrix, Slack, Discord, agent context).
|
|
/// Default: `true`.
|
|
#[serde(default = "default_whatsapp_status_consumer")]
|
|
pub whatsapp_status_consumer: bool,
|
|
/// IANA timezone name (e.g. `"Europe/London"`, `"America/New_York"`).
|
|
/// When set, timer HH:MM inputs are interpreted in this timezone instead
|
|
/// of the container/host local time. Falls back to `chrono::Local` when absent.
|
|
#[serde(default)]
|
|
pub timezone: Option<String>,
|
|
/// WebSocket URL of a remote huskies node to sync CRDT state with.
|
|
/// Example: `rendezvous = "ws://server:3001/crdt-sync"`
|
|
/// When set, this node connects to the remote and exchanges CRDT ops
|
|
/// so both machines see the same pipeline state in real-time.
|
|
#[serde(default)]
|
|
pub rendezvous: Option<String>,
|
|
/// Hex-encoded Ed25519 public keys of trusted peers for WebSocket mutual auth.
|
|
/// When present, only peers whose pubkey is in this list are allowed to connect.
|
|
/// When empty or missing, all peers are rejected (closed-by-default).
|
|
#[serde(default)]
|
|
pub trusted_keys: Vec<String>,
|
|
/// When `true`, `/crdt-sync` WebSocket connections must supply a valid
|
|
/// `?token=<bearer-token>` query parameter or receive HTTP 401.
|
|
/// Defaults to `false` so trusted-network deployments keep the current
|
|
/// open behaviour.
|
|
#[serde(default)]
|
|
pub crdt_require_token: bool,
|
|
/// Static bearer tokens accepted for `/crdt-sync` connections.
|
|
/// Each entry is a raw token string; tokens expire 30 days after the
|
|
/// server starts. Only meaningful when `crdt_require_token` is `true`.
|
|
#[serde(default)]
|
|
pub crdt_tokens: Vec<String>,
|
|
/// Maximum number of supplementary mesh peer connections an agent opens.
|
|
/// The mesh discovery loop reads the CRDT `nodes` list and connects to up
|
|
/// to this many alive peers in addition to the primary rendezvous connection.
|
|
/// Default: 3. Set to 0 to disable mesh discovery entirely.
|
|
#[serde(default = "default_max_mesh_peers")]
|
|
pub max_mesh_peers: usize,
|
|
/// Base URL of the gateway this project should push status events to.
|
|
///
|
|
/// When set, a relay task is started that connects to the gateway's
|
|
/// `/gateway/events/push` WebSocket and forwards every [`StatusEvent`] from
|
|
/// the local broadcaster. Example: `gateway_url = "http://gateway:3000"`.
|
|
/// Disabled when absent. Also readable from the `HUSKIES_GATEWAY_URL`
|
|
/// environment variable.
|
|
#[serde(default)]
|
|
pub gateway_url: Option<String>,
|
|
/// Project name this instance identifies as when pushing events to the
|
|
/// gateway. Defaults to the project root directory name when not set.
|
|
/// Example: `gateway_project = "huskies"`.
|
|
#[serde(default)]
|
|
pub gateway_project: Option<String>,
|
|
}
|
|
|
|
/// Configuration for the filesystem watcher's sweep behaviour.
|
|
///
|
|
/// Controls how often the watcher checks `5_done/` for items to promote to
|
|
/// `6_archived/`, and how long items must remain in `5_done/` before promotion.
|
|
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
|
pub struct WatcherConfig {
|
|
/// How often (in seconds) to check `5_done/` for items to archive.
|
|
/// Default: 60 seconds.
|
|
#[serde(default = "default_sweep_interval_secs")]
|
|
pub sweep_interval_secs: u64,
|
|
/// How long (in seconds) an item must remain in `5_done/` before being
|
|
/// moved to `6_archived/`. Default: 14400 (4 hours).
|
|
#[serde(default = "default_done_retention_secs")]
|
|
pub done_retention_secs: u64,
|
|
}
|
|
|
|
impl Default for WatcherConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
sweep_interval_secs: default_sweep_interval_secs(),
|
|
done_retention_secs: default_done_retention_secs(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_sweep_interval_secs() -> u64 {
|
|
60
|
|
}
|
|
|
|
fn default_done_retention_secs() -> u64 {
|
|
4 * 60 * 60 // 4 hours
|
|
}
|
|
|
|
fn default_qa() -> String {
|
|
"server".to_string()
|
|
}
|
|
|
|
fn default_max_retries() -> u32 {
|
|
2
|
|
}
|
|
|
|
fn default_rate_limit_notifications() -> bool {
|
|
true
|
|
}
|
|
|
|
fn default_web_ui_status_consumer() -> bool {
|
|
true
|
|
}
|
|
|
|
fn default_matrix_status_consumer() -> bool {
|
|
true
|
|
}
|
|
|
|
fn default_slack_status_consumer() -> bool {
|
|
true
|
|
}
|
|
|
|
fn default_discord_status_consumer() -> bool {
|
|
true
|
|
}
|
|
|
|
fn default_whatsapp_status_consumer() -> bool {
|
|
true
|
|
}
|
|
|
|
fn default_max_mesh_peers() -> usize {
|
|
3
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
#[allow(dead_code)]
|
|
pub struct ComponentConfig {
|
|
pub name: String,
|
|
#[serde(default = "default_path")]
|
|
pub path: String,
|
|
#[serde(default)]
|
|
pub setup: Vec<String>,
|
|
#[serde(default)]
|
|
pub teardown: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
pub struct AgentConfig {
|
|
#[serde(default = "default_agent_name")]
|
|
pub name: String,
|
|
#[serde(default)]
|
|
pub role: String,
|
|
#[serde(default = "default_agent_command")]
|
|
pub command: String,
|
|
#[serde(default)]
|
|
pub args: Vec<String>,
|
|
#[serde(default = "default_agent_prompt")]
|
|
pub prompt: String,
|
|
#[serde(default)]
|
|
pub model: Option<String>,
|
|
#[serde(default)]
|
|
pub allowed_tools: Option<Vec<String>>,
|
|
#[serde(default)]
|
|
pub max_turns: Option<u32>,
|
|
#[serde(default)]
|
|
pub max_budget_usd: Option<f64>,
|
|
#[serde(default)]
|
|
pub system_prompt: Option<String>,
|
|
/// Pipeline stage this agent belongs to. Supported values: "coder", "qa",
|
|
/// "mergemaster", "other". When set, overrides the legacy name-based
|
|
/// detection used by `pipeline_stage()`.
|
|
#[serde(default)]
|
|
pub stage: Option<String>,
|
|
/// Inactivity timeout in seconds for the PTY read loop.
|
|
/// If no output is received within this duration, the agent process is killed
|
|
/// and marked as Failed. Default: 300 (5 minutes). Set to 0 to disable.
|
|
#[serde(default = "default_inactivity_timeout_secs")]
|
|
pub inactivity_timeout_secs: u64,
|
|
/// Agent runtime backend. Controls how the agent process is spawned and
|
|
/// how events are streamed. Default: `"claude-code"` (spawns the `claude`
|
|
/// CLI in a PTY). Future values: `"openai"`, `"gemini"`.
|
|
#[serde(default)]
|
|
pub runtime: Option<String>,
|
|
}
|
|
|
|
fn default_path() -> String {
|
|
".".to_string()
|
|
}
|
|
|
|
fn default_agent_name() -> String {
|
|
"default".to_string()
|
|
}
|
|
|
|
fn default_inactivity_timeout_secs() -> u64 {
|
|
300
|
|
}
|
|
|
|
fn default_agent_command() -> String {
|
|
"claude".to_string()
|
|
}
|
|
|
|
fn default_agent_prompt() -> String {
|
|
"You are working in a git worktree on story {{story_id}}. \
|
|
Read .huskies/README.md to understand the dev process, then pick up the story. \
|
|
Commit all your work when done — the server will automatically run acceptance \
|
|
gates (cargo clippy + tests) when your process exits."
|
|
.to_string()
|
|
}
|
|
|
|
/// Legacy config format with `agent` as an optional single table (`[agent]`).
|
|
#[derive(Debug, Deserialize)]
|
|
struct LegacyProjectConfig {
|
|
#[serde(default)]
|
|
component: Vec<ComponentConfig>,
|
|
agent: Option<AgentConfig>,
|
|
#[serde(default)]
|
|
watcher: WatcherConfig,
|
|
#[serde(default = "default_qa")]
|
|
default_qa: String,
|
|
#[serde(default)]
|
|
default_coder_model: Option<String>,
|
|
#[serde(default)]
|
|
max_coders: Option<usize>,
|
|
#[serde(default = "default_max_retries")]
|
|
max_retries: u32,
|
|
#[serde(default)]
|
|
base_branch: Option<String>,
|
|
#[serde(default = "default_rate_limit_notifications")]
|
|
rate_limit_notifications: bool,
|
|
#[serde(default)]
|
|
timezone: Option<String>,
|
|
}
|
|
|
|
impl Default for ProjectConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
component: Vec::new(),
|
|
agent: vec![AgentConfig {
|
|
name: default_agent_name(),
|
|
role: String::new(),
|
|
command: default_agent_command(),
|
|
args: vec![],
|
|
prompt: default_agent_prompt(),
|
|
model: None,
|
|
allowed_tools: None,
|
|
max_turns: None,
|
|
max_budget_usd: None,
|
|
system_prompt: None,
|
|
stage: None,
|
|
inactivity_timeout_secs: default_inactivity_timeout_secs(),
|
|
runtime: None,
|
|
}],
|
|
watcher: WatcherConfig::default(),
|
|
default_qa: default_qa(),
|
|
default_coder_model: None,
|
|
max_coders: None,
|
|
max_retries: default_max_retries(),
|
|
base_branch: None,
|
|
rate_limit_notifications: default_rate_limit_notifications(),
|
|
web_ui_status_consumer: default_web_ui_status_consumer(),
|
|
matrix_status_consumer: default_matrix_status_consumer(),
|
|
slack_status_consumer: default_slack_status_consumer(),
|
|
discord_status_consumer: default_discord_status_consumer(),
|
|
whatsapp_status_consumer: default_whatsapp_status_consumer(),
|
|
timezone: None,
|
|
rendezvous: None,
|
|
trusted_keys: Vec::new(),
|
|
crdt_require_token: false,
|
|
crdt_tokens: Vec::new(),
|
|
max_mesh_peers: default_max_mesh_peers(),
|
|
gateway_url: None,
|
|
gateway_project: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Config parsed from `.huskies/agents.toml` — agent definitions only.
|
|
#[derive(Debug, Deserialize)]
|
|
struct AgentsConfig {
|
|
#[serde(default)]
|
|
agent: Vec<AgentConfig>,
|
|
}
|
|
|
|
impl ProjectConfig {
|
|
/// Load from `.huskies/project.toml` relative to the given root,
|
|
/// then overlay agents from `.huskies/agents.toml` if present.
|
|
///
|
|
/// Loading order:
|
|
/// 1. Project settings (watcher, default_qa, etc.) always come from `project.toml`.
|
|
/// 2. Agent definitions come from `agents.toml` when that file exists.
|
|
/// 3. Falls back to inline `[[agent]]` blocks in `project.toml` for backwards
|
|
/// compatibility with projects that haven't migrated yet.
|
|
/// 4. Falls back to a single default agent when neither file defines agents.
|
|
pub fn load(project_root: &Path) -> Result<Self, String> {
|
|
let config_path = project_root.join(".huskies/project.toml");
|
|
let mut config = if config_path.exists() {
|
|
let content =
|
|
std::fs::read_to_string(&config_path).map_err(|e| format!("Read config: {e}"))?;
|
|
Self::parse(&content)?
|
|
} else {
|
|
Self::default()
|
|
};
|
|
|
|
// agents.toml takes priority over inline [[agent]] in project.toml.
|
|
let agents_path = project_root.join(".huskies/agents.toml");
|
|
if agents_path.exists() {
|
|
let content = std::fs::read_to_string(&agents_path)
|
|
.map_err(|e| format!("Read agents.toml: {e}"))?;
|
|
let agents_cfg: AgentsConfig =
|
|
toml::from_str(&content).map_err(|e| format!("Parse agents.toml: {e}"))?;
|
|
if !agents_cfg.agent.is_empty() {
|
|
validate_agents(&agents_cfg.agent)?;
|
|
config.agent = agents_cfg.agent;
|
|
}
|
|
}
|
|
|
|
Ok(config)
|
|
}
|
|
|
|
/// Parse config from a TOML string, supporting both new and legacy formats.
|
|
pub fn parse(content: &str) -> Result<Self, String> {
|
|
// Try new format first (agent as array of tables)
|
|
match toml::from_str::<ProjectConfig>(content) {
|
|
Ok(config) if !config.agent.is_empty() => {
|
|
validate_agents(&config.agent)?;
|
|
Ok(config)
|
|
}
|
|
Ok(config) => {
|
|
// Parsed successfully but no agents — could be legacy or no agent section.
|
|
// Try legacy format.
|
|
if let Ok(legacy) = toml::from_str::<LegacyProjectConfig>(content)
|
|
&& let Some(agent) = legacy.agent
|
|
{
|
|
slog!(
|
|
"[config] Warning: [agent] table is deprecated. \
|
|
Use [[agent]] array format instead."
|
|
);
|
|
let config = ProjectConfig {
|
|
component: legacy.component,
|
|
agent: vec![agent],
|
|
watcher: legacy.watcher,
|
|
default_qa: legacy.default_qa,
|
|
default_coder_model: legacy.default_coder_model,
|
|
max_coders: legacy.max_coders,
|
|
max_retries: legacy.max_retries,
|
|
base_branch: legacy.base_branch,
|
|
rate_limit_notifications: legacy.rate_limit_notifications,
|
|
web_ui_status_consumer: default_web_ui_status_consumer(),
|
|
matrix_status_consumer: default_matrix_status_consumer(),
|
|
slack_status_consumer: default_slack_status_consumer(),
|
|
discord_status_consumer: default_discord_status_consumer(),
|
|
whatsapp_status_consumer: default_whatsapp_status_consumer(),
|
|
timezone: legacy.timezone,
|
|
rendezvous: None,
|
|
trusted_keys: Vec::new(),
|
|
crdt_require_token: false,
|
|
crdt_tokens: Vec::new(),
|
|
max_mesh_peers: default_max_mesh_peers(),
|
|
gateway_url: None,
|
|
gateway_project: None,
|
|
};
|
|
validate_agents(&config.agent)?;
|
|
return Ok(config);
|
|
}
|
|
// No agent section at all
|
|
Ok(config)
|
|
}
|
|
Err(_) => {
|
|
// New format failed — try legacy
|
|
let legacy: LegacyProjectConfig =
|
|
toml::from_str(content).map_err(|e| format!("Parse config: {e}"))?;
|
|
if let Some(agent) = legacy.agent {
|
|
slog!(
|
|
"[config] Warning: [agent] table is deprecated. \
|
|
Use [[agent]] array format instead."
|
|
);
|
|
let config = ProjectConfig {
|
|
component: legacy.component,
|
|
agent: vec![agent],
|
|
watcher: legacy.watcher,
|
|
default_qa: legacy.default_qa,
|
|
default_coder_model: legacy.default_coder_model,
|
|
max_coders: legacy.max_coders,
|
|
max_retries: legacy.max_retries,
|
|
base_branch: legacy.base_branch,
|
|
rate_limit_notifications: legacy.rate_limit_notifications,
|
|
web_ui_status_consumer: default_web_ui_status_consumer(),
|
|
matrix_status_consumer: default_matrix_status_consumer(),
|
|
slack_status_consumer: default_slack_status_consumer(),
|
|
discord_status_consumer: default_discord_status_consumer(),
|
|
whatsapp_status_consumer: default_whatsapp_status_consumer(),
|
|
timezone: legacy.timezone,
|
|
rendezvous: None,
|
|
trusted_keys: Vec::new(),
|
|
crdt_require_token: false,
|
|
crdt_tokens: Vec::new(),
|
|
max_mesh_peers: default_max_mesh_peers(),
|
|
gateway_url: None,
|
|
gateway_project: None,
|
|
};
|
|
validate_agents(&config.agent)?;
|
|
Ok(config)
|
|
} else {
|
|
Ok(ProjectConfig {
|
|
component: legacy.component,
|
|
agent: Vec::new(),
|
|
watcher: legacy.watcher,
|
|
default_qa: legacy.default_qa,
|
|
default_coder_model: legacy.default_coder_model,
|
|
max_coders: legacy.max_coders,
|
|
max_retries: legacy.max_retries,
|
|
base_branch: legacy.base_branch,
|
|
rate_limit_notifications: legacy.rate_limit_notifications,
|
|
web_ui_status_consumer: default_web_ui_status_consumer(),
|
|
matrix_status_consumer: default_matrix_status_consumer(),
|
|
slack_status_consumer: default_slack_status_consumer(),
|
|
discord_status_consumer: default_discord_status_consumer(),
|
|
whatsapp_status_consumer: default_whatsapp_status_consumer(),
|
|
timezone: legacy.timezone,
|
|
rendezvous: None,
|
|
trusted_keys: Vec::new(),
|
|
crdt_require_token: false,
|
|
crdt_tokens: Vec::new(),
|
|
max_mesh_peers: default_max_mesh_peers(),
|
|
gateway_url: None,
|
|
gateway_project: None,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Return the project-wide default QA mode parsed from `default_qa`.
|
|
/// Falls back to `Server` if the value is unrecognised.
|
|
pub fn default_qa_mode(&self) -> crate::io::story_metadata::QaMode {
|
|
crate::io::story_metadata::QaMode::from_str(&self.default_qa)
|
|
.unwrap_or(crate::io::story_metadata::QaMode::Server)
|
|
}
|
|
|
|
/// Look up an agent config by name.
|
|
pub fn find_agent(&self, name: &str) -> Option<&AgentConfig> {
|
|
self.agent.iter().find(|a| a.name == name)
|
|
}
|
|
|
|
/// Get the default (first) agent config.
|
|
pub fn default_agent(&self) -> Option<&AgentConfig> {
|
|
self.agent.first()
|
|
}
|
|
|
|
/// Render template variables in agent args and prompt for the given agent.
|
|
/// If `agent_name` is None, uses the first (default) agent.
|
|
pub fn render_agent_args(
|
|
&self,
|
|
worktree_path: &str,
|
|
story_id: &str,
|
|
agent_name: Option<&str>,
|
|
base_branch: Option<&str>,
|
|
) -> Result<(String, Vec<String>, String), String> {
|
|
let agent = match agent_name {
|
|
Some(name) => self
|
|
.find_agent(name)
|
|
.ok_or_else(|| format!("No agent named '{name}' in config"))?,
|
|
None => self
|
|
.default_agent()
|
|
.ok_or_else(|| "No agents configured".to_string())?,
|
|
};
|
|
|
|
let bb = base_branch
|
|
.or(self.base_branch.as_deref())
|
|
.unwrap_or("master");
|
|
let aname = agent.name.as_str();
|
|
let render = |s: &str| {
|
|
s.replace("{{worktree_path}}", worktree_path)
|
|
.replace("{{story_id}}", story_id)
|
|
.replace("{{base_branch}}", bb)
|
|
.replace("{{agent_name}}", aname)
|
|
};
|
|
|
|
let command = render(&agent.command);
|
|
let mut args: Vec<String> = agent.args.iter().map(|a| render(a)).collect();
|
|
let prompt = render(&agent.prompt);
|
|
|
|
// Append structured CLI flags
|
|
if let Some(ref model) = agent.model {
|
|
args.push("--model".to_string());
|
|
args.push(model.clone());
|
|
}
|
|
if let Some(ref tools) = agent.allowed_tools
|
|
&& !tools.is_empty()
|
|
{
|
|
args.push("--allowedTools".to_string());
|
|
args.push(tools.join(","));
|
|
}
|
|
if let Some(turns) = agent.max_turns {
|
|
args.push("--max-turns".to_string());
|
|
args.push(turns.to_string());
|
|
}
|
|
if let Some(budget) = agent.max_budget_usd {
|
|
args.push("--max-budget-usd".to_string());
|
|
args.push(budget.to_string());
|
|
}
|
|
if let Some(ref sp) = agent.system_prompt {
|
|
args.push("--append-system-prompt".to_string());
|
|
args.push(render(sp));
|
|
}
|
|
|
|
Ok((command, args, prompt))
|
|
}
|
|
}
|
|
|
|
/// Validate agent configs: no duplicate names, no empty names, positive budgets/turns.
|
|
fn validate_agents(agents: &[AgentConfig]) -> Result<(), String> {
|
|
let mut names = HashSet::new();
|
|
for agent in agents {
|
|
if agent.name.trim().is_empty() {
|
|
return Err("Agent name must not be empty".to_string());
|
|
}
|
|
if !names.insert(&agent.name) {
|
|
return Err(format!("Duplicate agent name: '{}'", agent.name));
|
|
}
|
|
if let Some(budget) = agent.max_budget_usd
|
|
&& budget <= 0.0
|
|
{
|
|
return Err(format!(
|
|
"Agent '{}': max_budget_usd must be positive, got {budget}",
|
|
agent.name
|
|
));
|
|
}
|
|
if let Some(turns) = agent.max_turns
|
|
&& turns == 0
|
|
{
|
|
return Err(format!(
|
|
"Agent '{}': max_turns must be positive, got 0",
|
|
agent.name
|
|
));
|
|
}
|
|
if let Some(ref runtime) = agent.runtime {
|
|
match runtime.as_str() {
|
|
"claude-code" | "gemini" => {}
|
|
other => {
|
|
return Err(format!(
|
|
"Agent '{}': unknown runtime '{other}'. Supported: 'claude-code', 'gemini'",
|
|
agent.name
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|