Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
## Unreleased

- Render structured typed tool results as JSON text for chat templates while preserving string results and source objects.

- Preserve Qwen XML tool argument types using declared schemas and reject invalid or undeclared calls without exposing executable tool deltas.

- Settle pending LiteRT-LM requests when a worker stops, close their response ports, and report unverified native cleanup as an error.
Expand Down
12 changes: 12 additions & 0 deletions lib/src/core/template/template_render_context.dart
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ class TemplateRenderContext {
);

/// Serializes [messages] into the JSON shape expected by a template handler.
/// Typed tool results become JSON text (or text parts for multimodal
/// templates); string results and the original typed messages are unchanged.
static List<Map<String, dynamic>> messagesForTemplate(
List<LlamaChatMessage> messages, {
TemplateToolCallSerialization toolCallSerialization =
Expand All @@ -86,6 +88,16 @@ class TemplateRenderContext {
final rendered = multimodal
? message.toJsonMultimodal()
: message.toJson();
final toolResults = message.parts.whereType<LlamaToolResultContent>();
if (toolResults.isNotEmpty) {
final result = toolResults.first.result;
final text = result is String ? result : jsonEncode(result);
rendered['content'] = multimodal
? [
{'type': 'text', 'text': text},
]
: text;
}
if (rendered['tool_calls'] is List) {
hasToolCalls = true;
}
Expand Down
74 changes: 74 additions & 0 deletions test/e2e/template/llama_cpp_chat_tests_e2e_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'dart:io';
import 'package:llamadart/llamadart.dart';
import 'package:test/test.dart';

import '../../support/qwen35_tool_result_fixture.dart' as typed;
import '../../support/qwen_tool_schema_fixture.dart';

void main() {
Expand Down Expand Up @@ -117,6 +118,79 @@ void main() {
}
},
);
test(
'typed Qwen result history matches pinned upstream template rendering',
() async {
final build =
Platform.environment['LLAMA_CPP_CHAT_TEST_BUILD_DIR'] ??
'${Directory.current.path}/.dart_tool/llama_cpp_chat_tests';
final binary = File('$build/bin/test-chat-template');
expect(
binary.existsSync(),
isTrue,
reason: 'Run the upstream selection to build test-chat-template.',
);
final temp = Directory.systemTemp.createTempSync(
'qwen-tool-result-parity-',
);
addTearDown(() => temp.deleteSync(recursive: true));
for (final thinking in [true, false]) {
// Independent upstream input oracle: the public typed payload is encoded
// as JSON text, without calling the production normalization helper.
final messages =
jsonDecode(
jsonEncode(
typed
.qwenResultHistory(stringControl: true)
.map((m) => m.toJson())
.toList(),
),
)
as List<dynamic>;
((messages[1]['tool_calls'] as List).single['function']
as Map)['arguments'] =
typed.qwenResultPayload;
final input = File('${temp.path}/input.json')
..writeAsStringSync(
jsonEncode({
'messages': messages,
'tools': [typed.qwenResultTool.toJson()],
'bos_token': '<|im_start|>',
'eos_token': '<|im_end|>',
'add_generation_prompt': true,
'enable_thinking': thinking,
}),
);
final output = File('${temp.path}/prompt.txt');
final result = await Process.run(binary.path, [
'--no-common',
'--json',
input.path,
'--output',
output.path,
File(typed.qwenResultTemplatePath).absolute.path,
]);
expect(
result.exitCode,
0,
reason: 'Upstream Qwen render failed: ${result.stderr}',
);
expect(output.existsSync(), isTrue);
final rendered = typed.renderQwenResultHistory(
choice: ToolChoice.auto,
thinking: thinking,
);
// Upstream tojson uses spaced separators; Dart emits compact JSON.
// Canonicalize only the tools declaration, preserving history and the
// generation/thinking suffix byte-for-byte.
expect(
_canonicalToolDeclarations(rendered.prompt),
_canonicalToolDeclarations(output.readAsStringSync()),
);
expect(rendered.prompt, contains(jsonEncode(typed.qwenResultPayload)));
}
},
);
}

String _canonicalToolDeclarations(String prompt) => prompt.replaceFirstMapped(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import 'package:llamadart/src/core/template/handlers/hunyuan_v3_handler.dart';
import 'package:llamadart/src/core/template/handlers/llama_cpp_specialized_handlers.dart';
import 'package:test/test.dart';

import '../../support/qwen35_tool_result_fixture.dart' as typed;
import '../../support/qwen_tool_schema_fixture.dart';

void main() {
Expand All @@ -37,6 +38,35 @@ void main() {
expect(File(validator).existsSync(), isTrue);
});

test('Qwen typed Map result history preserves compiled follow-up grammar', () {
for (final thinking in [true, false]) {
final rendered = typed.renderQwenResultHistory(thinking: thinking);
expect(rendered.prompt, contains(jsonEncode(typed.qwenResultPayload)));
expect(rendered.grammarLazy, isFalse);
// Qwen's existing grammar constrains the XML envelope and declared names;
// scalar typing is reconstructed by the output parser, tested separately.
// Its grammar root begins at the tool envelope, not the thinking prefix.
_expectGrammar(
validator,
rendered.grammar!,
valid: [typed.qwenResultEnvelope],
invalid: [
typed.qwenResultEnvelope.replaceFirst(
'<function=inspect>',
'<function=unknown>',
),
typed.qwenResultEnvelope.replaceFirst(
'<parameter=count>',
'<parameter=unknown>',
),
typed.qwenResultEnvelope.replaceFirst('</tool_call>', ''),
typed.qwenResultEnvelope.replaceFirst('</function>', ''),
'No tool',
],
);
}
});

test('Qwen typed result history preserves compiled follow-up grammar', () {
for (final thinking in [true, false]) {
final rendered = renderQwenResultHistory(
Expand Down
9 changes: 9 additions & 0 deletions test/fixtures/Qwen3_5-0_8B.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Qwen3.5 0.8B template fixture

Exact `tokenizer.chat_template` extracted from `ggml-org/Qwen3.5-0.8B-GGUF`, revision `8fea620810c4afa23dd6443f999a48574c1611a3`, file `Qwen3.5-0.8B-Q4_0.gguf`.

- Model SHA256: `57d1997790d1744fba5b40a7317df71ea5e2acee28c47e78f0cce39c0703f8cf`.
- Template UTF-8 SHA256: `273d8e0e683b885071fb17e08d71e5f2a5ddfb5309756181681de4f5a1822d80`.
- Source: https://huggingface.co/ggml-org/Qwen3.5-0.8B-GGUF/tree/8fea620810c4afa23dd6443f999a48574c1611a3

Used by `test/integration/core/template/qwen35_tool_result_test.dart` to reproduce mapping-valued tool-result rejection and verify render-boundary JSON normalization. The template is unchanged; the fix belongs to Dart input serialization.
157 changes: 157 additions & 0 deletions test/integration/core/template/qwen35_tool_result_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
@TestOn('vm')
library;

import 'dart:convert';
import 'dart:io';

import 'package:llamadart/llamadart.dart';
import 'package:llamadart/src/core/engine/chat_completion_stream_parser.dart';
import 'package:test/test.dart';

import '../../../support/qwen35_tool_result_fixture.dart';

void main() {
test('exact Qwen3.5 template accepts structured typed tool results', () {
final source = File(
'test/fixtures/templates/Qwen3_5-0_8B.jinja',
).readAsStringSync();
final payload = {
'city': 'Montréal 👋',
'temperature_celsius': 17,
'nested': [true, null],
'escaped': '"quoted"\nline',
};
final messages = [
const LlamaChatMessage.fromText(
role: LlamaChatRole.user,
text: 'Call get_weather for Montréal.',
),
const LlamaChatMessage.withContent(
role: LlamaChatRole.assistant,
content: [
LlamaToolCallContent(
id: 'call_0',
name: 'get_weather',
arguments: {'city': 'Montréal'},
rawJson: '{"city":"Montréal"}',
),
],
),
LlamaChatMessage.withContent(
role: LlamaChatRole.tool,
content: [
LlamaToolResultContent(
id: 'call_0',
name: 'get_weather',
result: payload,
),
],
),
const LlamaChatMessage.fromText(
role: LlamaChatRole.user,
text: 'What is the temperature_celsius?',
),
];
final output = ChatTemplateEngine.render(
templateSource: source,
messages: messages,
metadata: const {},
toolChoice: ToolChoice.none,
enableThinking: false,
);
expect(
output.prompt,
contains('<tool_response>\n${jsonEncode(payload)}\n</tool_response>'),
);
expect(output.prompt, contains('<function=get_weather>'));
expect(output.prompt, contains('Montréal'));
expect(messages[2].toJson()['content'], same(payload));
expect(output.prompt, isNot(contains('{city:')));
});

test('typed result history preserves tool choices and thinking prefixes', () {
for (final choice in ToolChoice.values) {
for (final thinking in [true, false]) {
final actual = renderQwenResultHistory(
choice: choice,
thinking: thinking,
);
final control = renderQwenResultHistory(
choice: choice,
thinking: thinking,
stringControl: true,
);
expect(actual.prompt, control.prompt);
expect(actual.prompt, contains(jsonEncode(qwenResultPayload)));
expect(actual.grammar, control.grammar);
expect(actual.grammar, choice == ToolChoice.none ? isNull : isNotNull);
expect(actual.grammarLazy, choice == ToolChoice.auto);
expect(actual.thinkingForcedOpen, thinking);
expect(actual.preservedTokens, control.preservedTokens);
expect(actual.additionalStops, control.additionalStops);
}
}
});

// Keep strict reconstruction and rollback oracles for typed result histories.
for (final malformed in [false, true]) {
test(
'typed result history ${malformed ? 'rolls back undeclared tools' : 'preserves schema-directed scalar and container types'}',
() async {
for (final choice in [ToolChoice.auto, ToolChoice.required]) {
for (final thinking in [true, false]) {
final rendered = renderQwenResultHistory(
choice: choice,
thinking: thinking,
);
final body = malformed
? qwenResultEnvelope.replaceFirst(
'<function=inspect>',
'<function=unknown>',
)
: qwenResultEnvelope;
final prefix = thinking ? 'reason</think>\n\n' : '';
final chunks = await ChatCompletionStreamParser.parse(
tokenStream: Stream.fromIterable([prefix, ...body.split('')]),
templateResult: rendered,
parseToolCallsEnabled: true,
enableThinking: thinking,
modelName: 'qwen-typed-result-history',
completionId: 'typed-result-$choice-$thinking-$malformed',
tools: [qwenResultTool],
).toList();
final contentChunks = chunks
.map((c) => c.choices.single.delta.content ?? '')
.where((c) => c.isNotEmpty)
.toList();
final reasoning = chunks
.map((c) => c.choices.single.delta.thinking ?? '')
.join();
final calls = chunks
.expand(
(c) =>
c.choices.single.delta.toolCalls ??
const <LlamaCompletionChunkToolCall>[],
)
.toList();
expect(reasoning.trim(), thinking ? 'reason' : '');
if (malformed) {
expect(calls, isEmpty);
expect(contentChunks, [body]);
expect(chunks.last.choices.single.finishReason, 'stop');
} else {
expect(contentChunks, isEmpty);
expect(calls, hasLength(1));
expect(calls.single.function!.name, 'inspect');
expect(
jsonDecode(calls.single.function!.arguments!),
qwenResultPayload,
);
expect(chunks.last.choices.single.finishReason, 'tool_calls');
}
}
}
},
);
}
}
Loading
Loading