diff --git a/AGENTS.md b/AGENTS.md index 13998fe1d..f3201f9f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -364,7 +364,7 @@ Rendering bugs in tmux/iTerm2 — ghosting on scroll. Use `curses` (stdlib) inst Leaks as literal `?[K` text under `prompt_toolkit`'s `patch_stdout`. Use space-padding: `f"\r{line}{' ' * pad}"`. ### `_last_resolved_tool_names` is a process-global in `model_tools.py` -When subagents overwrite this global, `execute_code` calls after delegation may fail with missing tool imports. Known bug. +`_run_single_child()` in `delegate_tool.py` saves and restores this global around subagent execution. If you add new code that reads this global, be aware it may be temporarily stale during child agent runs. ### Tests must not write to `~/.hermes/` The `_isolate_hermes_home` autouse fixture in `tests/conftest.py` redirects `HERMES_HOME` to a temp dir. Never hardcode `~/.hermes/` paths in tests. diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 476a2401b..24c3e458a 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -249,6 +249,49 @@ class TestDelegateTask(unittest.TestCase): self.assertEqual(kwargs["api_mode"], parent.api_mode) +class TestToolNamePreservation(unittest.TestCase): + """Verify _last_resolved_tool_names is restored after subagent runs.""" + + def test_global_tool_names_restored_after_delegation(self): + """The process-global _last_resolved_tool_names must be restored + after a subagent completes so the parent's execute_code sandbox + generates correct imports.""" + import model_tools + + parent = _make_mock_parent(depth=0) + original_tools = ["terminal", "read_file", "web_search", "execute_code", "delegate_task"] + model_tools._last_resolved_tool_names = list(original_tools) + + with patch("run_agent.AIAgent") as MockAgent: + mock_child = MagicMock() + mock_child.run_conversation.return_value = { + "final_response": "done", "completed": True, "api_calls": 1, + } + MockAgent.return_value = mock_child + + delegate_task(goal="Test tool preservation", parent_agent=parent) + + self.assertEqual(model_tools._last_resolved_tool_names, original_tools) + + def test_global_tool_names_restored_after_child_failure(self): + """Even when the child agent raises, the global must be restored.""" + import model_tools + + parent = _make_mock_parent(depth=0) + original_tools = ["terminal", "read_file", "web_search"] + model_tools._last_resolved_tool_names = list(original_tools) + + with patch("run_agent.AIAgent") as MockAgent: + mock_child = MagicMock() + mock_child.run_conversation.side_effect = RuntimeError("boom") + MockAgent.return_value = mock_child + + result = json.loads(delegate_task(goal="Crash test", parent_agent=parent)) + self.assertEqual(result["results"][0]["status"], "error") + + self.assertEqual(model_tools._last_resolved_tool_names, original_tools) + + class TestDelegateObservability(unittest.TestCase): """Tests for enriched metadata returned by _run_single_child.""" diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 2ef505dab..2a0e5b131 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -171,6 +171,11 @@ def _build_child_agent( model on OpenRouter while the parent runs on Nous Portal). """ from run_agent import AIAgent + import model_tools + + # Save the parent's resolved tool names before the child agent can + # overwrite the process-global via get_tool_definitions(). + _saved_tool_names = list(model_tools._last_resolved_tool_names) # When no explicit toolsets given, inherit from parent's enabled toolsets # so disabled tools (e.g. web) don't leak to subagents. @@ -365,6 +370,10 @@ def _run_single_child( } finally: + # Restore the parent's tool names so the process-global is correct + # for any subsequent execute_code calls or other consumers. + model_tools._last_resolved_tool_names = _saved_tool_names + # Unregister child from interrupt propagation if hasattr(parent_agent, '_active_children'): try: