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:
Dave
2026-02-19 18:46:14 +00:00
parent f9fc2472fd
commit 6d57b06636
12 changed files with 1013 additions and 214 deletions

View File

@@ -10,7 +10,31 @@ path = "."
setup = ["cargo check"] setup = ["cargo check"]
teardown = [] teardown = []
[agent] [[agent]]
command = "claude" name = "supervisor"
args = [] role = "Coordinates work, reviews PRs, decomposes stories."
prompt = "Read .story_kit/README.md, then pick up story {{story_id}}" model = "opus"
max_turns = 50
max_budget_usd = 10.00
system_prompt = "You are a senior engineering lead. Coordinate the work, review code, and ensure quality."
[[agent]]
name = "coder-1"
role = "Full-stack engineer. Implements features across all components."
model = "sonnet"
max_turns = 30
max_budget_usd = 5.00
[[agent]]
name = "coder-2"
role = "Full-stack engineer. Implements features across all components."
model = "sonnet"
max_turns = 30
max_budget_usd = 5.00
[[agent]]
name = "reviewer"
role = "Reviews code changes, runs tests, checks quality gates."
model = "sonnet"
max_turns = 20
max_budget_usd = 3.00

View File

@@ -2,6 +2,7 @@ export type AgentStatusValue = "pending" | "running" | "completed" | "failed";
export interface AgentInfo { export interface AgentInfo {
story_id: string; story_id: string;
agent_name: string;
status: AgentStatusValue; status: AgentStatusValue;
session_id: string | null; session_id: string | null;
worktree_path: string | null; worktree_path: string | null;
@@ -10,6 +11,7 @@ export interface AgentInfo {
export interface AgentEvent { export interface AgentEvent {
type: "status" | "output" | "agent_json" | "done" | "error" | "warning"; type: "status" | "output" | "agent_json" | "done" | "error" | "warning";
story_id?: string; story_id?: string;
agent_name?: string;
status?: string; status?: string;
text?: string; text?: string;
data?: unknown; data?: unknown;
@@ -17,6 +19,15 @@ export interface AgentEvent {
message?: string; message?: string;
} }
export interface AgentConfigInfo {
name: string;
role: string;
model: string | null;
allowed_tools: string[] | null;
max_turns: number | null;
max_budget_usd: number | null;
}
const DEFAULT_API_BASE = "/api"; const DEFAULT_API_BASE = "/api";
function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string { function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string {
@@ -45,23 +56,29 @@ async function requestJson<T>(
} }
export const agentsApi = { export const agentsApi = {
startAgent(storyId: string, baseUrl?: string) { startAgent(storyId: string, agentName?: string, baseUrl?: string) {
return requestJson<AgentInfo>( return requestJson<AgentInfo>(
"/agents/start", "/agents/start",
{ {
method: "POST", method: "POST",
body: JSON.stringify({ story_id: storyId }), body: JSON.stringify({
story_id: storyId,
agent_name: agentName,
}),
}, },
baseUrl, baseUrl,
); );
}, },
stopAgent(storyId: string, baseUrl?: string) { stopAgent(storyId: string, agentName: string, baseUrl?: string) {
return requestJson<boolean>( return requestJson<boolean>(
"/agents/stop", "/agents/stop",
{ {
method: "POST", method: "POST",
body: JSON.stringify({ story_id: storyId }), body: JSON.stringify({
story_id: storyId,
agent_name: agentName,
}),
}, },
baseUrl, baseUrl,
); );
@@ -70,6 +87,18 @@ export const agentsApi = {
listAgents(baseUrl?: string) { listAgents(baseUrl?: string) {
return requestJson<AgentInfo[]>("/agents", {}, baseUrl); return requestJson<AgentInfo[]>("/agents", {}, baseUrl);
}, },
getAgentConfig(baseUrl?: string) {
return requestJson<AgentConfigInfo[]>("/agents/config", {}, baseUrl);
},
reloadConfig(baseUrl?: string) {
return requestJson<AgentConfigInfo[]>(
"/agents/config/reload",
{ method: "POST" },
baseUrl,
);
},
}; };
/** /**
@@ -78,11 +107,12 @@ export const agentsApi = {
*/ */
export function subscribeAgentStream( export function subscribeAgentStream(
storyId: string, storyId: string,
agentName: string,
onEvent: (event: AgentEvent) => void, onEvent: (event: AgentEvent) => void,
onError?: (error: Event) => void, onError?: (error: Event) => void,
): () => void { ): () => void {
const host = import.meta.env.DEV ? "http://127.0.0.1:3001" : ""; const host = import.meta.env.DEV ? "http://127.0.0.1:3001" : "";
const url = `${host}/agents/${encodeURIComponent(storyId)}/stream`; const url = `${host}/agents/${encodeURIComponent(storyId)}/${encodeURIComponent(agentName)}/stream`;
const eventSource = new EventSource(url); const eventSource = new EventSource(url);

View File

@@ -1,5 +1,10 @@
import * as React from "react"; import * as React from "react";
import type { AgentEvent, AgentInfo, AgentStatusValue } from "../api/agents"; import type {
AgentConfigInfo,
AgentEvent,
AgentInfo,
AgentStatusValue,
} from "../api/agents";
import { agentsApi, subscribeAgentStream } from "../api/agents"; import { agentsApi, subscribeAgentStream } from "../api/agents";
import type { UpcomingStory } from "../api/workflow"; import type { UpcomingStory } from "../api/workflow";
@@ -10,6 +15,7 @@ interface AgentPanelProps {
} }
interface AgentState { interface AgentState {
agentName: string;
status: AgentStatusValue; status: AgentStatusValue;
log: string[]; log: string[];
sessionId: string | null; sessionId: string | null;
@@ -71,30 +77,65 @@ function StatusBadge({ status }: { status: AgentStatusValue }) {
); );
} }
function RosterBadge({ agent }: { agent: AgentConfigInfo }) {
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "4px",
padding: "2px 8px",
borderRadius: "6px",
fontSize: "0.7em",
background: "#ffffff08",
color: "#888",
border: "1px solid #333",
}}
title={agent.role || agent.name}
>
<span style={{ fontWeight: 600, color: "#aaa" }}>{agent.name}</span>
{agent.model && <span style={{ color: "#666" }}>{agent.model}</span>}
</span>
);
}
/** Build a composite key for tracking agent state. */
function agentKey(storyId: string, agentName: string): string {
return `${storyId}:${agentName}`;
}
export function AgentPanel({ stories }: AgentPanelProps) { export function AgentPanel({ stories }: AgentPanelProps) {
const [agents, setAgents] = useState<Record<string, AgentState>>({}); const [agents, setAgents] = useState<Record<string, AgentState>>({});
const [expandedStory, setExpandedStory] = useState<string | null>(null); const [roster, setRoster] = useState<AgentConfigInfo[]>([]);
const [expandedKey, setExpandedKey] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const [lastRefresh, setLastRefresh] = useState<Date | null>(null); const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
const [selectorStory, setSelectorStory] = useState<string | null>(null);
const cleanupRefs = useRef<Record<string, () => void>>({}); const cleanupRefs = useRef<Record<string, () => void>>({});
const logEndRefs = useRef<Record<string, HTMLDivElement | null>>({}); const logEndRefs = useRef<Record<string, HTMLDivElement | null>>({});
// Load existing agents on mount // Load roster and existing agents on mount
useEffect(() => { useEffect(() => {
agentsApi
.getAgentConfig()
.then(setRoster)
.catch((err) => console.error("Failed to load agent config:", err));
agentsApi agentsApi
.listAgents() .listAgents()
.then((agentList) => { .then((agentList) => {
const agentMap: Record<string, AgentState> = {}; const agentMap: Record<string, AgentState> = {};
for (const a of agentList) { for (const a of agentList) {
agentMap[a.story_id] = { const key = agentKey(a.story_id, a.agent_name);
agentMap[key] = {
agentName: a.agent_name,
status: a.status, status: a.status,
log: [], log: [],
sessionId: a.session_id, sessionId: a.session_id,
worktreePath: a.worktree_path, worktreePath: a.worktree_path,
}; };
// Re-subscribe to running agents
if (a.status === "running" || a.status === "pending") { if (a.status === "running" || a.status === "pending") {
subscribeToAgent(a.story_id); subscribeToAgent(a.story_id, a.agent_name);
} }
} }
setAgents(agentMap); setAgents(agentMap);
@@ -110,15 +151,17 @@ export function AgentPanel({ stories }: AgentPanelProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const subscribeToAgent = useCallback((storyId: string) => { const subscribeToAgent = useCallback((storyId: string, agentName: string) => {
// Clean up existing subscription const key = agentKey(storyId, agentName);
cleanupRefs.current[storyId]?.(); cleanupRefs.current[key]?.();
const cleanup = subscribeAgentStream( const cleanup = subscribeAgentStream(
storyId, storyId,
agentName,
(event: AgentEvent) => { (event: AgentEvent) => {
setAgents((prev) => { setAgents((prev) => {
const current = prev[storyId] ?? { const current = prev[key] ?? {
agentName,
status: "pending" as AgentStatusValue, status: "pending" as AgentStatusValue,
log: [], log: [],
sessionId: null, sessionId: null,
@@ -129,7 +172,7 @@ export function AgentPanel({ stories }: AgentPanelProps) {
case "status": case "status":
return { return {
...prev, ...prev,
[storyId]: { [key]: {
...current, ...current,
status: (event.status as AgentStatusValue) ?? current.status, status: (event.status as AgentStatusValue) ?? current.status,
}, },
@@ -137,7 +180,7 @@ export function AgentPanel({ stories }: AgentPanelProps) {
case "output": case "output":
return { return {
...prev, ...prev,
[storyId]: { [key]: {
...current, ...current,
log: [...current.log, event.text ?? ""], log: [...current.log, event.text ?? ""],
}, },
@@ -145,7 +188,7 @@ export function AgentPanel({ stories }: AgentPanelProps) {
case "done": case "done":
return { return {
...prev, ...prev,
[storyId]: { [key]: {
...current, ...current,
status: "completed", status: "completed",
sessionId: event.session_id ?? current.sessionId, sessionId: event.session_id ?? current.sessionId,
@@ -154,7 +197,7 @@ export function AgentPanel({ stories }: AgentPanelProps) {
case "error": case "error":
return { return {
...prev, ...prev,
[storyId]: { [key]: {
...current, ...current,
status: "failed", status: "failed",
log: [ log: [
@@ -173,47 +216,51 @@ export function AgentPanel({ stories }: AgentPanelProps) {
}, },
); );
cleanupRefs.current[storyId] = cleanup; cleanupRefs.current[key] = cleanup;
}, []); }, []);
// Auto-scroll log when expanded // Auto-scroll log when expanded
useEffect(() => { useEffect(() => {
if (expandedStory) { if (expandedKey) {
const el = logEndRefs.current[expandedStory]; const el = logEndRefs.current[expandedKey];
el?.scrollIntoView({ behavior: "smooth" }); el?.scrollIntoView({ behavior: "smooth" });
} }
}, [expandedStory, agents]); }, [expandedKey, agents]);
const handleStart = async (storyId: string) => { const handleStart = async (storyId: string, agentName?: string) => {
setActionError(null); setActionError(null);
setSelectorStory(null);
try { try {
const info: AgentInfo = await agentsApi.startAgent(storyId); const info: AgentInfo = await agentsApi.startAgent(storyId, agentName);
const key = agentKey(info.story_id, info.agent_name);
setAgents((prev) => ({ setAgents((prev) => ({
...prev, ...prev,
[storyId]: { [key]: {
agentName: info.agent_name,
status: info.status, status: info.status,
log: [], log: [],
sessionId: info.session_id, sessionId: info.session_id,
worktreePath: info.worktree_path, worktreePath: info.worktree_path,
}, },
})); }));
setExpandedStory(storyId); setExpandedKey(key);
subscribeToAgent(storyId); subscribeToAgent(info.story_id, info.agent_name);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
setActionError(`Failed to start agent for ${storyId}: ${message}`); setActionError(`Failed to start agent for ${storyId}: ${message}`);
} }
}; };
const handleStop = async (storyId: string) => { const handleStop = async (storyId: string, agentName: string) => {
setActionError(null); setActionError(null);
const key = agentKey(storyId, agentName);
try { try {
await agentsApi.stopAgent(storyId); await agentsApi.stopAgent(storyId, agentName);
cleanupRefs.current[storyId]?.(); cleanupRefs.current[key]?.();
delete cleanupRefs.current[storyId]; delete cleanupRefs.current[key];
setAgents((prev) => { setAgents((prev) => {
const next = { ...prev }; const next = { ...prev };
delete next[storyId]; delete next[key];
return next; return next;
}); });
} catch (err) { } catch (err) {
@@ -222,9 +269,23 @@ export function AgentPanel({ stories }: AgentPanelProps) {
} }
}; };
const isAgentActive = (storyId: string): boolean => { const handleRunClick = (storyId: string) => {
const agent = agents[storyId]; if (roster.length <= 1) {
return agent?.status === "running" || agent?.status === "pending"; handleStart(storyId);
} else {
setSelectorStory(selectorStory === storyId ? null : storyId);
}
};
/** Get all active agent keys for a story. */
const getActiveKeysForStory = (storyId: string): string[] => {
return Object.keys(agents).filter((key) => {
const a = agents[key];
return (
key.startsWith(`${storyId}:`) &&
(a.status === "running" || a.status === "pending")
);
});
}; };
return ( return (
@@ -273,6 +334,21 @@ export function AgentPanel({ stories }: AgentPanelProps) {
)} )}
</div> </div>
{/* Roster badges */}
{roster.length > 0 && (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "4px",
}}
>
{roster.map((a) => (
<RosterBadge key={`roster-${a.name}`} agent={a} />
))}
</div>
)}
{actionError && ( {actionError && (
<div <div
style={{ style={{
@@ -300,8 +376,13 @@ export function AgentPanel({ stories }: AgentPanelProps) {
}} }}
> >
{stories.map((story) => { {stories.map((story) => {
const agent = agents[story.story_id]; const activeKeys = getActiveKeysForStory(story.story_id);
const isExpanded = expandedStory === story.story_id; const hasActive = activeKeys.length > 0;
// Gather all agent states for this story
const storyAgentEntries = Object.entries(agents).filter(([key]) =>
key.startsWith(`${story.story_id}:`),
);
return ( return (
<div <div
@@ -324,11 +405,19 @@ export function AgentPanel({ stories }: AgentPanelProps) {
<button <button
type="button" type="button"
onClick={() => onClick={() =>
setExpandedStory(isExpanded ? null : story.story_id) setExpandedKey(
expandedKey?.startsWith(`${story.story_id}:`)
? null
: (storyAgentEntries[0]?.[0] ?? story.story_id),
)
} }
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") { if (e.key === "Enter" || e.key === " ") {
setExpandedStory(isExpanded ? null : story.story_id); setExpandedKey(
expandedKey?.startsWith(`${story.story_id}:`)
? null
: (storyAgentEntries[0]?.[0] ?? story.story_id),
);
} }
}} }}
style={{ style={{
@@ -338,7 +427,9 @@ export function AgentPanel({ stories }: AgentPanelProps) {
cursor: "pointer", cursor: "pointer",
fontSize: "0.8em", fontSize: "0.8em",
padding: "0 4px", padding: "0 4px",
transform: isExpanded ? "rotate(90deg)" : "rotate(0deg)", transform: expandedKey?.startsWith(`${story.story_id}:`)
? "rotate(90deg)"
: "rotate(0deg)",
transition: "transform 0.15s", transition: "transform 0.15s",
}} }}
> >
@@ -358,12 +449,38 @@ export function AgentPanel({ stories }: AgentPanelProps) {
{story.name ?? story.story_id} {story.name ?? story.story_id}
</div> </div>
{agent && <StatusBadge status={agent.status} />} {storyAgentEntries.map(([key, a]) => (
<span
key={`badge-${key}`}
style={{
display: "inline-flex",
alignItems: "center",
gap: "4px",
}}
>
<span
style={{
fontSize: "0.7em",
color: "#666",
}}
>
{a.agentName}
</span>
<StatusBadge status={a.status} />
</span>
))}
{isAgentActive(story.story_id) ? ( {hasActive ? (
<button <button
type="button" type="button"
onClick={() => handleStop(story.story_id)} onClick={() => {
for (const key of activeKeys) {
const a = agents[key];
if (a) {
handleStop(story.story_id, a.agentName);
}
}
}}
style={{ style={{
padding: "4px 10px", padding: "4px 10px",
borderRadius: "999px", borderRadius: "999px",
@@ -378,88 +495,164 @@ export function AgentPanel({ stories }: AgentPanelProps) {
Stop Stop
</button> </button>
) : ( ) : (
<button <div style={{ position: "relative" }}>
type="button" <button
onClick={() => handleStart(story.story_id)} type="button"
style={{ onClick={() => handleRunClick(story.story_id)}
padding: "4px 10px", style={{
borderRadius: "999px", padding: "4px 10px",
border: "1px solid #7ee78744", borderRadius: "999px",
background: "#7ee78711", border: "1px solid #7ee78744",
color: "#7ee787", background: "#7ee78711",
cursor: "pointer", color: "#7ee787",
fontSize: "0.75em", cursor: "pointer",
fontWeight: 600, fontSize: "0.75em",
}} fontWeight: 600,
> }}
Run >
</button> Run
</button>
{selectorStory === story.story_id &&
roster.length > 1 && (
<div
style={{
position: "absolute",
top: "100%",
right: 0,
marginTop: "4px",
background: "#222",
border: "1px solid #444",
borderRadius: "6px",
padding: "4px 0",
zIndex: 10,
minWidth: "160px",
}}
>
{roster.map((r) => (
<button
key={`sel-${r.name}`}
type="button"
onClick={() =>
handleStart(story.story_id, r.name)
}
style={{
display: "block",
width: "100%",
padding: "6px 12px",
background: "none",
border: "none",
color: "#ccc",
cursor: "pointer",
textAlign: "left",
fontSize: "0.8em",
}}
onMouseEnter={(e) => {
(
e.target as HTMLButtonElement
).style.background = "#333";
}}
onMouseLeave={(e) => {
(
e.target as HTMLButtonElement
).style.background = "none";
}}
>
<div style={{ fontWeight: 600 }}>{r.name}</div>
{r.role && (
<div
style={{
fontSize: "0.85em",
color: "#888",
}}
>
{r.role}
</div>
)}
</button>
))}
</div>
)}
</div>
)} )}
</div> </div>
{isExpanded && agent && ( {/* Expanded detail per agent */}
<div {storyAgentEntries.map(([key, a]) => {
style={{ if (expandedKey !== key) return null;
borderTop: "1px solid #2a2a2a", return (
padding: "8px 12px", <div
}} key={`detail-${key}`}
> style={{
{agent.worktreePath && ( borderTop: "1px solid #2a2a2a",
padding: "8px 12px",
}}
>
<div <div
style={{ style={{
fontSize: "0.75em", fontSize: "0.75em",
color: "#666", color: "#888",
fontFamily: "monospace", marginBottom: "4px",
marginBottom: "6px", fontWeight: 600,
}} }}
> >
Worktree: {agent.worktreePath} {a.agentName}
</div> </div>
)} {a.worktreePath && (
<div <div
style={{ style={{
maxHeight: "300px", fontSize: "0.75em",
overflowY: "auto", color: "#666",
background: "#111", fontFamily: "monospace",
borderRadius: "6px", marginBottom: "6px",
padding: "8px", }}
fontFamily: "monospace", >
fontSize: "0.8em", Worktree: {a.worktreePath}
lineHeight: "1.5", </div>
color: "#ccc",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{agent.log.length === 0 ? (
<span style={{ color: "#555" }}>
{agent.status === "pending" ||
agent.status === "running"
? "Waiting for output..."
: "No output captured."}
</span>
) : (
agent.log.map((line, i) => (
<div
key={`log-${story.story_id}-${i}`}
style={{
color: line.startsWith("[ERROR]")
? "#ff7b72"
: "#ccc",
}}
>
{line}
</div>
))
)} )}
<div <div
ref={(el) => { style={{
logEndRefs.current[story.story_id] = el; maxHeight: "300px",
overflowY: "auto",
background: "#111",
borderRadius: "6px",
padding: "8px",
fontFamily: "monospace",
fontSize: "0.8em",
lineHeight: "1.5",
color: "#ccc",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}} }}
/> >
{a.log.length === 0 ? (
<span style={{ color: "#555" }}>
{a.status === "pending" || a.status === "running"
? "Waiting for output..."
: "No output captured."}
</span>
) : (
a.log.map((line, i) => (
<div
key={`log-${key}-${i.toString()}`}
style={{
color: line.startsWith("[ERROR]")
? "#ff7b72"
: "#ccc",
}}
>
{line}
</div>
))
)}
<div
ref={(el) => {
logEndRefs.current[key] = el;
}}
/>
</div>
</div> </div>
</div> );
)} })}
</div> </div>
); );
})} })}

View File

@@ -475,7 +475,11 @@ describe("Chat review panel", () => {
it("fetches upcoming stories on mount and renders panel", async () => { it("fetches upcoming stories on mount and renders panel", async () => {
mockedWorkflow.getUpcomingStories.mockResolvedValueOnce({ mockedWorkflow.getUpcomingStories.mockResolvedValueOnce({
stories: [ stories: [
{ story_id: "31_view_upcoming", name: "View Upcoming Stories", error: null }, {
story_id: "31_view_upcoming",
name: "View Upcoming Stories",
error: null,
},
{ story_id: "32_worktree", name: null, error: null }, { story_id: "32_worktree", name: null, error: null },
], ],
}); });

View File

@@ -43,7 +43,11 @@ describe("UpcomingPanel", () => {
it("renders story list with names", () => { it("renders story list with names", () => {
const stories: UpcomingStory[] = [ const stories: UpcomingStory[] = [
{ story_id: "31_view_upcoming", name: "View Upcoming Stories", error: null }, {
story_id: "31_view_upcoming",
name: "View Upcoming Stories",
error: null,
},
{ story_id: "32_worktree", name: "Worktree Orchestration", error: null }, { story_id: "32_worktree", name: "Worktree Orchestration", error: null },
]; ];
render(<UpcomingPanel {...baseProps} stories={stories} />); render(<UpcomingPanel {...baseProps} stories={stories} />);

View File

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

View File

@@ -1,11 +1,13 @@
use serde::Deserialize; use serde::Deserialize;
use std::collections::HashSet;
use std::path::Path; use std::path::Path;
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct ProjectConfig { pub struct ProjectConfig {
#[serde(default)] #[serde(default)]
pub component: Vec<ComponentConfig>, pub component: Vec<ComponentConfig>,
pub agent: Option<AgentConfig>, #[serde(default)]
pub agent: Vec<AgentConfig>,
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
@@ -21,18 +23,36 @@ pub struct ComponentConfig {
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
pub struct AgentConfig { pub struct AgentConfig {
#[serde(default = "default_agent_name")]
pub name: String,
#[serde(default)]
pub role: String,
#[serde(default = "default_agent_command")] #[serde(default = "default_agent_command")]
pub command: String, pub command: String,
#[serde(default)] #[serde(default)]
pub args: Vec<String>, pub args: Vec<String>,
#[serde(default = "default_agent_prompt")] #[serde(default = "default_agent_prompt")]
pub prompt: String, 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 { fn default_path() -> String {
".".to_string() ".".to_string()
} }
fn default_agent_name() -> String {
"default".to_string()
}
fn default_agent_command() -> String { fn default_agent_command() -> String {
"claude".to_string() "claude".to_string()
} }
@@ -41,15 +61,30 @@ fn default_agent_prompt() -> String {
"Read .story_kit/README.md, then pick up story {{story_id}}".to_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 { impl Default for ProjectConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
component: Vec::new(), component: Vec::new(),
agent: Some(AgentConfig { agent: vec![AgentConfig {
name: default_agent_name(),
role: String::new(),
command: default_agent_command(), command: default_agent_command(),
args: vec![], args: vec![],
prompt: default_agent_prompt(), prompt: default_agent_prompt(),
}), model: None,
allowed_tools: None,
max_turns: None,
max_budget_usd: None,
system_prompt: None,
}],
} }
} }
} }
@@ -57,6 +92,9 @@ impl Default for ProjectConfig {
impl ProjectConfig { impl ProjectConfig {
/// Load from `.story_kit/project.toml` relative to the given root. /// Load from `.story_kit/project.toml` relative to the given root.
/// Falls back to sensible defaults if the file doesn't exist. /// 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> { pub fn load(project_root: &Path) -> Result<Self, String> {
let config_path = project_root.join(".story_kit/project.toml"); let config_path = project_root.join(".story_kit/project.toml");
if !config_path.exists() { if !config_path.exists() {
@@ -64,27 +102,152 @@ impl ProjectConfig {
} }
let content = let content =
std::fs::read_to_string(&config_path).map_err(|e| format!("Read config: {e}"))?; std::fs::read_to_string(&config_path).map_err(|e| format!("Read config: {e}"))?;
toml::from_str(&content).map_err(|e| format!("Parse config: {e}")) Self::parse(&content)
} }
/// Render template variables in agent args and prompt. /// 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( pub fn render_agent_args(
&self, &self,
worktree_path: &str, worktree_path: &str,
story_id: &str, story_id: &str,
) -> Option<(String, Vec<String>, String)> { agent_name: Option<&str>,
let agent = self.agent.as_ref()?; ) -> 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| { let render = |s: &str| {
s.replace("{{worktree_path}}", worktree_path) s.replace("{{worktree_path}}", worktree_path)
.replace("{{story_id}}", story_id) .replace("{{story_id}}", story_id)
}; };
let command = render(&agent.command); let command = render(&agent.command);
let args: Vec<String> = agent.args.iter().map(|a| render(a)).collect(); let mut args: Vec<String> = agent.args.iter().map(|a| render(a)).collect();
let prompt = render(&agent.prompt); let prompt = render(&agent.prompt);
Some((command, args, 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -94,12 +257,204 @@ mod tests {
fn default_config_when_missing() { fn default_config_when_missing() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let config = ProjectConfig::load(tmp.path()).unwrap(); let config = ProjectConfig::load(tmp.path()).unwrap();
assert!(config.agent.is_some()); assert_eq!(config.agent.len(), 1);
assert_eq!(config.agent[0].name, "default");
assert!(config.component.is_empty()); assert!(config.component.is_empty());
} }
#[test] #[test]
fn parse_project_toml() { 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 tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".story_kit"); let sk = tmp.path().join(".story_kit");
fs::create_dir_all(&sk).unwrap(); fs::create_dir_all(&sk).unwrap();
@@ -117,10 +472,12 @@ name = "frontend"
path = "frontend" path = "frontend"
setup = ["pnpm install"] setup = ["pnpm install"]
[agent] [[agent]]
name = "main"
command = "claude" command = "claude"
args = ["--print", "--directory", "{{worktree_path}}"] args = ["--print", "--directory", "{{worktree_path}}"]
prompt = "Pick up story {{story_id}}" prompt = "Pick up story {{story_id}}"
model = "sonnet"
"#, "#,
) )
.unwrap(); .unwrap();
@@ -129,17 +486,8 @@ prompt = "Pick up story {{story_id}}"
assert_eq!(config.component.len(), 2); assert_eq!(config.component.len(), 2);
assert_eq!(config.component[0].name, "server"); assert_eq!(config.component[0].name, "server");
assert_eq!(config.component[1].setup, vec!["pnpm install"]); assert_eq!(config.component[1].setup, vec!["pnpm install"]);
assert_eq!(config.agent.len(), 1);
let agent = config.agent.unwrap(); assert_eq!(config.agent[0].name, "main");
assert_eq!(agent.command, "claude"); assert_eq!(config.agent[0].model, Some("sonnet".to_string()));
}
#[test]
fn render_template_vars() {
let config = ProjectConfig::default();
let (cmd, args, prompt) = config.render_agent_args("/tmp/wt", "42_foo").unwrap();
assert_eq!(cmd, "claude");
assert!(args.is_empty());
assert!(prompt.contains("42_foo"));
} }
} }

View File

@@ -1,3 +1,4 @@
use crate::config::ProjectConfig;
use crate::http::context::{AppContext, OpenApiResult, bad_request}; use crate::http::context::{AppContext, OpenApiResult, bad_request};
use poem_openapi::{Object, OpenApi, Tags, payload::Json}; use poem_openapi::{Object, OpenApi, Tags, payload::Json};
use serde::Serialize; use serde::Serialize;
@@ -9,18 +10,36 @@ enum AgentsTags {
} }
#[derive(Object)] #[derive(Object)]
struct StoryIdPayload { struct StartAgentPayload {
story_id: String, story_id: String,
agent_name: Option<String>,
}
#[derive(Object)]
struct StopAgentPayload {
story_id: String,
agent_name: String,
} }
#[derive(Object, Serialize)] #[derive(Object, Serialize)]
struct AgentInfoResponse { struct AgentInfoResponse {
story_id: String, story_id: String,
agent_name: String,
status: String, status: String,
session_id: Option<String>, session_id: Option<String>,
worktree_path: 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 struct AgentsApi {
pub ctx: Arc<AppContext>, pub ctx: Arc<AppContext>,
} }
@@ -28,10 +47,11 @@ pub struct AgentsApi {
#[OpenApi(tag = "AgentsTags::Agents")] #[OpenApi(tag = "AgentsTags::Agents")]
impl AgentsApi { impl AgentsApi {
/// Start an agent for a given story (creates worktree, runs setup, spawns agent). /// 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")] #[oai(path = "/agents/start", method = "post")]
async fn start_agent( async fn start_agent(
&self, &self,
payload: Json<StoryIdPayload>, payload: Json<StartAgentPayload>,
) -> OpenApiResult<Json<AgentInfoResponse>> { ) -> OpenApiResult<Json<AgentInfoResponse>> {
let project_root = self let project_root = self
.ctx .ctx
@@ -42,12 +62,17 @@ impl AgentsApi {
let info = self let info = self
.ctx .ctx
.agents .agents
.start_agent(&project_root, &payload.0.story_id) .start_agent(
&project_root,
&payload.0.story_id,
payload.0.agent_name.as_deref(),
)
.await .await
.map_err(bad_request)?; .map_err(bad_request)?;
Ok(Json(AgentInfoResponse { Ok(Json(AgentInfoResponse {
story_id: info.story_id, story_id: info.story_id,
agent_name: info.agent_name,
status: info.status.to_string(), status: info.status.to_string(),
session_id: info.session_id, session_id: info.session_id,
worktree_path: info.worktree_path, worktree_path: info.worktree_path,
@@ -56,7 +81,7 @@ impl AgentsApi {
/// Stop a running agent and clean up its worktree. /// Stop a running agent and clean up its worktree.
#[oai(path = "/agents/stop", method = "post")] #[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 let project_root = self
.ctx .ctx
.agents .agents
@@ -65,7 +90,11 @@ impl AgentsApi {
self.ctx self.ctx
.agents .agents
.stop_agent(&project_root, &payload.0.story_id) .stop_agent(
&project_root,
&payload.0.story_id,
&payload.0.agent_name,
)
.await .await
.map_err(bad_request)?; .map_err(bad_request)?;
@@ -82,6 +111,7 @@ impl AgentsApi {
.into_iter() .into_iter()
.map(|info| AgentInfoResponse { .map(|info| AgentInfoResponse {
story_id: info.story_id, story_id: info.story_id,
agent_name: info.agent_name,
status: info.status.to_string(), status: info.status.to_string(),
session_id: info.session_id, session_id: info.session_id,
worktree_path: info.worktree_path, worktree_path: info.worktree_path,
@@ -89,4 +119,62 @@ impl AgentsApi {
.collect(), .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(),
))
}
} }

View File

@@ -5,16 +5,16 @@ use poem::web::{Data, Path};
use poem::{Body, IntoResponse, Response}; use poem::{Body, IntoResponse, Response};
use std::sync::Arc; 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 /// Streams `AgentEvent`s as Server-Sent Events. Each event is JSON-encoded
/// with `data:` prefix and double newline terminator per the SSE spec. /// with `data:` prefix and double newline terminator per the SSE spec.
#[handler] #[handler]
pub async fn agent_stream( pub async fn agent_stream(
Path(story_id): Path<String>, Path((story_id, agent_name)): Path<(String, String)>,
ctx: Data<&Arc<AppContext>>, ctx: Data<&Arc<AppContext>>,
) -> impl IntoResponse { ) -> 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, Ok(rx) => rx,
Err(e) => { Err(e) => {
return Response::builder() return Response::builder()

View File

@@ -35,7 +35,7 @@ pub fn build_routes(ctx: AppContext) -> impl poem::Endpoint {
.nest("/docs", docs_service.swagger_ui()) .nest("/docs", docs_service.swagger_ui())
.at("/ws", get(ws::ws_handler)) .at("/ws", get(ws::ws_handler))
.at( .at(
"/agents/:story_id/stream", "/agents/:story_id/:agent_name/stream",
get(agents_sse::agent_stream), get(agents_sse::agent_stream),
) )
.at("/health", get(health::health)) .at("/health", get(health::health))

View File

@@ -44,6 +44,8 @@ fn remove_port_file(path: &Path) {
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), std::io::Error> { async fn main() -> Result<(), std::io::Error> {
let app_state = Arc::new(SessionState::default()); let app_state = Arc::new(SessionState::default());
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
*app_state.project_root.lock().unwrap() = Some(cwd.clone());
let store = Arc::new( let store = Arc::new(
JsonFileStore::from_path(PathBuf::from("store.json")).map_err(std::io::Error::other)?, JsonFileStore::from_path(PathBuf::from("store.json")).map_err(std::io::Error::other)?,
); );
@@ -69,7 +71,10 @@ async fn main() -> Result<(), std::io::Error> {
println!("\x1b[96;1mFrontend:\x1b[0m \x1b[94mhttp://{addr}\x1b[0m"); println!("\x1b[96;1mFrontend:\x1b[0m \x1b[94mhttp://{addr}\x1b[0m");
println!("\x1b[92;1mOpenAPI Docs:\x1b[0m \x1b[94mhttp://{addr}/docs\x1b[0m"); println!("\x1b[92;1mOpenAPI Docs:\x1b[0m \x1b[94mhttp://{addr}/docs\x1b[0m");
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); // Validate agent config at startup — panic on invalid project.toml.
config::ProjectConfig::load(&cwd)
.unwrap_or_else(|e| panic!("Invalid project.toml: {e}"));
let port_file = write_port_file(&cwd, port); let port_file = write_port_file(&cwd, port);
let result = Server::new(TcpListener::bind(&addr)).run(app).await; let result = Server::new(TcpListener::bind(&addr)).run(app).await;
@@ -100,6 +105,28 @@ mod tests {
assert_eq!(parse_port(Some("not_a_number".to_string())), 3001); assert_eq!(parse_port(Some("not_a_number".to_string())), 3001);
} }
#[test]
#[should_panic(expected = "Invalid project.toml: Duplicate agent name")]
fn panics_on_duplicate_agent_names() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".story_kit");
std::fs::create_dir_all(&sk).unwrap();
std::fs::write(
sk.join("project.toml"),
r#"
[[agent]]
name = "coder"
[[agent]]
name = "coder"
"#,
)
.unwrap();
config::ProjectConfig::load(tmp.path())
.unwrap_or_else(|e| panic!("Invalid project.toml: {e}"));
}
#[test] #[test]
fn write_and_remove_port_file() { fn write_and_remove_port_file() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();