use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use anyhow::Context as _;
use serde::Deserialize;
#[derive(Debug, Clone, PartialEq)]
pub struct Sidecar {
pub agents: BTreeMap<String, AgentDef>,
pub tools: BTreeMap<String, ToolDefToml>,
pub eval: Option<EvalSection>,
pub base_dir: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct AgentDef {
pub description: Option<String>,
pub instructions: InstructionsDef,
pub model: ModelDef,
pub max_turns: Option<u32>,
#[serde(default)]
pub tools: Vec<String>,
#[serde(default)]
pub handoffs: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(untagged)]
pub enum InstructionsDef {
Inline(String),
File {
file: PathBuf,
},
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(tag = "provider", rename_all = "lowercase")]
pub enum ModelDef {
Openai {
id: String,
},
Anthropic {
id: String,
},
Mock {
script: PathBuf,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct ToolDefToml {
pub description: String,
pub params: serde_json::Value,
pub script: Option<PathBuf>,
pub inline: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct EvalSection {
pub evaluators: Vec<String>,
pub json_schema: Option<JsonSchemaCfg>,
pub llm_judge: Option<LlmJudgeCfg>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct JsonSchemaCfg {
pub schema: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct LlmJudgeCfg {
pub model: ModelDef,
pub rubric: Option<String>,
pub threshold: Option<f64>,
}
#[derive(Debug, Deserialize)]
struct RawSidecar {
#[serde(default)]
agents: BTreeMap<String, AgentDef>,
#[serde(default)]
tools: BTreeMap<String, RawToolDef>,
eval: Option<EvalSection>,
}
#[derive(Debug, Deserialize)]
struct RawToolDef {
description: String,
params: toml::Value,
script: Option<PathBuf>,
inline: Option<String>,
}
const KNOWN_EVALUATORS: &[&str] = &["exact_match", "json_schema", "llm_judge", "tool_trajectory"];
impl Sidecar {
pub fn load(path: &Path) -> anyhow::Result<Self> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("failed to read sidecar file '{}'", path.display()))?;
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
Self::parse(&text, base_dir)
}
pub fn parse(text: &str, base_dir: &Path) -> anyhow::Result<Self> {
let raw: RawSidecar = toml::from_str(text).context("failed to parse sidecar TOML")?;
let mut tools = BTreeMap::new();
for (name, raw_tool) in raw.tools {
let params = serde_json::to_value(&raw_tool.params)
.with_context(|| format!("tool '{name}': failed to convert 'params' to JSON"))?;
tools.insert(
name,
ToolDefToml {
description: raw_tool.description,
params,
script: raw_tool.script,
inline: raw_tool.inline,
},
);
}
let sidecar = Sidecar {
agents: raw.agents,
tools,
eval: raw.eval,
base_dir: base_dir.to_path_buf(),
};
sidecar.validate()?;
Ok(sidecar)
}
pub fn first_agent(&self) -> Option<&str> {
self.agents.keys().next().map(String::as_str)
}
fn validate(&self) -> anyhow::Result<()> {
for (agent_name, agent) in &self.agents {
for tool_name in &agent.tools {
if !self.tools.contains_key(tool_name) {
anyhow::bail!("agent '{agent_name}' references unknown tool '{tool_name}'");
}
}
for handoff in &agent.handoffs {
if handoff == agent_name {
anyhow::bail!(
"agent '{agent_name}' declares a handoff to itself ('{handoff}')"
);
}
if !self.agents.contains_key(handoff) {
anyhow::bail!("agent '{agent_name}' references unknown handoff '{handoff}'");
}
}
}
validate_handoffs_acyclic(&self.agents)?;
for (tool_name, tool) in &self.tools {
match (&tool.script, &tool.inline) {
(Some(_), Some(_)) => anyhow::bail!(
"tool '{tool_name}' declares both 'script' and 'inline' — exactly one is required"
),
(None, None) => anyhow::bail!(
"tool '{tool_name}' declares neither 'script' nor 'inline' — exactly one is required"
),
_ => {}
}
if !tool.params.is_object() {
anyhow::bail!("tool '{tool_name}': 'params' must be a JSON object");
}
}
if let Some(eval) = &self.eval {
for evaluator in &eval.evaluators {
if !KNOWN_EVALUATORS.contains(&evaluator.as_str()) {
anyhow::bail!("unknown evaluator '{evaluator}'");
}
if evaluator == "json_schema" && eval.json_schema.is_none() {
anyhow::bail!(
"evaluator 'json_schema' is declared but '[eval.json_schema]' is missing"
);
}
if evaluator == "llm_judge" && eval.llm_judge.is_none() {
anyhow::bail!(
"evaluator 'llm_judge' is declared but '[eval.llm_judge]' is missing"
);
}
}
}
Ok(())
}
}
fn validate_handoffs_acyclic(agents: &BTreeMap<String, AgentDef>) -> anyhow::Result<()> {
let mut visited: HashSet<&str> = HashSet::new();
for start in agents.keys() {
if visited.contains(start.as_str()) {
continue;
}
let mut stack: Vec<&str> = Vec::new();
visit_handoffs(start, agents, &mut visited, &mut stack)?;
}
Ok(())
}
fn visit_handoffs<'a>(
name: &'a str,
agents: &'a BTreeMap<String, AgentDef>,
visited: &mut HashSet<&'a str>,
stack: &mut Vec<&'a str>,
) -> anyhow::Result<()> {
if stack.contains(&name) {
anyhow::bail!("handoff cycle detected involving '{name}' — declare one-way handoff chains");
}
if visited.contains(name) {
return Ok(());
}
stack.push(name);
if let Some(agent) = agents.get(name) {
for next in &agent.handoffs {
visit_handoffs(next.as_str(), agents, visited, stack)?;
}
}
stack.pop();
visited.insert(name);
Ok(())
}