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    /// Live swarm-progress publisher for the `agent_swarm` tool.
128    pub swarm_progress_sink: Option<Arc<dyn crate::subagents::AgentSwarmProgressSink>>,
129}
130
131impl fmt::Debug for ToolExecutionHandles {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        f.debug_struct("ToolExecutionHandles")
134            .field("workspace", &self.workspace.is_some())
135            .field("remote_workspace", &self.remote_workspace.is_some())
136            .field("process_runner", &self.process_runner.is_some())
137            .field("subagent_trace_sink", &self.subagent_trace_sink.is_some())
138            .field("context_artifacts", &self.context_artifacts.is_some())
139            .field("goal_controller", &self.goal_controller.is_some())
140            .field("swarm_progress_sink", &self.swarm_progress_sink.is_some())
141            .finish()
142    }
143}
144
145pub trait ScopedWorkspaceHandle: Send + Sync + 'static {
146    fn workspace_root(&self) -> Option<PathBuf>;
147}
148
149pub trait ScopedProcessRunner: Send + Sync + 'static {
150    fn runner_name(&self) -> &str;
151}
152
153#[derive(Debug, Clone)]
154pub struct LocalWorkspaceHandle {
155    root: PathBuf,
156}
157
158impl LocalWorkspaceHandle {
159    pub fn new(root: impl Into<PathBuf>) -> Self {
160        Self { root: root.into() }
161    }
162}
163
164impl ScopedWorkspaceHandle for LocalWorkspaceHandle {
165    fn workspace_root(&self) -> Option<PathBuf> {
166        Some(self.root.clone())
167    }
168}
169
170#[derive(Debug, Clone, Default)]
171pub struct LocalProcessRunnerHandle;
172
173impl ScopedProcessRunner for LocalProcessRunnerHandle {
174    fn runner_name(&self) -> &str {
175        "local-process"
176    }
177}
178
179#[derive(Debug, Clone)]
180pub struct ToolExecutionContext {
181    pub thread_id: ThreadId,
182    pub turn_id: TurnId,
183    pub effective_mode: PolicyMode,
184    pub command_shell: Option<String>,
185    pub deadline_remaining_seconds: Option<u64>,
186    pub handles: ToolExecutionHandles,
187}
188
189impl ToolExecutionContext {
190    pub fn new(
191        thread_id: impl Into<ThreadId>,
192        turn_id: impl Into<TurnId>,
193        effective_mode: PolicyMode,
194    ) -> Self {
195        Self {
196            thread_id: thread_id.into(),
197            turn_id: turn_id.into(),
198            effective_mode,
199            command_shell: None,
200            deadline_remaining_seconds: None,
201            handles: ToolExecutionHandles::default(),
202        }
203    }
204
205    pub fn with_command_shell(mut self, shell: impl Into<String>) -> Self {
206        let shell = shell.into();
207        if !shell.trim().is_empty() {
208            self.command_shell = Some(shell);
209        }
210        self
211    }
212
213    pub fn with_deadline_remaining_seconds(mut self, seconds: u64) -> Self {
214        self.deadline_remaining_seconds = Some(seconds);
215        self
216    }
217
218    pub fn with_workspace_handle(mut self, handle: Arc<dyn ScopedWorkspaceHandle>) -> Self {
219        self.handles.workspace = Some(handle);
220        self
221    }
222
223    pub fn with_remote_workspace(mut self, remote: Arc<RemoteWorkspace>) -> Self {
224        self.handles.remote_workspace = Some(remote);
225        self
226    }
227
228    pub fn with_process_runner(mut self, runner: Arc<dyn ScopedProcessRunner>) -> Self {
229        self.handles.process_runner = Some(runner);
230        self
231    }
232
233    pub fn with_subagent_trace_sink(mut self, sink: Arc<dyn SubagentTraceSink>) -> Self {
234        self.handles.subagent_trace_sink = Some(sink);
235        self
236    }
237
238    pub fn with_context_artifacts(mut self, store: Arc<dyn ContextArtifactAccess>) -> Self {
239        self.handles.context_artifacts = Some(store);
240        self
241    }
242
243    pub fn with_goal_controller(mut self, controller: Arc<dyn ThreadGoalController>) -> Self {
244        self.handles.goal_controller = Some(controller);
245        self
246    }
247
248    pub fn with_swarm_progress_sink(
249        mut self,
250        sink: Arc<dyn crate::subagents::AgentSwarmProgressSink>,
251    ) -> Self {
252        self.handles.swarm_progress_sink = Some(sink);
253        self
254    }
255
256    pub fn require_workspace(&self) -> anyhow::Result<Arc<dyn ScopedWorkspaceHandle>> {
257        self.handles
258            .workspace
259            .clone()
260            .ok_or_else(|| anyhow::anyhow!("workspace handle is not available"))
261    }
262
263    pub fn require_process_runner(&self) -> anyhow::Result<Arc<dyn ScopedProcessRunner>> {
264        self.handles
265            .process_runner
266            .clone()
267            .ok_or_else(|| anyhow::anyhow!("process runner is not available"))
268    }
269
270    pub fn require_context_artifacts(&self) -> anyhow::Result<Arc<dyn ContextArtifactAccess>> {
271        self.handles
272            .context_artifacts
273            .clone()
274            .ok_or_else(|| anyhow::anyhow!("context artifact store is not available"))
275    }
276
277    pub fn require_goal_controller(&self) -> anyhow::Result<Arc<dyn ThreadGoalController>> {
278        self.handles
279            .goal_controller
280            .clone()
281            .ok_or_else(|| anyhow::anyhow!("goal controller is not available"))
282    }
283}
284
285#[async_trait::async_trait]
286pub trait ToolExecutor: Send + Sync + 'static {
287    fn spec(&self) -> ToolSpec;
288
289    async fn execute(
290        &self,
291        ctx: ToolExecutionContext,
292        call: ToolCall,
293    ) -> anyhow::Result<ToolResult>;
294}
295
296#[derive(Default, Clone)]
297pub struct ToolRegistry {
298    tools: BTreeMap<String, Arc<dyn ToolExecutor>>,
299}
300
301impl ToolRegistry {
302    pub fn register(&mut self, tool: Arc<dyn ToolExecutor>) -> anyhow::Result<()> {
303        let name = tool.spec().name;
304        if self.tools.contains_key(&name) {
305            anyhow::bail!("tool {name:?} is already registered");
306        }
307        self.tools.insert(name, tool);
308        Ok(())
309    }
310
311    /// Registers `tool`, replacing any executor already registered under the
312    /// same name. Used by the runtime to swap fake reference tools for fully
313    /// wired implementations.
314    pub fn replace(&mut self, tool: Arc<dyn ToolExecutor>) {
315        self.tools.insert(tool.spec().name, tool);
316    }
317
318    pub fn specs(&self) -> Vec<ToolSpec> {
319        self.tools
320            .values()
321            .map(|tool| {
322                tool.spec()
323                    .normalized_for_model(ToolSchemaPolicy::warning())
324            })
325            .collect()
326    }
327
328    pub fn specs_for_edit_tool(&self, edit_tool: Option<&str>) -> Vec<ToolSpec> {
329        self.specs_for_edit_tool_with_schema_policy(edit_tool, ModelSchemaPolicy::RequiredFirstFlat)
330    }
331
332    pub fn specs_for_edit_tool_with_schema_policy(
333        &self,
334        edit_tool: Option<&str>,
335        schema_policy: ModelSchemaPolicy,
336    ) -> Vec<ToolSpec> {
337        self.tools
338            .values()
339            .map(|tool| tool.spec())
340            .filter(|spec| keep_tool_for_edit_tool(&spec.name, edit_tool))
341            .map(|spec| spec.normalized_for_model_profile(schema_policy))
342            .collect()
343    }
344
345    pub fn get(&self, name: &str) -> Option<Arc<dyn ToolExecutor>> {
346        self.tools.get(name).cloned()
347    }
348
349    pub fn is_empty(&self) -> bool {
350        self.tools.is_empty()
351    }
352}
353
354fn keep_tool_for_edit_tool(name: &str, edit_tool: Option<&str>) -> bool {
355    match name {
356        "apply_patch" => true,
357        "write_file" | "edit" | "multi_edit" => !matches!(edit_tool, Some("patch")),
358        _ => true,
359    }
360}
361
362pub trait ToolContributor: Send + Sync + 'static {
363    fn id(&self) -> ToolProviderId;
364    fn contribute(&self, registry: &mut ToolRegistry) -> anyhow::Result<()>;
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    #[test]
372    fn tool_spec_can_be_represented_as_discovery_item() {
373        let spec = ToolSpec {
374            name: "grep".to_string(),
375            description: "Search files".to_string(),
376            parameters: serde_json::json!({
377                "type": "object",
378                "properties": {
379                    "query": { "type": "string" }
380                },
381                "required": ["query"]
382            }),
383        };
384
385        let item = spec.discovery_item(
386            "builtin-coding-tools",
387            "discovery/tools/builtin-coding-tools/grep.schema.json",
388        );
389        assert_eq!(item.id, "tool:builtin-coding-tools/grep");
390        assert_eq!(item.group_id, "tools:builtin-coding-tools");
391        assert_eq!(item.source.kind, DiscoverySourceKind::InternalTools);
392        assert_eq!(item.source.auth_state, DiscoveryAuthState::NotRequired);
393        assert_eq!(item.status, DiscoveryItemStatus::Available);
394        assert_eq!(item.lifecycle, DiscoveryLifecycleState::Discovered);
395        assert_eq!(
396            item.schema.as_ref().map(|schema| schema.format.clone()),
397            Some(DiscoverySchemaFormat::JsonSchema)
398        );
399    }
400
401    #[test]
402    fn apply_patch_is_kept_for_all_edit_tool_profiles() {
403        assert!(keep_tool_for_edit_tool("apply_patch", None));
404        assert!(keep_tool_for_edit_tool("apply_patch", Some("edit")));
405        assert!(keep_tool_for_edit_tool("apply_patch", Some("patch")));
406
407        assert!(keep_tool_for_edit_tool("edit", None));
408        assert!(keep_tool_for_edit_tool("edit", Some("edit")));
409        assert!(!keep_tool_for_edit_tool("edit", Some("patch")));
410        assert!(!keep_tool_for_edit_tool("multi_edit", Some("patch")));
411        assert!(!keep_tool_for_edit_tool("write_file", Some("patch")));
412    }
413}