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>
This commit is contained in:
@@ -8,23 +8,45 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
/// Build the composite key used to track agents in the pool.
|
||||
fn composite_key(story_id: &str, agent_name: &str) -> String {
|
||||
format!("{story_id}:{agent_name}")
|
||||
}
|
||||
|
||||
/// Events streamed from a running agent to SSE clients.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum AgentEvent {
|
||||
/// Agent status changed.
|
||||
Status { story_id: String, status: String },
|
||||
Status {
|
||||
story_id: String,
|
||||
agent_name: String,
|
||||
status: String,
|
||||
},
|
||||
/// Raw text output from the agent process.
|
||||
Output { story_id: String, text: String },
|
||||
Output {
|
||||
story_id: String,
|
||||
agent_name: String,
|
||||
text: String,
|
||||
},
|
||||
/// Agent produced a JSON event from `--output-format stream-json`.
|
||||
AgentJson { story_id: String, data: serde_json::Value },
|
||||
AgentJson {
|
||||
story_id: String,
|
||||
agent_name: String,
|
||||
data: serde_json::Value,
|
||||
},
|
||||
/// Agent finished.
|
||||
Done {
|
||||
story_id: String,
|
||||
agent_name: String,
|
||||
session_id: Option<String>,
|
||||
},
|
||||
/// Agent errored.
|
||||
Error { story_id: String, message: String },
|
||||
Error {
|
||||
story_id: String,
|
||||
agent_name: String,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, PartialEq)]
|
||||
@@ -50,12 +72,14 @@ impl std::fmt::Display for AgentStatus {
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct AgentInfo {
|
||||
pub story_id: String,
|
||||
pub agent_name: String,
|
||||
pub status: AgentStatus,
|
||||
pub session_id: Option<String>,
|
||||
pub worktree_path: Option<String>,
|
||||
}
|
||||
|
||||
struct StoryAgent {
|
||||
agent_name: String,
|
||||
status: AgentStatus,
|
||||
worktree_info: Option<WorktreeInfo>,
|
||||
config: ProjectConfig,
|
||||
@@ -77,32 +101,54 @@ impl AgentPool {
|
||||
}
|
||||
|
||||
/// Start an agent for a story: load config, create worktree, spawn agent.
|
||||
/// If `agent_name` is None, defaults to the first configured agent.
|
||||
pub async fn start_agent(
|
||||
&self,
|
||||
project_root: &Path,
|
||||
story_id: &str,
|
||||
agent_name: Option<&str>,
|
||||
) -> Result<AgentInfo, String> {
|
||||
let config = ProjectConfig::load(project_root)?;
|
||||
|
||||
// Resolve agent name from config
|
||||
let resolved_name = match agent_name {
|
||||
Some(name) => {
|
||||
config
|
||||
.find_agent(name)
|
||||
.ok_or_else(|| format!("No agent named '{name}' in config"))?;
|
||||
name.to_string()
|
||||
}
|
||||
None => config
|
||||
.default_agent()
|
||||
.ok_or_else(|| "No agents configured".to_string())?
|
||||
.name
|
||||
.clone(),
|
||||
};
|
||||
|
||||
let key = composite_key(story_id, &resolved_name);
|
||||
|
||||
// Check not already running
|
||||
{
|
||||
let agents = self.agents.lock().map_err(|e| e.to_string())?;
|
||||
if let Some(agent) = agents.get(story_id)
|
||||
&& (agent.status == AgentStatus::Running || agent.status == AgentStatus::Pending) {
|
||||
return Err(format!(
|
||||
"Agent for story '{story_id}' is already {}",
|
||||
agent.status
|
||||
));
|
||||
}
|
||||
if let Some(agent) = agents.get(&key)
|
||||
&& (agent.status == AgentStatus::Running || agent.status == AgentStatus::Pending)
|
||||
{
|
||||
return Err(format!(
|
||||
"Agent '{resolved_name}' for story '{story_id}' is already {}",
|
||||
agent.status
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let config = ProjectConfig::load(project_root)?;
|
||||
let (tx, _) = broadcast::channel::<AgentEvent>(256);
|
||||
|
||||
// Register as pending
|
||||
{
|
||||
let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
|
||||
agents.insert(
|
||||
story_id.to_string(),
|
||||
key.clone(),
|
||||
StoryAgent {
|
||||
agent_name: resolved_name.clone(),
|
||||
status: AgentStatus::Pending,
|
||||
worktree_info: None,
|
||||
config: config.clone(),
|
||||
@@ -115,6 +161,7 @@ impl AgentPool {
|
||||
|
||||
let _ = tx.send(AgentEvent::Status {
|
||||
story_id: story_id.to_string(),
|
||||
agent_name: resolved_name.clone(),
|
||||
status: "pending".to_string(),
|
||||
});
|
||||
|
||||
@@ -124,51 +171,55 @@ impl AgentPool {
|
||||
// Update with worktree info
|
||||
{
|
||||
let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
|
||||
if let Some(agent) = agents.get_mut(story_id) {
|
||||
if let Some(agent) = agents.get_mut(&key) {
|
||||
agent.worktree_info = Some(wt_info.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn the agent process
|
||||
let wt_path_str = wt_info.path.to_string_lossy().to_string();
|
||||
let rendered = config.render_agent_args(&wt_path_str, story_id);
|
||||
|
||||
let (command, args, prompt) = rendered.ok_or_else(|| {
|
||||
"No [agent] section in config — cannot spawn agent".to_string()
|
||||
})?;
|
||||
let (command, args, prompt) =
|
||||
config.render_agent_args(&wt_path_str, story_id, Some(&resolved_name))?;
|
||||
|
||||
let sid = story_id.to_string();
|
||||
let aname = resolved_name.clone();
|
||||
let tx_clone = tx.clone();
|
||||
let agents_ref = self.agents.clone();
|
||||
let cwd = wt_path_str.clone();
|
||||
let key_clone = key.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let _ = tx_clone.send(AgentEvent::Status {
|
||||
story_id: sid.clone(),
|
||||
agent_name: aname.clone(),
|
||||
status: "running".to_string(),
|
||||
});
|
||||
|
||||
match run_agent_pty_streaming(&sid, &command, &args, &prompt, &cwd, &tx_clone).await {
|
||||
match run_agent_pty_streaming(&sid, &aname, &command, &args, &prompt, &cwd, &tx_clone)
|
||||
.await
|
||||
{
|
||||
Ok(session_id) => {
|
||||
// Mark completed in the pool
|
||||
if let Ok(mut agents) = agents_ref.lock()
|
||||
&& let Some(agent) = agents.get_mut(&sid) {
|
||||
agent.status = AgentStatus::Completed;
|
||||
agent.session_id = session_id.clone();
|
||||
}
|
||||
&& let Some(agent) = agents.get_mut(&key_clone)
|
||||
{
|
||||
agent.status = AgentStatus::Completed;
|
||||
agent.session_id = session_id.clone();
|
||||
}
|
||||
let _ = tx_clone.send(AgentEvent::Done {
|
||||
story_id: sid.clone(),
|
||||
agent_name: aname.clone(),
|
||||
session_id,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
// Mark failed in the pool
|
||||
if let Ok(mut agents) = agents_ref.lock()
|
||||
&& let Some(agent) = agents.get_mut(&sid) {
|
||||
agent.status = AgentStatus::Failed;
|
||||
}
|
||||
&& let Some(agent) = agents.get_mut(&key_clone)
|
||||
{
|
||||
agent.status = AgentStatus::Failed;
|
||||
}
|
||||
let _ = tx_clone.send(AgentEvent::Error {
|
||||
story_id: sid.clone(),
|
||||
agent_name: aname.clone(),
|
||||
message: e,
|
||||
});
|
||||
}
|
||||
@@ -178,7 +229,7 @@ impl AgentPool {
|
||||
// Update status to running with task handle
|
||||
{
|
||||
let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
|
||||
if let Some(agent) = agents.get_mut(story_id) {
|
||||
if let Some(agent) = agents.get_mut(&key) {
|
||||
agent.status = AgentStatus::Running;
|
||||
agent.task_handle = Some(handle);
|
||||
}
|
||||
@@ -186,6 +237,7 @@ impl AgentPool {
|
||||
|
||||
Ok(AgentInfo {
|
||||
story_id: story_id.to_string(),
|
||||
agent_name: resolved_name,
|
||||
status: AgentStatus::Running,
|
||||
session_id: None,
|
||||
worktree_path: Some(wt_path_str),
|
||||
@@ -193,12 +245,19 @@ impl AgentPool {
|
||||
}
|
||||
|
||||
/// Stop a running agent and clean up its worktree.
|
||||
pub async fn stop_agent(&self, project_root: &Path, story_id: &str) -> Result<(), String> {
|
||||
pub async fn stop_agent(
|
||||
&self,
|
||||
project_root: &Path,
|
||||
story_id: &str,
|
||||
agent_name: &str,
|
||||
) -> Result<(), String> {
|
||||
let key = composite_key(story_id, agent_name);
|
||||
|
||||
let (worktree_info, config, task_handle, tx) = {
|
||||
let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
|
||||
let agent = agents
|
||||
.get_mut(story_id)
|
||||
.ok_or_else(|| format!("No agent for story '{story_id}'"))?;
|
||||
.get_mut(&key)
|
||||
.ok_or_else(|| format!("No agent '{agent_name}' for story '{story_id}'"))?;
|
||||
|
||||
let wt = agent.worktree_info.clone();
|
||||
let cfg = agent.config.clone();
|
||||
@@ -216,19 +275,21 @@ impl AgentPool {
|
||||
|
||||
// Remove worktree
|
||||
if let Some(ref wt) = worktree_info
|
||||
&& let Err(e) = worktree::remove_worktree(project_root, wt, &config).await {
|
||||
eprintln!("[agents] Worktree cleanup warning for {story_id}: {e}");
|
||||
}
|
||||
&& let Err(e) = worktree::remove_worktree(project_root, wt, &config).await
|
||||
{
|
||||
eprintln!("[agents] Worktree cleanup warning for {story_id}:{agent_name}: {e}");
|
||||
}
|
||||
|
||||
let _ = tx.send(AgentEvent::Status {
|
||||
story_id: story_id.to_string(),
|
||||
agent_name: agent_name.to_string(),
|
||||
status: "stopped".to_string(),
|
||||
});
|
||||
|
||||
// Remove from map
|
||||
{
|
||||
let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
|
||||
agents.remove(story_id);
|
||||
agents.remove(&key);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -239,24 +300,37 @@ impl AgentPool {
|
||||
let agents = self.agents.lock().map_err(|e| e.to_string())?;
|
||||
Ok(agents
|
||||
.iter()
|
||||
.map(|(story_id, agent)| AgentInfo {
|
||||
story_id: story_id.clone(),
|
||||
status: agent.status.clone(),
|
||||
session_id: agent.session_id.clone(),
|
||||
worktree_path: agent
|
||||
.worktree_info
|
||||
.as_ref()
|
||||
.map(|wt| wt.path.to_string_lossy().to_string()),
|
||||
.map(|(key, agent)| {
|
||||
// Extract story_id from composite key "story_id:agent_name"
|
||||
let story_id = key
|
||||
.rsplit_once(':')
|
||||
.map(|(sid, _)| sid.to_string())
|
||||
.unwrap_or_else(|| key.clone());
|
||||
AgentInfo {
|
||||
story_id,
|
||||
agent_name: agent.agent_name.clone(),
|
||||
status: agent.status.clone(),
|
||||
session_id: agent.session_id.clone(),
|
||||
worktree_path: agent
|
||||
.worktree_info
|
||||
.as_ref()
|
||||
.map(|wt| wt.path.to_string_lossy().to_string()),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Subscribe to events for a story agent.
|
||||
pub fn subscribe(&self, story_id: &str) -> Result<broadcast::Receiver<AgentEvent>, String> {
|
||||
pub fn subscribe(
|
||||
&self,
|
||||
story_id: &str,
|
||||
agent_name: &str,
|
||||
) -> Result<broadcast::Receiver<AgentEvent>, String> {
|
||||
let key = composite_key(story_id, agent_name);
|
||||
let agents = self.agents.lock().map_err(|e| e.to_string())?;
|
||||
let agent = agents
|
||||
.get(story_id)
|
||||
.ok_or_else(|| format!("No agent for story '{story_id}'"))?;
|
||||
.get(&key)
|
||||
.ok_or_else(|| format!("No agent '{agent_name}' for story '{story_id}'"))?;
|
||||
Ok(agent.tx.subscribe())
|
||||
}
|
||||
|
||||
@@ -272,6 +346,7 @@ impl AgentPool {
|
||||
/// Spawn claude agent in a PTY and stream events through the broadcast channel.
|
||||
async fn run_agent_pty_streaming(
|
||||
story_id: &str,
|
||||
agent_name: &str,
|
||||
command: &str,
|
||||
args: &[String],
|
||||
prompt: &str,
|
||||
@@ -279,6 +354,7 @@ async fn run_agent_pty_streaming(
|
||||
tx: &broadcast::Sender<AgentEvent>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let sid = story_id.to_string();
|
||||
let aname = agent_name.to_string();
|
||||
let cmd = command.to_string();
|
||||
let args = args.to_vec();
|
||||
let prompt = prompt.to_string();
|
||||
@@ -286,7 +362,7 @@ async fn run_agent_pty_streaming(
|
||||
let tx = tx.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
run_agent_pty_blocking(&sid, &cmd, &args, &prompt, &cwd, &tx)
|
||||
run_agent_pty_blocking(&sid, &aname, &cmd, &args, &prompt, &cwd, &tx)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Agent task panicked: {e}"))?
|
||||
@@ -294,6 +370,7 @@ async fn run_agent_pty_streaming(
|
||||
|
||||
fn run_agent_pty_blocking(
|
||||
story_id: &str,
|
||||
agent_name: &str,
|
||||
command: &str,
|
||||
args: &[String],
|
||||
prompt: &str,
|
||||
@@ -317,7 +394,7 @@ fn run_agent_pty_blocking(
|
||||
cmd.arg("-p");
|
||||
cmd.arg(prompt);
|
||||
|
||||
// Add configured args (e.g., --directory /path/to/worktree)
|
||||
// Add configured args (e.g., --directory /path/to/worktree, --model, etc.)
|
||||
for arg in args {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
@@ -333,12 +410,12 @@ fn run_agent_pty_blocking(
|
||||
cmd.cwd(cwd);
|
||||
cmd.env("NO_COLOR", "1");
|
||||
|
||||
eprintln!("[agent:{story_id}] Spawning {command} in {cwd} with args: {args:?}");
|
||||
eprintln!("[agent:{story_id}:{agent_name}] Spawning {command} in {cwd} with args: {args:?}");
|
||||
|
||||
let mut child = pair
|
||||
.slave
|
||||
.spawn_command(cmd)
|
||||
.map_err(|e| format!("Failed to spawn agent for {story_id}: {e}"))?;
|
||||
.map_err(|e| format!("Failed to spawn agent for {story_id}:{agent_name}: {e}"))?;
|
||||
|
||||
drop(pair.slave);
|
||||
|
||||
@@ -370,6 +447,7 @@ fn run_agent_pty_blocking(
|
||||
// Non-JSON output (terminal escapes etc.) — send as raw output
|
||||
let _ = tx.send(AgentEvent::Output {
|
||||
story_id: story_id.to_string(),
|
||||
agent_name: agent_name.to_string(),
|
||||
text: trimmed.to_string(),
|
||||
});
|
||||
continue;
|
||||
@@ -387,16 +465,18 @@ fn run_agent_pty_blocking(
|
||||
}
|
||||
"assistant" => {
|
||||
if let Some(message) = json.get("message")
|
||||
&& let Some(content) = message.get("content").and_then(|c| c.as_array()) {
|
||||
for block in content {
|
||||
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
|
||||
let _ = tx.send(AgentEvent::Output {
|
||||
story_id: story_id.to_string(),
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
&& let Some(content) = message.get("content").and_then(|c| c.as_array())
|
||||
{
|
||||
for block in content {
|
||||
if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
|
||||
let _ = tx.send(AgentEvent::Output {
|
||||
story_id: story_id.to_string(),
|
||||
agent_name: agent_name.to_string(),
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -404,6 +484,7 @@ fn run_agent_pty_blocking(
|
||||
// Forward all JSON events
|
||||
let _ = tx.send(AgentEvent::AgentJson {
|
||||
story_id: story_id.to_string(),
|
||||
agent_name: agent_name.to_string(),
|
||||
data: json,
|
||||
});
|
||||
}
|
||||
@@ -411,7 +492,7 @@ fn run_agent_pty_blocking(
|
||||
let _ = child.kill();
|
||||
|
||||
eprintln!(
|
||||
"[agent:{story_id}] Done. Session: {:?}",
|
||||
"[agent:{story_id}:{agent_name}] Done. Session: {:?}",
|
||||
session_id
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user