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 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 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 pub swarm_progress_sink: Option<Arc<dyn crate::subagents::AgentSwarmProgressSink>>,
153 pub parent_model_selection: Option<crate::inference::ModelSelection>,
157 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
331pub enum ToolExecutionMode {
332 SharedRead,
333 #[default]
334 Exclusive,
335 Coordinator,
338}
339
340#[async_trait::async_trait]
341pub trait ToolExecutor: Send + Sync + 'static {
342 fn spec(&self) -> ToolSpec;
343
344 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 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}