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) &&
_pendingParts.isEmpty) ||
_generating) {
return;
}
_errorMessage = null;
_cancelRequested = false;
_generating = true;
notifyListeners();
_log('User Message: $text');
try {
// ── Downscale images to optimal dimension for LLM vision models ─────
List<MessageAttachment>? processedAttachments = attachments;
if (attachments != null && attachments.isNotEmpty) {
final optList = <MessageAttachment>[];
for (final att in attachments) {
if (att.bytes != null && ImageOptimizer.isImageMime(att.mimeType)) {
final optBytes = await ImageOptimizer.shrinkToOptimalSize(
att.bytes!,
maxDimension: 1024,
);
optList.add(att.copyWith(bytes: optBytes, size: optBytes.length));
} else {
optList.add(att);
}
}
processedAttachments = optList;
}
if (_pendingParts.isNotEmpty) {
final optParts = <ChatContentPart>[];
for (final part in _pendingParts) {
if (part is ImagePart && part.bytes != null) {
final optBytes = await ImageOptimizer.shrinkToOptimalSize(
part.bytes!,
maxDimension: 1024,
);
optParts.add(part.copyWith(bytes: optBytes));
} else {
optParts.add(part);
}
}
_pendingParts
..clear()
..addAll(optParts);
}
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)
List<ChatContentPart>? initialParts;
if (i == 0 && _pendingParts.isNotEmpty) {
final staged = <ChatContentPart>[];
if (prompt.trim().isNotEmpty) staged.add(TextPart(prompt));
final existingAttachmentBytes = processedAttachments
?.where((a) =>
a.bytes != null && ImageOptimizer.isImageMime(a.mimeType))
.map((a) => a.bytes!.length)
.toSet() ??
<int>{};
for (final part in _pendingParts) {
if (part is ImagePart && part.bytes != null) {
if (existingAttachmentBytes.contains(part.bytes!.length)) {
continue; // Prevent duplicate image across attachments and parts
}
}
staged.add(part);
}
if (staged.isNotEmpty) {
initialParts = staged;
}
}
final userMsg = ChatMessage(
id: _uuid.v4(),
content: prompt,
role: ChatRole.user,
attachments: (i == 0) ? processedAttachments : null,
parts: initialParts,
timestamp: DateTime.now(),
);
if (i == 0) {
_pendingParts.clear();
}
_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.';
// Append active skill instructions if present
if (_activeSkill != null && _activeSkill!.skillDef.trim().isNotEmpty) {
systemPrompt += '\n\n### Active Skill (${_activeSkill!.name}):\n${_activeSkill!.skillDef.trim()}';
}
// Append active dynamic skill instructions if present
for (final skill in _dynamicSkills) {
if (_activeSkillNames.contains(skill.name) &&
(skill.systemPrompt?.trim().isNotEmpty ?? false)) {
systemPrompt +=
'\n\n### Active Skill (${skill.name}):\n${skill.systemPrompt!.trim()}';
}
}
if (_dynamicSkills.isNotEmpty) {
systemPrompt += '\n\nAvailable Dynamic Skills (call activate_skills to activate):\n';
for (final s in _dynamicSkills) {
final status = _activeSkillNames.contains(s.name) ? '[Active]' : '[Inactive]';
systemPrompt += '- ${s.name} $status: ${s.description}\n';
}
}
// 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 = [];
if (_progressiveMcpDiscovery) {
final listTool = McpListToolsTool(mcpManager: _mcpManager, localTools: _localTools);
final callTool = McpCallToolTool(mcpManager: _mcpManager, localTools: _localTools);
allAvailableTools.add(listTool.toMCPTool());
allAvailableTools.add(callTool.toMCPTool());
} else {
allAvailableTools.addAll(_localTools.map((t) => t.toMCPTool()));
allAvailableTools.addAll(_mcpManager.availableTools);
}
// Dynamic Skills Tools
final activateTool = _dynamicSkills.isNotEmpty
? ActivateSkillsTool(
availableSkills: _dynamicSkills.map((s) => s.name).toList(),
onActivate: (skills) async {
_activeSkillNames.addAll(skills);
_log('[Skills] Activated: ${skills.join(", ")}');
notifyListeners();
return 'Activated skills: ${skills.join(", ")}';
},
)
: null;
final deactivateTool = _dynamicSkills.isNotEmpty
? DeactivateSkillsTool(
onDeactivate: (skills) async {
_activeSkillNames.removeAll(skills);
_log('[Skills] Deactivated: ${skills.join(", ")}');
notifyListeners();
return 'Deactivated skills: ${skills.join(", ")}';
},
)
: null;
// Episodic Memory Tool
final memoryTool = _enableEpisodicMemory
? RetrieveMemoryTool(memoryStore: episodicMemoryStore)
: null;
// Sub-Agent Delegation Tool
final delegateTool = _enableSubAgents
? DelegateTaskTool(
runner: ({
required String assignee,
required String task,
List<String>? enabledTools,
}) async {
final subId = _uuid.v4();
final subExec = SubAgentExecution(
id: subId,
task: task,
depth: 1,
startedAt: DateTime.now(),
);
_subAgents[subId] = subExec;
_log('[SubAgent] Started: $task (assignee: $assignee)');
notifyListeners();
try {
final subTools = enabledTools != null
? allAvailableTools
.where((t) => enabledTools.contains(t.name))
.toList()
: allAvailableTools
.where((t) => t.name != 'delegate_task')
.toList();
final subMessages = <ChatMessage>[
ChatMessage.user(task),
];
String resultText = '';
int subIterations = 0;
const maxSubIterations = 5;
while (subIterations < maxSubIterations) {
subIterations++;
final subResponse = await LLMService.generate(
config: activeLlmConfig,
messages: subMessages,
tools: subTools,
systemPrompt:
'You are an autonomous specialist sub-agent ($assignee) tasked with: $task\n'
'You have access to specialized tools. You must actively execute the tools to inspect code, query telemetry, or audit dependencies. '
'Do not just state your intent or give conversational preamble. Run the tools immediately, analyze their results, and provide the complete audit report.',
);
if (subResponse.toolCalls.isEmpty) {
resultText = subResponse.text;
break;
}
// Execute each tool call and feed results back to the sub-agent
for (final call in subResponse.toolCalls) {
subExec.addProgress('Executing tool ${call.name}...');
notifyListeners();
final localMatch = _localTools
.where((t) => t.name == call.name)
.toList();
MCPToolResult toolRes;
if (localMatch.isNotEmpty) {
toolRes = await localMatch.first.execute(call.arguments);
} else {
toolRes = await _mcpManager.callTool(call.name, call.arguments);
}
final textRes = toolRes.content
.where((c) => c.type == 'text')
.map((c) => c.text ?? '')
.join('\n');
subExec.addProgress('Received output from ${call.name}');
notifyListeners();
subMessages.add(
ChatMessage(
id: call.id,
content: 'Calling tool: ${call.name}',
role: ChatRole.assistant,
type: MessageType.toolCall,
toolName: call.name,
toolArguments: call.arguments,
timestamp: DateTime.now(),
),
);
subMessages.add(
ChatMessage(
id: call.id,
role: ChatRole.tool,
type: MessageType.toolResponse,
toolName: call.name,
content: textRes,
timestamp: DateTime.now(),
),
);
}
}
if (resultText.isEmpty && subMessages.isNotEmpty) {
resultText = subMessages.last.content;
}
if (resultText.isEmpty) {
resultText = 'Completed task.';
}
subExec.complete(resultText);
_log('[SubAgent] Completed: $task');
notifyListeners();
return resultText;
} catch (e) {
subExec.fail(e.toString());
_log('[SubAgent] Failed: $e');
notifyListeners();
rethrow;
}
},
)
: null;
if (activateTool != null) allAvailableTools.add(activateTool.toMCPTool());
if (deactivateTool != null) allAvailableTools.add(deactivateTool.toMCPTool());
if (memoryTool != null) allAvailableTools.add(memoryTool.toMCPTool());
if (delegateTool != null) allAvailableTools.add(delegateTool.toMCPTool());
final List<MCPTool> activeStepTools = allAvailableTools.where((t) {
if (stepEnabled != null) {
return stepEnabled.contains(t.name);
}
if (_progressiveMcpDiscovery && (t.name == 'mcp_list_tools' || t.name == 'mcp_call_tool')) {
return true;
}
if (_enableEpisodicMemory && t.name == 'retrieve_memory') return true;
if (_enableSubAgents && t.name == 'delegate_task') return true;
if (_dynamicSkills.isNotEmpty &&
(t.name == 'activate_skills' || t.name == 'deactivate_skills')) {
return true;
}
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 (_progressiveMcpDiscovery && call.name == 'mcp_list_tools') {
result = await McpListToolsTool(mcpManager: _mcpManager, localTools: _localTools).execute(call.arguments);
} else if (_progressiveMcpDiscovery && call.name == 'mcp_call_tool') {
result = await McpCallToolTool(mcpManager: _mcpManager, localTools: _localTools).execute(call.arguments);
} else if (memoryTool != null && call.name == 'retrieve_memory') {
result = await memoryTool.execute(call.arguments);
} else if (delegateTool != null && call.name == 'delegate_task') {
result = await delegateTool.execute(call.arguments);
} else if (activateTool != null && call.name == 'activate_skills') {
result = await activateTool.execute(call.arguments);
} else if (deactivateTool != null && call.name == 'deactivate_skills') {
result = await deactivateTool.execute(call.arguments);
} else 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();
}
}