Skip to content

Commit 23e1668

Browse files
authored
Merge branch 'main' into feat/telegram-send-audio
2 parents c2b9f0d + b42810b commit 23e1668

File tree

4 files changed

+111
-8
lines changed

4 files changed

+111
-8
lines changed

src/llm/model.rs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1236,15 +1236,21 @@ fn parse_anthropic_response(
12361236
}
12371237
}
12381238

1239-
let choice = OneOrMany::many(assistant_content).map_err(|_| {
1239+
let choice = OneOrMany::many(assistant_content).unwrap_or_else(|_| {
1240+
// Anthropic returns an empty content array when stop_reason is end_turn
1241+
// and the model has nothing further to say (e.g. after a side-effect-only
1242+
// tool call like react/skip). Treat this as a clean empty response rather
1243+
// than an error so the agentic loop terminates gracefully.
1244+
let stop_reason = body["stop_reason"].as_str().unwrap_or("unknown");
12401245
tracing::debug!(
1241-
stop_reason = body["stop_reason"].as_str().unwrap_or("unknown"),
1246+
stop_reason,
12421247
content_blocks = content_blocks.len(),
1243-
raw_content = %body["content"],
1244-
"empty assistant_content after parsing Anthropic response"
1248+
"empty assistant_content from Anthropic — returning synthetic empty text"
12451249
);
1246-
CompletionError::ResponseError("empty response from Anthropic".into())
1247-
})?;
1250+
OneOrMany::one(AssistantContent::Text(Text {
1251+
text: String::new(),
1252+
}))
1253+
});
12481254

12491255
let input_tokens = body["usage"]["input_tokens"].as_u64().unwrap_or(0);
12501256
let output_tokens = body["usage"]["output_tokens"].as_u64().unwrap_or(0);

src/tools.rs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,51 @@ use std::path::PathBuf;
8888
use std::sync::Arc;
8989
use tokio::sync::{broadcast, mpsc};
9090

91+
/// Deserialize a `u64` that may arrive as either a JSON number or a JSON string.
92+
///
93+
/// LLMs sometimes send `"timeout_seconds": "400"` instead of `"timeout_seconds": 400`.
94+
/// This helper accepts both forms so the tool call doesn't fail on a type mismatch.
95+
pub fn deserialize_string_or_u64<'de, D>(deserializer: D) -> Result<u64, D::Error>
96+
where
97+
D: serde::Deserializer<'de>,
98+
{
99+
use serde::de;
100+
101+
struct StringOrU64;
102+
103+
impl<'de> de::Visitor<'de> for StringOrU64 {
104+
type Value = u64;
105+
106+
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
107+
formatter.write_str("a u64 or a string containing a u64")
108+
}
109+
110+
fn visit_u64<E: de::Error>(self, value: u64) -> Result<u64, E> {
111+
Ok(value)
112+
}
113+
114+
fn visit_i64<E: de::Error>(self, value: i64) -> Result<u64, E> {
115+
u64::try_from(value).map_err(|_| E::custom(format!("negative value: {value}")))
116+
}
117+
118+
fn visit_f64<E: de::Error>(self, value: f64) -> Result<u64, E> {
119+
if value >= 0.0 && value <= u64::MAX as f64 && value.fract() == 0.0 {
120+
Ok(value as u64)
121+
} else {
122+
Err(E::custom(format!("invalid timeout value: {value}")))
123+
}
124+
}
125+
126+
fn visit_str<E: de::Error>(self, value: &str) -> Result<u64, E> {
127+
value
128+
.parse::<u64>()
129+
.map_err(|_| E::custom(format!("cannot parse \"{value}\" as a positive integer")))
130+
}
131+
}
132+
133+
deserializer.deserialize_any(StringOrU64)
134+
}
135+
91136
/// Maximum byte length for tool output strings (stdout, stderr, file content).
92137
/// ~50KB keeps a single tool result under ~12,500 tokens (at ~4 chars/token).
93138
pub const MAX_TOOL_OUTPUT_BYTES: usize = 50_000;
@@ -296,3 +341,49 @@ pub fn create_cortex_chat_tool_server(
296341

297342
server.run()
298343
}
344+
345+
#[cfg(test)]
346+
mod tests {
347+
use super::*;
348+
349+
#[test]
350+
fn shell_args_parses_timeout_as_integer() {
351+
let args: shell::ShellArgs =
352+
serde_json::from_str(r#"{"command": "ls", "timeout_seconds": 120}"#).unwrap();
353+
assert_eq!(args.timeout_seconds, 120);
354+
}
355+
356+
#[test]
357+
fn shell_args_parses_timeout_as_string() {
358+
let args: shell::ShellArgs =
359+
serde_json::from_str(r#"{"command": "ls", "timeout_seconds": "400"}"#).unwrap();
360+
assert_eq!(args.timeout_seconds, 400);
361+
}
362+
363+
#[test]
364+
fn shell_args_uses_default_when_timeout_missing() {
365+
let args: shell::ShellArgs = serde_json::from_str(r#"{"command": "ls"}"#).unwrap();
366+
assert_eq!(args.timeout_seconds, 60);
367+
}
368+
369+
#[test]
370+
fn shell_args_rejects_non_numeric_string() {
371+
let result: Result<shell::ShellArgs, _> =
372+
serde_json::from_str(r#"{"command": "ls", "timeout_seconds": "abc"}"#);
373+
assert!(result.is_err());
374+
}
375+
376+
#[test]
377+
fn exec_args_parses_timeout_as_string() {
378+
let args: exec::ExecArgs =
379+
serde_json::from_str(r#"{"program": "/bin/ls", "timeout_seconds": "300"}"#).unwrap();
380+
assert_eq!(args.timeout_seconds, 300);
381+
}
382+
383+
#[test]
384+
fn exec_args_parses_timeout_as_integer() {
385+
let args: exec::ExecArgs =
386+
serde_json::from_str(r#"{"program": "/bin/ls", "timeout_seconds": 90}"#).unwrap();
387+
assert_eq!(args.timeout_seconds, 90);
388+
}
389+
}

src/tools/exec.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,10 @@ pub struct ExecArgs {
8181
#[serde(default)]
8282
pub env: Vec<EnvVar>,
8383
/// Timeout in seconds (default: 60).
84-
#[serde(default = "default_timeout")]
84+
#[serde(
85+
default = "default_timeout",
86+
deserialize_with = "crate::tools::deserialize_string_or_u64"
87+
)]
8588
pub timeout_seconds: u64,
8689
}
8790

src/tools/shell.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,10 @@ pub struct ShellArgs {
248248
/// Optional working directory for the command.
249249
pub working_dir: Option<String>,
250250
/// Optional timeout in seconds (default: 60).
251-
#[serde(default = "default_timeout")]
251+
#[serde(
252+
default = "default_timeout",
253+
deserialize_with = "crate::tools::deserialize_string_or_u64"
254+
)]
252255
pub timeout_seconds: u64,
253256
}
254257

0 commit comments

Comments
 (0)