main
py 735 lines 22.1 KB
Raw
1 import json
2 import sys
3 from pathlib import Path
4
5 import pytest
6 from langchain_core.messages import HumanMessage
7
8
9 PROJECT_ROOT = Path(__file__).resolve().parents[1]
10 if str(PROJECT_ROOT) not in sys.path:
11 sys.path.insert(0, str(PROJECT_ROOT))
12
13 import models
14 from agent import Agent, AgentConfig, AgentContextType, LoopData
15 from helpers import extract_tools, history, litellm_transport
16 from helpers.log import Log
17 from helpers.llm_result import LLMResult, result_from_metadata
18 from helpers.persist_chat import _collect_response_ids
19 from helpers.tool import Response
20
21
22 @pytest.fixture(autouse=True)
23 def _clear_transport_capability_cache():
24 litellm_transport.clear_transport_capability_cache()
25
26
27 class _AsyncEventStream:
28 def __init__(self, events: list[dict]):
29 self.events = events
30 self.index = 0
31 self.closed = False
32
33 def __aiter__(self):
34 return self
35
36 async def __anext__(self):
37 if self.index >= len(self.events):
38 raise StopAsyncIteration
39 event = self.events[self.index]
40 self.index += 1
41 return event
42
43 async def aclose(self):
44 self.closed = True
45
46
47 def test_responses_function_call_text_preserves_non_ascii_tool_args():
48 result = LLMResult.from_response(
49 {
50 "output": [
51 {
52 "type": "function_call",
53 "name": "response",
54 "arguments": '{"text":"привет"}',
55 }
56 ]
57 }
58 )
59
60 assert result.function_calls_text() == '{"tool_name": "response", "tool_args": {"text": "привет"}}'
61
62
63 def test_llm_result_persists_only_durable_responses_metadata():
64 result = LLMResult.from_response(
65 {
66 "id": "resp_123",
67 "usage": {"input_tokens": 10},
68 "output": [
69 {"type": "reasoning", "summary": [{"text": "because"}]},
70 {
71 "type": "function_call",
72 "id": "fc_1",
73 "call_id": "call_1",
74 "name": "lookup",
75 "arguments": '{"q":"a0"}',
76 },
77 {
78 "type": "web_search_call",
79 "id": "ws_1",
80 "status": "completed",
81 },
82 ],
83 },
84 input_items=[{"role": "user", "content": "question"}],
85 previous_response_id="resp_prev",
86 provider_model_key="openai/gpt-5.4",
87 )
88
89 metadata = result.metadata()
90 persisted = metadata["responses"]
91 assert "response" not in persisted
92 assert "reasoning" not in persisted
93 assert "input_items" not in persisted
94 assert "raw" not in persisted
95
96 loaded = result_from_metadata(metadata)
97
98 assert loaded is not None
99 assert loaded.response_id == "resp_123"
100 assert loaded.previous_response_id == "resp_prev"
101 assert loaded.function_calls[0].name == "lookup"
102 assert loaded.function_calls[0].arguments == {"q": "a0"}
103 assert loaded.builtin_items[0].type == "web_search_call"
104
105
106 def test_history_migrates_legacy_ai_metadata_and_preserves_tool_inputs():
107 class DummyAgent:
108 pass
109
110 hist = history.History(DummyAgent())
111 result = LLMResult.from_response(
112 {"id": "resp_1", "output": [{"type": "message", "content": [{"type": "output_text", "text": "ok"}]}]},
113 input_items=[{"role": "user", "content": "question"}],
114 provider_model_key="openai/gpt-5.4",
115 )
116
117 message = hist.add_message(True, "ok", metadata=result.metadata())
118 tool_item = {"type": "function_call_output", "call_id": "call_1", "output": "done"}
119 hist.add_message(
120 False,
121 "done",
122 metadata={"responses": {"input_items": [tool_item]}},
123 )
124 serialized = hist.serialize()
125 assert '"input_items":[{"type":"function_call_output"' in serialized
126 restored = history.deserialize_history(serialized, DummyAgent())
127
128 restored_message = restored.all_messages()[0]
129 assert restored_message.sequence == message.sequence
130 assert result_from_metadata(restored_message.metadata).response_id == "resp_1"
131 assert restored.all_messages()[1].metadata["responses"]["input_items"] == [tool_item]
132
133 migrated = history.Message.from_dict(
134 {
135 "_cls": "Message",
136 "ai": True,
137 "content": "old",
138 "metadata": {"custom": "keep", "responses": result.to_dict()},
139 },
140 restored,
141 )
142 assert "input_items" not in migrated.metadata["responses"]
143 assert migrated.metadata["custom"] == "keep"
144
145 old = history.Message.from_dict({"_cls": "Message", "ai": False, "content": "old"}, restored)
146 assert old.metadata == {}
147 assert old.sequence == 0
148
149
150 @pytest.mark.asyncio
151 async def test_chat_completion_transport_preserves_reported_usage(monkeypatch):
152 async def fake_acompletion(**kwargs):
153 return {
154 "choices": [{"message": {"content": "done"}}],
155 "usage": {
156 "prompt_tokens": 120,
157 "completion_tokens": 8,
158 "total_tokens": 128,
159 },
160 "_hidden_params": {"response_cost": 0.0042},
161 }
162
163 monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
164 transport = litellm_transport.LiteLLMTransport(
165 model="custom/model",
166 messages=[{"role": "user", "content": "question"}],
167 kwargs={"a0_api_mode": "chat_completions"},
168 )
169
170 await transport.acomplete()
171
172 assert transport.last_result is not None
173 assert transport.last_result.usage == {
174 "prompt_tokens": 120,
175 "completion_tokens": 8,
176 "total_tokens": 128,
177 "cost": 0.0042,
178 }
179
180
181 def test_responses_provider_state_uses_previous_response_and_new_items():
182 new_items = [{"type": "function_call_output", "call_id": "call_1", "output": "done"}]
183 local_items = [{"role": "user", "content": "full replay"}]
184
185 request = litellm_transport.ResponsesTransport.from_chat(
186 [{"role": "user", "content": "ignored while continuing provider state"}],
187 {
188 "previous_response_id": "resp_1",
189 "responses_input_items": new_items,
190 "responses_local_input_items": local_items,
191 },
192 model="openai/gpt-5.4",
193 )
194
195 assert request["store"] is True
196 assert request["previous_response_id"] == "resp_1"
197 assert request["input"] == new_items
198
199 local_request = litellm_transport.ResponsesTransport.from_chat(
200 [{"role": "user", "content": "ignored"}],
201 {
202 "responses_state": "local",
203 "previous_response_id": "resp_1",
204 "responses_input_items": new_items,
205 "responses_local_input_items": local_items,
206 },
207 model="openai/gpt-5.4",
208 )
209
210 assert local_request["store"] is False
211 assert "previous_response_id" not in local_request
212 assert local_request["input"] == local_items
213
214
215 @pytest.mark.asyncio
216 async def test_transport_retries_provider_state_as_local_replay(monkeypatch):
217 calls: list[dict] = []
218
219 async def fake_aresponses(*args, **kwargs):
220 calls.append(kwargs)
221 if len(calls) == 1:
222 raise RuntimeError("previous_response_id is not supported by this provider")
223 return {
224 "id": "resp_local",
225 "output": [
226 {
227 "type": "message",
228 "content": [{"type": "output_text", "text": "ok"}],
229 }
230 ],
231 }
232
233 monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
234
235 transport = litellm_transport.LiteLLMTransport(
236 model="openai/gpt-5.4",
237 messages=[{"role": "user", "content": "new"}],
238 kwargs={
239 "a0_api_mode": "responses",
240 "previous_response_id": "resp_1",
241 "responses_input_items": [{"role": "user", "content": "new"}],
242 "responses_local_input_items": [{"role": "user", "content": "full"}],
243 },
244 )
245
246 parsed = await transport.acomplete()
247
248 assert parsed["response_delta"] == "ok"
249 assert calls[0]["store"] is True
250 assert calls[0]["previous_response_id"] == "resp_1"
251 assert calls[1]["store"] is False
252 assert "previous_response_id" not in calls[1]
253 assert calls[1]["input"] == [{"role": "user", "content": "full"}]
254 assert transport.last_result.response_id == "resp_local"
255
256
257 @pytest.mark.asyncio
258 async def test_transport_downgrades_unsupported_builtin_tools(monkeypatch):
259 calls: list[dict] = []
260
261 async def fake_aresponses(*args, **kwargs):
262 calls.append(kwargs)
263 if len(calls) == 1:
264 raise RuntimeError("unsupported tool type: web_search")
265 return {
266 "id": "resp_no_builtin",
267 "output": [
268 {
269 "type": "message",
270 "content": [{"type": "output_text", "text": "ok"}],
271 }
272 ],
273 }
274
275 monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
276
277 transport = litellm_transport.LiteLLMTransport(
278 model="openai/gpt-5.4",
279 messages=[{"role": "user", "content": "new"}],
280 kwargs={
281 "a0_api_mode": "responses",
282 "responses_builtin_tools": [{"type": "web_search"}],
283 },
284 )
285
286 parsed = await transport.acomplete()
287
288 assert parsed["response_delta"] == "ok"
289 assert calls[0]["tools"] == [{"type": "web_search"}]
290 assert "tools" not in calls[1]
291 assert transport.last_result.capability["builtin_tool_downgrades"] == [
292 "web_search"
293 ]
294
295 next_transport = litellm_transport.LiteLLMTransport(
296 model="openai/gpt-5.4",
297 messages=[{"role": "user", "content": "again"}],
298 kwargs={
299 "a0_api_mode": "responses",
300 "responses_builtin_tools": [{"type": "web_search"}],
301 },
302 )
303 request = next_transport._responses_request(stream=False)
304 assert "tools" not in request
305
306
307 @pytest.mark.asyncio
308 async def test_unified_turn_keeps_streamed_call_when_completion_omits_output(
309 monkeypatch,
310 ):
311 stream = _AsyncEventStream(
312 [
313 {
314 "type": "response.output_item.added",
315 "output_index": 0,
316 "item": {
317 "type": "function_call",
318 "id": "fc_1",
319 "call_id": "call_1",
320 "name": "lookup",
321 "arguments": "",
322 },
323 },
324 {
325 "type": "response.function_call_arguments.done",
326 "item_id": "fc_1",
327 "output_index": 0,
328 "name": "lookup",
329 "arguments": '{"q":"a0"}',
330 },
331 {
332 "type": "response.completed",
333 "response": {
334 "id": "resp_1",
335 "output": [],
336 },
337 },
338 ]
339 )
340
341 async def fake_aresponses(*args, **kwargs):
342 return stream
343
344 async def fake_rate_limiter(*args, **kwargs):
345 return None
346
347 monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
348 monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
349
350 wrapper = models.LiteLLMChatWrapper(
351 model="test-model",
352 provider="openai",
353 model_config=None,
354 a0_api_mode="responses",
355 )
356
357 async def response_callback(chunk: str, full: str):
358 return None
359
360 result = await wrapper.unified_turn(
361 messages=[HumanMessage(content="hi")],
362 response_callback=response_callback,
363 )
364
365 assert stream.index == 3
366 assert stream.closed is False
367 assert result.response_id == "resp_1"
368 assert result.function_calls[0].call_id == "call_1"
369 assert result.function_calls[0].arguments == {"q": "a0"}
370
371
372 @pytest.mark.asyncio
373 async def test_unified_turn_waits_for_completed_native_responses_calls(monkeypatch):
374 calls = [
375 {
376 "type": "function_call",
377 "id": "fc_1",
378 "call_id": "call_1",
379 "name": "lookup",
380 "arguments": '{"q":"a0"}',
381 },
382 {
383 "type": "function_call",
384 "id": "fc_2",
385 "call_id": "call_2",
386 "name": "summarize",
387 "arguments": '{"style":"short"}',
388 },
389 ]
390 stream = _AsyncEventStream(
391 [
392 {
393 "type": "response.output_item.added",
394 "output_index": 0,
395 "item": {**calls[0], "arguments": ""},
396 },
397 {
398 "type": "response.function_call_arguments.done",
399 "item_id": "fc_1",
400 "output_index": 0,
401 "name": "lookup",
402 "arguments": calls[0]["arguments"],
403 },
404 {
405 "type": "response.output_item.added",
406 "output_index": 1,
407 "item": {**calls[1], "arguments": ""},
408 },
409 {
410 "type": "response.function_call_arguments.done",
411 "item_id": "fc_2",
412 "output_index": 1,
413 "name": "summarize",
414 "arguments": calls[1]["arguments"],
415 },
416 {
417 "type": "response.completed",
418 "response": {
419 "id": "resp_parallel",
420 "output": calls,
421 "usage": {"input_tokens": 10, "output_tokens": 5},
422 "_hidden_params": {"response_cost": 0.0012},
423 },
424 },
425 ]
426 )
427
428 async def fake_aresponses(*args, **kwargs):
429 return stream
430
431 async def fake_rate_limiter(*args, **kwargs):
432 return None
433
434 monkeypatch.setattr(litellm_transport, "aresponses", fake_aresponses)
435 monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
436
437 wrapper = models.LiteLLMChatWrapper(
438 model="test-model",
439 provider="openai",
440 model_config=None,
441 a0_api_mode="responses",
442 )
443
444 async def response_callback(chunk: str, full: str):
445 return full if extract_tools.extract_tool_request(full) else None
446
447 result = await wrapper.unified_turn(
448 messages=[HumanMessage(content="hi")],
449 response_callback=response_callback,
450 )
451
452 assert stream.index == 5
453 assert stream.closed is False
454 assert result.mode == "responses"
455 assert result.response_id == "resp_parallel"
456 assert result.usage == {
457 "input_tokens": 10,
458 "output_tokens": 5,
459 "cost": 0.0012,
460 }
461 assert [call.name for call in result.function_calls] == ["lookup", "summarize"]
462 assert json.loads(result.response) == {
463 "tool_name": "parallel_tool_calls",
464 "tool_args": {
465 "calls": [
466 {"tool_name": "lookup", "tool_args": {"q": "a0"}},
467 {"tool_name": "summarize", "tool_args": {"style": "short"}},
468 ]
469 },
470 }
471
472
473 def test_collect_response_ids_from_agent_state_and_history_metadata():
474 payload = {
475 "agents": [
476 {
477 "data": {
478 "responses_state": {
479 "response_id": "resp_latest",
480 "response_ids": ["resp_old", "resp_latest"],
481 }
482 },
483 "history": '{"current":{"messages":[{"metadata":{"responses":{"response_id":"resp_history"}}}]}}',
484 }
485 ]
486 }
487
488 assert _collect_response_ids(payload) == [
489 "resp_latest",
490 "resp_old",
491 "resp_history",
492 ]
493
494
495 @pytest.mark.asyncio
496 async def test_agent_executes_native_responses_function_call_and_records_output():
497 class DummyContext:
498 paused = False
499 log = Log()
500 type = AgentContextType.USER
501
502 def get_data(self, key, recursive=True):
503 return None
504
505 class DummyTool:
506 name = "lookup"
507 progress = ""
508
509 def __init__(self, agent):
510 self.agent = agent
511
512 async def before_execution(self, **kwargs):
513 self.args = kwargs
514
515 async def execute(self, **kwargs):
516 return Response(message=f"done:{kwargs['q']}", break_loop=False)
517
518 async def after_execution(self, response):
519 self.agent.hist_add_tool_result(
520 self.name,
521 response.message,
522 **(response.additional or {}),
523 )
524
525 agent = object.__new__(Agent)
526 agent.data = {Agent.DATA_NAME_RESPONSES_TOOL_NAME_MAP: {}}
527 agent.context = DummyContext()
528 agent.config = AgentConfig(mcp_servers="")
529 agent.loop_data = LoopData()
530 agent.history = history.History(agent)
531 agent.intervention = None
532 agent.agent_name = "A0"
533 agent.number = 0
534
535 def get_tool(**kwargs):
536 return DummyTool(agent)
537
538 agent.get_tool = get_tool
539
540 result = LLMResult.from_response(
541 {
542 "id": "resp_1",
543 "output": [
544 {
545 "type": "function_call",
546 "id": "fc_1",
547 "call_id": "call_1",
548 "name": "lookup",
549 "arguments": '{"q":"a0"}',
550 }
551 ],
552 },
553 provider_model_key="openai/gpt-5.4",
554 )
555
556 assert await Agent.process_llm_result_tools(agent, result) is None
557
558 recorded = agent.history.all_messages()[0]
559 metadata = result_from_metadata(recorded.metadata)
560 assert recorded.content["tool_result"] == "done:a0"
561 assert metadata.input_items == [
562 {
563 "type": "function_call_output",
564 "call_id": "call_1",
565 "output": "done:a0",
566 }
567 ]
568
569
570 @pytest.mark.asyncio
571 async def test_agent_routes_chat_retries_and_native_responses_text() -> None:
572 agent = object.__new__(Agent)
573 processed: list[str] = []
574 executed: list[dict] = []
575
576 async def log_builtin_items(result):
577 return None
578
579 async def process_tools(message):
580 processed.append(message)
581 return None
582
583 async def execute_tool_request(**kwargs):
584 executed.append(kwargs)
585 return None
586
587 agent._log_response_builtin_items = log_builtin_items
588 agent.process_tools = process_tools
589 agent._execute_tool_request = execute_tool_request
590
591 tool_request = '{"type":"function","name":"response","parameters":{"text":"ok"}}'
592 chat_messages = (
593 "Plain final answer.",
594 '{"status":"planning"}',
595 f"Example tool JSON: {tool_request}",
596 f"\n{tool_request}",
597 (
598 '{"thoughts":["Done"],"headline":"Done","tool_args":'
599 '{"text":"ok","tool_name":"response"}'
600 ),
601 )
602 for message in chat_messages:
603 assert await Agent.process_llm_result_tools(
604 agent, LLMResult.from_chat(response=message)
605 ) is None
606 assert processed == list(chat_messages)
607
608 processed.clear()
609 responses_messages = (
610 "Plain final answer.",
611 '{"status":"planning"}',
612 f"Example tool JSON: {tool_request}",
613 )
614 for message in responses_messages:
615 assert await Agent.process_llm_result_tools(
616 agent, LLMResult(response=message)
617 ) is None
618 assert processed == []
619 assert executed == [
620 {
621 "tool_name": "response",
622 "tool_args": {"text": message},
623 "message": message,
624 }
625 for message in responses_messages
626 ]
627
628 processed.clear()
629 executed.clear()
630 assert await Agent.process_llm_result_tools(
631 agent, LLMResult.from_chat(response=tool_request)
632 ) is None
633 assert processed == [tool_request]
634
635 processed.clear()
636 assert await Agent.process_llm_result_tools(
637 agent, LLMResult(response="", reasoning=tool_request)
638 ) is None
639 assert processed == [tool_request]
640
641 processed.clear()
642 assert await Agent.process_llm_result_tools(
643 agent, LLMResult(response="", reasoning='{"status":"planning"}')
644 ) is None
645 assert processed == [""]
646
647
648 @pytest.mark.asyncio
649 async def test_agent_routes_misformatted_tool_intent_to_repair() -> None:
650 agent = object.__new__(Agent)
651 processed: list[str] = []
652
653 async def log_builtin_items(result):
654 return None
655
656 async def process_tools(message):
657 processed.append(message)
658 return None
659
660 agent._log_response_builtin_items = log_builtin_items
661 agent.process_tools = process_tools
662
663 malformed = (
664 '{"thoughts":["Plan the work", "Run the tools", '
665 '"headline":"Save results", "tool_name":"parallel", '
666 '"tool_args":{"tool_calls":[{"tool_name":"memory_save",'
667 '"tool_args":{"text":"ok"}}],"wait":true}}'
668 )
669
670 assert await Agent.process_llm_result_tools(
671 agent, LLMResult.from_chat(response=malformed)
672 ) is None
673 assert processed == [malformed]
674
675 processed.clear()
676 assert await Agent.process_llm_result_tools(
677 agent, LLMResult(response="", reasoning=malformed)
678 ) is None
679 assert processed == [malformed]
680
681 fenced = (
682 "I will call the tool.\n\n```json\n"
683 '{"tool_name":"response","tool_args":{"text":"ok"}}\n```'
684 )
685 processed.clear()
686 assert await Agent.process_llm_result_tools(
687 agent, LLMResult.from_chat(response=fenced)
688 ) is None
689 assert processed == [fenced]
690
691
692 @pytest.mark.asyncio
693 async def test_text_tool_execution_uses_normalized_tool_args(monkeypatch) -> None:
694 class DummyMCPConfig:
695 def get_tool(self, agent, tool_name):
696 return None
697
698 class DummyTool:
699 def __init__(self):
700 self.args = {}
701
702 async def before_execution(self, **kwargs):
703 assert self.args == {"text": "ok"}
704
705 async def execute(self, **kwargs):
706 assert kwargs == {"text": "ok"}
707 return Response(message=self.args["text"], break_loop=True)
708
709 async def after_execution(self, response):
710 return None
711
712 async def no_extension(*args, **kwargs):
713 return None
714
715 async def no_intervention(*args, **kwargs):
716 return None
717
718 import agent as agent_module
719 from helpers import mcp_handler
720
721 monkeypatch.setattr(
722 mcp_handler.MCPConfig, "get_instance", lambda: DummyMCPConfig()
723 )
724 monkeypatch.setattr(agent_module.extension, "call_extensions_async", no_extension)
725
726 tool = DummyTool()
727 agent = object.__new__(Agent)
728 agent.data = {}
729 agent.loop_data = LoopData()
730 agent.handle_intervention = no_intervention
731 agent.get_tool = lambda **kwargs: tool
732
733 assert await Agent.process_tools(
734 agent, '{"actions":[{"tool_name":"response","tool_args":{"text":"ok"}}]}'
735 ) == "ok"