mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-29 07:21:37 +08:00
Compare commits
1 Commits
fix/plugin
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ce9edcb29 |
@@ -21,6 +21,7 @@ Public API (signatures preserved from the original 2,400-line version):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
@@ -365,6 +366,33 @@ _AGENT_LOOP_TOOLS = {"todo", "memory", "session_search", "delegate_task"}
|
|||||||
_READ_SEARCH_TOOLS = {"read_file", "search_files"}
|
_READ_SEARCH_TOOLS = {"read_file", "search_files"}
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_tool_error(error_msg: str) -> str:
|
||||||
|
"""Sanitize tool error messages before sending to the LLM.
|
||||||
|
|
||||||
|
- Strips XML/JSON boundary markers that could confuse the model
|
||||||
|
- Truncates to 2000 chars max
|
||||||
|
- Wraps in a clear error format so the LLM knows it's an error
|
||||||
|
"""
|
||||||
|
sanitized = error_msg
|
||||||
|
# Strip XML-like tags that could confuse the LLM (role / framing tags)
|
||||||
|
sanitized = re.sub(
|
||||||
|
r'</?(?:tool_call|function_call|result|response|output|input|system|assistant|user)>',
|
||||||
|
'', sanitized,
|
||||||
|
)
|
||||||
|
# Strip markdown code fences (opening and closing)
|
||||||
|
sanitized = re.sub(r'^\s*```(?:json|xml)?\s*', '', sanitized)
|
||||||
|
sanitized = re.sub(r'\s*```\s*$', '', sanitized)
|
||||||
|
# Remove CDATA sections
|
||||||
|
sanitized = re.sub(r'<!\[CDATA\[.*?\]\]>', '', sanitized, flags=re.DOTALL)
|
||||||
|
|
||||||
|
# Truncate very long error messages
|
||||||
|
if len(sanitized) > 2000:
|
||||||
|
sanitized = sanitized[:1997] + '...'
|
||||||
|
|
||||||
|
# Wrap in clear error format
|
||||||
|
return f"[TOOL_ERROR] {sanitized}"
|
||||||
|
|
||||||
|
|
||||||
def handle_function_call(
|
def handle_function_call(
|
||||||
function_name: str,
|
function_name: str,
|
||||||
function_args: Dict[str, Any],
|
function_args: Dict[str, Any],
|
||||||
@@ -438,9 +466,10 @@ def handle_function_call(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = f"Error executing {function_name}: {str(e)}"
|
raw_error = f"Error executing {function_name}: {str(e)}"
|
||||||
logger.error(error_msg)
|
logger.error(raw_error)
|
||||||
return json.dumps({"error": error_msg}, ensure_ascii=False)
|
sanitized = _sanitize_tool_error(raw_error)
|
||||||
|
return json.dumps({"error": sanitized}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
82
run_agent.py
82
run_agent.py
@@ -6270,6 +6270,7 @@ class AIAgent:
|
|||||||
codex_ack_continuations = 0
|
codex_ack_continuations = 0
|
||||||
length_continue_retries = 0
|
length_continue_retries = 0
|
||||||
truncated_response_prefix = ""
|
truncated_response_prefix = ""
|
||||||
|
truncated_tool_call_count = 0
|
||||||
compression_attempts = 0
|
compression_attempts = 0
|
||||||
|
|
||||||
# Clear any stale interrupt state at start
|
# Clear any stale interrupt state at start
|
||||||
@@ -6434,6 +6435,11 @@ class AIAgent:
|
|||||||
while retry_count < max_retries:
|
while retry_count < max_retries:
|
||||||
try:
|
try:
|
||||||
api_kwargs = self._build_api_kwargs(api_messages)
|
api_kwargs = self._build_api_kwargs(api_messages)
|
||||||
|
# Feature: Temporarily disable tools after repeated truncations
|
||||||
|
if getattr(self, '_tools_temporarily_disabled', False):
|
||||||
|
api_kwargs['tools'] = None
|
||||||
|
self._tools_temporarily_disabled = False
|
||||||
|
self._vprint(f"{self.log_prefix}ℹ️ Tools temporarily disabled for this call")
|
||||||
if self.api_mode == "codex_responses":
|
if self.api_mode == "codex_responses":
|
||||||
api_kwargs = self._preflight_codex_api_kwargs(api_kwargs, allow_stream=False)
|
api_kwargs = self._preflight_codex_api_kwargs(api_kwargs, allow_stream=False)
|
||||||
|
|
||||||
@@ -6697,6 +6703,46 @@ class AIAgent:
|
|||||||
|
|
||||||
if self.api_mode == "chat_completions":
|
if self.api_mode == "chat_completions":
|
||||||
assistant_message = response.choices[0].message
|
assistant_message = response.choices[0].message
|
||||||
|
if assistant_message.tool_calls:
|
||||||
|
# Feature: Discard truncated tool calls (Ironclaw #1632)
|
||||||
|
# When finish_reason=length with tool_calls, the calls
|
||||||
|
# are likely truncated (incomplete JSON). Discard them.
|
||||||
|
truncated_tool_call_count += 1
|
||||||
|
tc_count = len(assistant_message.tool_calls)
|
||||||
|
self._vprint(
|
||||||
|
f"{self.log_prefix}⚠️ Discarding {tc_count} truncated tool call(s) "
|
||||||
|
f"(finish_reason='length', consecutive={truncated_tool_call_count})",
|
||||||
|
force=True,
|
||||||
|
)
|
||||||
|
# Save any text content that preceded the truncated calls
|
||||||
|
partial_content = assistant_message.content or ""
|
||||||
|
if partial_content:
|
||||||
|
truncated_response_prefix += partial_content
|
||||||
|
# Build message WITHOUT tool_calls
|
||||||
|
assistant_message.tool_calls = None
|
||||||
|
interim_msg = self._build_assistant_message(assistant_message, finish_reason)
|
||||||
|
messages.append(interim_msg)
|
||||||
|
|
||||||
|
truncation_nudge = (
|
||||||
|
'Your previous response was truncated due to context length limits. '
|
||||||
|
'The tool calls were discarded. Please summarize your progress so '
|
||||||
|
'far and continue with a shorter response.'
|
||||||
|
)
|
||||||
|
messages.append({"role": "user", "content": truncation_nudge})
|
||||||
|
|
||||||
|
# After 3 consecutive truncations, temporarily disable tools
|
||||||
|
if truncated_tool_call_count >= 3:
|
||||||
|
self._vprint(
|
||||||
|
f"{self.log_prefix}⚠️ 3 consecutive truncations with tool calls — "
|
||||||
|
f"temporarily disabling tools for next call",
|
||||||
|
force=True,
|
||||||
|
)
|
||||||
|
self._tools_temporarily_disabled = True
|
||||||
|
|
||||||
|
self._session_messages = messages
|
||||||
|
self._save_session_log(messages)
|
||||||
|
continue
|
||||||
|
|
||||||
if not assistant_message.tool_calls:
|
if not assistant_message.tool_calls:
|
||||||
length_continue_retries += 1
|
length_continue_retries += 1
|
||||||
interim_msg = self._build_assistant_message(assistant_message, finish_reason)
|
interim_msg = self._build_assistant_message(assistant_message, finish_reason)
|
||||||
@@ -7518,6 +7564,8 @@ class AIAgent:
|
|||||||
|
|
||||||
# Check for tool calls
|
# Check for tool calls
|
||||||
if assistant_message.tool_calls:
|
if assistant_message.tool_calls:
|
||||||
|
# Reset truncated tool call counter on successful (non-truncated) tool calls
|
||||||
|
truncated_tool_call_count = 0
|
||||||
if not self.quiet_mode:
|
if not self.quiet_mode:
|
||||||
self._vprint(f"{self.log_prefix}🔧 Processing {len(assistant_message.tool_calls)} tool call(s)...")
|
self._vprint(f"{self.log_prefix}🔧 Processing {len(assistant_message.tool_calls)} tool call(s)...")
|
||||||
|
|
||||||
@@ -7793,11 +7841,39 @@ class AIAgent:
|
|||||||
content_preview = final_response[:80] + "..." if len(final_response) > 80 else final_response
|
content_preview = final_response[:80] + "..." if len(final_response) > 80 else final_response
|
||||||
self._vprint(f"{self.log_prefix} Content: '{content_preview}'")
|
self._vprint(f"{self.log_prefix} Content: '{content_preview}'")
|
||||||
|
|
||||||
if self._empty_content_retries < 3:
|
if self._empty_content_retries < 2:
|
||||||
self._vprint(f"{self.log_prefix}🔄 Retrying API call ({self._empty_content_retries}/3)...")
|
self._vprint(f"{self.log_prefix}🔄 Retrying API call ({self._empty_content_retries}/2)...")
|
||||||
|
# Feature: Empty response recovery (Ironclaw #1677 + #1720)
|
||||||
|
# On first empty retry, check for prior meaningful output
|
||||||
|
if self._empty_content_retries == 1:
|
||||||
|
_has_prior_output = any(
|
||||||
|
isinstance(m, dict)
|
||||||
|
and m.get("role") == "assistant"
|
||||||
|
and m.get("content")
|
||||||
|
and self._has_content_after_think_block(m["content"])
|
||||||
|
for m in messages
|
||||||
|
)
|
||||||
|
if _has_prior_output:
|
||||||
|
# Model already produced output earlier; treat as completion
|
||||||
|
self._vprint(f"{self.log_prefix}ℹ️ Prior meaningful output exists — treating empty response as completion")
|
||||||
|
for m in reversed(messages):
|
||||||
|
if (isinstance(m, dict) and m.get("role") == "assistant"
|
||||||
|
and m.get("content") and self._has_content_after_think_block(m["content"])):
|
||||||
|
final_response = self._strip_think_blocks(m["content"]).strip()
|
||||||
|
break
|
||||||
|
if final_response:
|
||||||
|
self._empty_content_retries = 0
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# No prior output — inject a nudge to help the model
|
||||||
|
nudge_msg = {
|
||||||
|
"role": "user",
|
||||||
|
"content": "Your previous response was empty. Please continue with the task.",
|
||||||
|
}
|
||||||
|
messages.append(nudge_msg)
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
self._vprint(f"{self.log_prefix}❌ Max retries (3) for empty content exceeded.", force=True)
|
self._vprint(f"{self.log_prefix}❌ Max retries (2) for empty content exceeded.", force=True)
|
||||||
self._empty_content_retries = 0
|
self._empty_content_retries = 0
|
||||||
|
|
||||||
# If a prior tool_calls turn had real content, salvage it:
|
# If a prior tool_calls turn had real content, salvage it:
|
||||||
|
|||||||
252
tests/test_agent_resilience.py
Normal file
252
tests/test_agent_resilience.py
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
"""Tests for agent resilience features inspired by Ironclaw PRs.
|
||||||
|
|
||||||
|
Feature 1: Discard truncated tool calls on finish_reason=length (#1632)
|
||||||
|
Feature 2: Empty response recovery (#1677 + #1720)
|
||||||
|
Feature 3: Sanitize tool error results (#1639)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Ensure repo root is importable
|
||||||
|
_repo_root = Path(__file__).resolve().parent.parent
|
||||||
|
if str(_repo_root) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_repo_root))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _mock_tool_call(name="test_tool", args='{"key": "value"}', tc_id="tc_1"):
|
||||||
|
return SimpleNamespace(
|
||||||
|
id=tc_id,
|
||||||
|
function=SimpleNamespace(name=name, arguments=args),
|
||||||
|
type="function",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_response(content="Hello", finish_reason="stop", tool_calls=None, usage=None):
|
||||||
|
msg = SimpleNamespace(
|
||||||
|
content=content,
|
||||||
|
tool_calls=tool_calls,
|
||||||
|
reasoning_content=None,
|
||||||
|
reasoning=None,
|
||||||
|
)
|
||||||
|
choice = SimpleNamespace(message=msg, finish_reason=finish_reason)
|
||||||
|
resp = SimpleNamespace(choices=[choice], model="test/model")
|
||||||
|
resp.usage = SimpleNamespace(**usage) if usage else None
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Feature 3: Sanitize tool error results
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
class TestSanitizeToolError:
|
||||||
|
"""Test _sanitize_tool_error helper function in model_tools.py."""
|
||||||
|
|
||||||
|
def test_import(self):
|
||||||
|
"""Verify the sanitize function can be imported."""
|
||||||
|
from model_tools import _sanitize_tool_error
|
||||||
|
assert callable(_sanitize_tool_error)
|
||||||
|
|
||||||
|
def test_truncation(self):
|
||||||
|
"""Error messages longer than 2000 chars are truncated."""
|
||||||
|
from model_tools import _sanitize_tool_error
|
||||||
|
long_msg = "x" * 5000
|
||||||
|
result = _sanitize_tool_error(long_msg)
|
||||||
|
# Account for the [TOOL_ERROR] prefix
|
||||||
|
assert len(result) <= 2000 + len("[TOOL_ERROR] ")
|
||||||
|
assert result.endswith("...")
|
||||||
|
|
||||||
|
def test_xml_tag_stripping(self):
|
||||||
|
"""XML-like boundary tags are stripped from errors."""
|
||||||
|
from model_tools import _sanitize_tool_error
|
||||||
|
error = "<tool_call>Error: file not found</tool_call>"
|
||||||
|
result = _sanitize_tool_error(error)
|
||||||
|
assert "<tool_call>" not in result
|
||||||
|
assert "</tool_call>" not in result
|
||||||
|
assert "file not found" in result
|
||||||
|
|
||||||
|
def test_system_tag_stripping(self):
|
||||||
|
"""System/assistant/user tags are stripped."""
|
||||||
|
from model_tools import _sanitize_tool_error
|
||||||
|
error = "<system>Permission denied</system>"
|
||||||
|
result = _sanitize_tool_error(error)
|
||||||
|
assert "<system>" not in result
|
||||||
|
assert "Permission denied" in result
|
||||||
|
|
||||||
|
def test_code_fence_stripping(self):
|
||||||
|
"""Markdown code fences are stripped."""
|
||||||
|
from model_tools import _sanitize_tool_error
|
||||||
|
error = "```json\n{\"error\": \"bad\"}\n```"
|
||||||
|
result = _sanitize_tool_error(error)
|
||||||
|
assert "```" not in result
|
||||||
|
|
||||||
|
def test_cdata_stripping(self):
|
||||||
|
"""CDATA sections are stripped."""
|
||||||
|
from model_tools import _sanitize_tool_error
|
||||||
|
error = "Error: <![CDATA[some internal data]]> happened"
|
||||||
|
result = _sanitize_tool_error(error)
|
||||||
|
assert "CDATA" not in result
|
||||||
|
assert "happened" in result
|
||||||
|
|
||||||
|
def test_error_format_prefix(self):
|
||||||
|
"""Error is wrapped with [TOOL_ERROR] prefix."""
|
||||||
|
from model_tools import _sanitize_tool_error
|
||||||
|
result = _sanitize_tool_error("something went wrong")
|
||||||
|
assert result.startswith("[TOOL_ERROR]")
|
||||||
|
assert "something went wrong" in result
|
||||||
|
|
||||||
|
def test_short_error_preserved(self):
|
||||||
|
"""Short, clean errors are preserved intact (with prefix)."""
|
||||||
|
from model_tools import _sanitize_tool_error
|
||||||
|
result = _sanitize_tool_error("File not found: /tmp/test.txt")
|
||||||
|
assert result == "[TOOL_ERROR] File not found: /tmp/test.txt"
|
||||||
|
|
||||||
|
def test_handle_function_call_uses_sanitizer(self):
|
||||||
|
"""handle_function_call sanitizes error messages from exceptions."""
|
||||||
|
from model_tools import handle_function_call, _sanitize_tool_error
|
||||||
|
# The registry returns its own error for unknown tools (not via the
|
||||||
|
# except block). Verify the sanitizer is called in the except path
|
||||||
|
# by directly testing what would happen.
|
||||||
|
raw_error = "Error executing bad_tool: <system>Internal traceback</system>"
|
||||||
|
sanitized = _sanitize_tool_error(raw_error)
|
||||||
|
result_json = json.dumps({"error": sanitized}, ensure_ascii=False)
|
||||||
|
parsed = json.loads(result_json)
|
||||||
|
assert "[TOOL_ERROR]" in parsed["error"]
|
||||||
|
assert "<system>" not in parsed["error"]
|
||||||
|
|
||||||
|
def test_mixed_tags_and_long_error(self):
|
||||||
|
"""Complex error with tags AND length > 2000."""
|
||||||
|
from model_tools import _sanitize_tool_error
|
||||||
|
error = "<result>" + ("a" * 3000) + "</result>"
|
||||||
|
result = _sanitize_tool_error(error)
|
||||||
|
assert "<result>" not in result
|
||||||
|
assert "</result>" not in result
|
||||||
|
assert len(result) <= 2020 # prefix + 2000 + ...
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Feature 1: Discard truncated tool calls on finish_reason=length
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
class TestTruncatedToolCallDiscard:
|
||||||
|
"""Test that truncated tool calls (finish_reason=length) are discarded."""
|
||||||
|
|
||||||
|
def test_truncated_tool_calls_message_content(self):
|
||||||
|
"""Verify the truncation nudge message text is correct."""
|
||||||
|
expected_nudge = (
|
||||||
|
'Your previous response was truncated due to context length limits. '
|
||||||
|
'The tool calls were discarded. Please summarize your progress so '
|
||||||
|
'far and continue with a shorter response.'
|
||||||
|
)
|
||||||
|
# This is the message that should be injected into the conversation
|
||||||
|
assert "truncated" in expected_nudge.lower()
|
||||||
|
assert "discarded" in expected_nudge.lower()
|
||||||
|
|
||||||
|
def test_tools_temporarily_disabled_attribute(self):
|
||||||
|
"""Verify the _tools_temporarily_disabled attribute pattern works."""
|
||||||
|
# Test the attribute access pattern used in the implementation
|
||||||
|
obj = SimpleNamespace()
|
||||||
|
assert getattr(obj, '_tools_temporarily_disabled', False) is False
|
||||||
|
obj._tools_temporarily_disabled = True
|
||||||
|
assert getattr(obj, '_tools_temporarily_disabled', False) is True
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Feature 2: Empty response recovery
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
class TestEmptyResponseRecovery:
|
||||||
|
"""Test empty response recovery behavior."""
|
||||||
|
|
||||||
|
def test_empty_response_nudge_text(self):
|
||||||
|
"""Verify the nudge message for empty responses."""
|
||||||
|
nudge = "Your previous response was empty. Please continue with the task."
|
||||||
|
assert "empty" in nudge.lower()
|
||||||
|
assert "continue" in nudge.lower()
|
||||||
|
|
||||||
|
def test_prior_meaningful_output_detection(self):
|
||||||
|
"""Test logic for detecting prior meaningful output in messages."""
|
||||||
|
messages = [
|
||||||
|
{"role": "user", "content": "Hello"},
|
||||||
|
{"role": "assistant", "content": "Here is a detailed response about your question."},
|
||||||
|
{"role": "user", "content": "Thanks, continue"},
|
||||||
|
]
|
||||||
|
# Check that we can find prior assistant output
|
||||||
|
has_prior = any(
|
||||||
|
isinstance(m, dict)
|
||||||
|
and m.get("role") == "assistant"
|
||||||
|
and m.get("content")
|
||||||
|
and len(m["content"].strip()) > 0
|
||||||
|
for m in messages
|
||||||
|
)
|
||||||
|
assert has_prior is True
|
||||||
|
|
||||||
|
def test_no_prior_meaningful_output(self):
|
||||||
|
"""Test when no prior meaningful assistant output exists."""
|
||||||
|
messages = [
|
||||||
|
{"role": "user", "content": "Hello"},
|
||||||
|
]
|
||||||
|
has_prior = any(
|
||||||
|
isinstance(m, dict)
|
||||||
|
and m.get("role") == "assistant"
|
||||||
|
and m.get("content")
|
||||||
|
and len(m["content"].strip()) > 0
|
||||||
|
for m in messages
|
||||||
|
)
|
||||||
|
assert has_prior is False
|
||||||
|
|
||||||
|
def test_think_block_only_not_meaningful(self):
|
||||||
|
"""Responses with only think blocks should not count as meaningful."""
|
||||||
|
messages = [
|
||||||
|
{"role": "assistant", "content": "<think>Internal reasoning only</think>"},
|
||||||
|
]
|
||||||
|
# The agent uses _has_content_after_think_block to check this
|
||||||
|
# For our test, verify the pattern: content that's only a think block
|
||||||
|
content = messages[0]["content"]
|
||||||
|
stripped = re.sub(
|
||||||
|
r'<(?:REASONING_SCRATCHPAD|think|reasoning)>.*?</(?:REASONING_SCRATCHPAD|think|reasoning)>',
|
||||||
|
'', content, flags=re.DOTALL
|
||||||
|
).strip()
|
||||||
|
assert stripped == "" # No meaningful content after stripping think blocks
|
||||||
|
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Integration-style tests for sanitize_tool_error in handle_function_call
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
class TestHandleFunctionCallSanitization:
|
||||||
|
"""Test that handle_function_call properly sanitizes errors."""
|
||||||
|
|
||||||
|
def test_registry_dispatch_error_sanitized(self):
|
||||||
|
"""When registry.dispatch raises, the error should be sanitized."""
|
||||||
|
from model_tools import _sanitize_tool_error
|
||||||
|
|
||||||
|
# Simulate what happens in the except block
|
||||||
|
error = Exception("Connection refused: <system>Internal error</system> " + "x" * 3000)
|
||||||
|
raw_error = f"Error executing test_tool: {str(error)}"
|
||||||
|
sanitized = _sanitize_tool_error(raw_error)
|
||||||
|
|
||||||
|
result_json = json.dumps({"error": sanitized}, ensure_ascii=False)
|
||||||
|
parsed = json.loads(result_json)
|
||||||
|
|
||||||
|
assert "[TOOL_ERROR]" in parsed["error"]
|
||||||
|
assert "<system>" not in parsed["error"]
|
||||||
|
# Truncated
|
||||||
|
assert len(parsed["error"]) <= 2020
|
||||||
|
|
||||||
|
def test_normal_error_readable(self):
|
||||||
|
"""Normal short errors should remain readable."""
|
||||||
|
from model_tools import _sanitize_tool_error
|
||||||
|
result = _sanitize_tool_error("Error executing write_file: Permission denied")
|
||||||
|
assert "Permission denied" in result
|
||||||
|
assert result.startswith("[TOOL_ERROR]")
|
||||||
Reference in New Issue
Block a user