procyon 0.1.0

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
//! The agent's own vocabulary: the shapes a conversation is made of, independent of any provider.
//!
//! Nothing here knows a wire format. That matters most for `ContentPart`: it is what the session
//! log persists, so its serialized shape is a durability commitment, while a provider's request
//! and event shapes follow whatever that API asks for this month. The adapters — `anthropic` and
//! `openai` — translate to and from these types, and each keeps its own protocol details.

pub mod subagent;

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
    User,
    Assistant,
}

impl std::fmt::Display for Role {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Role::User => write!(f, "user"),
            Role::Assistant => write!(f, "assistant"),
        }
    }
}

impl std::str::FromStr for Role {
    type Err = String;

    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
        match s {
            "user" => Ok(Role::User),
            "assistant" => Ok(Role::Assistant),
            _ => Err(format!("Unknown role: {}", s)),
        }
    }
}

#[derive(Debug, Clone, Serialize)]
pub struct Message {
    pub role: Role,
    pub content: Vec<ContentPart>,
}

impl Message {
    pub fn user(text: &str) -> Self {
        Self {
            role: Role::User,
            content: vec![ContentPart::Text {
                text: text.to_string(),
            }],
        }
    }

    pub fn assistant(blocks: Vec<ContentPart>) -> Self {
        Self {
            role: Role::Assistant,
            content: blocks,
        }
    }

    pub fn tool_results(results: Vec<(String, String)>) -> Self {
        Self {
            role: Role::User,
            content: results
                .into_iter()
                .map(|(tool_use_id, content)| ContentPart::ToolResult {
                    tool_use_id,
                    content,
                })
                .collect(),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ContentPart {
    #[serde(rename = "text")]
    Text { text: String },
    #[serde(rename = "tool_use")]
    ToolUse {
        id: String,
        name: String,
        input: serde_json::Value,
    },
    #[serde(rename = "tool_result")]
    ToolResult {
        tool_use_id: String,
        content: String,
    },
}

// Kept as a suffix rather than a whole replacement prompt so toggling it does not rewrite the
// cached prefix of an ongoing conversation.
pub const EXPLAIN_SYSTEM_PROMPT: &str =
    "Before each tool call, state in one sentence what you are about to do and why. \
     After it returns, say in one sentence what the result means. Keep the narration brief.";

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDefinition {
    pub name: String,
    pub description: String,
    pub input_schema: serde_json::Value,
}

/// Resolves the arguments of a tool call into the object a `ToolUse` must carry.
///
/// A tool call whose arguments do not parse still has to become a `ToolUse`. Returning a
/// `ToolResult` instead — which is where the error naturally belongs — puts it inside an assistant
/// turn, and that is invalid on both wire formats: the request is rejected outright, and because
/// the block reaches the session log first, a resumed conversation is rejected too. The turn also
/// ends up with no `tool_use` at all, so the loop stops and the model is never told anything.
///
/// So the call is emitted with empty arguments and the tool reports the failure itself, through
/// the one channel that is valid for it — a `tool_result` in the following user turn. The parse
/// error would otherwise be lost, so it is recorded.
pub fn tool_input(name: &str, raw: &str) -> serde_json::Value {
    let empty = serde_json::Value::Object(Default::default());

    if raw.trim().is_empty() {
        // A tool taking no arguments sends no fragments at all.
        return empty;
    }

    match serde_json::from_str::<serde_json::Value>(raw) {
        // Tools read their arguments by key, so anything but an object is unusable. `null` is how
        // some providers spell "no arguments"; the rest is malformed.
        Ok(serde_json::Value::Object(map)) => serde_json::Value::Object(map),
        Ok(serde_json::Value::Null) => empty,
        Ok(other) => {
            crate::diag::warn(format!(
                "tool call {}: arguments are {} rather than an object, treated as empty",
                name,
                kind_of(&other)
            ));
            empty
        }
        Err(e) => {
            crate::diag::warn(format!(
                "tool call {}: arguments did not parse ({}), treated as empty. raw: {}",
                name, e, raw
            ));
            empty
        }
    }
}

/// How much of one tool result is allowed into the conversation.
///
/// Roughly 8k tokens at the estimator's four-characters-per-token. Chosen to be generous for a
/// source file or a CLI transcript while staying a small fraction of the smallest window this
/// build budgets against, so no single result can dominate the context.
const MAX_TOOL_RESULT_CHARS: usize = 32_000;

/// Clamps a tool result to something a context window can hold.
///
/// `read_file` reads whatever is on disk and the CLI tools return whatever the process printed;
/// neither has an upper bound, and the result went into the history verbatim. One large file was
/// enough to blow the window in a single step — and because the budget is checked *before* a
/// request rather than after a tool returns, the overflow was only discovered on the next turn,
/// when the history already held it.
///
/// The clamp keeps both ends: the head carries the shape of the output and the tail carries the
/// error or summary that a command prints last. The elision is stated in-band, because a model
/// that cannot tell truncated output from complete output will draw conclusions from the gap.
pub fn clamp_tool_result(result: String) -> String {
    if result.len() <= MAX_TOOL_RESULT_CHARS {
        return result;
    }

    // Half the budget each way, split on character boundaries so the result stays valid UTF-8.
    let half = MAX_TOOL_RESULT_CHARS / 2;
    let head_end = floor_boundary(&result, half);
    let tail_start = ceil_boundary(&result, result.len() - half);
    let dropped = tail_start - head_end;

    format!(
        "{}\n\n[... {} bytes elided by Procyon: this tool result was too large for the context \
         window. Narrow the call — a more specific path, a grep, or a smaller range — if the \
         middle matters. ...]\n\n{}",
        &result[..head_end],
        dropped,
        &result[tail_start..]
    )
}

fn floor_boundary(s: &str, mut at: usize) -> usize {
    while at > 0 && !s.is_char_boundary(at) {
        at -= 1;
    }
    at
}

fn ceil_boundary(s: &str, mut at: usize) -> usize {
    while at < s.len() && !s.is_char_boundary(at) {
        at += 1;
    }
    at
}

fn kind_of(value: &serde_json::Value) -> &'static str {
    match value {
        serde_json::Value::Null => "null",
        serde_json::Value::Bool(_) => "a boolean",
        serde_json::Value::Number(_) => "a number",
        serde_json::Value::String(_) => "a string",
        serde_json::Value::Array(_) => "an array",
        serde_json::Value::Object(_) => "an object",
    }
}

/// What the request actually cost, used to anchor the local token estimate.
#[derive(Debug, Default, Clone, Copy, PartialEq)]
pub struct TokenUsage {
    pub input: usize,
    pub cache_read: usize,
    pub cache_write: usize,
    pub output: usize,
}

impl TokenUsage {
    pub fn total(&self) -> usize {
        self.input + self.cache_read + self.cache_write + self.output
    }
}

#[derive(Debug)]
pub struct StreamOutcome {
    pub blocks: Vec<ContentPart>,
    // The loop decides whether to continue from the presence of tool_use blocks, so the reason is
    // carried for diagnostics rather than control flow.
    #[allow(dead_code)]
    pub stop_reason: Option<String>,
    pub usage: Option<TokenUsage>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn usage_total_sums_input_cache_and_output() {
        let usage = TokenUsage {
            input: 1200,
            cache_read: 400,
            cache_write: 30,
            output: 915,
        };
        assert_eq!(usage.total(), 2545);
    }

    #[test]
    fn tool_results_share_one_user_message() {
        let msg = Message::tool_results(vec![
            ("id_a".to_string(), "ra".to_string()),
            ("id_b".to_string(), "rb".to_string()),
        ]);
        assert_eq!(msg.role, Role::User);
        assert_eq!(
            msg.content.len(),
            2,
            "the API requires one user message holding every tool_result of a turn"
        );
    }

    // The session log stores these verbatim, so a resumed conversation has to deserialize the
    // shape an earlier run wrote.
    #[test]
    fn content_parts_round_trip_through_serde() {
        let parts = vec![
            ContentPart::Text {
                text: "hi".to_string(),
            },
            ContentPart::ToolUse {
                id: "t1".to_string(),
                name: "read".to_string(),
                input: serde_json::json!({"path": "a.rs"}),
            },
            ContentPart::ToolResult {
                tool_use_id: "t1".to_string(),
                content: "ok".to_string(),
            },
        ];

        let written = serde_json::to_string(&parts).unwrap();
        let read: Vec<ContentPart> = serde_json::from_str(&written).unwrap();

        assert_eq!(read, parts);
    }

    #[test]
    fn well_formed_arguments_pass_through() {
        assert_eq!(
            tool_input("read", r#"{"path":"a.rs"}"#),
            serde_json::json!({"path": "a.rs"})
        );
    }

    // A tool taking no arguments sends no fragments at all, and some providers spell the same
    // thing as a literal `null`. Both are the zero-argument case, not a failure.
    #[test]
    fn the_zero_argument_spellings_all_yield_an_object() {
        for raw in ["", "   ", "{}", "null"] {
            assert_eq!(
                tool_input("list", raw),
                serde_json::json!({}),
                "raw was {:?}",
                raw
            );
        }
    }

    // Anything that is not an object is unusable — tools read their arguments by key — but it must
    // not be silent, because the call still goes out as if it had no arguments.
    #[test]
    fn a_non_object_is_emptied_and_recorded() {
        let _guard = crate::diag::test_lock();

        for raw in ["[1,2]", "42", "\"text\"", "{invalid"] {
            crate::diag::drain();
            assert_eq!(tool_input("read", raw), serde_json::json!({}));
            assert!(
                !crate::diag::drain().is_empty(),
                "nothing recorded for {:?}",
                raw
            );
        }
    }

    #[test]
    fn a_result_that_fits_is_returned_untouched() {
        let small = "ok".repeat(100);
        assert_eq!(clamp_tool_result(small.clone()), small);
    }

    // The regression: one unbounded `read_file` used to be able to fill the whole window.
    #[test]
    fn an_oversized_result_is_clamped_and_says_so() {
        let huge = "x".repeat(MAX_TOOL_RESULT_CHARS * 3);
        let clamped = clamp_tool_result(huge);

        assert!(
            clamped.len() < MAX_TOOL_RESULT_CHARS + 500,
            "clamped to {} bytes",
            clamped.len()
        );
        assert!(
            clamped.contains("elided by Procyon"),
            "the model must be able to tell truncated output from complete output"
        );
    }

    // A command prints its error last, so the tail is the half most worth keeping.
    #[test]
    fn both_ends_of_an_oversized_result_survive() {
        let body = format!(
            "FIRST LINE\n{}\nerror: deploy failed",
            "filler ".repeat(MAX_TOOL_RESULT_CHARS)
        );
        let clamped = clamp_tool_result(body);

        assert!(clamped.starts_with("FIRST LINE"));
        assert!(clamped.ends_with("error: deploy failed"));
    }

    // Cutting a multi-byte character in half would panic on the slice.
    #[test]
    fn clamping_never_splits_a_character() {
        for pad in 0..4 {
            let body = format!("{}{}", "a".repeat(pad), "é".repeat(MAX_TOOL_RESULT_CHARS));
            let clamped = clamp_tool_result(body);
            assert!(clamped.contains("elided"), "pad {} was not clamped", pad);
        }
    }

    #[test]
    fn role_display_and_from_str_agree() {
        for role in [Role::User, Role::Assistant] {
            assert_eq!(role.to_string().parse::<Role>().unwrap(), role);
        }
    }
}