1use crate::llm::actions::{
4 protocol_trait::{ActionResult, Protocol, Server},
5 ActionDefinition, Parameter,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14pub struct HttpProtocol;
16
17impl Default for HttpProtocol {
18 fn default() -> Self {
19 Self::new()
20 }
21}
22
23impl HttpProtocol {
24 pub fn new() -> Self {
25 Self
26 }
27}
28
29impl Protocol for HttpProtocol {
31 fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
32 let mut params = crate::server::tls_cert_manager::get_tls_startup_parameters();
33 params.extend(crate::server::http_common::handler::request_handling_startup_parameters());
34 params
35 }
36 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
37 Vec::new()
39 }
40 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
41 vec![send_http_response_action()]
42 }
43 fn protocol_name(&self) -> &'static str {
44 "HTTP"
45 }
46 fn get_event_types(&self) -> Vec<EventType> {
47 get_http_event_types()
48 }
49 fn stack_name(&self) -> &'static str {
50 "ETH>IP>TCP>HTTP"
51 }
52 fn keywords(&self) -> Vec<&'static str> {
53 vec!["http", "http server", "http stack", "via http", "hyper"]
54 }
55 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
56 use crate::protocol::metadata::{
57 DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
58 };
59
60 ProtocolMetadataV2::builder()
61 .state(DevelopmentState::Beta)
62 .privilege_requirement(PrivilegeRequirement::PrivilegedPort(80))
65 .well_known_port(80)
66 .implementation("hyper v1.0 HTTP/1.1 server, optional TLS via rustls")
67 .llm_control("Response content (status, headers, text body) — one response per request")
68 .e2e_testing(
69 "THREE independent clients, none #[ignore]d and none able to skip. \
70 (1) reqwest (hyper) in tests/server/http/test.rs, 7 scenarios. \
71 (2) the real curl binary and (3) Python's http.client, both in \
72 tests/server/http/real_client_test.rs, which FAILS naming the install when \
73 either is missing. curl is libcurl, C, and parses the status line itself -- \
74 `--fail` on a 404 is asserted, so a 200 carrying the words `no such thing` \
75 would not pass. http.client is a third implementation in Python's standard \
76 library, needing no install anywhere, and it exposes the raw header list and \
77 the reason phrase, neither of which reqwest surfaces here: a custom header, a \
78 case-insensitive lookup of it, the Content-Type and a non-empty reason phrase \
79 are all asserted. Verified by changing the header value the model sets, which \
80 the test then reports. \
81 The rating rested on reqwest ALONE until September 2026, which is the shape \
82 that turned out to be hiding a total failure in etcd, grpc and mysql. \
83 Note the generic-HTTP-client caveat in the project CLAUDE.md does NOT apply \
84 here: it is about protocols layered OVER http, where reqwest proves only that \
85 an HTTP server answered. Here HTTP is the protocol. \
86 UNPROVEN: TLS (the server supports it; no test drives a real client through \
87 it), the h2c upgrade path, chunked or streaming responses, and binary bodies.",
88 )
89 .notes(
90 "Text bodies only: no binary response bodies, no chunked/streaming responses, \
91 and request bodies are fully buffered before the LLM sees them",
92 )
93 .max_inbound_bytes(crate::server::http_common::MAX_REQUEST_BODY_BYTES)
94 .request_only(
95 "HTTP only answers requests; a server cannot send a peer anything unprompted",
96 )
97 .build()
98 }
99 fn description(&self) -> &'static str {
100 "Web server serving HTTP traffic"
101 }
102 fn example_prompt(&self) -> &'static str {
103 "Pretend to be a sassy HTTP server on port 8080 serving cooking recipes"
104 }
105 fn group_name(&self) -> &'static str {
106 "Core"
107 }
108
109 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
110 use crate::llm::actions::StartupExamples;
111 use serde_json::json;
112
113 let script = r#"import json, sys
117data = json.load(sys.stdin)
118event = data["event"]
119if data["event_type_id"] == "http_request":
120 if event.get("path", "/") == "/health":
121 actions = [{"type": "send_http_response", "status": 200,
122 "headers": {"Content-Type": "application/json"},
123 "body": '{"status": "ok"}'}]
124 else:
125 actions = [{"type": "send_http_response", "status": 404,
126 "headers": {"Content-Type": "text/plain"},
127 "body": "Not Found"}]
128else:
129 actions = []
130print(json.dumps({"actions": actions}))"#;
131
132 StartupExamples::new(
133 json!({
135 "type": "open_server",
136 "port": 8080,
137 "base_stack": "http",
138 "instruction": "Serve a REST API for a blog. For GET /posts return a JSON array of posts; for GET /posts/{id} return that single post, inventing a plausible title and body from the id; return 404 with a JSON error object for any unknown path. Reason about the method and path of each request."
139 }),
140 json!({
142 "type": "open_server",
143 "port": 8080,
144 "base_stack": "http",
145 "event_handlers": [{
146 "event_pattern": "http_request",
147 "handler": {
148 "type": "script",
149 "language": "python",
150 "code": script
151 }
152 }]
153 }),
154 json!({
156 "type": "open_server",
157 "port": 8080,
158 "base_stack": "http",
159 "event_handlers": [{
160 "event_pattern": "http_request",
161 "handler": {
162 "type": "static",
163 "actions": [{
164 "type": "send_http_response",
165 "status": 200,
166 "headers": {"Content-Type": "text/plain"},
167 "body": "Hello World"
168 }]
169 }
170 }]
171 }),
172 )
173 }
174}
175
176impl Server for HttpProtocol {
178 fn spawn(
179 &self,
180 ctx: crate::protocol::SpawnContext,
181 ) -> std::pin::Pin<
182 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
183 > {
184 Box::pin(async move {
185 use crate::server::http::HttpServer;
186
187 let tls_config = if let Some(ref params) = ctx.startup_params {
189 match crate::server::tls_cert_manager::extract_tls_config_from_params(params) {
190 Ok(config) => config,
191 Err(e) => {
192 return Err(anyhow::anyhow!("Failed to create TLS config: {}", e));
193 }
194 }
195 } else {
196 None
197 };
198
199 HttpServer::spawn_with_llm_actions(
200 ctx.legacy_listen_addr(),
201 ctx.llm_client,
202 ctx.state,
203 ctx.status_tx,
204 ctx.server_id,
205 tls_config,
206 )
207 .await
208 })
209 }
210 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
211 let action_type = action
212 .get("type")
213 .and_then(|v| v.as_str())
214 .context("Missing 'type' field in action")?;
215
216 match action_type {
217 "send_http_response" => self.execute_send_http_response(action),
218 _ => Err(anyhow::anyhow!("Unknown HTTP action: {action_type}")),
219 }
220 }
221}
222
223impl HttpProtocol {
224 fn execute_send_http_response(&self, action: serde_json::Value) -> Result<ActionResult> {
226 crate::server::http_common::execute_http_response_action(action)
228 }
229}
230
231fn send_http_response_action() -> ActionDefinition {
233 ActionDefinition {
234 name: "send_http_response".to_string(),
235 description: "Respond to the HTTP request that triggered this event. This is the ONLY \
236 action that produces an HTTP response - do NOT use generic 'send_data' or \
237 'show_message' for that. ALWAYS emit exactly one send_http_response for every request \
238 — never return an empty action list, or the client gets a blank page. \
239 HONOR THE CLIENT'S `Accept` REQUEST HEADER (in the event's headers.accept): return a \
240 `Content-Type` the client accepts, and a body in that format. If Accept is \
241 `text/html`, return HTML; `application/json`, return JSON; `text/plain`, return text. \
242 If the client asks ONLY for a type you cannot produce — most commonly an image \
243 (`Accept: image/*`, e.g. a browser fetching /favicon.ico) or any other binary type — \
244 respond `404` (or `204`) rather than sending text mislabeled as an image: the body is \
245 UTF-8 text only, so binary payloads (images, gzip, protobuf) cannot be produced at \
246 all. The response is sent complete, in one piece, as soon as you return; there is no \
247 way to stream or chunk it, to send it in parts, or to keep the request open."
248 .to_string(),
249 parameters: vec![
250 Parameter {
251 name: "status".to_string(),
252 type_hint: "number".to_string(),
253 description:
254 "HTTP status code as a number between 100 and 599 (e.g. 200, 404, 500)."
255 .to_string(),
256 required: true,
257 },
258 Parameter {
259 name: "headers".to_string(),
260 type_hint: "object".to_string(),
261 description: "Optional response headers as a flat name->value object (e.g. \
262 {\"Content-Type\": \"text/html\"}). Set Content-Type yourself; Content-Length \
263 and Date are added automatically and must not be set here. Headers whose name \
264 or value is not legal HTTP (for example one containing a newline) are dropped."
265 .to_string(),
266 required: false,
267 },
268 Parameter {
269 name: "body".to_string(),
270 type_hint: "string".to_string(),
271 description: "Response body as text (HTML, JSON, plain text). Optional: omit it \
272 for an empty body, which is what 204 and 304 require. A JSON object or array \
273 is serialized to compact JSON text. Bytes cannot be sent - text only."
274 .to_string(),
275 required: false,
276 },
277 ],
278 example: json!({
279 "type": "send_http_response",
280 "status": 200,
281 "headers": {
282 "Content-Type": "text/html"
283 },
284 "body": "<html><body>Hello World</body></html>"
285 }),
286 log_template: Some(
287 LogTemplate::new()
288 .with_info("-> {status} ({output_bytes}B)")
289 .with_debug("HTTP response: status={status}, body={output_bytes}B")
290 .with_trace("HTTP response: {json_pretty(.)}"),
291 ),
292 }
293}
294
295pub static SEND_HTTP_RESPONSE_ACTION: LazyLock<ActionDefinition> =
300 LazyLock::new(send_http_response_action);
301
302pub static HTTP_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
308 EventType::new(
309 "http_request",
310 "HTTP request received from client. Always answer with exactly one send_http_response, \
311 and match the response Content-Type to the client's Accept header (headers.accept): \
312 HTML for text/html, JSON for application/json; if the client only accepts an image or \
313 other binary type you cannot produce (e.g. a browser fetching /favicon.ico with \
314 Accept: image/*), answer 404 rather than sending text as an image.",
315 serde_json::json!({
316 "type": "send_http_response",
317 "status": 200,
318 "headers": {
319 "Content-Type": "text/html"
320 },
321 "body": "<html><body>Hello World</body></html>"
322 }),
323 )
324 .with_parameters(vec![
325 Parameter {
326 name: "method".to_string(),
327 type_hint: "string".to_string(),
328 description: "HTTP method (GET, POST, etc.)".to_string(),
329 required: true,
330 },
331 Parameter {
332 name: "path".to_string(),
333 type_hint: "string".to_string(),
334 description: "Request path without query string (e.g., '/api/users')".to_string(),
335 required: true,
336 },
337 Parameter {
338 name: "query_string".to_string(),
339 type_hint: "string".to_string(),
340 description: "Raw query string if present (e.g., 'x=5&y=3')".to_string(),
341 required: false,
342 },
343 Parameter {
344 name: "query".to_string(),
345 type_hint: "object".to_string(),
346 description:
347 "Parsed query parameters as key-value pairs (e.g., {\"x\": \"5\", \"y\": \"3\"})"
348 .to_string(),
349 required: false,
350 },
351 Parameter {
352 name: "headers".to_string(),
353 type_hint: "object".to_string(),
354 description: "Request headers as key-value pairs".to_string(),
355 required: true,
356 },
357 Parameter {
358 name: "body".to_string(),
359 type_hint: "string".to_string(),
360 description:
361 "Request body decoded as UTF-8 text (empty string when there is no body). \
362 Bytes that are not valid UTF-8 are replaced with U+FFFD, so when body_is_binary is \
363 true this field is lossy and must not be treated as the exact request payload."
364 .to_string(),
365 required: false,
366 },
367 Parameter {
368 name: "body_bytes".to_string(),
369 type_hint: "number".to_string(),
370 description: "Size of the request body in bytes, before UTF-8 decoding.".to_string(),
371 required: false,
372 },
373 Parameter {
374 name: "body_is_binary".to_string(),
375 type_hint: "boolean".to_string(),
376 description: "Present and true only when the request body is not valid UTF-8. The \
377 body field is then a lossy decoding; the raw bytes are not available to you."
378 .to_string(),
379 required: false,
380 },
381 ])
382 .with_actions(vec![SEND_HTTP_RESPONSE_ACTION.clone()])
383 .with_alternative_example(serde_json::json!({
384 "type": "send_http_response",
385 "status": 404,
386 "headers": {
387 "Content-Type": "text/plain"
388 },
389 "body": "Not Found"
390 }))
391 .with_alternative_example(serde_json::json!({
392 "type": "send_http_response",
393 "status": 201,
394 "headers": {
395 "Content-Type": "application/json"
396 },
397 "body": "{\"status\": \"created\"}"
398 }))
399 .with_log_template(
400 LogTemplate::new()
401 .with_info(
402 "{client_ip} {method} {path} -> {status} ({response_bytes}B, {duration_ms}ms)",
403 )
404 .with_debug("HTTP {method} {path} from {client_ip}:{client_port}")
405 .with_trace("HTTP request: {json_pretty(.)}"),
406 )
407});
408
409pub fn get_http_event_types() -> Vec<EventType> {
411 vec![HTTP_REQUEST_EVENT.clone()]
412}