feat(hooks): pre_tool_call content transformation via modify directive

Adds a `modify` response type to pre_tool_call hooks so a hook can
transform tool arguments before the tool executes, instead of repairing
results afterwards via post_tool_call.

- hermes_cli/plugins.py: _dispatch_pre_tool_call_hooks() fires hooks once
  and returns (block_message, modified_args); modify directives
  shallow-merge into an accumulated dict built from the original args.
- agent/shell_hooks.py: _parse_response() accepts both the canonical
  {"action": "modify", "args": {...}} and Claude Code-compatible
  {"decision": "modify", "tool_input": {...}} wire formats.
- model_tools.py, agent/tool_executor.py, agent/agent_runtime_helpers.py:
  dispatch sites migrated; modified args applied before execution.
- Docs + 10 new tests (merge semantics, precedence, block interplay).

Salvaged from PR #28953. Best fix for #18988.
This commit is contained in:
Nikola Hristov
2026-08-15 22:48:57 -07:00
committed by Teknium
parent f3bf718a62
commit d083b85591
9 changed files with 287 additions and 22 deletions
+9 -7
View File
@@ -1370,22 +1370,22 @@ def handle_function_call(
if function_name in _AGENT_LOOP_TOOLS:
return tool_error(f"{function_name} must be handled by the agent loop")
# Check plugin hooks for a block/approve directive (unless caller
# Check plugin hooks for a block/approve/modify directive (unless caller
# already checked — e.g. run_agent._invoke_tool passes skip=True to
# avoid double-firing the hook).
#
# Single-fire contract: pre_tool_call fires exactly once per tool
# execution. resolve_pre_tool_block() internally calls
# invoke_hook("pre_tool_call", ...) once and returns the block message
# for a `block` directive OR for an `approve` directive whose human
# gate denied/timed-out/errored (fail-closed). Observer plugins see
# execution. _dispatch_pre_tool_call_hooks() internally calls
# invoke_hook("pre_tool_call", ...) once and returns both the block
# message (for `block`/`approve` directives) and any modified args
# (for `modify` directives). Observer plugins see
# the hook on that same pass. When skip=True, the caller already
# fired it — do nothing here.
if not skip_pre_tool_call_hook:
block_message: Optional[str] = None
try:
from hermes_cli.plugins import resolve_pre_tool_block
block_message = resolve_pre_tool_block(
from hermes_cli.plugins import _dispatch_pre_tool_call_hooks
block_message, modified_args = _dispatch_pre_tool_call_hooks(
function_name,
function_args,
task_id=task_id or "",
@@ -1395,6 +1395,8 @@ def handle_function_call(
api_request_id=api_request_id or "",
middleware_trace=list(_tool_middleware_trace),
)
if modified_args is not None:
function_args = modified_args
except Exception as _hook_err:
logger.debug("pre_tool_call hook error: %s", _hook_err)