Files
storkit/server/src/http/agents_sse.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

59 lines
2.2 KiB
Rust

use crate::http::context::AppContext;
use poem::handler;
use poem::http::StatusCode;
use poem::web::{Data, Path};
use poem::{Body, IntoResponse, Response};
use std::sync::Arc;
/// 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, agent_name)): Path<(String, String)>,
ctx: Data<&Arc<AppContext>>,
) -> impl IntoResponse {
let mut rx = match ctx.agents.subscribe(&story_id, &agent_name) {
Ok(rx) => rx,
Err(e) => {
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from_string(e));
}
};
let stream = async_stream::stream! {
loop {
match rx.recv().await {
Ok(event) => {
if let Ok(json) = serde_json::to_string(&event) {
yield Ok::<_, std::io::Error>(format!("data: {json}\n\n"));
}
// Check for terminal events
match &event {
crate::agents::AgentEvent::Done { .. }
| crate::agents::AgentEvent::Error { .. } => break,
crate::agents::AgentEvent::Status { status, .. }
if status == "stopped" => break,
_ => {}
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
let msg = format!("{{\"type\":\"warning\",\"message\":\"Skipped {n} events\"}}");
yield Ok::<_, std::io::Error>(format!("data: {msg}\n\n"));
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
};
Response::builder()
.header("Content-Type", "text/event-stream")
.header("Cache-Control", "no-cache")
.header("Connection", "keep-alive")
.body(Body::from_bytes_stream(
futures::StreamExt::map(stream, |r| r.map(bytes::Bytes::from)),
))
}