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
23mod scheduling;
24pub use scheduling::{ToolExecutionBarrier, ToolExecutionGuard};
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
27pub struct ToolSpec {
28    pub name: String,
29    pub description: String,
30    pub parameters: serde_json::Value,
31}
32
33impl ToolSpec {
34    /// The argument field a raw freeform-channel body maps to, for tools that
35    /// are best emitted on a provider's freeform/custom tool channel instead of
36    /// as a JSON-schema function.
37    ///
38    /// `apply_patch` emits grammar-constrained patches on the supported
39    /// OpenAI Responses custom-tool channel (`type:"custom"`),
40    /// where the call carries the raw patch text as a string `input` rather than
41    /// JSON arguments. Ordinary function tools return `None`.
42    /// Providers without a freeform channel ignore
43    /// it and always serialize `type:"function"`.
44    pub fn freeform_input_field(&self) -> Option<&'static str> {
45        Self::freeform_field_for_name(&self.name)
46    }
47
48    pub fn freeform_field_for_name(name: &str) -> Option<&'static str> {
49        match name {
50            "apply_patch" => Some("patch"),
51            _ => None,
52        }
53    }
54
55    pub fn normalized_for_model(&self, policy: ToolSchemaPolicy) -> Self {
56        let mut spec = self.clone();
57        spec.parameters = normalize_tool_schema(&spec.name, &spec.parameters, policy).schema;
58        spec
59    }
60
61    pub fn normalized_for_model_profile(&self, policy: ModelSchemaPolicy) -> Self {
62        match policy {
63            ModelSchemaPolicy::StandardRequiredFirst => {
64                self.normalized_for_model(ToolSchemaPolicy::warning())
65            }
66            ModelSchemaPolicy::RequiredFirstFlat => {
67                self.normalized_for_model(ToolSchemaPolicy::strict())
68            }
69        }
70    }
71
72    pub fn discovery_item(
73        &self,
74        provider_id: impl Into<String>,
75        schema_uri: impl Into<String>,
76    ) -> DiscoveryCatalogItem {
77        let provider_id = provider_id.into();
78        DiscoveryCatalogItem {
79            id: format!("tool:{provider_id}/{}", self.name),
80            group_id: format!("tools:{provider_id}"),
81            source: DiscoveryCatalogSource {
82                kind: DiscoverySourceKind::InternalTools,
83                id: provider_id.clone(),
84                display_name: provider_id,
85                origin: None,
86                auth_state: DiscoveryAuthState::NotRequired,
87                redaction: DiscoveryRedaction::none(),
88            },
89            name: self.name.clone(),
90            title: self.name.clone(),
91            description: Some(self.description.clone()),
92            status: DiscoveryItemStatus::Available,
93            lifecycle: DiscoveryLifecycleState::Discovered,
94            promotion: DiscoveryPromotionState::NotPromoted,
95            cache_status: DiscoveryCacheStatus::Cold,
96            schema: Some(DiscoverySchemaReference {
97                format: DiscoverySchemaFormat::JsonSchema,
98                uri: schema_uri.into(),
99                content_hash: None,
100                byte_count: None,
101                redaction: DiscoveryRedaction::none(),
102            }),
103            tags: vec!["tool".to_string()],
104            hints: Vec::new(),
105            redaction: DiscoveryRedaction::none(),
106            last_refreshed_at: None,
107        }
108    }
109}
110
111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
112pub enum ToolChoice {
113    Auto,
114    Any,
115    None,
116    Specific(String),
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
120pub struct ToolCall {
121    pub id: String,
122    pub name: String,
123    pub arguments: serde_json::Value,
124    pub raw_arguments: String,
125    pub thread_id: ThreadId,
126    pub turn_id: TurnId,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
130pub struct ToolResult {
131    pub id: String,
132    pub name: String,
133    pub text: String,
134    pub data: serde_json::Value,
135    pub is_error: bool,
136}
137
138#[derive(Clone, Default)]
139pub struct ToolExecutionHandles {
140    pub workspace: Option<Arc<dyn ScopedWorkspaceHandle>>,
141    /**
142     * Remote-runner workspace for the thread. When present it takes
143     * precedence over `workspace`: coding tools must route file and shell
144     * operations through the runner session instead of the local filesystem.
145     */
146    pub remote_workspace: Option<Arc<RemoteWorkspace>>,
147    pub process_runner: Option<Arc<dyn ScopedProcessRunner>>,
148    pub subagent_trace_sink: Option<Arc<dyn SubagentTraceSink>>,
149    pub context_artifacts: Option<Arc<dyn ContextArtifactAccess>>,
150    pub goal_controller: Option<Arc<dyn ThreadGoalController>>,
151    /// Live swarm-progress publisher for the `agent_swarm` tool.
152    pub swarm_progress_sink: Option<Arc<dyn crate::subagents::AgentSwarmProgressSink>>,
153    /// Parent turn's concrete model selection so `task` / `agent_swarm`
154    /// children inherit the live provider+model instead of process startup
155    /// defaults (for example SuperGrok/grok-4.5 rather than gpt-5.5).
156    pub parent_model_selection: Option<crate::inference::ModelSelection>,
157    /// Inherited by nested tool dispatchers so their concrete child operations
158    /// share the parent's resource barrier rather than recursively locking it.
159    pub execution_barrier: Option<Arc<ToolExecutionBarrier>>,
160}
161
162impl fmt::Debug for ToolExecutionHandles {
163    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164        f.debug_struct("ToolExecutionHandles")
165            .field("workspace", &self.workspace.is_some())
166            .field("remote_workspace", &self.remote_workspace.is_some())
167            .field("process_runner", &self.process_runner.is_some())
168            .field("subagent_trace_sink", &self.subagent_trace_sink.is_some())
169            .field("context_artifacts", &self.context_artifacts.is_some())
170            .field("goal_controller", &self.goal_controller.is_some())
171            .field("swarm_progress_sink", &self.swarm_progress_sink.is_some())
172            .field("parent_model_selection", &self.parent_model_selection)
173            .field("execution_barrier", &self.execution_barrier.is_some())
174            .finish()
175    }
176}
177
178pub trait ScopedWorkspaceHandle: Send + Sync + 'static {
179    fn workspace_root(&self) -> Option<PathBuf>;
180}
181
182pub trait ScopedProcessRunner: Send + Sync + 'static {
183    fn runner_name(&self) -> &str;
184}
185
186#[derive(Debug, Clone)]
187pub struct LocalWorkspaceHandle {
188    root: PathBuf,
189}
190
191impl LocalWorkspaceHandle {
192    pub fn new(root: impl Into<PathBuf>) -> Self {
193        Self { root: root.into() }
194    }
195}
196
197impl ScopedWorkspaceHandle for LocalWorkspaceHandle {
198    fn workspace_root(&self) -> Option<PathBuf> {
199        Some(self.root.clone())
200    }
201}
202
203#[derive(Debug, Clone, Default)]
204pub struct LocalProcessRunnerHandle;
205
206impl ScopedProcessRunner for LocalProcessRunnerHandle {
207    fn runner_name(&self) -> &str {
208        "local-process"
209    }
210}
211
212#[derive(Debug, Clone)]
213pub struct ToolExecutionContext {
214    pub thread_id: ThreadId,
215    pub turn_id: TurnId,
216    pub effective_mode: PolicyMode,
217    pub command_shell: Option<String>,
218    pub deadline_remaining_seconds: Option<u64>,
219    pub handles: ToolExecutionHandles,
220}
221
222impl ToolExecutionContext {
223    pub fn new(
224        thread_id: impl Into<ThreadId>,
225        turn_id: impl Into<TurnId>,
226        effective_mode: PolicyMode,
227    ) -> Self {
228        Self {
229            thread_id: thread_id.into(),
230            turn_id: turn_id.into(),
231            effective_mode,
232            command_shell: None,
233            deadline_remaining_seconds: None,
234            handles: ToolExecutionHandles::default(),
235        }
236    }
237
238    pub fn with_command_shell(mut self, shell: impl Into<String>) -> Self {
239        let shell = shell.into();
240        if !shell.trim().is_empty() {
241            self.command_shell = Some(shell);
242        }
243        self
244    }
245
246    pub fn with_deadline_remaining_seconds(mut self, seconds: u64) -> Self {
247        self.deadline_remaining_seconds = Some(seconds);
248        self
249    }
250
251    pub fn with_workspace_handle(mut self, handle: Arc<dyn ScopedWorkspaceHandle>) -> Self {
252        self.handles.workspace = Some(handle);
253        self
254    }
255
256    pub fn with_remote_workspace(mut self, remote: Arc<RemoteWorkspace>) -> Self {
257        self.handles.remote_workspace = Some(remote);
258        self
259    }
260
261    pub fn with_process_runner(mut self, runner: Arc<dyn ScopedProcessRunner>) -> Self {
262        self.handles.process_runner = Some(runner);
263        self
264    }
265
266    pub fn with_subagent_trace_sink(mut self, sink: Arc<dyn SubagentTraceSink>) -> Self {
267        self.handles.subagent_trace_sink = Some(sink);
268        self
269    }
270
271    pub fn with_context_artifacts(mut self, store: Arc<dyn ContextArtifactAccess>) -> Self {
272        self.handles.context_artifacts = Some(store);
273        self
274    }
275
276    pub fn with_goal_controller(mut self, controller: Arc<dyn ThreadGoalController>) -> Self {
277        self.handles.goal_controller = Some(controller);
278        self
279    }
280
281    pub fn with_parent_model_selection(
282        mut self,
283        selection: crate::inference::ModelSelection,
284    ) -> Self {
285        self.handles.parent_model_selection = Some(selection);
286        self
287    }
288
289    pub fn with_swarm_progress_sink(
290        mut self,
291        sink: Arc<dyn crate::subagents::AgentSwarmProgressSink>,
292    ) -> Self {
293        self.handles.swarm_progress_sink = Some(sink);
294        self
295    }
296
297    pub fn require_workspace(&self) -> anyhow::Result<Arc<dyn ScopedWorkspaceHandle>> {
298        self.handles
299            .workspace
300            .clone()
301            .ok_or_else(|| anyhow::anyhow!("workspace handle is not available"))
302    }
303
304    pub fn require_process_runner(&self) -> anyhow::Result<Arc<dyn ScopedProcessRunner>> {
305        self.handles
306            .process_runner
307            .clone()
308            .ok_or_else(|| anyhow::anyhow!("process runner is not available"))
309    }
310
311    pub fn require_context_artifacts(&self) -> anyhow::Result<Arc<dyn ContextArtifactAccess>> {
312        self.handles
313            .context_artifacts
314            .clone()
315            .ok_or_else(|| anyhow::anyhow!("context artifact store is not available"))
316    }
317
318    pub fn require_goal_controller(&self) -> anyhow::Result<Arc<dyn ThreadGoalController>> {
319        self.handles
320            .goal_controller
321            .clone()
322            .ok_or_else(|| anyhow::anyhow!("goal controller is not available"))
323    }
324}
325
326/// Scheduling contract for an executor, independent of approval and authorization.
327///
328/// Only independent, side-effect-free reads may opt into `SharedRead`. Unknown
329/// tools and tools that mutate any shared state must keep the exclusive default.
330#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
331pub enum ToolExecutionMode {
332    SharedRead,
333    #[default]
334    Exclusive,
335    /// Only delegates work; it must not access shared resources itself.
336    /// Nested concrete operations must acquire the inherited execution barrier.
337    Coordinator,
338}
339
340#[async_trait::async_trait]
341pub trait ToolExecutor: Send + Sync + 'static {
342    fn spec(&self) -> ToolSpec;
343
344    /// Shared reads can overlap other reads, including during model sampling.
345    /// This is not an approval decision: validation, policy and workspace
346    /// authorization still run normally for every execution.
347    fn execution_mode(&self) -> ToolExecutionMode {
348        ToolExecutionMode::Exclusive
349    }
350
351    async fn execute(
352        &self,
353        ctx: ToolExecutionContext,
354        call: ToolCall,
355    ) -> anyhow::Result<ToolResult>;
356}
357
358#[derive(Default, Clone)]
359pub struct ToolRegistry {
360    tools: BTreeMap<String, Arc<dyn ToolExecutor>>,
361}
362
363impl ToolRegistry {
364    pub fn register(&mut self, tool: Arc<dyn ToolExecutor>) -> anyhow::Result<()> {
365        let name = tool.spec().name;
366        if self.tools.contains_key(&name) {
367            anyhow::bail!("tool {name:?} is already registered");
368        }
369        self.tools.insert(name, tool);
370        Ok(())
371    }
372
373    /// Registers `tool`, replacing any executor already registered under the
374    /// same name. Used by the runtime to swap fake reference tools for fully
375    /// wired implementations.
376    pub fn replace(&mut self, tool: Arc<dyn ToolExecutor>) {
377        self.tools.insert(tool.spec().name, tool);
378    }
379
380    pub fn specs(&self) -> Vec<ToolSpec> {
381        self.tools
382            .values()
383            .map(|tool| {
384                tool.spec()
385                    .normalized_for_model(ToolSchemaPolicy::warning())
386            })
387            .collect()
388    }
389
390    pub fn specs_for_edit_tool(&self, edit_tool: Option<&str>) -> Vec<ToolSpec> {
391        self.specs_for_edit_tool_with_schema_policy(edit_tool, ModelSchemaPolicy::RequiredFirstFlat)
392    }
393
394    pub fn specs_for_edit_tool_with_schema_policy(
395        &self,
396        edit_tool: Option<&str>,
397        schema_policy: ModelSchemaPolicy,
398    ) -> Vec<ToolSpec> {
399        self.tools
400            .values()
401            .map(|tool| tool.spec())
402            .filter(|spec| keep_tool_for_edit_tool(&spec.name, edit_tool))
403            .map(|spec| spec.normalized_for_model_profile(schema_policy))
404            .collect()
405    }
406
407    pub fn get(&self, name: &str) -> Option<Arc<dyn ToolExecutor>> {
408        self.tools.get(name).cloned()
409    }
410
411    pub fn is_empty(&self) -> bool {
412        self.tools.is_empty()
413    }
414}
415
416fn keep_tool_for_edit_tool(name: &str, edit_tool: Option<&str>) -> bool {
417    match name {
418        "apply_patch" => true,
419        "write_file" | "edit" | "multi_edit" => !matches!(edit_tool, Some("patch")),
420        _ => true,
421    }
422}
423
424pub trait ToolContributor: Send + Sync + 'static {
425    fn id(&self) -> ToolProviderId;
426    fn contribute(&self, registry: &mut ToolRegistry) -> anyhow::Result<()>;
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    #[test]
434    fn tool_spec_can_be_represented_as_discovery_item() {
435        let spec = ToolSpec {
436            name: "grep".to_string(),
437            description: "Search files".to_string(),
438            parameters: serde_json::json!({
439                "type": "object",
440                "properties": {
441                    "query": { "type": "string" }
442                },
443                "required": ["query"]
444            }),
445        };
446
447        let item = spec.discovery_item(
448            "builtin-coding-tools",
449            "discovery/tools/builtin-coding-tools/grep.schema.json",
450        );
451        assert_eq!(item.id, "tool:builtin-coding-tools/grep");
452        assert_eq!(item.group_id, "tools:builtin-coding-tools");
453        assert_eq!(item.source.kind, DiscoverySourceKind::InternalTools);
454        assert_eq!(item.source.auth_state, DiscoveryAuthState::NotRequired);
455        assert_eq!(item.status, DiscoveryItemStatus::Available);
456        assert_eq!(item.lifecycle, DiscoveryLifecycleState::Discovered);
457        assert_eq!(
458            item.schema.as_ref().map(|schema| schema.format.clone()),
459            Some(DiscoverySchemaFormat::JsonSchema)
460        );
461    }
462
463    #[test]
464    fn apply_patch_is_kept_for_all_edit_tool_profiles() {
465        assert!(keep_tool_for_edit_tool("apply_patch", None));
466        assert!(keep_tool_for_edit_tool("apply_patch", Some("edit")));
467        assert!(keep_tool_for_edit_tool("apply_patch", Some("patch")));
468
469        assert!(keep_tool_for_edit_tool("edit", None));
470        assert!(keep_tool_for_edit_tool("edit", Some("edit")));
471        assert!(!keep_tool_for_edit_tool("edit", Some("patch")));
472        assert!(!keep_tool_for_edit_tool("multi_edit", Some("patch")));
473        assert!(!keep_tool_for_edit_tool("write_file", Some("patch")));
474    }
475}