| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113 |
- """Tests for the LLM executor's tool-calling loop."""
- from stt_daemon.assistant.executor import LlmExecutor
- from stt_daemon.assistant.llm import LlmResponse, ToolCall
- from stt_daemon.assistant.tools import default_registry
- from stt_daemon.config import LlmConfig
- class FakeClient:
- def __init__(self, responses):
- self.responses = list(responses)
- self.calls: list[dict] = []
- def chat(self, messages, tools=None):
- self.calls.append({"messages": messages, "tools": tools})
- return self.responses.pop(0)
- def _executor(responses, cfg=None):
- cfg = cfg or LlmConfig(max_tool_iterations=3)
- client = FakeClient(responses)
- return LlmExecutor(cfg, client, default_registry()), client
- def _collector():
- emitted: list[dict] = []
- return emitted, (lambda type_, **fields: emitted.append({"type": type_, **fields}))
- def test_tool_call_then_final_answer():
- call = ToolCall(id="1", name="get_datetime", arguments="{}")
- executor, client = _executor(
- [
- LlmResponse(content="", tool_calls=[call], finish_reason="tool_calls"),
- LlmResponse(content="It is 12:00."),
- ]
- )
- emitted, emit = _collector()
- result = executor.run("what time is it", emit)
- assert result.ok is True
- assert result.tool == "get_datetime"
- assert result.result == "It is 12:00."
- assert any(e["type"] == "tool_call" and e["tool"] == "get_datetime" for e in emitted)
- assert client.calls[1]["messages"][-1]["role"] == "tool"
- def test_no_tool_call_returns_answer():
- executor, _ = _executor([LlmResponse(content="Just an answer.")])
- result = executor.run("hello", lambda *a, **k: None)
- assert result.ok is True
- assert result.result == "Just an answer."
- def test_unknown_tool_is_rejected():
- call = ToolCall(id="1", name="nope", arguments="{}")
- executor, _ = _executor(
- [LlmResponse(content="", tool_calls=[call], finish_reason="tool_calls")]
- )
- emitted, emit = _collector()
- result = executor.run("do it", emit)
- assert result.ok is False
- assert any(e["type"] == "error" and e["code"] == "invalid_tool_call" for e in emitted)
- assert not any(e["type"] == "tool_call" for e in emitted)
- def test_malformed_arguments_are_rejected():
- call = ToolCall(id="1", name="get_datetime", arguments="{not json")
- executor, _ = _executor(
- [LlmResponse(content="", tool_calls=[call], finish_reason="tool_calls")]
- )
- emitted, emit = _collector()
- result = executor.run("do it", emit)
- assert result.ok is False
- assert any(e["type"] == "error" and e["code"] == "invalid_tool_call" for e in emitted)
- assert not any(e["type"] == "tool_call" for e in emitted)
- def test_tool_loop_is_capped():
- call = ToolCall(id="1", name="get_datetime", arguments="{}")
- responses = [
- LlmResponse(content="", tool_calls=[call], finish_reason="tool_calls")
- for _ in range(10)
- ]
- executor, client = _executor(responses, cfg=LlmConfig(max_tool_iterations=3))
- emitted, emit = _collector()
- result = executor.run("keep going", emit)
- assert result.ok is False
- assert any(e["type"] == "error" and e["code"] == "tool_loop" for e in emitted)
- assert len(client.calls) == 3
- def test_truncated_empty_answer_is_an_error():
- executor, _ = _executor([LlmResponse(content="", finish_reason="length")])
- emitted, emit = _collector()
- result = executor.run("do it", emit)
- assert result.ok is False
- assert result.result == "response truncated"
- assert any(e["type"] == "error" and e["code"] == "truncated" for e in emitted)
- def test_truncated_partial_answer_reports_partial():
- executor, _ = _executor([LlmResponse(content="par", finish_reason="length")])
- emitted, emit = _collector()
- result = executor.run("do it", emit)
- assert result.ok is False
- assert result.result == "par"
- assert any(e["type"] == "error" and e["code"] == "truncated" for e in emitted)
|