test_debug_runtime.py 52 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581
  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"] == []
  724. @pytest.mark.asyncio
  725. async def test_runtime_round_started_separates_available_events_from_round_budget():
  726. request = DebugRunRequest(
  727. user_message="debug this",
  728. system_prompts=[],
  729. pre_messages=[],
  730. chat_agent=AgentParams(model="chat-model", temperature=0.1, max_tokens=200),
  731. event_agent=EventAgentParams(
  732. enabled_tools=["handoff_note"],
  733. max_event_loops=1,
  734. ),
  735. )
  736. runtime = DebugRuntime(EventRoundStatsChatClient())
  737. outputs = [message async for message in runtime.run(request)]
  738. round_starts = [
  739. message
  740. for message in outputs
  741. if message.get("event") == "chat_round_started"
  742. ]
  743. assert round_starts[0]["details"]["events_enabled"] == ["handoff_note"]
  744. assert round_starts[0]["details"]["configured_events"] == ["handoff_note"]
  745. assert round_starts[0]["details"]["event_generation_enabled"] is True
  746. assert round_starts[0]["details"]["event_prompt_events"] == ["handoff_note"]
  747. assert round_starts[1]["details"]["events_enabled"] == []
  748. assert round_starts[1]["details"]["configured_events"] == ["handoff_note"]
  749. assert round_starts[1]["details"]["event_generation_enabled"] is False
  750. assert round_starts[1]["details"]["event_prompt_events"] == []
  751. @pytest.mark.asyncio
  752. async def test_runtime_event_agent_history_excludes_chat_agent_system_context():
  753. request = DebugRunRequest(
  754. user_message="debug this",
  755. system_prompts=["ChatAgent root prompt."],
  756. pre_messages=[
  757. ChatMessage(role="system", content="ChatAgent pre system."),
  758. ChatMessage(role="user", content="earlier user"),
  759. ChatMessage(role="assistant", content="earlier assistant"),
  760. ],
  761. chat_agent=AgentParams(model="chat-model", temperature=0.1, max_tokens=200),
  762. event_agent=EventAgentParams(
  763. model="event-model",
  764. enabled_tools=["handoff_note"],
  765. max_event_loops=1,
  766. ),
  767. )
  768. client = ContextBoundaryChatClient()
  769. runtime = DebugRuntime(client)
  770. outputs = [message async for message in runtime.run(request)]
  771. event_request = next(
  772. message for message in outputs if message.get("event") == "event_agent_request"
  773. )
  774. assert [
  775. (message["role"], message["content"])
  776. for message in event_request["details"]["history"]
  777. ] == [
  778. ("user", "earlier user"),
  779. ("assistant", "earlier assistant"),
  780. ("user", "debug this"),
  781. ("assistant", "I will check."),
  782. ]
  783. assert not any(
  784. message["role"] == "system"
  785. for message in event_request["details"]["history"]
  786. )
  787. assert client.event_agent_messages == []
  788. @pytest.mark.asyncio
  789. async def test_runtime_session_event_agent_history_excludes_internal_replies():
  790. request = DebugRunRequest(
  791. user_message="debug this",
  792. system_prompts=["ChatAgent root prompt."],
  793. pre_messages=[ChatMessage(role="assistant", content="prior answer")],
  794. chat_agent=AgentParams(model="chat-model", temperature=0.1, max_tokens=200),
  795. event_agent=EventAgentParams(
  796. model="event-model",
  797. enabled_tools=["handoff_note"],
  798. max_event_loops=1,
  799. ),
  800. )
  801. runtime = DebugRuntime(ContextBoundaryChatClient())
  802. queues = runtime.start_session(request)
  803. outputs: list[dict[str, Any]] = []
  804. while not any(message["type"] == "turn_completed" for message in outputs):
  805. outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
  806. await runtime.aclose()
  807. event_request = next(
  808. message for message in outputs if message.get("event") == "event_agent_request"
  809. )
  810. assert [
  811. (message["role"], message["content"], message["name"])
  812. for message in event_request["details"]["history"]
  813. ] == [
  814. ("assistant", "prior answer", None),
  815. ("user", "debug this", None),
  816. ("assistant", "I will check.", None),
  817. ]
  818. assert not any(
  819. message["name"] == "event_agent"
  820. for message in event_request["details"]["history"]
  821. )
  822. @pytest.mark.asyncio
  823. async def test_runtime_outputs_event_as_soon_as_chat_stream_detects_it():
  824. RuntimeQueues = _runtime_queues_class()
  825. queues = RuntimeQueues()
  826. client = SlowAfterEventChatClient()
  827. runtime = DebugRuntime(client, queues=queues)
  828. request = DebugRunRequest(
  829. user_message="debug this",
  830. system_prompts=[],
  831. pre_messages=[],
  832. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  833. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=1),
  834. )
  835. runtime.start(request)
  836. try:
  837. assert await _next_non_audit_from_queue(queues) == {"type": "session_started"}
  838. assert await _next_non_audit_from_queue(queues) == {
  839. "type": "message_delta",
  840. "content": "Need event.",
  841. }
  842. await asyncio.wait_for(client.event_seen.wait(), timeout=1)
  843. event_message = await asyncio.wait_for(
  844. _next_non_audit_from_queue(queues),
  845. timeout=0.2,
  846. )
  847. assert event_message["type"] == "event"
  848. assert event_message["event"]["name"] == "handoff_note"
  849. finally:
  850. client.release_stream.set()
  851. await runtime.aclose()
  852. @pytest.mark.asyncio
  853. async def test_runtime_batches_round_events_before_continuing_chat_agent():
  854. def resolve_from_history(
  855. event: ToolCallEvent,
  856. context: ToolExecutionContext,
  857. ) -> dict[str, Any]:
  858. return {"message": context.history[-1].content, "event": event.name}
  859. registry = ToolRegistry(
  860. [
  861. ToolDefinition(
  862. name="handoff_note",
  863. description="Send a handoff note.",
  864. parameters={
  865. "type": "object",
  866. "properties": {"message": {"type": "string"}},
  867. "required": ["message"],
  868. },
  869. handler=lambda event: {
  870. "tool": event.name,
  871. "message": event.arguments["message"],
  872. },
  873. argument_resolver=resolve_from_history,
  874. ),
  875. ToolDefinition(
  876. name="audit_note",
  877. description="Send an audit note.",
  878. parameters={
  879. "type": "object",
  880. "properties": {"message": {"type": "string"}},
  881. "required": ["message"],
  882. },
  883. handler=lambda event: {
  884. "tool": event.name,
  885. "message": event.arguments["message"],
  886. },
  887. argument_resolver=resolve_from_history,
  888. ),
  889. ]
  890. )
  891. request = DebugRunRequest(
  892. user_message="debug this",
  893. system_prompts=[],
  894. pre_messages=[],
  895. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  896. event_agent=EventAgentParams(
  897. enabled_tools=["handoff_note", "audit_note"],
  898. max_event_loops=2,
  899. ),
  900. )
  901. client = MultiEventChatClient()
  902. runtime = DebugRuntime(client, registry=registry)
  903. outputs = [message async for message in runtime.run(request)]
  904. assert _message_types(outputs) == [
  905. "session_started",
  906. "message_delta",
  907. "event",
  908. "event",
  909. "tool_result",
  910. "tool_result",
  911. "round_stats",
  912. "message_delta",
  913. "round_stats",
  914. "done",
  915. ]
  916. assert client.calls == 2
  917. assert [message.role for message in client.second_call_messages] == [
  918. "system",
  919. "user",
  920. "assistant",
  921. "user",
  922. ]
  923. assistant_message = client.second_call_messages[2]
  924. assert assistant_message.content == "Checking events."
  925. assert not any(message.role == "tool" for message in client.second_call_messages)
  926. assert client.second_call_messages[-1].content == (
  927. "EventAgent results:\n"
  928. '{"tool": "handoff_note", "message": "Checking events."}\n'
  929. '{"tool": "audit_note", "message": "Checking events."}'
  930. )
  931. assert client.second_call_messages[-1].name == "event_agent"
  932. @pytest.mark.asyncio
  933. async def test_runtime_start_returns_queues_for_downstream_output_consumer():
  934. request = DebugRunRequest(
  935. user_message="debug this",
  936. system_prompts=[],
  937. pre_messages=[],
  938. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  939. event_agent=EventAgentParams(enabled_tools=[], max_event_loops=1),
  940. )
  941. runtime = DebugRuntime(RoundStatsChatClient())
  942. queues = runtime.start(request)
  943. outputs: list[dict[str, Any]] = []
  944. while True:
  945. message = await asyncio.wait_for(queues.output.get(), timeout=1)
  946. outputs.append(message)
  947. if message["type"] == "done":
  948. break
  949. assert _message_types(outputs) == [
  950. "session_started",
  951. "message_delta",
  952. "usage",
  953. "round_stats",
  954. "done",
  955. ]
  956. @pytest.mark.asyncio
  957. async def test_runtime_finalizes_chat_after_reaching_event_loop_limit():
  958. request = DebugRunRequest(
  959. user_message="debug this",
  960. system_prompts=[],
  961. pre_messages=[],
  962. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  963. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=1),
  964. )
  965. client = EventLoopLimitChatClient()
  966. runtime = DebugRuntime(client)
  967. outputs = [message async for message in runtime.run(request)]
  968. business_outputs = _without_audit(outputs)
  969. assert client.calls == 2
  970. assert client.tools_by_call[0] == []
  971. assert client.tools_by_call[1] == []
  972. assert "Available events:" in client.messages_by_call[0][0].content
  973. assert not any(
  974. "Available events:" in message.content
  975. for message in client.messages_by_call[1]
  976. if message.role == "system"
  977. )
  978. assert _message_types(outputs) == [
  979. "session_started",
  980. "event",
  981. "tool_result",
  982. "round_stats",
  983. "message_delta",
  984. "round_stats",
  985. "done",
  986. ]
  987. assert business_outputs[4]["content"] == "final after event limit"
  988. @pytest.mark.asyncio
  989. async def test_runtime_buffers_upstream_user_input_until_after_matching_tool_reply():
  990. RuntimeQueues = _runtime_queues_class()
  991. queues = RuntimeQueues()
  992. request = DebugRunRequest(
  993. user_message="debug this",
  994. system_prompts=[],
  995. pre_messages=[],
  996. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  997. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=2),
  998. )
  999. client = StrictHistoryChatClient()
  1000. runtime = DebugRuntime(client, queues=queues)
  1001. stream = runtime.run(request)
  1002. assert await _next_non_audit(stream) == {"type": "session_started"}
  1003. event_message = await _next_non_audit(stream)
  1004. assert event_message["type"] == "event"
  1005. await queues.input.put(ChatMessage(role="user", content="follow-up while tool runs"))
  1006. remaining = [message async for message in stream]
  1007. assert remaining[-1] == {"type": "done"}
  1008. assert [message.role for message in client.second_call_messages] == [
  1009. "system",
  1010. "user",
  1011. "assistant",
  1012. "user",
  1013. "user",
  1014. ]
  1015. assert not any(message.role == "tool" for message in client.second_call_messages)
  1016. assert client.second_call_messages[3].content.startswith("EventAgent results:\n")
  1017. assert client.second_call_messages[3].name == "event_agent"
  1018. assert client.second_call_messages[4].content == "follow-up while tool runs"
  1019. @pytest.mark.asyncio
  1020. async def test_runtime_continues_when_event_agent_tool_handler_raises():
  1021. def fail_tool(event: ToolCallEvent) -> dict[str, Any]:
  1022. raise RuntimeError("boom")
  1023. registry = ToolRegistry(
  1024. [
  1025. ToolDefinition(
  1026. name="handoff_note",
  1027. description="Broken handoff tool.",
  1028. parameters={"type": "object"},
  1029. handler=fail_tool,
  1030. )
  1031. ]
  1032. )
  1033. request = DebugRunRequest(
  1034. user_message="debug this",
  1035. system_prompts=[],
  1036. pre_messages=[],
  1037. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  1038. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=2),
  1039. )
  1040. runtime = DebugRuntime(FakeChatClient(), registry=registry)
  1041. outputs = await asyncio.wait_for(
  1042. _collect_outputs(runtime.run(request)),
  1043. timeout=1,
  1044. )
  1045. business_outputs = _without_audit(outputs)
  1046. assert _message_types(outputs) == [
  1047. "session_started",
  1048. "event",
  1049. "tool_result",
  1050. "round_stats",
  1051. "message_delta",
  1052. "round_stats",
  1053. "done",
  1054. ]
  1055. assert json.loads(business_outputs[2]["message"]["content"]) == {
  1056. "tool": "handoff_note",
  1057. "error": "tool handler failed: boom",
  1058. }
  1059. @pytest.mark.asyncio
  1060. async def test_runtime_uses_event_and_input_queues_for_event_agent_handoff():
  1061. RuntimeQueues = _runtime_queues_class()
  1062. queue_log: list[tuple[str, str, str]] = []
  1063. queues = RuntimeQueues(
  1064. input=RecordingQueue("input", queue_log),
  1065. output=RecordingQueue("output", queue_log),
  1066. events=RecordingQueue("events", queue_log),
  1067. )
  1068. request = DebugRunRequest(
  1069. user_message="debug this",
  1070. system_prompts=["You are a debugger."],
  1071. pre_messages=[],
  1072. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  1073. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=3),
  1074. )
  1075. runtime = DebugRuntime(FakeChatClient(), queues=queues)
  1076. outputs = [message async for message in runtime.run(request)]
  1077. assert _message_types(outputs) == [
  1078. "session_started",
  1079. "event",
  1080. "tool_result",
  1081. "round_stats",
  1082. "message_delta",
  1083. "round_stats",
  1084. "done",
  1085. ]
  1086. assert queue_log.index(("input", "put", "user")) < queue_log.index(
  1087. ("input", "get", "user")
  1088. )
  1089. assert queue_log.index(
  1090. ("events", "put", "event_request:handoff_note:call_1")
  1091. ) < queue_log.index(("events", "get", "event_request:handoff_note:call_1"))
  1092. assert queue_log.index(
  1093. ("events", "get", "event_request:handoff_note:call_1")
  1094. ) < queue_log.index(("input", "put", "tool:call_1"))
  1095. assert queue_log.index(("input", "put", "tool:call_1")) < queue_log.index(
  1096. ("input", "get", "tool:call_1")
  1097. )
  1098. @pytest.mark.asyncio
  1099. async def test_runtime_run_consumes_output_queue_in_stream_order():
  1100. RuntimeQueues = _runtime_queues_class()
  1101. queue_log: list[tuple[str, str, str]] = []
  1102. queues = RuntimeQueues(
  1103. input=RecordingQueue("input", queue_log),
  1104. output=RecordingQueue("output", queue_log),
  1105. events=RecordingQueue("events", queue_log),
  1106. )
  1107. request = DebugRunRequest(
  1108. user_message="debug this",
  1109. system_prompts=["You are a debugger."],
  1110. pre_messages=[],
  1111. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  1112. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=3),
  1113. )
  1114. runtime = DebugRuntime(FakeChatClient(), queues=queues)
  1115. outputs = [message async for message in runtime.run(request)]
  1116. assert _message_types(outputs) == [
  1117. "session_started",
  1118. "event",
  1119. "tool_result",
  1120. "round_stats",
  1121. "message_delta",
  1122. "round_stats",
  1123. "done",
  1124. ]
  1125. output_puts = [
  1126. entry[2] for entry in queue_log if entry[0] == "output" and entry[1] == "put"
  1127. ]
  1128. output_gets = [
  1129. entry[2] for entry in queue_log if entry[0] == "output" and entry[1] == "get"
  1130. ]
  1131. assert [message for message in output_puts if message != "output:audit"] == [
  1132. "output:session_started",
  1133. "output:event",
  1134. "output:tool_result",
  1135. "output:round_stats",
  1136. "output:message_delta",
  1137. "output:round_stats",
  1138. "output:done",
  1139. ]
  1140. assert output_gets == output_puts
  1141. @pytest.mark.asyncio
  1142. async def test_runtime_continues_with_event_summary_without_tool_call_history():
  1143. request = DebugRunRequest(
  1144. user_message="debug this",
  1145. system_prompts=["You are a debugger."],
  1146. pre_messages=[],
  1147. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  1148. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=2),
  1149. )
  1150. client = StrictHistoryChatClient()
  1151. runtime = DebugRuntime(client)
  1152. outputs = [message async for message in runtime.run(request)]
  1153. assert client.calls == 2
  1154. assert [message.role for message in client.second_call_messages] == [
  1155. "system",
  1156. "system",
  1157. "user",
  1158. "assistant",
  1159. "user",
  1160. ]
  1161. assistant_message = client.second_call_messages[3]
  1162. assert assistant_message.content == ""
  1163. assert "handoff_note" in client.second_call_messages[1].content
  1164. assert not any(message.role == "tool" for message in client.second_call_messages)
  1165. assert client.second_call_messages[4].content.startswith("EventAgent results:\n")
  1166. assert client.second_call_messages[4].name == "event_agent"
  1167. assert outputs[-1] == {"type": "done"}
  1168. @pytest.mark.asyncio
  1169. async def test_runtime_passes_event_catalog_system_message_without_chat_tools():
  1170. registry = ToolRegistry(
  1171. [
  1172. ToolDefinition(
  1173. name="handoff_note",
  1174. description="Registry-owned handoff tool.",
  1175. parameters={
  1176. "type": "object",
  1177. "properties": {
  1178. "message": {"type": "string"},
  1179. "priority": {"type": "number"},
  1180. },
  1181. "required": ["message"],
  1182. },
  1183. handler=lambda event: {"tool": event.name, "message": "handled"},
  1184. )
  1185. ]
  1186. )
  1187. request = DebugRunRequest(
  1188. user_message="debug this",
  1189. system_prompts=[],
  1190. pre_messages=[],
  1191. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  1192. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=2),
  1193. )
  1194. client = ToolCapturingChatClient()
  1195. runtime = DebugRuntime(client, registry=registry)
  1196. outputs = [message async for message in runtime.run(request)]
  1197. assert outputs[-1] == {"type": "done"}
  1198. assert client.tools == []
  1199. assert client.messages[0].role == "system"
  1200. assert "Available events:" in client.messages[0].content
  1201. assert "- handoff_note: Registry-owned handoff tool." in client.messages[0].content
  1202. assert "message" not in client.messages[0].content
  1203. assert "priority" not in client.messages[0].content
  1204. @pytest.mark.asyncio
  1205. async def test_runtime_emits_round_stats_with_clock_and_usage_after_model_turn():
  1206. request = DebugRunRequest(
  1207. user_message="debug this",
  1208. system_prompts=[],
  1209. pre_messages=[],
  1210. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  1211. event_agent=EventAgentParams(enabled_tools=[], max_event_loops=1),
  1212. )
  1213. ticks = iter(
  1214. [
  1215. 0.9,
  1216. 0.91,
  1217. 1.0,
  1218. 1.01,
  1219. 1.02,
  1220. 1.123,
  1221. 1.2,
  1222. 1.25,
  1223. 1.3,
  1224. 1.456,
  1225. 1.7,
  1226. 1.8,
  1227. ]
  1228. )
  1229. runtime = DebugRuntime(RoundStatsChatClient(), clock=lambda: next(ticks))
  1230. outputs = [message async for message in runtime.run(request)]
  1231. business_outputs = _without_audit(outputs)
  1232. assert _message_types(outputs) == [
  1233. "session_started",
  1234. "message_delta",
  1235. "usage",
  1236. "round_stats",
  1237. "done",
  1238. ]
  1239. assert business_outputs[3] == {
  1240. "type": "round_stats",
  1241. "round_index": 1,
  1242. "ttft_ms": 123,
  1243. "elapsed_ms": 456,
  1244. "prompt_tokens": 10,
  1245. "completion_tokens": 20,
  1246. "total_tokens": 30,
  1247. "cached_tokens": 5,
  1248. "had_event": False,
  1249. }
  1250. @pytest.mark.asyncio
  1251. async def test_runtime_emits_round_stats_for_each_chat_call_in_event_handoff():
  1252. request = DebugRunRequest(
  1253. user_message="debug this",
  1254. system_prompts=[],
  1255. pre_messages=[],
  1256. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  1257. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=2),
  1258. )
  1259. ticks = iter(
  1260. [
  1261. 1.9,
  1262. 1.91,
  1263. 2.0,
  1264. 2.01,
  1265. 2.02,
  1266. 2.03,
  1267. 2.04,
  1268. 2.05,
  1269. 2.06,
  1270. 2.07,
  1271. 2.25,
  1272. 2.26,
  1273. 3.0,
  1274. 3.01,
  1275. 3.02,
  1276. 3.05,
  1277. 3.1,
  1278. 3.15,
  1279. 3.18,
  1280. 3.2,
  1281. 3.23,
  1282. 3.24,
  1283. 3.25,
  1284. 3.26,
  1285. ]
  1286. )
  1287. client = EventRoundStatsChatClient()
  1288. runtime = DebugRuntime(client, clock=lambda: next(ticks))
  1289. outputs = [message async for message in runtime.run(request)]
  1290. stats = [message for message in outputs if message["type"] == "round_stats"]
  1291. assert client.calls == 2
  1292. assert stats == [
  1293. {
  1294. "type": "round_stats",
  1295. "round_index": 1,
  1296. "ttft_ms": None,
  1297. "elapsed_ms": 250,
  1298. "prompt_tokens": 3,
  1299. "completion_tokens": 0,
  1300. "total_tokens": 3,
  1301. "cached_tokens": 0,
  1302. "had_event": True,
  1303. },
  1304. {
  1305. "type": "round_stats",
  1306. "round_index": 2,
  1307. "ttft_ms": 50,
  1308. "elapsed_ms": 200,
  1309. "prompt_tokens": 4,
  1310. "completion_tokens": 6,
  1311. "total_tokens": 10,
  1312. "cached_tokens": 0,
  1313. "had_event": False,
  1314. },
  1315. ]
  1316. @pytest.mark.asyncio
  1317. async def test_runtime_session_resets_event_budget_for_each_user_turn():
  1318. request = DebugRunRequest(
  1319. user_message="first turn",
  1320. system_prompts=[],
  1321. pre_messages=[],
  1322. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  1323. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=1),
  1324. )
  1325. client = TwoTurnSessionChatClient()
  1326. runtime = DebugRuntime(client)
  1327. queues = runtime.start_session(request)
  1328. outputs: list[dict[str, Any]] = []
  1329. while len([message for message in outputs if message["type"] == "turn_completed"]) < 1:
  1330. outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
  1331. await queues.input.put(ChatMessage(role="user", content="second turn"))
  1332. while len([message for message in outputs if message["type"] == "turn_completed"]) < 2:
  1333. outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
  1334. await runtime.aclose()
  1335. business_types = [message["type"] for message in _without_audit(outputs)]
  1336. assert business_types.count("turn_started") == 2
  1337. assert business_types.count("turn_completed") == 2
  1338. assert client.calls == 4
  1339. assert "Available events:" in client.messages_by_call[0][0].content
  1340. assert "Available events:" in client.messages_by_call[2][0].content
  1341. @pytest.mark.asyncio
  1342. async def test_runtime_persists_session_turn_messages_audit_and_usage(tmp_path):
  1343. store = SQLiteSessionStore(tmp_path / "agent_lab.sqlite3")
  1344. request = DebugRunRequest(
  1345. session_id="session-1",
  1346. user_message="persist this",
  1347. system_prompts=["You are a debugger."],
  1348. pre_messages=[],
  1349. chat_agent=AgentParams(model="chat-model", temperature=0.1, max_tokens=200),
  1350. event_agent=EventAgentParams(enabled_tools=[], max_event_loops=1),
  1351. )
  1352. runtime = DebugRuntime(RoundStatsChatClient(), session_store=store)
  1353. queues = runtime.start_session(request)
  1354. outputs: list[dict[str, Any]] = []
  1355. while not any(message["type"] == "turn_completed" for message in outputs):
  1356. outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
  1357. await runtime.aclose()
  1358. assert _without_audit(outputs)[0] == {
  1359. "type": "session_started",
  1360. "session_id": "session-1",
  1361. }
  1362. assert store.get_session("session-1")["config"]["chat_agent"]["model"] == "chat-model"
  1363. assert [
  1364. (message["turn_index"], message["role"], message["content"])
  1365. for message in store.list_messages("session-1")
  1366. ] == [
  1367. (1, "user", "persist this"),
  1368. (1, "assistant", "hello"),
  1369. ]
  1370. audit_events = [
  1371. audit["event"]
  1372. for audit in store.list_audit_logs("session-1")
  1373. ]
  1374. assert "session_started" in audit_events
  1375. assert "chat_agent_request" in audit_events
  1376. assert "turn_completed" in audit_events
  1377. usage = store.usage_summary("session-1")
  1378. assert usage["calls"][0]["total_tokens"] == 30
  1379. assert usage["turns"][0]["turn_index"] == 1
  1380. assert usage["session"]["total_tokens"] == 30
  1381. @pytest.mark.asyncio
  1382. async def test_runtime_continues_persisted_turn_indexes_for_existing_session(tmp_path):
  1383. store = SQLiteSessionStore(tmp_path / "agent_lab.sqlite3")
  1384. session_id = store.create_session(title="existing", config={})
  1385. store.start_turn(session_id, turn_index=1, user_message="old")
  1386. request = DebugRunRequest(
  1387. session_id=session_id,
  1388. user_message="new",
  1389. system_prompts=[],
  1390. pre_messages=[],
  1391. chat_agent=AgentParams(model="chat-model", temperature=0.1, max_tokens=200),
  1392. event_agent=EventAgentParams(enabled_tools=[], max_event_loops=1),
  1393. )
  1394. runtime = DebugRuntime(RoundStatsChatClient(), session_store=store)
  1395. queues = runtime.start_session(request)
  1396. outputs: list[dict[str, Any]] = []
  1397. while not any(message["type"] == "turn_completed" for message in outputs):
  1398. outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
  1399. await runtime.aclose()
  1400. assert [
  1401. (message["turn_index"], message["content"])
  1402. for message in store.list_messages(session_id)
  1403. ] == [
  1404. (2, "new"),
  1405. (2, "hello"),
  1406. ]