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}