feat!: migrate to OpenAI native tool calls and fix circular deps (#fuck-gemini)
- Fix circular dependencies in agent/tools - Migrate from custom JSON to OpenAI tool calls format - Add async streaming (step_stream, complete_stream) - Simplify prompt system and remove token counting - Add 5 new API endpoints (/health, /v1/models, /api/memory/*) - Add 3 new tools (get_torrent_by_index, add_torrent_by_index, set_language) - Fix all 500 tests and add coverage config (80% threshold) - Add comprehensive docs (README, pytest guide) BREAKING: LLM interface changed, memory injection via get_memory()
This commit is contained in:
329
tests/test_agent.py
Normal file
329
tests/test_agent.py
Normal file
@@ -0,0 +1,329 @@
|
||||
"""Tests for the Agent."""
|
||||
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from agent.agent import Agent
|
||||
from infrastructure.persistence import get_memory
|
||||
|
||||
|
||||
class TestAgentInit:
|
||||
"""Tests for Agent initialization."""
|
||||
|
||||
def test_init(self, memory, mock_llm):
|
||||
"""Should initialize agent with LLM."""
|
||||
agent = Agent(llm=mock_llm)
|
||||
|
||||
assert agent.llm is mock_llm
|
||||
assert agent.tools is not None
|
||||
assert agent.prompt_builder is not None
|
||||
assert agent.max_tool_iterations == 5
|
||||
|
||||
def test_init_custom_iterations(self, memory, mock_llm):
|
||||
"""Should accept custom max iterations."""
|
||||
agent = Agent(llm=mock_llm, max_tool_iterations=10)
|
||||
|
||||
assert agent.max_tool_iterations == 10
|
||||
|
||||
def test_tools_registered(self, memory, mock_llm):
|
||||
"""Should register all tools."""
|
||||
agent = Agent(llm=mock_llm)
|
||||
|
||||
expected_tools = [
|
||||
"set_path_for_folder",
|
||||
"list_folder",
|
||||
"find_media_imdb_id",
|
||||
"find_torrents",
|
||||
"add_torrent_by_index",
|
||||
"add_torrent_to_qbittorrent",
|
||||
"get_torrent_by_index",
|
||||
]
|
||||
|
||||
for tool_name in expected_tools:
|
||||
assert tool_name in agent.tools
|
||||
|
||||
|
||||
class TestParseIntent:
|
||||
"""Tests for _parse_intent method."""
|
||||
|
||||
def test_parse_valid_json(self, memory, mock_llm):
|
||||
"""Should parse valid tool call JSON."""
|
||||
agent = Agent(llm=mock_llm)
|
||||
|
||||
text = '{"thought": "test", "action": {"name": "find_torrents", "args": {"media_title": "Inception"}}}'
|
||||
intent = agent._parse_intent(text)
|
||||
|
||||
assert intent is not None
|
||||
assert intent["action"]["name"] == "find_torrents"
|
||||
assert intent["action"]["args"]["media_title"] == "Inception"
|
||||
|
||||
def test_parse_json_with_surrounding_text(self, memory, mock_llm):
|
||||
"""Should extract JSON from surrounding text."""
|
||||
agent = Agent(llm=mock_llm)
|
||||
|
||||
text = 'Let me search for that. {"thought": "searching", "action": {"name": "find_torrents", "args": {}}} Done.'
|
||||
intent = agent._parse_intent(text)
|
||||
|
||||
assert intent is not None
|
||||
assert intent["action"]["name"] == "find_torrents"
|
||||
|
||||
def test_parse_plain_text(self, memory, mock_llm):
|
||||
"""Should return None for plain text."""
|
||||
agent = Agent(llm=mock_llm)
|
||||
|
||||
text = "I found 3 torrents for Inception!"
|
||||
intent = agent._parse_intent(text)
|
||||
|
||||
assert intent is None
|
||||
|
||||
def test_parse_invalid_json(self, memory, mock_llm):
|
||||
"""Should return None for invalid JSON."""
|
||||
agent = Agent(llm=mock_llm)
|
||||
|
||||
text = '{"thought": "test", "action": {invalid}}'
|
||||
intent = agent._parse_intent(text)
|
||||
|
||||
assert intent is None
|
||||
|
||||
def test_parse_json_without_action(self, memory, mock_llm):
|
||||
"""Should return None for JSON without action."""
|
||||
agent = Agent(llm=mock_llm)
|
||||
|
||||
text = '{"thought": "test", "result": "something"}'
|
||||
intent = agent._parse_intent(text)
|
||||
|
||||
assert intent is None
|
||||
|
||||
def test_parse_json_with_invalid_action(self, memory, mock_llm):
|
||||
"""Should return None for invalid action structure."""
|
||||
agent = Agent(llm=mock_llm)
|
||||
|
||||
text = '{"thought": "test", "action": "not_an_object"}'
|
||||
intent = agent._parse_intent(text)
|
||||
|
||||
assert intent is None
|
||||
|
||||
def test_parse_json_without_action_name(self, memory, mock_llm):
|
||||
"""Should return None if action has no name."""
|
||||
agent = Agent(llm=mock_llm)
|
||||
|
||||
text = '{"thought": "test", "action": {"args": {}}}'
|
||||
intent = agent._parse_intent(text)
|
||||
|
||||
assert intent is None
|
||||
|
||||
def test_parse_whitespace(self, memory, mock_llm):
|
||||
"""Should handle whitespace around JSON."""
|
||||
agent = Agent(llm=mock_llm)
|
||||
|
||||
text = (
|
||||
' \n {"thought": "test", "action": {"name": "test", "args": {}}} \n '
|
||||
)
|
||||
intent = agent._parse_intent(text)
|
||||
|
||||
assert intent is not None
|
||||
|
||||
|
||||
class TestExecuteAction:
|
||||
"""Tests for _execute_action method."""
|
||||
|
||||
def test_execute_known_tool(self, memory, mock_llm, real_folder):
|
||||
"""Should execute known tool."""
|
||||
agent = Agent(llm=mock_llm)
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
|
||||
intent = {
|
||||
"action": {"name": "list_folder", "args": {"folder_type": "download"}}
|
||||
}
|
||||
result = agent._execute_action(intent)
|
||||
|
||||
assert result["status"] == "ok"
|
||||
|
||||
def test_execute_unknown_tool(self, memory, mock_llm):
|
||||
"""Should return error for unknown tool."""
|
||||
agent = Agent(llm=mock_llm)
|
||||
|
||||
intent = {"action": {"name": "unknown_tool", "args": {}}}
|
||||
result = agent._execute_action(intent)
|
||||
|
||||
assert result["error"] == "unknown_tool"
|
||||
assert "available_tools" in result
|
||||
|
||||
def test_execute_with_bad_args(self, memory, mock_llm):
|
||||
"""Should return error for bad arguments."""
|
||||
agent = Agent(llm=mock_llm)
|
||||
|
||||
# Missing required argument
|
||||
intent = {"action": {"name": "set_path_for_folder", "args": {}}}
|
||||
result = agent._execute_action(intent)
|
||||
|
||||
assert result["error"] == "bad_args"
|
||||
|
||||
def test_execute_tracks_errors(self, memory, mock_llm):
|
||||
"""Should track errors in episodic memory."""
|
||||
agent = Agent(llm=mock_llm)
|
||||
|
||||
intent = {
|
||||
"action": {"name": "list_folder", "args": {"folder_type": "download"}}
|
||||
}
|
||||
result = agent._execute_action(intent) # Will fail - folder not configured
|
||||
|
||||
mem = get_memory()
|
||||
assert len(mem.episodic.recent_errors) > 0
|
||||
|
||||
def test_execute_with_none_args(self, memory, mock_llm, real_folder):
|
||||
"""Should handle None args."""
|
||||
agent = Agent(llm=mock_llm)
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
|
||||
intent = {"action": {"name": "list_folder", "args": None}}
|
||||
result = agent._execute_action(intent)
|
||||
|
||||
# Should fail gracefully with bad_args, not crash
|
||||
assert "error" in result
|
||||
|
||||
|
||||
class TestStep:
|
||||
"""Tests for step method."""
|
||||
|
||||
def test_step_text_response(self, memory, mock_llm):
|
||||
"""Should return text response when no tool call."""
|
||||
mock_llm.complete.return_value = "Hello! How can I help you?"
|
||||
agent = Agent(llm=mock_llm)
|
||||
|
||||
response = agent.step("Hello")
|
||||
|
||||
assert response == "Hello! How can I help you?"
|
||||
|
||||
def test_step_saves_to_history(self, memory, mock_llm):
|
||||
"""Should save conversation to STM history."""
|
||||
mock_llm.complete.return_value = "Hello!"
|
||||
agent = Agent(llm=mock_llm)
|
||||
|
||||
agent.step("Hi there")
|
||||
|
||||
mem = get_memory()
|
||||
history = mem.stm.get_recent_history(10)
|
||||
assert len(history) == 2
|
||||
assert history[0]["role"] == "user"
|
||||
assert history[0]["content"] == "Hi there"
|
||||
assert history[1]["role"] == "assistant"
|
||||
|
||||
def test_step_with_tool_call(self, memory, mock_llm, real_folder):
|
||||
"""Should execute tool and continue."""
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
|
||||
mock_llm.complete.side_effect = [
|
||||
'{"thought": "listing", "action": {"name": "list_folder", "args": {"folder_type": "download"}}}',
|
||||
"I found 2 items in your download folder.",
|
||||
]
|
||||
agent = Agent(llm=mock_llm)
|
||||
|
||||
response = agent.step("List my downloads")
|
||||
|
||||
assert "2 items" in response or "found" in response.lower()
|
||||
assert mock_llm.complete.call_count == 2
|
||||
|
||||
def test_step_max_iterations(self, memory, mock_llm):
|
||||
"""Should stop after max iterations."""
|
||||
# Always return tool call
|
||||
mock_llm.complete.return_value = '{"thought": "loop", "action": {"name": "list_folder", "args": {"folder_type": "download"}}}'
|
||||
agent = Agent(llm=mock_llm, max_tool_iterations=3)
|
||||
|
||||
# Mock the final response after max iterations
|
||||
def side_effect(messages):
|
||||
if "final response" in str(messages[-1].get("content", "")).lower():
|
||||
return "I couldn't complete the task."
|
||||
return '{"thought": "loop", "action": {"name": "list_folder", "args": {"folder_type": "download"}}}'
|
||||
|
||||
mock_llm.complete.side_effect = side_effect
|
||||
|
||||
response = agent.step("Do something")
|
||||
|
||||
# Should have called LLM max_iterations + 1 times (for final response)
|
||||
assert mock_llm.complete.call_count == 4
|
||||
|
||||
def test_step_includes_history(self, memory_with_history, mock_llm):
|
||||
"""Should include conversation history in prompt."""
|
||||
mock_llm.complete.return_value = "Response"
|
||||
agent = Agent(llm=mock_llm)
|
||||
|
||||
agent.step("New message")
|
||||
|
||||
# Check that history was included in the call
|
||||
call_args = mock_llm.complete.call_args[0][0]
|
||||
messages_content = [m.get("content", "") for m in call_args]
|
||||
assert any("Hello" in c for c in messages_content)
|
||||
|
||||
def test_step_includes_events(self, memory, mock_llm):
|
||||
"""Should include unread events in prompt."""
|
||||
memory.episodic.add_background_event("download_complete", {"name": "Movie.mkv"})
|
||||
mock_llm.complete.return_value = "Response"
|
||||
agent = Agent(llm=mock_llm)
|
||||
|
||||
agent.step("What's new?")
|
||||
|
||||
call_args = mock_llm.complete.call_args[0][0]
|
||||
messages_content = [m.get("content", "") for m in call_args]
|
||||
assert any("download" in c.lower() for c in messages_content)
|
||||
|
||||
def test_step_saves_ltm(self, memory, mock_llm, temp_dir):
|
||||
"""Should save LTM after step."""
|
||||
mock_llm.complete.return_value = "Response"
|
||||
agent = Agent(llm=mock_llm)
|
||||
|
||||
agent.step("Hello")
|
||||
|
||||
# Check that LTM file was written
|
||||
ltm_file = temp_dir / "ltm.json"
|
||||
assert ltm_file.exists()
|
||||
|
||||
|
||||
class TestAgentIntegration:
|
||||
"""Integration tests for Agent."""
|
||||
|
||||
@patch("agent.tools.api.SearchTorrentsUseCase")
|
||||
def test_search_and_select_workflow(self, mock_use_case_class, memory, mock_llm):
|
||||
"""Should handle search and select workflow."""
|
||||
# Mock torrent search
|
||||
mock_response = Mock()
|
||||
mock_response.to_dict.return_value = {
|
||||
"status": "ok",
|
||||
"torrents": [
|
||||
{"name": "Inception.1080p", "seeders": 100, "magnet": "magnet:?xt=..."},
|
||||
],
|
||||
"count": 1,
|
||||
}
|
||||
mock_use_case = Mock()
|
||||
mock_use_case.execute.return_value = mock_response
|
||||
mock_use_case_class.return_value = mock_use_case
|
||||
|
||||
# First call: tool call, second call: response
|
||||
mock_llm.complete.side_effect = [
|
||||
'{"thought": "searching", "action": {"name": "find_torrents", "args": {"media_title": "Inception"}}}',
|
||||
"I found 1 torrent for Inception!",
|
||||
]
|
||||
|
||||
agent = Agent(llm=mock_llm)
|
||||
response = agent.step("Find Inception")
|
||||
|
||||
assert "found" in response.lower() or "torrent" in response.lower()
|
||||
|
||||
# Check that results are in episodic memory
|
||||
mem = get_memory()
|
||||
assert mem.episodic.last_search_results is not None
|
||||
|
||||
def test_multiple_tool_calls(self, memory, mock_llm, real_folder):
|
||||
"""Should handle multiple tool calls in sequence."""
|
||||
memory.ltm.set_config("download_folder", str(real_folder["downloads"]))
|
||||
memory.ltm.set_config("movie_folder", str(real_folder["movies"]))
|
||||
|
||||
mock_llm.complete.side_effect = [
|
||||
'{"thought": "list downloads", "action": {"name": "list_folder", "args": {"folder_type": "download"}}}',
|
||||
'{"thought": "list movies", "action": {"name": "list_folder", "args": {"folder_type": "movie"}}}',
|
||||
"I listed both folders for you.",
|
||||
]
|
||||
|
||||
agent = Agent(llm=mock_llm)
|
||||
response = agent.step("List my downloads and movies")
|
||||
|
||||
assert mock_llm.complete.call_count == 3
|
||||
Reference in New Issue
Block a user