render method

GemmaPrompt render(
  1. Iterable<ChatMessage> messages, {
  2. Iterable<AIFunctionDeclaration> tools = const <AIFunctionDeclaration>[],
  3. bool enableThinking = false,
  4. bool addGenerationPrompt = true,
})

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,
  );
}