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:
@@ -1,3 +1,4 @@
|
||||
use crate::config::ProjectConfig;
|
||||
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
||||
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
||||
use serde::Serialize;
|
||||
@@ -9,18 +10,36 @@ enum AgentsTags {
|
||||
}
|
||||
|
||||
#[derive(Object)]
|
||||
struct StoryIdPayload {
|
||||
struct StartAgentPayload {
|
||||
story_id: String,
|
||||
agent_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Object)]
|
||||
struct StopAgentPayload {
|
||||
story_id: String,
|
||||
agent_name: String,
|
||||
}
|
||||
|
||||
#[derive(Object, Serialize)]
|
||||
struct AgentInfoResponse {
|
||||
story_id: String,
|
||||
agent_name: String,
|
||||
status: String,
|
||||
session_id: Option<String>,
|
||||
worktree_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Object, Serialize)]
|
||||
struct AgentConfigInfoResponse {
|
||||
name: String,
|
||||
role: String,
|
||||
model: Option<String>,
|
||||
allowed_tools: Option<Vec<String>>,
|
||||
max_turns: Option<u32>,
|
||||
max_budget_usd: Option<f64>,
|
||||
}
|
||||
|
||||
pub struct AgentsApi {
|
||||
pub ctx: Arc<AppContext>,
|
||||
}
|
||||
@@ -28,10 +47,11 @@ pub struct AgentsApi {
|
||||
#[OpenApi(tag = "AgentsTags::Agents")]
|
||||
impl AgentsApi {
|
||||
/// Start an agent for a given story (creates worktree, runs setup, spawns agent).
|
||||
/// If agent_name is omitted, the first configured agent is used.
|
||||
#[oai(path = "/agents/start", method = "post")]
|
||||
async fn start_agent(
|
||||
&self,
|
||||
payload: Json<StoryIdPayload>,
|
||||
payload: Json<StartAgentPayload>,
|
||||
) -> OpenApiResult<Json<AgentInfoResponse>> {
|
||||
let project_root = self
|
||||
.ctx
|
||||
@@ -42,12 +62,17 @@ impl AgentsApi {
|
||||
let info = self
|
||||
.ctx
|
||||
.agents
|
||||
.start_agent(&project_root, &payload.0.story_id)
|
||||
.start_agent(
|
||||
&project_root,
|
||||
&payload.0.story_id,
|
||||
payload.0.agent_name.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(bad_request)?;
|
||||
|
||||
Ok(Json(AgentInfoResponse {
|
||||
story_id: info.story_id,
|
||||
agent_name: info.agent_name,
|
||||
status: info.status.to_string(),
|
||||
session_id: info.session_id,
|
||||
worktree_path: info.worktree_path,
|
||||
@@ -56,7 +81,7 @@ impl AgentsApi {
|
||||
|
||||
/// Stop a running agent and clean up its worktree.
|
||||
#[oai(path = "/agents/stop", method = "post")]
|
||||
async fn stop_agent(&self, payload: Json<StoryIdPayload>) -> OpenApiResult<Json<bool>> {
|
||||
async fn stop_agent(&self, payload: Json<StopAgentPayload>) -> OpenApiResult<Json<bool>> {
|
||||
let project_root = self
|
||||
.ctx
|
||||
.agents
|
||||
@@ -65,7 +90,11 @@ impl AgentsApi {
|
||||
|
||||
self.ctx
|
||||
.agents
|
||||
.stop_agent(&project_root, &payload.0.story_id)
|
||||
.stop_agent(
|
||||
&project_root,
|
||||
&payload.0.story_id,
|
||||
&payload.0.agent_name,
|
||||
)
|
||||
.await
|
||||
.map_err(bad_request)?;
|
||||
|
||||
@@ -82,6 +111,7 @@ impl AgentsApi {
|
||||
.into_iter()
|
||||
.map(|info| AgentInfoResponse {
|
||||
story_id: info.story_id,
|
||||
agent_name: info.agent_name,
|
||||
status: info.status.to_string(),
|
||||
session_id: info.session_id,
|
||||
worktree_path: info.worktree_path,
|
||||
@@ -89,4 +119,62 @@ impl AgentsApi {
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Get the configured agent roster from project.toml.
|
||||
#[oai(path = "/agents/config", method = "get")]
|
||||
async fn get_agent_config(
|
||||
&self,
|
||||
) -> OpenApiResult<Json<Vec<AgentConfigInfoResponse>>> {
|
||||
let project_root = self
|
||||
.ctx
|
||||
.agents
|
||||
.get_project_root(&self.ctx.state)
|
||||
.map_err(bad_request)?;
|
||||
|
||||
let config = ProjectConfig::load(&project_root).map_err(bad_request)?;
|
||||
|
||||
Ok(Json(
|
||||
config
|
||||
.agent
|
||||
.iter()
|
||||
.map(|a| AgentConfigInfoResponse {
|
||||
name: a.name.clone(),
|
||||
role: a.role.clone(),
|
||||
model: a.model.clone(),
|
||||
allowed_tools: a.allowed_tools.clone(),
|
||||
max_turns: a.max_turns,
|
||||
max_budget_usd: a.max_budget_usd,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Reload project config and return the updated agent roster.
|
||||
#[oai(path = "/agents/config/reload", method = "post")]
|
||||
async fn reload_config(
|
||||
&self,
|
||||
) -> OpenApiResult<Json<Vec<AgentConfigInfoResponse>>> {
|
||||
let project_root = self
|
||||
.ctx
|
||||
.agents
|
||||
.get_project_root(&self.ctx.state)
|
||||
.map_err(bad_request)?;
|
||||
|
||||
let config = ProjectConfig::load(&project_root).map_err(bad_request)?;
|
||||
|
||||
Ok(Json(
|
||||
config
|
||||
.agent
|
||||
.iter()
|
||||
.map(|a| AgentConfigInfoResponse {
|
||||
name: a.name.clone(),
|
||||
role: a.role.clone(),
|
||||
model: a.model.clone(),
|
||||
allowed_tools: a.allowed_tools.clone(),
|
||||
max_turns: a.max_turns,
|
||||
max_budget_usd: a.max_budget_usd,
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,16 @@ use poem::web::{Data, Path};
|
||||
use poem::{Body, IntoResponse, Response};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// SSE endpoint: `GET /agents/:story_id/stream`
|
||||
/// SSE endpoint: `GET /agents/:story_id/:agent_name/stream`
|
||||
///
|
||||
/// Streams `AgentEvent`s as Server-Sent Events. Each event is JSON-encoded
|
||||
/// with `data:` prefix and double newline terminator per the SSE spec.
|
||||
#[handler]
|
||||
pub async fn agent_stream(
|
||||
Path(story_id): Path<String>,
|
||||
Path((story_id, agent_name)): Path<(String, String)>,
|
||||
ctx: Data<&Arc<AppContext>>,
|
||||
) -> impl IntoResponse {
|
||||
let mut rx = match ctx.agents.subscribe(&story_id) {
|
||||
let mut rx = match ctx.agents.subscribe(&story_id, &agent_name) {
|
||||
Ok(rx) => rx,
|
||||
Err(e) => {
|
||||
return Response::builder()
|
||||
|
||||
@@ -35,7 +35,7 @@ pub fn build_routes(ctx: AppContext) -> impl poem::Endpoint {
|
||||
.nest("/docs", docs_service.swagger_ui())
|
||||
.at("/ws", get(ws::ws_handler))
|
||||
.at(
|
||||
"/agents/:story_id/stream",
|
||||
"/agents/:story_id/:agent_name/stream",
|
||||
get(agents_sse::agent_stream),
|
||||
)
|
||||
.at("/health", get(health::health))
|
||||
|
||||
Reference in New Issue
Block a user