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