fix(hooks): reset reminder counters after a successful Codex Serena call

Codex's documented hook wiring only routes `serena-hooks remind` through
PreToolUse on the `Bash` matcher, so `ToolUseCounter.update()`'s
reset-on-Serena-tool-use branch is never reached there: it only fires when
`remind` itself is invoked for a `mcp__serena__*` tool name, which the
Bash-only matcher excludes. Reminder counters therefore never clear after a
successful Serena call, and an unrelated grep/read burst afterwards can trip
the deny threshold on state that should have been reset.

Add a `serena-hooks reset` command, wired to PostToolUse on Serena's own
tools, that resets the counters after a successful call. Gated on the call
having succeeded (`tool_response` carrying no `isError: true`, the MCP
`tools/call` result shape) so a failed Serena call does not mask a real
grep/read streak, per the issue's own acceptance criteria. The symbolic-tool
classification is shared with the existing PreToolUse hook via a small
extracted helper so both agree on what counts as a Serena tool.

Fixes #1852
This commit is contained in:
Amir Fathi authored and Dr. Dominik Jain committed 2026-09-02 13:21:42 +02:00
1 parent 9f4a2b4830
commit 47c0a4bb66
4 files changed
+296 -20

No files matched your search

+77 -18
View File
@@ -48,23 +48,31 @@ class Hook(ABC):
pass
class PreToolUseHook(Hook, ABC):
_NON_SYMBOLIC_SERENA_TOOL_NAME_SUBSTRINGS = frozenset(
(
"pattern",
"read",
"diagnostics",
"memory",
"onboarding",
"config",
"list_file",
"find_file",
"shell",
"dashboard",
"restart_language_server",
)
#: substrings that mark a "serena"-containing tool name as one of Serena's own non-symbolic
#: utilities (read/config/dashboard/shell) rather than a code-navigation tool; shared across
#: PreToolUse and PostToolUse hooks so both classify a call the same way.
_NON_SYMBOLIC_SERENA_TOOL_NAME_SUBSTRINGS = frozenset(
(
"pattern",
"read",
"diagnostics",
"memory",
"onboarding",
"config",
"list_file",
"find_file",
"shell",
"dashboard",
"restart_language_server",
)
)
def _is_serena_symbolic_tool_name(tool_name: str) -> bool:
return "serena" in tool_name and not any(substring in tool_name for substring in _NON_SYMBOLIC_SERENA_TOOL_NAME_SUBSTRINGS)
class PreToolUseHook(Hook, ABC):
def __init__(self, client: HookClient):
super().__init__(client)
_tool_name = self._input_data.get("tool_name") or self._input_data.get("toolName", "") or ""
@@ -107,9 +115,7 @@ class PreToolUseHook(Hook, ABC):
return json.dumps(hook_output)
def is_serena_symbolic_tool(self) -> bool:
return "serena" in self._tool_name and not any(
substring in self._tool_name for substring in self._NON_SYMBOLIC_SERENA_TOOL_NAME_SUBSTRINGS
)
return _is_serena_symbolic_tool_name(self._tool_name)
class PreToolUseRemindAboutSymbolicToolsHook(PreToolUseHook):
@@ -521,6 +527,48 @@ class PreToolUseRemindAboutSymbolicToolsHook(PreToolUseHook):
)
class PostToolUseResetSymbolicToolCounterHook(Hook):
"""Post-tool-use hook that resets :class:`PreToolUseRemindAboutSymbolicToolsHook`'s
persisted counters after a successful Serena symbolic tool call.
``PreToolUseRemindAboutSymbolicToolsHook`` already resets on a Serena tool call, but
only when it is itself invoked for that call, which requires the client's PreToolUse
matcher to observe ``mcp__serena__*`` tool names. Codex's documented wiring (see
docs/02-usage/030_clients.md) attaches ``remind`` to the ``Bash`` matcher only, so it
is never invoked for Serena's own tools there and the reset branch is unreachable.
This hook closes that gap from the other side of the call: wired to PostToolUse with a
matcher on Serena's tools, it fires once the call has completed.
Gated on the call having succeeded (``tool_response`` carrying no ``isError: true``,
the MCP ``tools/call`` result shape) so a failed Serena call does not mask a real
grep/read-drift streak the agent is still in.
"""
def __init__(self, client: HookClient):
super().__init__(client)
raw_tool_name = self._input_data.get("tool_name") or self._input_data.get("toolName", "") or ""
tool_name = str(raw_tool_name).lower().strip()
if not tool_name:
raise ValueError("Tool name is required in the hook input data")
self._tool_name = tool_name
raw_tool_response = self._input_data.get("tool_response") or self._input_data.get("toolResponse")
self._tool_response: dict | None = raw_tool_response if isinstance(raw_tool_response, dict) else None
def _call_succeeded(self) -> bool:
# no structured response to check: be conservative and treat it as not confirmed
# successful, rather than resetting on data we can't actually read
if self._tool_response is None:
return False
return self._tool_response.get("isError") is not True
def execute(self) -> None:
if not _is_serena_symbolic_tool_name(self._tool_name) or not self._call_succeeded():
return
counter = PreToolUseRemindAboutSymbolicToolsHook.ToolUseCounter.load(self)
counter.reset()
counter.save(self)
class SessionStartActivateProjectHook(Hook):
def execute(self) -> None:
message = (
@@ -630,5 +678,16 @@ class HookCommands(AutoRegisteringGroup):
def auto_approve(client: str) -> None:
PreToolUseAutoApproveSerenaHook(HookClient(client)).execute()
@staticmethod
@click.command(
"reset",
help="Set this as hook at PostToolUse, matched to Serena's own tools, to reset the grep/read-drift "
"counters after a successful Serena tool call. For clients whose PreToolUse wiring does not observe "
"Serena tool calls (e.g. Codex, matched to Bash only); complements `remind`'s own reset branch.",
)
@_client_option
def reset(client: str) -> None:
PostToolUseResetSymbolicToolCounterHook(HookClient(client)).execute()
hook_commands = HookCommands()