From 6ef568cea001f401a20d23d14f7b867694fce02b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=98=8A?= Date: Fri, 24 Apr 2026 22:14:43 +0800 Subject: [PATCH 1/3] fix(gateway): make npm install timeout configurable via WHATSAPP_NPM_INSTALL_TIMEOUT Increase the default npm install timeout for WhatsApp bridge from 60s to 300s (5 minutes) to accommodate slower systems like Unraid NAS. Make it configurable via WHATSAPP_NPM_INSTALL_TIMEOUT environment variable for users who need even longer timeouts. Closes #14980 --- gateway/platforms/whatsapp.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index a82417a6015c..7a163c71dee0 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -379,12 +379,15 @@ async def connect(self) -> bool: if not (bridge_dir / "node_modules").exists(): print(f"[{self.name}] Installing WhatsApp bridge dependencies...") try: + # Read timeout from environment variable, default to 300 seconds (5 minutes) + # to accommodate slower systems like Unraid NAS + npm_install_timeout = int(os.environ.get("WHATSAPP_NPM_INSTALL_TIMEOUT", "300")) install_result = subprocess.run( ["npm", "install", "--silent"], cwd=str(bridge_dir), capture_output=True, text=True, - timeout=60, + timeout=npm_install_timeout, ) if install_result.returncode != 0: print(f"[{self.name}] npm install failed: {install_result.stderr}") From 5dc3423af6b31742f3873328229ee7924a2f8ac0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E6=98=8A?= Date: Sat, 25 Apr 2026 16:49:01 +0800 Subject: [PATCH 2/3] fix(tools): mark patch tool conditionally required params as required - Add 'path', 'old_string', 'new_string', and 'patch' to required list - Update description to clarify mode-specific parameter requirements - This addresses issue where LLMs would omit these parameters because they were not marked as required in the schema, even though they are required depending on the mode Fixes #15524 --- tests/tools/test_file_tools.py | 37 ++++++++++++++++++++++++++++++++++ tools/file_tools.py | 12 +++++------ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/tests/tools/test_file_tools.py b/tests/tools/test_file_tools.py index 5a215df14a0e..979d309b09da 100644 --- a/tests/tools/test_file_tools.py +++ b/tests/tools/test_file_tools.py @@ -323,4 +323,41 @@ def test_truncated_hint_with_nonzero_offset(self, mock_get): assert "offset=100" in raw +class TestPatchSchema: + """Tests for PATCH_SCHEMA to ensure required parameters are properly declared.""" + + def test_patch_schema_includes_all_required_params(self): + """PATCH_SCHEMA should include all parameters that are conditionally required.""" + from tools.file_tools import PATCH_SCHEMA + + # Verify schema structure + assert "parameters" in PATCH_SCHEMA + assert "required" in PATCH_SCHEMA["parameters"] + + # All parameters that are mode-specific should be in required list + required = PATCH_SCHEMA["parameters"]["required"] + assert "mode" in required + assert "path" in required + assert "old_string" in required + assert "new_string" in required + assert "patch" in required + + # replace_all is optional (has default), so it should NOT be in required + assert "replace_all" not in required + + def test_patch_schema_description_mentions_mode_specific_requirements(self): + """PATCH_SCHEMA description should explain mode-specific requirements.""" + from tools.file_tools import PATCH_SCHEMA + + description = PATCH_SCHEMA.get("description", "") + + # Description should mention mode-specific requirements + assert "mode-specific" in description.lower() or "IMPORTANT:" in description + + # Should mention both modes + assert "mode='replace'" in description + assert "mode='patch'" in description + + + diff --git a/tools/file_tools.py b/tools/file_tools.py index 609506c05e1b..385a0146d778 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -908,18 +908,18 @@ def _check_file_reqs(): PATCH_SCHEMA = { "name": "patch", - "description": "Targeted find-and-replace edits in files. Use this instead of sed/awk in terminal. Uses fuzzy matching (9 strategies) so minor whitespace/indentation differences won't break it. Returns a unified diff. Auto-runs syntax checks after editing.\n\nReplace mode (default): find a unique string and replace it.\nPatch mode: apply V4A multi-file patches for bulk changes.", + "description": "Targeted find-and-replace edits in files. Use this instead of sed/awk in terminal. Uses fuzzy matching (9 strategies) so minor whitespace/indentation differences won't break it. Returns a unified diff. Auto-runs syntax checks after editing.\n\nIMPORTANT: Parameters are mode-specific:\n- For mode='replace': provide path, old_string, new_string (optionally replace_all)\n- For mode='patch': provide patch content", "parameters": { "type": "object", "properties": { "mode": {"type": "string", "enum": ["replace", "patch"], "description": "Edit mode: 'replace' for targeted find-and-replace, 'patch' for V4A multi-file patches", "default": "replace"}, - "path": {"type": "string", "description": "File path to edit (required for 'replace' mode)"}, - "old_string": {"type": "string", "description": "Text to find in the file (required for 'replace' mode). Must be unique in the file unless replace_all=true. Include enough surrounding context to ensure uniqueness."}, - "new_string": {"type": "string", "description": "Replacement text (required for 'replace' mode). Can be empty string to delete the matched text."}, + "path": {"type": "string", "description": "File path to edit (required when mode='replace')"}, + "old_string": {"type": "string", "description": "Text to find in file (required when mode='replace'). Must be unique in file unless replace_all=true. Include enough surrounding context to ensure uniqueness."}, + "new_string": {"type": "string", "description": "Replacement text (required when mode='replace'). Can be empty string to delete the matched text."}, "replace_all": {"type": "boolean", "description": "Replace all occurrences instead of requiring a unique match (default: false)", "default": False}, - "patch": {"type": "string", "description": "V4A format patch content (required for 'patch' mode). Format:\n*** Begin Patch\n*** Update File: path/to/file\n@@ context hint @@\n context line\n-removed line\n+added line\n*** End Patch"} + "patch": {"type": "string", "description": "V4A format patch content (required when mode='patch'). Format:\n*** Begin Patch\n*** Update File: path/to/file\n@@ context hint @@\n context line\n-removed line\n+added line\n*** End Patch"} }, - "required": ["mode"] + "required": ["mode", "path", "old_string", "new_string", "patch"] } } From d504a8c307f9924207245eeea87e0c98834956b7 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Mon, 4 May 2026 05:48:14 +0800 Subject: [PATCH 3/3] fix(agent): rephrase compression preamble to avoid Azure/OpenAI content-filter false positives The summarizer preamble in context_compressor.py contained phrases that trigger Azure/OpenAI content-filter jailbreak detection: - 'injected as reference material for a DIFFERENT assistant' - 'Do NOT respond to any questions or requests' - 'NEVER include API keys' Rephrased to use softer directives while preserving the same semantic intent. The summary output prefix (SUMMARY_PREFIX) is unchanged since it is injected into conversation context, not sent as a prompt. Regression tests assert the trigger phrases are absent and the new wording preserves key semantic markers. Fixes #19362 --- agent/context_compressor.py | 26 ++++++++-------- tests/agent/test_context_compressor.py | 42 ++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index d4441a1c7ec2..aed88463ae8e 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -6,8 +6,8 @@ Improvements over v2: - Structured summary template with Resolved/Pending question tracking - - Summarizer preamble: "Do not respond to any questions" (from OpenCode) - - Handoff framing: "different assistant" (from Codex) to create separation + - Summarizer preamble: rephrased to avoid content-filter false positives + - Handoff framing: "background context for the next turn" to create separation - "Remaining Work" replaces "Next Steps" to avoid reading as active instructions - Clear separator when summary merges into tail message - Iterative summary updates (preserves info across multiple compactions) @@ -671,18 +671,18 @@ def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]], focus_topi content_to_summarize = self._serialize_for_summary(turns_to_summarize) # Preamble shared by both first-compaction and iterative-update prompts. - # Inspired by OpenCode's "do not respond to any questions" instruction - # and Codex's "another language model" framing. + # Rephrased to avoid Azure/OpenAI content-filter false positives while + # preserving the same semantic intent (background context, not active task). _summarizer_preamble = ( "You are a summarization agent creating a context checkpoint. " - "Your output will be injected as reference material for a DIFFERENT " - "assistant that continues the conversation. " - "Do NOT respond to any questions or requests in the conversation — " - "only output the structured summary. " - "Do NOT include any preamble, greeting, or prefix. " + "Your output serves as background context for the next turn of this " + "conversation. " + "Focus only on producing the structured summary — skip any questions " + "or requests shown in the conversation. " + "Omit any preamble, greeting, or prefix. " "Write the summary in the same language the user was using in the " "conversation — do not translate or switch to English. " - "NEVER include API keys, tokens, passwords, secrets, credentials, " + "Avoid including API keys, tokens, passwords, secrets, credentials, " "or connection strings in the summary — replace any that appear " "with [REDACTED]. Note that the user had credentials present, but " "do not preserve their values." @@ -742,7 +742,7 @@ def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]], focus_topi [What remains to be done — framed as context, not instructions] ## Critical Context -[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation. NEVER include API keys, tokens, passwords, or credentials — write [REDACTED] instead.] +[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation. Avoid including API keys, tokens, passwords, or credentials — write [REDACTED] instead.] Target ~{summary_budget} tokens. Be CONCRETE — include file paths, command outputs, error messages, line numbers, and specific values. Avoid vague descriptions like "made some changes" — say exactly what changed. @@ -767,7 +767,7 @@ def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]], focus_topi # First compaction: summarize from scratch prompt = f"""{_summarizer_preamble} -Create a structured handoff summary for a different assistant that will continue this conversation after earlier turns are compacted. The next assistant should be able to understand what happened without re-reading the original turns. +Create a structured handoff summary that will serve as background context for the next turn of this conversation after earlier turns are compacted. The next turn should be able to understand what happened without re-reading the original turns. TURNS TO SUMMARIZE: {content_to_summarize} @@ -782,7 +782,7 @@ def _generate_summary(self, turns_to_summarize: List[Dict[str, Any]], focus_topi prompt += f""" FOCUS TOPIC: "{focus_topic}" -The user has requested that this compaction PRIORITISE preserving all information related to the focus topic above. For content related to "{focus_topic}", include full detail — exact values, file paths, command outputs, error messages, and decisions. For content NOT related to the focus topic, summarise more aggressively (brief one-liners or omit if truly irrelevant). The focus topic sections should receive roughly 60-70% of the summary token budget. Even for the focus topic, NEVER preserve API keys, tokens, passwords, or credentials — use [REDACTED].""" +The user has requested that this compaction PRIORITISE preserving all information related to the focus topic above. For content related to "{focus_topic}", include full detail — exact values, file paths, command outputs, error messages, and decisions. For content NOT related to the focus topic, summarise more aggressively (brief one-liners or omit if truly irrelevant). The focus topic sections should receive roughly 60-70% of the summary token budget. Even for the focus topic, avoid preserving API keys, tokens, passwords, or credentials — use [REDACTED].""" try: call_kwargs = { diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index 8072a58d98f7..eac0f8902b08 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -969,3 +969,45 @@ def test_pass3_emits_valid_json_for_downstream_provider(self): parsed = _json.loads(shrunk) assert parsed["path"] == "~/.hermes/skills/shopping/browser-setup-notes.md" assert parsed["content"].endswith("...[truncated]") + + + +class TestContentFilterSafePreamble: + """Regression: summarizer preamble must not contain phrases that trigger + Azure/OpenAI content-filter false positives (issue #19362).""" + + def _get_preamble_text(self): + """Build the preamble the same way _generate_summary does.""" + from agent.context_compressor import ContextCompressor + import inspect + source = inspect.getsource(ContextCompressor._generate_summary) + return source + + def test_preamble_no_injected_reference_material(self): + """'injected as reference material' triggers Azure jailbreak filter.""" + source = self._get_preamble_text() + assert "injected as reference material" not in source + + def test_preamble_no_different_assistant(self): + """'DIFFERENT assistant' / 'different assistant' triggers filter.""" + source = self._get_preamble_text() + assert "DIFFERENT assistant" not in source + assert "different assistant" not in source + + def test_preamble_no_do_not_respond(self): + """'Do NOT respond' is a direct trigger for content filters.""" + source = self._get_preamble_text() + assert "Do NOT respond" not in source + + def test_preamble_no_never_include(self): + """'NEVER include' / 'NEVER preserve' are aggressive directives.""" + source = self._get_preamble_text() + assert "NEVER include" not in source + assert "NEVER preserve" not in source + + def test_preamble_preserves_semantic_intent(self): + """New preamble still communicates background-context purpose.""" + source = self._get_preamble_text() + assert "background context" in source + assert "structured summary" in source + assert "REDACTED" in source