mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
feat(code): drop TodoListMiddleware (#4685)
dcode no longer exposes the `write_todos` tool / todo-list middleware to the agent or its subagents when `DEEPAGENTS_CODE_EXPERIMENTAL` is set. --- `dcode` builds its agent with `create_deep_agent`, which always injects the SDK's `TodoListMiddleware` (and its `write_todos` tool) and exposes no parameter to disable it. Because `create_deep_agent`'s `_apply_custom_middleware` merge replaces a default middleware in place when a caller middleware shares its `.name`, we add a tool-less no-op middleware named `TodoListMiddleware` and thread it into the main-agent and subagent middleware lists — so the real middleware (and `write_todos`) is dropped without any SDK change. This is gated behind the new `DEEPAGENTS_CODE_EXPERIMENTAL` env var and is a no-op when unset, so default behavior is unchanged. Kept intentionally narrow: the todo prompt guidance and TUI rendering are left in place (dead only when the flag is on). Made by [Open SWE](https://openswe.vercel.app/agents/c5e51661-6737-cd07-a93a-30847caeef06) ## References - Plan: https://openswe.vercel.app/agents/c5e51661-6737-cd07-a93a-30847caeef06/plan --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
@@ -136,6 +136,16 @@ When set, this replaces (takes precedence over) the
|
||||
is never silently emptied.)
|
||||
"""
|
||||
|
||||
EXPERIMENTAL = "DEEPAGENTS_CODE_EXPERIMENTAL"
|
||||
"""Opt into experimental, unstable dcode behavior.
|
||||
|
||||
Off by default; parsed by `is_env_truthy` (see there for the accepted truthy
|
||||
values). Currently gates dropping the SDK's `TodoListMiddleware` (and its
|
||||
`write_todos` tool) from the agent and its subagents, along with the matching
|
||||
todo-list prompt guidance. Behavior behind this flag may change or be removed
|
||||
without notice.
|
||||
"""
|
||||
|
||||
EXTERNAL_EVENT_SOCKET = "DEEPAGENTS_CODE_EXTERNAL_EVENT_SOCKET"
|
||||
"""Enable the local Unix-socket external event listener.
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ if TYPE_CHECKING:
|
||||
from deepagents_code.mcp_tools import MCPServerInfo
|
||||
from deepagents_code.output import OutputFormat
|
||||
|
||||
from langchain.agents.middleware import TodoListMiddleware
|
||||
from langchain.agents.middleware.types import AgentMiddleware
|
||||
from langchain.tools import (
|
||||
ToolRuntime, # noqa: TC002 # LangChain inspects this annotation for runtime injection.
|
||||
@@ -66,6 +67,7 @@ from langchain_core.tools import StructuredTool, tool
|
||||
from deepagents_code import theme
|
||||
from deepagents_code._cli_context import CLIContextSchema
|
||||
from deepagents_code._constants import DEFAULT_AGENT_NAME
|
||||
from deepagents_code._env_vars import EXPERIMENTAL, is_env_truthy
|
||||
from deepagents_code.config import (
|
||||
_INHERITED_PYTHONPATH_ENV,
|
||||
_ShellAllowAll,
|
||||
@@ -101,6 +103,73 @@ logger = logging.getLogger(__name__)
|
||||
REQUIRE_COMPACT_TOOL_APPROVAL: bool = True
|
||||
"""When `True`, `compact_conversation` requires HITL approval like other gated tools."""
|
||||
|
||||
|
||||
class _NoTodoListMiddleware(AgentMiddleware):
|
||||
"""No-op stand-in that drops the SDK's `TodoListMiddleware` by name.
|
||||
|
||||
`create_deep_agent` always injects `TodoListMiddleware` and exposes no
|
||||
per-call parameter to disable it (only a globally registered
|
||||
`HarnessProfile.excluded_middleware` can strip it, which dcode does not use
|
||||
here). Its `_apply_custom_middleware` merge replaces a default middleware in
|
||||
place when a caller-supplied middleware shares its `.name`, so threading
|
||||
this tool-less stand-in into the agent and subagent middleware lists removes
|
||||
the real middleware — and its `write_todos` tool — without touching the SDK.
|
||||
|
||||
Deriving `name` from `TodoListMiddleware.__name__` makes a *rename* or
|
||||
removal of the SDK class fail loudly (`ImportError`) at the top-of-module
|
||||
import. It does not, on its own, guard a `.name` *override* on an unrenamed
|
||||
class: the merge keys on the instance `.name`, not `__name__`, so such an
|
||||
override would slip past the import and silently turn this into a no-op.
|
||||
That case is caught two ways — `_todo_list_middleware_override` re-checks the
|
||||
match at build time and raises, and `test_agent.py` guards it in CI. Gated
|
||||
behind `DEEPAGENTS_CODE_EXPERIMENTAL`; see `_todo_list_middleware_override`.
|
||||
"""
|
||||
|
||||
name: str = TodoListMiddleware.__name__
|
||||
tools: Sequence[BaseTool] = ()
|
||||
"""No tools — replacing the real `TodoListMiddleware` drops its `write_todos`.
|
||||
|
||||
Declared explicitly (mirroring the base's `transformers = ()` default) so a
|
||||
bare instance is self-contained rather than relying on the SDK's
|
||||
`getattr(mw, "tools", [])` fallback.
|
||||
"""
|
||||
|
||||
|
||||
def _todo_list_middleware_override() -> list[AgentMiddleware]:
|
||||
"""Return the middleware needed to strip `TodoListMiddleware`, if enabled.
|
||||
|
||||
Returns a single-element list with `_NoTodoListMiddleware` when the
|
||||
experimental flag is set, else an empty list. Callers splice the result
|
||||
into the middleware list they pass to `create_deep_agent` so the SDK's
|
||||
name-based merge drops the real `TodoListMiddleware`.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the stand-in's `.name` no longer matches the SDK
|
||||
middleware's instance `.name`. The merge replaces by name, so a
|
||||
mismatch would silently *append* the tool-less stand-in instead of
|
||||
replacing the real middleware, leaving `write_todos` bound. Failing
|
||||
fast here converts that silent no-op into a loud, actionable error
|
||||
(only ever runs when the flag is on).
|
||||
"""
|
||||
if not is_env_truthy(EXPERIMENTAL):
|
||||
return []
|
||||
stand_in = _NoTodoListMiddleware()
|
||||
sdk_name = TodoListMiddleware().name
|
||||
if stand_in.name != sdk_name:
|
||||
msg = (
|
||||
f"{EXPERIMENTAL} is set but the TodoListMiddleware override would be "
|
||||
f"a silent no-op: stand-in name {stand_in.name!r} no longer matches "
|
||||
f"the SDK middleware's instance name {sdk_name!r}. The SDK likely "
|
||||
f"overrode TodoListMiddleware.name; update _NoTodoListMiddleware."
|
||||
)
|
||||
raise RuntimeError(msg)
|
||||
logger.info(
|
||||
"%s set: dropping TodoListMiddleware / write_todos from this stack",
|
||||
EXPERIMENTAL,
|
||||
)
|
||||
return [stand_in]
|
||||
|
||||
|
||||
_RUBRIC_GRADER_READ_FILE_PREFIX = "/large_tool_results/"
|
||||
_RUBRIC_GRADER_SYSTEM_PROMPT = (
|
||||
GRADER_SYSTEM_PROMPT
|
||||
@@ -825,7 +894,11 @@ def get_system_prompt(
|
||||
... {CONDITIONAL SECTIONS} ...
|
||||
```
|
||||
"""
|
||||
template = (Path(__file__).parent / "system_prompt.md").read_text()
|
||||
prompt_dir = Path(__file__).parent
|
||||
template = (prompt_dir / "system_prompt.md").read_text()
|
||||
todo_list_section = ""
|
||||
if not is_env_truthy(EXPERIMENTAL):
|
||||
todo_list_section = (prompt_dir / "todo_list_prompt.md").read_text().rstrip()
|
||||
|
||||
skills_path = f"~/.deepagents/{assistant_id}/skills"
|
||||
|
||||
@@ -937,6 +1010,7 @@ def get_system_prompt(
|
||||
template.replace("{mode_description}", mode_description)
|
||||
.replace("{interactive_preamble}", interactive_preamble)
|
||||
.replace("{ambiguity_guidance}", ambiguity_guidance)
|
||||
.replace("{todo_list_section}", todo_list_section)
|
||||
.replace("{todo_guidance}", todo_guidance)
|
||||
.replace("{model_identity_section}", model_identity_section)
|
||||
.replace("{working_dir_section}", working_dir_section)
|
||||
@@ -1506,6 +1580,9 @@ def create_cli_agent(
|
||||
|
||||
def _subagent_cli_middleware(*, has_explicit_model: bool) -> list[AgentMiddleware]:
|
||||
middleware: list[AgentMiddleware] = []
|
||||
# Experimental: mirror the main agent and drop TodoListMiddleware /
|
||||
# write_todos from subagent stacks too. No-op unless the flag is set.
|
||||
middleware.extend(_todo_list_middleware_override())
|
||||
if not has_explicit_model:
|
||||
middleware.append(ConfigurableModelMiddleware(persist_model_state=False))
|
||||
if restrictive_shell_allow_list is not None:
|
||||
@@ -1567,6 +1644,9 @@ def create_cli_agent(
|
||||
# Build middleware stack based on enabled features
|
||||
agent_middleware: list[AgentMiddleware[Any, Any]] = [
|
||||
ConfigurableModelMiddleware(),
|
||||
# Experimental: drop the SDK's TodoListMiddleware / write_todos tool.
|
||||
# No-op unless DEEPAGENTS_CODE_EXPERIMENTAL is truthy.
|
||||
*_todo_list_middleware_override(),
|
||||
]
|
||||
|
||||
# Resume state: declares private checkpoint channels used on resume.
|
||||
|
||||
@@ -1075,6 +1075,14 @@ _STATIC_OPTIONS: tuple[ConfigOption, ...] = (
|
||||
default=True,
|
||||
env_var=_env_vars.OLLAMA_DISCOVERY,
|
||||
),
|
||||
ConfigOption(
|
||||
key="features.experimental",
|
||||
group="Tools",
|
||||
summary="Opt into experimental, unstable dcode behavior.",
|
||||
kind=OptionKind.BOOL,
|
||||
default=False,
|
||||
env_var=_env_vars.EXPERIMENTAL,
|
||||
),
|
||||
ConfigOption(
|
||||
key="events.external_socket",
|
||||
group="Tools",
|
||||
|
||||
@@ -201,15 +201,4 @@ When you use the web_search tool:
|
||||
|
||||
The user only sees your text responses - not tool results. Always provide a complete, natural language answer after using web_search.
|
||||
|
||||
### Todo List Management
|
||||
|
||||
When using the write_todos tool:
|
||||
|
||||
1. Use todos for any task with 2+ steps — they give the user visibility
|
||||
2. Mark tasks `in_progress` before starting, `completed` immediately after
|
||||
3. Don't batch completions — mark each item done as you finish it
|
||||
4. If a task reveals sub-tasks, add them right away
|
||||
5. For simple 1-step tasks, just do them directly
|
||||
{todo_guidance}
|
||||
|
||||
The todo list is a planning tool - use it judiciously to avoid overwhelming the user with excessive task tracking.
|
||||
{todo_list_section}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
### Todo List Management
|
||||
|
||||
When using the write_todos tool:
|
||||
|
||||
1. Use todos for any task with 2+ steps — they give the user visibility
|
||||
2. Mark tasks `in_progress` before starting, `completed` immediately after
|
||||
3. Don't batch completions — mark each item done as you finish it
|
||||
4. If a task reveals sub-tasks, add them right away
|
||||
5. For simple 1-step tasks, just do them directly
|
||||
{todo_guidance}
|
||||
|
||||
The todo list is a planning tool - use it judiciously to avoid overwhelming the user with excessive task tracking.
|
||||
@@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any, cast
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from langchain.agents.middleware import TodoListMiddleware
|
||||
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
@@ -20,6 +21,7 @@ if TYPE_CHECKING:
|
||||
from langgraph.runtime import Runtime
|
||||
|
||||
from deepagents_code._cli_context import CLIContext, CLIContextSchema
|
||||
from deepagents_code._env_vars import EXPERIMENTAL
|
||||
from deepagents_code.agent import (
|
||||
DEFAULT_AGENT_NAME,
|
||||
_add_interrupt_on,
|
||||
@@ -916,6 +918,44 @@ class TestGetSystemPromptNonInteractive:
|
||||
assert "do NOT ask the user to approve your plan" in prompt
|
||||
assert "mark the first item `in_progress` immediately" in prompt
|
||||
|
||||
def test_experimental_prompt_omits_todo_section(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Experimental mode must not reference its removed todo tool."""
|
||||
monkeypatch.setenv(EXPERIMENTAL, "1")
|
||||
mock_settings = Mock()
|
||||
mock_settings.model_name = None
|
||||
|
||||
with patch("deepagents_code.agent.settings", mock_settings):
|
||||
prompt = get_system_prompt("test-agent")
|
||||
|
||||
assert "Todo List Management" not in prompt
|
||||
assert "write_todos" not in prompt
|
||||
# `{todo_guidance}` lives only inside the gated section, so dropping the
|
||||
# section must not leave the placeholder unresolved.
|
||||
assert "{todo_list_section}" not in prompt
|
||||
assert "{todo_guidance}" not in prompt
|
||||
|
||||
def test_default_prompt_resolves_todo_placeholders(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Default mode keeps the todo section with no unresolved placeholders.
|
||||
|
||||
Guards the `.replace` ordering in `get_system_prompt`: `{todo_guidance}`
|
||||
is nested inside the todo section, so the section must be substituted
|
||||
before the guidance placeholder is filled.
|
||||
"""
|
||||
monkeypatch.delenv(EXPERIMENTAL, raising=False)
|
||||
mock_settings = Mock()
|
||||
mock_settings.model_name = None
|
||||
|
||||
with patch("deepagents_code.agent.settings", mock_settings):
|
||||
prompt = get_system_prompt("test-agent")
|
||||
|
||||
assert "Todo List Management" in prompt
|
||||
assert "{todo_list_section}" not in prompt
|
||||
assert "{todo_guidance}" not in prompt
|
||||
|
||||
|
||||
class TestGetSystemPromptCwdOSError:
|
||||
"""Tests for Path.cwd() OSError handling in get_system_prompt."""
|
||||
@@ -3086,6 +3126,158 @@ class TestCreateCliAgentShellMiddlewareWiring:
|
||||
)
|
||||
|
||||
|
||||
class TestExperimentalTodoMiddlewareWiring:
|
||||
"""`DEEPAGENTS_CODE_EXPERIMENTAL` drops TodoListMiddleware from every stack.
|
||||
|
||||
`collect_built_in_tools` (see `test_tool_catalog.py`) only inspects the main
|
||||
agent's bound tools, so the subagent splice needs its own coverage: these
|
||||
tests capture the `create_deep_agent` kwargs and assert the stand-in reaches
|
||||
the main agent, custom subagents, and the general-purpose subagent that
|
||||
dcode auto-adds.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _build_mock_settings(tmp_path: Path) -> Mock:
|
||||
agent_dir = tmp_path / "agent"
|
||||
agent_dir.mkdir()
|
||||
skills_dir = tmp_path / "skills"
|
||||
skills_dir.mkdir()
|
||||
|
||||
mock_settings = Mock()
|
||||
mock_settings.ensure_agent_dir.return_value = agent_dir
|
||||
mock_settings.ensure_user_skills_dir.return_value = skills_dir
|
||||
mock_settings.get_project_skills_dir.return_value = None
|
||||
mock_settings.get_built_in_skills_dir.return_value = (
|
||||
Settings.get_built_in_skills_dir()
|
||||
)
|
||||
mock_settings.get_user_agent_md_path.return_value = agent_dir / "AGENTS.md"
|
||||
mock_settings.get_project_agent_md_path.return_value = []
|
||||
mock_settings.get_user_agents_dir.return_value = tmp_path / "agents"
|
||||
mock_settings.get_project_agents_dir.return_value = None
|
||||
mock_settings.model_name = None
|
||||
mock_settings.model_provider = None
|
||||
mock_settings.model_unsupported_modalities = frozenset()
|
||||
mock_settings.model_context_limit = None
|
||||
mock_settings.project_root = None
|
||||
mock_settings.shell_allow_list = ["ls", "cat"]
|
||||
return mock_settings
|
||||
|
||||
def _capture_create_deep_agent_kwargs(
|
||||
self, tmp_path: Path, *, subagent_model: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Build a default agent + custom subagent; capture `create_deep_agent` kwargs.
|
||||
|
||||
Returns the kwargs dcode forwards to `create_deep_agent` so callers can
|
||||
assert on both the main `middleware` list and each `subagents` spec.
|
||||
`subagent_model` sets the custom subagent's `model:` frontmatter, which
|
||||
drives the `has_explicit_model` branch in `_subagent_cli_middleware`.
|
||||
"""
|
||||
mock_settings = self._build_mock_settings(tmp_path)
|
||||
mock_agent = Mock()
|
||||
mock_agent.with_config.return_value = mock_agent
|
||||
fake_model = _make_fake_chat_model()
|
||||
|
||||
subagent_meta = {
|
||||
"name": "researcher",
|
||||
"description": "Researches things",
|
||||
"system_prompt": "Investigate the task thoroughly.",
|
||||
"model": subagent_model,
|
||||
}
|
||||
|
||||
with (
|
||||
patch("deepagents_code.agent.settings", mock_settings),
|
||||
patch("deepagents_code.agent.SkillsMiddleware"),
|
||||
patch("deepagents_code.agent.MemoryMiddleware"),
|
||||
patch(
|
||||
"deepagents_code.agent.list_subagents",
|
||||
return_value=[subagent_meta],
|
||||
),
|
||||
patch(
|
||||
"deepagents_code.agent.create_deep_agent",
|
||||
return_value=mock_agent,
|
||||
) as mock_create,
|
||||
patch(
|
||||
"deepagents._models.init_chat_model",
|
||||
return_value=fake_model,
|
||||
),
|
||||
):
|
||||
create_cli_agent(
|
||||
model="fake-model",
|
||||
assistant_id="test",
|
||||
enable_memory=False,
|
||||
enable_skills=False,
|
||||
enable_shell=True,
|
||||
)
|
||||
|
||||
_, kwargs = mock_create.call_args
|
||||
return kwargs
|
||||
|
||||
@staticmethod
|
||||
def _has_todo_standin(middleware: list[Any]) -> bool:
|
||||
return any(
|
||||
getattr(mw, "name", None) == TodoListMiddleware.__name__
|
||||
for mw in middleware
|
||||
)
|
||||
|
||||
def test_standin_name_matches_sdk_middleware(self) -> None:
|
||||
"""The stand-in must impersonate the real middleware's `.name`.
|
||||
|
||||
The name-based merge keys on the instance `.name`, so this guards a
|
||||
hypothetical SDK `.name` override that `__name__`-derivation would miss.
|
||||
"""
|
||||
from deepagents_code.agent import _NoTodoListMiddleware
|
||||
|
||||
assert _NoTodoListMiddleware().name == TodoListMiddleware().name
|
||||
|
||||
def test_dropped_from_main_and_subagents_when_experimental(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv(EXPERIMENTAL, "1")
|
||||
kwargs = self._capture_create_deep_agent_kwargs(tmp_path)
|
||||
|
||||
assert self._has_todo_standin(kwargs["middleware"])
|
||||
|
||||
subagents_by_name = {sa["name"]: sa for sa in kwargs["subagents"]}
|
||||
assert {"researcher", "general-purpose"} <= set(subagents_by_name)
|
||||
for name, spec in subagents_by_name.items():
|
||||
assert self._has_todo_standin(spec.get("middleware", [])), (
|
||||
f"Expected TodoListMiddleware stand-in on subagent {name!r}"
|
||||
)
|
||||
|
||||
def test_dropped_from_explicit_model_subagent_when_experimental(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The splice must survive the `has_explicit_model` branch.
|
||||
|
||||
`_subagent_cli_middleware` extends the stand-in in *before* the
|
||||
`if not has_explicit_model:` model-middleware check, so a subagent with
|
||||
an explicit `model:` in frontmatter must still receive it. The other
|
||||
wiring cases only exercise the `model: None` branch, so without this a
|
||||
regression that moved the splice inside that `if` would pass unnoticed.
|
||||
"""
|
||||
monkeypatch.setenv(EXPERIMENTAL, "1")
|
||||
kwargs = self._capture_create_deep_agent_kwargs(
|
||||
tmp_path, subagent_model="fake-model"
|
||||
)
|
||||
|
||||
subagents_by_name = {sa["name"]: sa for sa in kwargs["subagents"]}
|
||||
researcher = subagents_by_name["researcher"]
|
||||
# Guards the premise: an explicit model must reach the spec, else the
|
||||
# subagent would take the `model: None` path and the test proves nothing.
|
||||
assert researcher.get("model"), "explicit subagent model was not forwarded"
|
||||
assert self._has_todo_standin(researcher.get("middleware", []))
|
||||
|
||||
def test_absent_from_all_stacks_by_default(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.delenv(EXPERIMENTAL, raising=False)
|
||||
kwargs = self._capture_create_deep_agent_kwargs(tmp_path)
|
||||
|
||||
assert not self._has_todo_standin(kwargs["middleware"])
|
||||
for spec in kwargs["subagents"]:
|
||||
assert not self._has_todo_standin(spec.get("middleware", []))
|
||||
|
||||
|
||||
def _mock_agents_dir(agents_dir: Path) -> Mock:
|
||||
mock_settings = Mock()
|
||||
mock_settings.user_deepagents_dir = agents_dir
|
||||
|
||||
@@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from deepagents_code._env_vars import EXPERIMENTAL
|
||||
from deepagents_code.config import Settings
|
||||
from deepagents_code.mcp_tools import MCPServerInfo, MCPToolInfo
|
||||
from deepagents_code.tool_catalog import (
|
||||
@@ -119,6 +120,26 @@ class TestCollectBuiltInTools:
|
||||
collect_built_in_tools()
|
||||
|
||||
|
||||
class TestExperimentalTodoRemoval:
|
||||
"""`DEEPAGENTS_CODE_EXPERIMENTAL` drops the SDK `write_todos` tool."""
|
||||
|
||||
def test_write_todos_bound_by_default(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.delenv(EXPERIMENTAL, raising=False)
|
||||
names = {tool.name for tool in collect_built_in_tools()}
|
||||
assert "write_todos" in names
|
||||
|
||||
def test_write_todos_removed_when_experimental(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv(EXPERIMENTAL, "1")
|
||||
names = {tool.name for tool in collect_built_in_tools()}
|
||||
assert "write_todos" not in names
|
||||
# Only the todo tool is dropped; the rest of the core set stays bound.
|
||||
assert names >= _CORE_BUILT_IN - {"write_todos"}
|
||||
|
||||
|
||||
class TestCollectToolsFromAgent:
|
||||
"""Tests for inspecting the tool node of an already-running local graph."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user