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 substrate: None,
342 capacity: None,
343 sandbox: None,
344 max_results: None,
345 output_schema: String::new(),
346 effect_row: Vec::new(),
347 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 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<ToolChunk>();
379
380 tokio::spawn(async move {
381 let send_terminator = |reason: ToolFinishReason| {
386 let _ = tx.send(ToolChunk::terminator("", reason));
387 };
388
389 if cancel.is_cancelled() {
391 send_terminator(ToolFinishReason::Cancelled);
392 return;
393 }
394
395 let client = shared_async_client();
399
400 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 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 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 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 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
489enum DrainOutcome {
492 Completed,
493 Cancelled,
494 Error(String),
495}
496
497async 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 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
551async 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
592async 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#[cfg(test)]
630mod tests {
631 use super::*;
632 use crate::tool_registry::{ToolEntry, ToolSource};
633
634 #[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 is_streaming: false,
683 scrape: None,
684 }
685 }
686
687 #[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 #[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 #[test]
745 fn dispatch_connection_refused() {
746 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 #[test]
762 fn json_body_passthrough() {
763 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 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}