Skip to main content

onwards/traits/
tool_executor.rs

1//! Tool execution trait for server-side tool handling
2//!
3//! Implement this trait to enable automatic tool execution during agent loops.
4//! When the model calls a tool that your executor provides (via `tools()`),
5//! the adapter will execute it locally and feed the result back to the model.
6
7use async_trait::async_trait;
8use std::fmt;
9
10/// Per-request context threaded through the tool executor.
11///
12/// Carries the model name (for per-deployment resolution) and arbitrary
13/// extension data inserted by middleware (e.g. resolved user/group info).
14///
15/// # Extensions
16///
17/// The `extensions` field is an in-process type map — it does **not** transit
18/// HTTP. It exists primarily for **library mode**, where onwards is embedded in
19/// another service: middleware layers can insert typed data (e.g. resolved
20/// user/group info, tenant context) that the `ToolExecutor` implementation
21/// reads when executing tools.
22///
23/// In **service mode** (onwards running as a standalone proxy), the
24/// `ToolExecutor` implementation typically resolves tools from its own state
25/// (database, config files, etc.) using information already present in the
26/// request such as the model name or auth headers, and `extensions` is unused.
27#[derive(Debug, Default)]
28pub struct RequestContext {
29    /// The model alias from the request body (if available).
30    pub model: Option<String>,
31    /// Arbitrary typed data inserted by middleware layers.
32    pub extensions: axum::http::Extensions,
33}
34
35impl RequestContext {
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    pub fn with_model(mut self, model: impl Into<String>) -> Self {
41        self.model = Some(model.into());
42        self
43    }
44
45    pub fn with_extension<T: Clone + Send + Sync + 'static>(mut self, val: T) -> Self {
46        self.extensions.insert(val);
47        self
48    }
49}
50
51/// Dispatch kind for a server-side tool.
52///
53/// Onwards stays agnostic to the underlying implementation (HTTP, MCP,
54/// sandboxed execution, etc.) — `Http` covers anything the executor
55/// fires-and-returns through [`ToolExecutor::execute`]. `Agent` is the
56/// only special case: when the multi-step orchestration loop encounters
57/// a step whose tool has `kind = Agent`, it recurses into a sub-loop
58/// scoped under that step instead of calling `execute`.
59#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
60pub enum ToolKind {
61    /// Standard tool. The loop calls [`ToolExecutor::execute`] and
62    /// persists the returned payload as the step's `response_payload`.
63    #[default]
64    Http,
65    /// Sub-agent dispatch. The loop recurses with `scope_parent =
66    /// Some(step_id)` and `depth + 1`. The sub-loop's final return value
67    /// becomes the spawning step's `response_payload`.
68    Agent,
69}
70
71/// Schema for a server-side tool, returned by [`ToolExecutor::tools`].
72#[derive(Debug, Clone)]
73pub struct ToolSchema {
74    /// Tool name (must be unique within a request).
75    pub name: String,
76    /// Human-readable description shown to the model.
77    pub description: String,
78    /// JSON Schema for the tool's parameters.
79    pub parameters: serde_json::Value,
80    /// Whether to enforce strict schema adherence (OpenAI-specific).
81    pub strict: bool,
82    /// Dispatch kind. Defaults to [`ToolKind::Http`] so existing
83    /// implementations that don't set this field continue to behave
84    /// exactly as before.
85    #[doc(hidden)] // hide from rendered docs while the field is opt-in
86    pub kind: ToolKind,
87}
88
89/// Error type for tool execution
90#[derive(Debug, Clone)]
91pub enum ToolError {
92    /// Tool is not recognized/supported
93    NotFound(String),
94    /// Tool execution failed
95    ExecutionError(String),
96    /// Invalid arguments provided to tool
97    InvalidArguments(String),
98    /// Tool execution timed out
99    Timeout(String),
100}
101
102impl fmt::Display for ToolError {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        match self {
105            ToolError::NotFound(name) => write!(f, "Tool not found: {}", name),
106            ToolError::ExecutionError(msg) => write!(f, "Tool execution error: {}", msg),
107            ToolError::InvalidArguments(msg) => write!(f, "Invalid arguments: {}", msg),
108            ToolError::Timeout(msg) => write!(f, "Tool timeout: {}", msg),
109        }
110    }
111}
112
113impl std::error::Error for ToolError {}
114
115/// Trait for executing tools server-side during agent loops.
116///
117/// Implement this to enable automatic tool execution in the Open Responses adapter.
118/// The adapter will:
119/// 1. Call `tools()` with the request context to discover available server-side tools
120/// 2. Merge server-side tool schemas with any client-provided tools
121/// 3. When the model returns a tool call for a server-side tool, call `execute()`
122/// 4. Feed the result back to the model and continue until completion
123///
124/// Tools that the executor does not provide (i.e. client-side tools) will be
125/// returned to the client with `requires_action` status.
126///
127/// # Example
128///
129/// ```ignore
130/// use onwards::traits::{ToolExecutor, ToolError, ToolSchema, RequestContext};
131/// use async_trait::async_trait;
132///
133/// struct WeatherTool;
134///
135/// #[async_trait]
136/// impl ToolExecutor for WeatherTool {
137///     async fn tools(&self, _ctx: &RequestContext) -> Vec<ToolSchema> {
138///         vec![ToolSchema {
139///             name: "get_weather".to_string(),
140///             description: "Get current weather for a location".to_string(),
141///             parameters: serde_json::json!({
142///                 "type": "object",
143///                 "properties": {"location": {"type": "string"}},
144///                 "required": ["location"]
145///             }),
146///             strict: false,
147///         }]
148///     }
149///
150///     async fn execute(
151///         &self,
152///         tool_name: &str,
153///         tool_call_id: &str,
154///         arguments: &serde_json::Value,
155///         ctx: &RequestContext,
156///     ) -> Result<serde_json::Value, ToolError> {
157///         if tool_name == "get_weather" {
158///             let location = arguments["location"].as_str()
159///                 .ok_or_else(|| ToolError::InvalidArguments("missing location".into()))?;
160///             Ok(serde_json::json!({"temperature": 72, "conditions": "sunny"}))
161///         } else {
162///             Err(ToolError::NotFound(tool_name.to_string()))
163///         }
164///     }
165/// }
166/// ```
167#[async_trait]
168pub trait ToolExecutor: Send + Sync {
169    /// Return the tool schemas available for this request context.
170    ///
171    /// Called once per request to discover server-side tools. The returned schemas
172    /// are merged with any client-provided tools and sent to the upstream model.
173    ///
174    /// Return an empty vec if no server-side tools are available.
175    async fn tools(&self, ctx: &RequestContext) -> Vec<ToolSchema>;
176
177    /// Execute a tool call and return the result.
178    ///
179    /// # Arguments
180    /// * `tool_name` - The name of the tool to execute
181    /// * `tool_call_id` - The unique ID for this tool call (for correlation)
182    /// * `arguments` - The arguments passed to the tool (as JSON)
183    /// * `ctx` - The per-request context
184    ///
185    /// # Returns
186    /// * `Ok(Value)` - The tool's output as JSON
187    /// * `Err(ToolError)` - If execution failed
188    async fn execute(
189        &self,
190        tool_name: &str,
191        tool_call_id: &str,
192        arguments: &serde_json::Value,
193        ctx: &RequestContext,
194    ) -> Result<serde_json::Value, ToolError>;
195}
196
197/// No-op implementation that handles no tools.
198///
199/// This is the default implementation used when no executor is configured.
200/// All tool calls will be returned to the client with `requires_action` status.
201#[derive(Debug, Clone, Default)]
202pub struct NoOpToolExecutor;
203
204#[async_trait]
205impl ToolExecutor for NoOpToolExecutor {
206    async fn tools(&self, _ctx: &RequestContext) -> Vec<ToolSchema> {
207        Vec::new()
208    }
209
210    async fn execute(
211        &self,
212        tool_name: &str,
213        _tool_call_id: &str,
214        _arguments: &serde_json::Value,
215        _ctx: &RequestContext,
216    ) -> Result<serde_json::Value, ToolError> {
217        Err(ToolError::NotFound(tool_name.to_string()))
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[tokio::test]
226    async fn test_noop_executor_returns_no_tools() {
227        let executor = NoOpToolExecutor;
228        let ctx = RequestContext::new();
229        assert!(executor.tools(&ctx).await.is_empty());
230    }
231
232    #[tokio::test]
233    async fn test_noop_executor_returns_not_found() {
234        let executor = NoOpToolExecutor;
235        let ctx = RequestContext::new();
236        let result = executor
237            .execute("test_tool", "call_123", &serde_json::json!({}), &ctx)
238            .await;
239        assert!(matches!(result, Err(ToolError::NotFound(_))));
240    }
241
242    #[test]
243    fn test_request_context_builder() {
244        let ctx = RequestContext::new()
245            .with_model("gpt-4o")
246            .with_extension(42u32);
247        assert_eq!(ctx.model.as_deref(), Some("gpt-4o"));
248        assert_eq!(ctx.extensions.get::<u32>(), Some(&42));
249    }
250}