flowent 0.0.7 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (387) hide show
  1. package/README.md +0 -3
  2. package/backend/README.md +0 -3
  3. package/backend/pyproject.toml +2 -8
  4. package/backend/src/flowent/__init__.py +6 -2
  5. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  6. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  7. package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
  13. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  14. package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
  15. package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/agent.py +213 -3173
  17. package/backend/src/flowent/cli.py +19 -24
  18. package/backend/src/flowent/context.py +127 -0
  19. package/backend/src/flowent/llm.py +256 -0
  20. package/backend/src/flowent/logging.py +170 -129
  21. package/backend/src/flowent/main.py +321 -70
  22. package/backend/src/flowent/patch.py +182 -0
  23. package/backend/src/flowent/paths.py +11 -0
  24. package/backend/src/flowent/sandbox.py +214 -40
  25. package/backend/src/flowent/static/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  26. package/backend/src/flowent/static/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  27. package/backend/src/flowent/static/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  28. package/backend/src/flowent/static/assets/index-C76K95ty.js +81 -0
  29. package/backend/src/flowent/static/assets/index-iUMNKvlU.css +2 -0
  30. package/backend/src/flowent/static/flowent.png +0 -0
  31. package/backend/src/flowent/static/index.html +5 -25
  32. package/backend/src/flowent/storage.py +302 -0
  33. package/backend/src/flowent/tools.py +376 -0
  34. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  35. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  36. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  37. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  38. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  39. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  40. package/backend/tests/test_agent_tools.py +477 -0
  41. package/backend/tests/test_health.py +12 -0
  42. package/backend/tests/test_llm_providers.py +113 -0
  43. package/backend/tests/test_logging.py +182 -0
  44. package/backend/tests/test_persistence.py +125 -0
  45. package/backend/tests/test_workspace_chat.py +578 -0
  46. package/backend/uv.lock +803 -99
  47. package/dist/frontend/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  48. package/dist/frontend/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  49. package/dist/frontend/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  50. package/dist/frontend/assets/index-C76K95ty.js +81 -0
  51. package/dist/frontend/assets/index-iUMNKvlU.css +2 -0
  52. package/dist/frontend/flowent.png +0 -0
  53. package/dist/frontend/index.html +5 -25
  54. package/package.json +1 -2
  55. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  56. package/backend/src/flowent/__pycache__/access.cpython-313.pyc +0 -0
  57. package/backend/src/flowent/__pycache__/assistant_commands.cpython-313.pyc +0 -0
  58. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  59. package/backend/src/flowent/__pycache__/config.cpython-313.pyc +0 -0
  60. package/backend/src/flowent/__pycache__/events.cpython-313.pyc +0 -0
  61. package/backend/src/flowent/__pycache__/graph_runtime.cpython-313.pyc +0 -0
  62. package/backend/src/flowent/__pycache__/graph_service.cpython-313.pyc +0 -0
  63. package/backend/src/flowent/__pycache__/image_assets.cpython-313.pyc +0 -0
  64. package/backend/src/flowent/__pycache__/model_metadata.cpython-313.pyc +0 -0
  65. package/backend/src/flowent/__pycache__/network.cpython-313.pyc +0 -0
  66. package/backend/src/flowent/__pycache__/observability_service.cpython-313.pyc +0 -0
  67. package/backend/src/flowent/__pycache__/registry.cpython-313.pyc +0 -0
  68. package/backend/src/flowent/__pycache__/role_management.cpython-313.pyc +0 -0
  69. package/backend/src/flowent/__pycache__/runtime.cpython-313.pyc +0 -0
  70. package/backend/src/flowent/__pycache__/security.cpython-313.pyc +0 -0
  71. package/backend/src/flowent/__pycache__/settings.cpython-313.pyc +0 -0
  72. package/backend/src/flowent/__pycache__/settings_management.cpython-313.pyc +0 -0
  73. package/backend/src/flowent/__pycache__/state_db.cpython-313.pyc +0 -0
  74. package/backend/src/flowent/__pycache__/workspace_store.cpython-313.pyc +0 -0
  75. package/backend/src/flowent/access.py +0 -247
  76. package/backend/src/flowent/assistant_commands.py +0 -115
  77. package/backend/src/flowent/channels/__init__.py +0 -3
  78. package/backend/src/flowent/channels/__pycache__/__init__.cpython-313.pyc +0 -0
  79. package/backend/src/flowent/channels/__pycache__/telegram.cpython-313.pyc +0 -0
  80. package/backend/src/flowent/channels/telegram.py +0 -615
  81. package/backend/src/flowent/config.py +0 -14
  82. package/backend/src/flowent/dev.py +0 -3
  83. package/backend/src/flowent/events.py +0 -157
  84. package/backend/src/flowent/graph_runtime.py +0 -60
  85. package/backend/src/flowent/graph_service.py +0 -2401
  86. package/backend/src/flowent/image_assets.py +0 -356
  87. package/backend/src/flowent/model_metadata.py +0 -102
  88. package/backend/src/flowent/models/__init__.py +0 -125
  89. package/backend/src/flowent/models/__pycache__/__init__.cpython-313.pyc +0 -0
  90. package/backend/src/flowent/models/__pycache__/agent.cpython-313.pyc +0 -0
  91. package/backend/src/flowent/models/__pycache__/base.cpython-313.pyc +0 -0
  92. package/backend/src/flowent/models/__pycache__/blueprint.cpython-313.pyc +0 -0
  93. package/backend/src/flowent/models/__pycache__/content.cpython-313.pyc +0 -0
  94. package/backend/src/flowent/models/__pycache__/delta.cpython-313.pyc +0 -0
  95. package/backend/src/flowent/models/__pycache__/event.cpython-313.pyc +0 -0
  96. package/backend/src/flowent/models/__pycache__/graph.cpython-313.pyc +0 -0
  97. package/backend/src/flowent/models/__pycache__/history.cpython-313.pyc +0 -0
  98. package/backend/src/flowent/models/__pycache__/llm.cpython-313.pyc +0 -0
  99. package/backend/src/flowent/models/__pycache__/message.cpython-313.pyc +0 -0
  100. package/backend/src/flowent/models/__pycache__/tab.cpython-313.pyc +0 -0
  101. package/backend/src/flowent/models/__pycache__/todo.cpython-313.pyc +0 -0
  102. package/backend/src/flowent/models/agent.py +0 -34
  103. package/backend/src/flowent/models/base.py +0 -24
  104. package/backend/src/flowent/models/blueprint.py +0 -176
  105. package/backend/src/flowent/models/content.py +0 -164
  106. package/backend/src/flowent/models/delta.py +0 -44
  107. package/backend/src/flowent/models/event.py +0 -51
  108. package/backend/src/flowent/models/graph.py +0 -472
  109. package/backend/src/flowent/models/history.py +0 -272
  110. package/backend/src/flowent/models/llm.py +0 -62
  111. package/backend/src/flowent/models/message.py +0 -33
  112. package/backend/src/flowent/models/tab.py +0 -85
  113. package/backend/src/flowent/models/todo.py +0 -10
  114. package/backend/src/flowent/network.py +0 -146
  115. package/backend/src/flowent/observability_service.py +0 -218
  116. package/backend/src/flowent/prompts/__init__.py +0 -67
  117. package/backend/src/flowent/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
  118. package/backend/src/flowent/prompts/__pycache__/common.cpython-313.pyc +0 -0
  119. package/backend/src/flowent/prompts/__pycache__/steward.cpython-313.pyc +0 -0
  120. package/backend/src/flowent/prompts/common.py +0 -250
  121. package/backend/src/flowent/prompts/steward.py +0 -64
  122. package/backend/src/flowent/providers/__init__.py +0 -23
  123. package/backend/src/flowent/providers/__pycache__/__init__.cpython-313.pyc +0 -0
  124. package/backend/src/flowent/providers/__pycache__/anthropic.cpython-313.pyc +0 -0
  125. package/backend/src/flowent/providers/__pycache__/base_url.cpython-313.pyc +0 -0
  126. package/backend/src/flowent/providers/__pycache__/configuration.cpython-313.pyc +0 -0
  127. package/backend/src/flowent/providers/__pycache__/content.cpython-313.pyc +0 -0
  128. package/backend/src/flowent/providers/__pycache__/errors.cpython-313.pyc +0 -0
  129. package/backend/src/flowent/providers/__pycache__/gateway.cpython-313.pyc +0 -0
  130. package/backend/src/flowent/providers/__pycache__/headers.cpython-313.pyc +0 -0
  131. package/backend/src/flowent/providers/__pycache__/management.cpython-313.pyc +0 -0
  132. package/backend/src/flowent/providers/__pycache__/openai.cpython-313.pyc +0 -0
  133. package/backend/src/flowent/providers/__pycache__/openai_responses.cpython-313.pyc +0 -0
  134. package/backend/src/flowent/providers/__pycache__/registry.cpython-313.pyc +0 -0
  135. package/backend/src/flowent/providers/__pycache__/sse.cpython-313.pyc +0 -0
  136. package/backend/src/flowent/providers/__pycache__/thinking.cpython-313.pyc +0 -0
  137. package/backend/src/flowent/providers/anthropic.py +0 -468
  138. package/backend/src/flowent/providers/base_url.py +0 -60
  139. package/backend/src/flowent/providers/configuration.py +0 -189
  140. package/backend/src/flowent/providers/content.py +0 -122
  141. package/backend/src/flowent/providers/errors.py +0 -223
  142. package/backend/src/flowent/providers/gateway.py +0 -169
  143. package/backend/src/flowent/providers/gemini.py +0 -447
  144. package/backend/src/flowent/providers/headers.py +0 -20
  145. package/backend/src/flowent/providers/management.py +0 -96
  146. package/backend/src/flowent/providers/ollama.py +0 -293
  147. package/backend/src/flowent/providers/openai.py +0 -422
  148. package/backend/src/flowent/providers/openai_responses.py +0 -655
  149. package/backend/src/flowent/providers/registry.py +0 -144
  150. package/backend/src/flowent/providers/sse.py +0 -31
  151. package/backend/src/flowent/providers/thinking.py +0 -79
  152. package/backend/src/flowent/registry.py +0 -73
  153. package/backend/src/flowent/role_management.py +0 -270
  154. package/backend/src/flowent/routes/__init__.py +0 -26
  155. package/backend/src/flowent/routes/__pycache__/__init__.cpython-313.pyc +0 -0
  156. package/backend/src/flowent/routes/__pycache__/access.cpython-313.pyc +0 -0
  157. package/backend/src/flowent/routes/__pycache__/assistant.cpython-313.pyc +0 -0
  158. package/backend/src/flowent/routes/__pycache__/image_assets.cpython-313.pyc +0 -0
  159. package/backend/src/flowent/routes/__pycache__/meta.cpython-313.pyc +0 -0
  160. package/backend/src/flowent/routes/__pycache__/nodes.cpython-313.pyc +0 -0
  161. package/backend/src/flowent/routes/__pycache__/prompts.cpython-313.pyc +0 -0
  162. package/backend/src/flowent/routes/__pycache__/providers_route.cpython-313.pyc +0 -0
  163. package/backend/src/flowent/routes/__pycache__/roles.cpython-313.pyc +0 -0
  164. package/backend/src/flowent/routes/__pycache__/settings.cpython-313.pyc +0 -0
  165. package/backend/src/flowent/routes/__pycache__/tabs.cpython-313.pyc +0 -0
  166. package/backend/src/flowent/routes/__pycache__/ws.cpython-313.pyc +0 -0
  167. package/backend/src/flowent/routes/access.py +0 -48
  168. package/backend/src/flowent/routes/assistant.py +0 -158
  169. package/backend/src/flowent/routes/image_assets.py +0 -33
  170. package/backend/src/flowent/routes/meta.py +0 -28
  171. package/backend/src/flowent/routes/nodes.py +0 -423
  172. package/backend/src/flowent/routes/prompts.py +0 -46
  173. package/backend/src/flowent/routes/providers_route.py +0 -365
  174. package/backend/src/flowent/routes/roles.py +0 -207
  175. package/backend/src/flowent/routes/settings.py +0 -379
  176. package/backend/src/flowent/routes/tabs.py +0 -298
  177. package/backend/src/flowent/routes/ws.py +0 -33
  178. package/backend/src/flowent/runtime.py +0 -160
  179. package/backend/src/flowent/security.py +0 -37
  180. package/backend/src/flowent/settings.py +0 -2112
  181. package/backend/src/flowent/settings_management.py +0 -394
  182. package/backend/src/flowent/state_db.py +0 -108
  183. package/backend/src/flowent/static/assets/AssistantPage-BW7XAd9I.js +0 -1
  184. package/backend/src/flowent/static/assets/ChannelsPage-tCJHgt6m.js +0 -1
  185. package/backend/src/flowent/static/assets/PageScaffold-f6g2l7XN.js +0 -1
  186. package/backend/src/flowent/static/assets/PromptsPage-C3Sxn2D7.js +0 -1
  187. package/backend/src/flowent/static/assets/ProvidersPage-BfmdXmNt.js +0 -3
  188. package/backend/src/flowent/static/assets/RolesPage-DET8wO4r.js +0 -1
  189. package/backend/src/flowent/static/assets/SettingsPage-D-g3deMm.js +0 -3
  190. package/backend/src/flowent/static/assets/ToolsPage-CDmtE2g4.js +0 -1
  191. package/backend/src/flowent/static/assets/WorkspacePage-AZsJ0sD0.js +0 -3
  192. package/backend/src/flowent/static/assets/WorkspacePanels-CteCjolX.js +0 -1
  193. package/backend/src/flowent/static/assets/alert-dialog-Duorp_S-.js +0 -1
  194. package/backend/src/flowent/static/assets/dialog-C3ixjGjN.js +0 -1
  195. package/backend/src/flowent/static/assets/elk-worker.min-C9JGDOE-.js +0 -6312
  196. package/backend/src/flowent/static/assets/graph-vendor-CHpVij2M.css +0 -1
  197. package/backend/src/flowent/static/assets/graph-vendor-DRq_-6fV.js +0 -7
  198. package/backend/src/flowent/static/assets/index--o_0fv0N.css +0 -1
  199. package/backend/src/flowent/static/assets/index-C9HuekJm.js +0 -10
  200. package/backend/src/flowent/static/assets/layout.worker-jMHqAFbP.js +0 -24
  201. package/backend/src/flowent/static/assets/markdown-vendor-C9RtvaJh.js +0 -29
  202. package/backend/src/flowent/static/assets/modelParams-DmnF2hwR.js +0 -1
  203. package/backend/src/flowent/static/assets/providerTypes-DT3Ahwl_.js +0 -1
  204. package/backend/src/flowent/static/assets/react-vendor-mEs_JJxa.js +0 -9
  205. package/backend/src/flowent/static/assets/roles-CuRT_chR.js +0 -1
  206. package/backend/src/flowent/static/assets/rolldown-runtime-BYbx6iT9.js +0 -1
  207. package/backend/src/flowent/static/assets/select-DCfeNu-F.js +0 -1
  208. package/backend/src/flowent/static/assets/surface-pWwG5ogx.js +0 -1
  209. package/backend/src/flowent/static/assets/ui-vendor-C5pJa8N7.js +0 -51
  210. package/backend/src/flowent/static/assets/useAppRoute-FgSHBKhV.js +0 -1
  211. package/backend/src/flowent/static/favicon.svg +0 -4
  212. package/backend/src/flowent/tools/__init__.py +0 -176
  213. package/backend/src/flowent/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  214. package/backend/src/flowent/tools/__pycache__/connect.cpython-313.pyc +0 -0
  215. package/backend/src/flowent/tools/__pycache__/contacts.cpython-313.pyc +0 -0
  216. package/backend/src/flowent/tools/__pycache__/create_agent.cpython-313.pyc +0 -0
  217. package/backend/src/flowent/tools/__pycache__/create_tab.cpython-313.pyc +0 -0
  218. package/backend/src/flowent/tools/__pycache__/delete_tab.cpython-313.pyc +0 -0
  219. package/backend/src/flowent/tools/__pycache__/edit.cpython-313.pyc +0 -0
  220. package/backend/src/flowent/tools/__pycache__/exec.cpython-313.pyc +0 -0
  221. package/backend/src/flowent/tools/__pycache__/fetch.cpython-313.pyc +0 -0
  222. package/backend/src/flowent/tools/__pycache__/idle.cpython-313.pyc +0 -0
  223. package/backend/src/flowent/tools/__pycache__/list_roles.cpython-313.pyc +0 -0
  224. package/backend/src/flowent/tools/__pycache__/list_tabs.cpython-313.pyc +0 -0
  225. package/backend/src/flowent/tools/__pycache__/list_tools.cpython-313.pyc +0 -0
  226. package/backend/src/flowent/tools/__pycache__/manage_prompts.cpython-313.pyc +0 -0
  227. package/backend/src/flowent/tools/__pycache__/manage_providers.cpython-313.pyc +0 -0
  228. package/backend/src/flowent/tools/__pycache__/manage_roles.cpython-313.pyc +0 -0
  229. package/backend/src/flowent/tools/__pycache__/manage_settings.cpython-313.pyc +0 -0
  230. package/backend/src/flowent/tools/__pycache__/read.cpython-313.pyc +0 -0
  231. package/backend/src/flowent/tools/__pycache__/send.cpython-313.pyc +0 -0
  232. package/backend/src/flowent/tools/__pycache__/set_permissions.cpython-313.pyc +0 -0
  233. package/backend/src/flowent/tools/__pycache__/sleep.cpython-313.pyc +0 -0
  234. package/backend/src/flowent/tools/__pycache__/todo.cpython-313.pyc +0 -0
  235. package/backend/src/flowent/tools/connect.py +0 -100
  236. package/backend/src/flowent/tools/contacts.py +0 -22
  237. package/backend/src/flowent/tools/create_agent.py +0 -191
  238. package/backend/src/flowent/tools/create_tab.py +0 -61
  239. package/backend/src/flowent/tools/delete_tab.py +0 -39
  240. package/backend/src/flowent/tools/edit.py +0 -142
  241. package/backend/src/flowent/tools/exec.py +0 -118
  242. package/backend/src/flowent/tools/fetch.py +0 -85
  243. package/backend/src/flowent/tools/idle.py +0 -27
  244. package/backend/src/flowent/tools/list_roles.py +0 -68
  245. package/backend/src/flowent/tools/list_tabs.py +0 -100
  246. package/backend/src/flowent/tools/list_tools.py +0 -28
  247. package/backend/src/flowent/tools/manage_prompts.py +0 -102
  248. package/backend/src/flowent/tools/manage_providers.py +0 -220
  249. package/backend/src/flowent/tools/manage_roles.py +0 -275
  250. package/backend/src/flowent/tools/manage_settings.py +0 -326
  251. package/backend/src/flowent/tools/read.py +0 -152
  252. package/backend/src/flowent/tools/send.py +0 -68
  253. package/backend/src/flowent/tools/set_permissions.py +0 -99
  254. package/backend/src/flowent/tools/sleep.py +0 -41
  255. package/backend/src/flowent/tools/todo.py +0 -51
  256. package/backend/src/flowent/workspace_store.py +0 -479
  257. package/backend/tests/__init__.py +0 -0
  258. package/backend/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  259. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  260. package/backend/tests/conftest.py +0 -6
  261. package/backend/tests/integration/api/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  262. package/backend/tests/integration/api/__pycache__/test_access_api.cpython-313-pytest-9.0.3.pyc +0 -0
  263. package/backend/tests/integration/api/__pycache__/test_assistant_api.cpython-313-pytest-9.0.3.pyc +0 -0
  264. package/backend/tests/integration/api/__pycache__/test_frontend_mounting.cpython-313-pytest-9.0.3.pyc +0 -0
  265. package/backend/tests/integration/api/__pycache__/test_meta_api.cpython-313-pytest-9.0.3.pyc +0 -0
  266. package/backend/tests/integration/api/__pycache__/test_nodes_api.cpython-313-pytest-9.0.3.pyc +0 -0
  267. package/backend/tests/integration/api/__pycache__/test_prompts_api.cpython-313-pytest-9.0.3.pyc +0 -0
  268. package/backend/tests/integration/api/__pycache__/test_roles_api.cpython-313-pytest-9.0.3.pyc +0 -0
  269. package/backend/tests/integration/api/__pycache__/test_tabs_api.cpython-313-pytest-9.0.3.pyc +0 -0
  270. package/backend/tests/integration/api/conftest.py +0 -29
  271. package/backend/tests/integration/api/test_access_api.py +0 -182
  272. package/backend/tests/integration/api/test_assistant_api.py +0 -422
  273. package/backend/tests/integration/api/test_frontend_mounting.py +0 -61
  274. package/backend/tests/integration/api/test_meta_api.py +0 -32
  275. package/backend/tests/integration/api/test_nodes_api.py +0 -787
  276. package/backend/tests/integration/api/test_prompts_api.py +0 -47
  277. package/backend/tests/integration/api/test_roles_api.py +0 -228
  278. package/backend/tests/integration/api/test_tabs_api.py +0 -688
  279. package/backend/tests/unit/__pycache__/test_access.cpython-313-pytest-9.0.3.pyc +0 -0
  280. package/backend/tests/unit/__pycache__/test_cli.cpython-313-pytest-9.0.3.pyc +0 -0
  281. package/backend/tests/unit/__pycache__/test_graph_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  282. package/backend/tests/unit/__pycache__/test_network.cpython-313-pytest-9.0.3.pyc +0 -0
  283. package/backend/tests/unit/__pycache__/test_state_sqlite_storage.cpython-313-pytest-9.0.3.pyc +0 -0
  284. package/backend/tests/unit/__pycache__/test_workspace_store.cpython-313-pytest-9.0.3.pyc +0 -0
  285. package/backend/tests/unit/agent/__pycache__/test_agent_public_api.cpython-313-pytest-9.0.3.pyc +0 -0
  286. package/backend/tests/unit/agent/__pycache__/test_agent_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  287. package/backend/tests/unit/agent/test_agent_public_api.py +0 -822
  288. package/backend/tests/unit/agent/test_agent_runtime.py +0 -3088
  289. package/backend/tests/unit/channels/__pycache__/test_telegram_channel.cpython-313-pytest-9.0.3.pyc +0 -0
  290. package/backend/tests/unit/channels/test_telegram_channel.py +0 -552
  291. package/backend/tests/unit/logging/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  292. package/backend/tests/unit/logging/test_logging.py +0 -132
  293. package/backend/tests/unit/prompts/__pycache__/test_prompts.cpython-313-pytest-9.0.3.pyc +0 -0
  294. package/backend/tests/unit/prompts/test_prompts.py +0 -570
  295. package/backend/tests/unit/providers/__pycache__/test_anthropic_provider.cpython-313-pytest-9.0.3.pyc +0 -0
  296. package/backend/tests/unit/providers/__pycache__/test_errors.cpython-313-pytest-9.0.3.pyc +0 -0
  297. package/backend/tests/unit/providers/__pycache__/test_extract_delta_parts.cpython-313-pytest-9.0.3.pyc +0 -0
  298. package/backend/tests/unit/providers/__pycache__/test_openai_provider.cpython-313-pytest-9.0.3.pyc +0 -0
  299. package/backend/tests/unit/providers/__pycache__/test_openai_responses.cpython-313-pytest-9.0.3.pyc +0 -0
  300. package/backend/tests/unit/providers/__pycache__/test_provider_gateway.cpython-313-pytest-9.0.3.pyc +0 -0
  301. package/backend/tests/unit/providers/__pycache__/test_think_tag_parser.cpython-313-pytest-9.0.3.pyc +0 -0
  302. package/backend/tests/unit/providers/test_anthropic_provider.py +0 -185
  303. package/backend/tests/unit/providers/test_errors.py +0 -68
  304. package/backend/tests/unit/providers/test_extract_delta_parts.py +0 -22
  305. package/backend/tests/unit/providers/test_openai_provider.py +0 -139
  306. package/backend/tests/unit/providers/test_openai_responses.py +0 -402
  307. package/backend/tests/unit/providers/test_provider_gateway.py +0 -359
  308. package/backend/tests/unit/providers/test_think_tag_parser.py +0 -36
  309. package/backend/tests/unit/routes/__pycache__/test_prompts_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  310. package/backend/tests/unit/routes/__pycache__/test_providers_route.cpython-313-pytest-9.0.3.pyc +0 -0
  311. package/backend/tests/unit/routes/__pycache__/test_roles_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  312. package/backend/tests/unit/routes/__pycache__/test_settings_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  313. package/backend/tests/unit/routes/test_prompts_routes.py +0 -82
  314. package/backend/tests/unit/routes/test_providers_route.py +0 -370
  315. package/backend/tests/unit/routes/test_roles_routes.py +0 -539
  316. package/backend/tests/unit/routes/test_settings_routes.py +0 -1123
  317. package/backend/tests/unit/runtime/__pycache__/test_bootstrap_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  318. package/backend/tests/unit/runtime/test_bootstrap_runtime.py +0 -1002
  319. package/backend/tests/unit/sandbox/__pycache__/test_sandbox_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  320. package/backend/tests/unit/sandbox/test_sandbox_tools.py +0 -78
  321. package/backend/tests/unit/security/__pycache__/test_security.cpython-313-pytest-9.0.3.pyc +0 -0
  322. package/backend/tests/unit/security/test_security.py +0 -124
  323. package/backend/tests/unit/settings/__pycache__/test_settings_roles.cpython-313-pytest-9.0.3.pyc +0 -0
  324. package/backend/tests/unit/settings/test_settings_roles.py +0 -703
  325. package/backend/tests/unit/test_access.py +0 -45
  326. package/backend/tests/unit/test_cli.py +0 -102
  327. package/backend/tests/unit/test_graph_runtime.py +0 -72
  328. package/backend/tests/unit/test_network.py +0 -51
  329. package/backend/tests/unit/test_state_sqlite_storage.py +0 -87
  330. package/backend/tests/unit/test_workspace_store.py +0 -228
  331. package/backend/tests/unit/tools/__pycache__/test_connect_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  332. package/backend/tests/unit/tools/__pycache__/test_create_agent_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  333. package/backend/tests/unit/tools/__pycache__/test_delete_tab_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  334. package/backend/tests/unit/tools/__pycache__/test_edit_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  335. package/backend/tests/unit/tools/__pycache__/test_exec_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  336. package/backend/tests/unit/tools/__pycache__/test_fetch_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  337. package/backend/tests/unit/tools/__pycache__/test_manage_prompts_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  338. package/backend/tests/unit/tools/__pycache__/test_manage_providers_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  339. package/backend/tests/unit/tools/__pycache__/test_manage_roles_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  340. package/backend/tests/unit/tools/__pycache__/test_manage_settings_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  341. package/backend/tests/unit/tools/__pycache__/test_read_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  342. package/backend/tests/unit/tools/__pycache__/test_set_permissions_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  343. package/backend/tests/unit/tools/__pycache__/test_todo_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  344. package/backend/tests/unit/tools/__pycache__/test_tool_registry.cpython-313-pytest-9.0.3.pyc +0 -0
  345. package/backend/tests/unit/tools/test_connect_tool.py +0 -228
  346. package/backend/tests/unit/tools/test_create_agent_tool.py +0 -404
  347. package/backend/tests/unit/tools/test_delete_tab_tool.py +0 -116
  348. package/backend/tests/unit/tools/test_edit_tool.py +0 -115
  349. package/backend/tests/unit/tools/test_exec_tool.py +0 -81
  350. package/backend/tests/unit/tools/test_fetch_tool.py +0 -65
  351. package/backend/tests/unit/tools/test_manage_prompts_tool.py +0 -92
  352. package/backend/tests/unit/tools/test_manage_providers_tool.py +0 -460
  353. package/backend/tests/unit/tools/test_manage_roles_tool.py +0 -411
  354. package/backend/tests/unit/tools/test_manage_settings_tool.py +0 -611
  355. package/backend/tests/unit/tools/test_read_tool.py +0 -33
  356. package/backend/tests/unit/tools/test_set_permissions_tool.py +0 -595
  357. package/backend/tests/unit/tools/test_todo_tool.py +0 -37
  358. package/backend/tests/unit/tools/test_tool_registry.py +0 -199
  359. package/dist/frontend/assets/AssistantPage-BW7XAd9I.js +0 -1
  360. package/dist/frontend/assets/ChannelsPage-tCJHgt6m.js +0 -1
  361. package/dist/frontend/assets/PageScaffold-f6g2l7XN.js +0 -1
  362. package/dist/frontend/assets/PromptsPage-C3Sxn2D7.js +0 -1
  363. package/dist/frontend/assets/ProvidersPage-BfmdXmNt.js +0 -3
  364. package/dist/frontend/assets/RolesPage-DET8wO4r.js +0 -1
  365. package/dist/frontend/assets/SettingsPage-D-g3deMm.js +0 -3
  366. package/dist/frontend/assets/ToolsPage-CDmtE2g4.js +0 -1
  367. package/dist/frontend/assets/WorkspacePage-AZsJ0sD0.js +0 -3
  368. package/dist/frontend/assets/WorkspacePanels-CteCjolX.js +0 -1
  369. package/dist/frontend/assets/alert-dialog-Duorp_S-.js +0 -1
  370. package/dist/frontend/assets/dialog-C3ixjGjN.js +0 -1
  371. package/dist/frontend/assets/elk-worker.min-C9JGDOE-.js +0 -6312
  372. package/dist/frontend/assets/graph-vendor-CHpVij2M.css +0 -1
  373. package/dist/frontend/assets/graph-vendor-DRq_-6fV.js +0 -7
  374. package/dist/frontend/assets/index--o_0fv0N.css +0 -1
  375. package/dist/frontend/assets/index-C9HuekJm.js +0 -10
  376. package/dist/frontend/assets/layout.worker-jMHqAFbP.js +0 -24
  377. package/dist/frontend/assets/markdown-vendor-C9RtvaJh.js +0 -29
  378. package/dist/frontend/assets/modelParams-DmnF2hwR.js +0 -1
  379. package/dist/frontend/assets/providerTypes-DT3Ahwl_.js +0 -1
  380. package/dist/frontend/assets/react-vendor-mEs_JJxa.js +0 -9
  381. package/dist/frontend/assets/roles-CuRT_chR.js +0 -1
  382. package/dist/frontend/assets/rolldown-runtime-BYbx6iT9.js +0 -1
  383. package/dist/frontend/assets/select-DCfeNu-F.js +0 -1
  384. package/dist/frontend/assets/surface-pWwG5ogx.js +0 -1
  385. package/dist/frontend/assets/ui-vendor-C5pJa8N7.js +0 -51
  386. package/dist/frontend/assets/useAppRoute-FgSHBKhV.js +0 -1
  387. package/dist/frontend/favicon.svg +0 -4
@@ -1,3203 +1,243 @@
1
1
  from __future__ import annotations
2
2
 
3
- import json
4
- import re
5
- import threading
6
- import time as _time
7
- import uuid as _uuid
8
- from collections.abc import Callable
9
- from dataclasses import dataclass, field
10
- from functools import lru_cache, partial
11
- from queue import Empty, Queue
12
- from typing import Any
13
- from urllib.parse import urlparse
14
-
15
- from loguru import logger
16
-
17
- from flowent.assistant_commands import build_conversation_help_text
18
- from flowent.events import event_bus
19
- from flowent.image_assets import create_image_asset, require_image_asset
20
- from flowent.models import (
21
- AgentState,
22
- AssistantText,
23
- AssistantThinking,
24
- CommandResultEntry,
25
- ContentDelta,
26
- ErrorEntry,
27
- Event,
28
- EventType,
29
- HistoryEntry,
30
- ImagePart,
31
- LLMOutputImagePart,
32
- LLMOutputTextPart,
33
- LLMResponse,
34
- LLMUsage,
35
- Message,
36
- ModelInfo,
37
- NodeConfig,
38
- NodeType,
39
- PortInboundEntry,
40
- ReceivedMessage,
41
- SentMessage,
42
- SystemEntry,
43
- TextPart,
44
- ThinkingDelta,
45
- TodoItem,
46
- ToolCall,
47
- ToolResultDelta,
48
- WorkflowActivationState,
49
- WorkflowNodeKind,
50
- content_parts_to_text,
51
- deserialize_content_parts,
52
- has_image_parts,
53
- parse_content_parts_payload,
54
- )
55
- from flowent.prompts import get_system_prompt
56
- from flowent.providers.errors import LLMProviderError
57
- from flowent.providers.gateway import gateway
58
- from flowent.providers.thinking import ThinkTagParser, split_thinking_content
59
- from flowent.security import authorize
60
- from flowent.settings import get_settings
61
-
62
-
63
- @lru_cache(maxsize=1)
64
- def _get_tool_registry() -> Any:
65
- from flowent.tools import build_tool_registry
66
-
67
- return build_tool_registry()
68
-
69
-
70
- @dataclass
71
- class WakeSignal:
72
- reason: str
73
- payload: dict[str, Any]
74
- resume_reason: str = ""
75
-
76
-
77
- @dataclass
78
- class StreamingContentState:
79
- content_buffer: str = ""
80
- thinking_buffer: str = ""
81
- saw_content_chunks: bool = False
82
- think_parser: ThinkTagParser = field(default_factory=ThinkTagParser)
83
- emitted_human_content: bool = False
84
-
85
-
86
- @dataclass(frozen=True)
87
- class ContextPreflight:
88
- estimated_total_tokens: int
89
- context_window_tokens: int | None = None
90
- auto_compact_token_limit: int | None = None
91
- safe_input_tokens: int | None = None
92
-
93
-
94
- class InterruptRequestedError(Exception):
95
- def __init__(self, stream_state: StreamingContentState | None = None) -> None:
96
- super().__init__("interrupt requested")
97
- self.stream_state = stream_state
98
-
99
-
100
- class ContextPreflightError(RuntimeError):
101
- pass
102
-
103
-
104
- DEFAULT_CONTEXT_OUTPUT_BUDGET_TOKENS = 1024
105
- DEFAULT_CONTEXT_PROVIDER_HEADROOM_TOKENS = 1024
106
-
107
-
108
- @dataclass(frozen=True)
109
- class PreparedLLMContext:
110
- messages: list[dict[str, Any]]
111
- system_messages: list[dict[str, Any]]
112
- execution_context_messages: list[dict[str, Any]]
113
- runtime_tail_messages: list[dict[str, Any]]
114
-
115
-
116
- @dataclass(frozen=True)
117
- class ContextTokenUsageBaseline:
118
- usage: LLMUsage
119
- system_messages: list[dict[str, Any]]
120
- execution_context_messages: list[dict[str, Any]]
121
- runtime_tail_messages: list[dict[str, Any]]
122
-
123
-
124
- @dataclass(frozen=True)
125
- class ResolvedModelSource:
126
- provider_id: str | None
127
- provider_name: str | None
128
- provider_type: str | None
129
- provider_base_url: str | None
130
- model: str | None
131
- model_info: ModelInfo | None
132
-
133
-
134
- def build_error_context(content: str) -> str:
135
- return (
136
- f"<system>Previous runtime error:\n{normalize_error_context(content)}</system>"
137
- )
138
-
139
-
140
- _RAW_HTML_DOCUMENT_PATTERN = re.compile(
141
- r"<!doctype\s+html|</?html\b|</?head\b|</?body\b|<title\b",
142
- re.IGNORECASE,
143
- )
144
- _RAW_HTML_FRAGMENT_LINE_PATTERN = re.compile(
145
- r"^\s*</?(?:div|span|pre|code|p|br|h[1-6]|script|style)\b",
146
- re.IGNORECASE,
3
+ import logging
4
+ from collections.abc import AsyncIterator, Callable, Mapping, Sequence
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from uuid import uuid4
8
+
9
+ from pydantic import BaseModel, ConfigDict
10
+
11
+ from flowent.llm import (
12
+ CompletionCallable,
13
+ ProviderConnection,
14
+ ToolCallDelta,
15
+ chunk_delta_content,
16
+ chunk_delta_tool_calls,
17
+ stream_chat_chunks,
147
18
  )
148
- _ERROR_RESPONSE_TEXT_PATTERN = re.compile(
149
- r"\b(?:bad gateway|gateway timeout|internal server error|service unavailable|"
150
- r"not found|forbidden|unauthorized|upstream|exception|runtimeerror|error)\b",
151
- re.IGNORECASE,
19
+ from flowent.logging import TRACE_LEVEL
20
+ from flowent.tools import (
21
+ ToolContext,
22
+ new_tool_item,
23
+ parse_tool_arguments,
24
+ run_tool,
25
+ tool_specs,
152
26
  )
153
- _TRACEBACK_PATTERN = re.compile(r"(^|\n)\s*traceback\b", re.IGNORECASE)
154
-
155
-
156
- def contains_raw_error_response(content: str) -> bool:
157
- if _RAW_HTML_DOCUMENT_PATTERN.search(content):
158
- return True
159
- if not _ERROR_RESPONSE_TEXT_PATTERN.search(content):
160
- return False
161
- return any(
162
- _RAW_HTML_FRAGMENT_LINE_PATTERN.search(line) for line in content.splitlines()
163
- )
164
-
165
-
166
- def normalize_error_context(content: str) -> str:
167
- normalized = content.strip()
168
- if not normalized:
169
- return "Runtime error"
170
-
171
- if contains_raw_error_response(normalized):
172
- return "Upstream returned an unexpected error response"
173
-
174
- traceback_match = _TRACEBACK_PATTERN.search(normalized)
175
- if traceback_match is None:
176
- return normalized
177
-
178
- prefix = normalized[: traceback_match.start()].strip()
179
- if prefix:
180
- return prefix
181
-
182
- for line in reversed(normalized.splitlines()):
183
- candidate = line.strip()
184
- if not candidate:
185
- continue
186
- if candidate.lower().startswith("traceback"):
187
- continue
188
- if candidate.startswith(("File ", "^")):
189
- continue
190
- if candidate.startswith("The above exception") or candidate.startswith(
191
- "During handling"
192
- ):
193
- continue
194
- return candidate
195
- return "Runtime error"
196
-
197
-
198
- def normalize_compacted_execution_context(content: str) -> str:
199
- normalized = content.strip()
200
- if not normalized:
201
- return ""
202
- if contains_raw_error_response(normalized):
203
- return "Previous compacted execution context contained an unexpected error response."
204
-
205
- traceback_match = _TRACEBACK_PATTERN.search(normalized)
206
- if traceback_match is None:
207
- return normalized
208
-
209
- prefix = normalized[: traceback_match.start()].strip()
210
- if prefix:
211
- return prefix
212
- return normalize_error_context(normalized)
213
-
214
-
215
- def build_runtime_error_summary(exc: Exception) -> str:
216
- message = str(exc).strip()
217
- if not message:
218
- return type(exc).__name__
219
- return f"{type(exc).__name__}: {message}"
220
-
221
-
222
- class Agent:
223
- def __init__(
224
- self,
225
- config: NodeConfig,
226
- uuid: str | None = None,
227
- ) -> None:
228
- self.uuid = uuid or str(_uuid.uuid4())
229
- self.config = config
230
- self.node_type = config.node_type
231
- self.role_name = config.role_name
232
- self.state = AgentState.INITIALIZING
233
- self.todos: list[TodoItem] = []
234
- self.connections: list[str] = []
235
- self.history: list[HistoryEntry] = []
236
- self._terminate = threading.Event()
237
- self._interrupt_requested = threading.Event()
238
- self._interrupt_callback_lock = threading.Lock()
239
- self._interrupt_callback: Callable[[], None] | None = None
240
- self._command_interrupt_lock = threading.Lock()
241
- self._pause_after_interrupt_requested = threading.Event()
242
- self._paused_for_command = threading.Event()
243
- self._resume_after_command = threading.Event()
244
- self._idle_state_event = threading.Event()
245
- self._idle_started_at: float | None = None
246
- self._idle_started_by_tool_call_id: str | None = None
247
- self._wake_queue: Queue[WakeSignal] = Queue()
248
- self._thread: threading.Thread | None = None
249
- self._termination_reason: str = ""
250
- self._preserve_workspace_state_on_exit = False
251
- self._connections_lock = threading.Lock()
252
- self._history_lock = threading.Lock()
253
- self._todos_lock = threading.Lock()
254
- self._runtime_notice_lock = threading.Lock()
255
- self._execution_context_lock = threading.Lock()
256
- self._execution_context_summary: str = ""
257
- self._execution_context_history_cutoff: int = 0
258
- self._context_token_usage_baseline: ContextTokenUsageBaseline | None = None
259
- self._pending_runtime_notices: list[str] = []
260
- self._pending_input_turn = False
261
- self._turn_started_with_pending_input = False
262
- self._turn_made_progress = False
263
- self._log = logger.bind(
264
- agent_id=self.uuid[:8], node_type=self.config.node_type.value
265
- )
266
-
267
- def _persist_workspace_node(self) -> None:
268
- if self.node_type != NodeType.ASSISTANT and not self.config.tab_id:
269
- return
270
- from flowent.models import GraphNodeRecord
271
- from flowent.workspace_store import workspace_store
272
-
273
- existing = workspace_store.get_node_record(self.uuid)
274
- record = GraphNodeRecord(
275
- id=self.uuid,
276
- config=self.config,
277
- state=self.state,
278
- todos=self.get_todos_snapshot(),
279
- history=self.get_history_snapshot(),
280
- execution_context_summary=self.get_execution_context_summary(),
281
- execution_context_history_cutoff=self.get_execution_context_history_cutoff(),
282
- position=existing.position if existing is not None else None,
283
- created_at=existing.created_at if existing is not None else _time.time(),
284
- updated_at=_time.time(),
285
- )
286
- workspace_store.upsert_node_record(record)
287
-
288
- def add_connection(self, other_uuid: str) -> None:
289
- with self._connections_lock:
290
- if other_uuid not in self.connections:
291
- self.connections.append(other_uuid)
292
-
293
- def remove_connection(self, other_uuid: str) -> None:
294
- with self._connections_lock:
295
- if other_uuid in self.connections:
296
- self.connections.remove(other_uuid)
297
-
298
- def is_connected_to(self, uuid: str) -> bool:
299
- with self._connections_lock:
300
- return uuid in self.connections
301
-
302
- def get_connections_snapshot(self) -> list[str]:
303
- with self._connections_lock:
304
- return list(self.connections)
305
-
306
- def get_history_snapshot(self) -> list[HistoryEntry]:
307
- with self._history_lock:
308
- return list(self.history)
309
-
310
- def get_todos_snapshot(self) -> list[TodoItem]:
311
- with self._todos_lock:
312
- return [TodoItem(text=t.text) for t in self.todos]
313
-
314
- def get_execution_context_summary(self) -> str:
315
- with self._execution_context_lock:
316
- return self._execution_context_summary
317
-
318
- def get_execution_context_history_cutoff(self) -> int:
319
- with self._execution_context_lock:
320
- return self._execution_context_history_cutoff
321
-
322
- def _set_execution_context(
323
- self,
324
- *,
325
- summary: str,
326
- history_cutoff: int,
327
- ) -> None:
328
- with self._execution_context_lock:
329
- self._execution_context_summary = summary
330
- self._execution_context_history_cutoff = max(history_cutoff, 0)
331
- self._context_token_usage_baseline = None
332
27
 
333
- def _reset_execution_context(self) -> None:
334
- self._set_execution_context(summary="", history_cutoff=0)
28
+ logger = logging.getLogger("flowent.agent")
335
29
 
336
- def prime_runtime_state(self, state: AgentState) -> None:
337
- self.state = state
338
- if state == AgentState.IDLE:
339
- self._idle_started_at = _time.perf_counter()
340
- self._idle_state_event.set()
341
- else:
342
- self._idle_started_at = None
343
- self._idle_started_by_tool_call_id = None
344
- self._idle_state_event.clear()
345
30
 
346
- def set_todos(self, todos: list[TodoItem]) -> None:
347
- with self._todos_lock:
348
- self.todos = [TodoItem(text=t.text) for t in todos]
349
- self._persist_workspace_node()
31
+ FLOWENT_AGENT_SYSTEM_PROMPT = """You are Flowent, an agent that completes tasks by combining conversation context with available tools.
350
32
 
351
- def request_idle(self, *, tool_call_id: str | None = None) -> str:
352
- if self._has_pending_runtime_notices():
353
- self._log.debug("Skipping idle because runtime notice is pending")
354
- return ""
355
- if (
356
- self.node_type == NodeType.ASSISTANT
357
- and self._turn_started_with_pending_input
358
- and not self._turn_made_progress
359
- ):
360
- self._queue_runtime_notice(self._build_idle_without_progress_notice())
361
- self._log.debug("Skipping idle because fresh input is still unhandled")
362
- return ""
363
- actionable_todo = self._get_first_actionable_todo()
364
- if actionable_todo is not None:
365
- self._queue_runtime_notice(
366
- self._build_actionable_todo_notice(actionable_todo)
367
- )
368
- self._log.debug("Skipping idle because TODO is still actionable")
369
- return ""
370
- self._idle_started_by_tool_call_id = tool_call_id
371
- self.set_state(AgentState.IDLE)
372
- signal = self._wait_for_wakeup()
373
- elapsed = self._get_idle_elapsed_seconds(tool_call_id=tool_call_id)
374
- self._resume_from_wakeup(signal)
375
- return f"idle {elapsed:.2f}s"
376
-
377
- def request_sleep(self, *, seconds: float) -> str:
378
- duration = max(0.0, seconds)
379
- started_at = _time.perf_counter()
380
- if duration <= 0:
381
- self._queue_runtime_notice(self._build_sleep_deadline_notice())
382
- return "slept 0.00s"
383
-
384
- self.set_state(AgentState.SLEEPING, f"sleeping for {duration:.2f}s")
385
- while not self._terminate.is_set():
386
- self._raise_if_interrupt_requested()
387
- remaining = duration - (_time.perf_counter() - started_at)
388
- if remaining <= 0:
389
- self._queue_runtime_notice(self._build_sleep_deadline_notice())
390
- self.set_state(AgentState.RUNNING, "sleep deadline reached")
391
- break
392
- try:
393
- signal = self._wake_queue.get(timeout=min(remaining, 0.1))
394
- except Empty:
395
- continue
396
- if signal.reason == "termination":
397
- self._terminate.set()
398
- break
399
- if signal.reason == "message":
400
- self._resume_from_wakeup(signal)
401
- self._drain_messages()
402
- elapsed = max(0.0, _time.perf_counter() - started_at)
403
- return f"woken by message after {elapsed:.2f}s"
404
- elapsed = min(duration, max(0.0, _time.perf_counter() - started_at))
405
- return f"slept {elapsed:.2f}s"
406
-
407
- def _get_idle_elapsed_seconds(self, *, tool_call_id: str | None) -> float:
408
- started_at = self._idle_started_at
409
- if started_at is None:
410
- return 0.0
411
- if tool_call_id is not None and self._idle_started_by_tool_call_id not in {
412
- None,
413
- tool_call_id,
414
- }:
415
- return 0.0
416
- return max(0.0, _time.perf_counter() - started_at)
417
-
418
- def get_contact_ids_snapshot(self) -> list[str]:
419
- from flowent.graph_service import get_tab_leader_id, list_agent_contact_paths
420
- from flowent.registry import registry
421
- from flowent.workspace_store import workspace_store
422
-
423
- if self.node_type == NodeType.ASSISTANT:
424
- return [
425
- leader_id
426
- for leader_id in (
427
- get_tab_leader_id(tab.id) for tab in workspace_store.list_tabs()
428
- )
429
- if leader_id and registry.get(leader_id) is not None
430
- ]
431
-
432
- seen_ids: set[str] = set()
433
- contact_ids: list[str] = []
434
-
435
- def append_contact(node_id: str) -> None:
436
- if node_id == self.uuid or node_id in seen_ids:
437
- return
438
- if registry.get(node_id) is None:
439
- return
440
- seen_ids.add(node_id)
441
- contact_ids.append(node_id)
442
-
443
- if self.config.tab_id is None:
444
- return contact_ids
445
-
446
- leader_id = get_tab_leader_id(self.config.tab_id)
447
- is_leader = leader_id == self.uuid
448
- assistant = registry.get_assistant()
449
- if is_leader and assistant is not None:
450
- append_contact(assistant.uuid)
451
-
452
- if is_leader:
453
- for node in registry.get_all():
454
- if node.uuid == self.uuid or node.node_type != NodeType.AGENT:
455
- continue
456
- if node.config.tab_id != self.config.tab_id:
457
- continue
458
- if node.uuid == leader_id:
459
- continue
460
- append_contact(node.uuid)
461
- return contact_ids
462
-
463
- for path in list_agent_contact_paths(
464
- tab_id=self.config.tab_id,
465
- node_id=self.uuid,
466
- ):
467
- append_contact(path.target_id)
468
- return contact_ids
33
+ Work through each turn until the request is resolved. If the current context is enough, answer directly. If more information or action is needed, call the appropriate tool, read the result, and continue from that new context.
469
34
 
470
- def get_contacts_info(self) -> list[dict[str, Any]]:
471
- from flowent.graph_service import (
472
- is_tab_leader,
473
- list_agent_contact_paths,
474
- )
475
- from flowent.registry import registry
35
+ Use tools deliberately:
36
+ - Read files and list directories before making file changes that depend on existing project context.
37
+ - Search files when you need to find definitions, references, or related behavior.
38
+ - Apply structured patches for file edits.
39
+ - Run shell commands for diagnostics, builds, tests, and operations that require the local environment.
40
+ - Search the web only when current external information is needed.
41
+ - Update the plan when a task has multiple meaningful steps.
476
42
 
477
- result: list[dict[str, Any]] = []
478
- if (
479
- self.config.tab_id is not None
480
- and not is_tab_leader(node_id=self.uuid, tab_id=self.config.tab_id)
481
- and self.node_type == NodeType.AGENT
482
- ):
483
- return [
484
- path.serialize()
485
- for path in list_agent_contact_paths(
486
- tab_id=self.config.tab_id,
487
- node_id=self.uuid,
488
- )
489
- ]
43
+ After each tool result, decide whether the task is complete, whether another tool is needed, or whether you need to explain a blocker. When no more tool work is needed, provide the final response."""
490
44
 
491
- for contact_id in self.get_contact_ids_snapshot():
492
- node = registry.get(contact_id)
493
- if node is None:
494
- continue
495
- result.append(
496
- {
497
- "id": node.uuid,
498
- "node_type": node.config.node_type.value,
499
- "role_name": node.config.role_name,
500
- "name": node.config.name,
501
- "state": node.state.value,
502
- "is_leader": (
503
- node.config.tab_id is not None
504
- and is_tab_leader(node_id=node.uuid, tab_id=node.config.tab_id)
505
- ),
506
- }
507
- )
508
- return result
509
45
 
510
- def can_contact(self, node_id: str) -> bool:
511
- return node_id in set(self.get_contact_ids_snapshot())
46
+ class AgentStreamEvent(BaseModel):
47
+ model_config = ConfigDict(extra="forbid")
512
48
 
513
- def wait_until_idle(self, timeout: float | None = None) -> bool:
514
- if self.state == AgentState.IDLE:
515
- return True
516
- return self._idle_state_event.wait(timeout=timeout)
49
+ data: dict[str, object]
50
+ event: str
517
51
 
518
- def start(self) -> None:
519
- from flowent.graph_service import is_tab_leader
520
52
 
521
- self._thread = threading.Thread(
522
- target=self._run,
523
- name=f"agent-{self.uuid[:8]}",
524
- daemon=True,
525
- )
526
- self._thread.start()
527
- event_bus.emit(
528
- Event(
529
- type=EventType.NODE_CREATED,
530
- agent_id=self.uuid,
531
- data={
532
- "node_type": self.config.node_type.value,
533
- "role_name": self.config.role_name,
534
- "name": self.config.name,
535
- "tab_id": self.config.tab_id,
536
- "is_leader": is_tab_leader(
537
- node_id=self.uuid,
538
- tab_id=self.config.tab_id,
539
- ),
53
+ @dataclass
54
+ class PendingToolCall:
55
+ arguments: str = ""
56
+ id: str = ""
57
+ name: str = ""
58
+ type: str = "function"
59
+
60
+ def apply_delta(self, delta: ToolCallDelta) -> None:
61
+ if delta.id:
62
+ self.id = delta.id
63
+ if delta.name:
64
+ self.name = delta.name
65
+ if delta.type:
66
+ self.type = delta.type
67
+ if delta.arguments:
68
+ self.arguments += delta.arguments
69
+
70
+
71
+ def assistant_tool_call_message(
72
+ tool_calls: Sequence[PendingToolCall],
73
+ content: str,
74
+ ) -> dict[str, object]:
75
+ return {
76
+ "role": "assistant",
77
+ "content": content or None,
78
+ "tool_calls": [
79
+ {
80
+ "id": tool_call.id or f"call_{index}",
81
+ "type": tool_call.type,
82
+ "function": {
83
+ "name": tool_call.name,
84
+ "arguments": tool_call.arguments,
540
85
  },
541
- ),
542
- )
543
- self._persist_workspace_node()
544
-
545
- def _append_history(self, entry: HistoryEntry) -> None:
546
- with self._history_lock:
547
- self.history.append(entry)
548
- if isinstance(entry, ReceivedMessage):
549
- self._pending_input_turn = True
550
- data = entry.serialize()
551
- self._log.debug(
552
- "History append: type={}, content_len={}",
553
- data.get("type"),
554
- len(getattr(entry, "content", None) or "")
555
- if hasattr(entry, "content")
556
- else 0,
557
- )
558
- event_bus.emit(
559
- Event(
560
- type=EventType.HISTORY_ENTRY_ADDED,
561
- agent_id=self.uuid,
562
- data=data,
563
- ),
564
- )
565
- self._persist_workspace_node()
566
-
567
- def _clear_pending_message_wakeups(self) -> None:
568
- preserved_signals: list[WakeSignal] = []
569
-
570
- while True:
571
- try:
572
- signal = self._wake_queue.get_nowait()
573
- except Empty:
574
- break
575
-
576
- if signal.reason != "message":
577
- preserved_signals.append(signal)
578
-
579
- for signal in preserved_signals:
580
- self._wake_queue.put(signal)
581
-
582
- def _extract_pending_message_wakeups(self) -> list[WakeSignal]:
583
- extracted_signals: list[WakeSignal] = []
584
- preserved_signals: list[WakeSignal] = []
585
-
586
- while True:
587
- try:
588
- signal = self._wake_queue.get_nowait()
589
- except Empty:
590
- break
591
-
592
- if signal.reason == "message":
593
- extracted_signals.append(signal)
594
- continue
595
- preserved_signals.append(signal)
596
-
597
- for signal in preserved_signals:
598
- self._wake_queue.put(signal)
599
-
600
- return extracted_signals
601
-
602
- def _restore_pending_message_wakeups(
603
- self,
604
- signals: list[WakeSignal],
605
- ) -> None:
606
- for signal in signals:
607
- self._wake_queue.put(signal)
608
-
609
- def _pause_for_command_execution(self, *, timeout: float) -> bool:
610
- if self.state not in {AgentState.RUNNING, AgentState.SLEEPING}:
611
- return False
612
- if not self._command_interrupt_lock.acquire(timeout=timeout):
613
- raise TimeoutError("Current chat did not pause for the command in time")
614
-
615
- self._pause_after_interrupt_requested.set()
616
- self._paused_for_command.clear()
617
- self._resume_after_command.clear()
618
-
619
- try:
620
- if not self.request_interrupt():
621
- self._pause_after_interrupt_requested.clear()
622
- self._resume_after_command.set()
623
- self._command_interrupt_lock.release()
624
- return False
625
- if not self._paused_for_command.wait(timeout=timeout):
626
- raise TimeoutError("Current chat did not pause after interrupt")
627
- return True
628
- except Exception:
629
- self._pause_after_interrupt_requested.clear()
630
- self._resume_after_command.set()
631
- self._command_interrupt_lock.release()
632
- raise
633
-
634
- def _resume_after_command_execution(self) -> None:
635
- self._pause_after_interrupt_requested.clear()
636
- self._resume_after_command.set()
637
- self._command_interrupt_lock.release()
638
-
639
- def clear_chat_history(self, *, interrupt_timeout: float = 5.0) -> None:
640
- if self.node_type != NodeType.ASSISTANT:
641
- from flowent.graph_service import is_tab_leader
642
-
643
- if not is_tab_leader(node_id=self.uuid, tab_id=self.config.tab_id):
644
- raise RuntimeError("Only Assistant or workflow chats can be cleared")
645
-
646
- paused_for_command = self._pause_for_command_execution(
647
- timeout=interrupt_timeout
648
- )
649
- try:
650
- self._clear_pending_message_wakeups()
651
- with self._runtime_notice_lock:
652
- self._pending_runtime_notices.clear()
653
- with self._history_lock:
654
- self.history = [
655
- entry for entry in self.history if isinstance(entry, SystemEntry)
656
- ]
657
- self._pending_input_turn = False
658
- self._turn_started_with_pending_input = False
659
- self._turn_made_progress = False
660
- self._reset_execution_context()
661
-
662
- event_bus.emit(
663
- Event(
664
- type=EventType.HISTORY_CLEARED,
665
- agent_id=self.uuid,
666
- data={
667
- "scope": "assistant_chat"
668
- if self.node_type == NodeType.ASSISTANT
669
- else "workflow_chat"
670
- },
671
- )
672
- )
673
- self._persist_workspace_node()
674
- finally:
675
- if paused_for_command:
676
- self._resume_after_command_execution()
677
-
678
- def retry_human_message(
679
- self,
680
- *,
681
- message_id: str,
682
- interrupt_timeout: float = 5.0,
683
- ) -> str:
684
- if self.node_type != NodeType.ASSISTANT:
685
- raise RuntimeError("Only assistant chat history can be retried")
686
-
687
- normalized_message_id = message_id.strip()
688
- if not normalized_message_id:
689
- raise ValueError("Assistant retry message_id cannot be empty")
690
-
691
- paused_for_command = self._pause_for_command_execution(
692
- timeout=interrupt_timeout
693
- )
694
- extracted_message_signals: list[WakeSignal] = []
695
- previous_runtime_notices: list[str] = []
696
- previous_history: list[HistoryEntry] = []
697
- previous_pending_input_turn = False
698
- previous_turn_started_with_pending_input = False
699
- previous_turn_made_progress = False
700
- previous_execution_summary = ""
701
- previous_execution_cutoff = 0
702
- previous_context_token_usage_baseline: ContextTokenUsageBaseline | None = None
703
- try:
704
- with self._history_lock:
705
- anchor_index = -1
706
- anchor_parts: list[TextPart | ImagePart] = []
707
- current_history = list(self.history)
708
- for index, entry in enumerate(current_history):
709
- if (
710
- isinstance(entry, ReceivedMessage)
711
- and entry.from_id == "human"
712
- and entry.message_id == normalized_message_id
713
- ):
714
- anchor_index = index
715
- anchor_parts = list(entry.parts)
716
- break
717
-
718
- if anchor_index < 0:
719
- raise LookupError(
720
- f"Assistant human message `{normalized_message_id}` was not found."
721
- )
722
-
723
- if has_image_parts(anchor_parts) and not self.supports_input_image():
724
- raise RuntimeError(
725
- "Assistant current model does not support `input_image`."
726
- )
727
-
728
- for part in anchor_parts:
729
- asset_id = getattr(part, "asset_id", None)
730
- if isinstance(asset_id, str):
731
- require_image_asset(asset_id)
732
-
733
- extracted_message_signals = self._extract_pending_message_wakeups()
734
- with self._runtime_notice_lock:
735
- previous_runtime_notices = list(self._pending_runtime_notices)
736
- self._pending_runtime_notices.clear()
737
-
738
- with self._history_lock:
739
- previous_history = list(self.history)
740
- previous_pending_input_turn = self._pending_input_turn
741
- previous_turn_started_with_pending_input = (
742
- self._turn_started_with_pending_input
743
- )
744
- previous_turn_made_progress = self._turn_made_progress
745
- self.history = [
746
- entry
747
- for index, entry in enumerate(previous_history)
748
- if index < anchor_index or isinstance(entry, SystemEntry)
749
- ]
750
- retried_message_id = str(_uuid.uuid4())
751
- self.history.append(
752
- ReceivedMessage(
753
- from_id="human",
754
- parts=anchor_parts,
755
- message_id=retried_message_id,
756
- )
757
- )
758
- self._pending_input_turn = True
759
- self._turn_started_with_pending_input = False
760
- self._turn_made_progress = False
761
-
762
- with self._execution_context_lock:
763
- previous_execution_summary = self._execution_context_summary
764
- previous_execution_cutoff = self._execution_context_history_cutoff
765
- previous_context_token_usage_baseline = (
766
- self._context_token_usage_baseline
767
- )
768
- self._set_execution_context(summary="", history_cutoff=0)
769
- self._persist_workspace_node()
770
- self.enqueue_message(
771
- Message(
772
- from_id="human",
773
- to_id=self.uuid,
774
- parts=anchor_parts,
775
- message_id=retried_message_id,
776
- history_recorded=True,
777
- )
778
- )
779
- history_snapshot = self.get_history_snapshot()
780
- event_bus.emit(
781
- Event(
782
- type=EventType.HISTORY_REPLACED,
783
- agent_id=self.uuid,
784
- data={
785
- "scope": "assistant_retry",
786
- "replaced_message_id": normalized_message_id,
787
- "message_id": retried_message_id,
788
- "history": [entry.serialize() for entry in history_snapshot],
789
- },
790
- )
791
- )
792
- return retried_message_id
793
- except Exception:
794
- if previous_history:
795
- with self._history_lock:
796
- self.history = previous_history
797
- self._pending_input_turn = previous_pending_input_turn
798
- self._turn_started_with_pending_input = (
799
- previous_turn_started_with_pending_input
800
- )
801
- self._turn_made_progress = previous_turn_made_progress
802
- with self._runtime_notice_lock:
803
- self._pending_runtime_notices = previous_runtime_notices
804
- with self._execution_context_lock:
805
- self._execution_context_summary = previous_execution_summary
806
- self._execution_context_history_cutoff = previous_execution_cutoff
807
- self._context_token_usage_baseline = (
808
- previous_context_token_usage_baseline
809
- )
810
- if extracted_message_signals:
811
- self._restore_pending_message_wakeups(extracted_message_signals)
812
- self._persist_workspace_node()
813
- raise
814
- finally:
815
- if paused_for_command:
816
- self._resume_after_command_execution()
817
-
818
- def retry_received_message(
819
- self,
820
- *,
821
- message_id: str,
822
- interrupt_timeout: float = 5.0,
823
- ) -> str:
824
- normalized_message_id = message_id.strip()
825
- if not normalized_message_id:
826
- raise ValueError("Retry message_id cannot be empty")
827
-
828
- leader_human_only = False
829
- if self.node_type != NodeType.ASSISTANT and self.config.tab_id:
830
- from flowent.graph_service import is_tab_leader
831
-
832
- leader_human_only = is_tab_leader(
833
- node_id=self.uuid,
834
- tab_id=self.config.tab_id,
835
- )
836
-
837
- paused_for_command = self._pause_for_command_execution(
838
- timeout=interrupt_timeout
839
- )
840
- extracted_message_signals: list[WakeSignal] = []
841
- previous_runtime_notices: list[str] = []
842
- previous_history: list[HistoryEntry] = []
843
- previous_pending_input_turn = False
844
- previous_turn_started_with_pending_input = False
845
- previous_turn_made_progress = False
846
- previous_execution_summary = ""
847
- previous_execution_cutoff = 0
848
- previous_context_token_usage_baseline: ContextTokenUsageBaseline | None = None
849
- try:
850
- with self._history_lock:
851
- anchor_index = -1
852
- anchor_parts: list[TextPart | ImagePart] = []
853
- anchor_from_id = "human"
854
- current_history = list(self.history)
855
- for index, entry in enumerate(current_history):
856
- if (
857
- isinstance(entry, ReceivedMessage)
858
- and entry.message_id == normalized_message_id
859
- and (not leader_human_only or entry.from_id == "human")
860
- ):
861
- anchor_index = index
862
- anchor_parts = list(entry.parts)
863
- anchor_from_id = entry.from_id
864
- break
865
-
866
- if anchor_index < 0:
867
- if leader_human_only:
868
- raise LookupError(
869
- f"Leader human message `{normalized_message_id}` was not found."
870
- )
871
- raise LookupError(
872
- f"Received message `{normalized_message_id}` was not found."
873
- )
874
-
875
- if has_image_parts(anchor_parts) and not self.supports_input_image():
876
- raise RuntimeError("Current model does not support `input_image`.")
877
-
878
- for part in anchor_parts:
879
- asset_id = getattr(part, "asset_id", None)
880
- if isinstance(asset_id, str):
881
- require_image_asset(asset_id)
882
-
883
- extracted_message_signals = self._extract_pending_message_wakeups()
884
- with self._runtime_notice_lock:
885
- previous_runtime_notices = list(self._pending_runtime_notices)
886
- self._pending_runtime_notices.clear()
887
-
888
- with self._history_lock:
889
- previous_history = list(self.history)
890
- previous_pending_input_turn = self._pending_input_turn
891
- previous_turn_started_with_pending_input = (
892
- self._turn_started_with_pending_input
893
- )
894
- previous_turn_made_progress = self._turn_made_progress
895
- self.history = [
896
- entry
897
- for index, entry in enumerate(previous_history)
898
- if index < anchor_index or isinstance(entry, SystemEntry)
899
- ]
900
- retried_message_id = str(_uuid.uuid4())
901
- self.history.append(
902
- ReceivedMessage(
903
- from_id=anchor_from_id,
904
- parts=anchor_parts,
905
- message_id=retried_message_id,
906
- )
907
- )
908
- self._pending_input_turn = True
909
- self._turn_started_with_pending_input = False
910
- self._turn_made_progress = False
911
-
912
- with self._execution_context_lock:
913
- previous_execution_summary = self._execution_context_summary
914
- previous_execution_cutoff = self._execution_context_history_cutoff
915
- previous_context_token_usage_baseline = (
916
- self._context_token_usage_baseline
917
- )
918
- self._set_execution_context(summary="", history_cutoff=0)
919
- self._persist_workspace_node()
920
- self.enqueue_message(
921
- Message(
922
- from_id=anchor_from_id,
923
- to_id=self.uuid,
924
- parts=anchor_parts,
925
- message_id=retried_message_id,
926
- history_recorded=True,
927
- )
928
- )
929
- history_snapshot = self.get_history_snapshot()
930
- event_bus.emit(
931
- Event(
932
- type=EventType.HISTORY_REPLACED,
933
- agent_id=self.uuid,
934
- data={
935
- "scope": "node_retry",
936
- "replaced_message_id": normalized_message_id,
937
- "message_id": retried_message_id,
938
- "history": [entry.serialize() for entry in history_snapshot],
939
- },
940
- )
941
- )
942
- return retried_message_id
943
- except Exception:
944
- if previous_history:
945
- with self._history_lock:
946
- self.history = previous_history
947
- self._pending_input_turn = previous_pending_input_turn
948
- self._turn_started_with_pending_input = (
949
- previous_turn_started_with_pending_input
950
- )
951
- self._turn_made_progress = previous_turn_made_progress
952
- with self._runtime_notice_lock:
953
- self._pending_runtime_notices = previous_runtime_notices
954
- self._restore_pending_message_wakeups(extracted_message_signals)
955
- with self._execution_context_lock:
956
- self._execution_context_summary = previous_execution_summary
957
- self._execution_context_history_cutoff = previous_execution_cutoff
958
- self._context_token_usage_baseline = (
959
- previous_context_token_usage_baseline
960
- )
961
- self._persist_workspace_node()
962
- raise
963
- finally:
964
- if paused_for_command:
965
- self._resume_after_command_execution()
966
-
967
- def compact_chat_history(
968
- self,
969
- *,
970
- focus: str | None = None,
971
- interrupt_timeout: float = 5.0,
972
- ) -> CommandResultEntry:
973
- if self.node_type != NodeType.ASSISTANT:
974
- from flowent.graph_service import is_tab_leader
975
-
976
- if not is_tab_leader(node_id=self.uuid, tab_id=self.config.tab_id):
977
- raise RuntimeError("Only Assistant or workflow chats can be compacted")
978
-
979
- paused_for_command = self._pause_for_command_execution(
980
- timeout=interrupt_timeout
981
- )
982
- try:
983
- self._run_compact_with_observability(
984
- trigger_type="manual",
985
- focus=focus,
986
- )
987
- content = "Compacted this chat for future replies."
988
- if focus and focus.strip():
989
- content += f"\n\nFocus: {focus.strip()}"
990
-
991
- with self._runtime_notice_lock:
992
- self._pending_runtime_notices.clear()
993
-
994
- return CommandResultEntry(
995
- command_name="/compact",
996
- content=content,
997
- include_in_context=False,
998
- )
999
- finally:
1000
- if paused_for_command:
1001
- self._resume_after_command_execution()
1002
-
1003
- def execute_conversation_command(
1004
- self,
1005
- *,
1006
- command_name: str,
1007
- argument: str = "",
1008
- interrupt_timeout: float = 5.0,
1009
- ) -> CommandResultEntry:
1010
- append_to_history = True
1011
- if command_name == "/clear":
1012
- self.clear_chat_history(interrupt_timeout=interrupt_timeout)
1013
- entry = CommandResultEntry(
1014
- command_name=command_name,
1015
- content="Cleared the current chat.",
1016
- include_in_context=False,
1017
- )
1018
- append_to_history = False
1019
- elif command_name == "/compact":
1020
- entry = self.compact_chat_history(
1021
- focus=argument or None,
1022
- interrupt_timeout=interrupt_timeout,
1023
- )
1024
- elif command_name == "/help":
1025
- entry = CommandResultEntry(
1026
- command_name=command_name,
1027
- content=build_conversation_help_text(),
1028
- )
1029
- else:
1030
- raise RuntimeError(f"Unsupported conversation command: {command_name}")
1031
-
1032
- if append_to_history:
1033
- self._append_history(entry)
1034
- return entry
1035
-
1036
- def execute_assistant_command(
1037
- self,
1038
- *,
1039
- command_name: str,
1040
- argument: str = "",
1041
- interrupt_timeout: float = 5.0,
1042
- ) -> CommandResultEntry:
1043
- return self.execute_conversation_command(
1044
- command_name=command_name,
1045
- argument=argument,
1046
- interrupt_timeout=interrupt_timeout,
1047
- )
1048
-
1049
- def _run(self) -> None:
1050
- with logger.contextualize(
1051
- agent_id=self.uuid[:8],
1052
- node_type=self.config.node_type.value,
1053
- ):
1054
- with self._history_lock:
1055
- has_system_entry = any(
1056
- isinstance(entry, SystemEntry) for entry in self.history
1057
- )
1058
- if has_system_entry:
1059
- self._sync_system_prompt_entry()
1060
- else:
1061
- self._append_history(
1062
- SystemEntry(content=get_system_prompt(self.config))
1063
- )
1064
-
1065
- if self.state == AgentState.INITIALIZING:
1066
- self.set_state(AgentState.IDLE, "initialized, awaiting first message")
1067
- self._log.info("Agent started, waiting for first message")
1068
- else:
1069
- self._log.info(
1070
- "Agent restored in state {}, waiting for input",
1071
- self.state.value,
1072
- )
1073
- self._wait_for_input()
1074
-
1075
- if self._terminate.is_set():
1076
- if self._should_preserve_workspace_state_on_exit():
1077
- self._log.info(
1078
- "Agent stopped for process exit with state {}",
1079
- self.state.value,
1080
- )
1081
- return
1082
- self._finalize_termination("terminated before first message")
1083
- return
1084
-
1085
- while not self._terminate.is_set():
1086
- try:
1087
- self._sync_system_prompt_entry()
1088
- self._drain_messages()
1089
- self._turn_started_with_pending_input = self._pending_input_turn
1090
- self._turn_made_progress = False
1091
-
1092
- tools_schema = _get_tool_registry().get_tools_schema(self)
1093
- prepared_context = self._prepare_messages_for_llm()
1094
- messages = prepared_context.messages
1095
-
1096
- self._log.debug(
1097
- "LLM request: messages={}, tools={}, history_len={}",
1098
- len(messages),
1099
- len(tools_schema) if tools_schema else 0,
1100
- len(self.history),
1101
- )
1102
- stream_state: StreamingContentState | None = None
1103
- try:
1104
- response, stream_state = self._chat_with_retries(
1105
- prepared_context=prepared_context,
1106
- tools_schema=tools_schema,
1107
- )
1108
- self._flush_streaming_think_parser(stream_state)
1109
-
1110
- self._log.debug(
1111
- "LLM response: content_len={}, parts_len={}, thinking_len={}, tool_calls={}",
1112
- len(response.content) if response.content else 0,
1113
- len(response.parts) if response.parts else 0,
1114
- len(response.thinking) if response.thinking else 0,
1115
- [tc.name for tc in response.tool_calls]
1116
- if response.tool_calls
1117
- else None,
1118
- )
1119
-
1120
- final_thinking = (
1121
- response.thinking or stream_state.thinking_buffer
1122
- )
1123
- final_content = (
1124
- stream_state.content_buffer
1125
- if stream_state.saw_content_chunks
1126
- else response.content
1127
- )
1128
- final_parts = response.parts
1129
- if final_content and final_thinking:
1130
- final_content, _ = split_thinking_content(final_content)
1131
-
1132
- if final_thinking:
1133
- self._append_history(
1134
- AssistantThinking(content=final_thinking),
1135
- )
1136
- stream_state.thinking_buffer = ""
1137
-
1138
- if response.tool_calls:
1139
- self._log.debug(
1140
- "Processing {} tool call(s)",
1141
- len(response.tool_calls),
1142
- )
1143
- if final_parts:
1144
- self._record_content_parts_output(
1145
- self._normalize_llm_output_parts(final_parts),
1146
- emitted_human_content=stream_state.emitted_human_content,
1147
- )
1148
- stream_state.content_buffer = ""
1149
- elif final_content:
1150
- self._record_text_output(
1151
- final_content,
1152
- emitted_human_content=stream_state.emitted_human_content,
1153
- )
1154
- stream_state.content_buffer = ""
1155
- self._raise_if_interrupt_requested()
1156
- stop_future_send_calls = False
1157
- for tc in response.tool_calls:
1158
- if stop_future_send_calls and tc.name == "send":
1159
- continue
1160
- tool_result = self._handle_tool_call(
1161
- tc.name,
1162
- tc.arguments,
1163
- tc.id,
1164
- )
1165
- if tc.name == "send" and self._tool_result_has_error(
1166
- tool_result
1167
- ):
1168
- stop_future_send_calls = True
1169
- self._raise_if_interrupt_requested()
1170
- if self._terminate.is_set():
1171
- break
1172
- elif final_parts:
1173
- self._record_content_parts_output(
1174
- self._normalize_llm_output_parts(final_parts),
1175
- emitted_human_content=stream_state.emitted_human_content,
1176
- )
1177
- stream_state.content_buffer = ""
1178
- self._log.debug(
1179
- "No tool calls, continuing execution after structured response"
1180
- )
1181
- elif final_content:
1182
- self._record_text_output(
1183
- final_content,
1184
- emitted_human_content=stream_state.emitted_human_content,
1185
- )
1186
- stream_state.content_buffer = ""
1187
- self._log.debug(
1188
- "No tool calls, continuing execution after text response"
1189
- )
1190
- else:
1191
- self._log.warning(
1192
- "LLM returned empty response (no content, no tool_calls)",
1193
- )
1194
- except InterruptRequestedError as exc:
1195
- self._handle_interrupt(exc.stream_state or stream_state)
1196
- if self._terminate.is_set():
1197
- break
1198
- continue
1199
-
1200
- except LLMProviderError as exc:
1201
- self._interrupt_requested.clear()
1202
- self.set_interrupt_callback(None)
1203
- self._log.warning("Agent LLM provider error: {}", exc)
1204
- error_summary = str(exc)
1205
- self._append_history(ErrorEntry(content=error_summary))
1206
- self.set_state(AgentState.ERROR, error_summary)
1207
- self._wait_for_input()
1208
- if self._terminate.is_set():
1209
- break
1210
-
1211
- except ContextPreflightError as exc:
1212
- self._interrupt_requested.clear()
1213
- self.set_interrupt_callback(None)
1214
- self._log.warning("Agent context preflight failed: {}", exc)
1215
- error_summary = str(exc)
1216
- self._append_history(ErrorEntry(content=error_summary))
1217
- self.set_state(AgentState.ERROR, error_summary)
1218
- self._wait_for_input()
1219
- if self._terminate.is_set():
1220
- break
1221
-
1222
- except Exception as exc:
1223
- self._interrupt_requested.clear()
1224
- self.set_interrupt_callback(None)
1225
- self._log.exception("Agent error")
1226
- error_summary = build_runtime_error_summary(exc)
1227
- self._append_history(ErrorEntry(content=error_summary))
1228
- self.set_state(AgentState.ERROR, error_summary)
1229
- self._wait_for_input()
1230
- if self._terminate.is_set():
1231
- break
1232
-
1233
- if self._should_preserve_workspace_state_on_exit():
1234
- self._log.info(
1235
- "Agent stopped for process exit with state {}",
1236
- self.state.value,
1237
- )
1238
- return
1239
-
1240
- self._finalize_termination(self._termination_reason or "finished")
1241
-
1242
- def _sync_system_prompt_entry(self) -> None:
1243
- system_prompt = get_system_prompt(self.config)
1244
- with self._history_lock:
1245
- for entry in self.history:
1246
- if isinstance(entry, SystemEntry):
1247
- entry.content = system_prompt
1248
- break
1249
- else:
1250
- self.history.insert(0, SystemEntry(content=system_prompt))
1251
-
1252
- @staticmethod
1253
- def _build_runtime_system_message(content: str) -> dict[str, str]:
1254
- return {"role": "user", "content": f"<system>{content}</system>"}
86
+ }
87
+ for index, tool_call in enumerate(tool_calls)
88
+ ],
89
+ }
90
+
91
+
92
+ def tool_result_message(tool_call_id: str, content: str) -> dict[str, object]:
93
+ return {
94
+ "role": "tool",
95
+ "tool_call_id": tool_call_id,
96
+ "content": content,
97
+ }
98
+
99
+
100
+ async def run_agent_stream(
101
+ *,
102
+ completion: CompletionCallable | None,
103
+ connection: ProviderConnection,
104
+ cwd: Path,
105
+ messages: Sequence[Mapping[str, object]],
106
+ web_searcher: Callable[[str], Sequence[dict[str, str]]] | None = None,
107
+ ) -> AsyncIterator[AgentStreamEvent]:
108
+ conversation: list[Mapping[str, object]] = [
109
+ {"role": "system", "content": FLOWENT_AGENT_SYSTEM_PROMPT},
110
+ *[dict(message) for message in messages],
111
+ ]
112
+ assistant_id = str(uuid4())
113
+ logger.info(
114
+ "Agent response started id=%s provider=%s model=%s",
115
+ assistant_id,
116
+ connection.provider,
117
+ connection.model,
118
+ )
119
+ logger.log(TRACE_LEVEL, "Agent initial messages=%r", conversation)
120
+ yield AgentStreamEvent(event="start", data={"id": assistant_id})
1255
121
 
1256
- def _build_runtime_tail_messages(self) -> list[dict[str, str]]:
1257
- with self._todos_lock:
1258
- todos = [TodoItem(text=t.text) for t in self.todos]
1259
- with self._history_lock:
1260
- history_snapshot = list(self.history)
1261
- runtime_notices = self._consume_runtime_notices()
1262
- custom_post_prompt = get_settings().custom_post_prompt.strip()
1263
- messages: list[dict[str, str]] = []
1264
- todo_message = self._build_runtime_todo_message(todos)
1265
- if todo_message is not None:
1266
- messages.append(todo_message)
1267
- messages.append(
1268
- self._build_runtime_post_prompt_message(
1269
- has_todos=bool(todos),
1270
- pending_agent_dispatches=self._get_pending_agent_dispatches(
1271
- history_snapshot
1272
- ),
1273
- )
1274
- )
1275
- for notice in runtime_notices:
1276
- messages.append(self._build_runtime_system_message(notice))
1277
- if custom_post_prompt:
1278
- messages.append(self._build_runtime_system_message(custom_post_prompt))
1279
- return messages
122
+ final_content = ""
1280
123
 
1281
- def _build_runtime_todo_message(
1282
- self,
1283
- todos: list[TodoItem],
1284
- ) -> dict[str, str] | None:
1285
- if not todos:
1286
- return None
1287
- lines = []
1288
- for todo in todos:
1289
- lines.append(f" - {todo.text}")
1290
- todo_text = "Current TODO list:\n" + "\n".join(lines)
1291
- return self._build_runtime_system_message(todo_text)
124
+ round_number = 0
125
+ while True:
126
+ round_number += 1
127
+ logger.debug("Agent round started id=%s round=%s", assistant_id, round_number)
128
+ yield AgentStreamEvent(event="output_start", data={"index": round_number})
129
+ round_content = ""
130
+ pending: dict[int, PendingToolCall] = {}
1292
131
 
1293
- @staticmethod
1294
- def _get_created_agent_label(payload: dict[str, Any]) -> str | None:
1295
- agent_id = payload.get("agent_id")
1296
- if not isinstance(agent_id, str) or not agent_id:
1297
- agent_id = payload.get("id")
1298
- if not isinstance(agent_id, str) or not agent_id:
1299
- return None
1300
- name = payload.get("name")
1301
- if (not isinstance(name, str) or not name.strip()) and isinstance(
1302
- payload.get("config"), dict
132
+ async for chunk in stream_chat_chunks(
133
+ connection, conversation, completion=completion, tools=tool_specs()
1303
134
  ):
1304
- config = payload["config"]
1305
- config_name = config.get("name")
1306
- config_role_name = config.get("role_name")
1307
- if isinstance(config_name, str) and config_name.strip():
1308
- name = config_name
1309
- elif isinstance(config_role_name, str) and config_role_name.strip():
1310
- name = config_role_name
1311
- short_id = agent_id[:8]
1312
- if isinstance(name, str) and name.strip():
1313
- return f"{name.strip()} (`{short_id}`)"
1314
- return f"`{short_id}`"
1315
-
1316
- @classmethod
1317
- def _iter_dispatched_agent_payloads(
1318
- cls,
1319
- tool_name: str,
1320
- payload: dict[str, Any],
1321
- ) -> list[tuple[str, str]]:
1322
- if tool_name == "create_agent":
1323
- label = cls._get_created_agent_label(payload)
1324
- agent_id = payload.get("id")
1325
- if isinstance(agent_id, str) and label is not None:
1326
- return [(agent_id, label)]
1327
- return []
1328
- return []
1329
-
1330
- def _get_pending_agent_dispatches(
1331
- self,
1332
- history_snapshot: list[HistoryEntry],
1333
- ) -> list[str]:
1334
- pending: dict[str, str] = {}
1335
-
1336
- for entry in history_snapshot:
1337
- if isinstance(entry, ToolCall):
1338
- if entry.tool_name != "create_agent":
1339
- continue
1340
- if entry.result is None:
1341
- continue
1342
- try:
1343
- payload = json.loads(entry.result)
1344
- except json.JSONDecodeError:
1345
- continue
1346
- if not isinstance(payload, dict) or payload.get("error") is not None:
1347
- continue
1348
- for agent_id, label in self._iter_dispatched_agent_payloads(
1349
- entry.tool_name,
1350
- payload,
1351
- ):
1352
- pending[agent_id] = label
1353
- continue
1354
-
1355
- if isinstance(entry, SentMessage):
1356
- pending.pop(entry.to_id, None)
1357
-
1358
- return list(pending.values())
1359
-
1360
- def _build_runtime_post_prompt_message(
1361
- self,
1362
- *,
1363
- has_todos: bool,
1364
- pending_agent_dispatches: list[str],
1365
- ) -> dict[str, str]:
1366
- if self._is_entry_level_sender():
1367
- send_lines = [
1368
- "- To send a formal message to another node, use `send` with a single `target` and ordered `parts`.",
1369
- "- Use `contacts` to inspect the node ids and names you can currently message directly.",
1370
- ]
1371
- else:
1372
- send_lines = [
1373
- "- To send through a workflow path, use `send` with one target, source output port, target input port, and a value matching that port type.",
1374
- "- Use `contacts` to inspect the current output-port paths you can send through.",
1375
- ]
1376
- lines = [
1377
- "Runtime post prompt:",
1378
- "- Plain content is never delivered to other agents.",
1379
- *send_lines,
1380
- "- `@target:` or any other `@name:` text inside normal content is just text. It does not send anything.",
1381
- ]
1382
- if pending_agent_dispatches:
1383
- targets = ", ".join(pending_agent_dispatches)
1384
- lines.append(
1385
- f"- Newly created agents still waiting for their first task: {targets}."
1386
- )
1387
- lines.append(
1388
- "- `create_agent` only adds a new agent node to the current workflow. It does not start work by itself."
1389
- )
1390
- lines.append(
1391
- "- Before calling `idle`, dispatch each waiting agent a concrete first task with `send`."
1392
- )
1393
- elif has_todos:
1394
- lines.append(
1395
- "- If the TODO list is not complete yet, use `todo` to replace it with the latest remaining items."
1396
- )
1397
- else:
1398
- lines.append(
1399
- "- If there is no unfinished TODO and the task is finished with no immediate next action, call `idle`."
1400
- )
1401
- return self._build_runtime_system_message("\n".join(lines))
1402
-
1403
- @staticmethod
1404
- def _build_idle_without_progress_notice() -> str:
1405
- return (
1406
- "Idle reminder: you received a new message this turn, but this "
1407
- "response did not send a reply, call `send`, or use any "
1408
- "non-idle tool. Do not call `idle` yet. First reply to the Human, "
1409
- "dispatch/delegate work, or take another concrete step."
1410
- )
1411
-
1412
- @staticmethod
1413
- def _build_actionable_todo_notice(todo_text: str) -> str:
1414
- return (
1415
- "Idle reminder: your first remaining TODO still looks actionable "
1416
- f"(`{todo_text}`). Do that next, or update the TODO list so the "
1417
- "first remaining item is the actual waiting step, before calling "
1418
- "`idle`."
1419
- )
1420
-
1421
- @staticmethod
1422
- def _build_sleep_deadline_notice() -> str:
1423
- return (
1424
- "Sleep deadline reached: the timed wait has expired. Continue from "
1425
- "that deadline wake-up and decide whether to retry, follow up, or "
1426
- "escalate."
1427
- )
1428
-
1429
- def _queue_runtime_notice(self, content: str) -> None:
1430
- with self._runtime_notice_lock:
1431
- if content not in self._pending_runtime_notices:
1432
- self._pending_runtime_notices.append(content)
1433
-
1434
- def _has_pending_runtime_notices(self) -> bool:
1435
- with self._runtime_notice_lock:
1436
- return bool(self._pending_runtime_notices)
1437
-
1438
- def _consume_runtime_notices(self) -> list[str]:
1439
- with self._runtime_notice_lock:
1440
- notices = list(self._pending_runtime_notices)
1441
- self._pending_runtime_notices.clear()
1442
- return notices
1443
-
1444
- def _handle_llm_chunk(
1445
- self,
1446
- state: StreamingContentState,
1447
- chunk_type: str,
1448
- text: str,
1449
- ) -> None:
1450
- self._raise_if_interrupt_requested()
1451
- if chunk_type == "content":
1452
- state.saw_content_chunks = True
1453
- for normalized_type, normalized_text in state.think_parser.feed(text):
1454
- if normalized_type == "thinking":
1455
- self._handle_streaming_thinking_chunk(state, normalized_text)
1456
- else:
1457
- self._handle_streaming_content_chunk(state, normalized_text)
1458
- return
1459
- if chunk_type != "thinking":
1460
- return
1461
- self._handle_streaming_thinking_chunk(state, text)
1462
-
1463
- def _handle_streaming_content_chunk(
1464
- self,
1465
- state: StreamingContentState,
1466
- text: str,
1467
- ) -> None:
1468
- if not text:
1469
- return
1470
- state.content_buffer += text
1471
- delta = ContentDelta(text=text)
1472
- event_bus.emit(
1473
- Event(
1474
- type=EventType.HISTORY_ENTRY_DELTA,
1475
- agent_id=self.uuid,
1476
- data=delta.serialize(),
1477
- ),
1478
- )
1479
- if self.node_type == NodeType.ASSISTANT:
1480
- state.emitted_human_content = True
1481
- event_bus.emit(
1482
- Event(
1483
- type=EventType.ASSISTANT_CONTENT,
1484
- agent_id=self.uuid,
1485
- data={"content": text},
1486
- ),
1487
- )
1488
-
1489
- def _handle_streaming_thinking_chunk(
1490
- self,
1491
- state: StreamingContentState,
1492
- text: str,
1493
- ) -> None:
1494
- if not text:
1495
- return
1496
- state.thinking_buffer += text
1497
- delta = ThinkingDelta(text=text)
1498
- event_bus.emit(
1499
- Event(
1500
- type=EventType.HISTORY_ENTRY_DELTA,
1501
- agent_id=self.uuid,
1502
- data=delta.serialize(),
1503
- ),
1504
- )
1505
-
1506
- def _flush_streaming_think_parser(self, state: StreamingContentState) -> None:
1507
- for normalized_type, normalized_text in state.think_parser.flush():
1508
- if normalized_type == "thinking":
1509
- self._handle_streaming_thinking_chunk(state, normalized_text)
1510
- else:
1511
- self._handle_streaming_content_chunk(state, normalized_text)
1512
-
1513
- def _deliver_message(
1514
- self,
1515
- target: Agent,
1516
- parts: list[TextPart | ImagePart],
1517
- message_id: str,
1518
- ) -> None:
1519
- content_preview = content_parts_to_text(parts)
1520
- target._append_history(
1521
- ReceivedMessage(
1522
- from_id=self.uuid,
1523
- parts=parts,
1524
- message_id=message_id,
1525
- )
1526
- )
1527
- target.enqueue_message(
1528
- Message(
1529
- from_id=self.uuid,
1530
- to_id=target.uuid,
1531
- parts=parts,
1532
- message_id=message_id,
1533
- history_recorded=True,
1534
- )
1535
- )
1536
- self._log.debug(
1537
- "Message sent: {} -> {} ({} chars)",
1538
- self.uuid[:8],
1539
- target.uuid[:8],
1540
- len(content_preview),
1541
- )
1542
- event_bus.emit(
1543
- Event(
1544
- type=EventType.NODE_MESSAGE,
1545
- agent_id=self.uuid,
135
+ content = chunk_delta_content(chunk)
136
+ if content:
137
+ round_content += content
138
+ final_content += content
139
+ logger.log(
140
+ TRACE_LEVEL,
141
+ "Agent stream delta id=%s content=%r",
142
+ assistant_id,
143
+ content,
144
+ )
145
+ yield AgentStreamEvent(event="delta", data={"content": content})
146
+ for delta in chunk_delta_tool_calls(chunk):
147
+ pending.setdefault(delta.index, PendingToolCall()).apply_delta(delta)
148
+
149
+ tool_calls = [pending[index] for index in sorted(pending)]
150
+ logger.log(
151
+ TRACE_LEVEL,
152
+ "Agent round tool calls id=%s tool_calls=%r",
153
+ assistant_id,
154
+ tool_calls,
155
+ )
156
+ if not tool_calls:
157
+ logger.info(
158
+ "Agent response completed id=%s content_length=%s",
159
+ assistant_id,
160
+ len(final_content),
161
+ )
162
+ logger.log(
163
+ TRACE_LEVEL,
164
+ "Agent final content id=%s content=%r",
165
+ assistant_id,
166
+ final_content,
167
+ )
168
+ yield AgentStreamEvent(
169
+ event="done",
1546
170
  data={
1547
- "to_id": target.uuid,
1548
- "content": content_preview,
1549
- "message_id": message_id,
1550
- },
1551
- ),
1552
- )
1553
-
1554
- @staticmethod
1555
- def _parts_value(parts: list[TextPart | ImagePart]) -> list[dict[str, Any]]:
1556
- return [part.serialize() for part in parts]
1557
-
1558
- def _record_text_output(
1559
- self,
1560
- content: str,
1561
- *,
1562
- emitted_human_content: bool,
1563
- ) -> None:
1564
- normalized_content, normalized_thinking = split_thinking_content(content)
1565
-
1566
- if normalized_thinking.strip():
1567
- self._mark_turn_progress()
1568
- self._append_history(AssistantThinking(content=normalized_thinking))
1569
-
1570
- plain_content = normalized_content
1571
- if not plain_content.strip():
1572
- return
1573
-
1574
- self._record_content_parts_output(
1575
- [TextPart(text=plain_content)],
1576
- emitted_human_content=emitted_human_content,
1577
- )
1578
-
1579
- def _record_content_output(
1580
- self,
1581
- content: str,
1582
- *,
1583
- emitted_human_content: bool,
1584
- ) -> None:
1585
- self._record_text_output(
1586
- content,
1587
- emitted_human_content=emitted_human_content,
1588
- )
1589
-
1590
- def _normalize_llm_output_parts(
1591
- self,
1592
- parts: list[LLMOutputTextPart | LLMOutputImagePart],
1593
- ) -> list[TextPart | ImagePart]:
1594
- normalized: list[TextPart | ImagePart] = []
1595
- for part in parts:
1596
- if isinstance(part, LLMOutputTextPart):
1597
- normalized_content, normalized_thinking = split_thinking_content(
1598
- part.text
1599
- )
1600
- if normalized_thinking.strip():
1601
- self._append_history(AssistantThinking(content=normalized_thinking))
1602
- if normalized_content:
1603
- normalized.append(TextPart(text=normalized_content))
1604
- continue
1605
- if not self.supports_output_image():
1606
- raise ContextPreflightError(
1607
- "Current model does not support `output_image`."
1608
- )
1609
- asset = create_image_asset(part.data, mime_type=part.mime_type)
1610
- normalized.append(
1611
- ImagePart(
1612
- asset_id=asset.id,
1613
- mime_type=asset.mime_type,
1614
- width=part.width or asset.width,
1615
- height=part.height or asset.height,
1616
- )
1617
- )
1618
- return normalized
1619
-
1620
- def _record_content_parts_output(
1621
- self,
1622
- parts: list[TextPart | ImagePart],
1623
- *,
1624
- emitted_human_content: bool,
1625
- ) -> None:
1626
- normalized_parts = [
1627
- part for part in parts if not isinstance(part, TextPart) or part.text
1628
- ]
1629
- if not normalized_parts:
1630
- return
1631
-
1632
- visible_text = "".join(
1633
- part.text for part in normalized_parts if isinstance(part, TextPart)
1634
- )
1635
- content_preview = content_parts_to_text(normalized_parts)
1636
- self._mark_turn_progress()
1637
-
1638
- if (
1639
- self.node_type == NodeType.ASSISTANT
1640
- and not emitted_human_content
1641
- and visible_text.strip()
1642
- ):
1643
- event_bus.emit(
1644
- Event(
1645
- type=EventType.ASSISTANT_CONTENT,
1646
- agent_id=self.uuid,
1647
- data={"content": visible_text},
1648
- ),
1649
- )
1650
-
1651
- self._append_history(
1652
- AssistantText(
1653
- parts=normalized_parts,
1654
- content=content_preview,
1655
- )
1656
- )
1657
-
1658
- def _resolve_contact_target(self, target_ref: str) -> Agent:
1659
- from flowent.graph_runtime import resolve_node_ref
1660
-
1661
- target = resolve_node_ref(target_ref)
1662
- if target is None:
1663
- raise ValueError(f"Send failed: target `{target_ref}` was not found.")
1664
- if not self.can_contact(target.uuid):
1665
- raise ValueError(f"Send failed: target `{target_ref}` is not in contacts.")
1666
- return target
1667
-
1668
- def _is_entry_level_sender(self) -> bool:
1669
- if self.node_type == NodeType.ASSISTANT:
1670
- return True
1671
- if self.config.tab_id is None:
1672
- return False
1673
- from flowent.graph_service import is_tab_leader
1674
-
1675
- return is_tab_leader(node_id=self.uuid, tab_id=self.config.tab_id)
1676
-
1677
- def _resolve_port_send_path(
1678
- self,
1679
- *,
1680
- target_ref: str,
1681
- from_output_port_key: str,
1682
- to_input_port_key: str,
1683
- ):
1684
- if self.config.tab_id is None:
1685
- raise ValueError("Send failed: node is not part of a workflow.")
1686
- from flowent.graph_service import (
1687
- resolve_agent_contact_path,
1688
- resolve_workflow_node_ref,
1689
- )
1690
-
1691
- target_id = resolve_workflow_node_ref(
1692
- tab_id=self.config.tab_id,
1693
- node_ref=target_ref,
1694
- )
1695
- if target_id is None:
1696
- raise ValueError(f"Send failed: target `{target_ref}` was not found.")
1697
- path = resolve_agent_contact_path(
1698
- tab_id=self.config.tab_id,
1699
- source_node_id=self.uuid,
1700
- target_node_id=target_id,
1701
- from_output_port_key=from_output_port_key,
1702
- to_input_port_key=to_input_port_key,
1703
- )
1704
- if path is None:
1705
- raise ValueError(
1706
- f"Send failed: target `{target_ref}` is not connected from `{from_output_port_key}` to `{to_input_port_key}`."
1707
- )
1708
- return path
1709
-
1710
- def _ensure_can_dispatch_to_contact(self, target: Agent) -> None:
1711
- if self.config.tab_id is None:
1712
- return
1713
-
1714
- from flowent.graph_service import is_tab_leader
1715
- from flowent.workspace_store import workspace_store
1716
-
1717
- if not is_tab_leader(node_id=self.uuid, tab_id=self.config.tab_id):
1718
- return
1719
- if target.node_type != NodeType.AGENT:
1720
- return
1721
- if is_tab_leader(node_id=target.uuid, tab_id=target.config.tab_id):
1722
- return
1723
-
1724
- tab = workspace_store.get_tab(self.config.tab_id)
1725
- if tab is not None and tab.activation_state != WorkflowActivationState.ACTIVE:
1726
- raise ValueError(
1727
- "Activate this workflow before sending work to agent nodes."
1728
- )
1729
-
1730
- def supports_input_image(self) -> bool:
1731
- _, model_info = self._get_effective_model_info()
1732
- if model_info is None:
1733
- return False
1734
- return model_info.capabilities.input_image
1735
-
1736
- def supports_output_image(self) -> bool:
1737
- _, model_info = self._get_effective_model_info()
1738
- if model_info is None:
1739
- return False
1740
- return model_info.capabilities.output_image
1741
-
1742
- def send_message(
1743
- self,
1744
- *,
1745
- target_ref: str,
1746
- raw_parts: Any,
1747
- from_output_port_key: str | None = None,
1748
- to_input_port_key: str | None = None,
1749
- raw_value: Any | None = None,
1750
- ) -> str:
1751
- if not self._is_entry_level_sender():
1752
- return self.send_port_value(
1753
- target_ref=target_ref,
1754
- from_output_port_key=from_output_port_key,
1755
- to_input_port_key=to_input_port_key,
1756
- raw_value=raw_value if raw_value is not None else raw_parts,
1757
- )
1758
- parts = parse_content_parts_payload(raw_parts)
1759
- target = self._resolve_contact_target(target_ref)
1760
- self._ensure_can_dispatch_to_contact(target)
1761
- if has_image_parts(parts) and not target.supports_input_image():
1762
- raise ValueError(
1763
- f"Send failed: target `{target_ref}` does not support `input_image`."
1764
- )
1765
- for part in parts:
1766
- asset_id = getattr(part, "asset_id", None)
1767
- if isinstance(asset_id, str):
1768
- require_image_asset(asset_id)
1769
- message_id = str(_uuid.uuid4())
1770
- self._deliver_message(target, parts, message_id)
1771
- self._mark_turn_progress()
1772
- self._append_history(
1773
- SentMessage(
1774
- to_id=target.uuid,
1775
- parts=parts,
1776
- message_id=message_id,
1777
- )
1778
- )
1779
- return json.dumps({"status": "sent", "target_id": target.uuid})
1780
-
1781
- def send_port_value(
1782
- self,
1783
- *,
1784
- target_ref: str,
1785
- from_output_port_key: str | None,
1786
- to_input_port_key: str | None,
1787
- raw_value: Any,
1788
- ) -> str:
1789
- if self._is_entry_level_sender():
1790
- raise ValueError("Send failed: entry contacts use target and parts.")
1791
- if (
1792
- not isinstance(from_output_port_key, str)
1793
- or not from_output_port_key.strip()
1794
- ):
1795
- raise ValueError("send.from_output_port_key must be a non-empty string")
1796
- if not isinstance(to_input_port_key, str) or not to_input_port_key.strip():
1797
- raise ValueError("send.to_input_port_key must be a non-empty string")
1798
-
1799
- path = self._resolve_port_send_path(
1800
- target_ref=target_ref,
1801
- from_output_port_key=from_output_port_key.strip(),
1802
- to_input_port_key=to_input_port_key.strip(),
1803
- )
1804
- value = raw_value
1805
- if path.port_type == "parts":
1806
- parts = parse_content_parts_payload(raw_value)
1807
- value = self._parts_value(parts)
1808
- if path.target_node_type == WorkflowNodeKind.AGENT.value:
1809
- target = self._resolve_contact_target(path.target_id)
1810
- if has_image_parts(parts) and not target.supports_input_image():
1811
- raise ValueError(
1812
- f"Send failed: target `{target_ref}` does not support `input_image`."
1813
- )
1814
- for part in parts:
1815
- asset_id = getattr(part, "asset_id", None)
1816
- if isinstance(asset_id, str):
1817
- require_image_asset(asset_id)
1818
-
1819
- message_id = str(_uuid.uuid4())
1820
- from flowent.graph_service import dispatch_port_value
1821
-
1822
- payload, error = dispatch_port_value(
1823
- tab_id=self.config.tab_id or "",
1824
- source_node_id=self.uuid,
1825
- source_output_port_key=path.from_output_port_key,
1826
- target_node_id=path.target_id,
1827
- target_input_port_key=path.to_input_port_key,
1828
- value=value,
1829
- source_is_agent_send=True,
1830
- message_id=message_id,
1831
- )
1832
- if error is not None or payload is None:
1833
- raise ValueError(f"Send failed: {error or 'path unavailable'}.")
1834
-
1835
- self._mark_turn_progress()
1836
- sent_parts = (
1837
- parse_content_parts_payload(value) if path.port_type == "parts" else []
1838
- )
1839
- value_summary = str(payload.get("value_summary", ""))
1840
- self._append_history(
1841
- SentMessage(
1842
- to_id=path.target_id,
1843
- parts=sent_parts,
1844
- content=value_summary if path.port_type != "parts" else "",
1845
- message_id=message_id,
1846
- from_output_port_key=path.from_output_port_key,
1847
- to_input_port_key=path.to_input_port_key,
1848
- value_summary=value_summary,
1849
- )
1850
- )
1851
- return json.dumps(payload)
1852
-
1853
- def _mark_turn_progress(self) -> None:
1854
- self._turn_made_progress = True
1855
- self._pending_input_turn = False
1856
-
1857
- def _get_first_actionable_todo(self) -> str | None:
1858
- with self._todos_lock:
1859
- if not self.todos:
1860
- return None
1861
- first_todo = self.todos[0].text.strip()
1862
- if not first_todo:
1863
- return None
1864
- normalized = first_todo.lower()
1865
- waiting_prefixes = (
1866
- "wait",
1867
- "await",
1868
- "waiting",
1869
- "awaiting",
1870
- "monitor",
1871
- "listen",
1872
- "idle",
1873
- "sleep",
1874
- )
1875
- if normalized.startswith(waiting_prefixes) or first_todo.startswith(
1876
- ("等待", "等候", "监听", "休眠", "空闲")
1877
- ):
1878
- return None
1879
- return first_todo
1880
-
1881
- def _raise_if_interrupt_requested(self) -> None:
1882
- if self._interrupt_requested.is_set():
1883
- raise InterruptRequestedError()
1884
-
1885
- def _get_llm_retry_policy(self) -> str:
1886
- return get_settings().model.retry_policy
1887
-
1888
- def _get_llm_max_retries(self) -> int:
1889
- settings = get_settings()
1890
- if settings.model.retry_policy != "limited":
1891
- return 0
1892
- return settings.model.max_retries
1893
-
1894
- def _get_llm_retry_delay(self, retry_number: int) -> float:
1895
- settings = get_settings()
1896
- capped_retry_number = min(
1897
- max(retry_number - 1, 0),
1898
- settings.model.retry_backoff_cap_retries - 1,
1899
- )
1900
- return min(
1901
- settings.model.retry_max_delay_seconds,
1902
- settings.model.retry_initial_delay_seconds * (2**capped_retry_number),
1903
- )
1904
-
1905
- def _get_llm_retry_429_delay(self, status_code: int | None) -> float:
1906
- if status_code != 429:
1907
- return 0.0
1908
- from flowent.settings import find_provider, find_role
1909
-
1910
- settings = get_settings()
1911
- provider_id = settings.model.active_provider_id
1912
- role_cfg = (
1913
- find_role(settings, self.config.role_name)
1914
- if self.config.role_name
1915
- else None
1916
- )
1917
- if (
1918
- role_cfg is not None
1919
- and role_cfg.model is not None
1920
- and role_cfg.model.provider_id
1921
- and role_cfg.model.model
1922
- ):
1923
- provider_id = role_cfg.model.provider_id
1924
- if not provider_id:
1925
- return 0.0
1926
- provider = find_provider(settings, provider_id)
1927
- if provider is None:
1928
- return 0.0
1929
- return float(provider.retry_429_delay_seconds)
1930
-
1931
- def _wait_for_llm_retry_delay(self, delay_seconds: float) -> None:
1932
- self.set_interrupt_callback(self._interrupt_requested.set)
1933
- try:
1934
- if self._interrupt_requested.wait(max(delay_seconds, 0.0)):
1935
- raise InterruptRequestedError()
1936
- finally:
1937
- self.set_interrupt_callback(None)
1938
-
1939
- def _chat_with_retries(
1940
- self,
1941
- *,
1942
- prepared_context: PreparedLLMContext,
1943
- tools_schema: list[dict[str, Any]] | None,
1944
- ) -> tuple[LLMResponse, StreamingContentState]:
1945
- retry_policy = self._get_llm_retry_policy()
1946
- retry_limit = self._get_llm_max_retries()
1947
- retry_count = 0
1948
- started_at = _time.time()
1949
- self._raise_if_llm_network_disallowed()
1950
-
1951
- while True:
1952
- stream_state = StreamingContentState()
1953
- try:
1954
- try:
1955
- messages = prepared_context.messages
1956
- response = gateway.chat(
1957
- messages=messages,
1958
- tools=tools_schema or None,
1959
- on_chunk=partial(self._handle_llm_chunk, stream_state),
1960
- register_interrupt=self.set_interrupt_callback,
1961
- role_name=self.config.role_name,
1962
- )
1963
- except Exception:
1964
- if self._interrupt_requested.is_set():
1965
- raise InterruptRequestedError(stream_state) from None
1966
- raise
1967
- finally:
1968
- self.set_interrupt_callback(None)
1969
- self._raise_if_interrupt_requested()
1970
- self._record_request_observability(
1971
- started_at=started_at,
1972
- ended_at=_time.time(),
1973
- retry_count=retry_count,
1974
- result="success",
1975
- usage=response.usage,
1976
- raw_usage=response.raw_usage,
1977
- )
1978
- self._record_context_token_usage_baseline(
1979
- prepared_context=prepared_context,
1980
- usage=response.usage,
1981
- )
1982
- return response, stream_state
1983
- except InterruptRequestedError:
1984
- raise
1985
- except LLMProviderError as exc:
1986
- should_retry = False
1987
- if exc.transient:
1988
- if retry_policy == "limited":
1989
- should_retry = retry_count < retry_limit
1990
- elif retry_policy == "unlimited":
1991
- should_retry = True
1992
- if not should_retry:
1993
- self._record_request_observability(
1994
- started_at=started_at,
1995
- ended_at=_time.time(),
1996
- retry_count=retry_count,
1997
- result="error",
1998
- error_summary=str(exc),
1999
- )
2000
- raise
2001
- retry_count += 1
2002
- delay_seconds = self._get_llm_retry_delay(
2003
- retry_count
2004
- ) + self._get_llm_retry_429_delay(exc.status_code)
2005
- self._log.warning(
2006
- "Transient LLM error, retry {} ({}) in {:.2f}s: {}",
2007
- retry_count,
2008
- retry_policy,
2009
- delay_seconds,
2010
- exc,
2011
- )
2012
- self._wait_for_llm_retry_delay(delay_seconds)
2013
- except Exception as exc:
2014
- self._record_request_observability(
2015
- started_at=started_at,
2016
- ended_at=_time.time(),
2017
- retry_count=retry_count,
2018
- result="error",
2019
- error_summary=str(exc),
2020
- )
2021
- raise
2022
-
2023
- def _handle_interrupt(self, stream_state: StreamingContentState | None) -> None:
2024
- if stream_state is not None:
2025
- self._flush_streaming_think_parser(stream_state)
2026
- if stream_state.thinking_buffer:
2027
- self._append_history(
2028
- AssistantThinking(content=stream_state.thinking_buffer),
2029
- )
2030
- if stream_state.content_buffer:
2031
- self._record_text_output(
2032
- stream_state.content_buffer,
2033
- emitted_human_content=stream_state.emitted_human_content,
2034
- )
2035
- self._interrupt_requested.clear()
2036
- self.set_interrupt_callback(None)
2037
- self._pending_input_turn = False
2038
- self.set_state(AgentState.IDLE, "interrupted by human")
2039
- if self._pause_after_interrupt_requested.is_set():
2040
- self._paused_for_command.set()
2041
- self._resume_after_command.wait()
2042
- self._paused_for_command.clear()
2043
- self._resume_after_command.clear()
2044
- self._wait_for_input()
2045
-
2046
- def _generate_compacted_history_summary(
2047
- self,
2048
- *,
2049
- focus: str | None = None,
2050
- context_messages: list[dict[str, Any]],
2051
- ) -> str:
2052
- if not context_messages:
2053
- return "- No prior execution context was available to compact."
2054
-
2055
- self._raise_if_llm_network_disallowed()
2056
-
2057
- focus_text = focus.strip() if focus else ""
2058
- request_lines = [
2059
- "Compact this agent execution context into a durable markdown summary.",
2060
- "Preserve only confirmed information.",
2061
- "Keep the summary concise and directly reusable as future context.",
2062
- "Use these sections in order:",
2063
- "## Current Goal",
2064
- "## Active Task Boundary",
2065
- "## Key Constraints",
2066
- "## Confirmed Decisions",
2067
- "## Open Questions",
2068
- "## Next Actions",
2069
- ]
2070
- if focus_text:
2071
- request_lines.append(f"Prioritize this focus: {focus_text}")
2072
- request_lines.append("Return only the markdown summary.")
2073
-
2074
- response = gateway.chat(
2075
- messages=[
2076
- {
2077
- "role": "system",
2078
- "content": (
2079
- "You compress Assistant conversations into durable "
2080
- "task summaries. Do not address the human. Do not "
2081
- "invent facts. Keep the result tightly scoped to what "
2082
- "future turns need."
2083
- ),
2084
- },
2085
- *context_messages,
2086
- {"role": "user", "content": "\n".join(request_lines)},
2087
- ],
2088
- tools=None,
2089
- role_name=self.config.role_name,
2090
- )
2091
- summary = (response.content or response.thinking or "").strip()
2092
- if not summary:
2093
- raise RuntimeError("Assistant compact did not produce a summary")
2094
- return summary
2095
-
2096
- def _build_prepared_llm_context(self) -> PreparedLLMContext:
2097
- system_messages = [
2098
- {"role": "system", "content": get_system_prompt(self.config)}
2099
- ]
2100
- with self._history_lock:
2101
- history_snapshot = list(self.history)
2102
- execution_context_messages = self._build_execution_context_messages(
2103
- history_snapshot
2104
- )
2105
- runtime_tail_messages = self._build_runtime_tail_messages()
2106
- return PreparedLLMContext(
2107
- messages=[
2108
- *system_messages,
2109
- *execution_context_messages,
2110
- *runtime_tail_messages,
2111
- ],
2112
- system_messages=system_messages,
2113
- execution_context_messages=execution_context_messages,
2114
- runtime_tail_messages=runtime_tail_messages,
2115
- )
2116
-
2117
- def _build_messages(self) -> list[dict[str, Any]]:
2118
- return self._build_prepared_llm_context().messages
2119
-
2120
- def _build_execution_context_messages(
2121
- self,
2122
- history_snapshot: list[HistoryEntry],
2123
- ) -> list[dict[str, Any]]:
2124
- with self._execution_context_lock:
2125
- summary = self._execution_context_summary.strip()
2126
- history_cutoff = min(
2127
- self._execution_context_history_cutoff,
2128
- len(history_snapshot),
2129
- )
2130
-
2131
- messages: list[dict[str, Any]] = []
2132
- if summary:
2133
- messages.append(
2134
- self._build_runtime_system_message(
2135
- "Compacted execution context:\n"
2136
- f"{normalize_compacted_execution_context(summary)}"
2137
- )
2138
- )
2139
- messages.extend(self._build_history_messages(history_snapshot[history_cutoff:]))
2140
- return messages
2141
-
2142
- @staticmethod
2143
- def _serialize_context_parts(
2144
- parts: list[TextPart | ImagePart],
2145
- ) -> str | list[dict[str, Any]]:
2146
- if all(isinstance(part, TextPart) for part in parts):
2147
- text_parts = [part for part in parts if isinstance(part, TextPart)]
2148
- return "".join(part.text for part in text_parts)
2149
- return [part.serialize() for part in parts]
2150
-
2151
- @classmethod
2152
- def _wrap_context_parts(
2153
- cls,
2154
- parts: list[TextPart | ImagePart],
2155
- *,
2156
- prefix: str,
2157
- suffix: str = "",
2158
- ) -> str | list[dict[str, Any]]:
2159
- if all(isinstance(part, TextPart) for part in parts):
2160
- text_parts = [part for part in parts if isinstance(part, TextPart)]
2161
- return prefix + "".join(part.text for part in text_parts) + suffix
2162
- wrapped: list[TextPart | ImagePart] = [TextPart(text=prefix), *parts]
2163
- if suffix:
2164
- wrapped.append(TextPart(text=suffix))
2165
- return cls._serialize_context_parts(wrapped)
2166
-
2167
- def _build_history_messages(
2168
- self,
2169
- history_snapshot: list[HistoryEntry],
2170
- ) -> list[dict[str, Any]]:
2171
- messages: list[dict[str, Any]] = []
2172
- pending_tool_calls: list[dict[str, Any]] = []
2173
-
2174
- for entry in history_snapshot:
2175
- if isinstance(entry, SystemEntry):
2176
- continue
2177
-
2178
- elif isinstance(entry, ReceivedMessage):
2179
- self._flush_tool_calls(messages, pending_tool_calls)
2180
- if entry.from_output_port_key and entry.to_input_port_key:
2181
- prefix = (
2182
- f'<message from="{entry.from_id}" '
2183
- f'from_output="{entry.from_output_port_key}" '
2184
- f'to_input="{entry.to_input_port_key}">'
2185
- )
2186
- else:
2187
- prefix = f'<message from="{entry.from_id}">'
2188
- messages.append(
2189
- {
2190
- "role": "user",
2191
- "content": self._wrap_context_parts(
2192
- entry.parts,
2193
- prefix=prefix,
2194
- suffix="</message>",
2195
- ),
2196
- }
2197
- )
2198
-
2199
- elif isinstance(entry, AssistantText):
2200
- self._flush_tool_calls(messages, pending_tool_calls)
2201
- messages.append(
2202
- {
2203
- "role": "assistant",
2204
- "content": self._serialize_context_parts(entry.parts),
171
+ "message": {
172
+ "author": "assistant",
173
+ "content": final_content,
174
+ "id": assistant_id,
2205
175
  }
2206
- )
2207
-
2208
- elif isinstance(entry, SentMessage):
2209
- self._flush_tool_calls(messages, pending_tool_calls)
2210
- if entry.from_output_port_key and entry.to_input_port_key:
2211
- prefix = (
2212
- f'<message to="{entry.to_id}" '
2213
- f'from_output="{entry.from_output_port_key}" '
2214
- f'to_input="{entry.to_input_port_key}">'
2215
- )
2216
- else:
2217
- prefix = f'<message to="{entry.to_id}">'
2218
- messages.append(
2219
- {
2220
- "role": "assistant",
2221
- "content": self._wrap_context_parts(
2222
- entry.parts,
2223
- prefix=prefix,
2224
- suffix="</message>",
2225
- ),
2226
- }
2227
- )
2228
-
2229
- elif isinstance(entry, AssistantThinking):
2230
- pass
2231
-
2232
- elif isinstance(entry, ToolCall):
2233
- if entry.streaming:
2234
- continue
2235
-
2236
- pending_tool_calls.append(
2237
- {
2238
- "id": entry.tool_call_id,
2239
- "type": "function",
2240
- "function": {
2241
- "name": entry.tool_name,
2242
- "arguments": json.dumps(entry.arguments)
2243
- if entry.arguments
2244
- else "{}",
2245
- },
2246
- }
2247
- )
2248
-
2249
- if entry.result is not None:
2250
- self._flush_tool_calls(messages, pending_tool_calls)
2251
- messages.append(
2252
- {
2253
- "role": "tool",
2254
- "tool_call_id": entry.tool_call_id,
2255
- "content": entry.result,
2256
- }
2257
- )
2258
-
2259
- elif isinstance(entry, ErrorEntry):
2260
- self._flush_tool_calls(messages, pending_tool_calls)
2261
- messages.append(
2262
- {
2263
- "role": "user",
2264
- "content": build_error_context(entry.content),
2265
- }
2266
- )
2267
-
2268
- elif isinstance(entry, CommandResultEntry):
2269
- if not entry.include_in_context or entry.command_name == "/compact":
2270
- continue
2271
- self._flush_tool_calls(messages, pending_tool_calls)
2272
- messages.append(self._build_runtime_system_message(entry.content))
2273
-
2274
- elif isinstance(entry, PortInboundEntry):
2275
- self._flush_tool_calls(messages, pending_tool_calls)
2276
- messages.append(
2277
- self._build_runtime_system_message(
2278
- "Port input received: "
2279
- f"from {entry.from_id}.{entry.from_output_port_key} "
2280
- f"to {entry.to_input_port_key} "
2281
- f"({entry.port_type})\n"
2282
- f"{json.dumps(entry.value, ensure_ascii=False, sort_keys=True)}"
2283
- )
2284
- )
2285
-
2286
- self._flush_tool_calls(messages, pending_tool_calls)
2287
- return messages
2288
-
2289
- @staticmethod
2290
- def _estimate_text_tokens(text: str) -> int:
2291
- stripped = text.strip()
2292
- if not stripped:
2293
- return 0
2294
- return max(1, (len(stripped) + 3) // 4)
2295
-
2296
- @classmethod
2297
- def _estimate_message_tokens(cls, message: dict[str, Any]) -> int:
2298
- total = cls._estimate_text_tokens(str(message.get("role", "")))
2299
- content = message.get("content")
2300
- if isinstance(content, str):
2301
- total += cls._estimate_text_tokens(content)
2302
- elif content is not None:
2303
- total += cls._estimate_text_tokens(json.dumps(content, ensure_ascii=False))
2304
- tool_calls = message.get("tool_calls")
2305
- if isinstance(tool_calls, list):
2306
- for tool_call in tool_calls:
2307
- if not isinstance(tool_call, dict):
2308
- continue
2309
- function = tool_call.get("function")
2310
- if not isinstance(function, dict):
2311
- continue
2312
- total += cls._estimate_text_tokens(str(function.get("name", "")))
2313
- total += cls._estimate_text_tokens(str(function.get("arguments", "")))
2314
- return total
2315
-
2316
- @classmethod
2317
- def _estimate_input_tokens(cls, messages: list[dict[str, Any]]) -> int:
2318
- return sum(cls._estimate_message_tokens(message) for message in messages)
2319
-
2320
- def _estimate_tokens_from_usage_baseline(
2321
- self,
2322
- prepared_context: PreparedLLMContext,
2323
- ) -> int | None:
2324
- with self._execution_context_lock:
2325
- baseline = self._context_token_usage_baseline
2326
-
2327
- if baseline is None:
2328
- return None
2329
- if baseline.system_messages != prepared_context.system_messages:
2330
- return None
2331
- if baseline.runtime_tail_messages != prepared_context.runtime_tail_messages:
2332
- return None
2333
-
2334
- baseline_execution_messages = baseline.execution_context_messages
2335
- current_execution_messages = prepared_context.execution_context_messages
2336
- if len(current_execution_messages) < len(baseline_execution_messages):
2337
- return None
2338
- if (
2339
- current_execution_messages[: len(baseline_execution_messages)]
2340
- != baseline_execution_messages
2341
- ):
2342
- return None
2343
-
2344
- tail_messages = current_execution_messages[len(baseline_execution_messages) :]
2345
- return baseline.usage.total_tokens + self._estimate_input_tokens(tail_messages)
2346
-
2347
- def _record_context_token_usage_baseline(
2348
- self,
2349
- *,
2350
- prepared_context: PreparedLLMContext,
2351
- usage: LLMUsage | None,
2352
- ) -> None:
2353
- if usage is None:
2354
- return
2355
-
2356
- with self._execution_context_lock:
2357
- self._context_token_usage_baseline = ContextTokenUsageBaseline(
2358
- usage=usage,
2359
- system_messages=list(prepared_context.system_messages),
2360
- execution_context_messages=list(
2361
- prepared_context.execution_context_messages
2362
- ),
2363
- runtime_tail_messages=list(prepared_context.runtime_tail_messages),
2364
- )
2365
-
2366
- def _get_observability_node_label(self) -> str:
2367
- if self.config.name:
2368
- return self.config.name
2369
- if self.config.role_name:
2370
- return self.config.role_name
2371
- if self.node_type == NodeType.ASSISTANT:
2372
- return "Assistant"
2373
- from flowent.graph_service import is_tab_leader
2374
-
2375
- if is_tab_leader(node_id=self.uuid, tab_id=self.config.tab_id):
2376
- return "Leader"
2377
- return "Agent"
2378
-
2379
- def _get_observability_tab_title(self) -> str | None:
2380
- if not self.config.tab_id:
2381
- return None
2382
- from flowent.workspace_store import workspace_store
2383
-
2384
- tab = workspace_store.get_tab(self.config.tab_id)
2385
- if tab is None:
2386
- return None
2387
- return tab.title
2388
-
2389
- def _get_effective_model_source(self) -> ResolvedModelSource:
2390
- from flowent.settings import find_provider, find_role, resolve_model_info
2391
-
2392
- settings = get_settings()
2393
- provider_id = settings.model.active_provider_id
2394
- model_id = settings.model.active_model
2395
- use_system_model_overrides = True
2396
- role_cfg = (
2397
- find_role(settings, self.config.role_name)
2398
- if self.config.role_name
2399
- else None
2400
- )
2401
- if (
2402
- role_cfg is not None
2403
- and role_cfg.model is not None
2404
- and role_cfg.model.provider_id
2405
- and role_cfg.model.model
2406
- ):
2407
- provider_id = role_cfg.model.provider_id
2408
- model_id = role_cfg.model.model
2409
- use_system_model_overrides = False
2410
- if not provider_id or not model_id:
2411
- return ResolvedModelSource(
2412
- provider_id=None,
2413
- provider_name=None,
2414
- provider_type=None,
2415
- provider_base_url=None,
2416
- model=None,
2417
- model_info=None,
2418
- )
2419
- provider = find_provider(settings, provider_id)
2420
- if provider is None:
2421
- return ResolvedModelSource(
2422
- provider_id=None,
2423
- provider_name=None,
2424
- provider_type=None,
2425
- provider_base_url=None,
2426
- model=None,
2427
- model_info=None,
2428
- )
2429
- return ResolvedModelSource(
2430
- provider_id=provider.id,
2431
- provider_name=provider.name,
2432
- provider_type=provider.type,
2433
- provider_base_url=provider.base_url,
2434
- model=model_id,
2435
- model_info=resolve_model_info(
2436
- provider=provider,
2437
- model_id=model_id,
2438
- input_image=(
2439
- settings.model.input_image if use_system_model_overrides else None
2440
- ),
2441
- output_image=(
2442
- settings.model.output_image if use_system_model_overrides else None
2443
- ),
2444
- context_window_tokens=(
2445
- settings.model.context_window_tokens
2446
- if use_system_model_overrides
2447
- else None
2448
- ),
2449
- ),
2450
- )
2451
-
2452
- def _raise_if_llm_network_disallowed(self) -> None:
2453
- from flowent.graph_service import resolve_effective_permissions_for_agent
2454
-
2455
- allow_network, _ = resolve_effective_permissions_for_agent(self)
2456
- if allow_network:
2457
- return
2458
- source = self._get_effective_model_source()
2459
- if not self._model_source_requires_network(source):
2460
- return
2461
- raise LLMProviderError(
2462
- "Network access is disabled for this workflow",
2463
- transient=False,
2464
- )
2465
-
2466
- @staticmethod
2467
- def _model_source_requires_network(source: ResolvedModelSource) -> bool:
2468
- base_url = source.provider_base_url
2469
- if not base_url:
2470
- return False
2471
- parsed = urlparse(base_url)
2472
- host = (parsed.hostname or "").lower()
2473
- return host not in {"localhost", "127.0.0.1", "::1"}
2474
-
2475
- def _get_effective_model_info(self) -> tuple[str | None, ModelInfo | None]:
2476
- resolved_source = self._get_effective_model_source()
2477
- return resolved_source.provider_type, resolved_source.model_info
2478
-
2479
- def _record_request_observability(
2480
- self,
2481
- *,
2482
- started_at: float,
2483
- ended_at: float,
2484
- retry_count: int,
2485
- result: str,
2486
- usage: LLMUsage | None = None,
2487
- raw_usage: dict[str, Any] | None = None,
2488
- error_summary: str | None = None,
2489
- ) -> None:
2490
- from flowent.observability_service import (
2491
- RequestRecordInput,
2492
- observability_store,
2493
- )
2494
-
2495
- resolved_source = self._get_effective_model_source()
2496
- observability_store.record_request(
2497
- RequestRecordInput(
2498
- node_id=self.uuid,
2499
- node_label=self._get_observability_node_label(),
2500
- role_name=self.config.role_name,
2501
- tab_id=self.config.tab_id,
2502
- tab_title=self._get_observability_tab_title(),
2503
- provider_id=resolved_source.provider_id,
2504
- provider_name=resolved_source.provider_name,
2505
- provider_type=resolved_source.provider_type,
2506
- model=resolved_source.model,
2507
- started_at=started_at,
2508
- ended_at=ended_at,
2509
- retry_count=retry_count,
2510
- result="success" if result == "success" else "error",
2511
- normalized_usage=usage,
2512
- raw_usage=raw_usage,
2513
- error_summary=error_summary,
2514
- )
2515
- )
2516
-
2517
- def _run_compact_with_observability(
2518
- self,
2519
- *,
2520
- trigger_type: str,
2521
- focus: str | None = None,
2522
- ) -> str:
2523
- from flowent.observability_service import (
2524
- CompactRecordInput,
2525
- observability_store,
2526
- )
2527
-
2528
- started_at = _time.time()
2529
- resolved_source = self._get_effective_model_source()
2530
- try:
2531
- result = self._compact_execution_context(focus=focus)
2532
- except Exception as exc:
2533
- observability_store.record_compact(
2534
- CompactRecordInput(
2535
- node_id=self.uuid,
2536
- node_label=self._get_observability_node_label(),
2537
- role_name=self.config.role_name,
2538
- tab_id=self.config.tab_id,
2539
- tab_title=self._get_observability_tab_title(),
2540
- provider_id=resolved_source.provider_id,
2541
- provider_name=resolved_source.provider_name,
2542
- provider_type=resolved_source.provider_type,
2543
- model=resolved_source.model,
2544
- trigger_type="manual" if trigger_type == "manual" else "auto",
2545
- started_at=started_at,
2546
- ended_at=_time.time(),
2547
- result="error",
2548
- error_summary=str(exc),
2549
- )
2550
- )
2551
- raise
2552
- observability_store.record_compact(
2553
- CompactRecordInput(
2554
- node_id=self.uuid,
2555
- node_label=self._get_observability_node_label(),
2556
- role_name=self.config.role_name,
2557
- tab_id=self.config.tab_id,
2558
- tab_title=self._get_observability_tab_title(),
2559
- provider_id=resolved_source.provider_id,
2560
- provider_name=resolved_source.provider_name,
2561
- provider_type=resolved_source.provider_type,
2562
- model=resolved_source.model,
2563
- trigger_type="manual" if trigger_type == "manual" else "auto",
2564
- started_at=started_at,
2565
- ended_at=_time.time(),
2566
- result="success",
2567
- )
2568
- )
2569
- return result
2570
-
2571
- def _get_effective_output_budget_tokens(self) -> int:
2572
- from flowent.settings import find_role, merge_model_params
2573
-
2574
- settings = get_settings()
2575
- role_cfg = (
2576
- find_role(settings, self.config.role_name)
2577
- if self.config.role_name
2578
- else None
2579
- )
2580
- model_params = merge_model_params(
2581
- settings.model.params,
2582
- role_cfg.model_params if role_cfg is not None else None,
2583
- )
2584
- if model_params is not None and model_params.max_output_tokens is not None:
2585
- return max(1, model_params.max_output_tokens)
2586
- return DEFAULT_CONTEXT_OUTPUT_BUDGET_TOKENS
2587
-
2588
- def _compute_context_preflight(
2589
- self,
2590
- prepared_context: PreparedLLMContext,
2591
- ) -> ContextPreflight:
2592
- settings = get_settings()
2593
- _, model_info = self._get_effective_model_info()
2594
- estimated_total_tokens = self._estimate_tokens_from_usage_baseline(
2595
- prepared_context
2596
- )
2597
- if estimated_total_tokens is None:
2598
- estimated_total_tokens = self._estimate_input_tokens(
2599
- prepared_context.messages
2600
- )
2601
-
2602
- auto_compact_token_limit = settings.model.auto_compact_token_limit
2603
- if model_info is None or model_info.context_window_tokens is None:
2604
- return ContextPreflight(
2605
- estimated_total_tokens=estimated_total_tokens,
2606
- auto_compact_token_limit=auto_compact_token_limit,
2607
- )
2608
-
2609
- output_budget_tokens = self._get_effective_output_budget_tokens()
2610
- safe_input_tokens = max(
2611
- 1,
2612
- model_info.context_window_tokens
2613
- - output_budget_tokens
2614
- - DEFAULT_CONTEXT_PROVIDER_HEADROOM_TOKENS,
2615
- )
2616
- return ContextPreflight(
2617
- estimated_total_tokens=estimated_total_tokens,
2618
- context_window_tokens=model_info.context_window_tokens,
2619
- auto_compact_token_limit=auto_compact_token_limit,
2620
- safe_input_tokens=safe_input_tokens,
2621
- )
2622
-
2623
- def _compact_execution_context(self, *, focus: str | None = None) -> str:
2624
- with self._history_lock:
2625
- history_snapshot = list(self.history)
2626
- context_messages = self._build_execution_context_messages(history_snapshot)
2627
- if not context_messages:
2628
- self._set_execution_context(
2629
- summary="",
2630
- history_cutoff=len(history_snapshot),
2631
- )
2632
- self._persist_workspace_node()
2633
- return ""
2634
- summary = self._generate_compacted_history_summary(
2635
- focus=focus,
2636
- context_messages=context_messages,
2637
- )
2638
- self._set_execution_context(
2639
- summary=summary,
2640
- history_cutoff=len(history_snapshot),
2641
- )
2642
- self._persist_workspace_node()
2643
- return summary
2644
-
2645
- def _prepare_messages_for_llm(self) -> PreparedLLMContext:
2646
- prepared_context = self._build_prepared_llm_context()
2647
- preflight = self._compute_context_preflight(prepared_context)
2648
- if (
2649
- preflight.auto_compact_token_limit is None
2650
- or preflight.estimated_total_tokens < preflight.auto_compact_token_limit
2651
- ):
2652
- return prepared_context
2653
-
2654
- self._log.debug(
2655
- "Automatic compact preflight: estimated_total={}, token_limit={}, safe={}, context_window={}",
2656
- preflight.estimated_total_tokens,
2657
- preflight.auto_compact_token_limit,
2658
- preflight.safe_input_tokens,
2659
- preflight.context_window_tokens,
2660
- )
2661
- try:
2662
- self._run_compact_with_observability(trigger_type="auto")
2663
- except Exception as exc:
2664
- if (
2665
- preflight.safe_input_tokens is not None
2666
- and preflight.estimated_total_tokens > preflight.safe_input_tokens
2667
- ):
2668
- raise ContextPreflightError(
2669
- "Automatic compact failed and the current execution context exceeds the safe model window."
2670
- ) from exc
2671
- self._log.warning("Automatic compact failed below safe window: {}", exc)
2672
- return prepared_context
2673
-
2674
- prepared_context = self._build_prepared_llm_context()
2675
- post_compact = self._compute_context_preflight(prepared_context)
2676
- if (
2677
- post_compact.safe_input_tokens is not None
2678
- and post_compact.estimated_total_tokens > post_compact.safe_input_tokens
2679
- ):
2680
- raise ContextPreflightError(
2681
- "Automatic compact completed but the current execution context still exceeds the safe model window."
176
+ },
2682
177
  )
2683
- return prepared_context
2684
-
2685
- @staticmethod
2686
- def _flush_tool_calls(
2687
- messages: list[dict[str, Any]],
2688
- pending: list[dict[str, Any]],
2689
- ) -> None:
2690
- if not pending:
2691
178
  return
2692
- last = messages[-1] if messages else None
2693
- if last and last["role"] == "assistant":
2694
- last.setdefault("tool_calls", []).extend(pending)
2695
- else:
2696
- messages.append({"role": "assistant", "tool_calls": list(pending)})
2697
- pending.clear()
2698
-
2699
- @staticmethod
2700
- def _tool_result_has_error(result: str | None) -> bool:
2701
- if not isinstance(result, str):
2702
- return False
2703
- try:
2704
- payload = json.loads(result)
2705
- except json.JSONDecodeError:
2706
- return False
2707
- return isinstance(payload, dict) and isinstance(payload.get("error"), str)
2708
179
 
2709
- def _drain_messages(self) -> None:
2710
- drained: list[WakeSignal] = []
2711
- while True:
180
+ conversation.append(assistant_tool_call_message(tool_calls, round_content))
181
+ for index, tool_call in enumerate(tool_calls):
182
+ tool_call_id = tool_call.id or f"call_{index}"
2712
183
  try:
2713
- signal = self._wake_queue.get_nowait()
2714
- except Empty:
2715
- break
2716
- if signal.reason == "message":
2717
- drained.append(signal)
2718
- continue
2719
- if signal.reason == "termination":
2720
- self._terminate.set()
2721
- break
2722
-
2723
- if drained:
2724
- self._log.debug("Drained {} message(s) from queue", len(drained))
2725
-
2726
- for signal in drained:
2727
- message = signal.payload.get("message", {})
2728
- content = message.get("content", "")
2729
- parts = deserialize_content_parts(
2730
- message.get("parts"),
2731
- fallback_text=content if isinstance(content, str) else None,
2732
- )
2733
- from_id = message.get("from", "")
2734
- message_id = message.get("message_id")
2735
- history_recorded = bool(message.get("history_recorded", False))
2736
- port_inbound_recorded = bool(message.get("port_inbound_recorded", False))
2737
- if (
2738
- not isinstance(content, str)
2739
- or not isinstance(from_id, str)
2740
- or (message_id is not None and not isinstance(message_id, str))
2741
- ):
2742
- continue
2743
- self._log.debug(
2744
- "Message from {}: {}",
2745
- from_id,
2746
- (content[:100] + "...") if len(content) > 100 else content,
2747
- )
2748
- if not history_recorded:
2749
- self._append_history(
2750
- ReceivedMessage(
2751
- from_id=from_id,
2752
- parts=parts,
2753
- content=content,
2754
- message_id=message_id,
2755
- ),
2756
- )
2757
- elif not port_inbound_recorded and message.get("port_type") in {
2758
- "string",
2759
- "json",
2760
- }:
2761
- value = message.get("value")
2762
- self._append_history(
2763
- PortInboundEntry(
2764
- from_id=from_id,
2765
- from_output_port_key=str(
2766
- message.get("from_output_port_key", "")
2767
- ),
2768
- to_input_port_key=str(message.get("to_input_port_key", "")),
2769
- port_type=str(message.get("port_type", "")),
2770
- value=value,
2771
- value_summary=str(message.get("value_summary", "")),
2772
- )
2773
- )
2774
-
2775
- def _wait_for_input(self) -> None:
2776
- signal = self._wait_for_wakeup()
2777
- self._resume_from_wakeup(signal)
2778
-
2779
- def _resume_from_wakeup(self, signal: WakeSignal) -> None:
2780
- if signal.reason == "message":
2781
- message = signal.payload.get("message")
2782
- if isinstance(message, dict):
2783
- content = message.get("content")
2784
- parts = deserialize_content_parts(
2785
- message.get("parts"),
2786
- fallback_text=content if isinstance(content, str) else None,
2787
- )
2788
- from_id = message.get("from")
2789
- message_id = message.get("message_id")
2790
- history_recorded = bool(message.get("history_recorded", False))
2791
- port_inbound_recorded = bool(
2792
- message.get("port_inbound_recorded", False)
184
+ arguments = parse_tool_arguments(tool_call.arguments)
185
+ except Exception as error:
186
+ arguments = {}
187
+ result_content = str(error)
188
+ tool_item = new_tool_item(tool_call.name, arguments)
189
+ logger.debug("Tool call argument parse failed name=%s", tool_call.name)
190
+ logger.log(TRACE_LEVEL, "Tool start item=%r", tool_item)
191
+ yield AgentStreamEvent(event="tool_start", data={"tool": tool_item})
192
+ logger.log(
193
+ TRACE_LEVEL,
194
+ "Tool error id=%s content=%r",
195
+ tool_item["id"],
196
+ result_content,
197
+ )
198
+ yield AgentStreamEvent(
199
+ event="tool_error",
200
+ data={
201
+ "id": tool_item["id"],
202
+ "content": result_content,
203
+ "data": {},
204
+ "status": "failed",
205
+ "title": tool_call.name or "Tool failed",
206
+ },
2793
207
  )
2794
- if (
2795
- isinstance(content, str)
2796
- and isinstance(from_id, str)
2797
- and (message_id is None or isinstance(message_id, str))
2798
- and not history_recorded
2799
- ):
2800
- self._append_history(
2801
- ReceivedMessage(
2802
- from_id=from_id,
2803
- parts=parts,
2804
- content=content,
2805
- message_id=message_id,
2806
- )
2807
- )
2808
- elif (
2809
- isinstance(from_id, str)
2810
- and history_recorded
2811
- and not port_inbound_recorded
2812
- and message.get("port_type") in {"string", "json"}
2813
- ):
2814
- self._append_history(
2815
- PortInboundEntry(
2816
- from_id=from_id,
2817
- from_output_port_key=str(
2818
- message.get("from_output_port_key", "")
2819
- ),
2820
- to_input_port_key=str(message.get("to_input_port_key", "")),
2821
- port_type=str(message.get("port_type", "")),
2822
- value=message.get("value"),
2823
- value_summary=str(message.get("value_summary", "")),
2824
- )
2825
- )
2826
-
2827
- if signal.reason != "termination":
2828
- self.set_state(
2829
- AgentState.RUNNING,
2830
- signal.resume_reason or f"woke due to {signal.reason}",
2831
- )
2832
-
2833
- def _wait_for_wakeup(self) -> WakeSignal:
2834
- while not self._terminate.is_set():
2835
- try:
2836
- return self._wake_queue.get(timeout=2.0)
2837
- except Empty:
2838
- continue
2839
-
2840
- return WakeSignal(
2841
- reason="termination",
2842
- payload={},
2843
- resume_reason="termination requested",
2844
- )
2845
-
2846
- def _handle_tool_call(
2847
- self,
2848
- name: str,
2849
- arguments: dict[str, Any],
2850
- call_id: str,
2851
- ) -> str | None:
2852
- self._log.debug(
2853
- "Tool call: name={}, call_id={}, args={}",
2854
- name,
2855
- call_id[:8],
2856
- json.dumps(arguments, ensure_ascii=False)[:200],
2857
- )
2858
-
2859
- tool = _get_tool_registry().get(name)
2860
- if tool is None:
2861
- self._log.warning("Unknown tool: {}", name)
2862
- error_msg = json.dumps({"error": f"Unknown tool: {name}"})
2863
- self._append_history(
2864
- ToolCall(
2865
- tool_name=name,
2866
- tool_call_id=call_id,
2867
- arguments=arguments,
2868
- result=error_msg,
2869
- streaming=False,
2870
- ),
2871
- )
2872
- return error_msg
2873
-
2874
- registry = _get_tool_registry()
2875
- if hasattr(registry, "get_tools_for_agent"):
2876
- allowed_tool_names = {
2877
- allowed_tool.name for allowed_tool in registry.get_tools_for_agent(self)
2878
- }
2879
- else:
2880
- allowed_tool_names = set(self.config.tools)
2881
- if name not in allowed_tool_names:
2882
- self._log.warning("Tool not granted in current boundary: {}", name)
2883
- error_msg = json.dumps({"error": f"Tool not granted: {name}"})
2884
- self._append_history(
2885
- ToolCall(
2886
- tool_name=name,
2887
- tool_call_id=call_id,
2888
- arguments=arguments,
2889
- result=error_msg,
2890
- streaming=False,
2891
- ),
2892
- )
2893
- return error_msg
2894
-
2895
- authorization_error = authorize(name, self, arguments)
2896
- if authorization_error is not None:
2897
- self._log.warning(
2898
- "Tool denied by security policy: {} ({})",
2899
- name,
2900
- authorization_error,
2901
- )
2902
- error_msg = json.dumps({"error": authorization_error})
2903
- self._append_history(
2904
- ToolCall(
2905
- tool_name=name,
2906
- tool_call_id=call_id,
2907
- arguments=arguments,
2908
- result=error_msg,
2909
- streaming=False,
2910
- ),
2911
- )
2912
- return error_msg
2913
-
2914
- event_bus.emit(
2915
- Event(
2916
- type=EventType.TOOL_CALLED,
2917
- agent_id=self.uuid,
2918
- data={"tool": name, "arguments": arguments},
2919
- ),
2920
- )
2921
-
2922
- if name == "send":
2923
- t0 = _time.perf_counter()
2924
- try:
2925
- self._raise_if_interrupt_requested()
2926
- result = tool.execute(
2927
- self,
208
+ else:
209
+ tool_item = new_tool_item(tool_call.name, arguments)
210
+ logger.debug(
211
+ "Tool call started name=%s id=%s", tool_call.name, tool_item["id"]
212
+ )
213
+ logger.log(TRACE_LEVEL, "Tool start item=%r", tool_item)
214
+ yield AgentStreamEvent(event="tool_start", data={"tool": tool_item})
215
+ result = run_tool(
216
+ tool_call.name,
2928
217
  arguments,
2929
- on_output=None,
2930
- tool_call_id=call_id,
2931
- )
2932
- self._raise_if_interrupt_requested()
2933
- elapsed = _time.perf_counter() - t0
2934
- self._log.debug(
2935
- "Tool {} completed in {:.2f}s, result_len={}",
2936
- name,
2937
- elapsed,
2938
- len(result) if result else 0,
2939
- )
2940
- return result
2941
- except InterruptRequestedError:
2942
- raise
2943
- except Exception as exc:
2944
- elapsed = _time.perf_counter() - t0
2945
- self._log.warning(
2946
- "Tool {} failed after {:.2f}s: {}", name, elapsed, exc
2947
- )
2948
- error_text = str(exc)
2949
- self._append_history(ErrorEntry(content=error_text))
2950
- return json.dumps({"error": error_text})
2951
-
2952
- streaming_entry = ToolCall(
2953
- tool_name=name,
2954
- tool_call_id=call_id,
2955
- arguments=arguments,
2956
- streaming=True,
2957
- )
2958
- self._append_history(streaming_entry)
2959
- streamed_result_parts: list[str] = []
2960
-
2961
- def _on_tool_output(text: str) -> None:
2962
- self._raise_if_interrupt_requested()
2963
- streamed_result_parts.append(text)
2964
- delta = ToolResultDelta(tool_call_id=call_id, text=text)
2965
- event_bus.emit(
2966
- Event(
2967
- type=EventType.HISTORY_ENTRY_DELTA,
2968
- agent_id=self.uuid,
2969
- data=delta.serialize(),
2970
- ),
2971
- )
2972
- self._raise_if_interrupt_requested()
2973
-
2974
- t0 = _time.perf_counter()
2975
- try:
2976
- self._raise_if_interrupt_requested()
2977
- result = tool.execute(
2978
- self,
2979
- arguments,
2980
- on_output=_on_tool_output,
2981
- tool_call_id=call_id,
2982
- )
2983
- self._raise_if_interrupt_requested()
2984
- elapsed = _time.perf_counter() - t0
2985
- self._log.debug(
2986
- "Tool {} completed in {:.2f}s, result_len={}",
2987
- name,
2988
- elapsed,
2989
- len(result) if result else 0,
2990
- )
2991
-
2992
- self._finalize_tool_call(call_id, name, arguments, result)
2993
- return result
2994
- except InterruptRequestedError:
2995
- partial_result = "".join(streamed_result_parts) or None
2996
- self._finalize_tool_call(call_id, name, arguments, partial_result)
2997
- raise
2998
- except Exception as e:
2999
- elapsed = _time.perf_counter() - t0
3000
- self._log.exception("Tool {} failed after {:.2f}s", name, elapsed)
3001
- error_msg = json.dumps({"error": str(e)})
3002
- self._finalize_tool_call(call_id, name, arguments, error_msg)
3003
- return error_msg
3004
-
3005
- def _finalize_tool_call(
3006
- self,
3007
- call_id: str,
3008
- name: str,
3009
- arguments: dict[str, Any],
3010
- result: str | None,
3011
- ) -> None:
3012
- final: ToolCall | None = None
3013
- with self._history_lock:
3014
- for i in range(len(self.history) - 1, -1, -1):
3015
- entry = self.history[i]
3016
- if (
3017
- isinstance(entry, ToolCall)
3018
- and entry.tool_call_id == call_id
3019
- and entry.streaming
3020
- ):
3021
- final = ToolCall(
3022
- tool_name=name,
3023
- tool_call_id=call_id,
3024
- arguments=arguments,
3025
- result=result,
3026
- streaming=False,
3027
- )
3028
- self.history[i] = final
3029
- break
3030
-
3031
- if final is not None:
3032
- event_bus.emit(
3033
- Event(
3034
- type=EventType.HISTORY_ENTRY_ADDED,
3035
- agent_id=self.uuid,
3036
- data=final.serialize(),
3037
- ),
3038
- )
3039
-
3040
- def enqueue_message(self, msg: Message) -> None:
3041
- payload = {
3042
- "from": msg.from_id,
3043
- "content": msg.content,
3044
- "parts": [part.serialize() for part in msg.parts],
3045
- "history_recorded": msg.history_recorded,
3046
- }
3047
- if msg.from_output_port_key is not None:
3048
- payload["from_output_port_key"] = msg.from_output_port_key
3049
- if msg.to_input_port_key is not None:
3050
- payload["to_input_port_key"] = msg.to_input_port_key
3051
- if msg.port_type is not None:
3052
- payload["port_type"] = msg.port_type
3053
- if msg.value is not None:
3054
- payload["value"] = msg.value
3055
- if msg.value_summary is not None:
3056
- payload["value_summary"] = msg.value_summary
3057
- if msg.port_inbound_recorded:
3058
- payload["port_inbound_recorded"] = msg.port_inbound_recorded
3059
- if msg.message_id is not None:
3060
- payload["message_id"] = msg.message_id
3061
- self._wake_queue.put(
3062
- WakeSignal(
3063
- reason="message",
3064
- payload={"message": payload},
3065
- resume_reason=f"received message from {msg.from_id}",
3066
- )
3067
- )
3068
-
3069
- def set_state(
3070
- self,
3071
- state: AgentState,
3072
- reason: str = "",
3073
- *,
3074
- force_emit: bool = False,
3075
- ) -> None:
3076
- from flowent.graph_service import is_tab_leader
3077
-
3078
- old = self.state
3079
- self.state = state
3080
- if state == AgentState.IDLE:
3081
- if old != AgentState.IDLE:
3082
- self._idle_started_at = _time.perf_counter()
3083
- self._idle_state_event.set()
3084
- else:
3085
- if old == AgentState.IDLE:
3086
- self._idle_started_at = None
3087
- self._idle_started_by_tool_call_id = None
3088
- self._idle_state_event.clear()
3089
- if old != state or force_emit:
3090
- self._log.debug(
3091
- "State: {} -> {}{}",
3092
- old.value,
3093
- state.value,
3094
- f" ({reason})" if reason else "",
3095
- )
3096
- event_bus.emit(
3097
- Event(
3098
- type=EventType.NODE_STATE_CHANGED,
3099
- agent_id=self.uuid,
218
+ ToolContext(cwd=cwd, web_searcher=web_searcher),
219
+ )
220
+ result_content = result.content
221
+ logger.debug(
222
+ "Tool call finished name=%s id=%s ok=%s",
223
+ tool_call.name,
224
+ tool_item["id"],
225
+ result.ok,
226
+ )
227
+ logger.log(
228
+ TRACE_LEVEL,
229
+ "Tool result id=%s result=%r",
230
+ tool_item["id"],
231
+ result.model_dump(),
232
+ )
233
+ yield AgentStreamEvent(
234
+ event="tool_done" if result.ok else "tool_error",
3100
235
  data={
3101
- "old_state": old.value,
3102
- "new_state": state.value,
3103
- "tab_id": self.config.tab_id,
3104
- "role_name": self.config.role_name,
3105
- "name": self.config.name,
3106
- "is_leader": is_tab_leader(
3107
- node_id=self.uuid,
3108
- tab_id=self.config.tab_id,
3109
- ),
3110
- "todos": [t.serialize() for t in self.get_todos_snapshot()],
236
+ "id": tool_item["id"],
237
+ "content": result.content,
238
+ "data": result.data,
239
+ "status": "success" if result.ok else "failed",
240
+ "title": result.title,
3111
241
  },
3112
- ),
3113
- )
3114
- self._persist_workspace_node()
3115
-
3116
- def request_termination(self, reason: str = "") -> None:
3117
- self._preserve_workspace_state_on_exit = False
3118
- self._termination_reason = reason
3119
- self._terminate.set()
3120
- self._wake_queue.put(
3121
- WakeSignal(
3122
- reason="termination",
3123
- payload={},
3124
- resume_reason="termination requested",
3125
- )
3126
- )
3127
-
3128
- def request_process_exit(self) -> None:
3129
- self._preserve_workspace_state_on_exit = (
3130
- self.node_type == NodeType.ASSISTANT or bool(self.config.tab_id)
3131
- )
3132
- self._termination_reason = "process_exit"
3133
- self._terminate.set()
3134
- self._wake_queue.put(
3135
- WakeSignal(
3136
- reason="termination",
3137
- payload={},
3138
- resume_reason="process exit requested",
3139
- )
3140
- )
3141
-
3142
- def request_interrupt(self) -> bool:
3143
- if self.state not in {AgentState.RUNNING, AgentState.SLEEPING}:
3144
- return False
3145
- self._interrupt_requested.set()
3146
- self._invoke_interrupt_callback()
3147
- return True
3148
-
3149
- def is_interrupt_requested(self) -> bool:
3150
- return self._interrupt_requested.is_set()
3151
-
3152
- def set_interrupt_callback(self, callback: Callable[[], None] | None) -> None:
3153
- with self._interrupt_callback_lock:
3154
- self._interrupt_callback = callback
3155
- if callback is not None and self._interrupt_requested.is_set():
3156
- self._invoke_interrupt_callback()
3157
-
3158
- def _invoke_interrupt_callback(self) -> None:
3159
- with self._interrupt_callback_lock:
3160
- callback = self._interrupt_callback
3161
- if callback is None:
3162
- return
3163
- try:
3164
- callback()
3165
- except Exception:
3166
- self._log.debug("Interrupt callback raised")
3167
-
3168
- def terminate_and_wait(self, timeout: float = 10.0) -> None:
3169
- self.request_termination("shutdown")
3170
- self.wait_for_termination(timeout=timeout)
3171
-
3172
- def wait_for_termination(self, timeout: float = 10.0) -> bool:
3173
- if self._thread and self._thread.is_alive():
3174
- self._thread.join(timeout=timeout)
3175
- return not (self._thread and self._thread.is_alive())
3176
-
3177
- def _should_preserve_workspace_state_on_exit(self) -> bool:
3178
- return self._preserve_workspace_state_on_exit and (
3179
- self.node_type == NodeType.ASSISTANT or bool(self.config.tab_id)
3180
- )
3181
-
3182
- def _finalize_termination(self, reason: str) -> None:
3183
- from flowent.registry import registry
3184
-
3185
- self.set_state(AgentState.TERMINATED, reason)
3186
- self._log.info("Agent terminated (reason: {})", reason)
3187
- event_bus.emit(
3188
- Event(
3189
- type=EventType.NODE_TERMINATED,
3190
- agent_id=self.uuid,
3191
- data={"reason": reason},
3192
- ),
3193
- )
3194
- registry.unregister(self.uuid)
3195
-
3196
- peer_ids = self.get_connections_snapshot()
3197
- for peer_id in peer_ids:
3198
- peer = registry.get(peer_id)
3199
- if peer is not None:
3200
- peer.remove_connection(self.uuid)
3201
- self.remove_connection(peer_id)
3202
-
3203
- self._persist_workspace_node()
242
+ )
243
+ conversation.append(tool_result_message(tool_call_id, result_content))