diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 579314018cde4..7a25e39ae328b 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -52,6 +52,54 @@ logger = logging.getLogger(__name__) _MAX_AUTH_REFRESH_ATTEMPTS = 2 +_REASONING_BLOCK_PATTERNS = ( + re.compile(r'.*?', re.DOTALL | re.IGNORECASE), + re.compile(r'.*?', re.DOTALL | re.IGNORECASE), + re.compile(r'.*?', re.DOTALL | re.IGNORECASE), + re.compile( + r'.*?', + re.DOTALL | re.IGNORECASE, + ), + re.compile(r'.*?', re.DOTALL | re.IGNORECASE), +) + +_TOOL_CALL_BLOCK_PATTERNS = ( + re.compile(r']*>.*?', re.DOTALL | re.IGNORECASE), + re.compile(r']*>.*?', re.DOTALL | re.IGNORECASE), + re.compile(r']*>.*?', re.DOTALL | re.IGNORECASE), + re.compile( + r']*>.*?', + re.DOTALL | re.IGNORECASE, + ), + re.compile( + r']*>.*?', + re.DOTALL | re.IGNORECASE, + ), +) + +_NAMED_FUNCTION_BLOCK_PATTERN = re.compile( + r'(?:(?<=^)|(?<=[\n\r.!?:]))[ \t]*' + r']*\bname\s*=[^>]*>' + r'(?:(?:(?!).)*)', + re.DOTALL | re.IGNORECASE, +) + +_UNTERMINATED_REASONING_BLOCK_PATTERN = re.compile( + r'(?:^|\n)[ \t]*<(?:think|thinking|reasoning|thought|REASONING_SCRATCHPAD)\b[^>]*>.*$', + re.DOTALL | re.IGNORECASE, +) + +_ORPHAN_REASONING_TAG_PATTERN = re.compile( + r'\s*', + re.IGNORECASE, +) + +_STRAY_TOOL_CALL_CLOSER_PATTERN = re.compile( + r'\s*', + re.IGNORECASE, +) + + def _ra(): """Lazy ``run_agent`` reference for test-patch routing.""" import run_agent @@ -826,62 +874,31 @@ def strip_think_blocks(agent, content: str) -> str: # 1. Closed tag pairs — case-insensitive for all variants so # mixed-case tags (, ) don't slip through to # the unterminated-tag pass and take trailing content with them. - content = re.sub(r'.*?', '', content, flags=re.DOTALL | re.IGNORECASE) - content = re.sub(r'.*?', '', content, flags=re.DOTALL | re.IGNORECASE) - content = re.sub(r'.*?', '', content, flags=re.DOTALL | re.IGNORECASE) - content = re.sub(r'.*?', '', content, flags=re.DOTALL | re.IGNORECASE) - content = re.sub(r'.*?', '', content, flags=re.DOTALL | re.IGNORECASE) + for _pattern in _REASONING_BLOCK_PATTERNS: + content = _pattern.sub('', content) # 1b. Tool-call XML blocks (openclaw/openclaw#67318). Handle the # generic tag names first — they have no attribute gating since # a literal in prose is already vanishingly rare. - for _tc_name in ("tool_call", "tool_calls", "tool_result", - "function_call", "function_calls"): - content = re.sub( - rf'<{_tc_name}\b[^>]*>.*?', - '', - content, - flags=re.DOTALL | re.IGNORECASE, - ) + for _pattern in _TOOL_CALL_BLOCK_PATTERNS: + content = _pattern.sub('', content) # 1c. ... — Gemma-style standalone # tool call. Only strip when the tag sits at a block boundary # (start of text, after a newline, or after sentence-ending # punctuation) AND carries a name="..." attribute. This keeps # prose mentions like "Use to declare" safe. - content = re.sub( - r'(?:(?<=^)|(?<=[\n\r.!?:]))[ \t]*' - r']*\bname\s*=[^>]*>' - r'(?:(?:(?!).)*)', - '', - content, - flags=re.DOTALL | re.IGNORECASE, - ) + content = _NAMED_FUNCTION_BLOCK_PATTERN.sub('', content) # 2. Unterminated reasoning block — open tag at a block boundary # (start of text, or after a newline) with no matching close. # Strip from the tag to end of string. Fixes #8878 / #9568 # (MiniMax M2.7 leaking raw reasoning into assistant content). - content = re.sub( - r'(?:^|\n)[ \t]*<(?:think|thinking|reasoning|thought|REASONING_SCRATCHPAD)\b[^>]*>.*$', - '', - content, - flags=re.DOTALL | re.IGNORECASE, - ) + content = _UNTERMINATED_REASONING_BLOCK_PATTERN.sub('', content) # 3. Stray orphan open/close tags that slipped through. - content = re.sub( - r'\s*', - '', - content, - flags=re.IGNORECASE, - ) + content = _ORPHAN_REASONING_TAG_PATTERN.sub('', content) # 3b. Stray tool-call closers. (We do NOT strip bare or # unterminated because a truncated tail # during streaming may still be valuable to the user; matches # OpenClaw's intentional asymmetry.) - content = re.sub( - r'\s*', - '', - content, - flags=re.IGNORECASE, - ) + content = _STRAY_TOOL_CALL_CLOSER_PATTERN.sub('', content) return content diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index aa33253858ee4..c504f670e4ad2 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -418,6 +418,25 @@ class TestStripThinkBlocks: + @pytest.mark.parametrize( + ("text", "expected"), + [ + ( + "before {x} after", + "before {x}after", + ), + ( + "before {x} after", + "before {x}after", + ), + ], + ) + def test_mismatched_generic_tool_tags_preserve_opener_and_payload( + self, agent, text, expected + ): + assert agent._strip_think_blocks(text) == expected + + class TestExtractReasoning: def test_reasoning_field(self, agent): msg = _mock_assistant_msg(reasoning="thinking hard") @@ -5805,4 +5824,3 @@ class TestMemoryContextSanitization: assert "stale observation" not in result assert "how is the honcho working" in result - diff --git a/tools/skills_guard.py b/tools/skills_guard.py index 2ba266e4fd978..eeaa7be33e035 100644 --- a/tools/skills_guard.py +++ b/tools/skills_guard.py @@ -523,6 +523,11 @@ THREAT_PATTERNS = [ "instructs agent to send data to a URL"), ] +_COMPILED_THREAT_PATTERNS = [ + (re.compile(pattern, re.IGNORECASE), pid, severity, category, description) + for pattern, pid, severity, category, description in THREAT_PATTERNS +] + # Structural limits for skill directories MAX_FILE_COUNT = 50 # skills shouldn't have 50+ files MAX_TOTAL_SIZE_KB = 1024 # 1MB total is suspicious for a skill @@ -594,11 +599,11 @@ def scan_file(file_path: Path, rel_path: str = "") -> List[Finding]: seen = set() # (pattern_id, line_number) for deduplication # Regex pattern matching - for pattern, pid, severity, category, description in THREAT_PATTERNS: + for pattern, pid, severity, category, description in _COMPILED_THREAT_PATTERNS: for i, line in enumerate(lines, start=1): if (pid, i) in seen: continue - if re.search(pattern, line, re.IGNORECASE): + if pattern.search(line): seen.add((pid, i)) matched_text = line.strip() if len(matched_text) > 120: