Skip to content

Commit d295b41

Browse files
authored
Fix structured-output parser and grammar regressions (#421)
* fix: recover structured output format correctness * style: format template integration coverage * fix: reject duplicate optional tool arguments * fix: compile zero-argument Kimi grammars * fix: close structured streaming and schema gaps * fix: require canonical tool attribute encoding * fix: bind structured schemas to exact tool routes * fix: reject malformed structured route metadata * fix: harden specialized grammar generation * fix: reject empty specialized tool identities * fix: defer disabled specialized schema validation * fix: reconcile structured output recovery coverage * style: match CI formatter * fix: complete donor structured output reconciliation * fix: preserve optional schema argument order parity * fix: bound optional grammar state expansion * fix: reject duplicate MiniMax JSON keys * fix: close structured stream delimiter gaps * fix: align GLM marker parsing with grammar * fix: preserve GLM marker-like content
1 parent c53ee8d commit d295b41

30 files changed

Lines changed: 5312 additions & 244 deletions

AGENTS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ For docs and release-sensitive snippets:
4141
dart run tool/testing/verify_release_docs_versions.dart
4242
```
4343

44+
For chat-template handler, parser, or grammar changes, run the pinned upstream
45+
suite plus compiled specialized-grammar acceptance checks:
46+
47+
```bash
48+
tool/testing/run_template_parity_suites.sh
49+
```
50+
4451
For coverage when `lib/` behavior changes or coverage is in doubt:
4552

4653
```bash

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
11
## Unreleased
22

3+
* Fixed DeepSeek V3.2 DSML tool calls using their upstream
4+
`<|DSML|function_calls>` envelope while preserving DeepSeek V4's distinct
5+
`<|DSML|tool_calls>` grammar and parser behavior.
6+
7+
* Fixed partial GLM 4.5, Poolside Laguna, and Muse Glimmer tool envelopes
8+
leaking into streamed assistant content, while preserving completed calls,
9+
malformed final output, and ordinary text surrounding Muse recipient
10+
channels.
11+
12+
* Fixed schema-constrained tool calls for Kimi K3, MiniMax M1/M3, DeepSeek
13+
V3.2/V4, and Muse Glimmer, including exact escaped names, required fields,
14+
declared value types, matching MiniMax M3 element tags, zero-argument calls,
15+
and strings containing delimiter characters. Required-tool mode now accepts
16+
each format's reasoning/content prefix while still requiring a call.
17+
MiniMax M3, DeepSeek DSML, Muse Glimmer, Poolside Laguna, and GLM 4.5 now
18+
reconstruct argument values from the declared tool schema instead of
19+
guessing from text. Added `ToolParam.nullType` for null-only JSON Schema
20+
properties.
21+
322
* llama.cpp backend initialization failures now complete the worker startup
423
handshake with a typed `LlamaBackendInitializationException` and collected
524
native-loader diagnostics. A failed or incompatible worker is torn down

lib/llamadart.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ export 'src/core/models/chat/completion_chunk.dart';
9494

9595
// Tools
9696
export 'src/core/models/tools/tool_definition.dart';
97-
export 'src/core/models/tools/tool_param.dart';
97+
export 'src/core/models/tools/tool_param.dart' show ToolParam;
9898
export 'src/core/models/tools/tool_params.dart';
9999

100100
// Models - Config

lib/src/core/engine/chat_completion_stream_parser.dart

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import 'dart:async';
33
import '../llama_logger.dart';
44
import '../models/chat/chat_template_result.dart';
55
import '../models/chat/completion_chunk.dart';
6+
import '../models/tools/tool_definition.dart';
7+
import '../template/chat_format.dart';
68
import '../template/chat_template_engine.dart';
79

810
enum _ToolStreamingMode { undecided, raw, parsed }
@@ -42,6 +44,7 @@ class ChatCompletionStreamParser {
4244
required bool enableThinking,
4345
required String modelName,
4446
required String completionId,
47+
List<ToolDefinition>? tools,
4548
}) async* {
4649
final buffer = StringBuffer();
4750
var streamedContent = '';
@@ -58,7 +61,9 @@ class ChatCompletionStreamParser {
5861
// A forced-open thought can transition straight into a tool envelope
5962
// without producing `</think>`. Start in parsed mode so that envelope is
6063
// never streamed as reasoning before the final structured parse.
61-
var streamingMode = templateResult.thinkingForcedOpen
64+
var streamingMode =
65+
templateResult.thinkingForcedOpen ||
66+
_mayEmbedToolEnvelopeAfterContent(templateResult.format)
6267
? _ToolStreamingMode.parsed
6368
: _ToolStreamingMode.undecided;
6469
var undecidedPrefix = '';
@@ -167,6 +172,7 @@ class ChatCompletionStreamParser {
167172
parseToolCalls: true,
168173
thinkingForcedOpen: templateResult.thinkingForcedOpen,
169174
parser: templateResult.parser,
175+
tools: tools,
170176
);
171177

172178
final partialReasoning = partialParsed.reasoningContent ?? '';
@@ -286,6 +292,7 @@ class ChatCompletionStreamParser {
286292
parseToolCalls: parseToolCallsEnabled,
287293
thinkingForcedOpen: templateResult.thinkingForcedOpen,
288294
parser: templateResult.parser,
295+
tools: tools,
289296
);
290297

291298
if (parseToolCallsEnabled) {
@@ -412,6 +419,16 @@ class ChatCompletionStreamParser {
412419
return false;
413420
}
414421

422+
static bool _mayEmbedToolEnvelopeAfterContent(int formatIndex) =>
423+
formatIndex == ChatFormat.kimiK3.index ||
424+
formatIndex == ChatFormat.minimaxM1.index ||
425+
formatIndex == ChatFormat.minimaxM3.index ||
426+
formatIndex == ChatFormat.deepseekV32.index ||
427+
formatIndex == ChatFormat.deepseekV4.index ||
428+
formatIndex == ChatFormat.museGlimmer.index ||
429+
formatIndex == ChatFormat.glm45.index ||
430+
formatIndex == ChatFormat.laguna.index;
431+
415432
static int? _firstNonWhitespaceIndex(String value) {
416433
for (var i = 0; i < value.length; i++) {
417434
if (!_isWhitespaceCodeUnit(value.codeUnitAt(i))) {

lib/src/core/engine/engine.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -686,6 +686,7 @@ class LlamaEngine {
686686
enableThinking: enableThinking,
687687
modelName: _modelPath ?? 'llama_model',
688688
completionId: completionId,
689+
tools: effectiveTools,
689690
);
690691
}
691692

lib/src/core/models/tools/tool_param.dart

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
/// - [ToolParam.integer] for integer parameters
66
/// - [ToolParam.number] for floating-point parameters
77
/// - [ToolParam.boolean] for boolean parameters
8+
/// - [ToolParam.nullType] for parameters whose only valid value is `null`
89
/// - [ToolParam.enumType] for enum parameters with allowed values
910
/// - [ToolParam.array] for array parameters
1011
/// - [ToolParam.object] for nested object parameters
@@ -52,6 +53,13 @@ sealed class ToolParam {
5253
bool required = false,
5354
}) => _BooleanParam(name: name, description: description, required: required);
5455

56+
/// Creates a parameter whose only valid JSON value is `null`.
57+
static ToolParam nullType(
58+
String name, {
59+
String? description,
60+
bool required = false,
61+
}) => _NullParam(name: name, description: description, required: required);
62+
5563
/// Creates an enum parameter with a list of allowed values.
5664
static ToolParam enumType(
5765
String name, {
@@ -95,6 +103,47 @@ sealed class ToolParam {
95103
Map<String, dynamic> toJsonSchema();
96104
}
97105

106+
/// Returns an actionable identity error for [parameters], or `null` when every
107+
/// object property name is non-empty and unique within its containing object.
108+
///
109+
/// Array item names are not represented in JSON Schema and are therefore not
110+
/// validated, but object properties nested inside array items are.
111+
String? toolParamIdentityError(
112+
List<ToolParam> parameters, {
113+
required String path,
114+
}) {
115+
final names = <String>{};
116+
for (final parameter in parameters) {
117+
if (parameter.name.isEmpty) {
118+
return 'Structured tool schemas require non-empty parameter names at '
119+
'$path.';
120+
}
121+
if (!names.add(parameter.name)) {
122+
return 'Structured tool schemas require unique parameter names at '
123+
'$path; "${parameter.name}" is declared more than once.';
124+
}
125+
}
126+
127+
for (final parameter in parameters) {
128+
final nestedPath = '$path.${parameter.name}';
129+
final error = _nestedToolParamIdentityError(parameter, nestedPath);
130+
if (error != null) {
131+
return error;
132+
}
133+
}
134+
return null;
135+
}
136+
137+
String? _nestedToolParamIdentityError(ToolParam parameter, String path) {
138+
if (parameter is _ObjectParam) {
139+
return toolParamIdentityError(parameter.properties, path: path);
140+
}
141+
if (parameter is _ArrayParam) {
142+
return _nestedToolParamIdentityError(parameter.itemType, '$path[]');
143+
}
144+
return null;
145+
}
146+
98147
final class _StringParam extends ToolParam {
99148
const _StringParam({required super.name, super.description, super.required})
100149
: super._();
@@ -139,6 +188,17 @@ final class _BooleanParam extends ToolParam {
139188
};
140189
}
141190

191+
final class _NullParam extends ToolParam {
192+
const _NullParam({required super.name, super.description, super.required})
193+
: super._();
194+
195+
@override
196+
Map<String, dynamic> toJsonSchema() => {
197+
'type': 'null',
198+
if (description != null) 'description': description,
199+
};
200+
}
201+
142202
final class _EnumParam extends ToolParam {
143203
final List<String> values;
144204

lib/src/core/template/chat_format.dart

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,14 +133,19 @@ enum ChatFormat {
133133
/// MiniMax M3 — namespaced XML invokes with `<mm:think>` reasoning.
134134
minimaxM3,
135135

136-
/// DeepSeek V3.2/V4 — DSML invokes with typed parameter values.
136+
/// DeepSeek V4 — DSML `tool_calls` with typed parameter values.
137137
deepseekV4,
138138

139139
/// Muse Glimmer — recipient channels with ATEM function-call markup.
140140
museGlimmer,
141141

142142
/// Poolside Laguna — tagged reasoning and arg-key/value tool calls.
143143
laguna,
144+
145+
/// DeepSeek V3.2 — DSML `function_calls` with typed parameter values.
146+
///
147+
/// Appended to preserve the serialized indices of existing formats.
148+
deepseekV32,
144149
}
145150

146151
/// Detects the [ChatFormat] by scanning a Jinja template source string
@@ -179,8 +184,12 @@ ChatFormat detectChatFormat(String? templateSource) {
179184
// variable, so detecting only literal rendered tags misses every variant.
180185
if (templateSource.contains('dsml_token') &&
181186
templateSource.contains('DSML') &&
182-
(templateSource.contains('function_calls') ||
183-
templateSource.contains('tool_calls'))) {
187+
templateSource.contains('function_calls')) {
188+
return ChatFormat.deepseekV32;
189+
}
190+
if (templateSource.contains('dsml_token') &&
191+
templateSource.contains('DSML') &&
192+
templateSource.contains('tool_calls')) {
184193
return ChatFormat.deepseekV4;
185194
}
186195

lib/src/core/template/chat_template_engine.dart

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,7 @@ class ChatTemplateEngine {
413413
LlamaChatTemplateResult result,
414414
ToolChoice toolChoice,
415415
) {
416-
if (toolChoice != ToolChoice.required || !result.grammarLazy) {
416+
if (toolChoice != ToolChoice.required) {
417417
return result;
418418
}
419419
if (result.grammar == null || result.grammar!.isEmpty) {
@@ -423,7 +423,8 @@ class ChatTemplateEngine {
423423
final resultFormat = result.format < ChatFormat.values.length
424424
? ChatFormat.values[result.format]
425425
: ChatFormat.generic;
426-
if (_requiredKeepsLazyFormats.contains(resultFormat)) {
426+
if (_requiredKeepsLazyFormats.contains(resultFormat) &&
427+
result.grammarLazy) {
427428
return result;
428429
}
429430

@@ -434,7 +435,7 @@ class ChatTemplateEngine {
434435
grammarLazy: false,
435436
additionalStops: result.additionalStops,
436437
preservedTokens: result.preservedTokens,
437-
grammarTriggers: result.grammarTriggers,
438+
grammarTriggers: const [],
438439
thinkingForcedOpen: result.thinkingForcedOpen,
439440
parser: result.parser,
440441
tokenCount: result.tokenCount,
@@ -444,13 +445,16 @@ class ChatTemplateEngine {
444445
/// Parses raw LLM output using the format's handler.
445446
///
446447
/// The [formatIndex] should come from [LlamaChatTemplateResult.format].
448+
/// [tools] supplies the schemas needed by formats whose wire representation
449+
/// does not encode enough type information to reconstruct arguments safely.
447450
static ChatParseResult parse(
448451
int formatIndex,
449452
String output, {
450453
bool isPartial = false,
451454
bool parseToolCalls = true,
452455
bool thinkingForcedOpen = false,
453456
String? parser,
457+
List<ToolDefinition>? tools,
454458
}) {
455459
final resolved = _resolveHandlerForParse(formatIndex: formatIndex);
456460
final handler = resolved.handler;
@@ -482,6 +486,16 @@ class ChatTemplateEngine {
482486
);
483487
}
484488

489+
if (handler is ToolSchemaAwareChatTemplateHandler) {
490+
return (handler as ToolSchemaAwareChatTemplateHandler).parseWithTools(
491+
output,
492+
tools: tools,
493+
isPartial: isPartial,
494+
parseToolCalls: parseToolCalls,
495+
thinkingForcedOpen: thinkingForcedOpen,
496+
);
497+
}
498+
485499
return handler.parse(
486500
output,
487501
isPartial: isPartial,
@@ -557,6 +571,8 @@ class ChatTemplateEngine {
557571
return MinimaxM3Handler();
558572
case ChatFormat.deepseekV4:
559573
return DeepseekV4Handler();
574+
case ChatFormat.deepseekV32:
575+
return DeepseekV32Handler();
560576
case ChatFormat.museGlimmer:
561577
return MuseGlimmerHandler();
562578
case ChatFormat.laguna:

lib/src/core/template/chat_template_handler.dart

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,3 +236,20 @@ abstract class ChatTemplateHandler {
236236
return DateTime.now();
237237
}
238238
}
239+
240+
/// Optional handler contract for output formats whose argument decoding depends
241+
/// on the tool JSON schemas supplied for the completion request.
242+
///
243+
/// [ChatTemplateEngine] uses this contract when schemas are available while
244+
/// retaining [ChatTemplateHandler.parse] for callers that only have raw output.
245+
abstract interface class ToolSchemaAwareChatTemplateHandler {
246+
/// Parses [output] using [tools] to validate names, required properties, and
247+
/// schema-directed argument types.
248+
ChatParseResult parseWithTools(
249+
String output, {
250+
List<ToolDefinition>? tools,
251+
bool isPartial = false,
252+
bool parseToolCalls = true,
253+
bool thinkingForcedOpen = false,
254+
});
255+
}

0 commit comments

Comments
 (0)