Skip to main content

roder_api/
tools.rs

1use std::collections::BTreeMap;
2use std::fmt;
3use std::path::PathBuf;
4use std::sync::Arc;
5
6use serde::{Deserialize, Serialize};
7
8use crate::artifacts::ContextArtifactAccess;
9use crate::discovery::{
10    DiscoveryAuthState, DiscoveryCacheStatus, DiscoveryCatalogItem, DiscoveryCatalogSource,
11    DiscoveryItemStatus, DiscoveryLifecycleState, DiscoveryPromotionState, DiscoveryRedaction,
12    DiscoverySchemaFormat, DiscoverySchemaReference, DiscoverySourceKind,
13};
14use crate::events::{ThreadId, TurnId};
15use crate::extension::ToolProviderId;
16use crate::goals::ThreadGoalController;
17use crate::inference::ModelSchemaPolicy;
18use crate::policy_mode::PolicyMode;
19use crate::remote_runner::RemoteWorkspace;
20use crate::trace::SubagentTraceSink;
21use crate::{ToolSchemaPolicy, normalize_tool_schema};
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
24pub struct ToolSpec {
25    pub name: String,
26    pub description: String,
27    pub parameters: serde_json::Value,
28}
29
30impl ToolSpec {
31    pub fn normalized_for_model(&self, policy: ToolSchemaPolicy) -> Self {
32        let mut spec = self.clone();
33        spec.parameters = normalize_tool_schema(&spec.name, &spec.parameters, policy).schema;
34        spec
35    }
36
37    pub fn normalized_for_model_profile(&self, policy: ModelSchemaPolicy) -> Self {
38        match policy {
39            ModelSchemaPolicy::StandardRequiredFirst => {
40                self.normalized_for_model(ToolSchemaPolicy::warning())
41            }
42            ModelSchemaPolicy::RequiredFirstFlat => {
43                self.normalized_for_model(ToolSchemaPolicy::strict())
44            }
45        }
46    }
47
48    pub fn discovery_item(
49        &self,
50        provider_id: impl Into<String>,
51        schema_uri: impl Into<String>,
52    ) -> DiscoveryCatalogItem {
53        let provider_id = provider_id.into();
54        DiscoveryCatalogItem {
55            id: format!("tool:{provider_id}/{}", self.name),
56            group_id: format!("tools:{provider_id}"),
57            source: DiscoveryCatalogSource {
58                kind: DiscoverySourceKind::InternalTools,
59                id: provider_id.clone(),
60                display_name: provider_id,
61                origin: None,
62                auth_state: DiscoveryAuthState::NotRequired,
63                redaction: DiscoveryRedaction::none(),
64            },
65            name: self.name.clone(),
66            title: self.name.clone(),
67            description: Some(self.description.clone()),
68            status: DiscoveryItemStatus::Available,
69            lifecycle: DiscoveryLifecycleState::Discovered,
70            promotion: DiscoveryPromotionState::NotPromoted,
71            cache_status: DiscoveryCacheStatus::Cold,
72            schema: Some(DiscoverySchemaReference {
73                format: DiscoverySchemaFormat::JsonSchema,
74                uri: schema_uri.into(),
75                content_hash: None,
76                byte_count: None,
77                redaction: DiscoveryRedaction::none(),
78            }),
79            tags: vec!["tool".to_string()],
80            hints: Vec::new(),
81            redaction: DiscoveryRedaction::none(),
82            last_refreshed_at: None,
83        }
84    }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
88pub enum ToolChoice {
89    Auto,
90    Any,
91    None,
92    Specific(String),
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
96pub struct ToolCall {
97    pub id: String,
98    pub name: String,
99    pub arguments: serde_json::Value,
100    pub raw_arguments: String,
101    pub thread_id: ThreadId,
102    pub turn_id: TurnId,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
106pub struct ToolResult {
107    pub id: String,
108    pub name: String,
109    pub text: String,
110    pub data: serde_json::Value,
111    pub is_error: bool,
112}
113
114#[derive(Clone, Default)]
115pub struct ToolExecutionHandles {
116    pub workspace: Option<Arc<dyn ScopedWorkspaceHandle>>,
117    /**
118     * Remote-runner workspace for the thread. When present it takes
119     * precedence over `workspace`: coding tools must route file and shell
120     * operations through the runner session instead of the local filesystem.
121     */
122    pub remote_workspace: Option<Arc<RemoteWorkspace>>,
123    pub process_runner: Option<Arc<dyn ScopedProcessRunner>>,
124    pub subagent_trace_sink: Option<Arc<dyn SubagentTraceSink>>,
125    pub context_artifacts: Option<Arc<dyn ContextArtifactAccess>>,
126    pub goal_controller: Option<Arc<dyn ThreadGoalController>>,
127}
128
129impl fmt::Debug for ToolExecutionHandles {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        f.debug_struct("ToolExecutionHandles")
132            .field("workspace", &self.workspace.is_some())
133            .field("remote_workspace", &self.remote_workspace.is_some())
134            .field("process_runner", &self.process_runner.is_some())
135            .field("subagent_trace_sink", &self.subagent_trace_sink.is_some())
136            .field("context_artifacts", &self.context_artifacts.is_some())
137            .field("goal_controller", &self.goal_controller.is_some())
138            .finish()
139    }
140}
141
142pub trait ScopedWorkspaceHandle: Send + Sync + 'static {
143    fn workspace_root(&self) -> Option<PathBuf>;
144}
145
146pub trait ScopedProcessRunner: Send + Sync + 'static {
147    fn runner_name(&self) -> &str;
148}
149
150#[derive(Debug, Clone)]
151pub struct LocalWorkspaceHandle {
152    root: PathBuf,
153}
154
155impl LocalWorkspaceHandle {
156    pub fn new(root: impl Into<PathBuf>) -> Self {
157        Self { root: root.into() }
158    }
159}
160
161impl ScopedWorkspaceHandle for LocalWorkspaceHandle {
162    fn workspace_root(&self) -> Option<PathBuf> {
163        Some(self.root.clone())
164    }
165}
166
167#[derive(Debug, Clone, Default)]
168pub struct LocalProcessRunnerHandle;
169
170impl ScopedProcessRunner for LocalProcessRunnerHandle {
171    fn runner_name(&self) -> &str {
172        "local-process"
173    }
174}
175
176#[derive(Debug, Clone)]
177pub struct ToolExecutionContext {
178    pub thread_id: ThreadId,
179    pub turn_id: TurnId,
180    pub effective_mode: PolicyMode,
181    pub command_shell: Option<String>,
182    pub deadline_remaining_seconds: Option<u64>,
183    pub handles: ToolExecutionHandles,
184}
185
186impl ToolExecutionContext {
187    pub fn new(
188        thread_id: impl Into<ThreadId>,
189        turn_id: impl Into<TurnId>,
190        effective_mode: PolicyMode,
191    ) -> Self {
192        Self {
193            thread_id: thread_id.into(),
194            turn_id: turn_id.into(),
195            effective_mode,
196            command_shell: None,
197            deadline_remaining_seconds: None,
198            handles: ToolExecutionHandles::default(),
199        }
200    }
201
202    pub fn with_command_shell(mut self, shell: impl Into<String>) -> Self {
203        let shell = shell.into();
204        if !shell.trim().is_empty() {
205            self.command_shell = Some(shell);
206        }
207        self
208    }
209
210    pub fn with_deadline_remaining_seconds(mut self, seconds: u64) -> Self {
211        self.deadline_remaining_seconds = Some(seconds);
212        self
213    }
214
215    pub fn with_workspace_handle(mut self, handle: Arc<dyn ScopedWorkspaceHandle>) -> Self {
216        self.handles.workspace = Some(handle);
217        self
218    }
219
220    pub fn with_remote_workspace(mut self, remote: Arc<RemoteWorkspace>) -> Self {
221        self.handles.remote_workspace = Some(remote);
222        self
223    }
224
225    pub fn with_process_runner(mut self, runner: Arc<dyn ScopedProcessRunner>) -> Self {
226        self.handles.process_runner = Some(runner);
227        self
228    }
229
230    pub fn with_subagent_trace_sink(mut self, sink: Arc<dyn SubagentTraceSink>) -> Self {
231        self.handles.subagent_trace_sink = Some(sink);
232        self
233    }
234
235    pub fn with_context_artifacts(mut self, store: Arc<dyn ContextArtifactAccess>) -> Self {
236        self.handles.context_artifacts = Some(store);
237        self
238    }
239
240    pub fn with_goal_controller(mut self, controller: Arc<dyn ThreadGoalController>) -> Self {
241        self.handles.goal_controller = Some(controller);
242        self
243    }
244
245    pub fn require_workspace(&self) -> anyhow::Result<Arc<dyn ScopedWorkspaceHandle>> {
246        self.handles
247            .workspace
248            .clone()
249            .ok_or_else(|| anyhow::anyhow!("workspace handle is not available"))
250    }
251
252    pub fn require_process_runner(&self) -> anyhow::Result<Arc<dyn ScopedProcessRunner>> {
253        self.handles
254            .process_runner
255            .clone()
256            .ok_or_else(|| anyhow::anyhow!("process runner is not available"))
257    }
258
259    pub fn require_context_artifacts(&self) -> anyhow::Result<Arc<dyn ContextArtifactAccess>> {
260        self.handles
261            .context_artifacts
262            .clone()
263            .ok_or_else(|| anyhow::anyhow!("context artifact store is not available"))
264    }
265
266    pub fn require_goal_controller(&self) -> anyhow::Result<Arc<dyn ThreadGoalController>> {
267        self.handles
268            .goal_controller
269            .clone()
270            .ok_or_else(|| anyhow::anyhow!("goal controller is not available"))
271    }
272}
273
274#[async_trait::async_trait]
275pub trait ToolExecutor: Send + Sync + 'static {
276    fn spec(&self) -> ToolSpec;
277
278    async fn execute(
279        &self,
280        ctx: ToolExecutionContext,
281        call: ToolCall,
282    ) -> anyhow::Result<ToolResult>;
283}
284
285#[derive(Default, Clone)]
286pub struct ToolRegistry {
287    tools: BTreeMap<String, Arc<dyn ToolExecutor>>,
288}
289
290impl ToolRegistry {
291    pub fn register(&mut self, tool: Arc<dyn ToolExecutor>) -> anyhow::Result<()> {
292        let name = tool.spec().name;
293        if self.tools.contains_key(&name) {
294            anyhow::bail!("tool {name:?} is already registered");
295        }
296        self.tools.insert(name, tool);
297        Ok(())
298    }
299
300    /// Registers `tool`, replacing any executor already registered under the
301    /// same name. Used by the runtime to swap fake reference tools for fully
302    /// wired implementations.
303    pub fn replace(&mut self, tool: Arc<dyn ToolExecutor>) {
304        self.tools.insert(tool.spec().name, tool);
305    }
306
307    pub fn specs(&self) -> Vec<ToolSpec> {
308        self.tools
309            .values()
310            .map(|tool| {
311                tool.spec()
312                    .normalized_for_model(ToolSchemaPolicy::warning())
313            })
314            .collect()
315    }
316
317    pub fn specs_for_edit_tool(&self, edit_tool: Option<&str>) -> Vec<ToolSpec> {
318        self.specs_for_edit_tool_with_schema_policy(edit_tool, ModelSchemaPolicy::RequiredFirstFlat)
319    }
320
321    pub fn specs_for_edit_tool_with_schema_policy(
322        &self,
323        edit_tool: Option<&str>,
324        schema_policy: ModelSchemaPolicy,
325    ) -> Vec<ToolSpec> {
326        self.tools
327            .values()
328            .map(|tool| tool.spec())
329            .filter(|spec| keep_tool_for_edit_tool(&spec.name, edit_tool))
330            .map(|spec| spec.normalized_for_model_profile(schema_policy))
331            .collect()
332    }
333
334    pub fn get(&self, name: &str) -> Option<Arc<dyn ToolExecutor>> {
335        self.tools.get(name).cloned()
336    }
337
338    pub fn is_empty(&self) -> bool {
339        self.tools.is_empty()
340    }
341}
342
343fn keep_tool_for_edit_tool(name: &str, edit_tool: Option<&str>) -> bool {
344    match name {
345        "apply_patch" => true,
346        "write_file" | "edit" | "multi_edit" => !matches!(edit_tool, Some("patch")),
347        _ => true,
348    }
349}
350
351pub trait ToolContributor: Send + Sync + 'static {
352    fn id(&self) -> ToolProviderId;
353    fn contribute(&self, registry: &mut ToolRegistry) -> anyhow::Result<()>;
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn tool_spec_can_be_represented_as_discovery_item() {
362        let spec = ToolSpec {
363            name: "grep".to_string(),
364            description: "Search files".to_string(),
365            parameters: serde_json::json!({
366                "type": "object",
367                "properties": {
368                    "query": { "type": "string" }
369                },
370                "required": ["query"]
371            }),
372        };
373
374        let item = spec.discovery_item(
375            "builtin-coding-tools",
376            "discovery/tools/builtin-coding-tools/grep.schema.json",
377        );
378        assert_eq!(item.id, "tool:builtin-coding-tools/grep");
379        assert_eq!(item.group_id, "tools:builtin-coding-tools");
380        assert_eq!(item.source.kind, DiscoverySourceKind::InternalTools);
381        assert_eq!(item.source.auth_state, DiscoveryAuthState::NotRequired);
382        assert_eq!(item.status, DiscoveryItemStatus::Available);
383        assert_eq!(item.lifecycle, DiscoveryLifecycleState::Discovered);
384        assert_eq!(
385            item.schema.as_ref().map(|schema| schema.format.clone()),
386            Some(DiscoverySchemaFormat::JsonSchema)
387        );
388    }
389
390    #[test]
391    fn apply_patch_is_kept_for_all_edit_tool_profiles() {
392        assert!(keep_tool_for_edit_tool("apply_patch", None));
393        assert!(keep_tool_for_edit_tool("apply_patch", Some("edit")));
394        assert!(keep_tool_for_edit_tool("apply_patch", Some("patch")));
395
396        assert!(keep_tool_for_edit_tool("edit", None));
397        assert!(keep_tool_for_edit_tool("edit", Some("edit")));
398        assert!(!keep_tool_for_edit_tool("edit", Some("patch")));
399        assert!(!keep_tool_for_edit_tool("multi_edit", Some("patch")));
400        assert!(!keep_tool_for_edit_tool("write_file", Some("patch")));
401    }
402}