From 3e20d212060b786f444bd6e2aee436c83d2d63c6 Mon Sep 17 00:00:00 2001 From: ryuketsukami Date: Wed, 25 Mar 2026 02:26:35 +0200 Subject: [PATCH] fix: tool output uses json.dumps instead of f-string interpolation The tool result message in llm_withtools.py used f-string interpolation for tool_input (a Python dict), producing Python repr format (single quotes, True/False/None) instead of valid JSON. The LLM received malformed JSON in its conversation history. Also moves system prompt to first message only instead of prepending to every tool-result message, reducing token waste over multi-turn tool conversations. Adds system_msg parameter support for proper role separation. Added tests proving the old approach produces invalid JSON and the new approach handles special characters correctly. Co-Authored-By: Claude Opus 4.6 (1M context) --- agent/llm_withtools.py | 33 +-- tests/__init__.py | 0 tests/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 160 bytes .../conftest.cpython-314-pytest-9.0.2.pyc | Bin 0 -> 9966 bytes ...chive_parsing.cpython-314-pytest-9.0.2.pyc | Bin 0 -> 19613 bytes ...bash_sentinel.cpython-314-pytest-9.0.2.pyc | Bin 0 -> 11451 bytes ...test_ensemble.cpython-314-pytest-9.0.2.pyc | Bin 0 -> 16869 bytes ...sis_evaluator.cpython-314-pytest-9.0.2.pyc | Bin 0 -> 18035 bytes ..._llm_metadata.cpython-314-pytest-9.0.2.pyc | Bin 0 -> 10500 bytes ...t_instruction.cpython-314-pytest-9.0.2.pyc | Bin 0 -> 8799 bytes ...tadata_atomic.cpython-314-pytest-9.0.2.pyc | Bin 0 -> 14566 bytes ...st_smoke_test.cpython-314-pytest-9.0.2.pyc | Bin 0 -> 8738 bytes ...output_format.cpython-314-pytest-9.0.2.pyc | Bin 0 -> 13142 bytes tests/conftest.py | 188 ++++++++++++++++++ tests/test_archive_parsing.py | 180 +++++++++++++++++ tests/test_bash_sentinel.py | 68 +++++++ tests/test_ensemble.py | 174 ++++++++++++++++ tests/test_llm_metadata.py | 93 +++++++++ tests/test_meta_agent_instruction.py | 69 +++++++ tests/test_metadata_atomic.py | 139 +++++++++++++ tests/test_smoke_test.py | 101 ++++++++++ tests/test_tool_output_format.py | 158 +++++++++++++++ 22 files changed, 1190 insertions(+), 13 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/__pycache__/__init__.cpython-314.pyc create mode 100644 tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_archive_parsing.cpython-314-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_bash_sentinel.cpython-314-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_ensemble.cpython-314-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_genesis_evaluator.cpython-314-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_llm_metadata.cpython-314-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_meta_agent_instruction.cpython-314-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_metadata_atomic.cpython-314-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_smoke_test.cpython-314-pytest-9.0.2.pyc create mode 100644 tests/__pycache__/test_tool_output_format.cpython-314-pytest-9.0.2.pyc create mode 100644 tests/conftest.py create mode 100644 tests/test_archive_parsing.py create mode 100644 tests/test_bash_sentinel.py create mode 100644 tests/test_ensemble.py create mode 100644 tests/test_llm_metadata.py create mode 100644 tests/test_meta_agent_instruction.py create mode 100644 tests/test_metadata_atomic.py create mode 100644 tests/test_smoke_test.py create mode 100644 tests/test_tool_output_format.py diff --git a/agent/llm_withtools.py b/agent/llm_withtools.py index b25741e..72aaf25 100644 --- a/agent/llm_withtools.py +++ b/agent/llm_withtools.py @@ -95,7 +95,7 @@ def chat_with_agent( logging=print, tools_available=[], # Empty list means no tools, 'all' means all tools multiple_tool_calls=False, # Whether to allow multiple tool calls in a single response - max_tool_calls=40, # Maximum number of tool calls allowed in a single response, -1 for unlimited + max_tool_calls=40, # Max tool calls per response, -1=unlimited ): get_response_fn = get_response_from_llm # Construct message @@ -107,15 +107,17 @@ def chat_with_agent( # Load all tools all_tools = load_tools(logging=logging, names=tools_available) tools_dict = {tool['info']['name']: tool for tool in all_tools} - system_msg = f"{get_tooluse_prompt([tool['info'] for tool in all_tools])}\n\n" + tool_infos = [t['info'] for t in all_tools] + tool_system_msg = get_tooluse_prompt(tool_infos) num_tool_calls = 0 - # Call API + # Call API — tool descriptions sent as system message logging(f"Input: {repr(msg)}") response, new_msg_history, info = get_response_fn( - msg=system_msg + msg, + msg=msg, model=model, msg_history=new_msg_history, + system_msg=tool_system_msg, ) logging(f"Output: {repr(response)}") # logging(f"Info: {repr(info)}") @@ -139,13 +141,17 @@ def chat_with_agent( tool_input = tool_use['tool_input'] tool_output = process_tool_call(tools_dict, tool_name, tool_input) num_tool_calls += 1 - tool_msg = f''' - {{ - "tool_name": "{tool_name}", - "tool_input": {tool_input}, - "tool_output": "{tool_output}" - }} - '''.strip() + tool_msg_data = { + "tool_name": tool_name, + "tool_input": tool_input, + "tool_output": str(tool_output), + } + tool_json = json.dumps( + tool_msg_data, indent=2, + ) + tool_msg = ( + f"\n{tool_json}\n" + ) logging(f"Tool output: {repr(tool_msg)}") tool_msgs.append(tool_msg) @@ -154,9 +160,10 @@ def chat_with_agent( logging("Error: Output context exceeded. Please try again.") tool_msgs.append("Error: Output context exceeded. Please try again.") - # Get tool response + # Get tool response — no system_msg on + # subsequent calls (already in context) response, new_msg_history, info = get_response_fn( - msg=system_msg + '\n\n'.join(tool_msgs), + msg='\n\n'.join(tool_msgs), model=model, msg_history=new_msg_history, ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/__init__.cpython-314.pyc b/tests/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fe6a863bf5982869a99d8cb6e5f529372e165d46 GIT binary patch literal 160 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 KLmpbpf%|`~QLe@S literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_archive_parsing.cpython-314-pytest-9.0.2.pyc b/tests/__pycache__/test_archive_parsing.cpython-314-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..80ec9e0708ca6e9007c4bbb18ed43fd1ff72dad5 GIT binary patch literal 19613 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_bash_sentinel.cpython-314-pytest-9.0.2.pyc b/tests/__pycache__/test_bash_sentinel.cpython-314-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1221ccec3fd1d062fbac0f6b4b54f46349533f4e GIT binary patch literal 11451 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_metadata_atomic.cpython-314-pytest-9.0.2.pyc b/tests/__pycache__/test_metadata_atomic.cpython-314-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d53ae9a32659c79b2f137188b8a72001f0259953 GIT binary patch literal 14566 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 F`)_Nsb65ZX literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_smoke_test.cpython-314-pytest-9.0.2.pyc b/tests/__pycache__/test_smoke_test.cpython-314-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..db08b306ffae9769875feb4d3f88c09d30b50ee4 GIT binary patch literal 8738 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 literal 0 HcmV?d00001 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2708571 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,188 @@ +"""Shared fixtures for HyperAgents test suite.""" + +import importlib +import importlib.util +import os +import json +import sys +import tempfile +import shutil +import types + +import pytest + +# ---- Project root on sys.path ---- +_PROJ = os.path.normpath( + "C:/Users/ryuke/Desktop/Projects/Hyperagents" +) +if _PROJ not in sys.path: + sys.path.insert(0, _PROJ) + + +def _install_lightweight_mocks(): + """Install minimal mock modules so that project + modules can be imported without heavy deps like + docker, litellm, backoff, torch, etc. + + Only installs mocks for modules NOT already + present -- safe to call multiple times. + """ + def _ensure(name, factory): + if name not in sys.modules: + sys.modules[name] = factory() + + # docker + _ensure("docker", lambda: types.ModuleType("docker")) + + # utils.docker_utils + def _make_docker_utils(): + m = types.ModuleType("utils.docker_utils") + m.copy_to_container = lambda *a, **k: None + m.log_container_output = lambda *a, **k: None + return m + _ensure("utils.docker_utils", _make_docker_utils) + + # utils.git_utils + def _make_git_utils(): + m = types.ModuleType("utils.git_utils") + m.commit_repo = lambda *a, **k: "abc123" + m.get_git_commit_hash = lambda *a, **k: "abc" + return m + _ensure("utils.git_utils", _make_git_utils) + + # backoff + def _make_backoff(): + m = types.ModuleType("backoff") + m.expo = "expo" + m.on_exception = ( + lambda *a, **kw: (lambda f: f) + ) + return m + _ensure("backoff", _make_backoff) + + # requests / requests.exceptions + def _make_requests(): + m = types.ModuleType("requests") + exc = types.ModuleType("requests.exceptions") + exc.RequestException = Exception + m.exceptions = exc + sys.modules["requests.exceptions"] = exc + return m + _ensure("requests", _make_requests) + + # litellm + def _make_litellm(): + m = types.ModuleType("litellm") + m.drop_params = True + m.completion = lambda **kw: None + return m + _ensure("litellm", _make_litellm) + + # dotenv + def _make_dotenv(): + m = types.ModuleType("dotenv") + m.load_dotenv = lambda *a, **kw: None + return m + _ensure("dotenv", _make_dotenv) + + # utils.thread_logger + def _make_thread_logger(): + m = types.ModuleType("utils.thread_logger") + class FakeLM: + def __init__(self, **kw): + self.log = print + m.ThreadLoggerManager = FakeLM + return m + _ensure( + "utils.thread_logger", _make_thread_logger + ) + + # tqdm (used by genesis evaluator) + def _make_tqdm(): + m = types.ModuleType("tqdm") + m.tqdm = lambda *a, **kw: iter([]) + return m + _ensure("tqdm", _make_tqdm) + + # pandas (used by ensemble.py) + def _make_pandas(): + m = types.ModuleType("pandas") + m.read_csv = lambda *a, **kw: None + return m + _ensure("pandas", _make_pandas) + _ensure("pd", _make_pandas) + + +# Install mocks at import time so all test modules +# benefit. +_install_lightweight_mocks() + + +def load_module_from_file(module_name, file_path): + """Load a Python module directly from a file path, + bypassing package __init__.py files. + + Useful for modules whose package __init__ imports + heavy deps (e.g., torch). + """ + abs_path = os.path.join(_PROJ, file_path) + spec = importlib.util.spec_from_file_location( + module_name, abs_path + ) + mod = importlib.util.module_from_spec(spec) + sys.modules[module_name] = mod + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture +def tmp_dir(): + """Provide a temporary directory, cleaned up after test.""" + d = tempfile.mkdtemp() + yield d + shutil.rmtree(d, ignore_errors=True) + + +@pytest.fixture +def sample_archive_jsonl(tmp_dir): + """Create a sample archive.jsonl file with valid data.""" + path = os.path.join(tmp_dir, "archive.jsonl") + entries = [ + { + "current_genid": 0, + "archive": [0], + }, + { + "current_genid": 1, + "archive": [0, 1], + }, + { + "current_genid": 2, + "archive": [0, 1, 2], + }, + ] + with open(path, "w") as f: + for entry in entries: + f.write(json.dumps(entry) + "\n") + return path + + +@pytest.fixture +def sample_metadata_dir(tmp_dir): + """Create gen_X directories with metadata.json files.""" + for genid in range(3): + gen_dir = os.path.join( + tmp_dir, f"gen_{genid}" + ) + os.makedirs(gen_dir, exist_ok=True) + metadata = { + "parent_genid": genid - 1 if genid > 0 else None, + "valid_parent": True, + "prev_patch_files": [], + "curr_patch_files": [], + } + with open( + os.path.join(gen_dir, "metadata.json"), "w" + ) as f: + json.dump(metadata, f) + return tmp_dir diff --git a/tests/test_archive_parsing.py b/tests/test_archive_parsing.py new file mode 100644 index 0000000..f6fb711 --- /dev/null +++ b/tests/test_archive_parsing.py @@ -0,0 +1,180 @@ +"""Tests for JSONL archive parsing (F-07 fix). + +Validates that load_archive_data() correctly parses +JSONL format (one JSON object per line) instead of +treating the whole file as a single JSON array. +""" + +import json +import os +import tempfile + +import pytest + + +# --------------- helpers to avoid heavy project imports ----- +# We extract the parsing logic directly to test in +# isolation. If import works, we test the real function +# too. + +def _parse_jsonl(filepath, last_only=True): + """Pure-Python reimplementation of the JSONL parsing + logic from utils/gl_utils.py::load_archive_data.""" + if not os.path.exists(filepath): + raise FileNotFoundError( + f"Metadata file not found at {filepath}" + ) + archive_data = [] + with open(filepath, "r") as f: + for line in f: + line = line.strip() + if line: + try: + archive_data.append(json.loads(line)) + except json.JSONDecodeError: + continue + if last_only: + return archive_data[-1] + return archive_data + + +class TestLoadArchiveDataParsing: + """Tests for JSONL line-by-line parsing.""" + + def test_parse_valid_jsonl(self, tmp_dir): + """Valid JSONL with multiple lines parses each + line independently.""" + path = os.path.join(tmp_dir, "archive.jsonl") + entries = [ + {"current_genid": 0, "archive": [0]}, + {"current_genid": 1, "archive": [0, 1]}, + ] + with open(path, "w") as f: + for e in entries: + f.write(json.dumps(e) + "\n") + + result = _parse_jsonl(path, last_only=False) + assert len(result) == 2 + assert result[0]["current_genid"] == 0 + assert result[1]["archive"] == [0, 1] + + def test_parse_last_only(self, tmp_dir): + """last_only=True returns only the final entry.""" + path = os.path.join(tmp_dir, "archive.jsonl") + entries = [ + {"current_genid": 0, "archive": [0]}, + {"current_genid": 1, "archive": [0, 1]}, + {"current_genid": 2, "archive": [0, 1, 2]}, + ] + with open(path, "w") as f: + for e in entries: + f.write(json.dumps(e) + "\n") + + result = _parse_jsonl(path, last_only=True) + assert result["current_genid"] == 2 + assert len(result["archive"]) == 3 + + def test_empty_lines_skipped(self, tmp_dir): + """Blank lines between entries are ignored.""" + path = os.path.join(tmp_dir, "archive.jsonl") + with open(path, "w") as f: + f.write(json.dumps({"a": 1}) + "\n") + f.write("\n") + f.write(" \n") + f.write(json.dumps({"a": 2}) + "\n") + + result = _parse_jsonl(path, last_only=False) + assert len(result) == 2 + + def test_malformed_lines_skipped(self, tmp_dir): + """Malformed JSON lines are skipped without + crashing.""" + path = os.path.join(tmp_dir, "archive.jsonl") + with open(path, "w") as f: + f.write(json.dumps({"ok": True}) + "\n") + f.write("this is not json\n") + f.write("{broken json\n") + f.write(json.dumps({"ok": True}) + "\n") + + result = _parse_jsonl(path, last_only=False) + assert len(result) == 2 + assert all(e["ok"] for e in result) + + def test_empty_file_raises(self, tmp_dir): + """An empty file (no valid entries) raises + IndexError when last_only=True.""" + path = os.path.join(tmp_dir, "archive.jsonl") + with open(path, "w") as f: + f.write("") + + with pytest.raises(IndexError): + _parse_jsonl(path, last_only=True) + + def test_empty_file_returns_empty_list(self, tmp_dir): + """An empty file returns [] when last_only=False.""" + path = os.path.join(tmp_dir, "archive.jsonl") + with open(path, "w") as f: + f.write("") + + result = _parse_jsonl(path, last_only=False) + assert result == [] + + def test_missing_file_raises(self, tmp_dir): + """A nonexistent file raises FileNotFoundError.""" + path = os.path.join(tmp_dir, "nonexistent.jsonl") + with pytest.raises(FileNotFoundError): + _parse_jsonl(path) + + def test_single_line_file(self, tmp_dir): + """A file with exactly one line works correctly.""" + path = os.path.join(tmp_dir, "archive.jsonl") + entry = {"current_genid": 0, "archive": [0]} + with open(path, "w") as f: + f.write(json.dumps(entry) + "\n") + + result = _parse_jsonl(path, last_only=True) + assert result == entry + + result_all = _parse_jsonl(path, last_only=False) + assert len(result_all) == 1 + + +class TestLoadArchiveDataReal: + """Test the real load_archive_data function. + + conftest.py installs lightweight mocks so the + import succeeds without docker/litellm/etc. + """ + + @pytest.fixture(autouse=True) + def _try_import(self): + """Import load_archive_data (mocks in + conftest handle heavy deps).""" + try: + from utils.gl_utils import ( + load_archive_data, + ) + self.load_fn = load_archive_data + except Exception as e: + pytest.skip( + f"Could not import: {e}" + ) + + def test_real_parse_valid( + self, sample_archive_jsonl + ): + """Real function parses valid JSONL.""" + result = self.load_fn( + sample_archive_jsonl, last_only=False + ) + assert len(result) == 3 + assert result[-1]["current_genid"] == 2 + + def test_real_last_only( + self, sample_archive_jsonl + ): + """Real function returns last entry.""" + result = self.load_fn( + sample_archive_jsonl, last_only=True + ) + assert result["current_genid"] == 2 diff --git a/tests/test_bash_sentinel.py b/tests/test_bash_sentinel.py new file mode 100644 index 0000000..ee35689 --- /dev/null +++ b/tests/test_bash_sentinel.py @@ -0,0 +1,68 @@ +"""Tests for BashSession random sentinel (F-14). + +Validates that each BashSession instance gets a +unique, random sentinel instead of the static +'<>' string, preventing command output from +accidentally matching the sentinel. +""" + +import re +import sys + +import pytest + +from agent.tools.bash import BashSession + + +class TestBashSentinelUniqueness: + """F-14: Each BashSession gets a unique + random sentinel.""" + + def test_sentinel_is_not_static(self): + """Sentinel must not be the old static + '<>' string.""" + session = BashSession() + assert session._sentinel != "<>" + assert session._sentinel != "<>" + + def test_sentinel_matches_expected_pattern(self): + """Sentinel matches <> format.""" + session = BashSession() + pattern = r"^<>$" + assert re.match(pattern, session._sentinel), ( + f"Sentinel {session._sentinel!r} does not " + f"match pattern {pattern}" + ) + + def test_each_instance_gets_unique_sentinel(self): + """Two separate BashSession instances must have + different sentinels.""" + s1 = BashSession() + s2 = BashSession() + assert s1._sentinel != s2._sentinel, ( + "Two sessions should not share a sentinel" + ) + + def test_many_instances_all_unique(self): + """Creating 50 sessions yields 50 distinct + sentinels.""" + sentinels = { + BashSession()._sentinel for _ in range(50) + } + assert len(sentinels) == 50 + + def test_sentinel_hex_length(self): + """The hex portion has 32 chars (uuid4.hex).""" + session = BashSession() + # <> + inner = session._sentinel[7:-2] # strip <> + assert len(inner) == 32 + assert all(c in "0123456789abcdef" for c in inner) + + def test_sentinel_used_in_run_command(self): + """The run() method references self._sentinel, + not a hardcoded string.""" + import inspect + src = inspect.getsource(BashSession.run) + assert "self._sentinel" in src + assert "<>" not in src.lower() diff --git a/tests/test_ensemble.py b/tests/test_ensemble.py new file mode 100644 index 0000000..3148c0f --- /dev/null +++ b/tests/test_ensemble.py @@ -0,0 +1,174 @@ +"""Tests for ensemble majority voting logic. + +Validates weighted majority voting for classification +domains, single-best fallback, and domain gating. +""" + +import json +import os +import sys +from collections import defaultdict +from unittest.mock import patch, MagicMock + +import pytest + +_PROJ = "C:/Users/ryuke/Desktop/Projects/Hyperagents" +if _PROJ not in sys.path: + sys.path.insert(0, _PROJ) + + +# ------ Pure logic tests (no imports needed) ------ + +class TestMajorityVoteLogic: + """Test the weighted majority voting algorithm + in isolation.""" + + @staticmethod + def _weighted_majority(predictions_scores): + """Reimplement the core voting logic from + ensemble.py for isolated testing. + + Args: + predictions_scores: list of (pred, score) + Returns: + The prediction with highest total weight. + """ + votes = defaultdict(float) + for pred, score in predictions_scores: + if pred is not None: + votes[pred] += score + if votes: + return max(votes, key=votes.get) + return None + + def test_majority_simple(self): + """3 agents: A(0.8), B(0.7), A(0.6) -> A + wins with 1.4 vs 0.7.""" + result = self._weighted_majority([ + ("A", 0.8), ("B", 0.7), ("A", 0.6), + ]) + assert result == "A" + + def test_weights_override_count(self): + """2 agents vote B with low scores, 1 votes A + with high score. A wins on weight.""" + result = self._weighted_majority([ + ("A", 0.95), ("B", 0.1), ("B", 0.1), + ]) + assert result == "A" + + def test_tie_broken_deterministically(self): + """When weights tie, max() returns + deterministically.""" + result = self._weighted_majority([ + ("A", 0.5), ("B", 0.5), + ]) + # max() returns first key encountered with max + assert result in ("A", "B") + + def test_all_same_vote(self): + """All agents agree -> that answer wins.""" + result = self._weighted_majority([ + ("X", 0.9), ("X", 0.8), ("X", 0.7), + ]) + assert result == "X" + + def test_none_predictions_ignored(self): + """None predictions don't count toward any + vote.""" + result = self._weighted_majority([ + ("A", 0.9), (None, 0.8), ("B", 0.7), + ]) + assert result == "A" + + def test_all_none_returns_none(self): + """If all predictions are None, returns None.""" + result = self._weighted_majority([ + (None, 0.9), (None, 0.8), + ]) + assert result is None + + +class TestEnsembleDomainGating: + """Verify domain-based routing: classification + domains use voting, others use single-best.""" + + def test_classification_domains_known(self): + """Classification domains that support + ensemble are a known set.""" + # conftest.py mocks make this import work + from ensemble import _CLASSIFICATION_DOMAINS + assert ( + "search_arena" in _CLASSIFICATION_DOMAINS + ) + assert ( + "paper_review" in _CLASSIFICATION_DOMAINS + ) + assert ( + "imo_grading" in _CLASSIFICATION_DOMAINS + ) + + def test_non_classification_not_in_set(self): + """Non-classification domains are NOT in the + classification set.""" + from ensemble import _CLASSIFICATION_DOMAINS + assert ( + "genesis_go2walking" + not in _CLASSIFICATION_DOMAINS + ) + assert ( + "balrog_babyai" + not in _CLASSIFICATION_DOMAINS + ) + assert ( + "polyglot" + not in _CLASSIFICATION_DOMAINS + ) + + +class TestEnsembleFallback: + """Test fallback behavior when <3 agents + available.""" + + @staticmethod + def _should_use_voting( + domain, can_ensemble, n_agents + ): + """Replicate the gating logic from ensemble() + for testing.""" + classification = { + "search_arena", + "paper_review", + "imo_grading", + } + return ( + domain in classification + and can_ensemble + and n_agents >= 3 + ) + + def test_fewer_than_3_uses_single_best(self): + """With 2 agents, should NOT use voting.""" + assert not self._should_use_voting( + "search_arena", True, 2 + ) + + def test_3_or_more_uses_voting(self): + """With 3+ agents, should use voting.""" + assert self._should_use_voting( + "search_arena", True, 3 + ) + + def test_non_classification_always_single(self): + """Non-classification domain never uses + voting regardless of agent count.""" + assert not self._should_use_voting( + "genesis_go2walking", False, 5 + ) + + def test_can_ensemble_false_blocks_voting(self): + """Even a classification domain with + can_ensemble=False uses single-best.""" + assert not self._should_use_voting( + "imo_grading", False, 3 + ) diff --git a/tests/test_llm_metadata.py b/tests/test_llm_metadata.py new file mode 100644 index 0000000..759f9cd --- /dev/null +++ b/tests/test_llm_metadata.py @@ -0,0 +1,93 @@ +"""Tests for LLM response metadata (F-10). + +Validates that get_response_from_llm() returns an +info dict with expected keys: finish_reason, usage, +model. +""" + +import inspect +from unittest.mock import MagicMock, patch + +import pytest + +# conftest.py mocks backoff, litellm, dotenv, etc. +from agent.llm import get_response_from_llm + + +class TestLlmMetadataKeys: + """F-10: info dict contains expected keys.""" + + def test_return_annotation_is_tuple(self): + """get_response_from_llm returns a 3-tuple + (text, history, info).""" + sig = inspect.signature( + get_response_from_llm + ) + ret = sig.return_annotation + assert "Tuple" in str(ret) + + def test_info_dict_constructed_in_source(self): + """Source constructs info with finish_reason, + usage, model keys.""" + src = inspect.getsource( + get_response_from_llm + ) + assert '"finish_reason"' in src + assert '"usage"' in src + assert '"model"' in src + + def test_info_dict_is_returned(self): + """The function returns (response_text, + new_msg_history, info).""" + src = inspect.getsource( + get_response_from_llm + ) + assert ( + "return response_text, " + "new_msg_history, info" + ) in src + + def test_info_structure_via_mock(self): + """Mock litellm.completion and verify the + returned info dict shape.""" + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = ( + "hello" + ) + mock_response.choices[0].finish_reason = ( + "stop" + ) + mock_response.usage = MagicMock() + mock_response.model = "test-model" + + # Make response subscriptable for the + # response['choices'][0]['message']['content'] + # pattern used in the source. + choice_msg = {"content": "hello"} + choice = {"message": choice_msg} + + def getitem(self, key): + if key == "choices": + return [choice] + return None + + type(mock_response).__getitem__ = getitem + + with patch( + "agent.llm.litellm.completion", + return_value=mock_response, + ): + text, history, info = ( + get_response_from_llm( + msg="test", model="test-model" + ) + ) + + assert isinstance(info, dict) + assert "finish_reason" in info + assert "usage" in info + assert "model" in info + assert info["finish_reason"] == "stop" + assert info["model"] == "test-model" + assert text == "hello" diff --git a/tests/test_meta_agent_instruction.py b/tests/test_meta_agent_instruction.py new file mode 100644 index 0000000..f4c7631 --- /dev/null +++ b/tests/test_meta_agent_instruction.py @@ -0,0 +1,69 @@ +"""Tests for MetaAgent instruction construction (F-04). + +Validates that the instruction string built inside +MetaAgent.forward() contains eval_path, iterations_left, +and is not trivially short. +""" + +import inspect + +import pytest + +# conftest.py mocks backoff, litellm, dotenv, +# thread_logger, etc. +from meta_agent import MetaAgent + + +class TestMetaAgentInstruction: + """F-04: Instruction string is comprehensive.""" + + def test_forward_accepts_eval_path(self): + """forward() signature includes eval_path.""" + sig = inspect.signature(MetaAgent.forward) + params = list(sig.parameters.keys()) + assert "eval_path" in params + + def test_forward_accepts_iterations_left(self): + """forward() signature includes + iterations_left.""" + sig = inspect.signature(MetaAgent.forward) + params = list(sig.parameters.keys()) + assert "iterations_left" in params + + def test_eval_path_appears_in_instruction(self): + """The source of forward() references + eval_path in the instruction string.""" + src = inspect.getsource(MetaAgent.forward) + assert "eval_path" in src + + def test_iterations_left_in_instruction(self): + """When iterations_left is provided, it + appears in the instruction.""" + src = inspect.getsource(MetaAgent.forward) + assert "iterations_left" in src + + def test_instruction_is_substantial(self): + """The instruction is built with multiple + concatenations, not just a few words.""" + src = inspect.getsource(MetaAgent.forward) + plus_eq_count = src.count("instruction +=") + assert plus_eq_count >= 3, ( + f"Expected 3+ instruction +=, got " + f"{plus_eq_count}" + ) + + def test_instruction_mentions_readme(self): + """Instruction tells the agent to read the + README for orientation.""" + src = inspect.getsource(MetaAgent.forward) + assert "README" in src + + def test_instruction_mentions_repo_path(self): + """Instruction references repo_path.""" + src = inspect.getsource(MetaAgent.forward) + assert "repo_path" in src + + def test_forward_calls_chat_with_agent(self): + """forward() delegates to chat_with_agent.""" + src = inspect.getsource(MetaAgent.forward) + assert "chat_with_agent" in src diff --git a/tests/test_metadata_atomic.py b/tests/test_metadata_atomic.py new file mode 100644 index 0000000..59bcc92 --- /dev/null +++ b/tests/test_metadata_atomic.py @@ -0,0 +1,139 @@ +"""Tests for atomic metadata writes (F-11). + +Validates that update_node_metadata() uses the +temp-file + os.replace pattern to avoid corruption. +""" + +import json +import os +import sys + +import pytest + +# conftest.py mocks heavy deps (docker, etc.) +from utils.gl_utils import ( + update_node_metadata, + get_node_metadata_key, +) + + +class TestUpdateNodeMetadataAtomic: + """F-11: metadata writes use temp + rename.""" + + def _make_gen_dir(self, tmp_dir, genid, data): + """Helper: create gen_{genid}/metadata.json.""" + gen_dir = os.path.join( + tmp_dir, f"gen_{genid}" + ) + os.makedirs(gen_dir, exist_ok=True) + meta_path = os.path.join( + gen_dir, "metadata.json" + ) + with open(meta_path, "w") as f: + json.dump(data, f) + return meta_path + + def test_update_writes_correct_data(self, tmp_dir): + """After update, the file contains the merged + data.""" + original = {"parent_genid": None, "score": 0.5} + self._make_gen_dir(tmp_dir, 0, original) + + update_node_metadata( + tmp_dir, 0, {"score": 0.9, "new_key": True} + ) + + meta_path = os.path.join( + tmp_dir, "gen_0", "metadata.json" + ) + with open(meta_path, "r") as f: + result = json.load(f) + + assert result["score"] == 0.9 + assert result["new_key"] is True + assert result["parent_genid"] is None + + def test_no_temp_file_left_behind(self, tmp_dir): + """After a successful write, no .tmp file + remains.""" + self._make_gen_dir( + tmp_dir, 0, {"key": "value"} + ) + update_node_metadata( + tmp_dir, 0, {"key": "updated"} + ) + + gen_dir = os.path.join(tmp_dir, "gen_0") + files = os.listdir(gen_dir) + tmp_files = [f for f in files if f.endswith(".tmp")] + assert len(tmp_files) == 0, ( + f"Temp files remain: {tmp_files}" + ) + + def test_atomic_pattern_in_source(self): + """Source code uses tmp_file + os.replace + pattern.""" + import inspect + src = inspect.getsource(update_node_metadata) + assert "tmp" in src.lower(), ( + "Should use a temp file" + ) + assert "os.replace" in src or "os.rename" in src, ( + "Should use os.replace or os.rename for " + "atomic swap" + ) + assert "f.flush()" in src, ( + "Should flush before fsync" + ) + assert "os.fsync" in src, ( + "Should fsync before replace" + ) + + def test_missing_metadata_is_noop(self, tmp_dir): + """If metadata.json doesn't exist, + update_node_metadata does nothing.""" + # gen_99 does not exist + update_node_metadata( + tmp_dir, 99, {"key": "value"} + ) + gen_dir = os.path.join(tmp_dir, "gen_99") + assert not os.path.exists(gen_dir) + + def test_get_after_update_returns_new_value( + self, tmp_dir + ): + """get_node_metadata_key returns the updated + value after update_node_metadata.""" + self._make_gen_dir( + tmp_dir, 1, {"status": "pending"} + ) + update_node_metadata( + tmp_dir, 1, {"status": "complete"} + ) + val = get_node_metadata_key( + tmp_dir, 1, "status" + ) + assert val == "complete" + + def test_concurrent_safety_no_partial_writes( + self, tmp_dir + ): + """Simulates that the original file stays + intact if we check it before os.replace + would run -- i.e., the tmp file is written + first, then swapped.""" + original = {"step": 1, "data": "original"} + meta_path = self._make_gen_dir( + tmp_dir, 0, original + ) + + # Perform multiple updates sequentially + for i in range(2, 6): + update_node_metadata( + tmp_dir, 0, {"step": i} + ) + + with open(meta_path, "r") as f: + result = json.load(f) + assert result["step"] == 5 + assert result["data"] == "original" diff --git a/tests/test_smoke_test.py b/tests/test_smoke_test.py new file mode 100644 index 0000000..3a56c57 --- /dev/null +++ b/tests/test_smoke_test.py @@ -0,0 +1,101 @@ +"""Tests for run_smoke_test() (F-15). + +Validates that run_smoke_test correctly interprets +container.exec_run results: True on success, +False on non-zero exit code or missing sentinel. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +# conftest.py mocks docker, etc. +from utils.gl_utils import run_smoke_test + + +def _make_mock_container(exit_code, output_text): + """Create a mock Docker container whose exec_run + returns the given exit code and output.""" + container = MagicMock() + exec_result = MagicMock() + exec_result.exit_code = exit_code + exec_result.output = output_text.encode("utf-8") + container.exec_run.return_value = exec_result + return container + + +class TestRunSmokeTest: + """F-15: run_smoke_test container validation.""" + + def _run(self, exit_code, output): + """Helper: run smoke test with mock.""" + container = _make_mock_container( + exit_code, output + ) + with patch( + "utils.gl_utils.log_container_output", + lambda *a, **kw: None, + ): + return run_smoke_test(container) + + def test_success_returns_true(self): + """Exit code 0 + sentinel present -> True.""" + result = self._run( + 0, "some output\nsmoke_test_passed\n" + ) + assert result is True + + def test_nonzero_exit_returns_false(self): + """Non-zero exit code -> False.""" + result = self._run( + 1, "smoke_test_passed\n" + ) + assert result is False + + def test_missing_sentinel_returns_false(self): + """Exit code 0 but no sentinel string -> + False.""" + result = self._run( + 0, "import succeeded\n" + ) + assert result is False + + def test_empty_output_returns_false(self): + """Empty output -> False.""" + result = self._run(0, "") + assert result is False + + def test_exception_returns_false(self): + """If exec_run raises, returns False.""" + container = MagicMock() + container.exec_run.side_effect = ( + RuntimeError("docker error") + ) + with patch( + "utils.gl_utils.log_container_output", + lambda *a, **kw: None, + ): + result = run_smoke_test(container) + assert result is False + + def test_calls_exec_run_with_command(self): + """exec_run is called with a command list + including 'python' and '-c'.""" + container = _make_mock_container( + 0, "smoke_test_passed" + ) + with patch( + "utils.gl_utils.log_container_output", + lambda *a, **kw: None, + ): + run_smoke_test(container) + + call_args = container.exec_run.call_args + cmd = call_args.kwargs.get( + "cmd", + call_args.args[0] + if call_args.args + else None, + ) + assert "python" in cmd + assert "-c" in cmd diff --git a/tests/test_tool_output_format.py b/tests/test_tool_output_format.py new file mode 100644 index 0000000..440b94b --- /dev/null +++ b/tests/test_tool_output_format.py @@ -0,0 +1,158 @@ +"""Tests for tool output JSON serialization (F-03). + +Validates that tool output messages use json.dumps() +for proper serialization, avoiding the old f-string +approach which produced invalid JSON with unescaped +quotes and special characters. +""" + +import json + +import pytest + + +class TestToolOutputJsonSerialization: + """Verify json.dumps produces valid JSON for tool + output messages.""" + + def test_basic_tool_output_is_valid_json(self): + """A simple tool output round-trips through + json.dumps / json.loads.""" + tool_input = { + "command": "ls -la", + "path": "/tmp", + } + tool_msg_data = { + "tool_name": "bash", + "tool_input": tool_input, + "tool_output": "file1.txt\nfile2.txt", + } + serialized = json.dumps(tool_msg_data) + parsed = json.loads(serialized) + assert parsed["tool_name"] == "bash" + assert parsed["tool_input"] == tool_input + assert "file1.txt" in parsed["tool_output"] + + def test_old_fstring_approach_produces_invalid_json( + self, + ): + """Demonstrate the bug: f-string interpolation + of dicts produces repr() output that is NOT + valid JSON (single quotes, unescaped chars).""" + tool_input = { + "command": "echo 'hello'", + "path": "/tmp", + } + tool_output = 'He said "hello"' + + # Old broken approach: f-string with dict + old_msg = ( + f'{{"tool_name": "bash", ' + f'"tool_input": {tool_input}, ' + f'"tool_output": "{tool_output}"}}' + ) + with pytest.raises(json.JSONDecodeError): + json.loads(old_msg) + + def test_special_chars_quotes(self): + """Double quotes in tool_output are properly + escaped by json.dumps.""" + data = { + "tool_name": "bash", + "tool_input": {"command": "echo"}, + "tool_output": 'He said "hello"', + } + serialized = json.dumps(data) + parsed = json.loads(serialized) + assert parsed["tool_output"] == ( + 'He said "hello"' + ) + + def test_special_chars_newlines(self): + """Newlines in tool_output are escaped.""" + data = { + "tool_name": "bash", + "tool_input": {"command": "ls"}, + "tool_output": "line1\nline2\nline3", + } + serialized = json.dumps(data) + # Raw string should contain \\n, not newlines + assert "\\n" in serialized + parsed = json.loads(serialized) + assert parsed["tool_output"].count("\n") == 2 + + def test_special_chars_backslashes(self): + """Backslashes in tool_output are escaped.""" + data = { + "tool_name": "bash", + "tool_input": {"command": "echo"}, + "tool_output": "C:\\Users\\test\\file.txt", + } + serialized = json.dumps(data) + parsed = json.loads(serialized) + assert ( + parsed["tool_output"] + == "C:\\Users\\test\\file.txt" + ) + + def test_special_chars_tabs_and_unicode(self): + """Tabs and unicode in tool_output are handled.""" + data = { + "tool_name": "bash", + "tool_input": {"command": "cat"}, + "tool_output": "col1\tcol2\n\u2603 snowman", + } + serialized = json.dumps(data) + parsed = json.loads(serialized) + assert "\t" in parsed["tool_output"] + assert "\u2603" in parsed["tool_output"] + + def test_nested_json_in_output(self): + """Tool output containing JSON-like strings + serializes correctly.""" + inner = json.dumps({"key": "value"}) + data = { + "tool_name": "bash", + "tool_input": {"command": "curl"}, + "tool_output": inner, + } + serialized = json.dumps(data) + parsed = json.loads(serialized) + # The output is a string, not a dict + assert isinstance(parsed["tool_output"], str) + inner_parsed = json.loads( + parsed["tool_output"] + ) + assert inner_parsed["key"] == "value" + + def test_empty_tool_output(self): + """Empty string tool output serializes.""" + data = { + "tool_name": "bash", + "tool_input": {"command": "true"}, + "tool_output": "", + } + serialized = json.dumps(data) + parsed = json.loads(serialized) + assert parsed["tool_output"] == "" + + def test_actual_format_with_xml_wrapper(self): + """Test the actual format used in + llm_withtools.py: ...json.dumps... + """ + tool_msg_data = { + "tool_name": "bash", + "tool_input": {"command": "ls"}, + "tool_output": "file.txt", + } + tool_msg = ( + f"\n" + f"{json.dumps(tool_msg_data, indent=2)}" + f"\n" + ) + # Extract the JSON between tags + start = tool_msg.index("\n") + 7 + end = tool_msg.index("\n") + extracted = tool_msg[start:end] + parsed = json.loads(extracted) + assert parsed["tool_name"] == "bash"