render method
Renders messages (with optional tools) into a Gemma 4 prompt.
When addGenerationPrompt is true a trailing <|turn>model is appended
unless the conversation already ends mid-model-turn (after a tool call or
response). enableThinking injects the <|think|> marker.
Implementation
GemmaPrompt render(
Iterable<ChatMessage> messages, {
Iterable<AIFunctionDeclaration> tools = const <AIFunctionDeclaration>[],
bool enableThinking = false,
bool addGenerationPrompt = true,
}) {
final all = messages.toList();
final toolList = tools.toList();
final out = StringBuffer();
final media = <Uint8List>[];
var loopStart = 0;
final firstIsSystem = all.isNotEmpty && _roleOf(all.first) == 'system';
if (enableThinking || toolList.isNotEmpty || firstIsSystem) {
out.write(
'$turnOpen'
'system\n',
);
if (enableThinking) {
out.write('$thinkToken\n');
}
if (firstIsSystem) {
out.write(all.first.text.trim());
loopStart = 1;
}
for (final tool in toolList) {
out
..write(toolOpen)
..write(_formatDeclaration(tool).trim())
..write(toolClose);
}
out.write('$turnClose\n');
}
final loop = all.sublist(loopStart);
String? prevType;
for (var i = 0; i < loop.length; i++) {
final msg = loop[i];
if (_roleOf(msg) == 'tool') {
continue;
}
prevType = null;
final isAssistant = _roleOf(msg) == 'assistant';
final roleStr = isAssistant ? 'model' : _roleOf(msg);
final prev = _prevNonTool(loop, i);
final continueSameTurn =
roleStr == 'model' && prev != null && _roleOf(prev) == 'assistant';
if (!continueSameTurn) {
out.write('$turnOpen$roleStr\n');
}
final calls = msg.contents.whereType<FunctionCallContent>().toList();
if (calls.isNotEmpty) {
for (final call in calls) {
out
..write(toolCallOpen)
..write('call:${call.name}')
..write(
_formatArgument(
call.arguments ?? const <String, Object?>{},
escapeKeys: false,
),
)
..write(toolCallClose);
}
prevType = 'tool_call';
}
var emittedResponse = false;
if (calls.isNotEmpty) {
for (
var k = i + 1;
k < loop.length && _roleOf(loop[k]) == 'tool';
k++
) {
for (final result
in loop[k].contents.whereType<FunctionResultContent>()) {
final name =
result.name ??
_nameForCallId(calls, result.callId) ??
'unknown';
out.write(_formatToolResponse(name, result.result));
emittedResponse = true;
prevType = 'tool_response';
}
}
}
// Gemma 4 accepts both vision and audio through mtmd. Collect either kind
// of media blob in content order and emit one marker apiece; mtmd
// substitutes the model-specific image/audio tokens and auto-detects the
// kind from each blob's magic bytes.
final mediaMarkers = StringBuffer();
for (final data in msg.contents.whereType<DataContent>()) {
final bytes = data.data;
if (bytes != null &&
(data.hasTopLevelMediaType('image') ||
data.hasTopLevelMediaType('audio'))) {
media.add(bytes);
mediaMarkers.write(mediaMarker);
}
}
final base = isAssistant ? _stripThinking(msg.text) : msg.text.trim();
final content = '$mediaMarkers$base';
out.write(content);
final hasContent = content.trim().isNotEmpty;
if (prevType == 'tool_call' && !emittedResponse) {
out.write(toolResponseOpen);
} else if (!(emittedResponse && !hasContent)) {
// Deliberate divergence from the upstream jinja: closing the turn
// must also clear the mid-turn state. Upstream keeps
// prev_message_type == 'tool_response' here, so a completed tool
// round that also carries prose suppresses the trailing
// `<|turn>model` and the prompt ends headerless after `<turn|>` —
// the model then invents its own turn/channel markup, which leaks
// into user-visible text.
out.write('$turnClose\n');
prevType = null;
}
}
if (addGenerationPrompt &&
prevType != 'tool_response' &&
prevType != 'tool_call') {
out.write(
'$turnOpen'
'model\n',
);
}
return GemmaPrompt(
text: out.toString(),
stopSequences: stopSequences,
media: media,
);
}