Files
storkit/server/src/config.rs
Dave 6d57b06636 Accept story 34: Per-Project Agent Configuration and Role Definitions
Replace single [agent] config with multi-agent [[agent]] roster system.
Each agent has name, role, model, allowed_tools, max_turns, max_budget_usd,
and system_prompt fields that map to Claude CLI flags at spawn time.

- AgentConfig expanded with structured fields, validated at startup (panics
  on duplicate names, empty names, non-positive budgets/turns)
- Backwards-compatible: legacy [agent] format auto-wraps with deprecation warning
- AgentPool uses composite "story_id:agent_name" keys for concurrent agents
- agent_name added to AgentEvent variants, AgentInfo, start/stop/subscribe APIs
- GET /agents/config returns roster, POST /agents/config/reload hot-reloads
- POST /agents/start accepts optional agent_name, /agents/stop requires it
- SSE route updated to /agents/:story_id/:agent_name/stream
- Frontend: roster badges, agent selector dropdown, composite-key state
- Project root initialized to cwd at startup so config endpoints work immediately

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:46:14 +00:00

494 lines
15 KiB
Rust

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>,
}
#[derive(Debug, Clone, Deserialize)]
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>,
}
fn default_path() -> String {
".".to_string()
}
fn default_agent_name() -> String {
"default".to_string()
}
fn default_agent_command() -> String {
"claude".to_string()
}
fn default_agent_prompt() -> String {
"Read .story_kit/README.md, then pick up story {{story_id}}".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>,
}
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,
}],
}
}
}
impl ProjectConfig {
/// Load from `.story_kit/project.toml` relative to the given root.
/// Falls back to sensible defaults if the file doesn't exist.
///
/// Supports both the new `[[agent]]` array format and the legacy
/// `[agent]` single-table format (with a deprecation warning).
pub fn load(project_root: &Path) -> Result<Self, String> {
let config_path = project_root.join(".story_kit/project.toml");
if !config_path.exists() {
return Ok(Self::default());
}
let content =
std::fs::read_to_string(&config_path).map_err(|e| format!("Read config: {e}"))?;
Self::parse(&content)
}
/// 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 {
eprintln!(
"[config] Warning: [agent] table is deprecated. \
Use [[agent]] array format instead."
);
let config = ProjectConfig {
component: legacy.component,
agent: vec![agent],
};
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 {
eprintln!(
"[config] Warning: [agent] table is deprecated. \
Use [[agent]] array format instead."
);
let config = ProjectConfig {
component: legacy.component,
agent: vec![agent],
};
validate_agents(&config.agent)?;
Ok(config)
} else {
Ok(ProjectConfig {
component: legacy.component,
agent: Vec::new(),
})
}
}
}
}
/// 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>,
) -> 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 render = |s: &str| {
s.replace("{{worktree_path}}", worktree_path)
.replace("{{story_id}}", story_id)
};
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
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn default_config_when_missing() {
let tmp = tempfile::tempdir().unwrap();
let config = ProjectConfig::load(tmp.path()).unwrap();
assert_eq!(config.agent.len(), 1);
assert_eq!(config.agent[0].name, "default");
assert!(config.component.is_empty());
}
#[test]
fn parse_multi_agent_toml() {
let toml_str = r#"
[[component]]
name = "server"
path = "."
setup = ["cargo check"]
[[agent]]
name = "supervisor"
role = "Coordinates work"
model = "opus"
max_turns = 50
max_budget_usd = 10.00
system_prompt = "You are a senior engineer"
[[agent]]
name = "coder-1"
role = "Full-stack engineer"
model = "sonnet"
max_turns = 30
max_budget_usd = 5.00
"#;
let config = ProjectConfig::parse(toml_str).unwrap();
assert_eq!(config.agent.len(), 2);
assert_eq!(config.agent[0].name, "supervisor");
assert_eq!(config.agent[0].role, "Coordinates work");
assert_eq!(config.agent[0].model, Some("opus".to_string()));
assert_eq!(config.agent[0].max_turns, Some(50));
assert_eq!(config.agent[0].max_budget_usd, Some(10.0));
assert_eq!(
config.agent[0].system_prompt,
Some("You are a senior engineer".to_string())
);
assert_eq!(config.agent[1].name, "coder-1");
assert_eq!(config.agent[1].model, Some("sonnet".to_string()));
assert_eq!(config.component.len(), 1);
}
#[test]
fn parse_legacy_single_agent() {
let toml_str = r#"
[[component]]
name = "server"
path = "."
[agent]
command = "claude"
args = ["--print", "--directory", "{{worktree_path}}"]
prompt = "Pick up story {{story_id}}"
"#;
let config = ProjectConfig::parse(toml_str).unwrap();
assert_eq!(config.agent.len(), 1);
assert_eq!(config.agent[0].name, "default");
assert_eq!(config.agent[0].command, "claude");
}
#[test]
fn validate_duplicate_names() {
let toml_str = r#"
[[agent]]
name = "coder"
role = "Engineer"
[[agent]]
name = "coder"
role = "Another engineer"
"#;
let err = ProjectConfig::parse(toml_str).unwrap_err();
assert!(err.contains("Duplicate agent name: 'coder'"));
}
#[test]
fn validate_empty_name() {
let toml_str = r#"
[[agent]]
name = ""
role = "Engineer"
"#;
let err = ProjectConfig::parse(toml_str).unwrap_err();
assert!(err.contains("Agent name must not be empty"));
}
#[test]
fn validate_non_positive_budget() {
let toml_str = r#"
[[agent]]
name = "coder"
max_budget_usd = -1.0
"#;
let err = ProjectConfig::parse(toml_str).unwrap_err();
assert!(err.contains("must be positive"));
}
#[test]
fn validate_zero_max_turns() {
let toml_str = r#"
[[agent]]
name = "coder"
max_turns = 0
"#;
let err = ProjectConfig::parse(toml_str).unwrap_err();
assert!(err.contains("max_turns must be positive"));
}
#[test]
fn render_agent_args_default() {
let config = ProjectConfig::default();
let (cmd, args, prompt) = config
.render_agent_args("/tmp/wt", "42_foo", None)
.unwrap();
assert_eq!(cmd, "claude");
assert!(args.is_empty());
assert!(prompt.contains("42_foo"));
}
#[test]
fn render_agent_args_by_name() {
let toml_str = r#"
[[agent]]
name = "supervisor"
model = "opus"
max_turns = 50
max_budget_usd = 10.00
system_prompt = "You lead story {{story_id}}"
allowed_tools = ["Read", "Write", "Bash"]
[[agent]]
name = "coder"
model = "sonnet"
max_turns = 30
"#;
let config = ProjectConfig::parse(toml_str).unwrap();
let (cmd, args, prompt) = config
.render_agent_args("/tmp/wt", "42_foo", Some("supervisor"))
.unwrap();
assert_eq!(cmd, "claude");
assert!(args.contains(&"--model".to_string()));
assert!(args.contains(&"opus".to_string()));
assert!(args.contains(&"--max-turns".to_string()));
assert!(args.contains(&"50".to_string()));
assert!(args.contains(&"--max-budget-usd".to_string()));
assert!(args.contains(&"10".to_string()));
assert!(args.contains(&"--allowedTools".to_string()));
assert!(args.contains(&"Read,Write,Bash".to_string()));
assert!(args.contains(&"--append-system-prompt".to_string()));
// System prompt should have template rendered
assert!(args.contains(&"You lead story 42_foo".to_string()));
assert!(prompt.contains("42_foo"));
// Render for coder
let (_, coder_args, _) = config
.render_agent_args("/tmp/wt", "42_foo", Some("coder"))
.unwrap();
assert!(coder_args.contains(&"sonnet".to_string()));
assert!(coder_args.contains(&"30".to_string()));
assert!(!coder_args.contains(&"--max-budget-usd".to_string()));
assert!(!coder_args.contains(&"--append-system-prompt".to_string()));
}
#[test]
fn render_agent_args_not_found() {
let config = ProjectConfig::default();
let result = config.render_agent_args("/tmp/wt", "42_foo", Some("nonexistent"));
assert!(result.is_err());
assert!(result.unwrap_err().contains("No agent named 'nonexistent'"));
}
#[test]
fn find_agent_and_default() {
let toml_str = r#"
[[agent]]
name = "first"
[[agent]]
name = "second"
"#;
let config = ProjectConfig::parse(toml_str).unwrap();
assert_eq!(config.default_agent().unwrap().name, "first");
assert_eq!(config.find_agent("second").unwrap().name, "second");
assert!(config.find_agent("missing").is_none());
}
#[test]
fn parse_project_toml_from_file() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".story_kit");
fs::create_dir_all(&sk).unwrap();
fs::write(
sk.join("project.toml"),
r#"
[[component]]
name = "server"
path = "."
setup = ["cargo check"]
teardown = []
[[component]]
name = "frontend"
path = "frontend"
setup = ["pnpm install"]
[[agent]]
name = "main"
command = "claude"
args = ["--print", "--directory", "{{worktree_path}}"]
prompt = "Pick up story {{story_id}}"
model = "sonnet"
"#,
)
.unwrap();
let config = ProjectConfig::load(tmp.path()).unwrap();
assert_eq!(config.component.len(), 2);
assert_eq!(config.component[0].name, "server");
assert_eq!(config.component[1].setup, vec!["pnpm install"]);
assert_eq!(config.agent.len(), 1);
assert_eq!(config.agent[0].name, "main");
assert_eq!(config.agent[0].model, Some("sonnet".to_string()));
}
}