@tduncan / Cortana / commits / 2478d89

feat: add multi-server tool calling

abdulmominsakib committed May 26, 2026 at 20:39 UTC 2478d89db6e46f17f3cdbef471319a2f8c848903
29 files changed +1509 -46
lib/core/storage/entities.dart
+15
@@ -2,6 +2,7 @@ import 'dart:convert';
2 import 'package:objectbox/objectbox.dart';
3 import '../models/enums.dart';
4 import '../../features/chat/data/models/message.dart';
5 +import '../../features/chat/data/tools/tool_event.dart';
6 import '../../features/conversations/data/models/conversation.dart';
7 import '../../features/personas/data/models/persona.dart';
8 import '../../features/servers/data/models/server.dart';
@@ -293,6 +294,8 @@ class MessageEntity {
294 String? toolCallsJson;
295 String? toolCallId;
296 bool isProcessing;
297 + String? toolSessionId;
298 + String? toolEventsJson;
299
300 MessageEntity({
301 this.internalId = 0,
@@ -311,6 +314,8 @@ class MessageEntity {
314 this.toolCallsJson,
315 this.toolCallId,
316 this.isProcessing = false,
317 + this.toolSessionId,
318 + this.toolEventsJson,
319 });
320
321 factory MessageEntity.fromDomain(Message message) {
@@ -334,6 +339,10 @@ class MessageEntity {
339 : null,
340 toolCallId: message.toolCallId,
341 isProcessing: message.isProcessing,
342 + toolSessionId: message.toolSessionId,
343 + toolEventsJson: message.toolEvents != null
344 + ? jsonEncode(message.toolEvents!.map((e) => e.toMap()).toList())
345 + : null,
346 );
347 }
348
@@ -360,6 +369,12 @@ class MessageEntity {
369 : null,
370 toolCallId: toolCallId,
371 isProcessing: isProcessing,
372 + toolSessionId: toolSessionId,
373 + toolEvents: toolEventsJson != null
374 + ? (jsonDecode(toolEventsJson!) as List)
375 + .map((e) => ToolEvent.fromMap(Map<String, dynamic>.from(e)))
376 + .toList()
377 + : null,
378 );
379 }
380 }
lib/features/chat/data/chat_service.dart
+51
@@ -10,6 +10,10 @@ import '../../../core/models/enums.dart';
10 import '../../../core/logger/app_logger.dart';
11 import '../../on_device/data/on_device_gemma_service.dart';
12 import '../../on_device/data/on_device_chat_service.dart';
13 +import 'tools/adapters/tool_transport_adapter.dart';
14 +import 'tools/adapters/openai_tool_adapter.dart';
15 +import 'tools/adapters/openrouter_tool_adapter.dart';
16 +import 'tools/adapters/ollama_tool_adapter.dart';
17
18 abstract class ChatService {
19 Stream<ChatResponse> sendMessage({
@@ -449,6 +453,7 @@ class OpenAICompatibleChatService implements ChatService {
453 String? previousResponseId,
454 }) async* {
455 _cancelToken = CancelToken();
456 + final toolAdapter = OllamaToolAdapter();
457
458 final apiMessages = messages
459 .map((m) => {'role': _roleToString(m.role), 'content': m.content})
@@ -533,11 +538,21 @@ class OpenAICompatibleChatService implements ChatService {
538 if (data == '[DONE]') {
539 _timeoutTimer?.cancel();
540 Log.debug('OpenAICompatible: Received [DONE]');
541 + for (final call in toolAdapter.takeCompletedCalls()) {
542 + yield ChatResponse(
543 + type: ChatResponseType.toolCall,
544 + toolCall: ToolCallData(
545 + tool: call.name,
546 + arguments: call.arguments,
547 + ),
548 + );
549 + }
550 yield const ChatResponse(type: ChatResponseType.done);
551 return;
552 }
553 try {
554 final json = jsonDecode(data) as Map<String, dynamic>;
555 + toolAdapter.consumeDynamicChunk(json);
556
557 // Check for errors in the response
558 if (json['error'] != null) {
@@ -642,6 +657,7 @@ class OllamaChatService implements ChatService {
657 String? previousResponseId,
658 }) async* {
659 _cancelToken = CancelToken();
660 + final toolAdapter = OpenAiToolAdapter();
661
662 final body = {
663 'model': modelId,
@@ -678,6 +694,7 @@ class OllamaChatService implements ChatService {
694 if (line.isNotEmpty) {
695 try {
696 final json = jsonDecode(line) as Map<String, dynamic>;
697 + toolAdapter.consumeDynamicChunk(json);
698 final message = json['message'];
699 if (message != null && message is Map<String, dynamic>) {
700 final content = message['content'] as String?;
@@ -697,6 +714,15 @@ class OllamaChatService implements ChatService {
714 }
715 }
716 if (json['done'] == true) {
717 + for (final call in toolAdapter.takeCompletedCalls()) {
718 + yield ChatResponse(
719 + type: ChatResponseType.toolCall,
720 + toolCall: ToolCallData(
721 + tool: call.name,
722 + arguments: call.arguments,
723 + ),
724 + );
725 + }
726 yield const ChatResponse(type: ChatResponseType.done);
727 return;
728 }
@@ -738,6 +764,7 @@ class OpenRouterChatService implements ChatService {
764 String? previousResponseId,
765 }) async* {
766 _cancelToken = CancelToken();
767 + final toolAdapter = OpenRouterToolAdapter();
768
769 final body = {
770 'model': modelId,
@@ -813,11 +840,21 @@ class OpenRouterChatService implements ChatService {
840 if (data == '[DONE]') {
841 _timeoutTimer?.cancel();
842 Log.debug('OpenRouter: Received [DONE]');
843 + for (final call in toolAdapter.takeCompletedCalls()) {
844 + yield ChatResponse(
845 + type: ChatResponseType.toolCall,
846 + toolCall: ToolCallData(
847 + tool: call.name,
848 + arguments: call.arguments,
849 + ),
850 + );
851 + }
852 yield const ChatResponse(type: ChatResponseType.done);
853 return;
854 }
855 try {
856 final json = jsonDecode(data) as Map<String, dynamic>;
857 + toolAdapter.consumeDynamicChunk(json);
858
859 // Check for errors in the response
860 if (json['error'] != null) {
@@ -969,3 +1006,17 @@ String _roleToString(MessageRole role) {
1006 return 'tool';
1007 }
1008 }
1009 +
1010 +/// Returns the streaming-parsing adapter for a server type.
1011 +///
1012 +/// OpenAI-compatible and Ollama servers use adapter-based tool call parsing
1013 +/// from streaming chunks. LM Studio and on-device servers collect tool calls
1014 +/// directly from [ChatResponseType.toolCall] events emitted during streaming
1015 +/// and feed them to the loop via [ToolExecutionLoop.run]'s [preParsedCalls].
1016 +ToolTransportAdapter createAdapterForServerType(ServerType type) => switch (type) {
1017 + ServerType.openAICompatible => OpenAiToolAdapter(),
1018 + ServerType.openRouter => OpenRouterToolAdapter(),
1019 + ServerType.lmStudio => OpenAiToolAdapter(),
1020 + ServerType.onDevice => OpenAiToolAdapter(),
1021 + ServerType.ollama => OllamaToolAdapter(),
1022 +};
lib/features/chat/data/models/message.dart
+9
@@ -1,6 +1,7 @@
1 import 'dart:convert';
2
3 import '../../../../core/models/enums.dart';
4 +import '../tools/tool_event.dart';
5
6 class Message {
7 final String id;
@@ -18,6 +19,8 @@ class Message {
19 final List<ToolCallData>? toolCalls;
20 final String? toolCallId;
21 final bool isProcessing;
22 + final String? toolSessionId;
23 + final List<ToolEvent>? toolEvents;
24
25 Message({
26 required this.id,
@@ -35,6 +38,8 @@ class Message {
38 this.toolCalls,
39 this.toolCallId,
40 this.isProcessing = false,
41 + this.toolSessionId,
42 + this.toolEvents,
43 });
44
45 Message copyWith({
@@ -53,6 +58,8 @@ class Message {
58 List<ToolCallData>? toolCalls,
59 String? toolCallId,
60 bool? isProcessing,
61 + String? toolSessionId,
62 + List<ToolEvent>? toolEvents,
63 }) {
64 return Message(
65 id: id ?? this.id,
@@ -70,6 +77,8 @@ class Message {
77 toolCalls: toolCalls ?? this.toolCalls,
78 toolCallId: toolCallId ?? this.toolCallId,
79 isProcessing: isProcessing ?? this.isProcessing,
80 + toolSessionId: toolSessionId ?? this.toolSessionId,
81 + toolEvents: toolEvents ?? this.toolEvents,
82 );
83 }
84 }
lib/features/chat/data/tools/adapters/lm_studio_tool_adapter.dart new
+61
@@ -0,0 +1,61 @@
1 +class ServerExecutedToolCall {
2 + final String name;
3 + final Map<String, dynamic> arguments;
4 + final String output;
5 + const ServerExecutedToolCall({required this.name, required this.arguments, required this.output});
6 +}
7 +
8 +class LmStudioToolAdapter {
9 + final List<ServerExecutedToolCall> _completedCalls = [];
10 + String? _currentTool;
11 + Map<String, dynamic> _currentArgs = {};
12 +
13 + void consumeEvent(String eventType, Map<String, dynamic> data) {
14 + switch (eventType) {
15 + case 'tool_call.start':
16 + _currentTool = data['tool'] as String?;
17 + _currentArgs = {};
18 + break;
19 + case 'tool_call.arguments':
20 + final args = data['arguments'] as Map<String, dynamic>?;
21 + if (args != null) {
22 + _currentArgs = args;
23 + }
24 + break;
25 + case 'tool_call.success':
26 + final tool = data['tool'] as String? ?? _currentTool;
27 + final args = data['arguments'] as Map<String, dynamic>? ?? _currentArgs;
28 + final output = data['output'] as String? ?? '';
29 + if (tool != null) {
30 + _completedCalls.add(ServerExecutedToolCall(
31 + name: tool,
32 + arguments: args,
33 + output: output,
34 + ));
35 + }
36 + _currentTool = null;
37 + _currentArgs = {};
38 + break;
39 + case 'tool_call.failure':
40 + final tool = data['tool'] as String? ?? _currentTool;
41 + final args = data['arguments'] as Map<String, dynamic>? ?? _currentArgs;
42 + final reason = data['reason'] as String? ?? 'Tool execution failed';
43 + if (tool != null) {
44 + _completedCalls.add(ServerExecutedToolCall(
45 + name: tool,
46 + arguments: args,
47 + output: reason,
48 + ));
49 + }
50 + _currentTool = null;
51 + _currentArgs = {};
52 + break;
53 + }
54 + }
55 +
56 + List<ServerExecutedToolCall> takeServerExecutedCalls() {
57 + final calls = List<ServerExecutedToolCall>.from(_completedCalls);
58 + _completedCalls.clear();
59 + return calls;
60 + }
61 +}
lib/features/chat/data/tools/adapters/ollama_tool_adapter.dart new
+60
@@ -0,0 +1,60 @@
1 +import 'dart:convert';
2 +
3 +import 'package:localmind/features/chat/data/tools/adapters/tool_transport_adapter.dart';
4 +import 'package:localmind/features/chat/data/tools/tool_definition.dart';
5 +
6 +class OllamaToolAdapter implements ToolTransportAdapter {
7 + final List<ParsedToolCall> _completedCalls = [];
8 +
9 + @override
10 + Map<String, dynamic> buildToolDefinitionPayload(List<ToolDefinition> tools) {
11 + return {
12 + 'tools': tools.map((t) => {
13 + 'type': 'function',
14 + 'function': {
15 + 'name': t.name,
16 + 'description': t.description,
17 + 'parameters': t.inputSchema,
18 + },
19 + }).toList(),
20 + };
21 + }
22 +
23 + @override
24 + void consumeDynamicChunk(Map<String, dynamic> chunk) {
25 + final message = chunk['message'] as Map<String, dynamic>?;
26 + if (message == null) return;
27 + final toolCalls = message['tool_calls'] as List?;
28 + if (toolCalls == null) return;
29 + for (final tc in toolCalls) {
30 + final func = tc['function'] as Map<String, dynamic>?;
31 + if (func == null) continue;
32 + _completedCalls.add(ParsedToolCall(
33 + id: tc['id'] ?? '',
34 + name: func['name'] as String? ?? '',
35 + arguments: Map<String, dynamic>.from(
36 + jsonDecode(func['arguments'] as String? ?? '{}') as Map,
37 + ),
38 + ));
39 + }
40 + }
41 +
42 + @override
43 + List<ParsedToolCall> takeCompletedCalls() {
44 + final calls = List<ParsedToolCall>.from(_completedCalls);
45 + _completedCalls.clear();
46 + return calls;
47 + }
48 +
49 + @override
50 + Map<String, dynamic> buildToolResultMessage({
51 + required ParsedToolCall call,
52 + required String output,
53 + }) {
54 + return {
55 + 'role': 'tool',
56 + 'tool_name': call.name,
57 + 'content': output,
58 + };
59 + }
60 +}
lib/features/chat/data/tools/adapters/on_device_tool_adapter.dart new
+19
@@ -0,0 +1,19 @@
1 +import 'package:localmind/features/chat/data/tools/adapters/tool_transport_adapter.dart';
2 +
3 +class OnDeviceToolAdapter {
4 + final List<ParsedToolCall> _completedCalls = [];
5 +
6 + void consumeFunctionCall(String name, Map<String, dynamic> args) {
7 + _completedCalls.add(ParsedToolCall(
8 + id: 'ondevice_${_completedCalls.length}',
9 + name: name,
10 + arguments: Map<String, dynamic>.from(args),
11 + ));
12 + }
13 +
14 + List<ParsedToolCall> takeCompletedCalls() {
15 + final calls = List<ParsedToolCall>.from(_completedCalls);
16 + _completedCalls.clear();
17 + return calls;
18 + }
19 +}
lib/features/chat/data/tools/adapters/openai_tool_adapter.dart new
+85
@@ -0,0 +1,85 @@
1 +import 'dart:convert';
2 +
3 +import 'package:localmind/features/chat/data/tools/adapters/tool_transport_adapter.dart';
4 +import 'package:localmind/features/chat/data/tools/tool_definition.dart';
5 +
6 +class _AccumulatingChunk {
7 + String? id;
8 + String? name;
9 + final StringBuffer argumentsBuffer = StringBuffer();
10 +}
11 +
12 +class OpenAiToolAdapter implements ToolTransportAdapter {
13 + final Map<int, _AccumulatingChunk> _accumulators = {};
14 +
15 + @override
16 + Map<String, dynamic> buildToolDefinitionPayload(List<ToolDefinition> tools) {
17 + return {
18 + 'tools': tools.map((t) => {
19 + 'type': 'function',
20 + 'function': {
21 + 'name': t.name,
22 + 'description': t.description,
23 + 'parameters': t.inputSchema,
24 + },
25 + }).toList(),
26 + };
27 + }
28 +
29 + @override
30 + void consumeDynamicChunk(Map<String, dynamic> chunk) {
31 + final choices = chunk['choices'] as List?;
32 + if (choices == null || choices.isEmpty) return;
33 + final delta = choices[0]['delta'] as Map<String, dynamic>?;
34 + if (delta == null) return;
35 + final toolCalls = delta['tool_calls'] as List?;
36 + if (toolCalls == null) return;
37 + for (final tc in toolCalls) {
38 + final index = tc['index'] as int;
39 + final acc = _accumulators.putIfAbsent(index, () => _AccumulatingChunk());
40 + final func = tc['function'] as Map<String, dynamic>?;
41 + if (tc['id'] != null) acc.id = tc['id'] as String;
42 + if (func != null) {
43 + if (func['name'] != null) acc.name = func['name'] as String;
44 + if (func['arguments'] != null) {
45 + acc.argumentsBuffer.write(func['arguments'] as String);
46 + }
47 + }
48 + }
49 + }
50 +
51 + @override
52 + List<ParsedToolCall> takeCompletedCalls() {
53 + if (_accumulators.isEmpty) return const [];
54 + final calls = _accumulators.entries.map((e) {
55 + final acc = e.value;
56 + Map<String, dynamic> parsedArgs;
57 + try {
58 + parsedArgs = Map<String, dynamic>.from(
59 + jsonDecode(acc.argumentsBuffer.toString()) as Map,
60 + );
61 + } catch (_) {
62 + parsedArgs = {};
63 + }
64 + return ParsedToolCall(
65 + id: acc.id ?? '',
66 + name: acc.name ?? '',
67 + arguments: parsedArgs,
68 + );
69 + }).toList();
70 + _accumulators.clear();
71 + return calls;
72 + }
73 +
74 + @override
75 + Map<String, dynamic> buildToolResultMessage({
76 + required ParsedToolCall call,
77 + required String output,
78 + }) {
79 + return {
80 + 'role': 'tool',
81 + 'tool_call_id': call.id,
82 + 'content': output,
83 + };
84 + }
85 +}
lib/features/chat/data/tools/adapters/openrouter_tool_adapter.dart new
+6
@@ -0,0 +1,6 @@
1 +import 'package:localmind/features/chat/data/tools/adapters/openai_tool_adapter.dart';
2 +
3 +class OpenRouterToolAdapter extends OpenAiToolAdapter {
4 + // OpenRouter uses identical function calling protocol to OpenAI
5 + // No override needed — inherits all behavior
6 +}
lib/features/chat/data/tools/adapters/tool_transport_adapter.dart new
+23
@@ -0,0 +1,23 @@
1 +import 'package:localmind/features/chat/data/tools/tool_definition.dart';
2 +
3 +class ParsedToolCall {
4 + final String id;
5 + final String name;
6 + final Map<String, dynamic> arguments;
7 +
8 + const ParsedToolCall({
9 + required this.id,
10 + required this.name,
11 + required this.arguments,
12 + });
13 +}
14 +
15 +abstract class ToolTransportAdapter {
16 + Map<String, dynamic> buildToolDefinitionPayload(List<ToolDefinition> tools);
17 + void consumeDynamicChunk(Map<String, dynamic> chunk);
18 + List<ParsedToolCall> takeCompletedCalls();
19 + Map<String, dynamic> buildToolResultMessage({
20 + required ParsedToolCall call,
21 + required String output,
22 + });
23 +}
lib/features/chat/data/tools/builtin_tool_provider.dart new
+51
@@ -0,0 +1,51 @@
1 +import 'tool_definition.dart';
2 +import 'tool_registry.dart';
3 +
4 +class BuiltInToolProvider implements ToolProvider {
5 + @override
6 + Future<List<ToolDefinition>> listTools() async => const [
7 + ToolDefinition(
8 + name: 'calc.add',
9 + description: 'Add two numbers',
10 + inputSchema: {
11 + 'type': 'object',
12 + 'properties': {
13 + 'a': {'type': 'number'},
14 + 'b': {'type': 'number'},
15 + },
16 + 'required': ['a', 'b'],
17 + },
18 + providerType: ToolProviderType.builtIn,
19 + ),
20 + ToolDefinition(
21 + name: 'calc.multiply',
22 + description: 'Multiply two numbers',
23 + inputSchema: {
24 + 'type': 'object',
25 + 'properties': {
26 + 'a': {'type': 'number'},
27 + 'b': {'type': 'number'},
28 + },
29 + 'required': ['a', 'b'],
30 + },
31 + providerType: ToolProviderType.builtIn,
32 + ),
33 + ];
34 +
35 + @override
36 + Future<ToolExecutionResult> execute(
37 + String name,
38 + Map<String, dynamic> args,
39 + ) async {
40 + switch (name) {
41 + case 'calc.add':
42 + final sum = (args['a'] as num) + (args['b'] as num);
43 + return ToolExecutionResult.success(sum.toString());
44 + case 'calc.multiply':
45 + final product = (args['a'] as num) * (args['b'] as num);
46 + return ToolExecutionResult.success(product.toString());
47 + default:
48 + return const ToolExecutionResult.failure('Unknown built-in tool');
49 + }
50 + }
51 +}
lib/features/chat/data/tools/mcp_tool_provider.dart new
+47
@@ -0,0 +1,47 @@
1 +import 'package:localmind/features/chat/data/mcp_server_manager.dart';
2 +
3 +import 'tool_definition.dart';
4 +import 'tool_registry.dart';
5 +
6 +class McpToolProvider implements ToolProvider {
7 + final McpServerManager serverManager;
8 +
9 + McpToolProvider({required this.serverManager});
10 +
11 + @override
12 + Future<List<ToolDefinition>> listTools() async {
13 + final tools = <ToolDefinition>[];
14 + for (final label in serverManager.serverLabels) {
15 + final serverTools = serverManager.getTools(label);
16 + for (final tool in serverTools) {
17 + tools.add(ToolDefinition(
18 + name: tool.name,
19 + description: tool.description ?? '',
20 + inputSchema: tool.inputSchema,
21 + providerType: ToolProviderType.mcp,
22 + providerRef: label,
23 + ));
24 + }
25 + }
26 + return tools;
27 + }
28 +
29 + @override
30 + Future<ToolExecutionResult> execute(
31 + String name,
32 + Map<String, dynamic> args,
33 + ) async {
34 + for (final label in serverManager.serverLabels) {
35 + final serverTools = serverManager.getTools(label);
36 + if (serverTools.any((t) => t.name == name)) {
37 + try {
38 + final result = await serverManager.callTool(label, name, args);
39 + return ToolExecutionResult.success(result);
40 + } catch (e) {
41 + return ToolExecutionResult.failure(e.toString());
42 + }
43 + }
44 + }
45 + return const ToolExecutionResult.failure('MCP tool not found');
46 + }
47 +}
lib/features/chat/data/tools/tool_definition.dart new
+31
@@ -0,0 +1,31 @@
1 +enum ToolProviderType { builtIn, mcp, lmStudioServer }
2 +
3 +class ToolDefinition {
4 + final String name;
5 + final String description;
6 + final Map<String, dynamic> inputSchema;
7 + final ToolProviderType providerType;
8 + final String? providerRef;
9 +
10 + const ToolDefinition({
11 + required this.name,
12 + required this.description,
13 + required this.inputSchema,
14 + required this.providerType,
15 + this.providerRef,
16 + });
17 +}
18 +
19 +class ToolExecutionResult {
20 + final bool success;
21 + final String output;
22 + final String? error;
23 +
24 + const ToolExecutionResult.success(this.output)
25 + : success = true,
26 + error = null;
27 +
28 + const ToolExecutionResult.failure(this.error)
29 + : success = false,
30 + output = '';
31 +}
lib/features/chat/data/tools/tool_event.dart new
+78
@@ -0,0 +1,78 @@
1 +import 'tool_definition.dart';
2 +
3 +enum ToolEventStatus { requested, approved, rejected, running, completed, failed }
4 +
5 +class ToolEvent {
6 + final String eventId;
7 + final DateTime timestamp;
8 + final ToolEventStatus status;
9 + final String toolName;
10 + final ToolProviderType providerType;
11 + final String? providerRef;
12 + final Map<String, dynamic>? arguments;
13 + final String? result;
14 + final String? error;
15 + final int? durationMs;
16 +
17 + const ToolEvent({
18 + required this.eventId,
19 + required this.timestamp,
20 + required this.status,
21 + required this.toolName,
22 + required this.providerType,
23 + this.providerRef,
24 + this.arguments,
25 + this.result,
26 + this.error,
27 + this.durationMs,
28 + });
29 +
30 + factory ToolEvent.requested({
31 + required String eventId,
32 + required String toolName,
33 + required ToolProviderType providerType,
34 + String? providerRef,
35 + Map<String, dynamic>? arguments,
36 + }) =>
37 + ToolEvent(
38 + eventId: eventId,
39 + timestamp: DateTime.now(),
40 + status: ToolEventStatus.requested,
41 + toolName: toolName,
42 + providerType: providerType,
43 + providerRef: providerRef,
44 + arguments: arguments,
45 + );
46 +
47 + Map<String, dynamic> toMap() {
48 + return {
49 + 'eventId': eventId,
50 + 'timestamp': timestamp.toIso8601String(),
51 + 'status': status.name,
52 + 'toolName': toolName,
53 + 'providerType': providerType.name,
54 + 'providerRef': providerRef,
55 + 'arguments': arguments,
56 + 'result': result,
57 + 'error': error,
58 + 'durationMs': durationMs,
59 + };
60 + }
61 +
62 + factory ToolEvent.fromMap(Map<String, dynamic> map) {
63 + return ToolEvent(
64 + eventId: map['eventId'],
65 + timestamp: DateTime.parse(map['timestamp']),
66 + status: ToolEventStatus.values.byName(map['status']),
67 + toolName: map['toolName'],
68 + providerType: ToolProviderType.values.byName(map['providerType']),
69 + providerRef: map['providerRef'],
70 + arguments: map['arguments'] != null
71 + ? Map<String, dynamic>.from(map['arguments'])
72 + : null,
73 + result: map['result'],
74 + error: map['error'],
75 + durationMs: map['durationMs'],
76 + );
77 + }
78 +}
lib/features/chat/data/tools/tool_execution_loop.dart new
+197
@@ -0,0 +1,197 @@
1 +import 'dart:async';
2 +import 'tool_definition.dart';
3 +import 'tool_event.dart';
4 +import 'tool_registry.dart';
5 +import 'adapters/tool_transport_adapter.dart';
6 +
7 +class ToolLoopResult {
8 + final String finalAssistantContent;
9 + final List<ToolEvent> events;
10 + final bool completedWithinLimit;
11 + final int toolCallCount;
12 +
13 + const ToolLoopResult({
14 + required this.finalAssistantContent,
15 + required this.events,
16 + this.completedWithinLimit = true,
17 + this.toolCallCount = 0,
18 + });
19 +}
20 +
21 +typedef ToolApprovalCallback = Future<bool> Function(ParsedToolCall call);
22 +
23 +class ToolExecutionLoop {
24 + final ToolTransportAdapter adapter;
25 + final ToolRegistry registry;
26 + final int maxIterations;
27 + final Duration toolTimeout;
28 + final Duration approvalTimeout;
29 + final ToolApprovalCallback? onRequestApproval;
30 +
31 + String _sessionId = '';
32 + final List<ToolEvent> _events = [];
33 +
34 + ToolExecutionLoop({
35 + required this.adapter,
36 + required this.registry,
37 + this.maxIterations = 6,
38 + this.toolTimeout = const Duration(seconds: 30),
39 + this.approvalTimeout = const Duration(seconds: 60),
40 + this.onRequestApproval,
41 + });
42 +
43 + String get sessionId => _sessionId;
44 +
45 + Future<ToolLoopResult> run({
46 + required String initialUserMessage,
47 + List<Map<String, dynamic>> conversationHistory = const [],
48 + String? assistantContent,
49 + List<ParsedToolCall>? preParsedCalls,
50 + }) async {
51 + _sessionId = DateTime.now().millisecondsSinceEpoch.toString();
52 + _events.clear();
53 +
54 + int iteration = 0;
55 + int toolCallCount = 0;
56 + final toolDefinitions = {
57 + for (final tool in await registry.listTools()) tool.name: tool,
58 + };
59 +
60 + final callsToProcess = preParsedCalls ?? adapter.takeCompletedCalls();
61 +
62 + if (callsToProcess.isEmpty) {
63 + return ToolLoopResult(
64 + finalAssistantContent: assistantContent ?? '',
65 + events: const [],
66 + completedWithinLimit: true,
67 + toolCallCount: 0,
68 + );
69 + }
70 +
71 + iteration++;
72 + for (final call in callsToProcess) {
73 + toolCallCount++;
74 + final baseId = '${_sessionId}_${iteration}_${call.id}';
75 + final toolDefinition = toolDefinitions[call.name];
76 + final providerType = toolDefinition?.providerType ?? ToolProviderType.builtIn;
77 + final providerRef = toolDefinition?.providerRef;
78 +
79 + if (onRequestApproval != null) {
80 + _events.add(ToolEvent(
81 + eventId: '$baseId.requested',
82 + timestamp: DateTime.now(),
83 + status: ToolEventStatus.requested,
84 + toolName: call.name,
85 + providerType: providerType,
86 + providerRef: providerRef,
87 + arguments: call.arguments,
88 + ));
89 +
90 + try {
91 + final approved = await onRequestApproval!(call)
92 + .timeout(approvalTimeout);
93 + if (!approved) {
94 + _events.add(ToolEvent(
95 + eventId: '$baseId.rejected',
96 + timestamp: DateTime.now(),
97 + status: ToolEventStatus.rejected,
98 + toolName: call.name,
99 + providerType: providerType,
100 + providerRef: providerRef,
101 + arguments: call.arguments,
102 + error: 'Rejected by user',
103 + ));
104 + continue;
105 + }
106 + } on TimeoutException {
107 + _events.add(ToolEvent(
108 + eventId: '$baseId.rejected',
109 + timestamp: DateTime.now(),
110 + status: ToolEventStatus.rejected,
111 + toolName: call.name,
112 + providerType: providerType,
113 + providerRef: providerRef,
114 + arguments: call.arguments,
115 + error: 'Approval timed out',
116 + ));
117 + continue;
118 + } catch (_) {
119 + _events.add(ToolEvent(
120 + eventId: '$baseId.failed',
121 + timestamp: DateTime.now(),
122 + status: ToolEventStatus.failed,
123 + toolName: call.name,
124 + providerType: providerType,
125 + providerRef: providerRef,
126 + arguments: call.arguments,
127 + error: 'Approval callback error',
128 + ));
129 + continue;
130 + }
131 +
132 + _events.add(ToolEvent(
133 + eventId: '$baseId.approved',
134 + timestamp: DateTime.now(),
135 + status: ToolEventStatus.approved,
136 + toolName: call.name,
137 + providerType: providerType,
138 + providerRef: providerRef,
139 + arguments: call.arguments,
140 + ));
141 + }
142 +
143 + _events.add(ToolEvent(
144 + eventId: '$baseId.running',
145 + timestamp: DateTime.now(),
146 + status: ToolEventStatus.running,
147 + toolName: call.name,
148 + providerType: providerType,
149 + providerRef: providerRef,
150 + arguments: call.arguments,
151 + ));
152 +
153 + final stopwatch = Stopwatch()..start();
154 + try {
155 + final result = await registry
156 + .execute(call.name, call.arguments)
157 + .timeout(toolTimeout);
158 +
159 + stopwatch.stop();
160 +
161 + _events.add(ToolEvent(
162 + eventId: '$baseId.${result.success ? "completed" : "failed"}',
163 + timestamp: DateTime.now(),
164 + status: result.success ? ToolEventStatus.completed : ToolEventStatus.failed,
165 + toolName: call.name,
166 + providerType: providerType,
167 + providerRef: providerRef,
168 + arguments: call.arguments,
169 + result: result.success ? result.output : null,
170 + error: result.success ? null : result.error,
171 + durationMs: stopwatch.elapsedMilliseconds,
172 + ));
173 + } catch (e) {
174 + stopwatch.stop();
175 +
176 + _events.add(ToolEvent(
177 + eventId: '$baseId.failed',
178 + timestamp: DateTime.now(),
179 + status: ToolEventStatus.failed,
180 + toolName: call.name,
181 + providerType: providerType,
182 + providerRef: providerRef,
183 + arguments: call.arguments,
184 + error: 'Timeout or error: $e',
185 + durationMs: stopwatch.elapsedMilliseconds,
186 + ));
187 + }
188 + }
189 +
190 + return ToolLoopResult(
191 + finalAssistantContent: assistantContent ?? '',
192 + events: List<ToolEvent>.from(_events),
193 + completedWithinLimit: iteration < maxIterations,
194 + toolCallCount: toolCallCount,
195 + );
196 + }
197 +}
lib/features/chat/data/tools/tool_registry.dart new
+34
@@ -0,0 +1,34 @@
1 +import 'tool_definition.dart';
2 +
3 +abstract class ToolProvider {
4 + Future<List<ToolDefinition>> listTools();
5 + Future<ToolExecutionResult> execute(String name, Map<String, dynamic> args);
6 +}
7 +
8 +class ToolRegistry {
9 + final List<ToolProvider> providers;
10 +
11 + ToolRegistry({required this.providers});
12 +
13 + Future<List<ToolDefinition>> listTools({Set<String>? allowedTools}) async {
14 + final all = <ToolDefinition>[];
15 + for (final provider in providers) {
16 + all.addAll(await provider.listTools());
17 + }
18 + if (allowedTools == null || allowedTools.isEmpty) return all;
19 + return all.where((t) => allowedTools.contains(t.name)).toList();
20 + }
21 +
22 + Future<ToolExecutionResult> execute(
23 + String name,
24 + Map<String, dynamic> args,
25 + ) async {
26 + for (final provider in providers) {
27 + final tools = await provider.listTools();
28 + if (tools.any((t) => t.name == name)) {
29 + return provider.execute(name, args);
30 + }
31 + }
32 + return const ToolExecutionResult.failure('Tool not found');
33 + }
34 +}
lib/features/chat/providers/chat_providers.dart
+61 -2
@@ -23,9 +23,12 @@ import '../../../objectbox.g.dart';
23 import '../../conversations/data/models/conversation.dart';
24 import '../data/chat_service.dart';
25 import '../data/models/chat_parameters.dart';
26 -import '../data/models/message.dart';
26 +import '../data/models/message.dart' hide ToolCallData;
27 import '../data/smart_reply_service.dart';
28 +import '../data/tools/tool_execution_loop.dart';
29 +import '../data/tools/adapters/tool_transport_adapter.dart' show ParsedToolCall;
30 import '../../../core/providers/chat_background_service_provider.dart';
31 +import './tooling_providers.dart';
32
33 final selectedModelProvider =
34 NotifierProvider<SelectedModelNotifier, ModelInfo?>(() {
@@ -622,12 +625,20 @@ class ChatNotifier extends Notifier<ChatState> {
625 var streamingAssistantMessage = assistantMessage;
626
627 if (chatService != null) {
628 + final mcpConfig = ref.read(chatMcpConfigProvider);
629 + final integrations = mcpConfig.enabled && mcpConfig.integrations.isNotEmpty
630 + ? mcpConfig.integrations
631 + : null;
632 +
633 + final collectedToolCalls = <ToolCallData>[];
634 +
635 _streamSubscription = chatService
636 .sendMessage(
637 server: server,
638 modelId: selectedModel?.id ?? 'default',
639 messages: messagesForApi,
640 params: chatParams,
641 + integrations: integrations,
642 )
643 .listen(
644 (response) async {
@@ -765,6 +776,10 @@ class ChatNotifier extends Notifier<ChatState> {
776 await _saveMessage(streamingAssistantMessage);
777 break;
778 case ChatResponseType.toolCall:
779 + if (response.toolCall != null) {
780 + collectedToolCalls.add(response.toolCall!);
781 + }
782 + break;
783 case ChatResponseType.invalidToolCall:
784 case ChatResponseType.done:
785 break;
@@ -812,10 +827,54 @@ class ChatNotifier extends Notifier<ChatState> {
827 }
828 } else {
829 // Normal completion with content
815 - final finalMessage = streamingMessage.copyWith(
830 + var finalMessage = streamingMessage.copyWith(
831 status: MessageStatus.complete,
832 isProcessing: false,
833 );
834 +
835 + // ---- TOOL LOOP INTEGRATION ----
836 + if (mcpConfig.enabled && collectedToolCalls.isNotEmpty) {
837 + try {
838 + final registry = ref.read(toolRegistryProvider);
839 + final adapter = createAdapterForServerType(server.type);
840 +
841 + final preParsedCalls = collectedToolCalls
842 + .map((tc) => ParsedToolCall(
843 + id: tc.tool,
844 + name: tc.tool,
845 + arguments: tc.arguments,
846 + ))
847 + .toList();
848 +
849 + final loop = ToolExecutionLoop(
850 + adapter: adapter,
851 + registry: registry,
852 + onRequestApproval: mcpConfig.autoExecuteTools
853 + ? null
854 + : (call) async {
855 + // TODO: wire real approval UI dialog
856 + return false;
857 + },
858 + );
859 +
860 + final loopResult = await loop.run(
861 + initialUserMessage: content,
862 + assistantContent: streamingAssistantMessage.content,
863 + preParsedCalls: preParsedCalls,
864 + );
865 +
866 + if (loopResult.events.isNotEmpty) {
867 + finalMessage = finalMessage.copyWith(
868 + toolSessionId: loop.sessionId,
869 + toolEvents: loopResult.events,
870 + );
871 + }
872 + } catch (e) {
873 + Log.error('Tool execution loop failed: $e');
874 + }
875 + }
876 + // ---- END TOOL LOOP INTEGRATION ----
877 +
878 await _saveMessage(finalMessage);
879 if (isCurrentContext) {
880 final messageIndex = state.messages.indexWhere(
lib/features/chat/providers/tooling_providers.dart new
+24
@@ -0,0 +1,24 @@
1 +import 'package:flutter_riverpod/flutter_riverpod.dart';
2 +import '../data/tools/tool_registry.dart';
3 +import '../data/tools/builtin_tool_provider.dart';
4 +import '../data/tools/mcp_tool_provider.dart';
5 +import '../data/mcp_server_manager.dart';
6 +
7 +final mcpServerManagerProvider = Provider<McpServerManager>((ref) {
8 + return McpServerManager();
9 +});
10 +
11 +final builtInToolProviderProvider = Provider<BuiltInToolProvider>((ref) {
12 + return BuiltInToolProvider();
13 +});
14 +
15 +final toolRegistryProvider = Provider<ToolRegistry>((ref) {
16 + final mcpServerManager = ref.watch(mcpServerManagerProvider);
17 + return ToolRegistry(
18 + providers: [
19 + ref.watch(builtInToolProviderProvider),
20 + if (mcpServerManager.serverCount > 0)
21 + McpToolProvider(serverManager: mcpServerManager),
22 + ],
23 + );
24 +});
lib/features/chat/views/components/chat_bubble.dart
+167
@@ -9,6 +9,7 @@ import 'message_action_bar.dart';
9 import 'processing_indicator.dart';
10 import 'typing_indicator.dart';
11 import 'reasoning_widget.dart';
12 +import '../../data/tools/tool_event.dart';
13
14 class ChatBubble extends StatelessWidget {
15 const ChatBubble({
@@ -261,6 +262,11 @@ class _AssistantBubble extends StatelessWidget {
262 ),
263 ),
264 ),
265 + if (message.toolEvents != null && message.toolEvents!.isNotEmpty)
266 + Padding(
267 + padding: const EdgeInsets.only(top: 8),
268 + child: _ToolTimeline(events: message.toolEvents!),
269 + ),
270 if (isStreaming && message.content.isNotEmpty)
271 const Padding(
272 padding: EdgeInsets.only(top: 8),
@@ -509,6 +515,167 @@ class _ToolBubble extends StatelessWidget {
515 }
516 }
517
518 +class _ToolTimeline extends StatelessWidget {
519 + const _ToolTimeline({required this.events});
520 + final List<ToolEvent> events;
521 +
522 + @override
523 + Widget build(BuildContext context) {
524 + final theme = Theme.of(context);
525 + final isDark = theme.brightness == Brightness.dark;
526 +
527 + return Container(
528 + decoration: BoxDecoration(
529 + color: isDark ? const Color(0xFF1E293B) : const Color(0xFFF8FAFC),
530 + borderRadius: BorderRadius.circular(8),
531 + border: Border.all(
532 + color: isDark ? const Color(0xFF334155) : const Color(0xFFE2E8F0),
533 + ),
534 + ),
535 + child: Column(
536 + crossAxisAlignment: CrossAxisAlignment.start,
537 + children: [
538 + Padding(
539 + padding: const EdgeInsets.fromLTRB(10, 8, 10, 4),
540 + child: Row(
541 + children: [
542 + Icon(Icons.troubleshoot, size: 14, color: isDark ? const Color(0xFF94A3B8) : const Color(0xFF64748B)),
543 + const SizedBox(width: 4),
544 + Text('Tools', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: isDark ? const Color(0xFF94A3B8) : const Color(0xFF64748B))),
545 + ],
546 + ),
547 + ),
548 + ...events.map((event) => _buildEventRow(event, isDark)),
549 + ],
550 + ),
551 + );
552 + }
553 +
554 + Widget _buildEventRow(ToolEvent event, bool isDark) {
555 + final iconData = switch (event.status) {
556 + ToolEventStatus.requested => Icons.hourglass_empty,
557 + ToolEventStatus.approved => Icons.check_circle_outline,
558 + ToolEventStatus.rejected => Icons.cancel_outlined,
559 + ToolEventStatus.running => Icons.play_circle_outline,
560 + ToolEventStatus.completed => Icons.check_circle,
561 + ToolEventStatus.failed => Icons.error_outline,
562 + };
563 + final iconColor = switch (event.status) {
564 + ToolEventStatus.completed => const Color(0xFF22C55E),
565 + ToolEventStatus.failed || ToolEventStatus.rejected => const Color(0xFFEF4444),
566 + ToolEventStatus.running || ToolEventStatus.requested => const Color(0xFFF59E0B),
567 + ToolEventStatus.approved => const Color(0xFF3B82F6),
568 + };
569 + final label = switch (event.status) {
570 + ToolEventStatus.requested => 'Requested',
571 + ToolEventStatus.approved => 'Approved',
572 + ToolEventStatus.rejected => 'Rejected',
573 + ToolEventStatus.running => 'Running',
574 + ToolEventStatus.completed => 'Done',
575 + ToolEventStatus.failed => 'Failed',
576 + };
577 +
578 + return Padding(
579 + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
580 + child: Row(
581 + crossAxisAlignment: CrossAxisAlignment.start,
582 + children: [
583 + Padding(
584 + padding: const EdgeInsets.only(top: 2),
585 + child: Icon(iconData, size: 14, color: iconColor),
586 + ),
587 + const SizedBox(width: 6),
588 + Expanded(
589 + child: Column(
590 + crossAxisAlignment: CrossAxisAlignment.start,
591 + children: [
592 + Row(
593 + children: [
594 + Flexible(
595 + child: Text(
596 + event.toolName,
597 + style: TextStyle(
598 + fontSize: 12,
599 + fontWeight: FontWeight.w600,
600 + color: isDark ? Colors.white : Colors.black87,
601 + fontFamily: 'monospace',
602 + ),
603 + overflow: TextOverflow.ellipsis,
604 + ),
605 + ),
606 + const SizedBox(width: 4),
607 + Text(
608 + label,
609 + style: TextStyle(fontSize: 10, color: iconColor, fontWeight: FontWeight.w500),
610 + ),
611 + ],
612 + ),
613 + if (event.arguments != null && event.arguments!.isNotEmpty)
614 + Padding(
615 + padding: const EdgeInsets.only(top: 2),
616 + child: Text(
617 + _formatArgs(event.arguments!),
618 + style: TextStyle(
619 + fontSize: 10,
620 + color: isDark ? const Color(0xFF94A3B8) : const Color(0xFF64748B),
621 + fontFamily: 'monospace',
622 + ),
623 + maxLines: 3,
624 + overflow: TextOverflow.ellipsis,
625 + ),
626 + ),
627 + if (event.result != null && event.result!.isNotEmpty)
628 + Padding(
629 + padding: const EdgeInsets.only(top: 2),
630 + child: Text(
631 + '→ ${event.result}',
632 + style: TextStyle(
633 + fontSize: 10,
634 + color: isDark ? const Color(0xFF86EFAC) : const Color(0xFF166534),
635 + fontFamily: 'monospace',
636 + ),
637 + maxLines: 2,
638 + overflow: TextOverflow.ellipsis,
639 + ),
640 + ),
641 + if (event.error != null && event.error!.isNotEmpty)
642 + Padding(
643 + padding: const EdgeInsets.only(top: 2),
644 + child: Text(
645 + '✕ ${event.error}',
646 + style: TextStyle(
647 + fontSize: 10,
648 + color: const Color(0xFFEF4444),
649 + fontFamily: 'monospace',
650 + ),
651 + maxLines: 2,
652 + overflow: TextOverflow.ellipsis,
653 + ),
654 + ),
655 + if (event.durationMs != null)
656 + Padding(
657 + padding: const EdgeInsets.only(top: 1),
658 + child: Text(
659 + '${event.durationMs}ms',
660 + style: TextStyle(
661 + fontSize: 9,
662 + color: isDark ? const Color(0xFF64748B) : const Color(0xFF94A3B8),
663 + ),
664 + ),
665 + ),
666 + ],
667 + ),
668 + ),
669 + ],
670 + ),
671 + );
672 + }
673 +
674 + String _formatArgs(Map<String, dynamic> args) {
675 + return args.entries.map((e) => '${e.key}: ${e.value}').join(', ');
676 + }
677 +}
678 +
679 class _AttachmentList extends StatelessWidget {
680 const _AttachmentList({required this.paths, required this.isUser});
681
lib/features/on_device/data/on_device_chat_service.dart
+1
@@ -63,6 +63,7 @@ class OnDeviceChatService implements ChatService {
63
64 final chat = await _gemmaService.createChat(
65 systemInstruction: systemInstruction,
66 + tools: const [], // will be populated in Task 6
67 );
68
69 if (chat == null) {
lib/features/on_device/data/on_device_gemma_service.dart
+6 -1
@@ -137,13 +137,18 @@ class OnDeviceGemmaService {
137 Log.info('Model $modelId loaded successfully');
138 }
139
140 - Future<InferenceChat?> createChat({String? systemInstruction}) async {
140 + Future<InferenceChat?> createChat({
141 + String? systemInstruction,
142 + List<Tool> tools = const [],
143 + }) async {
144 if (_model == null) {
145 throw StateError('Model not loaded. Call loadModel first.');
146 }
147
148 return _model!.createChat(
149 systemInstruction: systemInstruction,
150 + tools: tools,
151 + supportsFunctionCalls: tools.isNotEmpty,
152 );
153 }
154
lib/features/tts/providers/tts_providers.dart
+57 -35
@@ -1,4 +1,5 @@
1 import 'dart:async';
2 +import 'dart:io';
3 import 'dart:isolate';
4 import 'dart:typed_data';
5
@@ -76,26 +77,33 @@ class TtsNotifier extends Notifier<TtsState> {
77 bool _isWorkerInitializing = false;
78 int _currentSessionId = 0;
79
80 + StreamSubscription<void>? _playerCompleteSubscription;
81 + bool _isProcessingChunk = false;
82 + int _playbackSessionId = 0;
83 + Completer<SendPort>? _workerPortCompleter;
84 +
85 @override
86 TtsState build() {
87 ref.keepAlive();
88
83 - // Listen for engine changes to update the state without rebuilding the whole notifier
89 ref.listen(settingsProvider, (previous, next) {
90 if (previous?.ttsEngine != next.ttsEngine) {
91 state = state.copyWith(activeEngine: next.ttsEngine);
92 }
93 });
94
90 - _player.onPlayerComplete.listen((_) {
95 + _playerCompleteSubscription = _player.onPlayerComplete.listen((_) {
96 _onChunkFinished();
97 });
98
99 _setupWorkerListener();
100
101 ref.onDispose(() {
102 + _workerPortCompleter?.completeError(StateError('Disposed'));
103 + _workerPortCompleter = null;
104 _workerIsolate?.kill();
105 _mainReceivePort.close();
106 + _playerCompleteSubscription?.cancel();
107 if (!_isPlayerDisposed) {
108 _isPlayerDisposed = true;
109 try {
@@ -112,6 +120,8 @@ class TtsNotifier extends Notifier<TtsState> {
120 _mainReceivePort.listen((message) {
121 if (message is SendPort) {
122 _workerSendPort = message;
123 + _workerPortCompleter?.complete(message);
124 + _workerPortCompleter = null;
125 } else if (message is TtsWorkerResponse) {
126 if (message.isInitResponse) {
127 _initCompleter?.complete();
@@ -123,7 +133,6 @@ class TtsNotifier extends Notifier<TtsState> {
133 }
134 if (message.wavBytes != null) {
135 _synthesizedChunks[message.chunkIndex!] = message.wavBytes!;
126 - // If this was the chunk we were waiting for, play it
136 if (message.chunkIndex == _currentChunkIndex && state.isSpeaking) {
137 _playNextChunk();
138 }
@@ -148,25 +157,20 @@ class TtsNotifier extends Notifier<TtsState> {
157 if (_workerIsolate != null) return;
158
159 Log.info('Spawning TTS worker isolate...');
160 + _workerPortCompleter = Completer<SendPort>();
161 _workerIsolate = await Isolate.spawn(
162 ttsWorkerEntry,
163 _mainReceivePort.sendPort,
164 );
165
156 - // Wait for worker to send its SendPort
157 - int attempts = 0;
158 - while (_workerSendPort == null && attempts < 200) {
159 - await Future.delayed(const Duration(milliseconds: 50));
160 - attempts++;
161 - }
162 -
163 - if (_workerSendPort == null) {
164 - throw StateError('Failed to establish communication with TTS worker');
165 - }
166 + await _workerPortCompleter!.future.timeout(
167 + const Duration(seconds: 10),
168 + );
169 }
170
171 void _onPlaybackFinished() {
172 if (_isStopping) return;
173 + if (_currentSessionId != _playbackSessionId) return;
174 state = state.copyWith(
175 isSpeaking: false,
176 isInitializing: false,
@@ -175,19 +179,23 @@ class TtsNotifier extends Notifier<TtsState> {
179 }
180
181 void _onChunkFinished() {
178 - if (_isStopping) return;
179 - _currentChunkIndex++;
180 - if (_currentChunkIndex < _chunks.length) {
181 - _playNextChunk();
182 - } else {
183 - _onPlaybackFinished();
182 + if (_isStopping || _isProcessingChunk) return;
183 + _isProcessingChunk = true;
184 + try {
185 + _currentChunkIndex++;
186 + if (_currentChunkIndex < _chunks.length) {
187 + _playNextChunk();
188 + } else {
189 + _onPlaybackFinished();
190 + }
191 + } finally {
192 + _isProcessingChunk = false;
193 }
194 }
195
196 List<String> _splitText(String text) {
197 if (text.length <= 250) return [text];
198
190 - // Split by common sentence terminators but keep them
199 final regex = RegExp(r'(?<=[.!?])\s+');
200 final sentences = text.split(regex);
201
@@ -202,7 +210,6 @@ class TtsNotifier extends Notifier<TtsState> {
210 }
211
212 if (sentence.length > 250) {
205 - // If a single sentence is too long, split it by commas or spaces
213 final subRegex = RegExp(r'(?<=[,;])\s+');
214 final parts = sentence.split(subRegex);
215 for (final part in parts) {
@@ -229,7 +236,9 @@ class TtsNotifier extends Notifier<TtsState> {
236 await _ensureWorker();
237
238 if (_isWorkerInitializing && _initCompleter != null) {
232 - await _initCompleter!.future;
239 + await _initCompleter!.future.timeout(
240 + const Duration(seconds: 30),
241 + );
242 }
243
244 String modelPath = '';
@@ -313,7 +322,9 @@ class TtsNotifier extends Notifier<TtsState> {
322 ),
323 );
324
316 - await _initCompleter!.future;
325 + await _initCompleter!.future.timeout(
326 + const Duration(seconds: 30),
327 + );
328 _currentEngine = engineId;
329 _currentModelPath = modelPath;
330 _currentVoicesPath = voicesPath;
@@ -331,7 +342,6 @@ class TtsNotifier extends Notifier<TtsState> {
342 _isStopping = false;
343 if (state.isSpeaking) {
344 await stop();
334 - // Wait a bit for the player to fully stop
345 await Future.delayed(const Duration(milliseconds: 100));
346 _isStopping = false;
347 }
@@ -359,6 +369,11 @@ class TtsNotifier extends Notifier<TtsState> {
369 await _flutterTts!.setSpeechRate(
370 (settings.ttsSpeed * 0.5).clamp(0.0, 1.0),
371 );
372 + _flutterTts!.setCompletionHandler(() {
373 + if (!_isStopping) {
374 + state = state.copyWith(isSpeaking: false, playingContent: null);
375 + }
376 + });
377 await _flutterTts!.awaitSpeakCompletion(true);
378 await _flutterTts!.speak(text);
379 } catch (e) {
@@ -368,8 +383,6 @@ class TtsNotifier extends Notifier<TtsState> {
383 playingContent: null,
384 );
385 rethrow;
371 - } finally {
372 - state = state.copyWith(isSpeaking: false, playingContent: null);
386 }
387 return;
388 }
@@ -380,6 +393,7 @@ class TtsNotifier extends Notifier<TtsState> {
393 _synthesizedChunks.clear();
394 _lastEnqueuedChunk = -1;
395 _currentSessionId++;
396 + _playbackSessionId = _currentSessionId;
397
398 state = state.copyWith(
399 isSpeaking: true,
@@ -408,17 +422,15 @@ class TtsNotifier extends Notifier<TtsState> {
422 Future<void> _playNextChunk() async {
423 if (_isStopping || _currentChunkIndex >= _chunks.length) return;
424
411 - // Start synthesizing more chunks
425 _enqueueNextChunks();
426
414 - // Check if current chunk is synthesized
427 if (!_synthesizedChunks.containsKey(_currentChunkIndex)) {
428 Log.debug('Waiting for chunk $_currentChunkIndex to be synthesized...');
429 return;
430 }
431
432 final wavBytes = _synthesizedChunks[_currentChunkIndex]!;
421 - _synthesizedChunks.remove(_currentChunkIndex); // Clear memory
433 + _synthesizedChunks.remove(_currentChunkIndex);
434
435 try {
436 Log.debug(
@@ -426,11 +438,22 @@ class TtsNotifier extends Notifier<TtsState> {
438 );
439
440 await _player.stop();
429 - await _player.setSource(BytesSource(wavBytes));
430 - await _player.resume();
441 +
442 + final tempDir = Directory.systemTemp;
443 + final tempFile = File(
444 + '${tempDir.path}/tts_chunk_${_currentSessionId}_$_currentChunkIndex.wav',
445 + );
446 + await tempFile.writeAsBytes(wavBytes);
447 +
448 + await _player.play(DeviceFileSource(tempFile.path));
449 } catch (e) {
450 Log.error('Error playing chunk $_currentChunkIndex: $e');
433 - _onChunkFinished();
451 + _currentChunkIndex++;
452 + if (_currentChunkIndex < _chunks.length) {
453 + _playNextChunk();
454 + } else {
455 + _onPlaybackFinished();
456 + }
457 }
458 }
459
@@ -442,7 +465,7 @@ class TtsNotifier extends Notifier<TtsState> {
465 final voiceId =
466 _currentVoice ??
467 settings.ttsVoiceId ??
445 - (availableVoices.isNotEmpty ? availableVoices.first.id : '');
468 + (availableVoices.isNotEmpty ? availableVoices.first.id : null);
469
470 while (_lastEnqueuedChunk < _chunks.length - 1 &&
471 _lastEnqueuedChunk < _currentChunkIndex + _lookAheadCount) {
@@ -454,7 +477,7 @@ class TtsNotifier extends Notifier<TtsState> {
477 _workerSendPort!.send(
478 TtsSynthesizeRequest(
479 text: text,
457 - voiceId: voiceId,
480 + voiceId: voiceId ?? '',
481 speed: settings.ttsSpeed,
482 chunkIndex: chunkIndex,
483 sessionId: _currentSessionId,
@@ -468,7 +491,6 @@ class TtsNotifier extends Notifier<TtsState> {
491 if (_isPlayerDisposed) return;
492 try {
493 await _player.stop();
471 - // Don't release here, as it makes restarting slower and can cause state issues
494 } catch (_) {}
495 await _flutterTts?.stop();
496 state = state.copyWith(
pubspec.lock
+8 -8
@@ -801,10 +801,10 @@ packages:
801 dependency: transitive
802 description:
803 name: matcher
804 - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
804 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
805 url: "https://pub.dev"
806 source: hosted
807 - version: "0.12.18"
807 + version: "0.12.19"
808 material_color_utilities:
809 dependency: transitive
810 description:
@@ -1351,26 +1351,26 @@ packages:
1351 dependency: transitive
1352 description:
1353 name: test
1354 - sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a"
1354 + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
1355 url: "https://pub.dev"
1356 source: hosted
1357 - version: "1.29.0"
1357 + version: "1.30.0"
1358 test_api:
1359 dependency: transitive
1360 description:
1361 name: test_api
1362 - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
1362 + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
1363 url: "https://pub.dev"
1364 source: hosted
1365 - version: "0.7.9"
1365 + version: "0.7.10"
1366 test_core:
1367 dependency: transitive
1368 description:
1369 name: test_core
1370 - sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943"
1370 + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
1371 url: "https://pub.dev"
1372 source: hosted
1373 - version: "0.6.15"
1373 + version: "0.6.16"
1374 theme_extensions_builder_annotation:
1375 dependency: transitive
1376 description:
test/features/chat/tools/chat_service_adapter_selection_test.dart new
+25
@@ -0,0 +1,25 @@
1 +import 'package:flutter_test/flutter_test.dart';
2 +import 'package:localmind/core/models/enums.dart';
3 +import 'package:localmind/features/chat/data/chat_service.dart';
4 +import 'package:localmind/features/chat/data/tools/adapters/ollama_tool_adapter.dart';
5 +import 'package:localmind/features/chat/data/tools/adapters/openai_tool_adapter.dart';
6 +import 'package:localmind/features/chat/data/tools/adapters/openrouter_tool_adapter.dart';
7 +
8 +void main() {
9 + group('createAdapterForServerType', () {
10 + test('uses OpenAI adapter for OpenAI-compatible servers', () {
11 + final adapter = createAdapterForServerType(ServerType.openAICompatible);
12 + expect(adapter, isA<OpenAiToolAdapter>());
13 + });
14 +
15 + test('uses OpenRouter adapter for OpenRouter servers', () {
16 + final adapter = createAdapterForServerType(ServerType.openRouter);
17 + expect(adapter, isA<OpenRouterToolAdapter>());
18 + });
19 +
20 + test('uses Ollama adapter for Ollama servers', () {
21 + final adapter = createAdapterForServerType(ServerType.ollama);
22 + expect(adapter, isA<OllamaToolAdapter>());
23 + });
24 + });
25 +}
test/features/chat/tools/lm_studio_tool_adapter_test.dart new
+40
@@ -0,0 +1,40 @@
1 +import 'package:flutter_test/flutter_test.dart';
2 +import 'package:localmind/features/chat/data/tools/adapters/lm_studio_tool_adapter.dart';
3 +
4 +void main() {
5 + group('LmStudioToolAdapter', () {
6 + test('maps tool_call.success event to completed call', () {
7 + final adapter = LmStudioToolAdapter();
8 + adapter.consumeEvent('tool_call.success', {
9 + 'tool': 'calc.add',
10 + 'arguments': {'a': 1, 'b': 2},
11 + 'output': '3',
12 + });
13 + final calls = adapter.takeServerExecutedCalls();
14 + expect(calls.length, 1);
15 + expect(calls.single.name, 'calc.add');
16 + expect(calls.single.output, '3');
17 + });
18 +
19 + test('tracks lifecycle across start-arguments-success events', () {
20 + final adapter = LmStudioToolAdapter();
21 + adapter.consumeEvent('tool_call.start', {'tool': 'calc.add'});
22 + adapter.consumeEvent('tool_call.arguments', {'arguments': {'a': 1, 'b': 2}});
23 + adapter.consumeEvent('tool_call.success', {'output': '3'});
24 + final calls = adapter.takeServerExecutedCalls();
25 + expect(calls.length, 1);
26 + expect(calls.single.output, '3');
27 + });
28 +
29 + test('handles tool_call.failure events', () {
30 + final adapter = LmStudioToolAdapter();
31 + adapter.consumeEvent('tool_call.failure', {
32 + 'tool': 'calc.add',
33 + 'reason': 'Division by zero',
34 + });
35 + final calls = adapter.takeServerExecutedCalls();
36 + expect(calls.length, 1);
37 + expect(calls.single.output, 'Division by zero');
38 + });
39 + });
40 +}
test/features/chat/tools/ollama_tool_adapter_test.dart new
+42
@@ -0,0 +1,42 @@
1 +import 'package:flutter_test/flutter_test.dart';
2 +import 'package:localmind/features/chat/data/tools/adapters/ollama_tool_adapter.dart';
3 +import 'package:localmind/features/chat/data/tools/adapters/tool_transport_adapter.dart';
4 +
5 +void main() {
6 + group('OllamaToolAdapter', () {
7 + test('parses tool calls from final message', () {
8 + final adapter = OllamaToolAdapter();
9 + adapter.consumeDynamicChunk({
10 + 'message': {
11 + 'role': 'assistant',
12 + 'content': '',
13 + 'tool_calls': [
14 + {
15 + 'id': 'tc_1',
16 + 'function': {'name': 'calc.add', 'arguments': '{"a":1,"b":2}'},
17 + },
18 + ],
19 + },
20 + });
21 +
22 + final calls = adapter.takeCompletedCalls();
23 + expect(calls.length, 1);
24 + expect(calls.single.name, 'calc.add');
25 + expect(calls.single.arguments['a'], 1);
26 + });
27 +
28 + test('builds tool result message', () {
29 + final adapter = OllamaToolAdapter();
30 + final msg = adapter.buildToolResultMessage(
31 + call: const ParsedToolCall(
32 + id: 'tc_1',
33 + name: 'calc.add',
34 + arguments: {'a': 1},
35 + ),
36 + output: '1',
37 + );
38 + expect(msg['role'], 'tool');
39 + expect(msg['tool_name'], 'calc.add');
40 + });
41 + });
42 +}
test/features/chat/tools/on_device_tool_adapter_test.dart new
+22
@@ -0,0 +1,22 @@
1 +import 'package:flutter_test/flutter_test.dart';
2 +import 'package:localmind/features/chat/data/tools/adapters/on_device_tool_adapter.dart';
3 +
4 +void main() {
5 + group('OnDeviceToolAdapter', () {
6 + test('collects function calls and returns them', () {
7 + final adapter = OnDeviceToolAdapter();
8 + adapter.consumeFunctionCall('calc.add', {'a': 1, 'b': 2});
9 + final calls = adapter.takeCompletedCalls();
10 + expect(calls.length, 1);
11 + expect(calls.single.name, 'calc.add');
12 + expect(calls.single.arguments['a'], 1);
13 + });
14 +
15 + test('clears state after takeCompletedCalls', () {
16 + final adapter = OnDeviceToolAdapter();
17 + adapter.consumeFunctionCall('calc.add', {'a': 1, 'b': 2});
18 + adapter.takeCompletedCalls();
19 + expect(adapter.takeCompletedCalls(), isEmpty);
20 + });
21 + });
22 +}
test/features/chat/tools/openai_tool_adapter_test.dart new
+73
@@ -0,0 +1,73 @@
1 +import 'package:flutter_test/flutter_test.dart';
2 +import 'package:localmind/features/chat/data/tools/adapters/openai_tool_adapter.dart';
3 +import 'package:localmind/features/chat/data/tools/adapters/tool_transport_adapter.dart';
4 +import 'package:localmind/features/chat/data/tools/tool_definition.dart';
5 +
6 +void main() {
7 + group('OpenAiToolAdapter', () {
8 + test('reconstructs tool call arguments from delta chunks', () {
9 + final adapter = OpenAiToolAdapter();
10 + adapter.consumeDynamicChunk({
11 + 'choices': [
12 + {
13 + 'delta': {
14 + 'tool_calls': [
15 + {
16 + 'index': 0,
17 + 'id': 'call_1',
18 + 'type': 'function',
19 + 'function': {'name': 'calc.add', 'arguments': ''},
20 + },
21 + ],
22 + },
23 + },
24 + ],
25 + });
26 + adapter.consumeDynamicChunk({
27 + 'choices': [
28 + {
29 + 'delta': {
30 + 'tool_calls': [
31 + {'index': 0, 'function': {'arguments': '{"a":1,"b":2}'}},
32 + ],
33 + },
34 + },
35 + ],
36 + });
37 +
38 + final calls = adapter.takeCompletedCalls();
39 + expect(calls.length, 1);
40 + expect(calls.single.name, 'calc.add');
41 + expect(calls.single.arguments['a'], 1);
42 + });
43 +
44 + test('builds tool definition payload', () {
45 + final adapter = OpenAiToolAdapter();
46 + final payload = adapter.buildToolDefinitionPayload([
47 + const ToolDefinition(
48 + name: 'calc.add',
49 + description: 'Add two numbers',
50 + inputSchema: {'type': 'object'},
51 + providerType: ToolProviderType.builtIn,
52 + ),
53 + ]);
54 + expect(payload['tools'], isA<List>());
55 + expect(payload['tools'][0]['function']['name'], 'calc.add');
56 + });
57 +
58 + test('builds tool result message', () {
59 + final adapter = OpenAiToolAdapter();
60 + final msg = adapter.buildToolResultMessage(
61 + call: const ParsedToolCall(
62 + id: 'call_1',
63 + name: 'calc.add',
64 + arguments: {'a': 1, 'b': 2},
65 + ),
66 + output: '3',
67 + );
68 + expect(msg['role'], 'tool');
69 + expect(msg['tool_call_id'], 'call_1');
70 + expect(msg['content'], '3');
71 + });
72 + });
73 +}
test/features/chat/tools/tool_execution_loop_test.dart new
+163
@@ -0,0 +1,163 @@
1 +import 'package:flutter_test/flutter_test.dart';
2 +import 'package:localmind/features/chat/data/tools/adapters/tool_transport_adapter.dart';
3 +import 'package:localmind/features/chat/data/tools/tool_execution_loop.dart';
4 +import 'package:localmind/features/chat/data/tools/tool_registry.dart';
5 +import 'package:localmind/features/chat/data/tools/builtin_tool_provider.dart';
6 +import 'package:localmind/features/chat/data/tools/tool_definition.dart';
7 +import 'package:localmind/features/chat/data/tools/tool_event.dart';
8 +
9 +class FakeToolAdapter implements ToolTransportAdapter {
10 + final List<ParsedToolCall> callsToReturn;
11 + int callCount = 0;
12 +
13 + FakeToolAdapter.singleToolCall(String name, Map<String, dynamic> args)
14 + : callsToReturn = [ParsedToolCall(id: 'call_1', name: name, arguments: args)];
15 +
16 + @override
17 + Map<String, dynamic> buildToolDefinitionPayload(List<ToolDefinition> tools) {
18 + return {'tools': []};
19 + }
20 +
21 + @override
22 + void consumeDynamicChunk(Map<String, dynamic> chunk) {}
23 +
24 + @override
25 + List<ParsedToolCall> takeCompletedCalls() {
26 + if (callCount < callsToReturn.length) {
27 + callCount++;
28 + return [callsToReturn[callCount - 1]];
29 + }
30 + return const [];
31 + }
32 +
33 + @override
34 + Map<String, dynamic> buildToolResultMessage({
35 + required ParsedToolCall call,
36 + required String output,
37 + }) {
38 + return {'role': 'tool', 'tool_call_id': call.id, 'content': output};
39 + }
40 +}
41 +
42 +void main() {
43 + group('ToolExecutionLoop', () {
44 + test('executes requested tool and returns completed event', () async {
45 + final loop = ToolExecutionLoop(
46 + adapter: FakeToolAdapter.singleToolCall('calc.add', {'a': 1, 'b': 2}),
47 + registry: ToolRegistry(providers: [BuiltInToolProvider()]),
48 + );
49 +
50 + final result = await loop.run(
51 + initialUserMessage: 'add 1 and 2',
52 + assistantContent: 'The result is 3',
53 + );
54 + expect(result.events.any((e) => e.status == ToolEventStatus.completed), true);
55 + expect(result.finalAssistantContent, 'The result is 3');
56 + });
57 +
58 + test('handles tool that returns error', () async {
59 + final loop = ToolExecutionLoop(
60 + adapter: FakeToolAdapter.singleToolCall('unknown.tool', {}),
61 + registry: ToolRegistry(providers: [BuiltInToolProvider()]),
62 + );
63 +
64 + final result = await loop.run(initialUserMessage: 'do something');
65 + expect(result.events.any((e) => e.status == ToolEventStatus.failed), true);
66 + });
67 +
68 + test('respects maxIterations', () async {
69 + final adapter = FakeToolAdapter.singleToolCall('calc.add', {'a': 1, 'b': 2});
70 + final loop = ToolExecutionLoop(
71 + adapter: adapter,
72 + registry: ToolRegistry(providers: [BuiltInToolProvider()]),
73 + maxIterations: 1,
74 + );
75 +
76 + final result = await loop.run(initialUserMessage: 'add');
77 + expect(result.events.any((e) => e.status == ToolEventStatus.completed), true);
78 + expect(result.toolCallCount, greaterThan(0));
79 + });
80 +
81 + test('creates timeline with event IDs', () async {
82 + final loop = ToolExecutionLoop(
83 + adapter: FakeToolAdapter.singleToolCall('calc.add', {'a': 1, 'b': 2}),
84 + registry: ToolRegistry(providers: [BuiltInToolProvider()]),
85 + );
86 +
87 + final result = await loop.run(initialUserMessage: 'add');
88 + expect(result.events.first.eventId, isNotEmpty);
89 + });
90 +
91 + test('emits approved event when approval callback returns true', () async {
92 + final loop = ToolExecutionLoop(
93 + adapter: FakeToolAdapter.singleToolCall('calc.add', {'a': 1, 'b': 2}),
94 + registry: ToolRegistry(providers: [BuiltInToolProvider()]),
95 + onRequestApproval: (call) async => true,
96 + );
97 +
98 + final result = await loop.run(initialUserMessage: 'add');
99 + expect(result.events.any((e) => e.status == ToolEventStatus.approved), true);
100 + expect(result.events.any((e) => e.status == ToolEventStatus.completed), true);
101 + });
102 +
103 + test('rejects tool when approval callback returns false', () async {
104 + final loop = ToolExecutionLoop(
105 + adapter: FakeToolAdapter.singleToolCall('calc.add', {'a': 1, 'b': 2}),
106 + registry: ToolRegistry(providers: [BuiltInToolProvider()]),
107 + onRequestApproval: (call) async => false,
108 + );
109 +
110 + final result = await loop.run(initialUserMessage: 'add');
111 + expect(result.events.any((e) => e.status == ToolEventStatus.rejected), true);
112 + expect(result.events.any((e) => e.status == ToolEventStatus.completed), false);
113 + });
114 +
115 + test('loop runs with tool calls and returns events with assistant content', () async {
116 + final adapter = FakeToolAdapter.singleToolCall('calc.add', {'a': 1, 'b': 2});
117 + final loop = ToolExecutionLoop(
118 + adapter: adapter,
119 + registry: ToolRegistry(providers: [BuiltInToolProvider()]),
120 + );
121 +
122 + final result = await loop.run(
123 + initialUserMessage: 'hello',
124 + assistantContent: 'Hi there!',
125 + );
126 + expect(result.finalAssistantContent, 'Hi there!');
127 + expect(result.events, isNotEmpty);
128 + });
129 +
130 + test('loop processes preParsedCalls when provided', () async {
131 + final loop = ToolExecutionLoop(
132 + adapter: FakeToolAdapter.singleToolCall('calc.add', {'a': 1, 'b': 2}),
133 + registry: ToolRegistry(providers: [BuiltInToolProvider()]),
134 + );
135 +
136 + final result = await loop.run(
137 + initialUserMessage: 'add',
138 + preParsedCalls: [
139 + const ParsedToolCall(id: 'pc_1', name: 'calc.add', arguments: {'a': 1, 'b': 2}),
140 + ],
141 + );
142 + expect(result.events.any((e) => e.status == ToolEventStatus.completed), true);
143 + expect(result.toolCallCount, 1);
144 + });
145 +
146 + test('loop returns empty result when no tool calls', () async {
147 + final adapter = FakeToolAdapter.singleToolCall('calc.add', {'a': 1, 'b': 2});
148 + final loop = ToolExecutionLoop(
149 + adapter: adapter,
150 + registry: ToolRegistry(providers: [BuiltInToolProvider()]),
151 + );
152 +
153 + final result = await loop.run(
154 + initialUserMessage: 'hello',
155 + assistantContent: 'No tools needed',
156 + preParsedCalls: const [],
157 + );
158 + expect(result.events, isEmpty);
159 + expect(result.finalAssistantContent, 'No tools needed');
160 + expect(result.toolCallCount, 0);
161 + });
162 + });
163 +}
test/features/chat/tools/tool_registry_test.dart new
+53
@@ -0,0 +1,53 @@
1 +import 'package:flutter_test/flutter_test.dart';
2 +import 'package:localmind/core/models/enums.dart';
3 +import 'package:localmind/features/chat/data/models/message.dart';
4 +import 'package:localmind/features/chat/data/tools/builtin_tool_provider.dart';
5 +import 'package:localmind/features/chat/data/tools/tool_definition.dart';
6 +import 'package:localmind/features/chat/data/tools/tool_event.dart';
7 +import 'package:localmind/features/chat/data/tools/tool_registry.dart';
8 +
9 +void main() {
10 + test('Message preserves toolEvents in copyWith', () {
11 + final msg = Message(
12 + id: 'm1',
13 + conversationId: 'c1',
14 + role: MessageRole.assistant,
15 + content: 'hi',
16 + createdAt: DateTime(2026, 1, 1),
17 + toolEvents: [
18 + ToolEvent.requested(
19 + eventId: 'e1',
20 + toolName: 'calc.add',
21 + providerType: ToolProviderType.builtIn,
22 + ),
23 + ],
24 + );
25 +
26 + expect(msg.copyWith().toolEvents!.length, 1);
27 + });
28 +
29 + test('registry resolves built-in tools with allow-list filtering', () async {
30 + final registry = ToolRegistry(
31 + providers: [BuiltInToolProvider()],
32 + );
33 + final tools = await registry.listTools(allowedTools: {'calc.add'});
34 + expect(tools.map((t) => t.name), ['calc.add']);
35 + });
36 +
37 + test('registry executes tool from correct provider', () async {
38 + final registry = ToolRegistry(
39 + providers: [BuiltInToolProvider()],
40 + );
41 + final result = await registry.execute('calc.add', {'a': 1, 'b': 2});
42 + expect(result.success, true);
43 + expect(result.output, '3');
44 + });
45 +
46 + test('registry returns failure for unknown tool', () async {
47 + final registry = ToolRegistry(
48 + providers: [BuiltInToolProvider()],
49 + );
50 + final result = await registry.execute('unknown.tool', {});
51 + expect(result.success, false);
52 + });
53 +}