use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use std::fmt;
use crate::handover::RecoveryPolicy;
use crate::mcp::McpServer;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
#[serde(transparent)]
pub struct AgentName(String);
impl AgentName {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
Self(name.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for AgentName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl From<&str> for AgentName {
fn from(value: &str) -> Self {
Self(value.to_owned())
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Access {
ReadOnly,
#[default]
ReadWrite,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PromptSpec {
Inline(String),
File(PathBuf),
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Agent {
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub purpose: Option<String>,
#[serde(default)]
pub runner: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub prompt: Option<String>,
#[serde(default)]
pub prompt_file: Option<PathBuf>,
#[serde(default)]
pub access: Access,
#[serde(default)]
pub entry: bool,
#[serde(default)]
pub resident: bool,
#[serde(default)]
pub fuel_usd: Option<f64>,
#[serde(default)]
pub env_from: Vec<String>,
#[serde(default)]
pub mcp: BTreeMap<String, McpServer>,
#[serde(default)]
pub recovery: RecoveryPolicy,
#[serde(default)]
pub work_dir: Option<PathBuf>,
}
impl Agent {
pub fn prompt_spec(&self) -> Result<PromptSpec, PromptSpecError> {
match (self.prompt.as_ref(), self.prompt_file.as_ref()) {
(Some(text), None) => Ok(PromptSpec::Inline(text.clone())),
(None, Some(path)) => Ok(PromptSpec::File(path.clone())),
(Some(_), Some(_)) => Err(PromptSpecError::Both),
(None, None) => Err(PromptSpecError::Neither),
}
}
#[must_use]
pub fn description_or_placeholder(&self) -> &str {
self.description
.as_deref()
.unwrap_or("(no description configured)")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum PromptSpecError {
#[error("neither `prompt` nor `prompt_file` is set")]
Neither,
#[error("both `prompt` and `prompt_file` are set; exactly one is allowed")]
Both,
}
#[cfg(test)]
mod tests {
use super::*;
fn agent(body: &str) -> Agent {
toml::from_str(body).expect("agent parses")
}
#[test]
fn a_name_round_trips_through_display() {
let name = AgentName::new("analyst");
assert_eq!(name.as_str(), "analyst");
assert_eq!(name.to_string(), "analyst");
assert_eq!(AgentName::from("analyst"), name);
}
#[test]
fn an_inline_prompt_is_recognised() {
let agent = agent(r#"prompt = "do the thing""#);
assert_eq!(
agent.prompt_spec(),
Ok(PromptSpec::Inline("do the thing".to_owned()))
);
}
#[test]
fn a_prompt_file_is_recognised() {
let agent = agent(r#"prompt_file = "prompts/tester.md""#);
assert_eq!(
agent.prompt_spec(),
Ok(PromptSpec::File(PathBuf::from("prompts/tester.md")))
);
}
#[test]
fn giving_both_prompt_forms_is_rejected() {
let agent = agent(
r#"
prompt = "inline"
prompt_file = "prompts/tester.md"
"#,
);
assert_eq!(agent.prompt_spec(), Err(PromptSpecError::Both));
}
#[test]
fn giving_neither_prompt_form_is_rejected() {
let agent = agent(r#"description = "does something""#);
assert_eq!(agent.prompt_spec(), Err(PromptSpecError::Neither));
}
#[test]
fn description_and_purpose_are_optional_but_preserved() {
let agent = agent(
r#"
description = "Turns a request into a work item"
purpose = "Longer explanation of when to route here."
prompt = "analyse"
"#,
);
assert_eq!(
agent.description.as_deref(),
Some("Turns a request into a work item")
);
assert!(agent.purpose.is_some());
assert_eq!(
agent.description_or_placeholder(),
"Turns a request into a work item"
);
}
#[test]
fn a_missing_description_falls_back_to_a_placeholder() {
let agent = agent(r#"prompt = "analyse""#);
assert_eq!(
agent.description_or_placeholder(),
"(no description configured)"
);
}
}