rho-coding-agent 0.28.1

A lightweight agent harness inspired by Pi
Documentation
use crate::tool::ToolError;
use serde::Deserialize;
use serde_json::{json, Value};

#[derive(Deserialize)]
pub(super) struct Args {
    edits: Option<Vec<EditArgs>>,
    path: Option<String>,
    old_string: Option<String>,
    new_string: Option<String>,
    replace_all: Option<bool>,
    expected_match_count: Option<usize>,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct EditArgs {
    path: String,
    old_string: String,
    new_string: String,
    #[serde(default = "default_expected_match_count")]
    expected_match_count: usize,
}

pub(super) struct Edit {
    pub(super) path: String,
    pub(super) old_string: String,
    pub(super) new_string: String,
    match_count: MatchCount,
}

enum MatchCount {
    Exact(usize),
    All,
}

pub(super) fn input_schema() -> Value {
    json!({
        "type": "object",
        "properties": {
            "edits": {
                "type": "array",
                "description": "Atomic replacements to apply in order. No files are changed if any edit fails validation.",
                "minItems": 1,
                "items": {
                    "type": "object",
                    "properties": {
                        "path": {"type": "string"},
                        "old_string": {"type": "string"},
                        "new_string": {"type": "string"},
                        "expected_match_count": {
                            "type": "integer",
                            "minimum": 1,
                            "default": 1,
                            "description": "Exact number of occurrences that must match and will be replaced."
                        }
                    },
                    "required": ["path", "old_string", "new_string"],
                    "additionalProperties": false
                }
            },
            "path": {"type": "string", "description": "Path for a legacy single edit."},
            "old_string": {"type": "string", "description": "Text to replace in a legacy single edit."},
            "new_string": {"type": "string", "description": "Replacement text for a legacy single edit."},
            "replace_all": {"type": "boolean", "description": "Replace every match in a legacy single edit."},
            "expected_match_count": {
                "type": "integer",
                "minimum": 1,
                "description": "Exact match count for a legacy single edit. Cannot be combined with replace_all=true."
            }
        },
        "anyOf": [
            {"type": "object", "required": ["edits"]},
            {"type": "object", "required": ["path", "old_string", "new_string"]}
        ]
    })
}

impl Args {
    pub(super) fn into_edits(self) -> Result<Vec<Edit>, ToolError> {
        if let Some(edits) = self.edits {
            if self.path.is_some()
                || self.old_string.is_some()
                || self.new_string.is_some()
                || self.replace_all.is_some()
                || self.expected_match_count.is_some()
            {
                return Err(ToolError::Message(
                    "edits cannot be combined with single-edit arguments".into(),
                ));
            }
            if edits.is_empty() {
                return Err(ToolError::Message("edits must not be empty".into()));
            }
            return Ok(edits
                .into_iter()
                .map(|edit| Edit {
                    path: edit.path,
                    old_string: edit.old_string,
                    new_string: edit.new_string,
                    match_count: MatchCount::Exact(edit.expected_match_count),
                })
                .collect());
        }

        let path = self
            .path
            .ok_or_else(|| ToolError::Message("path is required".into()))?;
        let old_string = self
            .old_string
            .ok_or_else(|| ToolError::Message("old_string is required".into()))?;
        let new_string = self
            .new_string
            .ok_or_else(|| ToolError::Message("new_string is required".into()))?;
        if self.replace_all == Some(true) && self.expected_match_count.is_some() {
            return Err(ToolError::Message(
                "replace_all=true cannot be combined with expected_match_count".into(),
            ));
        }
        let match_count = if self.replace_all == Some(true) {
            MatchCount::All
        } else {
            MatchCount::Exact(self.expected_match_count.unwrap_or(1))
        };
        Ok(vec![Edit {
            path,
            old_string,
            new_string,
            match_count,
        }])
    }
}

impl Edit {
    pub(super) fn validate(&self, index: usize) -> Result<(), ToolError> {
        if self.old_string.is_empty() {
            return Err(edit_error(
                index,
                &self.path,
                "old_string must not be empty",
            ));
        }
        if self.old_string == self.new_string {
            return Err(edit_error(
                index,
                &self.path,
                "old_string and new_string are identical; nothing to change",
            ));
        }
        if matches!(self.match_count, MatchCount::Exact(0)) {
            return Err(edit_error(
                index,
                &self.path,
                "expected_match_count must be at least 1",
            ));
        }
        Ok(())
    }

    pub(super) fn validate_match_count(
        &self,
        index: usize,
        actual: usize,
    ) -> Result<(), ToolError> {
        let expected = match self.match_count {
            MatchCount::All if actual > 0 => return Ok(()),
            MatchCount::All => 1,
            MatchCount::Exact(expected) if actual == expected => return Ok(()),
            MatchCount::Exact(expected) => expected,
        };
        let reason = if actual < expected {
            "missing match"
        } else {
            "ambiguous match"
        };
        Err(edit_error(
            index,
            &self.path,
            format!("{reason}: found {actual} occurrence(s), expected {expected}"),
        ))
    }
}

pub(super) fn edit_error(index: usize, path: &str, message: impl std::fmt::Display) -> ToolError {
    ToolError::Message(format!("edit {} ({path}) failed: {message}", index + 1))
}

fn default_expected_match_count() -> usize {
    1
}