test_executor.py 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  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. assert not any(e["type"] == "tool_call" for e in emitted)
  50. def test_malformed_arguments_are_rejected():
  51. call = ToolCall(id="1", name="get_datetime", arguments="{not json")
  52. executor, _ = _executor(
  53. [LlmResponse(content="", tool_calls=[call], finish_reason="tool_calls")]
  54. )
  55. emitted, emit = _collector()
  56. result = executor.run("do it", emit)
  57. assert result.ok is False
  58. assert any(e["type"] == "error" and e["code"] == "invalid_tool_call" for e in emitted)
  59. assert not any(e["type"] == "tool_call" for e in emitted)
  60. def test_tool_loop_is_capped():
  61. call = ToolCall(id="1", name="get_datetime", arguments="{}")
  62. responses = [
  63. LlmResponse(content="", tool_calls=[call], finish_reason="tool_calls")
  64. for _ in range(10)
  65. ]
  66. executor, client = _executor(responses, cfg=LlmConfig(max_tool_iterations=3))
  67. emitted, emit = _collector()
  68. result = executor.run("keep going", emit)
  69. assert result.ok is False
  70. assert any(e["type"] == "error" and e["code"] == "tool_loop" for e in emitted)
  71. assert len(client.calls) == 3
  72. def test_truncated_empty_answer_is_an_error():
  73. executor, _ = _executor([LlmResponse(content="", finish_reason="length")])
  74. emitted, emit = _collector()
  75. result = executor.run("do it", emit)
  76. assert result.ok is False
  77. assert result.result == "response truncated"
  78. assert any(e["type"] == "error" and e["code"] == "truncated" for e in emitted)
  79. def test_truncated_partial_answer_reports_partial():
  80. executor, _ = _executor([LlmResponse(content="par", finish_reason="length")])
  81. emitted, emit = _collector()
  82. result = executor.run("do it", emit)
  83. assert result.ok is False
  84. assert result.result == "par"
  85. assert any(e["type"] == "error" and e["code"] == "truncated" for e in emitted)
  86. class RecordingClient:
  87. """Fake that accepts (and records) every extra keyword passed to `chat`."""
  88. def __init__(self, responses):
  89. self.responses = list(responses)
  90. self.calls: list[dict] = []
  91. def chat(self, messages, tools=None, **kwargs):
  92. self.calls.append({"messages": messages, "tools": tools, **kwargs})
  93. return self.responses.pop(0)
  94. def test_executor_forwards_configured_slot():
  95. cfg = LlmConfig(max_tool_iterations=3)
  96. client = RecordingClient([LlmResponse(content="answer")])
  97. executor = LlmExecutor(cfg, client, default_registry(), slot_id=4)
  98. result = executor.run("hello", lambda *a, **k: None)
  99. assert result.ok is True
  100. assert result.result == "answer"
  101. assert client.calls[0]["slot_id"] == 4
  102. def test_executor_omits_slot_when_none_for_old_fake():
  103. """Misleading-success probe: an old fake with no `slot_id` parameter is called
  104. fine, proving the kwarg is omitted (passing it would raise TypeError)."""
  105. executor, client = _executor([LlmResponse(content="answer")])
  106. result = executor.run("hello", lambda *a, **k: None)
  107. assert result.ok is True
  108. assert result.result == "answer"
  109. assert len(client.calls) == 1