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 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 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 pub swarm_progress_sink: Option<Arc<dyn crate::subagents::AgentSwarmProgressSink>>,
146 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 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}