fix(agent): parse legacy schedule tool_call payloads
This commit is contained in:
parent
af5d1f3066
commit
9eff7a13bb
1 changed files with 120 additions and 3 deletions
|
|
@ -301,6 +301,74 @@ fn parse_tool_call_value(value: &serde_json::Value) -> Option<ParsedToolCall> {
|
||||||
Some(ParsedToolCall { name, arguments })
|
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<ParsedToolCall> {
|
||||||
|
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:
|
||||||
|
// <tool_call>{"action":"create","expression":"...","command":"..."}</tool_call>
|
||||||
|
// 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<ParsedToolCall> {
|
||||||
|
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::<serde_json::Value>(payload).ok()?;
|
||||||
|
|
||||||
|
Some(ParsedToolCall {
|
||||||
|
name: name.to_string(),
|
||||||
|
arguments: json,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_tool_calls_from_json_value(value: &serde_json::Value) -> Vec<ParsedToolCall> {
|
fn parse_tool_calls_from_json_value(value: &serde_json::Value) -> Vec<ParsedToolCall> {
|
||||||
let mut calls = Vec::new();
|
let mut calls = Vec::new();
|
||||||
|
|
||||||
|
|
@ -327,6 +395,8 @@ fn parse_tool_calls_from_json_value(value: &serde_json::Value) -> Vec<ParsedTool
|
||||||
|
|
||||||
if let Some(parsed) = parse_tool_call_value(value) {
|
if let Some(parsed) = parse_tool_call_value(value) {
|
||||||
calls.push(parsed);
|
calls.push(parsed);
|
||||||
|
} else if let Some(parsed) = parse_legacy_tool_call_value(value) {
|
||||||
|
calls.push(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
calls
|
calls
|
||||||
|
|
@ -455,6 +525,13 @@ fn parse_tool_calls(response: &str) -> (String, Vec<ParsedToolCall>, bool) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !parsed_any {
|
||||||
|
if let Some(parsed) = parse_prefixed_tool_name_with_json(inner) {
|
||||||
|
parsed_any = true;
|
||||||
|
calls.push(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !parsed_any {
|
if !parsed_any {
|
||||||
tracing::warn!("Malformed <tool_call> JSON: expected tool-call object in tag body");
|
tracing::warn!("Malformed <tool_call> JSON: expected tool-call object in tag body");
|
||||||
malformed_markup = true;
|
malformed_markup = true;
|
||||||
|
|
@ -1497,18 +1574,58 @@ Some text after."#;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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:
|
let response = r#"I will schedule a 3AM update task. First, I will inspect existing tasks:
|
||||||
<tool_call>
|
<tool_call>
|
||||||
{"action":"create","command":"nova update","expression":"0 3 * * *","id":"nova-self-update"}
|
{"action":"create","command":"nova update","expression":"0 3 * * *","id":"nova-self-update"}
|
||||||
</tool_call>"#;
|
</tool_call>"#;
|
||||||
|
|
||||||
let (text, calls, malformed) = parse_tool_calls(response);
|
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!(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:
|
||||||
|
<tool_call>
|
||||||
|
{"invalid":[1,2,3]}
|
||||||
|
</tool_call>"#;
|
||||||
|
|
||||||
|
let (text, calls, malformed) = parse_tool_calls(response);
|
||||||
|
assert!(calls.is_empty());
|
||||||
|
assert!(text.contains("I will inspect existing tasks"));
|
||||||
assert!(malformed);
|
assert!(malformed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_tool_calls_handles_prefixed_tool_name_inside_tag() {
|
||||||
|
let response = r#"<tool_call>
|
||||||
|
schedule {"action":"list"}
|
||||||
|
</tool_call>"#;
|
||||||
|
|
||||||
|
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#"<tool_call>
|
||||||
|
{"schedule":{"action":"list"}}
|
||||||
|
</tool_call>"#;
|
||||||
|
|
||||||
|
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]
|
#[test]
|
||||||
fn parse_tool_calls_text_before_and_after() {
|
fn parse_tool_calls_text_before_and_after() {
|
||||||
let response = r#"Before text.
|
let response = r#"Before text.
|
||||||
|
|
@ -2172,7 +2289,7 @@ Done."#;
|
||||||
Ok(
|
Ok(
|
||||||
r#"I will schedule a 3AM update task. First, I will inspect existing tasks:
|
r#"I will schedule a 3AM update task. First, I will inspect existing tasks:
|
||||||
<tool_call>
|
<tool_call>
|
||||||
{"action":"create","command":"nova update","expression":"0 3 * * *","id":"nova-self-update"}
|
{"invalid":[1,2,3]}
|
||||||
</tool_call>"#
|
</tool_call>"#
|
||||||
.to_string(),
|
.to_string(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue