Skip to content

Commit 6e1048e

Browse files
authored
Merge pull request #135 from TransformerOptimus/feat/bench-runner-llm-policy
Bench-runner LLM transport fix: HTTP/1.1 + no pool + 15s header timeout (LlmPolicy)
2 parents 908afb7 + 92b61b4 commit 6e1048e

9 files changed

Lines changed: 171 additions & 24 deletions

File tree

apps/desktop/src-tauri/src/agent_bridge/commands.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,7 @@ pub fn provider_to_llm_config(p: &ProviderConfig, model: &str) -> LlmClientConfi
592592
extra_headers: vec![],
593593
thinking: None,
594594
disable_cache_control: false,
595+
policy: Default::default(),
595596
}
596597
}
597598

crates/agent/src/agent/loop_.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1555,6 +1555,7 @@ mod tests {
15551555
extra_headers: vec![],
15561556
thinking: None,
15571557
disable_cache_control: false,
1558+
policy: Default::default(),
15581559
},
15591560
std::path::PathBuf::from("/tmp"),
15601561
);
@@ -1950,6 +1951,52 @@ mod tests {
19501951
assert_eq!(result, "ok");
19511952
}
19521953

1954+
#[tokio::test]
1955+
async fn test_retry_on_header_timeout() {
1956+
// HeaderTimeout is the new non-API error class added for bench-runner's
1957+
// 15s header-arrival timeout. Like ChunkTimeout it must take the
1958+
// retryable fall-through arm — otherwise a stalled-headers re-send never
1959+
// fires and the run dies on the first attempt.
1960+
let mock = MockLlm::new(vec![
1961+
Err(AgentError::HeaderTimeout(15_000)),
1962+
Ok(text_response("recovered")),
1963+
]);
1964+
let (mut agent, _rx) = make_agent(mock, ToolRegistry::new(), None);
1965+
agent.config.retry_config.max_retries = 3;
1966+
1967+
let result = agent.run(ChatMessage::user("test")).await.unwrap().unwrap_done();
1968+
assert_eq!(result, "recovered", "HeaderTimeout must be retryable");
1969+
}
1970+
1971+
#[test]
1972+
fn test_llm_policy_values() {
1973+
let app = crate::llm::LlmPolicy::default();
1974+
assert!(!app.http1_only);
1975+
assert!(!app.no_pool);
1976+
assert_eq!(app.header_timeout_ms, None);
1977+
1978+
let bench = crate::llm::LlmPolicy::bench();
1979+
assert!(bench.http1_only);
1980+
assert!(bench.no_pool);
1981+
assert_eq!(bench.header_timeout_ms, Some(15_000));
1982+
}
1983+
1984+
#[tokio::test]
1985+
async fn test_retry_on_chunk_timeout_nonapi_error() {
1986+
// ChunkTimeout takes the SAME fall-through arm in call_llm_with_retry as
1987+
// HttpError (mid-stream decode). If this retries, the non-API error path
1988+
// re-sends; if it doesn't, the decode-bypass bug is in the classification.
1989+
let mock = MockLlm::new(vec![
1990+
Err(AgentError::ChunkTimeout(60)),
1991+
Ok(text_response("recovered")),
1992+
]);
1993+
let (mut agent, _rx) = make_agent(mock, ToolRegistry::new(), None);
1994+
agent.config.retry_config.max_retries = 3;
1995+
1996+
let result = agent.run(ChatMessage::user("test")).await.unwrap().unwrap_done();
1997+
assert_eq!(result, "recovered", "non-API error (ChunkTimeout) must re-send");
1998+
}
1999+
19532000
#[tokio::test]
19542001
async fn test_no_retry_on_401() {
19552002
let mock = MockLlm::new(vec![Err(AgentError::LlmApiError {
@@ -2297,6 +2344,7 @@ mod tests {
22972344
extra_headers: vec![],
22982345
thinking: None,
22992346
disable_cache_control: false,
2347+
policy: Default::default(),
23002348
},
23012349
std::path::PathBuf::from("/tmp"),
23022350
);
@@ -2362,6 +2410,7 @@ mod tests {
23622410
extra_headers: vec![],
23632411
thinking: None,
23642412
disable_cache_control: false,
2413+
policy: Default::default(),
23652414
},
23662415
std::path::PathBuf::from("/tmp"),
23672416
);
@@ -2427,6 +2476,7 @@ mod tests {
24272476
extra_headers: vec![],
24282477
thinking: None,
24292478
disable_cache_control: false,
2479+
policy: Default::default(),
24302480
},
24312481
std::path::PathBuf::from("/tmp"),
24322482
);
@@ -2521,6 +2571,7 @@ mod tests {
25212571
extra_headers: vec![],
25222572
thinking: None,
25232573
disable_cache_control: false,
2574+
policy: Default::default(),
25242575
},
25252576
std::path::PathBuf::from("/tmp"),
25262577
);
@@ -2617,6 +2668,7 @@ mod tests {
26172668
extra_headers: vec![],
26182669
thinking: None,
26192670
disable_cache_control: false,
2671+
policy: Default::default(),
26202672
},
26212673
std::path::PathBuf::from("/tmp"),
26222674
);
@@ -2699,6 +2751,7 @@ mod tests {
26992751
extra_headers: vec![],
27002752
thinking: None,
27012753
disable_cache_control: false,
2754+
policy: Default::default(),
27022755
},
27032756
std::path::PathBuf::from("/tmp"),
27042757
);
@@ -2773,6 +2826,7 @@ mod tests {
27732826
extra_headers: vec![],
27742827
thinking: None,
27752828
disable_cache_control: false,
2829+
policy: Default::default(),
27762830
},
27772831
work_dir.path().to_path_buf(),
27782832
);
@@ -3022,6 +3076,7 @@ mod tests {
30223076
extra_headers: vec![],
30233077
thinking: None,
30243078
disable_cache_control: false,
3079+
policy: Default::default(),
30253080
},
30263081
tmp.path().to_path_buf(),
30273082
);
@@ -3089,6 +3144,7 @@ mod tests {
30893144
extra_headers: vec![],
30903145
thinking: None,
30913146
disable_cache_control: false,
3147+
policy: Default::default(),
30923148
},
30933149
tmp.path().to_path_buf(),
30943150
);
@@ -3164,6 +3220,7 @@ mod tests {
31643220
extra_headers: vec![],
31653221
thinking: None,
31663222
disable_cache_control: false,
3223+
policy: Default::default(),
31673224
},
31683225
tmp.path().to_path_buf(),
31693226
);
@@ -3230,6 +3287,7 @@ mod tests {
32303287
extra_headers: vec![],
32313288
thinking: None,
32323289
disable_cache_control: false,
3290+
policy: Default::default(),
32333291
},
32343292
tmp.path().to_path_buf(),
32353293
);
@@ -3298,6 +3356,7 @@ mod tests {
32983356
extra_headers: vec![],
32993357
thinking: None,
33003358
disable_cache_control: false,
3359+
policy: Default::default(),
33013360
},
33023361
tmp.path().to_path_buf(),
33033362
);
@@ -3372,6 +3431,7 @@ mod tests {
33723431
extra_headers: vec![],
33733432
thinking: None,
33743433
disable_cache_control: false,
3434+
policy: Default::default(),
33753435
},
33763436
tmp.path().to_path_buf(),
33773437
);
@@ -3590,6 +3650,7 @@ mod tests {
35903650
extra_headers: vec![],
35913651
thinking: None,
35923652
disable_cache_control: false,
3653+
policy: Default::default(),
35933654
},
35943655
tmp.path().to_path_buf(),
35953656
);
@@ -3675,6 +3736,7 @@ mod tests {
36753736
extra_headers: vec![],
36763737
thinking: None,
36773738
disable_cache_control: false,
3739+
policy: Default::default(),
36783740
},
36793741
tmp.path().to_path_buf(),
36803742
);
@@ -3730,6 +3792,7 @@ mod tests {
37303792
extra_headers: vec![],
37313793
thinking: None,
37323794
disable_cache_control: false,
3795+
policy: Default::default(),
37333796
},
37343797
tmp.path().to_path_buf(),
37353798
);
@@ -3792,6 +3855,7 @@ mod tests {
37923855
extra_headers: vec![],
37933856
thinking: None,
37943857
disable_cache_control: false,
3858+
policy: Default::default(),
37953859
},
37963860
std::path::PathBuf::from("/tmp"),
37973861
);

crates/agent/src/error.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ pub enum AgentError {
1212
#[error("SSE chunk timeout: no data received for {0}s")]
1313
ChunkTimeout(u64),
1414

15+
#[error("LLM header timeout: response headers did not arrive within {0}ms")]
16+
HeaderTimeout(u64),
17+
1518
#[error("HTTP error: {0}")]
1619
HttpError(#[from] reqwest::Error),
1720

crates/agent/src/llm/anthropic.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,7 @@ mod tests {
581581
extra_headers: vec![],
582582
thinking: None,
583583
disable_cache_control: false,
584+
policy: Default::default(),
584585
}
585586
}
586587

crates/agent/src/llm/client.rs

Lines changed: 87 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,49 @@ static SHARED_HTTP_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
5050
.expect("Failed to create HTTP client")
5151
});
5252

53+
/// Per-binary behavior knobs for the LLM HTTP client. The default is **permissive**
54+
/// (byte-identical to the desktop app's historical behavior — pooled HTTP/2 via the
55+
/// shared client); `LlmPolicy::bench()` is the strict variant the headless
56+
/// `bench-runner` opts into so the eval harness matches opencode's working transport
57+
/// shape (HTTP/1.1, fresh connection per request, 15s header-arrival timeout). Gating
58+
/// these behind a policy keeps the shipped app client unchanged.
59+
#[derive(Debug, Clone)]
60+
pub struct LlmPolicy {
61+
/// Pin the LLM client to HTTP/1.1. `false` = let reqwest negotiate (defaults to h2
62+
/// when ALPN offers it).
63+
pub http1_only: bool,
64+
/// Disable connection pooling — every LLM request opens a fresh TCP+TLS connection.
65+
/// `false` = use the shared pooled client.
66+
pub no_pool: bool,
67+
/// Abort the request if response headers do not arrive within this many ms. `None`
68+
/// = no header-arrival timeout (only the per-chunk idle timeout applies once the
69+
/// body starts).
70+
pub header_timeout_ms: Option<u64>,
71+
}
72+
73+
impl Default for LlmPolicy {
74+
/// Permissive — preserves the historical desktop-app behavior exactly.
75+
fn default() -> Self {
76+
Self {
77+
http1_only: false,
78+
no_pool: false,
79+
header_timeout_ms: None,
80+
}
81+
}
82+
}
83+
84+
impl LlmPolicy {
85+
/// Strict policy for the eval harness: matches opencode's working transport shape
86+
/// to neutralize router-side mid-stream resets on long Fireworks/Kimi runs.
87+
pub fn bench() -> Self {
88+
Self {
89+
http1_only: true,
90+
no_pool: true,
91+
header_timeout_ms: Some(15_000),
92+
}
93+
}
94+
}
95+
5396
/// Configuration for the LLM HTTP client.
5497
#[derive(Debug, Clone)]
5598
pub struct LlmClientConfig {
@@ -75,6 +118,9 @@ pub struct LlmClientConfig {
75118
pub thinking: Option<serde_json::Value>,
76119
/// Skip cache_control / prompt_cache_key — set for one-shot calls like compaction where writes never read back.
77120
pub disable_cache_control: bool,
121+
/// Per-binary LLM transport behavior. Default = pooled HTTP/2 (app); `bench()` =
122+
/// HTTP/1.1, no pool, 15s header timeout (matches opencode).
123+
pub policy: LlmPolicy,
78124
}
79125

80126
/// HTTP client that speaks either OpenAI chat-completions or the Anthropic
@@ -86,10 +132,24 @@ pub struct LlmClient {
86132

87133
impl LlmClient {
88134
pub fn new(config: LlmClientConfig) -> Self {
89-
Self {
90-
config,
91-
http: SHARED_HTTP_CLIENT.clone(),
92-
}
135+
// Build a dedicated client when the policy deviates from default; otherwise
136+
// share the pooled process-wide client. Bench-runner takes this path to match
137+
// opencode's transport shape (h1.1, no pool) which has zero decode deaths on
138+
// the same router that gave us 26.
139+
let http = if config.policy.http1_only || config.policy.no_pool {
140+
let mut b = reqwest::Client::builder()
141+
.connect_timeout(std::time::Duration::from_secs(30));
142+
if config.policy.http1_only {
143+
b = b.http1_only();
144+
}
145+
if config.policy.no_pool {
146+
b = b.pool_max_idle_per_host(0);
147+
}
148+
b.build().expect("Failed to build dedicated LLM HTTP client")
149+
} else {
150+
SHARED_HTTP_CLIENT.clone()
151+
};
152+
Self { config, http }
93153
}
94154

95155
/// Send a streaming chat completion request and return the assembled response.
@@ -216,18 +276,12 @@ impl LlmClient {
216276
}
217277
}
218278

219-
let response = match self
220-
.http
221-
.post(&url)
222-
.headers(headers)
223-
.json(&request_body)
224-
.send()
225-
.await
226-
{
279+
let send_future = self.http.post(&url).headers(headers).json(&request_body).send();
280+
let response = match send_with_header_timeout(send_future, self.config.policy.header_timeout_ms).await {
227281
Ok(resp) => resp,
228282
Err(e) => {
229283
log::error!("[LLM] HTTP request failed: {e}");
230-
return Err(e.into());
284+
return Err(e);
231285
}
232286
};
233287

@@ -287,18 +341,12 @@ impl LlmClient {
287341
}
288342
}
289343

290-
let response = match self
291-
.http
292-
.post(&url)
293-
.headers(headers)
294-
.json(&request_body)
295-
.send()
296-
.await
297-
{
344+
let send_future = self.http.post(&url).headers(headers).json(&request_body).send();
345+
let response = match send_with_header_timeout(send_future, self.config.policy.header_timeout_ms).await {
298346
Ok(resp) => resp,
299347
Err(e) => {
300348
log::error!("[LLM] HTTP request failed: {e}");
301-
return Err(e.into());
349+
return Err(e);
302350
}
303351
};
304352

@@ -318,6 +366,23 @@ impl LlmClient {
318366
}
319367
}
320368

369+
/// Race the `.send()` future against an optional header-arrival timeout. Bounds the
370+
/// request-issue + header-receive phase only; once headers arrive the body stream is
371+
/// returned and bounded separately by `CHUNK_IDLE_TIMEOUT` in the SSE reader. A
372+
/// `HeaderTimeout` is retryable by `call_llm_with_retry` (same arm as `HttpError`).
373+
async fn send_with_header_timeout<F>(fut: F, timeout_ms: Option<u64>) -> Result<reqwest::Response, AgentError>
374+
where
375+
F: std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
376+
{
377+
match timeout_ms {
378+
Some(ms) => match tokio::time::timeout(std::time::Duration::from_millis(ms), fut).await {
379+
Ok(res) => res.map_err(AgentError::HttpError),
380+
Err(_) => Err(AgentError::HeaderTimeout(ms)),
381+
},
382+
None => fut.await.map_err(AgentError::HttpError),
383+
}
384+
}
385+
321386
/// Strip orphaned tool_result messages whose originating tool_use is no longer
322387
/// present (e.g. lost to compaction). Shared by both provider paths.
323388
fn sanitize_orphaned_tool_results(messages: &[ChatMessage]) -> Vec<ChatMessage> {

crates/agent/src/llm/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@ pub mod sse;
33
pub mod client;
44
pub mod anthropic;
55

6-
pub use client::{LlmClient, LlmClientConfig, LlmProvider};
6+
pub use client::{LlmClient, LlmClientConfig, LlmPolicy, LlmProvider};
77
pub use types::{ChatMessage, ToolDefinition, ToolCall, LlmResponse, Usage, MessageContent, ContentBlock, ImageUrlContent, Provider};

crates/agent/src/session.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,7 @@ mod tests {
425425
extra_headers: vec![],
426426
thinking: None,
427427
disable_cache_control: false,
428+
policy: Default::default(),
428429
},
429430
PathBuf::from("/tmp"),
430431
);

0 commit comments

Comments
 (0)