detectToolCalls function
List<ToolCall> ?
detectToolCalls(
- String text,
- Set<
String> validNames, { - List<
FunctionTool> functions = const [],
Reads model output as tool calls, accepting only validNames.
Returns a non-empty list, or null. Never throws: every input is either a
call list or null. functions, when given, repairs argument types against
each declared schema — losslessly or not at all.
Implementation
List<ToolCall>? detectToolCalls(
String text,
Set<String> validNames, {
List<FunctionTool> functions = const [],
}) {
if (validNames.isEmpty || text.isEmpty) return null;
// Reasoning scratchpads first: their braces would otherwise anchor the
// balanced scan on content that is not the answer.
var cleaned = text.replaceAll(_thinkBlock, '');
cleaned = cleaned.replaceAll(_thinkUnclosed, '');
cleaned = cleaned.replaceFirst(_toolCallsMarker, '').trim();
if (cleaned.isEmpty) return null;
// Every <tool_call> tag is a deliberate, marked invocation, so ALL of them
// together are the answer: models trained on that format emit one tag per
// parallel call. A tag whose payload fails the allow-list is dropped, not
// fatal — the calls that did qualify are still the calls the model made.
final tagged = <ToolCall>[];
for (final m in _toolCallTag.allMatches(cleaned)) {
final calls = _parseCandidate(m.group(1)!.trim(), validNames);
if (calls != null) tagged.addAll(calls);
}
if (tagged.isNotEmpty) return applySchemas(tagged, functions);
// A response that is EXACTLY a JSON array is authoritative: the model chose
// that structure deliberately, so the all-or-nothing rule decides it
// outright rather than letting the single-object fallback resurrect one call
// from what is much more likely a list of data.
if (cleaned.startsWith('[') && loadsTolerant(cleaned) is List) {
return applySchemas(_parseCandidate(cleaned, validNames), functions);
}
// Among several parseable candidates the LAST one wins. A model that
// illustrates the format before committing ("here is how I would call it:
// ``` … ``` — now the real call: {…}") puts the demo first and the real call
// last, so taking the first match hands back the example's arguments.
(int, List<ToolCall>)? best;
for (final candidate in _jsonCandidates(cleaned)) {
if (candidate.text.isEmpty || !candidate.qualifies) continue;
final calls = _parseCandidate(candidate.text, validNames);
if (calls != null && (best == null || candidate.position >= best.$1)) {
best = (candidate.position, calls);
}
}
return best == null ? null : applySchemas(best.$2, functions);
}