test_debug_runtime.py 100 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114
  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.events import EventBatchExecutor, ResultPolicy
  11. from agent_lab.application.runtime import DebugRuntime
  12. from agent_lab.application.tools import ToolDefinition, ToolExecutionContext, ToolRegistry
  13. from agent_lab.domain.events import ToolCallEvent
  14. from agent_lab.domain.messages import ChatMessage, StreamItem, TokenUsage
  15. from agent_lab.infrastructure.chat_client import OpenAICompatibleChatClient
  16. from agent_lab.infrastructure.sqlite_store import SQLiteSessionStore
  17. def _runtime_queues_class():
  18. module = importlib.import_module("agent_lab.application.queues")
  19. return module.RuntimeQueues
  20. async def _collect_outputs(stream: AsyncIterator[dict[str, Any]]) -> list[dict[str, Any]]:
  21. return [message async for message in stream]
  22. def _without_audit(outputs: list[dict[str, Any]]) -> list[dict[str, Any]]:
  23. return [message for message in outputs if message["type"] != "audit"]
  24. def _message_types(outputs: list[dict[str, Any]]) -> list[str]:
  25. return [message["type"] for message in _without_audit(outputs)]
  26. def _event_tool_call_from_tools(
  27. tools: list[dict],
  28. messages: list[ChatMessage],
  29. ) -> StreamItem:
  30. tool_name = tools[0]["function"]["name"]
  31. content = ""
  32. for role in ("assistant", "user"):
  33. content = next(
  34. (
  35. message.content
  36. for message in reversed(messages)
  37. if message.role == role and message.content.strip()
  38. ),
  39. "",
  40. )
  41. if content:
  42. break
  43. arguments = {
  44. "message": content,
  45. "query": content,
  46. "title": content,
  47. }
  48. return StreamItem.provider_tool_call(
  49. ToolCallEvent(
  50. id="event_agent_call_1",
  51. name=tool_name,
  52. arguments=arguments,
  53. raw_arguments=json.dumps(arguments),
  54. )
  55. )
  56. async def _next_non_audit(
  57. stream: AsyncIterator[dict[str, Any]],
  58. ) -> dict[str, Any]:
  59. while True:
  60. message = await anext(stream)
  61. if message["type"] != "audit":
  62. return message
  63. async def _next_non_audit_from_queue(queues: Any) -> dict[str, Any]:
  64. while True:
  65. message = await queues.output.get()
  66. if message["type"] != "audit":
  67. return message
  68. class RecordingQueue(asyncio.Queue):
  69. def __init__(self, name: str, log: list[tuple[str, str, str]]) -> None:
  70. super().__init__()
  71. self.name = name
  72. self.log = log
  73. async def put(self, item: Any) -> None:
  74. self.log.append((self.name, "put", self._describe(item)))
  75. await super().put(item)
  76. async def get(self) -> Any:
  77. item = await super().get()
  78. self.log.append((self.name, "get", self._describe(item)))
  79. return item
  80. def _describe(self, item: Any) -> str:
  81. if isinstance(item, ChatMessage):
  82. if item.role == "tool":
  83. return f"tool:{item.tool_call_id}"
  84. return item.role
  85. if isinstance(item, ToolCallEvent):
  86. return f"event:{item.name}:{item.id}"
  87. if isinstance(item, EventAgentRequest):
  88. events = ",".join(f"{event.name}:{event.id}" for event in item.events)
  89. return f"event_request:{events}"
  90. if isinstance(item, dict):
  91. return f"output:{item.get('type')}"
  92. return type(item).__name__
  93. class FakeChatClient:
  94. def __init__(self) -> None:
  95. self.calls = 0
  96. async def stream_chat(
  97. self,
  98. messages: list[ChatMessage],
  99. tools: list[dict],
  100. params: AgentParams,
  101. tool_choice: dict[str, Any] | None = None,
  102. ) -> AsyncIterator[StreamItem]:
  103. if tools:
  104. yield StreamItem.raw_response_chunk(
  105. {
  106. "choices": [
  107. {
  108. "delta": {
  109. "tool_calls": [
  110. {
  111. "index": 0,
  112. "function": {"name": tools[0]["function"]["name"]},
  113. }
  114. ]
  115. },
  116. "finish_reason": None,
  117. }
  118. ]
  119. }
  120. )
  121. yield _event_tool_call_from_tools(tools, messages)
  122. return
  123. self.calls += 1
  124. if self.calls == 1:
  125. yield StreamItem.raw_response_chunk(
  126. {
  127. "choices": [
  128. {
  129. "delta": {"content": "<agent_events>handoff_note</agent_events>"},
  130. "finish_reason": None,
  131. }
  132. ]
  133. }
  134. )
  135. yield StreamItem.text_event(
  136. ToolCallEvent(
  137. id="call_1",
  138. name="handoff_note",
  139. arguments={},
  140. raw_arguments="{}",
  141. )
  142. )
  143. return
  144. assert not any(message.role == "tool" for message in messages)
  145. assert any(message.name == "event_agent" for message in messages)
  146. yield StreamItem.message_delta("final answer")
  147. class WrongSourceEventChatClient:
  148. def __init__(self, item: StreamItem) -> None:
  149. self.item = item
  150. self.calls = 0
  151. async def stream_chat(
  152. self,
  153. messages: list[ChatMessage],
  154. tools: list[dict],
  155. params: AgentParams,
  156. tool_choice: dict[str, Any] | None = None,
  157. ) -> AsyncIterator[StreamItem]:
  158. if tools:
  159. yield _event_tool_call_from_tools(tools, messages)
  160. return
  161. self.calls += 1
  162. if self.calls == 1:
  163. yield self.item
  164. return
  165. yield StreamItem.message_delta("unexpected continuation")
  166. class IncrementingClock:
  167. def __init__(self, current: float = 100.0, step: float = 0.01) -> None:
  168. self.current = current
  169. self.step = step
  170. def __call__(self) -> float:
  171. self.current += self.step
  172. return self.current
  173. class StrictHistoryChatClient:
  174. def __init__(self) -> None:
  175. self.calls = 0
  176. self.second_call_messages: list[ChatMessage] = []
  177. async def stream_chat(
  178. self,
  179. messages: list[ChatMessage],
  180. tools: list[dict],
  181. params: AgentParams,
  182. tool_choice: dict[str, Any] | None = None,
  183. ) -> AsyncIterator[StreamItem]:
  184. if tools:
  185. yield StreamItem.raw_response_chunk(
  186. {
  187. "choices": [
  188. {
  189. "delta": {
  190. "tool_calls": [
  191. {
  192. "index": 0,
  193. "function": {"name": tools[0]["function"]["name"]},
  194. }
  195. ]
  196. },
  197. "finish_reason": None,
  198. }
  199. ]
  200. }
  201. )
  202. yield _event_tool_call_from_tools(tools, messages)
  203. return
  204. self.calls += 1
  205. if self.calls == 1:
  206. yield StreamItem.raw_response_chunk(
  207. {
  208. "choices": [
  209. {
  210. "delta": {"content": "<agent_events>handoff_note</agent_events>"},
  211. "finish_reason": None,
  212. }
  213. ]
  214. }
  215. )
  216. yield StreamItem.text_event(
  217. ToolCallEvent(
  218. id="call_1",
  219. name="handoff_note",
  220. arguments={},
  221. raw_arguments="{}",
  222. )
  223. )
  224. return
  225. self.second_call_messages = list(messages)
  226. yield StreamItem.message_delta("final answer")
  227. class MultiEventChatClient:
  228. def __init__(self) -> None:
  229. self.calls = 0
  230. self.second_call_messages: list[ChatMessage] = []
  231. async def stream_chat(
  232. self,
  233. messages: list[ChatMessage],
  234. tools: list[dict],
  235. params: AgentParams,
  236. tool_choice: dict[str, Any] | None = None,
  237. ) -> AsyncIterator[StreamItem]:
  238. if tools:
  239. yield _event_tool_call_from_tools(tools, messages)
  240. return
  241. self.calls += 1
  242. if self.calls == 1:
  243. yield StreamItem.message_delta("Checking events.")
  244. yield StreamItem.text_event(
  245. ToolCallEvent(
  246. id="call_1",
  247. name="handoff_note",
  248. arguments={"message": "ignored chat argument"},
  249. raw_arguments='{"message":"ignored chat argument"}',
  250. )
  251. )
  252. yield StreamItem.text_event(
  253. ToolCallEvent(
  254. id="call_2",
  255. name="audit_note",
  256. arguments={"message": "ignored chat argument"},
  257. raw_arguments='{"message":"ignored chat argument"}',
  258. )
  259. )
  260. return
  261. self.second_call_messages = list(messages)
  262. yield StreamItem.message_delta("Final answer.")
  263. class ToolCapturingChatClient:
  264. def __init__(self) -> None:
  265. self.tools: list[dict[str, Any]] = []
  266. self.messages: list[ChatMessage] = []
  267. async def stream_chat(
  268. self,
  269. messages: list[ChatMessage],
  270. tools: list[dict],
  271. params: AgentParams,
  272. tool_choice: dict[str, Any] | None = None,
  273. ) -> AsyncIterator[StreamItem]:
  274. self.messages = list(messages)
  275. self.tools = list(tools)
  276. yield StreamItem.message_delta("final answer")
  277. class EventLoopLimitChatClient:
  278. def __init__(self) -> None:
  279. self.calls = 0
  280. self.tools_by_call: list[list[dict[str, Any]]] = []
  281. self.messages_by_call: list[list[ChatMessage]] = []
  282. async def stream_chat(
  283. self,
  284. messages: list[ChatMessage],
  285. tools: list[dict],
  286. params: AgentParams,
  287. tool_choice: dict[str, Any] | None = None,
  288. ) -> AsyncIterator[StreamItem]:
  289. if tools:
  290. yield _event_tool_call_from_tools(tools, messages)
  291. return
  292. self.calls += 1
  293. self.tools_by_call.append(list(tools))
  294. self.messages_by_call.append(list(messages))
  295. if self.calls == 1:
  296. yield StreamItem.text_event(
  297. ToolCallEvent(
  298. id="call_1",
  299. name="handoff_note",
  300. arguments={},
  301. raw_arguments="{}",
  302. )
  303. )
  304. return
  305. yield StreamItem.message_delta("final after event limit")
  306. class RoundStatsChatClient:
  307. async def stream_chat(
  308. self,
  309. messages: list[ChatMessage],
  310. tools: list[dict],
  311. params: AgentParams,
  312. tool_choice: dict[str, Any] | None = None,
  313. ) -> AsyncIterator[StreamItem]:
  314. yield StreamItem.message_delta("hello")
  315. yield StreamItem.usage_item(
  316. TokenUsage(
  317. prompt_tokens=10,
  318. completion_tokens=20,
  319. total_tokens=30,
  320. cached_tokens=5,
  321. )
  322. )
  323. class EventRoundStatsChatClient:
  324. def __init__(self) -> None:
  325. self.calls = 0
  326. async def stream_chat(
  327. self,
  328. messages: list[ChatMessage],
  329. tools: list[dict],
  330. params: AgentParams,
  331. tool_choice: dict[str, Any] | None = None,
  332. ) -> AsyncIterator[StreamItem]:
  333. if tools:
  334. yield StreamItem.raw_response_chunk(
  335. {
  336. "choices": [
  337. {
  338. "delta": {
  339. "tool_calls": [
  340. {
  341. "index": 0,
  342. "function": {
  343. "name": tools[0]["function"]["name"],
  344. },
  345. }
  346. ]
  347. },
  348. "finish_reason": None,
  349. }
  350. ]
  351. }
  352. )
  353. yield _event_tool_call_from_tools(tools, messages)
  354. return
  355. self.calls += 1
  356. if self.calls == 1:
  357. yield StreamItem.raw_response_chunk(
  358. {
  359. "choices": [
  360. {
  361. "delta": {
  362. "content": "<agent_events>handoff_note</agent_events>",
  363. },
  364. "finish_reason": None,
  365. }
  366. ]
  367. }
  368. )
  369. yield StreamItem.text_event(
  370. ToolCallEvent(
  371. id="call_1",
  372. name="handoff_note",
  373. arguments={},
  374. raw_arguments="{}",
  375. )
  376. )
  377. yield StreamItem.usage_item(
  378. TokenUsage(prompt_tokens=3, completion_tokens=0, total_tokens=3)
  379. )
  380. return
  381. yield StreamItem.raw_response_chunk(
  382. {
  383. "choices": [
  384. {
  385. "delta": {"content": "final answer"},
  386. "finish_reason": None,
  387. }
  388. ]
  389. }
  390. )
  391. yield StreamItem.message_delta("final answer")
  392. yield StreamItem.usage_item(
  393. TokenUsage(prompt_tokens=4, completion_tokens=6, total_tokens=10)
  394. )
  395. class ContextBoundaryChatClient:
  396. def __init__(self) -> None:
  397. self.calls = 0
  398. self.event_agent_messages: list[list[ChatMessage]] = []
  399. async def stream_chat(
  400. self,
  401. messages: list[ChatMessage],
  402. tools: list[dict],
  403. params: AgentParams,
  404. tool_choice: dict[str, Any] | None = None,
  405. ) -> AsyncIterator[StreamItem]:
  406. if tools:
  407. self.event_agent_messages.append(list(messages))
  408. yield _event_tool_call_from_tools(tools, messages)
  409. return
  410. self.calls += 1
  411. if self.calls == 1:
  412. yield StreamItem.message_delta("I will check.")
  413. yield StreamItem.text_event(
  414. ToolCallEvent(
  415. id="call_1",
  416. name="handoff_note",
  417. arguments={},
  418. raw_arguments="{}",
  419. )
  420. )
  421. return
  422. yield StreamItem.message_delta("final answer")
  423. class TwoTurnSessionChatClient:
  424. def __init__(self) -> None:
  425. self.calls = 0
  426. self.messages_by_call: list[list[ChatMessage]] = []
  427. async def stream_chat(
  428. self,
  429. messages: list[ChatMessage],
  430. tools: list[dict],
  431. params: AgentParams,
  432. tool_choice: dict[str, Any] | None = None,
  433. ) -> AsyncIterator[StreamItem]:
  434. if tools:
  435. yield _event_tool_call_from_tools(tools, messages)
  436. return
  437. self.calls += 1
  438. self.messages_by_call.append(list(messages))
  439. if self.calls in {1, 3}:
  440. yield StreamItem.text_event(
  441. ToolCallEvent(
  442. id=f"call_{self.calls}",
  443. name="handoff_note",
  444. arguments={},
  445. raw_arguments="{}",
  446. )
  447. )
  448. return
  449. yield StreamItem.message_delta(f"final answer {self.calls}")
  450. class SlowAfterEventChatClient:
  451. def __init__(self) -> None:
  452. self.calls = 0
  453. self.event_seen = asyncio.Event()
  454. self.release_stream = asyncio.Event()
  455. async def stream_chat(
  456. self,
  457. messages: list[ChatMessage],
  458. tools: list[dict],
  459. params: AgentParams,
  460. tool_choice: dict[str, Any] | None = None,
  461. ) -> AsyncIterator[StreamItem]:
  462. if tools:
  463. yield _event_tool_call_from_tools(tools, messages)
  464. return
  465. self.calls += 1
  466. if self.calls == 1:
  467. yield StreamItem.message_delta("Need event.")
  468. yield StreamItem.text_event(
  469. ToolCallEvent(
  470. id="call_1",
  471. name="handoff_note",
  472. arguments={},
  473. raw_arguments="{}",
  474. )
  475. )
  476. self.event_seen.set()
  477. await self.release_stream.wait()
  478. return
  479. yield StreamItem.message_delta("final answer")
  480. def test_runtime_queues_exposes_input_output_and_events_queues():
  481. RuntimeQueues = _runtime_queues_class()
  482. queues = RuntimeQueues()
  483. assert isinstance(queues.input, asyncio.Queue)
  484. assert isinstance(queues.output, asyncio.Queue)
  485. assert isinstance(queues.events, asyncio.Queue)
  486. assert queues.input is not queues.output
  487. assert queues.input is not queues.events
  488. assert queues.output is not queues.events
  489. @pytest.mark.asyncio
  490. async def test_runtime_routes_chat_events_through_event_agent_then_continues_chat():
  491. request = DebugRunRequest(
  492. user_message="debug this",
  493. system_prompts=["You are a debugger."],
  494. pre_messages=[],
  495. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  496. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=3),
  497. )
  498. client = FakeChatClient()
  499. runtime = DebugRuntime(client)
  500. outputs = [message async for message in runtime.run(request)]
  501. business_outputs = _without_audit(outputs)
  502. assert client.calls == 2
  503. assert _message_types(outputs) == [
  504. "session_started",
  505. "event",
  506. "tool_result",
  507. "round_stats",
  508. "message_delta",
  509. "round_stats",
  510. "done",
  511. ]
  512. assert business_outputs[1]["event"]["name"] == "handoff_note"
  513. assert business_outputs[4]["content"] == "final answer"
  514. @pytest.mark.asyncio
  515. @pytest.mark.parametrize(
  516. "item",
  517. [
  518. StreamItem.provider_tool_call(
  519. ToolCallEvent(
  520. id="provider_call_1",
  521. name="handoff_note",
  522. arguments={"message": "wrong source"},
  523. raw_arguments='{"message":"wrong source"}',
  524. )
  525. ),
  526. StreamItem.event(
  527. ToolCallEvent(
  528. id="legacy_event_1",
  529. name="handoff_note",
  530. arguments={},
  531. raw_arguments="{}",
  532. )
  533. ),
  534. ],
  535. ids=["provider_tool_call", "legacy_event"],
  536. )
  537. async def test_runtime_ignores_non_text_event_sources(item: StreamItem):
  538. request = DebugRunRequest(
  539. user_message="debug this",
  540. system_prompts=[],
  541. pre_messages=[],
  542. chat_agent=AgentParams(model="fake-model"),
  543. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=2),
  544. )
  545. client = WrongSourceEventChatClient(item)
  546. outputs = [message async for message in DebugRuntime(client).run(request)]
  547. assert client.calls == 1
  548. assert "event" not in _message_types(outputs)
  549. assert "tool_result" not in _message_types(outputs)
  550. @pytest.mark.asyncio
  551. async def test_runtime_emits_audit_events_and_backend_logs(caplog):
  552. request = DebugRunRequest(
  553. user_message="debug this",
  554. system_prompts=[],
  555. pre_messages=[],
  556. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  557. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=1),
  558. )
  559. runtime = DebugRuntime(FakeChatClient())
  560. with caplog.at_level(logging.INFO, logger="agent_lab.application.runtime"):
  561. outputs = [message async for message in runtime.run(request)]
  562. audit_events = [
  563. message["event"]
  564. for message in outputs
  565. if message["type"] == "audit"
  566. ]
  567. assert audit_events == [
  568. "session_started",
  569. "chat_round_started",
  570. "chat_agent_request",
  571. "chat_event_detected",
  572. "chat_agent_response",
  573. "event_batch_started",
  574. "event_agent_request",
  575. "event_agent_response",
  576. "event_agent_completed",
  577. "event_batch_results",
  578. "event_policy_decision",
  579. "chat_round_finished",
  580. "chat_round_started",
  581. "chat_agent_request",
  582. "chat_message_stream_started",
  583. "chat_message_stream_finished",
  584. "chat_agent_response",
  585. "chat_round_finished",
  586. "session_finished",
  587. ]
  588. assert "chat_event_detected" in caplog.text
  589. assert "event_agent_completed" in caplog.text
  590. @pytest.mark.asyncio
  591. async def test_runtime_audits_chat_message_stream_boundaries_in_output_order():
  592. request = DebugRunRequest(
  593. user_message="debug this",
  594. system_prompts=[],
  595. pre_messages=[],
  596. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  597. event_agent=EventAgentParams(enabled_tools=[], max_event_loops=1),
  598. )
  599. runtime = DebugRuntime(RoundStatsChatClient())
  600. outputs = [message async for message in runtime.run(request)]
  601. ordered_labels = [
  602. message["event"] if message["type"] == "audit" else message["type"]
  603. for message in outputs
  604. ]
  605. assert ordered_labels.index("chat_message_stream_started") < ordered_labels.index(
  606. "message_delta"
  607. )
  608. assert ordered_labels.index("message_delta") < ordered_labels.index(
  609. "chat_message_stream_finished"
  610. )
  611. assert ordered_labels.index("chat_message_stream_finished") < ordered_labels.index(
  612. "chat_agent_response"
  613. )
  614. stream_started = next(
  615. message
  616. for message in outputs
  617. if message.get("event") == "chat_message_stream_started"
  618. )
  619. stream_finished = next(
  620. message
  621. for message in outputs
  622. if message.get("event") == "chat_message_stream_finished"
  623. )
  624. assert stream_started["details"]["agent"] == "chat_agent"
  625. assert stream_started["details"]["round_index"] == 1
  626. assert stream_finished["details"]["delta_count"] == 1
  627. assert stream_finished["details"]["content_length"] == len("hello")
  628. @pytest.mark.asyncio
  629. async def test_runtime_audit_events_include_turn_relative_elapsed_time():
  630. request = DebugRunRequest(
  631. user_message="debug this",
  632. system_prompts=[],
  633. pre_messages=[],
  634. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  635. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=1),
  636. )
  637. runtime = DebugRuntime(FakeChatClient(), clock=IncrementingClock())
  638. queues = runtime.start_session(request)
  639. outputs: list[dict[str, Any]] = []
  640. while not any(message["type"] == "turn_completed" for message in outputs):
  641. outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
  642. await runtime.aclose()
  643. turn_audits = [
  644. message
  645. for message in outputs
  646. if message["type"] == "audit"
  647. ]
  648. elapsed_values = [
  649. message["details"].get("turn_elapsed_ms") for message in turn_audits
  650. ]
  651. assert turn_audits
  652. assert all(isinstance(value, int) for value in elapsed_values)
  653. assert all(value >= 0 for value in elapsed_values)
  654. assert elapsed_values == sorted(elapsed_values)
  655. assert next(
  656. message
  657. for message in turn_audits
  658. if message["event"] == "event_agent_request"
  659. )["details"]["turn_elapsed_ms"] >= 0
  660. @pytest.mark.asyncio
  661. async def test_runtime_audit_includes_model_params_prompts_results_and_usage():
  662. request = DebugRunRequest(
  663. user_message="debug this",
  664. system_prompts=["You are a debugger."],
  665. pre_messages=[],
  666. chat_agent=AgentParams(model="chat-model", temperature=0.1, max_tokens=200),
  667. event_agent=EventAgentParams(
  668. model="event-model",
  669. temperature=0.4,
  670. max_tokens=80,
  671. enabled_tools=["handoff_note"],
  672. max_event_loops=1,
  673. ),
  674. )
  675. runtime = DebugRuntime(EventRoundStatsChatClient())
  676. outputs = [message async for message in runtime.run(request)]
  677. audits = [message for message in outputs if message["type"] == "audit"]
  678. chat_request = next(
  679. message for message in audits if message["event"] == "chat_agent_request"
  680. )
  681. assert chat_request["details"]["agent"] == "chat_agent"
  682. assert chat_request["details"]["params"]["model"] == "chat-model"
  683. assert chat_request["details"]["params"]["temperature"] == 0.1
  684. assert chat_request["details"]["params"]["max_tokens"] == 200
  685. assert chat_request["details"]["tools"] == []
  686. assert chat_request["details"]["messages"][0] == {
  687. "role": "system",
  688. "content": "You are a debugger.",
  689. "name": None,
  690. "tool_call_id": None,
  691. "tool_calls": [],
  692. }
  693. chat_response = next(
  694. message for message in audits if message["event"] == "chat_agent_response"
  695. )
  696. assert chat_response["details"]["event_names"] == ["handoff_note"]
  697. assert chat_response["details"]["raw_chunks"] == [
  698. {
  699. "choices": [
  700. {
  701. "delta": {"content": "<agent_events>handoff_note</agent_events>"},
  702. "finish_reason": None,
  703. }
  704. ]
  705. }
  706. ]
  707. assert chat_response["details"]["usage"] == {
  708. "prompt_tokens": 3,
  709. "completion_tokens": 0,
  710. "total_tokens": 3,
  711. "cached_tokens": 0,
  712. }
  713. event_request = next(
  714. message for message in audits if message["event"] == "event_agent_request"
  715. )
  716. assert event_request["details"]["agent"] == "event_agent"
  717. assert event_request["details"]["params"]["model"] == "event-model"
  718. assert event_request["details"]["params"]["temperature"] == 0.4
  719. assert event_request["details"]["events"][0]["name"] == "handoff_note"
  720. assert event_request["details"]["tools"][0]["function"]["name"] == "handoff_note"
  721. assert "Authorization" not in str(event_request["details"])
  722. event_response = next(
  723. message for message in audits if message["event"] == "event_agent_response"
  724. )
  725. assert event_response["details"]["replies"][0]["role"] == "tool"
  726. assert '"tool": "handoff_note"' in event_response["details"]["replies"][0]["content"]
  727. assert event_response["details"]["raw_model_chunks"][0]["event_name"] == "handoff_note"
  728. assert event_response["details"]["raw_model_chunks"][0]["chunks"] == []
  729. @pytest.mark.asyncio
  730. async def test_runtime_round_started_separates_available_events_from_round_budget():
  731. request = DebugRunRequest(
  732. user_message="debug this",
  733. system_prompts=[],
  734. pre_messages=[],
  735. chat_agent=AgentParams(model="chat-model", temperature=0.1, max_tokens=200),
  736. event_agent=EventAgentParams(
  737. enabled_tools=["handoff_note"],
  738. max_event_loops=1,
  739. ),
  740. )
  741. runtime = DebugRuntime(EventRoundStatsChatClient())
  742. outputs = [message async for message in runtime.run(request)]
  743. round_starts = [
  744. message
  745. for message in outputs
  746. if message.get("event") == "chat_round_started"
  747. ]
  748. assert round_starts[0]["details"]["events_enabled"] == ["handoff_note"]
  749. assert round_starts[0]["details"]["configured_events"] == ["handoff_note"]
  750. assert round_starts[0]["details"]["event_generation_enabled"] is True
  751. assert round_starts[0]["details"]["event_prompt_events"] == ["handoff_note"]
  752. assert round_starts[1]["details"]["events_enabled"] == []
  753. assert round_starts[1]["details"]["configured_events"] == ["handoff_note"]
  754. assert round_starts[1]["details"]["event_generation_enabled"] is False
  755. assert round_starts[1]["details"]["event_prompt_events"] == []
  756. @pytest.mark.asyncio
  757. async def test_runtime_event_agent_history_excludes_chat_agent_system_context():
  758. request = DebugRunRequest(
  759. user_message="debug this",
  760. system_prompts=["ChatAgent root prompt."],
  761. pre_messages=[
  762. ChatMessage(role="system", content="ChatAgent pre system."),
  763. ChatMessage(role="user", content="earlier user"),
  764. ChatMessage(role="assistant", content="earlier assistant"),
  765. ],
  766. chat_agent=AgentParams(model="chat-model", temperature=0.1, max_tokens=200),
  767. event_agent=EventAgentParams(
  768. model="event-model",
  769. enabled_tools=["handoff_note"],
  770. max_event_loops=1,
  771. ),
  772. )
  773. client = ContextBoundaryChatClient()
  774. runtime = DebugRuntime(client)
  775. outputs = [message async for message in runtime.run(request)]
  776. event_request = next(
  777. message for message in outputs if message.get("event") == "event_agent_request"
  778. )
  779. assert [
  780. (message["role"], message["content"])
  781. for message in event_request["details"]["history"]
  782. ] == [
  783. ("user", "earlier user"),
  784. ("assistant", "earlier assistant"),
  785. ("user", "debug this"),
  786. ("assistant", "I will check."),
  787. ]
  788. assert not any(
  789. message["role"] == "system"
  790. for message in event_request["details"]["history"]
  791. )
  792. assert client.event_agent_messages == []
  793. @pytest.mark.asyncio
  794. async def test_runtime_session_event_agent_history_excludes_internal_replies():
  795. request = DebugRunRequest(
  796. user_message="debug this",
  797. system_prompts=["ChatAgent root prompt."],
  798. pre_messages=[ChatMessage(role="assistant", content="prior answer")],
  799. chat_agent=AgentParams(model="chat-model", temperature=0.1, max_tokens=200),
  800. event_agent=EventAgentParams(
  801. model="event-model",
  802. enabled_tools=["handoff_note"],
  803. max_event_loops=1,
  804. ),
  805. )
  806. runtime = DebugRuntime(ContextBoundaryChatClient())
  807. queues = runtime.start_session(request)
  808. outputs: list[dict[str, Any]] = []
  809. while not any(message["type"] == "turn_completed" for message in outputs):
  810. outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
  811. await runtime.aclose()
  812. event_request = next(
  813. message for message in outputs if message.get("event") == "event_agent_request"
  814. )
  815. assert [
  816. (message["role"], message["content"], message["name"])
  817. for message in event_request["details"]["history"]
  818. ] == [
  819. ("assistant", "prior answer", None),
  820. ("user", "debug this", None),
  821. ("assistant", "I will check.", None),
  822. ]
  823. assert not any(
  824. message["name"] == "event_agent"
  825. for message in event_request["details"]["history"]
  826. )
  827. @pytest.mark.asyncio
  828. async def test_runtime_outputs_event_as_soon_as_chat_stream_detects_it():
  829. RuntimeQueues = _runtime_queues_class()
  830. queues = RuntimeQueues()
  831. client = SlowAfterEventChatClient()
  832. runtime = DebugRuntime(client, queues=queues)
  833. request = DebugRunRequest(
  834. user_message="debug this",
  835. system_prompts=[],
  836. pre_messages=[],
  837. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  838. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=1),
  839. )
  840. runtime.start(request)
  841. try:
  842. assert await _next_non_audit_from_queue(queues) == {"type": "session_started"}
  843. assert await _next_non_audit_from_queue(queues) == {
  844. "type": "message_delta",
  845. "content": "Need event.",
  846. }
  847. await asyncio.wait_for(client.event_seen.wait(), timeout=1)
  848. event_message = await asyncio.wait_for(
  849. _next_non_audit_from_queue(queues),
  850. timeout=0.2,
  851. )
  852. assert event_message["type"] == "event"
  853. assert event_message["event"]["name"] == "handoff_note"
  854. finally:
  855. client.release_stream.set()
  856. await runtime.aclose()
  857. @pytest.mark.asyncio
  858. async def test_runtime_batches_round_events_before_continuing_chat_agent():
  859. def resolve_from_history(
  860. event: ToolCallEvent,
  861. context: ToolExecutionContext,
  862. ) -> dict[str, Any]:
  863. return {"message": context.history[-1].content, "event": event.name}
  864. registry = ToolRegistry(
  865. [
  866. ToolDefinition(
  867. name="handoff_note",
  868. description="Send a handoff note.",
  869. parameters={
  870. "type": "object",
  871. "properties": {"message": {"type": "string"}},
  872. "required": ["message"],
  873. },
  874. handler=lambda event: {
  875. "tool": event.name,
  876. "message": event.arguments["message"],
  877. },
  878. argument_resolver=resolve_from_history,
  879. ),
  880. ToolDefinition(
  881. name="audit_note",
  882. description="Send an audit note.",
  883. parameters={
  884. "type": "object",
  885. "properties": {"message": {"type": "string"}},
  886. "required": ["message"],
  887. },
  888. handler=lambda event: {
  889. "tool": event.name,
  890. "message": event.arguments["message"],
  891. },
  892. argument_resolver=resolve_from_history,
  893. ),
  894. ]
  895. )
  896. request = DebugRunRequest(
  897. user_message="debug this",
  898. system_prompts=[],
  899. pre_messages=[],
  900. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  901. event_agent=EventAgentParams(
  902. enabled_tools=["handoff_note", "audit_note"],
  903. max_event_loops=2,
  904. ),
  905. )
  906. client = MultiEventChatClient()
  907. runtime = DebugRuntime(client, registry=registry)
  908. outputs = [message async for message in runtime.run(request)]
  909. assert _message_types(outputs) == [
  910. "session_started",
  911. "message_delta",
  912. "event",
  913. "event",
  914. "tool_result",
  915. "tool_result",
  916. "round_stats",
  917. "message_delta",
  918. "round_stats",
  919. "done",
  920. ]
  921. assert client.calls == 2
  922. assert [message.role for message in client.second_call_messages] == [
  923. "system",
  924. "user",
  925. "assistant",
  926. "user",
  927. ]
  928. assistant_message = client.second_call_messages[2]
  929. assert assistant_message.content == "Checking events."
  930. assert not any(message.role == "tool" for message in client.second_call_messages)
  931. assert client.second_call_messages[-1].content == (
  932. "EventAgent results:\n"
  933. '{"tool": "handoff_note", "message": "Checking events."}\n'
  934. '{"tool": "audit_note", "message": "Checking events."}'
  935. )
  936. assert client.second_call_messages[-1].name == "event_agent"
  937. @pytest.mark.asyncio
  938. async def test_runtime_start_returns_queues_for_downstream_output_consumer():
  939. request = DebugRunRequest(
  940. user_message="debug this",
  941. system_prompts=[],
  942. pre_messages=[],
  943. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  944. event_agent=EventAgentParams(enabled_tools=[], max_event_loops=1),
  945. )
  946. runtime = DebugRuntime(RoundStatsChatClient())
  947. queues = runtime.start(request)
  948. outputs: list[dict[str, Any]] = []
  949. while True:
  950. message = await asyncio.wait_for(queues.output.get(), timeout=1)
  951. outputs.append(message)
  952. if message["type"] == "done":
  953. break
  954. assert _message_types(outputs) == [
  955. "session_started",
  956. "message_delta",
  957. "usage",
  958. "round_stats",
  959. "done",
  960. ]
  961. @pytest.mark.asyncio
  962. async def test_runtime_finalizes_chat_after_reaching_event_loop_limit():
  963. request = DebugRunRequest(
  964. user_message="debug this",
  965. system_prompts=[],
  966. pre_messages=[],
  967. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  968. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=1),
  969. )
  970. client = EventLoopLimitChatClient()
  971. runtime = DebugRuntime(client)
  972. outputs = [message async for message in runtime.run(request)]
  973. business_outputs = _without_audit(outputs)
  974. assert client.calls == 2
  975. assert client.tools_by_call[0] == []
  976. assert client.tools_by_call[1] == []
  977. assert "Available events:" in client.messages_by_call[0][0].content
  978. assert not any(
  979. "Available events:" in message.content
  980. for message in client.messages_by_call[1]
  981. if message.role == "system"
  982. )
  983. assert _message_types(outputs) == [
  984. "session_started",
  985. "event",
  986. "tool_result",
  987. "round_stats",
  988. "message_delta",
  989. "round_stats",
  990. "done",
  991. ]
  992. assert business_outputs[4]["content"] == "final after event limit"
  993. @pytest.mark.asyncio
  994. async def test_runtime_buffers_upstream_user_input_until_after_matching_tool_reply():
  995. RuntimeQueues = _runtime_queues_class()
  996. queues = RuntimeQueues()
  997. request = DebugRunRequest(
  998. user_message="debug this",
  999. system_prompts=[],
  1000. pre_messages=[],
  1001. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  1002. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=2),
  1003. )
  1004. client = StrictHistoryChatClient()
  1005. runtime = DebugRuntime(client, queues=queues)
  1006. stream = runtime.run(request)
  1007. assert await _next_non_audit(stream) == {"type": "session_started"}
  1008. event_message = await _next_non_audit(stream)
  1009. assert event_message["type"] == "event"
  1010. await queues.input.put(ChatMessage(role="user", content="follow-up while tool runs"))
  1011. remaining = [message async for message in stream]
  1012. assert remaining[-1] == {"type": "done"}
  1013. assert [message.role for message in client.second_call_messages] == [
  1014. "system",
  1015. "user",
  1016. "assistant",
  1017. "user",
  1018. "user",
  1019. ]
  1020. assert not any(message.role == "tool" for message in client.second_call_messages)
  1021. assert client.second_call_messages[3].content.startswith("EventAgent results:\n")
  1022. assert client.second_call_messages[3].name == "event_agent"
  1023. assert client.second_call_messages[4].content == "follow-up while tool runs"
  1024. @pytest.mark.asyncio
  1025. async def test_runtime_continues_when_event_agent_tool_handler_raises():
  1026. def fail_tool(event: ToolCallEvent) -> dict[str, Any]:
  1027. raise RuntimeError("boom")
  1028. registry = ToolRegistry(
  1029. [
  1030. ToolDefinition(
  1031. name="handoff_note",
  1032. description="Broken handoff tool.",
  1033. parameters={"type": "object"},
  1034. handler=fail_tool,
  1035. )
  1036. ]
  1037. )
  1038. request = DebugRunRequest(
  1039. user_message="debug this",
  1040. system_prompts=[],
  1041. pre_messages=[],
  1042. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  1043. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=2),
  1044. )
  1045. runtime = DebugRuntime(FakeChatClient(), registry=registry)
  1046. outputs = await asyncio.wait_for(
  1047. _collect_outputs(runtime.run(request)),
  1048. timeout=1,
  1049. )
  1050. business_outputs = _without_audit(outputs)
  1051. assert _message_types(outputs) == [
  1052. "session_started",
  1053. "event",
  1054. "tool_result",
  1055. "round_stats",
  1056. "message_delta",
  1057. "round_stats",
  1058. "done",
  1059. ]
  1060. assert json.loads(business_outputs[2]["message"]["content"]) == {
  1061. "tool": "handoff_note",
  1062. "error": "tool handler failed: boom",
  1063. }
  1064. @pytest.mark.asyncio
  1065. async def test_runtime_uses_event_and_input_queues_for_event_agent_handoff():
  1066. RuntimeQueues = _runtime_queues_class()
  1067. queue_log: list[tuple[str, str, str]] = []
  1068. queues = RuntimeQueues(
  1069. input=RecordingQueue("input", queue_log),
  1070. output=RecordingQueue("output", queue_log),
  1071. events=RecordingQueue("events", queue_log),
  1072. )
  1073. request = DebugRunRequest(
  1074. user_message="debug this",
  1075. system_prompts=["You are a debugger."],
  1076. pre_messages=[],
  1077. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  1078. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=3),
  1079. )
  1080. runtime = DebugRuntime(FakeChatClient(), queues=queues)
  1081. outputs = [message async for message in runtime.run(request)]
  1082. assert _message_types(outputs) == [
  1083. "session_started",
  1084. "event",
  1085. "tool_result",
  1086. "round_stats",
  1087. "message_delta",
  1088. "round_stats",
  1089. "done",
  1090. ]
  1091. assert queue_log.index(("input", "put", "user")) < queue_log.index(
  1092. ("input", "get", "user")
  1093. )
  1094. assert queue_log.index(
  1095. ("events", "put", "event_request:handoff_note:call_1")
  1096. ) < queue_log.index(("events", "get", "event_request:handoff_note:call_1"))
  1097. assert queue_log.index(
  1098. ("events", "get", "event_request:handoff_note:call_1")
  1099. ) < queue_log.index(("input", "put", "tool:call_1"))
  1100. assert queue_log.index(("input", "put", "tool:call_1")) < queue_log.index(
  1101. ("input", "get", "tool:call_1")
  1102. )
  1103. @pytest.mark.asyncio
  1104. async def test_runtime_run_consumes_output_queue_in_stream_order():
  1105. RuntimeQueues = _runtime_queues_class()
  1106. queue_log: list[tuple[str, str, str]] = []
  1107. queues = RuntimeQueues(
  1108. input=RecordingQueue("input", queue_log),
  1109. output=RecordingQueue("output", queue_log),
  1110. events=RecordingQueue("events", queue_log),
  1111. )
  1112. request = DebugRunRequest(
  1113. user_message="debug this",
  1114. system_prompts=["You are a debugger."],
  1115. pre_messages=[],
  1116. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  1117. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=3),
  1118. )
  1119. runtime = DebugRuntime(FakeChatClient(), queues=queues)
  1120. outputs = [message async for message in runtime.run(request)]
  1121. assert _message_types(outputs) == [
  1122. "session_started",
  1123. "event",
  1124. "tool_result",
  1125. "round_stats",
  1126. "message_delta",
  1127. "round_stats",
  1128. "done",
  1129. ]
  1130. output_puts = [
  1131. entry[2] for entry in queue_log if entry[0] == "output" and entry[1] == "put"
  1132. ]
  1133. output_gets = [
  1134. entry[2] for entry in queue_log if entry[0] == "output" and entry[1] == "get"
  1135. ]
  1136. assert [message for message in output_puts if message != "output:audit"] == [
  1137. "output:session_started",
  1138. "output:event",
  1139. "output:tool_result",
  1140. "output:round_stats",
  1141. "output:message_delta",
  1142. "output:round_stats",
  1143. "output:done",
  1144. ]
  1145. assert output_gets == output_puts
  1146. @pytest.mark.asyncio
  1147. async def test_runtime_continues_with_event_summary_without_tool_call_history():
  1148. request = DebugRunRequest(
  1149. user_message="debug this",
  1150. system_prompts=["You are a debugger."],
  1151. pre_messages=[],
  1152. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  1153. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=2),
  1154. )
  1155. client = StrictHistoryChatClient()
  1156. runtime = DebugRuntime(client)
  1157. outputs = [message async for message in runtime.run(request)]
  1158. assert client.calls == 2
  1159. assert [message.role for message in client.second_call_messages] == [
  1160. "system",
  1161. "system",
  1162. "user",
  1163. "assistant",
  1164. "user",
  1165. ]
  1166. assistant_message = client.second_call_messages[3]
  1167. assert assistant_message.content == ""
  1168. assert "handoff_note" in client.second_call_messages[1].content
  1169. assert not any(message.role == "tool" for message in client.second_call_messages)
  1170. assert client.second_call_messages[4].content.startswith("EventAgent results:\n")
  1171. assert client.second_call_messages[4].name == "event_agent"
  1172. assert outputs[-1] == {"type": "done"}
  1173. @pytest.mark.asyncio
  1174. async def test_runtime_passes_event_catalog_system_message_without_chat_tools():
  1175. registry = ToolRegistry(
  1176. [
  1177. ToolDefinition(
  1178. name="handoff_note",
  1179. description="Registry-owned handoff tool.",
  1180. parameters={
  1181. "type": "object",
  1182. "properties": {
  1183. "message": {"type": "string"},
  1184. "priority": {"type": "number"},
  1185. },
  1186. "required": ["message"],
  1187. },
  1188. handler=lambda event: {"tool": event.name, "message": "handled"},
  1189. )
  1190. ]
  1191. )
  1192. request = DebugRunRequest(
  1193. user_message="debug this",
  1194. system_prompts=[],
  1195. pre_messages=[],
  1196. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  1197. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=2),
  1198. )
  1199. client = ToolCapturingChatClient()
  1200. runtime = DebugRuntime(client, registry=registry)
  1201. outputs = [message async for message in runtime.run(request)]
  1202. assert outputs[-1] == {"type": "done"}
  1203. assert client.tools == []
  1204. assert client.messages[0].role == "system"
  1205. assert "Available events:" in client.messages[0].content
  1206. assert "- handoff_note: Registry-owned handoff tool." in client.messages[0].content
  1207. assert "message" not in client.messages[0].content
  1208. assert "priority" not in client.messages[0].content
  1209. @pytest.mark.asyncio
  1210. async def test_runtime_emits_round_stats_with_clock_and_usage_after_model_turn():
  1211. request = DebugRunRequest(
  1212. user_message="debug this",
  1213. system_prompts=[],
  1214. pre_messages=[],
  1215. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  1216. event_agent=EventAgentParams(enabled_tools=[], max_event_loops=1),
  1217. )
  1218. ticks = iter(
  1219. [
  1220. 0.9,
  1221. 0.91,
  1222. 1.0,
  1223. 1.01,
  1224. 1.02,
  1225. 1.123,
  1226. 1.2,
  1227. 1.25,
  1228. 1.3,
  1229. 1.456,
  1230. 1.7,
  1231. 1.8,
  1232. ]
  1233. )
  1234. runtime = DebugRuntime(RoundStatsChatClient(), clock=lambda: next(ticks))
  1235. outputs = [message async for message in runtime.run(request)]
  1236. business_outputs = _without_audit(outputs)
  1237. assert _message_types(outputs) == [
  1238. "session_started",
  1239. "message_delta",
  1240. "usage",
  1241. "round_stats",
  1242. "done",
  1243. ]
  1244. assert business_outputs[3] == {
  1245. "type": "round_stats",
  1246. "round_index": 1,
  1247. "ttft_ms": 123,
  1248. "elapsed_ms": 456,
  1249. "prompt_tokens": 10,
  1250. "completion_tokens": 20,
  1251. "total_tokens": 30,
  1252. "cached_tokens": 5,
  1253. "had_event": False,
  1254. }
  1255. @pytest.mark.asyncio
  1256. async def test_runtime_emits_round_stats_for_each_chat_call_in_event_handoff():
  1257. request = DebugRunRequest(
  1258. user_message="debug this",
  1259. system_prompts=[],
  1260. pre_messages=[],
  1261. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  1262. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=2),
  1263. )
  1264. ticks = iter(
  1265. [
  1266. 1.9,
  1267. 1.91,
  1268. 2.0,
  1269. 2.01,
  1270. 2.02,
  1271. 2.03,
  1272. 2.04,
  1273. 2.05,
  1274. 2.06,
  1275. 2.07,
  1276. 2.08,
  1277. 2.09,
  1278. 2.1,
  1279. 2.25,
  1280. 2.26,
  1281. 3.0,
  1282. 3.01,
  1283. 3.02,
  1284. 3.05,
  1285. 3.1,
  1286. 3.15,
  1287. 3.18,
  1288. 3.2,
  1289. 3.23,
  1290. 3.24,
  1291. 3.25,
  1292. 3.26,
  1293. ]
  1294. )
  1295. client = EventRoundStatsChatClient()
  1296. runtime = DebugRuntime(client, clock=lambda: next(ticks))
  1297. outputs = [message async for message in runtime.run(request)]
  1298. stats = [message for message in outputs if message["type"] == "round_stats"]
  1299. assert client.calls == 2
  1300. assert stats == [
  1301. {
  1302. "type": "round_stats",
  1303. "round_index": 1,
  1304. "ttft_ms": None,
  1305. "elapsed_ms": 250,
  1306. "prompt_tokens": 3,
  1307. "completion_tokens": 0,
  1308. "total_tokens": 3,
  1309. "cached_tokens": 0,
  1310. "had_event": True,
  1311. },
  1312. {
  1313. "type": "round_stats",
  1314. "round_index": 2,
  1315. "ttft_ms": 50,
  1316. "elapsed_ms": 200,
  1317. "prompt_tokens": 4,
  1318. "completion_tokens": 6,
  1319. "total_tokens": 10,
  1320. "cached_tokens": 0,
  1321. "had_event": False,
  1322. },
  1323. ]
  1324. @pytest.mark.asyncio
  1325. async def test_runtime_session_resets_event_budget_for_each_user_turn():
  1326. request = DebugRunRequest(
  1327. user_message="first turn",
  1328. system_prompts=[],
  1329. pre_messages=[],
  1330. chat_agent=AgentParams(model="fake-model", temperature=0.1, max_tokens=200),
  1331. event_agent=EventAgentParams(enabled_tools=["handoff_note"], max_event_loops=1),
  1332. )
  1333. client = TwoTurnSessionChatClient()
  1334. runtime = DebugRuntime(client)
  1335. queues = runtime.start_session(request)
  1336. outputs: list[dict[str, Any]] = []
  1337. while len([message for message in outputs if message["type"] == "turn_completed"]) < 1:
  1338. outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
  1339. await queues.input.put(ChatMessage(role="user", content="second turn"))
  1340. while len([message for message in outputs if message["type"] == "turn_completed"]) < 2:
  1341. outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
  1342. await runtime.aclose()
  1343. business_types = [message["type"] for message in _without_audit(outputs)]
  1344. assert business_types.count("turn_started") == 2
  1345. assert business_types.count("turn_completed") == 2
  1346. assert client.calls == 4
  1347. assert "Available events:" in client.messages_by_call[0][0].content
  1348. assert "Available events:" in client.messages_by_call[2][0].content
  1349. @pytest.mark.asyncio
  1350. async def test_runtime_persists_session_turn_messages_audit_and_usage(tmp_path):
  1351. store = SQLiteSessionStore(tmp_path / "agent_lab.sqlite3")
  1352. request = DebugRunRequest(
  1353. session_id="session-1",
  1354. user_message="persist this",
  1355. system_prompts=["You are a debugger."],
  1356. pre_messages=[],
  1357. chat_agent=AgentParams(model="chat-model", temperature=0.1, max_tokens=200),
  1358. event_agent=EventAgentParams(enabled_tools=[], max_event_loops=1),
  1359. )
  1360. runtime = DebugRuntime(RoundStatsChatClient(), session_store=store)
  1361. queues = runtime.start_session(request)
  1362. outputs: list[dict[str, Any]] = []
  1363. while not any(message["type"] == "turn_completed" for message in outputs):
  1364. outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
  1365. await runtime.aclose()
  1366. assert _without_audit(outputs)[0] == {
  1367. "type": "session_started",
  1368. "session_id": "session-1",
  1369. }
  1370. assert store.get_session("session-1")["config"]["chat_agent"]["model"] == "chat-model"
  1371. assert [
  1372. (message["turn_index"], message["role"], message["content"])
  1373. for message in store.list_messages("session-1")
  1374. ] == [
  1375. (1, "user", "persist this"),
  1376. (1, "assistant", "hello"),
  1377. ]
  1378. audit_events = [
  1379. audit["event"]
  1380. for audit in store.list_audit_logs("session-1")
  1381. ]
  1382. assert "session_started" in audit_events
  1383. assert "chat_agent_request" in audit_events
  1384. assert "turn_completed" in audit_events
  1385. usage = store.usage_summary("session-1")
  1386. assert usage["calls"][0]["total_tokens"] == 30
  1387. assert usage["turns"][0]["turn_index"] == 1
  1388. assert usage["session"]["total_tokens"] == 30
  1389. @pytest.mark.asyncio
  1390. async def test_runtime_continues_persisted_turn_indexes_for_existing_session(tmp_path):
  1391. store = SQLiteSessionStore(tmp_path / "agent_lab.sqlite3")
  1392. session_id = store.create_session(title="existing", config={})
  1393. store.start_turn(session_id, turn_index=1, user_message="old")
  1394. request = DebugRunRequest(
  1395. session_id=session_id,
  1396. user_message="new",
  1397. system_prompts=[],
  1398. pre_messages=[],
  1399. chat_agent=AgentParams(model="chat-model", temperature=0.1, max_tokens=200),
  1400. event_agent=EventAgentParams(enabled_tools=[], max_event_loops=1),
  1401. )
  1402. runtime = DebugRuntime(RoundStatsChatClient(), session_store=store)
  1403. queues = runtime.start_session(request)
  1404. outputs: list[dict[str, Any]] = []
  1405. while not any(message["type"] == "turn_completed" for message in outputs):
  1406. outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
  1407. await runtime.aclose()
  1408. assert [
  1409. (message["turn_index"], message["content"])
  1410. for message in store.list_messages(session_id)
  1411. ] == [
  1412. (2, "new"),
  1413. (2, "hello"),
  1414. ]
  1415. class ScriptedChatClient:
  1416. def __init__(self, rounds: list[list[StreamItem]]) -> None:
  1417. self.rounds = rounds
  1418. self.calls = 0
  1419. self.messages_by_call: list[list[ChatMessage]] = []
  1420. self.tools_by_call: list[list[dict[str, Any]]] = []
  1421. async def stream_chat(
  1422. self,
  1423. messages: list[ChatMessage],
  1424. tools: list[dict],
  1425. params: AgentParams,
  1426. tool_choice: dict[str, Any] | None = None,
  1427. ) -> AsyncIterator[StreamItem]:
  1428. self.messages_by_call.append(list(messages))
  1429. self.tools_by_call.append(list(tools))
  1430. round_items = self.rounds[self.calls]
  1431. self.calls += 1
  1432. for item in round_items:
  1433. yield item
  1434. class TranscriptValidatingScriptedChatClient(ScriptedChatClient):
  1435. async def stream_chat(
  1436. self,
  1437. messages: list[ChatMessage],
  1438. tools: list[dict],
  1439. params: AgentParams,
  1440. tool_choice: dict[str, Any] | None = None,
  1441. ) -> AsyncIterator[StreamItem]:
  1442. OpenAICompatibleChatClient._validate_tool_transcript(self, messages)
  1443. async for item in super().stream_chat(
  1444. messages,
  1445. tools,
  1446. params,
  1447. tool_choice,
  1448. ):
  1449. yield item
  1450. def _direct_request(
  1451. *,
  1452. enabled_tools: list[str],
  1453. max_event_loops: int = 1,
  1454. session_id: str | None = None,
  1455. ) -> DebugRunRequest:
  1456. return DebugRunRequest(
  1457. session_id=session_id,
  1458. user_message="debug direct tools",
  1459. system_prompts=["You are a debugger."],
  1460. pre_messages=[],
  1461. chat_agent=AgentParams(model="chat-model"),
  1462. event_agent=EventAgentParams(
  1463. enabled_tools=enabled_tools,
  1464. max_event_loops=max_event_loops,
  1465. ),
  1466. tool_invocation_mode="chat_agent_tools",
  1467. )
  1468. @pytest.mark.asyncio
  1469. async def test_direct_mode_uses_enabled_schemas_and_valid_ordered_provider_transcript():
  1470. executed: list[tuple[str, dict[str, Any], str]] = []
  1471. def handle(event: ToolCallEvent) -> dict[str, Any]:
  1472. executed.append((event.name, event.arguments, event.raw_arguments))
  1473. return {"tool": event.name, "value": event.arguments["value"]}
  1474. registry = ToolRegistry(
  1475. [
  1476. ToolDefinition(
  1477. name="first_tool",
  1478. description="First direct tool.",
  1479. parameters={
  1480. "type": "object",
  1481. "properties": {"value": {"type": "string"}},
  1482. "required": ["value"],
  1483. },
  1484. handler=handle,
  1485. ),
  1486. ToolDefinition(
  1487. name="second_tool",
  1488. description="Second direct tool.",
  1489. parameters={
  1490. "type": "object",
  1491. "properties": {"value": {"type": "string"}},
  1492. "required": ["value"],
  1493. },
  1494. handler=handle,
  1495. ),
  1496. ]
  1497. )
  1498. calls = [
  1499. ToolCallEvent(
  1500. id="provider-1",
  1501. name="first_tool",
  1502. arguments={"value": "one"},
  1503. raw_arguments='{"value":"one"}',
  1504. ),
  1505. ToolCallEvent(
  1506. id="provider-2",
  1507. name="second_tool",
  1508. arguments={"value": "two"},
  1509. raw_arguments='{"value":"two"}',
  1510. ),
  1511. ]
  1512. ignored_text_event = ToolCallEvent(
  1513. id="text-ignored",
  1514. name="first_tool",
  1515. arguments={},
  1516. raw_arguments="{}",
  1517. )
  1518. client = ScriptedChatClient(
  1519. [
  1520. [
  1521. StreamItem.message_delta("Visible before tools."),
  1522. StreamItem.provider_tool_call(calls[0]),
  1523. StreamItem.text_event(ignored_text_event),
  1524. StreamItem.provider_tool_call(calls[1]),
  1525. ],
  1526. [StreamItem.message_delta("Final answer.")],
  1527. ]
  1528. )
  1529. outputs = await _collect_outputs(
  1530. DebugRuntime(client, registry=registry).run(
  1531. _direct_request(enabled_tools=["first_tool", "second_tool"])
  1532. )
  1533. )
  1534. assert [tool["function"]["name"] for tool in client.tools_by_call[0]] == [
  1535. "first_tool",
  1536. "second_tool",
  1537. ]
  1538. assert client.tools_by_call[1] == []
  1539. assert not any(
  1540. "Available events:" in message.content
  1541. for message in client.messages_by_call[0]
  1542. if message.role == "system"
  1543. )
  1544. transcript = client.messages_by_call[1]
  1545. assert [message.role for message in transcript[-4:]] == [
  1546. "user",
  1547. "assistant",
  1548. "tool",
  1549. "tool",
  1550. ]
  1551. assert transcript[-3].content == "Visible before tools."
  1552. assert transcript[-3].tool_calls == calls
  1553. assert [message.tool_call_id for message in transcript[-2:]] == [
  1554. "provider-1",
  1555. "provider-2",
  1556. ]
  1557. assert [message.name for message in transcript[-2:]] == [
  1558. "first_tool",
  1559. "second_tool",
  1560. ]
  1561. assert executed == [
  1562. ("first_tool", {"value": "one"}, '{"value":"one"}'),
  1563. ("second_tool", {"value": "two"}, '{"value":"two"}'),
  1564. ]
  1565. emitted_events = [
  1566. message["event"] for message in outputs if message["type"] == "event"
  1567. ]
  1568. assert emitted_events == [call.model_dump() for call in calls]
  1569. assert "event_agent_request" not in [
  1570. message.get("event") for message in outputs if message["type"] == "audit"
  1571. ]
  1572. assert not any(
  1573. message.get("event", {}).get("id") == "text-ignored"
  1574. for message in outputs
  1575. if message["type"] == "event"
  1576. )
  1577. assert all(
  1578. message["details"]["tool_invocation_mode"] == "chat_agent_tools"
  1579. for message in outputs
  1580. if message["type"] == "audit"
  1581. and message["event"] in {"chat_round_started", "chat_agent_request"}
  1582. )
  1583. @pytest.mark.asyncio
  1584. async def test_direct_mode_does_not_execute_unknown_disabled_or_failed_handlers():
  1585. executed: list[str] = []
  1586. def disabled_handler(event: ToolCallEvent) -> dict[str, Any]:
  1587. executed.append(event.name)
  1588. return {"tool": event.name}
  1589. def failing_handler(event: ToolCallEvent) -> dict[str, Any]:
  1590. executed.append(event.name)
  1591. raise RuntimeError("direct boom")
  1592. registry = ToolRegistry(
  1593. [
  1594. ToolDefinition(
  1595. name="enabled_tool",
  1596. description="Enabled.",
  1597. parameters={"type": "object"},
  1598. handler=lambda event: {"tool": event.name},
  1599. ),
  1600. ToolDefinition(
  1601. name="disabled_tool",
  1602. description="Disabled.",
  1603. parameters={"type": "object"},
  1604. handler=disabled_handler,
  1605. ),
  1606. ToolDefinition(
  1607. name="failing_tool",
  1608. description="Fails.",
  1609. parameters={"type": "object"},
  1610. handler=failing_handler,
  1611. ),
  1612. ]
  1613. )
  1614. client = ScriptedChatClient(
  1615. [
  1616. [
  1617. StreamItem.provider_tool_call(
  1618. ToolCallEvent(
  1619. id="unknown-1",
  1620. name="unknown_tool",
  1621. arguments={"kept": True},
  1622. raw_arguments='{"kept":true}',
  1623. )
  1624. ),
  1625. StreamItem.provider_tool_call(
  1626. ToolCallEvent(
  1627. id="disabled-1",
  1628. name="disabled_tool",
  1629. arguments={},
  1630. raw_arguments="{}",
  1631. )
  1632. ),
  1633. StreamItem.provider_tool_call(
  1634. ToolCallEvent(
  1635. id="failed-1",
  1636. name="failing_tool",
  1637. arguments={},
  1638. raw_arguments="{}",
  1639. )
  1640. ),
  1641. ],
  1642. [StreamItem.message_delta("continued")],
  1643. ]
  1644. )
  1645. outputs = await _collect_outputs(
  1646. DebugRuntime(client, registry=registry).run(
  1647. _direct_request(enabled_tools=["enabled_tool", "failing_tool"])
  1648. )
  1649. )
  1650. assert [tool["function"]["name"] for tool in client.tools_by_call[0]] == [
  1651. "enabled_tool",
  1652. "failing_tool",
  1653. ]
  1654. assert executed == ["failing_tool"]
  1655. results = [
  1656. json.loads(message["message"]["content"])
  1657. for message in outputs
  1658. if message["type"] == "tool_result"
  1659. ]
  1660. assert results == [
  1661. {"tool": "unknown_tool", "error": "unknown tool"},
  1662. {"tool": "disabled_tool", "error": "tool disabled"},
  1663. {"tool": "failing_tool", "error": "tool handler failed: direct boom"},
  1664. ]
  1665. @pytest.mark.asyncio
  1666. async def test_direct_mode_ignores_provider_calls_after_event_budget_exhaustion():
  1667. executed: list[str] = []
  1668. registry = ToolRegistry(
  1669. [
  1670. ToolDefinition(
  1671. name="once_tool",
  1672. description="Run once.",
  1673. parameters={"type": "object"},
  1674. handler=lambda event: executed.append(event.id) or {"tool": event.name},
  1675. )
  1676. ]
  1677. )
  1678. client = ScriptedChatClient(
  1679. [
  1680. [
  1681. StreamItem.provider_tool_call(
  1682. ToolCallEvent(
  1683. id="accepted",
  1684. name="once_tool",
  1685. arguments={},
  1686. raw_arguments="{}",
  1687. )
  1688. )
  1689. ],
  1690. [
  1691. StreamItem.provider_tool_call(
  1692. ToolCallEvent(
  1693. id="ignored",
  1694. name="once_tool",
  1695. arguments={},
  1696. raw_arguments="{}",
  1697. )
  1698. ),
  1699. StreamItem.message_delta("budget exhausted"),
  1700. ],
  1701. ]
  1702. )
  1703. outputs = await _collect_outputs(
  1704. DebugRuntime(client, registry=registry).run(
  1705. _direct_request(enabled_tools=["once_tool"], max_event_loops=1)
  1706. )
  1707. )
  1708. assert client.tools_by_call == [
  1709. [registry.tool_schema("once_tool")],
  1710. [],
  1711. ]
  1712. assert executed == ["accepted"]
  1713. assert [
  1714. message["event"]["id"]
  1715. for message in outputs
  1716. if message["type"] == "event"
  1717. ] == ["accepted"]
  1718. assert outputs[-1] == {"type": "done"}
  1719. @pytest.mark.asyncio
  1720. async def test_direct_mode_session_turns_reset_budget_and_snapshot_mode(tmp_path):
  1721. store = SQLiteSessionStore(tmp_path / "agent_lab.sqlite3")
  1722. registry = ToolRegistry(
  1723. [
  1724. ToolDefinition(
  1725. name="turn_tool",
  1726. description="Per-turn tool.",
  1727. parameters={"type": "object"},
  1728. handler=lambda event: {"tool": event.name, "call_id": event.id},
  1729. )
  1730. ]
  1731. )
  1732. client = ScriptedChatClient(
  1733. [
  1734. [
  1735. StreamItem.provider_tool_call(
  1736. ToolCallEvent(
  1737. id="turn-1-call",
  1738. name="turn_tool",
  1739. arguments={},
  1740. raw_arguments="{}",
  1741. )
  1742. )
  1743. ],
  1744. [StreamItem.message_delta("turn one done")],
  1745. [
  1746. StreamItem.provider_tool_call(
  1747. ToolCallEvent(
  1748. id="turn-2-call",
  1749. name="turn_tool",
  1750. arguments={},
  1751. raw_arguments="{}",
  1752. )
  1753. )
  1754. ],
  1755. [StreamItem.message_delta("turn two done")],
  1756. ]
  1757. )
  1758. runtime = DebugRuntime(client, registry=registry, session_store=store)
  1759. request = _direct_request(
  1760. enabled_tools=["turn_tool"],
  1761. max_event_loops=1,
  1762. session_id="direct-session",
  1763. )
  1764. queues = runtime.start_session(request)
  1765. outputs: list[dict[str, Any]] = []
  1766. while sum(message["type"] == "turn_completed" for message in outputs) < 1:
  1767. outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
  1768. await queues.input.put(ChatMessage(role="user", content="second direct turn"))
  1769. while sum(message["type"] == "turn_completed" for message in outputs) < 2:
  1770. outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
  1771. await runtime.aclose()
  1772. assert [bool(tools) for tools in client.tools_by_call] == [True, False, True, False]
  1773. assert [message.role for message in client.messages_by_call[1][-3:]] == [
  1774. "user",
  1775. "assistant",
  1776. "tool",
  1777. ]
  1778. second_turn_transcript = client.messages_by_call[3]
  1779. assert not any(message.name == "event_agent" for message in second_turn_transcript)
  1780. assert [
  1781. message.tool_call_id
  1782. for message in second_turn_transcript
  1783. if message.role == "tool"
  1784. ] == ["turn-1-call", "turn-2-call"]
  1785. session = store.get_session("direct-session")
  1786. assert session is not None
  1787. assert session["config"]["tool_invocation_mode"] == "chat_agent_tools"
  1788. persisted_messages = store.list_messages("direct-session")
  1789. assert [message["role"] for message in persisted_messages] == [
  1790. "user",
  1791. "assistant",
  1792. "tool",
  1793. "assistant",
  1794. "user",
  1795. "assistant",
  1796. "tool",
  1797. "assistant",
  1798. ]
  1799. assert [
  1800. call["id"]
  1801. for message in persisted_messages
  1802. for call in message["tool_calls"]
  1803. ] == ["turn-1-call", "turn-2-call"]
  1804. assert [
  1805. message["tool_call_id"]
  1806. for message in persisted_messages
  1807. if message["role"] == "tool"
  1808. ] == ["turn-1-call", "turn-2-call"]
  1809. request_audits = [
  1810. audit
  1811. for audit in store.list_audit_logs("direct-session")
  1812. if audit["event"] == "chat_agent_request"
  1813. ]
  1814. assert request_audits
  1815. assert all(
  1816. audit["details"]["tool_invocation_mode"] == "chat_agent_tools"
  1817. for audit in request_audits
  1818. )
  1819. @pytest.mark.asyncio
  1820. async def test_direct_mode_reconnect_restores_provider_valid_session_transcript(tmp_path):
  1821. store = SQLiteSessionStore(tmp_path / "agent_lab.sqlite3")
  1822. session_id = store.create_session(
  1823. title="restored direct",
  1824. config={"tool_invocation_mode": "chat_agent_tools"},
  1825. session_id="restored-direct",
  1826. )
  1827. store.start_turn(session_id, turn_index=1, user_message="past user")
  1828. previous_call = ToolCallEvent(
  1829. id="past-call",
  1830. name="safe_tool",
  1831. arguments={"value": 1},
  1832. raw_arguments='{"value":1}',
  1833. )
  1834. for message in [
  1835. ChatMessage(role="user", content="past user"),
  1836. ChatMessage(role="assistant", content="", tool_calls=[previous_call]),
  1837. ChatMessage(
  1838. role="tool",
  1839. content='{"ok":true}',
  1840. name="safe_tool",
  1841. tool_call_id="past-call",
  1842. ),
  1843. ChatMessage(role="assistant", content="past answer"),
  1844. ]:
  1845. store.append_message(session_id, turn_index=1, message=message)
  1846. store.complete_turn(session_id, turn_index=1)
  1847. client = TranscriptValidatingScriptedChatClient(
  1848. [[StreamItem.message_delta("current answer")]]
  1849. )
  1850. runtime = DebugRuntime(
  1851. client,
  1852. registry=_single_tool_registry(lambda event: {"ok": True}),
  1853. session_store=store,
  1854. )
  1855. request = _direct_request(
  1856. enabled_tools=["safe_tool"],
  1857. session_id=session_id,
  1858. ).model_copy(
  1859. update={
  1860. "user_message": "current user",
  1861. "pre_messages": [ChatMessage(role="user", content="configured pre")],
  1862. }
  1863. )
  1864. queues = runtime.start_session(request)
  1865. outputs: list[dict[str, Any]] = []
  1866. while not any(message["type"] == "turn_completed" for message in outputs):
  1867. outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
  1868. await runtime.aclose()
  1869. transcript = client.messages_by_call[0]
  1870. assert [(message.role, message.content) for message in transcript] == [
  1871. ("system", "You are a debugger."),
  1872. ("user", "configured pre"),
  1873. ("user", "past user"),
  1874. ("assistant", ""),
  1875. ("tool", '{"ok":true}'),
  1876. ("assistant", "past answer"),
  1877. ("user", "current user"),
  1878. ]
  1879. assert transcript[3].tool_calls == [previous_call]
  1880. assert sum(
  1881. message.role == "user" and message.content == "current user"
  1882. for message in transcript
  1883. ) == 1
  1884. @pytest.mark.asyncio
  1885. async def test_cancelled_direct_batch_persists_no_partial_protocol_and_reconnects(tmp_path):
  1886. store = SQLiteSessionStore(tmp_path / "agent_lab.sqlite3")
  1887. handler_started = asyncio.Event()
  1888. release_handler = asyncio.Event()
  1889. async def blocking_handler(event: ToolCallEvent) -> dict[str, Any]:
  1890. handler_started.set()
  1891. await release_handler.wait()
  1892. return {"ok": True, "call_id": event.id}
  1893. registry = _single_tool_registry(blocking_handler)
  1894. first_client = ScriptedChatClient(
  1895. [[StreamItem.provider_tool_call(_tool_call("cancelled-call"))]]
  1896. )
  1897. first_runtime = DebugRuntime(
  1898. first_client,
  1899. registry=registry,
  1900. session_store=store,
  1901. )
  1902. request = _direct_request(
  1903. enabled_tools=["safe_tool"],
  1904. session_id="cancelled-direct",
  1905. )
  1906. first_runtime.start_session(request)
  1907. await asyncio.wait_for(handler_started.wait(), timeout=1)
  1908. await first_runtime.aclose()
  1909. persisted_after_cancel = store.list_messages("cancelled-direct")
  1910. assert [message["role"] for message in persisted_after_cancel] == ["user"]
  1911. assert not any(message["tool_calls"] for message in persisted_after_cancel)
  1912. reconnect_client = TranscriptValidatingScriptedChatClient(
  1913. [[StreamItem.message_delta("reconnected")]]
  1914. )
  1915. reconnect_runtime = DebugRuntime(
  1916. reconnect_client,
  1917. registry=registry,
  1918. session_store=store,
  1919. )
  1920. reconnect_request = request.model_copy(
  1921. update={"user_message": "after cancellation"}
  1922. )
  1923. queues = reconnect_runtime.start_session(reconnect_request)
  1924. outputs: list[dict[str, Any]] = []
  1925. while not any(
  1926. message["type"] in {"turn_completed", "error"} for message in outputs
  1927. ):
  1928. outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
  1929. await reconnect_runtime.aclose()
  1930. assert not any(message["type"] == "error" for message in outputs)
  1931. assert [
  1932. (message.role, message.content)
  1933. for message in reconnect_client.messages_by_call[0]
  1934. if message.role != "system"
  1935. ] == [
  1936. ("user", "debug direct tools"),
  1937. ("user", "after cancellation"),
  1938. ]
  1939. @pytest.mark.asyncio
  1940. async def test_direct_restore_drops_invalid_protocol_fragments_and_keeps_visible_history(
  1941. tmp_path,
  1942. ):
  1943. store = SQLiteSessionStore(tmp_path / "agent_lab.sqlite3")
  1944. session_id = store.create_session(
  1945. title="repair transcript",
  1946. config={"tool_invocation_mode": "chat_agent_tools"},
  1947. session_id="repair-transcript",
  1948. )
  1949. store.start_turn(session_id, turn_index=1, user_message="visible user")
  1950. complete_calls = [_tool_call("complete-1"), _tool_call("complete-2")]
  1951. store.append_messages(
  1952. session_id,
  1953. turn_index=1,
  1954. messages=[
  1955. ChatMessage(role="user", content="visible user"),
  1956. ChatMessage(
  1957. role="assistant",
  1958. content="using tools",
  1959. tool_calls=complete_calls,
  1960. ),
  1961. ChatMessage(
  1962. role="tool",
  1963. content='{"id":1}',
  1964. name="safe_tool",
  1965. tool_call_id="complete-1",
  1966. ),
  1967. ChatMessage(
  1968. role="tool",
  1969. content='{"id":2}',
  1970. name="safe_tool",
  1971. tool_call_id="complete-2",
  1972. ),
  1973. ChatMessage(role="assistant", content="visible answer"),
  1974. ChatMessage(
  1975. role="tool",
  1976. content='{"orphan":true}',
  1977. name="safe_tool",
  1978. tool_call_id="orphan-call",
  1979. ),
  1980. ChatMessage(role="assistant", content="visible after orphan"),
  1981. ChatMessage(
  1982. role="assistant",
  1983. content="",
  1984. tool_calls=[_tool_call("incomplete-call")],
  1985. ),
  1986. ],
  1987. )
  1988. store.complete_turn(session_id, turn_index=1)
  1989. client = TranscriptValidatingScriptedChatClient(
  1990. [[StreamItem.message_delta("repaired")]]
  1991. )
  1992. runtime = DebugRuntime(
  1993. client,
  1994. registry=_single_tool_registry(lambda event: {"ok": True}),
  1995. session_store=store,
  1996. )
  1997. request = _direct_request(
  1998. enabled_tools=["safe_tool"],
  1999. session_id=session_id,
  2000. ).model_copy(update={"user_message": "current user"})
  2001. queues = runtime.start_session(request)
  2002. outputs: list[dict[str, Any]] = []
  2003. while not any(
  2004. message["type"] in {"turn_completed", "error"} for message in outputs
  2005. ):
  2006. outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
  2007. await runtime.aclose()
  2008. assert not any(message["type"] == "error" for message in outputs)
  2009. transcript = client.messages_by_call[0]
  2010. assert [(message.role, message.content) for message in transcript] == [
  2011. ("system", "You are a debugger."),
  2012. ("user", "visible user"),
  2013. ("assistant", "using tools"),
  2014. ("tool", '{"id":1}'),
  2015. ("tool", '{"id":2}'),
  2016. ("assistant", "visible answer"),
  2017. ("assistant", "visible after orphan"),
  2018. ("user", "current user"),
  2019. ]
  2020. assert transcript[2].tool_calls == complete_calls
  2021. @pytest.mark.asyncio
  2022. async def test_dual_mode_reconnect_restores_visible_history_without_tool_protocol(tmp_path):
  2023. store = SQLiteSessionStore(tmp_path / "agent_lab.sqlite3")
  2024. session_id = store.create_session(
  2025. title="restored dual",
  2026. config={"tool_invocation_mode": "dual_agent"},
  2027. session_id="restored-dual",
  2028. )
  2029. store.start_turn(session_id, turn_index=1, user_message="past user")
  2030. store.append_message(
  2031. session_id,
  2032. turn_index=1,
  2033. message=ChatMessage(role="user", content="past user"),
  2034. )
  2035. store.append_message(
  2036. session_id,
  2037. turn_index=1,
  2038. message=ChatMessage(role="assistant", content="past answer"),
  2039. )
  2040. store.complete_turn(session_id, turn_index=1)
  2041. registry = _single_tool_registry(lambda event: {"ok": True})
  2042. client = ScriptedChatClient(
  2043. [
  2044. [StreamItem.text_event(_tool_call("dual-call"))],
  2045. [StreamItem.message_delta("current answer")],
  2046. ]
  2047. )
  2048. runtime = DebugRuntime(client, registry=registry, session_store=store)
  2049. request = DebugRunRequest(
  2050. session_id=session_id,
  2051. user_message="current user",
  2052. system_prompts=["system context"],
  2053. pre_messages=[ChatMessage(role="assistant", content="configured pre")],
  2054. chat_agent=AgentParams(model="chat-model"),
  2055. event_agent=EventAgentParams(
  2056. enabled_tools=["safe_tool"],
  2057. max_event_loops=1,
  2058. ),
  2059. tool_invocation_mode="dual_agent",
  2060. )
  2061. queues = runtime.start_session(request)
  2062. outputs: list[dict[str, Any]] = []
  2063. while not any(message["type"] == "turn_completed" for message in outputs):
  2064. outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
  2065. await runtime.aclose()
  2066. first_request = client.messages_by_call[0]
  2067. assert [(message.role, message.content) for message in first_request if message.role != "system"] == [
  2068. ("assistant", "configured pre"),
  2069. ("user", "past user"),
  2070. ("assistant", "past answer"),
  2071. ("user", "current user"),
  2072. ]
  2073. assert sum(
  2074. message.role == "user" and message.content == "current user"
  2075. for message in first_request
  2076. ) == 1
  2077. persisted_messages = store.list_messages(session_id)
  2078. assert not any(message["role"] == "tool" for message in persisted_messages)
  2079. assert not any(message["tool_calls"] for message in persisted_messages)
  2080. assert not any(message["content"] == "" for message in persisted_messages)
  2081. def _single_tool_registry(
  2082. handler: Any,
  2083. *,
  2084. name: str = "safe_tool",
  2085. ) -> ToolRegistry:
  2086. return ToolRegistry(
  2087. [
  2088. ToolDefinition(
  2089. name=name,
  2090. description="Tool used by execution-safety tests.",
  2091. parameters={"type": "object"},
  2092. handler=handler,
  2093. )
  2094. ]
  2095. )
  2096. def _tool_call(call_id: str, *, name: str = "safe_tool") -> ToolCallEvent:
  2097. return ToolCallEvent(
  2098. id=call_id,
  2099. name=name,
  2100. arguments={},
  2101. raw_arguments="{}",
  2102. )
  2103. @pytest.mark.asyncio
  2104. async def test_direct_mode_rejects_provider_call_id_reused_across_one_shot_rounds():
  2105. executed: list[str] = []
  2106. registry = _single_tool_registry(
  2107. lambda event: executed.append(event.id) or {"tool": event.name}
  2108. )
  2109. client = ScriptedChatClient(
  2110. [
  2111. [StreamItem.provider_tool_call(_tool_call("duplicate-call"))],
  2112. [StreamItem.provider_tool_call(_tool_call("duplicate-call"))],
  2113. ]
  2114. )
  2115. outputs = await _collect_outputs(
  2116. DebugRuntime(client, registry=registry).run(
  2117. _direct_request(enabled_tools=["safe_tool"], max_event_loops=2)
  2118. )
  2119. )
  2120. assert executed == ["duplicate-call"]
  2121. assert [
  2122. message["message"]["tool_call_id"]
  2123. for message in outputs
  2124. if message["type"] == "tool_result"
  2125. ] == ["duplicate-call"]
  2126. assert outputs[-1]["type"] == "error"
  2127. assert "duplicate provider tool-call ID: duplicate-call" in outputs[-1]["message"]
  2128. @pytest.mark.asyncio
  2129. async def test_direct_mode_rejects_provider_call_id_reused_in_session_rounds():
  2130. executed: list[str] = []
  2131. registry = _single_tool_registry(
  2132. lambda event: executed.append(event.id) or {"tool": event.name}
  2133. )
  2134. client = ScriptedChatClient(
  2135. [
  2136. [StreamItem.provider_tool_call(_tool_call("session-duplicate"))],
  2137. [StreamItem.provider_tool_call(_tool_call("session-duplicate"))],
  2138. ]
  2139. )
  2140. runtime = DebugRuntime(client, registry=registry)
  2141. queues = runtime.start_session(
  2142. _direct_request(enabled_tools=["safe_tool"], max_event_loops=2)
  2143. )
  2144. outputs: list[dict[str, Any]] = []
  2145. while not any(message["type"] == "error" for message in outputs):
  2146. outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
  2147. await runtime.aclose()
  2148. assert executed == ["session-duplicate"]
  2149. assert [
  2150. message["message"]["tool_call_id"]
  2151. for message in outputs
  2152. if message["type"] == "tool_result"
  2153. ] == ["session-duplicate"]
  2154. assert "duplicate provider tool-call ID: session-duplicate" in outputs[-1][
  2155. "message"
  2156. ]
  2157. @pytest.mark.asyncio
  2158. async def test_direct_mode_rejects_duplicate_provider_call_ids_within_batch():
  2159. executed: list[str] = []
  2160. registry = _single_tool_registry(
  2161. lambda event: executed.append(event.id) or {"tool": event.name}
  2162. )
  2163. client = ScriptedChatClient(
  2164. [
  2165. [
  2166. StreamItem.provider_tool_call(_tool_call("same-batch")),
  2167. StreamItem.provider_tool_call(_tool_call("same-batch")),
  2168. ]
  2169. ]
  2170. )
  2171. outputs = await _collect_outputs(
  2172. DebugRuntime(client, registry=registry).run(
  2173. _direct_request(enabled_tools=["safe_tool"])
  2174. )
  2175. )
  2176. assert executed == []
  2177. assert outputs[-1]["type"] == "error"
  2178. assert "duplicate provider tool-call ID: same-batch" in outputs[-1]["message"]
  2179. @pytest.mark.asyncio
  2180. async def test_direct_mode_executes_batch_concurrently_but_replies_in_provider_order():
  2181. both_started = asyncio.Event()
  2182. first_release = asyncio.Event()
  2183. second_release = asyncio.Event()
  2184. second_finished = asyncio.Event()
  2185. started: list[str] = []
  2186. async def handler(event: ToolCallEvent) -> dict[str, Any]:
  2187. started.append(event.id)
  2188. if len(started) == 2:
  2189. both_started.set()
  2190. if event.id == "first-call":
  2191. await first_release.wait()
  2192. else:
  2193. await second_release.wait()
  2194. second_finished.set()
  2195. return {"tool": event.name, "call_id": event.id}
  2196. registry = _single_tool_registry(handler)
  2197. client = ScriptedChatClient(
  2198. [
  2199. [
  2200. StreamItem.provider_tool_call(
  2201. _tool_call("first-call").model_copy(
  2202. update={"arguments": {"order": 1}, "raw_arguments": '{"order":1}'}
  2203. )
  2204. ),
  2205. StreamItem.provider_tool_call(
  2206. _tool_call("second-call").model_copy(
  2207. update={"arguments": {"order": 2}, "raw_arguments": '{"order":2}'}
  2208. )
  2209. ),
  2210. ],
  2211. [StreamItem.message_delta("finished")],
  2212. ]
  2213. )
  2214. runtime = DebugRuntime(client, registry=registry)
  2215. run_task = asyncio.create_task(
  2216. _collect_outputs(
  2217. runtime.run(_direct_request(enabled_tools=["safe_tool"]))
  2218. )
  2219. )
  2220. overlapped = False
  2221. try:
  2222. try:
  2223. await asyncio.wait_for(both_started.wait(), timeout=1)
  2224. except TimeoutError:
  2225. pass
  2226. else:
  2227. overlapped = True
  2228. second_release.set()
  2229. await asyncio.wait_for(second_finished.wait(), timeout=1)
  2230. assert not run_task.done()
  2231. finally:
  2232. first_release.set()
  2233. second_release.set()
  2234. outputs = await asyncio.wait_for(run_task, timeout=1)
  2235. assert overlapped is True
  2236. assert started == ["first-call", "second-call"]
  2237. assert [
  2238. message["message"]["tool_call_id"]
  2239. for message in outputs
  2240. if message["type"] == "tool_result"
  2241. ] == ["first-call", "second-call"]
  2242. def _dual_budget_request() -> DebugRunRequest:
  2243. return DebugRunRequest(
  2244. user_message="dual budget",
  2245. system_prompts=[],
  2246. pre_messages=[],
  2247. chat_agent=AgentParams(model="chat-model"),
  2248. event_agent=EventAgentParams(
  2249. enabled_tools=["safe_tool"],
  2250. max_event_loops=1,
  2251. ),
  2252. )
  2253. @pytest.mark.asyncio
  2254. async def test_dual_mode_ignores_text_event_after_one_shot_budget_exhaustion():
  2255. executed: list[str] = []
  2256. registry = _single_tool_registry(
  2257. lambda event: executed.append(event.id) or {"tool": event.name}
  2258. )
  2259. client = ScriptedChatClient(
  2260. [
  2261. [StreamItem.text_event(_tool_call("accepted-text-event"))],
  2262. [
  2263. StreamItem.text_event(_tool_call("ignored-text-event")),
  2264. StreamItem.message_delta("final dual answer"),
  2265. ],
  2266. ]
  2267. )
  2268. outputs = await _collect_outputs(
  2269. DebugRuntime(client, registry=registry).run(_dual_budget_request())
  2270. )
  2271. assert executed == ["accepted-text-event"]
  2272. assert [
  2273. message["event"]["id"]
  2274. for message in outputs
  2275. if message["type"] == "event"
  2276. ] == ["accepted-text-event"]
  2277. assert outputs[-1] == {"type": "done"}
  2278. @pytest.mark.asyncio
  2279. async def test_dual_mode_session_main_path_ignores_text_event_after_budget_exhaustion():
  2280. executed: list[str] = []
  2281. registry = _single_tool_registry(
  2282. lambda event: executed.append(event.id) or {"tool": event.name}
  2283. )
  2284. client = ScriptedChatClient(
  2285. [
  2286. [StreamItem.text_event(_tool_call("session-accepted"))],
  2287. [
  2288. StreamItem.text_event(_tool_call("session-ignored")),
  2289. StreamItem.message_delta("session final"),
  2290. ],
  2291. ]
  2292. )
  2293. runtime = DebugRuntime(client, registry=registry)
  2294. queues = runtime.start_session(_dual_budget_request())
  2295. outputs: list[dict[str, Any]] = []
  2296. while not any(
  2297. message["type"] in {"turn_completed", "error"} for message in outputs
  2298. ):
  2299. outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
  2300. await runtime.aclose()
  2301. assert executed == ["session-accepted"]
  2302. assert [
  2303. message["event"]["id"]
  2304. for message in outputs
  2305. if message["type"] == "event"
  2306. ] == ["session-accepted"]
  2307. assert outputs[-1]["type"] == "turn_completed"
  2308. def _policy_request(
  2309. *,
  2310. enabled_tools: list[str],
  2311. mode: str = "dual_agent",
  2312. session_id: str | None = None,
  2313. ) -> DebugRunRequest:
  2314. return DebugRunRequest(
  2315. session_id=session_id,
  2316. user_message="exercise event result policy",
  2317. system_prompts=[],
  2318. pre_messages=[],
  2319. chat_agent=AgentParams(model="chat-model"),
  2320. event_agent=EventAgentParams(
  2321. enabled_tools=enabled_tools,
  2322. max_event_loops=1,
  2323. ),
  2324. tool_invocation_mode=mode,
  2325. )
  2326. @pytest.mark.asyncio
  2327. async def test_dual_silent_success_does_not_enqueue_legacy_summary_or_call_chat_again():
  2328. registry = ToolRegistry(
  2329. [
  2330. ToolDefinition(
  2331. name="example.silent",
  2332. description="Complete silently.",
  2333. parameters={"type": "object"},
  2334. handler=lambda event: {"tool": event.name, "status": "applied"},
  2335. result_policy=ResultPolicy.SILENT_SUCCESS,
  2336. )
  2337. ]
  2338. )
  2339. client = ScriptedChatClient(
  2340. [
  2341. [
  2342. StreamItem.message_delta("Applying it now."),
  2343. StreamItem.text_event(_tool_call("silent-1", name="example.silent")),
  2344. ]
  2345. ]
  2346. )
  2347. outputs = await _collect_outputs(
  2348. DebugRuntime(client, registry=registry).run(
  2349. _policy_request(enabled_tools=["example.silent"])
  2350. )
  2351. )
  2352. assert client.calls == 1
  2353. assert outputs[-1] == {"type": "done"}
  2354. assert not any(
  2355. message.get("details", {}).get("result_summary")
  2356. for message in outputs
  2357. if message.get("event") == "event_agent_completed"
  2358. )
  2359. @pytest.mark.asyncio
  2360. async def test_direct_template_success_emits_plugin_confirmation_without_model_call():
  2361. schedule = ToolCallEvent(
  2362. id="schedule-1",
  2363. name="calendar.schedule.create",
  2364. arguments={
  2365. "title": "Design review",
  2366. "start_at": "2026-07-14T09:30:00+08:00",
  2367. "timezone": "Asia/Shanghai",
  2368. },
  2369. raw_arguments=(
  2370. '{"title":"Design review","start_at":"2026-07-14T09:30:00+08:00",'
  2371. '"timezone":"Asia/Shanghai"}'
  2372. ),
  2373. )
  2374. client = ScriptedChatClient(
  2375. [
  2376. [
  2377. StreamItem.message_delta("I will add it."),
  2378. StreamItem.provider_tool_call(schedule),
  2379. ]
  2380. ]
  2381. )
  2382. outputs = await _collect_outputs(
  2383. DebugRuntime(client).run(
  2384. _policy_request(
  2385. enabled_tools=["calendar.schedule.create"],
  2386. mode="chat_agent_tools",
  2387. )
  2388. )
  2389. )
  2390. assert client.calls == 1
  2391. assert [
  2392. message["content"]
  2393. for message in outputs
  2394. if message["type"] == "message_delta"
  2395. ] == [
  2396. "I will add it.",
  2397. "Scheduled Design review for 2026-07-14T09:30:00+08:00 (Asia/Shanghai).",
  2398. ]
  2399. assert outputs[-1] == {"type": "done"}
  2400. @pytest.mark.asyncio
  2401. async def test_direct_silent_failure_requests_exactly_one_corrective_follow_up():
  2402. def fail(event: ToolCallEvent) -> dict[str, Any]:
  2403. raise RuntimeError("device offline")
  2404. registry = ToolRegistry(
  2405. [
  2406. ToolDefinition(
  2407. name="example.silent",
  2408. description="Complete silently on success.",
  2409. parameters={"type": "object"},
  2410. handler=fail,
  2411. result_policy=ResultPolicy.SILENT_SUCCESS,
  2412. )
  2413. ]
  2414. )
  2415. client = ScriptedChatClient(
  2416. [
  2417. [
  2418. StreamItem.message_delta("Trying it."),
  2419. StreamItem.provider_tool_call(
  2420. _tool_call("silent-failure", name="example.silent")
  2421. ),
  2422. ],
  2423. [StreamItem.message_delta("I could not apply that change.")],
  2424. ]
  2425. )
  2426. outputs = await _collect_outputs(
  2427. DebugRuntime(client, registry=registry).run(
  2428. _policy_request(
  2429. enabled_tools=["example.silent"],
  2430. mode="chat_agent_tools",
  2431. )
  2432. )
  2433. )
  2434. assert client.calls == 2
  2435. assert [
  2436. message["content"]
  2437. for message in outputs
  2438. if message["type"] == "message_delta"
  2439. ] == ["Trying it.", "I could not apply that change."]
  2440. @pytest.mark.asyncio
  2441. async def test_mixed_builtin_batch_emits_template_then_terminates_without_llm():
  2442. calls = [
  2443. ToolCallEvent(
  2444. id="terminate-1",
  2445. name="session.terminate",
  2446. arguments={},
  2447. raw_arguments="{}",
  2448. ),
  2449. ToolCallEvent(
  2450. id="schedule-1",
  2451. name="calendar.schedule.create",
  2452. arguments={
  2453. "title": "Design review",
  2454. "start_at": "2026-07-14T09:30:00+08:00",
  2455. "timezone": "Asia/Shanghai",
  2456. },
  2457. raw_arguments=(
  2458. '{"title":"Design review","start_at":"2026-07-14T09:30:00+08:00",'
  2459. '"timezone":"Asia/Shanghai"}'
  2460. ),
  2461. ),
  2462. ToolCallEvent(
  2463. id="search-1",
  2464. name="knowledge.web.search",
  2465. arguments={"query": "event batch executors"},
  2466. raw_arguments='{"query":"event batch executors"}',
  2467. ),
  2468. ToolCallEvent(
  2469. id="volume-1",
  2470. name="device.volume.adjust",
  2471. arguments={"mode": "absolute", "value": 30},
  2472. raw_arguments='{"mode":"absolute","value":30}',
  2473. ),
  2474. ]
  2475. client = ScriptedChatClient(
  2476. [
  2477. [
  2478. StreamItem.message_delta("Goodbye, I will finish those first."),
  2479. *[StreamItem.provider_tool_call(call) for call in calls],
  2480. ]
  2481. ]
  2482. )
  2483. outputs = await _collect_outputs(
  2484. DebugRuntime(client).run(
  2485. _policy_request(
  2486. enabled_tools=[call.name for call in calls],
  2487. mode="chat_agent_tools",
  2488. )
  2489. )
  2490. )
  2491. assert client.calls == 1
  2492. assert [
  2493. message["message"]["tool_call_id"]
  2494. for message in outputs
  2495. if message["type"] == "tool_result"
  2496. ] == [call.id for call in calls]
  2497. assert [
  2498. message["content"]
  2499. for message in outputs
  2500. if message["type"] == "message_delta"
  2501. ][-1] == (
  2502. "Scheduled Design review for 2026-07-14T09:30:00+08:00 "
  2503. "(Asia/Shanghai)."
  2504. )
  2505. decision = next(
  2506. message
  2507. for message in outputs
  2508. if message.get("event") == "event_policy_decision"
  2509. )
  2510. assert decision["details"]["terminate"] is True
  2511. assert decision["details"]["llm_follow_up"] is False
  2512. assert any(
  2513. message.get("event") == "terminal_completed" for message in outputs
  2514. )
  2515. assert outputs[-1] == {"type": "done"}
  2516. @pytest.mark.asyncio
  2517. async def test_reusable_session_terminate_completes_turn_and_session_task():
  2518. client = ScriptedChatClient(
  2519. [
  2520. [
  2521. StreamItem.message_delta("Goodbye."),
  2522. StreamItem.provider_tool_call(
  2523. ToolCallEvent(
  2524. id="terminate-session",
  2525. name="session.terminate",
  2526. arguments={},
  2527. raw_arguments="{}",
  2528. )
  2529. ),
  2530. ]
  2531. ]
  2532. )
  2533. runtime = DebugRuntime(client)
  2534. queues = runtime.start_session(
  2535. _policy_request(
  2536. enabled_tools=["session.terminate"],
  2537. mode="chat_agent_tools",
  2538. session_id="reusable-session",
  2539. )
  2540. )
  2541. outputs: list[dict[str, Any]] = []
  2542. while not any(message["type"] == "done" for message in outputs):
  2543. outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
  2544. await asyncio.wait_for(runtime._wait_for_tasks(), timeout=1)
  2545. business_types = [message["type"] for message in _without_audit(outputs)]
  2546. assert business_types[-2:] == ["turn_completed", "done"]
  2547. @pytest.mark.asyncio
  2548. async def test_dual_text_event_scope_uses_round_and_does_not_replay_stale_history():
  2549. handled_messages: list[str] = []
  2550. def resolve_from_history(
  2551. event: ToolCallEvent,
  2552. context: ToolExecutionContext,
  2553. ) -> dict[str, Any]:
  2554. del event
  2555. return {
  2556. "message": next(
  2557. message.content
  2558. for message in reversed(context.history)
  2559. if message.role == "assistant" and message.content
  2560. )
  2561. }
  2562. registry = ToolRegistry(
  2563. [
  2564. ToolDefinition(
  2565. name="example.history",
  2566. description="Capture the current assistant history.",
  2567. parameters={
  2568. "type": "object",
  2569. "properties": {"message": {"type": "string"}},
  2570. "required": ["message"],
  2571. },
  2572. handler=lambda event: handled_messages.append(
  2573. event.arguments["message"]
  2574. )
  2575. or {"message": event.arguments["message"]},
  2576. argument_resolver=resolve_from_history,
  2577. )
  2578. ]
  2579. )
  2580. repeated = _tool_call("event_1", name="example.history")
  2581. client = ScriptedChatClient(
  2582. [
  2583. [StreamItem.message_delta("first history"), StreamItem.text_event(repeated)],
  2584. [StreamItem.message_delta("second history"), StreamItem.text_event(repeated)],
  2585. [StreamItem.message_delta("final answer")],
  2586. ]
  2587. )
  2588. request = DebugRunRequest(
  2589. user_message="capture each round",
  2590. system_prompts=[],
  2591. pre_messages=[],
  2592. chat_agent=AgentParams(model="chat-model"),
  2593. event_agent=EventAgentParams(
  2594. enabled_tools=["example.history"],
  2595. max_event_loops=2,
  2596. ),
  2597. )
  2598. outputs = await _collect_outputs(
  2599. DebugRuntime(client, registry=registry).run(request)
  2600. )
  2601. assert handled_messages == ["first history", "second history"]
  2602. scopes = [
  2603. message["details"]["scope"]
  2604. for message in outputs
  2605. if message.get("event") == "event_batch_started"
  2606. ]
  2607. assert scopes[0].endswith(":round-1")
  2608. assert scopes[1].endswith(":round-2")
  2609. @pytest.mark.asyncio
  2610. @pytest.mark.parametrize("mode", ["dual_agent", "chat_agent_tools"])
  2611. async def test_runtime_releases_batch_scope_after_results(monkeypatch, mode: str):
  2612. released: list[Any] = []
  2613. original_release = EventBatchExecutor.release_scope
  2614. def record_release(self: EventBatchExecutor, scope: Any) -> None:
  2615. released.append(scope)
  2616. original_release(self, scope)
  2617. monkeypatch.setattr(EventBatchExecutor, "release_scope", record_release)
  2618. registry = ToolRegistry(
  2619. [
  2620. ToolDefinition(
  2621. name="example.silent",
  2622. description="Complete without another model call.",
  2623. parameters={"type": "object"},
  2624. handler=lambda event: {"event_id": event.id},
  2625. result_policy=ResultPolicy.SILENT_SUCCESS,
  2626. )
  2627. ]
  2628. )
  2629. event = _tool_call("release-1", name="example.silent")
  2630. item = (
  2631. StreamItem.text_event(event)
  2632. if mode == "dual_agent"
  2633. else StreamItem.provider_tool_call(event)
  2634. )
  2635. client = ScriptedChatClient([[item]])
  2636. await _collect_outputs(
  2637. DebugRuntime(client, registry=registry).run(
  2638. _policy_request(enabled_tools=["example.silent"], mode=mode)
  2639. )
  2640. )
  2641. assert len(released) == 1
  2642. assert str(released[0]).endswith(":round-1")
  2643. @pytest.mark.asyncio
  2644. @pytest.mark.parametrize("mode", ["dual_agent", "chat_agent_tools"])
  2645. async def test_tool_only_terminate_emits_plugin_farewell_once(mode: str):
  2646. event = ToolCallEvent(
  2647. id="terminate-only",
  2648. name="session.terminate",
  2649. arguments={},
  2650. raw_arguments="{}",
  2651. )
  2652. item = (
  2653. StreamItem.text_event(event)
  2654. if mode == "dual_agent"
  2655. else StreamItem.provider_tool_call(event)
  2656. )
  2657. client = ScriptedChatClient([[item]])
  2658. outputs = await _collect_outputs(
  2659. DebugRuntime(client).run(
  2660. _policy_request(enabled_tools=["session.terminate"], mode=mode)
  2661. )
  2662. )
  2663. assert client.calls == 1
  2664. assert [
  2665. message["content"]
  2666. for message in outputs
  2667. if message["type"] == "message_delta"
  2668. ] == ["Goodbye."]
  2669. assert outputs[-1] == {"type": "done"}
  2670. @pytest.mark.asyncio
  2671. @pytest.mark.parametrize("mode", ["dual_agent", "chat_agent_tools"])
  2672. async def test_coalesced_terminate_has_one_farewell_and_one_audit_result(mode: str):
  2673. events = [
  2674. ToolCallEvent(
  2675. id=event_id,
  2676. name="session.terminate",
  2677. arguments={},
  2678. raw_arguments="{}",
  2679. )
  2680. for event_id in ("terminate-primary", "terminate-duplicate")
  2681. ]
  2682. items = [
  2683. (
  2684. StreamItem.text_event(event)
  2685. if mode == "dual_agent"
  2686. else StreamItem.provider_tool_call(event)
  2687. )
  2688. for event in events
  2689. ]
  2690. client = ScriptedChatClient([items])
  2691. outputs = await _collect_outputs(
  2692. DebugRuntime(client).run(
  2693. _policy_request(enabled_tools=["session.terminate"], mode=mode)
  2694. )
  2695. )
  2696. assert [
  2697. message["content"]
  2698. for message in outputs
  2699. if message["type"] == "message_delta"
  2700. ] == ["Goodbye."]
  2701. assert [
  2702. message["message"]["tool_call_id"]
  2703. for message in outputs
  2704. if message["type"] == "tool_result"
  2705. ] == [event.id for event in events]
  2706. batch_audit = next(
  2707. message
  2708. for message in outputs
  2709. if message.get("event") == "event_batch_results"
  2710. )
  2711. terminal_audit = next(
  2712. message
  2713. for message in outputs
  2714. if message.get("event") == "terminal_completed"
  2715. )
  2716. completion_audit = next(
  2717. message
  2718. for message in outputs
  2719. if message.get("event")
  2720. == (
  2721. "provider_tools_completed"
  2722. if mode == "chat_agent_tools"
  2723. else "event_agent_completed"
  2724. )
  2725. )
  2726. assert [
  2727. result["event_id"] for result in batch_audit["details"]["results"]
  2728. ] == ["terminate-primary"]
  2729. assert completion_audit["details"]["result_count"] == 1
  2730. assert terminal_audit["details"]["event_ids"] == ["terminate-primary"]
  2731. @pytest.mark.asyncio
  2732. async def test_tool_only_terminate_ends_reusable_session_after_plugin_farewell():
  2733. client = ScriptedChatClient(
  2734. [
  2735. [
  2736. StreamItem.provider_tool_call(
  2737. ToolCallEvent(
  2738. id="terminate-session-only",
  2739. name="session.terminate",
  2740. arguments={},
  2741. raw_arguments="{}",
  2742. )
  2743. )
  2744. ]
  2745. ]
  2746. )
  2747. runtime = DebugRuntime(client)
  2748. queues = runtime.start_session(
  2749. _policy_request(
  2750. enabled_tools=["session.terminate"],
  2751. mode="chat_agent_tools",
  2752. session_id="terminal-only-session",
  2753. )
  2754. )
  2755. outputs: list[dict[str, Any]] = []
  2756. while not any(message["type"] == "done" for message in outputs):
  2757. outputs.append(await asyncio.wait_for(queues.output.get(), timeout=1))
  2758. await asyncio.wait_for(runtime._wait_for_tasks(), timeout=1)
  2759. assert [
  2760. message["content"]
  2761. for message in outputs
  2762. if message["type"] == "message_delta"
  2763. ] == ["Goodbye."]
  2764. assert [message["type"] for message in _without_audit(outputs)][-2:] == [
  2765. "turn_completed",
  2766. "done",
  2767. ]
  2768. @pytest.mark.asyncio
  2769. @pytest.mark.parametrize("mode", ["dual_agent", "chat_agent_tools"])
  2770. async def test_web_search_has_first_answer_tool_result_and_one_second_answer(mode: str):
  2771. search = ToolCallEvent(
  2772. id="search-two-answer",
  2773. name="knowledge.web.search",
  2774. arguments={"query": "event batch safety"},
  2775. raw_arguments='{"query":"event batch safety"}',
  2776. )
  2777. item = (
  2778. StreamItem.text_event(search)
  2779. if mode == "dual_agent"
  2780. else StreamItem.provider_tool_call(search)
  2781. )
  2782. client = ScriptedChatClient(
  2783. [
  2784. [StreamItem.message_delta("I will check."), item],
  2785. [StreamItem.message_delta("Grounded search update.")],
  2786. ]
  2787. )
  2788. outputs = await _collect_outputs(
  2789. DebugRuntime(client).run(
  2790. _policy_request(enabled_tools=["knowledge.web.search"], mode=mode)
  2791. )
  2792. )
  2793. business = _without_audit(outputs)
  2794. visible = [
  2795. (index, message["content"])
  2796. for index, message in enumerate(business)
  2797. if message["type"] == "message_delta"
  2798. ]
  2799. tool_index = next(
  2800. index for index, message in enumerate(business) if message["type"] == "tool_result"
  2801. )
  2802. assert client.calls == 2
  2803. assert [content for _, content in visible] == [
  2804. "I will check.",
  2805. "Grounded search update.",
  2806. ]
  2807. assert visible[0][0] < tool_index < visible[1][0]
  2808. assert "sources" in business[tool_index]["message"]["content"]