1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use anyhow::Context;
6use serde::{Deserialize, Serialize};
7use tokio::fs;
8use tokio::sync::RwLock;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(rename_all = "lowercase")]
12pub enum AgentMode {
13 Primary,
14 Subagent,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct AgentDefinition {
19 pub name: String,
20 pub mode: AgentMode,
21 #[serde(default)]
22 pub hidden: bool,
23 #[serde(default)]
24 pub system_prompt: Option<String>,
25 #[serde(default)]
26 pub tools: Option<Vec<String>>,
27 #[serde(default)]
28 pub skills: Option<Vec<String>>,
29}
30
31#[derive(Debug, Clone, Deserialize)]
32struct AgentFrontmatter {
33 name: Option<String>,
34 mode: Option<AgentMode>,
35 hidden: Option<bool>,
36 tools: Option<Vec<String>>,
37 skills: Option<Vec<String>>,
38}
39
40#[derive(Clone)]
41pub struct AgentRegistry {
42 agents: Arc<RwLock<HashMap<String, AgentDefinition>>>,
43 default_agent: String,
44}
45
46impl AgentRegistry {
47 pub async fn new(workspace_root: impl Into<PathBuf>) -> anyhow::Result<Self> {
48 let mut by_name = HashMap::new();
49 for agent in default_agents() {
50 by_name.insert(agent.name.clone(), agent);
51 }
52
53 let root: PathBuf = workspace_root.into();
54 let custom = load_custom_agents(root.join(".tandem").join("agent")).await?;
55 for agent in custom {
56 by_name.insert(agent.name.clone(), agent);
57 }
58
59 Ok(Self {
60 agents: Arc::new(RwLock::new(by_name)),
61 default_agent: "build".to_string(),
62 })
63 }
64
65 pub async fn list(&self) -> Vec<AgentDefinition> {
66 let mut agents = self
67 .agents
68 .read()
69 .await
70 .values()
71 .cloned()
72 .collect::<Vec<_>>();
73 agents.sort_by(|a, b| a.name.cmp(&b.name));
74 agents
75 }
76
77 pub async fn get(&self, name: Option<&str>) -> AgentDefinition {
78 let wanted = name.unwrap_or(&self.default_agent);
79 let agents = self.agents.read().await;
80 agents
81 .get(wanted)
82 .cloned()
83 .or_else(|| agents.get(&self.default_agent).cloned())
84 .unwrap_or_else(|| AgentDefinition {
85 name: self.default_agent.clone(),
86 mode: AgentMode::Primary,
87 hidden: false,
88 system_prompt: None,
89 tools: None,
90 skills: None,
91 })
92 }
93}
94
95fn default_agents() -> Vec<AgentDefinition> {
96 vec![
97 AgentDefinition {
98 name: "build".to_string(),
99 mode: AgentMode::Primary,
100 hidden: false,
101 system_prompt: Some(
102 "You are a build-focused engineering agent. Prefer concrete implementation. \
103You are running inside a local workspace and have tool access. \
104When the user asks about the current project/repo/files, inspect the workspace first \
105using tools (ls/glob/read/search) and then answer with concrete findings. \
106Do not ask generic clarification questions before attempting local inspection, unless \
107tool permissions are denied."
108 .to_string(),
109 ),
110 tools: None,
111 skills: None,
112 },
113 AgentDefinition {
114 name: "plan".to_string(),
115 mode: AgentMode::Primary,
116 hidden: false,
117 system_prompt: Some(
118 "You are a planning-focused engineering agent.\n\
119Produce structured task plans and keep state with `todo_write`.\n\
120When details are missing, do NOT ask plain-text questions; call the `question` tool with structured options.\n\
121After receiving answers, continue planning and update todos."
122 .to_string(),
123 ),
124 tools: None,
125 skills: None,
126 },
127 AgentDefinition {
128 name: "explore".to_string(),
129 mode: AgentMode::Subagent,
130 hidden: false,
131 system_prompt: Some(
132 "You are an exploration agent. Gather evidence from the codebase quickly. \
133Start by inspecting local files when a user asks project-understanding questions. \
134Use ls/glob/read/search and summarize what you find. \
135Only ask for clarification after an initial workspace pass if results are insufficient."
136 .to_string(),
137 ),
138 tools: None,
139 skills: None,
140 },
141 AgentDefinition {
142 name: "general".to_string(),
143 mode: AgentMode::Subagent,
144 hidden: false,
145 system_prompt: Some(
146 "You are a general-purpose helper agent with local workspace tool access. \
147For requests about the current project/codebase, inspect the workspace first \
148(ls/glob/read/search) and provide a grounded answer from findings. \
149Avoid asking broad context questions before attempting local inspection."
150 .to_string(),
151 ),
152 tools: None,
153 skills: None,
154 },
155 AgentDefinition {
156 name: "compaction".to_string(),
157 mode: AgentMode::Primary,
158 hidden: true,
159 system_prompt: Some(
160 "You summarize long conversations into compact context.".to_string(),
161 ),
162 tools: Some(vec![]),
163 skills: Some(vec![]),
164 },
165 AgentDefinition {
166 name: "title".to_string(),
167 mode: AgentMode::Primary,
168 hidden: true,
169 system_prompt: Some("You generate concise, descriptive session titles.".to_string()),
170 tools: Some(vec![]),
171 skills: Some(vec![]),
172 },
173 AgentDefinition {
174 name: "summary".to_string(),
175 mode: AgentMode::Primary,
176 hidden: true,
177 system_prompt: Some("You produce factual summaries of session content.".to_string()),
178 tools: Some(vec![]),
179 skills: Some(vec![]),
180 },
181 ]
182}
183
184async fn load_custom_agents(dir: PathBuf) -> anyhow::Result<Vec<AgentDefinition>> {
185 let mut out = Vec::new();
186 let mut entries = match fs::read_dir(&dir).await {
187 Ok(rd) => rd,
188 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(out),
189 Err(err) => {
190 return Err(err).with_context(|| format!("failed to read {}", dir.display()));
191 }
192 };
193
194 while let Some(entry) = entries.next_entry().await? {
195 let path = entry.path();
196 let Some(ext) = path.extension().and_then(|v| v.to_str()) else {
197 continue;
198 };
199 if ext != "md" {
200 continue;
201 }
202 let raw = fs::read_to_string(&path).await?;
203 if let Some(agent) = parse_agent_markdown(&raw, &path) {
204 out.push(agent);
205 }
206 }
207
208 Ok(out)
209}
210
211fn parse_agent_markdown(raw: &str, path: &Path) -> Option<AgentDefinition> {
212 let trimmed = raw.trim_start();
213 if !trimmed.starts_with("---") {
214 return None;
215 }
216 let mut parts = trimmed.splitn(3, "---");
217 let _ = parts.next();
218 let frontmatter = parts.next()?.trim();
219 let body = parts.next()?.trim().to_string();
220 let parsed: AgentFrontmatter = serde_yaml::from_str(frontmatter).ok()?;
221 let default_name = path.file_stem()?.to_string_lossy().to_string();
222 Some(AgentDefinition {
223 name: parsed.name.unwrap_or(default_name),
224 mode: parsed.mode.unwrap_or(AgentMode::Subagent),
225 hidden: parsed.hidden.unwrap_or(false),
226 system_prompt: if body.is_empty() { None } else { Some(body) },
227 tools: parsed.tools,
228 skills: parsed.skills,
229 })
230}