diff --git a/lib/services/model_orchestrator.dart b/lib/services/model_orchestrator.dart index 512a09f..0637c97 100644 --- a/lib/services/model_orchestrator.dart +++ b/lib/services/model_orchestrator.dart @@ -1511,6 +1511,8 @@ class ModelOrchestrator { bool hasPendingToolCalls = true; int toolRounds = 0; final List> allToolCalls = []; + var safetyStopped = false; + final maxOutputChars = GenerationSafety.maxOutputCharsForCustom(); _isStreaming = true; _streamingCompleter = Completer(); @@ -1529,6 +1531,25 @@ class ModelOrchestrator { fullResponse += token; textBuffer.write(token); + final safetyMsg = GenerationSafety.safetyStopMessage( + fullResponse, + maxOutputChars, + ); + if (safetyMsg != null) { + safetyStopped = true; + fullResponse = '$fullResponse$safetyMsg'; + yield InferenceResult( + text: _sanitizeAssistantText(fullResponse), + model: selector.primaryHeavy, + isStreaming: true, + thinking: currentThinking, + toolCalls: allToolCalls.isNotEmpty ? allToolCalls : null, + inferenceTimeMs: inferenceStopwatch.elapsedMilliseconds, + ); + unawaited(stopGeneration()); + break; + } + final parsedCalls = _tryParseFunctionCalls(textBuffer.toString()); if (parsedCalls != null && parsedCalls.isNotEmpty) { fullResponse = _sanitizeAssistantText(fullResponse); @@ -1587,7 +1608,7 @@ class ModelOrchestrator { } else if (event is ThinkingResponse) { currentThinking = event.content; yield InferenceResult( - text: fullResponse, + text: _sanitizeAssistantText(fullResponse), model: selector.primaryHeavy, isStreaming: true, thinking: currentThinking, @@ -1597,6 +1618,10 @@ class ModelOrchestrator { } } + if (safetyStopped || (_streamingCompleter?.isCompleted ?? false)) { + break; + } + if (hasPendingToolCalls) { final toolResultMessages = []; for (final tc in allToolCalls.where( @@ -1623,6 +1648,7 @@ class ModelOrchestrator { } inferenceStopwatch.stop(); + fullResponse = _sanitizeAssistantText(fullResponse); yield InferenceResult( text: fullResponse, diff --git a/lib/utils/tool_call_parser.dart b/lib/utils/tool_call_parser.dart index 33e4dd9..5f0bb63 100644 --- a/lib/utils/tool_call_parser.dart +++ b/lib/utils/tool_call_parser.dart @@ -5,26 +5,27 @@ class ToolCallParser { const ToolCallParser._(); /// Common hallucinated names → Nova built-in tool names. + /// + /// Keep aliases specific — short names like `search` / `time` cause false + /// positives when the model narrates tool use in prose. static const aliases = { 'google_search': 'search_web', 'web_search': 'search_web', - 'search': 'search_web', 'bing_search': 'search_web', - 'duckduckgo': 'search_web', + 'duckduckgo_search': 'search_web', 'open_browser': 'search_web', 'browser_search': 'search_web', 'get_current_time': 'get_time', 'current_time': 'get_time', - 'time': 'get_time', 'set_an_alarm': 'set_alarm', 'create_alarm': 'set_alarm', 'launch_app': 'open_app', 'start_app': 'open_app', 'open_application': 'open_app', - 'screenshot': 'take_screenshot', 'capture_screen': 'take_screenshot', - 'weather': 'get_weather', + 'take_a_screenshot': 'take_screenshot', 'check_weather': 'get_weather', + 'get_current_weather': 'get_weather', }; /// Returns normalized tool calls, or null if none found. @@ -100,10 +101,6 @@ class ToolCallParser { ), '', ); - out = out.replaceAll( - RegExp(r'call:\s*[a-zA-Z0-9_]+\s*\{[^{}]*(?:\}|$)', caseSensitive: false), - '', - ); out = out.replaceAll(RegExp(r'<\|?"?\|>'), ''); out = out.replaceAll(RegExp(r'[ \t]+\n'), '\n'); out = out.replaceAll(RegExp(r'\n{3,}'), '\n\n'); @@ -145,10 +142,13 @@ class ToolCallParser { /// Handles forms like: /// `<|tool_call>call:google_search{queries:[<|"|>Missypwns twitch<|"|>]}` + /// + /// Requires a tool-call delimiter so explanatory prose such as + /// `call:open_app{package:...}` is not executed as a real tool. static List> _parseMarkupCalls(String text) { final results = >[]; final pattern = RegExp( - r'(?:<\|?tool_call\|?>)?\s*call:\s*([a-zA-Z0-9_]+)\s*(\{[\s\S]*?\})\s*' + r'<\|?tool_call\|?>\s*call:\s*([a-zA-Z0-9_]+)\s*(\{[\s\S]*?\})\s*' r'(?:|)?', caseSensitive: false, ); diff --git a/test/utils/tool_call_parser_test.dart b/test/utils/tool_call_parser_test.dart index 3018951..c8616c2 100644 --- a/test/utils/tool_call_parser_test.dart +++ b/test/utils/tool_call_parser_test.dart @@ -41,5 +41,28 @@ void main() { test('returns null for ordinary prose', () { expect(ToolCallParser.parse('Hello, how can I help?'), isNull); }); + + test('ignores bare call: markup without tool_call delimiter', () { + const prose = + 'I would call:open_app{package:com.android.chrome} if tools worked.'; + expect(ToolCallParser.parse(prose), isNull); + }); + + test('does not alias short words like search or time', () { + expect( + ToolCallParser.normalizeCall({ + 'name': 'search', + 'args': {'query': 'x'}, + })['name'], + 'search', + ); + expect( + ToolCallParser.normalizeCall({ + 'name': 'time', + 'args': {}, + })['name'], + 'time', + ); + }); }); }