Skip to content
This repository was archived by the owner on Mar 7, 2026. It is now read-only.

Commit eb8f680

Browse files
Natfiiclaude
andcommitted
fix(ffi): share HTTP clients, add CAPTCHA detection, cap tool calls
- Share reqwest::Client across tool invocations (web_search, web_fetch, http_request) to reuse TLS sessions and connection pools instead of rebuilding per call - Detect DuckDuckGo CAPTCHA/anomaly pages and return clear error instead of silently returning "No results found" - Handle HTTP 403 from DuckDuckGo with explicit rate-limit message - Cap tool calls per model response to 5 (MAX_TOOL_CALLS_PER_RESPONSE) to prevent token waste from prompt-guided models emitting dozens of XML tool calls - Allow empty allowed_domains in web_fetch to permit all public hosts (still blocks private/local and blocked_domains) - Use config user_agent fields instead of hardcoded strings - Add tool output preview (first 200 chars) to debug logs - Remove screen-test and maestro-test from CI build gate - Fix detekt UseCheckOrError in TerminalViewModel Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 95da374 commit eb8f680

File tree

4 files changed

+144
-107
lines changed

4 files changed

+144
-107
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ jobs:
219219
build:
220220
name: Build
221221
runs-on: ubuntu-latest
222-
needs: [lint-rust, lint-kotlin, cargo-deny, screen-test, maestro-test]
222+
needs: [lint-rust, lint-kotlin, cargo-deny, test]
223223
if: ${{ !cancelled() && !contains(needs.*.result, 'failure') }}
224224
steps:
225225
- uses: actions/checkout@v4

app/src/main/java/com/zeroclaw/android/ui/screen/terminal/TerminalViewModel.kt

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -393,10 +393,8 @@ class TerminalViewModel(
393393

394394
try {
395395
withContext(Dispatchers.IO) {
396-
if (!ensureSession()) {
397-
throw IllegalStateException(
398-
"No active session — is the daemon running?",
399-
)
396+
check(ensureSession()) {
397+
"No active session — is the daemon running?"
400398
}
401399
sessionSend(
402400
message,

zeroclaw-android/zeroclaw-ffi/src/session.rs

Lines changed: 112 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ const MAX_MESSAGE_BYTES: usize = 1_048_576;
4848
/// Default maximum agentic tool-use iterations per user message.
4949
const 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.
5260
const DEFAULT_MAX_HISTORY_MESSAGES: usize = 50;
5361

@@ -256,8 +264,8 @@ impl Tool for FfiMemoryForgetTool {
256264
struct 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

427454
impl 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

647630
impl 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

Comments
 (0)