flowent 0.0.7 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (387) hide show
  1. package/README.md +0 -3
  2. package/backend/README.md +0 -3
  3. package/backend/pyproject.toml +2 -8
  4. package/backend/src/flowent/__init__.py +6 -2
  5. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  6. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  7. package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
  13. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  14. package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
  15. package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/agent.py +213 -3173
  17. package/backend/src/flowent/cli.py +19 -24
  18. package/backend/src/flowent/context.py +127 -0
  19. package/backend/src/flowent/llm.py +256 -0
  20. package/backend/src/flowent/logging.py +170 -129
  21. package/backend/src/flowent/main.py +321 -70
  22. package/backend/src/flowent/patch.py +182 -0
  23. package/backend/src/flowent/paths.py +11 -0
  24. package/backend/src/flowent/sandbox.py +214 -40
  25. package/backend/src/flowent/static/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  26. package/backend/src/flowent/static/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  27. package/backend/src/flowent/static/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  28. package/backend/src/flowent/static/assets/index-C76K95ty.js +81 -0
  29. package/backend/src/flowent/static/assets/index-iUMNKvlU.css +2 -0
  30. package/backend/src/flowent/static/flowent.png +0 -0
  31. package/backend/src/flowent/static/index.html +5 -25
  32. package/backend/src/flowent/storage.py +302 -0
  33. package/backend/src/flowent/tools.py +376 -0
  34. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  35. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  36. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  37. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  38. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  39. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  40. package/backend/tests/test_agent_tools.py +477 -0
  41. package/backend/tests/test_health.py +12 -0
  42. package/backend/tests/test_llm_providers.py +113 -0
  43. package/backend/tests/test_logging.py +182 -0
  44. package/backend/tests/test_persistence.py +125 -0
  45. package/backend/tests/test_workspace_chat.py +578 -0
  46. package/backend/uv.lock +803 -99
  47. package/dist/frontend/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  48. package/dist/frontend/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  49. package/dist/frontend/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  50. package/dist/frontend/assets/index-C76K95ty.js +81 -0
  51. package/dist/frontend/assets/index-iUMNKvlU.css +2 -0
  52. package/dist/frontend/flowent.png +0 -0
  53. package/dist/frontend/index.html +5 -25
  54. package/package.json +1 -2
  55. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  56. package/backend/src/flowent/__pycache__/access.cpython-313.pyc +0 -0
  57. package/backend/src/flowent/__pycache__/assistant_commands.cpython-313.pyc +0 -0
  58. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  59. package/backend/src/flowent/__pycache__/config.cpython-313.pyc +0 -0
  60. package/backend/src/flowent/__pycache__/events.cpython-313.pyc +0 -0
  61. package/backend/src/flowent/__pycache__/graph_runtime.cpython-313.pyc +0 -0
  62. package/backend/src/flowent/__pycache__/graph_service.cpython-313.pyc +0 -0
  63. package/backend/src/flowent/__pycache__/image_assets.cpython-313.pyc +0 -0
  64. package/backend/src/flowent/__pycache__/model_metadata.cpython-313.pyc +0 -0
  65. package/backend/src/flowent/__pycache__/network.cpython-313.pyc +0 -0
  66. package/backend/src/flowent/__pycache__/observability_service.cpython-313.pyc +0 -0
  67. package/backend/src/flowent/__pycache__/registry.cpython-313.pyc +0 -0
  68. package/backend/src/flowent/__pycache__/role_management.cpython-313.pyc +0 -0
  69. package/backend/src/flowent/__pycache__/runtime.cpython-313.pyc +0 -0
  70. package/backend/src/flowent/__pycache__/security.cpython-313.pyc +0 -0
  71. package/backend/src/flowent/__pycache__/settings.cpython-313.pyc +0 -0
  72. package/backend/src/flowent/__pycache__/settings_management.cpython-313.pyc +0 -0
  73. package/backend/src/flowent/__pycache__/state_db.cpython-313.pyc +0 -0
  74. package/backend/src/flowent/__pycache__/workspace_store.cpython-313.pyc +0 -0
  75. package/backend/src/flowent/access.py +0 -247
  76. package/backend/src/flowent/assistant_commands.py +0 -115
  77. package/backend/src/flowent/channels/__init__.py +0 -3
  78. package/backend/src/flowent/channels/__pycache__/__init__.cpython-313.pyc +0 -0
  79. package/backend/src/flowent/channels/__pycache__/telegram.cpython-313.pyc +0 -0
  80. package/backend/src/flowent/channels/telegram.py +0 -615
  81. package/backend/src/flowent/config.py +0 -14
  82. package/backend/src/flowent/dev.py +0 -3
  83. package/backend/src/flowent/events.py +0 -157
  84. package/backend/src/flowent/graph_runtime.py +0 -60
  85. package/backend/src/flowent/graph_service.py +0 -2401
  86. package/backend/src/flowent/image_assets.py +0 -356
  87. package/backend/src/flowent/model_metadata.py +0 -102
  88. package/backend/src/flowent/models/__init__.py +0 -125
  89. package/backend/src/flowent/models/__pycache__/__init__.cpython-313.pyc +0 -0
  90. package/backend/src/flowent/models/__pycache__/agent.cpython-313.pyc +0 -0
  91. package/backend/src/flowent/models/__pycache__/base.cpython-313.pyc +0 -0
  92. package/backend/src/flowent/models/__pycache__/blueprint.cpython-313.pyc +0 -0
  93. package/backend/src/flowent/models/__pycache__/content.cpython-313.pyc +0 -0
  94. package/backend/src/flowent/models/__pycache__/delta.cpython-313.pyc +0 -0
  95. package/backend/src/flowent/models/__pycache__/event.cpython-313.pyc +0 -0
  96. package/backend/src/flowent/models/__pycache__/graph.cpython-313.pyc +0 -0
  97. package/backend/src/flowent/models/__pycache__/history.cpython-313.pyc +0 -0
  98. package/backend/src/flowent/models/__pycache__/llm.cpython-313.pyc +0 -0
  99. package/backend/src/flowent/models/__pycache__/message.cpython-313.pyc +0 -0
  100. package/backend/src/flowent/models/__pycache__/tab.cpython-313.pyc +0 -0
  101. package/backend/src/flowent/models/__pycache__/todo.cpython-313.pyc +0 -0
  102. package/backend/src/flowent/models/agent.py +0 -34
  103. package/backend/src/flowent/models/base.py +0 -24
  104. package/backend/src/flowent/models/blueprint.py +0 -176
  105. package/backend/src/flowent/models/content.py +0 -164
  106. package/backend/src/flowent/models/delta.py +0 -44
  107. package/backend/src/flowent/models/event.py +0 -51
  108. package/backend/src/flowent/models/graph.py +0 -472
  109. package/backend/src/flowent/models/history.py +0 -272
  110. package/backend/src/flowent/models/llm.py +0 -62
  111. package/backend/src/flowent/models/message.py +0 -33
  112. package/backend/src/flowent/models/tab.py +0 -85
  113. package/backend/src/flowent/models/todo.py +0 -10
  114. package/backend/src/flowent/network.py +0 -146
  115. package/backend/src/flowent/observability_service.py +0 -218
  116. package/backend/src/flowent/prompts/__init__.py +0 -67
  117. package/backend/src/flowent/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
  118. package/backend/src/flowent/prompts/__pycache__/common.cpython-313.pyc +0 -0
  119. package/backend/src/flowent/prompts/__pycache__/steward.cpython-313.pyc +0 -0
  120. package/backend/src/flowent/prompts/common.py +0 -250
  121. package/backend/src/flowent/prompts/steward.py +0 -64
  122. package/backend/src/flowent/providers/__init__.py +0 -23
  123. package/backend/src/flowent/providers/__pycache__/__init__.cpython-313.pyc +0 -0
  124. package/backend/src/flowent/providers/__pycache__/anthropic.cpython-313.pyc +0 -0
  125. package/backend/src/flowent/providers/__pycache__/base_url.cpython-313.pyc +0 -0
  126. package/backend/src/flowent/providers/__pycache__/configuration.cpython-313.pyc +0 -0
  127. package/backend/src/flowent/providers/__pycache__/content.cpython-313.pyc +0 -0
  128. package/backend/src/flowent/providers/__pycache__/errors.cpython-313.pyc +0 -0
  129. package/backend/src/flowent/providers/__pycache__/gateway.cpython-313.pyc +0 -0
  130. package/backend/src/flowent/providers/__pycache__/headers.cpython-313.pyc +0 -0
  131. package/backend/src/flowent/providers/__pycache__/management.cpython-313.pyc +0 -0
  132. package/backend/src/flowent/providers/__pycache__/openai.cpython-313.pyc +0 -0
  133. package/backend/src/flowent/providers/__pycache__/openai_responses.cpython-313.pyc +0 -0
  134. package/backend/src/flowent/providers/__pycache__/registry.cpython-313.pyc +0 -0
  135. package/backend/src/flowent/providers/__pycache__/sse.cpython-313.pyc +0 -0
  136. package/backend/src/flowent/providers/__pycache__/thinking.cpython-313.pyc +0 -0
  137. package/backend/src/flowent/providers/anthropic.py +0 -468
  138. package/backend/src/flowent/providers/base_url.py +0 -60
  139. package/backend/src/flowent/providers/configuration.py +0 -189
  140. package/backend/src/flowent/providers/content.py +0 -122
  141. package/backend/src/flowent/providers/errors.py +0 -223
  142. package/backend/src/flowent/providers/gateway.py +0 -169
  143. package/backend/src/flowent/providers/gemini.py +0 -447
  144. package/backend/src/flowent/providers/headers.py +0 -20
  145. package/backend/src/flowent/providers/management.py +0 -96
  146. package/backend/src/flowent/providers/ollama.py +0 -293
  147. package/backend/src/flowent/providers/openai.py +0 -422
  148. package/backend/src/flowent/providers/openai_responses.py +0 -655
  149. package/backend/src/flowent/providers/registry.py +0 -144
  150. package/backend/src/flowent/providers/sse.py +0 -31
  151. package/backend/src/flowent/providers/thinking.py +0 -79
  152. package/backend/src/flowent/registry.py +0 -73
  153. package/backend/src/flowent/role_management.py +0 -270
  154. package/backend/src/flowent/routes/__init__.py +0 -26
  155. package/backend/src/flowent/routes/__pycache__/__init__.cpython-313.pyc +0 -0
  156. package/backend/src/flowent/routes/__pycache__/access.cpython-313.pyc +0 -0
  157. package/backend/src/flowent/routes/__pycache__/assistant.cpython-313.pyc +0 -0
  158. package/backend/src/flowent/routes/__pycache__/image_assets.cpython-313.pyc +0 -0
  159. package/backend/src/flowent/routes/__pycache__/meta.cpython-313.pyc +0 -0
  160. package/backend/src/flowent/routes/__pycache__/nodes.cpython-313.pyc +0 -0
  161. package/backend/src/flowent/routes/__pycache__/prompts.cpython-313.pyc +0 -0
  162. package/backend/src/flowent/routes/__pycache__/providers_route.cpython-313.pyc +0 -0
  163. package/backend/src/flowent/routes/__pycache__/roles.cpython-313.pyc +0 -0
  164. package/backend/src/flowent/routes/__pycache__/settings.cpython-313.pyc +0 -0
  165. package/backend/src/flowent/routes/__pycache__/tabs.cpython-313.pyc +0 -0
  166. package/backend/src/flowent/routes/__pycache__/ws.cpython-313.pyc +0 -0
  167. package/backend/src/flowent/routes/access.py +0 -48
  168. package/backend/src/flowent/routes/assistant.py +0 -158
  169. package/backend/src/flowent/routes/image_assets.py +0 -33
  170. package/backend/src/flowent/routes/meta.py +0 -28
  171. package/backend/src/flowent/routes/nodes.py +0 -423
  172. package/backend/src/flowent/routes/prompts.py +0 -46
  173. package/backend/src/flowent/routes/providers_route.py +0 -365
  174. package/backend/src/flowent/routes/roles.py +0 -207
  175. package/backend/src/flowent/routes/settings.py +0 -379
  176. package/backend/src/flowent/routes/tabs.py +0 -298
  177. package/backend/src/flowent/routes/ws.py +0 -33
  178. package/backend/src/flowent/runtime.py +0 -160
  179. package/backend/src/flowent/security.py +0 -37
  180. package/backend/src/flowent/settings.py +0 -2112
  181. package/backend/src/flowent/settings_management.py +0 -394
  182. package/backend/src/flowent/state_db.py +0 -108
  183. package/backend/src/flowent/static/assets/AssistantPage-BW7XAd9I.js +0 -1
  184. package/backend/src/flowent/static/assets/ChannelsPage-tCJHgt6m.js +0 -1
  185. package/backend/src/flowent/static/assets/PageScaffold-f6g2l7XN.js +0 -1
  186. package/backend/src/flowent/static/assets/PromptsPage-C3Sxn2D7.js +0 -1
  187. package/backend/src/flowent/static/assets/ProvidersPage-BfmdXmNt.js +0 -3
  188. package/backend/src/flowent/static/assets/RolesPage-DET8wO4r.js +0 -1
  189. package/backend/src/flowent/static/assets/SettingsPage-D-g3deMm.js +0 -3
  190. package/backend/src/flowent/static/assets/ToolsPage-CDmtE2g4.js +0 -1
  191. package/backend/src/flowent/static/assets/WorkspacePage-AZsJ0sD0.js +0 -3
  192. package/backend/src/flowent/static/assets/WorkspacePanels-CteCjolX.js +0 -1
  193. package/backend/src/flowent/static/assets/alert-dialog-Duorp_S-.js +0 -1
  194. package/backend/src/flowent/static/assets/dialog-C3ixjGjN.js +0 -1
  195. package/backend/src/flowent/static/assets/elk-worker.min-C9JGDOE-.js +0 -6312
  196. package/backend/src/flowent/static/assets/graph-vendor-CHpVij2M.css +0 -1
  197. package/backend/src/flowent/static/assets/graph-vendor-DRq_-6fV.js +0 -7
  198. package/backend/src/flowent/static/assets/index--o_0fv0N.css +0 -1
  199. package/backend/src/flowent/static/assets/index-C9HuekJm.js +0 -10
  200. package/backend/src/flowent/static/assets/layout.worker-jMHqAFbP.js +0 -24
  201. package/backend/src/flowent/static/assets/markdown-vendor-C9RtvaJh.js +0 -29
  202. package/backend/src/flowent/static/assets/modelParams-DmnF2hwR.js +0 -1
  203. package/backend/src/flowent/static/assets/providerTypes-DT3Ahwl_.js +0 -1
  204. package/backend/src/flowent/static/assets/react-vendor-mEs_JJxa.js +0 -9
  205. package/backend/src/flowent/static/assets/roles-CuRT_chR.js +0 -1
  206. package/backend/src/flowent/static/assets/rolldown-runtime-BYbx6iT9.js +0 -1
  207. package/backend/src/flowent/static/assets/select-DCfeNu-F.js +0 -1
  208. package/backend/src/flowent/static/assets/surface-pWwG5ogx.js +0 -1
  209. package/backend/src/flowent/static/assets/ui-vendor-C5pJa8N7.js +0 -51
  210. package/backend/src/flowent/static/assets/useAppRoute-FgSHBKhV.js +0 -1
  211. package/backend/src/flowent/static/favicon.svg +0 -4
  212. package/backend/src/flowent/tools/__init__.py +0 -176
  213. package/backend/src/flowent/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  214. package/backend/src/flowent/tools/__pycache__/connect.cpython-313.pyc +0 -0
  215. package/backend/src/flowent/tools/__pycache__/contacts.cpython-313.pyc +0 -0
  216. package/backend/src/flowent/tools/__pycache__/create_agent.cpython-313.pyc +0 -0
  217. package/backend/src/flowent/tools/__pycache__/create_tab.cpython-313.pyc +0 -0
  218. package/backend/src/flowent/tools/__pycache__/delete_tab.cpython-313.pyc +0 -0
  219. package/backend/src/flowent/tools/__pycache__/edit.cpython-313.pyc +0 -0
  220. package/backend/src/flowent/tools/__pycache__/exec.cpython-313.pyc +0 -0
  221. package/backend/src/flowent/tools/__pycache__/fetch.cpython-313.pyc +0 -0
  222. package/backend/src/flowent/tools/__pycache__/idle.cpython-313.pyc +0 -0
  223. package/backend/src/flowent/tools/__pycache__/list_roles.cpython-313.pyc +0 -0
  224. package/backend/src/flowent/tools/__pycache__/list_tabs.cpython-313.pyc +0 -0
  225. package/backend/src/flowent/tools/__pycache__/list_tools.cpython-313.pyc +0 -0
  226. package/backend/src/flowent/tools/__pycache__/manage_prompts.cpython-313.pyc +0 -0
  227. package/backend/src/flowent/tools/__pycache__/manage_providers.cpython-313.pyc +0 -0
  228. package/backend/src/flowent/tools/__pycache__/manage_roles.cpython-313.pyc +0 -0
  229. package/backend/src/flowent/tools/__pycache__/manage_settings.cpython-313.pyc +0 -0
  230. package/backend/src/flowent/tools/__pycache__/read.cpython-313.pyc +0 -0
  231. package/backend/src/flowent/tools/__pycache__/send.cpython-313.pyc +0 -0
  232. package/backend/src/flowent/tools/__pycache__/set_permissions.cpython-313.pyc +0 -0
  233. package/backend/src/flowent/tools/__pycache__/sleep.cpython-313.pyc +0 -0
  234. package/backend/src/flowent/tools/__pycache__/todo.cpython-313.pyc +0 -0
  235. package/backend/src/flowent/tools/connect.py +0 -100
  236. package/backend/src/flowent/tools/contacts.py +0 -22
  237. package/backend/src/flowent/tools/create_agent.py +0 -191
  238. package/backend/src/flowent/tools/create_tab.py +0 -61
  239. package/backend/src/flowent/tools/delete_tab.py +0 -39
  240. package/backend/src/flowent/tools/edit.py +0 -142
  241. package/backend/src/flowent/tools/exec.py +0 -118
  242. package/backend/src/flowent/tools/fetch.py +0 -85
  243. package/backend/src/flowent/tools/idle.py +0 -27
  244. package/backend/src/flowent/tools/list_roles.py +0 -68
  245. package/backend/src/flowent/tools/list_tabs.py +0 -100
  246. package/backend/src/flowent/tools/list_tools.py +0 -28
  247. package/backend/src/flowent/tools/manage_prompts.py +0 -102
  248. package/backend/src/flowent/tools/manage_providers.py +0 -220
  249. package/backend/src/flowent/tools/manage_roles.py +0 -275
  250. package/backend/src/flowent/tools/manage_settings.py +0 -326
  251. package/backend/src/flowent/tools/read.py +0 -152
  252. package/backend/src/flowent/tools/send.py +0 -68
  253. package/backend/src/flowent/tools/set_permissions.py +0 -99
  254. package/backend/src/flowent/tools/sleep.py +0 -41
  255. package/backend/src/flowent/tools/todo.py +0 -51
  256. package/backend/src/flowent/workspace_store.py +0 -479
  257. package/backend/tests/__init__.py +0 -0
  258. package/backend/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  259. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  260. package/backend/tests/conftest.py +0 -6
  261. package/backend/tests/integration/api/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  262. package/backend/tests/integration/api/__pycache__/test_access_api.cpython-313-pytest-9.0.3.pyc +0 -0
  263. package/backend/tests/integration/api/__pycache__/test_assistant_api.cpython-313-pytest-9.0.3.pyc +0 -0
  264. package/backend/tests/integration/api/__pycache__/test_frontend_mounting.cpython-313-pytest-9.0.3.pyc +0 -0
  265. package/backend/tests/integration/api/__pycache__/test_meta_api.cpython-313-pytest-9.0.3.pyc +0 -0
  266. package/backend/tests/integration/api/__pycache__/test_nodes_api.cpython-313-pytest-9.0.3.pyc +0 -0
  267. package/backend/tests/integration/api/__pycache__/test_prompts_api.cpython-313-pytest-9.0.3.pyc +0 -0
  268. package/backend/tests/integration/api/__pycache__/test_roles_api.cpython-313-pytest-9.0.3.pyc +0 -0
  269. package/backend/tests/integration/api/__pycache__/test_tabs_api.cpython-313-pytest-9.0.3.pyc +0 -0
  270. package/backend/tests/integration/api/conftest.py +0 -29
  271. package/backend/tests/integration/api/test_access_api.py +0 -182
  272. package/backend/tests/integration/api/test_assistant_api.py +0 -422
  273. package/backend/tests/integration/api/test_frontend_mounting.py +0 -61
  274. package/backend/tests/integration/api/test_meta_api.py +0 -32
  275. package/backend/tests/integration/api/test_nodes_api.py +0 -787
  276. package/backend/tests/integration/api/test_prompts_api.py +0 -47
  277. package/backend/tests/integration/api/test_roles_api.py +0 -228
  278. package/backend/tests/integration/api/test_tabs_api.py +0 -688
  279. package/backend/tests/unit/__pycache__/test_access.cpython-313-pytest-9.0.3.pyc +0 -0
  280. package/backend/tests/unit/__pycache__/test_cli.cpython-313-pytest-9.0.3.pyc +0 -0
  281. package/backend/tests/unit/__pycache__/test_graph_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  282. package/backend/tests/unit/__pycache__/test_network.cpython-313-pytest-9.0.3.pyc +0 -0
  283. package/backend/tests/unit/__pycache__/test_state_sqlite_storage.cpython-313-pytest-9.0.3.pyc +0 -0
  284. package/backend/tests/unit/__pycache__/test_workspace_store.cpython-313-pytest-9.0.3.pyc +0 -0
  285. package/backend/tests/unit/agent/__pycache__/test_agent_public_api.cpython-313-pytest-9.0.3.pyc +0 -0
  286. package/backend/tests/unit/agent/__pycache__/test_agent_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  287. package/backend/tests/unit/agent/test_agent_public_api.py +0 -822
  288. package/backend/tests/unit/agent/test_agent_runtime.py +0 -3088
  289. package/backend/tests/unit/channels/__pycache__/test_telegram_channel.cpython-313-pytest-9.0.3.pyc +0 -0
  290. package/backend/tests/unit/channels/test_telegram_channel.py +0 -552
  291. package/backend/tests/unit/logging/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  292. package/backend/tests/unit/logging/test_logging.py +0 -132
  293. package/backend/tests/unit/prompts/__pycache__/test_prompts.cpython-313-pytest-9.0.3.pyc +0 -0
  294. package/backend/tests/unit/prompts/test_prompts.py +0 -570
  295. package/backend/tests/unit/providers/__pycache__/test_anthropic_provider.cpython-313-pytest-9.0.3.pyc +0 -0
  296. package/backend/tests/unit/providers/__pycache__/test_errors.cpython-313-pytest-9.0.3.pyc +0 -0
  297. package/backend/tests/unit/providers/__pycache__/test_extract_delta_parts.cpython-313-pytest-9.0.3.pyc +0 -0
  298. package/backend/tests/unit/providers/__pycache__/test_openai_provider.cpython-313-pytest-9.0.3.pyc +0 -0
  299. package/backend/tests/unit/providers/__pycache__/test_openai_responses.cpython-313-pytest-9.0.3.pyc +0 -0
  300. package/backend/tests/unit/providers/__pycache__/test_provider_gateway.cpython-313-pytest-9.0.3.pyc +0 -0
  301. package/backend/tests/unit/providers/__pycache__/test_think_tag_parser.cpython-313-pytest-9.0.3.pyc +0 -0
  302. package/backend/tests/unit/providers/test_anthropic_provider.py +0 -185
  303. package/backend/tests/unit/providers/test_errors.py +0 -68
  304. package/backend/tests/unit/providers/test_extract_delta_parts.py +0 -22
  305. package/backend/tests/unit/providers/test_openai_provider.py +0 -139
  306. package/backend/tests/unit/providers/test_openai_responses.py +0 -402
  307. package/backend/tests/unit/providers/test_provider_gateway.py +0 -359
  308. package/backend/tests/unit/providers/test_think_tag_parser.py +0 -36
  309. package/backend/tests/unit/routes/__pycache__/test_prompts_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  310. package/backend/tests/unit/routes/__pycache__/test_providers_route.cpython-313-pytest-9.0.3.pyc +0 -0
  311. package/backend/tests/unit/routes/__pycache__/test_roles_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  312. package/backend/tests/unit/routes/__pycache__/test_settings_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  313. package/backend/tests/unit/routes/test_prompts_routes.py +0 -82
  314. package/backend/tests/unit/routes/test_providers_route.py +0 -370
  315. package/backend/tests/unit/routes/test_roles_routes.py +0 -539
  316. package/backend/tests/unit/routes/test_settings_routes.py +0 -1123
  317. package/backend/tests/unit/runtime/__pycache__/test_bootstrap_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  318. package/backend/tests/unit/runtime/test_bootstrap_runtime.py +0 -1002
  319. package/backend/tests/unit/sandbox/__pycache__/test_sandbox_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  320. package/backend/tests/unit/sandbox/test_sandbox_tools.py +0 -78
  321. package/backend/tests/unit/security/__pycache__/test_security.cpython-313-pytest-9.0.3.pyc +0 -0
  322. package/backend/tests/unit/security/test_security.py +0 -124
  323. package/backend/tests/unit/settings/__pycache__/test_settings_roles.cpython-313-pytest-9.0.3.pyc +0 -0
  324. package/backend/tests/unit/settings/test_settings_roles.py +0 -703
  325. package/backend/tests/unit/test_access.py +0 -45
  326. package/backend/tests/unit/test_cli.py +0 -102
  327. package/backend/tests/unit/test_graph_runtime.py +0 -72
  328. package/backend/tests/unit/test_network.py +0 -51
  329. package/backend/tests/unit/test_state_sqlite_storage.py +0 -87
  330. package/backend/tests/unit/test_workspace_store.py +0 -228
  331. package/backend/tests/unit/tools/__pycache__/test_connect_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  332. package/backend/tests/unit/tools/__pycache__/test_create_agent_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  333. package/backend/tests/unit/tools/__pycache__/test_delete_tab_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  334. package/backend/tests/unit/tools/__pycache__/test_edit_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  335. package/backend/tests/unit/tools/__pycache__/test_exec_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  336. package/backend/tests/unit/tools/__pycache__/test_fetch_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  337. package/backend/tests/unit/tools/__pycache__/test_manage_prompts_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  338. package/backend/tests/unit/tools/__pycache__/test_manage_providers_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  339. package/backend/tests/unit/tools/__pycache__/test_manage_roles_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  340. package/backend/tests/unit/tools/__pycache__/test_manage_settings_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  341. package/backend/tests/unit/tools/__pycache__/test_read_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  342. package/backend/tests/unit/tools/__pycache__/test_set_permissions_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  343. package/backend/tests/unit/tools/__pycache__/test_todo_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  344. package/backend/tests/unit/tools/__pycache__/test_tool_registry.cpython-313-pytest-9.0.3.pyc +0 -0
  345. package/backend/tests/unit/tools/test_connect_tool.py +0 -228
  346. package/backend/tests/unit/tools/test_create_agent_tool.py +0 -404
  347. package/backend/tests/unit/tools/test_delete_tab_tool.py +0 -116
  348. package/backend/tests/unit/tools/test_edit_tool.py +0 -115
  349. package/backend/tests/unit/tools/test_exec_tool.py +0 -81
  350. package/backend/tests/unit/tools/test_fetch_tool.py +0 -65
  351. package/backend/tests/unit/tools/test_manage_prompts_tool.py +0 -92
  352. package/backend/tests/unit/tools/test_manage_providers_tool.py +0 -460
  353. package/backend/tests/unit/tools/test_manage_roles_tool.py +0 -411
  354. package/backend/tests/unit/tools/test_manage_settings_tool.py +0 -611
  355. package/backend/tests/unit/tools/test_read_tool.py +0 -33
  356. package/backend/tests/unit/tools/test_set_permissions_tool.py +0 -595
  357. package/backend/tests/unit/tools/test_todo_tool.py +0 -37
  358. package/backend/tests/unit/tools/test_tool_registry.py +0 -199
  359. package/dist/frontend/assets/AssistantPage-BW7XAd9I.js +0 -1
  360. package/dist/frontend/assets/ChannelsPage-tCJHgt6m.js +0 -1
  361. package/dist/frontend/assets/PageScaffold-f6g2l7XN.js +0 -1
  362. package/dist/frontend/assets/PromptsPage-C3Sxn2D7.js +0 -1
  363. package/dist/frontend/assets/ProvidersPage-BfmdXmNt.js +0 -3
  364. package/dist/frontend/assets/RolesPage-DET8wO4r.js +0 -1
  365. package/dist/frontend/assets/SettingsPage-D-g3deMm.js +0 -3
  366. package/dist/frontend/assets/ToolsPage-CDmtE2g4.js +0 -1
  367. package/dist/frontend/assets/WorkspacePage-AZsJ0sD0.js +0 -3
  368. package/dist/frontend/assets/WorkspacePanels-CteCjolX.js +0 -1
  369. package/dist/frontend/assets/alert-dialog-Duorp_S-.js +0 -1
  370. package/dist/frontend/assets/dialog-C3ixjGjN.js +0 -1
  371. package/dist/frontend/assets/elk-worker.min-C9JGDOE-.js +0 -6312
  372. package/dist/frontend/assets/graph-vendor-CHpVij2M.css +0 -1
  373. package/dist/frontend/assets/graph-vendor-DRq_-6fV.js +0 -7
  374. package/dist/frontend/assets/index--o_0fv0N.css +0 -1
  375. package/dist/frontend/assets/index-C9HuekJm.js +0 -10
  376. package/dist/frontend/assets/layout.worker-jMHqAFbP.js +0 -24
  377. package/dist/frontend/assets/markdown-vendor-C9RtvaJh.js +0 -29
  378. package/dist/frontend/assets/modelParams-DmnF2hwR.js +0 -1
  379. package/dist/frontend/assets/providerTypes-DT3Ahwl_.js +0 -1
  380. package/dist/frontend/assets/react-vendor-mEs_JJxa.js +0 -9
  381. package/dist/frontend/assets/roles-CuRT_chR.js +0 -1
  382. package/dist/frontend/assets/rolldown-runtime-BYbx6iT9.js +0 -1
  383. package/dist/frontend/assets/select-DCfeNu-F.js +0 -1
  384. package/dist/frontend/assets/surface-pWwG5ogx.js +0 -1
  385. package/dist/frontend/assets/ui-vendor-C5pJa8N7.js +0 -51
  386. package/dist/frontend/assets/useAppRoute-FgSHBKhV.js +0 -1
  387. package/dist/frontend/favicon.svg +0 -4
@@ -1,3088 +0,0 @@
1
- import json
2
- import threading
3
- import time
4
-
5
- import pytest
6
- from loguru import logger
7
-
8
- from flowent.agent import (
9
- Agent,
10
- ContextPreflight,
11
- InterruptRequestedError,
12
- PreparedLLMContext,
13
- WakeSignal,
14
- )
15
- from flowent.events import event_bus
16
- from flowent.graph_service import build_workflow_node_definition, create_edge
17
- from flowent.models import (
18
- AgentState,
19
- AssistantText,
20
- AssistantThinking,
21
- CommandResultEntry,
22
- ErrorEntry,
23
- EventType,
24
- ImagePart,
25
- LLMResponse,
26
- LLMUsage,
27
- Message,
28
- NodeConfig,
29
- NodeType,
30
- ReceivedMessage,
31
- SentMessage,
32
- SystemEntry,
33
- Tab,
34
- TodoItem,
35
- ToolCall,
36
- ToolCallResult,
37
- WorkflowActivationState,
38
- WorkflowDefinition,
39
- WorkflowNodeKind,
40
- )
41
- from flowent.observability_service import observability_store
42
- from flowent.providers.errors import LLMProviderError
43
- from flowent.registry import registry
44
- from flowent.settings import ModelSettings, ProviderConfig, Settings
45
- from flowent.workspace_store import workspace_store
46
-
47
-
48
- @pytest.fixture(autouse=True)
49
- def reset_runtime_state(monkeypatch, tmp_path):
50
- import flowent.settings as settings_module
51
-
52
- settings_file = tmp_path / "settings.json"
53
- settings_file.write_text("{}", encoding="utf-8")
54
- monkeypatch.setattr(settings_module, "_SETTINGS_FILE", settings_file)
55
- monkeypatch.setattr(settings_module, "_cached_settings", None)
56
- registry.reset()
57
- workspace_store.reset_cache()
58
- observability_store.reset()
59
- yield
60
- registry.reset()
61
- workspace_store.reset_cache()
62
- observability_store.reset()
63
- monkeypatch.setattr(settings_module, "_cached_settings", None)
64
-
65
-
66
- def _register_tab_leader(*, tab_id: str = "tab-1", leader_id: str = "leader") -> Agent:
67
- workspace_store.upsert_tab(
68
- Tab(
69
- id=tab_id,
70
- title="Task",
71
- leader_id=leader_id,
72
- allow_network=True,
73
- permissions_initialized=True,
74
- )
75
- )
76
- leader = Agent(
77
- NodeConfig(
78
- node_type=NodeType.AGENT,
79
- role_name="Conductor",
80
- name="Leader",
81
- tab_id=tab_id,
82
- ),
83
- uuid=leader_id,
84
- )
85
- registry.register(leader)
86
- return leader
87
-
88
-
89
- def _add_agent_path(
90
- *,
91
- tab_id: str = "tab-1",
92
- source_id: str,
93
- target_id: str,
94
- ) -> None:
95
- tab = workspace_store.get_tab(tab_id)
96
- assert tab is not None
97
- existing = {node.id for node in tab.definition.nodes}
98
- next_nodes = list(tab.definition.nodes)
99
- for node_id in (source_id, target_id):
100
- if node_id not in existing:
101
- next_nodes.append(
102
- build_workflow_node_definition(
103
- node_id=node_id,
104
- node_kind=WorkflowNodeKind.AGENT,
105
- )
106
- )
107
- tab.definition = WorkflowDefinition(
108
- version=tab.definition.version,
109
- nodes=next_nodes,
110
- edges=list(tab.definition.edges),
111
- view=tab.definition.view,
112
- )
113
- workspace_store.upsert_tab(tab)
114
- edge, error = create_edge(
115
- tab_id=tab_id,
116
- from_node_id=source_id,
117
- to_node_id=target_id,
118
- )
119
- assert error is None and edge is not None
120
-
121
-
122
- def test_agent_keeps_running_after_pure_text_response(monkeypatch):
123
- agent = Agent(NodeConfig(node_type=NodeType.AGENT, allow_network=True))
124
- wait_calls = 0
125
- llm_messages: list[list[dict]] = []
126
- responses = iter([LLMResponse(content="working through the task"), LLMResponse()])
127
-
128
- def fake_wait_for_input() -> None:
129
- nonlocal wait_calls
130
- wait_calls += 1
131
- if wait_calls == 1:
132
- agent._append_history(
133
- ReceivedMessage(content="finish the task", from_id="tester")
134
- )
135
- agent.set_state(AgentState.RUNNING, "received message from tester")
136
- return
137
- raise AssertionError("agent should not auto-idle after pure assistant text")
138
-
139
- def fake_chat(
140
- messages,
141
- tools=None,
142
- on_chunk=None,
143
- register_interrupt=None,
144
- role_name=None,
145
- ):
146
- llm_messages.append(messages)
147
- if len(llm_messages) == 2:
148
- agent.request_termination("done")
149
- return next(responses)
150
-
151
- monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
152
- monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
153
-
154
- agent._run()
155
-
156
- assert wait_calls == 1
157
- assert len(llm_messages) == 2
158
- assert agent.state == AgentState.TERMINATED
159
- assert any(
160
- isinstance(entry, AssistantText) and entry.content == "working through the task"
161
- for entry in agent.get_history_snapshot()
162
- )
163
- assert any(
164
- msg.get("role") == "assistant"
165
- and msg.get("content") == "working through the task"
166
- for msg in llm_messages[1]
167
- )
168
-
169
-
170
- def test_agent_retries_transient_llm_errors_before_succeeding(monkeypatch):
171
- agent = Agent(NodeConfig(node_type=NodeType.AGENT, allow_network=True))
172
- wait_calls = 0
173
- llm_calls = 0
174
-
175
- def fake_wait_for_input() -> None:
176
- nonlocal wait_calls
177
- wait_calls += 1
178
- if wait_calls == 1:
179
- agent._append_history(
180
- ReceivedMessage(content="finish the task", from_id="tester")
181
- )
182
- agent.set_state(AgentState.RUNNING, "received message from tester")
183
- return
184
- raise AssertionError("agent should not return to idle while retrying")
185
-
186
- def fake_chat(
187
- messages,
188
- tools=None,
189
- on_chunk=None,
190
- register_interrupt=None,
191
- role_name=None,
192
- ):
193
- nonlocal llm_calls
194
- llm_calls += 1
195
- if llm_calls < 3:
196
- raise LLMProviderError(
197
- f"temporary failure {llm_calls}",
198
- transient=True,
199
- status_code=429,
200
- )
201
- if llm_calls == 4:
202
- agent.request_termination("done")
203
- return LLMResponse()
204
- if llm_calls == 3:
205
- return LLMResponse(content="Recovered answer")
206
- raise AssertionError("unexpected extra LLM call")
207
-
208
- monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
209
- monkeypatch.setattr(agent, "_get_llm_retry_delay", lambda retry_number: 0.0)
210
- monkeypatch.setattr(
211
- "flowent.agent.get_settings",
212
- lambda: Settings(model=ModelSettings(max_retries=2)),
213
- )
214
- monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
215
-
216
- agent._run()
217
-
218
- assert wait_calls == 1
219
- assert llm_calls == 4
220
- assert agent.state == AgentState.TERMINATED
221
- assert not any(
222
- isinstance(entry, ErrorEntry) for entry in agent.get_history_snapshot()
223
- )
224
- assert any(
225
- isinstance(entry, AssistantText) and entry.content == "Recovered answer"
226
- for entry in agent.get_history_snapshot()
227
- )
228
-
229
-
230
- def test_chat_with_retries_records_single_request_stat(monkeypatch):
231
- workspace_store.upsert_tab(
232
- Tab(
233
- id="tab-1",
234
- title="Task",
235
- leader_id="leader-1",
236
- allow_network=True,
237
- permissions_initialized=True,
238
- )
239
- )
240
- agent = Agent(
241
- NodeConfig(
242
- node_type=NodeType.AGENT,
243
- role_name="Worker",
244
- name="Planner",
245
- tab_id="tab-1",
246
- ),
247
- uuid="agent-1",
248
- )
249
- settings = Settings(
250
- model=ModelSettings(
251
- active_provider_id="provider-1",
252
- active_model="gpt-5.2",
253
- retry_policy="limited",
254
- max_retries=2,
255
- ),
256
- providers=[
257
- ProviderConfig(
258
- id="provider-1",
259
- name="Primary",
260
- type="openai_responses",
261
- base_url="https://api.example.com/v1",
262
- api_key="secret",
263
- )
264
- ],
265
- )
266
- monkeypatch.setattr("flowent.agent.get_settings", lambda: settings)
267
- monkeypatch.setattr(agent, "_get_llm_retry_delay", lambda retry_number: 0.0)
268
-
269
- llm_calls = 0
270
-
271
- def fake_chat(
272
- messages,
273
- tools=None,
274
- on_chunk=None,
275
- register_interrupt=None,
276
- role_name=None,
277
- ):
278
- nonlocal llm_calls
279
- llm_calls += 1
280
- if llm_calls == 1:
281
- raise LLMProviderError(
282
- "temporary failure",
283
- transient=True,
284
- status_code=429,
285
- )
286
- return LLMResponse(
287
- content="Done",
288
- usage=LLMUsage(
289
- total_tokens=120,
290
- input_tokens=90,
291
- output_tokens=30,
292
- cache_read_tokens=12,
293
- ),
294
- raw_usage={"total_tokens": 120, "input_tokens": 90},
295
- )
296
-
297
- monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
298
-
299
- response, _ = agent._chat_with_retries(
300
- prepared_context=PreparedLLMContext(
301
- messages=[{"role": "user", "content": "hello"}],
302
- system_messages=[],
303
- execution_context_messages=[],
304
- runtime_tail_messages=[],
305
- ),
306
- tools_schema=None,
307
- )
308
-
309
- records = observability_store.list_requests(since=0)
310
-
311
- assert response.content == "Done"
312
- assert len(records) == 1
313
- assert records[0]["node_id"] == "agent-1"
314
- assert records[0]["node_label"] == "Planner"
315
- assert records[0]["tab_title"] == "Task"
316
- assert records[0]["provider_id"] == "provider-1"
317
- assert records[0]["model"] == "gpt-5.2"
318
- assert records[0]["retry_count"] == 1
319
- assert records[0]["result"] == "success"
320
- assert records[0]["normalized_usage"]["cache_read_tokens"] == 12
321
- assert records[0]["raw_usage"] == {"total_tokens": 120, "input_tokens": 90}
322
-
323
-
324
- def test_chat_with_retries_blocks_remote_model_when_network_is_disabled(monkeypatch):
325
- workspace_store.upsert_tab(
326
- Tab(
327
- id="tab-1",
328
- title="Task",
329
- leader_id="leader-1",
330
- allow_network=False,
331
- permissions_initialized=True,
332
- )
333
- )
334
- agent = Agent(
335
- NodeConfig(
336
- node_type=NodeType.AGENT,
337
- role_name="Worker",
338
- name="Planner",
339
- tab_id="tab-1",
340
- ),
341
- uuid="agent-1",
342
- )
343
- settings = Settings(
344
- model=ModelSettings(
345
- active_provider_id="provider-1",
346
- active_model="gpt-5.2",
347
- ),
348
- providers=[
349
- ProviderConfig(
350
- id="provider-1",
351
- name="Primary",
352
- type="openai_responses",
353
- base_url="https://api.example.com/v1",
354
- api_key="secret",
355
- )
356
- ],
357
- )
358
- monkeypatch.setattr("flowent.agent.get_settings", lambda: settings)
359
- monkeypatch.setattr(
360
- "flowent.agent.gateway.chat",
361
- lambda **kwargs: (_ for _ in ()).throw(
362
- AssertionError("gateway should not be called")
363
- ),
364
- )
365
-
366
- with pytest.raises(
367
- LLMProviderError,
368
- match="Network access is disabled for this workflow",
369
- ):
370
- agent._chat_with_retries(
371
- prepared_context=PreparedLLMContext(
372
- messages=[{"role": "user", "content": "hello"}],
373
- system_messages=[],
374
- execution_context_messages=[],
375
- runtime_tail_messages=[],
376
- ),
377
- tools_schema=None,
378
- )
379
-
380
-
381
- def test_agent_does_not_retry_transient_llm_errors_when_retry_policy_is_no_retry(
382
- monkeypatch,
383
- ):
384
- agent = Agent(NodeConfig(node_type=NodeType.AGENT, allow_network=True))
385
- wait_calls = 0
386
- llm_calls = 0
387
-
388
- def fake_wait_for_input() -> None:
389
- nonlocal wait_calls
390
- wait_calls += 1
391
- if wait_calls == 1:
392
- agent._append_history(
393
- ReceivedMessage(content="finish the task", from_id="tester")
394
- )
395
- agent.set_state(AgentState.RUNNING, "received message from tester")
396
- return
397
- agent.request_termination("done")
398
-
399
- def fake_chat(
400
- messages,
401
- tools=None,
402
- on_chunk=None,
403
- register_interrupt=None,
404
- role_name=None,
405
- ):
406
- nonlocal llm_calls
407
- llm_calls += 1
408
- raise LLMProviderError(
409
- "temporary failure",
410
- transient=True,
411
- status_code=429,
412
- )
413
-
414
- monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
415
- monkeypatch.setattr(
416
- "flowent.agent.get_settings",
417
- lambda: Settings(
418
- model=ModelSettings(
419
- retry_policy="no_retry",
420
- max_retries=5,
421
- )
422
- ),
423
- )
424
- monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
425
-
426
- agent._run()
427
-
428
- assert llm_calls == 1
429
- assert wait_calls == 2
430
- assert any(
431
- isinstance(entry, ErrorEntry) and entry.content == "temporary failure"
432
- for entry in agent.get_history_snapshot()
433
- )
434
-
435
-
436
- def test_agent_does_not_retry_non_transient_llm_errors(monkeypatch):
437
- agent = Agent(NodeConfig(node_type=NodeType.AGENT, allow_network=True))
438
- wait_calls = 0
439
- llm_calls = 0
440
- error_summary = (
441
- "LLM API error\n"
442
- "Provider: Test Provider\n"
443
- "Type: openai\n"
444
- "Model: gpt-5.2\n"
445
- "Base URL: http://example.invalid\n"
446
- "Status: 401\n"
447
- "Detail: Invalid API key"
448
- )
449
-
450
- def fake_wait_for_input() -> None:
451
- nonlocal wait_calls
452
- wait_calls += 1
453
- if wait_calls == 1:
454
- agent._append_history(
455
- ReceivedMessage(content="finish the task", from_id="tester")
456
- )
457
- agent.set_state(AgentState.RUNNING, "received message from tester")
458
- return
459
- agent.request_termination("done")
460
-
461
- def fake_chat(
462
- messages,
463
- tools=None,
464
- on_chunk=None,
465
- register_interrupt=None,
466
- role_name=None,
467
- ):
468
- nonlocal llm_calls
469
- llm_calls += 1
470
- raise LLMProviderError(
471
- error_summary,
472
- transient=False,
473
- status_code=401,
474
- )
475
-
476
- monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
477
- monkeypatch.setattr(
478
- "flowent.agent.get_settings",
479
- lambda: Settings(model=ModelSettings(max_retries=5)),
480
- )
481
- monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
482
-
483
- agent._run()
484
-
485
- assert llm_calls == 1
486
- assert wait_calls == 2
487
- assert any(
488
- isinstance(entry, ErrorEntry) and entry.content == error_summary
489
- for entry in agent.get_history_snapshot()
490
- )
491
- assert all(
492
- entry.__class__.__name__ != "StateEntry"
493
- for entry in agent.get_history_snapshot()
494
- )
495
- assert not any(
496
- isinstance(entry, ErrorEntry)
497
- and (
498
- "traceback" in entry.content.lower() or "LLMProviderError:" in entry.content
499
- )
500
- for entry in agent.get_history_snapshot()
501
- )
502
-
503
-
504
- def test_agent_interrupt_stops_retry_backoff(monkeypatch):
505
- agent = Agent(NodeConfig(node_type=NodeType.AGENT, allow_network=True))
506
- wait_calls = 0
507
- llm_calls = 0
508
- interrupter: threading.Thread | None = None
509
-
510
- def fake_wait_for_input() -> None:
511
- nonlocal wait_calls
512
- wait_calls += 1
513
- if wait_calls == 1:
514
- agent._append_history(
515
- ReceivedMessage(content="finish the task", from_id="tester")
516
- )
517
- agent.set_state(AgentState.RUNNING, "received message from tester")
518
- return
519
- agent.request_termination("done")
520
-
521
- def fake_chat(
522
- messages,
523
- tools=None,
524
- on_chunk=None,
525
- register_interrupt=None,
526
- role_name=None,
527
- ):
528
- nonlocal llm_calls, interrupter
529
- llm_calls += 1
530
- if llm_calls == 1:
531
- interrupter = threading.Thread(
532
- target=lambda: (time.sleep(0.01), agent.request_interrupt())
533
- )
534
- interrupter.start()
535
- raise LLMProviderError(
536
- "temporary failure",
537
- transient=True,
538
- status_code=429,
539
- )
540
- raise AssertionError("interrupt should stop retry before next attempt")
541
-
542
- monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
543
- monkeypatch.setattr(agent, "_get_llm_retry_delay", lambda retry_number: 1.0)
544
- monkeypatch.setattr(
545
- "flowent.agent.get_settings",
546
- lambda: Settings(model=ModelSettings(max_retries=5)),
547
- )
548
- monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
549
-
550
- agent._run()
551
- if interrupter is not None:
552
- interrupter.join(timeout=1.0)
553
-
554
- assert llm_calls == 1
555
- assert wait_calls == 2
556
- assert not any(
557
- isinstance(entry, ErrorEntry) for entry in agent.get_history_snapshot()
558
- )
559
-
560
-
561
- def test_agent_retries_transient_errors_when_retry_policy_is_unlimited(monkeypatch):
562
- agent = Agent(NodeConfig(node_type=NodeType.AGENT, allow_network=True))
563
- wait_calls = 0
564
- llm_calls = 0
565
-
566
- def fake_wait_for_input() -> None:
567
- nonlocal wait_calls
568
- wait_calls += 1
569
- if wait_calls == 1:
570
- agent._append_history(
571
- ReceivedMessage(content="finish the task", from_id="tester")
572
- )
573
- agent.set_state(AgentState.RUNNING, "received message from tester")
574
- return
575
- raise AssertionError("agent should not return to idle while retrying")
576
-
577
- def fake_chat(
578
- messages,
579
- tools=None,
580
- on_chunk=None,
581
- register_interrupt=None,
582
- role_name=None,
583
- ):
584
- nonlocal llm_calls
585
- llm_calls += 1
586
- if llm_calls < 4:
587
- raise LLMProviderError(
588
- f"temporary failure {llm_calls}",
589
- transient=True,
590
- status_code=429,
591
- )
592
- if llm_calls == 5:
593
- agent.request_termination("done")
594
- return LLMResponse()
595
- if llm_calls == 4:
596
- return LLMResponse(content="Recovered after unlimited retries")
597
- raise AssertionError("unexpected extra LLM call")
598
-
599
- monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
600
- monkeypatch.setattr(agent, "_get_llm_retry_delay", lambda retry_number: 0.0)
601
- monkeypatch.setattr(
602
- "flowent.agent.get_settings",
603
- lambda: Settings(
604
- model=ModelSettings(
605
- retry_policy="unlimited",
606
- max_retries=1,
607
- )
608
- ),
609
- )
610
- monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
611
-
612
- agent._run()
613
-
614
- assert wait_calls == 1
615
- assert llm_calls == 5
616
- assert agent.state == AgentState.TERMINATED
617
- assert any(
618
- isinstance(entry, AssistantText)
619
- and entry.content == "Recovered after unlimited retries"
620
- for entry in agent.get_history_snapshot()
621
- )
622
-
623
-
624
- def test_get_llm_retry_delay_uses_configured_backoff_settings(monkeypatch):
625
- agent = Agent(NodeConfig(node_type=NodeType.AGENT))
626
-
627
- monkeypatch.setattr(
628
- "flowent.agent.get_settings",
629
- lambda: Settings(
630
- model=ModelSettings(
631
- retry_initial_delay_seconds=0.75,
632
- retry_max_delay_seconds=5.0,
633
- retry_backoff_cap_retries=3,
634
- )
635
- ),
636
- )
637
-
638
- assert agent._get_llm_retry_delay(1) == 0.75
639
- assert agent._get_llm_retry_delay(2) == 1.5
640
- assert agent._get_llm_retry_delay(3) == 3.0
641
- assert agent._get_llm_retry_delay(4) == 3.0
642
-
643
-
644
- def test_get_llm_retry_429_delay_uses_active_provider_only_for_429(monkeypatch):
645
- agent = Agent(NodeConfig(node_type=NodeType.AGENT, role_name="Worker"))
646
-
647
- monkeypatch.setattr(
648
- "flowent.agent.get_settings",
649
- lambda: Settings(
650
- model=ModelSettings(
651
- active_provider_id="provider-1",
652
- active_model="gpt-test",
653
- ),
654
- providers=[
655
- ProviderConfig(
656
- id="provider-1",
657
- name="Primary",
658
- type="openai_compatible",
659
- base_url="https://api.example.com/v1",
660
- api_key="secret",
661
- retry_429_delay_seconds=4,
662
- )
663
- ],
664
- ),
665
- )
666
-
667
- assert agent._get_llm_retry_429_delay(429) == 4.0
668
- assert agent._get_llm_retry_429_delay(500) == 0.0
669
-
670
-
671
- def test_prepare_messages_records_auto_compact_stat(monkeypatch):
672
- workspace_store.upsert_tab(
673
- Tab(
674
- id="tab-1",
675
- title="Task",
676
- leader_id="leader-1",
677
- allow_network=True,
678
- permissions_initialized=True,
679
- )
680
- )
681
- agent = Agent(
682
- NodeConfig(
683
- node_type=NodeType.AGENT,
684
- role_name="Worker",
685
- name="Planner",
686
- tab_id="tab-1",
687
- ),
688
- uuid="agent-1",
689
- )
690
- settings = Settings(
691
- model=ModelSettings(
692
- active_provider_id="provider-1",
693
- active_model="gpt-5.2",
694
- ),
695
- providers=[
696
- ProviderConfig(
697
- id="provider-1",
698
- name="Primary",
699
- type="openai_responses",
700
- base_url="https://api.example.com/v1",
701
- api_key="secret",
702
- )
703
- ],
704
- )
705
- monkeypatch.setattr("flowent.agent.get_settings", lambda: settings)
706
-
707
- prepared_context = PreparedLLMContext(
708
- messages=[{"role": "user", "content": "hello"}],
709
- system_messages=[],
710
- execution_context_messages=[],
711
- runtime_tail_messages=[],
712
- )
713
- preflights = iter(
714
- [
715
- ContextPreflight(
716
- estimated_total_tokens=50,
717
- auto_compact_token_limit=10,
718
- ),
719
- ContextPreflight(
720
- estimated_total_tokens=2,
721
- auto_compact_token_limit=10,
722
- ),
723
- ]
724
- )
725
-
726
- monkeypatch.setattr(agent, "_build_prepared_llm_context", lambda: prepared_context)
727
- monkeypatch.setattr(
728
- agent,
729
- "_compute_context_preflight",
730
- lambda context: next(preflights),
731
- )
732
- compact_calls: list[str | None] = []
733
- monkeypatch.setattr(
734
- agent,
735
- "_compact_execution_context",
736
- lambda focus=None: compact_calls.append(focus) or "",
737
- )
738
-
739
- result = agent._prepare_messages_for_llm()
740
- records = observability_store.list_compacts(since=0)
741
-
742
- assert result == prepared_context
743
- assert compact_calls == [None]
744
- assert len(records) == 1
745
- assert records[0]["trigger_type"] == "auto"
746
- assert records[0]["result"] == "success"
747
- assert records[0]["provider_id"] == "provider-1"
748
- assert records[0]["model"] == "gpt-5.2"
749
-
750
-
751
- def test_clear_assistant_chat_history_drops_conversation_entries():
752
- assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
753
- assistant.history.extend(
754
- [
755
- SystemEntry(content="system prompt"),
756
- ReceivedMessage(content="hello", from_id="human"),
757
- AssistantThinking(content="planning"),
758
- AssistantText(content="hi"),
759
- ToolCall(
760
- tool_name="idle",
761
- tool_call_id="tool-1",
762
- arguments={},
763
- result="idle 1.00s",
764
- ),
765
- ErrorEntry(content="boom"),
766
- ]
767
- )
768
-
769
- assistant.clear_chat_history()
770
-
771
- assert all(
772
- isinstance(entry, SystemEntry) for entry in assistant.get_history_snapshot()
773
- )
774
-
775
-
776
- @pytest.mark.parametrize(
777
- ("state", "reason"),
778
- [
779
- (AgentState.RUNNING, "processing"),
780
- (AgentState.SLEEPING, "waiting for reply"),
781
- ],
782
- )
783
- def test_clear_assistant_chat_history_interrupts_active_agent(
784
- monkeypatch,
785
- state,
786
- reason,
787
- ):
788
- assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
789
- assistant.set_state(state, reason)
790
- assistant.history.append(ReceivedMessage(content="hello", from_id="human"))
791
- interrupt_thread = threading.Thread(
792
- target=assistant._handle_interrupt,
793
- args=(None,),
794
- daemon=True,
795
- )
796
-
797
- def fake_request_interrupt() -> bool:
798
- if not interrupt_thread.is_alive():
799
- interrupt_thread.start()
800
- return True
801
-
802
- monkeypatch.setattr(assistant, "request_interrupt", fake_request_interrupt)
803
-
804
- assistant.clear_chat_history()
805
-
806
- assert assistant.state == AgentState.IDLE
807
- assert not any(
808
- isinstance(entry, ReceivedMessage) for entry in assistant.get_history_snapshot()
809
- )
810
-
811
- assistant.request_termination("done")
812
- interrupt_thread.join(timeout=1.0)
813
- assert interrupt_thread.is_alive() is False
814
-
815
-
816
- def test_clear_assistant_chat_history_drops_queued_messages_after_interrupt(
817
- monkeypatch,
818
- ):
819
- assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
820
- assistant.set_state(AgentState.RUNNING, "processing")
821
- assistant._wake_queue.put(
822
- WakeSignal(
823
- reason="message",
824
- payload={"message": {"content": "queued message", "from": "human"}},
825
- resume_reason="received message from human",
826
- )
827
- )
828
-
829
- interrupt_thread = threading.Thread(
830
- target=assistant._handle_interrupt,
831
- args=(None,),
832
- daemon=True,
833
- )
834
-
835
- def fake_request_interrupt() -> bool:
836
- if not interrupt_thread.is_alive():
837
- interrupt_thread.start()
838
- return True
839
-
840
- monkeypatch.setattr(assistant, "request_interrupt", fake_request_interrupt)
841
- assistant.clear_chat_history()
842
-
843
- assert assistant.state == AgentState.IDLE
844
- assert not any(
845
- isinstance(entry, ReceivedMessage) and entry.content == "queued message"
846
- for entry in assistant.get_history_snapshot()
847
- )
848
-
849
- assistant.request_termination("done")
850
- interrupt_thread.join(timeout=1.0)
851
- assert interrupt_thread.is_alive() is False
852
-
853
-
854
- def test_execute_clear_command_does_not_append_visible_feedback():
855
- assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
856
- assistant.history.extend(
857
- [
858
- ReceivedMessage(content="hello", from_id="human"),
859
- AssistantText(content="hi"),
860
- ]
861
- )
862
-
863
- entry = assistant.execute_assistant_command(command_name="/clear")
864
- history = assistant.get_history_snapshot()
865
-
866
- assert isinstance(entry, CommandResultEntry)
867
- assert entry.command_name == "/clear"
868
- assert entry.include_in_context is False
869
- assert not any(isinstance(item, ReceivedMessage) for item in history)
870
- assert not any(isinstance(item, AssistantText) for item in history)
871
- assert not any(
872
- isinstance(item, CommandResultEntry) and item.command_name == "/clear"
873
- for item in history
874
- )
875
-
876
-
877
- def test_retry_human_message_replaces_selected_tail_and_requeues_input(monkeypatch):
878
- assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
879
- queued_messages: list[Message] = []
880
- monkeypatch.setattr(
881
- assistant,
882
- "enqueue_message",
883
- lambda message: queued_messages.append(message),
884
- )
885
- assistant.history.extend(
886
- [
887
- ReceivedMessage(
888
- content="Keep this message",
889
- from_id="human",
890
- message_id="msg-1",
891
- ),
892
- AssistantText(content="Keep this reply"),
893
- ReceivedMessage(
894
- content="Retry this request",
895
- from_id="human",
896
- message_id="msg-2",
897
- ),
898
- AssistantThinking(content="Old thinking"),
899
- AssistantText(content="Discard this reply"),
900
- ErrorEntry(content="Old failure"),
901
- ]
902
- )
903
-
904
- retried_message_id = assistant.retry_human_message(message_id="msg-2")
905
- history = assistant.get_history_snapshot()
906
-
907
- assert retried_message_id != "msg-2"
908
- assert any(
909
- isinstance(entry, ReceivedMessage)
910
- and entry.message_id == "msg-1"
911
- and entry.content == "Keep this message"
912
- for entry in history
913
- )
914
- assert any(
915
- isinstance(entry, AssistantText) and entry.content == "Keep this reply"
916
- for entry in history
917
- )
918
- assert not any(
919
- isinstance(entry, ReceivedMessage) and entry.message_id == "msg-2"
920
- for entry in history
921
- )
922
- assert not any(
923
- isinstance(entry, AssistantText) and entry.content == "Discard this reply"
924
- for entry in history
925
- )
926
- assert not any(
927
- isinstance(entry, ErrorEntry) and entry.content == "Old failure"
928
- for entry in history
929
- )
930
- assert any(
931
- isinstance(entry, ReceivedMessage)
932
- and entry.message_id == retried_message_id
933
- and entry.content == "Retry this request"
934
- for entry in history
935
- )
936
- assert len(queued_messages) == 1
937
- assert queued_messages[0].message_id == retried_message_id
938
- assert queued_messages[0].history_recorded is True
939
- assert queued_messages[0].content == "Retry this request"
940
-
941
-
942
- def test_retry_human_message_reuses_image_parts(monkeypatch):
943
- assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
944
- queued_messages: list[Message] = []
945
- monkeypatch.setattr(assistant, "supports_input_image", lambda: True)
946
- monkeypatch.setattr(
947
- "flowent.agent.require_image_asset",
948
- lambda asset_id: object() if asset_id == "asset-1" else None,
949
- )
950
- monkeypatch.setattr(
951
- assistant,
952
- "enqueue_message",
953
- lambda message: queued_messages.append(message),
954
- )
955
- assistant.history.extend(
956
- [
957
- ReceivedMessage(
958
- from_id="human",
959
- parts=[
960
- ImagePart(
961
- asset_id="asset-1",
962
- mime_type="image/png",
963
- width=1,
964
- height=1,
965
- )
966
- ],
967
- message_id="msg-image",
968
- ),
969
- AssistantText(content="Old image reply"),
970
- ]
971
- )
972
-
973
- retried_message_id = assistant.retry_human_message(message_id="msg-image")
974
-
975
- assert retried_message_id
976
- assert len(queued_messages) == 1
977
- assert len(queued_messages[0].parts) == 1
978
- assert isinstance(queued_messages[0].parts[0], ImagePart)
979
- assert queued_messages[0].parts[0].asset_id == "asset-1"
980
-
981
-
982
- def test_retry_human_message_missing_anchor_keeps_queued_messages():
983
- assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
984
- assistant._wake_queue.put(
985
- WakeSignal(
986
- reason="message",
987
- payload={"message": {"content": "queued message", "from": "human"}},
988
- resume_reason="received message from human",
989
- )
990
- )
991
-
992
- with pytest.raises(LookupError):
993
- assistant.retry_human_message(message_id="missing")
994
-
995
- signal = assistant._wake_queue.get_nowait()
996
- assert signal.reason == "message"
997
- assert signal.payload["message"]["content"] == "queued message"
998
-
999
-
1000
- def test_retry_human_message_rolls_back_when_persist_fails(monkeypatch):
1001
- assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1002
- assistant.history.extend(
1003
- [
1004
- ReceivedMessage(
1005
- content="Retry this request",
1006
- from_id="human",
1007
- message_id="msg-2",
1008
- ),
1009
- AssistantText(content="Discard this reply"),
1010
- ]
1011
- )
1012
- persist_calls = 0
1013
-
1014
- def fake_persist() -> None:
1015
- nonlocal persist_calls
1016
- persist_calls += 1
1017
- if persist_calls == 1:
1018
- raise RuntimeError("persist failed")
1019
-
1020
- monkeypatch.setattr(assistant, "_persist_workspace_node", fake_persist)
1021
-
1022
- with pytest.raises(RuntimeError, match="persist failed"):
1023
- assistant.retry_human_message(message_id="msg-2")
1024
-
1025
- history = assistant.get_history_snapshot()
1026
- assert any(
1027
- isinstance(entry, ReceivedMessage) and entry.message_id == "msg-2"
1028
- for entry in history
1029
- )
1030
- assert any(
1031
- isinstance(entry, AssistantText) and entry.content == "Discard this reply"
1032
- for entry in history
1033
- )
1034
-
1035
-
1036
- def test_leader_retry_received_message_requires_human_anchor(monkeypatch):
1037
- leader = _register_tab_leader()
1038
- leader.history.extend(
1039
- [
1040
- ReceivedMessage(
1041
- content="Assistant brief",
1042
- from_id="assistant",
1043
- message_id="brief-1",
1044
- ),
1045
- ReceivedMessage(
1046
- content="Human follow-up",
1047
- from_id="human",
1048
- message_id="msg-human",
1049
- ),
1050
- ]
1051
- )
1052
- monkeypatch.setattr(
1053
- "flowent.graph_service.is_tab_leader",
1054
- lambda **kwargs: kwargs["node_id"] == leader.uuid,
1055
- )
1056
-
1057
- with pytest.raises(
1058
- LookupError,
1059
- match=r"Leader human message `brief-1` was not found\.",
1060
- ):
1061
- leader.retry_received_message(message_id="brief-1")
1062
-
1063
-
1064
- def test_execute_compact_command_replaces_history_with_summary(monkeypatch):
1065
- assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1066
- assistant.history.extend(
1067
- [
1068
- ReceivedMessage(content="Summarize the rollout", from_id="human"),
1069
- AssistantText(content="Working through the changes."),
1070
- ErrorEntry(content="temporary failure"),
1071
- ]
1072
- )
1073
-
1074
- monkeypatch.setattr(
1075
- "flowent.agent.gateway.chat",
1076
- lambda *args, **kwargs: LLMResponse(
1077
- content=(
1078
- "## Current Goal\nShip the command layer.\n\n"
1079
- "## Active Task Boundary\nKeep the change in Assistant chat.\n\n"
1080
- "## Key Constraints\nPreserve persistence.\n\n"
1081
- "## Confirmed Decisions\nUse built-in commands only.\n\n"
1082
- "## Open Questions\nNone.\n\n"
1083
- "## Next Actions\nFinish the UI."
1084
- )
1085
- ),
1086
- )
1087
-
1088
- entry = assistant.execute_assistant_command(
1089
- command_name="/compact",
1090
- argument="slash rollout",
1091
- )
1092
-
1093
- history = assistant.get_history_snapshot()
1094
-
1095
- assert isinstance(entry, CommandResultEntry)
1096
- assert entry.include_in_context is False
1097
- assert history[-1] == entry
1098
- assert any(
1099
- isinstance(item, ReceivedMessage) and item.content == "Summarize the rollout"
1100
- for item in history
1101
- )
1102
- assert any(
1103
- isinstance(item, AssistantText)
1104
- and item.content == "Working through the changes."
1105
- for item in history
1106
- )
1107
- assert (
1108
- assistant.get_execution_context_summary().startswith("## Current Goal\n")
1109
- is True
1110
- )
1111
-
1112
- messages = assistant._build_messages()
1113
- serialized = json.dumps(messages)
1114
-
1115
- assert "Summarize the rollout" not in serialized
1116
- assert "Compacted execution context" in serialized
1117
- assert "Ship the command layer." in serialized
1118
- assert "Compacted this chat for future replies." not in serialized
1119
-
1120
-
1121
- def test_compacted_context_normalizes_legacy_traceback_summary(monkeypatch):
1122
- monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
1123
-
1124
- assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1125
- assistant._set_execution_context(
1126
- summary=(
1127
- "## Current Goal\nRecover the chat.\n\n"
1128
- "Traceback (most recent call last):\n"
1129
- ' File "/tmp/example.py", line 1, in <module>\n'
1130
- "RuntimeError: boom"
1131
- ),
1132
- history_cutoff=0,
1133
- )
1134
-
1135
- messages = assistant._build_messages()
1136
- serialized = json.dumps(messages)
1137
-
1138
- assert "Compacted execution context" in serialized
1139
- assert "Recover the chat." in serialized
1140
- assert "Traceback" not in serialized
1141
- assert "/tmp/example.py" not in serialized
1142
-
1143
-
1144
- def test_compacted_context_normalizes_legacy_html_summary(monkeypatch):
1145
- monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
1146
-
1147
- assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1148
- assistant._set_execution_context(
1149
- summary=(
1150
- "## Current Goal\nRecover the chat.\n\n"
1151
- "<!doctype html><html><body>Bad Gateway</body></html>"
1152
- ),
1153
- history_cutoff=0,
1154
- )
1155
-
1156
- messages = assistant._build_messages()
1157
- serialized = json.dumps(messages)
1158
-
1159
- assert "Compacted execution context" in serialized
1160
- assert "unexpected error response" in serialized
1161
- assert "<!doctype html" not in serialized
1162
- assert "<html" not in serialized
1163
-
1164
-
1165
- def test_compact_command_excludes_queued_messages_from_summary(monkeypatch):
1166
- assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1167
- assistant.history.extend(
1168
- [
1169
- ReceivedMessage(content="Existing history", from_id="human"),
1170
- AssistantText(content="Existing reply"),
1171
- ]
1172
- )
1173
- assistant.set_state(AgentState.RUNNING, "processing")
1174
- assistant._wake_queue.put(
1175
- WakeSignal(
1176
- reason="message",
1177
- payload={"message": {"content": "queued message", "from": "human"}},
1178
- resume_reason="received message from human",
1179
- )
1180
- )
1181
-
1182
- captured_messages: list[list[dict]] = []
1183
-
1184
- def fake_chat(*, messages, **kwargs):
1185
- captured_messages.append(messages)
1186
- return LLMResponse(
1187
- content=(
1188
- "## Current Goal\nShip the command layer.\n\n"
1189
- "## Active Task Boundary\nKeep the change in Assistant chat.\n\n"
1190
- "## Key Constraints\nPreserve persistence.\n\n"
1191
- "## Confirmed Decisions\nUse built-in commands only.\n\n"
1192
- "## Open Questions\nNone.\n\n"
1193
- "## Next Actions\nFinish the UI."
1194
- )
1195
- )
1196
-
1197
- interrupt_thread = threading.Thread(
1198
- target=assistant._handle_interrupt,
1199
- args=(None,),
1200
- daemon=True,
1201
- )
1202
-
1203
- def fake_request_interrupt() -> bool:
1204
- if not interrupt_thread.is_alive():
1205
- interrupt_thread.start()
1206
- return True
1207
-
1208
- monkeypatch.setattr(assistant, "request_interrupt", fake_request_interrupt)
1209
- monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
1210
-
1211
- assistant.compact_chat_history()
1212
-
1213
- assert captured_messages
1214
- assert "queued message" not in json.dumps(captured_messages[0])
1215
-
1216
- assistant.request_termination("done")
1217
- interrupt_thread.join(timeout=1.0)
1218
- assert interrupt_thread.is_alive() is False
1219
-
1220
-
1221
- def test_help_command_result_does_not_reenter_model_context():
1222
- assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1223
-
1224
- entry = assistant.execute_assistant_command(command_name="/help")
1225
- messages = assistant._build_messages()
1226
- serialized = json.dumps(messages)
1227
-
1228
- assert isinstance(entry, CommandResultEntry)
1229
- assert entry.include_in_context is False
1230
- assert "/compact" in entry.content
1231
- assert "Available commands" not in serialized
1232
-
1233
-
1234
- def test_agent_normalizes_think_tags_in_final_content(monkeypatch):
1235
- agent = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1236
- wait_calls = 0
1237
- llm_calls = 0
1238
-
1239
- def fake_wait_for_input() -> None:
1240
- nonlocal wait_calls
1241
- wait_calls += 1
1242
- if wait_calls == 1:
1243
- agent._append_history(
1244
- ReceivedMessage(content="reply to me", from_id="human")
1245
- )
1246
- agent.set_state(AgentState.RUNNING, "received message from human")
1247
- return
1248
- agent.request_termination("done")
1249
-
1250
- def fake_chat(
1251
- messages,
1252
- tools=None,
1253
- on_chunk=None,
1254
- register_interrupt=None,
1255
- role_name=None,
1256
- ):
1257
- nonlocal llm_calls
1258
- llm_calls += 1
1259
- if llm_calls == 2:
1260
- agent.request_termination("done")
1261
- return LLMResponse()
1262
- return LLMResponse(content="<think>Drafting plan</think>\nHello there")
1263
-
1264
- monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
1265
- monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
1266
-
1267
- agent._run()
1268
-
1269
- history = agent.get_history_snapshot()
1270
-
1271
- assert any(
1272
- isinstance(entry, AssistantThinking) and entry.content == "Drafting plan"
1273
- for entry in history
1274
- )
1275
- assert any(
1276
- isinstance(entry, AssistantText) and entry.content == "Hello there"
1277
- for entry in history
1278
- )
1279
- assert not any(
1280
- isinstance(entry, AssistantText) and "<think>" in entry.content
1281
- for entry in history
1282
- )
1283
-
1284
-
1285
- def test_agent_dedupes_structured_thinking_and_raw_think_tags(monkeypatch):
1286
- agent = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1287
- wait_calls = 0
1288
- llm_calls = 0
1289
-
1290
- def fake_wait_for_input() -> None:
1291
- nonlocal wait_calls
1292
- wait_calls += 1
1293
- if wait_calls == 1:
1294
- agent._append_history(
1295
- ReceivedMessage(content="reply to me", from_id="human")
1296
- )
1297
- agent.set_state(AgentState.RUNNING, "received message from human")
1298
- return
1299
- agent.request_termination("done")
1300
-
1301
- def fake_chat(
1302
- messages,
1303
- tools=None,
1304
- on_chunk=None,
1305
- register_interrupt=None,
1306
- role_name=None,
1307
- ):
1308
- nonlocal llm_calls
1309
- llm_calls += 1
1310
- if llm_calls == 2:
1311
- agent.request_termination("done")
1312
- return LLMResponse()
1313
- return LLMResponse(
1314
- content="<think>Drafting plan</think>\nHello there",
1315
- thinking="Drafting plan",
1316
- )
1317
-
1318
- monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
1319
- monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
1320
-
1321
- agent._run()
1322
-
1323
- thinking_entries = [
1324
- entry.content
1325
- for entry in agent.get_history_snapshot()
1326
- if isinstance(entry, AssistantThinking)
1327
- ]
1328
-
1329
- assert thinking_entries == ["Drafting plan"]
1330
-
1331
-
1332
- def test_agent_unregisters_from_registry_after_termination_request(monkeypatch):
1333
- registry.reset()
1334
- agent = Agent(
1335
- NodeConfig(node_type=NodeType.AGENT, allow_network=True),
1336
- uuid="agent-x",
1337
- )
1338
- registry.register(agent)
1339
- events = []
1340
-
1341
- def fake_wait_for_input() -> None:
1342
- agent._append_history(
1343
- ReceivedMessage(content="finish the task", from_id="tester")
1344
- )
1345
- agent.set_state(AgentState.RUNNING, "received message from tester")
1346
-
1347
- monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
1348
-
1349
- def fake_chat(
1350
- messages,
1351
- tools=None,
1352
- on_chunk=None,
1353
- register_interrupt=None,
1354
- role_name=None,
1355
- ):
1356
- agent.request_termination("done")
1357
- return LLMResponse()
1358
-
1359
- monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
1360
- monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
1361
-
1362
- agent._run()
1363
-
1364
- assert agent.state == AgentState.TERMINATED
1365
- assert registry.get(agent.uuid) is None
1366
- assert [event.type for event in events[-2:]] == [
1367
- EventType.NODE_STATE_CHANGED,
1368
- EventType.NODE_TERMINATED,
1369
- ]
1370
- assert events[-1].data == {"reason": "done"}
1371
-
1372
-
1373
- def test_finalize_termination_removes_bidirectional_connections():
1374
- registry.reset()
1375
- try:
1376
- assistant = Agent(
1377
- NodeConfig(node_type=NodeType.ASSISTANT),
1378
- )
1379
- worker = Agent(
1380
- NodeConfig(node_type=NodeType.AGENT),
1381
- uuid="worker",
1382
- )
1383
- registry.register(assistant)
1384
- registry.register(worker)
1385
- assistant.add_connection(worker.uuid)
1386
- worker.add_connection(assistant.uuid)
1387
-
1388
- worker._finalize_termination("done")
1389
-
1390
- assert registry.get(worker.uuid) is None
1391
- assert assistant.get_connections_snapshot() == []
1392
- assert worker.get_connections_snapshot() == []
1393
- finally:
1394
- registry.reset()
1395
-
1396
-
1397
- def test_agent_interrupts_streaming_response_and_returns_to_idle(monkeypatch):
1398
- monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
1399
- registry.reset()
1400
- assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1401
- registry.register(assistant)
1402
- events = []
1403
- wait_calls = 0
1404
-
1405
- def fake_wait_for_input() -> None:
1406
- nonlocal wait_calls
1407
- wait_calls += 1
1408
- if wait_calls == 1:
1409
- assistant._append_history(
1410
- ReceivedMessage(content="start working", from_id="human")
1411
- )
1412
- assistant.set_state(AgentState.RUNNING, "received message from human")
1413
- return
1414
- assistant.request_termination("done")
1415
-
1416
- def fake_chat(
1417
- messages,
1418
- tools=None,
1419
- on_chunk=None,
1420
- register_interrupt=None,
1421
- role_name=None,
1422
- ):
1423
- assert on_chunk is not None
1424
- on_chunk("thinking", "Drafting plan")
1425
- on_chunk("content", "Working")
1426
- assert assistant.request_interrupt() is True
1427
- on_chunk("content", " on the task")
1428
- raise AssertionError("interrupt should stop streaming before completion")
1429
-
1430
- monkeypatch.setattr(assistant, "_wait_for_input", fake_wait_for_input)
1431
- monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
1432
- monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
1433
-
1434
- try:
1435
- assistant._run()
1436
- finally:
1437
- registry.reset()
1438
-
1439
- history = assistant.get_history_snapshot()
1440
-
1441
- assert any(
1442
- isinstance(entry, AssistantThinking) and entry.content == "Drafting plan"
1443
- for entry in history
1444
- )
1445
- assert any(
1446
- isinstance(entry, AssistantText) and entry.content == "Working"
1447
- for entry in history
1448
- )
1449
- assert any(
1450
- event.type == EventType.NODE_STATE_CHANGED
1451
- and event.data.get("new_state") == "idle"
1452
- for event in events
1453
- )
1454
-
1455
-
1456
- def test_agent_normalizes_think_tags_in_streaming_content(monkeypatch):
1457
- assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1458
- wait_calls = 0
1459
- events = []
1460
- llm_calls = 0
1461
-
1462
- def fake_wait_for_input() -> None:
1463
- nonlocal wait_calls
1464
- wait_calls += 1
1465
- if wait_calls == 1:
1466
- assistant._append_history(
1467
- ReceivedMessage(content="reply to me", from_id="human")
1468
- )
1469
- assistant.set_state(AgentState.RUNNING, "received message from human")
1470
- return
1471
- assistant.request_termination("done")
1472
-
1473
- def fake_chat(
1474
- messages,
1475
- tools=None,
1476
- on_chunk=None,
1477
- register_interrupt=None,
1478
- role_name=None,
1479
- ):
1480
- nonlocal llm_calls
1481
- llm_calls += 1
1482
- if llm_calls == 2:
1483
- assistant.request_termination("done")
1484
- return LLMResponse()
1485
- assert on_chunk is not None
1486
- on_chunk("content", "<think>Drafting plan</think>\nHello there")
1487
- return LLMResponse(content="<think>Drafting plan</think>\nHello there")
1488
-
1489
- monkeypatch.setattr(assistant, "_wait_for_input", fake_wait_for_input)
1490
- monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
1491
- monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
1492
-
1493
- assistant._run()
1494
-
1495
- history = assistant.get_history_snapshot()
1496
- assistant_content_events = [
1497
- event.data.get("content")
1498
- for event in events
1499
- if event.type == EventType.ASSISTANT_CONTENT
1500
- ]
1501
-
1502
- assert any(
1503
- isinstance(entry, AssistantThinking) and entry.content == "Drafting plan"
1504
- for entry in history
1505
- )
1506
- assert any(
1507
- isinstance(entry, AssistantText) and entry.content == "Hello there"
1508
- for entry in history
1509
- )
1510
- assert assistant_content_events == ["Hello there"]
1511
-
1512
-
1513
- def test_agent_does_not_duplicate_thinking_when_provider_returns_both(monkeypatch):
1514
- assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1515
- wait_calls = 0
1516
- llm_calls = 0
1517
-
1518
- def fake_wait_for_input() -> None:
1519
- nonlocal wait_calls
1520
- wait_calls += 1
1521
- if wait_calls == 1:
1522
- assistant._append_history(
1523
- ReceivedMessage(content="reply to me", from_id="human")
1524
- )
1525
- assistant.set_state(AgentState.RUNNING, "received message from human")
1526
- return
1527
- assistant.request_termination("done")
1528
-
1529
- def fake_chat(
1530
- messages,
1531
- tools=None,
1532
- on_chunk=None,
1533
- register_interrupt=None,
1534
- role_name=None,
1535
- ):
1536
- nonlocal llm_calls
1537
- llm_calls += 1
1538
- if llm_calls == 2:
1539
- assistant.request_termination("done")
1540
- return LLMResponse()
1541
- return LLMResponse(
1542
- content="<think>Drafting plan</think>\nHello there",
1543
- thinking="Drafting plan",
1544
- )
1545
-
1546
- monkeypatch.setattr(assistant, "_wait_for_input", fake_wait_for_input)
1547
- monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
1548
-
1549
- assistant._run()
1550
-
1551
- thinking_entries = [
1552
- entry
1553
- for entry in assistant.get_history_snapshot()
1554
- if isinstance(entry, AssistantThinking)
1555
- ]
1556
-
1557
- assert [entry.content for entry in thinking_entries] == ["Drafting plan"]
1558
-
1559
-
1560
- def test_request_sleep_raises_interrupt_when_running_agent_is_interrupted():
1561
- agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent-a")
1562
- agent.set_state(AgentState.RUNNING, "sleeping")
1563
- assert agent.request_interrupt() is True
1564
-
1565
- try:
1566
- agent.request_sleep(seconds=0.2)
1567
- except InterruptRequestedError:
1568
- pass
1569
- else:
1570
- raise AssertionError("expected interrupt during sleep")
1571
-
1572
-
1573
- def test_request_sleep_wakes_early_when_new_message_arrives():
1574
- agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent-a")
1575
- agent.set_state(AgentState.RUNNING, "processing")
1576
-
1577
- def enqueue_message() -> None:
1578
- time.sleep(0.02)
1579
- agent.enqueue_message(
1580
- Message(from_id="tester", to_id=agent.uuid, content="wake up")
1581
- )
1582
-
1583
- wake_thread = threading.Thread(target=enqueue_message, daemon=True)
1584
- wake_thread.start()
1585
-
1586
- result = agent.request_sleep(seconds=0.3)
1587
-
1588
- wake_thread.join(timeout=1.0)
1589
-
1590
- assert result.startswith("woken by message after ")
1591
- assert agent.state == AgentState.RUNNING
1592
- received_entries = [
1593
- entry
1594
- for entry in agent.get_history_snapshot()
1595
- if isinstance(entry, ReceivedMessage)
1596
- ]
1597
- assert len(received_entries) == 1
1598
- assert received_entries[0].content == "wake up"
1599
- assert all(entry.__class__.__name__ != "StateEntry" for entry in agent.history)
1600
-
1601
-
1602
- def test_request_sleep_timeout_queues_deadline_notice():
1603
- agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent-a")
1604
- agent.set_state(AgentState.RUNNING, "processing")
1605
-
1606
- result = agent.request_sleep(seconds=0.01)
1607
-
1608
- assert result.startswith("slept ")
1609
- assert agent.state == AgentState.RUNNING
1610
- assert agent._consume_runtime_notices() == [agent._build_sleep_deadline_notice()]
1611
- assert all(entry.__class__.__name__ != "StateEntry" for entry in agent.history)
1612
-
1613
-
1614
- def test_agent_interrupts_blocked_provider_without_streaming_output(monkeypatch):
1615
- monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
1616
- registry.reset()
1617
- assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1618
- registry.register(assistant)
1619
- events = []
1620
- wait_calls = 0
1621
- provider_started = threading.Event()
1622
-
1623
- def fake_wait_for_input() -> None:
1624
- nonlocal wait_calls
1625
- wait_calls += 1
1626
- if wait_calls == 1:
1627
- assistant._append_history(
1628
- ReceivedMessage(content="start working", from_id="human")
1629
- )
1630
- assistant.set_state(AgentState.RUNNING, "received message from human")
1631
- return
1632
- assistant.request_termination("done")
1633
-
1634
- def fake_chat(
1635
- messages,
1636
- tools=None,
1637
- on_chunk=None,
1638
- register_interrupt=None,
1639
- role_name=None,
1640
- ):
1641
- closed = threading.Event()
1642
- assert register_interrupt is not None
1643
- register_interrupt(closed.set)
1644
- provider_started.set()
1645
- while not closed.wait(0.01):
1646
- continue
1647
- raise RuntimeError("stream closed")
1648
-
1649
- def request_interrupt() -> None:
1650
- provider_started.wait(timeout=1.0)
1651
- assistant.request_interrupt()
1652
-
1653
- interrupter = threading.Thread(target=request_interrupt, daemon=True)
1654
- interrupter.start()
1655
-
1656
- monkeypatch.setattr(assistant, "_wait_for_input", fake_wait_for_input)
1657
- monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
1658
- monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
1659
-
1660
- try:
1661
- assistant._run()
1662
- finally:
1663
- registry.reset()
1664
-
1665
- interrupter.join(timeout=1.0)
1666
-
1667
- assert any(
1668
- event.type == EventType.NODE_STATE_CHANGED
1669
- and event.data.get("new_state") == "idle"
1670
- for event in events
1671
- )
1672
-
1673
-
1674
- def test_provider_resolution_error_is_recorded_in_history(monkeypatch):
1675
- agent = Agent(
1676
- NodeConfig(node_type=NodeType.AGENT, allow_network=True),
1677
- uuid="agent-y",
1678
- )
1679
- wait_calls = 0
1680
-
1681
- def fake_wait_for_input() -> None:
1682
- nonlocal wait_calls
1683
- wait_calls += 1
1684
- if wait_calls == 1:
1685
- agent._append_history(
1686
- ReceivedMessage(content="do the task", from_id="tester")
1687
- )
1688
- agent.set_state(AgentState.RUNNING, "received message from tester")
1689
- return
1690
- agent.request_termination("stop")
1691
-
1692
- monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
1693
- monkeypatch.setattr(
1694
- "flowent.agent.gateway.chat",
1695
- lambda messages, tools=None, on_chunk=None, register_interrupt=None, role_name=None: (
1696
- (_ for _ in ()).throw(RuntimeError("No active provider configured"))
1697
- ),
1698
- )
1699
-
1700
- agent._run()
1701
-
1702
- assert wait_calls == 2
1703
- assert agent.state == AgentState.TERMINATED
1704
- assert any(
1705
- isinstance(entry, ErrorEntry)
1706
- and "No active provider configured" in entry.content
1707
- for entry in agent.get_history_snapshot()
1708
- )
1709
-
1710
-
1711
- def test_assistant_content_streams_even_when_response_has_tool_calls(monkeypatch):
1712
- registry.reset()
1713
- assistant = Agent(
1714
- NodeConfig(node_type=NodeType.ASSISTANT, tools=["idle"]),
1715
- )
1716
- registry.register(assistant)
1717
- events = []
1718
-
1719
- def fake_wait_for_input() -> None:
1720
- assistant._append_history(
1721
- ReceivedMessage(content="report progress", from_id="human")
1722
- )
1723
- assistant.set_state(AgentState.RUNNING, "received message from human")
1724
-
1725
- def fake_chat(
1726
- messages,
1727
- tools=None,
1728
- on_chunk=None,
1729
- register_interrupt=None,
1730
- role_name=None,
1731
- ):
1732
- if on_chunk is not None:
1733
- on_chunk("content", "Working on it")
1734
- return LLMResponse(
1735
- content="Working on it",
1736
- tool_calls=[
1737
- ToolCallResult(
1738
- id="call-idle",
1739
- name="idle",
1740
- arguments={},
1741
- )
1742
- ],
1743
- )
1744
-
1745
- monkeypatch.setattr(assistant, "_wait_for_input", fake_wait_for_input)
1746
- monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
1747
- monkeypatch.setattr(
1748
- assistant,
1749
- "_handle_tool_call",
1750
- lambda name, arguments, call_id: assistant.request_termination("done"),
1751
- )
1752
- monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
1753
-
1754
- assistant._run()
1755
-
1756
- assistant_events = [
1757
- event for event in events if event.type == EventType.ASSISTANT_CONTENT
1758
- ]
1759
- assert [event.data for event in assistant_events] == [{"content": "Working on it"}]
1760
-
1761
-
1762
- def test_send_message_delivers_to_single_contact_and_records_histories(monkeypatch):
1763
- registry.reset()
1764
- _register_tab_leader()
1765
- child = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="child")
1766
- peer = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="peer")
1767
- registry.register(child)
1768
- registry.register(peer)
1769
- _add_agent_path(source_id="child", target_id="peer")
1770
- events = []
1771
-
1772
- monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
1773
-
1774
- try:
1775
- result = json.loads(
1776
- child.send_message(
1777
- target_ref="peer",
1778
- from_output_port_key="out",
1779
- to_input_port_key="in",
1780
- raw_parts=[{"type": "text", "text": "investigate the error"}],
1781
- )
1782
- )
1783
- finally:
1784
- registry.reset()
1785
-
1786
- sent_entry = next(
1787
- entry
1788
- for entry in child.get_history_snapshot()
1789
- if isinstance(entry, SentMessage)
1790
- )
1791
- received_entry = next(
1792
- entry
1793
- for entry in peer.get_history_snapshot()
1794
- if isinstance(entry, ReceivedMessage)
1795
- )
1796
- signal = peer._wake_queue.get_nowait()
1797
-
1798
- assert result == {
1799
- "status": "sent",
1800
- "target_id": "peer",
1801
- "from_output_port_key": "out",
1802
- "to_input_port_key": "in",
1803
- "port_type": "parts",
1804
- "value_summary": "investigate the error",
1805
- }
1806
- assert sent_entry.to_id == "peer"
1807
- assert sent_entry.content == "investigate the error"
1808
- assert sent_entry.from_output_port_key == "out"
1809
- assert sent_entry.to_input_port_key == "in"
1810
- assert sent_entry.value_summary == "investigate the error"
1811
- assert received_entry.from_id == "child"
1812
- assert received_entry.content == "investigate the error"
1813
- assert received_entry.from_output_port_key == "out"
1814
- assert received_entry.to_input_port_key == "in"
1815
- assert received_entry.value_summary == "investigate the error"
1816
- assert sent_entry.message_id == received_entry.message_id
1817
- assert signal.payload == {
1818
- "message": {
1819
- "from": "child",
1820
- "content": "investigate the error",
1821
- "parts": [{"type": "text", "text": "investigate the error"}],
1822
- "history_recorded": True,
1823
- "message_id": sent_entry.message_id,
1824
- "from_output_port_key": "out",
1825
- "to_input_port_key": "in",
1826
- "port_type": "parts",
1827
- "value": [{"type": "text", "text": "investigate the error"}],
1828
- "value_summary": "investigate the error",
1829
- }
1830
- }
1831
- assert [event.data for event in events if event.type == EventType.NODE_MESSAGE] == [
1832
- {
1833
- "to_id": "peer",
1834
- "content": "investigate the error",
1835
- "message_id": sent_entry.message_id,
1836
- "from_output_port_key": "out",
1837
- "to_input_port_key": "in",
1838
- "port_type": "parts",
1839
- }
1840
- ]
1841
-
1842
-
1843
- def test_send_message_reports_error_when_target_is_not_in_contacts():
1844
- registry.reset()
1845
- _register_tab_leader()
1846
- child = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="child")
1847
- peer = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="peer")
1848
- registry.register(child)
1849
- registry.register(peer)
1850
-
1851
- try:
1852
- with pytest.raises(
1853
- ValueError,
1854
- match=r"Send failed: target `peer` is not connected from `out` to `in`\.",
1855
- ):
1856
- child.send_message(
1857
- target_ref="peer",
1858
- from_output_port_key="out",
1859
- to_input_port_key="in",
1860
- raw_parts=[{"type": "text", "text": "reply with the findings"}],
1861
- )
1862
- finally:
1863
- registry.reset()
1864
-
1865
-
1866
- def test_send_message_validates_target_before_image_capability():
1867
- registry.reset()
1868
- _register_tab_leader()
1869
- child = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="child")
1870
- peer = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="peer")
1871
- registry.register(child)
1872
- registry.register(peer)
1873
-
1874
- try:
1875
- with pytest.raises(
1876
- ValueError,
1877
- match=r"Send failed: target `peer` is not connected from `out` to `in`\.",
1878
- ):
1879
- child.send_message(
1880
- target_ref="peer",
1881
- from_output_port_key="out",
1882
- to_input_port_key="in",
1883
- raw_parts=[{"type": "image", "asset_id": "asset-1"}],
1884
- )
1885
- finally:
1886
- registry.reset()
1887
-
1888
-
1889
- def test_send_message_reports_error_when_target_lacks_input_image_support():
1890
- registry.reset()
1891
- _register_tab_leader()
1892
- child = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="child")
1893
- peer = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="peer")
1894
- registry.register(child)
1895
- registry.register(peer)
1896
- _add_agent_path(source_id="child", target_id="peer")
1897
-
1898
- try:
1899
- with pytest.raises(
1900
- ValueError,
1901
- match=r"Send failed: target `peer` does not support `input_image`\.",
1902
- ):
1903
- child.send_message(
1904
- target_ref="peer",
1905
- from_output_port_key="out",
1906
- to_input_port_key="in",
1907
- raw_parts=[{"type": "image", "asset_id": "asset-1"}],
1908
- )
1909
- finally:
1910
- registry.reset()
1911
-
1912
-
1913
- def test_inactive_leader_cannot_send_work_to_agent_nodes():
1914
- registry.reset()
1915
- leader = _register_tab_leader()
1916
- worker = Agent(
1917
- NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1", name="Worker"),
1918
- uuid="worker",
1919
- )
1920
- registry.register(worker)
1921
-
1922
- try:
1923
- with pytest.raises(
1924
- ValueError,
1925
- match=r"Activate this workflow before sending work to agent nodes\.",
1926
- ):
1927
- leader.send_message(
1928
- target_ref="worker",
1929
- raw_parts=[{"type": "text", "text": "start the task"}],
1930
- )
1931
- assert not any(
1932
- isinstance(entry, ReceivedMessage)
1933
- for entry in worker.get_history_snapshot()
1934
- )
1935
-
1936
- tab = workspace_store.get_tab("tab-1")
1937
- assert tab is not None
1938
- tab.activation_state = WorkflowActivationState.ACTIVE
1939
- workspace_store.upsert_tab(tab)
1940
-
1941
- result = json.loads(
1942
- leader.send_message(
1943
- target_ref="worker",
1944
- raw_parts=[{"type": "text", "text": "start the task"}],
1945
- )
1946
- )
1947
- assert result == {"status": "sent", "target_id": "worker"}
1948
- finally:
1949
- registry.reset()
1950
-
1951
-
1952
- def test_record_content_output_treats_target_like_text_as_plain_output(monkeypatch):
1953
- registry.reset()
1954
- assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1955
- registry.register(assistant)
1956
- events = []
1957
-
1958
- monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
1959
-
1960
- try:
1961
- assistant._record_content_output(
1962
- "@worker: do the follow-up task",
1963
- emitted_human_content=False,
1964
- )
1965
- finally:
1966
- registry.reset()
1967
-
1968
- history = assistant.get_history_snapshot()
1969
- assert isinstance(history[-1], AssistantText)
1970
- assert history[-1].content == "@worker: do the follow-up task"
1971
- assert not any(isinstance(entry, SentMessage) for entry in history)
1972
- assert any(
1973
- event.type == EventType.ASSISTANT_CONTENT
1974
- and event.data == {"content": "@worker: do the follow-up task"}
1975
- for event in events
1976
- )
1977
-
1978
-
1979
- def test_handle_tool_call_send_success_omits_toolcall_history(monkeypatch):
1980
- registry.reset()
1981
- _register_tab_leader()
1982
- child = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="child")
1983
- peer = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="peer")
1984
- registry.register(child)
1985
- registry.register(peer)
1986
- _add_agent_path(source_id="child", target_id="peer")
1987
-
1988
- try:
1989
- result = child._handle_tool_call(
1990
- "send",
1991
- {
1992
- "target": "peer",
1993
- "from_output_port_key": "out",
1994
- "to_input_port_key": "in",
1995
- "value": [{"type": "text", "text": "reply with the findings"}],
1996
- },
1997
- "call-send",
1998
- )
1999
- finally:
2000
- registry.reset()
2001
-
2002
- assert json.loads(result) == {
2003
- "status": "sent",
2004
- "target_id": "peer",
2005
- "from_output_port_key": "out",
2006
- "to_input_port_key": "in",
2007
- "port_type": "parts",
2008
- "value_summary": "reply with the findings",
2009
- }
2010
- assert not any(
2011
- isinstance(entry, ToolCall) and entry.tool_call_id == "call-send"
2012
- for entry in child.get_history_snapshot()
2013
- )
2014
- assert any(isinstance(entry, SentMessage) for entry in child.get_history_snapshot())
2015
-
2016
-
2017
- def test_handle_tool_call_send_failure_records_error_without_toolcall():
2018
- registry.reset()
2019
- _register_tab_leader()
2020
- child = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="child")
2021
- peer = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="peer")
2022
- registry.register(child)
2023
- registry.register(peer)
2024
-
2025
- try:
2026
- result = child._handle_tool_call(
2027
- "send",
2028
- {
2029
- "target": "peer",
2030
- "from_output_port_key": "out",
2031
- "to_input_port_key": "in",
2032
- "value": [{"type": "text", "text": "reply with the findings"}],
2033
- },
2034
- "call-send",
2035
- )
2036
- finally:
2037
- registry.reset()
2038
-
2039
- assert json.loads(result) == {
2040
- "error": "Send failed: target `peer` is not connected from `out` to `in`."
2041
- }
2042
- assert not any(
2043
- isinstance(entry, ToolCall) and entry.tool_call_id == "call-send"
2044
- for entry in child.get_history_snapshot()
2045
- )
2046
- assert any(
2047
- isinstance(entry, ErrorEntry)
2048
- and entry.content
2049
- == "Send failed: target `peer` is not connected from `out` to `in`."
2050
- for entry in child.get_history_snapshot()
2051
- )
2052
-
2053
-
2054
- def test_multiple_send_tool_calls_stop_after_first_failure(monkeypatch):
2055
- registry.reset()
2056
- _register_tab_leader()
2057
- child = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="child")
2058
- peer = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="peer")
2059
- helper = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="helper")
2060
- registry.register(child)
2061
- registry.register(peer)
2062
- registry.register(helper)
2063
- _add_agent_path(source_id="child", target_id="peer")
2064
-
2065
- wait_calls = 0
2066
- chat_calls = 0
2067
-
2068
- def fake_wait_for_input() -> None:
2069
- nonlocal wait_calls
2070
- wait_calls += 1
2071
- if wait_calls == 1:
2072
- child._append_history(ReceivedMessage(content="begin", from_id="human"))
2073
- child.set_state(AgentState.RUNNING, "received message from human")
2074
- return
2075
- child.request_termination("done")
2076
-
2077
- def fake_chat(
2078
- messages,
2079
- tools=None,
2080
- on_chunk=None,
2081
- register_interrupt=None,
2082
- role_name=None,
2083
- ):
2084
- nonlocal chat_calls
2085
- chat_calls += 1
2086
- if chat_calls == 1:
2087
- return LLMResponse(
2088
- tool_calls=[
2089
- ToolCallResult(
2090
- id="call-send-1",
2091
- name="send",
2092
- arguments={
2093
- "target": "peer",
2094
- "from_output_port_key": "out",
2095
- "to_input_port_key": "in",
2096
- "value": [{"type": "text", "text": "first"}],
2097
- },
2098
- ),
2099
- ToolCallResult(
2100
- id="call-send-2",
2101
- name="send",
2102
- arguments={
2103
- "target": "helper",
2104
- "from_output_port_key": "out",
2105
- "to_input_port_key": "in",
2106
- "value": [{"type": "text", "text": "second"}],
2107
- },
2108
- ),
2109
- ToolCallResult(
2110
- id="call-send-3",
2111
- name="send",
2112
- arguments={
2113
- "target": "peer",
2114
- "from_output_port_key": "out",
2115
- "to_input_port_key": "in",
2116
- "value": [{"type": "text", "text": "third"}],
2117
- },
2118
- ),
2119
- ]
2120
- )
2121
- child.request_termination("done")
2122
- return LLMResponse()
2123
-
2124
- monkeypatch.setattr(child, "_wait_for_input", fake_wait_for_input)
2125
- monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
2126
-
2127
- try:
2128
- child._run()
2129
- finally:
2130
- registry.reset()
2131
-
2132
- sent_entries = [
2133
- entry
2134
- for entry in child.get_history_snapshot()
2135
- if isinstance(entry, SentMessage)
2136
- ]
2137
- error_entries = [
2138
- entry for entry in child.get_history_snapshot() if isinstance(entry, ErrorEntry)
2139
- ]
2140
-
2141
- assert [entry.content for entry in sent_entries] == ["first"]
2142
- assert [
2143
- entry.content
2144
- for entry in peer.get_history_snapshot()
2145
- if isinstance(entry, ReceivedMessage)
2146
- ] == ["first"]
2147
- assert helper._wake_queue.empty()
2148
- assert any(
2149
- entry.content
2150
- == "Send failed: target `helper` is not connected from `out` to `in`."
2151
- for entry in error_entries
2152
- )
2153
-
2154
-
2155
- def test_build_messages_replays_sent_messages_as_message_to_context(monkeypatch):
2156
- monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2157
-
2158
- agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2159
- agent._append_history(ReceivedMessage(content="begin", from_id="human"))
2160
- agent._append_history(SentMessage(content="to peer", to_id="peer"))
2161
- agent._append_history(AssistantText(content="final answer"))
2162
-
2163
- messages = agent._build_messages()
2164
-
2165
- assert messages == [
2166
- {"role": "system", "content": messages[0]["content"]},
2167
- {"role": "user", "content": '<message from="human">begin</message>'},
2168
- {"role": "assistant", "content": '<message to="peer">to peer</message>'},
2169
- {"role": "assistant", "content": "final answer"},
2170
- {
2171
- "role": "user",
2172
- "content": "<system>Runtime post prompt:\n- Plain content is never delivered to other agents.\n- To send through a workflow path, use `send` with one target, source output port, target input port, and a value matching that port type.\n- Use `contacts` to inspect the current output-port paths you can send through.\n- `@target:` or any other `@name:` text inside normal content is just text. It does not send anything.\n- If there is no unfinished TODO and the task is finished with no immediate next action, call `idle`.</system>",
2173
- },
2174
- ]
2175
-
2176
-
2177
- def test_context_preflight_prefers_usage_baseline_and_estimates_only_new_tail(
2178
- monkeypatch,
2179
- ):
2180
- monkeypatch.setattr(
2181
- "flowent.agent.get_settings",
2182
- lambda: Settings(
2183
- providers=[
2184
- ProviderConfig(
2185
- id="provider-1",
2186
- name="Primary",
2187
- type="openai_responses",
2188
- base_url="https://api.example.com/v1",
2189
- api_key="secret",
2190
- )
2191
- ],
2192
- model=ModelSettings(
2193
- active_provider_id="provider-1",
2194
- active_model="gpt-5.2",
2195
- auto_compact_token_limit=48_000,
2196
- ),
2197
- ),
2198
- )
2199
-
2200
- agent = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
2201
- agent._append_history(ReceivedMessage(content="first", from_id="human"))
2202
-
2203
- baseline_context = agent._build_prepared_llm_context()
2204
- agent._record_context_token_usage_baseline(
2205
- prepared_context=baseline_context,
2206
- usage=LLMUsage(
2207
- total_tokens=4_200,
2208
- input_tokens=3_000,
2209
- output_tokens=1_200,
2210
- ),
2211
- )
2212
-
2213
- agent._append_history(
2214
- ToolCall(
2215
- tool_name="read",
2216
- tool_call_id="call-read",
2217
- arguments={"path": "README.md"},
2218
- result="done",
2219
- )
2220
- )
2221
- next_context = agent._build_prepared_llm_context()
2222
- preflight = agent._compute_context_preflight(next_context)
2223
-
2224
- expected_tail_tokens = agent._estimate_input_tokens(
2225
- next_context.execution_context_messages[
2226
- len(baseline_context.execution_context_messages) :
2227
- ]
2228
- )
2229
-
2230
- assert preflight.estimated_total_tokens == 4_200 + expected_tail_tokens
2231
- assert preflight.auto_compact_token_limit == 48_000
2232
- assert preflight.context_window_tokens == 128_000
2233
-
2234
-
2235
- def test_context_preflight_bootstraps_again_when_runtime_tail_changes(monkeypatch):
2236
- monkeypatch.setattr(
2237
- "flowent.agent.get_settings",
2238
- lambda: Settings(
2239
- providers=[
2240
- ProviderConfig(
2241
- id="provider-1",
2242
- name="Primary",
2243
- type="openai_responses",
2244
- base_url="https://api.example.com/v1",
2245
- api_key="secret",
2246
- )
2247
- ],
2248
- model=ModelSettings(
2249
- active_provider_id="provider-1",
2250
- active_model="gpt-5.2",
2251
- auto_compact_token_limit=48_000,
2252
- ),
2253
- ),
2254
- )
2255
-
2256
- agent = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
2257
- agent._append_history(ReceivedMessage(content="first", from_id="human"))
2258
-
2259
- baseline_context = agent._build_prepared_llm_context()
2260
- agent._record_context_token_usage_baseline(
2261
- prepared_context=baseline_context,
2262
- usage=LLMUsage(total_tokens=4_200),
2263
- )
2264
-
2265
- agent.set_todos([TodoItem(text="Inspect files")])
2266
- next_context = agent._build_prepared_llm_context()
2267
- preflight = agent._compute_context_preflight(next_context)
2268
-
2269
- assert preflight.estimated_total_tokens == agent._estimate_input_tokens(
2270
- next_context.messages
2271
- )
2272
-
2273
-
2274
- def test_prepare_messages_for_llm_uses_token_limit_even_without_context_window(
2275
- monkeypatch,
2276
- ):
2277
- monkeypatch.setattr(
2278
- "flowent.agent.get_settings",
2279
- lambda: Settings(
2280
- providers=[
2281
- ProviderConfig(
2282
- id="provider-1",
2283
- name="Primary",
2284
- type="openai_compatible",
2285
- base_url="https://api.example.com/v1",
2286
- api_key="secret",
2287
- )
2288
- ],
2289
- model=ModelSettings(
2290
- active_provider_id="provider-1",
2291
- active_model="custom-model",
2292
- auto_compact_token_limit=1,
2293
- ),
2294
- ),
2295
- )
2296
-
2297
- agent = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
2298
- agent._append_history(ReceivedMessage(content="hello", from_id="human"))
2299
- compact_calls: list[str] = []
2300
-
2301
- monkeypatch.setattr(
2302
- agent,
2303
- "_compact_execution_context",
2304
- lambda focus=None: compact_calls.append("compact") or "",
2305
- )
2306
-
2307
- prepared_context = agent._prepare_messages_for_llm()
2308
-
2309
- assert compact_calls == ["compact"]
2310
- assert len(prepared_context.messages) > 0
2311
-
2312
-
2313
- def test_idle_is_blocked_when_fresh_input_has_no_progress(monkeypatch):
2314
- monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2315
- agent = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
2316
- agent.set_state(AgentState.RUNNING, "processing")
2317
- agent._turn_started_with_pending_input = True
2318
- agent._turn_made_progress = False
2319
-
2320
- idle_result = agent.request_idle()
2321
- messages = agent._build_messages()
2322
-
2323
- reminder = (
2324
- "<system>Idle reminder: you received a new message this turn, but this "
2325
- "response did not send a reply, call `send`, or use any non-idle "
2326
- "tool. Do not call `idle` yet. First reply to the Human, dispatch/"
2327
- "delegate work, or take another concrete step.</system>"
2328
- )
2329
-
2330
- assert idle_result == ""
2331
- assert agent.state == AgentState.RUNNING
2332
- assert any(msg.get("content") == reminder for msg in messages)
2333
-
2334
-
2335
- def test_idle_is_blocked_when_first_todo_is_actionable(monkeypatch):
2336
- monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2337
- agent = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
2338
- agent.set_state(AgentState.RUNNING, "processing")
2339
- agent.set_todos(
2340
- [
2341
- TodoItem(text="Forward pyproject summary to Project Synthesizer"),
2342
- TodoItem(text="Wait for final synthesis"),
2343
- ]
2344
- )
2345
-
2346
- idle_result = agent.request_idle()
2347
- messages = agent._build_messages()
2348
-
2349
- reminder = (
2350
- "<system>Idle reminder: your first remaining TODO still looks actionable "
2351
- "(`Forward pyproject summary to Project Synthesizer`). Do that next, or "
2352
- "update the TODO list so the first remaining item is the actual waiting "
2353
- "step, before calling `idle`.</system>"
2354
- )
2355
-
2356
- assert idle_result == ""
2357
- assert agent.state == AgentState.RUNNING
2358
- assert any(msg.get("content") == reminder for msg in messages)
2359
-
2360
-
2361
- def test_build_messages_appends_runtime_todo_context_without_history_entry(monkeypatch):
2362
- monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2363
-
2364
- agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2365
- agent._append_history(ReceivedMessage(content="begin", from_id="human"))
2366
- agent.set_todos([TodoItem(text="Inspect files"), TodoItem(text="Report results")])
2367
-
2368
- messages = agent._build_messages()
2369
- history = agent.get_history_snapshot()
2370
-
2371
- received_entries = [
2372
- entry for entry in history if isinstance(entry, ReceivedMessage)
2373
- ]
2374
- assert len(received_entries) == 1
2375
- assert messages == [
2376
- {"role": "system", "content": messages[0]["content"]},
2377
- {"role": "user", "content": '<message from="human">begin</message>'},
2378
- {
2379
- "role": "user",
2380
- "content": "<system>Current TODO list:\n - Inspect files\n - Report results</system>",
2381
- },
2382
- {
2383
- "role": "user",
2384
- "content": "<system>Runtime post prompt:\n- Plain content is never delivered to other agents.\n- To send through a workflow path, use `send` with one target, source output port, target input port, and a value matching that port type.\n- Use `contacts` to inspect the current output-port paths you can send through.\n- `@target:` or any other `@name:` text inside normal content is just text. It does not send anything.\n- If the TODO list is not complete yet, use `todo` to replace it with the latest remaining items.</system>",
2385
- },
2386
- ]
2387
-
2388
-
2389
- def test_build_messages_appends_runtime_post_prompt_and_idle_guidance(monkeypatch):
2390
- monkeypatch.setattr(
2391
- "flowent.agent.get_settings",
2392
- lambda: Settings(custom_post_prompt="Append this after history."),
2393
- )
2394
-
2395
- agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2396
- agent._append_history(ReceivedMessage(content="begin", from_id="human"))
2397
- agent.set_todos([TodoItem(text="Inspect files")])
2398
- agent.set_todos([])
2399
-
2400
- messages = agent._build_messages()
2401
- history = agent.get_history_snapshot()
2402
-
2403
- received_entries = [
2404
- entry for entry in history if isinstance(entry, ReceivedMessage)
2405
- ]
2406
- assert len(received_entries) == 1
2407
- assert messages == [
2408
- {"role": "system", "content": messages[0]["content"]},
2409
- {"role": "user", "content": '<message from="human">begin</message>'},
2410
- {
2411
- "role": "user",
2412
- "content": "<system>Runtime post prompt:\n- Plain content is never delivered to other agents.\n- To send through a workflow path, use `send` with one target, source output port, target input port, and a value matching that port type.\n- Use `contacts` to inspect the current output-port paths you can send through.\n- `@target:` or any other `@name:` text inside normal content is just text. It does not send anything.\n- If there is no unfinished TODO and the task is finished with no immediate next action, call `idle`.</system>",
2413
- },
2414
- {
2415
- "role": "user",
2416
- "content": "<system>Append this after history.</system>",
2417
- },
2418
- ]
2419
-
2420
-
2421
- def test_build_messages_warns_about_newly_created_agents_waiting_for_first_task(
2422
- monkeypatch,
2423
- ):
2424
- monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2425
-
2426
- agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2427
- agent._append_history(ReceivedMessage(content="begin", from_id="human"))
2428
- agent._append_history(
2429
- ToolCall(
2430
- tool_name="create_agent",
2431
- tool_call_id="call-create-agent",
2432
- arguments={
2433
- "role_name": "Worker",
2434
- "name": "Directory Worker",
2435
- },
2436
- result=json.dumps(
2437
- {
2438
- "id": "12345678-aaaa-bbbb-cccc-ddddeeeeffff",
2439
- "config": {
2440
- "node_type": "agent",
2441
- "role_name": "Worker",
2442
- "tab_id": "tab-1",
2443
- "name": "Directory Worker",
2444
- "tools": ["idle", "sleep", "todo", "contacts", "read"],
2445
- "write_dirs": [],
2446
- "allow_network": False,
2447
- },
2448
- "state": "initializing",
2449
- "todos": [],
2450
- "history": [],
2451
- "position": None,
2452
- "created_at": 1.0,
2453
- "updated_at": 1.0,
2454
- }
2455
- ),
2456
- )
2457
- )
2458
-
2459
- messages = agent._build_messages()
2460
-
2461
- assert messages == [
2462
- {"role": "system", "content": messages[0]["content"]},
2463
- {"role": "user", "content": '<message from="human">begin</message>'},
2464
- {
2465
- "role": "assistant",
2466
- "tool_calls": [
2467
- {
2468
- "id": "call-create-agent",
2469
- "type": "function",
2470
- "function": {
2471
- "name": "create_agent",
2472
- "arguments": '{"role_name": "Worker", "name": "Directory Worker"}',
2473
- },
2474
- }
2475
- ],
2476
- },
2477
- {
2478
- "role": "tool",
2479
- "tool_call_id": "call-create-agent",
2480
- "content": '{"id": "12345678-aaaa-bbbb-cccc-ddddeeeeffff", "config": {"node_type": "agent", "role_name": "Worker", "tab_id": "tab-1", "name": "Directory Worker", "tools": ["idle", "sleep", "todo", "contacts", "read"], "write_dirs": [], "allow_network": false}, "state": "initializing", "todos": [], "history": [], "position": null, "created_at": 1.0, "updated_at": 1.0}',
2481
- },
2482
- {
2483
- "role": "user",
2484
- "content": "<system>Runtime post prompt:\n- Plain content is never delivered to other agents.\n- To send through a workflow path, use `send` with one target, source output port, target input port, and a value matching that port type.\n- Use `contacts` to inspect the current output-port paths you can send through.\n- `@target:` or any other `@name:` text inside normal content is just text. It does not send anything.\n- Newly created agents still waiting for their first task: Directory Worker (`12345678`).\n- `create_agent` only adds a new agent node to the current workflow. It does not start work by itself.\n- Before calling `idle`, dispatch each waiting agent a concrete first task with `send`.</system>",
2485
- },
2486
- ]
2487
-
2488
-
2489
- def test_build_messages_uses_role_name_when_created_agent_has_no_explicit_name(
2490
- monkeypatch,
2491
- ):
2492
- monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2493
-
2494
- agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2495
- agent._append_history(ReceivedMessage(content="begin", from_id="human"))
2496
- agent._append_history(
2497
- ToolCall(
2498
- tool_name="create_agent",
2499
- tool_call_id="call-create-agent",
2500
- arguments={"role_name": "Worker"},
2501
- result=json.dumps(
2502
- {
2503
- "id": "12345678-aaaa-bbbb-cccc-ddddeeeeffff",
2504
- "config": {
2505
- "node_type": "agent",
2506
- "role_name": "Worker",
2507
- "tab_id": "tab-1",
2508
- "name": None,
2509
- "tools": ["idle", "sleep", "todo", "contacts", "read"],
2510
- "write_dirs": [],
2511
- "allow_network": False,
2512
- },
2513
- "state": "initializing",
2514
- "todos": [],
2515
- "history": [],
2516
- "position": None,
2517
- "created_at": 1.0,
2518
- "updated_at": 1.0,
2519
- }
2520
- ),
2521
- )
2522
- )
2523
-
2524
- messages = agent._build_messages()
2525
-
2526
- assert messages == [
2527
- {"role": "system", "content": messages[0]["content"]},
2528
- {"role": "user", "content": '<message from="human">begin</message>'},
2529
- {
2530
- "role": "assistant",
2531
- "tool_calls": [
2532
- {
2533
- "id": "call-create-agent",
2534
- "type": "function",
2535
- "function": {
2536
- "name": "create_agent",
2537
- "arguments": '{"role_name": "Worker"}',
2538
- },
2539
- }
2540
- ],
2541
- },
2542
- {
2543
- "role": "tool",
2544
- "tool_call_id": "call-create-agent",
2545
- "content": '{"id": "12345678-aaaa-bbbb-cccc-ddddeeeeffff", "config": {"node_type": "agent", "role_name": "Worker", "tab_id": "tab-1", "name": null, "tools": ["idle", "sleep", "todo", "contacts", "read"], "write_dirs": [], "allow_network": false}, "state": "initializing", "todos": [], "history": [], "position": null, "created_at": 1.0, "updated_at": 1.0}',
2546
- },
2547
- {
2548
- "role": "user",
2549
- "content": "<system>Runtime post prompt:\n- Plain content is never delivered to other agents.\n- To send through a workflow path, use `send` with one target, source output port, target input port, and a value matching that port type.\n- Use `contacts` to inspect the current output-port paths you can send through.\n- `@target:` or any other `@name:` text inside normal content is just text. It does not send anything.\n- Newly created agents still waiting for their first task: Worker (`12345678`).\n- `create_agent` only adds a new agent node to the current workflow. It does not start work by itself.\n- Before calling `idle`, dispatch each waiting agent a concrete first task with `send`.</system>",
2550
- },
2551
- ]
2552
-
2553
-
2554
- def test_build_messages_clears_new_agent_warning_after_first_sent_message(monkeypatch):
2555
- monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2556
-
2557
- agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2558
- agent._append_history(ReceivedMessage(content="begin", from_id="human"))
2559
- agent._append_history(
2560
- ToolCall(
2561
- tool_name="create_agent",
2562
- tool_call_id="call-create-agent",
2563
- arguments={
2564
- "role_name": "Worker",
2565
- "name": "Directory Worker",
2566
- },
2567
- result=json.dumps(
2568
- {
2569
- "id": "12345678-aaaa-bbbb-cccc-ddddeeeeffff",
2570
- "config": {
2571
- "node_type": "agent",
2572
- "role_name": "Worker",
2573
- "tab_id": "tab-1",
2574
- "name": "Directory Worker",
2575
- "tools": ["idle", "sleep", "todo", "contacts", "read"],
2576
- "write_dirs": [],
2577
- "allow_network": False,
2578
- },
2579
- "state": "initializing",
2580
- "todos": [],
2581
- "history": [],
2582
- "position": None,
2583
- "created_at": 1.0,
2584
- "updated_at": 1.0,
2585
- }
2586
- ),
2587
- )
2588
- )
2589
- agent._append_history(
2590
- SentMessage(
2591
- content="inspect the current directory",
2592
- to_id="12345678-aaaa-bbbb-cccc-ddddeeeeffff",
2593
- )
2594
- )
2595
-
2596
- messages = agent._build_messages()
2597
-
2598
- assert messages == [
2599
- {"role": "system", "content": messages[0]["content"]},
2600
- {"role": "user", "content": '<message from="human">begin</message>'},
2601
- {
2602
- "role": "assistant",
2603
- "tool_calls": [
2604
- {
2605
- "id": "call-create-agent",
2606
- "type": "function",
2607
- "function": {
2608
- "name": "create_agent",
2609
- "arguments": '{"role_name": "Worker", "name": "Directory Worker"}',
2610
- },
2611
- }
2612
- ],
2613
- },
2614
- {
2615
- "role": "tool",
2616
- "tool_call_id": "call-create-agent",
2617
- "content": '{"id": "12345678-aaaa-bbbb-cccc-ddddeeeeffff", "config": {"node_type": "agent", "role_name": "Worker", "tab_id": "tab-1", "name": "Directory Worker", "tools": ["idle", "sleep", "todo", "contacts", "read"], "write_dirs": [], "allow_network": false}, "state": "initializing", "todos": [], "history": [], "position": null, "created_at": 1.0, "updated_at": 1.0}',
2618
- },
2619
- {
2620
- "role": "assistant",
2621
- "content": '<message to="12345678-aaaa-bbbb-cccc-ddddeeeeffff">inspect the current directory</message>',
2622
- },
2623
- {
2624
- "role": "user",
2625
- "content": "<system>Runtime post prompt:\n- Plain content is never delivered to other agents.\n- To send through a workflow path, use `send` with one target, source output port, target input port, and a value matching that port type.\n- Use `contacts` to inspect the current output-port paths you can send through.\n- `@target:` or any other `@name:` text inside normal content is just text. It does not send anything.\n- If there is no unfinished TODO and the task is finished with no immediate next action, call `idle`.</system>",
2626
- },
2627
- ]
2628
-
2629
-
2630
- def test_build_messages_keeps_sleep_tool_results_in_context(monkeypatch):
2631
- monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2632
-
2633
- agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2634
- agent._append_history(
2635
- ReceivedMessage(content="pause before continuing", from_id="human")
2636
- )
2637
- agent._append_history(
2638
- ToolCall(
2639
- tool_name="sleep",
2640
- tool_call_id="call-sleep",
2641
- arguments={"seconds": 0.5},
2642
- result="slept 0.50s",
2643
- )
2644
- )
2645
-
2646
- messages = agent._build_messages()
2647
-
2648
- assert any(
2649
- msg.get("role") == "assistant"
2650
- and msg.get("tool_calls")
2651
- == [
2652
- {
2653
- "id": "call-sleep",
2654
- "type": "function",
2655
- "function": {
2656
- "name": "sleep",
2657
- "arguments": '{"seconds": 0.5}',
2658
- },
2659
- }
2660
- ]
2661
- for msg in messages
2662
- )
2663
- assert any(
2664
- msg.get("role") == "tool"
2665
- and msg.get("tool_call_id") == "call-sleep"
2666
- and msg.get("content") == "slept 0.50s"
2667
- for msg in messages
2668
- )
2669
-
2670
-
2671
- def test_build_messages_keeps_idle_tool_results_in_context(monkeypatch):
2672
- monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2673
-
2674
- agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2675
- agent._append_history(ReceivedMessage(content="resume after wait", from_id="human"))
2676
- agent._append_history(
2677
- ToolCall(
2678
- tool_name="idle",
2679
- tool_call_id="call-idle",
2680
- arguments={},
2681
- result="idle 1.25s",
2682
- )
2683
- )
2684
-
2685
- messages = agent._build_messages()
2686
-
2687
- assert any(
2688
- msg.get("role") == "assistant"
2689
- and msg.get("tool_calls")
2690
- == [
2691
- {
2692
- "id": "call-idle",
2693
- "type": "function",
2694
- "function": {
2695
- "name": "idle",
2696
- "arguments": "{}",
2697
- },
2698
- }
2699
- ]
2700
- for msg in messages
2701
- )
2702
- assert any(
2703
- msg.get("role") == "tool"
2704
- and msg.get("tool_call_id") == "call-idle"
2705
- and msg.get("content") == "idle 1.25s"
2706
- for msg in messages
2707
- )
2708
-
2709
-
2710
- def test_build_messages_keeps_error_entries_in_context(monkeypatch):
2711
- monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2712
-
2713
- agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2714
- agent._append_history(ReceivedMessage(content="begin", from_id="human"))
2715
- agent._append_history(ErrorEntry(content="RuntimeError: boom"))
2716
-
2717
- messages = agent._build_messages()
2718
-
2719
- assert any(
2720
- msg.get("role") == "user"
2721
- and msg.get("content")
2722
- == "<system>Previous runtime error:\nRuntimeError: boom</system>"
2723
- for msg in messages
2724
- )
2725
-
2726
-
2727
- def test_build_messages_normalizes_legacy_traceback_errors(monkeypatch):
2728
- monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2729
-
2730
- agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2731
- agent._append_history(
2732
- ErrorEntry(
2733
- content=(
2734
- "RuntimeError: boom\n\n"
2735
- "Traceback (most recent call last):\n"
2736
- ' File "/tmp/example.py", line 1, in <module>\n'
2737
- "RuntimeError: boom"
2738
- )
2739
- )
2740
- )
2741
-
2742
- messages = agent._build_messages()
2743
-
2744
- assert any(
2745
- msg.get("role") == "user"
2746
- and msg.get("content")
2747
- == "<system>Previous runtime error:\nRuntimeError: boom</system>"
2748
- for msg in messages
2749
- )
2750
- assert not any("Traceback" in str(msg.get("content")) for msg in messages)
2751
-
2752
-
2753
- def test_build_messages_normalizes_legacy_html_errors(monkeypatch):
2754
- monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2755
-
2756
- agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2757
- agent._append_history(
2758
- ErrorEntry(content="<html><body><pre>Internal Server Error</pre></body></html>")
2759
- )
2760
-
2761
- messages = agent._build_messages()
2762
- serialized = json.dumps(messages)
2763
-
2764
- assert "Upstream returned an unexpected error response" in serialized
2765
- assert "<html" not in serialized
2766
- assert "<pre>" not in serialized
2767
-
2768
-
2769
- def test_build_messages_keeps_provider_error_text_in_context(monkeypatch):
2770
- monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2771
-
2772
- agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2773
- agent._append_history(ErrorEntry(content="LLMProviderError: provider offline"))
2774
-
2775
- messages = agent._build_messages()
2776
-
2777
- assert any(
2778
- msg.get("role") == "user"
2779
- and msg.get("content")
2780
- == (
2781
- "<system>Previous runtime error:\n"
2782
- "LLMProviderError: provider offline</system>"
2783
- )
2784
- for msg in messages
2785
- )
2786
-
2787
-
2788
- def test_unhandled_runtime_error_history_omits_traceback(monkeypatch):
2789
- agent = Agent(NodeConfig(node_type=NodeType.AGENT, allow_network=True))
2790
- wait_calls = 0
2791
- chat_calls = 0
2792
-
2793
- def fake_wait_for_input() -> None:
2794
- nonlocal wait_calls
2795
- wait_calls += 1
2796
- if wait_calls == 1:
2797
- agent._append_history(ReceivedMessage(content="start", from_id="human"))
2798
- agent.set_state(AgentState.RUNNING, "received message from human")
2799
- return
2800
- agent.request_termination("done")
2801
-
2802
- def fake_chat(
2803
- messages,
2804
- tools=None,
2805
- on_chunk=None,
2806
- register_interrupt=None,
2807
- role_name=None,
2808
- ):
2809
- nonlocal chat_calls
2810
- chat_calls += 1
2811
- raise RuntimeError("boom")
2812
-
2813
- monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
2814
- monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
2815
-
2816
- agent._run()
2817
-
2818
- assert chat_calls == 1
2819
- assert any(
2820
- isinstance(entry, ErrorEntry) and entry.content == "RuntimeError: boom"
2821
- for entry in agent.get_history_snapshot()
2822
- )
2823
- assert not any(
2824
- isinstance(entry, ErrorEntry) and "traceback" in entry.content.lower()
2825
- for entry in agent.get_history_snapshot()
2826
- )
2827
-
2828
-
2829
- def test_assistant_emits_human_content_for_plain_text_with_target_like_prefix(
2830
- monkeypatch,
2831
- ):
2832
- registry.reset()
2833
- _register_tab_leader()
2834
- assistant = Agent(
2835
- NodeConfig(node_type=NodeType.ASSISTANT),
2836
- uuid="assistant",
2837
- )
2838
- registry.register(assistant)
2839
- events = []
2840
- responses = iter(
2841
- [LLMResponse(content="@leader: investigate the error"), LLMResponse()]
2842
- )
2843
-
2844
- def fake_wait_for_input() -> None:
2845
- assistant._append_history(
2846
- ReceivedMessage(content="please investigate", from_id="human")
2847
- )
2848
- assistant.set_state(AgentState.RUNNING, "received message from human")
2849
-
2850
- def fake_chat(
2851
- messages,
2852
- tools=None,
2853
- on_chunk=None,
2854
- register_interrupt=None,
2855
- role_name=None,
2856
- ):
2857
- response = next(responses)
2858
- if response.content and on_chunk is not None:
2859
- on_chunk("content", response.content)
2860
- if response.content is None:
2861
- assistant.request_termination("done")
2862
- return response
2863
-
2864
- monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
2865
- monkeypatch.setattr(assistant, "_wait_for_input", fake_wait_for_input)
2866
- monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
2867
-
2868
- try:
2869
- assistant._run()
2870
- finally:
2871
- registry.reset()
2872
-
2873
- assert any(event.type == EventType.ASSISTANT_CONTENT for event in events)
2874
- assert not any(
2875
- isinstance(entry, SentMessage) for entry in assistant.get_history_snapshot()
2876
- )
2877
- assert any(
2878
- isinstance(entry, AssistantText)
2879
- and entry.content == "@leader: investigate the error"
2880
- for entry in assistant.get_history_snapshot()
2881
- )
2882
-
2883
-
2884
- def test_idle_tool_records_wakeup_message_as_new_input_block(monkeypatch):
2885
- agent = Agent(
2886
- NodeConfig(node_type=NodeType.AGENT, tools=["idle"], allow_network=True)
2887
- )
2888
- wait_calls = 0
2889
- llm_messages: list[list[dict]] = []
2890
- responses = iter(
2891
- [
2892
- LLMResponse(
2893
- tool_calls=[
2894
- ToolCallResult(
2895
- id="call-idle",
2896
- name="idle",
2897
- arguments={},
2898
- )
2899
- ]
2900
- ),
2901
- LLMResponse(),
2902
- ]
2903
- )
2904
-
2905
- def fake_wait_for_input() -> None:
2906
- nonlocal wait_calls
2907
- wait_calls += 1
2908
- agent._append_history(
2909
- ReceivedMessage(content="start waiting", from_id="tester")
2910
- )
2911
- agent.set_state(AgentState.RUNNING, "received message from tester")
2912
-
2913
- def fake_chat(
2914
- messages,
2915
- tools=None,
2916
- on_chunk=None,
2917
- register_interrupt=None,
2918
- role_name=None,
2919
- ):
2920
- llm_messages.append(messages)
2921
- if len(llm_messages) == 1:
2922
- timer = threading.Timer(
2923
- 0.01,
2924
- lambda: agent.enqueue_message(
2925
- Message(
2926
- from_id="human",
2927
- to_id=agent.uuid,
2928
- content="wake up now",
2929
- )
2930
- ),
2931
- )
2932
- timer.start()
2933
- if len(llm_messages) == 2:
2934
- agent.request_termination("done")
2935
- return next(responses)
2936
-
2937
- monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
2938
- monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
2939
-
2940
- agent._run()
2941
-
2942
- assert wait_calls == 1
2943
- assert agent.state == AgentState.TERMINATED
2944
- second_round = llm_messages[1]
2945
- assert any(
2946
- msg.get("role") == "assistant"
2947
- and msg.get("tool_calls")
2948
- == [
2949
- {
2950
- "id": "call-idle",
2951
- "type": "function",
2952
- "function": {
2953
- "name": "idle",
2954
- "arguments": "{}",
2955
- },
2956
- }
2957
- ]
2958
- for msg in second_round
2959
- )
2960
- assert any(
2961
- msg.get("role") == "tool"
2962
- and msg.get("tool_call_id") == "call-idle"
2963
- and isinstance(msg.get("content"), str)
2964
- and msg.get("content", "").startswith("idle ")
2965
- for msg in second_round
2966
- )
2967
- assert any(
2968
- msg.get("role") == "user"
2969
- and msg.get("content") == '<message from="human">wake up now</message>'
2970
- for msg in second_round
2971
- )
2972
- assert any(
2973
- isinstance(entry, ToolCall)
2974
- and entry.tool_name == "idle"
2975
- and isinstance(entry.result, str)
2976
- and entry.result.startswith("idle ")
2977
- for entry in agent.get_history_snapshot()
2978
- )
2979
-
2980
-
2981
- def test_agent_contextualizes_plain_loguru_calls(monkeypatch):
2982
- agent = Agent(
2983
- NodeConfig(node_type=NodeType.AGENT, allow_network=True),
2984
- uuid="agent-z",
2985
- )
2986
- captured: list[tuple[str, str | None]] = []
2987
- sink_id = logger.add(
2988
- lambda message: captured.append(
2989
- (message.record["message"], message.record["extra"].get("agent_id"))
2990
- )
2991
- )
2992
-
2993
- def fake_wait_for_input() -> None:
2994
- agent._append_history(ReceivedMessage(content="do the task", from_id="tester"))
2995
- agent.set_state(AgentState.RUNNING, "received message from tester")
2996
-
2997
- def fake_chat(
2998
- messages,
2999
- tools=None,
3000
- on_chunk=None,
3001
- register_interrupt=None,
3002
- role_name=None,
3003
- ):
3004
- logger.info("plain log inside agent")
3005
- agent.request_termination("done")
3006
- return LLMResponse()
3007
-
3008
- monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
3009
- monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
3010
-
3011
- try:
3012
- agent._run()
3013
- finally:
3014
- logger.remove(sink_id)
3015
-
3016
- assert ("plain log inside agent", "agent-z") in captured
3017
-
3018
-
3019
- def test_agent_denies_tool_call_before_edit_execute(monkeypatch, tmp_path):
3020
- agent = Agent(
3021
- NodeConfig(node_type=NodeType.AGENT, tools=["edit"]),
3022
- uuid="agent-security",
3023
- )
3024
-
3025
- def fail_execute(*_args, **_kwargs):
3026
- raise AssertionError("edit execute should not be called")
3027
-
3028
- monkeypatch.setattr("flowent.tools.edit.EditTool.execute", fail_execute)
3029
-
3030
- result = agent._handle_tool_call(
3031
- "edit",
3032
- {
3033
- "path": str(tmp_path / "blocked.txt"),
3034
- "edits": [
3035
- {
3036
- "start_line": 1,
3037
- "end_line": 1,
3038
- "new_content": "hello\n",
3039
- }
3040
- ],
3041
- },
3042
- "call-edit",
3043
- )
3044
-
3045
- assert result == json.dumps({"error": "Write access is disabled for this workflow"})
3046
- assert isinstance(agent.history[-1], ToolCall)
3047
- assert agent.history[-1].result == result
3048
-
3049
-
3050
- def test_handle_tool_call_emits_streaming_tool_result_deltas(monkeypatch):
3051
- agent = Agent(
3052
- NodeConfig(node_type=NodeType.AGENT, tools=["streaming_tool"]),
3053
- uuid="agent-stream",
3054
- )
3055
- events = []
3056
-
3057
- class FakeTool:
3058
- def execute(self, agent, args, **kwargs):
3059
- on_output = kwargs.get("on_output")
3060
- assert on_output is not None
3061
- on_output("chunk 1\n")
3062
- on_output("chunk 2\n")
3063
- return json.dumps({"status": "done"})
3064
-
3065
- class FakeRegistry:
3066
- def get(self, name):
3067
- if name == "streaming_tool":
3068
- return FakeTool()
3069
- return None
3070
-
3071
- monkeypatch.setattr("flowent.agent._get_tool_registry", lambda: FakeRegistry())
3072
- monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
3073
-
3074
- result = agent._handle_tool_call("streaming_tool", {}, "call-stream")
3075
-
3076
- assert result == json.dumps({"status": "done"})
3077
- assert isinstance(agent.history[-1], ToolCall)
3078
- assert agent.history[-1].tool_call_id == "call-stream"
3079
- assert agent.history[-1].result == result
3080
- assert agent.history[-1].streaming is False
3081
- assert [
3082
- event.data["text"]
3083
- for event in events
3084
- if event.type == EventType.HISTORY_ENTRY_DELTA
3085
- ] == [
3086
- "chunk 1\n",
3087
- "chunk 2\n",
3088
- ]