nanny_runtime/tools/http_get.rs
1// http_get — the first real tool.
2//
3// Makes a single HTTP GET request and returns the response body.
4//
5// Rules:
6// - URL argument is required and must start with http:// or https://
7// - Response body is capped at 1MB — fail closed on large responses
8// - Timeout is enforced — the tool cannot run forever
9// - Cost is only charged on success — failed calls do not spend budget
10// - Non-2xx HTTP responses are treated as failures
11
12use nanny_core::tool::{Tool, ToolArgs, ToolError, ToolOutput};
13use std::io::Read;
14use std::time::Duration;
15
16/// Maximum response body size.
17/// A tool that reads unbounded data is a resource leak.
18/// Fail closed at 1MB.
19const MAX_BODY_BYTES: u64 = 1024 * 1024;
20
21/// Cost units charged for a successful http_get call.
22const HTTP_GET_COST: u64 = 10;
23
24/// Default timeout for the HTTP request.
25const DEFAULT_TIMEOUT_MS: u64 = 5_000;
26
27// ── HttpGet ───────────────────────────────────────────────────────────────────
28
29/// A tool that makes a single HTTP GET request.
30///
31/// Declared cost: 10 units (charged only on success).
32/// Timeout: 5000ms by default, configurable via `with_timeout`.
33pub struct HttpGet {
34 timeout_ms: u64,
35}
36
37impl HttpGet {
38 /// Create a new HttpGet tool with the default 5000ms timeout.
39 pub fn new() -> Self {
40 Self {
41 timeout_ms: DEFAULT_TIMEOUT_MS,
42 }
43 }
44
45 /// Create an HttpGet tool with a custom timeout.
46 pub fn with_timeout(timeout_ms: u64) -> Self {
47 Self { timeout_ms }
48 }
49}
50
51impl Default for HttpGet {
52 fn default() -> Self {
53 Self::new()
54 }
55}
56
57impl Tool for HttpGet {
58 fn name(&self) -> &str {
59 "http_get"
60 }
61
62 /// Cost charged on success only.
63 /// The ledger is never debited for a failed request.
64 fn declared_cost(&self) -> u64 {
65 HTTP_GET_COST
66 }
67
68 fn execute(&self, args: &ToolArgs) -> Result<ToolOutput, ToolError> {
69 // ── Step 1: Require the url argument ──────────────────────────────────
70 let url = args.get("url").ok_or_else(|| ToolError::InvalidArgument {
71 arg: "url".to_string(),
72 reason: "required argument missing".to_string(),
73 })?;
74
75 // ── Step 2: Validate URL format ───────────────────────────────────────
76 //
77 // We do not resolve DNS, follow redirects, or check reachability here.
78 // We only verify the shape is safe to pass to the HTTP client.
79 if !url.starts_with("http://") && !url.starts_with("https://") {
80 return Err(ToolError::InvalidArgument {
81 arg: "url".to_string(),
82 reason: format!(
83 "must start with http:// or https://, got: {url}"
84 ),
85 });
86 }
87
88 // ── Step 3: Build the HTTP agent with timeout ─────────────────────────
89 //
90 // The agent is created per-call intentionally — no connection pooling,
91 // no shared state between tool executions. Each call is independent.
92 let agent = ureq::AgentBuilder::new()
93 .timeout(Duration::from_millis(self.timeout_ms))
94 .build();
95
96 // ── Step 4: Make the request ──────────────────────────────────────────
97 let response = agent.get(url).call().map_err(|e| match e {
98 // Non-2xx HTTP status — the server replied but with an error.
99 ureq::Error::Status(code, _) => {
100 ToolError::ExecutionFailed(format!("HTTP {code}"))
101 }
102 // Transport-level error — timeout, DNS failure, connection refused.
103 ureq::Error::Transport(ref t) => {
104 // ureq surfaces timeouts as transport errors.
105 // We detect them by message content — not ideal but correct for v0.1.
106 let msg = t.to_string();
107 if msg.contains("timed out") || msg.contains("deadline") {
108 ToolError::Timeout {
109 timeout_ms: self.timeout_ms,
110 }
111 } else {
112 ToolError::ExecutionFailed(msg)
113 }
114 }
115 })?;
116
117 // ── Step 5: Read the body with a hard size cap ────────────────────────
118 //
119 // `take(MAX_BODY_BYTES)` ensures we never read more than 1MB.
120 // If the response is larger, we stop at the limit and return what we have.
121 // This is intentional — fail closed on large payloads.
122 let mut body = String::new();
123 response
124 .into_reader()
125 .take(MAX_BODY_BYTES)
126 .read_to_string(&mut body)
127 .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
128
129 Ok(ToolOutput { content: body })
130 }
131}
132
133// ── Tests ─────────────────────────────────────────────────────────────────────
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 fn tool() -> HttpGet {
140 HttpGet::new()
141 }
142
143 // ── Validation tests (no network required) ────────────────────────────────
144
145 #[test]
146 fn rejects_missing_url() {
147 let result = tool().execute(&ToolArgs::new());
148
149 assert!(matches!(
150 result,
151 Err(ToolError::InvalidArgument { ref arg, .. }) if arg == "url"
152 ));
153 }
154
155 #[test]
156 fn rejects_url_without_scheme() {
157 let mut args = ToolArgs::new();
158 args.insert("url".to_string(), "example.com/path".to_string());
159
160 let result = tool().execute(&args);
161
162 assert!(matches!(
163 result,
164 Err(ToolError::InvalidArgument { ref arg, .. }) if arg == "url"
165 ));
166 }
167
168 #[test]
169 fn rejects_ftp_scheme() {
170 let mut args = ToolArgs::new();
171 args.insert("url".to_string(), "ftp://example.com".to_string());
172
173 let result = tool().execute(&args);
174
175 assert!(matches!(
176 result,
177 Err(ToolError::InvalidArgument { ref arg, .. }) if arg == "url"
178 ));
179 }
180
181 #[test]
182 fn accepts_http_scheme() {
183 // We only test that the URL passes validation, not that the request succeeds.
184 // A request to localhost:1 will fail at the network level, not the validation level.
185 let mut args = ToolArgs::new();
186 args.insert("url".to_string(), "http://localhost:1/test".to_string());
187
188 let result = tool().execute(&args);
189
190 // Any error here is a network error, not a validation error.
191 // The absence of InvalidArgument means validation passed.
192 assert!(!matches!(result, Err(ToolError::InvalidArgument { .. })));
193 }
194
195 #[test]
196 fn accepts_https_scheme() {
197 let mut args = ToolArgs::new();
198 args.insert("url".to_string(), "https://localhost:1/test".to_string());
199
200 let result = tool().execute(&args);
201
202 assert!(!matches!(result, Err(ToolError::InvalidArgument { .. })));
203 }
204
205 #[test]
206 fn declared_cost_is_ten() {
207 assert_eq!(tool().declared_cost(), 10);
208 }
209
210 #[test]
211 fn name_is_http_get() {
212 assert_eq!(tool().name(), "http_get");
213 }
214}