tandem-core 0.4.16

Core types and helpers for the Tandem engine
Documentation
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use anyhow::Context;
use serde::{Deserialize, Serialize};
use tokio::fs;
use tokio::sync::RwLock;

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AgentMode {
    Primary,
    Subagent,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentDefinition {
    pub name: String,
    pub mode: AgentMode,
    #[serde(default)]
    pub hidden: bool,
    #[serde(default)]
    pub system_prompt: Option<String>,
    #[serde(default)]
    pub tools: Option<Vec<String>>,
    #[serde(default)]
    pub skills: Option<Vec<String>>,
}

#[derive(Debug, Clone, Deserialize)]
struct AgentFrontmatter {
    name: Option<String>,
    mode: Option<AgentMode>,
    hidden: Option<bool>,
    tools: Option<Vec<String>>,
    skills: Option<Vec<String>>,
}

#[derive(Clone)]
pub struct AgentRegistry {
    agents: Arc<RwLock<HashMap<String, AgentDefinition>>>,
    default_agent: String,
}

impl AgentRegistry {
    pub async fn new(workspace_root: impl Into<PathBuf>) -> anyhow::Result<Self> {
        let mut by_name = HashMap::new();
        for agent in default_agents() {
            by_name.insert(agent.name.clone(), agent);
        }

        let root: PathBuf = workspace_root.into();
        let custom = load_custom_agents(root.join(".tandem").join("agent")).await?;
        for agent in custom {
            by_name.insert(agent.name.clone(), agent);
        }

        Ok(Self {
            agents: Arc::new(RwLock::new(by_name)),
            default_agent: "build".to_string(),
        })
    }

    pub async fn list(&self) -> Vec<AgentDefinition> {
        let mut agents = self
            .agents
            .read()
            .await
            .values()
            .cloned()
            .collect::<Vec<_>>();
        agents.sort_by(|a, b| a.name.cmp(&b.name));
        agents
    }

    pub async fn get(&self, name: Option<&str>) -> AgentDefinition {
        let wanted = name.unwrap_or(&self.default_agent);
        let agents = self.agents.read().await;
        agents
            .get(wanted)
            .cloned()
            .or_else(|| agents.get(&self.default_agent).cloned())
            .unwrap_or_else(|| AgentDefinition {
                name: self.default_agent.clone(),
                mode: AgentMode::Primary,
                hidden: false,
                system_prompt: None,
                tools: None,
                skills: None,
            })
    }
}

fn default_agents() -> Vec<AgentDefinition> {
    vec![
        AgentDefinition {
            name: "build".to_string(),
            mode: AgentMode::Primary,
            hidden: false,
            system_prompt: Some(
                "You are a build-focused engineering agent. Prefer concrete implementation. \
You are running inside a local workspace and have tool access. \
When the user asks about the current project/repo/files, inspect the workspace first \
using tools (ls/glob/read/search) and then answer with concrete findings. \
Do not ask generic clarification questions before attempting local inspection, unless \
tool permissions are denied."
                    .to_string(),
            ),
            tools: None,
            skills: None,
        },
        AgentDefinition {
            name: "plan".to_string(),
            mode: AgentMode::Primary,
            hidden: false,
            system_prompt: Some(
                "You are a planning-focused engineering agent.\n\
Produce structured task plans and keep state with `todo_write`.\n\
When details are missing, do NOT ask plain-text questions; call the `question` tool with structured options.\n\
After receiving answers, continue planning and update todos."
                    .to_string(),
            ),
            tools: None,
            skills: None,
        },
        AgentDefinition {
            name: "pack_builder".to_string(),
            mode: AgentMode::Primary,
            hidden: false,
            system_prompt: Some(
                "You are Pack Builder. Build Tandem packs from plain-English goals.\n\
Treat external data sources/actions as MCP-first: resolve to concrete MCP servers/tools where possible.\n\
Prefer explicit MCP tool IDs in generated missions and agent instructions.\n\
Use built-in tools only when no viable MCP mapping exists, and call that out clearly.\n\
Always provide a preview summary (connectors, tools, secrets, schedule) before apply.\n\
When connector choice, auth, or secrets block progress, call the `question` tool to ask one structured follow-up question.\n\
Do not apply until the user confirms."
                    .to_string(),
            ),
            tools: Some(vec![
                "pack_builder".to_string(),
                "question".to_string(),
                "websearch".to_string(),
                "webfetch".to_string(),
            ]),
            skills: None,
        },
        AgentDefinition {
            name: "explore".to_string(),
            mode: AgentMode::Subagent,
            hidden: false,
            system_prompt: Some(
                "You are an exploration agent. Gather evidence from the codebase quickly. \
Start by inspecting local files when a user asks project-understanding questions. \
Use ls/glob/read/search and summarize what you find. \
Only ask for clarification after an initial workspace pass if results are insufficient."
                    .to_string(),
            ),
            tools: None,
            skills: None,
        },
        AgentDefinition {
            name: "general".to_string(),
            mode: AgentMode::Subagent,
            hidden: false,
            system_prompt: Some(
                "You are a general-purpose helper agent with local workspace tool access. \
For requests about the current project/codebase, inspect the workspace first \
(ls/glob/read/search) and provide a grounded answer from findings. \
Avoid asking broad context questions before attempting local inspection."
                    .to_string(),
            ),
            tools: None,
            skills: None,
        },
        AgentDefinition {
            name: "compaction".to_string(),
            mode: AgentMode::Primary,
            hidden: true,
            system_prompt: Some(
                "You summarize long conversations into compact context.".to_string(),
            ),
            tools: Some(vec![]),
            skills: Some(vec![]),
        },
        AgentDefinition {
            name: "title".to_string(),
            mode: AgentMode::Primary,
            hidden: true,
            system_prompt: Some("You generate concise, descriptive session titles.".to_string()),
            tools: Some(vec![]),
            skills: Some(vec![]),
        },
        AgentDefinition {
            name: "summary".to_string(),
            mode: AgentMode::Primary,
            hidden: true,
            system_prompt: Some("You produce factual summaries of session content.".to_string()),
            tools: Some(vec![]),
            skills: Some(vec![]),
        },
    ]
}

async fn load_custom_agents(dir: PathBuf) -> anyhow::Result<Vec<AgentDefinition>> {
    let mut out = Vec::new();
    let mut entries = match fs::read_dir(&dir).await {
        Ok(rd) => rd,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(out),
        Err(err) => {
            return Err(err).with_context(|| format!("failed to read {}", dir.display()));
        }
    };

    while let Some(entry) = entries.next_entry().await? {
        let path = entry.path();
        let Some(ext) = path.extension().and_then(|v| v.to_str()) else {
            continue;
        };
        if ext != "md" {
            continue;
        }
        let raw = fs::read_to_string(&path).await?;
        if let Some(agent) = parse_agent_markdown(&raw, &path) {
            out.push(agent);
        }
    }

    Ok(out)
}

fn parse_agent_markdown(raw: &str, path: &Path) -> Option<AgentDefinition> {
    let trimmed = raw.trim_start();
    if !trimmed.starts_with("---") {
        return None;
    }
    let mut parts = trimmed.splitn(3, "---");
    let _ = parts.next();
    let frontmatter = parts.next()?.trim();
    let body = parts.next()?.trim().to_string();
    let parsed: AgentFrontmatter = serde_yaml::from_str(frontmatter).ok()?;
    let default_name = path.file_stem()?.to_string_lossy().to_string();
    Some(AgentDefinition {
        name: parsed.name.unwrap_or(default_name),
        mode: parsed.mode.unwrap_or(AgentMode::Subagent),
        hidden: parsed.hidden.unwrap_or(false),
        system_prompt: if body.is_empty() { None } else { Some(body) },
        tools: parsed.tools,
        skills: parsed.skills,
    })
}