sendMessage method
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();
}
}