ralph-adapters 2.9.3

Agent adapters for Ralph Orchestrator (Claude, Kiro, Gemini, ACP)
Documentation
use serde_json::Value;

pub fn format_tool_summary(name: &str, input: &Value) -> Option<String> {
    match name {
        "Read" | "Edit" | "Write" | "read" | "edit" | "write" => input
            .get("file_path")
            .or_else(|| input.get("path"))
            .and_then(|v| v.as_str())
            .map(|s| s.to_string()),
        "Bash" | "bash" | "shell" => {
            let cmd = input.get("command")?.as_str()?;
            Some(truncate_preview(cmd, 60))
        }
        "Grep" | "grep" => input.get("pattern")?.as_str().map(|s| s.to_string()),
        "Glob" | "glob" | "ls" => input
            .get("pattern")
            .or_else(|| input.get("path"))
            .and_then(|v| v.as_str())
            .map(|s| s.to_string()),
        "Task" => input.get("description")?.as_str().map(|s| s.to_string()),
        "WebFetch" | "web_fetch" => input.get("url")?.as_str().map(|s| s.to_string()),
        "WebSearch" | "web_search" => input.get("query")?.as_str().map(|s| s.to_string()),
        "LSP" => {
            let op = input.get("operation")?.as_str()?;
            let file = input.get("filePath")?.as_str()?;
            Some(format!("{} @ {}", op, file))
        }
        "NotebookEdit" => input.get("notebook_path")?.as_str().map(|s| s.to_string()),
        "TodoWrite" => Some("updating todo list".to_string()),
        _ => input
            .get("path")
            .or_else(|| input.get("file_path"))
            .or_else(|| input.get("command"))
            .or_else(|| input.get("pattern"))
            .or_else(|| input.get("url"))
            .or_else(|| input.get("query"))
            .and_then(|v| v.as_str())
            .map(|s| truncate_preview(s, 60)),
    }
}

pub fn format_tool_result(output: &str) -> String {
    let Ok(val) = serde_json::from_str::<Value>(output) else {
        return summarize_plain_text(output);
    };
    let Some(items) = val.get("items").and_then(|v| v.as_array()) else {
        return output.to_string();
    };
    let Some(item) = items.first() else {
        return String::new();
    };

    if let Some(text) = item.get("Text").and_then(|v| v.as_str()) {
        return summarize_plain_text(text);
    }

    if let Some(json) = item.get("Json") {
        if let Some(stdout) = json.get("stdout").and_then(|v| v.as_str()) {
            let stderr = json.get("stderr").and_then(|v| v.as_str()).unwrap_or("");
            let exit = json
                .get("exit_status")
                .and_then(|v| v.as_str())
                .unwrap_or("");
            let failed = !exit.contains("status: 0");
            return if failed && !stderr.is_empty() {
                summarize_plain_text(stderr)
            } else if !stdout.is_empty() {
                summarize_plain_text(stdout)
            } else {
                summarize_plain_text(stderr)
            };
        }

        if let Some(paths) = json.get("filePaths").and_then(|v| v.as_array()) {
            let total = json
                .get("totalFiles")
                .and_then(|v| v.as_u64())
                .unwrap_or(paths.len() as u64);
            let names: Vec<&str> = paths
                .iter()
                .filter_map(|p| p.as_str())
                .map(|p| p.rsplit('/').next().unwrap_or(p))
                .collect();
            let shown = names.iter().take(3).copied().collect::<Vec<_>>();
            return if total > shown.len() as u64 {
                format!(
                    "{} files: {} (+{} more)",
                    total,
                    shown.join(", "),
                    total - shown.len() as u64
                )
            } else {
                format!("{} files: {}", total, shown.join(", "))
            };
        }

        if let Some(results) = json.get("results").and_then(|v| v.as_array()) {
            let num_matches = json.get("numMatches").and_then(|v| v.as_u64()).unwrap_or(0);
            let first_match = results.first().and_then(|r| {
                let file = r.get("file").and_then(|v| v.as_str()).unwrap_or("");
                let basename = file.rsplit('/').next().unwrap_or(file);
                let matches = r.get("matches").and_then(|v| v.as_array())?;
                let first = matches.first().and_then(|m| m.as_str())?;
                Some(format!("{}: {}", basename, first.trim()))
            });
            return match first_match {
                Some(m) => format!("{} matches: {}", num_matches, m),
                None => format!("{} matches", num_matches),
            };
        }

        return json.to_string();
    }

    summarize_plain_text(output)
}

fn summarize_plain_text(output: &str) -> String {
    let trimmed = output.trim();
    if trimmed.is_empty() {
        return String::new();
    }

    let lines: Vec<&str> = trimmed
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty())
        .collect();

    if lines.len() <= 1 {
        return trimmed.to_string();
    }

    let short_lines = lines.len() <= 4 && lines.iter().all(|line| line.chars().count() <= 60);
    if short_lines {
        return lines.join("");
    }

    let first = lines.first().copied().unwrap_or(trimmed);
    let second = lines.get(1).copied();
    let remaining = lines.len().saturating_sub(2);

    match (second, remaining) {
        (Some(next), 0) => format!("{}{}", first, next),
        (Some(next), more) => format!("{}{} (+{} more lines)", first, next, more),
        (None, _) => first.to_string(),
    }
}

fn truncate_preview(s: &str, max_len: usize) -> String {
    if s.chars().count() <= max_len {
        s.to_string()
    } else {
        let byte_idx = s
            .char_indices()
            .nth(max_len)
            .map(|(idx, _)| idx)
            .unwrap_or(s.len());
        format!("{}...", &s[..byte_idx])
    }
}