diff --git a/flutter_app/lib/src/agent_exception.dart b/flutter_app/lib/src/agent_exception.dart new file mode 100644 index 0000000..baf78f6 --- /dev/null +++ b/flutter_app/lib/src/agent_exception.dart @@ -0,0 +1,11 @@ +/// A failure the assistant wants to surface to the user with a readable +/// message. Thrown by both the cloud and local agent paths; consumers render +/// [message] directly rather than branching on the provider. +class AgentException implements Exception { + const AgentException(this.message); + + final String message; + + @override + String toString() => message; +} diff --git a/flutter_app/lib/src/agent_llm_provider.dart b/flutter_app/lib/src/agent_llm_provider.dart index 9cbd157..8fc7998 100644 --- a/flutter_app/lib/src/agent_llm_provider.dart +++ b/flutter_app/lib/src/agent_llm_provider.dart @@ -1,4 +1,5 @@ import 'agent_config_store.dart'; +import 'agent_exception.dart'; import 'chat_session_mutation.dart'; import 'cloud_agent_client.dart'; import 'mail_tools.dart'; @@ -19,6 +20,7 @@ class AgentLlmRequest { required this.context, required this.memoryText, required this.appendMemory, + required this.readMemory, required this.readSchedule, required this.mailTools, required this.onToolTrace, @@ -33,6 +35,7 @@ class AgentLlmRequest { final PromptContext context; final String memoryText; final Future Function(String text) appendMemory; + final Future Function() readMemory; final Future Function() readSchedule; final MailToolRunner mailTools; final void Function(ToolTrace trace) onToolTrace; @@ -131,7 +134,7 @@ class LocalNativeLlmProvider implements AgentLlmProvider { final toolContext = StudyOsToolContext( promptContext: request.context, appendMemory: request.appendMemory, - readMemory: () async => request.memoryText, + readMemory: request.readMemory, readSchedule: request.readSchedule, mailTools: request.mailTools, nativeTools: nativeTools, @@ -155,15 +158,12 @@ class LocalNativeLlmProvider implements AgentLlmProvider { toolContext, ); } on Object catch (error) { + final failedOutput = _toolFailureOutput(error); request.onToolTrace( - _traceForCall( - call, - 'failed', - callId: callId, - output: error.toString(), - ), + _traceForCall(call, 'failed', callId: callId, output: failedOutput), ); - rethrow; + feedback.add('- ${call.name}: $failedOutput'); + continue; } request.onToolTrace( _traceForCall(call, 'done', callId: callId, output: output), @@ -179,6 +179,14 @@ class LocalNativeLlmProvider implements AgentLlmProvider { localBackend: request.config.localBackend.name, ); } + // Mirror the cloud client: a turn that still requests tools after the + // round budget is exhausted is a stuck loop, not an answer. Surface it as + // an error instead of returning raw tool directives to the user. + if (_toolCalls(response).isNotEmpty) { + throw const AgentException( + 'Local tool loop exceeded the maximum number of tool rounds.', + ); + } return response; } @@ -262,6 +270,11 @@ class LocalNativeLlmProvider implements AgentLlmProvider { } } +String _toolFailureOutput(Object error) { + final message = error.toString().trim(); + return message.isEmpty ? 'Tool failed.' : 'Tool failed: $message'; +} + class _LocalToolCall { const _LocalToolCall({required this.name, required this.arguments}); @@ -295,7 +308,7 @@ class CloudLlmProvider implements AgentLlmProvider { Future send(AgentLlmRequest request) async { final apiKey = await _configStore.readApiKey(); if (apiKey == null || apiKey.isEmpty) { - throw const CloudAgentException('Cloud API key is required.'); + throw const AgentException('Cloud API key is required.'); } return _cloudClient.sendMessage( config: request.config, diff --git a/flutter_app/lib/src/agent_request_runner.dart b/flutter_app/lib/src/agent_request_runner.dart index 2c80036..b31d023 100644 --- a/flutter_app/lib/src/agent_request_runner.dart +++ b/flutter_app/lib/src/agent_request_runner.dart @@ -58,6 +58,7 @@ class AgentRequestRunner { context: context, memoryText: memoryText, appendMemory: appendMemory, + readMemory: memoryStore.read, readSchedule: readSchedule, mailTools: mailTools, onToolTrace: onToolTrace, diff --git a/flutter_app/lib/src/cloud_agent_client.dart b/flutter_app/lib/src/cloud_agent_client.dart index 8a61800..7c52f61 100644 --- a/flutter_app/lib/src/cloud_agent_client.dart +++ b/flutter_app/lib/src/cloud_agent_client.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; +import 'agent_exception.dart'; import 'cloud_tool_definitions.dart'; import 'mail_tools.dart'; import 'models.dart'; @@ -25,6 +26,7 @@ class CloudAgentClient { final http.Client _httpClient; final StudyOsToolExecutor _toolExecutor; final NativeToolRunner? _nativeTools; + static const int _maxToolRounds = 3; Future sendMessage({ required AgentConfig config, @@ -42,41 +44,28 @@ class CloudAgentClient { }) async { final endpoint = Uri.tryParse(config.cloudEndpoint.trim()); if (endpoint == null || !endpoint.hasScheme || !endpoint.hasAuthority) { - throw const CloudAgentException('Custom AI server URL is not valid.'); + throw const AgentException('Custom AI server URL is not valid.'); } if (config.cloudModel.trim().isEmpty) { - throw const CloudAgentException('Model name is required.'); + throw const AgentException('Model name is required.'); } if (apiKey.trim().isEmpty) { - throw const CloudAgentException('API key is required.'); + throw const AgentException('API key is required.'); } final supportedNativeToolNames = await _nativeTools?.supportedToolNames() ?? const {}; - final request = _requestBody( + var request = _requestBody( config: config, history: history, userText: userText, context: context, supportedNativeToolNames: supportedNativeToolNames, ); - final message = await _fetchTurn( - endpoint, - apiKey, - request, - onDelta, - cancelToken, + final messages = List>.from( + request['messages'] as List, ); - final toolCalls = _toolCalls(message); - if (toolCalls.isEmpty) { - return _contentFromMessage(message); - } - - // A streamed turn that resolved into tool calls carries no user-facing - // answer yet; clear the live buffer before the follow-up answer streams. - onDelta?.call(const AgentStreamDelta(reset: true)); - final toolMessages = >[]; final toolContext = StudyOsToolContext( promptContext: context, appendMemory: appendMemory, @@ -85,47 +74,71 @@ class CloudAgentClient { mailTools: mailTools, nativeTools: _nativeTools, ); - for (final call in toolCalls) { - if (cancelToken?.isCancelled ?? false) { - throw const AgentCancelledException(); + for (var round = 0; ; round += 1) { + final message = await _fetchTurn( + endpoint, + apiKey, + request, + onDelta, + cancelToken, + ); + final toolCalls = _toolCalls(message); + if (toolCalls.isEmpty) { + return _contentFromMessage(message); } - onToolTrace?.call(_traceForCall(call, 'running')); - final String output; - try { - output = await _toolExecutor.execute( - call.name, - call.arguments, - toolContext, - ); - } on Object catch (error) { - onToolTrace?.call( - _traceForCall(call, 'failed', output: error.toString()), + if (round >= _maxToolRounds) { + throw const AgentException( + 'Cloud tool loop exceeded the maximum number of tool rounds.', ); - rethrow; } - onToolTrace?.call(_traceForCall(call, 'done', output: output)); - toolMessages.add({ - 'role': 'tool', - 'tool_call_id': call.id, - 'content': output, - }); - } - final followUp = await _fetchTurn( - endpoint, - apiKey, - { + // A streamed turn that resolved into tool calls carries no user-facing + // answer yet; clear the live buffer before the follow-up answer streams. + onDelta?.call(const AgentStreamDelta(reset: true)); + + final toolMessages = >[]; + for (final call in toolCalls) { + if (cancelToken?.isCancelled ?? false) { + throw const AgentCancelledException(); + } + onToolTrace?.call(_traceForCall(call, 'running')); + final String output; + try { + output = await _toolExecutor.execute( + call.name, + call.arguments, + toolContext, + ); + } on Object catch (error) { + final failure = _toolFailureOutput(error); + onToolTrace?.call(_traceForCall(call, 'failed', output: failure)); + toolMessages.add({ + 'role': 'tool', + 'tool_call_id': call.id, + 'content': failure, + }); + continue; + } + onToolTrace?.call(_traceForCall(call, 'done', output: output)); + toolMessages.add({ + 'role': 'tool', + 'tool_call_id': call.id, + 'content': output, + }); + } + + messages + ..add(message) + ..addAll(toolMessages); + request = { 'model': config.cloudModel.trim(), - 'messages': >[ - ...List>.from(request['messages'] as List), - message, - ...toolMessages, - ], - }, - onDelta, - cancelToken, - ); - return _contentFromMessage(followUp); + 'messages': messages, + 'tools': cloudToolDefinitions( + supportedNativeToolNames: supportedNativeToolNames, + ), + 'tool_choice': 'auto', + }; + } } /// Fetches one assistant turn, returning a message map shaped like @@ -142,12 +155,31 @@ class CloudAgentClient { throw const AgentCancelledException(); } if (onDelta == null) { - final response = await _post(endpoint, apiKey, body); + final response = await _awaitCancellable( + _post(endpoint, apiKey, body), + cancelToken, + ); return _messageFromResponse(_decodeResponse(response)); } return _streamTurn(endpoint, apiKey, body, onDelta, cancelToken); } + /// Awaits [future] but abandons it the moment the request is cancelled, so a + /// stalled connect (e.g. a misconfigured or unreachable endpoint) unblocks + /// Stop immediately instead of hanging until the socket times out. The + /// abandoned request keeps running in the background; [Future.any] swallows + /// its late completion so it never surfaces as an unhandled error. + Future _awaitCancellable( + Future future, + AgentCancelToken? cancelToken, + ) { + if (cancelToken == null) return future; + final cancelled = cancelToken.whenCancelled.then( + (_) => throw const AgentCancelledException(), + ); + return Future.any(>[future, cancelled]); + } + Future> _streamTurn( Uri endpoint, String apiKey, @@ -161,10 +193,13 @@ class CloudAgentClient { ..headers['Accept'] = 'text/event-stream' ..body = jsonEncode({...body, 'stream': true}); - final response = await _httpClient.send(request); + final response = await _awaitCancellable( + _httpClient.send(request), + cancelToken, + ); if (response.statusCode < 200 || response.statusCode >= 300) { await response.stream.drain(); - throw CloudAgentException( + throw AgentException( 'Custom AI service returned HTTP ${response.statusCode}.', ); } @@ -331,14 +366,14 @@ class CloudAgentClient { Map _decodeResponse(http.Response response) { if (response.statusCode < 200 || response.statusCode >= 300) { - throw CloudAgentException( + throw AgentException( 'Custom AI service returned HTTP ${response.statusCode}.', ); } final decoded = jsonDecode(response.body); if (decoded is! Map) { - throw const CloudAgentException('Cloud response was not a JSON object.'); + throw const AgentException('Cloud response was not a JSON object.'); } return decoded; } @@ -346,16 +381,12 @@ class CloudAgentClient { Map _messageFromResponse(Map decoded) { final choices = decoded['choices']; if (choices is! List || choices.isEmpty || choices.first is! Map) { - throw const CloudAgentException( - 'Cloud response did not include choices.', - ); + throw const AgentException('Cloud response did not include choices.'); } final choice = Map.from(choices.first as Map); final message = choice['message']; if (message is! Map) { - throw const CloudAgentException( - 'Cloud response did not include content.', - ); + throw const AgentException('Cloud response did not include content.'); } return Map.from(message); } @@ -363,7 +394,7 @@ class CloudAgentClient { String _contentFromMessage(Map message) { final content = message['content']?.toString(); if (content == null || content.trim().isEmpty) { - throw const CloudAgentException('Cloud response content was empty.'); + throw const AgentException('Cloud response content was empty.'); } return content; } @@ -425,11 +456,7 @@ class _StreamingToolCall { final StringBuffer arguments = StringBuffer(); } -class CloudAgentException implements Exception { - const CloudAgentException(this.message); - - final String message; - - @override - String toString() => message; +String _toolFailureOutput(Object error) { + final message = error.toString().trim(); + return message.isEmpty ? 'Tool failed.' : 'Tool failed: $message'; } diff --git a/flutter_app/lib/src/send_error_message.dart b/flutter_app/lib/src/send_error_message.dart index 7be0e6f..a97513a 100644 --- a/flutter_app/lib/src/send_error_message.dart +++ b/flutter_app/lib/src/send_error_message.dart @@ -1,10 +1,10 @@ import 'package:flutter/services.dart'; -import 'cloud_agent_client.dart'; +import 'agent_exception.dart'; String? sendErrorMessage(Object error) { return switch (error) { - CloudAgentException(:final message) => message, + AgentException(:final message) => message, MissingPluginException() => 'This device cannot use the built-in assistant yet.', PlatformException(:final message) => diff --git a/flutter_app/lib/src/voice_controller.dart b/flutter_app/lib/src/voice_controller.dart index 7842117..efdc92c 100644 --- a/flutter_app/lib/src/voice_controller.dart +++ b/flutter_app/lib/src/voice_controller.dart @@ -455,8 +455,11 @@ class VoiceController extends ChangeNotifier { @override void dispose() { - unawaited(_speech.cancel()); - unawaited(_tts.stop()); + // Best-effort teardown: swallow platform errors (e.g. a missing plugin in + // tests, or an unavailable engine) so dispose never emits an unhandled + // async exception. + unawaited(_speech.cancel().catchError((_) {})); + unawaited(_tts.stop().catchError((_) {})); super.dispose(); } } diff --git a/flutter_app/test/agent_llm_provider_test.dart b/flutter_app/test/agent_llm_provider_test.dart index be9866f..c137029 100644 --- a/flutter_app/test/agent_llm_provider_test.dart +++ b/flutter_app/test/agent_llm_provider_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:studyos_agent/src/agent_config_store.dart'; import 'package:studyos_agent/src/agent_llm_provider.dart'; import 'package:studyos_agent/src/agent_request_runner.dart'; +import 'package:studyos_agent/src/agent_exception.dart'; import 'package:studyos_agent/src/mail_repository.dart'; import 'package:studyos_agent/src/mail_tools.dart'; import 'package:studyos_agent/src/memory_store.dart'; @@ -91,6 +92,7 @@ void main() { ), memoryText: '', appendMemory: (_) async {}, + readMemory: () async => '', readSchedule: () async => 'No schedule.', mailTools: MailToolRunner( repository: MailRepository.test(), @@ -147,6 +149,7 @@ void main() { ), memoryText: '', appendMemory: (_) async {}, + readMemory: () async => '', readSchedule: () async => 'No schedule.', mailTools: MailToolRunner( repository: MailRepository.test(), @@ -162,6 +165,87 @@ void main() { isNot(contains(nativeSetFlashlightToolName)), ); }); + + test('local provider reads fresh memory during tool execution', () async { + final prompts = []; + final bridge = _FakeNativeBridge.sequence([ + '[TOOL:read_memories:{}]', + 'I used fresh memory.', + ], prompts: prompts); + final provider = LocalNativeLlmProvider(bridge); + + final response = await provider.send( + AgentLlmRequest( + config: const AgentConfig( + provider: AgentProvider.local, + cloudEndpoint: 'https://example.invalid/v1/chat/completions', + cloudModel: 'test-model', + hasApiKey: false, + localModelId: 'test-local', + localModelPath: '/tmp/model.litertlm', + ), + sessions: const [], + activeSessionId: null, + userText: 'What should I remember?', + context: const PromptContext( + profile: null, + memory: 'Stale memory snapshot', + worldState: {}, + ), + memoryText: 'Stale memory snapshot', + appendMemory: (_) async {}, + readMemory: () async => 'Fresh memory from disk', + readSchedule: () async => 'No schedule.', + mailTools: MailToolRunner( + repository: MailRepository.test(), + profile: null, + ), + onToolTrace: (_) {}, + ), + ); + + expect(response, 'I used fresh memory.'); + expect(prompts.last, contains('Fresh memory from disk')); + expect(prompts.last, isNot(contains('Stale memory snapshot'))); + }); + + test('local provider throws when tool rounds are exhausted', () async { + final bridge = _FakeNativeBridge('[TOOL:read_memories:{}]'); + final provider = LocalNativeLlmProvider(bridge); + + await expectLater( + provider.send( + AgentLlmRequest( + config: const AgentConfig( + provider: AgentProvider.local, + cloudEndpoint: 'https://example.invalid/v1/chat/completions', + cloudModel: 'test-model', + hasApiKey: false, + localModelId: 'test-local', + localModelPath: '/tmp/model.litertlm', + ), + sessions: const [], + activeSessionId: null, + userText: 'Loop forever', + context: const PromptContext( + profile: null, + memory: '', + worldState: {}, + ), + memoryText: '', + appendMemory: (_) async {}, + readMemory: () async => '', + readSchedule: () async => 'No schedule.', + mailTools: MailToolRunner( + repository: MailRepository.test(), + profile: null, + ), + onToolTrace: (_) {}, + ), + ), + throwsA(isA()), + ); + }); } class _FakeLlmProvider implements AgentLlmProvider { @@ -189,11 +273,19 @@ class _FakeNativeBridge extends NativeBridge { _FakeNativeBridge( this.response, { this.nativeTools = const >[], - }); + }) : responses = null, + prompts = null; + + _FakeNativeBridge.sequence(this.responses, {this.prompts}) + : response = '', + nativeTools = const >[]; final String response; + final List? responses; + final List? prompts; final List> nativeTools; String? lastSystemPrompt; + int _responseIndex = 0; @override Future> getNativeToolCapabilities() async { @@ -209,6 +301,11 @@ class _FakeNativeBridge extends NativeBridge { String? localBackend, }) async { lastSystemPrompt = systemPrompt; - return response; + prompts?.add(text); + final queued = responses; + if (queued == null) return response; + final index = _responseIndex.clamp(0, queued.length - 1).toInt(); + _responseIndex += 1; + return queued[index]; } } diff --git a/flutter_app/test/cloud_agent_client_test.dart b/flutter_app/test/cloud_agent_client_test.dart index 0f24b41..aebdbc9 100644 --- a/flutter_app/test/cloud_agent_client_test.dart +++ b/flutter_app/test/cloud_agent_client_test.dart @@ -1,8 +1,10 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; +import 'package:studyos_agent/src/agent_exception.dart'; import 'package:studyos_agent/src/cloud_agent_client.dart'; import 'package:studyos_agent/src/cloud_tool_definitions.dart'; import 'package:studyos_agent/src/mail_repository.dart'; @@ -141,7 +143,166 @@ void main() { expect(traces.every((trace) => trace.toolName == 'read_memories'), isTrue); expect(bodies.first['tool_choice'], 'auto'); expect(bodies.first['tools'], isA()); - expect(bodies.last.containsKey('tools'), isFalse); + expect(bodies.last['tools'], isA()); + }); + + test('runs multiple bounded cloud tool rounds', () async { + var requestCount = 0; + final bodies = >[]; + final client = CloudAgentClient( + httpClient: MockClient((request) async { + requestCount += 1; + bodies.add(jsonDecode(request.body) as Map); + if (requestCount == 1) { + return _toolCallResponse( + request, + id: 'call_memory', + name: 'read_memories', + arguments: '{}', + ); + } + if (requestCount == 2) { + expect(request.body, contains('Fresh memory')); + return _toolCallResponse( + request, + id: 'call_schedule', + name: 'get_schedule', + arguments: '{}', + ); + } + expect(request.body, contains('Algorithms 10:00')); + return _contentResponse(request, 'Use memory and attend Algorithms.'); + }), + ); + final traces = []; + + final response = await client.sendMessage( + config: const AgentConfig( + provider: AgentProvider.cloud, + cloudEndpoint: 'https://openrouter.ai/api/v1/chat/completions', + cloudModel: 'openai/gpt-4.1-mini', + hasApiKey: true, + localModelId: 'gemma-4-e2b-it', + localModelPath: '', + ), + apiKey: 'secret', + history: const [], + userText: 'Plan my morning', + context: const PromptContext( + profile: null, + memory: '', + worldState: {}, + ), + appendMemory: (_) async {}, + readMemory: () async => 'Fresh memory', + readSchedule: () async => 'Algorithms 10:00', + mailTools: _fakeMailTools(), + onToolTrace: traces.add, + ); + + expect(response, 'Use memory and attend Algorithms.'); + expect(bodies, hasLength(3)); + expect(bodies[1]['tools'], isA()); + expect(bodies[2]['tools'], isA()); + expect(traces.map((trace) => trace.toolName), [ + 'read_memories', + 'read_memories', + 'get_schedule', + 'get_schedule', + ]); + }); + + test('returns failed cloud tool results to the model', () async { + var requestCount = 0; + final bodies = >[]; + final client = CloudAgentClient( + httpClient: MockClient((request) async { + requestCount += 1; + bodies.add(jsonDecode(request.body) as Map); + if (requestCount == 1) { + return _toolCallResponse( + request, + id: 'call_schedule', + name: 'get_schedule', + arguments: '{}', + ); + } + expect(request.body, contains('Tool failed:')); + return _contentResponse( + request, + 'I could not read the schedule, but can still help.', + ); + }), + ); + final traces = []; + + final response = await client.sendMessage( + config: const AgentConfig( + provider: AgentProvider.cloud, + cloudEndpoint: 'https://openrouter.ai/api/v1/chat/completions', + cloudModel: 'openai/gpt-4.1-mini', + hasApiKey: true, + localModelId: 'gemma-4-e2b-it', + localModelPath: '', + ), + apiKey: 'secret', + history: const [], + userText: 'What is next?', + context: const PromptContext( + profile: null, + memory: '', + worldState: {}, + ), + appendMemory: (_) async {}, + readMemory: () async => '', + readSchedule: () async => throw StateError('schedule unavailable'), + mailTools: _fakeMailTools(), + onToolTrace: traces.add, + ); + + expect(response, 'I could not read the schedule, but can still help.'); + expect(bodies, hasLength(2)); + expect(traces.map((trace) => trace.status), ['running', 'failed']); + }); + + test('throws when cloud tool rounds are exhausted', () async { + final client = CloudAgentClient( + httpClient: MockClient((request) async { + return _toolCallResponse( + request, + id: 'call_memory', + name: 'read_memories', + arguments: '{}', + ); + }), + ); + + await expectLater( + client.sendMessage( + config: const AgentConfig( + provider: AgentProvider.cloud, + cloudEndpoint: 'https://openrouter.ai/api/v1/chat/completions', + cloudModel: 'openai/gpt-4.1-mini', + hasApiKey: true, + localModelId: 'gemma-4-e2b-it', + localModelPath: '', + ), + apiKey: 'secret', + history: const [], + userText: 'Loop forever', + context: const PromptContext( + profile: null, + memory: '', + worldState: {}, + ), + appendMemory: (_) async {}, + readMemory: () async => '', + readSchedule: () async => 'No schedule.', + mailTools: _fakeMailTools(), + onToolTrace: (_) {}, + ), + throwsA(isA()), + ); }); test('accepts cloud responses that skip tools', () async { @@ -269,6 +430,44 @@ void main() { expect(response, 'Your next lecture is Algorithms.'); }); + test('cancels promptly while the connection is still stalling', () async { + // Simulate a misconfigured/unreachable endpoint: the connection never + // resolves, so the client would otherwise hang until the socket times out. + final cancelToken = AgentCancelToken(); + final client = CloudAgentClient( + httpClient: MockClient((request) => Completer().future), + ); + + final future = client.sendMessage( + config: const AgentConfig( + provider: AgentProvider.cloud, + cloudEndpoint: 'https://unreachable.invalid/v1/chat/completions', + cloudModel: 'openai/gpt-4.1-mini', + hasApiKey: true, + localModelId: 'gemma-4-e2b-it', + localModelPath: '', + ), + apiKey: 'secret', + history: const [], + userText: 'Are you there?', + context: const PromptContext( + profile: null, + memory: '', + worldState: {}, + ), + appendMemory: (_) async {}, + readMemory: () async => '', + readSchedule: () async => '', + mailTools: _fakeMailTools(), + onDelta: (_) {}, + cancelToken: cancelToken, + ); + + cancelToken.cancel(); + + await expectLater(future, throwsA(isA())); + }); + test('executes read-only mail tool calls', () async { var requestCount = 0; final client = CloudAgentClient( @@ -345,6 +544,52 @@ void main() { }); } +http.Response _toolCallResponse( + http.BaseRequest request, { + required String id, + required String name, + required String arguments, +}) { + return http.Response( + jsonEncode({ + 'choices': [ + { + 'message': { + 'role': 'assistant', + 'content': null, + 'tool_calls': [ + { + 'id': id, + 'type': 'function', + 'function': { + 'name': name, + 'arguments': arguments, + }, + }, + ], + }, + }, + ], + }), + 200, + request: request, + ); +} + +http.Response _contentResponse(http.BaseRequest request, String content) { + return http.Response( + jsonEncode({ + 'choices': [ + { + 'message': {'role': 'assistant', 'content': content}, + }, + ], + }), + 200, + request: request, + ); +} + MailToolRunner _fakeMailTools() { return MailToolRunner(repository: _FakeMailRepository(), profile: null); }