procyon 0.1.2

Terminal development harness for Stellar and Soroban smart contracts, driven by a language model
pub mod accounts;
pub mod approval;
pub mod bindings;
pub mod caatinga;
pub mod docs;
pub mod events;
pub mod file;
pub mod invoke;
pub mod mainnet;
pub mod party;
pub mod paths;
pub mod persona;
pub mod plugin;
pub mod project;
pub mod search;
pub mod skill;
pub mod test;
pub mod update;

use async_trait::async_trait;
use serde_json::Value;

// A Stellar contract id is exactly 56 chars of uppercase base32 starting with 'C'.
pub fn is_contract_id(candidate: &str) -> bool {
    candidate.len() == 56
        && candidate.starts_with('C')
        && candidate
            .bytes()
            .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
}

#[async_trait]
pub trait Tool: Send + Sync {
    fn name(&self) -> &str;
    fn description(&self) -> &str;
    fn input_schema(&self) -> Value;
    async fn execute(&self, input: Value) -> Result<String, String>;

    /// What this tool does, for `crate::risk` to decide what that means here.
    ///
    /// Declared per tool rather than looked up in a table keyed by name, because a table only ever
    /// knows the names its author wrote down: MCP tools arrive as `server__tool` and plugin tools
    /// are named by a user-authored manifest, and both used to fall through such a table as
    /// "nothing to ask about". The default is the cautious one, so forgetting to classify a tool
    /// costs a prompt rather than the user's files.
    fn capability(&self) -> crate::risk::Capability {
        crate::risk::Capability::default()
    }
}

/// Watches every call the model makes.
///
/// Attached to the registry for the same reason the gate is: `execute` is the one path every caller
/// shares, so `spawn_agent`, `run_skill` and party mode are counted too. An observer that sat in
/// the turn loop would have measured only the calls the top-level agent made itself, which is not
/// what a benchmark is asking about.
pub trait ToolObserver: Send + Sync {
    /// Called once per call, after it resolves. `Err` carries what the model was told, denials
    /// included — a refused call is an outcome, not an absence of one.
    fn observe(&self, tool: &str, outcome: Result<(), &str>);
}

pub struct ToolRegistry {
    tools: Vec<Box<dyn Tool>>,
    /// `None` means nothing is gated — the registry runs whatever it is asked to. That is what a
    /// test wants and what the real app must never have, so the app builds its registry through
    /// `with_approver`.
    approver: Option<std::sync::Arc<approval::Approver>>,
    observer: Option<std::sync::Arc<dyn ToolObserver>>,
}

impl ToolRegistry {
    pub fn new() -> Self {
        Self {
            tools: Vec::new(),
            approver: None,
            observer: None,
        }
    }

    pub fn with_approver(approver: std::sync::Arc<approval::Approver>) -> Self {
        Self {
            tools: Vec::new(),
            approver: Some(approver),
            observer: None,
        }
    }

    /// Attaches an observer. There is one, not a list: the caller that wants several can fan out
    /// behind its own, and a registry holding a list would invite ordering questions nobody needs.
    pub fn observe(&mut self, observer: std::sync::Arc<dyn ToolObserver>) {
        self.observer = Some(observer);
    }

    pub fn register(&mut self, tool: Box<dyn Tool>) {
        self.tools.push(tool);
    }

    // Plugin tools are named by a user-authored manifest, so a name may collide with a builtin.
    // Refusing keeps a plugin from shadowing something like `write_file`.
    pub fn try_register(&mut self, tool: Box<dyn Tool>) -> Result<(), String> {
        if self.get_tool(tool.name()).is_some() {
            return Err(format!(
                "tool '{}' is already registered and was skipped",
                tool.name()
            ));
        }
        self.tools.push(tool);
        Ok(())
    }

    /// Drops every tool the predicate rejects.
    ///
    /// How a specialist's registry is built: see `runtime::persona_tools`. Removing the tool is the
    /// point — a restriction expressed as a prompt is a request, and one expressed as an absent
    /// tool is a fact.
    pub fn retain(&mut self, keep: impl Fn(&dyn Tool) -> bool) {
        self.tools.retain(|tool| keep(tool.as_ref()));
    }

    pub fn get_tool(&self, name: &str) -> Option<&dyn Tool> {
        self.tools
            .iter()
            .find(|t| t.name() == name)
            .map(|t| t.as_ref())
    }

    pub fn definitions(&self) -> Vec<crate::agent::ToolDefinition> {
        self.tools
            .iter()
            .map(|t| crate::agent::ToolDefinition {
                name: t.name().to_string(),
                description: t.description().to_string(),
                input_schema: t.input_schema(),
            })
            .collect()
    }

    pub async fn execute(&self, name: &str, input: Value) -> Result<String, String> {
        // Reported, not just returned: a call to a tool that does not exist is the clearest signal
        // there is that the model invented a capability, and it is the one outcome an observer
        // hooked further in would never see.
        let Some(tool) = self.get_tool(name) else {
            let unknown = format!("Unknown tool: {}", name);
            self.report(name, Err(&unknown));
            return Err(unknown);
        };

        // Asked here, at the one place every caller funnels through, rather than in the turn loop:
        // `agent::subagent` runs tools too, and a gate in the loop would have left `spawn_agent`,
        // `run_skill` and `party_mode` as a way around it.
        // A denial is reported to the observer like any other outcome, so it is done inside this
        // function rather than around the `execute` below.
        if let Some(approver) = &self.approver {
            if let Err(refusal) = approver.approve(name, tool.capability(), &input).await {
                self.report(name, Err(&refusal));
                return Err(refusal);
            }
        }

        let outcome = tool.execute(input).await;
        self.report(name, outcome.as_ref().map(|_| ()).map_err(String::as_str));
        outcome
    }

    fn report(&self, name: &str, outcome: Result<(), &str>) {
        if let Some(observer) = &self.observer {
            observer.observe(name, outcome);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;
    use std::sync::atomic::{AtomicUsize, Ordering};
    use std::sync::Arc;

    // Stands in for a slow external CLI: it awaits, so a correctly-written tool yields the
    // runtime while it waits.
    struct SlowTool;

    #[async_trait]
    impl Tool for SlowTool {
        fn name(&self) -> &str {
            "slow"
        }
        fn description(&self) -> &str {
            "sleeps"
        }
        fn input_schema(&self) -> Value {
            json!({"type": "object"})
        }
        // Slow, but it changes nothing — it stands in for a lookup against a remote server.
        fn capability(&self) -> crate::risk::Capability {
            crate::risk::Capability::ReadOnly
        }
        async fn execute(&self, _input: Value) -> Result<String, String> {
            tokio::time::sleep(std::time::Duration::from_millis(300)).await;
            Ok("done".to_string())
        }
    }

    #[tokio::test(flavor = "current_thread")]
    async fn a_slow_tool_does_not_stall_the_render_loop() {
        let mut registry = ToolRegistry::new();
        registry.register(Box::new(SlowTool));

        let frames = Arc::new(AtomicUsize::new(0));
        let ticker = {
            let frames = frames.clone();
            tokio::spawn(async move {
                loop {
                    tokio::time::sleep(std::time::Duration::from_millis(10)).await;
                    frames.fetch_add(1, Ordering::Relaxed);
                }
            })
        };

        let result = registry.execute("slow", json!({})).await.unwrap();
        ticker.abort();

        assert_eq!(result, "done");
        // A single-threaded runtime is used on purpose: a blocking tool would starve the ticker
        // completely, exactly as it starved the TUI redraw.
        assert!(
            frames.load(Ordering::Relaxed) > 5,
            "render loop only advanced {} frames during a 300ms tool",
            frames.load(Ordering::Relaxed)
        );
    }

    // Stands in for a tool that changes something. Named `write_file` because the gate keys off
    // the name, and counts its runs so a blocked call is distinguishable from a silent one.
    struct WritingTool(Arc<AtomicUsize>);

    #[async_trait]
    impl Tool for WritingTool {
        fn name(&self) -> &str {
            "write_file"
        }
        fn description(&self) -> &str {
            "writes"
        }
        fn input_schema(&self) -> Value {
            json!({"type": "object"})
        }
        async fn execute(&self, _input: Value) -> Result<String, String> {
            self.0.fetch_add(1, Ordering::SeqCst);
            Ok("written".to_string())
        }
    }

    #[tokio::test]
    async fn a_denied_tool_never_reaches_its_own_execute() {
        let (updates, _updates_rx) = tokio::sync::mpsc::unbounded_channel();
        let (decisions_tx, decisions) = tokio::sync::mpsc::unbounded_channel();
        let runs = Arc::new(AtomicUsize::new(0));

        let mut registry = ToolRegistry::with_approver(Arc::new(approval::Approver::new(
            updates,
            decisions,
            crate::channels::CancelFlag::default(),
        )));
        registry.register(Box::new(WritingTool(runs.clone())));

        decisions_tx
            .send(crate::channels::ApprovalDecision::Deny)
            .unwrap();
        assert!(registry.execute("write_file", json!({})).await.is_err());
        assert_eq!(
            runs.load(Ordering::SeqCst),
            0,
            "a denial that still ran the tool is not a denial"
        );

        // And the gate is not a one-shot: approving lets the same call through.
        decisions_tx
            .send(crate::channels::ApprovalDecision::Once)
            .unwrap();
        assert!(registry.execute("write_file", json!({})).await.is_ok());
        assert_eq!(runs.load(Ordering::SeqCst), 1);
    }

    // The gate sits on `execute` rather than in the turn loop precisely so that `agent::subagent`,
    // which calls `execute` directly, cannot route around it.
    #[tokio::test]
    async fn the_gate_is_on_the_path_every_caller_shares() {
        let (updates, mut updates_rx) = tokio::sync::mpsc::unbounded_channel();
        let (decisions_tx, decisions) = tokio::sync::mpsc::unbounded_channel();
        let runs = Arc::new(AtomicUsize::new(0));

        let mut registry = ToolRegistry::with_approver(Arc::new(approval::Approver::new(
            updates,
            decisions,
            crate::channels::CancelFlag::default(),
        )));
        registry.register(Box::new(WritingTool(runs.clone())));
        registry.register(Box::new(SlowTool));

        decisions_tx
            .send(crate::channels::ApprovalDecision::Once)
            .unwrap();
        let _ = registry.execute("write_file", json!({})).await;
        assert!(
            matches!(
                updates_rx.try_recv(),
                Ok(crate::channels::AgentUpdate::Approval(_))
            ),
            "execute must be what raises the question"
        );

        // A tool that changes nothing still runs without one.
        assert!(registry.execute("slow", json!({})).await.is_ok());
        assert!(updates_rx.try_recv().is_err());
    }

    #[tokio::test(flavor = "current_thread")]
    async fn npx_check_does_not_spawn_a_process() {
        // A PATH lookup must stay fast enough to sit on the hot path of every build/deploy.
        let start = std::time::Instant::now();
        for _ in 0..50 {
            let _ = crate::tools::caatinga::check_npx_available();
        }
        assert!(
            start.elapsed() < std::time::Duration::from_millis(250),
            "50 npx checks took {:?}; that is process-spawn territory",
            start.elapsed()
        );
    }
}