|
|
@@ -425,6 +425,63 @@ def test_chat_message_allows_internal_tool_replies():
|
|
|
assert message.tool_call_id == "call_1"
|
|
|
|
|
|
|
|
|
+def test_chat_message_preserves_assistant_tool_calls():
|
|
|
+ message = ChatMessage(
|
|
|
+ role="assistant",
|
|
|
+ content="I will inspect both sources.",
|
|
|
+ tool_calls=[
|
|
|
+ ToolCallEvent(
|
|
|
+ id="call_1",
|
|
|
+ name="mock_search",
|
|
|
+ arguments={"query": "latency docs"},
|
|
|
+ raw_arguments='{"query":"latency docs"}',
|
|
|
+ ),
|
|
|
+ ToolCallEvent(
|
|
|
+ id="call_2",
|
|
|
+ name="handoff_note",
|
|
|
+ arguments={"message": "inspect provider behavior"},
|
|
|
+ raw_arguments='{"message":"inspect provider behavior"}',
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ )
|
|
|
+
|
|
|
+ assert message.model_dump()["tool_calls"] == [
|
|
|
+ {
|
|
|
+ "id": "call_1",
|
|
|
+ "name": "mock_search",
|
|
|
+ "arguments": {"query": "latency docs"},
|
|
|
+ "raw_arguments": '{"query":"latency docs"}',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "id": "call_2",
|
|
|
+ "name": "handoff_note",
|
|
|
+ "arguments": {"message": "inspect provider behavior"},
|
|
|
+ "raw_arguments": '{"message":"inspect provider behavior"}',
|
|
|
+ },
|
|
|
+ ]
|
|
|
+
|
|
|
+
|
|
|
+def test_chat_message_rejects_tool_calls_on_non_assistant_role():
|
|
|
+ with pytest.raises(ValidationError, match="tool_calls require assistant role"):
|
|
|
+ ChatMessage(
|
|
|
+ role="user",
|
|
|
+ content="invalid",
|
|
|
+ tool_calls=[
|
|
|
+ ToolCallEvent(
|
|
|
+ id="call_1",
|
|
|
+ name="mock_search",
|
|
|
+ arguments={},
|
|
|
+ raw_arguments="{}",
|
|
|
+ )
|
|
|
+ ],
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def test_chat_message_rejects_tool_role_without_tool_call_id():
|
|
|
+ with pytest.raises(ValidationError, match="tool messages require tool_call_id"):
|
|
|
+ ChatMessage(role="tool", content="orphan result")
|
|
|
+
|
|
|
+
|
|
|
class HistoryCapturingChatClient:
|
|
|
def __init__(self) -> None:
|
|
|
self.calls = 0
|
|
|
@@ -497,10 +554,7 @@ async def test_openai_chat_client_streams_sse_chunks_through_parser():
|
|
|
assert payload["model"] == "model-x"
|
|
|
assert payload["messages"] == [{"role": "user", "content": "hi"}]
|
|
|
assert payload["tools"][0]["function"]["name"] == "handoff_note"
|
|
|
- assert payload["tool_choice"] == {
|
|
|
- "type": "function",
|
|
|
- "function": {"name": "handoff_note"},
|
|
|
- }
|
|
|
+ assert "tool_choice" not in payload
|
|
|
assert payload["temperature"] == 0.3
|
|
|
assert payload["max_tokens"] == 50
|
|
|
assert request.headers["authorization"] == "Bearer test-key"
|
|
|
@@ -544,6 +598,47 @@ async def test_openai_chat_client_streams_sse_chunks_through_parser():
|
|
|
]
|
|
|
|
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_openai_chat_client_serializes_explicit_tool_choice():
|
|
|
+ captured_payloads: list[dict] = []
|
|
|
+ forced_choice = {
|
|
|
+ "type": "function",
|
|
|
+ "function": {"name": "handoff_note"},
|
|
|
+ }
|
|
|
+
|
|
|
+ def handler(request: httpx.Request) -> httpx.Response:
|
|
|
+ captured_payloads.append(json.loads(request.content))
|
|
|
+ return httpx.Response(200, content=b"data: [DONE]\n\n")
|
|
|
+
|
|
|
+ async with httpx.AsyncClient(
|
|
|
+ transport=httpx.MockTransport(handler),
|
|
|
+ base_url="https://llm.test/v1",
|
|
|
+ ) as http_client:
|
|
|
+ client = OpenAICompatibleChatClient(
|
|
|
+ api_key="",
|
|
|
+ base_url="https://llm.test/v1",
|
|
|
+ default_model="provider-default",
|
|
|
+ request_timeout_seconds=5,
|
|
|
+ http_client=http_client,
|
|
|
+ )
|
|
|
+ [
|
|
|
+ item
|
|
|
+ async for item in client.stream_chat(
|
|
|
+ messages=[ChatMessage(role="user", content="prepare handoff")],
|
|
|
+ tools=[
|
|
|
+ {
|
|
|
+ "type": "function",
|
|
|
+ "function": {"name": "handoff_note", "parameters": {}},
|
|
|
+ }
|
|
|
+ ],
|
|
|
+ params=AgentParams(model="provider-default"),
|
|
|
+ tool_choice=forced_choice,
|
|
|
+ )
|
|
|
+ ]
|
|
|
+
|
|
|
+ assert captured_payloads[0]["tool_choice"] == forced_choice
|
|
|
+
|
|
|
+
|
|
|
@pytest.mark.asyncio
|
|
|
async def test_openai_chat_client_flushes_one_provider_tool_call_at_done():
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
|
@@ -633,17 +728,122 @@ async def test_openai_chat_client_uses_default_model_when_request_model_is_blank
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
-async def test_openai_chat_client_rejects_internal_tool_messages_before_network():
|
|
|
- network_called = False
|
|
|
+async def test_openai_chat_client_serializes_provider_tool_transcript():
|
|
|
+ captured_payloads: list[dict] = []
|
|
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
|
- nonlocal network_called
|
|
|
- network_called = True
|
|
|
+ captured_payloads.append(json.loads(request.content))
|
|
|
return httpx.Response(
|
|
|
200,
|
|
|
content=b'data: {"choices":[{"delta":{"content":"done"},"finish_reason":null}]}\n\n',
|
|
|
)
|
|
|
|
|
|
+ async with httpx.AsyncClient(
|
|
|
+ transport=httpx.MockTransport(handler),
|
|
|
+ base_url="https://llm.test/v1",
|
|
|
+ ) as http_client:
|
|
|
+ client = OpenAICompatibleChatClient(
|
|
|
+ api_key="",
|
|
|
+ base_url="https://llm.test/v1",
|
|
|
+ default_model="provider-default",
|
|
|
+ request_timeout_seconds=5,
|
|
|
+ http_client=http_client,
|
|
|
+ )
|
|
|
+ [
|
|
|
+ item
|
|
|
+ async for item in client.stream_chat(
|
|
|
+ messages=[
|
|
|
+ ChatMessage(role="user", content="inspect both"),
|
|
|
+ ChatMessage(
|
|
|
+ role="assistant",
|
|
|
+ content="I will inspect both sources.",
|
|
|
+ tool_calls=[
|
|
|
+ ToolCallEvent(
|
|
|
+ id="call_1",
|
|
|
+ name="mock_search",
|
|
|
+ arguments={"query": "latency docs"},
|
|
|
+ raw_arguments='{"query":"latency docs"}',
|
|
|
+ ),
|
|
|
+ ToolCallEvent(
|
|
|
+ id="call_2",
|
|
|
+ name="handoff_note",
|
|
|
+ arguments={"message": "inspect provider behavior"},
|
|
|
+ raw_arguments='{"message":"inspect provider behavior"}',
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ChatMessage(
|
|
|
+ role="tool",
|
|
|
+ content='{"results":[]}',
|
|
|
+ name="mock_search",
|
|
|
+ tool_call_id="call_1",
|
|
|
+ ),
|
|
|
+ ChatMessage(
|
|
|
+ role="tool",
|
|
|
+ content='{"message":"handled"}',
|
|
|
+ name="handoff_note",
|
|
|
+ tool_call_id="call_2",
|
|
|
+ ),
|
|
|
+ ChatMessage(role="user", content="continue"),
|
|
|
+ ],
|
|
|
+ tools=[],
|
|
|
+ params=AgentParams(
|
|
|
+ model="provider-default",
|
|
|
+ temperature=0.3,
|
|
|
+ max_tokens=50,
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ ]
|
|
|
+
|
|
|
+ assert captured_payloads[0]["messages"] == [
|
|
|
+ {"role": "user", "content": "inspect both"},
|
|
|
+ {
|
|
|
+ "role": "assistant",
|
|
|
+ "content": "I will inspect both sources.",
|
|
|
+ "tool_calls": [
|
|
|
+ {
|
|
|
+ "id": "call_1",
|
|
|
+ "type": "function",
|
|
|
+ "function": {
|
|
|
+ "name": "mock_search",
|
|
|
+ "arguments": '{"query":"latency docs"}',
|
|
|
+ },
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "id": "call_2",
|
|
|
+ "type": "function",
|
|
|
+ "function": {
|
|
|
+ "name": "handoff_note",
|
|
|
+ "arguments": '{"message":"inspect provider behavior"}',
|
|
|
+ },
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "role": "tool",
|
|
|
+ "content": '{"results":[]}',
|
|
|
+ "tool_call_id": "call_1",
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "role": "tool",
|
|
|
+ "content": '{"message":"handled"}',
|
|
|
+ "tool_call_id": "call_2",
|
|
|
+ },
|
|
|
+ {"role": "user", "content": "continue"},
|
|
|
+ ]
|
|
|
+
|
|
|
+
|
|
|
+async def _assert_tool_transcript_rejected(
|
|
|
+ messages: list[ChatMessage],
|
|
|
+ error_match: str,
|
|
|
+) -> None:
|
|
|
+ network_called = False
|
|
|
+
|
|
|
+ def handler(request: httpx.Request) -> httpx.Response:
|
|
|
+ nonlocal network_called
|
|
|
+ network_called = True
|
|
|
+ return httpx.Response(200)
|
|
|
+
|
|
|
async with httpx.AsyncClient(
|
|
|
transport=httpx.MockTransport(handler),
|
|
|
base_url="https://llm.test/v1",
|
|
|
@@ -656,25 +856,128 @@ async def test_openai_chat_client_rejects_internal_tool_messages_before_network(
|
|
|
http_client=http_client,
|
|
|
)
|
|
|
|
|
|
- with pytest.raises(ValueError, match="tool messages are internal"):
|
|
|
+ with pytest.raises(ValueError, match=error_match):
|
|
|
[
|
|
|
item
|
|
|
async for item in client.stream_chat(
|
|
|
- messages=[
|
|
|
- ChatMessage(
|
|
|
- role="tool",
|
|
|
- content="internal tool result",
|
|
|
- tool_call_id="call_1",
|
|
|
- )
|
|
|
- ],
|
|
|
+ messages=messages,
|
|
|
tools=[],
|
|
|
- params=AgentParams(model="provider-default", temperature=0.3, max_tokens=50),
|
|
|
+ params=AgentParams(model="provider-default"),
|
|
|
)
|
|
|
]
|
|
|
|
|
|
assert network_called is False
|
|
|
|
|
|
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_openai_chat_client_rejects_orphan_tool_reply_before_network():
|
|
|
+ await _assert_tool_transcript_rejected(
|
|
|
+ [
|
|
|
+ ChatMessage(
|
|
|
+ role="tool",
|
|
|
+ content="orphan",
|
|
|
+ tool_call_id="call_1",
|
|
|
+ )
|
|
|
+ ],
|
|
|
+ "orphan tool reply: call_1",
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_openai_chat_client_rejects_mismatched_tool_call_id_before_network():
|
|
|
+ await _assert_tool_transcript_rejected(
|
|
|
+ [
|
|
|
+ ChatMessage(
|
|
|
+ role="assistant",
|
|
|
+ content="",
|
|
|
+ tool_calls=[
|
|
|
+ ToolCallEvent(
|
|
|
+ id="call_1",
|
|
|
+ name="mock_search",
|
|
|
+ arguments={},
|
|
|
+ raw_arguments="{}",
|
|
|
+ )
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ChatMessage(role="tool", content="wrong", tool_call_id="call_2"),
|
|
|
+ ],
|
|
|
+ "mismatched tool_call_id: call_2",
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_openai_chat_client_rejects_duplicate_tool_reply_before_network():
|
|
|
+ await _assert_tool_transcript_rejected(
|
|
|
+ [
|
|
|
+ ChatMessage(
|
|
|
+ role="assistant",
|
|
|
+ content="",
|
|
|
+ tool_calls=[
|
|
|
+ ToolCallEvent(
|
|
|
+ id="call_1",
|
|
|
+ name="mock_search",
|
|
|
+ arguments={},
|
|
|
+ raw_arguments="{}",
|
|
|
+ )
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ChatMessage(role="tool", content="first", tool_call_id="call_1"),
|
|
|
+ ChatMessage(role="tool", content="duplicate", tool_call_id="call_1"),
|
|
|
+ ],
|
|
|
+ "duplicate tool reply: call_1",
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_openai_chat_client_rejects_non_tool_message_with_unresolved_calls():
|
|
|
+ await _assert_tool_transcript_rejected(
|
|
|
+ [
|
|
|
+ ChatMessage(
|
|
|
+ role="assistant",
|
|
|
+ content="checking",
|
|
|
+ tool_calls=[
|
|
|
+ ToolCallEvent(
|
|
|
+ id="call_1",
|
|
|
+ name="mock_search",
|
|
|
+ arguments={},
|
|
|
+ raw_arguments="{}",
|
|
|
+ ),
|
|
|
+ ToolCallEvent(
|
|
|
+ id="call_2",
|
|
|
+ name="handoff_note",
|
|
|
+ arguments={},
|
|
|
+ raw_arguments="{}",
|
|
|
+ ),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ ChatMessage(role="tool", content="done", tool_call_id="call_1"),
|
|
|
+ ChatMessage(role="user", content="continue too early"),
|
|
|
+ ],
|
|
|
+ "unresolved tool calls before user message: call_2",
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+@pytest.mark.asyncio
|
|
|
+async def test_openai_chat_client_rejects_unfinished_tool_calls_at_end():
|
|
|
+ await _assert_tool_transcript_rejected(
|
|
|
+ [
|
|
|
+ ChatMessage(
|
|
|
+ role="assistant",
|
|
|
+ content="checking",
|
|
|
+ tool_calls=[
|
|
|
+ ToolCallEvent(
|
|
|
+ id="call_1",
|
|
|
+ name="mock_search",
|
|
|
+ arguments={},
|
|
|
+ raw_arguments="{}",
|
|
|
+ )
|
|
|
+ ],
|
|
|
+ )
|
|
|
+ ],
|
|
|
+ "unresolved tool calls at end: call_1",
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
def test_static_pre_message_role_selector_does_not_offer_tool():
|
|
|
html = Path("src/agent_lab/presentation/static/index.html").read_text()
|
|
|
|