From df07e1d6695a7510069d4aa82f41dce2ea72ad1b Mon Sep 17 00:00:00 2001 From: using-system Date: Sun, 26 Apr 2026 14:03:54 +0200 Subject: [PATCH 1/7] feat(tools): add debug logging across recipe search tool chain Add detailed debugPrint statements to trace the full tool-call flow: provider registration, tool dispatch, Cookidoo HTTP requests/responses, and chat stream handling. All logs are prefixed with >>> and guarded by kDebugMode. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../chat/presentation/conversation_page.dart | 28 +++++++++++++- .../cookidoo/data/cookidoo_client.dart | 38 +++++++++++++++++++ lib/features/tools/providers.dart | 13 +++++++ lib/features/tools/tool_registry.dart | 11 ++++++ 4 files changed, 89 insertions(+), 1 deletion(-) diff --git a/lib/features/chat/presentation/conversation_page.dart b/lib/features/chat/presentation/conversation_page.dart index 2e7dffc..5a343c7 100644 --- a/lib/features/chat/presentation/conversation_page.dart +++ b/lib/features/chat/presentation/conversation_page.dart @@ -588,30 +588,51 @@ class _ConversationPageState extends ConsumerState { await Future.delayed(Duration.zero); } } else if (response is FunctionCallResponse) { + debugPrint('>>> Stream: FunctionCallResponse name="${response.name}" ' + 'args=${response.args}'); if (mounted) { final toolReg = ref.read(toolRegistryProvider); final toolResult = await toolReg.handle(response, context); if (toolResult != null && _chat == chat) { _hadToolCall = true; + debugPrint('>>> Stream: sending toolResponse for ' + '"${toolResult.name}" to chat'); await chat.addQueryChunk(gemma.Message.toolResponse( toolName: toolResult.name, response: toolResult.result, )); + debugPrint('>>> Stream: toolResponse sent successfully'); + } else { + debugPrint('>>> Stream: tool returned null or chat changed ' + '(toolResult=${toolResult != null}, sameChat=${_chat == chat})'); } } } else if (response is ParallelFunctionCallResponse) { + debugPrint('>>> Stream: ParallelFunctionCallResponse with ' + '${response.calls.length} calls'); if (!mounted) continue; final toolReg = ref.read(toolRegistryProvider); for (final call in response.calls) { + debugPrint('>>> Stream: parallel call name="${call.name}" ' + 'args=${call.args}'); final toolResult = await toolReg.handle(call, context); if (toolResult != null && _chat == chat) { _hadToolCall = true; + debugPrint('>>> Stream: sending parallel toolResponse for ' + '"${toolResult.name}"'); await chat.addQueryChunk(gemma.Message.toolResponse( toolName: toolResult.name, response: toolResult.result, )); + debugPrint('>>> Stream: parallel toolResponse sent'); + } else { + debugPrint('>>> Stream: parallel tool returned null or ' + 'chat changed'); } } + } else { + debugPrint('>>> Stream: unknown response type: ' + '${response.runtimeType}'); } } @@ -634,7 +655,12 @@ class _ConversationPageState extends ConsumerState { await Future.delayed(Duration.zero); } } else if (response is FunctionCallResponse) { - debugPrint('>>> Re-gen: LLM called another tool: ${response.name}'); + debugPrint('>>> Re-gen: LLM called another tool: ' + '"${response.name}" args=${response.args} — ' + 'NOT handled (only 1 round of tool calls supported)'); + } else { + debugPrint('>>> Re-gen: unexpected response type: ' + '${response.runtimeType}'); } } debugPrint('>>> Re-gen done: $tokenCount tokens'); diff --git a/lib/features/cookidoo/data/cookidoo_client.dart b/lib/features/cookidoo/data/cookidoo_client.dart index e71f62e..2873e77 100644 --- a/lib/features/cookidoo/data/cookidoo_client.dart +++ b/lib/features/cookidoo/data/cookidoo_client.dart @@ -134,13 +134,29 @@ class CookidooClient { '?query=${Uri.encodeComponent(query)}&context=recipes&limit=$limit', ); + if (kDebugMode) { + debugPrint('>>> CookidooClient.searchRecipes: GET $url'); + } + final http.Response response; try { response = await _http.get(url, headers: {'Accept': 'application/json'}); } on Exception catch (e) { + if (kDebugMode) { + debugPrint('>>> CookidooClient.searchRecipes: request exception — $e'); + } throw CookidooNetworkException('Search request failed: $e'); } + if (kDebugMode) { + debugPrint( + '>>> CookidooClient.searchRecipes: ${response.statusCode} ' + '(${response.body.length} bytes)'); + debugPrint( + '>>> CookidooClient.searchRecipes body: ' + '${response.body.length > 500 ? '${response.body.substring(0, 500)}…' : response.body}'); + } + if (response.statusCode != 200) { throw CookidooNetworkException( 'Search failed (${response.statusCode})', @@ -149,6 +165,11 @@ class CookidooClient { final json = jsonDecode(response.body) as Map; final data = json['data'] as List? ?? []; + if (kDebugMode) { + debugPrint( + '>>> CookidooClient.searchRecipes: parsed ${data.length} items ' + 'from json keys=${json.keys.toList()}'); + } return data .map((e) => CookidooRecipeOverview.fromJson(e as Map)) @@ -167,6 +188,10 @@ class CookidooClient { '${_baseUrl(countryCode)}/recipes/recipe/$lang/$recipeId', ); + if (kDebugMode) { + debugPrint('>>> CookidooClient.getRecipeDetail: GET $url'); + } + final http.Response response; try { response = await _http.get(url, headers: { @@ -174,9 +199,22 @@ class CookidooClient { 'Authorization': 'Bearer ${_token!.accessToken}', }); } on Exception catch (e) { + if (kDebugMode) { + debugPrint( + '>>> CookidooClient.getRecipeDetail: request exception — $e'); + } throw CookidooNetworkException('Recipe detail request failed: $e'); } + if (kDebugMode) { + debugPrint( + '>>> CookidooClient.getRecipeDetail: ${response.statusCode} ' + '(${response.body.length} bytes)'); + debugPrint( + '>>> CookidooClient.getRecipeDetail body: ' + '${response.body.length > 500 ? '${response.body.substring(0, 500)}…' : response.body}'); + } + if (response.statusCode == 404) { throw CookidooNotFoundException(recipeId); } diff --git a/lib/features/tools/providers.dart b/lib/features/tools/providers.dart index 35826c1..a17e595 100644 --- a/lib/features/tools/providers.dart +++ b/lib/features/tools/providers.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../cookidoo/providers.dart'; @@ -26,11 +27,23 @@ final toolRegistryProvider = Provider( final enabledToolNames = enabledSkills.expand((s) => s.tools).toSet(); + if (kDebugMode) { + debugPrint('>>> ToolRegistryProvider: enabled skills=' + '${enabledSkills.map((s) => s.name).toList()}, ' + 'enabled tools=$enabledToolNames, ' + 'all handlers=${allHandlers.keys.toList()}'); + } + final activeHandlers = allHandlers.entries .where((e) => enabledToolNames.contains(e.key)) .map((e) => e.value) .toList(); + if (kDebugMode) { + debugPrint('>>> ToolRegistryProvider: active handlers=' + '${activeHandlers.map((h) => h.definition.name).toList()}'); + } + return ToolRegistry(activeHandlers); }, ); diff --git a/lib/features/tools/tool_registry.dart b/lib/features/tools/tool_registry.dart index 8fe7a6a..25c2b92 100644 --- a/lib/features/tools/tool_registry.dart +++ b/lib/features/tools/tool_registry.dart @@ -55,6 +55,17 @@ class ToolRegistry { return null; } final result = await handler.execute(response.args, context); + if (kDebugMode) { + if (result == null) { + debugPrint('>>> ToolRegistry: handler "${response.name}" returned null ' + '(fire-and-forget)'); + } else { + final preview = result.toString(); + debugPrint('>>> ToolRegistry: handler "${response.name}" result ' + '(${preview.length} chars): ' + '${preview.length > 300 ? '${preview.substring(0, 300)}…' : preview}'); + } + } if (result == null) return null; return (name: response.name, result: result); } From ed160cfe8c7e16772adce7348da5aae2d15300c0 Mon Sep 17 00:00:00 2001 From: using-system Date: Sun, 26 Apr 2026 15:11:58 +0200 Subject: [PATCH 2/7] fix(chat): support multi-round tool calls and fix credentials loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-gen loop after a tool call only handled TextResponse, ignoring ThinkingResponse and subsequent FunctionCallResponse (e.g. search → get_recipe_detail). This caused empty responses when the LLM chained tool calls. - Re-gen now loops up to 10 rounds, handling ThinkingResponse, FunctionCallResponse, and ParallelFunctionCallResponse - Make credentialsReader async so credentials resolve even when the provider Future has not yet completed - Update search-recipe skill to always call get_recipe_detail and adapt the recipe to user settings while keeping faithful to the original Co-Authored-By: Claude Opus 4.6 (1M context) --- assets/skills/search-recipe/SKILL.md | 18 +++--- .../chat/presentation/conversation_page.dart | 58 +++++++++++++++---- .../data/cookidoo_repository_impl.dart | 8 +-- lib/features/cookidoo/providers.dart | 11 ++-- .../data/cookidoo_repository_impl_test.dart | 16 ++--- 5 files changed, 74 insertions(+), 37 deletions(-) diff --git a/assets/skills/search-recipe/SKILL.md b/assets/skills/search-recipe/SKILL.md index 0c0fd64..8ef90cb 100644 --- a/assets/skills/search-recipe/SKILL.md +++ b/assets/skills/search-recipe/SKILL.md @@ -14,20 +14,20 @@ NEVER generate a recipe from your own knowledge without searching first. - query: a concise search term matching the user's request. String. - limit: number of results, default 5. Integer. -After receiving search results, base your recipe on the Cookidoo results. -Pick the best matching recipe and adapt it to the user's settings (portions, dietary restrictions, Thermomix version, difficulty level). +After receiving search results, pick the best matching recipe and call `get_recipe_detail` to get the full ingredients and steps: -If Cookidoo credentials are configured, call `get_recipe_detail` on the most relevant result to get the full ingredients and steps: +- recipe_id: the Cookidoo recipe ID of the best match. String. -- recipe_id: the Cookidoo recipe ID from search results (e.g. "r145192"). String. - -When you have the full recipe detail, use it as the base for your answer. Adapt the format, language, and portions but keep the ingredients and steps faithful to the original. +When you have the full recipe detail, adapt it to the user's settings (portions, dietary restrictions, Thermomix version, difficulty level, unit system, language). Keep the ingredients, quantities, and steps faithful to the original — only adjust portions and units according to the user's preferences. ## Guidelines - ALWAYS search before answering a recipe request. No exceptions. -- Base your recipe on the search results. Do not invent recipes. +- ALWAYS call `get_recipe_detail` after searching to get the full recipe. +- Base your recipe on the detail results. Do NOT invent ingredients or steps. +- Do NOT change cooking temperatures, Thermomix speeds, or cooking times. +- Do NOT add or remove ingredients unless the user's dietary restrictions require it. +- When adjusting portions, scale all quantities proportionally. - Do NOT mention Cookidoo to the user unless they explicitly ask about it. -- Adapt the recipe to the user's language, unit system, and preferences. -- If multiple results are relevant, combine the best elements. +- If `get_recipe_detail` returns an error, present the recipe overview from the search results as-is. - If search returns no results, and only then, generate a recipe from your own knowledge. diff --git a/lib/features/chat/presentation/conversation_page.dart b/lib/features/chat/presentation/conversation_page.dart index 5a343c7..b088cc5 100644 --- a/lib/features/chat/presentation/conversation_page.dart +++ b/lib/features/chat/presentation/conversation_page.dart @@ -638,13 +638,23 @@ class _ConversationPageState extends ConsumerState { // After stream ends, if a tool was called, re-generate so the LLM // produces its final answer using the tool results as context. - if (_hadToolCall && mounted && _chat == chat) { - debugPrint('>>> Re-generating after tool call...'); + // Loop up to maxRounds to support chained tool calls (e.g. + // search_recipes → get_recipe_detail → final text). + const maxToolRounds = 10; + for (var round = 0; + _hadToolCall && mounted && _chat == chat && round < maxToolRounds; + round++) { + debugPrint('>>> Re-generating after tool call (round ${round + 1})...'); int tokenCount = 0; + _hadToolCall = false; // reset for this round + await for (final response in chat.generateChatResponseAsync()) { if (!mounted) break; - debugPrint('>>> Re-gen response: ${response.runtimeType}'); - if (response is TextResponse) { + + if (response is ThinkingResponse) { + // Thinking tokens during re-gen — skip silently. + continue; + } else if (response is TextResponse) { tokenCount++; buffer.write(response.token); final elapsed = DateTime.now().difference(lastUpdate); @@ -655,15 +665,41 @@ class _ConversationPageState extends ConsumerState { await Future.delayed(Duration.zero); } } else if (response is FunctionCallResponse) { - debugPrint('>>> Re-gen: LLM called another tool: ' - '"${response.name}" args=${response.args} — ' - 'NOT handled (only 1 round of tool calls supported)'); - } else { - debugPrint('>>> Re-gen: unexpected response type: ' - '${response.runtimeType}'); + debugPrint('>>> Re-gen round ${round + 1}: tool call ' + '"${response.name}" args=${response.args}'); + if (mounted) { + final toolReg = ref.read(toolRegistryProvider); + final toolResult = await toolReg.handle(response, context); + if (toolResult != null && _chat == chat) { + _hadToolCall = true; + debugPrint('>>> Re-gen round ${round + 1}: sending ' + 'toolResponse for "${toolResult.name}"'); + await chat.addQueryChunk(gemma.Message.toolResponse( + toolName: toolResult.name, + response: toolResult.result, + )); + debugPrint('>>> Re-gen round ${round + 1}: toolResponse sent'); + } + } + } else if (response is ParallelFunctionCallResponse) { + if (!mounted) continue; + final toolReg = ref.read(toolRegistryProvider); + for (final call in response.calls) { + debugPrint('>>> Re-gen round ${round + 1}: parallel tool call ' + '"${call.name}" args=${call.args}'); + final toolResult = await toolReg.handle(call, context); + if (toolResult != null && _chat == chat) { + _hadToolCall = true; + await chat.addQueryChunk(gemma.Message.toolResponse( + toolName: toolResult.name, + response: toolResult.result, + )); + } + } } } - debugPrint('>>> Re-gen done: $tokenCount tokens'); + debugPrint('>>> Re-gen round ${round + 1} done: $tokenCount tokens, ' + 'hadToolCall=$_hadToolCall'); } } catch (e, stack) { debugPrint('Stream error: $e\n$stack'); diff --git a/lib/features/cookidoo/data/cookidoo_repository_impl.dart b/lib/features/cookidoo/data/cookidoo_repository_impl.dart index 6acbbb7..291abc5 100644 --- a/lib/features/cookidoo/data/cookidoo_repository_impl.dart +++ b/lib/features/cookidoo/data/cookidoo_repository_impl.dart @@ -14,9 +14,7 @@ class CookidooRepositoryImpl implements CookidooRepository { final CookidooClient client; final String locale; - final CookidooCredentials? Function() credentialsReader; - - CookidooCredentials? get credentials => credentialsReader(); + final Future Function() credentialsReader; String get _lang => locale; String get _countryCode => CookidooClient.countryCodeFromLocale(locale); @@ -36,7 +34,7 @@ class CookidooRepositoryImpl implements CookidooRepository { @override Future getRecipeDetail(String recipeId) async { - final creds = credentials; + final creds = await credentialsReader(); if (creds == null || creds.isEmpty) { throw const CookidooAuthException( 'Cookidoo credentials not configured', @@ -52,7 +50,7 @@ class CookidooRepositoryImpl implements CookidooRepository { @override Future isAuthenticated() async { - final creds = credentials; + final creds = await credentialsReader(); if (creds == null || creds.isEmpty) return false; try { await client.login(creds, countryCode: _countryCode); diff --git a/lib/features/cookidoo/providers.dart b/lib/features/cookidoo/providers.dart index 99cb45b..0c9b86c 100644 --- a/lib/features/cookidoo/providers.dart +++ b/lib/features/cookidoo/providers.dart @@ -61,9 +61,12 @@ final cookidooRepositoryProvider = Provider((ref) { return CookidooRepositoryImpl( client: client, locale: lang, - // Read credentials lazily to avoid rebuilding the repository (and the - // entire tool registry chain) every time credentials change. - credentialsReader: () => - ref.read(cookidooCredentialsProvider).valueOrNull, + // Read credentials lazily and asynchronously so the provider resolves + // even when accessed before the credentials Future completes. + credentialsReader: () async { + final storage = + await ref.read(cookidooCredentialsStorageProvider.future); + return storage.read(); + }, ); }); diff --git a/test/features/cookidoo/data/cookidoo_repository_impl_test.dart b/test/features/cookidoo/data/cookidoo_repository_impl_test.dart index 180ebe3..a0fe662 100644 --- a/test/features/cookidoo/data/cookidoo_repository_impl_test.dart +++ b/test/features/cookidoo/data/cookidoo_repository_impl_test.dart @@ -40,7 +40,7 @@ void main() { final repo = CookidooRepositoryImpl( client: CookidooClient(httpClient: mockClient), locale: 'fr-FR', - credentialsReader: () => null, + credentialsReader: () async => null, ); final results = await repo.searchRecipes('pasta', limit: 3); @@ -55,7 +55,7 @@ void main() { final repo = CookidooRepositoryImpl( client: CookidooClient(), locale: 'en-US', - credentialsReader: () => null, + credentialsReader: () async => null, ); expect( @@ -68,7 +68,7 @@ void main() { final repo = CookidooRepositoryImpl( client: CookidooClient(), locale: 'en-US', - credentialsReader: () => + credentialsReader: () async => const CookidooCredentials(email: '', password: ''), ); @@ -82,7 +82,7 @@ void main() { final repo = CookidooRepositoryImpl( client: CookidooClient(), locale: 'en-US', - credentialsReader: () => + credentialsReader: () async => const CookidooCredentials(email: '', password: 'secret'), ); @@ -98,7 +98,7 @@ void main() { final repo = CookidooRepositoryImpl( client: CookidooClient(), locale: 'en-US', - credentialsReader: () => null, + credentialsReader: () async => null, ); expect(await repo.isAuthenticated(), isFalse); @@ -108,7 +108,7 @@ void main() { final repo = CookidooRepositoryImpl( client: CookidooClient(), locale: 'en-US', - credentialsReader: () => + credentialsReader: () async => const CookidooCredentials(email: '', password: ''), ); @@ -121,7 +121,7 @@ void main() { final repo = CookidooRepositoryImpl( client: CookidooClient(httpClient: mockClient), locale: 'en-US', - credentialsReader: () => + credentialsReader: () async => const CookidooCredentials(email: 'a@b.com', password: 'pw'), ); @@ -141,7 +141,7 @@ void main() { final repo = CookidooRepositoryImpl( client: CookidooClient(httpClient: mockClient), locale: 'en-US', - credentialsReader: () => + credentialsReader: () async => const CookidooCredentials(email: 'a@b.com', password: 'pw'), ); From a47eaa64eb1b7f09ec2ec8458be26dda2b5e7d46 Mon Sep 17 00:00:00 2001 From: using-system Date: Sun, 26 Apr 2026 15:49:54 +0200 Subject: [PATCH 3/7] fix(chat): handle raw tool call tokens in non-thinking mode Without reasoning mode the model emits tool calls as raw text tokens (`<|tool_call>call:name{...}`) instead of parsed FunctionCallResponse objects. The re-gen loop missed these entirely, resulting in empty responses after search_recipes. - Add _parseRawToolCall helper to detect and parse the raw format - Check text buffer after both the initial stream and each re-gen round - Simplify search-recipe skill to present recipes as-is - Temporarily disable config line in system prompt to reduce model confusion Co-Authored-By: Claude Opus 4.6 (1M context) --- assets/skills/search-recipe/SKILL.md | 7 +- .../chat/presentation/conversation_page.dart | 74 +++++++++++++++++++ .../recipe/domain/system_prompt_builder.dart | 3 +- 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/assets/skills/search-recipe/SKILL.md b/assets/skills/search-recipe/SKILL.md index 8ef90cb..2b3db61 100644 --- a/assets/skills/search-recipe/SKILL.md +++ b/assets/skills/search-recipe/SKILL.md @@ -18,16 +18,13 @@ After receiving search results, pick the best matching recipe and call `get_reci - recipe_id: the Cookidoo recipe ID of the best match. String. -When you have the full recipe detail, adapt it to the user's settings (portions, dietary restrictions, Thermomix version, difficulty level, unit system, language). Keep the ingredients, quantities, and steps faithful to the original — only adjust portions and units according to the user's preferences. +When you have the full recipe detail, present it as-is. Do NOT adapt, rewrite, or modify the recipe. ## Guidelines - ALWAYS search before answering a recipe request. No exceptions. - ALWAYS call `get_recipe_detail` after searching to get the full recipe. -- Base your recipe on the detail results. Do NOT invent ingredients or steps. -- Do NOT change cooking temperatures, Thermomix speeds, or cooking times. -- Do NOT add or remove ingredients unless the user's dietary restrictions require it. -- When adjusting portions, scale all quantities proportionally. +- Present the recipe exactly as returned. Do NOT modify ingredients, quantities, steps, times, or temperatures. - Do NOT mention Cookidoo to the user unless they explicitly ask about it. - If `get_recipe_detail` returns an error, present the recipe overview from the search results as-is. - If search returns no results, and only then, generate a recipe from your own knowledge. diff --git a/lib/features/chat/presentation/conversation_page.dart b/lib/features/chat/presentation/conversation_page.dart index b088cc5..8a4db98 100644 --- a/lib/features/chat/presentation/conversation_page.dart +++ b/lib/features/chat/presentation/conversation_page.dart @@ -41,6 +41,30 @@ class ConversationPage extends ConsumerStatefulWidget { ConsumerState createState() => _ConversationPageState(); } +/// Parse a raw `<|tool_call>call:name{key:<|"|>value<|"|>}` +/// token into a tool name and args map. Returns `null` if not a match. +({String name, Map args})? _parseRawToolCall(String text) { + final raw = text.trim(); + final re = RegExp( + r'<\|tool_call\>call:(\w+)\{(.+?)\}', + ); + final match = re.firstMatch(raw); + if (match == null) return null; + + final name = match.group(1)!; + final paramsRaw = match.group(2)!; + final args = {}; + + // Parse key:<|"|>value<|"|> pairs. + final paramRe = RegExp(r'(\w+):<\|"\|>(.+?)<\|"\|>'); + for (final pm in paramRe.allMatches(paramsRaw)) { + args[pm.group(1)!] = pm.group(2)!; + } + + debugPrint('>>> _parseRawToolCall: name="$name" args=$args'); + return (name: name, args: args); +} + class _ConversationPageState extends ConsumerState { final InMemoryChatController _chatController = InMemoryChatController(); final StreamStateStore _streamStates = StreamStateStore(); @@ -636,6 +660,30 @@ class _ConversationPageState extends ConsumerState { } } + // Detect raw tool call tokens that the model emits as text + // (happens without thinking mode). + if (!_hadToolCall && mounted && _chat == chat) { + final parsed = _parseRawToolCall(buffer.toString()); + if (parsed != null) { + debugPrint('>>> Stream: detected raw tool call in text buffer'); + buffer.clear(); + final toolReg = ref.read(toolRegistryProvider); + final fakeResponse = FunctionCallResponse( + name: parsed.name, + args: parsed.args, + ); + final toolResult = await toolReg.handle(fakeResponse, context); + if (toolResult != null && _chat == chat) { + _hadToolCall = true; + await chat.addQueryChunk(gemma.Message.toolResponse( + toolName: toolResult.name, + response: toolResult.result, + )); + debugPrint('>>> Stream: raw tool call handled'); + } + } + } + // After stream ends, if a tool was called, re-generate so the LLM // produces its final answer using the tool results as context. // Loop up to maxRounds to support chained tool calls (e.g. @@ -698,6 +746,32 @@ class _ConversationPageState extends ConsumerState { } } } + + // Detect raw tool call tokens in text buffer (no-thinking mode). + if (!_hadToolCall && mounted && _chat == chat) { + final parsed = _parseRawToolCall(buffer.toString()); + if (parsed != null) { + debugPrint('>>> Re-gen round ${round + 1}: ' + 'detected raw tool call in text buffer'); + buffer.clear(); + final toolReg = ref.read(toolRegistryProvider); + final fakeResponse = FunctionCallResponse( + name: parsed.name, + args: parsed.args, + ); + final toolResult = await toolReg.handle(fakeResponse, context); + if (toolResult != null && _chat == chat) { + _hadToolCall = true; + await chat.addQueryChunk(gemma.Message.toolResponse( + toolName: toolResult.name, + response: toolResult.result, + )); + debugPrint('>>> Re-gen round ${round + 1}: ' + 'raw tool call handled'); + } + } + } + debugPrint('>>> Re-gen round ${round + 1} done: $tokenCount tokens, ' 'hadToolCall=$_hadToolCall'); } diff --git a/lib/features/recipe/domain/system_prompt_builder.dart b/lib/features/recipe/domain/system_prompt_builder.dart index e27888c..313e2e2 100644 --- a/lib/features/recipe/domain/system_prompt_builder.dart +++ b/lib/features/recipe/domain/system_prompt_builder.dart @@ -9,8 +9,9 @@ String buildSystemPrompt({ ? 'aucune' : config.dietaryRestrictions; + // TODO: re-enable config when the model handles it reliably. + // Config: ${config.tmVersion.name.toUpperCase()}, $language, ${config.unitSystem.name}, ${config.portions} servings, level ${config.level.name}, restrictions: $restrictions. return ''' CookMate: Thermomix recipe assistant. Answer any food or recipe related request (text, audio or image). -Config: ${config.tmVersion.name.toUpperCase()}, $language, ${config.unitSystem.name}, ${config.portions} servings, level ${config.level.name}, restrictions: $restrictions. $skillInstructions'''; } From 89b642007dcc662c21128b98d8e4ff9fbc400b3f Mon Sep 17 00:00:00 2001 From: using-system Date: Sun, 26 Apr 2026 19:37:14 +0200 Subject: [PATCH 4/7] feat(chat): add SuperGemma4 model option and fix disposed ref crash Add SuperGemma4-E4B-abliterated as an alternative model choice. Guard ref.read() calls with mounted checks in raw tool call handlers to prevent "Cannot use ref after widget disposed" crash when the user leaves the page during a tool call. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/features/chat/domain/chat_model_preference.dart | 8 ++++++++ lib/features/chat/presentation/conversation_page.dart | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/features/chat/domain/chat_model_preference.dart b/lib/features/chat/domain/chat_model_preference.dart index 6451b75..7e580e0 100644 --- a/lib/features/chat/domain/chat_model_preference.dart +++ b/lib/features/chat/domain/chat_model_preference.dart @@ -17,6 +17,14 @@ enum ChatModelPreference { modelType: ModelType.gemmaIt, fileType: ModelFileType.task, ), + superGemma4E4BAbliterated( + label: 'SuperGemma4-E4B-abliterated', + fileName: 'supergemma4-e4b-abliterated.litertlm', + url: + 'https://huggingface.co/typomonster/supergemma4-e4b-abliterated-litert-lm/resolve/main/supergemma4-e4b-abliterated.litertlm', + modelType: ModelType.gemmaIt, + fileType: ModelFileType.litertlm, + ), ; const ChatModelPreference({ diff --git a/lib/features/chat/presentation/conversation_page.dart b/lib/features/chat/presentation/conversation_page.dart index 8a4db98..bdfbceb 100644 --- a/lib/features/chat/presentation/conversation_page.dart +++ b/lib/features/chat/presentation/conversation_page.dart @@ -664,7 +664,7 @@ class _ConversationPageState extends ConsumerState { // (happens without thinking mode). if (!_hadToolCall && mounted && _chat == chat) { final parsed = _parseRawToolCall(buffer.toString()); - if (parsed != null) { + if (parsed != null && mounted) { debugPrint('>>> Stream: detected raw tool call in text buffer'); buffer.clear(); final toolReg = ref.read(toolRegistryProvider); @@ -750,7 +750,7 @@ class _ConversationPageState extends ConsumerState { // Detect raw tool call tokens in text buffer (no-thinking mode). if (!_hadToolCall && mounted && _chat == chat) { final parsed = _parseRawToolCall(buffer.toString()); - if (parsed != null) { + if (parsed != null && mounted) { debugPrint('>>> Re-gen round ${round + 1}: ' 'detected raw tool call in text buffer'); buffer.clear(); From a3a384dc6baba590f8416519cd1692c082428aa6 Mon Sep 17 00:00:00 2001 From: using-system Date: Mon, 27 Apr 2026 08:38:10 +0200 Subject: [PATCH 5/7] test(recipe): update system prompt tests for disabled config line The config line in buildSystemPrompt was temporarily disabled so the tests that asserted config values in the output now fail. Simplify the tests to match the current prompt format. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../domain/system_prompt_builder_test.dart | 60 ++----------------- 1 file changed, 6 insertions(+), 54 deletions(-) diff --git a/test/features/recipe/domain/system_prompt_builder_test.dart b/test/features/recipe/domain/system_prompt_builder_test.dart index 7c6e958..f88491b 100644 --- a/test/features/recipe/domain/system_prompt_builder_test.dart +++ b/test/features/recipe/domain/system_prompt_builder_test.dart @@ -1,41 +1,18 @@ import 'package:cookmate/features/recipe/domain/recipe_config.dart'; -import 'package:cookmate/features/recipe/domain/recipe_level.dart'; import 'package:cookmate/features/recipe/domain/system_prompt_builder.dart'; -import 'package:cookmate/features/recipe/domain/tm_version.dart'; -import 'package:cookmate/features/recipe/domain/unit_system.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('buildSystemPrompt', () { - test('contains expected config values in output', () { - const config = RecipeConfig( - tmVersion: TmVersion.tm6, - unitSystem: UnitSystem.metric, - portions: 4, - level: RecipeLevel.beginner, - dietaryRestrictions: '', - ); - + test('contains CookMate assistant preamble', () { + const config = RecipeConfig(); final prompt = buildSystemPrompt(config: config, language: 'en'); - - expect(prompt, contains('TM6')); - expect(prompt, contains('metric')); - expect(prompt, contains('4 servings')); - expect(prompt, contains('beginner')); - }); - - test('uses "aucune" when dietary restrictions are empty', () { - const config = RecipeConfig(dietaryRestrictions: ''); - final prompt = buildSystemPrompt(config: config, language: 'fr'); - expect(prompt, contains('aucune')); + expect(prompt, contains('CookMate')); + expect(prompt, contains('Thermomix recipe assistant')); }); - test('includes dietary restrictions when provided', () { - const config = RecipeConfig(dietaryRestrictions: 'gluten-free, vegan'); - final prompt = buildSystemPrompt(config: config, language: 'en'); - expect(prompt, contains('gluten-free, vegan')); - expect(prompt, isNot(contains('aucune'))); - }); + // Config line is temporarily disabled (TODO in source). + // These tests verify the prompt still works without it. test('includes skill instructions when provided', () { const config = RecipeConfig(); @@ -50,32 +27,7 @@ void main() { test('skill instructions default to empty string', () { const config = RecipeConfig(); final prompt = buildSystemPrompt(config: config, language: 'en'); - // Should not throw and should not contain extra instructions. expect(prompt, isNotEmpty); }); - - test('includes the language in output', () { - const config = RecipeConfig(); - final prompt = buildSystemPrompt(config: config, language: 'de'); - expect(prompt, contains('de')); - }); - - test('tm version name is uppercased', () { - const config = RecipeConfig(tmVersion: TmVersion.tm5); - final prompt = buildSystemPrompt(config: config, language: 'en'); - expect(prompt, contains('TM5')); - }); - - test('portions value is reflected in prompt', () { - const config = RecipeConfig(portions: 6); - final prompt = buildSystemPrompt(config: config, language: 'en'); - expect(prompt, contains('6 servings')); - }); - - test('imperial unit system appears in prompt', () { - const config = RecipeConfig(unitSystem: UnitSystem.imperial); - final prompt = buildSystemPrompt(config: config, language: 'en'); - expect(prompt, contains('imperial')); - }); }); } From 40eca56348320ce3535f0b8c7b380550a399b0c0 Mon Sep 17 00:00:00 2001 From: using-system Date: Mon, 27 Apr 2026 08:40:37 +0200 Subject: [PATCH 6/7] fix(recipe): comment out unused restrictions variable The variable triggers a warning that fails CI Analyze. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/features/recipe/domain/system_prompt_builder.dart | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/features/recipe/domain/system_prompt_builder.dart b/lib/features/recipe/domain/system_prompt_builder.dart index 313e2e2..7940dad 100644 --- a/lib/features/recipe/domain/system_prompt_builder.dart +++ b/lib/features/recipe/domain/system_prompt_builder.dart @@ -5,11 +5,10 @@ String buildSystemPrompt({ required String language, String skillInstructions = '', }) { - final restrictions = config.dietaryRestrictions.isEmpty - ? 'aucune' - : config.dietaryRestrictions; - // TODO: re-enable config when the model handles it reliably. + // final restrictions = config.dietaryRestrictions.isEmpty + // ? 'aucune' + // : config.dietaryRestrictions; // Config: ${config.tmVersion.name.toUpperCase()}, $language, ${config.unitSystem.name}, ${config.portions} servings, level ${config.level.name}, restrictions: $restrictions. return ''' CookMate: Thermomix recipe assistant. Answer any food or recipe related request (text, audio or image). From a74d6be2c87c9327e22719d5cd8aaa8e355bfa02 Mon Sep 17 00:00:00 2001 From: using-system Date: Mon, 27 Apr 2026 08:46:20 +0200 Subject: [PATCH 7/7] fix(chat): address PR review feedback from Copilot - Anchor raw tool call regex with ^...$ to prevent false matches on text that merely mentions the token format - Parse numeric values from raw tool call args so typed parameters (e.g. limit) are not silently dropped as strings - Wrap all verbose debugPrint calls with kDebugMode to prevent leaking user queries into production logs - Align SuperGemma4 fileType to ModelFileType.task to match the other Gemma 4 model entries Co-Authored-By: Claude Opus 4.6 (1M context) --- .../chat/domain/chat_model_preference.dart | 2 +- .../chat/presentation/conversation_page.dart | 107 ++++++++++++------ 2 files changed, 74 insertions(+), 35 deletions(-) diff --git a/lib/features/chat/domain/chat_model_preference.dart b/lib/features/chat/domain/chat_model_preference.dart index 7e580e0..915edfd 100644 --- a/lib/features/chat/domain/chat_model_preference.dart +++ b/lib/features/chat/domain/chat_model_preference.dart @@ -23,7 +23,7 @@ enum ChatModelPreference { url: 'https://huggingface.co/typomonster/supergemma4-e4b-abliterated-litert-lm/resolve/main/supergemma4-e4b-abliterated.litertlm', modelType: ModelType.gemmaIt, - fileType: ModelFileType.litertlm, + fileType: ModelFileType.task, ), ; diff --git a/lib/features/chat/presentation/conversation_page.dart b/lib/features/chat/presentation/conversation_page.dart index bdfbceb..6e5909c 100644 --- a/lib/features/chat/presentation/conversation_page.dart +++ b/lib/features/chat/presentation/conversation_page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; import 'package:cookmate/l10n/app_localizations.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:just_audio/just_audio.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; @@ -46,7 +47,7 @@ class ConversationPage extends ConsumerStatefulWidget { ({String name, Map args})? _parseRawToolCall(String text) { final raw = text.trim(); final re = RegExp( - r'<\|tool_call\>call:(\w+)\{(.+?)\}', + r'^<\|tool_call\>call:(\w+)\{(.+?)\}$', ); final match = re.firstMatch(raw); if (match == null) return null; @@ -55,13 +56,17 @@ class ConversationPage extends ConsumerStatefulWidget { final paramsRaw = match.group(2)!; final args = {}; - // Parse key:<|"|>value<|"|> pairs. + // Parse key:<|"|>value<|"|> pairs, attempting numeric conversion. final paramRe = RegExp(r'(\w+):<\|"\|>(.+?)<\|"\|>'); for (final pm in paramRe.allMatches(paramsRaw)) { - args[pm.group(1)!] = pm.group(2)!; + final value = pm.group(2)!; + final asNum = num.tryParse(value); + args[pm.group(1)!] = asNum ?? value; } - debugPrint('>>> _parseRawToolCall: name="$name" args=$args'); + if (kDebugMode) { + debugPrint('>>> _parseRawToolCall: name="$name" args=$args'); + } return (name: name, args: args); } @@ -612,49 +617,63 @@ class _ConversationPageState extends ConsumerState { await Future.delayed(Duration.zero); } } else if (response is FunctionCallResponse) { - debugPrint('>>> Stream: FunctionCallResponse name="${response.name}" ' - 'args=${response.args}'); + if (kDebugMode) { + debugPrint('>>> Stream: FunctionCallResponse name="${response.name}" ' + 'args=${response.args}'); + } if (mounted) { final toolReg = ref.read(toolRegistryProvider); final toolResult = await toolReg.handle(response, context); if (toolResult != null && _chat == chat) { _hadToolCall = true; - debugPrint('>>> Stream: sending toolResponse for ' - '"${toolResult.name}" to chat'); + if (kDebugMode) { + debugPrint('>>> Stream: sending toolResponse for ' + '"${toolResult.name}" to chat'); + } await chat.addQueryChunk(gemma.Message.toolResponse( toolName: toolResult.name, response: toolResult.result, )); - debugPrint('>>> Stream: toolResponse sent successfully'); - } else { + if (kDebugMode) { + debugPrint('>>> Stream: toolResponse sent successfully'); + } + } else if (kDebugMode) { debugPrint('>>> Stream: tool returned null or chat changed ' '(toolResult=${toolResult != null}, sameChat=${_chat == chat})'); } } } else if (response is ParallelFunctionCallResponse) { - debugPrint('>>> Stream: ParallelFunctionCallResponse with ' - '${response.calls.length} calls'); + if (kDebugMode) { + debugPrint('>>> Stream: ParallelFunctionCallResponse with ' + '${response.calls.length} calls'); + } if (!mounted) continue; final toolReg = ref.read(toolRegistryProvider); for (final call in response.calls) { - debugPrint('>>> Stream: parallel call name="${call.name}" ' - 'args=${call.args}'); + if (kDebugMode) { + debugPrint('>>> Stream: parallel call name="${call.name}" ' + 'args=${call.args}'); + } final toolResult = await toolReg.handle(call, context); if (toolResult != null && _chat == chat) { _hadToolCall = true; - debugPrint('>>> Stream: sending parallel toolResponse for ' - '"${toolResult.name}"'); + if (kDebugMode) { + debugPrint('>>> Stream: sending parallel toolResponse for ' + '"${toolResult.name}"'); + } await chat.addQueryChunk(gemma.Message.toolResponse( toolName: toolResult.name, response: toolResult.result, )); - debugPrint('>>> Stream: parallel toolResponse sent'); - } else { + if (kDebugMode) { + debugPrint('>>> Stream: parallel toolResponse sent'); + } + } else if (kDebugMode) { debugPrint('>>> Stream: parallel tool returned null or ' 'chat changed'); } } - } else { + } else if (kDebugMode) { debugPrint('>>> Stream: unknown response type: ' '${response.runtimeType}'); } @@ -665,7 +684,9 @@ class _ConversationPageState extends ConsumerState { if (!_hadToolCall && mounted && _chat == chat) { final parsed = _parseRawToolCall(buffer.toString()); if (parsed != null && mounted) { - debugPrint('>>> Stream: detected raw tool call in text buffer'); + if (kDebugMode) { + debugPrint('>>> Stream: detected raw tool call in text buffer'); + } buffer.clear(); final toolReg = ref.read(toolRegistryProvider); final fakeResponse = FunctionCallResponse( @@ -679,7 +700,9 @@ class _ConversationPageState extends ConsumerState { toolName: toolResult.name, response: toolResult.result, )); - debugPrint('>>> Stream: raw tool call handled'); + if (kDebugMode) { + debugPrint('>>> Stream: raw tool call handled'); + } } } } @@ -692,7 +715,9 @@ class _ConversationPageState extends ConsumerState { for (var round = 0; _hadToolCall && mounted && _chat == chat && round < maxToolRounds; round++) { - debugPrint('>>> Re-generating after tool call (round ${round + 1})...'); + if (kDebugMode) { + debugPrint('>>> Re-generating after tool call (round ${round + 1})...'); + } int tokenCount = 0; _hadToolCall = false; // reset for this round @@ -713,28 +738,36 @@ class _ConversationPageState extends ConsumerState { await Future.delayed(Duration.zero); } } else if (response is FunctionCallResponse) { - debugPrint('>>> Re-gen round ${round + 1}: tool call ' - '"${response.name}" args=${response.args}'); + if (kDebugMode) { + debugPrint('>>> Re-gen round ${round + 1}: tool call ' + '"${response.name}" args=${response.args}'); + } if (mounted) { final toolReg = ref.read(toolRegistryProvider); final toolResult = await toolReg.handle(response, context); if (toolResult != null && _chat == chat) { _hadToolCall = true; - debugPrint('>>> Re-gen round ${round + 1}: sending ' - 'toolResponse for "${toolResult.name}"'); + if (kDebugMode) { + debugPrint('>>> Re-gen round ${round + 1}: sending ' + 'toolResponse for "${toolResult.name}"'); + } await chat.addQueryChunk(gemma.Message.toolResponse( toolName: toolResult.name, response: toolResult.result, )); - debugPrint('>>> Re-gen round ${round + 1}: toolResponse sent'); + if (kDebugMode) { + debugPrint('>>> Re-gen round ${round + 1}: toolResponse sent'); + } } } } else if (response is ParallelFunctionCallResponse) { if (!mounted) continue; final toolReg = ref.read(toolRegistryProvider); for (final call in response.calls) { - debugPrint('>>> Re-gen round ${round + 1}: parallel tool call ' - '"${call.name}" args=${call.args}'); + if (kDebugMode) { + debugPrint('>>> Re-gen round ${round + 1}: parallel tool call ' + '"${call.name}" args=${call.args}'); + } final toolResult = await toolReg.handle(call, context); if (toolResult != null && _chat == chat) { _hadToolCall = true; @@ -751,8 +784,10 @@ class _ConversationPageState extends ConsumerState { if (!_hadToolCall && mounted && _chat == chat) { final parsed = _parseRawToolCall(buffer.toString()); if (parsed != null && mounted) { - debugPrint('>>> Re-gen round ${round + 1}: ' - 'detected raw tool call in text buffer'); + if (kDebugMode) { + debugPrint('>>> Re-gen round ${round + 1}: ' + 'detected raw tool call in text buffer'); + } buffer.clear(); final toolReg = ref.read(toolRegistryProvider); final fakeResponse = FunctionCallResponse( @@ -766,14 +801,18 @@ class _ConversationPageState extends ConsumerState { toolName: toolResult.name, response: toolResult.result, )); - debugPrint('>>> Re-gen round ${round + 1}: ' - 'raw tool call handled'); + if (kDebugMode) { + debugPrint('>>> Re-gen round ${round + 1}: ' + 'raw tool call handled'); + } } } } - debugPrint('>>> Re-gen round ${round + 1} done: $tokenCount tokens, ' + if (kDebugMode) { + debugPrint('>>> Re-gen round ${round + 1} done: $tokenCount tokens, ' 'hadToolCall=$_hadToolCall'); + } } } catch (e, stack) { debugPrint('Stream error: $e\n$stack');