rho-coding-agent 1.3.0

A lightweight agent harness inspired by Pi
Documentation
//! Subagent presets and the run-status contract shared between a parent rho
//! process and the `rho run --preset` children it spawns.
//!
//! Presets are markdown files with YAML frontmatter. The body becomes extra
//! system-prompt instructions for the subagent. Discovery mirrors skill
//! discovery roots (`~/.rho/agents`, `~/.agents/agents`, and per-project
//! `.agents/agents`), with built-in presets appended last so user files with
//! the same name override them.

use std::{
    collections::HashSet,
    fs::{File, OpenOptions},
    io::Write,
    path::{Path, PathBuf},
};

use serde::{Deserialize, Serialize};

use crate::reasoning::ReasoningLevel;

pub const RESULT_FILE_NAME: &str = "result.json";
pub const LOG_FILE_NAME: &str = "log.txt";
pub const ATTACHMENT_FILE_NAME: &str = "events.jsonl";
pub const CANCEL_FILE_NAME: &str = "cancel.requested";

const BUILTIN_PRESETS: &[(&str, &str)] = &[
    ("explorer", include_str!("builtin_agents/explorer.md")),
    ("reviewer", include_str!("builtin_agents/reviewer.md")),
    ("worker", include_str!("builtin_agents/worker.md")),
];

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PresetSource {
    BuiltIn,
    File(PathBuf),
}

#[derive(Clone, Debug, PartialEq)]
pub struct Preset {
    pub name: String,
    pub description: String,
    pub model: Option<String>,
    pub provider: Option<String>,
    pub reasoning: Option<ReasoningLevel>,
    /// Tool names the subagent may use; `None` grants the full tool set.
    pub tools: Option<Vec<String>>,
    /// Markdown body, appended to the subagent's system prompt.
    pub prompt: String,
    pub source: PresetSource,
}

pub fn discover(cwd: &Path) -> Vec<Preset> {
    let home = crate::paths::home_dir();
    discover_with_home(cwd, home.as_deref())
}

pub fn discover_with_home(cwd: &Path, home: Option<&Path>) -> Vec<Preset> {
    let mut roots = Vec::new();
    if let Some(home) = home {
        roots.push(home.join(".rho").join("agents"));
        roots.push(home.join(".agents").join("agents"));
    }
    roots.extend(
        crate::workspace::project_ancestor_dirs(cwd)
            .into_iter()
            .rev()
            .map(|path| path.join(".agents").join("agents")),
    );

    let mut discovered: Vec<Preset> = roots
        .into_iter()
        .flat_map(|root| preset_paths(&root))
        .filter_map(|path| read_preset(&path).ok())
        .collect();
    discovered.extend(BUILTIN_PRESETS.iter().map(|(name, contents)| {
        parse_preset(name, contents, PresetSource::BuiltIn).expect("embedded presets must be valid")
    }));

    let mut seen = HashSet::new();
    discovered
        .into_iter()
        .filter(|preset| seen.insert(preset.name.clone()))
        .collect()
}

pub fn find(cwd: &Path, name: &str) -> anyhow::Result<Preset> {
    discover(cwd)
        .into_iter()
        .find(|preset| preset.name == name)
        .ok_or_else(|| anyhow::anyhow!("unknown subagent preset '{name}'"))
}

fn preset_paths(root: &Path) -> Vec<PathBuf> {
    let Ok(entries) = std::fs::read_dir(root) else {
        return Vec::new();
    };

    let mut paths: Vec<_> = entries
        .filter_map(Result::ok)
        .map(|entry| entry.path())
        .filter(|path| path.is_file() && path.extension().is_some_and(|ext| ext == "md"))
        .collect();
    paths.sort();
    paths
}

fn read_preset(path: &Path) -> anyhow::Result<Preset> {
    let contents = std::fs::read_to_string(path)?;
    let name = path
        .file_stem()
        .and_then(|stem| stem.to_str())
        .ok_or_else(|| anyhow::anyhow!("preset file has no valid name"))?;
    parse_preset(name, &contents, PresetSource::File(path.to_path_buf()))
}

fn parse_preset(name: &str, contents: &str, source: PresetSource) -> anyhow::Result<Preset> {
    validate_name(name)?;
    let (fields, body) = parse_frontmatter(contents)?;
    let field = |key: &str| {
        fields
            .iter()
            .find(|(k, _)| k == key)
            .map(|(_, value)| value.clone())
    };
    let description = field("description")
        .filter(|value| !value.is_empty())
        .ok_or_else(|| anyhow::anyhow!("preset '{name}' is missing a description"))?;
    if description.len() > 1024 {
        anyhow::bail!("preset description must be at most 1024 characters");
    }
    let reasoning = field("reasoning")
        .map(|value| {
            value
                .parse::<ReasoningLevel>()
                .map_err(|error| anyhow::anyhow!("preset '{name}': {error}"))
        })
        .transpose()?;
    let tools = field("tools").map(|value| parse_tool_list(&value));

    Ok(Preset {
        name: name.to_string(),
        description,
        model: field("model").filter(|value| !value.is_empty()),
        provider: field("provider").filter(|value| !value.is_empty()),
        reasoning,
        tools,
        prompt: body.trim().to_string(),
        source,
    })
}

fn parse_tool_list(value: &str) -> Vec<String> {
    value
        .trim()
        .trim_start_matches('[')
        .trim_end_matches(']')
        .split(',')
        .map(|item| item.trim().trim_matches('"').trim_matches('\'').to_string())
        .filter(|item| !item.is_empty())
        .collect()
}

/// Parses `---`-delimited frontmatter into key/value pairs plus the body.
fn parse_frontmatter(contents: &str) -> anyhow::Result<(Vec<(String, String)>, String)> {
    let mut lines = contents.lines();
    if lines.next() != Some("---") {
        anyhow::bail!("preset file must start with YAML frontmatter");
    }
    let mut fields: Vec<(String, String)> = Vec::new();
    let mut sequence_field = None;
    for line in lines.by_ref() {
        if line == "---" {
            let body: String = lines.collect::<Vec<_>>().join("\n");
            return Ok((fields, body));
        }
        if line.trim().is_empty() {
            continue;
        }
        if let (Some(key), Some(item)) = (sequence_field.as_deref(), line.trim().strip_prefix("- "))
        {
            if let Some((_, value)) = fields.iter_mut().find(|(field, _)| field == key) {
                if !value.is_empty() {
                    value.push(',');
                }
                value.push_str(&unquote(item.trim()));
            }
            continue;
        }
        if line.starts_with(' ') || line.starts_with('\t') {
            continue;
        }
        sequence_field = None;
        if let Some((key, value)) = line.split_once(':') {
            let key = key.trim().to_string();
            let value = unquote(value.trim());
            if value.is_empty() {
                sequence_field = Some(key.clone());
            }
            fields.push((key, value));
        }
    }
    anyhow::bail!("unterminated YAML frontmatter")
}

fn unquote(value: &str) -> String {
    if value.len() >= 2
        && ((value.starts_with('"') && value.ends_with('"'))
            || (value.starts_with('\'') && value.ends_with('\'')))
    {
        value[1..value.len() - 1].to_string()
    } else {
        value.to_string()
    }
}

fn validate_name(name: &str) -> anyhow::Result<()> {
    if name.is_empty() || name.len() > 64 {
        anyhow::bail!("preset name must be 1-64 characters");
    }
    let bytes = name.as_bytes();
    if bytes.first() == Some(&b'-') || bytes.last() == Some(&b'-') || name.contains("--") {
        anyhow::bail!("preset name must use single hyphen separators");
    }
    if !bytes
        .iter()
        .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
    {
        anyhow::bail!("preset name must be lowercase alphanumeric with hyphen separators");
    }
    Ok(())
}

/// State machine for a subagent run, persisted in the result file.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunState {
    #[default]
    Starting,
    Running,
    Ok,
    Error,
    Stopped,
}

impl RunState {
    pub fn is_terminal(self) -> bool {
        matches!(self, Self::Ok | Self::Error | Self::Stopped)
    }

    pub fn as_str(self) -> &'static str {
        match self {
            Self::Starting => "starting",
            Self::Running => "running",
            Self::Ok => "ok",
            Self::Error => "error",
            Self::Stopped => "stopped",
        }
    }
}

/// Contents of the `--output-file` a subagent writes atomically as it runs.
///
/// The parent process reads this file for status checks and completion
/// detection; the pane or log output is display-only.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct RunStatus {
    pub state: RunState,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub preset: Option<String>,
    #[serde(default)]
    pub turns: u64,
    #[serde(default)]
    pub input_tokens: u64,
    #[serde(default)]
    pub output_tokens: u64,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_activity: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_text: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub result: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub attachment_error: Option<String>,
}

/// Writes the status file atomically (temp file + rename) so readers never
/// observe a torn write.
pub fn write_status(path: &Path, status: &RunStatus) -> std::io::Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let contents = serde_json::to_vec_pretty(status)?;
    let file_name = path
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or(RESULT_FILE_NAME);
    let temp = path.with_file_name(format!(
        ".{file_name}.{}.tmp",
        uuid::Uuid::new_v4().simple()
    ));
    let result = (|| {
        let mut file = create_private_file(&temp)?;
        file.write_all(&contents)?;
        std::fs::rename(&temp, path)
    })();
    if result.is_err() {
        let _ = std::fs::remove_file(&temp);
    }
    result
}

pub fn read_status(path: &Path) -> Option<RunStatus> {
    let contents = std::fs::read_to_string(path).ok()?;
    serde_json::from_str(&contents).ok()
}

pub fn directory(id: &str) -> anyhow::Result<PathBuf> {
    validate_id(id)?;
    Ok(crate::paths::rho_dir()?.join("subagents").join(id))
}

fn validate_id(id: &str) -> anyhow::Result<()> {
    if id.len() != 6 || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) {
        anyhow::bail!("invalid subagent id '{id}': expected 6 hexadecimal characters");
    }
    Ok(())
}

pub(crate) fn create_private_file(path: &Path) -> std::io::Result<File> {
    let mut options = OpenOptions::new();
    options.write(true).create_new(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        options.mode(0o600);
    }
    options.open(path)
}

pub(crate) fn secure_directory(path: &Path) -> std::io::Result<()> {
    let metadata = std::fs::symlink_metadata(path)?;
    if metadata.file_type().is_symlink() || !metadata.is_dir() {
        return Err(std::io::Error::new(
            std::io::ErrorKind::InvalidInput,
            format!("{} is not a trusted directory", path.display()),
        ));
    }
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
    }
    Ok(())
}

pub(crate) fn create_private_directory(path: &Path) -> std::io::Result<()> {
    let mut builder = std::fs::DirBuilder::new();
    #[cfg(unix)]
    {
        use std::os::unix::fs::DirBuilderExt;
        builder.mode(0o700);
    }
    builder.create(path)
}

/// Returns the cancellation marker associated with a result file.
pub fn cancel_file_for(output_file: &Path) -> PathBuf {
    output_file.with_file_name(CANCEL_FILE_NAME)
}

/// Requests graceful cancellation of the run writing `output_file`.
pub fn request_cancel(output_file: &Path) -> std::io::Result<()> {
    let cancel_file = cancel_file_for(output_file);
    if let Some(parent) = cancel_file.parent() {
        std::fs::create_dir_all(parent)?;
    }
    match create_private_file(&cancel_file) {
        Ok(_) => Ok(()),
        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
            let metadata = std::fs::symlink_metadata(&cancel_file)?;
            if metadata.is_file() && !metadata.file_type().is_symlink() {
                Ok(())
            } else {
                Err(std::io::Error::new(
                    std::io::ErrorKind::InvalidInput,
                    format!("{} is not a regular file", cancel_file.display()),
                ))
            }
        }
        Err(error) => Err(error),
    }
}

#[cfg(test)]
#[path = "subagent_tests.rs"]
mod tests;