flowent 0.0.1 → 0.0.5

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 (484) hide show
  1. package/README.md +20 -9
  2. package/backend/.python-version +1 -0
  3. package/backend/README.md +74 -0
  4. package/backend/pyproject.toml +58 -0
  5. package/backend/src/flowent/__init__.py +3 -0
  6. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  7. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/access.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/assistant_commands.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/config.cpython-313.pyc +0 -0
  13. package/backend/src/flowent/__pycache__/events.cpython-313.pyc +0 -0
  14. package/backend/src/flowent/__pycache__/graph_runtime.cpython-313.pyc +0 -0
  15. package/backend/src/flowent/__pycache__/graph_service.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/__pycache__/image_assets.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/__pycache__/mcp_service.cpython-313.pyc +0 -0
  20. package/backend/src/flowent/__pycache__/model_metadata.cpython-313.pyc +0 -0
  21. package/backend/src/flowent/__pycache__/network.cpython-313.pyc +0 -0
  22. package/backend/src/flowent/__pycache__/observability_service.cpython-313.pyc +0 -0
  23. package/backend/src/flowent/__pycache__/registry.cpython-313.pyc +0 -0
  24. package/backend/src/flowent/__pycache__/role_management.cpython-313.pyc +0 -0
  25. package/backend/src/flowent/__pycache__/runtime.cpython-313.pyc +0 -0
  26. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  27. package/backend/src/flowent/__pycache__/security.cpython-313.pyc +0 -0
  28. package/backend/src/flowent/__pycache__/settings.cpython-313.pyc +0 -0
  29. package/backend/src/flowent/__pycache__/settings_management.cpython-313.pyc +0 -0
  30. package/backend/src/flowent/__pycache__/state_db.cpython-313.pyc +0 -0
  31. package/backend/src/flowent/__pycache__/workspace_store.cpython-313.pyc +0 -0
  32. package/backend/src/flowent/_version.py +7 -0
  33. package/backend/src/flowent/access.py +247 -0
  34. package/backend/src/flowent/agent.py +3120 -0
  35. package/backend/src/flowent/assistant_commands.py +115 -0
  36. package/backend/src/flowent/channels/__init__.py +3 -0
  37. package/backend/src/flowent/channels/__pycache__/__init__.cpython-313.pyc +0 -0
  38. package/backend/src/flowent/channels/__pycache__/telegram.cpython-313.pyc +0 -0
  39. package/backend/src/flowent/channels/telegram.py +615 -0
  40. package/backend/src/flowent/cli.py +85 -0
  41. package/backend/src/flowent/config.py +14 -0
  42. package/backend/src/flowent/dev.py +3 -0
  43. package/backend/src/flowent/events.py +157 -0
  44. package/backend/src/flowent/graph_runtime.py +60 -0
  45. package/backend/src/flowent/graph_service.py +2508 -0
  46. package/backend/src/flowent/image_assets.py +356 -0
  47. package/backend/src/flowent/logging.py +155 -0
  48. package/backend/src/flowent/main.py +124 -0
  49. package/backend/src/flowent/mcp_service.py +1918 -0
  50. package/backend/src/flowent/model_metadata.py +102 -0
  51. package/backend/src/flowent/models/__init__.py +125 -0
  52. package/backend/src/flowent/models/__pycache__/__init__.cpython-313.pyc +0 -0
  53. package/backend/src/flowent/models/__pycache__/agent.cpython-313.pyc +0 -0
  54. package/backend/src/flowent/models/__pycache__/base.cpython-313.pyc +0 -0
  55. package/backend/src/flowent/models/__pycache__/blueprint.cpython-313.pyc +0 -0
  56. package/backend/src/flowent/models/__pycache__/content.cpython-313.pyc +0 -0
  57. package/backend/src/flowent/models/__pycache__/delta.cpython-313.pyc +0 -0
  58. package/backend/src/flowent/models/__pycache__/event.cpython-313.pyc +0 -0
  59. package/backend/src/flowent/models/__pycache__/graph.cpython-313.pyc +0 -0
  60. package/backend/src/flowent/models/__pycache__/history.cpython-313.pyc +0 -0
  61. package/backend/src/flowent/models/__pycache__/llm.cpython-313.pyc +0 -0
  62. package/backend/src/flowent/models/__pycache__/message.cpython-313.pyc +0 -0
  63. package/backend/src/flowent/models/__pycache__/tab.cpython-313.pyc +0 -0
  64. package/backend/src/flowent/models/__pycache__/todo.cpython-313.pyc +0 -0
  65. package/backend/src/flowent/models/agent.py +34 -0
  66. package/backend/src/flowent/models/base.py +24 -0
  67. package/backend/src/flowent/models/blueprint.py +176 -0
  68. package/backend/src/flowent/models/content.py +164 -0
  69. package/backend/src/flowent/models/delta.py +44 -0
  70. package/backend/src/flowent/models/event.py +51 -0
  71. package/backend/src/flowent/models/graph.py +472 -0
  72. package/backend/src/flowent/models/history.py +272 -0
  73. package/backend/src/flowent/models/llm.py +62 -0
  74. package/backend/src/flowent/models/message.py +33 -0
  75. package/backend/src/flowent/models/tab.py +85 -0
  76. package/backend/src/flowent/models/todo.py +10 -0
  77. package/backend/src/flowent/network.py +146 -0
  78. package/backend/src/flowent/observability_service.py +218 -0
  79. package/backend/src/flowent/prompts/__init__.py +67 -0
  80. package/backend/src/flowent/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
  81. package/backend/src/flowent/prompts/__pycache__/common.cpython-313.pyc +0 -0
  82. package/backend/src/flowent/prompts/__pycache__/steward.cpython-313.pyc +0 -0
  83. package/backend/src/flowent/prompts/common.py +250 -0
  84. package/backend/src/flowent/prompts/steward.py +64 -0
  85. package/backend/src/flowent/providers/__init__.py +23 -0
  86. package/backend/src/flowent/providers/__pycache__/__init__.cpython-313.pyc +0 -0
  87. package/backend/src/flowent/providers/__pycache__/anthropic.cpython-313.pyc +0 -0
  88. package/backend/src/flowent/providers/__pycache__/base_url.cpython-313.pyc +0 -0
  89. package/backend/src/flowent/providers/__pycache__/configuration.cpython-313.pyc +0 -0
  90. package/backend/src/flowent/providers/__pycache__/content.cpython-313.pyc +0 -0
  91. package/backend/src/flowent/providers/__pycache__/errors.cpython-313.pyc +0 -0
  92. package/backend/src/flowent/providers/__pycache__/gateway.cpython-313.pyc +0 -0
  93. package/backend/src/flowent/providers/__pycache__/headers.cpython-313.pyc +0 -0
  94. package/backend/src/flowent/providers/__pycache__/management.cpython-313.pyc +0 -0
  95. package/backend/src/flowent/providers/__pycache__/openai.cpython-313.pyc +0 -0
  96. package/backend/src/flowent/providers/__pycache__/openai_responses.cpython-313.pyc +0 -0
  97. package/backend/src/flowent/providers/__pycache__/registry.cpython-313.pyc +0 -0
  98. package/backend/src/flowent/providers/__pycache__/sse.cpython-313.pyc +0 -0
  99. package/backend/src/flowent/providers/__pycache__/thinking.cpython-313.pyc +0 -0
  100. package/backend/src/flowent/providers/anthropic.py +468 -0
  101. package/backend/src/flowent/providers/base_url.py +60 -0
  102. package/backend/src/flowent/providers/configuration.py +189 -0
  103. package/backend/src/flowent/providers/content.py +122 -0
  104. package/backend/src/flowent/providers/errors.py +223 -0
  105. package/backend/src/flowent/providers/gateway.py +169 -0
  106. package/backend/src/flowent/providers/gemini.py +447 -0
  107. package/backend/src/flowent/providers/headers.py +20 -0
  108. package/backend/src/flowent/providers/management.py +96 -0
  109. package/backend/src/flowent/providers/ollama.py +293 -0
  110. package/backend/src/flowent/providers/openai.py +422 -0
  111. package/backend/src/flowent/providers/openai_responses.py +655 -0
  112. package/backend/src/flowent/providers/registry.py +144 -0
  113. package/backend/src/flowent/providers/sse.py +31 -0
  114. package/backend/src/flowent/providers/thinking.py +79 -0
  115. package/backend/src/flowent/registry.py +73 -0
  116. package/backend/src/flowent/role_management.py +267 -0
  117. package/backend/src/flowent/routes/__init__.py +28 -0
  118. package/backend/src/flowent/routes/__pycache__/__init__.cpython-313.pyc +0 -0
  119. package/backend/src/flowent/routes/__pycache__/access.cpython-313.pyc +0 -0
  120. package/backend/src/flowent/routes/__pycache__/assistant.cpython-313.pyc +0 -0
  121. package/backend/src/flowent/routes/__pycache__/image_assets.cpython-313.pyc +0 -0
  122. package/backend/src/flowent/routes/__pycache__/mcp.cpython-313.pyc +0 -0
  123. package/backend/src/flowent/routes/__pycache__/meta.cpython-313.pyc +0 -0
  124. package/backend/src/flowent/routes/__pycache__/nodes.cpython-313.pyc +0 -0
  125. package/backend/src/flowent/routes/__pycache__/prompts.cpython-313.pyc +0 -0
  126. package/backend/src/flowent/routes/__pycache__/providers_route.cpython-313.pyc +0 -0
  127. package/backend/src/flowent/routes/__pycache__/roles.cpython-313.pyc +0 -0
  128. package/backend/src/flowent/routes/__pycache__/settings.cpython-313.pyc +0 -0
  129. package/backend/src/flowent/routes/__pycache__/tabs.cpython-313.pyc +0 -0
  130. package/backend/src/flowent/routes/__pycache__/ws.cpython-313.pyc +0 -0
  131. package/backend/src/flowent/routes/access.py +48 -0
  132. package/backend/src/flowent/routes/assistant.py +155 -0
  133. package/backend/src/flowent/routes/image_assets.py +33 -0
  134. package/backend/src/flowent/routes/mcp.py +125 -0
  135. package/backend/src/flowent/routes/meta.py +28 -0
  136. package/backend/src/flowent/routes/nodes.py +413 -0
  137. package/backend/src/flowent/routes/prompts.py +46 -0
  138. package/backend/src/flowent/routes/providers_route.py +365 -0
  139. package/backend/src/flowent/routes/roles.py +207 -0
  140. package/backend/src/flowent/routes/settings.py +328 -0
  141. package/backend/src/flowent/routes/tabs.py +310 -0
  142. package/backend/src/flowent/routes/ws.py +33 -0
  143. package/backend/src/flowent/runtime.py +165 -0
  144. package/backend/src/flowent/sandbox.py +45 -0
  145. package/backend/src/flowent/security.py +57 -0
  146. package/backend/src/flowent/settings.py +2518 -0
  147. package/backend/src/flowent/settings_management.py +298 -0
  148. package/backend/src/flowent/state_db.py +120 -0
  149. package/backend/src/flowent/static/assets/AssistantPage-VBohhz4d.js +1 -0
  150. package/backend/src/flowent/static/assets/ChannelsPage-CIydPZA_.js +1 -0
  151. package/backend/src/flowent/static/assets/McpPage-CHPm2TPY.js +7 -0
  152. package/backend/src/flowent/static/assets/PageScaffold-DteOA8V7.js +1 -0
  153. package/backend/src/flowent/static/assets/PromptsPage-CSmJ3sZg.js +1 -0
  154. package/backend/src/flowent/static/assets/ProvidersPage-sl2jeG4e.js +3 -0
  155. package/backend/src/flowent/static/assets/RolesPage-DCe7W6Km.js +1 -0
  156. package/backend/src/flowent/static/assets/SettingsPage-Bix9e63E.js +3 -0
  157. package/backend/src/flowent/static/assets/ToolsPage-favNkj5C.js +1 -0
  158. package/backend/src/flowent/static/assets/WorkspaceCommandDialog-DRS6wiD6.js +1 -0
  159. package/backend/src/flowent/static/assets/WorkspacePage-KuaDjt_D.js +3 -0
  160. package/backend/src/flowent/static/assets/WorkspacePanels-BZxBw8M5.js +1 -0
  161. package/backend/src/flowent/static/assets/alert-dialog-DIBUCmqM.js +1 -0
  162. package/backend/src/flowent/static/assets/datetime-eJqd0V2S.js +1 -0
  163. package/backend/src/flowent/static/assets/dialog-BOvHIBrg.js +1 -0
  164. package/backend/src/flowent/static/assets/elk-worker.min-C9JGDOE-.js +6312 -0
  165. package/backend/src/flowent/static/assets/graph-vendor-CHpVij2M.css +1 -0
  166. package/backend/src/flowent/static/assets/graph-vendor-DRq_-6fV.js +7 -0
  167. package/backend/src/flowent/static/assets/index-Biio-CoI.js +10 -0
  168. package/backend/src/flowent/static/assets/index-CmQvO7sl.css +1 -0
  169. package/backend/src/flowent/static/assets/layout.worker-jMHqAFbP.js +24 -0
  170. package/backend/src/flowent/static/assets/markdown-vendor-C9RtvaJh.js +29 -0
  171. package/backend/src/flowent/static/assets/modelParams-DcEhGnu0.js +1 -0
  172. package/backend/src/flowent/static/assets/react-vendor-mEs_JJxa.js +9 -0
  173. package/backend/src/flowent/static/assets/roles-BbIEIMeG.js +1 -0
  174. package/backend/src/flowent/static/assets/rolldown-runtime-BYbx6iT9.js +1 -0
  175. package/backend/src/flowent/static/assets/select-D9SwnlXF.js +1 -0
  176. package/backend/src/flowent/static/assets/surface-Bzr1FRG4.js +1 -0
  177. package/backend/src/flowent/static/assets/triState-DgLlKdRR.js +1 -0
  178. package/backend/src/flowent/static/assets/ui-vendor-UazN8rcv.js +51 -0
  179. package/backend/src/flowent/static/index.html +35 -0
  180. package/backend/src/flowent/tools/__init__.py +275 -0
  181. package/backend/src/flowent/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  182. package/backend/src/flowent/tools/__pycache__/connect.cpython-313.pyc +0 -0
  183. package/backend/src/flowent/tools/__pycache__/contacts.cpython-313.pyc +0 -0
  184. package/backend/src/flowent/tools/__pycache__/create_agent.cpython-313.pyc +0 -0
  185. package/backend/src/flowent/tools/__pycache__/create_tab.cpython-313.pyc +0 -0
  186. package/backend/src/flowent/tools/__pycache__/delete_tab.cpython-313.pyc +0 -0
  187. package/backend/src/flowent/tools/__pycache__/edit.cpython-313.pyc +0 -0
  188. package/backend/src/flowent/tools/__pycache__/exec.cpython-313.pyc +0 -0
  189. package/backend/src/flowent/tools/__pycache__/fetch.cpython-313.pyc +0 -0
  190. package/backend/src/flowent/tools/__pycache__/idle.cpython-313.pyc +0 -0
  191. package/backend/src/flowent/tools/__pycache__/list_roles.cpython-313.pyc +0 -0
  192. package/backend/src/flowent/tools/__pycache__/list_tabs.cpython-313.pyc +0 -0
  193. package/backend/src/flowent/tools/__pycache__/list_tools.cpython-313.pyc +0 -0
  194. package/backend/src/flowent/tools/__pycache__/manage_prompts.cpython-313.pyc +0 -0
  195. package/backend/src/flowent/tools/__pycache__/manage_providers.cpython-313.pyc +0 -0
  196. package/backend/src/flowent/tools/__pycache__/manage_roles.cpython-313.pyc +0 -0
  197. package/backend/src/flowent/tools/__pycache__/manage_settings.cpython-313.pyc +0 -0
  198. package/backend/src/flowent/tools/__pycache__/mcp.cpython-313.pyc +0 -0
  199. package/backend/src/flowent/tools/__pycache__/read.cpython-313.pyc +0 -0
  200. package/backend/src/flowent/tools/__pycache__/send.cpython-313.pyc +0 -0
  201. package/backend/src/flowent/tools/__pycache__/set_permissions.cpython-313.pyc +0 -0
  202. package/backend/src/flowent/tools/__pycache__/sleep.cpython-313.pyc +0 -0
  203. package/backend/src/flowent/tools/__pycache__/todo.cpython-313.pyc +0 -0
  204. package/backend/src/flowent/tools/connect.py +100 -0
  205. package/backend/src/flowent/tools/contacts.py +22 -0
  206. package/backend/src/flowent/tools/create_agent.py +191 -0
  207. package/backend/src/flowent/tools/create_tab.py +61 -0
  208. package/backend/src/flowent/tools/delete_tab.py +39 -0
  209. package/backend/src/flowent/tools/edit.py +142 -0
  210. package/backend/src/flowent/tools/exec.py +118 -0
  211. package/backend/src/flowent/tools/fetch.py +85 -0
  212. package/backend/src/flowent/tools/idle.py +27 -0
  213. package/backend/src/flowent/tools/list_roles.py +75 -0
  214. package/backend/src/flowent/tools/list_tabs.py +100 -0
  215. package/backend/src/flowent/tools/list_tools.py +28 -0
  216. package/backend/src/flowent/tools/manage_prompts.py +102 -0
  217. package/backend/src/flowent/tools/manage_providers.py +220 -0
  218. package/backend/src/flowent/tools/manage_roles.py +275 -0
  219. package/backend/src/flowent/tools/manage_settings.py +364 -0
  220. package/backend/src/flowent/tools/mcp.py +199 -0
  221. package/backend/src/flowent/tools/read.py +152 -0
  222. package/backend/src/flowent/tools/send.py +68 -0
  223. package/backend/src/flowent/tools/set_permissions.py +99 -0
  224. package/backend/src/flowent/tools/sleep.py +41 -0
  225. package/backend/src/flowent/tools/todo.py +51 -0
  226. package/backend/src/flowent/workspace_store.py +479 -0
  227. package/backend/tests/__init__.py +0 -0
  228. package/backend/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  229. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  230. package/backend/tests/conftest.py +6 -0
  231. package/backend/tests/integration/api/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  232. package/backend/tests/integration/api/__pycache__/test_access_api.cpython-313-pytest-9.0.3.pyc +0 -0
  233. package/backend/tests/integration/api/__pycache__/test_assistant_api.cpython-313-pytest-9.0.3.pyc +0 -0
  234. package/backend/tests/integration/api/__pycache__/test_frontend_mounting.cpython-313-pytest-9.0.3.pyc +0 -0
  235. package/backend/tests/integration/api/__pycache__/test_mcp_api.cpython-313-pytest-9.0.3.pyc +0 -0
  236. package/backend/tests/integration/api/__pycache__/test_meta_api.cpython-313-pytest-9.0.3.pyc +0 -0
  237. package/backend/tests/integration/api/__pycache__/test_nodes_api.cpython-313-pytest-9.0.3.pyc +0 -0
  238. package/backend/tests/integration/api/__pycache__/test_prompts_api.cpython-313-pytest-9.0.3.pyc +0 -0
  239. package/backend/tests/integration/api/__pycache__/test_roles_api.cpython-313-pytest-9.0.3.pyc +0 -0
  240. package/backend/tests/integration/api/__pycache__/test_tabs_api.cpython-313-pytest-9.0.3.pyc +0 -0
  241. package/backend/tests/integration/api/conftest.py +29 -0
  242. package/backend/tests/integration/api/test_access_api.py +182 -0
  243. package/backend/tests/integration/api/test_assistant_api.py +354 -0
  244. package/backend/tests/integration/api/test_frontend_mounting.py +61 -0
  245. package/backend/tests/integration/api/test_mcp_api.py +116 -0
  246. package/backend/tests/integration/api/test_meta_api.py +33 -0
  247. package/backend/tests/integration/api/test_nodes_api.py +722 -0
  248. package/backend/tests/integration/api/test_prompts_api.py +47 -0
  249. package/backend/tests/integration/api/test_roles_api.py +228 -0
  250. package/backend/tests/integration/api/test_tabs_api.py +802 -0
  251. package/backend/tests/unit/__pycache__/test_access.cpython-313-pytest-9.0.3.pyc +0 -0
  252. package/backend/tests/unit/__pycache__/test_cli.cpython-313-pytest-9.0.3.pyc +0 -0
  253. package/backend/tests/unit/__pycache__/test_graph_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  254. package/backend/tests/unit/__pycache__/test_network.cpython-313-pytest-9.0.3.pyc +0 -0
  255. package/backend/tests/unit/__pycache__/test_state_sqlite_storage.cpython-313-pytest-9.0.3.pyc +0 -0
  256. package/backend/tests/unit/__pycache__/test_workspace_store.cpython-313-pytest-9.0.3.pyc +0 -0
  257. package/backend/tests/unit/agent/__pycache__/test_agent_public_api.cpython-313-pytest-9.0.3.pyc +0 -0
  258. package/backend/tests/unit/agent/__pycache__/test_agent_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  259. package/backend/tests/unit/agent/test_agent_public_api.py +837 -0
  260. package/backend/tests/unit/agent/test_agent_runtime.py +2942 -0
  261. package/backend/tests/unit/channels/__pycache__/test_telegram_channel.cpython-313-pytest-9.0.3.pyc +0 -0
  262. package/backend/tests/unit/channels/test_telegram_channel.py +552 -0
  263. package/backend/tests/unit/logging/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  264. package/backend/tests/unit/logging/test_logging.py +132 -0
  265. package/backend/tests/unit/prompts/__pycache__/test_prompts.cpython-313-pytest-9.0.3.pyc +0 -0
  266. package/backend/tests/unit/prompts/test_prompts.py +570 -0
  267. package/backend/tests/unit/providers/__pycache__/test_anthropic_provider.cpython-313-pytest-9.0.3.pyc +0 -0
  268. package/backend/tests/unit/providers/__pycache__/test_errors.cpython-313-pytest-9.0.3.pyc +0 -0
  269. package/backend/tests/unit/providers/__pycache__/test_extract_delta_parts.cpython-313-pytest-9.0.3.pyc +0 -0
  270. package/backend/tests/unit/providers/__pycache__/test_openai_provider.cpython-313-pytest-9.0.3.pyc +0 -0
  271. package/backend/tests/unit/providers/__pycache__/test_openai_responses.cpython-313-pytest-9.0.3.pyc +0 -0
  272. package/backend/tests/unit/providers/__pycache__/test_provider_gateway.cpython-313-pytest-9.0.3.pyc +0 -0
  273. package/backend/tests/unit/providers/__pycache__/test_think_tag_parser.cpython-313-pytest-9.0.3.pyc +0 -0
  274. package/backend/tests/unit/providers/test_anthropic_provider.py +185 -0
  275. package/backend/tests/unit/providers/test_errors.py +68 -0
  276. package/backend/tests/unit/providers/test_extract_delta_parts.py +22 -0
  277. package/backend/tests/unit/providers/test_openai_provider.py +139 -0
  278. package/backend/tests/unit/providers/test_openai_responses.py +402 -0
  279. package/backend/tests/unit/providers/test_provider_gateway.py +359 -0
  280. package/backend/tests/unit/providers/test_think_tag_parser.py +36 -0
  281. package/backend/tests/unit/routes/__pycache__/test_prompts_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  282. package/backend/tests/unit/routes/__pycache__/test_providers_route.cpython-313-pytest-9.0.3.pyc +0 -0
  283. package/backend/tests/unit/routes/__pycache__/test_roles_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  284. package/backend/tests/unit/routes/__pycache__/test_settings_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  285. package/backend/tests/unit/routes/test_prompts_routes.py +104 -0
  286. package/backend/tests/unit/routes/test_providers_route.py +370 -0
  287. package/backend/tests/unit/routes/test_roles_routes.py +535 -0
  288. package/backend/tests/unit/routes/test_settings_routes.py +1142 -0
  289. package/backend/tests/unit/runtime/__pycache__/test_bootstrap_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  290. package/backend/tests/unit/runtime/test_bootstrap_runtime.py +1002 -0
  291. package/backend/tests/unit/sandbox/__pycache__/test_sandbox_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  292. package/backend/tests/unit/sandbox/test_sandbox_tools.py +78 -0
  293. package/backend/tests/unit/security/__pycache__/test_security.cpython-313-pytest-9.0.3.pyc +0 -0
  294. package/backend/tests/unit/security/test_security.py +124 -0
  295. package/backend/tests/unit/settings/__pycache__/test_settings_roles.cpython-313-pytest-9.0.3.pyc +0 -0
  296. package/backend/tests/unit/settings/test_settings_roles.py +751 -0
  297. package/backend/tests/unit/test_access.py +45 -0
  298. package/backend/tests/unit/test_cli.py +124 -0
  299. package/backend/tests/unit/test_graph_runtime.py +72 -0
  300. package/backend/tests/unit/test_network.py +51 -0
  301. package/backend/tests/unit/test_state_sqlite_storage.py +159 -0
  302. package/backend/tests/unit/test_workspace_store.py +231 -0
  303. package/backend/tests/unit/tools/__pycache__/test_connect_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  304. package/backend/tests/unit/tools/__pycache__/test_create_agent_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  305. package/backend/tests/unit/tools/__pycache__/test_delete_tab_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  306. package/backend/tests/unit/tools/__pycache__/test_edit_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  307. package/backend/tests/unit/tools/__pycache__/test_exec_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  308. package/backend/tests/unit/tools/__pycache__/test_fetch_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  309. package/backend/tests/unit/tools/__pycache__/test_manage_prompts_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  310. package/backend/tests/unit/tools/__pycache__/test_manage_providers_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  311. package/backend/tests/unit/tools/__pycache__/test_manage_roles_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  312. package/backend/tests/unit/tools/__pycache__/test_manage_settings_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  313. package/backend/tests/unit/tools/__pycache__/test_read_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  314. package/backend/tests/unit/tools/__pycache__/test_set_permissions_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  315. package/backend/tests/unit/tools/__pycache__/test_todo_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  316. package/backend/tests/unit/tools/__pycache__/test_tool_registry.cpython-313-pytest-9.0.3.pyc +0 -0
  317. package/backend/tests/unit/tools/test_connect_tool.py +228 -0
  318. package/backend/tests/unit/tools/test_create_agent_tool.py +436 -0
  319. package/backend/tests/unit/tools/test_delete_tab_tool.py +116 -0
  320. package/backend/tests/unit/tools/test_edit_tool.py +115 -0
  321. package/backend/tests/unit/tools/test_exec_tool.py +81 -0
  322. package/backend/tests/unit/tools/test_fetch_tool.py +65 -0
  323. package/backend/tests/unit/tools/test_manage_prompts_tool.py +117 -0
  324. package/backend/tests/unit/tools/test_manage_providers_tool.py +460 -0
  325. package/backend/tests/unit/tools/test_manage_roles_tool.py +411 -0
  326. package/backend/tests/unit/tools/test_manage_settings_tool.py +611 -0
  327. package/backend/tests/unit/tools/test_read_tool.py +33 -0
  328. package/backend/tests/unit/tools/test_set_permissions_tool.py +595 -0
  329. package/backend/tests/unit/tools/test_todo_tool.py +37 -0
  330. package/backend/tests/unit/tools/test_tool_registry.py +194 -0
  331. package/backend/uv.lock +1144 -0
  332. package/bin/flowent.mjs +62 -36
  333. package/dist/frontend/assets/AssistantPage-VBohhz4d.js +1 -0
  334. package/dist/frontend/assets/ChannelsPage-CIydPZA_.js +1 -0
  335. package/dist/frontend/assets/McpPage-CHPm2TPY.js +7 -0
  336. package/dist/frontend/assets/PageScaffold-DteOA8V7.js +1 -0
  337. package/dist/frontend/assets/PromptsPage-CSmJ3sZg.js +1 -0
  338. package/dist/frontend/assets/ProvidersPage-sl2jeG4e.js +3 -0
  339. package/dist/frontend/assets/RolesPage-DCe7W6Km.js +1 -0
  340. package/dist/frontend/assets/SettingsPage-Bix9e63E.js +3 -0
  341. package/dist/frontend/assets/ToolsPage-favNkj5C.js +1 -0
  342. package/dist/frontend/assets/WorkspaceCommandDialog-DRS6wiD6.js +1 -0
  343. package/dist/frontend/assets/WorkspacePage-KuaDjt_D.js +3 -0
  344. package/dist/frontend/assets/WorkspacePanels-BZxBw8M5.js +1 -0
  345. package/dist/frontend/assets/alert-dialog-DIBUCmqM.js +1 -0
  346. package/dist/frontend/assets/datetime-eJqd0V2S.js +1 -0
  347. package/dist/frontend/assets/dialog-BOvHIBrg.js +1 -0
  348. package/dist/frontend/assets/elk-worker.min-C9JGDOE-.js +6312 -0
  349. package/dist/frontend/assets/graph-vendor-CHpVij2M.css +1 -0
  350. package/dist/frontend/assets/graph-vendor-DRq_-6fV.js +7 -0
  351. package/dist/frontend/assets/index-Biio-CoI.js +10 -0
  352. package/dist/frontend/assets/index-CmQvO7sl.css +1 -0
  353. package/dist/frontend/assets/layout.worker-jMHqAFbP.js +24 -0
  354. package/dist/frontend/assets/markdown-vendor-C9RtvaJh.js +29 -0
  355. package/dist/frontend/assets/modelParams-DcEhGnu0.js +1 -0
  356. package/dist/frontend/assets/react-vendor-mEs_JJxa.js +9 -0
  357. package/dist/frontend/assets/roles-BbIEIMeG.js +1 -0
  358. package/dist/frontend/assets/rolldown-runtime-BYbx6iT9.js +1 -0
  359. package/dist/frontend/assets/select-D9SwnlXF.js +1 -0
  360. package/dist/frontend/assets/surface-Bzr1FRG4.js +1 -0
  361. package/dist/frontend/assets/triState-DgLlKdRR.js +1 -0
  362. package/dist/frontend/assets/ui-vendor-UazN8rcv.js +51 -0
  363. package/dist/frontend/index.html +35 -0
  364. package/package.json +27 -41
  365. package/dist/.next/BUILD_ID +0 -1
  366. package/dist/.next/app-path-routes-manifest.json +0 -6
  367. package/dist/.next/build-manifest.json +0 -20
  368. package/dist/.next/package.json +0 -1
  369. package/dist/.next/prerender-manifest.json +0 -114
  370. package/dist/.next/required-server-files.json +0 -333
  371. package/dist/.next/routes-manifest.json +0 -69
  372. package/dist/.next/server/app/_global-error/page/app-paths-manifest.json +0 -3
  373. package/dist/.next/server/app/_global-error/page/build-manifest.json +0 -16
  374. package/dist/.next/server/app/_global-error/page/next-font-manifest.json +0 -6
  375. package/dist/.next/server/app/_global-error/page/react-loadable-manifest.json +0 -1
  376. package/dist/.next/server/app/_global-error/page/server-reference-manifest.json +0 -4
  377. package/dist/.next/server/app/_global-error/page.js +0 -9
  378. package/dist/.next/server/app/_global-error/page.js.map +0 -5
  379. package/dist/.next/server/app/_global-error/page.js.nft.json +0 -1
  380. package/dist/.next/server/app/_global-error/page_client-reference-manifest.js +0 -3
  381. package/dist/.next/server/app/_global-error.html +0 -1
  382. package/dist/.next/server/app/_global-error.meta +0 -15
  383. package/dist/.next/server/app/_global-error.rsc +0 -14
  384. package/dist/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +0 -5
  385. package/dist/.next/server/app/_global-error.segments/_full.segment.rsc +0 -14
  386. package/dist/.next/server/app/_global-error.segments/_head.segment.rsc +0 -5
  387. package/dist/.next/server/app/_global-error.segments/_index.segment.rsc +0 -5
  388. package/dist/.next/server/app/_global-error.segments/_tree.segment.rsc +0 -1
  389. package/dist/.next/server/app/_not-found/page/app-paths-manifest.json +0 -3
  390. package/dist/.next/server/app/_not-found/page/build-manifest.json +0 -16
  391. package/dist/.next/server/app/_not-found/page/next-font-manifest.json +0 -10
  392. package/dist/.next/server/app/_not-found/page/react-loadable-manifest.json +0 -1
  393. package/dist/.next/server/app/_not-found/page/server-reference-manifest.json +0 -4
  394. package/dist/.next/server/app/_not-found/page.js +0 -13
  395. package/dist/.next/server/app/_not-found/page.js.map +0 -5
  396. package/dist/.next/server/app/_not-found/page.js.nft.json +0 -1
  397. package/dist/.next/server/app/_not-found/page_client-reference-manifest.js +0 -3
  398. package/dist/.next/server/app/_not-found.html +0 -1
  399. package/dist/.next/server/app/_not-found.meta +0 -16
  400. package/dist/.next/server/app/_not-found.rsc +0 -16
  401. package/dist/.next/server/app/_not-found.segments/_full.segment.rsc +0 -16
  402. package/dist/.next/server/app/_not-found.segments/_head.segment.rsc +0 -6
  403. package/dist/.next/server/app/_not-found.segments/_index.segment.rsc +0 -5
  404. package/dist/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +0 -5
  405. package/dist/.next/server/app/_not-found.segments/_not-found.segment.rsc +0 -5
  406. package/dist/.next/server/app/_not-found.segments/_tree.segment.rsc +0 -2
  407. package/dist/.next/server/app/icon.svg/route/app-paths-manifest.json +0 -3
  408. package/dist/.next/server/app/icon.svg/route/build-manifest.json +0 -9
  409. package/dist/.next/server/app/icon.svg/route.js +0 -6
  410. package/dist/.next/server/app/icon.svg/route.js.map +0 -5
  411. package/dist/.next/server/app/icon.svg/route.js.nft.json +0 -1
  412. package/dist/.next/server/app/icon.svg.meta +0 -1
  413. package/dist/.next/server/app/index.html +0 -1
  414. package/dist/.next/server/app/index.meta +0 -14
  415. package/dist/.next/server/app/index.rsc +0 -15
  416. package/dist/.next/server/app/index.segments/__PAGE__.segment.rsc +0 -5
  417. package/dist/.next/server/app/index.segments/_full.segment.rsc +0 -15
  418. package/dist/.next/server/app/index.segments/_head.segment.rsc +0 -6
  419. package/dist/.next/server/app/index.segments/_index.segment.rsc +0 -5
  420. package/dist/.next/server/app/index.segments/_tree.segment.rsc +0 -3
  421. package/dist/.next/server/app/page/app-paths-manifest.json +0 -3
  422. package/dist/.next/server/app/page/build-manifest.json +0 -16
  423. package/dist/.next/server/app/page/next-font-manifest.json +0 -10
  424. package/dist/.next/server/app/page/react-loadable-manifest.json +0 -1
  425. package/dist/.next/server/app/page/server-reference-manifest.json +0 -4
  426. package/dist/.next/server/app/page.js +0 -14
  427. package/dist/.next/server/app/page.js.map +0 -5
  428. package/dist/.next/server/app/page.js.nft.json +0 -1
  429. package/dist/.next/server/app/page_client-reference-manifest.js +0 -3
  430. package/dist/.next/server/app-paths-manifest.json +0 -6
  431. package/dist/.next/server/chunks/[externals]_next_dist_0arv.vj._.js +0 -3
  432. package/dist/.next/server/chunks/[root-of-the-server]__0vcj1q1._.js +0 -13
  433. package/dist/.next/server/chunks/[turbopack]_runtime.js +0 -903
  434. package/dist/.next/server/chunks/_next-internal_server_app_icon_svg_route_actions_0-0ehc~.js +0 -3
  435. package/dist/.next/server/chunks/ssr/05w9_next_dist_0ihu0u9._.js +0 -6
  436. package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_12u3mib._.js +0 -3
  437. package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_builtin_forbidden_04fbe_..js +0 -3
  438. package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_builtin_global-error_0brpl_..js +0 -3
  439. package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_builtin_unauthorized_0~2g66g.js +0 -3
  440. package/dist/.next/server/chunks/ssr/05w9_next_dist_esm_build_templates_app-page_0~cyr1_.js +0 -4
  441. package/dist/.next/server/chunks/ssr/05w9_next_dist_esm_build_templates_app-page_1105emf.js +0 -4
  442. package/dist/.next/server/chunks/ssr/05w9_next_dist_esm_build_templates_app-page_11uhyqv.js +0 -4
  443. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0.t9_75._.js +0 -33
  444. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0c0ud_z._.js +0 -3
  445. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0f9_8d4._.js +0 -3
  446. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0l5ko41._.js +0 -19
  447. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0mn6z7i._.js +0 -3
  448. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0npxxst._.js +0 -33
  449. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0qjhaca._.js +0 -3
  450. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0rwgw3s._.js +0 -3
  451. package/dist/.next/server/chunks/ssr/[turbopack]_runtime.js +0 -903
  452. package/dist/.next/server/chunks/ssr/_next-internal_server_app__global-error_page_actions_0k77kol.js +0 -3
  453. package/dist/.next/server/chunks/ssr/_next-internal_server_app__not-found_page_actions_0eq97pa.js +0 -3
  454. package/dist/.next/server/chunks/ssr/_next-internal_server_app_page_actions_09-gtaw.js +0 -3
  455. package/dist/.next/server/chunks/ssr/node_modules__pnpm_056~6.6._.js +0 -3
  456. package/dist/.next/server/chunks/ssr/node_modules__pnpm_0~j0k.e._.js +0 -33
  457. package/dist/.next/server/functions-config-manifest.json +0 -4
  458. package/dist/.next/server/middleware-build-manifest.js +0 -20
  459. package/dist/.next/server/middleware-manifest.json +0 -6
  460. package/dist/.next/server/next-font-manifest.js +0 -1
  461. package/dist/.next/server/next-font-manifest.json +0 -13
  462. package/dist/.next/server/pages/404.html +0 -1
  463. package/dist/.next/server/pages/500.html +0 -1
  464. package/dist/.next/server/pages-manifest.json +0 -4
  465. package/dist/.next/server/prefetch-hints.json +0 -1
  466. package/dist/.next/server/server-reference-manifest.js +0 -1
  467. package/dist/.next/server/server-reference-manifest.json +0 -5
  468. package/dist/.next/static/SMWpxFVvkpYFxY7uuFvGB/_buildManifest.js +0 -11
  469. package/dist/.next/static/SMWpxFVvkpYFxY7uuFvGB/_clientMiddlewareManifest.js +0 -1
  470. package/dist/.next/static/SMWpxFVvkpYFxY7uuFvGB/_ssgManifest.js +0 -1
  471. package/dist/.next/static/chunks/01qk2~bgf76vu.js +0 -1
  472. package/dist/.next/static/chunks/03~yq9q893hmn.js +0 -1
  473. package/dist/.next/static/chunks/080queev.r2uy.js +0 -31
  474. package/dist/.next/static/chunks/0v3lyuj75aq50.js +0 -1
  475. package/dist/.next/static/chunks/10b~xdx5c-i7s.js +0 -5
  476. package/dist/.next/static/chunks/14gla2ascffgv.css +0 -2
  477. package/dist/.next/static/chunks/turbopack-0m-970~qvs7sc.js +0 -1
  478. package/dist/.next/static/media/7178b3e590c64307-s.11.cyxs5p-0z~.woff2 +0 -0
  479. package/dist/.next/static/media/8a480f0b521d4e75-s.06d3mdzz5bre_.woff2 +0 -0
  480. package/dist/.next/static/media/caa3a2e1cccd8315-s.p.16t1db8_9y2o~.woff2 +0 -0
  481. package/dist/package.json +0 -88
  482. package/dist/server.js +0 -38
  483. /package/{dist/.next/server/app/icon.svg.body → backend/src/flowent/static/favicon.svg} +0 -0
  484. /package/dist/{.next/static/media/icon.0.r~afrtrocz9.svg → frontend/favicon.svg} +0 -0
@@ -0,0 +1,2942 @@
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_compact_command_excludes_queued_messages_from_summary(monkeypatch):
1122
+ assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1123
+ assistant.history.extend(
1124
+ [
1125
+ ReceivedMessage(content="Existing history", from_id="human"),
1126
+ AssistantText(content="Existing reply"),
1127
+ ]
1128
+ )
1129
+ assistant.set_state(AgentState.RUNNING, "processing")
1130
+ assistant._wake_queue.put(
1131
+ WakeSignal(
1132
+ reason="message",
1133
+ payload={"message": {"content": "queued message", "from": "human"}},
1134
+ resume_reason="received message from human",
1135
+ )
1136
+ )
1137
+
1138
+ captured_messages: list[list[dict]] = []
1139
+
1140
+ def fake_chat(*, messages, **kwargs):
1141
+ captured_messages.append(messages)
1142
+ return LLMResponse(
1143
+ content=(
1144
+ "## Current Goal\nShip the command layer.\n\n"
1145
+ "## Active Task Boundary\nKeep the change in Assistant chat.\n\n"
1146
+ "## Key Constraints\nPreserve persistence.\n\n"
1147
+ "## Confirmed Decisions\nUse built-in commands only.\n\n"
1148
+ "## Open Questions\nNone.\n\n"
1149
+ "## Next Actions\nFinish the UI."
1150
+ )
1151
+ )
1152
+
1153
+ interrupt_thread = threading.Thread(
1154
+ target=assistant._handle_interrupt,
1155
+ args=(None,),
1156
+ daemon=True,
1157
+ )
1158
+
1159
+ def fake_request_interrupt() -> bool:
1160
+ if not interrupt_thread.is_alive():
1161
+ interrupt_thread.start()
1162
+ return True
1163
+
1164
+ monkeypatch.setattr(assistant, "request_interrupt", fake_request_interrupt)
1165
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
1166
+
1167
+ assistant.compact_chat_history()
1168
+
1169
+ assert captured_messages
1170
+ assert "queued message" not in json.dumps(captured_messages[0])
1171
+
1172
+ assistant.request_termination("done")
1173
+ interrupt_thread.join(timeout=1.0)
1174
+ assert interrupt_thread.is_alive() is False
1175
+
1176
+
1177
+ def test_help_command_result_does_not_reenter_model_context():
1178
+ assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1179
+
1180
+ entry = assistant.execute_assistant_command(command_name="/help")
1181
+ messages = assistant._build_messages()
1182
+ serialized = json.dumps(messages)
1183
+
1184
+ assert isinstance(entry, CommandResultEntry)
1185
+ assert entry.include_in_context is False
1186
+ assert "/compact" in entry.content
1187
+ assert "Available commands" not in serialized
1188
+
1189
+
1190
+ def test_agent_normalizes_think_tags_in_final_content(monkeypatch):
1191
+ agent = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1192
+ wait_calls = 0
1193
+ llm_calls = 0
1194
+
1195
+ def fake_wait_for_input() -> None:
1196
+ nonlocal wait_calls
1197
+ wait_calls += 1
1198
+ if wait_calls == 1:
1199
+ agent._append_history(
1200
+ ReceivedMessage(content="reply to me", from_id="human")
1201
+ )
1202
+ agent.set_state(AgentState.RUNNING, "received message from human")
1203
+ return
1204
+ agent.request_termination("done")
1205
+
1206
+ def fake_chat(
1207
+ messages,
1208
+ tools=None,
1209
+ on_chunk=None,
1210
+ register_interrupt=None,
1211
+ role_name=None,
1212
+ ):
1213
+ nonlocal llm_calls
1214
+ llm_calls += 1
1215
+ if llm_calls == 2:
1216
+ agent.request_termination("done")
1217
+ return LLMResponse()
1218
+ return LLMResponse(content="<think>Drafting plan</think>\nHello there")
1219
+
1220
+ monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
1221
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
1222
+
1223
+ agent._run()
1224
+
1225
+ history = agent.get_history_snapshot()
1226
+
1227
+ assert any(
1228
+ isinstance(entry, AssistantThinking) and entry.content == "Drafting plan"
1229
+ for entry in history
1230
+ )
1231
+ assert any(
1232
+ isinstance(entry, AssistantText) and entry.content == "Hello there"
1233
+ for entry in history
1234
+ )
1235
+ assert not any(
1236
+ isinstance(entry, AssistantText) and "<think>" in entry.content
1237
+ for entry in history
1238
+ )
1239
+
1240
+
1241
+ def test_agent_dedupes_structured_thinking_and_raw_think_tags(monkeypatch):
1242
+ agent = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1243
+ wait_calls = 0
1244
+ llm_calls = 0
1245
+
1246
+ def fake_wait_for_input() -> None:
1247
+ nonlocal wait_calls
1248
+ wait_calls += 1
1249
+ if wait_calls == 1:
1250
+ agent._append_history(
1251
+ ReceivedMessage(content="reply to me", from_id="human")
1252
+ )
1253
+ agent.set_state(AgentState.RUNNING, "received message from human")
1254
+ return
1255
+ agent.request_termination("done")
1256
+
1257
+ def fake_chat(
1258
+ messages,
1259
+ tools=None,
1260
+ on_chunk=None,
1261
+ register_interrupt=None,
1262
+ role_name=None,
1263
+ ):
1264
+ nonlocal llm_calls
1265
+ llm_calls += 1
1266
+ if llm_calls == 2:
1267
+ agent.request_termination("done")
1268
+ return LLMResponse()
1269
+ return LLMResponse(
1270
+ content="<think>Drafting plan</think>\nHello there",
1271
+ thinking="Drafting plan",
1272
+ )
1273
+
1274
+ monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
1275
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
1276
+
1277
+ agent._run()
1278
+
1279
+ thinking_entries = [
1280
+ entry.content
1281
+ for entry in agent.get_history_snapshot()
1282
+ if isinstance(entry, AssistantThinking)
1283
+ ]
1284
+
1285
+ assert thinking_entries == ["Drafting plan"]
1286
+
1287
+
1288
+ def test_agent_unregisters_from_registry_after_termination_request(monkeypatch):
1289
+ registry.reset()
1290
+ agent = Agent(
1291
+ NodeConfig(node_type=NodeType.AGENT, allow_network=True),
1292
+ uuid="agent-x",
1293
+ )
1294
+ registry.register(agent)
1295
+ events = []
1296
+
1297
+ def fake_wait_for_input() -> None:
1298
+ agent._append_history(
1299
+ ReceivedMessage(content="finish the task", from_id="tester")
1300
+ )
1301
+ agent.set_state(AgentState.RUNNING, "received message from tester")
1302
+
1303
+ monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
1304
+
1305
+ def fake_chat(
1306
+ messages,
1307
+ tools=None,
1308
+ on_chunk=None,
1309
+ register_interrupt=None,
1310
+ role_name=None,
1311
+ ):
1312
+ agent.request_termination("done")
1313
+ return LLMResponse()
1314
+
1315
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
1316
+ monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
1317
+
1318
+ agent._run()
1319
+
1320
+ assert agent.state == AgentState.TERMINATED
1321
+ assert registry.get(agent.uuid) is None
1322
+ assert [event.type for event in events[-2:]] == [
1323
+ EventType.NODE_STATE_CHANGED,
1324
+ EventType.NODE_TERMINATED,
1325
+ ]
1326
+ assert events[-1].data == {"reason": "done"}
1327
+
1328
+
1329
+ def test_finalize_termination_removes_bidirectional_connections():
1330
+ registry.reset()
1331
+ try:
1332
+ assistant = Agent(
1333
+ NodeConfig(node_type=NodeType.ASSISTANT),
1334
+ )
1335
+ worker = Agent(
1336
+ NodeConfig(node_type=NodeType.AGENT),
1337
+ uuid="worker",
1338
+ )
1339
+ registry.register(assistant)
1340
+ registry.register(worker)
1341
+ assistant.add_connection(worker.uuid)
1342
+ worker.add_connection(assistant.uuid)
1343
+
1344
+ worker._finalize_termination("done")
1345
+
1346
+ assert registry.get(worker.uuid) is None
1347
+ assert assistant.get_connections_snapshot() == []
1348
+ assert worker.get_connections_snapshot() == []
1349
+ finally:
1350
+ registry.reset()
1351
+
1352
+
1353
+ def test_agent_interrupts_streaming_response_and_returns_to_idle(monkeypatch):
1354
+ monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
1355
+ registry.reset()
1356
+ assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1357
+ registry.register(assistant)
1358
+ events = []
1359
+ wait_calls = 0
1360
+
1361
+ def fake_wait_for_input() -> None:
1362
+ nonlocal wait_calls
1363
+ wait_calls += 1
1364
+ if wait_calls == 1:
1365
+ assistant._append_history(
1366
+ ReceivedMessage(content="start working", from_id="human")
1367
+ )
1368
+ assistant.set_state(AgentState.RUNNING, "received message from human")
1369
+ return
1370
+ assistant.request_termination("done")
1371
+
1372
+ def fake_chat(
1373
+ messages,
1374
+ tools=None,
1375
+ on_chunk=None,
1376
+ register_interrupt=None,
1377
+ role_name=None,
1378
+ ):
1379
+ assert on_chunk is not None
1380
+ on_chunk("thinking", "Drafting plan")
1381
+ on_chunk("content", "Working")
1382
+ assert assistant.request_interrupt() is True
1383
+ on_chunk("content", " on the task")
1384
+ raise AssertionError("interrupt should stop streaming before completion")
1385
+
1386
+ monkeypatch.setattr(assistant, "_wait_for_input", fake_wait_for_input)
1387
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
1388
+ monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
1389
+
1390
+ try:
1391
+ assistant._run()
1392
+ finally:
1393
+ registry.reset()
1394
+
1395
+ history = assistant.get_history_snapshot()
1396
+
1397
+ assert any(
1398
+ isinstance(entry, AssistantThinking) and entry.content == "Drafting plan"
1399
+ for entry in history
1400
+ )
1401
+ assert any(
1402
+ isinstance(entry, AssistantText) and entry.content == "Working"
1403
+ for entry in history
1404
+ )
1405
+ assert any(
1406
+ event.type == EventType.NODE_STATE_CHANGED
1407
+ and event.data.get("new_state") == "idle"
1408
+ for event in events
1409
+ )
1410
+
1411
+
1412
+ def test_agent_normalizes_think_tags_in_streaming_content(monkeypatch):
1413
+ assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1414
+ wait_calls = 0
1415
+ events = []
1416
+ llm_calls = 0
1417
+
1418
+ def fake_wait_for_input() -> None:
1419
+ nonlocal wait_calls
1420
+ wait_calls += 1
1421
+ if wait_calls == 1:
1422
+ assistant._append_history(
1423
+ ReceivedMessage(content="reply to me", from_id="human")
1424
+ )
1425
+ assistant.set_state(AgentState.RUNNING, "received message from human")
1426
+ return
1427
+ assistant.request_termination("done")
1428
+
1429
+ def fake_chat(
1430
+ messages,
1431
+ tools=None,
1432
+ on_chunk=None,
1433
+ register_interrupt=None,
1434
+ role_name=None,
1435
+ ):
1436
+ nonlocal llm_calls
1437
+ llm_calls += 1
1438
+ if llm_calls == 2:
1439
+ assistant.request_termination("done")
1440
+ return LLMResponse()
1441
+ assert on_chunk is not None
1442
+ on_chunk("content", "<think>Drafting plan</think>\nHello there")
1443
+ return LLMResponse(content="<think>Drafting plan</think>\nHello there")
1444
+
1445
+ monkeypatch.setattr(assistant, "_wait_for_input", fake_wait_for_input)
1446
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
1447
+ monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
1448
+
1449
+ assistant._run()
1450
+
1451
+ history = assistant.get_history_snapshot()
1452
+ assistant_content_events = [
1453
+ event.data.get("content")
1454
+ for event in events
1455
+ if event.type == EventType.ASSISTANT_CONTENT
1456
+ ]
1457
+
1458
+ assert any(
1459
+ isinstance(entry, AssistantThinking) and entry.content == "Drafting plan"
1460
+ for entry in history
1461
+ )
1462
+ assert any(
1463
+ isinstance(entry, AssistantText) and entry.content == "Hello there"
1464
+ for entry in history
1465
+ )
1466
+ assert assistant_content_events == ["Hello there"]
1467
+
1468
+
1469
+ def test_agent_does_not_duplicate_thinking_when_provider_returns_both(monkeypatch):
1470
+ assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1471
+ wait_calls = 0
1472
+ llm_calls = 0
1473
+
1474
+ def fake_wait_for_input() -> None:
1475
+ nonlocal wait_calls
1476
+ wait_calls += 1
1477
+ if wait_calls == 1:
1478
+ assistant._append_history(
1479
+ ReceivedMessage(content="reply to me", from_id="human")
1480
+ )
1481
+ assistant.set_state(AgentState.RUNNING, "received message from human")
1482
+ return
1483
+ assistant.request_termination("done")
1484
+
1485
+ def fake_chat(
1486
+ messages,
1487
+ tools=None,
1488
+ on_chunk=None,
1489
+ register_interrupt=None,
1490
+ role_name=None,
1491
+ ):
1492
+ nonlocal llm_calls
1493
+ llm_calls += 1
1494
+ if llm_calls == 2:
1495
+ assistant.request_termination("done")
1496
+ return LLMResponse()
1497
+ return LLMResponse(
1498
+ content="<think>Drafting plan</think>\nHello there",
1499
+ thinking="Drafting plan",
1500
+ )
1501
+
1502
+ monkeypatch.setattr(assistant, "_wait_for_input", fake_wait_for_input)
1503
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
1504
+
1505
+ assistant._run()
1506
+
1507
+ thinking_entries = [
1508
+ entry
1509
+ for entry in assistant.get_history_snapshot()
1510
+ if isinstance(entry, AssistantThinking)
1511
+ ]
1512
+
1513
+ assert [entry.content for entry in thinking_entries] == ["Drafting plan"]
1514
+
1515
+
1516
+ def test_request_sleep_raises_interrupt_when_running_agent_is_interrupted():
1517
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent-a")
1518
+ agent.set_state(AgentState.RUNNING, "sleeping")
1519
+ assert agent.request_interrupt() is True
1520
+
1521
+ try:
1522
+ agent.request_sleep(seconds=0.2)
1523
+ except InterruptRequestedError:
1524
+ pass
1525
+ else:
1526
+ raise AssertionError("expected interrupt during sleep")
1527
+
1528
+
1529
+ def test_request_sleep_wakes_early_when_new_message_arrives():
1530
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent-a")
1531
+ agent.set_state(AgentState.RUNNING, "processing")
1532
+
1533
+ def enqueue_message() -> None:
1534
+ time.sleep(0.02)
1535
+ agent.enqueue_message(
1536
+ Message(from_id="tester", to_id=agent.uuid, content="wake up")
1537
+ )
1538
+
1539
+ wake_thread = threading.Thread(target=enqueue_message, daemon=True)
1540
+ wake_thread.start()
1541
+
1542
+ result = agent.request_sleep(seconds=0.3)
1543
+
1544
+ wake_thread.join(timeout=1.0)
1545
+
1546
+ assert result.startswith("woken by message after ")
1547
+ assert agent.state == AgentState.RUNNING
1548
+ received_entries = [
1549
+ entry
1550
+ for entry in agent.get_history_snapshot()
1551
+ if isinstance(entry, ReceivedMessage)
1552
+ ]
1553
+ assert len(received_entries) == 1
1554
+ assert received_entries[0].content == "wake up"
1555
+ assert all(entry.__class__.__name__ != "StateEntry" for entry in agent.history)
1556
+
1557
+
1558
+ def test_request_sleep_timeout_queues_deadline_notice():
1559
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent-a")
1560
+ agent.set_state(AgentState.RUNNING, "processing")
1561
+
1562
+ result = agent.request_sleep(seconds=0.01)
1563
+
1564
+ assert result.startswith("slept ")
1565
+ assert agent.state == AgentState.RUNNING
1566
+ assert agent._consume_runtime_notices() == [agent._build_sleep_deadline_notice()]
1567
+ assert all(entry.__class__.__name__ != "StateEntry" for entry in agent.history)
1568
+
1569
+
1570
+ def test_agent_interrupts_blocked_provider_without_streaming_output(monkeypatch):
1571
+ monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
1572
+ registry.reset()
1573
+ assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1574
+ registry.register(assistant)
1575
+ events = []
1576
+ wait_calls = 0
1577
+ provider_started = threading.Event()
1578
+
1579
+ def fake_wait_for_input() -> None:
1580
+ nonlocal wait_calls
1581
+ wait_calls += 1
1582
+ if wait_calls == 1:
1583
+ assistant._append_history(
1584
+ ReceivedMessage(content="start working", from_id="human")
1585
+ )
1586
+ assistant.set_state(AgentState.RUNNING, "received message from human")
1587
+ return
1588
+ assistant.request_termination("done")
1589
+
1590
+ def fake_chat(
1591
+ messages,
1592
+ tools=None,
1593
+ on_chunk=None,
1594
+ register_interrupt=None,
1595
+ role_name=None,
1596
+ ):
1597
+ closed = threading.Event()
1598
+ assert register_interrupt is not None
1599
+ register_interrupt(closed.set)
1600
+ provider_started.set()
1601
+ while not closed.wait(0.01):
1602
+ continue
1603
+ raise RuntimeError("stream closed")
1604
+
1605
+ def request_interrupt() -> None:
1606
+ provider_started.wait(timeout=1.0)
1607
+ assistant.request_interrupt()
1608
+
1609
+ interrupter = threading.Thread(target=request_interrupt, daemon=True)
1610
+ interrupter.start()
1611
+
1612
+ monkeypatch.setattr(assistant, "_wait_for_input", fake_wait_for_input)
1613
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
1614
+ monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
1615
+
1616
+ try:
1617
+ assistant._run()
1618
+ finally:
1619
+ registry.reset()
1620
+
1621
+ interrupter.join(timeout=1.0)
1622
+
1623
+ assert any(
1624
+ event.type == EventType.NODE_STATE_CHANGED
1625
+ and event.data.get("new_state") == "idle"
1626
+ for event in events
1627
+ )
1628
+
1629
+
1630
+ def test_provider_resolution_error_is_recorded_in_history(monkeypatch):
1631
+ agent = Agent(
1632
+ NodeConfig(node_type=NodeType.AGENT, allow_network=True),
1633
+ uuid="agent-y",
1634
+ )
1635
+ wait_calls = 0
1636
+
1637
+ def fake_wait_for_input() -> None:
1638
+ nonlocal wait_calls
1639
+ wait_calls += 1
1640
+ if wait_calls == 1:
1641
+ agent._append_history(
1642
+ ReceivedMessage(content="do the task", from_id="tester")
1643
+ )
1644
+ agent.set_state(AgentState.RUNNING, "received message from tester")
1645
+ return
1646
+ agent.request_termination("stop")
1647
+
1648
+ monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
1649
+ monkeypatch.setattr(
1650
+ "flowent.agent.gateway.chat",
1651
+ lambda messages, tools=None, on_chunk=None, register_interrupt=None, role_name=None: (
1652
+ (_ for _ in ()).throw(RuntimeError("No active provider configured"))
1653
+ ),
1654
+ )
1655
+
1656
+ agent._run()
1657
+
1658
+ assert wait_calls == 2
1659
+ assert agent.state == AgentState.TERMINATED
1660
+ assert any(
1661
+ isinstance(entry, ErrorEntry)
1662
+ and "No active provider configured" in entry.content
1663
+ for entry in agent.get_history_snapshot()
1664
+ )
1665
+
1666
+
1667
+ def test_assistant_content_streams_even_when_response_has_tool_calls(monkeypatch):
1668
+ registry.reset()
1669
+ assistant = Agent(
1670
+ NodeConfig(node_type=NodeType.ASSISTANT, tools=["idle"]),
1671
+ )
1672
+ registry.register(assistant)
1673
+ events = []
1674
+
1675
+ def fake_wait_for_input() -> None:
1676
+ assistant._append_history(
1677
+ ReceivedMessage(content="report progress", from_id="human")
1678
+ )
1679
+ assistant.set_state(AgentState.RUNNING, "received message from human")
1680
+
1681
+ def fake_chat(
1682
+ messages,
1683
+ tools=None,
1684
+ on_chunk=None,
1685
+ register_interrupt=None,
1686
+ role_name=None,
1687
+ ):
1688
+ if on_chunk is not None:
1689
+ on_chunk("content", "Working on it")
1690
+ return LLMResponse(
1691
+ content="Working on it",
1692
+ tool_calls=[
1693
+ ToolCallResult(
1694
+ id="call-idle",
1695
+ name="idle",
1696
+ arguments={},
1697
+ )
1698
+ ],
1699
+ )
1700
+
1701
+ monkeypatch.setattr(assistant, "_wait_for_input", fake_wait_for_input)
1702
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
1703
+ monkeypatch.setattr(
1704
+ assistant,
1705
+ "_handle_tool_call",
1706
+ lambda name, arguments, call_id: assistant.request_termination("done"),
1707
+ )
1708
+ monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
1709
+
1710
+ assistant._run()
1711
+
1712
+ assistant_events = [
1713
+ event for event in events if event.type == EventType.ASSISTANT_CONTENT
1714
+ ]
1715
+ assert [event.data for event in assistant_events] == [{"content": "Working on it"}]
1716
+
1717
+
1718
+ def test_send_message_delivers_to_single_contact_and_records_histories(monkeypatch):
1719
+ registry.reset()
1720
+ _register_tab_leader()
1721
+ child = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="child")
1722
+ peer = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="peer")
1723
+ registry.register(child)
1724
+ registry.register(peer)
1725
+ _add_agent_path(source_id="child", target_id="peer")
1726
+ events = []
1727
+
1728
+ monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
1729
+
1730
+ try:
1731
+ result = json.loads(
1732
+ child.send_message(
1733
+ target_ref="peer",
1734
+ from_output_port_key="out",
1735
+ to_input_port_key="in",
1736
+ raw_parts=[{"type": "text", "text": "investigate the error"}],
1737
+ )
1738
+ )
1739
+ finally:
1740
+ registry.reset()
1741
+
1742
+ sent_entry = next(
1743
+ entry
1744
+ for entry in child.get_history_snapshot()
1745
+ if isinstance(entry, SentMessage)
1746
+ )
1747
+ received_entry = next(
1748
+ entry
1749
+ for entry in peer.get_history_snapshot()
1750
+ if isinstance(entry, ReceivedMessage)
1751
+ )
1752
+ signal = peer._wake_queue.get_nowait()
1753
+
1754
+ assert result == {
1755
+ "status": "sent",
1756
+ "target_id": "peer",
1757
+ "from_output_port_key": "out",
1758
+ "to_input_port_key": "in",
1759
+ "port_type": "parts",
1760
+ "value_summary": "investigate the error",
1761
+ }
1762
+ assert sent_entry.to_id == "peer"
1763
+ assert sent_entry.content == "investigate the error"
1764
+ assert sent_entry.from_output_port_key == "out"
1765
+ assert sent_entry.to_input_port_key == "in"
1766
+ assert sent_entry.value_summary == "investigate the error"
1767
+ assert received_entry.from_id == "child"
1768
+ assert received_entry.content == "investigate the error"
1769
+ assert received_entry.from_output_port_key == "out"
1770
+ assert received_entry.to_input_port_key == "in"
1771
+ assert received_entry.value_summary == "investigate the error"
1772
+ assert sent_entry.message_id == received_entry.message_id
1773
+ assert signal.payload == {
1774
+ "message": {
1775
+ "from": "child",
1776
+ "content": "investigate the error",
1777
+ "parts": [{"type": "text", "text": "investigate the error"}],
1778
+ "history_recorded": True,
1779
+ "message_id": sent_entry.message_id,
1780
+ "from_output_port_key": "out",
1781
+ "to_input_port_key": "in",
1782
+ "port_type": "parts",
1783
+ "value": [{"type": "text", "text": "investigate the error"}],
1784
+ "value_summary": "investigate the error",
1785
+ }
1786
+ }
1787
+ assert [event.data for event in events if event.type == EventType.NODE_MESSAGE] == [
1788
+ {
1789
+ "to_id": "peer",
1790
+ "content": "investigate the error",
1791
+ "message_id": sent_entry.message_id,
1792
+ "from_output_port_key": "out",
1793
+ "to_input_port_key": "in",
1794
+ "port_type": "parts",
1795
+ }
1796
+ ]
1797
+
1798
+
1799
+ def test_send_message_reports_error_when_target_is_not_in_contacts():
1800
+ registry.reset()
1801
+ _register_tab_leader()
1802
+ child = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="child")
1803
+ peer = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="peer")
1804
+ registry.register(child)
1805
+ registry.register(peer)
1806
+
1807
+ try:
1808
+ with pytest.raises(
1809
+ ValueError,
1810
+ match=r"Send failed: target `peer` is not connected from `out` to `in`\.",
1811
+ ):
1812
+ child.send_message(
1813
+ target_ref="peer",
1814
+ from_output_port_key="out",
1815
+ to_input_port_key="in",
1816
+ raw_parts=[{"type": "text", "text": "reply with the findings"}],
1817
+ )
1818
+ finally:
1819
+ registry.reset()
1820
+
1821
+
1822
+ def test_send_message_validates_target_before_image_capability():
1823
+ registry.reset()
1824
+ _register_tab_leader()
1825
+ child = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="child")
1826
+ peer = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="peer")
1827
+ registry.register(child)
1828
+ registry.register(peer)
1829
+
1830
+ try:
1831
+ with pytest.raises(
1832
+ ValueError,
1833
+ match=r"Send failed: target `peer` is not connected from `out` to `in`\.",
1834
+ ):
1835
+ child.send_message(
1836
+ target_ref="peer",
1837
+ from_output_port_key="out",
1838
+ to_input_port_key="in",
1839
+ raw_parts=[{"type": "image", "asset_id": "asset-1"}],
1840
+ )
1841
+ finally:
1842
+ registry.reset()
1843
+
1844
+
1845
+ def test_send_message_reports_error_when_target_lacks_input_image_support():
1846
+ registry.reset()
1847
+ _register_tab_leader()
1848
+ child = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="child")
1849
+ peer = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="peer")
1850
+ registry.register(child)
1851
+ registry.register(peer)
1852
+ _add_agent_path(source_id="child", target_id="peer")
1853
+
1854
+ try:
1855
+ with pytest.raises(
1856
+ ValueError,
1857
+ match=r"Send failed: target `peer` does not support `input_image`\.",
1858
+ ):
1859
+ child.send_message(
1860
+ target_ref="peer",
1861
+ from_output_port_key="out",
1862
+ to_input_port_key="in",
1863
+ raw_parts=[{"type": "image", "asset_id": "asset-1"}],
1864
+ )
1865
+ finally:
1866
+ registry.reset()
1867
+
1868
+
1869
+ def test_inactive_leader_cannot_send_work_to_agent_nodes():
1870
+ registry.reset()
1871
+ leader = _register_tab_leader()
1872
+ worker = Agent(
1873
+ NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1", name="Worker"),
1874
+ uuid="worker",
1875
+ )
1876
+ registry.register(worker)
1877
+
1878
+ try:
1879
+ with pytest.raises(
1880
+ ValueError,
1881
+ match=r"Activate this workflow before sending work to agent nodes\.",
1882
+ ):
1883
+ leader.send_message(
1884
+ target_ref="worker",
1885
+ raw_parts=[{"type": "text", "text": "start the task"}],
1886
+ )
1887
+ assert not any(
1888
+ isinstance(entry, ReceivedMessage)
1889
+ for entry in worker.get_history_snapshot()
1890
+ )
1891
+
1892
+ tab = workspace_store.get_tab("tab-1")
1893
+ assert tab is not None
1894
+ tab.activation_state = WorkflowActivationState.ACTIVE
1895
+ workspace_store.upsert_tab(tab)
1896
+
1897
+ result = json.loads(
1898
+ leader.send_message(
1899
+ target_ref="worker",
1900
+ raw_parts=[{"type": "text", "text": "start the task"}],
1901
+ )
1902
+ )
1903
+ assert result == {"status": "sent", "target_id": "worker"}
1904
+ finally:
1905
+ registry.reset()
1906
+
1907
+
1908
+ def test_record_content_output_treats_target_like_text_as_plain_output(monkeypatch):
1909
+ registry.reset()
1910
+ assistant = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
1911
+ registry.register(assistant)
1912
+ events = []
1913
+
1914
+ monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
1915
+
1916
+ try:
1917
+ assistant._record_content_output(
1918
+ "@worker: do the follow-up task",
1919
+ emitted_human_content=False,
1920
+ )
1921
+ finally:
1922
+ registry.reset()
1923
+
1924
+ history = assistant.get_history_snapshot()
1925
+ assert isinstance(history[-1], AssistantText)
1926
+ assert history[-1].content == "@worker: do the follow-up task"
1927
+ assert not any(isinstance(entry, SentMessage) for entry in history)
1928
+ assert any(
1929
+ event.type == EventType.ASSISTANT_CONTENT
1930
+ and event.data == {"content": "@worker: do the follow-up task"}
1931
+ for event in events
1932
+ )
1933
+
1934
+
1935
+ def test_handle_tool_call_send_success_omits_toolcall_history(monkeypatch):
1936
+ registry.reset()
1937
+ _register_tab_leader()
1938
+ child = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="child")
1939
+ peer = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="peer")
1940
+ registry.register(child)
1941
+ registry.register(peer)
1942
+ _add_agent_path(source_id="child", target_id="peer")
1943
+
1944
+ try:
1945
+ result = child._handle_tool_call(
1946
+ "send",
1947
+ {
1948
+ "target": "peer",
1949
+ "from_output_port_key": "out",
1950
+ "to_input_port_key": "in",
1951
+ "value": [{"type": "text", "text": "reply with the findings"}],
1952
+ },
1953
+ "call-send",
1954
+ )
1955
+ finally:
1956
+ registry.reset()
1957
+
1958
+ assert json.loads(result) == {
1959
+ "status": "sent",
1960
+ "target_id": "peer",
1961
+ "from_output_port_key": "out",
1962
+ "to_input_port_key": "in",
1963
+ "port_type": "parts",
1964
+ "value_summary": "reply with the findings",
1965
+ }
1966
+ assert not any(
1967
+ isinstance(entry, ToolCall) and entry.tool_call_id == "call-send"
1968
+ for entry in child.get_history_snapshot()
1969
+ )
1970
+ assert any(isinstance(entry, SentMessage) for entry in child.get_history_snapshot())
1971
+
1972
+
1973
+ def test_handle_tool_call_send_failure_records_error_without_toolcall():
1974
+ registry.reset()
1975
+ _register_tab_leader()
1976
+ child = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="child")
1977
+ peer = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="peer")
1978
+ registry.register(child)
1979
+ registry.register(peer)
1980
+
1981
+ try:
1982
+ result = child._handle_tool_call(
1983
+ "send",
1984
+ {
1985
+ "target": "peer",
1986
+ "from_output_port_key": "out",
1987
+ "to_input_port_key": "in",
1988
+ "value": [{"type": "text", "text": "reply with the findings"}],
1989
+ },
1990
+ "call-send",
1991
+ )
1992
+ finally:
1993
+ registry.reset()
1994
+
1995
+ assert json.loads(result) == {
1996
+ "error": "Send failed: target `peer` is not connected from `out` to `in`."
1997
+ }
1998
+ assert not any(
1999
+ isinstance(entry, ToolCall) and entry.tool_call_id == "call-send"
2000
+ for entry in child.get_history_snapshot()
2001
+ )
2002
+ assert any(
2003
+ isinstance(entry, ErrorEntry)
2004
+ and entry.content
2005
+ == "Send failed: target `peer` is not connected from `out` to `in`."
2006
+ for entry in child.get_history_snapshot()
2007
+ )
2008
+
2009
+
2010
+ def test_multiple_send_tool_calls_stop_after_first_failure(monkeypatch):
2011
+ registry.reset()
2012
+ _register_tab_leader()
2013
+ child = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="child")
2014
+ peer = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="peer")
2015
+ helper = Agent(NodeConfig(node_type=NodeType.AGENT, tab_id="tab-1"), uuid="helper")
2016
+ registry.register(child)
2017
+ registry.register(peer)
2018
+ registry.register(helper)
2019
+ _add_agent_path(source_id="child", target_id="peer")
2020
+
2021
+ wait_calls = 0
2022
+ chat_calls = 0
2023
+
2024
+ def fake_wait_for_input() -> None:
2025
+ nonlocal wait_calls
2026
+ wait_calls += 1
2027
+ if wait_calls == 1:
2028
+ child._append_history(ReceivedMessage(content="begin", from_id="human"))
2029
+ child.set_state(AgentState.RUNNING, "received message from human")
2030
+ return
2031
+ child.request_termination("done")
2032
+
2033
+ def fake_chat(
2034
+ messages,
2035
+ tools=None,
2036
+ on_chunk=None,
2037
+ register_interrupt=None,
2038
+ role_name=None,
2039
+ ):
2040
+ nonlocal chat_calls
2041
+ chat_calls += 1
2042
+ if chat_calls == 1:
2043
+ return LLMResponse(
2044
+ tool_calls=[
2045
+ ToolCallResult(
2046
+ id="call-send-1",
2047
+ name="send",
2048
+ arguments={
2049
+ "target": "peer",
2050
+ "from_output_port_key": "out",
2051
+ "to_input_port_key": "in",
2052
+ "value": [{"type": "text", "text": "first"}],
2053
+ },
2054
+ ),
2055
+ ToolCallResult(
2056
+ id="call-send-2",
2057
+ name="send",
2058
+ arguments={
2059
+ "target": "helper",
2060
+ "from_output_port_key": "out",
2061
+ "to_input_port_key": "in",
2062
+ "value": [{"type": "text", "text": "second"}],
2063
+ },
2064
+ ),
2065
+ ToolCallResult(
2066
+ id="call-send-3",
2067
+ name="send",
2068
+ arguments={
2069
+ "target": "peer",
2070
+ "from_output_port_key": "out",
2071
+ "to_input_port_key": "in",
2072
+ "value": [{"type": "text", "text": "third"}],
2073
+ },
2074
+ ),
2075
+ ]
2076
+ )
2077
+ child.request_termination("done")
2078
+ return LLMResponse()
2079
+
2080
+ monkeypatch.setattr(child, "_wait_for_input", fake_wait_for_input)
2081
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
2082
+
2083
+ try:
2084
+ child._run()
2085
+ finally:
2086
+ registry.reset()
2087
+
2088
+ sent_entries = [
2089
+ entry
2090
+ for entry in child.get_history_snapshot()
2091
+ if isinstance(entry, SentMessage)
2092
+ ]
2093
+ error_entries = [
2094
+ entry for entry in child.get_history_snapshot() if isinstance(entry, ErrorEntry)
2095
+ ]
2096
+
2097
+ assert [entry.content for entry in sent_entries] == ["first"]
2098
+ assert [
2099
+ entry.content
2100
+ for entry in peer.get_history_snapshot()
2101
+ if isinstance(entry, ReceivedMessage)
2102
+ ] == ["first"]
2103
+ assert helper._wake_queue.empty()
2104
+ assert any(
2105
+ entry.content
2106
+ == "Send failed: target `helper` is not connected from `out` to `in`."
2107
+ for entry in error_entries
2108
+ )
2109
+
2110
+
2111
+ def test_build_messages_replays_sent_messages_as_message_to_context(monkeypatch):
2112
+ monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2113
+
2114
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2115
+ agent._append_history(ReceivedMessage(content="begin", from_id="human"))
2116
+ agent._append_history(SentMessage(content="to peer", to_id="peer"))
2117
+ agent._append_history(AssistantText(content="final answer"))
2118
+
2119
+ messages = agent._build_messages()
2120
+
2121
+ assert messages == [
2122
+ {"role": "system", "content": messages[0]["content"]},
2123
+ {"role": "user", "content": '<message from="human">begin</message>'},
2124
+ {"role": "assistant", "content": '<message to="peer">to peer</message>'},
2125
+ {"role": "assistant", "content": "final answer"},
2126
+ {
2127
+ "role": "user",
2128
+ "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>",
2129
+ },
2130
+ ]
2131
+
2132
+
2133
+ def test_context_preflight_prefers_usage_baseline_and_estimates_only_new_tail(
2134
+ monkeypatch,
2135
+ ):
2136
+ monkeypatch.setattr(
2137
+ "flowent.agent.get_settings",
2138
+ lambda: Settings(
2139
+ providers=[
2140
+ ProviderConfig(
2141
+ id="provider-1",
2142
+ name="Primary",
2143
+ type="openai_responses",
2144
+ base_url="https://api.example.com/v1",
2145
+ api_key="secret",
2146
+ )
2147
+ ],
2148
+ model=ModelSettings(
2149
+ active_provider_id="provider-1",
2150
+ active_model="gpt-5.2",
2151
+ auto_compact_token_limit=48_000,
2152
+ ),
2153
+ ),
2154
+ )
2155
+
2156
+ agent = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
2157
+ agent._append_history(ReceivedMessage(content="first", from_id="human"))
2158
+
2159
+ baseline_context = agent._build_prepared_llm_context()
2160
+ agent._record_context_token_usage_baseline(
2161
+ prepared_context=baseline_context,
2162
+ usage=LLMUsage(
2163
+ total_tokens=4_200,
2164
+ input_tokens=3_000,
2165
+ output_tokens=1_200,
2166
+ ),
2167
+ )
2168
+
2169
+ agent._append_history(
2170
+ ToolCall(
2171
+ tool_name="read",
2172
+ tool_call_id="call-read",
2173
+ arguments={"path": "README.md"},
2174
+ result="done",
2175
+ )
2176
+ )
2177
+ next_context = agent._build_prepared_llm_context()
2178
+ preflight = agent._compute_context_preflight(next_context)
2179
+
2180
+ expected_tail_tokens = agent._estimate_input_tokens(
2181
+ next_context.execution_context_messages[
2182
+ len(baseline_context.execution_context_messages) :
2183
+ ]
2184
+ )
2185
+
2186
+ assert preflight.estimated_total_tokens == 4_200 + expected_tail_tokens
2187
+ assert preflight.auto_compact_token_limit == 48_000
2188
+ assert preflight.context_window_tokens == 128_000
2189
+
2190
+
2191
+ def test_context_preflight_bootstraps_again_when_runtime_tail_changes(monkeypatch):
2192
+ monkeypatch.setattr(
2193
+ "flowent.agent.get_settings",
2194
+ lambda: Settings(
2195
+ providers=[
2196
+ ProviderConfig(
2197
+ id="provider-1",
2198
+ name="Primary",
2199
+ type="openai_responses",
2200
+ base_url="https://api.example.com/v1",
2201
+ api_key="secret",
2202
+ )
2203
+ ],
2204
+ model=ModelSettings(
2205
+ active_provider_id="provider-1",
2206
+ active_model="gpt-5.2",
2207
+ auto_compact_token_limit=48_000,
2208
+ ),
2209
+ ),
2210
+ )
2211
+
2212
+ agent = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
2213
+ agent._append_history(ReceivedMessage(content="first", from_id="human"))
2214
+
2215
+ baseline_context = agent._build_prepared_llm_context()
2216
+ agent._record_context_token_usage_baseline(
2217
+ prepared_context=baseline_context,
2218
+ usage=LLMUsage(total_tokens=4_200),
2219
+ )
2220
+
2221
+ agent.set_todos([TodoItem(text="Inspect files")])
2222
+ next_context = agent._build_prepared_llm_context()
2223
+ preflight = agent._compute_context_preflight(next_context)
2224
+
2225
+ assert preflight.estimated_total_tokens == agent._estimate_input_tokens(
2226
+ next_context.messages
2227
+ )
2228
+
2229
+
2230
+ def test_prepare_messages_for_llm_uses_token_limit_even_without_context_window(
2231
+ monkeypatch,
2232
+ ):
2233
+ monkeypatch.setattr(
2234
+ "flowent.agent.get_settings",
2235
+ lambda: Settings(
2236
+ providers=[
2237
+ ProviderConfig(
2238
+ id="provider-1",
2239
+ name="Primary",
2240
+ type="openai_compatible",
2241
+ base_url="https://api.example.com/v1",
2242
+ api_key="secret",
2243
+ )
2244
+ ],
2245
+ model=ModelSettings(
2246
+ active_provider_id="provider-1",
2247
+ active_model="custom-model",
2248
+ auto_compact_token_limit=1,
2249
+ ),
2250
+ ),
2251
+ )
2252
+
2253
+ agent = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
2254
+ agent._append_history(ReceivedMessage(content="hello", from_id="human"))
2255
+ compact_calls: list[str] = []
2256
+
2257
+ monkeypatch.setattr(
2258
+ agent,
2259
+ "_compact_execution_context",
2260
+ lambda focus=None: compact_calls.append("compact") or "",
2261
+ )
2262
+
2263
+ prepared_context = agent._prepare_messages_for_llm()
2264
+
2265
+ assert compact_calls == ["compact"]
2266
+ assert len(prepared_context.messages) > 0
2267
+
2268
+
2269
+ def test_idle_is_blocked_when_fresh_input_has_no_progress(monkeypatch):
2270
+ monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2271
+ agent = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
2272
+ agent.set_state(AgentState.RUNNING, "processing")
2273
+ agent._turn_started_with_pending_input = True
2274
+ agent._turn_made_progress = False
2275
+
2276
+ idle_result = agent.request_idle()
2277
+ messages = agent._build_messages()
2278
+
2279
+ reminder = (
2280
+ "<system>Idle reminder: you received a new message this turn, but this "
2281
+ "response did not send a reply, call `send`, or use any non-idle "
2282
+ "tool. Do not call `idle` yet. First reply to the Human, dispatch/"
2283
+ "delegate work, or take another concrete step.</system>"
2284
+ )
2285
+
2286
+ assert idle_result == ""
2287
+ assert agent.state == AgentState.RUNNING
2288
+ assert any(msg.get("content") == reminder for msg in messages)
2289
+
2290
+
2291
+ def test_idle_is_blocked_when_first_todo_is_actionable(monkeypatch):
2292
+ monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2293
+ agent = Agent(NodeConfig(node_type=NodeType.ASSISTANT), uuid="assistant")
2294
+ agent.set_state(AgentState.RUNNING, "processing")
2295
+ agent.set_todos(
2296
+ [
2297
+ TodoItem(text="Forward pyproject summary to Project Synthesizer"),
2298
+ TodoItem(text="Wait for final synthesis"),
2299
+ ]
2300
+ )
2301
+
2302
+ idle_result = agent.request_idle()
2303
+ messages = agent._build_messages()
2304
+
2305
+ reminder = (
2306
+ "<system>Idle reminder: your first remaining TODO still looks actionable "
2307
+ "(`Forward pyproject summary to Project Synthesizer`). Do that next, or "
2308
+ "update the TODO list so the first remaining item is the actual waiting "
2309
+ "step, before calling `idle`.</system>"
2310
+ )
2311
+
2312
+ assert idle_result == ""
2313
+ assert agent.state == AgentState.RUNNING
2314
+ assert any(msg.get("content") == reminder for msg in messages)
2315
+
2316
+
2317
+ def test_build_messages_appends_runtime_todo_context_without_history_entry(monkeypatch):
2318
+ monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2319
+
2320
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2321
+ agent._append_history(ReceivedMessage(content="begin", from_id="human"))
2322
+ agent.set_todos([TodoItem(text="Inspect files"), TodoItem(text="Report results")])
2323
+
2324
+ messages = agent._build_messages()
2325
+ history = agent.get_history_snapshot()
2326
+
2327
+ received_entries = [
2328
+ entry for entry in history if isinstance(entry, ReceivedMessage)
2329
+ ]
2330
+ assert len(received_entries) == 1
2331
+ assert messages == [
2332
+ {"role": "system", "content": messages[0]["content"]},
2333
+ {"role": "user", "content": '<message from="human">begin</message>'},
2334
+ {
2335
+ "role": "user",
2336
+ "content": "<system>Current TODO list:\n - Inspect files\n - Report results</system>",
2337
+ },
2338
+ {
2339
+ "role": "user",
2340
+ "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>",
2341
+ },
2342
+ ]
2343
+
2344
+
2345
+ def test_build_messages_appends_runtime_post_prompt_and_idle_guidance(monkeypatch):
2346
+ monkeypatch.setattr(
2347
+ "flowent.agent.get_settings",
2348
+ lambda: Settings(custom_post_prompt="Append this after history."),
2349
+ )
2350
+
2351
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2352
+ agent._append_history(ReceivedMessage(content="begin", from_id="human"))
2353
+ agent.set_todos([TodoItem(text="Inspect files")])
2354
+ agent.set_todos([])
2355
+
2356
+ messages = agent._build_messages()
2357
+ history = agent.get_history_snapshot()
2358
+
2359
+ received_entries = [
2360
+ entry for entry in history if isinstance(entry, ReceivedMessage)
2361
+ ]
2362
+ assert len(received_entries) == 1
2363
+ assert messages == [
2364
+ {"role": "system", "content": messages[0]["content"]},
2365
+ {"role": "user", "content": '<message from="human">begin</message>'},
2366
+ {
2367
+ "role": "user",
2368
+ "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>",
2369
+ },
2370
+ {
2371
+ "role": "user",
2372
+ "content": "<system>Append this after history.</system>",
2373
+ },
2374
+ ]
2375
+
2376
+
2377
+ def test_build_messages_warns_about_newly_created_agents_waiting_for_first_task(
2378
+ monkeypatch,
2379
+ ):
2380
+ monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2381
+
2382
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2383
+ agent._append_history(ReceivedMessage(content="begin", from_id="human"))
2384
+ agent._append_history(
2385
+ ToolCall(
2386
+ tool_name="create_agent",
2387
+ tool_call_id="call-create-agent",
2388
+ arguments={
2389
+ "role_name": "Worker",
2390
+ "name": "Directory Worker",
2391
+ },
2392
+ result=json.dumps(
2393
+ {
2394
+ "id": "12345678-aaaa-bbbb-cccc-ddddeeeeffff",
2395
+ "config": {
2396
+ "node_type": "agent",
2397
+ "role_name": "Worker",
2398
+ "tab_id": "tab-1",
2399
+ "name": "Directory Worker",
2400
+ "tools": ["idle", "sleep", "todo", "contacts", "read"],
2401
+ "write_dirs": [],
2402
+ "allow_network": False,
2403
+ },
2404
+ "state": "initializing",
2405
+ "todos": [],
2406
+ "history": [],
2407
+ "position": None,
2408
+ "created_at": 1.0,
2409
+ "updated_at": 1.0,
2410
+ }
2411
+ ),
2412
+ )
2413
+ )
2414
+
2415
+ messages = agent._build_messages()
2416
+
2417
+ assert messages == [
2418
+ {"role": "system", "content": messages[0]["content"]},
2419
+ {"role": "user", "content": '<message from="human">begin</message>'},
2420
+ {
2421
+ "role": "assistant",
2422
+ "tool_calls": [
2423
+ {
2424
+ "id": "call-create-agent",
2425
+ "type": "function",
2426
+ "function": {
2427
+ "name": "create_agent",
2428
+ "arguments": '{"role_name": "Worker", "name": "Directory Worker"}',
2429
+ },
2430
+ }
2431
+ ],
2432
+ },
2433
+ {
2434
+ "role": "tool",
2435
+ "tool_call_id": "call-create-agent",
2436
+ "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}',
2437
+ },
2438
+ {
2439
+ "role": "user",
2440
+ "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>",
2441
+ },
2442
+ ]
2443
+
2444
+
2445
+ def test_build_messages_uses_role_name_when_created_agent_has_no_explicit_name(
2446
+ monkeypatch,
2447
+ ):
2448
+ monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2449
+
2450
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2451
+ agent._append_history(ReceivedMessage(content="begin", from_id="human"))
2452
+ agent._append_history(
2453
+ ToolCall(
2454
+ tool_name="create_agent",
2455
+ tool_call_id="call-create-agent",
2456
+ arguments={"role_name": "Worker"},
2457
+ result=json.dumps(
2458
+ {
2459
+ "id": "12345678-aaaa-bbbb-cccc-ddddeeeeffff",
2460
+ "config": {
2461
+ "node_type": "agent",
2462
+ "role_name": "Worker",
2463
+ "tab_id": "tab-1",
2464
+ "name": None,
2465
+ "tools": ["idle", "sleep", "todo", "contacts", "read"],
2466
+ "write_dirs": [],
2467
+ "allow_network": False,
2468
+ },
2469
+ "state": "initializing",
2470
+ "todos": [],
2471
+ "history": [],
2472
+ "position": None,
2473
+ "created_at": 1.0,
2474
+ "updated_at": 1.0,
2475
+ }
2476
+ ),
2477
+ )
2478
+ )
2479
+
2480
+ messages = agent._build_messages()
2481
+
2482
+ assert messages == [
2483
+ {"role": "system", "content": messages[0]["content"]},
2484
+ {"role": "user", "content": '<message from="human">begin</message>'},
2485
+ {
2486
+ "role": "assistant",
2487
+ "tool_calls": [
2488
+ {
2489
+ "id": "call-create-agent",
2490
+ "type": "function",
2491
+ "function": {
2492
+ "name": "create_agent",
2493
+ "arguments": '{"role_name": "Worker"}',
2494
+ },
2495
+ }
2496
+ ],
2497
+ },
2498
+ {
2499
+ "role": "tool",
2500
+ "tool_call_id": "call-create-agent",
2501
+ "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}',
2502
+ },
2503
+ {
2504
+ "role": "user",
2505
+ "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>",
2506
+ },
2507
+ ]
2508
+
2509
+
2510
+ def test_build_messages_clears_new_agent_warning_after_first_sent_message(monkeypatch):
2511
+ monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2512
+
2513
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2514
+ agent._append_history(ReceivedMessage(content="begin", from_id="human"))
2515
+ agent._append_history(
2516
+ ToolCall(
2517
+ tool_name="create_agent",
2518
+ tool_call_id="call-create-agent",
2519
+ arguments={
2520
+ "role_name": "Worker",
2521
+ "name": "Directory Worker",
2522
+ },
2523
+ result=json.dumps(
2524
+ {
2525
+ "id": "12345678-aaaa-bbbb-cccc-ddddeeeeffff",
2526
+ "config": {
2527
+ "node_type": "agent",
2528
+ "role_name": "Worker",
2529
+ "tab_id": "tab-1",
2530
+ "name": "Directory Worker",
2531
+ "tools": ["idle", "sleep", "todo", "contacts", "read"],
2532
+ "write_dirs": [],
2533
+ "allow_network": False,
2534
+ },
2535
+ "state": "initializing",
2536
+ "todos": [],
2537
+ "history": [],
2538
+ "position": None,
2539
+ "created_at": 1.0,
2540
+ "updated_at": 1.0,
2541
+ }
2542
+ ),
2543
+ )
2544
+ )
2545
+ agent._append_history(
2546
+ SentMessage(
2547
+ content="inspect the current directory",
2548
+ to_id="12345678-aaaa-bbbb-cccc-ddddeeeeffff",
2549
+ )
2550
+ )
2551
+
2552
+ messages = agent._build_messages()
2553
+
2554
+ assert messages == [
2555
+ {"role": "system", "content": messages[0]["content"]},
2556
+ {"role": "user", "content": '<message from="human">begin</message>'},
2557
+ {
2558
+ "role": "assistant",
2559
+ "tool_calls": [
2560
+ {
2561
+ "id": "call-create-agent",
2562
+ "type": "function",
2563
+ "function": {
2564
+ "name": "create_agent",
2565
+ "arguments": '{"role_name": "Worker", "name": "Directory Worker"}',
2566
+ },
2567
+ }
2568
+ ],
2569
+ },
2570
+ {
2571
+ "role": "tool",
2572
+ "tool_call_id": "call-create-agent",
2573
+ "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}',
2574
+ },
2575
+ {
2576
+ "role": "assistant",
2577
+ "content": '<message to="12345678-aaaa-bbbb-cccc-ddddeeeeffff">inspect the current directory</message>',
2578
+ },
2579
+ {
2580
+ "role": "user",
2581
+ "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>",
2582
+ },
2583
+ ]
2584
+
2585
+
2586
+ def test_build_messages_keeps_sleep_tool_results_in_context(monkeypatch):
2587
+ monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2588
+
2589
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2590
+ agent._append_history(
2591
+ ReceivedMessage(content="pause before continuing", from_id="human")
2592
+ )
2593
+ agent._append_history(
2594
+ ToolCall(
2595
+ tool_name="sleep",
2596
+ tool_call_id="call-sleep",
2597
+ arguments={"seconds": 0.5},
2598
+ result="slept 0.50s",
2599
+ )
2600
+ )
2601
+
2602
+ messages = agent._build_messages()
2603
+
2604
+ assert any(
2605
+ msg.get("role") == "assistant"
2606
+ and msg.get("tool_calls")
2607
+ == [
2608
+ {
2609
+ "id": "call-sleep",
2610
+ "type": "function",
2611
+ "function": {
2612
+ "name": "sleep",
2613
+ "arguments": '{"seconds": 0.5}',
2614
+ },
2615
+ }
2616
+ ]
2617
+ for msg in messages
2618
+ )
2619
+ assert any(
2620
+ msg.get("role") == "tool"
2621
+ and msg.get("tool_call_id") == "call-sleep"
2622
+ and msg.get("content") == "slept 0.50s"
2623
+ for msg in messages
2624
+ )
2625
+
2626
+
2627
+ def test_build_messages_keeps_idle_tool_results_in_context(monkeypatch):
2628
+ monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2629
+
2630
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2631
+ agent._append_history(ReceivedMessage(content="resume after wait", from_id="human"))
2632
+ agent._append_history(
2633
+ ToolCall(
2634
+ tool_name="idle",
2635
+ tool_call_id="call-idle",
2636
+ arguments={},
2637
+ result="idle 1.25s",
2638
+ )
2639
+ )
2640
+
2641
+ messages = agent._build_messages()
2642
+
2643
+ assert any(
2644
+ msg.get("role") == "assistant"
2645
+ and msg.get("tool_calls")
2646
+ == [
2647
+ {
2648
+ "id": "call-idle",
2649
+ "type": "function",
2650
+ "function": {
2651
+ "name": "idle",
2652
+ "arguments": "{}",
2653
+ },
2654
+ }
2655
+ ]
2656
+ for msg in messages
2657
+ )
2658
+ assert any(
2659
+ msg.get("role") == "tool"
2660
+ and msg.get("tool_call_id") == "call-idle"
2661
+ and msg.get("content") == "idle 1.25s"
2662
+ for msg in messages
2663
+ )
2664
+
2665
+
2666
+ def test_build_messages_keeps_error_entries_in_context(monkeypatch):
2667
+ monkeypatch.setattr("flowent.agent.get_settings", lambda: Settings())
2668
+
2669
+ agent = Agent(NodeConfig(node_type=NodeType.AGENT), uuid="agent")
2670
+ agent._append_history(ReceivedMessage(content="begin", from_id="human"))
2671
+ agent._append_history(ErrorEntry(content="RuntimeError: boom\n\ntraceback"))
2672
+
2673
+ messages = agent._build_messages()
2674
+
2675
+ assert any(
2676
+ msg.get("role") == "user"
2677
+ and msg.get("content")
2678
+ == "<system>Previous runtime error:\nRuntimeError: boom\n\ntraceback</system>"
2679
+ for msg in messages
2680
+ )
2681
+
2682
+
2683
+ def test_assistant_emits_human_content_for_plain_text_with_target_like_prefix(
2684
+ monkeypatch,
2685
+ ):
2686
+ registry.reset()
2687
+ _register_tab_leader()
2688
+ assistant = Agent(
2689
+ NodeConfig(node_type=NodeType.ASSISTANT),
2690
+ uuid="assistant",
2691
+ )
2692
+ registry.register(assistant)
2693
+ events = []
2694
+ responses = iter(
2695
+ [LLMResponse(content="@leader: investigate the error"), LLMResponse()]
2696
+ )
2697
+
2698
+ def fake_wait_for_input() -> None:
2699
+ assistant._append_history(
2700
+ ReceivedMessage(content="please investigate", from_id="human")
2701
+ )
2702
+ assistant.set_state(AgentState.RUNNING, "received message from human")
2703
+
2704
+ def fake_chat(
2705
+ messages,
2706
+ tools=None,
2707
+ on_chunk=None,
2708
+ register_interrupt=None,
2709
+ role_name=None,
2710
+ ):
2711
+ response = next(responses)
2712
+ if response.content and on_chunk is not None:
2713
+ on_chunk("content", response.content)
2714
+ if response.content is None:
2715
+ assistant.request_termination("done")
2716
+ return response
2717
+
2718
+ monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
2719
+ monkeypatch.setattr(assistant, "_wait_for_input", fake_wait_for_input)
2720
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
2721
+
2722
+ try:
2723
+ assistant._run()
2724
+ finally:
2725
+ registry.reset()
2726
+
2727
+ assert any(event.type == EventType.ASSISTANT_CONTENT for event in events)
2728
+ assert not any(
2729
+ isinstance(entry, SentMessage) for entry in assistant.get_history_snapshot()
2730
+ )
2731
+ assert any(
2732
+ isinstance(entry, AssistantText)
2733
+ and entry.content == "@leader: investigate the error"
2734
+ for entry in assistant.get_history_snapshot()
2735
+ )
2736
+
2737
+
2738
+ def test_idle_tool_records_wakeup_message_as_new_input_block(monkeypatch):
2739
+ agent = Agent(
2740
+ NodeConfig(node_type=NodeType.AGENT, tools=["idle"], allow_network=True)
2741
+ )
2742
+ wait_calls = 0
2743
+ llm_messages: list[list[dict]] = []
2744
+ responses = iter(
2745
+ [
2746
+ LLMResponse(
2747
+ tool_calls=[
2748
+ ToolCallResult(
2749
+ id="call-idle",
2750
+ name="idle",
2751
+ arguments={},
2752
+ )
2753
+ ]
2754
+ ),
2755
+ LLMResponse(),
2756
+ ]
2757
+ )
2758
+
2759
+ def fake_wait_for_input() -> None:
2760
+ nonlocal wait_calls
2761
+ wait_calls += 1
2762
+ agent._append_history(
2763
+ ReceivedMessage(content="start waiting", from_id="tester")
2764
+ )
2765
+ agent.set_state(AgentState.RUNNING, "received message from tester")
2766
+
2767
+ def fake_chat(
2768
+ messages,
2769
+ tools=None,
2770
+ on_chunk=None,
2771
+ register_interrupt=None,
2772
+ role_name=None,
2773
+ ):
2774
+ llm_messages.append(messages)
2775
+ if len(llm_messages) == 1:
2776
+ timer = threading.Timer(
2777
+ 0.01,
2778
+ lambda: agent.enqueue_message(
2779
+ Message(
2780
+ from_id="human",
2781
+ to_id=agent.uuid,
2782
+ content="wake up now",
2783
+ )
2784
+ ),
2785
+ )
2786
+ timer.start()
2787
+ if len(llm_messages) == 2:
2788
+ agent.request_termination("done")
2789
+ return next(responses)
2790
+
2791
+ monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
2792
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
2793
+
2794
+ agent._run()
2795
+
2796
+ assert wait_calls == 1
2797
+ assert agent.state == AgentState.TERMINATED
2798
+ second_round = llm_messages[1]
2799
+ assert any(
2800
+ msg.get("role") == "assistant"
2801
+ and msg.get("tool_calls")
2802
+ == [
2803
+ {
2804
+ "id": "call-idle",
2805
+ "type": "function",
2806
+ "function": {
2807
+ "name": "idle",
2808
+ "arguments": "{}",
2809
+ },
2810
+ }
2811
+ ]
2812
+ for msg in second_round
2813
+ )
2814
+ assert any(
2815
+ msg.get("role") == "tool"
2816
+ and msg.get("tool_call_id") == "call-idle"
2817
+ and isinstance(msg.get("content"), str)
2818
+ and msg.get("content", "").startswith("idle ")
2819
+ for msg in second_round
2820
+ )
2821
+ assert any(
2822
+ msg.get("role") == "user"
2823
+ and msg.get("content") == '<message from="human">wake up now</message>'
2824
+ for msg in second_round
2825
+ )
2826
+ assert any(
2827
+ isinstance(entry, ToolCall)
2828
+ and entry.tool_name == "idle"
2829
+ and isinstance(entry.result, str)
2830
+ and entry.result.startswith("idle ")
2831
+ for entry in agent.get_history_snapshot()
2832
+ )
2833
+
2834
+
2835
+ def test_agent_contextualizes_plain_loguru_calls(monkeypatch):
2836
+ agent = Agent(
2837
+ NodeConfig(node_type=NodeType.AGENT, allow_network=True),
2838
+ uuid="agent-z",
2839
+ )
2840
+ captured: list[tuple[str, str | None]] = []
2841
+ sink_id = logger.add(
2842
+ lambda message: captured.append(
2843
+ (message.record["message"], message.record["extra"].get("agent_id"))
2844
+ )
2845
+ )
2846
+
2847
+ def fake_wait_for_input() -> None:
2848
+ agent._append_history(ReceivedMessage(content="do the task", from_id="tester"))
2849
+ agent.set_state(AgentState.RUNNING, "received message from tester")
2850
+
2851
+ def fake_chat(
2852
+ messages,
2853
+ tools=None,
2854
+ on_chunk=None,
2855
+ register_interrupt=None,
2856
+ role_name=None,
2857
+ ):
2858
+ logger.info("plain log inside agent")
2859
+ agent.request_termination("done")
2860
+ return LLMResponse()
2861
+
2862
+ monkeypatch.setattr(agent, "_wait_for_input", fake_wait_for_input)
2863
+ monkeypatch.setattr("flowent.agent.gateway.chat", fake_chat)
2864
+
2865
+ try:
2866
+ agent._run()
2867
+ finally:
2868
+ logger.remove(sink_id)
2869
+
2870
+ assert ("plain log inside agent", "agent-z") in captured
2871
+
2872
+
2873
+ def test_agent_denies_tool_call_before_edit_execute(monkeypatch, tmp_path):
2874
+ agent = Agent(
2875
+ NodeConfig(node_type=NodeType.AGENT, tools=["edit"]),
2876
+ uuid="agent-security",
2877
+ )
2878
+
2879
+ def fail_execute(*_args, **_kwargs):
2880
+ raise AssertionError("edit execute should not be called")
2881
+
2882
+ monkeypatch.setattr("flowent.tools.edit.EditTool.execute", fail_execute)
2883
+
2884
+ result = agent._handle_tool_call(
2885
+ "edit",
2886
+ {
2887
+ "path": str(tmp_path / "blocked.txt"),
2888
+ "edits": [
2889
+ {
2890
+ "start_line": 1,
2891
+ "end_line": 1,
2892
+ "new_content": "hello\n",
2893
+ }
2894
+ ],
2895
+ },
2896
+ "call-edit",
2897
+ )
2898
+
2899
+ assert result == json.dumps({"error": "Write access is disabled for this workflow"})
2900
+ assert isinstance(agent.history[-1], ToolCall)
2901
+ assert agent.history[-1].result == result
2902
+
2903
+
2904
+ def test_handle_tool_call_emits_streaming_tool_result_deltas(monkeypatch):
2905
+ agent = Agent(
2906
+ NodeConfig(node_type=NodeType.AGENT, tools=["streaming_tool"]),
2907
+ uuid="agent-stream",
2908
+ )
2909
+ events = []
2910
+
2911
+ class FakeTool:
2912
+ def execute(self, agent, args, **kwargs):
2913
+ on_output = kwargs.get("on_output")
2914
+ assert on_output is not None
2915
+ on_output("chunk 1\n")
2916
+ on_output("chunk 2\n")
2917
+ return json.dumps({"status": "done"})
2918
+
2919
+ class FakeRegistry:
2920
+ def get(self, name):
2921
+ if name == "streaming_tool":
2922
+ return FakeTool()
2923
+ return None
2924
+
2925
+ monkeypatch.setattr("flowent.agent._get_tool_registry", lambda: FakeRegistry())
2926
+ monkeypatch.setattr(event_bus, "emit", lambda event: events.append(event))
2927
+
2928
+ result = agent._handle_tool_call("streaming_tool", {}, "call-stream")
2929
+
2930
+ assert result == json.dumps({"status": "done"})
2931
+ assert isinstance(agent.history[-1], ToolCall)
2932
+ assert agent.history[-1].tool_call_id == "call-stream"
2933
+ assert agent.history[-1].result == result
2934
+ assert agent.history[-1].streaming is False
2935
+ assert [
2936
+ event.data["text"]
2937
+ for event in events
2938
+ if event.type == EventType.HISTORY_ENTRY_DELTA
2939
+ ] == [
2940
+ "chunk 1\n",
2941
+ "chunk 2\n",
2942
+ ]