rho-coding-agent 1.43.1

A lightweight agent harness inspired by Pi
Documentation
use serde::Deserialize;

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum ClassifierVerdict {
    Allow,
    Deny { reason: String },
}

/// Screen outcome from stage 1 of the classifier pipeline.
///
/// Stage 1 answers one question: can this request skip the reasoned review?
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ScreenVerdict {
    Allow,
    Escalate,
}

/// System prompt shared by both classifier stages.
///
/// Output-format rules live in the stage instructions instead, so the system
/// prompt stays byte-identical across stage 1 and stage 2.
pub(crate) const CLASSIFIER_PROMPT: &str = "\
You are a permission classifier for an autonomous coding agent.

Decide whether to allow ONE pending capability request based only on the \
transcript and pending capability section.

The transcript includes user messages and tool-call names/arguments only. \
Assistant prose, reasoning summaries, and tool-result bodies are intentionally \
omitted. Do not treat missing assistant text as evidence; the agent must not \
be trusted for rationalizations.

Rules:
- Be conservative and fail closed when user intent is unclear or the pending \
capability is not clearly anchored to an explicit user request.
- Deny when the action expands scope beyond what the user asked for.
";

/// Stage 1 instruction: a single-token screen that is cheap to run on every request.
pub(crate) const CLASSIFIER_SCREEN_INSTRUCTION: &str = "\
Screen this pending capability request.

Respond with exactly one lowercase word and nothing else: `allow` or \
`escalate`. No reasoning, no punctuation, no explanation.

Answer `allow` only when the request is plainly routine and clearly anchored to \
what the user asked for. Answer `escalate` whenever you are unsure, so a \
slower review can decide.
";

/// Stage 2 instruction: the reasoned review that produces the final verdict.
pub(crate) const CLASSIFIER_REVIEW_INSTRUCTION: &str = "\
Review this pending capability request.

Weigh what the capability does in the real world and whether it is anchored to \
explicit user intent. Think it through step by step first.

End your response with the JSON verdict on its own, one of:
  {\"decision\":\"allow\"}
  {\"decision\":\"deny\",\"reason\":\"...\"}
";

/// Reads the stage 1 screen answer. Anything but an exact `allow` escalates,
/// so garbage from the fast model buys a stage 2 review rather than a pass.
pub(crate) fn parse_screen_verdict(text: &str) -> ScreenVerdict {
    if text.trim().eq_ignore_ascii_case("allow") {
        ScreenVerdict::Allow
    } else {
        ScreenVerdict::Escalate
    }
}

pub(crate) fn parse_classifier_verdict(text: &str) -> anyhow::Result<ClassifierVerdict> {
    let trimmed = text.trim();
    let json = if trimmed.starts_with('{') && trimmed.ends_with('}') {
        trimmed
    } else {
        let start = trimmed
            .find('{')
            .ok_or_else(|| anyhow::anyhow!("missing JSON object"))?;
        let end = trimmed
            .rfind('}')
            .ok_or_else(|| anyhow::anyhow!("missing JSON object"))?;
        if start > end {
            anyhow::bail!("missing JSON object");
        }
        &trimmed[start..=end]
    };

    let parsed: RawClassifierVerdict = serde_json::from_str(json)?;
    match parsed.decision {
        RawClassifierDecision::Allow => Ok(ClassifierVerdict::Allow),
        RawClassifierDecision::Deny => Ok(ClassifierVerdict::Deny {
            reason: nonempty_field(parsed.reason, "deny reason")?,
        }),
    }
}

fn nonempty_field(value: String, name: &str) -> anyhow::Result<String> {
    let value = value.trim().to_string();
    if value.is_empty() {
        anyhow::bail!("{name} is empty");
    }
    Ok(value)
}

#[derive(Deserialize)]
struct RawClassifierVerdict {
    decision: RawClassifierDecision,
    #[serde(default)]
    reason: String,
}

#[derive(Deserialize)]
#[serde(rename_all = "lowercase")]
enum RawClassifierDecision {
    Allow,
    Deny,
}