mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
15 P1 ship-stopper runtime bugs from the ty triage plus the cross-bucket cleanup in run_agent.py. Net: -138 ty diagnostics (1953 -> 1815). Major wins on not-subscriptable (-34), unresolved-attribute (-29), invalid-argument-type (-26), invalid-type-form (-20), unsupported-operator (-18), invalid-key (-9). Missing refs (structural): - tools/rl_training_tool.py: RunState dataclass gains api_log_file, trainer_log_file, env_log_file fields; stop-run was closing undeclared handles. - agent/credential_pool.py: remove_entry(entry_id) added, symmetric with add_entry; used by hermes_cli/web_server.py OAuth dashboard cleanup. - hermes_cli/config.py: _CamofoxConfig TypedDict defined (was referenced by _BrowserConfig but never declared). - hermes_cli/gateway.py: _setup_wecom_callback() added, mirroring _setup_wecom(). - tui_gateway/server.py: skills_hub imports corrected from hermes_cli.skills_hub -> tools.skills_hub. Typo / deprecation: - tools/transcription_tools.py: os.sys.modules -> sys.modules. - gateway/platforms/bluebubbles.py: datetime.utcnow() -> datetime.now(timezone.utc). None-guards: - gateway/platforms/telegram.py:~2798 - msg.sticker None guard. - gateway/platforms/discord.py:3602/3637 - interaction.data None + SelectMenu narrowing; :3009 - thread_id None before `in`; :1893 - guild.member_count None. - gateway/platforms/matrix.py:2174/2185 - walrus-narrow re.search().group(). - agent/display.py:732 - start_time None before elapsed subtraction. - gateway/run.py:10334 - assert _agent_timeout is not None before `// 60`. Platform override signature match: - gateway/platforms/email.py: send_image accepts metadata kwarg; send_document accepts **kwargs (matches base class). run_agent.py annotation pass: - callable/any -> Callable/Any in annotation position (15 sites in run_agent.py + 5 in cli.py, toolset_distributions.py, tools/delegate_tool.py, hermes_cli/dingtalk_auth.py, tui_gateway/server.py). - conversation_history param widened to list[dict[str, Any]] | None. - OMIT_TEMPERATURE sentinel guarded from leaking into call_llm(temperature): kwargs-dict pattern at run_agent.py:7337 + scripts/trajectory_compressor.py:618/688. - build_anthropic_client(timeout) widened to Optional[float]. Tests: - tests/agent/test_credential_pool.py: remove_entry (id match, unknown-id, priority renumbering). - tests/hermes_cli/test_config_shapes.py: _CamofoxConfig shape + nesting. - tests/tools/test_rl_training_tool.py: RunState log_file fields.
172 lines
5.7 KiB
Python
172 lines
5.7 KiB
Python
"""Tests for rl_training_tool.py — file handle lifecycle and cleanup.
|
|
|
|
Verifies that _stop_training_run properly closes log file handles,
|
|
terminates processes, and handles edge cases on failure paths.
|
|
Inspired by PR #715 (0xbyt4).
|
|
"""
|
|
|
|
import dataclasses
|
|
import io
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from tools.rl_training_tool import RunState, _stop_training_run
|
|
|
|
|
|
def _make_run_state(**overrides) -> RunState:
|
|
"""Create a minimal RunState for testing."""
|
|
defaults = {
|
|
"run_id": "test-run-001",
|
|
"environment": "test_env",
|
|
"config": {},
|
|
}
|
|
defaults.update(overrides)
|
|
return RunState(**defaults)
|
|
|
|
|
|
class TestStopTrainingRunFileHandles:
|
|
"""Verify that _stop_training_run closes log file handles stored as attributes."""
|
|
|
|
def test_closes_all_log_file_handles(self):
|
|
state = _make_run_state()
|
|
files = {}
|
|
for attr in ("api_log_file", "trainer_log_file", "env_log_file"):
|
|
fh = MagicMock()
|
|
setattr(state, attr, fh)
|
|
files[attr] = fh
|
|
|
|
_stop_training_run(state)
|
|
|
|
for attr, fh in files.items():
|
|
fh.close.assert_called_once()
|
|
assert getattr(state, attr) is None
|
|
|
|
def test_clears_file_attrs_to_none(self):
|
|
state = _make_run_state()
|
|
state.api_log_file = MagicMock()
|
|
|
|
_stop_training_run(state)
|
|
|
|
assert state.api_log_file is None
|
|
|
|
def test_close_exception_does_not_propagate(self):
|
|
"""If a file handle .close() raises, it must not crash."""
|
|
state = _make_run_state()
|
|
bad_fh = MagicMock()
|
|
bad_fh.close.side_effect = OSError("already closed")
|
|
good_fh = MagicMock()
|
|
state.api_log_file = bad_fh
|
|
state.trainer_log_file = good_fh
|
|
|
|
_stop_training_run(state) # should not raise
|
|
|
|
bad_fh.close.assert_called_once()
|
|
good_fh.close.assert_called_once()
|
|
|
|
def test_handles_missing_file_attrs(self):
|
|
"""RunState without log file attrs should not crash."""
|
|
state = _make_run_state()
|
|
# No log file attrs set at all — getattr(..., None) should handle it
|
|
_stop_training_run(state) # should not raise
|
|
|
|
|
|
class TestStopTrainingRunProcesses:
|
|
"""Verify that _stop_training_run terminates processes correctly."""
|
|
|
|
def test_terminates_running_processes(self):
|
|
state = _make_run_state()
|
|
for attr in ("api_process", "trainer_process", "env_process"):
|
|
proc = MagicMock()
|
|
proc.poll.return_value = None # still running
|
|
setattr(state, attr, proc)
|
|
|
|
_stop_training_run(state)
|
|
|
|
for attr in ("api_process", "trainer_process", "env_process"):
|
|
getattr(state, attr).terminate.assert_called_once()
|
|
|
|
def test_does_not_terminate_exited_processes(self):
|
|
state = _make_run_state()
|
|
proc = MagicMock()
|
|
proc.poll.return_value = 0 # already exited
|
|
state.api_process = proc
|
|
|
|
_stop_training_run(state)
|
|
|
|
proc.terminate.assert_not_called()
|
|
|
|
def test_handles_none_processes(self):
|
|
state = _make_run_state()
|
|
# All process attrs are None by default
|
|
_stop_training_run(state) # should not raise
|
|
|
|
def test_handles_mixed_running_and_exited_processes(self):
|
|
state = _make_run_state()
|
|
# api still running
|
|
api = MagicMock()
|
|
api.poll.return_value = None
|
|
state.api_process = api
|
|
# trainer already exited
|
|
trainer = MagicMock()
|
|
trainer.poll.return_value = 0
|
|
state.trainer_process = trainer
|
|
# env is None
|
|
state.env_process = None
|
|
|
|
_stop_training_run(state)
|
|
|
|
api.terminate.assert_called_once()
|
|
trainer.terminate.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests for RunState log_file fields (added in commit fc00f699)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestRunStateLogFileFields:
|
|
"""Verify api_log_file, trainer_log_file, env_log_file exist with None defaults."""
|
|
|
|
def test_log_file_fields_default_none(self):
|
|
"""All three log_file fields should default to None."""
|
|
state = _make_run_state()
|
|
assert state.api_log_file is None
|
|
assert state.trainer_log_file is None
|
|
assert state.env_log_file is None
|
|
|
|
def test_accepts_file_handle_for_api_log(self):
|
|
"""api_log_file should accept an open file-like object."""
|
|
api_log = io.StringIO()
|
|
state = _make_run_state(api_log_file=api_log)
|
|
assert state.api_log_file is api_log
|
|
|
|
def test_log_file_fields_present_in_dataclass(self):
|
|
"""All three field names must be declared on the RunState dataclass."""
|
|
field_names = {f.name for f in dataclasses.fields(RunState)}
|
|
assert "api_log_file" in field_names
|
|
assert "trainer_log_file" in field_names
|
|
assert "env_log_file" in field_names
|
|
|
|
|
|
class TestStopTrainingRunStatus:
|
|
"""Verify status transitions in _stop_training_run."""
|
|
|
|
def test_sets_status_to_stopped_when_running(self):
|
|
state = _make_run_state(status="running")
|
|
_stop_training_run(state)
|
|
assert state.status == "stopped"
|
|
|
|
def test_does_not_change_status_when_failed(self):
|
|
state = _make_run_state(status="failed")
|
|
_stop_training_run(state)
|
|
assert state.status == "failed"
|
|
|
|
def test_does_not_change_status_when_pending(self):
|
|
state = _make_run_state(status="pending")
|
|
_stop_training_run(state)
|
|
assert state.status == "pending"
|
|
|
|
def test_no_crash_with_no_processes_and_no_files(self):
|
|
state = _make_run_state()
|
|
_stop_training_run(state) # should not raise
|
|
assert state.status == "pending"
|