Skip to content

Commit 429a128

Browse files
committed
Fix agent lifecycle timeouts and retries
1 parent 67df6cb commit 429a128

22 files changed

Lines changed: 441 additions & 29 deletions

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ environment; commit templates, not local secrets.
192192
default_model = "provider/model-id"
193193
max_parallel_tasks = 4
194194
auto_parallel = false
195+
llm_api_timeout_ms = 120000
195196
196197
providers "provider" {
197198
apiKey = env("PROVIDER_API_KEY")
@@ -220,6 +221,9 @@ auto_delegation {
220221
}
221222
```
222223

224+
`llm_api_timeout_ms` applies only to model provider HTTP calls. Tool execution
225+
timeouts are configured separately through `SessionOptions::with_tool_timeout`.
226+
223227
`storage_backend = "file"` is only useful for local session persistence when it
224228
has a `sessions_dir`; otherwise pass a typed `FileSessionStore` from the SDK.
225229

core/src/agent.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@ pub(crate) struct AgentConfig {
110110
/// A timeout produces an error result sent back to the LLM rather than
111111
/// crashing the session.
112112
pub tool_timeout_ms: Option<u64>,
113+
/// Per-model API HTTP timeout in milliseconds (`None` = no timeout).
114+
///
115+
/// This is intentionally separate from `tool_timeout_ms`: slow shell/web
116+
/// tools and slow model providers have different operational envelopes.
117+
pub llm_api_timeout_ms: Option<u64>,
113118
/// Maximum number of sibling branches/tools to run concurrently in bounded
114119
/// parallel fan-out paths.
115120
pub max_parallel_tasks: usize,
@@ -193,6 +198,7 @@ impl std::fmt::Debug for AgentConfig {
193198
)
194199
.field("max_parse_retries", &self.max_parse_retries)
195200
.field("tool_timeout_ms", &self.tool_timeout_ms)
201+
.field("llm_api_timeout_ms", &self.llm_api_timeout_ms)
196202
.field("max_parallel_tasks", &self.max_parallel_tasks)
197203
.field("auto_delegation", &self.auto_delegation)
198204
.field(
@@ -234,6 +240,7 @@ impl Default for AgentConfig {
234240
enforce_active_skill_tool_restrictions: false,
235241
max_parse_retries: 2,
236242
tool_timeout_ms: None,
243+
llm_api_timeout_ms: None,
237244
max_parallel_tasks: DEFAULT_MAX_PARALLEL_TASKS,
238245
auto_delegation: crate::config::AutoDelegationConfig::default(),
239246
agent_registry: None,

core/src/agent/completion_runtime.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ use crate::verification::VerificationSummary;
66
use futures::future::join_all;
77
use tokio::sync::mpsc;
88

9+
const REASONING_ONLY_REPAIR: &str = "\
10+
Your previous assistant message contained reasoning/thinking content but no \
11+
normal reply content and no tool call. Continue from that state now. If tool \
12+
work is still required, call the needed tool. Otherwise provide the final answer \
13+
in normal assistant content only; do not put the final answer in reasoning.";
14+
15+
const REASONING_ONLY_FALLBACK: &str = "\
16+
The model completed but returned only reasoning content and did not provide a \
17+
final answer.";
18+
919
pub(super) enum CompletionFlow {
1020
Continue,
1121
Finished(String),
@@ -25,6 +35,18 @@ impl AgentLoop {
2535
) -> CompletionFlow {
2636
let candidate_text = response.text();
2737

38+
if self.inject_reasoning_only_repair_if_needed(state, turn, response) {
39+
return CompletionFlow::Continue;
40+
}
41+
42+
let candidate_text = if candidate_text.trim().is_empty()
43+
&& Self::is_terminal_reasoning_only_response(response)
44+
{
45+
REASONING_ONLY_FALLBACK.to_string()
46+
} else {
47+
candidate_text
48+
};
49+
2850
if self.inject_continuation_if_needed(state, turn, &candidate_text) {
2951
return CompletionFlow::Continue;
3052
}
@@ -42,6 +64,57 @@ impl AgentLoop {
4264
CompletionFlow::Finished(final_text)
4365
}
4466

67+
fn inject_reasoning_only_repair_if_needed(
68+
&self,
69+
state: &mut ExecutionLoopState,
70+
turn: usize,
71+
response: &LlmResponse,
72+
) -> bool {
73+
if !Self::is_terminal_reasoning_only_response(response) {
74+
return false;
75+
}
76+
77+
if !state.should_inject_reasoning_only_repair(
78+
self.config.continuation_enabled,
79+
self.config.max_tool_rounds,
80+
) {
81+
return false;
82+
}
83+
84+
tracing::info!(
85+
turn = turn,
86+
"Injecting reasoning-only repair message - response had no content"
87+
);
88+
state.messages.push(Message::user(REASONING_ONLY_REPAIR));
89+
true
90+
}
91+
92+
fn is_terminal_reasoning_only_response(response: &LlmResponse) -> bool {
93+
if !response.text().trim().is_empty() {
94+
return false;
95+
}
96+
if response
97+
.message
98+
.reasoning_content
99+
.as_deref()
100+
.map(str::trim)
101+
.unwrap_or_default()
102+
.is_empty()
103+
{
104+
return false;
105+
}
106+
107+
let reason = response
108+
.stop_reason
109+
.as_deref()
110+
.unwrap_or_default()
111+
.to_ascii_lowercase();
112+
!(reason.contains("length")
113+
|| reason.contains("max_tokens")
114+
|| reason.contains("tool")
115+
|| reason.contains("filter"))
116+
}
117+
45118
fn inject_continuation_if_needed(
46119
&self,
47120
state: &mut ExecutionLoopState,

core/src/agent/execution_state.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pub(super) struct ExecutionLoopState {
1414
turn: usize,
1515
parse_error_count: u32,
1616
continuation_count: u32,
17+
reasoning_only_repair_count: u32,
1718
recent_tool_signatures: Vec<String>,
1819
execution_start: Instant,
1920
}
@@ -58,6 +59,7 @@ impl ExecutionLoopState {
5859
turn: 0,
5960
parse_error_count: 0,
6061
continuation_count: 0,
62+
reasoning_only_repair_count: 0,
6163
recent_tool_signatures: Vec::new(),
6264
execution_start: Instant::now(),
6365
}
@@ -201,6 +203,19 @@ impl ExecutionLoopState {
201203
false
202204
}
203205

206+
pub(super) fn should_inject_reasoning_only_repair(
207+
&mut self,
208+
enabled: bool,
209+
max_tool_rounds: usize,
210+
) -> bool {
211+
if enabled && self.reasoning_only_repair_count == 0 && self.turn < max_tool_rounds {
212+
self.reasoning_only_repair_count += 1;
213+
return true;
214+
}
215+
216+
false
217+
}
218+
204219
pub(super) fn finish(self, text: String) -> AgentResult {
205220
AgentResult {
206221
text,

core/src/agent/extra_agent_tests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1692,6 +1692,7 @@ fn test_agent_config_resilience_defaults() {
16921692
let config = AgentConfig::default();
16931693
assert_eq!(config.max_parse_retries, 2);
16941694
assert_eq!(config.tool_timeout_ms, None);
1695+
assert_eq!(config.llm_api_timeout_ms, None);
16951696
assert_eq!(config.circuit_breaker_threshold, 3);
16961697
}
16971698

core/src/agent/tests.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,25 @@ impl MockLlmClient {
267267
}
268268
}
269269

270+
pub(crate) fn reasoning_only_response(reasoning: &str) -> LlmResponse {
271+
LlmResponse {
272+
message: Message {
273+
role: "assistant".to_string(),
274+
content: Vec::new(),
275+
reasoning_content: Some(reasoning.to_string()),
276+
},
277+
usage: TokenUsage {
278+
prompt_tokens: 10,
279+
completion_tokens: 5,
280+
total_tokens: 15,
281+
cache_read_tokens: None,
282+
cache_write_tokens: None,
283+
},
284+
stop_reason: Some("stop".to_string()),
285+
meta: None,
286+
}
287+
}
288+
270289
/// Create a response with a tool call
271290
pub(crate) fn tool_call_response(
272291
tool_id: &str,
@@ -404,6 +423,57 @@ async fn test_agent_simple_response() {
404423
assert_eq!(mock_client.call_count.load(Ordering::SeqCst), 1);
405424
}
406425

426+
#[tokio::test]
427+
async fn test_agent_repairs_reasoning_only_response_once() {
428+
let mock_client = Arc::new(MockLlmClient::new(vec![
429+
MockLlmClient::reasoning_only_response("I have the answer but put it in reasoning."),
430+
MockLlmClient::text_response("The answer is 42."),
431+
]));
432+
433+
let tool_executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
434+
let config = AgentConfig {
435+
max_continuation_turns: 0,
436+
..Default::default()
437+
};
438+
439+
let agent = AgentLoop::new(
440+
mock_client.clone(),
441+
tool_executor,
442+
test_tool_context(),
443+
config,
444+
);
445+
let result = agent.execute(&[], "Answer plainly", None).await.unwrap();
446+
447+
assert_eq!(result.text, "The answer is 42.");
448+
assert_eq!(mock_client.call_count.load(Ordering::SeqCst), 2);
449+
}
450+
451+
#[tokio::test]
452+
async fn test_agent_stops_after_repeated_reasoning_only_response() {
453+
let mock_client = Arc::new(MockLlmClient::new(vec![
454+
MockLlmClient::reasoning_only_response("Thinking only, first pass."),
455+
MockLlmClient::reasoning_only_response("Thinking only, second pass."),
456+
MockLlmClient::text_response("This response should not be consumed."),
457+
]));
458+
459+
let tool_executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
460+
let config = AgentConfig::default();
461+
462+
let agent = AgentLoop::new(
463+
mock_client.clone(),
464+
tool_executor,
465+
test_tool_context(),
466+
config,
467+
);
468+
let result = agent.execute(&[], "Answer plainly", None).await.unwrap();
469+
470+
assert_eq!(
471+
result.text,
472+
"The model completed but returned only reasoning content and did not provide a final answer."
473+
);
474+
assert_eq!(mock_client.call_count.load(Ordering::SeqCst), 2);
475+
}
476+
407477
#[tokio::test]
408478
async fn test_agent_with_tool_call() {
409479
let mock_client = Arc::new(MockLlmClient::new(vec![

core/src/agent_api.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,9 @@ pub struct SessionOptions {
218218
/// Per-tool execution timeout in milliseconds.
219219
/// `None` = no timeout (default).
220220
pub tool_timeout_ms: Option<u64>,
221+
/// Per-model API HTTP timeout in milliseconds.
222+
/// `None` = no timeout (default).
223+
pub llm_api_timeout_ms: Option<u64>,
221224
/// Circuit-breaker threshold: max consecutive LLM API failures before
222225
/// aborting in non-streaming mode (overrides default of 3).
223226
/// `None` uses the `AgentConfig` default.
@@ -1047,6 +1050,7 @@ impl AgentSession {
10471050
hook_engine: None,
10481051
skill_registry: self.config.skill_registry.clone(),
10491052
tool_timeout_ms: self.config.tool_timeout_ms,
1053+
llm_api_timeout_ms: self.config.llm_api_timeout_ms,
10501054
max_parallel_tasks: Some(self.config.max_parallel_tasks),
10511055
max_execution_time_ms: self.config.max_execution_time_ms,
10521056
circuit_breaker_threshold: Some(self.config.circuit_breaker_threshold),

core/src/agent_api/agent_bootstrap.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ fn base_agent_config(config: &CodeConfig) -> AgentConfig {
9494
.max_parallel_tasks
9595
.unwrap_or(AgentConfig::default().max_parallel_tasks)
9696
.max(1),
97+
llm_api_timeout_ms: config.llm_api_timeout_ms,
9798
auto_delegation,
9899
..AgentConfig::default()
99100
}

core/src/agent_api/capabilities.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ fn register_task_capability(
190190
hook_engine: None,
191191
skill_registry: opts.skill_registry.clone(),
192192
tool_timeout_ms: opts.tool_timeout_ms,
193+
llm_api_timeout_ms: opts.llm_api_timeout_ms.or(code_config.llm_api_timeout_ms),
193194
max_parallel_tasks: opts.max_parallel_tasks.or(code_config.max_parallel_tasks),
194195
max_execution_time_ms: opts.max_execution_time_ms,
195196
circuit_breaker_threshold: opts.circuit_breaker_threshold,

core/src/agent_api/run_lifecycle.rs

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,22 +29,25 @@ impl StreamRunWorkerState {
2929
where
3030
E: std::fmt::Display,
3131
{
32+
let cancelled = self
33+
.cancel_token
34+
.lock()
35+
.await
36+
.as_ref()
37+
.map(|t| t.is_cancelled())
38+
.unwrap_or(false);
3239
match result {
3340
Ok(result) => {
3441
if let Some(persistence) = &self.persistence {
3542
persistence.record_result(&result);
3643
self.should_auto_save
3744
.store(true, std::sync::atomic::Ordering::Release);
3845
}
46+
if cancelled {
47+
let _ = self.run_store.mark_cancelled(&self.run_id).await;
48+
}
3949
}
4050
Err(error) => {
41-
let cancelled = self
42-
.cancel_token
43-
.lock()
44-
.await
45-
.as_ref()
46-
.map(|t| t.is_cancelled())
47-
.unwrap_or(false);
4851
if cancelled {
4952
let _ = self.run_store.mark_cancelled(&self.run_id).await;
5053
} else {
@@ -190,6 +193,9 @@ impl BlockingRunLifecycle {
190193
persistence.record_result(&result);
191194
persistence.auto_save_if_enabled().await;
192195
}
196+
if cancelled {
197+
let _ = self.run_store.mark_cancelled(self.cleanup.run_id()).await;
198+
}
193199
self.cleanup.finish().await;
194200
Ok(result)
195201
}

0 commit comments

Comments
 (0)