Skip to main content

tandem_core/
agents.rs

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}