From b200cba5622e77fe10a67bf7246799fc3198b77e Mon Sep 17 00:00:00 2001 From: dev-lead Date: Fri, 8 May 2026 14:02:21 -0700 Subject: [PATCH] fix(test_concurrent_interrupt): add _tool_guardrails to _Stub fixture (partial close hermes-agent#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `_Stub` fixture in tests/run_agent/test_concurrent_interrupt.py bypasses `AIAgent.__init__`, so it must mirror any new instance attributes that production methods rely on. Tool-loop guardrails (introduced in 58b89965 "fix(agent): add tool-call loop guardrails", 2026-04-27) added three integration points to `_execute_tool_calls_concurrent`: 1. `self._tool_guardrails.before_call(...)` per tool (run_agent.py:9447) 2. `self._append_guardrail_observation(...)` per result (run_agent.py:9672) 3. `self._guardrail_block_result(...)` for blocked calls `_Stub` defined none of these, so both `test_concurrent_interrupt_cancels_pending` and `test_running_concurrent_worker_sees_is_interrupted` raised `AttributeError: '_Stub' object has no attribute '_tool_guardrails'` on the first concurrent tool call. Fix: - Add a real `ToolCallGuardrailController()` instance attribute, matching AIAgent.__init__ at run_agent.py:1160. Default config is warning-only so the controller observes but never blocks — the tests still exercise interrupt fanout, not guardrail behaviour. - Bind the real `_append_guardrail_observation` and `_guardrail_block_result` helpers from AIAgent (same pattern as the existing `_execute_tool_calls_concurrent` / `interrupt` / `clear_interrupt` bindings). - Stub `_set_tool_guardrail_halt` as a no-op + add `_tool_guardrail_halt_decision = None`. - Widen `slow_tool` and `polling_tool` side-effect signatures with `**kwargs` to swallow new production-only `_invoke_tool` kwargs (`messages`, `pre_tool_block_checked`). Verification: - pytest tests/run_agent/test_concurrent_interrupt.py -v # 4/4 pass - pytest tests/run_agent/ # 1193 passed, 9 skipped, only pre-existing test_primary_runtime_restore failure (issue #9 cluster, untouched here). Diff scope: single file, 21 insertions, 2 modifications. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/run_agent/test_concurrent_interrupt.py | 23 ++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/run_agent/test_concurrent_interrupt.py b/tests/run_agent/test_concurrent_interrupt.py index 9a6ba73e..61d7752f 100644 --- a/tests/run_agent/test_concurrent_interrupt.py +++ b/tests/run_agent/test_concurrent_interrupt.py @@ -20,6 +20,7 @@ def _make_agent(monkeypatch): monkeypatch.setenv("HERMES_INFERENCE_PROVIDER", "") # Avoid full AIAgent init — just import the class and build a stub import run_agent as _ra + from agent.tool_guardrails import ToolCallGuardrailController class _Stub: _interrupt_requested = False @@ -53,6 +54,12 @@ def _make_agent(monkeypatch): self._tool_worker_threads: set = set() self._tool_worker_threads_lock = threading.Lock() self._active_children_lock = threading.Lock() + # Mirror AIAgent.__init__ (run_agent.py:1160 — added in 58b89965 + # "fix(agent): add tool-call loop guardrails", 2026-04-27). + # _execute_tool_calls_concurrent calls self._tool_guardrails + # .before_call(...) on every tool, so the stub needs a real + # controller instance with default (warning-only) config. + self._tool_guardrails = ToolCallGuardrailController() def _touch_activity(self, desc): self._last_activity = time.time() @@ -77,6 +84,14 @@ def _make_agent(monkeypatch): stub._execute_tool_calls_concurrent = _ra.AIAgent._execute_tool_calls_concurrent.__get__(stub) stub.interrupt = _ra.AIAgent.interrupt.__get__(stub) stub.clear_interrupt = _ra.AIAgent.clear_interrupt.__get__(stub) + # Tool-loop guardrails (added in 58b89965, 2026-04-27) are invoked + # before/after every concurrent tool. Bind the real helpers — the + # default ToolCallGuardrailController() above is warning-only so + # they never block a tool, just observe. + stub._append_guardrail_observation = _ra.AIAgent._append_guardrail_observation.__get__(stub) + stub._guardrail_block_result = _ra.AIAgent._guardrail_block_result.__get__(stub) + stub._set_tool_guardrail_halt = lambda *a, **kw: None + stub._tool_guardrail_halt_decision = None # /steer injection (added in PR #12116) fires after every concurrent # tool batch. Stub it as a no-op — this test exercises interrupt # fanout, not steer injection. @@ -107,7 +122,9 @@ def test_concurrent_interrupt_cancels_pending(monkeypatch): original_invoke = agent._invoke_tool - def slow_tool(name, args, task_id, call_id=None): + def slow_tool(name, args, task_id, call_id=None, **kwargs): + # **kwargs swallows production-only kwargs (messages, + # pre_tool_block_checked) added to _invoke_tool over time. if name == "slow_one": # Block until the test sets the interrupt barrier.wait(timeout=10) @@ -184,7 +201,9 @@ def test_running_concurrent_worker_sees_is_interrupted(monkeypatch): observed = {"saw_true": False, "poll_count": 0, "worker_tid": None} worker_started = threading.Event() - def polling_tool(name, args, task_id, call_id=None, messages=None): + def polling_tool(name, args, task_id, call_id=None, messages=None, **kwargs): + # **kwargs swallows production-only kwargs (pre_tool_block_checked) + # added to _invoke_tool over time. observed["worker_tid"] = threading.current_thread().ident worker_started.set() deadline = time.monotonic() + 5.0