@@ -88,6 +88,51 @@ use std::path::PathBuf;
8888use std:: sync:: Arc ;
8989use 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).
93138pub 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+ }
0 commit comments