send method

  1. @override
Future<void> send(
  1. String text, {
  2. List<PromptAttachment> attachments = const [],
})
override

Implementation

@override
Future<void> send(
  String text, {
  List<PromptAttachment> attachments = const [],
}) async {
  if (_disposed) throw StateError('Session is disposed.');
  if (_active != null) throw StateError('A response is already running.');
  if (text.trim().isEmpty && attachments.isEmpty) return;
  final user = _userMessage(text, attachments);
  final system = <Map<String, Object?>>[
    if (systemPrompt.isNotEmpty) {'role': 'system', 'content': systemPrompt},
  ];
  _checkContext([...system, user]);
  final retained = List<List<Map<String, Object?>>>.of(_history);
  // Remove whole turns, preserving tool-call/result pairing.
  while (retained.isNotEmpty &&
      _contextSize([...system, ...retained.expand((t) => t), user]) >
          maxContextBytes) {
    retained.removeAt(0);
  }
  final history = retained.expand((t) => t).toList();
  final working = <Map<String, Object?>>[user];
  final cancellation = ChatCancellation();
  _active = cancellation;
  _error = null;
  _turn++;
  final turnId = _turn;
  _messages.add(
    ChatMessageData(
      id: '$sessionId:$turnId:user',
      role: ChatMessageRole.user,
      text: text,
      turnId: turnId,
      metadata: {
        if (attachments.isNotEmpty)
          'contentBlocks': [
            for (final a in attachments)
              {'type': 'image', 'data': a.data, 'mimeType': a.imageMimeType},
          ],
      },
    ),
  );
  _notify();
  try {
    for (var round = 0; round <= maxToolRounds; round++) {
      cancellation.check();
      final requestMessages = [...system, ...history, ...working];
      _checkContext(requestMessages);
      final message = _LlmTextMessage(
        '$sessionId:$turnId:$round',
        turnId,
        inputBudget,
      );
      final reasoning = StringBuffer();
      var reasoningBytes = 0;
      final calls = <int, _ToolCallBuffer>{};
      String? finishReason;
      var visible = false;
      await for (final chunk in client.stream(
        messages: requestMessages,
        cancellation: cancellation,
        tools: tools.map((t) => t.toJson()).toList(),
      )) {
        cancellation.check();
        final choices = chunk['choices'];
        if (choices is! List || choices.isEmpty) continue;
        final choice = choices
            .whereType<Map>()
            .where((c) => c['index'] == 0)
            .firstOrNull;
        if (choice == null) continue;
        final reason = choice['finish_reason'];
        if (reason is String) finishReason = reason;
        final delta = choice['delta'];
        if (delta is! Map) continue;
        final thought = delta['reasoning_content'];
        if (thought is String) {
          reasoningBytes += utf8.encode(thought).length;
          if (reasoningBytes > inputBudget.maxThoughtTextBytes) {
            throw const ChatApiException(
              'Reasoning exceeded its size limit.',
            );
          }
          reasoning.write(thought);
        }
        final content = delta['content'] ?? delta['refusal'];
        if (content is String && content.isNotEmpty) {
          message.append(content);
          if (!visible) {
            _messages.add(message);
            visible = true;
          }
        }
        final updates = delta['tool_calls'];
        if (updates is List) {
          for (final update in updates) {
            if (update is! Map || update['index'] is! int) {
              throw const ChatApiException('Invalid tool-call stream.');
            }
            final index = update['index'] as int;
            if (index < 0 || index >= maxToolCallsPerRound) {
              throw const ChatApiException(
                'Too many tool calls in one response.',
              );
            }
            calls
                .putIfAbsent(
                  index,
                  () => _ToolCallBuffer(inputBudget.maxMetadataBytes),
                )
                .add(update);
          }
        }
        _notify();
      }
      cancellation.check();
      message.finish();
      if (finishReason == null) {
        throw const ChatApiException(
          'The model did not finish its response.',
        );
      }
      if (calls.isEmpty) {
        if (finishReason == 'tool_calls') {
          throw const ChatApiException(
            'The model finished without its tool calls.',
          );
        }
        if (finishReason != 'stop') {
          throw ChatApiException(
            finishReason == 'length'
                ? 'The model reached its output limit. The partial reply is shown.'
                : 'The model could not complete this reply.',
          );
        }
        working.add({'role': 'assistant', 'content': message.text});
        _history
          ..clear()
          ..addAll(retained)
          ..add(working);
        while (_history.length > maxHistoryTurns) {
          _history.removeAt(0);
        }
        return;
      }
      if (finishReason != 'tool_calls') {
        throw const ChatApiException(
          'The model returned incomplete tool calls.',
        );
      }
      if (round >= maxToolRounds) {
        throw const ChatApiException('The tool round limit was reached.');
      }
      final ordered = calls.keys.toList()..sort();
      final completed = [for (final i in ordered) calls[i]!.finish()];
      if (completed.map((c) => c.id).toSet().length != completed.length) {
        throw const ChatApiException('The model reused a tool-call ID.');
      }
      working.add({
        'role': 'assistant',
        if (reasoning.isNotEmpty) 'reasoning_content': reasoning.toString(),
        'content': message.text.isEmpty ? null : message.text,
        'tool_calls': [for (final c in completed) c.toJson()],
      });
      for (final call in completed) {
        cancellation.check();
        final tool = tools.where((t) => t.name == call.name).firstOrNull;
        final index = _messages.length;
        _messages.add(
          ChatMessageData(
            id: '$sessionId:$turnId:tool:${call.id}',
            role: ChatMessageRole.tool,
            text: call.name,
            turnId: turnId,
            metadata: {
              'toolCallId': call.id,
              'title': call.name,
              'kind': 'tool',
              'status': 'in_progress',
              'rawInput': call.arguments,
            },
          ),
        );
        _notify();
        var failed = false;
        String output;
        if (tool == null) {
          output = 'Error: unknown tool.';
          failed = true;
        } else {
          Map<String, Object?>? arguments;
          try {
            final value = jsonDecode(call.arguments);
            if (value is Map<String, dynamic>) {
              arguments = Map<String, Object?>.from(value);
            }
          } catch (_) {
            /* Do not execute malformed arguments. */
          }
          if (arguments == null) {
            output = 'Error: tool arguments must be a JSON object.';
            failed = true;
          } else if (tool.requiresApproval &&
              !await _approve(call, cancellation)) {
            output = 'Tool execution denied by the user.';
            failed = true;
          } else {
            cancellation.check();
            try {
              output = await cancellation.bind(
                tool.execute(arguments, cancellation),
              );
            } on ChatCancelled {
              rethrow;
            } catch (_) {
              output = 'Error: the tool failed.';
              failed = true;
            }
          }
        }
        cancellation.check();
        if (utf8.encode(output).length > inputBudget.maxMetadataBytes) {
          output = 'Error: tool output exceeded its size limit.';
          failed = true;
        }
        final original = _messages[index] as ChatMessageData;
        _messages[index] = original.copyWith(
          metadata: {
            ...original.metadata,
            'status': failed ? 'failed' : 'completed',
            'rawOutput': output,
          },
        );
        working.add({
          'role': 'tool',
          'tool_call_id': call.id,
          'content': output,
        });
        _notify();
      }
    }
  } on ChatCancelled {
    _settleTools('cancelled');
    _messages.add(
      ChatMessageData(
        role: ChatMessageRole.status,
        text: 'Response stopped.',
        turnId: turnId,
      ),
    );
  } catch (error) {
    _settleTools('failed');
    _error = error is ChatApiException
        ? error.toString()
        : 'The response could not be completed.';
    _messages.add(
      ChatMessageData(
        role: ChatMessageRole.error,
        text: _error!,
        turnId: turnId,
      ),
    );
  } finally {
    if (identical(_active, cancellation)) {
      _active = null;
      _permission = null;
      _permissionDecision = null;
      _trimTimeline();
      _notify();
    }
  }
}