flowent 0.0.1 → 0.0.4

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 (493) hide show
  1. package/README.md +19 -8
  2. package/backend/.python-version +1 -0
  3. package/backend/pyproject.toml +57 -0
  4. package/backend/src/flowent/__init__.py +3 -0
  5. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  6. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  7. package/backend/src/flowent/__pycache__/access.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/assistant_commands.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/config.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/events.cpython-313.pyc +0 -0
  13. package/backend/src/flowent/__pycache__/graph_runtime.cpython-313.pyc +0 -0
  14. package/backend/src/flowent/__pycache__/graph_service.cpython-313.pyc +0 -0
  15. package/backend/src/flowent/__pycache__/image_assets.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/mcp_service.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/__pycache__/model_metadata.cpython-313.pyc +0 -0
  20. package/backend/src/flowent/__pycache__/network.cpython-313.pyc +0 -0
  21. package/backend/src/flowent/__pycache__/registry.cpython-313.pyc +0 -0
  22. package/backend/src/flowent/__pycache__/role_management.cpython-313.pyc +0 -0
  23. package/backend/src/flowent/__pycache__/runtime.cpython-313.pyc +0 -0
  24. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  25. package/backend/src/flowent/__pycache__/security.cpython-313.pyc +0 -0
  26. package/backend/src/flowent/__pycache__/settings.cpython-313.pyc +0 -0
  27. package/backend/src/flowent/__pycache__/settings_management.cpython-313.pyc +0 -0
  28. package/backend/src/flowent/__pycache__/state_db.cpython-313.pyc +0 -0
  29. package/backend/src/flowent/__pycache__/stats_service.cpython-313.pyc +0 -0
  30. package/backend/src/flowent/__pycache__/workspace_store.cpython-313.pyc +0 -0
  31. package/backend/src/flowent/_version.py +7 -0
  32. package/backend/src/flowent/access.py +247 -0
  33. package/backend/src/flowent/agent.py +2808 -0
  34. package/backend/src/flowent/assistant_commands.py +106 -0
  35. package/backend/src/flowent/channels/__init__.py +3 -0
  36. package/backend/src/flowent/channels/__pycache__/__init__.cpython-313.pyc +0 -0
  37. package/backend/src/flowent/channels/__pycache__/telegram.cpython-313.pyc +0 -0
  38. package/backend/src/flowent/channels/telegram.py +615 -0
  39. package/backend/src/flowent/cli.py +85 -0
  40. package/backend/src/flowent/config.py +14 -0
  41. package/backend/src/flowent/dev.py +3 -0
  42. package/backend/src/flowent/events.py +157 -0
  43. package/backend/src/flowent/graph_runtime.py +60 -0
  44. package/backend/src/flowent/graph_service.py +1346 -0
  45. package/backend/src/flowent/image_assets.py +356 -0
  46. package/backend/src/flowent/logging.py +155 -0
  47. package/backend/src/flowent/main.py +124 -0
  48. package/backend/src/flowent/mcp_service.py +1904 -0
  49. package/backend/src/flowent/model_metadata.py +98 -0
  50. package/backend/src/flowent/models/__init__.py +121 -0
  51. package/backend/src/flowent/models/__pycache__/__init__.cpython-313.pyc +0 -0
  52. package/backend/src/flowent/models/__pycache__/agent.cpython-313.pyc +0 -0
  53. package/backend/src/flowent/models/__pycache__/base.cpython-313.pyc +0 -0
  54. package/backend/src/flowent/models/__pycache__/blueprint.cpython-313.pyc +0 -0
  55. package/backend/src/flowent/models/__pycache__/content.cpython-313.pyc +0 -0
  56. package/backend/src/flowent/models/__pycache__/delta.cpython-313.pyc +0 -0
  57. package/backend/src/flowent/models/__pycache__/event.cpython-313.pyc +0 -0
  58. package/backend/src/flowent/models/__pycache__/graph.cpython-313.pyc +0 -0
  59. package/backend/src/flowent/models/__pycache__/history.cpython-313.pyc +0 -0
  60. package/backend/src/flowent/models/__pycache__/llm.cpython-313.pyc +0 -0
  61. package/backend/src/flowent/models/__pycache__/message.cpython-313.pyc +0 -0
  62. package/backend/src/flowent/models/__pycache__/tab.cpython-313.pyc +0 -0
  63. package/backend/src/flowent/models/__pycache__/todo.cpython-313.pyc +0 -0
  64. package/backend/src/flowent/models/agent.py +33 -0
  65. package/backend/src/flowent/models/base.py +24 -0
  66. package/backend/src/flowent/models/blueprint.py +176 -0
  67. package/backend/src/flowent/models/content.py +164 -0
  68. package/backend/src/flowent/models/delta.py +44 -0
  69. package/backend/src/flowent/models/event.py +51 -0
  70. package/backend/src/flowent/models/graph.py +437 -0
  71. package/backend/src/flowent/models/history.py +214 -0
  72. package/backend/src/flowent/models/llm.py +61 -0
  73. package/backend/src/flowent/models/message.py +27 -0
  74. package/backend/src/flowent/models/tab.py +48 -0
  75. package/backend/src/flowent/models/todo.py +10 -0
  76. package/backend/src/flowent/network.py +146 -0
  77. package/backend/src/flowent/prompts/__init__.py +67 -0
  78. package/backend/src/flowent/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
  79. package/backend/src/flowent/prompts/__pycache__/common.cpython-313.pyc +0 -0
  80. package/backend/src/flowent/prompts/__pycache__/steward.cpython-313.pyc +0 -0
  81. package/backend/src/flowent/prompts/common.py +250 -0
  82. package/backend/src/flowent/prompts/steward.py +64 -0
  83. package/backend/src/flowent/providers/__init__.py +23 -0
  84. package/backend/src/flowent/providers/__pycache__/__init__.cpython-313.pyc +0 -0
  85. package/backend/src/flowent/providers/__pycache__/anthropic.cpython-313.pyc +0 -0
  86. package/backend/src/flowent/providers/__pycache__/base_url.cpython-313.pyc +0 -0
  87. package/backend/src/flowent/providers/__pycache__/configuration.cpython-313.pyc +0 -0
  88. package/backend/src/flowent/providers/__pycache__/content.cpython-313.pyc +0 -0
  89. package/backend/src/flowent/providers/__pycache__/errors.cpython-313.pyc +0 -0
  90. package/backend/src/flowent/providers/__pycache__/gateway.cpython-313.pyc +0 -0
  91. package/backend/src/flowent/providers/__pycache__/headers.cpython-313.pyc +0 -0
  92. package/backend/src/flowent/providers/__pycache__/management.cpython-313.pyc +0 -0
  93. package/backend/src/flowent/providers/__pycache__/openai.cpython-313.pyc +0 -0
  94. package/backend/src/flowent/providers/__pycache__/openai_responses.cpython-313.pyc +0 -0
  95. package/backend/src/flowent/providers/__pycache__/registry.cpython-313.pyc +0 -0
  96. package/backend/src/flowent/providers/__pycache__/sse.cpython-313.pyc +0 -0
  97. package/backend/src/flowent/providers/__pycache__/thinking.cpython-313.pyc +0 -0
  98. package/backend/src/flowent/providers/anthropic.py +468 -0
  99. package/backend/src/flowent/providers/base_url.py +60 -0
  100. package/backend/src/flowent/providers/configuration.py +182 -0
  101. package/backend/src/flowent/providers/content.py +122 -0
  102. package/backend/src/flowent/providers/errors.py +223 -0
  103. package/backend/src/flowent/providers/gateway.py +169 -0
  104. package/backend/src/flowent/providers/gemini.py +447 -0
  105. package/backend/src/flowent/providers/headers.py +20 -0
  106. package/backend/src/flowent/providers/management.py +96 -0
  107. package/backend/src/flowent/providers/ollama.py +293 -0
  108. package/backend/src/flowent/providers/openai.py +422 -0
  109. package/backend/src/flowent/providers/openai_responses.py +655 -0
  110. package/backend/src/flowent/providers/registry.py +144 -0
  111. package/backend/src/flowent/providers/sse.py +31 -0
  112. package/backend/src/flowent/providers/thinking.py +79 -0
  113. package/backend/src/flowent/registry.py +73 -0
  114. package/backend/src/flowent/role_management.py +255 -0
  115. package/backend/src/flowent/routes/__init__.py +30 -0
  116. package/backend/src/flowent/routes/__pycache__/__init__.cpython-313.pyc +0 -0
  117. package/backend/src/flowent/routes/__pycache__/access.cpython-313.pyc +0 -0
  118. package/backend/src/flowent/routes/__pycache__/assistant.cpython-313.pyc +0 -0
  119. package/backend/src/flowent/routes/__pycache__/image_assets.cpython-313.pyc +0 -0
  120. package/backend/src/flowent/routes/__pycache__/mcp.cpython-313.pyc +0 -0
  121. package/backend/src/flowent/routes/__pycache__/meta.cpython-313.pyc +0 -0
  122. package/backend/src/flowent/routes/__pycache__/nodes.cpython-313.pyc +0 -0
  123. package/backend/src/flowent/routes/__pycache__/prompts.cpython-313.pyc +0 -0
  124. package/backend/src/flowent/routes/__pycache__/providers_route.cpython-313.pyc +0 -0
  125. package/backend/src/flowent/routes/__pycache__/roles.cpython-313.pyc +0 -0
  126. package/backend/src/flowent/routes/__pycache__/settings.cpython-313.pyc +0 -0
  127. package/backend/src/flowent/routes/__pycache__/stats.cpython-313.pyc +0 -0
  128. package/backend/src/flowent/routes/__pycache__/tabs.cpython-313.pyc +0 -0
  129. package/backend/src/flowent/routes/__pycache__/ws.cpython-313.pyc +0 -0
  130. package/backend/src/flowent/routes/access.py +48 -0
  131. package/backend/src/flowent/routes/assistant.py +155 -0
  132. package/backend/src/flowent/routes/image_assets.py +33 -0
  133. package/backend/src/flowent/routes/mcp.py +125 -0
  134. package/backend/src/flowent/routes/meta.py +28 -0
  135. package/backend/src/flowent/routes/nodes.py +365 -0
  136. package/backend/src/flowent/routes/prompts.py +46 -0
  137. package/backend/src/flowent/routes/providers_route.py +364 -0
  138. package/backend/src/flowent/routes/roles.py +207 -0
  139. package/backend/src/flowent/routes/settings.py +324 -0
  140. package/backend/src/flowent/routes/stats.py +229 -0
  141. package/backend/src/flowent/routes/tabs.py +292 -0
  142. package/backend/src/flowent/routes/ws.py +33 -0
  143. package/backend/src/flowent/runtime.py +188 -0
  144. package/backend/src/flowent/sandbox.py +45 -0
  145. package/backend/src/flowent/security.py +42 -0
  146. package/backend/src/flowent/settings.py +2467 -0
  147. package/backend/src/flowent/settings_management.py +286 -0
  148. package/backend/src/flowent/state_db.py +120 -0
  149. package/backend/src/flowent/static/assets/AssistantPage-B3Xc08AS.js +1 -0
  150. package/backend/src/flowent/static/assets/ChannelsPage-ByLd28xk.js +1 -0
  151. package/backend/src/flowent/static/assets/HomePage-C0hAx9_l.js +3 -0
  152. package/backend/src/flowent/static/assets/McpPage-DkrYLvBv.js +7 -0
  153. package/backend/src/flowent/static/assets/PageScaffold-D4jO9ooX.js +1 -0
  154. package/backend/src/flowent/static/assets/PromptsPage-DWA7rRJd.js +1 -0
  155. package/backend/src/flowent/static/assets/ProvidersPage-PUWT8seJ.js +3 -0
  156. package/backend/src/flowent/static/assets/RolesPage-CqcclGRw.js +1 -0
  157. package/backend/src/flowent/static/assets/SettingsPage-8tS2cJgX.js +3 -0
  158. package/backend/src/flowent/static/assets/StatsPage-BX9khYzu.js +1 -0
  159. package/backend/src/flowent/static/assets/ToolsPage-9Tl9FdeD.js +1 -0
  160. package/backend/src/flowent/static/assets/WorkspaceCommandDialog-CCXxjDL8.js +1 -0
  161. package/backend/src/flowent/static/assets/WorkspacePanels-aMdJ7ZH7.js +1 -0
  162. package/backend/src/flowent/static/assets/alert-dialog-kFYVQ7oX.js +1 -0
  163. package/backend/src/flowent/static/assets/badge-74-3jsCg.js +1 -0
  164. package/backend/src/flowent/static/assets/constants-XUzFf6i1.js +1 -0
  165. package/backend/src/flowent/static/assets/datetime-m6_O_Ci9.js +1 -0
  166. package/backend/src/flowent/static/assets/dialog-BeGSweF6.js +1 -0
  167. package/backend/src/flowent/static/assets/elk-worker.min-C9JGDOE-.js +6312 -0
  168. package/backend/src/flowent/static/assets/graph-vendor-CHpVij2M.css +1 -0
  169. package/backend/src/flowent/static/assets/graph-vendor-DRq_-6fV.js +7 -0
  170. package/backend/src/flowent/static/assets/index-BHC1Vhy8.css +1 -0
  171. package/backend/src/flowent/static/assets/index-CL1ALZ3r.js +10 -0
  172. package/backend/src/flowent/static/assets/layout.worker-jMHqAFbP.js +24 -0
  173. package/backend/src/flowent/static/assets/markdown-vendor-DVdy_w12.js +29 -0
  174. package/backend/src/flowent/static/assets/modelParams-CaHd0903.js +1 -0
  175. package/backend/src/flowent/static/assets/react-vendor-mEs_JJxa.js +9 -0
  176. package/backend/src/flowent/static/assets/roles-2OLDeTc5.js +1 -0
  177. package/backend/src/flowent/static/assets/rolldown-runtime-BYbx6iT9.js +1 -0
  178. package/backend/src/flowent/static/assets/select-DL_LPeDj.js +1 -0
  179. package/backend/src/flowent/static/assets/shared-CMxbpLeQ.js +1 -0
  180. package/backend/src/flowent/static/assets/triState-DEr3NkXV.js +1 -0
  181. package/backend/src/flowent/static/assets/ui-vendor-Dg9NNnWX.js +51 -0
  182. package/backend/src/flowent/static/index.html +36 -0
  183. package/backend/src/flowent/stats_service.py +218 -0
  184. package/backend/src/flowent/tools/__init__.py +201 -0
  185. package/backend/src/flowent/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  186. package/backend/src/flowent/tools/__pycache__/connect.cpython-313.pyc +0 -0
  187. package/backend/src/flowent/tools/__pycache__/contacts.cpython-313.pyc +0 -0
  188. package/backend/src/flowent/tools/__pycache__/create_agent.cpython-313.pyc +0 -0
  189. package/backend/src/flowent/tools/__pycache__/create_tab.cpython-313.pyc +0 -0
  190. package/backend/src/flowent/tools/__pycache__/delete_tab.cpython-313.pyc +0 -0
  191. package/backend/src/flowent/tools/__pycache__/edit.cpython-313.pyc +0 -0
  192. package/backend/src/flowent/tools/__pycache__/exec.cpython-313.pyc +0 -0
  193. package/backend/src/flowent/tools/__pycache__/fetch.cpython-313.pyc +0 -0
  194. package/backend/src/flowent/tools/__pycache__/idle.cpython-313.pyc +0 -0
  195. package/backend/src/flowent/tools/__pycache__/list_roles.cpython-313.pyc +0 -0
  196. package/backend/src/flowent/tools/__pycache__/list_tabs.cpython-313.pyc +0 -0
  197. package/backend/src/flowent/tools/__pycache__/list_tools.cpython-313.pyc +0 -0
  198. package/backend/src/flowent/tools/__pycache__/manage_prompts.cpython-313.pyc +0 -0
  199. package/backend/src/flowent/tools/__pycache__/manage_providers.cpython-313.pyc +0 -0
  200. package/backend/src/flowent/tools/__pycache__/manage_roles.cpython-313.pyc +0 -0
  201. package/backend/src/flowent/tools/__pycache__/manage_settings.cpython-313.pyc +0 -0
  202. package/backend/src/flowent/tools/__pycache__/mcp.cpython-313.pyc +0 -0
  203. package/backend/src/flowent/tools/__pycache__/read.cpython-313.pyc +0 -0
  204. package/backend/src/flowent/tools/__pycache__/send.cpython-313.pyc +0 -0
  205. package/backend/src/flowent/tools/__pycache__/set_permissions.cpython-313.pyc +0 -0
  206. package/backend/src/flowent/tools/__pycache__/sleep.cpython-313.pyc +0 -0
  207. package/backend/src/flowent/tools/__pycache__/todo.cpython-313.pyc +0 -0
  208. package/backend/src/flowent/tools/connect.py +156 -0
  209. package/backend/src/flowent/tools/contacts.py +22 -0
  210. package/backend/src/flowent/tools/create_agent.py +270 -0
  211. package/backend/src/flowent/tools/create_tab.py +59 -0
  212. package/backend/src/flowent/tools/delete_tab.py +39 -0
  213. package/backend/src/flowent/tools/edit.py +142 -0
  214. package/backend/src/flowent/tools/exec.py +117 -0
  215. package/backend/src/flowent/tools/fetch.py +85 -0
  216. package/backend/src/flowent/tools/idle.py +27 -0
  217. package/backend/src/flowent/tools/list_roles.py +50 -0
  218. package/backend/src/flowent/tools/list_tabs.py +96 -0
  219. package/backend/src/flowent/tools/list_tools.py +24 -0
  220. package/backend/src/flowent/tools/manage_prompts.py +102 -0
  221. package/backend/src/flowent/tools/manage_providers.py +220 -0
  222. package/backend/src/flowent/tools/manage_roles.py +275 -0
  223. package/backend/src/flowent/tools/manage_settings.py +346 -0
  224. package/backend/src/flowent/tools/mcp.py +199 -0
  225. package/backend/src/flowent/tools/read.py +152 -0
  226. package/backend/src/flowent/tools/send.py +50 -0
  227. package/backend/src/flowent/tools/set_permissions.py +84 -0
  228. package/backend/src/flowent/tools/sleep.py +41 -0
  229. package/backend/src/flowent/tools/todo.py +51 -0
  230. package/backend/src/flowent/workspace_store.py +479 -0
  231. package/backend/tests/__init__.py +0 -0
  232. package/backend/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  233. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  234. package/backend/tests/conftest.py +6 -0
  235. package/backend/tests/integration/api/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  236. package/backend/tests/integration/api/__pycache__/test_access_api.cpython-313-pytest-9.0.3.pyc +0 -0
  237. package/backend/tests/integration/api/__pycache__/test_assistant_api.cpython-313-pytest-9.0.3.pyc +0 -0
  238. package/backend/tests/integration/api/__pycache__/test_frontend_mounting.cpython-313-pytest-9.0.3.pyc +0 -0
  239. package/backend/tests/integration/api/__pycache__/test_mcp_api.cpython-313-pytest-9.0.3.pyc +0 -0
  240. package/backend/tests/integration/api/__pycache__/test_meta_api.cpython-313-pytest-9.0.3.pyc +0 -0
  241. package/backend/tests/integration/api/__pycache__/test_nodes_api.cpython-313-pytest-9.0.3.pyc +0 -0
  242. package/backend/tests/integration/api/__pycache__/test_prompts_api.cpython-313-pytest-9.0.3.pyc +0 -0
  243. package/backend/tests/integration/api/__pycache__/test_roles_api.cpython-313-pytest-9.0.3.pyc +0 -0
  244. package/backend/tests/integration/api/__pycache__/test_tabs_api.cpython-313-pytest-9.0.3.pyc +0 -0
  245. package/backend/tests/integration/api/conftest.py +29 -0
  246. package/backend/tests/integration/api/test_access_api.py +182 -0
  247. package/backend/tests/integration/api/test_assistant_api.py +354 -0
  248. package/backend/tests/integration/api/test_frontend_mounting.py +61 -0
  249. package/backend/tests/integration/api/test_mcp_api.py +116 -0
  250. package/backend/tests/integration/api/test_meta_api.py +33 -0
  251. package/backend/tests/integration/api/test_nodes_api.py +486 -0
  252. package/backend/tests/integration/api/test_prompts_api.py +47 -0
  253. package/backend/tests/integration/api/test_roles_api.py +227 -0
  254. package/backend/tests/integration/api/test_tabs_api.py +501 -0
  255. package/backend/tests/unit/__pycache__/test_access.cpython-313-pytest-9.0.3.pyc +0 -0
  256. package/backend/tests/unit/__pycache__/test_cli.cpython-313-pytest-9.0.3.pyc +0 -0
  257. package/backend/tests/unit/__pycache__/test_graph_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  258. package/backend/tests/unit/__pycache__/test_network.cpython-313-pytest-9.0.3.pyc +0 -0
  259. package/backend/tests/unit/__pycache__/test_state_sqlite_storage.cpython-313-pytest-9.0.3.pyc +0 -0
  260. package/backend/tests/unit/__pycache__/test_workspace_store.cpython-313-pytest-9.0.3.pyc +0 -0
  261. package/backend/tests/unit/agent/__pycache__/test_agent_public_api.cpython-313-pytest-9.0.3.pyc +0 -0
  262. package/backend/tests/unit/agent/__pycache__/test_agent_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  263. package/backend/tests/unit/agent/test_agent_public_api.py +746 -0
  264. package/backend/tests/unit/agent/test_agent_runtime.py +2726 -0
  265. package/backend/tests/unit/channels/__pycache__/test_telegram_channel.cpython-313-pytest-9.0.3.pyc +0 -0
  266. package/backend/tests/unit/channels/test_telegram_channel.py +552 -0
  267. package/backend/tests/unit/logging/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  268. package/backend/tests/unit/logging/test_logging.py +132 -0
  269. package/backend/tests/unit/prompts/__pycache__/test_prompts.cpython-313-pytest-9.0.3.pyc +0 -0
  270. package/backend/tests/unit/prompts/test_prompts.py +569 -0
  271. package/backend/tests/unit/providers/__pycache__/test_anthropic_provider.cpython-313-pytest-9.0.3.pyc +0 -0
  272. package/backend/tests/unit/providers/__pycache__/test_errors.cpython-313-pytest-9.0.3.pyc +0 -0
  273. package/backend/tests/unit/providers/__pycache__/test_extract_delta_parts.cpython-313-pytest-9.0.3.pyc +0 -0
  274. package/backend/tests/unit/providers/__pycache__/test_openai_provider.cpython-313-pytest-9.0.3.pyc +0 -0
  275. package/backend/tests/unit/providers/__pycache__/test_openai_responses.cpython-313-pytest-9.0.3.pyc +0 -0
  276. package/backend/tests/unit/providers/__pycache__/test_provider_gateway.cpython-313-pytest-9.0.3.pyc +0 -0
  277. package/backend/tests/unit/providers/__pycache__/test_think_tag_parser.cpython-313-pytest-9.0.3.pyc +0 -0
  278. package/backend/tests/unit/providers/test_anthropic_provider.py +185 -0
  279. package/backend/tests/unit/providers/test_errors.py +68 -0
  280. package/backend/tests/unit/providers/test_extract_delta_parts.py +22 -0
  281. package/backend/tests/unit/providers/test_openai_provider.py +139 -0
  282. package/backend/tests/unit/providers/test_openai_responses.py +402 -0
  283. package/backend/tests/unit/providers/test_provider_gateway.py +359 -0
  284. package/backend/tests/unit/providers/test_think_tag_parser.py +36 -0
  285. package/backend/tests/unit/routes/__pycache__/test_prompts_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  286. package/backend/tests/unit/routes/__pycache__/test_providers_route.cpython-313-pytest-9.0.3.pyc +0 -0
  287. package/backend/tests/unit/routes/__pycache__/test_roles_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  288. package/backend/tests/unit/routes/__pycache__/test_settings_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  289. package/backend/tests/unit/routes/__pycache__/test_stats_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  290. package/backend/tests/unit/routes/test_prompts_routes.py +104 -0
  291. package/backend/tests/unit/routes/test_providers_route.py +368 -0
  292. package/backend/tests/unit/routes/test_roles_routes.py +426 -0
  293. package/backend/tests/unit/routes/test_settings_routes.py +1138 -0
  294. package/backend/tests/unit/routes/test_stats_routes.py +149 -0
  295. package/backend/tests/unit/runtime/__pycache__/test_bootstrap_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  296. package/backend/tests/unit/runtime/test_bootstrap_runtime.py +1012 -0
  297. package/backend/tests/unit/sandbox/__pycache__/test_sandbox_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  298. package/backend/tests/unit/sandbox/test_sandbox_tools.py +78 -0
  299. package/backend/tests/unit/security/__pycache__/test_security.cpython-313-pytest-9.0.3.pyc +0 -0
  300. package/backend/tests/unit/security/test_security.py +110 -0
  301. package/backend/tests/unit/settings/__pycache__/test_settings_roles.cpython-313-pytest-9.0.3.pyc +0 -0
  302. package/backend/tests/unit/settings/test_settings_roles.py +711 -0
  303. package/backend/tests/unit/test_access.py +45 -0
  304. package/backend/tests/unit/test_cli.py +124 -0
  305. package/backend/tests/unit/test_graph_runtime.py +72 -0
  306. package/backend/tests/unit/test_network.py +51 -0
  307. package/backend/tests/unit/test_state_sqlite_storage.py +93 -0
  308. package/backend/tests/unit/test_workspace_store.py +231 -0
  309. package/backend/tests/unit/tools/__pycache__/test_connect_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  310. package/backend/tests/unit/tools/__pycache__/test_create_agent_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  311. package/backend/tests/unit/tools/__pycache__/test_delete_tab_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  312. package/backend/tests/unit/tools/__pycache__/test_edit_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  313. package/backend/tests/unit/tools/__pycache__/test_exec_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  314. package/backend/tests/unit/tools/__pycache__/test_fetch_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  315. package/backend/tests/unit/tools/__pycache__/test_manage_prompts_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  316. package/backend/tests/unit/tools/__pycache__/test_manage_providers_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  317. package/backend/tests/unit/tools/__pycache__/test_manage_roles_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  318. package/backend/tests/unit/tools/__pycache__/test_manage_settings_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  319. package/backend/tests/unit/tools/__pycache__/test_read_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  320. package/backend/tests/unit/tools/__pycache__/test_set_permissions_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  321. package/backend/tests/unit/tools/__pycache__/test_todo_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  322. package/backend/tests/unit/tools/__pycache__/test_tool_registry.cpython-313-pytest-9.0.3.pyc +0 -0
  323. package/backend/tests/unit/tools/test_connect_tool.py +229 -0
  324. package/backend/tests/unit/tools/test_create_agent_tool.py +524 -0
  325. package/backend/tests/unit/tools/test_delete_tab_tool.py +83 -0
  326. package/backend/tests/unit/tools/test_edit_tool.py +115 -0
  327. package/backend/tests/unit/tools/test_exec_tool.py +81 -0
  328. package/backend/tests/unit/tools/test_fetch_tool.py +65 -0
  329. package/backend/tests/unit/tools/test_manage_prompts_tool.py +117 -0
  330. package/backend/tests/unit/tools/test_manage_providers_tool.py +458 -0
  331. package/backend/tests/unit/tools/test_manage_roles_tool.py +411 -0
  332. package/backend/tests/unit/tools/test_manage_settings_tool.py +608 -0
  333. package/backend/tests/unit/tools/test_read_tool.py +33 -0
  334. package/backend/tests/unit/tools/test_set_permissions_tool.py +391 -0
  335. package/backend/tests/unit/tools/test_todo_tool.py +37 -0
  336. package/backend/tests/unit/tools/test_tool_registry.py +91 -0
  337. package/backend/uv.lock +1144 -0
  338. package/bin/flowent.mjs +62 -36
  339. package/dist/frontend/assets/AssistantPage-B3Xc08AS.js +1 -0
  340. package/dist/frontend/assets/ChannelsPage-ByLd28xk.js +1 -0
  341. package/dist/frontend/assets/HomePage-C0hAx9_l.js +3 -0
  342. package/dist/frontend/assets/McpPage-DkrYLvBv.js +7 -0
  343. package/dist/frontend/assets/PageScaffold-D4jO9ooX.js +1 -0
  344. package/dist/frontend/assets/PromptsPage-DWA7rRJd.js +1 -0
  345. package/dist/frontend/assets/ProvidersPage-PUWT8seJ.js +3 -0
  346. package/dist/frontend/assets/RolesPage-CqcclGRw.js +1 -0
  347. package/dist/frontend/assets/SettingsPage-8tS2cJgX.js +3 -0
  348. package/dist/frontend/assets/StatsPage-BX9khYzu.js +1 -0
  349. package/dist/frontend/assets/ToolsPage-9Tl9FdeD.js +1 -0
  350. package/dist/frontend/assets/WorkspaceCommandDialog-CCXxjDL8.js +1 -0
  351. package/dist/frontend/assets/WorkspacePanels-aMdJ7ZH7.js +1 -0
  352. package/dist/frontend/assets/alert-dialog-kFYVQ7oX.js +1 -0
  353. package/dist/frontend/assets/badge-74-3jsCg.js +1 -0
  354. package/dist/frontend/assets/constants-XUzFf6i1.js +1 -0
  355. package/dist/frontend/assets/datetime-m6_O_Ci9.js +1 -0
  356. package/dist/frontend/assets/dialog-BeGSweF6.js +1 -0
  357. package/dist/frontend/assets/elk-worker.min-C9JGDOE-.js +6312 -0
  358. package/dist/frontend/assets/graph-vendor-CHpVij2M.css +1 -0
  359. package/dist/frontend/assets/graph-vendor-DRq_-6fV.js +7 -0
  360. package/dist/frontend/assets/index-BHC1Vhy8.css +1 -0
  361. package/dist/frontend/assets/index-CL1ALZ3r.js +10 -0
  362. package/dist/frontend/assets/layout.worker-jMHqAFbP.js +24 -0
  363. package/dist/frontend/assets/markdown-vendor-DVdy_w12.js +29 -0
  364. package/dist/frontend/assets/modelParams-CaHd0903.js +1 -0
  365. package/dist/frontend/assets/react-vendor-mEs_JJxa.js +9 -0
  366. package/dist/frontend/assets/roles-2OLDeTc5.js +1 -0
  367. package/dist/frontend/assets/rolldown-runtime-BYbx6iT9.js +1 -0
  368. package/dist/frontend/assets/select-DL_LPeDj.js +1 -0
  369. package/dist/frontend/assets/shared-CMxbpLeQ.js +1 -0
  370. package/dist/frontend/assets/triState-DEr3NkXV.js +1 -0
  371. package/dist/frontend/assets/ui-vendor-Dg9NNnWX.js +51 -0
  372. package/dist/frontend/index.html +36 -0
  373. package/package.json +27 -41
  374. package/dist/.next/BUILD_ID +0 -1
  375. package/dist/.next/app-path-routes-manifest.json +0 -6
  376. package/dist/.next/build-manifest.json +0 -20
  377. package/dist/.next/package.json +0 -1
  378. package/dist/.next/prerender-manifest.json +0 -114
  379. package/dist/.next/required-server-files.json +0 -333
  380. package/dist/.next/routes-manifest.json +0 -69
  381. package/dist/.next/server/app/_global-error/page/app-paths-manifest.json +0 -3
  382. package/dist/.next/server/app/_global-error/page/build-manifest.json +0 -16
  383. package/dist/.next/server/app/_global-error/page/next-font-manifest.json +0 -6
  384. package/dist/.next/server/app/_global-error/page/react-loadable-manifest.json +0 -1
  385. package/dist/.next/server/app/_global-error/page/server-reference-manifest.json +0 -4
  386. package/dist/.next/server/app/_global-error/page.js +0 -9
  387. package/dist/.next/server/app/_global-error/page.js.map +0 -5
  388. package/dist/.next/server/app/_global-error/page.js.nft.json +0 -1
  389. package/dist/.next/server/app/_global-error/page_client-reference-manifest.js +0 -3
  390. package/dist/.next/server/app/_global-error.html +0 -1
  391. package/dist/.next/server/app/_global-error.meta +0 -15
  392. package/dist/.next/server/app/_global-error.rsc +0 -14
  393. package/dist/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +0 -5
  394. package/dist/.next/server/app/_global-error.segments/_full.segment.rsc +0 -14
  395. package/dist/.next/server/app/_global-error.segments/_head.segment.rsc +0 -5
  396. package/dist/.next/server/app/_global-error.segments/_index.segment.rsc +0 -5
  397. package/dist/.next/server/app/_global-error.segments/_tree.segment.rsc +0 -1
  398. package/dist/.next/server/app/_not-found/page/app-paths-manifest.json +0 -3
  399. package/dist/.next/server/app/_not-found/page/build-manifest.json +0 -16
  400. package/dist/.next/server/app/_not-found/page/next-font-manifest.json +0 -10
  401. package/dist/.next/server/app/_not-found/page/react-loadable-manifest.json +0 -1
  402. package/dist/.next/server/app/_not-found/page/server-reference-manifest.json +0 -4
  403. package/dist/.next/server/app/_not-found/page.js +0 -13
  404. package/dist/.next/server/app/_not-found/page.js.map +0 -5
  405. package/dist/.next/server/app/_not-found/page.js.nft.json +0 -1
  406. package/dist/.next/server/app/_not-found/page_client-reference-manifest.js +0 -3
  407. package/dist/.next/server/app/_not-found.html +0 -1
  408. package/dist/.next/server/app/_not-found.meta +0 -16
  409. package/dist/.next/server/app/_not-found.rsc +0 -16
  410. package/dist/.next/server/app/_not-found.segments/_full.segment.rsc +0 -16
  411. package/dist/.next/server/app/_not-found.segments/_head.segment.rsc +0 -6
  412. package/dist/.next/server/app/_not-found.segments/_index.segment.rsc +0 -5
  413. package/dist/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +0 -5
  414. package/dist/.next/server/app/_not-found.segments/_not-found.segment.rsc +0 -5
  415. package/dist/.next/server/app/_not-found.segments/_tree.segment.rsc +0 -2
  416. package/dist/.next/server/app/icon.svg/route/app-paths-manifest.json +0 -3
  417. package/dist/.next/server/app/icon.svg/route/build-manifest.json +0 -9
  418. package/dist/.next/server/app/icon.svg/route.js +0 -6
  419. package/dist/.next/server/app/icon.svg/route.js.map +0 -5
  420. package/dist/.next/server/app/icon.svg/route.js.nft.json +0 -1
  421. package/dist/.next/server/app/icon.svg.meta +0 -1
  422. package/dist/.next/server/app/index.html +0 -1
  423. package/dist/.next/server/app/index.meta +0 -14
  424. package/dist/.next/server/app/index.rsc +0 -15
  425. package/dist/.next/server/app/index.segments/__PAGE__.segment.rsc +0 -5
  426. package/dist/.next/server/app/index.segments/_full.segment.rsc +0 -15
  427. package/dist/.next/server/app/index.segments/_head.segment.rsc +0 -6
  428. package/dist/.next/server/app/index.segments/_index.segment.rsc +0 -5
  429. package/dist/.next/server/app/index.segments/_tree.segment.rsc +0 -3
  430. package/dist/.next/server/app/page/app-paths-manifest.json +0 -3
  431. package/dist/.next/server/app/page/build-manifest.json +0 -16
  432. package/dist/.next/server/app/page/next-font-manifest.json +0 -10
  433. package/dist/.next/server/app/page/react-loadable-manifest.json +0 -1
  434. package/dist/.next/server/app/page/server-reference-manifest.json +0 -4
  435. package/dist/.next/server/app/page.js +0 -14
  436. package/dist/.next/server/app/page.js.map +0 -5
  437. package/dist/.next/server/app/page.js.nft.json +0 -1
  438. package/dist/.next/server/app/page_client-reference-manifest.js +0 -3
  439. package/dist/.next/server/app-paths-manifest.json +0 -6
  440. package/dist/.next/server/chunks/[externals]_next_dist_0arv.vj._.js +0 -3
  441. package/dist/.next/server/chunks/[root-of-the-server]__0vcj1q1._.js +0 -13
  442. package/dist/.next/server/chunks/[turbopack]_runtime.js +0 -903
  443. package/dist/.next/server/chunks/_next-internal_server_app_icon_svg_route_actions_0-0ehc~.js +0 -3
  444. package/dist/.next/server/chunks/ssr/05w9_next_dist_0ihu0u9._.js +0 -6
  445. package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_12u3mib._.js +0 -3
  446. package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_builtin_forbidden_04fbe_..js +0 -3
  447. package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_builtin_global-error_0brpl_..js +0 -3
  448. package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_builtin_unauthorized_0~2g66g.js +0 -3
  449. package/dist/.next/server/chunks/ssr/05w9_next_dist_esm_build_templates_app-page_0~cyr1_.js +0 -4
  450. package/dist/.next/server/chunks/ssr/05w9_next_dist_esm_build_templates_app-page_1105emf.js +0 -4
  451. package/dist/.next/server/chunks/ssr/05w9_next_dist_esm_build_templates_app-page_11uhyqv.js +0 -4
  452. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0.t9_75._.js +0 -33
  453. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0c0ud_z._.js +0 -3
  454. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0f9_8d4._.js +0 -3
  455. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0l5ko41._.js +0 -19
  456. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0mn6z7i._.js +0 -3
  457. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0npxxst._.js +0 -33
  458. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0qjhaca._.js +0 -3
  459. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0rwgw3s._.js +0 -3
  460. package/dist/.next/server/chunks/ssr/[turbopack]_runtime.js +0 -903
  461. package/dist/.next/server/chunks/ssr/_next-internal_server_app__global-error_page_actions_0k77kol.js +0 -3
  462. package/dist/.next/server/chunks/ssr/_next-internal_server_app__not-found_page_actions_0eq97pa.js +0 -3
  463. package/dist/.next/server/chunks/ssr/_next-internal_server_app_page_actions_09-gtaw.js +0 -3
  464. package/dist/.next/server/chunks/ssr/node_modules__pnpm_056~6.6._.js +0 -3
  465. package/dist/.next/server/chunks/ssr/node_modules__pnpm_0~j0k.e._.js +0 -33
  466. package/dist/.next/server/functions-config-manifest.json +0 -4
  467. package/dist/.next/server/middleware-build-manifest.js +0 -20
  468. package/dist/.next/server/middleware-manifest.json +0 -6
  469. package/dist/.next/server/next-font-manifest.js +0 -1
  470. package/dist/.next/server/next-font-manifest.json +0 -13
  471. package/dist/.next/server/pages/404.html +0 -1
  472. package/dist/.next/server/pages/500.html +0 -1
  473. package/dist/.next/server/pages-manifest.json +0 -4
  474. package/dist/.next/server/prefetch-hints.json +0 -1
  475. package/dist/.next/server/server-reference-manifest.js +0 -1
  476. package/dist/.next/server/server-reference-manifest.json +0 -5
  477. package/dist/.next/static/SMWpxFVvkpYFxY7uuFvGB/_buildManifest.js +0 -11
  478. package/dist/.next/static/SMWpxFVvkpYFxY7uuFvGB/_clientMiddlewareManifest.js +0 -1
  479. package/dist/.next/static/SMWpxFVvkpYFxY7uuFvGB/_ssgManifest.js +0 -1
  480. package/dist/.next/static/chunks/01qk2~bgf76vu.js +0 -1
  481. package/dist/.next/static/chunks/03~yq9q893hmn.js +0 -1
  482. package/dist/.next/static/chunks/080queev.r2uy.js +0 -31
  483. package/dist/.next/static/chunks/0v3lyuj75aq50.js +0 -1
  484. package/dist/.next/static/chunks/10b~xdx5c-i7s.js +0 -5
  485. package/dist/.next/static/chunks/14gla2ascffgv.css +0 -2
  486. package/dist/.next/static/chunks/turbopack-0m-970~qvs7sc.js +0 -1
  487. package/dist/.next/static/media/7178b3e590c64307-s.11.cyxs5p-0z~.woff2 +0 -0
  488. package/dist/.next/static/media/8a480f0b521d4e75-s.06d3mdzz5bre_.woff2 +0 -0
  489. package/dist/.next/static/media/caa3a2e1cccd8315-s.p.16t1db8_9y2o~.woff2 +0 -0
  490. package/dist/package.json +0 -88
  491. package/dist/server.js +0 -38
  492. /package/{dist/.next/server/app/icon.svg.body → backend/src/flowent/static/favicon.svg} +0 -0
  493. /package/dist/{.next/static/media/icon.0.r~afrtrocz9.svg → frontend/favicon.svg} +0 -0
@@ -0,0 +1,1346 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from copy import deepcopy
5
+
6
+ from flowent import settings as settings_module
7
+ from flowent.events import event_bus
8
+ from flowent.models import (
9
+ AgentState,
10
+ EdgeKind,
11
+ Event,
12
+ EventType,
13
+ GraphEdge,
14
+ GraphNodeRecord,
15
+ Message,
16
+ NodeConfig,
17
+ NodeType,
18
+ PortDirection,
19
+ ReceivedMessage,
20
+ Tab,
21
+ WorkflowDefinition,
22
+ WorkflowNodeDefinition,
23
+ WorkflowNodeKind,
24
+ WorkflowPort,
25
+ )
26
+ from flowent.registry import registry
27
+ from flowent.runtime import SYSTEM_NODE_TIMEOUT
28
+ from flowent.settings import (
29
+ CONDUCTOR_ROLE_INCLUDED_TOOLS,
30
+ CONDUCTOR_ROLE_NAME,
31
+ DESIGNER_ROLE_INCLUDED_TOOLS,
32
+ DESIGNER_ROLE_NAME,
33
+ STEWARD_ROLE_INCLUDED_TOOLS,
34
+ STEWARD_ROLE_NAME,
35
+ build_assistant_write_dirs,
36
+ find_role,
37
+ resolve_path,
38
+ )
39
+ from flowent.tools import MINIMUM_TOOLS
40
+ from flowent.workspace_store import workspace_store
41
+
42
+ LEADER_NODE_NAME = "Leader"
43
+
44
+
45
+ def build_tools_for_role(
46
+ role_name: str,
47
+ *,
48
+ requested_tools: list[str] | None = None,
49
+ settings=None,
50
+ ) -> list[str]:
51
+ current_settings = settings or settings_module.get_settings()
52
+ normalized_role_name = role_name.strip()
53
+ role = find_role(current_settings, normalized_role_name)
54
+ if role is None:
55
+ if normalized_role_name == CONDUCTOR_ROLE_NAME:
56
+ included_tools = list(CONDUCTOR_ROLE_INCLUDED_TOOLS)
57
+ elif normalized_role_name == DESIGNER_ROLE_NAME:
58
+ included_tools = list(DESIGNER_ROLE_INCLUDED_TOOLS)
59
+ elif normalized_role_name == STEWARD_ROLE_NAME:
60
+ included_tools = list(STEWARD_ROLE_INCLUDED_TOOLS)
61
+ else:
62
+ included_tools = []
63
+ excluded_tools: set[str] = set()
64
+ else:
65
+ included_tools = list(role.included_tools)
66
+ excluded_tools = set(role.excluded_tools)
67
+
68
+ final_tools: list[str] = []
69
+ seen_tools: set[str] = set()
70
+ for tool_name in [*MINIMUM_TOOLS, *included_tools, *(requested_tools or [])]:
71
+ if tool_name in seen_tools:
72
+ continue
73
+ if tool_name in excluded_tools and tool_name not in MINIMUM_TOOLS:
74
+ continue
75
+ final_tools.append(tool_name)
76
+ seen_tools.add(tool_name)
77
+ return final_tools
78
+
79
+
80
+ def build_assistant_tools(*, settings=None) -> list[str]:
81
+ current_settings = settings or settings_module.get_settings()
82
+ assistant_tools = build_tools_for_role(
83
+ current_settings.assistant.role_name,
84
+ settings=current_settings,
85
+ )
86
+ final_tools: list[str] = []
87
+ seen_tools: set[str] = set()
88
+ for tool_name in [
89
+ *MINIMUM_TOOLS,
90
+ *STEWARD_ROLE_INCLUDED_TOOLS,
91
+ *assistant_tools,
92
+ ]:
93
+ if tool_name in seen_tools:
94
+ continue
95
+ final_tools.append(tool_name)
96
+ seen_tools.add(tool_name)
97
+ return final_tools
98
+
99
+
100
+ def resolve_leader_role_name(*, settings=None) -> str:
101
+ current_settings = settings or settings_module.get_settings()
102
+ configured_role_name = current_settings.leader.role_name.strip()
103
+ if configured_role_name and find_role(current_settings, configured_role_name):
104
+ return configured_role_name
105
+ return CONDUCTOR_ROLE_NAME
106
+
107
+
108
+ def get_tab_leader_id(tab_id: str) -> str | None:
109
+ tab = workspace_store.get_tab(tab_id)
110
+ if tab is None:
111
+ return None
112
+ return tab.leader_id
113
+
114
+
115
+ def is_tab_leader(*, node_id: str, tab_id: str | None = None) -> bool:
116
+ resolved_tab_id = tab_id
117
+ if resolved_tab_id is None:
118
+ record = workspace_store.get_node_record(node_id)
119
+ if record is not None:
120
+ resolved_tab_id = record.config.tab_id
121
+ else:
122
+ live_node = registry.get(node_id)
123
+ resolved_tab_id = live_node.config.tab_id if live_node is not None else None
124
+ if not resolved_tab_id:
125
+ return False
126
+ return get_tab_leader_id(resolved_tab_id) == node_id
127
+
128
+
129
+ def _default_ports(
130
+ node_kind: WorkflowNodeKind,
131
+ ) -> tuple[list[WorkflowPort], list[WorkflowPort]]:
132
+ if node_kind == WorkflowNodeKind.TRIGGER:
133
+ return (
134
+ [],
135
+ [
136
+ WorkflowPort(
137
+ key="out",
138
+ direction=PortDirection.OUTPUT,
139
+ kind=EdgeKind.CONTROL,
140
+ multiple=True,
141
+ )
142
+ ],
143
+ )
144
+ if node_kind == WorkflowNodeKind.CODE:
145
+ return (
146
+ [
147
+ WorkflowPort(
148
+ key="in",
149
+ direction=PortDirection.INPUT,
150
+ kind=EdgeKind.CONTROL,
151
+ ),
152
+ WorkflowPort(
153
+ key="input",
154
+ direction=PortDirection.INPUT,
155
+ kind=EdgeKind.DATA,
156
+ multiple=True,
157
+ ),
158
+ ],
159
+ [
160
+ WorkflowPort(
161
+ key="out",
162
+ direction=PortDirection.OUTPUT,
163
+ kind=EdgeKind.CONTROL,
164
+ multiple=True,
165
+ ),
166
+ WorkflowPort(
167
+ key="output",
168
+ direction=PortDirection.OUTPUT,
169
+ kind=EdgeKind.DATA,
170
+ multiple=True,
171
+ ),
172
+ ],
173
+ )
174
+ if node_kind == WorkflowNodeKind.IF:
175
+ return (
176
+ [
177
+ WorkflowPort(
178
+ key="in",
179
+ direction=PortDirection.INPUT,
180
+ kind=EdgeKind.CONTROL,
181
+ ),
182
+ WorkflowPort(
183
+ key="condition",
184
+ direction=PortDirection.INPUT,
185
+ kind=EdgeKind.DATA,
186
+ ),
187
+ ],
188
+ [
189
+ WorkflowPort(
190
+ key="true",
191
+ direction=PortDirection.OUTPUT,
192
+ kind=EdgeKind.CONTROL,
193
+ multiple=True,
194
+ ),
195
+ WorkflowPort(
196
+ key="false",
197
+ direction=PortDirection.OUTPUT,
198
+ kind=EdgeKind.CONTROL,
199
+ multiple=True,
200
+ ),
201
+ ],
202
+ )
203
+ if node_kind == WorkflowNodeKind.MERGE:
204
+ return (
205
+ [
206
+ WorkflowPort(
207
+ key="in",
208
+ direction=PortDirection.INPUT,
209
+ kind=EdgeKind.CONTROL,
210
+ multiple=True,
211
+ )
212
+ ],
213
+ [
214
+ WorkflowPort(
215
+ key="out",
216
+ direction=PortDirection.OUTPUT,
217
+ kind=EdgeKind.CONTROL,
218
+ multiple=True,
219
+ )
220
+ ],
221
+ )
222
+ return (
223
+ [
224
+ WorkflowPort(
225
+ key="in",
226
+ direction=PortDirection.INPUT,
227
+ kind=EdgeKind.CONTROL,
228
+ )
229
+ ],
230
+ [
231
+ WorkflowPort(
232
+ key="out",
233
+ direction=PortDirection.OUTPUT,
234
+ kind=EdgeKind.CONTROL,
235
+ multiple=True,
236
+ )
237
+ ],
238
+ )
239
+
240
+
241
+ def build_workflow_node_definition(
242
+ *,
243
+ node_id: str,
244
+ node_kind: WorkflowNodeKind,
245
+ config: dict[str, object] | None = None,
246
+ ) -> WorkflowNodeDefinition:
247
+ inputs, outputs = _default_ports(node_kind)
248
+ return WorkflowNodeDefinition(
249
+ id=node_id,
250
+ type=node_kind,
251
+ config=deepcopy(config or {}),
252
+ inputs=inputs,
253
+ outputs=outputs,
254
+ )
255
+
256
+
257
+ def list_workflow_nodes(tab_id: str) -> list[WorkflowNodeDefinition]:
258
+ tab = workspace_store.get_tab(tab_id)
259
+ if tab is None:
260
+ return []
261
+ return list(tab.definition.nodes)
262
+
263
+
264
+ def get_workflow_node(tab_id: str, node_id: str) -> WorkflowNodeDefinition | None:
265
+ tab = workspace_store.get_tab(tab_id)
266
+ if tab is None:
267
+ return None
268
+ return tab.definition.get_node(node_id)
269
+
270
+
271
+ def _sync_runtime_positions_into_definition(tab: Tab) -> bool:
272
+ changed = False
273
+ for record in workspace_store.list_node_records(tab.id):
274
+ if is_tab_leader(node_id=record.id, tab_id=tab.id):
275
+ continue
276
+ if record.position is None:
277
+ continue
278
+ current = tab.definition.view.positions.get(record.id)
279
+ if current == record.position:
280
+ continue
281
+ tab.definition.view.positions[record.id] = record.position
282
+ changed = True
283
+ return changed
284
+
285
+
286
+ def serialize_tab_summary(tab: Tab) -> dict[str, object]:
287
+ if _sync_runtime_positions_into_definition(tab):
288
+ workspace_store.upsert_tab(tab)
289
+ return {
290
+ "id": tab.id,
291
+ "title": tab.title,
292
+ "leader_id": tab.leader_id,
293
+ "created_at": tab.created_at,
294
+ "updated_at": tab.updated_at,
295
+ "definition": tab.definition.serialize(),
296
+ "node_count": len(tab.definition.nodes),
297
+ "edge_count": len(tab.definition.edges),
298
+ }
299
+
300
+
301
+ def _build_leader_record(
302
+ *,
303
+ tab_id: str,
304
+ leader_id: str,
305
+ settings,
306
+ allow_network: bool = False,
307
+ write_dirs: list[str] | None = None,
308
+ ) -> GraphNodeRecord:
309
+ role_name = resolve_leader_role_name(settings=settings)
310
+ normalized_write_dirs = build_assistant_write_dirs(
311
+ write_dirs or [],
312
+ field_name="write_dirs",
313
+ )
314
+ return GraphNodeRecord(
315
+ id=leader_id,
316
+ config=NodeConfig(
317
+ node_type=NodeType.AGENT,
318
+ role_name=role_name,
319
+ tab_id=tab_id,
320
+ name=LEADER_NODE_NAME,
321
+ tools=build_tools_for_role(role_name, settings=settings),
322
+ write_dirs=normalized_write_dirs,
323
+ allow_network=allow_network,
324
+ ),
325
+ state=AgentState.INITIALIZING,
326
+ )
327
+
328
+
329
+ def _sync_leader_record(
330
+ *,
331
+ tab_id: str,
332
+ record: GraphNodeRecord,
333
+ settings,
334
+ ) -> bool:
335
+ role_name = resolve_leader_role_name(settings=settings)
336
+ tools = build_tools_for_role(role_name, settings=settings)
337
+ changed = False
338
+ if record.config.node_type != NodeType.AGENT:
339
+ record.config.node_type = NodeType.AGENT
340
+ changed = True
341
+ if record.config.tab_id != tab_id:
342
+ record.config.tab_id = tab_id
343
+ changed = True
344
+ if record.config.role_name != role_name:
345
+ record.config.role_name = role_name
346
+ changed = True
347
+ if record.config.name != LEADER_NODE_NAME:
348
+ record.config.name = LEADER_NODE_NAME
349
+ changed = True
350
+ if record.config.tools != tools:
351
+ record.config.tools = tools
352
+ changed = True
353
+ return changed
354
+
355
+
356
+ def _start_persisted_agent(
357
+ *,
358
+ record: GraphNodeRecord,
359
+ ) -> tuple[GraphNodeRecord | None, str | None]:
360
+ from flowent.agent import Agent
361
+
362
+ node = Agent(record.config, uuid=record.id)
363
+ registry.register(node)
364
+ node.start()
365
+ return workspace_store.get_node_record(record.id), None
366
+
367
+
368
+ def ensure_tab_leaders(*, start_nodes: bool = False) -> bool:
369
+ settings = settings_module.get_settings()
370
+ changed = False
371
+ should_start_nodes = start_nodes and bool(registry.get_all())
372
+
373
+ for tab in workspace_store.list_tabs():
374
+ tab_nodes = list_tab_nodes(tab.id)
375
+ leader_record: GraphNodeRecord | None = None
376
+
377
+ if tab.leader_id:
378
+ current_leader = workspace_store.get_node_record(tab.leader_id)
379
+ if (
380
+ current_leader is not None
381
+ and current_leader.config.tab_id == tab.id
382
+ and current_leader.state != AgentState.TERMINATED
383
+ ):
384
+ leader_record = current_leader
385
+ elif (
386
+ current_leader is not None
387
+ and current_leader.config.tab_id == tab.id
388
+ and current_leader.state == AgentState.TERMINATED
389
+ ):
390
+ workspace_store.delete_node_record(current_leader.id)
391
+ changed = True
392
+
393
+ if leader_record is None:
394
+ conductor_candidates = sorted(
395
+ (
396
+ node
397
+ for node in tab_nodes
398
+ if node.state != AgentState.TERMINATED
399
+ and node.config.role_name == CONDUCTOR_ROLE_NAME
400
+ ),
401
+ key=lambda node: (node.created_at, node.id),
402
+ )
403
+ if conductor_candidates:
404
+ leader_record = conductor_candidates[0]
405
+ else:
406
+ leader_record = _build_leader_record(
407
+ tab_id=tab.id,
408
+ leader_id=str(uuid.uuid4()),
409
+ settings=settings,
410
+ )
411
+ workspace_store.upsert_node_record(leader_record)
412
+ changed = True
413
+
414
+ if tab.leader_id != leader_record.id:
415
+ tab.leader_id = leader_record.id
416
+ workspace_store.upsert_tab(tab)
417
+ changed = True
418
+
419
+ if _sync_leader_record(tab_id=tab.id, record=leader_record, settings=settings):
420
+ workspace_store.upsert_node_record(leader_record)
421
+ changed = True
422
+
423
+ if should_start_nodes and registry.get(leader_record.id) is None:
424
+ _start_persisted_agent(record=leader_record)
425
+
426
+ return changed
427
+
428
+
429
+ def sync_assistant_role(*, reason: str) -> None:
430
+ assistant = registry.get_assistant()
431
+ if assistant is None:
432
+ return
433
+ settings = settings_module.get_settings()
434
+ assistant.config.role_name = settings.assistant.role_name
435
+ assistant.config.tools = build_assistant_tools(settings=settings)
436
+ assistant.config.write_dirs = list(settings.assistant.write_dirs)
437
+ assistant.config.allow_network = settings.assistant.allow_network
438
+ assistant._sync_system_prompt_entry()
439
+ assistant.set_state(
440
+ assistant.state,
441
+ reason,
442
+ force_emit=True,
443
+ )
444
+
445
+
446
+ def sync_tab_leaders(*, reason: str) -> None:
447
+ ensure_tab_leaders()
448
+ settings = settings_module.get_settings()
449
+ for tab in workspace_store.list_tabs():
450
+ if not tab.leader_id:
451
+ continue
452
+ record = workspace_store.get_node_record(tab.leader_id)
453
+ if record is None:
454
+ continue
455
+ if _sync_leader_record(tab_id=tab.id, record=record, settings=settings):
456
+ workspace_store.upsert_node_record(record)
457
+ live_node = registry.get(record.id)
458
+ if live_node is None:
459
+ continue
460
+ live_node.config.role_name = record.config.role_name
461
+ live_node.config.name = record.config.name
462
+ live_node.config.tools = list(record.config.tools)
463
+ live_node.config.write_dirs = list(record.config.write_dirs)
464
+ live_node.config.allow_network = record.config.allow_network
465
+ live_node._sync_system_prompt_entry()
466
+ live_node.set_state(
467
+ live_node.state,
468
+ reason,
469
+ force_emit=True,
470
+ )
471
+
472
+
473
+ def _emit_tab_updated(*, tab_id: str, agent_id: str) -> None:
474
+ tab = workspace_store.get_tab(tab_id)
475
+ if tab is None:
476
+ return
477
+ event_bus.emit(
478
+ Event(
479
+ type=EventType.TAB_UPDATED,
480
+ agent_id=agent_id,
481
+ data=serialize_tab_summary(tab),
482
+ )
483
+ )
484
+
485
+
486
+ def _start_tab_runtime(tab_id: str) -> None:
487
+ tab = workspace_store.get_tab(tab_id)
488
+ if tab is None:
489
+ return
490
+ ordered_records = sorted(
491
+ list_tab_nodes(tab_id),
492
+ key=lambda record: (
493
+ record.id != tab.leader_id,
494
+ record.created_at,
495
+ record.id,
496
+ ),
497
+ )
498
+ for record in ordered_records:
499
+ if registry.get(record.id) is not None:
500
+ continue
501
+ _start_persisted_agent(record=record)
502
+
503
+
504
+ def create_tab(
505
+ *,
506
+ title: str,
507
+ allow_network: bool = False,
508
+ write_dirs: list[str] | None = None,
509
+ ) -> Tab:
510
+ settings = settings_module.get_settings()
511
+ leader_id = str(uuid.uuid4())
512
+ tab = Tab(
513
+ id=str(uuid.uuid4()),
514
+ title=title.strip(),
515
+ leader_id=leader_id,
516
+ definition=WorkflowDefinition(),
517
+ )
518
+ workspace_store.upsert_tab(tab)
519
+ leader_record = _build_leader_record(
520
+ tab_id=tab.id,
521
+ leader_id=leader_id,
522
+ settings=settings,
523
+ allow_network=allow_network,
524
+ write_dirs=write_dirs,
525
+ )
526
+ workspace_store.upsert_node_record(leader_record)
527
+ if registry.get_all():
528
+ _start_tab_runtime(tab.id)
529
+ event_bus.emit(
530
+ Event(
531
+ type=EventType.TAB_CREATED,
532
+ agent_id="assistant",
533
+ data=serialize_tab_summary(tab),
534
+ )
535
+ )
536
+ return tab
537
+
538
+
539
+ def duplicate_tab(
540
+ *,
541
+ tab_id: str,
542
+ ) -> tuple[Tab | None, str | None]:
543
+ source_tab = workspace_store.get_tab(tab_id)
544
+ if source_tab is None:
545
+ return None, f"Tab '{tab_id}' not found"
546
+
547
+ leader_record = (
548
+ workspace_store.get_node_record(source_tab.leader_id)
549
+ if source_tab.leader_id
550
+ else None
551
+ )
552
+ allow_network = leader_record.config.allow_network if leader_record else False
553
+ write_dirs = list(leader_record.config.write_dirs) if leader_record else []
554
+ duplicated_definition = WorkflowDefinition.from_mapping(
555
+ source_tab.definition.serialize()
556
+ )
557
+ id_map: dict[str, str] = {}
558
+ duplicated_nodes: list[WorkflowNodeDefinition] = []
559
+
560
+ for node in duplicated_definition.nodes:
561
+ new_node_id = str(uuid.uuid4())
562
+ id_map[node.id] = new_node_id
563
+ duplicated_node = build_workflow_node_definition(
564
+ node_id=new_node_id,
565
+ node_kind=node.type,
566
+ config=node.config,
567
+ )
568
+ duplicated_nodes.append(duplicated_node)
569
+
570
+ duplicated_edges = [
571
+ GraphEdge(
572
+ id=str(uuid.uuid4()),
573
+ from_node_id=id_map.get(edge.from_node_id, edge.from_node_id),
574
+ from_port_key=edge.from_port_key,
575
+ to_node_id=id_map.get(edge.to_node_id, edge.to_node_id),
576
+ to_port_key=edge.to_port_key,
577
+ kind=edge.kind,
578
+ )
579
+ for edge in duplicated_definition.edges
580
+ ]
581
+ duplicated_view_positions = {
582
+ id_map.get(node_id, node_id): position
583
+ for node_id, position in duplicated_definition.view.positions.items()
584
+ if id_map.get(node_id, node_id) in id_map.values()
585
+ }
586
+
587
+ settings = settings_module.get_settings()
588
+ new_tab = Tab(
589
+ id=str(uuid.uuid4()),
590
+ title=f"{source_tab.title} Copy",
591
+ leader_id=str(uuid.uuid4()),
592
+ definition=WorkflowDefinition(
593
+ version=duplicated_definition.version,
594
+ nodes=duplicated_nodes,
595
+ edges=duplicated_edges,
596
+ view=duplicated_definition.view.__class__(
597
+ positions=duplicated_view_positions
598
+ ),
599
+ ),
600
+ )
601
+ assert new_tab.leader_id is not None
602
+ workspace_store.upsert_tab(new_tab)
603
+ workspace_store.upsert_node_record(
604
+ _build_leader_record(
605
+ tab_id=new_tab.id,
606
+ leader_id=new_tab.leader_id,
607
+ settings=settings,
608
+ allow_network=allow_network,
609
+ write_dirs=write_dirs,
610
+ )
611
+ )
612
+
613
+ for node in source_tab.definition.nodes:
614
+ if node.type != WorkflowNodeKind.AGENT:
615
+ continue
616
+ duplicated_node_id = id_map.get(node.id)
617
+ if duplicated_node_id is None:
618
+ continue
619
+ config, error = build_node_config(
620
+ role_name=str(node.config.get("role_name", "")),
621
+ tab_id=new_tab.id,
622
+ name=str(node.config["name"])
623
+ if isinstance(node.config.get("name"), str)
624
+ else None,
625
+ )
626
+ if error is not None or config is None:
627
+ return None, error or "Failed to duplicate workflow"
628
+ workspace_store.upsert_node_record(
629
+ GraphNodeRecord(
630
+ id=duplicated_node_id,
631
+ config=config,
632
+ state=AgentState.INITIALIZING,
633
+ position=duplicated_view_positions.get(duplicated_node_id),
634
+ )
635
+ )
636
+
637
+ if registry.get_all():
638
+ _start_tab_runtime(new_tab.id)
639
+ event_bus.emit(
640
+ Event(
641
+ type=EventType.TAB_CREATED,
642
+ agent_id="assistant",
643
+ data=serialize_tab_summary(new_tab),
644
+ )
645
+ )
646
+ return new_tab, None
647
+
648
+
649
+ def _is_path_within_boundary(path: str, boundary_dirs: list[str]) -> bool:
650
+ resolved_path = resolve_path(path)
651
+ return any(
652
+ resolved_path.is_relative_to(resolve_path(boundary_dir))
653
+ for boundary_dir in boundary_dirs
654
+ )
655
+
656
+
657
+ def _clamp_write_dirs_to_boundary(
658
+ write_dirs: list[str],
659
+ boundary_dirs: list[str],
660
+ ) -> list[str]:
661
+ if not boundary_dirs:
662
+ return []
663
+ return [
664
+ path for path in write_dirs if _is_path_within_boundary(path, boundary_dirs)
665
+ ]
666
+
667
+
668
+ def set_tab_permissions(
669
+ *,
670
+ tab_id: str,
671
+ allow_network: bool | None = None,
672
+ write_dirs: list[str] | None = None,
673
+ caller_allow_network: bool,
674
+ caller_write_dirs: list[str],
675
+ actor_id: str,
676
+ ) -> tuple[dict[str, object] | None, str | None]:
677
+ tab = workspace_store.get_tab(tab_id)
678
+ if tab is None:
679
+ return None, f"Tab '{tab_id}' not found"
680
+
681
+ leader_id = get_tab_leader_id(tab_id)
682
+ if not leader_id:
683
+ return None, f"Tab '{tab_id}' does not have a bound Leader"
684
+
685
+ leader_record = workspace_store.get_node_record(leader_id)
686
+ if leader_record is None:
687
+ return None, f"Leader '{leader_id}' not found"
688
+
689
+ if allow_network is not None and allow_network and not caller_allow_network:
690
+ return (
691
+ None,
692
+ "allow_network boundary exceeded: caller disallows network access",
693
+ )
694
+ if write_dirs is not None:
695
+ invalid_write_dirs = sorted(
696
+ path
697
+ for path in write_dirs
698
+ if not _is_path_within_boundary(path, caller_write_dirs)
699
+ )
700
+ if invalid_write_dirs:
701
+ return (
702
+ None,
703
+ "write_dirs boundary exceeded: " + ", ".join(invalid_write_dirs),
704
+ )
705
+
706
+ next_allow_network = (
707
+ leader_record.config.allow_network if allow_network is None else allow_network
708
+ )
709
+ next_write_dirs = (
710
+ list(leader_record.config.write_dirs)
711
+ if write_dirs is None
712
+ else list(write_dirs)
713
+ )
714
+
715
+ changed_node_ids: list[str] = []
716
+
717
+ if (
718
+ leader_record.config.allow_network != next_allow_network
719
+ or leader_record.config.write_dirs != next_write_dirs
720
+ ):
721
+ leader_record.config.allow_network = next_allow_network
722
+ leader_record.config.write_dirs = list(next_write_dirs)
723
+ workspace_store.upsert_node_record(leader_record)
724
+ changed_node_ids.append(leader_record.id)
725
+
726
+ for record in list_tab_nodes(tab_id):
727
+ if record.id == leader_id:
728
+ continue
729
+ next_node_allow_network = record.config.allow_network and next_allow_network
730
+ next_node_write_dirs = _clamp_write_dirs_to_boundary(
731
+ record.config.write_dirs,
732
+ next_write_dirs,
733
+ )
734
+ if (
735
+ record.config.allow_network == next_node_allow_network
736
+ and record.config.write_dirs == next_node_write_dirs
737
+ ):
738
+ continue
739
+ record.config.allow_network = next_node_allow_network
740
+ record.config.write_dirs = list(next_node_write_dirs)
741
+ workspace_store.upsert_node_record(record)
742
+ changed_node_ids.append(record.id)
743
+
744
+ live_node = registry.get(record.id)
745
+ if live_node is not None:
746
+ live_node.config.allow_network = next_node_allow_network
747
+ live_node.config.write_dirs = list(next_node_write_dirs)
748
+ live_node.set_state(
749
+ live_node.state,
750
+ "tab_permissions_updated",
751
+ force_emit=True,
752
+ )
753
+
754
+ live_leader = registry.get(leader_id)
755
+ if live_leader is not None:
756
+ live_leader.config.allow_network = next_allow_network
757
+ live_leader.config.write_dirs = list(next_write_dirs)
758
+ live_leader.set_state(
759
+ live_leader.state,
760
+ "tab_permissions_updated",
761
+ force_emit=True,
762
+ )
763
+
764
+ updated_tab = workspace_store.get_tab(tab_id)
765
+ if updated_tab is not None:
766
+ event_bus.emit(
767
+ Event(
768
+ type=EventType.TAB_UPDATED,
769
+ agent_id=actor_id,
770
+ data=serialize_tab_summary(updated_tab),
771
+ )
772
+ )
773
+
774
+ return (
775
+ {
776
+ "tab_id": tab_id,
777
+ "leader_id": leader_id,
778
+ "allow_network": next_allow_network,
779
+ "write_dirs": list(next_write_dirs),
780
+ "updated_node_ids": changed_node_ids,
781
+ },
782
+ None,
783
+ )
784
+
785
+
786
+ def delete_tab(
787
+ *,
788
+ tab_id: str,
789
+ timeout: float = SYSTEM_NODE_TIMEOUT,
790
+ ) -> tuple[dict[str, object] | None, str | None]:
791
+ tab = workspace_store.get_tab(tab_id)
792
+ if tab is None:
793
+ return None, f"Tab '{tab_id}' not found"
794
+
795
+ stored_nodes = list_tab_nodes(tab_id)
796
+ live_nodes = [node for node in registry.get_all() if node.config.tab_id == tab_id]
797
+
798
+ removed_node_ids = list(
799
+ dict.fromkeys(
800
+ [
801
+ *(node.id for node in stored_nodes),
802
+ *(node.uuid for node in live_nodes),
803
+ *(node.id for node in tab.definition.nodes),
804
+ ]
805
+ )
806
+ )
807
+ removed_edge_ids = [edge.id for edge in tab.definition.edges]
808
+
809
+ for node in live_nodes:
810
+ node.request_termination("tab_deleted")
811
+
812
+ lingering_node_ids: list[str] = []
813
+ for node in live_nodes:
814
+ if not node.wait_for_termination(timeout=timeout):
815
+ lingering_node_ids.append(node.uuid)
816
+
817
+ if lingering_node_ids:
818
+ return (
819
+ None,
820
+ "Failed to delete workflow because some nodes did not terminate: "
821
+ + ", ".join(node_id[:8] for node_id in lingering_node_ids),
822
+ )
823
+
824
+ workspace_store.delete_tab(tab_id)
825
+ payload = {
826
+ **tab.serialize(),
827
+ "removed_node_ids": removed_node_ids,
828
+ "removed_edge_ids": removed_edge_ids,
829
+ }
830
+ event_bus.emit(
831
+ Event(
832
+ type=EventType.TAB_DELETED,
833
+ agent_id="assistant",
834
+ data=payload,
835
+ )
836
+ )
837
+ return payload, None
838
+
839
+
840
+ def build_node_config(
841
+ *,
842
+ role_name: str,
843
+ tab_id: str,
844
+ name: str | None = None,
845
+ tools: list[str] | None = None,
846
+ write_dirs: list[str] | None = None,
847
+ allow_network: bool = False,
848
+ ) -> tuple[NodeConfig | None, str | None]:
849
+ settings = settings_module.get_settings()
850
+ role = find_role(settings, role_name.strip())
851
+ if role is None:
852
+ return None, f"Role '{role_name.strip()}' not found"
853
+
854
+ requested_tools = tools or []
855
+ if not all(isinstance(item, str) for item in requested_tools):
856
+ return None, "tools must be an array of strings"
857
+ requested_write_dirs = write_dirs or []
858
+ if not all(isinstance(item, str) for item in requested_write_dirs):
859
+ return None, "write_dirs must be an array of strings"
860
+ try:
861
+ normalized_write_dirs = build_assistant_write_dirs(
862
+ requested_write_dirs,
863
+ field_name="write_dirs",
864
+ )
865
+ except ValueError as exc:
866
+ return None, str(exc)
867
+
868
+ return (
869
+ NodeConfig(
870
+ node_type=NodeType.AGENT,
871
+ role_name=role.name,
872
+ tab_id=tab_id,
873
+ name=name.strip() if isinstance(name, str) and name.strip() else None,
874
+ tools=build_tools_for_role(
875
+ role.name,
876
+ requested_tools=requested_tools,
877
+ settings=settings,
878
+ ),
879
+ write_dirs=normalized_write_dirs,
880
+ allow_network=allow_network,
881
+ ),
882
+ None,
883
+ )
884
+
885
+
886
+ def _persist_tab(tab: Tab, *, actor_id: str) -> Tab:
887
+ workspace_store.upsert_tab(tab)
888
+ _emit_tab_updated(tab_id=tab.id, agent_id=actor_id)
889
+ return tab
890
+
891
+
892
+ def create_graph_node(
893
+ *,
894
+ tab_id: str,
895
+ node_type: WorkflowNodeKind,
896
+ config: dict[str, object] | None = None,
897
+ actor_id: str,
898
+ ) -> tuple[WorkflowNodeDefinition | None, str | None]:
899
+ tab = workspace_store.get_tab(tab_id)
900
+ if tab is None:
901
+ return None, f"Tab '{tab_id}' not found"
902
+ node_id = str(uuid.uuid4())
903
+ node = build_workflow_node_definition(
904
+ node_id=node_id,
905
+ node_kind=node_type,
906
+ config=config,
907
+ )
908
+ tab.definition.nodes.append(node)
909
+ _persist_tab(tab, actor_id=actor_id)
910
+ return node, None
911
+
912
+
913
+ def create_agent_node(
914
+ *,
915
+ role_name: str,
916
+ tab_id: str,
917
+ name: str | None = None,
918
+ tools: list[str] | None = None,
919
+ write_dirs: list[str] | None = None,
920
+ allow_network: bool = False,
921
+ creator_node_id: str | None = None,
922
+ connect_to_creator: bool | None = None,
923
+ ) -> tuple[GraphNodeRecord | None, str | None]:
924
+ del creator_node_id, connect_to_creator
925
+ tab = workspace_store.get_tab(tab_id)
926
+ if tab is None:
927
+ return None, f"Tab '{tab_id}' not found"
928
+
929
+ config, error = build_node_config(
930
+ role_name=role_name,
931
+ tab_id=tab_id,
932
+ name=name,
933
+ tools=tools,
934
+ write_dirs=write_dirs,
935
+ allow_network=allow_network,
936
+ )
937
+ if error is not None or config is None:
938
+ return None, error
939
+ if config.role_name == CONDUCTOR_ROLE_NAME:
940
+ return None, f"Role '{CONDUCTOR_ROLE_NAME}' is reserved for a workflow Leader"
941
+
942
+ node_id = str(uuid.uuid4())
943
+ record = GraphNodeRecord(
944
+ id=node_id,
945
+ config=config,
946
+ state=AgentState.INITIALIZING,
947
+ )
948
+ workspace_store.upsert_node_record(record)
949
+ tab.definition.nodes.append(
950
+ build_workflow_node_definition(
951
+ node_id=node_id,
952
+ node_kind=WorkflowNodeKind.AGENT,
953
+ config={
954
+ "role_name": config.role_name or "",
955
+ **({"name": config.name} if config.name else {}),
956
+ },
957
+ )
958
+ )
959
+ workspace_store.upsert_tab(tab)
960
+ started_record, start_error = _start_persisted_agent(record=record)
961
+ if start_error is not None or started_record is None:
962
+ return None, start_error or "Failed to create agent"
963
+ _emit_tab_updated(tab_id=tab_id, agent_id=node_id)
964
+ return started_record, None
965
+
966
+
967
+ def update_tab_definition(
968
+ *,
969
+ tab_id: str,
970
+ definition_payload: dict[str, object],
971
+ actor_id: str,
972
+ ) -> tuple[Tab | None, str | None]:
973
+ tab = workspace_store.get_tab(tab_id)
974
+ if tab is None:
975
+ return None, f"Tab '{tab_id}' not found"
976
+ next_definition = WorkflowDefinition.from_mapping(definition_payload)
977
+ node_ids = [node.id for node in next_definition.nodes]
978
+ if len(node_ids) != len(set(node_ids)):
979
+ return None, "Workflow definition contains duplicate node ids"
980
+ edge_ids = [edge.id for edge in next_definition.edges]
981
+ if len(edge_ids) != len(set(edge_ids)):
982
+ return None, "Workflow definition contains duplicate edge ids"
983
+
984
+ current_agent_ids = {
985
+ node.id for node in tab.definition.nodes if node.type == WorkflowNodeKind.AGENT
986
+ }
987
+ next_agent_ids = {
988
+ node.id for node in next_definition.nodes if node.type == WorkflowNodeKind.AGENT
989
+ }
990
+ if current_agent_ids != next_agent_ids:
991
+ return None, "Agent nodes must be created or deleted through workflow node APIs"
992
+
993
+ current_records = {
994
+ record.id: record
995
+ for record in list_tab_nodes(tab_id)
996
+ if not is_tab_leader(node_id=record.id, tab_id=tab_id)
997
+ }
998
+ for node in next_definition.nodes:
999
+ if node.type != WorkflowNodeKind.AGENT:
1000
+ continue
1001
+ role_name = node.config.get("role_name")
1002
+ if not isinstance(role_name, str) or not role_name.strip():
1003
+ return None, f"Agent node '{node.id}' requires role_name"
1004
+ record = current_records.get(node.id)
1005
+ if record is None:
1006
+ return None, f"Runtime agent '{node.id}' was not found"
1007
+ config, error = build_node_config(
1008
+ role_name=role_name,
1009
+ tab_id=tab_id,
1010
+ name=str(node.config["name"])
1011
+ if isinstance(node.config.get("name"), str)
1012
+ else None,
1013
+ write_dirs=list(record.config.write_dirs),
1014
+ allow_network=record.config.allow_network,
1015
+ )
1016
+ if error is not None or config is None:
1017
+ return None, error or f"Failed to validate agent node '{node.id}'"
1018
+ record.config.role_name = config.role_name
1019
+ record.config.name = config.name
1020
+ record.config.tools = config.tools
1021
+ workspace_store.upsert_node_record(record)
1022
+ live_node = registry.get(node.id)
1023
+ if live_node is not None:
1024
+ live_node.config.role_name = record.config.role_name
1025
+ live_node.config.name = record.config.name
1026
+ live_node.config.tools = list(record.config.tools)
1027
+ live_node._sync_system_prompt_entry()
1028
+ live_node.set_state(
1029
+ live_node.state,
1030
+ "workflow_definition_updated",
1031
+ force_emit=True,
1032
+ )
1033
+
1034
+ seen_target_ports: set[tuple[str, str]] = set()
1035
+ for edge in next_definition.edges:
1036
+ source_node = next_definition.get_node(edge.from_node_id)
1037
+ target_node = next_definition.get_node(edge.to_node_id)
1038
+ if source_node is None:
1039
+ return None, f"Edge source node '{edge.from_node_id}' does not exist"
1040
+ if target_node is None:
1041
+ return None, f"Edge target node '{edge.to_node_id}' does not exist"
1042
+ source_port = _port_matches(
1043
+ source_node.outputs,
1044
+ port_key=edge.from_port_key,
1045
+ direction=PortDirection.OUTPUT,
1046
+ kind=edge.kind,
1047
+ )
1048
+ if source_port is None:
1049
+ return None, f"Output port '{edge.from_port_key}' is invalid"
1050
+ target_port = _port_matches(
1051
+ target_node.inputs,
1052
+ port_key=edge.to_port_key,
1053
+ direction=PortDirection.INPUT,
1054
+ kind=edge.kind,
1055
+ )
1056
+ if target_port is None:
1057
+ return None, f"Input port '{edge.to_port_key}' is invalid"
1058
+ target_key = (edge.to_node_id, edge.to_port_key)
1059
+ if target_key in seen_target_ports and not target_port.multiple:
1060
+ return None, f"Input port '{edge.to_port_key}' already has an incoming edge"
1061
+ seen_target_ports.add(target_key)
1062
+
1063
+ tab.definition = next_definition
1064
+ _persist_tab(tab, actor_id=actor_id)
1065
+ return tab, None
1066
+
1067
+
1068
+ def _port_matches(
1069
+ ports: list[WorkflowPort],
1070
+ *,
1071
+ port_key: str,
1072
+ direction: PortDirection,
1073
+ kind: EdgeKind,
1074
+ ) -> WorkflowPort | None:
1075
+ return next(
1076
+ (
1077
+ port
1078
+ for port in ports
1079
+ if port.key == port_key
1080
+ and port.direction == direction
1081
+ and port.kind == kind
1082
+ ),
1083
+ None,
1084
+ )
1085
+
1086
+
1087
+ def create_edge(
1088
+ *,
1089
+ tab_id: str | None = None,
1090
+ from_node_id: str,
1091
+ to_node_id: str,
1092
+ from_port_key: str = "out",
1093
+ to_port_key: str = "in",
1094
+ kind: EdgeKind | str = EdgeKind.CONTROL,
1095
+ ) -> tuple[GraphEdge | None, str | None]:
1096
+ resolved_kind = kind if isinstance(kind, EdgeKind) else EdgeKind(str(kind))
1097
+ resolved_tab_id = tab_id
1098
+ if resolved_tab_id is None:
1099
+ source_record = workspace_store.get_node_record(from_node_id)
1100
+ target_record = workspace_store.get_node_record(to_node_id)
1101
+ if source_record is not None and source_record.config.tab_id:
1102
+ resolved_tab_id = source_record.config.tab_id
1103
+ elif target_record is not None and target_record.config.tab_id:
1104
+ resolved_tab_id = target_record.config.tab_id
1105
+ if resolved_tab_id is None:
1106
+ return None, "tab_id is required"
1107
+ tab = workspace_store.get_tab(resolved_tab_id)
1108
+ if tab is None:
1109
+ return None, f"Tab '{resolved_tab_id}' not found"
1110
+ if is_tab_leader(node_id=from_node_id, tab_id=resolved_tab_id) or is_tab_leader(
1111
+ node_id=to_node_id,
1112
+ tab_id=resolved_tab_id,
1113
+ ):
1114
+ return None, "Workflow Leader does not participate in Workflow Graph edges"
1115
+ if from_node_id == to_node_id:
1116
+ return None, "Self-loop edges are not allowed"
1117
+ source_node = tab.definition.get_node(from_node_id)
1118
+ target_node = tab.definition.get_node(to_node_id)
1119
+ if source_node is None:
1120
+ return None, f"Node '{from_node_id}' not found"
1121
+ if target_node is None:
1122
+ return None, f"Node '{to_node_id}' not found"
1123
+ source_port = _port_matches(
1124
+ source_node.outputs,
1125
+ port_key=from_port_key,
1126
+ direction=PortDirection.OUTPUT,
1127
+ kind=resolved_kind,
1128
+ )
1129
+ if source_port is None:
1130
+ return None, f"Output port '{from_port_key}' is invalid"
1131
+ target_port = _port_matches(
1132
+ target_node.inputs,
1133
+ port_key=to_port_key,
1134
+ direction=PortDirection.INPUT,
1135
+ kind=resolved_kind,
1136
+ )
1137
+ if target_port is None:
1138
+ return None, f"Input port '{to_port_key}' is invalid"
1139
+ if any(
1140
+ edge.from_node_id == from_node_id
1141
+ and edge.from_port_key == from_port_key
1142
+ and edge.to_node_id == to_node_id
1143
+ and edge.to_port_key == to_port_key
1144
+ and edge.kind == resolved_kind
1145
+ for edge in tab.definition.edges
1146
+ ):
1147
+ return None, "Duplicate edges are not allowed"
1148
+ if not target_port.multiple and any(
1149
+ edge.to_node_id == to_node_id and edge.to_port_key == to_port_key
1150
+ for edge in tab.definition.edges
1151
+ ):
1152
+ return None, f"Input port '{to_port_key}' already has an incoming edge"
1153
+
1154
+ edge = GraphEdge(
1155
+ id=str(uuid.uuid4()),
1156
+ tab_id=resolved_tab_id,
1157
+ from_node_id=from_node_id,
1158
+ from_port_key=from_port_key,
1159
+ to_node_id=to_node_id,
1160
+ to_port_key=to_port_key,
1161
+ kind=resolved_kind,
1162
+ )
1163
+ tab.definition.edges.append(edge)
1164
+ _persist_tab(tab, actor_id=from_node_id)
1165
+ return edge, None
1166
+
1167
+
1168
+ def delete_edge(
1169
+ *,
1170
+ tab_id: str,
1171
+ edge_id: str | None = None,
1172
+ from_node_id: str | None = None,
1173
+ to_node_id: str | None = None,
1174
+ from_port_key: str | None = None,
1175
+ to_port_key: str | None = None,
1176
+ ) -> tuple[dict[str, object] | None, str | None]:
1177
+ tab = workspace_store.get_tab(tab_id)
1178
+ if tab is None:
1179
+ return None, f"Tab '{tab_id}' not found"
1180
+
1181
+ matched_edge: GraphEdge | None = None
1182
+ for edge in tab.definition.edges:
1183
+ if edge_id is not None and edge.id == edge_id:
1184
+ matched_edge = edge
1185
+ break
1186
+ if (
1187
+ from_node_id is not None
1188
+ and to_node_id is not None
1189
+ and edge.from_node_id == from_node_id
1190
+ and edge.to_node_id == to_node_id
1191
+ and (from_port_key is None or edge.from_port_key == from_port_key)
1192
+ and (to_port_key is None or edge.to_port_key == to_port_key)
1193
+ ):
1194
+ matched_edge = edge
1195
+ break
1196
+ if matched_edge is None:
1197
+ return None, "Edge not found"
1198
+
1199
+ tab.definition.edges = [
1200
+ edge for edge in tab.definition.edges if edge.id != matched_edge.id
1201
+ ]
1202
+ _persist_tab(tab, actor_id=matched_edge.from_node_id)
1203
+ return matched_edge.serialize(), None
1204
+
1205
+
1206
+ def delete_agent_node(
1207
+ *,
1208
+ tab_id: str,
1209
+ node_id: str,
1210
+ timeout: float = SYSTEM_NODE_TIMEOUT,
1211
+ ) -> tuple[dict[str, object] | None, str | None]:
1212
+ tab = workspace_store.get_tab(tab_id)
1213
+ if tab is None:
1214
+ return None, f"Tab '{tab_id}' not found"
1215
+
1216
+ node_definition = tab.definition.get_node(node_id)
1217
+ if node_definition is None:
1218
+ return None, f"Node '{node_id}' not found"
1219
+ if is_tab_leader(node_id=node_id, tab_id=tab_id):
1220
+ return None, "Workflow Leader cannot be deleted from the graph"
1221
+
1222
+ related_edges = [
1223
+ edge
1224
+ for edge in tab.definition.edges
1225
+ if edge.from_node_id == node_id or edge.to_node_id == node_id
1226
+ ]
1227
+ live_node = registry.get(node_id)
1228
+ record = workspace_store.get_node_record(node_id)
1229
+
1230
+ if live_node is not None:
1231
+ live_node.request_termination("graph_deleted")
1232
+ if not live_node.wait_for_termination(timeout=timeout):
1233
+ return (
1234
+ None,
1235
+ f"Failed to delete node '{node_id}' because it did not terminate",
1236
+ )
1237
+
1238
+ if record is not None:
1239
+ workspace_store.delete_node_record(node_id)
1240
+
1241
+ tab.definition.nodes = [node for node in tab.definition.nodes if node.id != node_id]
1242
+ tab.definition.edges = [
1243
+ edge
1244
+ for edge in tab.definition.edges
1245
+ if edge.id not in {item.id for item in related_edges}
1246
+ ]
1247
+ tab.definition.view.positions.pop(node_id, None)
1248
+ workspace_store.upsert_tab(tab)
1249
+ payload: dict[str, object] = {
1250
+ "id": node_id,
1251
+ "tab_id": tab_id,
1252
+ "removed_edge_ids": [edge.id for edge in related_edges],
1253
+ }
1254
+ event_bus.emit(
1255
+ Event(
1256
+ type=EventType.NODE_DELETED,
1257
+ agent_id=node_id,
1258
+ data=payload,
1259
+ )
1260
+ )
1261
+ _emit_tab_updated(
1262
+ tab_id=tab_id,
1263
+ agent_id=node_id,
1264
+ )
1265
+ return payload, None
1266
+
1267
+
1268
+ def dispatch_node_message(
1269
+ *,
1270
+ node_id: str,
1271
+ content: str,
1272
+ parts: list | None = None,
1273
+ from_id: str = "human",
1274
+ ) -> tuple[str | None, str | None]:
1275
+ target = registry.get(node_id)
1276
+ if target is None:
1277
+ return f"Node '{node_id}' is not active", None
1278
+ message_id = str(uuid.uuid4())
1279
+ normalized_parts = list(parts or [])
1280
+ target._append_history(
1281
+ ReceivedMessage(
1282
+ from_id=from_id,
1283
+ parts=normalized_parts,
1284
+ content=content,
1285
+ message_id=message_id,
1286
+ )
1287
+ )
1288
+ target.enqueue_message(
1289
+ Message(
1290
+ from_id=from_id,
1291
+ to_id=node_id,
1292
+ parts=normalized_parts,
1293
+ content=content,
1294
+ message_id=message_id,
1295
+ history_recorded=True,
1296
+ )
1297
+ )
1298
+ return None, message_id
1299
+
1300
+
1301
+ def list_tab_nodes(tab_id: str) -> list[GraphNodeRecord]:
1302
+ return sorted(
1303
+ workspace_store.list_node_records(tab_id),
1304
+ key=lambda record: (record.created_at, record.id),
1305
+ )
1306
+
1307
+
1308
+ def list_tab_edges(tab_id: str) -> list[GraphEdge]:
1309
+ tab = workspace_store.get_tab(tab_id)
1310
+ if tab is None:
1311
+ return []
1312
+ return sorted(
1313
+ [
1314
+ GraphEdge(
1315
+ id=edge.id,
1316
+ tab_id=tab_id,
1317
+ from_node_id=edge.from_node_id,
1318
+ from_port_key=edge.from_port_key,
1319
+ to_node_id=edge.to_node_id,
1320
+ to_port_key=edge.to_port_key,
1321
+ kind=edge.kind,
1322
+ created_at=edge.created_at,
1323
+ )
1324
+ for edge in tab.definition.edges
1325
+ ],
1326
+ key=lambda edge: (edge.created_at, edge.id),
1327
+ )
1328
+
1329
+
1330
+ def list_node_connection_ids(*, tab_id: str, node_id: str) -> list[str]:
1331
+ if is_tab_leader(node_id=node_id, tab_id=tab_id):
1332
+ return []
1333
+
1334
+ connection_ids: list[str] = []
1335
+ seen_node_ids: set[str] = set()
1336
+ for edge in list_tab_edges(tab_id):
1337
+ other_node_id: str | None = None
1338
+ if edge.from_node_id == node_id:
1339
+ other_node_id = edge.to_node_id
1340
+ elif edge.to_node_id == node_id:
1341
+ other_node_id = edge.from_node_id
1342
+ if other_node_id is None or other_node_id in seen_node_ids:
1343
+ continue
1344
+ seen_node_ids.add(other_node_id)
1345
+ connection_ids.append(other_node_id)
1346
+ return connection_ids