Skip to main content

netget/client/http/
actions.rs

1//! HTTP client protocol actions implementation
2
3use crate::llm::actions::{
4    client_trait::{Client, ClientActionResult},
5    protocol_trait::Protocol,
6    ActionDefinition, Parameter, ParameterDefinition,
7};
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14/// HTTP client connected event
15pub static HTTP_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
16    EventType::new(
17        "http_connected",
18        "HTTP client initialized and ready to send requests",
19        serde_json::json!({
20            "type": "send_http_request",
21            "method": "GET",
22            "path": "/"
23        }),
24    )
25    .with_parameters(vec![Parameter {
26        name: "base_url".to_string(),
27        type_hint: "string".to_string(),
28        description: "Base URL for HTTP requests".to_string(),
29        required: true,
30    }])
31});
32
33/// HTTP client response received event
34pub static HTTP_CLIENT_RESPONSE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
35    EventType::new(
36        "http_response_received",
37        "HTTP response received from server",
38        serde_json::json!({
39            "type": "send_http_request",
40            "method": "GET",
41            "path": "/api/next"
42        }),
43    )
44    .with_parameters(vec![
45        Parameter {
46            name: "status_code".to_string(),
47            type_hint: "number".to_string(),
48            description: "HTTP status code".to_string(),
49            required: true,
50        },
51        Parameter {
52            name: "headers".to_string(),
53            type_hint: "object".to_string(),
54            description: "Response headers".to_string(),
55            required: true,
56        },
57        Parameter {
58            name: "body".to_string(),
59            type_hint: "string".to_string(),
60            description: "Response body".to_string(),
61            required: true,
62        },
63    ])
64});
65
66/// HTTP client protocol action handler
67pub struct HttpClientProtocol;
68
69impl Default for HttpClientProtocol {
70    fn default() -> Self {
71        Self::new()
72    }
73}
74
75impl HttpClientProtocol {
76    pub fn new() -> Self {
77        Self
78    }
79}
80
81// Implement Protocol trait (common functionality)
82impl Protocol for HttpClientProtocol {
83    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
84        vec![ParameterDefinition {
85            name: "default_headers".to_string(),
86            description: "Default headers to include in all requests".to_string(),
87            type_hint: "object".to_string(),
88            required: false,
89            example: json!({
90                "User-Agent": "NetGet/1.0",
91                "Accept": "application/json"
92            }),
93            default: None,
94        }]
95    }
96    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
97        vec![
98            ActionDefinition {
99                name: "send_http_request".to_string(),
100                description: "Send an HTTP request to the server".to_string(),
101                parameters: vec![
102                    Parameter {
103                        name: "method".to_string(),
104                        type_hint: "string".to_string(),
105                        description: "HTTP method (GET, POST, PUT, DELETE, etc.)".to_string(),
106                        required: true,
107                    },
108                    Parameter {
109                        name: "path".to_string(),
110                        type_hint: "string".to_string(),
111                        description: "Request path (e.g., /api/users), resolved against the client's base URL. An absolute URL is accepted only on that same origin; another host needs its own client".to_string(),
112                        required: true,
113                    },
114                    Parameter {
115                        name: "headers".to_string(),
116                        type_hint: "object".to_string(),
117                        description: "Request headers".to_string(),
118                        required: false,
119                    },
120                    Parameter {
121                        name: "body".to_string(),
122                        type_hint: "string".to_string(),
123                        description: "Request body".to_string(),
124                        required: false,
125                    },
126                ],
127                example: json!({
128                    "type": "send_http_request",
129                    "method": "GET",
130                    "path": "/api/status",
131                    "headers": {
132                        "Accept": "application/json"
133                    }
134                }),
135                log_template: None,
136            },
137            ActionDefinition {
138                name: "disconnect".to_string(),
139                description: "Disconnect from the HTTP server".to_string(),
140                parameters: vec![],
141                example: json!({
142                    "type": "disconnect"
143                }),
144                log_template: None,
145            },
146        ]
147    }
148    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
149        vec![
150            ActionDefinition {
151                name: "send_http_request".to_string(),
152                description: "Send another HTTP request in response to received data".to_string(),
153                parameters: vec![
154                    Parameter {
155                        name: "method".to_string(),
156                        type_hint: "string".to_string(),
157                        description: "HTTP method".to_string(),
158                        required: true,
159                    },
160                    Parameter {
161                        name: "path".to_string(),
162                        type_hint: "string".to_string(),
163                        description: "Request path, resolved against the client's base URL. An absolute URL is accepted only on that same origin; another host needs its own client".to_string(),
164                        required: true,
165                    },
166                    Parameter {
167                        name: "headers".to_string(),
168                        type_hint: "object".to_string(),
169                        description: "Request headers".to_string(),
170                        required: false,
171                    },
172                    Parameter {
173                        name: "body".to_string(),
174                        type_hint: "string".to_string(),
175                        description: "Request body".to_string(),
176                        required: false,
177                    },
178                ],
179                example: json!({
180                    "type": "send_http_request",
181                    "method": "POST",
182                    "path": "/api/data",
183                    "body": "{\"key\": \"value\"}"
184                }),
185                log_template: None,
186            },
187            ActionDefinition {
188                name: "wait_for_more".to_string(),
189                description:
190                    "Take no action and wait for the next response. Use when nothing should be \
191                 sent yet."
192                        .to_string(),
193                parameters: vec![],
194                example: json!({ "type": "wait_for_more" }),
195                log_template: None,
196            },
197        ]
198    }
199    fn protocol_name(&self) -> &'static str {
200        "HTTP"
201    }
202    fn get_event_types(&self) -> Vec<EventType> {
203        vec![
204            EventType::new(
205                "http_connected",
206                "Triggered when HTTP client is initialized",
207                json!({"type": "placeholder", "event_id": "http_connected"}),
208            ),
209            EventType::new(
210                "http_response_received",
211                "Triggered when HTTP client receives a response",
212                json!({"type": "placeholder", "event_id": "http_response_received"}),
213            ),
214        ]
215    }
216    fn stack_name(&self) -> &'static str {
217        "ETH>IP>TCP>HTTP"
218    }
219    fn keywords(&self) -> Vec<&'static str> {
220        vec!["http", "http client", "connect to http", "https"]
221    }
222    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
223        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
224
225        ProtocolMetadataV2::builder()
226            .state(DevelopmentState::Beta)
227            .implementation(
228                "reqwest HTTP/HTTPS client library (HTTP/1.1, HTTP/2 via rustls); in the browser \
229                 build hyper's HTTP/1.1 client over the virtual loopback (plain http:// only)",
230            )
231            .llm_control("Full control over requests (method, path, headers, body)")
232            .e2e_testing(
233                "tests/client/http/real_server_test.rs, 5 LLM calls, against nginx (C, its own \
234                 HTTP parser; NetGet's client is reqwest/hyper). The model GETs a static file, \
235                 POSTs a header built from that file with a 13-byte body, and GETs a missing \
236                 path; nginx's own access log must hold exactly those request lines, the \
237                 model's header value, the default_headers User-Agent, the body length and the \
238                 200/200/404 statuses. HTTP is this client's protocol, so a real HTTP server is \
239                 the right peer. Not #[ignore]d, and a missing nginx fails the test rather than \
240                 skipping it.",
241            )
242            .notes(
243                "Validated against nginx over plain HTTP/1.1. HTTPS, HTTP/2, redirects and \
244                 compressed bodies are not exercised against a real server. A response body is \
245                 read whole up to 8 MiB and refused past it. The browser transport \
246                 (transport.rs) is exercised against NetGet's own servers only, chunked \
247                 responses included.",
248            )
249            .build()
250    }
251    fn description(&self) -> &'static str {
252        "HTTP/HTTPS client for making web requests (HTTP/1.1, HTTP/2)"
253    }
254    fn example_prompt(&self) -> &'static str {
255        "Connect to https://example.com and fetch /api/status"
256    }
257    fn group_name(&self) -> &'static str {
258        "Core"
259    }
260    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
261        use crate::llm::actions::StartupExamples;
262        use serde_json::json;
263
264        StartupExamples::new(
265            // LLM mode: LLM controls HTTP client interactions
266            json!({
267                "type": "open_client",
268                "remote_addr": "https://api.example.com",
269                "base_stack": "http",
270                "instruction": "Fetch the API status and report the results"
271            }),
272            // Script mode: Code-based deterministic responses
273            json!({
274                "type": "open_client",
275                "remote_addr": "https://api.example.com",
276                "base_stack": "http",
277                "event_handlers": [{
278                    "event_pattern": "http_response_received",
279                    "handler": {
280                        "type": "script",
281                        "language": "python",
282                        "code": "<http_client_handler>"
283                    }
284                }]
285            }),
286            // Static mode: Fixed responses
287            json!({
288                "type": "open_client",
289                "remote_addr": "https://api.example.com",
290                "base_stack": "http",
291                "event_handlers": [
292                    {
293                        "event_pattern": "http_connected",
294                        "handler": {
295                            "type": "static",
296                            "actions": [{
297                                "type": "send_http_request",
298                                "method": "GET",
299                                "path": "/health",
300                                "headers": {"Accept": "application/json"}
301                            }]
302                        }
303                    },
304                    {
305                        "event_pattern": "http_response_received",
306                        "handler": {
307                            "type": "static",
308                            "actions": [{
309                                "type": "disconnect"
310                            }]
311                        }
312                    }
313                ]
314            }),
315        )
316    }
317}
318
319// Implement Client trait (client-specific functionality)
320impl Client for HttpClientProtocol {
321    fn connect(
322        &self,
323        ctx: crate::protocol::ConnectContext,
324    ) -> std::pin::Pin<
325        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
326    > {
327        Box::pin(async move {
328            use crate::client::http::HttpClient;
329            HttpClient::connect_with_llm_actions(
330                ctx.remote_addr,
331                ctx.llm_client,
332                ctx.state,
333                ctx.status_tx,
334                ctx.client_id,
335                ctx.startup_params,
336            )
337            .await
338        })
339    }
340    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
341        let action_type = action
342            .get("type")
343            .and_then(|v| v.as_str())
344            .context("Missing 'type' field in action")?;
345
346        match action_type {
347            "send_http_request" => {
348                let method = action
349                    .get("method")
350                    .and_then(|v| v.as_str())
351                    .context("Missing 'method' field")?
352                    .to_string();
353
354                let path = action
355                    .get("path")
356                    .and_then(|v| v.as_str())
357                    .context("Missing 'path' field")?
358                    .to_string();
359
360                let headers = action.get("headers").and_then(|v| v.as_object()).cloned();
361
362                let body = action
363                    .get("body")
364                    .and_then(|v| v.as_str())
365                    .map(|s| s.to_string());
366
367                // Return custom result with request data
368                Ok(ClientActionResult::Custom {
369                    name: "http_request".to_string(),
370                    data: json!({
371                        "method": method,
372                        "path": path,
373                        "headers": headers,
374                        "body": body,
375                    }),
376                })
377            }
378            "disconnect" => Ok(ClientActionResult::Disconnect),
379            // CLAUDE.md lists wait_for_more as one of the three standard client
380            // sync actions, and ftp/imap/doh/rip/tcp/dns all implement it. These two
381            // did not, so returning it was an "unknown action" error the client then
382            // swallowed -- two suites passed while sending an action the client
383            // could never obey.
384            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
385            _ => Err(anyhow::anyhow!(
386                "Unknown HTTP client action: {}",
387                action_type
388            )),
389        }
390    }
391}