detectToolCalls function

List<ToolCall>? detectToolCalls(
  1. String text,
  2. Set<String> validNames, {
  3. 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);
}