~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/asyncai

0
0
main
125 lines · 3.9 KB Raw History
use std::sync::Arc;

use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;

#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ToolDefinition {
    pub name: String,
    pub description: Option<String>,
    pub input_schema: Value,
    /// MCP `outputSchema`, when the tool declares one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub output_schema: Option<Value>,
    /// MCP `readOnlyHint` annotation.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub read_only: Option<bool>,
    /// MCP `destructiveHint` annotation.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub destructive: Option<bool>,
}

impl ToolDefinition {
    pub fn new(
        name: impl Into<String>,
        description: Option<impl Into<String>>,
        input_schema: Value,
    ) -> Self {
        Self {
            name: name.into(),
            description: description.map(Into::into),
            input_schema,
            ..Self::default()
        }
    }

    /// True when the tool declares that it may change state.
    pub fn is_mutating(&self) -> bool {
        self.destructive == Some(true) || self.read_only == Some(false)
    }
}

#[derive(Clone, Debug, Default, PartialEq)]
pub struct ToolCallOutput {
    pub content: String,
    pub is_error: bool,
    /// Metadata for session records (e.g. code mode's nested MCP calls).
    /// Never sent to the model.
    pub audit: Option<Value>,
}

impl ToolCallOutput {
    pub fn text(content: impl Into<String>) -> Self {
        Self {
            content: content.into(),
            ..Self::default()
        }
    }

    pub fn error(content: impl Into<String>) -> Self {
        Self {
            content: content.into(),
            is_error: true,
            audit: None,
        }
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Error)]
#[error("{0}")]
pub struct ToolError(pub String);

impl ToolError {
    pub fn new(message: impl Into<String>) -> Self {
        Self(message.into())
    }
}

#[async_trait]
pub trait ToolExecutor: Send + Sync {
    async fn list_tools(&self) -> Result<Vec<ToolDefinition>, ToolError>;
    async fn call_tool(&self, name: &str, arguments: Value) -> Result<ToolCallOutput, ToolError>;

    /// Executor to use for one backend run. Stateful executors (code mode)
    /// return a fresh scope so per-run state is never shared across runs.
    fn run_scope(&self) -> Option<Arc<dyn ToolExecutor>> {
        None
    }

    /// Executor for one conversation thread: state persists across that
    /// thread's runs. Defaults to a fresh `run_scope`.
    fn thread_scope(&self, _thread_id: &str) -> Option<Arc<dyn ToolExecutor>> {
        self.run_scope()
    }

    /// State the model should know before this run (code mode: variables
    /// still in scope). Appended to the newest user message, never to the
    /// tool description, so the cached prompt prefix stays stable.
    fn context_note(&self) -> Option<String> {
        None
    }
}

/// Tool output for an NDJSON log record: JSON text is decoded and re-emitted
/// as JSON; anything else is logged as a string truncated to `max_chars`.
pub fn content_for_log(text: &str, max_chars: usize) -> Value {
    if let Ok(value) = serde_json::from_str::<Value>(text) {
        return value;
    }
    if text.chars().count() <= max_chars {
        Value::String(text.to_string())
    } else {
        let mut truncated: String = text.chars().take(max_chars).collect();
        truncated.push_str("...[truncated]");
        Value::String(truncated)
    }
}

/// Receives tool call/result notifications from a backend.
pub trait ToolObserver: Send + Sync {
    fn tool_call(&self, id: &str, name: &str, arguments: &Value);
    /// `audit` is session metadata (e.g. nested calls); not model input.
    fn tool_result(&self, id: &str, content: &str, is_error: bool, audit: Option<&Value>);
}