Merge spike/claude-code-integration: PTY-based Claude Code with multi-agent support

Spike proved: spawning claude -p in a PTY from Rust gets Max subscription
billing. Multi-agent concurrency confirmed with session resumption.
Includes AgentPool REST API, claude-code provider, and spike documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

# Conflicts:
#	.ignore
This commit is contained in:
Dave
2026-02-19 15:30:56 +00:00
18 changed files with 1188 additions and 15 deletions

View File

@@ -22,6 +22,8 @@ rust-embed = { workspace = true }
mime_guess = { workspace = true }
homedir = { workspace = true }
serde_yaml = "0.9"
portable-pty = { workspace = true }
strip-ansi-escapes = { workspace = true }
[dev-dependencies]

307
server/src/agents.rs Normal file
View File

@@ -0,0 +1,307 @@
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::{BufRead, BufReader};
use std::sync::Mutex;
/// Manages multiple concurrent Claude Code agent sessions.
///
/// Each agent is identified by a string name (e.g., "coder-1", "coder-2").
/// Agents run `claude -p` in a PTY for Max subscription billing.
/// Sessions can be resumed for multi-turn conversations.
pub struct AgentPool {
agents: Mutex<HashMap<String, AgentState>>,
}
#[derive(Clone, Serialize)]
pub struct AgentInfo {
pub name: String,
pub role: String,
pub cwd: String,
pub session_id: Option<String>,
pub status: AgentStatus,
pub message_count: usize,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentStatus {
Idle,
Running,
}
struct AgentState {
role: String,
cwd: String,
session_id: Option<String>,
message_count: usize,
}
#[derive(Deserialize)]
pub struct CreateAgentRequest {
pub name: String,
pub role: String,
pub cwd: String,
}
#[derive(Deserialize)]
pub struct SendMessageRequest {
pub message: String,
}
#[derive(Serialize)]
pub struct AgentResponse {
pub agent: String,
pub text: String,
pub session_id: Option<String>,
pub model: Option<String>,
pub api_key_source: Option<String>,
pub rate_limit_type: Option<String>,
pub cost_usd: Option<f64>,
pub input_tokens: Option<u64>,
pub output_tokens: Option<u64>,
pub duration_ms: Option<u64>,
}
impl AgentPool {
pub fn new() -> Self {
Self {
agents: Mutex::new(HashMap::new()),
}
}
pub fn create_agent(&self, req: CreateAgentRequest) -> Result<AgentInfo, String> {
let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
if agents.contains_key(&req.name) {
return Err(format!("Agent '{}' already exists", req.name));
}
let state = AgentState {
role: req.role.clone(),
cwd: req.cwd.clone(),
session_id: None,
message_count: 0,
};
let info = AgentInfo {
name: req.name.clone(),
role: req.role,
cwd: req.cwd,
session_id: None,
status: AgentStatus::Idle,
message_count: 0,
};
agents.insert(req.name, state);
Ok(info)
}
pub fn list_agents(&self) -> Result<Vec<AgentInfo>, String> {
let agents = self.agents.lock().map_err(|e| e.to_string())?;
Ok(agents
.iter()
.map(|(name, state)| AgentInfo {
name: name.clone(),
role: state.role.clone(),
cwd: state.cwd.clone(),
session_id: state.session_id.clone(),
status: AgentStatus::Idle,
message_count: state.message_count,
})
.collect())
}
/// Send a message to an agent and wait for the complete response.
/// This spawns a `claude -p` process in a PTY, optionally resuming
/// a previous session for multi-turn conversations.
pub async fn send_message(
&self,
agent_name: &str,
message: &str,
) -> Result<AgentResponse, String> {
let (cwd, role, session_id) = {
let agents = self.agents.lock().map_err(|e| e.to_string())?;
let state = agents
.get(agent_name)
.ok_or_else(|| format!("Agent '{}' not found", agent_name))?;
(
state.cwd.clone(),
state.role.clone(),
state.session_id.clone(),
)
};
let agent = agent_name.to_string();
let msg = message.to_string();
let role_clone = role.clone();
let result = tokio::task::spawn_blocking(move || {
run_agent_pty(&agent, &msg, &cwd, &role_clone, session_id.as_deref())
})
.await
.map_err(|e| format!("Agent task panicked: {e}"))??;
// Update session_id for next message
if let Some(ref sid) = result.session_id {
let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
if let Some(state) = agents.get_mut(agent_name) {
state.session_id = Some(sid.clone());
state.message_count += 1;
}
}
Ok(result)
}
}
fn run_agent_pty(
agent_name: &str,
message: &str,
cwd: &str,
role: &str,
resume_session: Option<&str>,
) -> Result<AgentResponse, String> {
let pty_system = native_pty_system();
let pair = pty_system
.openpty(PtySize {
rows: 50,
cols: 200,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| format!("Failed to open PTY: {e}"))?;
let mut cmd = CommandBuilder::new("claude");
cmd.arg("-p");
cmd.arg(message);
cmd.arg("--output-format");
cmd.arg("stream-json");
cmd.arg("--verbose");
// Append role as system prompt context
cmd.arg("--append-system-prompt");
cmd.arg(format!(
"You are agent '{}' with role: {}. Work autonomously on the task given.",
agent_name, role
));
// Resume previous session if available
if let Some(session_id) = resume_session {
cmd.arg("--resume");
cmd.arg(session_id);
}
cmd.cwd(cwd);
cmd.env("NO_COLOR", "1");
eprintln!(
"[agent:{}] Spawning claude -p (session: {:?})",
agent_name,
resume_session.unwrap_or("new")
);
let mut child = pair
.slave
.spawn_command(cmd)
.map_err(|e| format!("Failed to spawn claude for agent {agent_name}: {e}"))?;
drop(pair.slave);
let reader = pair
.master
.try_clone_reader()
.map_err(|e| format!("Failed to clone PTY reader: {e}"))?;
drop(pair.master);
let buf_reader = BufReader::new(reader);
let mut response = AgentResponse {
agent: agent_name.to_string(),
text: String::new(),
session_id: None,
model: None,
api_key_source: None,
rate_limit_type: None,
cost_usd: None,
input_tokens: None,
output_tokens: None,
duration_ms: None,
};
for line in buf_reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let json: serde_json::Value = match serde_json::from_str(trimmed) {
Ok(j) => j,
Err(_) => continue, // skip non-JSON (terminal escapes)
};
let event_type = json.get("type").and_then(|t| t.as_str()).unwrap_or("");
match event_type {
"system" => {
response.session_id = json
.get("session_id")
.and_then(|s| s.as_str())
.map(|s| s.to_string());
response.model = json
.get("model")
.and_then(|s| s.as_str())
.map(|s| s.to_string());
response.api_key_source = json
.get("apiKeySource")
.and_then(|s| s.as_str())
.map(|s| s.to_string());
}
"rate_limit_event" => {
if let Some(info) = json.get("rate_limit_info") {
response.rate_limit_type = info
.get("rateLimitType")
.and_then(|s| s.as_str())
.map(|s| s.to_string());
}
}
"assistant" => {
if let Some(message) = json.get("message") {
if 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()) {
response.text.push_str(text);
}
}
}
}
}
"result" => {
response.cost_usd = json.get("total_cost_usd").and_then(|c| c.as_f64());
response.duration_ms = json.get("duration_ms").and_then(|d| d.as_u64());
if let Some(usage) = json.get("usage") {
response.input_tokens =
usage.get("input_tokens").and_then(|t| t.as_u64());
response.output_tokens =
usage.get("output_tokens").and_then(|t| t.as_u64());
}
}
_ => {}
}
}
let _ = child.kill();
eprintln!(
"[agent:{}] Done. Session: {:?}, tokens: {:?}/{:?}",
agent_name, response.session_id, response.input_tokens, response.output_tokens
);
Ok(response)
}

127
server/src/http/agents.rs Normal file
View File

@@ -0,0 +1,127 @@
use crate::http::context::{AppContext, OpenApiResult, bad_request};
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
use serde::Serialize;
use std::sync::Arc;
#[derive(Tags)]
enum AgentsTags {
Agents,
}
#[derive(Object)]
struct CreateAgentPayload {
name: String,
role: String,
cwd: String,
}
#[derive(Object)]
struct SendMessagePayload {
message: String,
}
#[derive(Object, Serialize)]
struct AgentInfoResponse {
name: String,
role: String,
cwd: String,
session_id: Option<String>,
status: String,
message_count: usize,
}
#[derive(Object, Serialize)]
struct AgentMessageResponse {
agent: String,
text: String,
session_id: Option<String>,
model: Option<String>,
api_key_source: Option<String>,
rate_limit_type: Option<String>,
cost_usd: Option<f64>,
input_tokens: Option<u64>,
output_tokens: Option<u64>,
duration_ms: Option<u64>,
}
pub struct AgentsApi {
pub ctx: Arc<AppContext>,
}
#[OpenApi(tag = "AgentsTags::Agents")]
impl AgentsApi {
/// Create a new agent with a name, role, and working directory.
#[oai(path = "/agents", method = "post")]
async fn create_agent(
&self,
payload: Json<CreateAgentPayload>,
) -> OpenApiResult<Json<AgentInfoResponse>> {
let req = crate::agents::CreateAgentRequest {
name: payload.0.name,
role: payload.0.role,
cwd: payload.0.cwd,
};
let info = self.ctx.agents.create_agent(req).map_err(bad_request)?;
Ok(Json(AgentInfoResponse {
name: info.name,
role: info.role,
cwd: info.cwd,
session_id: info.session_id,
status: "idle".to_string(),
message_count: info.message_count,
}))
}
/// List all registered agents.
#[oai(path = "/agents", method = "get")]
async fn list_agents(&self) -> OpenApiResult<Json<Vec<AgentInfoResponse>>> {
let agents = self.ctx.agents.list_agents().map_err(bad_request)?;
Ok(Json(
agents
.into_iter()
.map(|info| AgentInfoResponse {
name: info.name,
role: info.role,
cwd: info.cwd,
session_id: info.session_id,
status: match info.status {
crate::agents::AgentStatus::Idle => "idle".to_string(),
crate::agents::AgentStatus::Running => "running".to_string(),
},
message_count: info.message_count,
})
.collect(),
))
}
/// Send a message to an agent and wait for its response.
#[oai(path = "/agents/:name/message", method = "post")]
async fn send_message(
&self,
name: poem_openapi::param::Path<String>,
payload: Json<SendMessagePayload>,
) -> OpenApiResult<Json<AgentMessageResponse>> {
let result = self
.ctx
.agents
.send_message(&name.0, &payload.0.message)
.await
.map_err(bad_request)?;
Ok(Json(AgentMessageResponse {
agent: result.agent,
text: result.text,
session_id: result.session_id,
model: result.model,
api_key_source: result.api_key_source,
rate_limit_type: result.rate_limit_type,
cost_usd: result.cost_usd,
input_tokens: result.input_tokens,
output_tokens: result.output_tokens,
duration_ms: result.duration_ms,
}))
}
}

View File

@@ -1,3 +1,4 @@
use crate::agents::AgentPool;
use crate::state::SessionState;
use crate::store::JsonFileStore;
use crate::workflow::WorkflowState;
@@ -9,6 +10,7 @@ pub struct AppContext {
pub state: Arc<SessionState>,
pub store: Arc<JsonFileStore>,
pub workflow: Arc<std::sync::Mutex<WorkflowState>>,
pub agents: Arc<AgentPool>,
}
pub type OpenApiResult<T> = poem::Result<T>;

View File

@@ -1,3 +1,4 @@
pub mod agents;
pub mod anthropic;
pub mod assets;
pub mod chat;
@@ -10,6 +11,7 @@ pub mod workflow;
pub mod project;
pub mod ws;
use agents::AgentsApi;
use anthropic::AnthropicApi;
use chat::ChatApi;
use context::AppContext;
@@ -45,6 +47,7 @@ type ApiTuple = (
IoApi,
ChatApi,
WorkflowApi,
AgentsApi,
);
type ApiService = OpenApiService<ApiTuple, ()>;
@@ -58,6 +61,7 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
IoApi { ctx: ctx.clone() },
ChatApi { ctx: ctx.clone() },
WorkflowApi { ctx: ctx.clone() },
AgentsApi { ctx: ctx.clone() },
);
let api_service =
@@ -69,7 +73,8 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
AnthropicApi::new(ctx.clone()),
IoApi { ctx: ctx.clone() },
ChatApi { ctx: ctx.clone() },
WorkflowApi { ctx },
WorkflowApi { ctx: ctx.clone() },
AgentsApi { ctx },
);
let docs_service =

View File

@@ -189,12 +189,55 @@ where
.clone()
.unwrap_or_else(|| "http://localhost:11434".to_string());
let is_claude = config.model.starts_with("claude-");
eprintln!("[chat] provider={} model={}", config.provider, config.model);
let is_claude_code = config.provider == "claude-code";
let is_claude = !is_claude_code && config.model.starts_with("claude-");
if !is_claude && config.provider.as_str() != "ollama" {
if !is_claude_code && !is_claude && config.provider.as_str() != "ollama" {
return Err(format!("Unsupported provider: {}", config.provider));
}
// Claude Code provider: bypasses our tool loop entirely.
// Claude Code has its own agent loop, tools, and context management.
// We just pipe the user message in and stream raw output back.
if is_claude_code {
use crate::llm::providers::claude_code::ClaudeCodeProvider;
let user_message = messages
.iter()
.rev()
.find(|m| m.role == Role::User)
.map(|m| m.content.clone())
.ok_or_else(|| "No user message found".to_string())?;
let project_root = state
.get_project_root()
.unwrap_or_else(|_| std::path::PathBuf::from("."));
let provider = ClaudeCodeProvider::new();
let response = provider
.chat_stream(
&user_message,
&project_root.to_string_lossy(),
&mut cancel_rx,
|token| on_token(token),
)
.await
.map_err(|e| format!("Claude Code Error: {e}"))?;
let assistant_msg = Message {
role: Role::Assistant,
content: response.content.unwrap_or_default(),
tool_calls: None,
tool_call_id: None,
};
let mut result = messages.clone();
result.push(assistant_msg);
on_update(&result);
return Ok(result);
}
let tool_defs = get_tool_definitions();
let tools = if config.enable_tools.unwrap_or(true) {
tool_defs.as_slice()

View File

@@ -0,0 +1,299 @@
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
use std::io::{BufRead, BufReader};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::watch;
use crate::llm::types::CompletionResponse;
/// Manages a Claude Code session via a pseudo-terminal.
///
/// Spawns `claude -p` in a PTY so isatty() returns true (which may
/// influence billing), while using `--output-format stream-json` to
/// get clean, structured NDJSON output instead of TUI escape sequences.
pub struct ClaudeCodeProvider;
impl ClaudeCodeProvider {
pub fn new() -> Self {
Self
}
pub async fn chat_stream<F>(
&self,
user_message: &str,
project_root: &str,
cancel_rx: &mut watch::Receiver<bool>,
mut on_token: F,
) -> Result<CompletionResponse, String>
where
F: FnMut(&str) + Send,
{
let message = user_message.to_string();
let cwd = project_root.to_string();
let cancelled = Arc::new(AtomicBool::new(false));
let cancelled_clone = cancelled.clone();
let mut cancel_watch = cancel_rx.clone();
tokio::spawn(async move {
while cancel_watch.changed().await.is_ok() {
if *cancel_watch.borrow() {
cancelled_clone.store(true, Ordering::Relaxed);
break;
}
}
});
let (token_tx, mut token_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let pty_handle = tokio::task::spawn_blocking(move || {
run_pty_session(&message, &cwd, cancelled, token_tx)
});
let mut full_output = String::new();
while let Some(token) = token_rx.recv().await {
full_output.push_str(&token);
on_token(&token);
}
pty_handle
.await
.map_err(|e| format!("PTY task panicked: {e}"))??;
Ok(CompletionResponse {
content: Some(full_output),
tool_calls: None,
})
}
}
/// Run `claude -p` with stream-json output inside a PTY.
///
/// The PTY makes isatty() return true. The `-p` flag gives us
/// single-shot non-interactive mode with structured output.
fn run_pty_session(
user_message: &str,
cwd: &str,
cancelled: Arc<AtomicBool>,
token_tx: tokio::sync::mpsc::UnboundedSender<String>,
) -> Result<(), String> {
let pty_system = native_pty_system();
let pair = pty_system
.openpty(PtySize {
rows: 50,
cols: 200,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| format!("Failed to open PTY: {e}"))?;
let mut cmd = CommandBuilder::new("claude");
cmd.arg("-p");
cmd.arg(user_message);
cmd.arg("--output-format");
cmd.arg("stream-json");
cmd.arg("--verbose");
cmd.cwd(cwd);
// Keep TERM reasonable but disable color
cmd.env("NO_COLOR", "1");
eprintln!("[pty-debug] Spawning: claude -p \"{}\" --output-format stream-json --verbose", user_message);
let mut child = pair
.slave
.spawn_command(cmd)
.map_err(|e| format!("Failed to spawn claude: {e}"))?;
eprintln!("[pty-debug] Process spawned, pid: {:?}", child.process_id());
drop(pair.slave);
let reader = pair
.master
.try_clone_reader()
.map_err(|e| format!("Failed to clone PTY reader: {e}"))?;
// We don't need to write anything — -p mode takes prompt as arg
drop(pair.master);
// Read NDJSON lines from stdout
let (line_tx, line_rx) = std::sync::mpsc::channel::<Option<String>>();
std::thread::spawn(move || {
let buf_reader = BufReader::new(reader);
eprintln!("[pty-debug] Reader thread started");
for line in buf_reader.lines() {
match line {
Ok(l) => {
eprintln!("[pty-debug] raw line: {}", l);
if line_tx.send(Some(l)).is_err() {
break;
}
}
Err(e) => {
eprintln!("[pty-debug] read error: {e}");
let _ = line_tx.send(None);
break;
}
}
}
eprintln!("[pty-debug] Reader thread done");
let _ = line_tx.send(None);
});
let mut got_result = false;
loop {
if cancelled.load(Ordering::Relaxed) {
let _ = child.kill();
return Err("Cancelled".to_string());
}
match line_rx.recv_timeout(std::time::Duration::from_millis(500)) {
Ok(Some(line)) => {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
eprintln!("[pty-debug] processing: {}...", &trimmed[..trimmed.len().min(120)]);
// Try to parse as JSON
if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
if let Some(event_type) = json.get("type").and_then(|t| t.as_str()) {
match event_type {
// Streaming deltas (when --include-partial-messages is used)
"stream_event" => {
if let Some(event) = json.get("event") {
handle_stream_event(event, &token_tx);
}
}
// Complete assistant message
"assistant" => {
if let Some(message) = json.get("message") {
if 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 _ = token_tx.send(text.to_string());
}
}
}
}
}
// Final result with usage stats
"result" => {
if let Some(cost) = json.get("total_cost_usd").and_then(|c| c.as_f64()) {
let _ = token_tx.send(format!("\n\n---\n_Cost: ${cost:.4}_\n"));
}
if let Some(usage) = json.get("usage") {
let input = usage.get("input_tokens").and_then(|t| t.as_u64()).unwrap_or(0);
let output = usage.get("output_tokens").and_then(|t| t.as_u64()).unwrap_or(0);
let cached = usage.get("cache_read_input_tokens").and_then(|t| t.as_u64()).unwrap_or(0);
let _ = token_tx.send(format!("_Tokens: {input} in / {output} out / {cached} cached_\n"));
}
got_result = true;
}
// System init — log billing info
"system" => {
let api_source = json.get("apiKeySource").and_then(|s| s.as_str()).unwrap_or("unknown");
let model = json.get("model").and_then(|s| s.as_str()).unwrap_or("unknown");
let _ = token_tx.send(format!("_[{model} | apiKey: {api_source}]_\n\n"));
}
// Rate limit info
"rate_limit_event" => {
if let Some(info) = json.get("rate_limit_info") {
let status = info.get("status").and_then(|s| s.as_str()).unwrap_or("unknown");
let limit_type = info.get("rateLimitType").and_then(|s| s.as_str()).unwrap_or("unknown");
let _ = token_tx.send(format!("_[rate limit: {status} ({limit_type})]_\n\n"));
}
}
_ => {}
}
}
}
// Ignore non-JSON lines (terminal escape sequences)
if got_result {
break;
}
}
Ok(None) => break, // EOF
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
// Check if child has exited
if let Ok(Some(_status)) = child.try_wait() {
// Drain remaining lines
while let Ok(Some(line)) = line_rx.try_recv() {
let trimmed = line.trim();
if let Ok(json) = serde_json::from_str::<serde_json::Value>(trimmed) {
if let Some(event) = json
.get("type")
.filter(|t| t.as_str() == Some("stream_event"))
.and_then(|_| json.get("event"))
{
handle_stream_event(event, &token_tx);
}
}
}
break;
}
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
// Don't set got_result here — just let the process finish naturally
let _ = got_result;
}
let _ = child.kill();
Ok(())
}
/// Extract text from a stream event and send to the token channel.
fn handle_stream_event(
event: &serde_json::Value,
token_tx: &tokio::sync::mpsc::UnboundedSender<String>,
) {
let event_type = event.get("type").and_then(|t| t.as_str()).unwrap_or("");
match event_type {
// Text content streaming
"content_block_delta" => {
if let Some(delta) = event.get("delta") {
let delta_type = delta.get("type").and_then(|t| t.as_str()).unwrap_or("");
match delta_type {
"text_delta" => {
if let Some(text) = delta.get("text").and_then(|t| t.as_str()) {
let _ = token_tx.send(text.to_string());
}
}
"thinking_delta" => {
if let Some(thinking) = delta.get("thinking").and_then(|t| t.as_str()) {
let _ = token_tx.send(format!("[thinking] {thinking}"));
}
}
_ => {}
}
}
}
// Message complete — log usage info
"message_delta" => {
if let Some(usage) = event.get("usage") {
let output_tokens = usage
.get("output_tokens")
.and_then(|t| t.as_u64())
.unwrap_or(0);
let _ = token_tx.send(format!("\n[tokens: {output_tokens} output]\n"));
}
}
// Log errors
"error" => {
if let Some(error) = event.get("error") {
let msg = error
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("unknown error");
let _ = token_tx.send(format!("\n[error: {msg}]\n"));
}
}
_ => {}
}
}

View File

@@ -1,2 +1,3 @@
pub mod anthropic;
pub mod claude_code;
pub mod ollama;

View File

@@ -1,3 +1,4 @@
mod agents;
mod http;
mod io;
mod llm;
@@ -5,6 +6,7 @@ mod state;
mod store;
mod workflow;
use crate::agents::AgentPool;
use crate::http::build_routes;
use crate::http::context::AppContext;
use crate::state::SessionState;
@@ -22,11 +24,13 @@ async fn main() -> Result<(), std::io::Error> {
JsonFileStore::from_path(PathBuf::from("store.json")).map_err(std::io::Error::other)?,
);
let workflow = Arc::new(std::sync::Mutex::new(WorkflowState::default()));
let agents = Arc::new(AgentPool::new());
let ctx = AppContext {
state: app_state,
store,
workflow,
agents,
};
let app = build_routes(ctx);