feat: Normalize alternative tool call formats (invoke/function_calls)

This commit is contained in:
0xallam
2026-02-20 07:31:45 -08:00
committed by Ahmed Allam
parent 6166be841b
commit f4d522164d
4 changed files with 70 additions and 17 deletions

View File

@@ -314,13 +314,29 @@ CRITICAL RULES:
4. Use ONLY the exact format shown above. NEVER use JSON/YAML/INI or any other syntax for tools or parameters. 4. Use ONLY the exact format shown above. NEVER use JSON/YAML/INI or any other syntax for tools or parameters.
5. When sending ANY multi-line content in tool parameters, use real newlines (actual line breaks). Do NOT emit literal "\n" sequences. Literal "\n" instead of real line breaks will cause tools to fail. 5. When sending ANY multi-line content in tool parameters, use real newlines (actual line breaks). Do NOT emit literal "\n" sequences. Literal "\n" instead of real line breaks will cause tools to fail.
6. Tool names must match exactly the tool "name" defined (no module prefixes, dots, or variants). 6. Tool names must match exactly the tool "name" defined (no module prefixes, dots, or variants).
- Correct: <function=think> ... </function>
- Incorrect: <thinking_tools.think> ... </function>
- Incorrect: <think> ... </think>
- Incorrect: {"think": {...}}
7. Parameters must use <parameter=param_name>value</parameter> exactly. Do NOT pass parameters as JSON or key:value lines. Do NOT add quotes/braces around values. 7. Parameters must use <parameter=param_name>value</parameter> exactly. Do NOT pass parameters as JSON or key:value lines. Do NOT add quotes/braces around values.
8. Do NOT wrap tool calls in markdown/code fences or add any text before or after the tool block. 8. Do NOT wrap tool calls in markdown/code fences or add any text before or after the tool block.
CORRECT format — use this EXACTLY:
<function=tool_name>
<parameter=param_name>value</parameter>
</function>
WRONG formats — NEVER use these:
- <invoke name="tool_name"><parameter name="param_name">value</parameter></invoke>
- <function_calls><invoke name="tool_name">...</invoke></function_calls>
- <tool_call><tool_name>...</tool_name></tool_call>
- {"tool_name": {"param_name": "value"}}
- ```<function=tool_name>...</function>```
Do NOT emit any extra XML tags in your output. In particular:
- NO <thinking>...</thinking> or <thought>...</thought> blocks
- NO <scratchpad>...</scratchpad> or <reasoning>...</reasoning> blocks
- NO <answer>...</answer> or <response>...</response> wrappers
If you need to reason, use the think tool. Your raw output must contain ONLY the tool call — no surrounding XML tags.
Notice: use <function=X> NOT <invoke name="X">, use <parameter=X> NOT <parameter name="X">, use </function> NOT </invoke>.
Example (agent creation tool): Example (agent creation tool):
<function=create_agent> <function=create_agent>
<parameter=task>Perform targeted XSS testing on the search endpoint</parameter> <parameter=task>Perform targeted XSS testing on the search endpoint</parameter>

View File

@@ -3,8 +3,11 @@ import re
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal from typing import Literal
from strix.llm.utils import normalize_tool_format
_FUNCTION_TAG_PREFIX = "<function=" _FUNCTION_TAG_PREFIX = "<function="
_INVOKE_TAG_PREFIX = "<invoke "
_FUNC_PATTERN = re.compile(r"<function=([^>]+)>") _FUNC_PATTERN = re.compile(r"<function=([^>]+)>")
_FUNC_END_PATTERN = re.compile(r"</function>") _FUNC_END_PATTERN = re.compile(r"</function>")
@@ -21,9 +24,8 @@ def _get_safe_content(content: str) -> tuple[str, str]:
return content, "" return content, ""
suffix = content[last_lt:] suffix = content[last_lt:]
target = _FUNCTION_TAG_PREFIX # "<function="
if target.startswith(suffix): if _FUNCTION_TAG_PREFIX.startswith(suffix) or _INVOKE_TAG_PREFIX.startswith(suffix):
return content[:last_lt], suffix return content[:last_lt], suffix
return content, "" return content, ""
@@ -42,6 +44,8 @@ def parse_streaming_content(content: str) -> list[StreamSegment]:
if not content: if not content:
return [] return []
content = normalize_tool_format(content)
segments: list[StreamSegment] = [] segments: list[StreamSegment] = []
func_matches = list(_FUNC_PATTERN.finditer(content)) func_matches = list(_FUNC_PATTERN.finditer(content))

View File

@@ -14,6 +14,7 @@ from strix.llm.memory_compressor import MemoryCompressor
from strix.llm.utils import ( from strix.llm.utils import (
_truncate_to_first_function, _truncate_to_first_function,
fix_incomplete_tool_call, fix_incomplete_tool_call,
normalize_tool_format,
parse_tool_invocations, parse_tool_invocations,
) )
from strix.skills import load_skills from strix.skills import load_skills
@@ -143,10 +144,12 @@ class LLM:
delta = self._get_chunk_content(chunk) delta = self._get_chunk_content(chunk)
if delta: if delta:
accumulated += delta accumulated += delta
if "</function>" in accumulated: if "</function>" in accumulated or "</invoke>" in accumulated:
accumulated = accumulated[ for end_tag in ("</function>", "</invoke>"):
: accumulated.find("</function>") + len("</function>") pos = accumulated.find(end_tag)
] if pos != -1:
accumulated = accumulated[: pos + len(end_tag)]
break
yield LLMResponse(content=accumulated) yield LLMResponse(content=accumulated)
done_streaming = 1 done_streaming = 1
continue continue
@@ -155,6 +158,7 @@ class LLM:
if chunks: if chunks:
self._update_usage_stats(stream_chunk_builder(chunks)) self._update_usage_stats(stream_chunk_builder(chunks))
accumulated = normalize_tool_format(accumulated)
accumulated = fix_incomplete_tool_call(_truncate_to_first_function(accumulated)) accumulated = fix_incomplete_tool_call(_truncate_to_first_function(accumulated))
yield LLMResponse( yield LLMResponse(
content=accumulated, content=accumulated,

View File

@@ -3,6 +3,29 @@ import re
from typing import Any from typing import Any
_INVOKE_OPEN = re.compile(r'<invoke\s+name="([^"]+)"\s*>')
_PARAM_NAME_ATTR = re.compile(r'<parameter\s+name="([^"]+)"\s*>')
_FUNCTION_CALLS_TAG = re.compile(r"</?function_calls>")
def normalize_tool_format(content: str) -> str:
"""Convert alternative tool-call XML format to the expected one.
Handles:
<function_calls>...</function_calls> → stripped
<invoke name="X"> → <function=X>
<parameter name="X"> → <parameter=X>
</invoke> → </function>
"""
if "<invoke" not in content and "<function_calls" not in content:
return content
content = _FUNCTION_CALLS_TAG.sub("", content)
content = _INVOKE_OPEN.sub(r"<function=\1>", content)
content = _PARAM_NAME_ATTR.sub(r"<parameter=\1>", content)
return content.replace("</invoke>", "</function>")
STRIX_MODEL_MAP: dict[str, str] = { STRIX_MODEL_MAP: dict[str, str] = {
"claude-sonnet-4.6": "anthropic/claude-sonnet-4-6", "claude-sonnet-4.6": "anthropic/claude-sonnet-4-6",
"claude-opus-4.6": "anthropic/claude-opus-4-6", "claude-opus-4.6": "anthropic/claude-opus-4-6",
@@ -41,7 +64,9 @@ def _truncate_to_first_function(content: str) -> str:
if not content: if not content:
return content return content
function_starts = [match.start() for match in re.finditer(r"<function=", content)] function_starts = [
match.start() for match in re.finditer(r"<function=|<invoke\s+name=", content)
]
if len(function_starts) >= 2: if len(function_starts) >= 2:
second_function_start = function_starts[1] second_function_start = function_starts[1]
@@ -52,6 +77,7 @@ def _truncate_to_first_function(content: str) -> str:
def parse_tool_invocations(content: str) -> list[dict[str, Any]] | None: def parse_tool_invocations(content: str) -> list[dict[str, Any]] | None:
content = normalize_tool_format(content)
content = fix_incomplete_tool_call(content) content = fix_incomplete_tool_call(content)
tool_invocations: list[dict[str, Any]] = [] tool_invocations: list[dict[str, Any]] = []
@@ -81,12 +107,14 @@ def parse_tool_invocations(content: str) -> list[dict[str, Any]] | None:
def fix_incomplete_tool_call(content: str) -> str: def fix_incomplete_tool_call(content: str) -> str:
"""Fix incomplete tool calls by adding missing </function> tag.""" """Fix incomplete tool calls by adding missing closing tag.
if (
"<function=" in content Handles both ``<function=…>`` and ``<invoke name="">`` formats.
and content.count("<function=") == 1 """
and "</function>" not in content has_open = "<function=" in content or "<invoke " in content
): count_open = content.count("<function=") + content.count("<invoke ")
has_close = "</function>" in content or "</invoke>" in content
if has_open and count_open == 1 and not has_close:
content = content.rstrip() content = content.rstrip()
content = content + "function>" if content.endswith("</") else content + "\n</function>" content = content + "function>" if content.endswith("</") else content + "\n</function>"
return content return content
@@ -107,6 +135,7 @@ def clean_content(content: str) -> str:
if not content: if not content:
return "" return ""
content = normalize_tool_format(content)
content = fix_incomplete_tool_call(content) content = fix_incomplete_tool_call(content)
tool_pattern = r"<function=[^>]+>.*?</function>" tool_pattern = r"<function=[^>]+>.*?</function>"