Skip to main content

axon/
http_tool.rs

1//! HTTP tool provider — executes tool calls as REST requests via reqwest.
2//!
3//! Tools declared with `provider: http` in .axon files dispatch their
4//! argument as the request body to the URL specified in `runtime`.
5//!
6//! Request format:
7//!   POST {runtime_url}
8//!   Content-Type: application/json
9//!   X-Axon-Tool: {tool_name}
10//!
11//!   Body: the tool argument (string, sent as JSON-wrapped if not already JSON)
12//!
13//! Response handling:
14//!   - 2xx: response body becomes tool output (success)
15//!   - 4xx/5xx: error message with status code (failure)
16//!   - Connection error: descriptive error (failure)
17//!
18//! Timeout: parsed from ToolEntry.timeout field (e.g., "10s", "500ms").
19//! Default timeout: 30 seconds.
20//!
21//! v1.29.0 — Streaming surface via [`HttpStreamingTool`].
22//! The async-trait Tool impl drives the upstream HTTP request via
23//! `reqwest::Client` (async) + drains the response body chunk-by-chunk.
24//! Content-Type drives framing:
25//!   - `text/event-stream` → per-W3C-SSE-event ToolChunks
26//!   - `application/x-ndjson` / `application/jsonl` → per-line ToolChunks
27//!   - Other (raw bytes, JSON, etc.) → single-chunk wrap (D9 backwards-
28//!     compat for non-streaming HTTP endpoints)
29//! Per-chunk cancel poll honors the D5 ≤100ms budget.
30
31use std::time::Duration;
32
33use crate::tool_executor::ToolResult;
34use crate::tool_registry::ToolEntry;
35
36// ── Timeout parsing ───────────────────────────────────────────────────────
37
38/// Parse a timeout string like "10s", "500ms", "2m" into Duration.
39/// Returns None for empty or unparseable values.
40fn parse_timeout(s: &str) -> Option<Duration> {
41    let s = s.trim();
42    if s.is_empty() {
43        return None;
44    }
45
46    if let Some(secs) = s.strip_suffix("ms") {
47        secs.trim().parse::<u64>().ok().map(Duration::from_millis)
48    } else if let Some(secs) = s.strip_suffix('s') {
49        secs.trim().parse::<u64>().ok().map(Duration::from_secs)
50    } else if let Some(mins) = s.strip_suffix('m') {
51        mins.trim()
52            .parse::<u64>()
53            .ok()
54            .map(|m| Duration::from_secs(m * 60))
55    } else {
56        // Try as raw seconds
57        s.parse::<u64>().ok().map(Duration::from_secs)
58    }
59}
60
61/// Public accessor for timeout parsing (used by emcp module).
62pub fn parse_timeout_pub(s: &str) -> Option<Duration> {
63    parse_timeout(s)
64}
65
66const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
67
68// ── HTTP dispatch ─────────────────────────────────────────────────────────
69
70/// Execute an HTTP tool call.
71///
72/// - `entry`: the tool's registry entry (must have provider == "http")
73/// - `argument`: the argument string from the use_tool step
74///
75/// Returns a ToolResult with the HTTP response body on success,
76/// or an error description on failure.
77pub fn dispatch_http(entry: &ToolEntry, argument: &str) -> ToolResult {
78    let url = entry.runtime.trim();
79
80    if url.is_empty() {
81        return ToolResult {
82            success: false,
83            output: format!(
84                "HTTP tool '{}': no endpoint URL. Set runtime: \"https://...\" in tool definition.",
85                entry.name
86            ),
87            tool_name: entry.name.clone(),
88        };
89    }
90
91    // Validate URL scheme
92    if !url.starts_with("http://") && !url.starts_with("https://") {
93        return ToolResult {
94            success: false,
95            output: format!(
96                "HTTP tool '{}': invalid URL '{}'. Must start with http:// or https://.",
97                entry.name, url
98            ),
99            tool_name: entry.name.clone(),
100        };
101    }
102
103    let timeout = parse_timeout(&entry.timeout).unwrap_or(DEFAULT_TIMEOUT);
104
105    // Build the request body — wrap as JSON string if not already JSON
106    let body = if argument.trim_start().starts_with('{') || argument.trim_start().starts_with('[') {
107        argument.to_string()
108    } else {
109        serde_json::json!({ "input": argument }).to_string()
110    };
111
112    // Execute the HTTP request
113    match execute_request(url, &entry.name, &body, timeout) {
114        Ok(response) => response,
115        Err(e) => ToolResult {
116            success: false,
117            output: format!("HTTP tool '{}': {}", entry.name, e),
118            tool_name: entry.name.clone(),
119        },
120    }
121}
122
123/// v2.69.0 (owed) — the PROCESS-SHARED blocking HTTP client. A fresh
124/// `reqwest::blocking::Client` per call threw away reqwest's internal connection
125/// pool, so every tool call paid a new TCP + TLS handshake. reqwest is explicitly
126/// designed to be built ONCE and reused (the pool lives on the `Client`); the
127/// per-call knob — the request timeout — is set per REQUEST instead, so one shared
128/// client serves every timeout. `http_tool` carries no per-resource client config
129/// (proxy / TLS impersonation is the `scrape` domain), so process-wide is the
130/// correct granularity here. Built lazily; `new()` only fails on a broken TLS
131/// backend, which would fail every call anyway.
132fn shared_blocking_client() -> &'static reqwest::blocking::Client {
133    static CLIENT: std::sync::OnceLock<reqwest::blocking::Client> = std::sync::OnceLock::new();
134    CLIENT.get_or_init(reqwest::blocking::Client::new)
135}
136
137/// v2.69.0 (owed) — the PROCESS-SHARED async HTTP client (the streaming-tool
138/// dual of [`shared_blocking_client`]). Same rationale: build once, reuse the
139/// connection pool, set the timeout per request.
140fn shared_async_client() -> &'static reqwest::Client {
141    static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
142    CLIENT.get_or_init(reqwest::Client::new)
143}
144
145/// Perform the actual HTTP POST request.
146fn execute_request(
147    url: &str,
148    tool_name: &str,
149    body: &str,
150    timeout: Duration,
151) -> Result<ToolResult, String> {
152    let response = shared_blocking_client()
153        .post(url)
154        .timeout(timeout)
155        .header("Content-Type", "application/json")
156        .header("X-Axon-Tool", tool_name)
157        .body(body.to_string())
158        .send()
159        .map_err(|e| {
160            if e.is_timeout() {
161                format!("request timed out after {}s", timeout.as_secs())
162            } else if e.is_connect() {
163                format!("connection failed to {url}")
164            } else {
165                format!("request failed: {e}")
166            }
167        })?;
168
169    let status = response.status();
170    let response_body = response
171        .text()
172        .map_err(|e| format!("failed to read response body: {e}"))?;
173
174    if status.is_success() {
175        Ok(ToolResult {
176            success: true,
177            output: response_body,
178            tool_name: tool_name.to_string(),
179        })
180    } else {
181        Ok(ToolResult {
182            success: false,
183            output: format!(
184                "HTTP {}: {}",
185                status.as_u16(),
186                if response_body.len() > 200 {
187                    format!("{}...", &response_body[..200])
188                } else {
189                    response_body
190                }
191            ),
192            tool_name: tool_name.to_string(),
193        })
194    }
195}
196
197// ════════════════════════════════════════════════════════════════════════════
198// v1.29.0 — HttpStreamingTool: async-trait Tool impl with per-chunk
199//  streaming wire emission via reqwest::Response::bytes_stream().
200// ════════════════════════════════════════════════════════════════════════════
201
202use async_trait::async_trait;
203use bytes::Bytes;
204use futures::StreamExt;
205
206use crate::backends::sse_streaming::{LineBuffer, SseEventParser};
207use crate::tool_trait::{Tool, ToolChunk, ToolContext, ToolFinishReason, ToolStream};
208
209/// HTTP tool with first-class streaming surface (v1.29.0).
210///
211/// `stream()` drives a `reqwest::Client::post(url)` async request +
212/// drains `response.bytes_stream()` chunk-by-chunk. Content-Type
213/// header decides framing:
214///
215/// - **`text/event-stream`** → SSE per W3C spec. Each `data:` field
216///   from a complete SSE event emits as a `ToolChunk::intermediate`.
217///   `event:` / `id:` / `retry:` fields are dropped (the adopter sees
218///   only the data payload — the framing was for HTTP transport).
219/// - **`application/x-ndjson`** / **`application/jsonl`** → one
220///   `ToolChunk::intermediate` per LF-delimited line. Empty lines
221///   are skipped.
222/// - **Other** (`application/json`, `text/plain`, raw bytes) →
223///   single `ToolChunk::intermediate` with the full body accumulated,
224///   then a terminator. This is the D9 backwards-compat path for
225///   non-streaming HTTP endpoints — the same response shape
226///   [`dispatch_http`] returns synchronously, projected onto the
227///   single-chunk streaming surface.
228///
229/// # Cancel discipline (D5)
230///
231/// `ctx.cancel` is polled between every `bytes_stream().next().await`
232/// boundary. When fired, the stream drops the `reqwest::Response`
233/// (closing the connection) + emits a single
234/// `ToolFinishReason::Cancelled` terminator chunk. Wall-clock budget
235/// is bounded by the network roundtrip latency to the next chunk
236/// arrival (typically ≤100ms for SSE streams with regular keepalive).
237///
238/// # Error discipline
239///
240/// Every failure surface (URL invalid / client build / connect /
241/// timeout / non-2xx status / mid-stream byte error / I/O error)
242/// is captured as a `ToolFinishReason::Error { message }` terminator
243/// chunk — the consumer never sees a panic or a silently-truncated
244/// stream.
245pub struct HttpStreamingTool {
246    name: String,
247    url: String,
248    timeout: Duration,
249}
250
251impl HttpStreamingTool {
252    /// Construct from a registry [`ToolEntry`]. Validates the URL +
253    /// extracts the timeout. Returns `Err` with adopter-facing
254    /// diagnostic when the URL is missing or has an invalid scheme.
255    pub fn from_entry(entry: &ToolEntry) -> Result<Self, String> {
256        let url = entry.runtime.trim();
257        if url.is_empty() {
258            return Err(format!(
259                "HTTP tool '{}': no endpoint URL. Set runtime: \"https://...\" in tool definition.",
260                entry.name
261            ));
262        }
263        if !url.starts_with("http://") && !url.starts_with("https://") {
264            return Err(format!(
265                "HTTP tool '{}': invalid URL '{}'. Must start with http:// or https://.",
266                entry.name, url
267            ));
268        }
269        let timeout = parse_timeout(&entry.timeout).unwrap_or(DEFAULT_TIMEOUT);
270        Ok(Self {
271            name: entry.name.clone(),
272            url: url.to_string(),
273            timeout,
274        })
275    }
276
277    /// Public new() ctor for tests + adopters who construct directly
278    /// without a registry entry.
279    pub fn new(name: String, url: String, timeout: Duration) -> Self {
280        Self { name, url, timeout }
281    }
282}
283
284/// Build the request body — wrap as JSON `{ "input": args }` if not
285/// already JSON. Same logic as [`dispatch_http`].
286fn build_request_body(args: &str) -> String {
287    let trimmed = args.trim_start();
288    if trimmed.starts_with('{') || trimmed.starts_with('[') {
289        args.to_string()
290    } else {
291        serde_json::json!({ "input": args }).to_string()
292    }
293}
294
295/// Classify an HTTP Content-Type header into the framing mode the
296/// streaming tool will use.
297#[derive(Debug, Clone, Copy, PartialEq, Eq)]
298enum FramingMode {
299    /// W3C Server-Sent Events. Drain via [`LineBuffer`] +
300    /// [`SseEventParser`]; emit each event's `data:` field as a
301    /// `ToolChunk`.
302    Sse,
303    /// Newline-delimited JSON. Drain via [`LineBuffer`]; emit each
304    /// non-empty line as a `ToolChunk`.
305    Ndjson,
306    /// Anything else. Accumulate full body + emit as 1 chunk +
307    /// terminator. D9-style backwards-compat for non-streaming
308    /// HTTP endpoints.
309    Single,
310}
311
312fn classify_framing(content_type: &str) -> FramingMode {
313    let lc = content_type.to_ascii_lowercase();
314    if lc.contains("text/event-stream") {
315        FramingMode::Sse
316    } else if lc.contains("application/x-ndjson") || lc.contains("application/jsonl") {
317        FramingMode::Ndjson
318    } else {
319        FramingMode::Single
320    }
321}
322
323#[async_trait]
324impl Tool for HttpStreamingTool {
325    async fn execute(&self, args: String, _ctx: ToolContext) -> ToolResult {
326        // Synchronous path — adopters calling execute() directly get
327        // the legacy [`dispatch_http`] behavior verbatim. The
328        // streaming path drives `stream()` exclusively.
329        //
330        // [`dispatch_http`] uses `reqwest::blocking::Client` (it
331        // existed pre-async-trait). Calling blocking-reqwest from
332        // inside a tokio runtime panics; we wrap the call with
333        // `spawn_blocking` so the synchronous client runs on tokio's
334        // blocking pool. Output is byte-equal to dispatch_http (D9).
335        let entry = ToolEntry {
336            name: self.name.clone(),
337            provider: "http".to_string(),
338            timeout: format!("{}s", self.timeout.as_secs()),
339            runtime: self.url.clone(),
340            resource_ref: String::new(),
341            substrate: None,
342            capacity: None,
343            sandbox: None,
344            max_results: None,
345            output_schema: String::new(),
346            effect_row: Vec::new(),
347            // v2.8.0 — reconstructed entry for the legacy sync
348            // delegate; no typed input schema needed on this path.
349            parameters: Vec::new(),
350            secret: String::new(),
351            secret_partition: String::new(),
352            source: crate::tool_registry::ToolSource::Program,
353            is_streaming: false,
354            scrape: None,
355        };
356        match tokio::task::spawn_blocking(move || dispatch_http(&entry, &args)).await {
357            Ok(result) => result,
358            Err(e) => ToolResult {
359                success: false,
360                output: format!("HTTP tool '{}': blocking task join failed: {e}", self.name),
361                tool_name: self.name.clone(),
362            },
363        }
364    }
365
366    async fn stream(&self, args: String, ctx: ToolContext) -> ToolStream {
367        let url = self.url.clone();
368        let name = self.name.clone();
369        let timeout = self.timeout;
370        let cancel = ctx.cancel.clone();
371        let body = build_request_body(&args);
372
373        // mpsc + spawn pattern: the background task drives the HTTP
374        // request + drains chunks into the channel; the returned
375        // stream wraps the receiver. This gives us real per-chunk
376        // streaming (chunks reach the dispatcher AS they arrive from
377        // upstream) without requiring async-stream macro.
378        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<ToolChunk>();
379
380        tokio::spawn(async move {
381            // Helper: send the terminator + drop tx so the consumer's
382            // stream ends cleanly. Returning `Err` from a sub-step
383            // sends an Error-terminator; reaching the natural end of
384            // the body sends a Stop-terminator.
385            let send_terminator = |reason: ToolFinishReason| {
386                let _ = tx.send(ToolChunk::terminator("", reason));
387            };
388
389            // Pre-flight cancel check.
390            if cancel.is_cancelled() {
391                send_terminator(ToolFinishReason::Cancelled);
392                return;
393            }
394
395            // 1. The PROCESS-SHARED async client (v2.69.0 owed — see
396            //    `shared_blocking_client`: pooling was thrown away by a
397            //    per-call `Client::builder().build()`). Timeout is per REQUEST.
398            let client = shared_async_client();
399
400            // 2. Issue request.
401            let response = match client
402                .post(&url)
403                .timeout(timeout)
404                .header("Content-Type", "application/json")
405                .header("X-Axon-Tool", &name)
406                .body(body)
407                .send()
408                .await
409            {
410                Ok(r) => r,
411                Err(e) => {
412                    let message = if e.is_timeout() {
413                        format!(
414                            "HTTP tool '{name}': request timed out after {}s",
415                            timeout.as_secs()
416                        )
417                    } else if e.is_connect() {
418                        format!("HTTP tool '{name}': connection failed to {url}")
419                    } else {
420                        format!("HTTP tool '{name}': request failed: {e}")
421                    };
422                    send_terminator(ToolFinishReason::Error { message });
423                    return;
424                }
425            };
426
427            // 3. Non-2xx → error terminator with status code +
428            //    truncated body. Mirrors dispatch_http's diagnostic
429            //    shape.
430            let status = response.status();
431            if !status.is_success() {
432                let body_text = response.text().await.unwrap_or_default();
433                let truncated = if body_text.len() > 200 {
434                    format!("{}...", &body_text[..200])
435                } else {
436                    body_text
437                };
438                send_terminator(ToolFinishReason::Error {
439                    message: format!("HTTP {}: {}", status.as_u16(), truncated),
440                });
441                return;
442            }
443
444            // 4. Read Content-Type header → classify framing.
445            let content_type = response
446                .headers()
447                .get(reqwest::header::CONTENT_TYPE)
448                .and_then(|v| v.to_str().ok())
449                .unwrap_or("")
450                .to_string();
451            let framing = classify_framing(&content_type);
452
453            // 5. Drain the body byte-stream per framing mode.
454            let mut byte_stream = response.bytes_stream();
455            let drain_result = match framing {
456                FramingMode::Sse => {
457                    drain_sse(&mut byte_stream, &cancel, &tx).await
458                }
459                FramingMode::Ndjson => {
460                    drain_ndjson(&mut byte_stream, &cancel, &tx).await
461                }
462                FramingMode::Single => {
463                    drain_single(&mut byte_stream, &cancel, &tx).await
464                }
465            };
466
467            match drain_result {
468                DrainOutcome::Completed => send_terminator(ToolFinishReason::Stop),
469                DrainOutcome::Cancelled => send_terminator(ToolFinishReason::Cancelled),
470                DrainOutcome::Error(message) => {
471                    send_terminator(ToolFinishReason::Error { message })
472                }
473            }
474        });
475
476        // Wrap the receiver as a Stream. Each `recv().await` yields
477        // a ToolChunk + holds the channel open until the producer
478        // task drops `tx`.
479        Box::pin(futures::stream::unfold(rx, |mut rx| async move {
480            rx.recv().await.map(|chunk| (chunk, rx))
481        }))
482    }
483
484    fn is_streaming(&self) -> bool {
485        true
486    }
487}
488
489/// Per-framing-mode drain outcome. Drives the terminator decision
490/// in the spawned task without leaking implementation details.
491enum DrainOutcome {
492    Completed,
493    Cancelled,
494    Error(String),
495}
496
497/// Drain SSE framing. Reuses the battle-tested
498/// [`crate::backends::sse_streaming::LineBuffer`] +
499/// [`crate::backends::sse_streaming::SseEventParser`] from v1.24.0
500/// so every adopter-emitted SSE shape (CRLF normalization, CR strip,
501/// multi-line data field, comment lines) is honored verbatim.
502async fn drain_sse<S>(
503    byte_stream: &mut S,
504    cancel: &crate::cancel_token::CancellationFlag,
505    tx: &tokio::sync::mpsc::UnboundedSender<ToolChunk>,
506) -> DrainOutcome
507where
508    S: futures::Stream<Item = reqwest::Result<Bytes>> + Unpin + Send,
509{
510    let mut line_buf = LineBuffer::new();
511    let mut sse_parser = SseEventParser::new();
512    loop {
513        if cancel.is_cancelled() {
514            return DrainOutcome::Cancelled;
515        }
516        match byte_stream.next().await {
517            None => break,
518            Some(Err(e)) => {
519                return DrainOutcome::Error(format!("SSE stream chunk error: {e}"))
520            }
521            Some(Ok(bytes)) => {
522                let lines = line_buf.push(&bytes);
523                for line in lines {
524                    if let Some(event) = sse_parser.push_line(&line) {
525                        if let Some(data) = event.data {
526                            if tx
527                                .send(ToolChunk::intermediate(data))
528                                .is_err()
529                            {
530                                return DrainOutcome::Cancelled;
531                            }
532                        }
533                    }
534                }
535            }
536        }
537    }
538    // Flush trailing line (events without a final blank-line
539    // terminator) — push it into the parser; if it completes an
540    // event, emit it.
541    if let Some(line) = line_buf.flush() {
542        if let Some(event) = sse_parser.push_line(&line) {
543            if let Some(data) = event.data {
544                let _ = tx.send(ToolChunk::intermediate(data));
545            }
546        }
547    }
548    DrainOutcome::Completed
549}
550
551/// Drain NDJSON framing. Each LF-delimited line emits as a
552/// `ToolChunk::intermediate`. Empty lines are skipped per
553/// `application/x-ndjson` spec.
554async fn drain_ndjson<S>(
555    byte_stream: &mut S,
556    cancel: &crate::cancel_token::CancellationFlag,
557    tx: &tokio::sync::mpsc::UnboundedSender<ToolChunk>,
558) -> DrainOutcome
559where
560    S: futures::Stream<Item = reqwest::Result<Bytes>> + Unpin + Send,
561{
562    let mut line_buf = LineBuffer::new();
563    loop {
564        if cancel.is_cancelled() {
565            return DrainOutcome::Cancelled;
566        }
567        match byte_stream.next().await {
568            None => break,
569            Some(Err(e)) => {
570                return DrainOutcome::Error(format!("NDJSON stream chunk error: {e}"))
571            }
572            Some(Ok(bytes)) => {
573                let lines = line_buf.push(&bytes);
574                for line in lines {
575                    if !line.is_empty()
576                        && tx.send(ToolChunk::intermediate(line)).is_err()
577                    {
578                        return DrainOutcome::Cancelled;
579                    }
580                }
581            }
582        }
583    }
584    if let Some(line) = line_buf.flush() {
585        if !line.is_empty() {
586            let _ = tx.send(ToolChunk::intermediate(line));
587        }
588    }
589    DrainOutcome::Completed
590}
591
592/// Drain single-chunk framing. Accumulate the full body + emit as
593/// 1 `ToolChunk::intermediate` (terminator follows from the caller).
594/// D9 backwards-compat for non-streaming HTTP endpoints.
595async fn drain_single<S>(
596    byte_stream: &mut S,
597    cancel: &crate::cancel_token::CancellationFlag,
598    tx: &tokio::sync::mpsc::UnboundedSender<ToolChunk>,
599) -> DrainOutcome
600where
601    S: futures::Stream<Item = reqwest::Result<Bytes>> + Unpin + Send,
602{
603    let mut acc: Vec<u8> = Vec::new();
604    loop {
605        if cancel.is_cancelled() {
606            return DrainOutcome::Cancelled;
607        }
608        match byte_stream.next().await {
609            None => break,
610            Some(Err(e)) => {
611                return DrainOutcome::Error(format!("HTTP body chunk error: {e}"))
612            }
613            Some(Ok(bytes)) => acc.extend_from_slice(&bytes),
614        }
615    }
616    let body_text = String::from_utf8_lossy(&acc).into_owned();
617    if !body_text.is_empty()
618        && tx
619            .send(ToolChunk::intermediate(body_text))
620            .is_err()
621    {
622        return DrainOutcome::Cancelled;
623    }
624    DrainOutcome::Completed
625}
626
627// ── Tests ─────────────────────────────────────────────────────────────────
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632    use crate::tool_registry::{ToolEntry, ToolSource};
633
634    /// v2.69.0 (owed) — **the HTTP client is POOLED: every call reuses ONE
635    /// client, not a fresh one per request.** reqwest's connection pool lives on
636    /// the `Client`; a fresh `Client::builder().build()` per call discarded it, so
637    /// every tool call paid a new TCP + TLS handshake. This pins the fix directly:
638    /// the shared accessor returns the SAME instance across calls — one client,
639    /// one pool. (Connection reuse from a shared client is reqwest's documented
640    /// behavior; what this crate owns is *not rebuilding the client per call*.)
641    #[test]
642    fn the_blocking_http_client_is_a_reused_singleton() {
643        let a = shared_blocking_client();
644        let b = shared_blocking_client();
645        assert!(
646            std::ptr::eq(a, b),
647            "every call must reuse ONE blocking client (its connection pool) — not build a fresh \
648             one per request"
649        );
650    }
651
652    #[test]
653    fn the_async_http_client_is_a_reused_singleton() {
654        let a = shared_async_client();
655        let b = shared_async_client();
656        assert!(
657            std::ptr::eq(a, b),
658            "every call must reuse ONE async client (its connection pool)"
659        );
660    }
661
662    fn make_http_entry(name: &str, url: &str, timeout: &str) -> ToolEntry {
663        ToolEntry {
664            name: name.to_string(),
665            provider: "http".to_string(),
666            timeout: timeout.to_string(),
667            runtime: url.to_string(),
668            resource_ref: String::new(),
669            substrate: None,
670            capacity: None,
671            sandbox: None,
672            max_results: None,
673            output_schema: "JSON".to_string(),
674            effect_row: vec!["network".to_string()],
675            parameters: Vec::new(),
676            secret: String::new(),
677            secret_partition: String::new(),
678            source: ToolSource::Program,
679            // v1.29.0 — HTTP tools default to non-streaming; effect_row
680            // carries `network` but no `stream:` prefix. HTTP streaming
681            // (SSE-aware adapter consuming upstream SSE) lands in v1.29.0.
682            is_streaming: false,
683            scrape: None,
684        }
685    }
686
687    // ── Timeout parsing ───────────────────────────────────────────
688
689    #[test]
690    fn parse_timeout_seconds() {
691        assert_eq!(parse_timeout("10s"), Some(Duration::from_secs(10)));
692        assert_eq!(parse_timeout("30s"), Some(Duration::from_secs(30)));
693    }
694
695    #[test]
696    fn parse_timeout_milliseconds() {
697        assert_eq!(parse_timeout("500ms"), Some(Duration::from_millis(500)));
698        assert_eq!(parse_timeout("100ms"), Some(Duration::from_millis(100)));
699    }
700
701    #[test]
702    fn parse_timeout_minutes() {
703        assert_eq!(parse_timeout("2m"), Some(Duration::from_secs(120)));
704    }
705
706    #[test]
707    fn parse_timeout_raw_number() {
708        assert_eq!(parse_timeout("15"), Some(Duration::from_secs(15)));
709    }
710
711    #[test]
712    fn parse_timeout_empty() {
713        assert_eq!(parse_timeout(""), None);
714        assert_eq!(parse_timeout("  "), None);
715    }
716
717    #[test]
718    fn parse_timeout_invalid() {
719        assert_eq!(parse_timeout("abc"), None);
720        assert_eq!(parse_timeout("10x"), None);
721    }
722
723    // ── URL validation ────────────────────────────────────────────
724
725    #[test]
726    fn dispatch_empty_url_fails() {
727        let entry = make_http_entry("DataAPI", "", "10s");
728        let result = dispatch_http(&entry, "test query");
729        assert!(!result.success);
730        assert!(result.output.contains("no endpoint URL"));
731    }
732
733    #[test]
734    fn dispatch_invalid_url_scheme_fails() {
735        let entry = make_http_entry("DataAPI", "ftp://example.com", "10s");
736        let result = dispatch_http(&entry, "test query");
737        assert!(!result.success);
738        assert!(result.output.contains("invalid URL"));
739        assert!(result.output.contains("http://"));
740    }
741
742    // ── Connection errors (no server) ─────────────────────────────
743
744    #[test]
745    fn dispatch_connection_refused() {
746        // Port 1 is almost certainly not listening
747        let entry = make_http_entry("TestTool", "http://127.0.0.1:1/api", "2s");
748        let result = dispatch_http(&entry, "test");
749        assert!(!result.success);
750        assert!(
751            result.output.contains("connection failed")
752                || result.output.contains("request failed")
753                || result.output.contains("timed out"),
754            "unexpected error: {}",
755            result.output
756        );
757    }
758
759    // ── Body wrapping ─────────────────────────────────────────────
760
761    #[test]
762    fn json_body_passthrough() {
763        // If argument is already JSON, it should be sent as-is
764        let arg = r#"{"query": "test"}"#;
765        let body = if arg.trim_start().starts_with('{') {
766            arg.to_string()
767        } else {
768            serde_json::json!({ "input": arg }).to_string()
769        };
770        assert_eq!(body, r#"{"query": "test"}"#);
771    }
772
773    #[test]
774    fn plain_text_wrapped() {
775        // If argument is plain text, it should be wrapped
776        let arg = "search for cats";
777        let body = if arg.trim_start().starts_with('{') || arg.trim_start().starts_with('[') {
778            arg.to_string()
779        } else {
780            serde_json::json!({ "input": arg }).to_string()
781        };
782        let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
783        assert_eq!(parsed["input"], "search for cats");
784    }
785
786    #[test]
787    fn array_body_passthrough() {
788        let arg = r#"[1, 2, 3]"#;
789        let body = if arg.trim_start().starts_with('{') || arg.trim_start().starts_with('[') {
790            arg.to_string()
791        } else {
792            serde_json::json!({ "input": arg }).to_string()
793        };
794        assert_eq!(body, "[1, 2, 3]");
795    }
796}