test_debug_runtime.py 50 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520
  1. import asyncio
  2. import importlib
  3. import json
  4. import logging
  5. from collections.abc import AsyncIterator
  6. from typing import Any
  7. import pytest
  8. from agent_lab.application.contracts import AgentParams, DebugRunRequest, EventAgentParams
  9. from agent_lab.application.event_agent import EventAgentRequest
  10. from agent_lab.application.runtime import DebugRuntime
  11. from agent_lab.application.tools import ToolDefinition, ToolExecutionContext, ToolRegistry
  12. from agent_lab.domain.events import ToolCallEvent
  13. from agent_lab.domain.messages import ChatMessage, StreamItem, TokenUsage
  14. from agent_lab.infrastructure.sqlite_store import SQLiteSessionStore
  15. def _runtime_queues_class():
  16. module = importlib.import_module("agent_lab.application.queues")
  17. return module.RuntimeQueues
  18. async def _collect_outputs(stream: AsyncIterator[dict[str, Any]]) -> list[dict[str, Any]]:
  19. return [message async for message in stream]
  20. def _without_audit(outputs: list[dict[str, Any]]) -> list[dict[str, Any]]:
  21. return [message for message in outputs if message["type"] != "audit"]
  22. def _message_types(outputs: list[dict[str, Any]]) -> list[str]:
  23. return [message["type"] for message in _without_audit(outputs)]
  24. def _event_tool_call_from_tools(
  25. tools: list[dict],
  26. messages: list[ChatMessage],
  27. ) -> StreamItem:
  28. tool_name = tools[0]["function"]["name"]
  29. content = ""
  30. for role in ("assistant", "user"):
  31. content = next(
  32. (
  33. message.content
  34. for message in reversed(messages)
  35. if message.role == role and message.content.strip()
  36. ),
  37. "",
  38. )
  39. if content:
  40. break
  41. arguments = {
  42. "message": content,
  43. "query": content,
  44. "title": content,
  45. }
  46. return StreamItem.event(
  47. ToolCallEvent(
  48. id="event_agent_call_1",
  49. name=tool_name,
  50. arguments=arguments,
  51. raw_arguments=json.dumps(arguments),
  52. )
  53. )
  54. async def _next_non_audit(
  55. stream: AsyncIterator[dict[str, Any]],
  56. ) -> dict[str, Any]:
  57. while True:
  58. message = await anext(stream)
  59. if message["type"] != "audit":
  60. return message
  61. async def _next_non_audit_from_queue(queues: Any) -> dict[str, Any]:
  62. while True:
  63. message = await queues.output.get()
  64. if message["type"] != "audit":
  65. return message
  66. class RecordingQueue(asyncio.Queue):
  67. def __init__(self, name: str, log: list[tuple[str, str, str]]) -> None:
  68. super().__init__()
  69. self.name = name
  70. self.log = log
  71. async def put(self, item: Any) -> None:
  72. self.log.append((self.name, "put", self._describe(item)))
  73. await super().put(item)
  74. async def get(self) -> Any:
  75. item = await super().get()
  76. self.log.append((self.name, "get", self._describe(item)))
  77. return item
  78. def _describe(self, item: Any) -> str:
  79. if isinstance(item, ChatMessage):
  80. if item.role == "tool":
  81. return f"tool:{item.tool_call_id}"
  82. return item.role
  83. if isinstance(item, ToolCallEvent):
  84. return f"event:{item.name}:{item.id}"
  85. if isinstance(item, EventAgentRequest):
  86. events = ",".join(f"{event.name}:{event.id}" for event in item.events)
  87. return f"event_request:{events}"
  88. if isinstance(item, dict):
  89. return f"output:{item.get('type')}"
  90. return type(item).__name__
  91. class FakeChatClient:
  92. def __init__(self) -> None:
  93. self.calls = 0
  94. async def stream_chat(
  95. self,
  96. messages: list[ChatMessage],
  97. tools: list[dict],
  98. params: AgentParams,
  99. ) -> AsyncIterator[StreamItem]:
  100. if tools:
  101. yield StreamItem.raw_response_chunk(
  102. {
  103. "choices": [
  104. {
  105. "delta": {
  106. "tool_calls": [
  107. {
  108. "index": 0,
  109. "function": {"name": tools[0]["function"]["name"]},
  110. }
  111. ]
  112. },
  113. "finish_reason": None,
  114. }
  115. ]
  116. }
  117. )
  118. yield _event_tool_call_from_tools(tools, messages)
  119. return
  120. self.calls += 1
  121. if self.calls == 1:
  122. yield StreamItem.raw_response_chunk(
  123. {
  124. "choices": [
  125. {
  126. "delta": {"content": "<agent_events>handoff_note</agent_events>"},
  127. "finish_reason": None,
  128. }
  129. ]
  130. }
  131. )
  132. yield StreamItem.event(
  133. ToolCallEvent(
  134. id="call_1",
  135. name="handoff_note",
  136. arguments={},
  137. raw_arguments="{}",
  138. )
  139. )
  140. return
  141. assert not any(message.role == "tool" for message in messages)
  142. assert any(message.name == "event_agent" for message in messages)
  143. yield StreamItem.message_delta("final answer")
  144. class IncrementingClock:
  145. def __init__(self, current: float = 100.0, step: float = 0.01) -> None:
  146. self.current = current
  147. self.step = step
  148. def __call__(self) -> float:
  149. self.current += self.step
  150. return self.current
  151. class StrictHistoryChatClient:
  152. def __init__(self) -> None:
  153. self.calls = 0
  154. self.second_call_messages: list[ChatMessage] = []
  155. async def stream_chat(
  156. self,
  157. messages: list[ChatMessage],
  158. tools: list[dict],
  159. params: AgentParams,
  160. ) -> AsyncIterator[StreamItem]:
  161. if tools:
  162. yield StreamItem.raw_response_chunk(
  163. {
  164. "choices": [
  165. {
  166. "delta": {
  167. "tool_calls": [
  168. {
  169. "index": 0,
  170. "function": {"name": tools[0]["function"]["name"]},
  171. }
  172. ]
  173. },
  174. "finish_reason": None,
  175. }
  176. ]
  177. }
  178. )
  179. yield _event_tool_call_from_tools(tools, messages)
  180. return
  181. self.calls += 1
  182. if self.calls == 1:
  183. yield StreamItem.raw_response_chunk(
  184. {
  185. "choices": [
  186. {
  187. "delta": {"content": "<agent_events>handoff_note</agent_events>"},
  188. "finish_reason": None,
  189. }
  190. ]
  191. }
  192. )
  193. yield StreamItem.event(
  194. ToolCallEvent(
  195. id="call_1",
  196. name="handoff_note",
  197. arguments={},
  198. raw_arguments="{}",
  199. )
  200. )
  201. return
  202. self.second_call_messages = list(messages)
  203. yield StreamItem.message_delta("final answer")
  204. class MultiEventChatClient:
  205. def __init__(self) -> None:
  206. self.calls = 0
  207. self.second_call_messages: list[ChatMessage] = []
  208. async def stream_chat(
  209. self,
  210. messages: list[ChatMessage],
  211. tools: list[dict],
  212. params: AgentParams,
  213. ) -> AsyncIterator[StreamItem]:
  214. if tools:
  215. yield _event_tool_call_from_tools(tools, messages)
  216. return
  217. self.calls += 1
  218. if self.calls == 1:
  219. yield StreamItem.message_delta("Checking events.")
  220. yield StreamItem.event(
  221. ToolCallEvent(
  222. id="call_1",
  223. name="handoff_note",
  224. arguments={"message": "ignored chat argument"},
  225. raw_arguments='{"message":"ignored chat argument"}',
  226. )
  227. )
  228. yield StreamItem.event(
  229. ToolCallEvent(
  230. id="call_2",
  231. name="audit_note",
  232. arguments={"message": "ignored chat argument"},
  233. raw_arguments='{"message":"ignored chat argument"}',
  234. )
  235. )
  236. return
  237. self.second_call_messages = list(messages)
  238. yield StreamItem.message_delta("Final answer.")
  239. class ToolCapturingChatClient:
  240. def __init__(self) -> None:
  241. self.tools: list[dict[str, Any]] = []
  242. self.messages: list[ChatMessage] = []
  243. async def stream_chat(
  244. self,
  245. messages: list[ChatMessage],
  246. tools: list[dict],
  247. params: AgentParams,
  248. ) -> AsyncIterator[StreamItem]:
  249. self.messages = list(messages)
  250. self.tools = list(tools)
  251. yield StreamItem.message_delta("final answer")
  252. class EventLoopLimitChatClient:
  253. def __init__(self) -> None:
  254. self.calls = 0
  255. self.tools_by_call: list[list[dict[str, Any]]] = []
  256. self.messages_by_call: list[list[ChatMessage]] = []
  257. async def stream_chat(
  258. self,
  259. messages: list[ChatMessage],
  260. tools: list[dict],
  261. params: AgentParams,
  262. ) -> AsyncIterator[StreamItem]:
  263. if tools:
  264. yield _event_tool_call_from_tools(tools, messages)
  265. return
  266. self.calls += 1
  267. self.tools_by_call.append(list(tools))
  268. self.messages_by_call.append(list(messages))
  269. if self.calls == 1:
  270. yield StreamItem.event(
  271. ToolCallEvent(
  272. id="call_1",
  273. name="handoff_note",
  274. arguments={},
  275. raw_arguments="{}",
  276. )
  277. )
  278. return
  279. yield StreamItem.message_delta("final after event limit")
  280. class RoundStatsChatClient:
  281. async def stream_chat(
  282. self,
  283. messages: list[ChatMessage],
  284. tools: list[dict],
  285. params: AgentParams,
  286. ) -> AsyncIterator[StreamItem]:
  287. yield StreamItem.message_delta("hello")
  288. yield StreamItem.usage_item(
  289. TokenUsage(
  290. prompt_tokens=10,
  291. completion_tokens=20,
  292. total_tokens=30,
  293. cached_tokens=5,
  294. )
  295. )
  296. class EventRoundStatsChatClient:
  297. def __init__(self) -> None:
  298. self.calls = 0
  299. async def stream_chat(
  300. self,
  301. messages: list[ChatMessage],
  302. tools: list[dict],
  303. params: AgentParams,
  304. ) -> AsyncIterator[StreamItem]:
  305. if tools:
  306. yield StreamItem.raw_response_chunk(
  307. {
  308. "choices": [
  309. {
  310. "delta": {
  311. "tool_calls": [
  312. {
  313. "index": 0,
  314. "function": {
  315. "name": tools[0]["function"]["name"],
  316. },
  317. }
  318. ]
  319. },
  320. "finish_reason": None,
  321. }
  322. ]
  323. }
  324. )
  325. yield _event_tool_call_from_tools(tools, messages)
  326. return
  327. self.calls += 1
  328. if self.calls == 1:
  329. yield StreamItem.raw_response_chunk(
  330. {
  331. "choices": [
  332. {
  333. "delta": {
  334. "content": "<agent_events>handoff_note</agent_events>",
  335. },
  336. "finish_reason": None,
  337. }
  338. ]
  339. }
  340. )
  341. yield StreamItem.event(
  342. ToolCallEvent(
  343. id="call_1",
  344. name="handoff_note",
  345. arguments={},
  346. raw_arguments="{}",
  347. )
  348. )
  349. yield StreamItem.usage_item(
  350. TokenUsage(prompt_tokens=3, completion_tokens=0, total_tokens=3)
  351. )
  352. return
  353. yield StreamItem.raw_response_chunk(
  354. {
  355. "choices": [
  356. {
  357. "delta": {"content": "final answer"},
  358. "finish_reason": None,
  359. }
  360. ]
  361. }
  362. )
  363. yield StreamItem.message_delta("final answer")
  364. yield StreamItem.usage_item(
  365. TokenUsage(prompt_tokens=4, completion_tokens=6, total_tokens=10)
  366. )
  367. class ContextBoundaryChatClient:
  368. def __init__(self) -> None:
  369. self.calls = 0
  370. self.event_agent_messages: list[list[ChatMessage]] = []
  371. async def stream_chat(
  372. self,
  373. messages: list[ChatMessage],
  374. tools: list[dict],
  375. params: AgentParams,
  376. ) -> AsyncIterator[StreamItem]:
  377. if tools:
  378. self.event_agent_messages.append(list(messages))
  379. yield _event_tool_call_from_tools(tools, messages)
  380. return
  381. self.calls += 1
  382. if self.calls == 1:
  383. yield StreamItem.message_delta("I will check.")
  384. yield StreamItem.event(
  385. ToolCallEvent(
  386. id="call_1",
  387. name="handoff_note",
  388. arguments={},
  389. raw_arguments="{}",
  390. )
  391. )
  392. return
  393. yield StreamItem.message_delta("final answer")
  394. class TwoTurnSessionChatClient:
  395. def __init__(self) -> None:
  396. self.calls = 0
  397. self.messages_by_call: list[list[ChatMessage]] = []
  398. async def stream_chat(
  399. self,
  400. messages: list[ChatMessage],
  401. tools: list[dict],
  402. params: AgentParams,
  403. ) -> AsyncIterator[StreamItem]:
  404. if tools:
  405. yield _event_tool_call_from_tools(tools, messages)
  406. return
  407. self.calls += 1
  408. self.messages_by_call.append(list(messages))
  409. if self.calls in {1, 3}:
  410. yield StreamItem.event(
  411. ToolCallEvent(
  412. id=f"call_{self.calls}",
  413. name="handoff_note",
  414. arguments={},
  415. raw_arguments="{}",
  416. )
  417. )
  418. return
  419. yield StreamItem.message_delta(f"final answer {self.calls}")
  420. class SlowAfterEventChatClient:
  421. def __init__(self) -> None:
  422. self.calls = 0
  423. self.event_seen = asyncio.Event()
  424. self.release_stream = asyncio.Event()
  425. async def stream_chat(
  426. self,
  427. messages: list[ChatMessage],
  428. tools: list[dict],
  429. params: AgentParams,
  430. ) -> AsyncIterator[StreamItem]:
  431. if tools:
  432. yield _event_tool_call_from_tools(tools, messages)
  433. return
  434. self.calls += 1
  435. if self.calls == 1:
  436. yield StreamItem.message_delta("Need event.")
  437. yield StreamItem.event(
  438. ToolCallEvent(
  439. id="call_1",
  440. name="handoff_note",
  441. arguments={},
  442. raw_arguments="{}",
  443. )
  444. )
  445. self.event_seen.set()
  446. await self.release_stream.wait()
  447. return
  448. yield StreamItem.message_delta("final answer")
  449. def test_runtime_queues_exposes_input_output_and_events_queues():
  450. RuntimeQueues = _runtime_queues_class()
  451. queues = RuntimeQueues()
  452. assert isinstance(queues.input, asyncio.Queue)
  453. assert isinstance(queues.output, asyncio.Queue)
  454. assert isinstance(queues.events, asyncio.Queue)
  455. assert queues.input is not queues.output
  456. assert queues.input is not queues.events
  457. assert queues.output is not queues.events
  458. @pytest.mark.asyncio
  459. async def test_runtime_routes_chat_events_through_event_agent_then_continues_chat():
  460. request = DebugRunRequest(
  461. user_message="debug this",
  462. system_prompts=["You are a debugger."],
  463. pre_messages=[],
  464. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  465. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=3),
  466. )
  467. client = FakeChatClient()
  468. runtime = DebugRuntime(client)
  469. outputs = [message async for message in runtime.run(request)]
  470. business_outputs = _without_audit(outputs)
  471. assert client.calls == 2
  472. assert _message_types(outputs) == [
  473. "session_started",
  474. "event",
  475. "tool_result",
  476. "round_stats",
  477. "message_delta",
  478. "round_stats",
  479. "done",
  480. ]
  481. assert business_outputs[1]["event"]["name"] == "handoff_note"
  482. assert business_outputs[4]["content"] == "final answer"
  483. @pytest.mark.asyncio
  484. async def test_runtime_emits_audit_events_and_backend_logs(caplog):
  485. request = DebugRunRequest(
  486. user_message="debug this",
  487. system_prompts=[],
  488. pre_messages=[],
  489. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  490. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=1),
  491. )
  492. runtime = DebugRuntime(FakeChatClient())
  493. with caplog.at_level(logging.INFO, logger="agent_lab.application.runtime"):
  494. outputs = [message async for message in runtime.run(request)]
  495. audit_events = [
  496. message["event"]
  497. for message in outputs
  498. if message["type"] == "audit"
  499. ]
  500. assert audit_events == [
  501. "session_started",
  502. "chat_round_started",
  503. "chat_agent_request",
  504. "chat_event_detected",
  505. "chat_agent_response",
  506. "event_agent_request",
  507. "event_agent_response",
  508. "event_agent_completed",
  509. "chat_round_finished",
  510. "chat_round_started",
  511. "chat_agent_request",
  512. "chat_message_stream_started",
  513. "chat_message_stream_finished",
  514. "chat_agent_response",
  515. "chat_round_finished",
  516. "session_finished",
  517. ]
  518. assert "chat_event_detected" in caplog.text
  519. assert "event_agent_completed" in caplog.text
  520. @pytest.mark.asyncio
  521. async def test_runtime_audits_chat_message_stream_boundaries_in_output_order():
  522. request = DebugRunRequest(
  523. user_message="debug this",
  524. system_prompts=[],
  525. pre_messages=[],
  526. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  527. event_agent=EventAgentParams(enabled_tools=[], max_event_loops=1),
  528. )
  529. runtime = DebugRuntime(RoundStatsChatClient())
  530. outputs = [message async for message in runtime.run(request)]
  531. ordered_labels = [
  532. message["event"] if message["type"] == "audit" else message["type"]
  533. for message in outputs
  534. ]
  535. assert ordered_labels.index("chat_message_stream_started") < ordered_labels.index(
  536. "message_delta"
  537. )
  538. assert ordered_labels.index("message_delta") < ordered_labels.index(
  539. "chat_message_stream_finished"
  540. )
  541. assert ordered_labels.index("chat_message_stream_finished") < ordered_labels.index(
  542. "chat_agent_response"
  543. )
  544. stream_started = next(
  545. message
  546. for message in outputs
  547. if message.get("event") == "chat_message_stream_started"
  548. )
  549. stream_finished = next(
  550. message
  551. for message in outputs
  552. if message.get("event") == "chat_message_stream_finished"
  553. )
  554. assert stream_started["details"]["agent"] == "chat_agent"
  555. assert stream_started["details"]["round_index"] == 1
  556. assert stream_finished["details"]["delta_count"] == 1
  557. assert stream_finished["details"]["content_length"] == len("hello")
  558. @pytest.mark.asyncio
  559. async def test_runtime_audit_events_include_turn_relative_elapsed_time():
  560. request = DebugRunRequest(
  561. user_message="debug this",
  562. system_prompts=[],
  563. pre_messages=[],
  564. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  565. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=1),
  566. )
  567. runtime = DebugRuntime(FakeChatClient(), clock=IncrementingClock())
  568. queues = runtime.start_session(request)
  569. outputs: list[dict[str, Any]] = []
  570. while not any(message["type"] == "turn_completed" for message in outputs):
  571. outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
  572. await runtime.aclose()
  573. turn_audits = [
  574. message
  575. for message in outputs
  576. if message["type"] == "audit"
  577. ]
  578. elapsed_values = [
  579. message["details"].get("turn_elapsed_ms") for message in turn_audits
  580. ]
  581. assert turn_audits
  582. assert all(isinstance(value, int) for value in elapsed_values)
  583. assert all(value >= 0 for value in elapsed_values)
  584. assert elapsed_values == sorted(elapsed_values)
  585. assert next(
  586. message
  587. for message in turn_audits
  588. if message["event"] == "event_agent_request"
  589. )["details"]["turn_elapsed_ms"] >= 0
  590. @pytest.mark.asyncio
  591. async def test_runtime_audit_includes_model_params_prompts_results_and_usage():
  592. request = DebugRunRequest(
  593. user_message="debug this",
  594. system_prompts=["You are a debugger."],
  595. pre_messages=[],
  596. chat_agent=AgentParams(model="chat-model", temperature=0.1, max_tokens=200),
  597. event_agent=EventAgentParams(
  598. model="event-model",
  599. temperature=0.4,
  600. max_tokens=80,
  601. enabled_tools=["handoff_note"],
  602. max_event_loops=1,
  603. ),
  604. )
  605. runtime = DebugRuntime(EventRoundStatsChatClient())
  606. outputs = [message async for message in runtime.run(request)]
  607. audits = [message for message in outputs if message["type"] == "audit"]
  608. chat_request = next(
  609. message for message in audits if message["event"] == "chat_agent_request"
  610. )
  611. assert chat_request["details"]["agent"] == "chat_agent"
  612. assert chat_request["details"]["params"]["model"] == "chat-model"
  613. assert chat_request["details"]["params"]["temperature"] == 0.1
  614. assert chat_request["details"]["params"]["max_tokens"] == 200
  615. assert chat_request["details"]["tools"] == []
  616. assert chat_request["details"]["messages"][0] == {
  617. "role": "system",
  618. "content": "You are a debugger.",
  619. "name": None,
  620. "tool_call_id": None,
  621. }
  622. chat_response = next(
  623. message for message in audits if message["event"] == "chat_agent_response"
  624. )
  625. assert chat_response["details"]["event_names"] == ["handoff_note"]
  626. assert chat_response["details"]["raw_chunks"] == [
  627. {
  628. "choices": [
  629. {
  630. "delta": {"content": "<agent_events>handoff_note</agent_events>"},
  631. "finish_reason": None,
  632. }
  633. ]
  634. }
  635. ]
  636. assert chat_response["details"]["usage"] == {
  637. "prompt_tokens": 3,
  638. "completion_tokens": 0,
  639. "total_tokens": 3,
  640. "cached_tokens": 0,
  641. }
  642. event_request = next(
  643. message for message in audits if message["event"] == "event_agent_request"
  644. )
  645. assert event_request["details"]["agent"] == "event_agent"
  646. assert event_request["details"]["params"]["model"] == "event-model"
  647. assert event_request["details"]["params"]["temperature"] == 0.4
  648. assert event_request["details"]["events"][0]["name"] == "handoff_note"
  649. assert event_request["details"]["tools"][0]["function"]["name"] == "handoff_note"
  650. assert "Authorization" not in str(event_request["details"])
  651. event_response = next(
  652. message for message in audits if message["event"] == "event_agent_response"
  653. )
  654. assert event_response["details"]["replies"][0]["role"] == "tool"
  655. assert '"tool": "handoff_note"' in event_response["details"]["replies"][0]["content"]
  656. assert event_response["details"]["raw_model_chunks"][0]["event_name"] == "handoff_note"
  657. assert event_response["details"]["raw_model_chunks"][0]["chunks"][0]["choices"][0]["delta"] == {
  658. "tool_calls": [
  659. {
  660. "index": 0,
  661. "function": {"name": "handoff_note"},
  662. }
  663. ]
  664. }
  665. @pytest.mark.asyncio
  666. async def test_runtime_round_started_separates_available_events_from_round_budget():
  667. request = DebugRunRequest(
  668. user_message="debug this",
  669. system_prompts=[],
  670. pre_messages=[],
  671. chat_agent=AgentParams(model="chat-model", temperature=0.1, max_tokens=200),
  672. event_agent=EventAgentParams(
  673. enabled_tools=["handoff_note"],
  674. max_event_loops=1,
  675. ),
  676. )
  677. runtime = DebugRuntime(EventRoundStatsChatClient())
  678. outputs = [message async for message in runtime.run(request)]
  679. round_starts = [
  680. message
  681. for message in outputs
  682. if message.get("event") == "chat_round_started"
  683. ]
  684. assert round_starts[0]["details"]["events_enabled"] == ["handoff_note"]
  685. assert round_starts[0]["details"]["configured_events"] == ["handoff_note"]
  686. assert round_starts[0]["details"]["event_generation_enabled"] is True
  687. assert round_starts[0]["details"]["event_prompt_events"] == ["handoff_note"]
  688. assert round_starts[1]["details"]["events_enabled"] == []
  689. assert round_starts[1]["details"]["configured_events"] == ["handoff_note"]
  690. assert round_starts[1]["details"]["event_generation_enabled"] is False
  691. assert round_starts[1]["details"]["event_prompt_events"] == []
  692. @pytest.mark.asyncio
  693. async def test_runtime_event_agent_history_excludes_chat_agent_system_context():
  694. request = DebugRunRequest(
  695. user_message="debug this",
  696. system_prompts=["ChatAgent root prompt."],
  697. pre_messages=[
  698. ChatMessage(role="system", content="ChatAgent pre system."),
  699. ChatMessage(role="user", content="earlier user"),
  700. ChatMessage(role="assistant", content="earlier assistant"),
  701. ],
  702. chat_agent=AgentParams(model="chat-model", temperature=0.1, max_tokens=200),
  703. event_agent=EventAgentParams(
  704. model="event-model",
  705. enabled_tools=["handoff_note"],
  706. max_event_loops=1,
  707. ),
  708. )
  709. client = ContextBoundaryChatClient()
  710. runtime = DebugRuntime(client)
  711. outputs = [message async for message in runtime.run(request)]
  712. event_request = next(
  713. message for message in outputs if message.get("event") == "event_agent_request"
  714. )
  715. assert [
  716. (message["role"], message["content"])
  717. for message in event_request["details"]["history"]
  718. ] == [
  719. ("user", "earlier user"),
  720. ("assistant", "earlier assistant"),
  721. ("user", "debug this"),
  722. ("assistant", "I will check."),
  723. ]
  724. assert not any(
  725. message["role"] == "system"
  726. for message in event_request["details"]["history"]
  727. )
  728. assert client.event_agent_messages
  729. assert [
  730. (message.role, message.content)
  731. for message in client.event_agent_messages[0]
  732. if message.content in {"ChatAgent root prompt.", "ChatAgent pre system."}
  733. ] == []
  734. @pytest.mark.asyncio
  735. async def test_runtime_session_event_agent_history_excludes_internal_replies():
  736. request = DebugRunRequest(
  737. user_message="debug this",
  738. system_prompts=["ChatAgent root prompt."],
  739. pre_messages=[ChatMessage(role="assistant", content="prior answer")],
  740. chat_agent=AgentParams(model="chat-model", temperature=0.1, max_tokens=200),
  741. event_agent=EventAgentParams(
  742. model="event-model",
  743. enabled_tools=["handoff_note"],
  744. max_event_loops=1,
  745. ),
  746. )
  747. runtime = DebugRuntime(ContextBoundaryChatClient())
  748. queues = runtime.start_session(request)
  749. outputs: list[dict[str, Any]] = []
  750. while not any(message["type"] == "turn_completed" for message in outputs):
  751. outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
  752. await runtime.aclose()
  753. event_request = next(
  754. message for message in outputs if message.get("event") == "event_agent_request"
  755. )
  756. assert [
  757. (message["role"], message["content"], message["name"])
  758. for message in event_request["details"]["history"]
  759. ] == [
  760. ("assistant", "prior answer", None),
  761. ("user", "debug this", None),
  762. ("assistant", "I will check.", None),
  763. ]
  764. assert not any(
  765. message["name"] == "event_agent"
  766. for message in event_request["details"]["history"]
  767. )
  768. @pytest.mark.asyncio
  769. async def test_runtime_outputs_event_as_soon_as_chat_stream_detects_it():
  770. RuntimeQueues = _runtime_queues_class()
  771. queues = RuntimeQueues()
  772. client = SlowAfterEventChatClient()
  773. runtime = DebugRuntime(client, queues=queues)
  774. request = DebugRunRequest(
  775. user_message="debug this",
  776. system_prompts=[],
  777. pre_messages=[],
  778. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  779. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=1),
  780. )
  781. runtime.start(request)
  782. try:
  783. assert await _next_non_audit_from_queue(queues) == {"type": "session_started"}
  784. assert await _next_non_audit_from_queue(queues) == {
  785. "type": "message_delta",
  786. "content": "Need event.",
  787. }
  788. await asyncio.wait_for(client.event_seen.wait(), timeout=1)
  789. event_message = await asyncio.wait_for(
  790. _next_non_audit_from_queue(queues),
  791. timeout=0.2,
  792. )
  793. assert event_message["type"] == "event"
  794. assert event_message["event"]["name"] == "handoff_note"
  795. finally:
  796. client.release_stream.set()
  797. await runtime.aclose()
  798. @pytest.mark.asyncio
  799. async def test_runtime_batches_round_events_before_continuing_chat_agent():
  800. def resolve_from_history(
  801. event: ToolCallEvent,
  802. context: ToolExecutionContext,
  803. ) -> dict[str, Any]:
  804. return {"message": context.history[-1].content, "event": event.name}
  805. registry = ToolRegistry(
  806. [
  807. ToolDefinition(
  808. name="handoff_note",
  809. description="Send a handoff note.",
  810. parameters={
  811. "type": "object",
  812. "properties": {"message": {"type": "string"}},
  813. "required": ["message"],
  814. },
  815. handler=lambda event: {
  816. "tool": event.name,
  817. "message": event.arguments["message"],
  818. },
  819. argument_resolver=resolve_from_history,
  820. ),
  821. ToolDefinition(
  822. name="audit_note",
  823. description="Send an audit note.",
  824. parameters={
  825. "type": "object",
  826. "properties": {"message": {"type": "string"}},
  827. "required": ["message"],
  828. },
  829. handler=lambda event: {
  830. "tool": event.name,
  831. "message": event.arguments["message"],
  832. },
  833. argument_resolver=resolve_from_history,
  834. ),
  835. ]
  836. )
  837. request = DebugRunRequest(
  838. user_message="debug this",
  839. system_prompts=[],
  840. pre_messages=[],
  841. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  842. event_agent=EventAgentParams(
  843. enabled_tools=["handoff_note", "audit_note"],
  844. max_event_loops=2,
  845. ),
  846. )
  847. client = MultiEventChatClient()
  848. runtime = DebugRuntime(client, registry=registry)
  849. outputs = [message async for message in runtime.run(request)]
  850. assert _message_types(outputs) == [
  851. "session_started",
  852. "message_delta",
  853. "event",
  854. "event",
  855. "tool_result",
  856. "tool_result",
  857. "round_stats",
  858. "message_delta",
  859. "round_stats",
  860. "done",
  861. ]
  862. assert client.calls == 2
  863. assert [message.role for message in client.second_call_messages] == [
  864. "system",
  865. "user",
  866. "assistant",
  867. "user",
  868. ]
  869. assistant_message = client.second_call_messages[2]
  870. assert assistant_message.content == "Checking events."
  871. assert not any(message.role == "tool" for message in client.second_call_messages)
  872. assert client.second_call_messages[-1].content == (
  873. "EventAgent results:\n"
  874. '{"tool": "handoff_note", "message": "Checking events."}\n'
  875. '{"tool": "audit_note", "message": "Checking events."}'
  876. )
  877. assert client.second_call_messages[-1].name == "event_agent"
  878. @pytest.mark.asyncio
  879. async def test_runtime_start_returns_queues_for_downstream_output_consumer():
  880. request = DebugRunRequest(
  881. user_message="debug this",
  882. system_prompts=[],
  883. pre_messages=[],
  884. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  885. event_agent=EventAgentParams(enabled_tools=[], max_event_loops=1),
  886. )
  887. runtime = DebugRuntime(RoundStatsChatClient())
  888. queues = runtime.start(request)
  889. outputs: list[dict[str, Any]] = []
  890. while True:
  891. message = await asyncio.wait_for(queues.output.get(), timeout=1)
  892. outputs.append(message)
  893. if message["type"] == "done":
  894. break
  895. assert _message_types(outputs) == [
  896. "session_started",
  897. "message_delta",
  898. "usage",
  899. "round_stats",
  900. "done",
  901. ]
  902. @pytest.mark.asyncio
  903. async def test_runtime_finalizes_chat_after_reaching_event_loop_limit():
  904. request = DebugRunRequest(
  905. user_message="debug this",
  906. system_prompts=[],
  907. pre_messages=[],
  908. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  909. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=1),
  910. )
  911. client = EventLoopLimitChatClient()
  912. runtime = DebugRuntime(client)
  913. outputs = [message async for message in runtime.run(request)]
  914. business_outputs = _without_audit(outputs)
  915. assert client.calls == 2
  916. assert client.tools_by_call[0] == []
  917. assert client.tools_by_call[1] == []
  918. assert "Available events:" in client.messages_by_call[0][0].content
  919. assert not any(
  920. "Available events:" in message.content
  921. for message in client.messages_by_call[1]
  922. if message.role == "system"
  923. )
  924. assert _message_types(outputs) == [
  925. "session_started",
  926. "event",
  927. "tool_result",
  928. "round_stats",
  929. "message_delta",
  930. "round_stats",
  931. "done",
  932. ]
  933. assert business_outputs[4]["content"] == "final after event limit"
  934. @pytest.mark.asyncio
  935. async def test_runtime_buffers_upstream_user_input_until_after_matching_tool_reply():
  936. RuntimeQueues = _runtime_queues_class()
  937. queues = RuntimeQueues()
  938. request = DebugRunRequest(
  939. user_message="debug this",
  940. system_prompts=[],
  941. pre_messages=[],
  942. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  943. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=2),
  944. )
  945. client = StrictHistoryChatClient()
  946. runtime = DebugRuntime(client, queues=queues)
  947. stream = runtime.run(request)
  948. assert await _next_non_audit(stream) == {"type": "session_started"}
  949. event_message = await _next_non_audit(stream)
  950. assert event_message["type"] == "event"
  951. await queues.input.put(ChatMessage(role="user", content="follow-up while tool runs"))
  952. remaining = [message async for message in stream]
  953. assert remaining[-1] == {"type": "done"}
  954. assert [message.role for message in client.second_call_messages] == [
  955. "system",
  956. "user",
  957. "assistant",
  958. "user",
  959. "user",
  960. ]
  961. assert not any(message.role == "tool" for message in client.second_call_messages)
  962. assert client.second_call_messages[3].content.startswith("EventAgent results:\n")
  963. assert client.second_call_messages[3].name == "event_agent"
  964. assert client.second_call_messages[4].content == "follow-up while tool runs"
  965. @pytest.mark.asyncio
  966. async def test_runtime_continues_when_event_agent_tool_handler_raises():
  967. def fail_tool(event: ToolCallEvent) -> dict[str, Any]:
  968. raise RuntimeError("boom")
  969. registry = ToolRegistry(
  970. [
  971. ToolDefinition(
  972. name="handoff_note",
  973. description="Broken handoff tool.",
  974. parameters={"type": "object"},
  975. handler=fail_tool,
  976. )
  977. ]
  978. )
  979. request = DebugRunRequest(
  980. user_message="debug this",
  981. system_prompts=[],
  982. pre_messages=[],
  983. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  984. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=2),
  985. )
  986. runtime = DebugRuntime(FakeChatClient(), registry=registry)
  987. outputs = await asyncio.wait_for(
  988. _collect_outputs(runtime.run(request)),
  989. timeout=1,
  990. )
  991. business_outputs = _without_audit(outputs)
  992. assert _message_types(outputs) == [
  993. "session_started",
  994. "event",
  995. "tool_result",
  996. "round_stats",
  997. "message_delta",
  998. "round_stats",
  999. "done",
  1000. ]
  1001. assert json.loads(business_outputs[2]["message"]["content"]) == {
  1002. "tool": "handoff_note",
  1003. "error": "tool handler failed: boom",
  1004. }
  1005. @pytest.mark.asyncio
  1006. async def test_runtime_uses_event_and_input_queues_for_event_agent_handoff():
  1007. RuntimeQueues = _runtime_queues_class()
  1008. queue_log: list[tuple[str, str, str]] = []
  1009. queues = RuntimeQueues(
  1010. input=RecordingQueue("input", queue_log),
  1011. output=RecordingQueue("output", queue_log),
  1012. events=RecordingQueue("events", queue_log),
  1013. )
  1014. request = DebugRunRequest(
  1015. user_message="debug this",
  1016. system_prompts=["You are a debugger."],
  1017. pre_messages=[],
  1018. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  1019. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=3),
  1020. )
  1021. runtime = DebugRuntime(FakeChatClient(), queues=queues)
  1022. outputs = [message async for message in runtime.run(request)]
  1023. assert _message_types(outputs) == [
  1024. "session_started",
  1025. "event",
  1026. "tool_result",
  1027. "round_stats",
  1028. "message_delta",
  1029. "round_stats",
  1030. "done",
  1031. ]
  1032. assert queue_log.index(("input", "put", "user")) < queue_log.index(
  1033. ("input", "get", "user")
  1034. )
  1035. assert queue_log.index(
  1036. ("events", "put", "event_request:handoff_note:call_1")
  1037. ) < queue_log.index(("events", "get", "event_request:handoff_note:call_1"))
  1038. assert queue_log.index(
  1039. ("events", "get", "event_request:handoff_note:call_1")
  1040. ) < queue_log.index(("input", "put", "tool:call_1"))
  1041. assert queue_log.index(("input", "put", "tool:call_1")) < queue_log.index(
  1042. ("input", "get", "tool:call_1")
  1043. )
  1044. @pytest.mark.asyncio
  1045. async def test_runtime_run_consumes_output_queue_in_stream_order():
  1046. RuntimeQueues = _runtime_queues_class()
  1047. queue_log: list[tuple[str, str, str]] = []
  1048. queues = RuntimeQueues(
  1049. input=RecordingQueue("input", queue_log),
  1050. output=RecordingQueue("output", queue_log),
  1051. events=RecordingQueue("events", queue_log),
  1052. )
  1053. request = DebugRunRequest(
  1054. user_message="debug this",
  1055. system_prompts=["You are a debugger."],
  1056. pre_messages=[],
  1057. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  1058. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=3),
  1059. )
  1060. runtime = DebugRuntime(FakeChatClient(), queues=queues)
  1061. outputs = [message async for message in runtime.run(request)]
  1062. assert _message_types(outputs) == [
  1063. "session_started",
  1064. "event",
  1065. "tool_result",
  1066. "round_stats",
  1067. "message_delta",
  1068. "round_stats",
  1069. "done",
  1070. ]
  1071. output_puts = [
  1072. entry[2] for entry in queue_log if entry[0] == "output" and entry[1] == "put"
  1073. ]
  1074. output_gets = [
  1075. entry[2] for entry in queue_log if entry[0] == "output" and entry[1] == "get"
  1076. ]
  1077. assert [message for message in output_puts if message != "output:audit"] == [
  1078. "output:session_started",
  1079. "output:event",
  1080. "output:tool_result",
  1081. "output:round_stats",
  1082. "output:message_delta",
  1083. "output:round_stats",
  1084. "output:done",
  1085. ]
  1086. assert output_gets == output_puts
  1087. @pytest.mark.asyncio
  1088. async def test_runtime_continues_with_event_summary_without_tool_call_history():
  1089. request = DebugRunRequest(
  1090. user_message="debug this",
  1091. system_prompts=["You are a debugger."],
  1092. pre_messages=[],
  1093. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  1094. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=2),
  1095. )
  1096. client = StrictHistoryChatClient()
  1097. runtime = DebugRuntime(client)
  1098. outputs = [message async for message in runtime.run(request)]
  1099. assert client.calls == 2
  1100. assert [message.role for message in client.second_call_messages] == [
  1101. "system",
  1102. "system",
  1103. "user",
  1104. "assistant",
  1105. "user",
  1106. ]
  1107. assistant_message = client.second_call_messages[3]
  1108. assert assistant_message.content == ""
  1109. assert "handoff_note" in client.second_call_messages[1].content
  1110. assert not any(message.role == "tool" for message in client.second_call_messages)
  1111. assert client.second_call_messages[4].content.startswith("EventAgent results:\n")
  1112. assert client.second_call_messages[4].name == "event_agent"
  1113. assert outputs[-1] == {"type": "done"}
  1114. @pytest.mark.asyncio
  1115. async def test_runtime_passes_event_catalog_system_message_without_chat_tools():
  1116. registry = ToolRegistry(
  1117. [
  1118. ToolDefinition(
  1119. name="handoff_note",
  1120. description="Registry-owned handoff tool.",
  1121. parameters={
  1122. "type": "object",
  1123. "properties": {
  1124. "message": {"type": "string"},
  1125. "priority": {"type": "number"},
  1126. },
  1127. "required": ["message"],
  1128. },
  1129. handler=lambda event: {"tool": event.name, "message": "handled"},
  1130. )
  1131. ]
  1132. )
  1133. request = DebugRunRequest(
  1134. user_message="debug this",
  1135. system_prompts=[],
  1136. pre_messages=[],
  1137. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  1138. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=2),
  1139. )
  1140. client = ToolCapturingChatClient()
  1141. runtime = DebugRuntime(client, registry=registry)
  1142. outputs = [message async for message in runtime.run(request)]
  1143. assert outputs[-1] == {"type": "done"}
  1144. assert client.tools == []
  1145. assert client.messages[0].role == "system"
  1146. assert "Available events:" in client.messages[0].content
  1147. assert "- handoff_note: Registry-owned handoff tool." in client.messages[0].content
  1148. assert "message" not in client.messages[0].content
  1149. assert "priority" not in client.messages[0].content
  1150. @pytest.mark.asyncio
  1151. async def test_runtime_emits_round_stats_with_clock_and_usage_after_model_turn():
  1152. request = DebugRunRequest(
  1153. user_message="debug this",
  1154. system_prompts=[],
  1155. pre_messages=[],
  1156. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  1157. event_agent=EventAgentParams(enabled_tools=[], max_event_loops=1),
  1158. )
  1159. ticks = iter(
  1160. [
  1161. 0.9,
  1162. 0.91,
  1163. 1.0,
  1164. 1.01,
  1165. 1.02,
  1166. 1.123,
  1167. 1.2,
  1168. 1.25,
  1169. 1.3,
  1170. 1.456,
  1171. 1.7,
  1172. 1.8,
  1173. ]
  1174. )
  1175. runtime = DebugRuntime(RoundStatsChatClient(), clock=lambda: next(ticks))
  1176. outputs = [message async for message in runtime.run(request)]
  1177. business_outputs = _without_audit(outputs)
  1178. assert _message_types(outputs) == [
  1179. "session_started",
  1180. "message_delta",
  1181. "usage",
  1182. "round_stats",
  1183. "done",
  1184. ]
  1185. assert business_outputs[3] == {
  1186. "type": "round_stats",
  1187. "round_index": 1,
  1188. "ttft_ms": 123,
  1189. "elapsed_ms": 456,
  1190. "prompt_tokens": 10,
  1191. "completion_tokens": 20,
  1192. "total_tokens": 30,
  1193. "cached_tokens": 5,
  1194. "had_event": False,
  1195. }
  1196. @pytest.mark.asyncio
  1197. async def test_runtime_emits_round_stats_for_each_chat_call_in_event_handoff():
  1198. request = DebugRunRequest(
  1199. user_message="debug this",
  1200. system_prompts=[],
  1201. pre_messages=[],
  1202. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  1203. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=2),
  1204. )
  1205. ticks = iter(
  1206. [
  1207. 1.9,
  1208. 1.91,
  1209. 2.0,
  1210. 2.01,
  1211. 2.02,
  1212. 2.03,
  1213. 2.04,
  1214. 2.05,
  1215. 2.06,
  1216. 2.07,
  1217. 2.25,
  1218. 2.26,
  1219. 3.0,
  1220. 3.01,
  1221. 3.02,
  1222. 3.05,
  1223. 3.1,
  1224. 3.15,
  1225. 3.18,
  1226. 3.2,
  1227. 3.23,
  1228. 3.24,
  1229. 3.25,
  1230. 3.26,
  1231. ]
  1232. )
  1233. client = EventRoundStatsChatClient()
  1234. runtime = DebugRuntime(client, clock=lambda: next(ticks))
  1235. outputs = [message async for message in runtime.run(request)]
  1236. stats = [message for message in outputs if message["type"] == "round_stats"]
  1237. assert client.calls == 2
  1238. assert stats == [
  1239. {
  1240. "type": "round_stats",
  1241. "round_index": 1,
  1242. "ttft_ms": None,
  1243. "elapsed_ms": 250,
  1244. "prompt_tokens": 3,
  1245. "completion_tokens": 0,
  1246. "total_tokens": 3,
  1247. "cached_tokens": 0,
  1248. "had_event": True,
  1249. },
  1250. {
  1251. "type": "round_stats",
  1252. "round_index": 2,
  1253. "ttft_ms": 50,
  1254. "elapsed_ms": 200,
  1255. "prompt_tokens": 4,
  1256. "completion_tokens": 6,
  1257. "total_tokens": 10,
  1258. "cached_tokens": 0,
  1259. "had_event": False,
  1260. },
  1261. ]
  1262. @pytest.mark.asyncio
  1263. async def test_runtime_session_resets_event_budget_for_each_user_turn():
  1264. request = DebugRunRequest(
  1265. user_message="first turn",
  1266. system_prompts=[],
  1267. pre_messages=[],
  1268. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  1269. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=1),
  1270. )
  1271. client = TwoTurnSessionChatClient()
  1272. runtime = DebugRuntime(client)
  1273. queues = runtime.start_session(request)
  1274. outputs: list[dict[str, Any]] = []
  1275. while len([message for message in outputs if message["type"] == "turn_completed"]) < 1:
  1276. outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
  1277. await queues.input.put(ChatMessage(role="user", content="second turn"))
  1278. while len([message for message in outputs if message["type"] == "turn_completed"]) < 2:
  1279. outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
  1280. await runtime.aclose()
  1281. business_types = [message["type"] for message in _without_audit(outputs)]
  1282. assert business_types.count("turn_started") == 2
  1283. assert business_types.count("turn_completed") == 2
  1284. assert client.calls == 4
  1285. assert "Available events:" in client.messages_by_call[0][0].content
  1286. assert "Available events:" in client.messages_by_call[2][0].content
  1287. @pytest.mark.asyncio
  1288. async def test_runtime_persists_session_turn_messages_audit_and_usage(tmp_path):
  1289. store = SQLiteSessionStore(tmp_path / "agent_lab.sqlite3")
  1290. request = DebugRunRequest(
  1291. session_id="session-1",
  1292. user_message="persist this",
  1293. system_prompts=["You are a debugger."],
  1294. pre_messages=[],
  1295. chat_agent=AgentParams(model="chat-model", temperature=0.1, max_tokens=200),
  1296. event_agent=EventAgentParams(enabled_tools=[], max_event_loops=1),
  1297. )
  1298. runtime = DebugRuntime(RoundStatsChatClient(), session_store=store)
  1299. queues = runtime.start_session(request)
  1300. outputs: list[dict[str, Any]] = []
  1301. while not any(message["type"] == "turn_completed" for message in outputs):
  1302. outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
  1303. await runtime.aclose()
  1304. assert _without_audit(outputs)[0] == {
  1305. "type": "session_started",
  1306. "session_id": "session-1",
  1307. }
  1308. assert store.get_session("session-1")["config"]["chat_agent"]["model"] == "chat-model"
  1309. assert [
  1310. (message["turn_index"], message["role"], message["content"])
  1311. for message in store.list_messages("session-1")
  1312. ] == [
  1313. (1, "user", "persist this"),
  1314. (1, "assistant", "hello"),
  1315. ]
  1316. audit_events = [
  1317. audit["event"]
  1318. for audit in store.list_audit_logs("session-1")
  1319. ]
  1320. assert "session_started" in audit_events
  1321. assert "chat_agent_request" in audit_events
  1322. assert "turn_completed" in audit_events
  1323. usage = store.usage_summary("session-1")
  1324. assert usage["calls"][0]["total_tokens"] == 30
  1325. assert usage["turns"][0]["turn_index"] == 1
  1326. assert usage["session"]["total_tokens"] == 30
  1327. @pytest.mark.asyncio
  1328. async def test_runtime_continues_persisted_turn_indexes_for_existing_session(tmp_path):
  1329. store = SQLiteSessionStore(tmp_path / "agent_lab.sqlite3")
  1330. session_id = store.create_session(title="existing", config={})
  1331. store.start_turn(session_id, turn_index=1, user_message="old")
  1332. request = DebugRunRequest(
  1333. session_id=session_id,
  1334. user_message="new",
  1335. system_prompts=[],
  1336. pre_messages=[],
  1337. chat_agent=AgentParams(model="chat-model", temperature=0.1, max_tokens=200),
  1338. event_agent=EventAgentParams(enabled_tools=[], max_event_loops=1),
  1339. )
  1340. runtime = DebugRuntime(RoundStatsChatClient(), session_store=store)
  1341. queues = runtime.start_session(request)
  1342. outputs: list[dict[str, Any]] = []
  1343. while not any(message["type"] == "turn_completed" for message in outputs):
  1344. outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
  1345. await runtime.aclose()
  1346. assert [
  1347. (message["turn_index"], message["content"])
  1348. for message in store.list_messages(session_id)
  1349. ] == [
  1350. (2, "new"),
  1351. (2, "hello"),
  1352. ]