refactor(tools): share tool result primitives from crate

This commit is contained in:
Hunter Bown
2026-04-28 01:23:21 -05:00
parent 281b712acc
commit 49d2be9e5c
5 changed files with 254 additions and 189 deletions
Generated
+1
View File
@@ -1169,6 +1169,7 @@ dependencies = [
"crossterm",
"csv",
"deepseek-secrets",
"deepseek-tools",
"dirs",
"dotenvy",
"flate2",
+243
View File
@@ -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<String>) -> Self {
Self::InvalidInput {
message: msg.into(),
}
}
#[must_use]
pub fn missing_field(field: impl Into<String>) -> Self {
Self::MissingField {
field: field.into(),
}
}
#[must_use]
pub fn execution_failed(msg: impl Into<String>) -> Self {
Self::ExecutionFailed {
message: msg.into(),
}
}
#[must_use]
pub fn path_escape(path: impl Into<PathBuf>) -> Self {
Self::PathEscape { path: path.into() }
}
#[must_use]
pub fn not_available(msg: impl Into<String>) -> Self {
Self::NotAvailable {
message: msg.into(),
}
}
#[must_use]
pub fn permission_denied(msg: impl Into<String>) -> 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<Value>,
}
impl ToolResult {
/// Create a successful result with content.
#[must_use]
pub fn success(content: impl Into<String>) -> Self {
Self {
content: content.into(),
success: true,
metadata: None,
}
}
/// Create an error result with message.
#[must_use]
pub fn error(message: impl Into<String>) -> Self {
Self {
content: message.into(),
success: false,
metadata: None,
}
}
/// Create a successful result from JSON.
pub fn json<T: Serialize>(value: &T) -> std::result::Result<Self, serde_json::Error> {
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<u64, ToolError> {
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'"
);
}
}
+1
View File
@@ -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"
+5 -189
View File
@@ -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<String>) -> Self {
Self::InvalidInput {
message: msg.into(),
}
}
#[must_use]
pub fn missing_field(field: impl Into<String>) -> Self {
Self::MissingField {
field: field.into(),
}
}
#[must_use]
pub fn execution_failed(msg: impl Into<String>) -> Self {
Self::ExecutionFailed {
message: msg.into(),
}
}
#[must_use]
#[allow(dead_code)]
pub fn path_escape(path: impl Into<PathBuf>) -> Self {
Self::PathEscape { path: path.into() }
}
#[must_use]
pub fn not_available(msg: impl Into<String>) -> Self {
Self::NotAvailable {
message: msg.into(),
}
}
#[must_use]
pub fn permission_denied(msg: impl Into<String>) -> 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<Value>,
}
impl ToolResult {
/// Create a successful result with content.
#[must_use]
pub fn success(content: impl Into<String>) -> Self {
Self {
content: content.into(),
success: true,
metadata: None,
}
}
/// Create an error result with message.
#[must_use]
pub fn error(message: impl Into<String>) -> Self {
Self {
content: message.into(),
success: false,
metadata: None,
}
}
/// Create a successful result from JSON.
pub fn json<T: Serialize>(value: &T) -> Result<Self, serde_json::Error> {
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<ToolResult, ToolError>;
}
// === 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<u64, ToolError> {
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)]
+4
View File
@@ -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