返回 CodeWhale
web_tool.rs
根目录 / crates / tui / src / tools / web_tool.rs
1 //! Canonical action-based wrapper for web tools.
2 //!
3 //! The model sees one tool: `Web` with an `action` parameter
4 //! (search | fetch | wait). The per-action legacy execution aliases were
5 //! removed in v0.9.3.
6
7 use async_trait::async_trait;
8 use serde_json::{Value, json};
9
10 use super::canonical_action::required_action;
11 use super::dev_server_readiness::WaitForDevServerTool;
12 use super::fetch_url::FetchUrlTool;
13 use super::spec::{
14 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
15 };
16 use super::web_search::WebSearchTool;
17
18 pub struct WebTool {
19 name: &'static str,
20 forced_action: Option<&'static str>,
21 }
22
23 impl WebTool {
24 pub const fn new(name: &'static str) -> Self {
25 Self {
26 name,
27 forced_action: None,
28 }
29 }
30
31 const ACTIONS: &'static [&'static str] = &["search", "fetch", "wait"];
32
33 /// Policy-side resolution: approval and parallel-safety predicates cannot
34 /// fail, so a missing action resolves to the most conservative answer.
35 /// Execution does not share this fallback — see `required_action`.
36 fn resolve_action<'a>(&self, input: &'a Value) -> &'a str {
37 self.forced_action.unwrap_or_else(|| {
38 input
39 .get("action")
40 .and_then(Value::as_str)
41 .unwrap_or("search")
42 })
43 }
44
45 fn required_action(&self, input: &Value) -> Result<String, ToolError> {
46 if let Some(forced) = self.forced_action {
47 return Ok(forced.to_string());
48 }
49 required_action(input, self.name, Self::ACTIONS)
50 }
51
52 fn strip_action(&self, input: Value) -> Result<Value, ToolError> {
53 let mut input = input;
54 if let Some(obj) = input.as_object_mut() {
55 obj.remove("action");
56 Ok(input)
57 } else {
58 Err(ToolError::invalid_input("Web tool input must be an object"))
59 }
60 }
61 }
62
63 #[async_trait]
64 impl ToolSpec for WebTool {
65 fn name(&self) -> &'static str {
66 self.name
67 }
68
69 fn model_visible(&self) -> bool {
70 self.name == "Web"
71 }
72
73 fn description(&self) -> &'static str {
74 "Search the web, fetch a known URL, or wait for a local dev server. Prefer fetch for a canonical URL and search when the source is unknown. Web actions are read-only and network-policy aware."
75 }
76
77 fn input_schema(&self) -> Value {
78 json!({
79 "type": "object",
80 "properties": {
81 "action": {
82 "type": "string",
83 "enum": ["search", "fetch", "wait"],
84 "description": "Action to perform"
85 },
86 "query": {
87 "type": "string",
88 "description": "Search query (action=search)"
89 },
90 "q": {
91 "type": "string",
92 "description": "Search query alias (action=search)"
93 },
94 "search_query": {
95 "type": "array",
96 "description": "Advanced search query array (action=search)",
97 "items": {
98 "type": "object",
99 "properties": {
100 "q": { "type": "string" },
101 "query": { "type": "string" },
102 "max_results": { "type": "integer" },
103 "recency": {
104 "oneOf": [
105 { "type": "string", "enum": ["day", "week", "month", "year"] },
106 { "type": "integer", "minimum": 1, "maximum": 3650 }
107 ]
108 },
109 "domains": { "type": "array", "items": { "type": "string" } },
110 "locale": { "type": "string" }
111 }
112 }
113 },
114 "max_results": {
115 "type": "integer",
116 "description": "Maximum search results (action=search)"
117 },
118 "timeout_ms": {
119 "type": "integer",
120 "description": "Timeout in milliseconds (action=search, fetch, or wait)"
121 },
122 "recency": {
123 "oneOf": [
124 { "type": "string", "enum": ["day", "week", "month", "year"] },
125 { "type": "integer", "minimum": 1, "maximum": 3650 }
126 ],
127 "description": "Requested freshness window (action=search)"
128 },
129 "domains": {
130 "type": "array",
131 "items": { "type": "string" },
132 "description": "Restrict search results to domains (action=search)"
133 },
134 "locale": {
135 "type": "string",
136 "description": "Requested result locale (action=search)"
137 },
138 "url": {
139 "type": "string",
140 "description": "URL to fetch (action=fetch) or healthcheck URL (action=wait)"
141 },
142 "format": {
143 "type": "string",
144 "enum": ["text", "markdown", "raw"],
145 "description": "Post-processing for fetched response (action=fetch)"
146 },
147 "max_bytes": {
148 "type": "integer",
149 "description": "Truncate fetched response after this many bytes (action=fetch)"
150 },
151 "fields": {
152 "type": "array",
153 "items": { "type": "string" },
154 "description": "Optional JSONPath projections for JSON responses (action=fetch)"
155 },
156 "host": {
157 "type": "string",
158 "description": "Loopback host to poll (action=wait)"
159 },
160 "port": {
161 "type": "integer",
162 "description": "TCP port to wait for (action=wait)"
163 },
164 "poll_interval_ms": {
165 "type": "integer",
166 "description": "Delay between readiness probes in milliseconds (action=wait)"
167 }
168 },
169 "required": ["action"]
170 })
171 }
172
173 fn capabilities(&self) -> Vec<ToolCapability> {
174 vec![ToolCapability::ReadOnly, ToolCapability::Network]
175 }
176
177 fn approval_requirement_for(&self, _input: &Value) -> ApprovalRequirement {
178 ApprovalRequirement::Auto
179 }
180
181 fn is_read_only_for(&self, _input: &Value) -> bool {
182 true
183 }
184
185 fn supports_parallel_for(&self, input: &Value) -> bool {
186 self.resolve_action(input) == "search"
187 }
188
189 fn starts_detached_for(&self, _input: &Value) -> bool {
190 false
191 }
192
193 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
194 let action = self.required_action(&input)?;
195 let input = self.strip_action(input)?;
196
197 match action.as_str() {
198 "search" => WebSearchTool.execute(input, context).await,
199 "fetch" => FetchUrlTool.execute(input, context).await,
200 "wait" => WaitForDevServerTool.execute(input, context).await,
201 other => Err(ToolError::invalid_input(format!(
202 "Unknown Web action \"{other}\"; nothing was run. Pass one of: {}.",
203 Self::ACTIONS.join(", ")
204 ))),
205 }
206 }
207 }
208
209 #[cfg(test)]
210 mod tests {
211 use super::*;
212 use serde_json::json;
213 use tempfile::tempdir;
214
215 async fn err(input: Value) -> String {
216 let tmp = tempdir().expect("tempdir");
217 let ctx = ToolContext::new(tmp.path().to_path_buf());
218 WebTool::new("Web")
219 .execute(input, &ctx)
220 .await
221 .expect_err("call must be refused")
222 .to_string()
223 }
224
225 /// `Web{url: ...}` meant `fetch`; defaulting turned it into a search.
226 #[tokio::test]
227 async fn missing_action_does_not_silently_search() {
228 let message = err(json!({"url": "https://example.com"})).await;
229 assert!(message.contains("requires an `action`"), "{message}");
230 assert!(message.contains("nothing was run"), "{message}");
231 assert!(message.contains("search, fetch, wait"), "{message}");
232 }
233
234 #[tokio::test]
235 async fn unknown_action_names_the_actions_that_dispatch() {
236 let message = err(json!({"action": "get", "url": "https://example.com"})).await;
237 assert!(message.contains("get"), "{message}");
238 assert!(message.contains("search, fetch, wait"), "{message}");
239 }
240
241 #[test]
242 fn advertised_actions_match_the_actions_that_dispatch() {
243 let schema = WebTool::new("Web").input_schema();
244 let advertised: Vec<&str> = schema["properties"]["action"]["enum"]
245 .as_array()
246 .expect("action enum")
247 .iter()
248 .map(|value| value.as_str().expect("string"))
249 .collect();
250 assert_eq!(advertised, WebTool::ACTIONS);
251 }
252 }
253
253 lines RUST