@@ -1715,101 +1715,21 @@ internal static bool TryBuildMalformedTextToolCall(
17151715 }
17161716
17171717 /// <summary>
1718- /// Pattern-aware tool call extraction from text.
1719- /// Matches JSON objects that contain both "name" and "arguments" keys (outside fenced ``` blocks),
1720- /// then falls back to <see cref="LlmToolCallTextExtractor"/> for pseudo-syntax memory writes
1721- /// (e.g. Qwen: <c>Action=write content="..."</c>).
1718+ /// Pattern-aware tool call extraction from text. Delegates to the portable
1719+ /// <see cref="LlmToolCallTextExtractor"/> so both layers share one parser: exact call shape
1720+ /// (top-level <c>"name"</c> string plus <c>"arguments"</c> object / <c>"arguments_json"</c>
1721+ /// string), backtick/quote-cited spans and fenced ``` blocks skipped, and pseudo-syntax
1722+ /// memory writes (e.g. Qwen: <c>Action=write content="..."</c>) picked up as fallback.
17221723 /// </summary>
17231724 internal static bool TryExtractToolCallsFromText (
17241725 string text ,
17251726 out List < MEAI . FunctionCallContent > toolCalls ,
17261727 out string cleanedText )
17271728 {
1728- toolCalls = new List < MEAI . FunctionCallContent > ( ) ;
1729- cleanedText = text ?? string . Empty ;
1730- if ( string . IsNullOrWhiteSpace ( text ) )
1731- {
1732- return false ;
1733- }
1734-
1735- // Strip fenced code blocks to avoid matching JSON inside them when giving **non-tool** examples.
1736- string textForSearch = StripCodeBlocks ( text ) ;
1737-
1738- // Find all balanced JSON objects that look like tool calls (only outside fenced ``` blocks;
1739- // StripCodeBlocks blanks ```...``` so examples like ```json {"name":...} ``` are not matched).
1740- List < JsonSpan > candidates = FindToolCallJsonSpans ( textForSearch ) ;
1741-
1742- if ( candidates . Count == 0 )
1743- {
1744- return TryPortableToolExtract ( text , out toolCalls , out cleanedText ) ;
1745- }
1746-
1747- // Build cleaned text by removing all found tool-call JSON spans (from original text)
1748- // *hidden* from search, the positions still correspond to the original text.
1749- System . Text . StringBuilder cleanBuilder = new ( text . Length ) ;
1750- int lastEnd = 0 ;
1751- foreach ( JsonSpan span in candidates )
1752- {
1753- // Verify span is valid in original text too
1754- if ( span . Start >= text . Length || span . Start + span . Length > text . Length )
1755- {
1756- continue ;
1757- }
1758-
1759- string originalFragment = text . Substring ( span . Start , span . Length ) ;
1760-
1761- // Re-validate the fragment in the original text
1762- if ( ! IsValidToolCallJson ( originalFragment ) )
1763- {
1764- continue ;
1765- }
1766-
1767- try
1768- {
1769- JObject json = JObject . Parse ( originalFragment ) ;
1770- string functionName = json [ "name" ] ? . ToString ( ) ? . Trim ( ) ;
1771- // Support both "arguments" and "arguments_json" (Qwen3.5 via LLMUnity).
1772- JToken argsToken = json [ "arguments" ] ?? json [ "arguments_json" ] ;
1773- if ( string . IsNullOrWhiteSpace ( functionName ) || argsToken == null )
1774- {
1775- continue ;
1776- }
1777-
1778- // If args is a string (e.g. "arguments_json": "{...}"), parse it as JSON.
1779- string argsStr = argsToken . Type == JTokenType . String
1780- ? argsToken . ToString ( )
1781- : argsToken . ToString ( Formatting . None ) ;
1782-
1783- Dictionary < string , object ? > arguments =
1784- JsonConvert . DeserializeObject < Dictionary < string , object ? > > ( argsStr )
1785- ?? new Dictionary < string , object ? > ( ) ;
1786-
1787- // Normalize JObject/JArray values to strings for MEAI compatibility.
1788- NormalizeJTokenValues ( arguments ) ;
1789-
1790- string callId = $ "stream_call_{ functionName } _{ Guid . NewGuid ( ) : N} ";
1791- toolCalls . Add ( new MEAI . FunctionCallContent ( callId , functionName , arguments ) ) ;
1792-
1793- cleanBuilder . Append ( text , lastEnd , span . Start - lastEnd ) ;
1794- lastEnd = span . Start + span . Length ;
1795- }
1796- catch
1797- {
1798- }
1799- }
1800-
1801- if ( toolCalls . Count == 0 )
1802- {
1803- return TryPortableToolExtract ( text , out toolCalls , out cleanedText ) ;
1804- }
1805-
1806- if ( lastEnd < text . Length )
1807- {
1808- cleanBuilder . Append ( text , lastEnd , text . Length - lastEnd ) ;
1809- }
1810-
1811- cleanedText = cleanBuilder . ToString ( ) . Trim ( ) ;
1812- return true ;
1729+ // WHY: This layer used to keep its own, laxer parser that executed cited schema
1730+ // examples (inline-code/quoted JSON, placeholder tool names). Delegating to the
1731+ // hardened portable extractor closes that gap instead of duplicating its guards.
1732+ return TryPortableToolExtract ( text , out toolCalls , out cleanedText ) ;
18131733 }
18141734
18151735 /// <summary>
@@ -1837,6 +1757,9 @@ private static bool TryPortableToolExtract(
18371757 JsonConvert . DeserializeObject < Dictionary < string , object ? > > ( m . ArgumentsJson )
18381758 ?? new Dictionary < string , object ? > ( ) ;
18391759
1760+ // Normalize JObject/JArray values to strings for MEAI compatibility.
1761+ NormalizeJTokenValues ( arguments ) ;
1762+
18401763 string callId = $ "stream_call_{ m . Name } _{ Guid . NewGuid ( ) : N} ";
18411764 toolCalls . Add ( new MEAI . FunctionCallContent ( callId , m . Name , arguments ) ) ;
18421765 }
@@ -1875,17 +1798,13 @@ internal static string StripCodeBlocks(string text)
18751798 return Regex . Replace ( text , @"```[\s\S]*?```" , m => new string ( ' ' , m . Length ) ) ;
18761799 }
18771800
1878- /// <summary>Checks if a JSON string looks like a tool call (has "name" and "arguments" or "arguments_json").</summary>
1801+ /// <summary>Checks if a JSON string has the exact tool- call shape (top-level "name" string plus "arguments" object or "arguments_json" string ).</summary>
18791802 internal static bool IsValidToolCallJson ( string json )
18801803 {
1881- if ( string . IsNullOrWhiteSpace ( json ) )
1882- {
1883- return false ;
1884- }
1885-
1886- // Quick heuristic before parsing: must contain both key patterns
1887- return json . Contains ( "\" name\" " ) &&
1888- ( json . Contains ( "\" arguments\" " ) || json . Contains ( "\" arguments_json\" " ) ) ;
1804+ // WHY: Shared with the portable extractor so the hybrid-hold span detection and the
1805+ // execution path agree on what counts as a tool call (substring hits alone treated
1806+ // quoted schema examples as commands).
1807+ return LlmToolCallTextExtractor . LooksLikeToolCallJson ( json ) ;
18891808 }
18901809
18911810 /// <summary>
0 commit comments