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:
+9
-7
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user