sendMessage method

Future<void> sendMessage(
  1. String text, {
  2. List<MessageAttachment>? attachments,
})

Sends a message and triggers the agentic tool call loop.

Implementation

Future<void> sendMessage(
  String text, {
  List<MessageAttachment>? attachments,
}) async {
  if ((text.trim().isEmpty && (attachments == null || attachments.isEmpty)) ||
      _generating) {
    return;
  }

  _errorMessage = null;
  _cancelRequested = false;
  _generating = true;
  notifyListeners();

  _log('User Message: $text');

  try {
    final subPrompts = parseSubPromptSteps(text);
    _log('Parsed ${subPrompts.length} sub-prompt steps.');

    String? lastToolOutput;
    String? lastTaskResult;

    for (int i = 0; i < subPrompts.length; i++) {
      // ── Cancellation check between sub-prompts ──────────────────────────
      if (_cancelRequested) {
        _messages.add(
          ChatMessage(
            id: _uuid.v4(),
            content:
                'Execution cancelled by user. Remaining sub-prompts skipped.',
            role: ChatRole.system,
            type: MessageType.log,
            timestamp: DateTime.now(),
          ),
        );
        notifyListeners();
        break;
      }
      final step = subPrompts[i];
      String prompt = step.text;

      // Substitute placeholders from previous steps
      if (lastToolOutput != null) {
        prompt = prompt
            .replaceAll(r'${tool_result}', lastToolOutput)
            .replaceAll('[tool_result]', lastToolOutput);
        lastToolOutput = null;
      }
      if (lastTaskResult != null) {
        prompt = prompt
            .replaceAll(r'${task_result}', lastTaskResult)
            .replaceAll('[task_result]', lastTaskResult);
        lastTaskResult = null;
      }

      // Determine if next step needs tool result
      final nextNeedsToolResult =
          (i + 1 < subPrompts.length) &&
          (subPrompts[i + 1].text.contains(r'${tool_result}') ||
              subPrompts[i + 1].text.contains('[tool_result]'));

      // Reset tool loop tracking for this step
      _toolIterationCount = 0;
      _forceNoToolCallsNextTurn = false;
      _forcedNoToolHintNextTurn = null;
      _executedToolCallSignatures.clear();
      _executedToolCallIds.clear();

      // 1. Add User Message (first step uses attachments if any, subsequent steps don't)
      final userMsg = ChatMessage(
        id: _uuid.v4(),
        content: prompt,
        role: ChatRole.user,
        attachments: (i == 0) ? attachments : null,
        timestamp: DateTime.now(),
      );
      _messages.add(userMsg);
      notifyListeners();

      final stepNewMsgs = <ChatMessage>[];

      // 3. Execution System Prompt
      String systemPrompt = _systemPrompt.trim().isNotEmpty
          ? _systemPrompt
          : 'You are an agent equipped with tools. Focus on the user\'s task. '
                'Use the tool schemas precisely. If you decide to call a tool, generate the tool call block. '
                'Present final answers directly. Present code and logs inside clean formatting.';

      // Inject short instructions into the system prompt to guide tool execution and loop prevention
      systemPrompt +=
          '\n\n'
          'Tool execution rules:\n'
          '- Each tool execution result is returned in a JSON structure: {"tool": "name", "id": "unique_id", "tool_executed": true, "tool_result": ...}.\n'
          '- Once a tool has been successfully executed (tool_executed is true), you must NEVER call that tool with the same "id" or parameters again.\n'
          '- Instead, formulate your final response to the user using the result provided in tool_result.';

      // Filter step tools
      final stepEnabled = step.enabledToolNames;
      final bool stepHasTools = stepEnabled != null
          ? stepEnabled.isNotEmpty
          : (_localTools.isNotEmpty || _mcpManager.availableTools.isNotEmpty);

      // Inject active tool descriptions into system prompt
      final List<MCPTool> allAvailableTools = [];
      allAvailableTools.addAll(_localTools.map((t) => t.toMCPTool()));
      allAvailableTools.addAll(_mcpManager.availableTools);

      final List<MCPTool> activeStepTools = allAvailableTools.where((t) {
        if (stepEnabled != null) {
          return stepEnabled.contains(t.name);
        }
        return _enabledToolNames.contains(t.name);
      }).toList();

      if (stepHasTools &&
          activeStepTools.isNotEmpty &&
          !activeLlmConfig.useNativeToolCall) {
        systemPrompt += '\n\nAvailable Tools:\n';
        for (final tool in activeStepTools) {
          systemPrompt += '- Tool Name: ${tool.name}\n';
          if (tool.description != null && tool.description!.isNotEmpty) {
            systemPrompt += '  Description: ${tool.description}\n';
          }
          if (tool.inputSchema != null) {
            systemPrompt +=
                '  Input Schema: ${jsonEncode(tool.inputSchema)}\n';
          }
        }
      }

      _log('System Prompt for step ${i + 1}:\n$systemPrompt');

      bool continueLoop = true;

      while (continueLoop) {
        // ── Cancellation check between tool iterations ───────────────────
        if (_cancelRequested) {
          _messages.add(
            ChatMessage(
              id: _uuid.v4(),
              content: 'Execution cancelled by user.',
              role: ChatRole.system,
              type: MessageType.log,
              timestamp: DateTime.now(),
            ),
          );
          notifyListeners();
          break;
        }

        if (_toolIterationCount >= _maxToolIterations) {
          final limitMsg = ChatMessage(
            id: _uuid.v4(),
            content:
                'Maximum tool iteration limit ($_maxToolIterations) reached. '
                'Please refine your request or ask for help.',
            role: ChatRole.assistant,
            timestamp: DateTime.now(),
          );
          _messages.add(limitMsg);
          stepNewMsgs.add(limitMsg);
          notifyListeners();
          break;
        }

        // Build tools list (may be suppressed on forced-final turn)
        final List<MCPTool> mcpTools = [];
        if (!_chatMode && !_forceNoToolCallsNextTurn) {
          mcpTools.addAll(activeStepTools);
        }

        final List<ChatMessage> requestMsgs = await _preprocessMessagesForLlm(
          _messages,
          activeLlmConfig.isMultiModal,
        );
        if (_forceNoToolCallsNextTurn && _forcedNoToolHintNextTurn != null) {
          requestMsgs.add(
            ChatMessage(
              id: _uuid.v4(),
              content: _forcedNoToolHintNextTurn!,
              role: ChatRole.user,
              timestamp: DateTime.now(),
            ),
          );
          _forceNoToolCallsNextTurn = false;
          _forcedNoToolHintNextTurn = null;
        }

        _log('Generating LLM response...');
        final LLMResponse response;
        if (activeLlmConfig.useStreaming ||
            activeLlmConfig.isSlm ||
            activeLlmConfig.provider == LlmProvider.ollama) {
          final assistantId = _uuid.v4();
          final streamMsg = ChatMessage(
            id: assistantId,
            content: '',
            role: ChatRole.assistant,
            timestamp: DateTime.now(),
          );
          _messages.add(streamMsg);
          notifyListeners();

          final textBuffer = StringBuffer();
          LLMResponse? finalResponse;

          try {
            await for (final chunk in LLMService.generateStream(
              config: activeLlmConfig,
              messages: requestMsgs,
              tools: mcpTools,
              systemPrompt: systemPrompt,
            )) {
              if (_cancelRequested) break;
              if (chunk.textDelta.isNotEmpty) {
                textBuffer.write(chunk.textDelta);
                final idx = _messages.indexWhere((m) => m.id == assistantId);
                if (idx != -1) {
                  _messages[idx] = streamMsg.copyWith(content: textBuffer.toString());
                  notifyListeners();
                }
              }
              if (chunk.isDone) {
                finalResponse = chunk.finalResponse;
              }
            }
          } finally {
            _messages.removeWhere((m) => m.id == assistantId);
          }

          if (_cancelRequested) break;
          response = finalResponse ?? LLMResponse(text: textBuffer.toString());
        } else {
          response = await LLMService.generate(
            config: activeLlmConfig,
            messages: requestMsgs,
            tools: mcpTools,
            systemPrompt: systemPrompt,
          );
        }

        // ── Cancellation check after LLM returns ──────────────────────
        if (_cancelRequested) {
          _messages.add(
            ChatMessage(
              id: _uuid.v4(),
              content: 'Execution cancelled by user.',
              role: ChatRole.system,
              type: MessageType.log,
              timestamp: DateTime.now(),
            ),
          );
          notifyListeners();
          break;
        }

        if (response.toolCalls.isEmpty) {
          if (response.text.isNotEmpty) {
            final textMsg = ChatMessage(
              id: _uuid.v4(),
              content: response.text,
              role: ChatRole.assistant,
              timestamp: DateTime.now(),
            );
            _messages.add(textMsg);
            stepNewMsgs.add(textMsg);
            _log('Assistant Response: ${response.text}');
          }
          continueLoop = false;
        } else {
          final call = response.toolCalls.first;
          _log(
            'Assistant Tool Call: ${call.name} with arguments: ${jsonEncode(call.arguments)}',
          );

          _toolIterationCount++;
          final toolSignature = '${call.name}|${jsonEncode(call.arguments)}';
          final hasDuplicateId = _executedToolCallIds.contains(call.id);
          final hasDuplicateSignature = _executedToolCallSignatures.contains(
            toolSignature,
          );

          if (hasDuplicateId || hasDuplicateSignature) {
            String previousResult = _messages
                .lastWhere(
                  (m) => m.role == ChatRole.tool && m.toolName == call.name,
                  orElse: () => ChatMessage(
                    id: '',
                    content: '',
                    role: ChatRole.tool,
                    timestamp: DateTime.now(),
                  ),
                )
                .content;

            if (previousResult.trim().startsWith('{')) {
              try {
                final decoded = jsonDecode(previousResult);
                if (decoded is Map && decoded.containsKey('tool_result')) {
                  previousResult = decoded['tool_result'].toString();
                }
              } catch (_) {}
            }

            final loopCorrectionText =
                'The tool "${call.name}" was already successfully executed. '
                'Previous result: $previousResult\n\n'
                'Do NOT call this tool again. Generate the final response using this result.';

            final dupMsg = ChatMessage(
              id: call.id,
              content: activeLlmConfig.provider == LlmProvider.ollama
                  ? jsonEncode({
                      'tool': call.name,
                      'id': call.id,
                      'tool_executed': true,
                      'tool_result': loopCorrectionText,
                    })
                  : loopCorrectionText,
              role: ChatRole.tool,
              type: MessageType.toolResponse,
              toolName: call.name,
              toolResult: MCPToolResult(
                content: [MCPContent(type: 'text', text: loopCorrectionText)],
                isError: false,
              ),
              timestamp: DateTime.now(),
            );
            _messages.add(dupMsg);
            stepNewMsgs.add(dupMsg);
            notifyListeners();

            _forceNoToolCallsNextTurn = true;
            _forcedNoToolHintNextTurn =
                'The tool "${call.name}" has already been successfully executed with these parameters. '
                'Do NOT call this tool or any other tool again. Use the tool results in the history to write your final response now.';

            continueLoop = true;
            continue;
          }

          _executedToolCallIds.add(call.id);
          _executedToolCallSignatures.add(toolSignature);

          final callMsg = ChatMessage(
            id: call.id,
            content:
                'Calling tool: ${call.name} with arguments: ${jsonEncode(call.arguments)}',
            role: ChatRole.assistant,
            type: MessageType.toolCall,
            toolName: call.name,
            toolArguments: call.arguments,
            timestamp: DateTime.now(),
          );
          _messages.add(callMsg);
          stepNewMsgs.add(callMsg);
          notifyListeners();

          _log('Executing Tool: ${call.name}');
          MCPToolResult result;
          final localMatch = _localTools
              .where((t) => t.name == call.name)
              .toList();

          if (localMatch.isNotEmpty) {
            result = await localMatch.first.execute(call.arguments);
          } else {
            result = await _mcpManager.callTool(call.name, call.arguments);
          }

          final String responseContentText = result.content
              .where((c) => c.type == 'text')
              .map((c) => c.text ?? '')
              .join('\n');

          _log('Tool Result: $responseContentText');

          final String finalContent;
          if (activeLlmConfig.provider == LlmProvider.ollama) {
            finalContent = jsonEncode({
              'tool': call.name,
              'id': call.id,
              'tool_executed': true,
              'tool_result': responseContentText.isNotEmpty
                  ? responseContentText
                  : 'Executed.',
            });
          } else {
            finalContent = responseContentText.isNotEmpty
                ? responseContentText
                : 'Executed.';
          }

          final resMsg = ChatMessage(
            id: call.id,
            content: finalContent,
            role: ChatRole.tool,
            type: MessageType.toolResponse,
            toolName: call.name,
            toolResult: result,
            timestamp: DateTime.now(),
          );
          _messages.add(resMsg);
          stepNewMsgs.add(resMsg);
          notifyListeners();

          final bool shouldStop =
              step.stopAfterToolCall ||
              _stopAfterToolCall ||
              nextNeedsToolResult;
          if (shouldStop) {
            continueLoop = false;
          }
        }
      }

      // Post-step processing: capture step output
      final toolTexts = stepNewMsgs
          .where((m) => m.role == ChatRole.tool && m.content.isNotEmpty)
          .map((m) {
            if (m.content.trim().startsWith('{')) {
              try {
                final decoded = jsonDecode(m.content);
                if (decoded is Map && decoded.containsKey('tool_result')) {
                  return decoded['tool_result'].toString();
                }
              } catch (_) {}
            }
            return m.content;
          })
          .join('\n\n');

      final assistantTexts = stepNewMsgs
          .where(
            (m) =>
                m.role == ChatRole.assistant &&
                m.content.isNotEmpty &&
                m.type != MessageType.toolCall,
          )
          .map((m) => m.content)
          .join('\n\n');

      final stepOutput = toolTexts.isNotEmpty
          ? toolTexts
          : (assistantTexts.isNotEmpty ? assistantTexts : null);

      if (stepOutput != null) {
        lastTaskResult = stepOutput;
        if (nextNeedsToolResult) {
          lastToolOutput = stepOutput;
          _log(
            'Captured step output for \${tool_result}/\${task_result} (${stepOutput.length} chars)',
          );
        } else {
          _log(
            'Captured step output for \${task_result} (${stepOutput.length} chars)',
          );
        }
      }

      final bool globalStopActive =
          _stopAfterToolCall && !nextNeedsToolResult;
      if (globalStopActive) {
        final nextWantsResult =
            (i + 1 < subPrompts.length) &&
            (subPrompts[i + 1].text.contains(r'${task_result}') ||
                subPrompts[i + 1].text.contains('[task_result]'));
        if (!nextWantsResult &&
            stepNewMsgs.any((m) => m.role == ChatRole.tool)) {
          _log(
            '[stopAfterToolCall] Breaking sub-prompt chain after step ${i + 1} — no result consumer in next step',
          );
          break;
        }
      }
    }
  } catch (e) {
    _errorMessage = 'Execution error: $e';
    _log('Execution error: $e');
    _messages.add(
      ChatMessage(
        id: _uuid.v4(),
        content: 'Error: $e',
        role: ChatRole.system,
        type: MessageType.log,
        timestamp: DateTime.now(),
      ),
    );
  } finally {
    _generating = false;
    notifyListeners();
  }
}