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