fluers_core/tool.rs
1//! Tool definitions, calls, and the `Tool` trait.
2//!
3//! Mirrors `AgentTool` / `AgentToolResult` from `pi-agent-core`.
4
5use std::collections::BTreeMap;
6
7use async_trait::async_trait;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use tokio_util::sync::CancellationToken;
11
12use crate::error::{CoreError, Result};
13
14/// A JSON value, aliased for ergonomic imports.
15pub type JsonValue = Value;
16
17/// JSON-Schema-ish parameter description for a tool.
18///
19/// Intentionally loose (a serde_json map) so adapters can carry any schema
20/// dialect a provider expects.
21#[derive(Debug, Clone, Default, Serialize, Deserialize)]
22pub struct ParameterSchema {
23 /// The schema fields.
24 #[serde(flatten)]
25 pub fields: BTreeMap<String, Value>,
26}
27
28/// Per-invocation context handed to a tool's [`Tool::execute`].
29pub struct InvokeContext {
30 /// The unique id of this invocation's tool call.
31 pub tool_call_id: String,
32 /// Cooperative + deadline cancellation. Tools should `select!` on
33 /// `cancel.cancelled()` for long-running work. Clonable and `'static`.
34 pub cancel: CancellationToken,
35}
36
37/// A tool call extracted from a model response.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct ToolCall {
40 /// The tool's name.
41 pub name: String,
42 /// The arguments object the model produced.
43 pub input: Value,
44}
45
46/// A tool's result returned to the model.
47#[derive(Debug, Clone, Default, Serialize, Deserialize)]
48pub struct ToolResult {
49 /// Human/model-readable content blocks describing the outcome.
50 pub content: Vec<Value>,
51 /// Optional structured details (not shown to the model verbatim).
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub details: Option<Value>,
54}
55
56/// A tool's static definition (name, schema, description).
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct ToolDefinition {
59 /// Machine name, e.g. `write`.
60 pub name: String,
61 /// Human label, e.g. `Write File`.
62 pub label: String,
63 /// Description shown to the model.
64 pub description: String,
65 /// Input parameter schema.
66 pub parameters: ParameterSchema,
67}
68
69/// The trait every tool implements.
70///
71/// Flue's `AgentTool` is an object with a `parameters` schema and an async
72/// `execute`. The Rust equivalent is this trait, wrapped in `Arc<dyn Tool>`.
73///
74/// # Panic safety (precondition)
75///
76/// `run_agent` wraps `execute` in `catch_unwind` so a panicking tool is
77/// converted to a model-visible `Error:` result rather than aborting the run.
78/// For this to be sound, a tool's interior mutability must be **poison-free**
79/// (use `parking_lot` / atomics / `tokio::sync`, not `std::sync::Mutex`/
80/// `RwLock`, which poison on panic). A tool left mid-mutation by an unwinding
81/// panic must still be safely callable on subsequent turns. The built-in tools
82/// and `TaskTool` satisfy this.
83#[async_trait]
84pub trait Tool: Send + Sync {
85 /// The static definition (name/schema/description).
86 fn definition(&self) -> ToolDefinition;
87
88 /// Execute the tool with validated input.
89 async fn execute(&self, ctx: InvokeContext, input: Value) -> Result<ToolResult>;
90}
91
92/// Validate `input` against a tool's `parameters` schema.
93///
94/// MVP: only checks top-level required keys are present. Full JSON-Schema
95/// validation is layered in later (see `PORTING_PLAN.md`).
96pub fn validate_input(def: &ToolDefinition, input: &Value) -> Result<()> {
97 let Some(obj) = input.as_object() else {
98 return Err(CoreError::ToolInputValidation(format!(
99 "tool `{}` expects an object input",
100 def.name
101 )));
102 };
103 if let Some(required) = def
104 .parameters
105 .fields
106 .get("required")
107 .and_then(Value::as_array)
108 {
109 for req in required {
110 if let Some(key) = req.as_str() {
111 if !obj.contains_key(key) {
112 return Err(CoreError::ToolInputValidation(format!(
113 "tool `{}` missing required parameter `{key}`",
114 def.name
115 )));
116 }
117 }
118 }
119 }
120 Ok(())
121}