Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Releases before 0.10.0 are in the
## Unreleased

- Parse Qwen3-Coder XML tool calls as llama.cpp does: a call needs a line break after each tag and around each value (a CRLF or compact `<tool_call><function=` call is dropped), its required arguments come before its optional ones, and a string value keeps its whitespace and any `</parameter>` not followed by a line break. Nemotron 3 Nano now parses as Qwen3-Coder XML, so a `<tool_call>` also ends its thought, and the tool-call grammar of these templates now requires that shape and starts at `<tool_call>` ([#1898](https://github.com/leehack/llamadart/issues/1898)).
- Parse Seed-OSS tool calls closer to llama.cpp: a finished reply whose last `<seed:tool_call>` scope is never closed keeps a call complete through `</function>` and drops one cut off before it, an argument declared as a string keeps its value as generated instead of being read as JSON (`\n42\n` stays a string), and with `ToolChoice.required` the grammar lets a thought and text come before the calls ([#1898](https://github.com/leehack/llamadart/issues/1898)).

## 0.19.4

Expand Down
156 changes: 143 additions & 13 deletions lib/src/core/template/handlers/seed_oss_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,23 @@ import 'package:dinja/dinja.dart';

import '../../models/chat/chat_message.dart';
import '../../models/chat/chat_template_result.dart';
import '../../models/inference/tool_choice.dart';
import '../../models/tools/tool_definition.dart';
import '../chat_format.dart';
import '../chat_parse_result.dart';
import '../chat_template_handler.dart';
import '../template_internal_metadata.dart';
import '../thinking_utils.dart';
import '../tool_call_grammar_utils.dart';
import '../tool_schema_utils.dart';
import '../xml_tool_call_format.dart';

/// Handler for Seed-OSS format.
///
/// Uses `<seed:think>`/`</seed:think>` for reasoning and
/// `<seed:tool_call><function=...>...</function></seed:tool_call>` for tools.
class SeedOssHandler extends ChatTemplateHandler {
class SeedOssHandler extends ChatTemplateHandler
implements ToolSchemaAwareChatTemplateHandler {
@override
ChatFormat get format => ChatFormat.seedOss;

Expand Down Expand Up @@ -77,19 +81,26 @@ class SeedOssHandler extends ChatTemplateHandler {
}

final hasTools = tools != null && tools.isNotEmpty;
final grammar = buildXmlToolCallGrammar(
tools,
XmlToolCallFormat.seedOss,
parallelToolCalls:
metadata[internalParallelToolCallsMetadataKey] == 'true',
);
final rendered = LlamaChatTemplateResult(
prompt: prompt,
format: format.index,
grammar: requiredGrammarAfterThought(
buildXmlToolCallGrammar(
tools,
XmlToolCallFormat.seedOss,
parallelToolCalls:
metadata[internalParallelToolCallsMetadataKey] == 'true',
),
metadata: metadata,
thinkingForcedOpen: thinkingForcedOpen,
),
grammar:
grammar != null &&
!thinkingForcedOpen &&
metadata[internalToolChoiceMetadataKey] ==
ToolChoice.required.name
? _withThoughtAndTextBeforeCalls(grammar)
: requiredGrammarAfterThought(
grammar,
metadata: metadata,
thinkingForcedOpen: thinkingForcedOpen,
),
grammarLazy: hasTools,
thinkingForcedOpen: thinkingForcedOpen,
additionalStops: getStops(
Expand All @@ -104,12 +115,50 @@ class SeedOssHandler extends ChatTemplateHandler {
return withThoughtClosedForThinkingOff(rendered, closed: thoughtClosed);
}

/// [grammar] after what llama.cpp's (`cd959bae9`) required-call grammar
/// lets precede the calls: a thought that starts the reply, then any text
/// without a scope start.
static String _withThoughtAndTextBeforeCalls(String grammar) => [
for (final line in grammar.trimRight().split('\n'))
line.startsWith('root ::= ')
? 'root ::= seed-thought? text-before-calls '
'(${line.substring('root ::= '.length)})'
: line,
'seed-thought ::= [ \\t\\n]* "<seed:think>" seed-thought-text '
'"</seed:think>"',
...ToolCallGrammarUtils.excludingRules('seed-thought-text', [
'</seed:think>',
]),
...ToolCallGrammarUtils.excludingRules('text-before-calls', [
XmlToolCallFormat.seedOss.scopeStart,
]),
].join('\n');

@override
ChatParseResult parse(
String output, {
bool isPartial = false,
bool parseToolCalls = true,
bool thinkingForcedOpen = false,
}) => parseWithTools(
output,
isPartial: isPartial,
parseToolCalls: parseToolCalls,
thinkingForcedOpen: thinkingForcedOpen,
);

/// Parses Seed-OSS output, keeping as generated the value of each argument
/// that [tools] declare as a string, as llama.cpp does.
///
/// Other values are read as JSON when they are JSON, and the call's tool and
/// arguments are not checked against [tools].
@override
ChatParseResult parseWithTools(
String output, {
List<ToolDefinition>? tools,
bool isPartial = false,
bool parseToolCalls = true,
bool thinkingForcedOpen = false,
}) {
// As in llama.cpp, a thought the prompt opened is the reply up to the
// first end tag, verbatim, and one it did not open exists only when the
Expand All @@ -128,19 +177,100 @@ class SeedOssHandler extends ChatTemplateHandler {
isPartial: isPartial,
)
: null;
final parsed = parseXmlToolCalls(
thought?.content ?? output,
final body = thought?.content ?? output;
final stringArguments = {
for (final tool in tools ?? const <ToolDefinition>[])
tool.name: {
for (final MapEntry(:key, :value) in schemaProperties(
tool.toJsonSchema(),
).entries)
if (schemaResolvesToString(value)) key,
},
};
ChatParseResult parseBody(String text) => parseXmlToolCalls(
text,
XmlToolCallFormat.seedOss,
parseToolCalls: parseToolCalls,
extractsThought: false,
stringArguments: stringArguments,
);
final ended = isPartial || !parseToolCalls
? (text: body, endAdded: false)
: _withLastScopeEnded(body);
var parsed = parseBody(ended.text);
// When the calls still fail, the added scope end would show in content.
if (ended.endAdded && parsed.toolCalls.isEmpty) parsed = parseBody(body);
return ChatParseResult(
content: parsed.content.trim(),
reasoningContent: thought?.reasoning,
toolCalls: parsed.toolCalls,
);
}

/// [reply] as llama.cpp reads a finished one that ends inside its last
/// scope: with the scope end that a complete call is missing, and without
/// a scope that ends before its call does.
///
/// A last scope whose text could not start a call is left as it is.
/// [endAdded] tells whether the scope end was added.
static ({String text, bool endAdded}) _withLastScopeEnded(String reply) {
const format = XmlToolCallFormat.seedOss;
final unchanged = (text: reply, endAdded: false);
final start = reply.lastIndexOf(format.scopeStart);
if (start < 0 || reply.contains(format.scopeEnd, start)) return unchanged;
final callEnd = _unendedCallEnd(reply, start + format.scopeStart.length);
if (callEnd == null) return unchanged;
if (callEnd < 0) return (text: reply.substring(0, start), endAdded: false);
final rest = reply.substring(callEnd).trimLeft();
return format.scopeEnd.startsWith(rest)
? (
text: '${reply.substring(0, callEnd)}${format.scopeEnd}',
endAdded: true,
)
: unchanged;
}

/// Where the call that the scope opening before [from] in [reply] holds
/// ends, after `</function>` and its line break; -1 when [reply] ends
/// before it does; null when the text cannot be such a call.
static int? _unendedCallEnd(String reply, int from) {
const format = XmlToolCallFormat.seedOss;
bool endsIn(int at, String token) =>
reply.length - at < token.length &&
token.startsWith(reply.substring(at));
var pos = _skipSpace(reply, from);
if (pos == reply.length || endsIn(pos, format.toolStart)) return -1;
if (!reply.startsWith(format.toolStart, pos)) return null;
final nameEnd = reply.indexOf(format.toolSep, pos);
if (nameEnd < 0) return -1;
pos = nameEnd + format.toolSep.length;
while (true) {
pos = _skipSpace(reply, pos);
if (pos == reply.length ||
endsIn(pos, format.keyStart) ||
endsIn(pos, format.toolEnd)) {
return -1;
}
if (reply.startsWith(format.toolEnd, pos)) {
pos += format.toolEnd.length;
if (pos == reply.length) return -1;
return reply.codeUnitAt(pos) == 0x0A ? pos + 1 : null;
}
if (!reply.startsWith(format.keyStart, pos)) return null;
final valueEnd = reply.indexOf(format.valEnd, pos);
if (valueEnd < 0) return -1;
pos = valueEnd + format.valEnd.length;
}
}

static int _skipSpace(String text, int from) {
var pos = from;
while (pos < text.length && ' \t\n\r'.contains(text[pos])) {
pos++;
}
return pos;
}

@override
String? buildGrammar(List<ToolDefinition>? tools) {
return buildXmlToolCallGrammar(
Expand Down
13 changes: 8 additions & 5 deletions lib/src/core/template/tool_call_grammar_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,14 @@ class ToolCallGrammarUtils {
static List<String> excludingRules(String name, List<String> delimiters) {
String state(int i) => i == 0 ? name : '$name-$i';
// llama.cpp's GBNF parser has no `\^` escape, and a leading `^` would
// negate the class.
String chars(String value) => value.replaceAllMapped(
RegExp(r'[\\\]\[^-]'),
(m) => m[0] == '^' ? r'\x5E' : '\\${m[0]}',
);
// negate the class. A raw line break would split the rule's line.
String chars(String value) =>
value.replaceAllMapped(RegExp(r'[\\\]\[^\x00-\x1F-]'), (m) {
final code = m[0]!.codeUnitAt(0);
return m[0] == '^' || code < 0x20
? '\\x${code.toRadixString(16).padLeft(2, '0').toUpperCase()}'
: '\\${m[0]}';
});
final children = <Map<String, int>>[{}];
final ends = <int>[0];
for (final delimiter in delimiters) {
Expand Down
31 changes: 26 additions & 5 deletions lib/src/core/template/xml_tool_call_format.dart
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,9 @@ String _literal(String value) {
/// content is the text before the first scope, the calls are those of the
/// consecutive scopes after it that each hold one valid call, up to
/// [maxToolCalls], and the rest is dropped.
///
/// Without [schemas], the arguments that [stringArguments] lists for a tool
/// keep their text as generated instead of being read as JSON.
ChatParseResult parseXmlToolCalls(
String input,
XmlToolCallFormat format, {
Expand All @@ -362,6 +365,7 @@ ChatParseResult parseXmlToolCalls(
bool endsAtLastCall = false,
int? maxToolCalls,
Map<String, Map<String, dynamic>>? schemas,
Map<String, Set<String>>? stringArguments,
}) {
String? reasoning;
var content = input;
Expand Down Expand Up @@ -480,7 +484,13 @@ ChatParseResult parseXmlToolCalls(
final toolIdx = _nextScopeToolStart(content, pos, format);
final nextCall = toolIdx == null
? null
: _parseXmlToolCall(content, toolIdx, format, schemas);
: _parseXmlToolCall(
content,
toolIdx,
format,
schemas,
stringArguments,
);
if (nextCall == null) break;
repeatedScope = (scopeEnd: pos, calls: toolCalls.length);
toolCall = nextCall;
Expand All @@ -507,7 +517,13 @@ ChatParseResult parseXmlToolCalls(
parsedContent.write(content.substring(pos, toolIdx));
}

toolCall = _parseXmlToolCall(content, toolIdx, format, schemas);
toolCall = _parseXmlToolCall(
content,
toolIdx,
format,
schemas,
stringArguments,
);
if (toolCall == null) {
if (format.scopeStart.isNotEmpty) {
parseFailed = true;
Expand Down Expand Up @@ -788,8 +804,9 @@ _ParsedXmlToolCall? _parseXmlToolCall(
String content,
int toolIdx,
XmlToolCallFormat format,
Map<String, Map<String, dynamic>>? schemas,
) {
Map<String, Map<String, dynamic>>? schemas, [
Map<String, Set<String>>? stringArguments,
]) {
final nameStart = toolIdx + format.toolStart.length;
final sepIdx = content.indexOf(format.toolSep, nameStart);
if (sepIdx == -1) {
Expand All @@ -806,6 +823,7 @@ _ParsedXmlToolCall? _parseXmlToolCall(
sepIdx + format.toolSep.length,
format,
schemas?[name],
stringArguments?[name] ?? const {},
);
if (arguments == null) {
return null;
Expand All @@ -823,6 +841,7 @@ _ParsedXmlArguments? _parseXmlArguments(
int start,
XmlToolCallFormat format,
Map<String, dynamic>? schema,
Set<String> stringArguments,
) {
final properties = schema == null ? null : schemaProperties(schema);
final strictScope = format.scopeStart.isNotEmpty;
Expand Down Expand Up @@ -870,6 +889,7 @@ _ParsedXmlArguments? _parseXmlArguments(
pos,
format,
schema: properties?[key],
keepsText: stringArguments.contains(key),
);
if (argumentValue == null) {
return null;
Expand Down Expand Up @@ -990,6 +1010,7 @@ _ParsedXmlArgumentValue? _parseXmlArgumentValue(
int start,
XmlToolCallFormat format, {
Map<String, dynamic>? schema,
bool keepsText = false,
}) {
if (schema != null) {
final cdata = _parseCdataValue(content, start, format);
Expand Down Expand Up @@ -1039,7 +1060,7 @@ _ParsedXmlArgumentValue? _parseXmlArgumentValue(
value = value.trim();
}
return _ParsedXmlArgumentValue(
value: format.rawArgval == true
value: format.rawArgval == true || keepsText
? value
: ToolCallParsingUtils.decodeJsonValueOrString(value),
nextPos: valueEnd.nextPos,
Expand Down
Loading
Loading