diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs index c848a86..4c8a265 100644 --- a/src/agent/loop_.rs +++ b/src/agent/loop_.rs @@ -301,6 +301,74 @@ fn parse_tool_call_value(value: &serde_json::Value) -> Option { Some(ParsedToolCall { name, arguments }) } +fn is_valid_tool_name(name: &str) -> bool { + let mut chars = name.chars(); + match chars.next() { + Some(c) if c == '_' || c.is_ascii_alphabetic() => {} + _ => return false, + } + chars.all(|c| c == '_' || c == '-' || c == ':' || c.is_ascii_alphanumeric()) +} + +fn parse_legacy_tool_call_value(value: &serde_json::Value) -> Option { + let object = value.as_object()?; + + // Legacy shorthand: {"schedule": {...args...}} + if object.len() == 1 { + let (name, arguments) = object.iter().next()?; + if is_valid_tool_name(name) && arguments.is_object() { + return Some(ParsedToolCall { + name: name.to_string(), + arguments: arguments.clone(), + }); + } + } + + // Legacy shorthand used by some models: + // {"action":"create","expression":"...","command":"..."} + // Infer "schedule" when payload matches schedule tool schema. + let Some(action) = object.get("action").and_then(serde_json::Value::as_str) else { + return None; + }; + let schedule_action = matches!( + action, + "create" | "add" | "once" | "list" | "get" | "cancel" | "remove" | "pause" | "resume" + ); + if !schedule_action { + return None; + } + let looks_like_schedule_payload = object.contains_key("expression") + || object.contains_key("delay") + || object.contains_key("run_at") + || object.contains_key("command") + || object.contains_key("id") + || action == "list"; + if !looks_like_schedule_payload { + return None; + } + + Some(ParsedToolCall { + name: "schedule".to_string(), + arguments: value.clone(), + }) +} + +fn parse_prefixed_tool_name_with_json(inner: &str) -> Option { + let trimmed = inner.trim(); + let first_json_start = trimmed.find('{')?; + let name = trimmed[..first_json_start].trim(); + if !is_valid_tool_name(name) { + return None; + } + let payload = trimmed[first_json_start..].trim(); + let json = serde_json::from_str::(payload).ok()?; + + Some(ParsedToolCall { + name: name.to_string(), + arguments: json, + }) +} + fn parse_tool_calls_from_json_value(value: &serde_json::Value) -> Vec { let mut calls = Vec::new(); @@ -327,6 +395,8 @@ fn parse_tool_calls_from_json_value(value: &serde_json::Value) -> Vec (String, Vec, bool) { } } + if !parsed_any { + if let Some(parsed) = parse_prefixed_tool_name_with_json(inner) { + parsed_any = true; + calls.push(parsed); + } + } + if !parsed_any { tracing::warn!("Malformed JSON: expected tool-call object in tag body"); malformed_markup = true; @@ -1497,18 +1574,58 @@ Some text after."#; } #[test] - fn parse_tool_calls_marks_malformed_when_text_precedes_invalid_tool_call() { + fn parse_tool_calls_infers_schedule_when_text_precedes_schedule_arguments() { let response = r#"I will schedule a 3AM update task. First, I will inspect existing tasks: {"action":"create","command":"nova update","expression":"0 3 * * *","id":"nova-self-update"} "#; let (text, calls, malformed) = parse_tool_calls(response); - assert!(calls.is_empty()); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "schedule"); assert!(text.contains("I will schedule a 3AM update task")); + assert!(!malformed); + } + + #[test] + fn parse_tool_calls_marks_malformed_when_text_precedes_invalid_tool_call() { + let response = r#"I will inspect existing tasks: + +{"invalid":[1,2,3]} +"#; + + let (text, calls, malformed) = parse_tool_calls(response); + assert!(calls.is_empty()); + assert!(text.contains("I will inspect existing tasks")); assert!(malformed); } + #[test] + fn parse_tool_calls_handles_prefixed_tool_name_inside_tag() { + let response = r#" +schedule {"action":"list"} +"#; + + let (_, calls, malformed) = parse_tool_calls(response); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "schedule"); + assert_eq!(calls[0].arguments["action"], "list"); + assert!(!malformed); + } + + #[test] + fn parse_tool_calls_handles_single_key_legacy_wrapper() { + let response = r#" +{"schedule":{"action":"list"}} +"#; + + let (_, calls, malformed) = parse_tool_calls(response); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "schedule"); + assert_eq!(calls[0].arguments["action"], "list"); + assert!(!malformed); + } + #[test] fn parse_tool_calls_text_before_and_after() { let response = r#"Before text. @@ -2172,7 +2289,7 @@ Done."#; Ok( r#"I will schedule a 3AM update task. First, I will inspect existing tasks: -{"action":"create","command":"nova update","expression":"0 3 * * *","id":"nova-self-update"} +{"invalid":[1,2,3]} "# .to_string(), )