@@ -48,6 +48,14 @@ const MAX_MESSAGE_BYTES: usize = 1_048_576;
4848/// Default maximum agentic tool-use iterations per user message.
4949const DEFAULT_MAX_TOOL_ITERATIONS : usize = 10 ;
5050
51+ /// Maximum number of tool calls to execute from a single model response.
52+ ///
53+ /// Prompt-guided models (e.g. Codex) sometimes emit dozens of
54+ /// `<tool_call>` tags in one response. Executing all of them wastes
55+ /// tokens and fills the thinking card with noise. Excess calls are
56+ /// dropped with a warning.
57+ const MAX_TOOL_CALLS_PER_RESPONSE : usize = 5 ;
58+
5159/// Non-system message count threshold that triggers auto-compaction.
5260const DEFAULT_MAX_HISTORY_MESSAGES : usize = 50 ;
5361
@@ -256,8 +264,8 @@ impl Tool for FfiMemoryForgetTool {
256264struct FfiWebSearchTool {
257265 /// Maximum search results to return (1-10).
258266 max_results : usize ,
259- /// HTTP request timeout .
260- timeout : Duration ,
267+ /// Shared HTTP client (reuses TLS sessions and connection pools) .
268+ client : reqwest :: Client ,
261269}
262270
263271/// Decode DuckDuckGo redirect URL to extract the actual destination.
@@ -383,18 +391,37 @@ impl Tool for FfiWebSearchTool {
383391 let encoded = urlencoding:: encode ( query) ;
384392 let url = format ! ( "https://html.duckduckgo.com/html/?q={encoded}" ) ;
385393
386- let client = reqwest:: Client :: builder ( )
387- . timeout ( self . timeout )
388- . user_agent ( "ZeroClaw/1.0" )
389- . build ( ) ?;
390-
391- let resp = client. get ( & url) . send ( ) . await ?;
394+ let resp = self . client . get ( & url) . send ( ) . await ?;
392395
396+ if resp. status ( ) == reqwest:: StatusCode :: FORBIDDEN {
397+ return Ok ( ToolResult {
398+ success : false ,
399+ output : String :: new ( ) ,
400+ error : Some (
401+ "Rate limited by DuckDuckGo (HTTP 403). Wait a minute before searching again."
402+ . to_string ( ) ,
403+ ) ,
404+ } ) ;
405+ }
393406 if !resp. status ( ) . is_success ( ) {
394407 anyhow:: bail!( "DuckDuckGo search failed with status: {}" , resp. status( ) ) ;
395408 }
396409
397410 let html = resp. text ( ) . await ?;
411+
412+ // Detect DuckDuckGo CAPTCHA/anomaly page.
413+ if html. contains ( "anomaly-modal" ) || html. contains ( "Please try again" ) {
414+ return Ok ( ToolResult {
415+ success : false ,
416+ output : String :: new ( ) ,
417+ error : Some (
418+ "DuckDuckGo is showing a CAPTCHA — search is temporarily \
419+ unavailable. Try again later or reduce search frequency."
420+ . to_string ( ) ,
421+ ) ,
422+ } ) ;
423+ }
424+
398425 let output = self . parse_duckduckgo_results ( & html, query) ;
399426
400427 Ok ( ToolResult {
@@ -420,8 +447,8 @@ struct FfiWebFetchTool {
420447 blocked_domains : Vec < String > ,
421448 /// Maximum response body size in bytes before truncation.
422449 max_response_size : usize ,
423- /// HTTP request timeout in seconds (0 falls back to 30s ).
424- timeout_secs : u64 ,
450+ /// Shared HTTP client (reuses TLS sessions and connection pools ).
451+ client : reqwest :: Client ,
425452}
426453
427454impl FfiWebFetchTool {
@@ -466,45 +493,6 @@ impl FfiWebFetchTool {
466493 Ok ( String :: from_utf8_lossy ( & bytes) . into_owned ( ) )
467494 }
468495
469- /// Builds a [`reqwest::Client`] with redirect validation that
470- /// checks each redirect target against the domain lists.
471- fn build_client ( & self ) -> Result < reqwest:: Client , String > {
472- let timeout_secs = if self . timeout_secs == 0 {
473- tracing:: warn!( "web_fetch: timeout_secs is 0, using safe default of 30s" ) ;
474- 30
475- } else {
476- self . timeout_secs
477- } ;
478-
479- let allowed = self . allowed_domains . clone ( ) ;
480- let blocked = self . blocked_domains . clone ( ) ;
481- let redirect_policy = reqwest:: redirect:: Policy :: custom ( move |attempt| {
482- if attempt. previous ( ) . len ( ) >= 10 {
483- return attempt. error ( std:: io:: Error :: other ( "Too many redirects (max 10)" ) ) ;
484- }
485- if let Err ( err) = url_helpers:: validate_target_url (
486- attempt. url ( ) . as_str ( ) ,
487- & allowed,
488- & blocked,
489- "web_fetch" ,
490- ) {
491- return attempt. error ( std:: io:: Error :: new (
492- std:: io:: ErrorKind :: PermissionDenied ,
493- format ! ( "Blocked redirect target: {err}" ) ,
494- ) ) ;
495- }
496- attempt. follow ( )
497- } ) ;
498-
499- reqwest:: Client :: builder ( )
500- . timeout ( Duration :: from_secs ( timeout_secs) )
501- . connect_timeout ( Duration :: from_secs ( 10 ) )
502- . redirect ( redirect_policy)
503- . user_agent ( "ZeroClaw/0.1 (web_fetch)" )
504- . build ( )
505- . map_err ( |e| format ! ( "Failed to build HTTP client: {e}" ) )
506- }
507-
508496 /// Determines the processing strategy for the response based on
509497 /// its `Content-Type` header. Returns `"html"`, `"plain"`, or an
510498 /// error for unsupported types.
@@ -587,12 +575,7 @@ impl Tool for FfiWebFetchTool {
587575 Err ( e) => return Ok ( fail_result ( e) ) ,
588576 } ;
589577
590- let client = match self . build_client ( ) {
591- Ok ( c) => c,
592- Err ( e) => return Ok ( fail_result ( e) ) ,
593- } ;
594-
595- let response = match client. get ( & url) . send ( ) . await {
578+ let response = match self . client . get ( & url) . send ( ) . await {
596579 Ok ( r) => r,
597580 Err ( e) => return Ok ( fail_result ( format ! ( "HTTP request failed: {e}" ) ) ) ,
598581 } ;
@@ -640,8 +623,8 @@ struct FfiHttpRequestTool {
640623 allowed_domains : Vec < String > ,
641624 /// Maximum response body size in bytes before truncation (0 = unlimited).
642625 max_response_size : usize ,
643- /// HTTP request timeout in seconds (0 falls back to 30s ).
644- timeout_secs : u64 ,
626+ /// Shared HTTP client (reuses TLS sessions and connection pools ).
627+ client : reqwest :: Client ,
645628}
646629
647630impl FfiHttpRequestTool {
@@ -717,24 +700,6 @@ impl FfiHttpRequestTool {
717700 }
718701 }
719702
720- /// Builds a [`reqwest::Client`] with no redirect following and the
721- /// configured timeout.
722- fn build_client ( & self ) -> Result < reqwest:: Client , String > {
723- let timeout_secs = if self . timeout_secs == 0 {
724- tracing:: warn!( "http_request: timeout_secs is 0, using safe default of 30s" ) ;
725- 30
726- } else {
727- self . timeout_secs
728- } ;
729-
730- reqwest:: Client :: builder ( )
731- . timeout ( Duration :: from_secs ( timeout_secs) )
732- . connect_timeout ( Duration :: from_secs ( 10 ) )
733- . redirect ( reqwest:: redirect:: Policy :: none ( ) )
734- . build ( )
735- . map_err ( |e| format ! ( "Failed to build HTTP client: {e}" ) )
736- }
737-
738703 /// Formats a successful response into the canonical output string
739704 /// including status line, headers, and (possibly truncated) body.
740705 async fn format_response ( & self , response : reqwest:: Response ) -> ToolResult {
@@ -852,10 +817,7 @@ impl Tool for FfiHttpRequestTool {
852817 let redacted = Self :: redact_headers_for_display ( & request_headers) ;
853818 tracing:: debug!( url = %url, method = %method, headers = ?redacted, "http_request: dispatching" ) ;
854819
855- let client = match self . build_client ( ) {
856- Ok ( c) => c,
857- Err ( e) => return Ok ( fail_result ( e) ) ,
858- } ;
820+ let client = & self . client ;
859821
860822 let mut request = client. request ( method, & url) ;
861823 for ( key, value) in request_headers {
@@ -906,32 +868,79 @@ fn build_tools_registry(config: &zeroclaw::Config, memory: Arc<dyn Memory>) -> V
906868 ] ;
907869
908870 if config. web_search . enabled {
871+ let client = reqwest:: Client :: builder ( )
872+ . timeout ( Duration :: from_secs ( config. web_search . timeout_secs ) )
873+ . user_agent ( & config. web_search . user_agent )
874+ . build ( )
875+ . unwrap_or_default ( ) ;
909876 tools. push ( Box :: new ( FfiWebSearchTool {
910877 max_results : config. web_search . max_results ,
911- timeout : Duration :: from_secs ( config . web_search . timeout_secs ) ,
878+ client ,
912879 } ) ) ;
913880 }
914881
915882 if config. web_fetch . enabled {
883+ let fetch_allowed =
884+ url_helpers:: normalize_allowed_domains ( config. web_fetch . allowed_domains . clone ( ) ) ;
885+ let fetch_blocked =
886+ url_helpers:: normalize_allowed_domains ( config. web_fetch . blocked_domains . clone ( ) ) ;
887+ let timeout_secs = if config. web_fetch . timeout_secs == 0 {
888+ 30
889+ } else {
890+ config. web_fetch . timeout_secs
891+ } ;
892+ let allowed_for_redirect = fetch_allowed. clone ( ) ;
893+ let blocked_for_redirect = fetch_blocked. clone ( ) ;
894+ let redirect_policy = reqwest:: redirect:: Policy :: custom ( move |attempt| {
895+ if attempt. previous ( ) . len ( ) >= 10 {
896+ return attempt. error ( std:: io:: Error :: other ( "Too many redirects (max 10)" ) ) ;
897+ }
898+ if let Err ( err) = url_helpers:: validate_target_url (
899+ attempt. url ( ) . as_str ( ) ,
900+ & allowed_for_redirect,
901+ & blocked_for_redirect,
902+ "web_fetch" ,
903+ ) {
904+ return attempt. error ( std:: io:: Error :: new (
905+ std:: io:: ErrorKind :: PermissionDenied ,
906+ format ! ( "Blocked redirect target: {err}" ) ,
907+ ) ) ;
908+ }
909+ attempt. follow ( )
910+ } ) ;
911+ let client = reqwest:: Client :: builder ( )
912+ . timeout ( Duration :: from_secs ( timeout_secs) )
913+ . connect_timeout ( Duration :: from_secs ( 10 ) )
914+ . redirect ( redirect_policy)
915+ . user_agent ( & config. web_fetch . user_agent )
916+ . build ( )
917+ . unwrap_or_default ( ) ;
916918 tools. push ( Box :: new ( FfiWebFetchTool {
917- allowed_domains : url_helpers:: normalize_allowed_domains (
918- config. web_fetch . allowed_domains . clone ( ) ,
919- ) ,
920- blocked_domains : url_helpers:: normalize_allowed_domains (
921- config. web_fetch . blocked_domains . clone ( ) ,
922- ) ,
919+ allowed_domains : fetch_allowed,
920+ blocked_domains : fetch_blocked,
923921 max_response_size : config. web_fetch . max_response_size ,
924- timeout_secs : config . web_fetch . timeout_secs ,
922+ client ,
925923 } ) ) ;
926924 }
927925
928926 if config. http_request . enabled {
927+ let timeout_secs = if config. http_request . timeout_secs == 0 {
928+ 30
929+ } else {
930+ config. http_request . timeout_secs
931+ } ;
932+ let client = reqwest:: Client :: builder ( )
933+ . timeout ( Duration :: from_secs ( timeout_secs) )
934+ . connect_timeout ( Duration :: from_secs ( 10 ) )
935+ . redirect ( reqwest:: redirect:: Policy :: none ( ) )
936+ . build ( )
937+ . unwrap_or_default ( ) ;
929938 tools. push ( Box :: new ( FfiHttpRequestTool {
930939 allowed_domains : url_helpers:: normalize_allowed_domains (
931940 config. http_request . allowed_domains . clone ( ) ,
932941 ) ,
933942 max_response_size : config. http_request . max_response_size ,
934- timeout_secs : config . http_request . timeout_secs ,
943+ client ,
935944 } ) ) ;
936945 }
937946
@@ -1886,6 +1895,16 @@ async fn run_agent_loop(
18861895 listener. on_thinking ( inline_thinking) ;
18871896 }
18881897
1898+ // Cap excessive tool calls from a single response.
1899+ if response. tool_calls . len ( ) > MAX_TOOL_CALLS_PER_RESPONSE {
1900+ tracing:: warn!(
1901+ total = response. tool_calls. len( ) ,
1902+ limit = MAX_TOOL_CALLS_PER_RESPONSE ,
1903+ "agent_loop: capping tool calls per response"
1904+ ) ;
1905+ response. tool_calls . truncate ( MAX_TOOL_CALLS_PER_RESPONSE ) ;
1906+ }
1907+
18891908 let tool_call_count = response. tool_calls . len ( ) ;
18901909 listener. on_progress ( FfiProgressPhase :: GotToolCalls {
18911910 count : u32:: try_from ( tool_call_count) . unwrap_or ( u32:: MAX ) ,
@@ -1963,11 +1982,13 @@ async fn run_agent_loop(
19631982 } ;
19641983
19651984 let duration_secs = start_time. elapsed ( ) . as_secs ( ) ;
1985+ let output_preview: String = output. chars ( ) . take ( 200 ) . collect ( ) ;
19661986 tracing:: info!(
19671987 tool = %call. name,
19681988 success,
19691989 duration_secs,
19701990 output_len = output. len( ) ,
1991+ output_preview,
19711992 "agent_loop: tool done"
19721993 ) ;
19731994 listener. on_tool_result ( call. name . clone ( ) , success, duration_secs) ;
@@ -2283,10 +2304,7 @@ fn build_android_tool_specs(config: &zeroclaw::Config) -> Vec<ToolSpec> {
22832304/// The output includes:
22842305/// - Format instructions with concrete examples
22852306/// - A list of available tools with their parameter schemas
2286- fn build_tool_use_protocol (
2287- tools_registry : & [ Box < dyn Tool > ] ,
2288- config : & zeroclaw:: Config ,
2289- ) -> String {
2307+ fn build_tool_use_protocol ( tools_registry : & [ Box < dyn Tool > ] , config : & zeroclaw:: Config ) -> String {
22902308 use std:: fmt:: Write ;
22912309
22922310 let mut out = String :: with_capacity ( 2048 ) ;
@@ -3572,7 +3590,8 @@ mod tests {
35723590
35733591 #[ test]
35743592 fn test_parse_xml_single_tool_call ( ) {
3575- let input = r#"<tool_call>{"name": "web_search", "arguments": {"query": "rust lang"}}</tool_call>"# ;
3593+ let input =
3594+ r#"<tool_call>{"name": "web_search", "arguments": {"query": "rust lang"}}</tool_call>"# ;
35763595 let ( clean, calls) = parse_xml_tool_calls ( input) ;
35773596 assert_eq ! ( calls. len( ) , 1 ) ;
35783597 assert_eq ! ( calls[ 0 ] . name, "web_search" ) ;
0 commit comments