diff --git a/openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py b/openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py
index 3b81d14363..0a8767e3b7 100644
--- a/openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py
+++ b/openhands-sdk/openhands/sdk/llm/mixins/fn_call_converter.py
@@ -450,7 +450,8 @@ def get_example_for_tools(tools: list[ChatCompletionToolParam]) -> str:
""" # noqa: E501
# Regex patterns for function call parsing
-FN_REGEX_PATTERN = r"]+)>\n(.*?)"
+# Note: newline after function name is optional for compatibility with various models
+FN_REGEX_PATTERN = r"]+)>\n?(.*?)"
FN_PARAM_REGEX_PATTERN = r"]+)>(.*?)"
# Add new regex pattern for tool execution results
@@ -702,7 +703,7 @@ def convert_fncall_messages_to_non_fncall_messages(
first_user_message_encountered = False
for message in messages:
role = message["role"]
- content: Content = message["content"]
+ content: Content = message.get("content") or ""
# 1. SYSTEM MESSAGES
# append system prompt suffix to content
@@ -880,6 +881,9 @@ def _extract_and_validate_params(
for param_match in param_matches:
param_name = param_match.group(1)
param_value = param_match.group(2)
+ # Normalize whitespace: some models add extra newlines around values
+ if isinstance(param_value, str):
+ param_value = param_value.strip()
# Validate parameter is allowed
if allowed_params and param_name not in allowed_params:
@@ -927,7 +931,11 @@ def _extract_and_validate_params(
found_params.add(param_name)
# Check all required parameters are present
- missing_params = required_params - found_params
+ # Note: security_risk is excluded here because its validation happens later
+ # in Agent._extract_security_risk(), which has context about whether a security
+ # analyzer is configured. This allows weaker models to omit it when no analyzer
+ # is active, while still enforcing it for stronger models with LLMSecurityAnalyzer.
+ missing_params = required_params - found_params - {"security_risk"}
if missing_params:
raise FunctionCallValidationError(
f"Missing required parameters for function '{fn_name}': {missing_params}"
@@ -935,12 +943,31 @@ def _extract_and_validate_params(
return params
+def _preprocess_model_output(content: str) -> str:
+ """Clean up model-specific formatting before parsing function calls.
+
+ Removes wrapper tags that some models (like Nemotron) emit around function calls:
+ - before the function call
+ - ... around the function call
+
+ Only strips tags at boundaries, not inside parameter values.
+ """
+ # Strip when it appears before \s*(?= when it appears right before \s*(?= when it appears right after
+ content = re.sub(r"(?<=)\s*", "", content)
+ return content
+
+
def _fix_stopword(content: str) -> str:
"""Fix the issue when some LLM would NOT return the stopword."""
+ content = _preprocess_model_output(content)
if ""
- else:
+ elif not content.rstrip().endswith(""):
content = content + "\n"
return content
@@ -981,8 +1008,8 @@ def convert_non_fncall_messages_to_fncall_messages(
first_user_message_encountered = False
for message in messages:
- role, content = message["role"], message["content"]
- content = content or "" # handle cases where content is None
+ role = message["role"]
+ content = message.get("content") or ""
# For system messages, remove the added suffix
if role == "system":
if isinstance(content, str):
@@ -1124,15 +1151,32 @@ def convert_non_fncall_messages_to_fncall_messages(
if fn_match:
fn_name = fn_match.group(1)
fn_body = _normalize_parameter_tags(fn_match.group(2))
- matching_tool: ChatCompletionToolParamFunctionChunk | None = next(
- (
- tool["function"]
- for tool in tools
- if tool["type"] == "function"
- and tool["function"]["name"] == fn_name
- ),
- None,
- )
+
+ def _find_tool(
+ name: str,
+ ) -> ChatCompletionToolParamFunctionChunk | None:
+ return next(
+ (
+ tool["function"]
+ for tool in tools
+ if tool["type"] == "function"
+ and tool["function"]["name"] == name
+ ),
+ None,
+ )
+
+ matching_tool = _find_tool(fn_name)
+ # Try aliases if tool not found (some models use legacy names)
+ if not matching_tool:
+ TOOL_NAME_ALIASES = {
+ "str_replace_editor": "file_editor",
+ "bash": "terminal",
+ "execute_bash": "terminal",
+ "str_replace": "file_editor",
+ }
+ if fn_name in TOOL_NAME_ALIASES:
+ fn_name = TOOL_NAME_ALIASES[fn_name]
+ matching_tool = _find_tool(fn_name)
# Validate function exists in tools
if not matching_tool:
available_tools = [
@@ -1203,7 +1247,8 @@ def convert_from_multiple_tool_calls_to_single_tool_call_messages(
for message in messages:
role: str
content: Content
- role, content = message["role"], message["content"]
+ role = message["role"]
+ content = message.get("content") or ""
if role == "assistant":
if message.get("tool_calls") and len(message["tool_calls"]) > 1:
# handle multiple tool calls by breaking them into multiple messages
diff --git a/openhands-sdk/openhands/sdk/llm/mixins/non_native_fc.py b/openhands-sdk/openhands/sdk/llm/mixins/non_native_fc.py
index 5f4c56e641..777ec85043 100644
--- a/openhands-sdk/openhands/sdk/llm/mixins/non_native_fc.py
+++ b/openhands-sdk/openhands/sdk/llm/mixins/non_native_fc.py
@@ -41,7 +41,11 @@ def pre_request_prompt_mock(
kwargs: dict,
) -> tuple[list[dict], dict]:
"""Convert to non-fncall prompting when native tool-calling is off."""
- add_iclex = not any(s in self.model for s in ("openhands-lm", "devstral"))
+ # Skip in-context learning examples for models that understand the format
+ # or have limited context windows
+ add_iclex = not any(
+ s in self.model for s in ("openhands-lm", "devstral", "nemotron")
+ )
messages = convert_fncall_messages_to_non_fncall_messages(
messages, tools, add_in_context_learning_example=add_iclex
)