1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
use tracing::{debug, info, warn};
use super::*;
use crate::api::tool_calling::{extract_tool_calls, extract_tool_calls_from_text};
pub(super) type CollectedToolCall = (String, String, Option<String>);
impl Agent {
/// Collect tool calls from a model response into the agent's internal
/// `(name, args, id)` triples.
///
/// Routes through the unified [`extract_tool_calls`] policy:
/// - If the message has native `tool_calls`, those are returned (validated).
/// - Otherwise, the multi-format parser scans `content`, then falls back to
/// `reasoning_content` if the content branch yields nothing.
///
/// This replaces the previously-duplicated native-vs-text branching logic
/// so the agent and the SWL runtime parse responses identically.
pub(super) fn collect_tool_calls(
&self,
content: &str,
reasoning_content: Option<&str>,
native_tool_calls: Option<&Vec<crate::api::types::ToolCall>>,
) -> Vec<(String, String, Option<String>)> {
// Build a synthetic Message we can hand off to the unified extractor.
let msg = crate::api::types::Message {
role: "assistant".to_string(),
content: crate::api::types::MessageContent::from_text(content),
reasoning_content: reasoning_content.map(|s| s.to_string()),
tool_calls: native_tool_calls.cloned(),
tool_call_id: None,
name: None,
};
let mut extracted = extract_tool_calls(&msg, self.config.agent.native_function_calling);
// Validate native tool calls against the loaded schema for early
// diagnostic. Validation failures are logged but do not abort —
// individual bad calls will still be rejected at dispatch time.
if !extracted.is_empty() && msg.tool_calls.as_ref().is_some_and(|c| !c.is_empty()) {
let defs = self.tools.definitions();
if let Err(e) = crate::agent::tool_validator::validate_tool_calls(&extracted, &defs) {
warn!("Native tool call batch validation failed: {}", e);
}
extracted.retain(|tc| match tc.validate_structure() {
Ok(()) => true,
Err(e) => {
warn!(
"Skipping malformed native tool call '{}': {}",
tc.function.name, e
);
false
}
});
}
// Fallback: when the content branch produced nothing, scan reasoning_content.
if extracted.is_empty() {
if let Some(reasoning_text) = reasoning_content {
let from_reasoning = extract_tool_calls_from_text(reasoning_text);
if !from_reasoning.is_empty() {
info!(
"Found {} tool calls in reasoning content",
from_reasoning.len()
);
extracted = from_reasoning;
}
}
}
if !extracted.is_empty() {
info!(
"Extracted {} tool call(s) via unified extractor",
extracted.len()
);
}
extracted
.into_iter()
.map(|tc| {
debug!(
"Tool call: {} (id: {}) with args: {}",
tc.function.name, tc.id, tc.function.arguments
);
// Synthetic ids generated by the text-fallback extractor start
// with `parsed_`. Downstream dispatch code uses
// `tool_call_id.is_some()` to decide whether to use the native
// function-calling result-message shape, so we collapse those
// synthetic ids back to `None` to preserve the prior behaviour.
let id = if tc.id.is_empty() || tc.id.starts_with("parsed_") {
None
} else {
Some(tc.id)
};
(tc.function.name, tc.function.arguments, id)
})
.collect()
}
}