test_debug_runtime.py 52 KB

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