test_executor.py 3.1 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
  1. """Tests for the LLM executor's tool-calling loop."""
  2. from stt_daemon.assistant.executor import LlmExecutor
  3. from stt_daemon.assistant.llm import LlmResponse, ToolCall
  4. from stt_daemon.assistant.tools import default_registry
  5. from stt_daemon.config import LlmConfig
  6. class FakeClient:
  7. def __init__(self, responses):
  8. self.responses = list(responses)
  9. self.calls: list[dict] = []
  10. def chat(self, messages, tools=None):
  11. self.calls.append({"messages": messages, "tools": tools})
  12. return self.responses.pop(0)
  13. def _executor(responses, cfg=None):
  14. cfg = cfg or LlmConfig(max_tool_iterations=3)
  15. client = FakeClient(responses)
  16. return LlmExecutor(cfg, client, default_registry()), client
  17. def _collector():
  18. emitted: list[dict] = []
  19. return emitted, (lambda type_, **fields: emitted.append({"type": type_, **fields}))
  20. def test_tool_call_then_final_answer():
  21. call = ToolCall(id="1", name="get_datetime", arguments="{}")
  22. executor, client = _executor(
  23. [
  24. LlmResponse(content="", tool_calls=[call], finish_reason="tool_calls"),
  25. LlmResponse(content="It is 12:00."),
  26. ]
  27. )
  28. emitted, emit = _collector()
  29. result = executor.run("what time is it", emit)
  30. assert result.ok is True
  31. assert result.tool == "get_datetime"
  32. assert result.result == "It is 12:00."
  33. assert any(e["type"] == "tool_call" and e["tool"] == "get_datetime" for e in emitted)
  34. assert client.calls[1]["messages"][-1]["role"] == "tool"
  35. def test_no_tool_call_returns_answer():
  36. executor, _ = _executor([LlmResponse(content="Just an answer.")])
  37. result = executor.run("hello", lambda *a, **k: None)
  38. assert result.ok is True
  39. assert result.result == "Just an answer."
  40. def test_unknown_tool_is_rejected():
  41. call = ToolCall(id="1", name="nope", arguments="{}")
  42. executor, _ = _executor(
  43. [LlmResponse(content="", tool_calls=[call], finish_reason="tool_calls")]
  44. )
  45. emitted, emit = _collector()
  46. result = executor.run("do it", emit)
  47. assert result.ok is False
  48. assert any(e["type"] == "error" and e["code"] == "invalid_tool_call" for e in emitted)
  49. def test_malformed_arguments_are_rejected():
  50. call = ToolCall(id="1", name="get_datetime", arguments="{not json")
  51. executor, _ = _executor(
  52. [LlmResponse(content="", tool_calls=[call], finish_reason="tool_calls")]
  53. )
  54. emitted, emit = _collector()
  55. result = executor.run("do it", emit)
  56. assert result.ok is False
  57. assert any(e["type"] == "error" and e["code"] == "invalid_tool_call" for e in emitted)
  58. def test_tool_loop_is_capped():
  59. call = ToolCall(id="1", name="get_datetime", arguments="{}")
  60. responses = [
  61. LlmResponse(content="", tool_calls=[call], finish_reason="tool_calls")
  62. for _ in range(10)
  63. ]
  64. executor, client = _executor(responses, cfg=LlmConfig(max_tool_iterations=3))
  65. emitted, emit = _collector()
  66. result = executor.run("keep going", emit)
  67. assert result.ok is False
  68. assert any(e["type"] == "error" and e["code"] == "tool_loop" for e in emitted)
  69. assert len(client.calls) == 3