-
-
Notifications
You must be signed in to change notification settings - Fork 0
fix: tighten tool markup parse and harden custom-model safety #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1511,6 +1511,8 @@ class ModelOrchestrator { | |
| bool hasPendingToolCalls = true; | ||
| int toolRounds = 0; | ||
| final List<Map<String, dynamic>> allToolCalls = []; | ||
| var safetyStopped = false; | ||
| final maxOutputChars = GenerationSafety.maxOutputCharsForCustom(); | ||
|
|
||
| _isStreaming = true; | ||
| _streamingCompleter = Completer<void>(); | ||
|
|
@@ -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 = <Message>[]; | ||
| for (final tc in allToolCalls.where( | ||
|
|
@@ -1623,6 +1648,7 @@ class ModelOrchestrator { | |
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unlike the standard |
||
|
|
||
| inferenceStopwatch.stop(); | ||
| fullResponse = _sanitizeAssistantText(fullResponse); | ||
|
|
||
| yield InferenceResult( | ||
| text: fullResponse, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 = <String, String>{ | ||
| '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<Map<String, dynamic>> _parseMarkupCalls(String text) { | ||
| final results = <Map<String, dynamic>>[]; | ||
| 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'(?:</?\|?tool_call\|?>|<tool_call\|>)?', | ||
| caseSensitive: false, | ||
| ); | ||
|
Comment on lines
150
to
154
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The lazy match |
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the model is safety-stopped (due to length limit or repetition) while in the middle of generating a tool call,
fullResponsewill end with an incomplete tool call markup (e.g.,<|tool_call>call:open_app{). AppendingsafetyMsgdirectly tofullResponseand then calling_sanitizeAssistantText(fullResponse)will cause the safety message to be matched by the lazystripMarkupregex (which strips everything from<|tool_call>to the end of the string$). As a result, the safety message is completely stripped and never shown to the user.Sanitizing the text before appending the safety message prevents it from being stripped. Note that the same issue exists in the standard
processMessagepath.