Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
# Conflicts: # tui_gateway/server.py
This commit is contained in:
@@ -2543,6 +2543,122 @@ class TestConcurrentToolExecution:
|
||||
assert json.loads(result) == {"error": "Blocked"}
|
||||
assert agent._turns_since_memory == 5
|
||||
|
||||
def test_concurrent_blocked_write_skips_checkpoint(self, agent, monkeypatch):
|
||||
"""Concurrent path: blocked write_file should not trigger checkpoint."""
|
||||
tc1 = _mock_tool_call(name="write_file",
|
||||
arguments='{"path":"test.txt","content":"hello"}',
|
||||
call_id="c1")
|
||||
tc2 = _mock_tool_call(name="read_file",
|
||||
arguments='{"path":"other.py"}',
|
||||
call_id="c2")
|
||||
mock_msg = _mock_assistant_msg(content="", tool_calls=[tc1, tc2])
|
||||
messages = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.plugins.get_pre_tool_call_block_message",
|
||||
lambda *args, **kwargs: "Blocked" if args[0] == "write_file" else None,
|
||||
)
|
||||
|
||||
agent._checkpoint_mgr.enabled = True
|
||||
|
||||
def fake_handle(name, args, task_id, **kwargs):
|
||||
return f"result_{name}"
|
||||
|
||||
with patch("run_agent.handle_function_call", side_effect=fake_handle):
|
||||
with patch.object(agent._checkpoint_mgr, "ensure_checkpoint") as cp_mock:
|
||||
agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1")
|
||||
|
||||
cp_mock.assert_not_called()
|
||||
|
||||
def test_concurrent_blocked_patch_skips_checkpoint(self, agent, monkeypatch):
|
||||
"""Concurrent path: blocked patch should not trigger checkpoint."""
|
||||
tc1 = _mock_tool_call(name="patch",
|
||||
arguments='{"path":"f.py","old":"a","new":"b"}',
|
||||
call_id="c1")
|
||||
tc2 = _mock_tool_call(name="read_file",
|
||||
arguments='{"path":"other.py"}',
|
||||
call_id="c2")
|
||||
mock_msg = _mock_assistant_msg(content="", tool_calls=[tc1, tc2])
|
||||
messages = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.plugins.get_pre_tool_call_block_message",
|
||||
lambda *args, **kwargs: "Blocked" if args[0] == "patch" else None,
|
||||
)
|
||||
|
||||
agent._checkpoint_mgr.enabled = True
|
||||
|
||||
def fake_handle(name, args, task_id, **kwargs):
|
||||
return f"result_{name}"
|
||||
|
||||
with patch("run_agent.handle_function_call", side_effect=fake_handle):
|
||||
with patch.object(agent._checkpoint_mgr, "ensure_checkpoint") as cp_mock:
|
||||
agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1")
|
||||
|
||||
cp_mock.assert_not_called()
|
||||
|
||||
def test_concurrent_blocked_terminal_skips_checkpoint(self, agent, monkeypatch):
|
||||
"""Concurrent path: blocked terminal should not trigger checkpoint."""
|
||||
tc1 = _mock_tool_call(name="terminal",
|
||||
arguments='{"command":"rm -rf /tmp/foo"}',
|
||||
call_id="c1")
|
||||
tc2 = _mock_tool_call(name="read_file",
|
||||
arguments='{"path":"other.py"}',
|
||||
call_id="c2")
|
||||
mock_msg = _mock_assistant_msg(content="", tool_calls=[tc1, tc2])
|
||||
messages = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.plugins.get_pre_tool_call_block_message",
|
||||
lambda *args, **kwargs: "Blocked" if args[0] == "terminal" else None,
|
||||
)
|
||||
|
||||
agent._checkpoint_mgr.enabled = True
|
||||
|
||||
def fake_handle(name, args, task_id, **kwargs):
|
||||
return f"result_{name}"
|
||||
|
||||
with patch("run_agent.handle_function_call", side_effect=fake_handle):
|
||||
with patch.object(agent._checkpoint_mgr, "ensure_checkpoint") as cp_mock:
|
||||
with patch("agent.tool_executor._is_destructive_command", return_value=True):
|
||||
agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1")
|
||||
|
||||
cp_mock.assert_not_called()
|
||||
|
||||
def test_concurrent_blocked_write_does_not_steal_slot_from_allowed_write(self, agent, monkeypatch):
|
||||
"""When write_file is blocked, its dedup slot must not be consumed,
|
||||
so a subsequent allowed write_file for the same path still checkpoints."""
|
||||
tc1 = _mock_tool_call(name="write_file",
|
||||
arguments='{"path":"dup.txt","content":"blocked"}',
|
||||
call_id="c1")
|
||||
tc2 = _mock_tool_call(name="write_file",
|
||||
arguments='{"path":"dup.txt","content":"allowed"}',
|
||||
call_id="c2")
|
||||
mock_msg = _mock_assistant_msg(content="", tool_calls=[tc1, tc2])
|
||||
messages = []
|
||||
|
||||
call_count = {"n": 0}
|
||||
def block_first_only(*args, **kwargs):
|
||||
call_count["n"] += 1
|
||||
return "Blocked" if call_count["n"] == 1 else None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.plugins.get_pre_tool_call_block_message",
|
||||
block_first_only,
|
||||
)
|
||||
|
||||
agent._checkpoint_mgr.enabled = True
|
||||
|
||||
def fake_handle(name, args, task_id, **kwargs):
|
||||
return f"result_{name}"
|
||||
|
||||
with patch("run_agent.handle_function_call", side_effect=fake_handle):
|
||||
with patch.object(agent._checkpoint_mgr, "ensure_checkpoint") as cp_mock:
|
||||
agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1")
|
||||
|
||||
# Second (allowed) write must checkpoint even though first was blocked.
|
||||
cp_mock.assert_called_once()
|
||||
|
||||
|
||||
class TestPathsOverlap:
|
||||
"""Unit tests for the _paths_overlap helper."""
|
||||
@@ -2756,6 +2872,40 @@ class TestHandleMaxIterations:
|
||||
]
|
||||
assert len(stub_ids) >= 1, f"No stub result for assistant tool_call: {stub_ids}"
|
||||
|
||||
def test_summary_strips_strict_schema_foreign_fields(self, agent):
|
||||
"""Regression: the max-iterations summary request must NOT carry
|
||||
Chat-Completions-schema-foreign keys — tool_name (SQLite FTS
|
||||
bookkeeping), codex_* reasoning carriers, or internal _-prefixed
|
||||
scaffolding. Strict gateways (Fireworks-backed OpenCode Go, Mistral,
|
||||
Kimi) reject these with 'Extra inputs are not permitted, field:
|
||||
messages[N].tool_name'. The transport's convert_messages() strips
|
||||
them on the main loop; this hand-built summary path must mirror it."""
|
||||
agent.client.chat.completions.create.return_value = _mock_response(content="Summary")
|
||||
agent._cached_system_prompt = "You are helpful."
|
||||
messages = [
|
||||
{"role": "user", "content": "do stuff"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"tool_calls": [{"id": "call_1", "function": {"name": "execute_code", "arguments": "{}"}}],
|
||||
"codex_reasoning_items": [{"id": "rs_1"}],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "result", "tool_name": "execute_code"},
|
||||
{"role": "assistant", "content": "Done.", "_empty_recovery_synthetic": True},
|
||||
]
|
||||
|
||||
result = agent._handle_max_iterations(messages, 60)
|
||||
|
||||
assert result == "Summary"
|
||||
sent_msgs = agent.client.chat.completions.create.call_args.kwargs.get("messages", [])
|
||||
for m in sent_msgs:
|
||||
assert "tool_name" not in m, m
|
||||
assert "codex_reasoning_items" not in m, m
|
||||
assert "codex_message_items" not in m, m
|
||||
assert not any(isinstance(k, str) and k.startswith("_") for k in m), m
|
||||
# Internal history is untouched — the path copies each message.
|
||||
assert messages[2]["tool_name"] == "execute_code"
|
||||
assert messages[1]["codex_reasoning_items"] == [{"id": "rs_1"}]
|
||||
|
||||
def test_summary_omits_provider_preferences_for_non_openrouter(self, agent):
|
||||
agent.base_url = "https://api.openai.com/v1"
|
||||
agent._base_url_lower = agent.base_url.lower()
|
||||
@@ -3046,7 +3196,11 @@ class TestRunConversation:
|
||||
|
||||
mock_compress.assert_not_called() # no compression triggered
|
||||
assert result["completed"] is True
|
||||
assert result["final_response"] == "(empty)"
|
||||
# #34452: the bare "(empty)" sentinel is now replaced by a
|
||||
# user-visible end-of-turn explanation so the failure isn't silent.
|
||||
assert result["final_response"] != "(empty)"
|
||||
assert "No reply:" in result["final_response"]
|
||||
assert result["turn_exit_reason"] == "empty_response_exhausted"
|
||||
assert result["api_calls"] == 6 # 1 original + 2 prefill + 3 retries
|
||||
|
||||
def test_reasoning_only_response_prefill_then_empty(self, agent):
|
||||
@@ -3066,7 +3220,9 @@ class TestRunConversation:
|
||||
):
|
||||
result = agent.run_conversation("answer me")
|
||||
assert result["completed"] is True
|
||||
assert result["final_response"] == "(empty)"
|
||||
# #34452: explanation replaces the bare "(empty)" sentinel.
|
||||
assert result["final_response"] != "(empty)"
|
||||
assert "No reply:" in result["final_response"]
|
||||
assert result["api_calls"] == 6 # 1 original + 2 prefill + 3 retries
|
||||
|
||||
def test_reasoning_only_prefill_succeeds_on_continuation(self, agent):
|
||||
@@ -3113,7 +3269,9 @@ class TestRunConversation:
|
||||
):
|
||||
result = agent.run_conversation("answer me")
|
||||
assert result["completed"] is True
|
||||
assert result["final_response"] == "(empty)"
|
||||
# #34452: explanation replaces the bare "(empty)" sentinel.
|
||||
assert result["final_response"] != "(empty)"
|
||||
assert "No reply:" in result["final_response"]
|
||||
assert result["api_calls"] == 4 # 1 original + 3 retries
|
||||
|
||||
def test_truly_empty_response_succeeds_on_nudge(self, agent):
|
||||
@@ -3209,7 +3367,9 @@ class TestRunConversation:
|
||||
):
|
||||
result = agent.run_conversation("answer me")
|
||||
assert result["completed"] is True
|
||||
assert result["final_response"] == "(empty)"
|
||||
# #34452: explanation replaces the bare "(empty)" sentinel.
|
||||
assert result["final_response"] != "(empty)"
|
||||
assert "No reply:" in result["final_response"]
|
||||
|
||||
def test_empty_response_emits_status_for_gateway(self, agent):
|
||||
"""_emit_status is called during empty retries so gateway users see feedback."""
|
||||
@@ -3235,7 +3395,10 @@ class TestRunConversation:
|
||||
):
|
||||
result = agent.run_conversation("answer me")
|
||||
|
||||
assert result["final_response"] == "(empty)"
|
||||
# #34452: explanation replaces the bare "(empty)" sentinel, but the
|
||||
# status emissions during retries are unchanged.
|
||||
assert result["final_response"] != "(empty)"
|
||||
assert "No reply:" in result["final_response"]
|
||||
# Should have emitted retry statuses (3 retries) + final failure
|
||||
retry_msgs = [m for m in status_messages if "retrying" in m.lower()]
|
||||
assert len(retry_msgs) == 3, f"Expected 3 retry status messages, got {len(retry_msgs)}: {status_messages}"
|
||||
|
||||
Reference in New Issue
Block a user