1use std::time::Duration;
32
33use crate::tool_executor::ToolResult;
34use crate::tool_registry::ToolEntry;
35
36fn 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 s.parse::<u64>().ok().map(Duration::from_secs)
58 }
59}
60
61pub fn parse_timeout_pub(s: &str) -> Option<Duration> {
63 parse_timeout(s)
64}
65
66const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
67
68pub 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 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 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 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
123fn 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
137fn 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
145fn 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
197use 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
209pub struct HttpStreamingTool {
246 name: String,
247 url: String,
248 timeout: Duration,
249}
250
251impl HttpStreamingTool {
252 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 pub fn new(name: String, url: String, timeout: Duration) -> Self {
280 Self { name, url, timeout }
281 }
282}
283
284fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
298enum FramingMode {
299 Sse,
303 Ndjson,
306 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 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 capacity: None,
342 sandbox: None,
343 max_results: None,
344 output_schema: String::new(),
345 effect_row: Vec::new(),
346 parameters: Vec::new(),
349 secret: String::new(),
350 secret_partition: String::new(),
351 source: crate::tool_registry::ToolSource::Program,
352 is_streaming: false,
353 scrape: None,
354 };
355 match tokio::task::spawn_blocking(move || dispatch_http(&entry, &args)).await {
356 Ok(result) => result,
357 Err(e) => ToolResult {
358 success: false,
359 output: format!("HTTP tool '{}': blocking task join failed: {e}", self.name),
360 tool_name: self.name.clone(),
361 },
362 }
363 }
364
365 async fn stream(&self, args: String, ctx: ToolContext) -> ToolStream {
366 let url = self.url.clone();
367 let name = self.name.clone();
368 let timeout = self.timeout;
369 let cancel = ctx.cancel.clone();
370 let body = build_request_body(&args);
371
372 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<ToolChunk>();
378
379 tokio::spawn(async move {
380 let send_terminator = |reason: ToolFinishReason| {
385 let _ = tx.send(ToolChunk::terminator("", reason));
386 };
387
388 if cancel.is_cancelled() {
390 send_terminator(ToolFinishReason::Cancelled);
391 return;
392 }
393
394 let client = shared_async_client();
398
399 let response = match client
401 .post(&url)
402 .timeout(timeout)
403 .header("Content-Type", "application/json")
404 .header("X-Axon-Tool", &name)
405 .body(body)
406 .send()
407 .await
408 {
409 Ok(r) => r,
410 Err(e) => {
411 let message = if e.is_timeout() {
412 format!(
413 "HTTP tool '{name}': request timed out after {}s",
414 timeout.as_secs()
415 )
416 } else if e.is_connect() {
417 format!("HTTP tool '{name}': connection failed to {url}")
418 } else {
419 format!("HTTP tool '{name}': request failed: {e}")
420 };
421 send_terminator(ToolFinishReason::Error { message });
422 return;
423 }
424 };
425
426 let status = response.status();
430 if !status.is_success() {
431 let body_text = response.text().await.unwrap_or_default();
432 let truncated = if body_text.len() > 200 {
433 format!("{}...", &body_text[..200])
434 } else {
435 body_text
436 };
437 send_terminator(ToolFinishReason::Error {
438 message: format!("HTTP {}: {}", status.as_u16(), truncated),
439 });
440 return;
441 }
442
443 let content_type = response
445 .headers()
446 .get(reqwest::header::CONTENT_TYPE)
447 .and_then(|v| v.to_str().ok())
448 .unwrap_or("")
449 .to_string();
450 let framing = classify_framing(&content_type);
451
452 let mut byte_stream = response.bytes_stream();
454 let drain_result = match framing {
455 FramingMode::Sse => {
456 drain_sse(&mut byte_stream, &cancel, &tx).await
457 }
458 FramingMode::Ndjson => {
459 drain_ndjson(&mut byte_stream, &cancel, &tx).await
460 }
461 FramingMode::Single => {
462 drain_single(&mut byte_stream, &cancel, &tx).await
463 }
464 };
465
466 match drain_result {
467 DrainOutcome::Completed => send_terminator(ToolFinishReason::Stop),
468 DrainOutcome::Cancelled => send_terminator(ToolFinishReason::Cancelled),
469 DrainOutcome::Error(message) => {
470 send_terminator(ToolFinishReason::Error { message })
471 }
472 }
473 });
474
475 Box::pin(futures::stream::unfold(rx, |mut rx| async move {
479 rx.recv().await.map(|chunk| (chunk, rx))
480 }))
481 }
482
483 fn is_streaming(&self) -> bool {
484 true
485 }
486}
487
488enum DrainOutcome {
491 Completed,
492 Cancelled,
493 Error(String),
494}
495
496async fn drain_sse<S>(
502 byte_stream: &mut S,
503 cancel: &crate::cancel_token::CancellationFlag,
504 tx: &tokio::sync::mpsc::UnboundedSender<ToolChunk>,
505) -> DrainOutcome
506where
507 S: futures::Stream<Item = reqwest::Result<Bytes>> + Unpin + Send,
508{
509 let mut line_buf = LineBuffer::new();
510 let mut sse_parser = SseEventParser::new();
511 loop {
512 if cancel.is_cancelled() {
513 return DrainOutcome::Cancelled;
514 }
515 match byte_stream.next().await {
516 None => break,
517 Some(Err(e)) => {
518 return DrainOutcome::Error(format!("SSE stream chunk error: {e}"))
519 }
520 Some(Ok(bytes)) => {
521 let lines = line_buf.push(&bytes);
522 for line in lines {
523 if let Some(event) = sse_parser.push_line(&line) {
524 if let Some(data) = event.data {
525 if tx
526 .send(ToolChunk::intermediate(data))
527 .is_err()
528 {
529 return DrainOutcome::Cancelled;
530 }
531 }
532 }
533 }
534 }
535 }
536 }
537 if let Some(line) = line_buf.flush() {
541 if let Some(event) = sse_parser.push_line(&line) {
542 if let Some(data) = event.data {
543 let _ = tx.send(ToolChunk::intermediate(data));
544 }
545 }
546 }
547 DrainOutcome::Completed
548}
549
550async fn drain_ndjson<S>(
554 byte_stream: &mut S,
555 cancel: &crate::cancel_token::CancellationFlag,
556 tx: &tokio::sync::mpsc::UnboundedSender<ToolChunk>,
557) -> DrainOutcome
558where
559 S: futures::Stream<Item = reqwest::Result<Bytes>> + Unpin + Send,
560{
561 let mut line_buf = LineBuffer::new();
562 loop {
563 if cancel.is_cancelled() {
564 return DrainOutcome::Cancelled;
565 }
566 match byte_stream.next().await {
567 None => break,
568 Some(Err(e)) => {
569 return DrainOutcome::Error(format!("NDJSON stream chunk error: {e}"))
570 }
571 Some(Ok(bytes)) => {
572 let lines = line_buf.push(&bytes);
573 for line in lines {
574 if !line.is_empty()
575 && tx.send(ToolChunk::intermediate(line)).is_err()
576 {
577 return DrainOutcome::Cancelled;
578 }
579 }
580 }
581 }
582 }
583 if let Some(line) = line_buf.flush() {
584 if !line.is_empty() {
585 let _ = tx.send(ToolChunk::intermediate(line));
586 }
587 }
588 DrainOutcome::Completed
589}
590
591async fn drain_single<S>(
595 byte_stream: &mut S,
596 cancel: &crate::cancel_token::CancellationFlag,
597 tx: &tokio::sync::mpsc::UnboundedSender<ToolChunk>,
598) -> DrainOutcome
599where
600 S: futures::Stream<Item = reqwest::Result<Bytes>> + Unpin + Send,
601{
602 let mut acc: Vec<u8> = Vec::new();
603 loop {
604 if cancel.is_cancelled() {
605 return DrainOutcome::Cancelled;
606 }
607 match byte_stream.next().await {
608 None => break,
609 Some(Err(e)) => {
610 return DrainOutcome::Error(format!("HTTP body chunk error: {e}"))
611 }
612 Some(Ok(bytes)) => acc.extend_from_slice(&bytes),
613 }
614 }
615 let body_text = String::from_utf8_lossy(&acc).into_owned();
616 if !body_text.is_empty()
617 && tx
618 .send(ToolChunk::intermediate(body_text))
619 .is_err()
620 {
621 return DrainOutcome::Cancelled;
622 }
623 DrainOutcome::Completed
624}
625
626#[cfg(test)]
629mod tests {
630 use super::*;
631 use crate::tool_registry::{ToolEntry, ToolSource};
632
633 #[test]
641 fn the_blocking_http_client_is_a_reused_singleton() {
642 let a = shared_blocking_client();
643 let b = shared_blocking_client();
644 assert!(
645 std::ptr::eq(a, b),
646 "every call must reuse ONE blocking client (its connection pool) — not build a fresh \
647 one per request"
648 );
649 }
650
651 #[test]
652 fn the_async_http_client_is_a_reused_singleton() {
653 let a = shared_async_client();
654 let b = shared_async_client();
655 assert!(
656 std::ptr::eq(a, b),
657 "every call must reuse ONE async client (its connection pool)"
658 );
659 }
660
661 fn make_http_entry(name: &str, url: &str, timeout: &str) -> ToolEntry {
662 ToolEntry {
663 name: name.to_string(),
664 provider: "http".to_string(),
665 timeout: timeout.to_string(),
666 runtime: url.to_string(),
667 resource_ref: String::new(),
668 capacity: None,
669 sandbox: None,
670 max_results: None,
671 output_schema: "JSON".to_string(),
672 effect_row: vec!["network".to_string()],
673 parameters: Vec::new(),
674 secret: String::new(),
675 secret_partition: String::new(),
676 source: ToolSource::Program,
677 is_streaming: false,
681 scrape: None,
682 }
683 }
684
685 #[test]
688 fn parse_timeout_seconds() {
689 assert_eq!(parse_timeout("10s"), Some(Duration::from_secs(10)));
690 assert_eq!(parse_timeout("30s"), Some(Duration::from_secs(30)));
691 }
692
693 #[test]
694 fn parse_timeout_milliseconds() {
695 assert_eq!(parse_timeout("500ms"), Some(Duration::from_millis(500)));
696 assert_eq!(parse_timeout("100ms"), Some(Duration::from_millis(100)));
697 }
698
699 #[test]
700 fn parse_timeout_minutes() {
701 assert_eq!(parse_timeout("2m"), Some(Duration::from_secs(120)));
702 }
703
704 #[test]
705 fn parse_timeout_raw_number() {
706 assert_eq!(parse_timeout("15"), Some(Duration::from_secs(15)));
707 }
708
709 #[test]
710 fn parse_timeout_empty() {
711 assert_eq!(parse_timeout(""), None);
712 assert_eq!(parse_timeout(" "), None);
713 }
714
715 #[test]
716 fn parse_timeout_invalid() {
717 assert_eq!(parse_timeout("abc"), None);
718 assert_eq!(parse_timeout("10x"), None);
719 }
720
721 #[test]
724 fn dispatch_empty_url_fails() {
725 let entry = make_http_entry("DataAPI", "", "10s");
726 let result = dispatch_http(&entry, "test query");
727 assert!(!result.success);
728 assert!(result.output.contains("no endpoint URL"));
729 }
730
731 #[test]
732 fn dispatch_invalid_url_scheme_fails() {
733 let entry = make_http_entry("DataAPI", "ftp://example.com", "10s");
734 let result = dispatch_http(&entry, "test query");
735 assert!(!result.success);
736 assert!(result.output.contains("invalid URL"));
737 assert!(result.output.contains("http://"));
738 }
739
740 #[test]
743 fn dispatch_connection_refused() {
744 let entry = make_http_entry("TestTool", "http://127.0.0.1:1/api", "2s");
746 let result = dispatch_http(&entry, "test");
747 assert!(!result.success);
748 assert!(
749 result.output.contains("connection failed")
750 || result.output.contains("request failed")
751 || result.output.contains("timed out"),
752 "unexpected error: {}",
753 result.output
754 );
755 }
756
757 #[test]
760 fn json_body_passthrough() {
761 let arg = r#"{"query": "test"}"#;
763 let body = if arg.trim_start().starts_with('{') {
764 arg.to_string()
765 } else {
766 serde_json::json!({ "input": arg }).to_string()
767 };
768 assert_eq!(body, r#"{"query": "test"}"#);
769 }
770
771 #[test]
772 fn plain_text_wrapped() {
773 let arg = "search for cats";
775 let body = if arg.trim_start().starts_with('{') || arg.trim_start().starts_with('[') {
776 arg.to_string()
777 } else {
778 serde_json::json!({ "input": arg }).to_string()
779 };
780 let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
781 assert_eq!(parsed["input"], "search for cats");
782 }
783
784 #[test]
785 fn array_body_passthrough() {
786 let arg = r#"[1, 2, 3]"#;
787 let body = if arg.trim_start().starts_with('{') || arg.trim_start().starts_with('[') {
788 arg.to_string()
789 } else {
790 serde_json::json!({ "input": arg }).to_string()
791 };
792 assert_eq!(body, "[1, 2, 3]");
793 }
794}