diff --git a/Cargo.lock b/Cargo.lock index 1dbfb626..54e74853 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1169,6 +1169,7 @@ dependencies = [ "crossterm", "csv", "deepseek-secrets", + "deepseek-tools", "dirs", "dotenvy", "flate2", diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index cec96628..363b0368 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -9,6 +10,213 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use tokio::sync::RwLock; +/// Capabilities that a tool may have or require. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ToolCapability { + /// Tool only reads data, never modifies state. + ReadOnly, + /// Tool writes to the filesystem. + WritesFiles, + /// Tool executes arbitrary shell commands. + ExecutesCode, + /// Tool makes network requests. + Network, + /// Tool can be run in a sandbox. + Sandboxable, + /// Tool requires user approval before execution. + RequiresApproval, +} + +/// Approval requirement for a tool. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ApprovalRequirement { + /// Never needs approval: safe read-only operations. + #[default] + Auto, + /// Suggest approval but allow user to skip. + Suggest, + /// Always require explicit user approval. + Required, +} + +/// Errors that can occur during tool execution. +#[derive(Debug, Clone)] +pub enum ToolError { + InvalidInput { message: String }, + MissingField { field: String }, + PathEscape { path: PathBuf }, + ExecutionFailed { message: String }, + Timeout { seconds: u64 }, + NotAvailable { message: String }, + PermissionDenied { message: String }, +} + +impl std::fmt::Display for ToolError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidInput { message } => { + write!(f, "Failed to validate input: {message}") + } + Self::MissingField { field } => { + write!( + f, + "Failed to validate input: missing required field '{field}'" + ) + } + Self::PathEscape { path } => { + write!( + f, + "Failed to resolve path '{}': path escapes workspace", + path.display() + ) + } + Self::ExecutionFailed { message } => { + write!(f, "Failed to execute tool: {message}") + } + Self::Timeout { seconds } => { + write!( + f, + "Failed to execute tool: operation timed out after {seconds}s" + ) + } + Self::NotAvailable { message } => { + write!(f, "Failed to locate tool: {message}") + } + Self::PermissionDenied { message } => { + write!(f, "Failed to authorize tool execution: {message}") + } + } + } +} + +impl std::error::Error for ToolError {} + +impl ToolError { + #[must_use] + pub fn invalid_input(msg: impl Into) -> Self { + Self::InvalidInput { + message: msg.into(), + } + } + + #[must_use] + pub fn missing_field(field: impl Into) -> Self { + Self::MissingField { + field: field.into(), + } + } + + #[must_use] + pub fn execution_failed(msg: impl Into) -> Self { + Self::ExecutionFailed { + message: msg.into(), + } + } + + #[must_use] + pub fn path_escape(path: impl Into) -> Self { + Self::PathEscape { path: path.into() } + } + + #[must_use] + pub fn not_available(msg: impl Into) -> Self { + Self::NotAvailable { + message: msg.into(), + } + } + + #[must_use] + pub fn permission_denied(msg: impl Into) -> Self { + Self::PermissionDenied { + message: msg.into(), + } + } +} + +/// Result of a tool execution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolResult { + /// The output content, which may be JSON or plain text. + pub content: String, + /// Whether the execution was successful. + pub success: bool, + /// Optional structured metadata. + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +impl ToolResult { + /// Create a successful result with content. + #[must_use] + pub fn success(content: impl Into) -> Self { + Self { + content: content.into(), + success: true, + metadata: None, + } + } + + /// Create an error result with message. + #[must_use] + pub fn error(message: impl Into) -> Self { + Self { + content: message.into(), + success: false, + metadata: None, + } + } + + /// Create a successful result from JSON. + pub fn json(value: &T) -> std::result::Result { + Ok(Self { + content: serde_json::to_string_pretty(value)?, + success: true, + metadata: None, + }) + } + + /// Add metadata to the result. + #[must_use] + pub fn with_metadata(mut self, metadata: Value) -> Self { + self.metadata = Some(metadata); + self + } +} + +/// Helper to extract a required string field from JSON input. +pub fn required_str<'a>(input: &'a Value, field: &str) -> std::result::Result<&'a str, ToolError> { + input + .get(field) + .and_then(Value::as_str) + .ok_or_else(|| ToolError::missing_field(field)) +} + +/// Helper to extract an optional string field from JSON input. +#[must_use] +pub fn optional_str<'a>(input: &'a Value, field: &str) -> Option<&'a str> { + input.get(field).and_then(Value::as_str) +} + +/// Helper to extract a required u64 field from JSON input. +pub fn required_u64(input: &Value, field: &str) -> std::result::Result { + input + .get(field) + .and_then(Value::as_u64) + .ok_or_else(|| ToolError::missing_field(field)) +} + +/// Helper to extract an optional u64 field with default. +#[must_use] +pub fn optional_u64(input: &Value, field: &str, default: u64) -> u64 { + input.get(field).and_then(Value::as_u64).unwrap_or(default) +} + +/// Helper to extract an optional bool field with default. +#[must_use] +pub fn optional_bool(input: &Value, field: &str, default: bool) -> bool { + input.get(field).and_then(Value::as_bool).unwrap_or(default) +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolSpec { pub name: String, @@ -200,3 +408,38 @@ fn tool_payload_kind(payload: &ToolPayload) -> ToolKind { | ToolPayload::LocalShell { .. } => ToolKind::Function, } } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn tool_result_json_round_trips_content() { + let result = ToolResult::json(&json!({"ok": true})).expect("json"); + assert!(result.success); + assert!(result.content.contains("\"ok\": true")); + } + + #[test] + fn helper_extractors_validate_shape() { + let input = json!({"name": "demo", "count": 7, "enabled": true}); + assert_eq!(required_str(&input, "name").expect("name"), "demo"); + assert_eq!(optional_u64(&input, "count", 0), 7); + assert!(optional_bool(&input, "enabled", false)); + assert!(matches!( + required_u64(&input, "name"), + Err(ToolError::MissingField { .. }) + )); + } + + #[test] + fn tool_error_display_matches_legacy_text() { + let err = ToolError::missing_field("path"); + assert_eq!( + err.to_string(), + "Failed to validate input: missing required field 'path'" + ); + } +} diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 5e555b70..6a69dceb 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -14,6 +14,7 @@ path = "src/main.rs" anyhow = "1.0.100" arboard = "3.4" deepseek-secrets = { path = "../secrets", version = "0.6.0" } +deepseek-tools = { path = "../tools", version = "0.6.0" } async-stream = "0.3.6" async-trait = "0.1" bytes = "1.11.0" diff --git a/crates/tui/src/tools/spec.rs b/crates/tui/src/tools/spec.rs index cb39055b..4f19b63f 100644 --- a/crates/tui/src/tools/spec.rs +++ b/crates/tui/src/tools/spec.rs @@ -9,160 +9,16 @@ use std::path::{Component, Path, PathBuf}; use async_trait::async_trait; -use serde::{Deserialize, Serialize}; use serde_json::Value; -use thiserror::Error; use crate::features::Features; use crate::network_policy::NetworkPolicyDecider; use crate::tools::shell::{SharedShellManager, new_shared_shell_manager}; - -/// Capabilities that a tool may have or require. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ToolCapability { - /// Tool only reads data, never modifies state - ReadOnly, - /// Tool writes to the filesystem - WritesFiles, - /// Tool executes arbitrary shell commands - ExecutesCode, - /// Tool makes network requests - Network, - /// Tool can be run in a sandbox - Sandboxable, - /// Tool requires user approval before execution - RequiresApproval, -} - -/// Approval requirement for a tool. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum ApprovalRequirement { - /// Never needs approval - safe read-only operations - #[default] - Auto, - /// Suggest approval but allow user to skip - Suggest, - /// Always require explicit user approval - Required, -} - -/// Errors that can occur during tool execution. -#[derive(Debug, Clone, Error)] -pub enum ToolError { - #[error("Failed to validate input: {message}")] - InvalidInput { message: String }, - - #[error("Failed to validate input: missing required field '{field}'")] - MissingField { field: String }, - - #[error("Failed to resolve path '{path}': path escapes workspace")] - PathEscape { path: PathBuf }, - - #[error("Failed to execute tool: {message}")] - ExecutionFailed { message: String }, - - #[error("Failed to execute tool: operation timed out after {seconds}s")] - Timeout { seconds: u64 }, - - #[error("Failed to locate tool: {message}")] - NotAvailable { message: String }, - - #[error("Failed to authorize tool execution: {message}")] - PermissionDenied { message: String }, -} - -impl ToolError { - #[must_use] - pub fn invalid_input(msg: impl Into) -> Self { - Self::InvalidInput { - message: msg.into(), - } - } - - #[must_use] - pub fn missing_field(field: impl Into) -> Self { - Self::MissingField { - field: field.into(), - } - } - - #[must_use] - pub fn execution_failed(msg: impl Into) -> Self { - Self::ExecutionFailed { - message: msg.into(), - } - } - - #[must_use] - #[allow(dead_code)] - pub fn path_escape(path: impl Into) -> Self { - Self::PathEscape { path: path.into() } - } - - #[must_use] - pub fn not_available(msg: impl Into) -> Self { - Self::NotAvailable { - message: msg.into(), - } - } - - #[must_use] - pub fn permission_denied(msg: impl Into) -> Self { - Self::PermissionDenied { - message: msg.into(), - } - } -} - -/// Result of a tool execution. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolResult { - /// The output content (may be JSON or plain text) - pub content: String, - /// Whether the execution was successful - pub success: bool, - /// Optional structured metadata - #[serde(skip_serializing_if = "Option::is_none")] - pub metadata: Option, -} - -impl ToolResult { - /// Create a successful result with content. - #[must_use] - pub fn success(content: impl Into) -> Self { - Self { - content: content.into(), - success: true, - metadata: None, - } - } - - /// Create an error result with message. - #[must_use] - pub fn error(message: impl Into) -> Self { - Self { - content: message.into(), - success: false, - metadata: None, - } - } - - /// Create a successful result from JSON. - pub fn json(value: &T) -> Result { - Ok(Self { - content: serde_json::to_string_pretty(value)?, - success: true, - metadata: None, - }) - } - - /// Add metadata to the result. - #[must_use] - pub fn with_metadata(mut self, metadata: Value) -> Self { - self.metadata = Some(metadata); - self - } -} +#[allow(unused_imports)] +pub use deepseek_tools::{ + ApprovalRequirement, ToolCapability, ToolError, ToolResult, optional_bool, optional_str, + optional_u64, required_str, required_u64, +}; /// Sandbox policy for command execution. #[derive(Debug, Clone, Default)] @@ -577,46 +433,6 @@ pub trait ToolSpec: Send + Sync { async fn execute(&self, input: Value, context: &ToolContext) -> Result; } -// === Helper functions for extracting values from JSON input === - -/// Helper to extract required string field from JSON input. -pub fn required_str<'a>(input: &'a Value, field: &str) -> Result<&'a str, ToolError> { - input - .get(field) - .and_then(|v| v.as_str()) - .ok_or_else(|| ToolError::missing_field(field)) -} - -/// Helper to extract optional string field from JSON input. -pub fn optional_str<'a>(input: &'a Value, field: &str) -> Option<&'a str> { - input.get(field).and_then(|v| v.as_str()) -} - -/// Helper to extract required u64 field from JSON input. -#[allow(dead_code)] -pub fn required_u64(input: &Value, field: &str) -> Result { - input - .get(field) - .and_then(serde_json::Value::as_u64) - .ok_or_else(|| ToolError::missing_field(field)) -} - -/// Helper to extract optional u64 field with default. -pub fn optional_u64(input: &Value, field: &str, default: u64) -> u64 { - input - .get(field) - .and_then(serde_json::Value::as_u64) - .unwrap_or(default) -} - -/// Helper to extract optional bool field with default. -pub fn optional_bool(input: &Value, field: &str, default: bool) -> bool { - input - .get(field) - .and_then(serde_json::Value::as_bool) - .unwrap_or(default) -} - // === Unit Tests === #[cfg(test)] diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 79fbd6f2..2fcbb9ed 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -81,6 +81,10 @@ Current boundary note: - **`config.rs`** - Configuration loading, profiles, environment variables - **`settings.rs`** - Runtime settings management +### Workspace Crates + +- **`crates/tools`** - Shared tool invocation primitives, including tool result/error/capability types used by the TUI runtime. + ### LLM Integration - **`client.rs`** - HTTP client for DeepSeek's documented OpenAI-compatible Chat Completions API