25
import 'package:localmind/features/servers/providers/server_providers.dart';
26
import 'package:localmind/objectbox.g.dart';
27
import '../data/chat_service.dart';
28
+import '../data/models/chat_parameters.dart';
29
+import '../data/models/mcp_integration.dart';
30
import '../data/models/message.dart' hide ToolCallData;
31
+import '../data/models/message.dart' as msg_model
32
+ show ToolCallData;
33
import '../data/title_generation_service.dart';
34
import '../data/tools/tool_definition.dart';
35
import '../data/tools/tool_event.dart';
1230
toolEvents: toolEvents,
1231
);
1232
}
1233
+
1234
+ // After successful tool execution the chat must
1235
+ // continue: the model has emitted a tool call, the
1236
+ // client (or server) executed it, and now we need to
1237
+ // send a follow-up chat completion containing the
1238
+ // tool-role result(s) so the model can produce the
1239
+ // final answer. Without this, the conversation just
1240
+ // ends as soon as the tool returns — see issue #77.
1241
+ final completedResults = toolEvents
1242
+ .where((e) => e.status == ToolEventStatus.completed)
1243
+ .toList();
1244
+ if (ref.mounted &&
1245
+ completedResults.isNotEmpty &&
1246
+ chatService != null) {
1247
+ await _sendFollowupWithToolResults(
1248
+ previousAssistant: finalMessage,
1249
+ toolEvents: toolEvents,
1250
+ server: server,
1251
+ selectedModel: selectedModel,
1252
+ effectiveModelId: effectiveModelId,
1253
+ chatService: chatService,
1254
+ chatParams: chatParams,
1255
+ tools: tools,
1256
+ integrations: integrations,
1257
+ streamConvId: streamConvId,
1258
+ isCurrentContext: isCurrentContext,
1259
+ );
1260
+ // After the follow-up the original `finalMessage`
1261
+ // already contains its `toolEvents` and is on disk;
1262
+ // mark it as final and bail out of the rest of the
1263
+ // post-stream bookkeeping that was meant for the
1264
+ // first turn only.
1265
+ if (ref.mounted) {
1266
+ await _saveMessage(finalMessage);
1267
+ if (isCurrentContext) {
1268
+ _replaceMessageInState(
1269
+ finalMessage,
1270
+ clearStreaming: true,
1271
+ );
1272
+ }
1273
+ }
1274
+ return;
1275
+ }
1276
} catch (e) {
1277
Log.error('Tool execution loop failed: $e');
1278
}
2062
await _runAssistantStream(assistantMessage, selectedModel);
2063
}
2064
2065
+ /// After successful tool execution (issue #77), build `tool` role messages
2066
+ /// for each completed tool event, persist them, and re-issue the chat
2067
+ /// completion so the model can produce the final answer.
2068
+ ///
2069
+ /// The assistant message emitted in the first turn has its `toolCalls`
2070
+ /// field populated with the executed results, plus matching `MessageRole.tool`
2071
+ /// messages are appended right after it so the OpenAI-compatible protocol
2072
+ /// gets the `tool_call_id` ↔ `tool_calls[].id` pairing the model needs to
2073
+ /// continue the turn.
2074
+ Future<void> _sendFollowupWithToolResults({
2075
+ required Message previousAssistant,
2076
+ required List<ToolEvent> toolEvents,
2077
+ required Server server,
2078
+ required ModelInfo? selectedModel,
2079
+ required String effectiveModelId,
2080
+ required ChatService chatService,
2081
+ required ChatParameters chatParams,
2082
+ required List<ToolDefinition> tools,
2083
+ required List<McpIntegration>? integrations,
2084
+ required String streamConvId,
2085
+ required bool isCurrentContext,
2086
+ }) async {
2087
+ if (!ref.mounted) return;
2088
+
2089
+ final completed = toolEvents
2090
+ .where((e) => e.status == ToolEventStatus.completed)
2091
+ .toList();
2092
+ if (completed.isEmpty) return;
2093
+
2094
+ // Synthesize stable tool-call IDs that match between the assistant
2095
+ // message's `tool_calls[].id` and the tool role message's `tool_call_id`.
2096
+ // The streaming-side `ToolCallData` doesn't carry an OpenAI `id`, so we
2097
+ // mint one here and reuse it on both sides.
2098
+ String toolCallIdFor(ToolEvent event) {
2099
+ final uniqueSuffix = event.eventId.isNotEmpty
2100
+ ? event.eventId
2101
+ : '${event.toolName}_${event.timestamp.microsecondsSinceEpoch}';
2102
+ return 'call_${uniqueSuffix}_completed';
2103
+ }
2104
+
2105
+ final toolCallEntries = completed
2106
+ .map(
2107
+ (e) => msg_model.ToolCallData(
2108
+ id: toolCallIdFor(e),
2109
+ toolName: e.toolName,
2110
+ arguments: e.arguments ?? const {},
2111
+ result: e.result,
2112
+ ),
2113
+ )
2114
+ .toList();
2115
+
2116
+ final assistantWithToolCalls = previousAssistant.copyWith(
2117
+ toolCalls: toolCallEntries,
2118
+ stopReason: 'tool_use',
2119
+ );
2120
+ await _saveMessage(assistantWithToolCalls);
2121
+ if (!ref.mounted) return;
2122
+ if (isCurrentContext) {
2123
+ _replaceMessageInAll(assistantWithToolCalls, clearStreaming: true);
2124
+ }
2125
+
2126
+ final toolMessages = completed.map((e) {
2127
+ final callId = toolCallIdFor(e);
2128
+ return Message(
2129
+ id: 'tool_${callId}_${e.timestamp.microsecondsSinceEpoch}',
2130
+ conversationId: previousAssistant.conversationId,
2131
+ role: MessageRole.tool,
2132
+ content: e.result ?? e.error ?? '',
2133
+ createdAt: e.timestamp,
2134
+ status: MessageStatus.complete,
2135
+ toolCallId: callId,
2136
+ toolSessionId: previousAssistant.toolSessionId,
2137
+ parentMessageId: previousAssistant.id,
2138
+ threadOrder: previousAssistant.threadOrder + 0,
2139
+ variantGroupId: previousAssistant.variantGroupId,
2140
+ variantIndex: previousAssistant.variantIndex,
2141
+ );
2142
+ }).toList();
2143
+
2144
+ for (final tm in toolMessages) {
2145
+ await _saveMessage(tm);
2146
+ }
2147
+
2148
+ if (!ref.mounted) return;
2149
+
2150
+ // Persist the assistant+tool messages into state.allMessages so future
2151
+ // `_buildMessagesForApi(selectedModel)` calls (used by the follow-up
2152
+ // stream) include them. Without this the next request would be missing
2153
+ // the tool history and the model would re-emit the same tool call.
2154
+ final newAll = [...state.allMessages, ...toolMessages];
2155
+ final activeTimeline = MessageVariants.resolveActiveTimeline(newAll);
2156
+ state = state.copyWith(
2157
+ allMessages: newAll,
2158
+ messages: activeTimeline,
2159
+ );
2160
+
2161
+ // Create a new assistant message that will receive the model's final
2162
+ // answer. It shares the variant group with the previous assistant turn
2163
+ // so users see a single grouped assistant card that contains both the
2164
+ // tool-call turn and the follow-up answer.
2165
+ final continuationThreadOrder = MessageVariants.nextThreadOrder(
2166
+ state.allMessages,
2167
+ );
2168
+ final continuationMessage = Message(
2169
+ id: generateUuid(),
2170
+ conversationId: previousAssistant.conversationId,
2171
+ role: MessageRole.assistant,
2172
+ content: '',
2173
+ createdAt: DateTime.now(),
2174
+ status: MessageStatus.streaming,
2175
+ modelId: effectiveModelId,
2176
+ variantGroupId: previousAssistant.variantGroupId,
2177
+ variantIndex: previousAssistant.variantIndex + 1,
2178
+ threadOrder: continuationThreadOrder,
2179
+ isActiveVariant: true,
2180
+ parentMessageId: previousAssistant.id,
2181
+ );
2182
+
2183
+ final newAllWithAssistant = [...newAll, continuationMessage];
2184
+ state = state.copyWith(
2185
+ allMessages: newAllWithAssistant,
2186
+ messages: MessageVariants.resolveActiveTimeline(newAllWithAssistant),
2187
+ isStreaming: true,
2188
+ streamingMessage: continuationMessage,
2189
+ clearStreaming: false,
2190
+ );
2191
+ ref.read(isStreamingProvider.notifier).setStreaming(true);
2192
+ ref.read(chatBackgroundServiceProvider).start();
2193
+ await _saveMessage(continuationMessage);
2194
+ if (!ref.mounted) return;
2195
+
2196
+ // Reuse the streaming logic — `_runAssistantStream` builds the API
2197
+ // message list from state, which now contains the tool history, and
2198
+ // will issue a fresh chat completion. If the model emits another tool
2199
+ // call, the same onDone flow will execute it again until the model
2200
+ // yields a final answer or `maxIterations` (enforced inside
2201
+ // `ToolExecutionLoop`) is reached.
2202
+ await _runAssistantStream(continuationMessage, selectedModel);
2203
+ }
2204
+
2205
Future<void> _runAssistantStream(
2206
Message assistantMessage,
2207
ModelInfo? selectedModel, {