parseToolEnvelope function
- String text,
- Set<
String> validNames, { - List<
FunctionTool> functions = const [],
Reads the extractor's reply, in two layers.
The MARKERS are ours and explicit: the prompt asks for exactly one of three, so kNeedInfoMarker and kNoToolMarker are read first and decide outright. They have no equivalent in any model's native dialect — "no function fits" and "a required parameter was never stated" are answers this design asks for, not shapes a model happens to emit.
Everything else goes through detectToolCalls, which reads a call out of any dialect a prompted model actually produces. Before that layer, every one of those cost a repair round trip — one more message off an anonymous hourly allowance.
validNames is the allow-list AFTER a ToolChoice has narrowed it: pass
the raw declared set and a pinned choice becomes a suggestion the model can
ignore while still producing a call that validates.
Implementation
ToolEnvelope parseToolEnvelope(
String text,
Set<String> validNames, {
List<FunctionTool> functions = const [],
}) {
final notes = <String>[];
var t = text.trim();
if (t.contains('```')) {
notes.add('fenced');
t = t.replaceAll(_fence, '').replaceAll('```', '').trim();
}
final hasCall = t.contains(kToolCallMarker);
if (t.contains(kNeedInfoMarker) && !hasCall) {
final payload = t.split(kNeedInfoMarker).last.trim();
final decoded = _tryDecodeObject(payload);
return EnvelopeNeedInfo(
'${decoded?['function'] ?? ''}',
[for (final m in (decoded?['missing'] as List? ?? const [])) '$m'],
notes,
);
}
if (t.contains(kNoToolMarker) && !hasCall) {
return EnvelopeCalls(const [], notes);
}
if (hasCall) {
if (kToolCallMarker.allMatches(t).length > 1) notes.add('duplicate-marker');
if (!t.startsWith(kToolCallMarker)) notes.add('prose-before');
// Between the first marker and the next one, if the model repeated it.
final payload =
t.split(kToolCallMarker)[1].split(kToolCallMarker).first.trim();
for (final candidate in [payload, extractJson(payload)]) {
final decoded = _tryDecodeObject(candidate);
final calls = decoded?['calls'];
if (calls is List) {
final read = _callsFrom(calls, validNames, functions);
if (read != null) return EnvelopeCalls(read, notes);
}
}
final fromPayload =
detectToolCalls(payload, validNames, functions: functions);
if (fromPayload != null) return EnvelopeCalls(fromPayload, notes);
// An explicitly EMPTY calls array is the model choosing this envelope and
// saying there are none. Detection is right to see no call there, but at
// this layer the emptiness IS the answer — reading it as unreadable would
// spend a repair round trip on a perfectly clear reply. Only the
// documented shape counts: a bare [] elsewhere is data.
for (final candidate in [payload, extractJson(payload)]) {
if (_tryDecodeObject(candidate)?['calls'] case final List<dynamic> l
when l.isEmpty) {
return EnvelopeCalls(const [], notes);
}
}
notes.add('marker-payload-unreadable');
} else {
notes.add('no-marker');
}
// No usable marker payload. The model may still have called, just not in the
// shape it was asked for — read the whole reply as any dialect.
final dialect = detectToolCalls(text, validNames, functions: functions);
if (dialect != null) return EnvelopeCalls(dialect, [...notes, 'dialect']);
return EnvelopeUnreadable([...notes, 'invalid-json']);
}