tools.rs

  1use std::path::PathBuf;
  2
  3use agent_client_protocol as acp;
  4use itertools::Itertools;
  5use schemars::JsonSchema;
  6use serde::{Deserialize, Serialize};
  7use util::ResultExt;
  8
  9pub enum ClaudeTool {
 10    Task(Option<TaskToolParams>),
 11    NotebookRead(Option<NotebookReadToolParams>),
 12    NotebookEdit(Option<NotebookEditToolParams>),
 13    Edit(Option<EditToolParams>),
 14    MultiEdit(Option<MultiEditToolParams>),
 15    ReadFile(Option<ReadToolParams>),
 16    Write(Option<WriteToolParams>),
 17    Ls(Option<LsToolParams>),
 18    Glob(Option<GlobToolParams>),
 19    Grep(Option<GrepToolParams>),
 20    Terminal(Option<BashToolParams>),
 21    WebFetch(Option<WebFetchToolParams>),
 22    WebSearch(Option<WebSearchToolParams>),
 23    TodoWrite(Option<TodoWriteToolParams>),
 24    ExitPlanMode(Option<ExitPlanModeToolParams>),
 25    Other {
 26        name: String,
 27        input: serde_json::Value,
 28    },
 29}
 30
 31impl ClaudeTool {
 32    pub fn infer(tool_name: &str, input: serde_json::Value) -> Self {
 33        match tool_name {
 34            // Known tools
 35            "mcp__zed__Read" => Self::ReadFile(serde_json::from_value(input).log_err()),
 36            "mcp__zed__Edit" => Self::Edit(serde_json::from_value(input).log_err()),
 37            "mcp__zed__Write" => Self::Write(serde_json::from_value(input).log_err()),
 38            "MultiEdit" => Self::MultiEdit(serde_json::from_value(input).log_err()),
 39            "Write" => Self::Write(serde_json::from_value(input).log_err()),
 40            "LS" => Self::Ls(serde_json::from_value(input).log_err()),
 41            "Glob" => Self::Glob(serde_json::from_value(input).log_err()),
 42            "Grep" => Self::Grep(serde_json::from_value(input).log_err()),
 43            "Bash" => Self::Terminal(serde_json::from_value(input).log_err()),
 44            "WebFetch" => Self::WebFetch(serde_json::from_value(input).log_err()),
 45            "WebSearch" => Self::WebSearch(serde_json::from_value(input).log_err()),
 46            "TodoWrite" => Self::TodoWrite(serde_json::from_value(input).log_err()),
 47            "exit_plan_mode" => Self::ExitPlanMode(serde_json::from_value(input).log_err()),
 48            "Task" => Self::Task(serde_json::from_value(input).log_err()),
 49            "NotebookRead" => Self::NotebookRead(serde_json::from_value(input).log_err()),
 50            "NotebookEdit" => Self::NotebookEdit(serde_json::from_value(input).log_err()),
 51            // Inferred from name
 52            _ => {
 53                let tool_name = tool_name.to_lowercase();
 54
 55                if tool_name.contains("edit") || tool_name.contains("write") {
 56                    Self::Edit(None)
 57                } else if tool_name.contains("terminal") {
 58                    Self::Terminal(None)
 59                } else {
 60                    Self::Other {
 61                        name: tool_name,
 62                        input,
 63                    }
 64                }
 65            }
 66        }
 67    }
 68
 69    pub fn label(&self) -> String {
 70        match &self {
 71            Self::Task(Some(params)) => params.description.clone(),
 72            Self::Task(None) => "Task".into(),
 73            Self::NotebookRead(Some(params)) => {
 74                format!("Read Notebook {}", params.notebook_path.display())
 75            }
 76            Self::NotebookRead(None) => "Read Notebook".into(),
 77            Self::NotebookEdit(Some(params)) => {
 78                format!("Edit Notebook {}", params.notebook_path.display())
 79            }
 80            Self::NotebookEdit(None) => "Edit Notebook".into(),
 81            Self::Terminal(Some(params)) => format!("`{}`", params.command),
 82            Self::Terminal(None) => "Terminal".into(),
 83            Self::ReadFile(_) => "Read File".into(),
 84            Self::Ls(Some(params)) => {
 85                format!("List Directory {}", params.path.display())
 86            }
 87            Self::Ls(None) => "List Directory".into(),
 88            Self::Edit(Some(params)) => {
 89                format!("Edit {}", params.abs_path.display())
 90            }
 91            Self::Edit(None) => "Edit".into(),
 92            Self::MultiEdit(Some(params)) => {
 93                format!("Multi Edit {}", params.file_path.display())
 94            }
 95            Self::MultiEdit(None) => "Multi Edit".into(),
 96            Self::Write(Some(params)) => {
 97                format!("Write {}", params.abs_path.display())
 98            }
 99            Self::Write(None) => "Write".into(),
100            Self::Glob(Some(params)) => {
101                format!("Glob `{params}`")
102            }
103            Self::Glob(None) => "Glob".into(),
104            Self::Grep(Some(params)) => format!("`{params}`"),
105            Self::Grep(None) => "Grep".into(),
106            Self::WebFetch(Some(params)) => format!("Fetch {}", params.url),
107            Self::WebFetch(None) => "Fetch".into(),
108            Self::WebSearch(Some(params)) => format!("Web Search: {}", params),
109            Self::WebSearch(None) => "Web Search".into(),
110            Self::TodoWrite(Some(params)) => format!(
111                "Update TODOs: {}",
112                params.todos.iter().map(|todo| &todo.content).join(", ")
113            ),
114            Self::TodoWrite(None) => "Update TODOs".into(),
115            Self::ExitPlanMode(_) => "Exit Plan Mode".into(),
116            Self::Other { name, .. } => name.clone(),
117        }
118    }
119    pub fn content(&self) -> Vec<acp::ToolCallContent> {
120        match &self {
121            Self::Other { input, .. } => vec![
122                format!(
123                    "```json\n{}```",
124                    serde_json::to_string_pretty(&input).unwrap_or("{}".to_string())
125                )
126                .into(),
127            ],
128            Self::Task(Some(params)) => vec![params.prompt.clone().into()],
129            Self::NotebookRead(Some(params)) => {
130                vec![params.notebook_path.display().to_string().into()]
131            }
132            Self::NotebookEdit(Some(params)) => vec![params.new_source.clone().into()],
133            Self::Terminal(Some(params)) => vec![
134                format!(
135                    "`{}`\n\n{}",
136                    params.command,
137                    params.description.as_deref().unwrap_or_default()
138                )
139                .into(),
140            ],
141            Self::ReadFile(Some(params)) => vec![params.abs_path.display().to_string().into()],
142            Self::Ls(Some(params)) => vec![params.path.display().to_string().into()],
143            Self::Glob(Some(params)) => vec![params.to_string().into()],
144            Self::Grep(Some(params)) => vec![format!("`{params}`").into()],
145            Self::WebFetch(Some(params)) => vec![params.prompt.clone().into()],
146            Self::WebSearch(Some(params)) => vec![params.to_string().into()],
147            Self::ExitPlanMode(Some(params)) => vec![params.plan.clone().into()],
148            Self::Edit(Some(params)) => vec![acp::ToolCallContent::Diff {
149                diff: acp::Diff {
150                    path: params.abs_path.clone(),
151                    old_text: Some(params.old_text.clone()),
152                    new_text: params.new_text.clone(),
153                },
154            }],
155            Self::Write(Some(params)) => vec![acp::ToolCallContent::Diff {
156                diff: acp::Diff {
157                    path: params.abs_path.clone(),
158                    old_text: None,
159                    new_text: params.content.clone(),
160                },
161            }],
162            Self::MultiEdit(Some(params)) => {
163                // todo: show multiple edits in a multibuffer?
164                params
165                    .edits
166                    .first()
167                    .map(|edit| {
168                        vec![acp::ToolCallContent::Diff {
169                            diff: acp::Diff {
170                                path: params.file_path.clone(),
171                                old_text: Some(edit.old_string.clone()),
172                                new_text: edit.new_string.clone(),
173                            },
174                        }]
175                    })
176                    .unwrap_or_default()
177            }
178            Self::TodoWrite(Some(_)) => {
179                // These are mapped to plan updates later
180                vec![]
181            }
182            Self::Task(None)
183            | Self::NotebookRead(None)
184            | Self::NotebookEdit(None)
185            | Self::Terminal(None)
186            | Self::ReadFile(None)
187            | Self::Ls(None)
188            | Self::Glob(None)
189            | Self::Grep(None)
190            | Self::WebFetch(None)
191            | Self::WebSearch(None)
192            | Self::TodoWrite(None)
193            | Self::ExitPlanMode(None)
194            | Self::Edit(None)
195            | Self::Write(None)
196            | Self::MultiEdit(None) => vec![],
197        }
198    }
199
200    pub fn kind(&self) -> acp::ToolKind {
201        match self {
202            Self::Task(_) => acp::ToolKind::Think,
203            Self::NotebookRead(_) => acp::ToolKind::Read,
204            Self::NotebookEdit(_) => acp::ToolKind::Edit,
205            Self::Edit(_) => acp::ToolKind::Edit,
206            Self::MultiEdit(_) => acp::ToolKind::Edit,
207            Self::Write(_) => acp::ToolKind::Edit,
208            Self::ReadFile(_) => acp::ToolKind::Read,
209            Self::Ls(_) => acp::ToolKind::Search,
210            Self::Glob(_) => acp::ToolKind::Search,
211            Self::Grep(_) => acp::ToolKind::Search,
212            Self::Terminal(_) => acp::ToolKind::Execute,
213            Self::WebSearch(_) => acp::ToolKind::Search,
214            Self::WebFetch(_) => acp::ToolKind::Fetch,
215            Self::TodoWrite(_) => acp::ToolKind::Think,
216            Self::ExitPlanMode(_) => acp::ToolKind::Think,
217            Self::Other { .. } => acp::ToolKind::Other,
218        }
219    }
220
221    pub fn locations(&self) -> Vec<acp::ToolCallLocation> {
222        match &self {
223            Self::Edit(Some(EditToolParams { abs_path, .. })) => vec![acp::ToolCallLocation {
224                path: abs_path.clone(),
225                line: None,
226            }],
227            Self::MultiEdit(Some(MultiEditToolParams { file_path, .. })) => {
228                vec![acp::ToolCallLocation {
229                    path: file_path.clone(),
230                    line: None,
231                }]
232            }
233            Self::Write(Some(WriteToolParams {
234                abs_path: file_path,
235                ..
236            })) => {
237                vec![acp::ToolCallLocation {
238                    path: file_path.clone(),
239                    line: None,
240                }]
241            }
242            Self::ReadFile(Some(ReadToolParams {
243                abs_path, offset, ..
244            })) => vec![acp::ToolCallLocation {
245                path: abs_path.clone(),
246                line: *offset,
247            }],
248            Self::NotebookRead(Some(NotebookReadToolParams { notebook_path, .. })) => {
249                vec![acp::ToolCallLocation {
250                    path: notebook_path.clone(),
251                    line: None,
252                }]
253            }
254            Self::NotebookEdit(Some(NotebookEditToolParams { notebook_path, .. })) => {
255                vec![acp::ToolCallLocation {
256                    path: notebook_path.clone(),
257                    line: None,
258                }]
259            }
260            Self::Glob(Some(GlobToolParams {
261                path: Some(path), ..
262            })) => vec![acp::ToolCallLocation {
263                path: path.clone(),
264                line: None,
265            }],
266            Self::Ls(Some(LsToolParams { path, .. })) => vec![acp::ToolCallLocation {
267                path: path.clone(),
268                line: None,
269            }],
270            Self::Grep(Some(GrepToolParams {
271                path: Some(path), ..
272            })) => vec![acp::ToolCallLocation {
273                path: PathBuf::from(path),
274                line: None,
275            }],
276            Self::Task(_)
277            | Self::NotebookRead(None)
278            | Self::NotebookEdit(None)
279            | Self::Edit(None)
280            | Self::MultiEdit(None)
281            | Self::Write(None)
282            | Self::ReadFile(None)
283            | Self::Ls(None)
284            | Self::Glob(_)
285            | Self::Grep(_)
286            | Self::Terminal(_)
287            | Self::WebFetch(_)
288            | Self::WebSearch(_)
289            | Self::TodoWrite(_)
290            | Self::ExitPlanMode(_)
291            | Self::Other { .. } => vec![],
292        }
293    }
294
295    pub fn as_acp(&self, id: acp::ToolCallId) -> acp::ToolCall {
296        acp::ToolCall {
297            id,
298            kind: self.kind(),
299            status: acp::ToolCallStatus::InProgress,
300            title: self.label(),
301            content: self.content(),
302            locations: self.locations(),
303            raw_input: None,
304            raw_output: None,
305        }
306    }
307}
308
309/// Edit a file.
310///
311/// In sessions with mcp__zed__Edit always use it instead of Edit as it will
312/// allow the user to conveniently review changes.
313///
314/// File editing instructions:
315/// - The `old_text` param must match existing file content, including indentation.
316/// - The `old_text` param must come from the actual file, not an outline.
317/// - The `old_text` section must not be empty.
318/// - Be minimal with replacements:
319///     - For unique lines, include only those lines.
320///     - For non-unique lines, include enough context to identify them.
321/// - Do not escape quotes, newlines, or other characters.
322/// - Only edit the specified file.
323#[derive(Deserialize, JsonSchema, Debug)]
324pub struct EditToolParams {
325    /// The absolute path to the file to read.
326    pub abs_path: PathBuf,
327    /// The old text to replace (must be unique in the file)
328    pub old_text: String,
329    /// The new text.
330    pub new_text: String,
331}
332
333/// Reads the content of the given file in the project.
334///
335/// Never attempt to read a path that hasn't been previously mentioned.
336///
337/// In sessions with mcp__zed__Read always use it instead of Read as it contains the most up-to-date contents.
338#[derive(Deserialize, JsonSchema, Debug)]
339pub struct ReadToolParams {
340    /// The absolute path to the file to read.
341    pub abs_path: PathBuf,
342    /// Which line to start reading from. Omit to start from the beginning.
343    #[serde(skip_serializing_if = "Option::is_none")]
344    pub offset: Option<u32>,
345    /// How many lines to read. Omit for the whole file.
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub limit: Option<u32>,
348}
349
350/// Writes content to the specified file in the project.
351///
352/// In sessions with mcp__zed__Write always use it instead of Write as it will
353/// allow the user to conveniently review changes.
354#[derive(Deserialize, JsonSchema, Debug)]
355pub struct WriteToolParams {
356    /// The absolute path of the file to write.
357    pub abs_path: PathBuf,
358    /// The full content to write.
359    pub content: String,
360}
361
362#[derive(Deserialize, JsonSchema, Debug)]
363pub struct BashToolParams {
364    /// Shell command to execute
365    pub command: String,
366    /// 5-10 word description of what command does
367    #[serde(skip_serializing_if = "Option::is_none")]
368    pub description: Option<String>,
369    /// Timeout in ms (max 600000ms/10min, default 120000ms)
370    #[serde(skip_serializing_if = "Option::is_none")]
371    pub timeout: Option<u32>,
372}
373
374#[derive(Deserialize, JsonSchema, Debug)]
375pub struct GlobToolParams {
376    /// Glob pattern like **/*.js or src/**/*.ts
377    pub pattern: String,
378    /// Directory to search in (omit for current directory)
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub path: Option<PathBuf>,
381}
382
383impl std::fmt::Display for GlobToolParams {
384    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
385        if let Some(path) = &self.path {
386            write!(f, "{}", path.display())?;
387        }
388        write!(f, "{}", self.pattern)
389    }
390}
391
392#[derive(Deserialize, JsonSchema, Debug)]
393pub struct LsToolParams {
394    /// Absolute path to directory
395    pub path: PathBuf,
396    /// Array of glob patterns to ignore
397    #[serde(default, skip_serializing_if = "Vec::is_empty")]
398    pub ignore: Vec<String>,
399}
400
401#[derive(Deserialize, JsonSchema, Debug)]
402pub struct GrepToolParams {
403    /// Regex pattern to search for
404    pub pattern: String,
405    /// File/directory to search (defaults to current directory)
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub path: Option<String>,
408    /// "content" (shows lines), "files_with_matches" (default), "count"
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub output_mode: Option<GrepOutputMode>,
411    /// Filter files with glob pattern like "*.js"
412    #[serde(skip_serializing_if = "Option::is_none")]
413    pub glob: Option<String>,
414    /// File type filter like "js", "py", "rust"
415    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
416    pub file_type: Option<String>,
417    /// Case insensitive search
418    #[serde(rename = "-i", default, skip_serializing_if = "is_false")]
419    pub case_insensitive: bool,
420    /// Show line numbers (content mode only)
421    #[serde(rename = "-n", default, skip_serializing_if = "is_false")]
422    pub line_numbers: bool,
423    /// Lines after match (content mode only)
424    #[serde(rename = "-A", skip_serializing_if = "Option::is_none")]
425    pub after_context: Option<u32>,
426    /// Lines before match (content mode only)
427    #[serde(rename = "-B", skip_serializing_if = "Option::is_none")]
428    pub before_context: Option<u32>,
429    /// Lines before and after match (content mode only)
430    #[serde(rename = "-C", skip_serializing_if = "Option::is_none")]
431    pub context: Option<u32>,
432    /// Enable multiline/cross-line matching
433    #[serde(default, skip_serializing_if = "is_false")]
434    pub multiline: bool,
435    /// Limit output to first N results
436    #[serde(skip_serializing_if = "Option::is_none")]
437    pub head_limit: Option<u32>,
438}
439
440impl std::fmt::Display for GrepToolParams {
441    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
442        write!(f, "grep")?;
443
444        // Boolean flags
445        if self.case_insensitive {
446            write!(f, " -i")?;
447        }
448        if self.line_numbers {
449            write!(f, " -n")?;
450        }
451
452        // Context options
453        if let Some(after) = self.after_context {
454            write!(f, " -A {}", after)?;
455        }
456        if let Some(before) = self.before_context {
457            write!(f, " -B {}", before)?;
458        }
459        if let Some(context) = self.context {
460            write!(f, " -C {}", context)?;
461        }
462
463        // Output mode
464        if let Some(mode) = &self.output_mode {
465            match mode {
466                GrepOutputMode::FilesWithMatches => write!(f, " -l")?,
467                GrepOutputMode::Count => write!(f, " -c")?,
468                GrepOutputMode::Content => {} // Default mode
469            }
470        }
471
472        // Head limit
473        if let Some(limit) = self.head_limit {
474            write!(f, " | head -{}", limit)?;
475        }
476
477        // Glob pattern
478        if let Some(glob) = &self.glob {
479            write!(f, " --include=\"{}\"", glob)?;
480        }
481
482        // File type
483        if let Some(file_type) = &self.file_type {
484            write!(f, " --type={}", file_type)?;
485        }
486
487        // Multiline
488        if self.multiline {
489            write!(f, " -P")?; // Perl-compatible regex for multiline
490        }
491
492        // Pattern (escaped if contains special characters)
493        write!(f, " \"{}\"", self.pattern)?;
494
495        // Path
496        if let Some(path) = &self.path {
497            write!(f, " {}", path)?;
498        }
499
500        Ok(())
501    }
502}
503
504#[derive(Default, Deserialize, Serialize, JsonSchema, strum::Display, Debug)]
505#[serde(rename_all = "snake_case")]
506pub enum TodoPriority {
507    High,
508    #[default]
509    Medium,
510    Low,
511}
512
513impl Into<acp::PlanEntryPriority> for TodoPriority {
514    fn into(self) -> acp::PlanEntryPriority {
515        match self {
516            TodoPriority::High => acp::PlanEntryPriority::High,
517            TodoPriority::Medium => acp::PlanEntryPriority::Medium,
518            TodoPriority::Low => acp::PlanEntryPriority::Low,
519        }
520    }
521}
522
523#[derive(Deserialize, Serialize, JsonSchema, Debug)]
524#[serde(rename_all = "snake_case")]
525pub enum TodoStatus {
526    Pending,
527    InProgress,
528    Completed,
529}
530
531impl Into<acp::PlanEntryStatus> for TodoStatus {
532    fn into(self) -> acp::PlanEntryStatus {
533        match self {
534            TodoStatus::Pending => acp::PlanEntryStatus::Pending,
535            TodoStatus::InProgress => acp::PlanEntryStatus::InProgress,
536            TodoStatus::Completed => acp::PlanEntryStatus::Completed,
537        }
538    }
539}
540
541#[derive(Deserialize, Serialize, JsonSchema, Debug)]
542pub struct Todo {
543    /// Task description
544    pub content: String,
545    /// Current status of the todo
546    pub status: TodoStatus,
547    /// Priority level of the todo
548    #[serde(default)]
549    pub priority: TodoPriority,
550}
551
552impl Into<acp::PlanEntry> for Todo {
553    fn into(self) -> acp::PlanEntry {
554        acp::PlanEntry {
555            content: self.content,
556            priority: self.priority.into(),
557            status: self.status.into(),
558        }
559    }
560}
561
562#[derive(Deserialize, JsonSchema, Debug)]
563pub struct TodoWriteToolParams {
564    pub todos: Vec<Todo>,
565}
566
567#[derive(Deserialize, JsonSchema, Debug)]
568pub struct ExitPlanModeToolParams {
569    /// Implementation plan in markdown format
570    pub plan: String,
571}
572
573#[derive(Deserialize, JsonSchema, Debug)]
574pub struct TaskToolParams {
575    /// Short 3-5 word description of task
576    pub description: String,
577    /// Detailed task for agent to perform
578    pub prompt: String,
579}
580
581#[derive(Deserialize, JsonSchema, Debug)]
582pub struct NotebookReadToolParams {
583    /// Absolute path to .ipynb file
584    pub notebook_path: PathBuf,
585    /// Specific cell ID to read
586    #[serde(skip_serializing_if = "Option::is_none")]
587    pub cell_id: Option<String>,
588}
589
590#[derive(Deserialize, Serialize, JsonSchema, Debug)]
591#[serde(rename_all = "snake_case")]
592pub enum CellType {
593    Code,
594    Markdown,
595}
596
597#[derive(Deserialize, Serialize, JsonSchema, Debug)]
598#[serde(rename_all = "snake_case")]
599pub enum EditMode {
600    Replace,
601    Insert,
602    Delete,
603}
604
605#[derive(Deserialize, JsonSchema, Debug)]
606pub struct NotebookEditToolParams {
607    /// Absolute path to .ipynb file
608    pub notebook_path: PathBuf,
609    /// New cell content
610    pub new_source: String,
611    /// Cell ID to edit
612    #[serde(skip_serializing_if = "Option::is_none")]
613    pub cell_id: Option<String>,
614    /// Type of cell (code or markdown)
615    #[serde(skip_serializing_if = "Option::is_none")]
616    pub cell_type: Option<CellType>,
617    /// Edit operation mode
618    #[serde(skip_serializing_if = "Option::is_none")]
619    pub edit_mode: Option<EditMode>,
620}
621
622#[derive(Deserialize, Serialize, JsonSchema, Debug)]
623pub struct MultiEditItem {
624    /// The text to search for and replace
625    pub old_string: String,
626    /// The replacement text
627    pub new_string: String,
628    /// Whether to replace all occurrences or just the first
629    #[serde(default, skip_serializing_if = "is_false")]
630    pub replace_all: bool,
631}
632
633#[derive(Deserialize, JsonSchema, Debug)]
634pub struct MultiEditToolParams {
635    /// Absolute path to file
636    pub file_path: PathBuf,
637    /// List of edits to apply
638    pub edits: Vec<MultiEditItem>,
639}
640
641fn is_false(v: &bool) -> bool {
642    !*v
643}
644
645#[derive(Deserialize, JsonSchema, Debug)]
646#[serde(rename_all = "snake_case")]
647pub enum GrepOutputMode {
648    Content,
649    FilesWithMatches,
650    Count,
651}
652
653#[derive(Deserialize, JsonSchema, Debug)]
654pub struct WebFetchToolParams {
655    /// Valid URL to fetch
656    #[serde(rename = "url")]
657    pub url: String,
658    /// What to extract from content
659    pub prompt: String,
660}
661
662#[derive(Deserialize, JsonSchema, Debug)]
663pub struct WebSearchToolParams {
664    /// Search query (min 2 chars)
665    pub query: String,
666    /// Only include these domains
667    #[serde(default, skip_serializing_if = "Vec::is_empty")]
668    pub allowed_domains: Vec<String>,
669    /// Exclude these domains
670    #[serde(default, skip_serializing_if = "Vec::is_empty")]
671    pub blocked_domains: Vec<String>,
672}
673
674impl std::fmt::Display for WebSearchToolParams {
675    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
676        write!(f, "\"{}\"", self.query)?;
677
678        if !self.allowed_domains.is_empty() {
679            write!(f, " (allowed: {})", self.allowed_domains.join(", "))?;
680        }
681
682        if !self.blocked_domains.is_empty() {
683            write!(f, " (blocked: {})", self.blocked_domains.join(", "))?;
684        }
685
686        Ok(())
687    }
688}