Skip to content

Commit ff7e9d1

Browse files
authored
Merge pull request #411 from droidrun/fix/deepseek-malformed-tool-call-guard
Stop FastAgent loops on malformed tool calls
2 parents ab55496 + 2c0271d commit ff7e9d1

5 files changed

Lines changed: 684 additions & 4 deletions

File tree

mobilerun/agent/fast_agent/events.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from llama_index.core.workflow import Event
1010

11+
from mobilerun.agent.fast_agent.xml_parser import ToolCallParseStatus
1112
from mobilerun.agent.usage import UsageResult
1213

1314

@@ -23,6 +24,7 @@ class FastAgentResponseEvent(Event):
2324
thought: str
2425
code: Optional[str] = None
2526
usage: Optional[UsageResult] = None
27+
tool_call_status: ToolCallParseStatus = ToolCallParseStatus.NO_MARKUP
2628

2729

2830
class FastAgentToolCallEvent(Event):

mobilerun/agent/fast_agent/fast_agent.py

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,12 @@
3333
FastAgentToolCallEvent,
3434
)
3535
from mobilerun.agent.fast_agent.xml_parser import (
36+
ToolCallParseStatus,
3637
ToolResult,
3738
extract_add_memory,
3839
format_tool_calls,
3940
format_tool_results,
40-
parse_tool_calls,
41+
parse_tool_calls_detailed,
4142
)
4243
from mobilerun.agent.usage import get_usage_from_response
4344
from mobilerun.agent.utils.chat_utils import limit_history
@@ -59,6 +60,24 @@
5960

6061
logger = logging.getLogger("mobilerun")
6162

63+
_MALFORMED_TOOL_CALL_LIMIT = 3
64+
65+
66+
def _malformed_tool_call_correction(attempt: int) -> str:
67+
"""Build a focused retry instruction without echoing malformed model output."""
68+
return (
69+
"Your previous response contained tool-call markup that could not be parsed "
70+
f"(attempt {attempt}/{_MALFORMED_TOOL_CALL_LIMIT}). No tool was executed.\n\n"
71+
"Repeat the intended call using only the ASCII XML tags shown below. Replace "
72+
"the placeholder names and value with the intended tool and arguments. Do not "
73+
"use provider-specific markers, full-width punctuation, or alternate tag names.\n\n"
74+
"<function_calls>\n"
75+
'<invoke name="tool_name">\n'
76+
'<parameter name="parameter_name">value</parameter>\n'
77+
"</invoke>\n"
78+
"</function_calls>"
79+
)
80+
6281

6382
class FastAgent(Workflow):
6483
"""Agent that uses XML tool-calling instead of code generation.
@@ -108,6 +127,7 @@ def __init__(
108127

109128
self.system_prompt: ChatMessage | None = None
110129
self.tool_call_counter = 0
130+
self._consecutive_malformed_tool_calls = 0
111131

112132
# Build tool descriptions and param types from registry
113133
self.tool_descriptions = self.registry.get_tool_descriptions_xml()
@@ -180,6 +200,7 @@ async def _build_user_prompt(self, goal: str) -> ChatMessage:
180200
async def prepare_chat(self, ctx: Context, ev: StartEvent) -> FastAgentInputEvent:
181201
"""Initialize message history with goal."""
182202
logger.debug("Preparing chat for task execution...")
203+
self._consecutive_malformed_tool_calls = 0
183204

184205
# Get available secrets (only if type_secret is actually in the registry)
185206
if (
@@ -373,7 +394,9 @@ async def handle_llm_input(
373394
response_text = response.message.content
374395

375396
# Parse tool calls from response
376-
thought, tool_calls = parse_tool_calls(response_text, self.param_types)
397+
parse_result = parse_tool_calls_detailed(response_text, self.param_types)
398+
thought = parse_result.thought
399+
tool_calls = parse_result.calls
377400

378401
# Extract <add_memory> from thought text and append to unified memory
379402
memory_update = extract_add_memory(thought)
@@ -396,17 +419,63 @@ async def handle_llm_input(
396419
thought=thought,
397420
code=tool_calls_xml,
398421
usage=usage,
422+
tool_call_status=parse_result.status,
399423
)
400424
ctx.write_event_to_stream(event)
401425
return event
402426

403427
@step
404428
async def handle_llm_output(
405429
self, ctx: Context, ev: FastAgentResponseEvent
406-
) -> FastAgentToolCallEvent | FastAgentInputEvent:
430+
) -> FastAgentToolCallEvent | FastAgentInputEvent | FastAgentEndEvent:
407431
"""Route to execution or request tool call if missing."""
408432
has_tool_calls = ev.code is not None
409433

434+
if ev.tool_call_status == ToolCallParseStatus.MALFORMED and not has_tool_calls:
435+
self._consecutive_malformed_tool_calls += 1
436+
attempt = self._consecutive_malformed_tool_calls
437+
logger.warning(
438+
"Malformed tool-call markup detected (%d/%d)",
439+
attempt,
440+
_MALFORMED_TOOL_CALL_LIMIT,
441+
)
442+
443+
if attempt >= _MALFORMED_TOOL_CALL_LIMIT:
444+
pending = self.shared_state.drain_user_messages()
445+
if pending:
446+
logger.warning(
447+
"⚠️ Dropping %d external user message(s) at malformed tool-call limit",
448+
len(pending),
449+
)
450+
ctx.write_event_to_stream(
451+
ExternalUserMessageDroppedEvent(
452+
message_ids=[message.id for message in pending],
453+
reason="malformed_tool_call_limit_reached",
454+
step_number=self.shared_state.step_number,
455+
)
456+
)
457+
event = FastAgentEndEvent(
458+
success=False,
459+
reason=(
460+
"Model produced malformed tool-call markup "
461+
f"{_MALFORMED_TOOL_CALL_LIMIT} consecutive times; stopped to prevent "
462+
"a retry loop. Switch models or verify tool-call protocol compatibility."
463+
),
464+
tool_call_count=self.tool_call_counter,
465+
)
466+
ctx.write_event_to_stream(event)
467+
return event
468+
469+
self.shared_state.message_history.append(
470+
ChatMessage(
471+
role="user",
472+
content=_malformed_tool_call_correction(attempt),
473+
)
474+
)
475+
return FastAgentInputEvent()
476+
477+
self._consecutive_malformed_tool_calls = 0
478+
410479
if not ev.thought:
411480
logger.warning("LLM provided tool calls without reasoning.")
412481
no_thoughts_text = (

mobilerun/agent/fast_agent/xml_parser.py

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import re
1111
import xml.etree.ElementTree as ET
1212
from dataclasses import dataclass, field
13+
from enum import Enum
1314
from html import escape
1415
from typing import Any, Dict, List, Optional, Tuple
1516

@@ -22,6 +23,15 @@
2223
r'(<parameter\s+name="[^"]*">)(.*?)(</parameter>)',
2324
re.DOTALL,
2425
)
26+
_PARAM_OPEN_RE = re.compile(r"<parameter(?=[\s>/])")
27+
28+
_DSML_MARKUP_PATTERN = r"(?:<|<)\s*/?\s*|+DSML|+"
29+
_DSML_MARKUP_RE = re.compile(_DSML_MARKUP_PATTERN, re.IGNORECASE)
30+
_TOOL_CALL_MARKUP_RE = re.compile(
31+
r"(?:<|<)\s*/?\s*(?:function_calls|invoke|parameter)\b"
32+
r"|" + _DSML_MARKUP_PATTERN,
33+
re.IGNORECASE,
34+
)
2535

2636

2737
@dataclass
@@ -33,6 +43,23 @@ class ToolCall:
3343
error: Optional[str] = None
3444

3545

46+
class ToolCallParseStatus(str, Enum):
47+
"""Classification of tool-call markup in an LLM response."""
48+
49+
VALID = "valid"
50+
NO_MARKUP = "no_markup"
51+
MALFORMED = "malformed"
52+
53+
54+
@dataclass(frozen=True)
55+
class ToolCallParseResult:
56+
"""Detailed result used internally to distinguish missing and invalid markup."""
57+
58+
thought: str
59+
calls: List[ToolCall]
60+
status: ToolCallParseStatus
61+
62+
3663
@dataclass
3764
class ToolResult:
3865
"""Result from executing a single tool."""
@@ -59,6 +86,28 @@ def parse_tool_calls(
5986
if OPEN_TAG not in text:
6087
return text.strip(), []
6188

89+
result = parse_tool_calls_detailed(text, param_types)
90+
return result.thought, result.calls
91+
92+
93+
def parse_tool_calls_detailed(
94+
text: str, param_types: Optional[Dict[str, str]] = None
95+
) -> ToolCallParseResult:
96+
"""Parse tool calls and classify whether markup was absent, valid, or malformed."""
97+
if OPEN_TAG not in text:
98+
markup_match = _TOOL_CALL_MARKUP_RE.search(text)
99+
if markup_match:
100+
return ToolCallParseResult(
101+
thought=text[: markup_match.start()].strip(),
102+
calls=[],
103+
status=ToolCallParseStatus.MALFORMED,
104+
)
105+
return ToolCallParseResult(
106+
thought=text.strip(),
107+
calls=[],
108+
status=ToolCallParseStatus.NO_MARKUP,
109+
)
110+
62111
parts = text.split(OPEN_TAG)
63112
text_before = parts[0].strip()
64113

@@ -72,12 +121,20 @@ def parse_tool_calls(
72121
if not block:
73122
continue
74123

124+
# Parameter-value sanitization intentionally preserves raw text such as
125+
# code and literal DSML. Provider-specific markup is malformed only
126+
# when it occurs outside a trustworthy canonical parameter payload.
127+
if _has_structural_dsml_markup(block):
128+
continue
129+
75130
calls = _parse_tool_call_block(block, param_types)
76131
if calls:
77132
call_blocks.append(calls)
78133

79134
deduped_blocks = _drop_adjacent_duplicate_blocks(call_blocks)
80-
return text_before, [call for block in deduped_blocks for call in block]
135+
calls = [call for block in deduped_blocks for call in block]
136+
status = ToolCallParseStatus.VALID if calls else ToolCallParseStatus.MALFORMED
137+
return ToolCallParseResult(thought=text_before, calls=calls, status=status)
81138

82139

83140
def format_tool_results(results: List[ToolResult]) -> str:
@@ -170,6 +227,27 @@ def _parse_tool_call_block(
170227
return calls
171228

172229

230+
def _has_structural_dsml_markup(block: str) -> bool:
231+
"""Return whether DSML occurs outside a trustworthy parameter payload."""
232+
payload_spans: List[Tuple[int, int]] = []
233+
for parameter in _PARAM_RE.finditer(block):
234+
payload = parameter.group(2)
235+
# A nested parameter opener means the regex may have crossed
236+
# from a missing close tag into a later parameter. Do not hide DSML in
237+
# that span from structural validation.
238+
if _PARAM_OPEN_RE.search(payload):
239+
continue
240+
payload_spans.append(parameter.span(2))
241+
242+
for marker in _DSML_MARKUP_RE.finditer(block):
243+
if not any(
244+
start <= marker.start() and marker.end() <= end
245+
for start, end in payload_spans
246+
):
247+
return True
248+
return False
249+
250+
173251
def _drop_adjacent_duplicate_blocks(
174252
blocks: List[List[ToolCall]],
175253
) -> List[List[ToolCall]]:

0 commit comments

Comments
 (0)