sendMessage method

Future<ConversationTurn> sendMessage(
  1. String text, {
  2. List<Map<String, dynamic>>? attachments,
})

Send a user message and run the agentic loop.

Implementation

Future<ConversationTurn> sendMessage(
  String text, {
  List<Map<String, dynamic>>? attachments,
}) async {
  _cancelled = false;
  final stopwatch = Stopwatch()..start();

  // Build user message
  final userContent = <Map<String, dynamic>>[];
  if (text.isNotEmpty) {
    userContent.add({'type': 'text', 'text': text});
  }
  if (attachments != null) {
    userContent.addAll(attachments);
  }

  final userMsg = {'role': 'user', 'content': userContent};
  _messages.add(userMsg);

  final toolExecutions = <ToolExecution>[];
  int turnInputTokens = 0;
  int turnOutputTokens = 0;

  try {
    _setState(ConversationState.streaming);

    // Agentic loop — keep calling API until no more tool_use
    var loopCount = 0;
    while (loopCount < _config.maxTurns && !_cancelled) {
      loopCount++;

      // Build API request
      final request = _buildRequest();

      // Stream the response
      final response = await _streamResponse(request);
      turnInputTokens += response.inputTokens;
      turnOutputTokens += response.outputTokens;

      // Add assistant message to history
      _messages.add({'role': 'assistant', 'content': response.contentBlocks});

      // Check if there are tool_use blocks
      final toolUseBlocks = response.contentBlocks
          .where((b) => b['type'] == 'tool_use')
          .toList();

      if (toolUseBlocks.isEmpty || response.stopReason != 'tool_use') {
        // No more tool calls — turn is complete
        break;
      }

      // Execute tools
      _setState(ConversationState.executingTool);
      final toolResults = <Map<String, dynamic>>[];

      for (final toolBlock in toolUseBlocks) {
        if (_cancelled) break;

        final toolName = toolBlock['name'] as String;
        final toolId = toolBlock['id'] as String;
        final toolInput = toolBlock['input'] as Map<String, dynamic>? ?? {};

        _events.add(ToolUseRequested(toolName, toolId, toolInput));

        // Check permission
        final permitted = await _checkPermission(toolName, toolInput);
        if (!permitted) {
          toolResults.add({
            'type': 'tool_result',
            'tool_use_id': toolId,
            'content': 'Permission denied by user.',
            'is_error': true,
          });
          toolExecutions.add(
            ToolExecution(
              toolName: toolName,
              toolUseId: toolId,
              input: toolInput,
              output: 'Permission denied by user.',
              isError: true,
              duration: Duration.zero,
              permissionGranted: false,
            ),
          );
          continue;
        }

        // Execute the tool
        _events.add(ToolExecutionStarted(toolName, toolId));
        final execStopwatch = Stopwatch()..start();

        try {
          final resolvedName = _config.toolAliases?[toolName] ?? toolName;
          final tool = _toolRegistry.get(resolvedName);

          String result;
          bool isError = false;

          if (tool != null) {
            final output = await tool
                .execute(toolInput)
                .timeout(_config.toolTimeout);
            result = output.content;
          } else {
            result = 'Tool "$toolName" not found.';
            isError = true;
          }

          execStopwatch.stop();

          // Truncate very long outputs
          if (result.length > 100000) {
            result =
                '${result.substring(0, 100000)}\n\n[Output truncated — ${result.length} chars total]';
          }

          toolResults.add({
            'type': 'tool_result',
            'tool_use_id': toolId,
            'content': result,
            if (isError) 'is_error': true,
          });

          final execution = ToolExecution(
            toolName: toolName,
            toolUseId: toolId,
            input: toolInput,
            output: result,
            isError: isError,
            duration: execStopwatch.elapsed,
            permissionGranted: true,
          );
          toolExecutions.add(execution);
          _events.add(ToolExecutionCompleted(execution));
        } on TimeoutException {
          execStopwatch.stop();
          toolResults.add({
            'type': 'tool_result',
            'tool_use_id': toolId,
            'content':
                'Tool execution timed out after ${_config.toolTimeout.inSeconds}s.',
            'is_error': true,
          });
          toolExecutions.add(
            ToolExecution(
              toolName: toolName,
              toolUseId: toolId,
              input: toolInput,
              output: 'Timeout',
              isError: true,
              duration: execStopwatch.elapsed,
            ),
          );
        } catch (e) {
          execStopwatch.stop();
          toolResults.add({
            'type': 'tool_result',
            'tool_use_id': toolId,
            'content': 'Error: $e',
            'is_error': true,
          });
          toolExecutions.add(
            ToolExecution(
              toolName: toolName,
              toolUseId: toolId,
              input: toolInput,
              output: 'Error: $e',
              isError: true,
              duration: execStopwatch.elapsed,
            ),
          );
        }
      }

      // Add tool results as user message
      _messages.add({'role': 'user', 'content': toolResults});

      // Continue the loop (next API call will see tool results)
      _setState(ConversationState.streaming);
    }

    stopwatch.stop();

    // Build turn summary
    final userMessage = Message(
      role: MessageRole.user,
      content: [TextBlock(text)],
    );
    final assistantText = _extractAssistantText();
    final assistantMessage = Message(
      role: MessageRole.assistant,
      content: [TextBlock(assistantText)],
    );

    final turn = ConversationTurn(
      userMessage: userMessage,
      assistantMessage: assistantMessage,
      toolExecutions: toolExecutions,
      duration: stopwatch.elapsed,
      inputTokens: turnInputTokens,
      outputTokens: turnOutputTokens,
      cost: _estimateCost(turnInputTokens, turnOutputTokens),
      turnIndex: _turns.length,
    );

    _turns.add(turn);
    _totalInputTokens += turnInputTokens;
    _totalOutputTokens += turnOutputTokens;

    _events.add(
      TokenUsageUpdated(
        _totalInputTokens,
        _totalOutputTokens,
        _totalCacheReadTokens,
        _totalCacheCreationTokens,
      ),
    );
    _events.add(TurnCompleted(turn));
    _setState(ConversationState.idle);

    return turn;
  } catch (e, st) {
    stopwatch.stop();
    _events.add(ConversationError(e, st));
    _setState(ConversationState.error);
    rethrow;
  }
}