flowent 0.0.6 → 0.0.10

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