flowent 0.0.7 → 0.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (387) hide show
  1. package/README.md +0 -3
  2. package/backend/README.md +0 -3
  3. package/backend/pyproject.toml +2 -8
  4. package/backend/src/flowent/__init__.py +6 -2
  5. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  6. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  7. package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
  13. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  14. package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
  15. package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/agent.py +213 -3173
  17. package/backend/src/flowent/cli.py +19 -24
  18. package/backend/src/flowent/context.py +127 -0
  19. package/backend/src/flowent/llm.py +256 -0
  20. package/backend/src/flowent/logging.py +170 -129
  21. package/backend/src/flowent/main.py +321 -70
  22. package/backend/src/flowent/patch.py +182 -0
  23. package/backend/src/flowent/paths.py +11 -0
  24. package/backend/src/flowent/sandbox.py +214 -40
  25. package/backend/src/flowent/static/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  26. package/backend/src/flowent/static/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  27. package/backend/src/flowent/static/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  28. package/backend/src/flowent/static/assets/index-C76K95ty.js +81 -0
  29. package/backend/src/flowent/static/assets/index-iUMNKvlU.css +2 -0
  30. package/backend/src/flowent/static/flowent.png +0 -0
  31. package/backend/src/flowent/static/index.html +5 -25
  32. package/backend/src/flowent/storage.py +302 -0
  33. package/backend/src/flowent/tools.py +376 -0
  34. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  35. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  36. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  37. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  38. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  39. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  40. package/backend/tests/test_agent_tools.py +477 -0
  41. package/backend/tests/test_health.py +12 -0
  42. package/backend/tests/test_llm_providers.py +113 -0
  43. package/backend/tests/test_logging.py +182 -0
  44. package/backend/tests/test_persistence.py +125 -0
  45. package/backend/tests/test_workspace_chat.py +578 -0
  46. package/backend/uv.lock +803 -99
  47. package/dist/frontend/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  48. package/dist/frontend/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  49. package/dist/frontend/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  50. package/dist/frontend/assets/index-C76K95ty.js +81 -0
  51. package/dist/frontend/assets/index-iUMNKvlU.css +2 -0
  52. package/dist/frontend/flowent.png +0 -0
  53. package/dist/frontend/index.html +5 -25
  54. package/package.json +1 -2
  55. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  56. package/backend/src/flowent/__pycache__/access.cpython-313.pyc +0 -0
  57. package/backend/src/flowent/__pycache__/assistant_commands.cpython-313.pyc +0 -0
  58. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  59. package/backend/src/flowent/__pycache__/config.cpython-313.pyc +0 -0
  60. package/backend/src/flowent/__pycache__/events.cpython-313.pyc +0 -0
  61. package/backend/src/flowent/__pycache__/graph_runtime.cpython-313.pyc +0 -0
  62. package/backend/src/flowent/__pycache__/graph_service.cpython-313.pyc +0 -0
  63. package/backend/src/flowent/__pycache__/image_assets.cpython-313.pyc +0 -0
  64. package/backend/src/flowent/__pycache__/model_metadata.cpython-313.pyc +0 -0
  65. package/backend/src/flowent/__pycache__/network.cpython-313.pyc +0 -0
  66. package/backend/src/flowent/__pycache__/observability_service.cpython-313.pyc +0 -0
  67. package/backend/src/flowent/__pycache__/registry.cpython-313.pyc +0 -0
  68. package/backend/src/flowent/__pycache__/role_management.cpython-313.pyc +0 -0
  69. package/backend/src/flowent/__pycache__/runtime.cpython-313.pyc +0 -0
  70. package/backend/src/flowent/__pycache__/security.cpython-313.pyc +0 -0
  71. package/backend/src/flowent/__pycache__/settings.cpython-313.pyc +0 -0
  72. package/backend/src/flowent/__pycache__/settings_management.cpython-313.pyc +0 -0
  73. package/backend/src/flowent/__pycache__/state_db.cpython-313.pyc +0 -0
  74. package/backend/src/flowent/__pycache__/workspace_store.cpython-313.pyc +0 -0
  75. package/backend/src/flowent/access.py +0 -247
  76. package/backend/src/flowent/assistant_commands.py +0 -115
  77. package/backend/src/flowent/channels/__init__.py +0 -3
  78. package/backend/src/flowent/channels/__pycache__/__init__.cpython-313.pyc +0 -0
  79. package/backend/src/flowent/channels/__pycache__/telegram.cpython-313.pyc +0 -0
  80. package/backend/src/flowent/channels/telegram.py +0 -615
  81. package/backend/src/flowent/config.py +0 -14
  82. package/backend/src/flowent/dev.py +0 -3
  83. package/backend/src/flowent/events.py +0 -157
  84. package/backend/src/flowent/graph_runtime.py +0 -60
  85. package/backend/src/flowent/graph_service.py +0 -2401
  86. package/backend/src/flowent/image_assets.py +0 -356
  87. package/backend/src/flowent/model_metadata.py +0 -102
  88. package/backend/src/flowent/models/__init__.py +0 -125
  89. package/backend/src/flowent/models/__pycache__/__init__.cpython-313.pyc +0 -0
  90. package/backend/src/flowent/models/__pycache__/agent.cpython-313.pyc +0 -0
  91. package/backend/src/flowent/models/__pycache__/base.cpython-313.pyc +0 -0
  92. package/backend/src/flowent/models/__pycache__/blueprint.cpython-313.pyc +0 -0
  93. package/backend/src/flowent/models/__pycache__/content.cpython-313.pyc +0 -0
  94. package/backend/src/flowent/models/__pycache__/delta.cpython-313.pyc +0 -0
  95. package/backend/src/flowent/models/__pycache__/event.cpython-313.pyc +0 -0
  96. package/backend/src/flowent/models/__pycache__/graph.cpython-313.pyc +0 -0
  97. package/backend/src/flowent/models/__pycache__/history.cpython-313.pyc +0 -0
  98. package/backend/src/flowent/models/__pycache__/llm.cpython-313.pyc +0 -0
  99. package/backend/src/flowent/models/__pycache__/message.cpython-313.pyc +0 -0
  100. package/backend/src/flowent/models/__pycache__/tab.cpython-313.pyc +0 -0
  101. package/backend/src/flowent/models/__pycache__/todo.cpython-313.pyc +0 -0
  102. package/backend/src/flowent/models/agent.py +0 -34
  103. package/backend/src/flowent/models/base.py +0 -24
  104. package/backend/src/flowent/models/blueprint.py +0 -176
  105. package/backend/src/flowent/models/content.py +0 -164
  106. package/backend/src/flowent/models/delta.py +0 -44
  107. package/backend/src/flowent/models/event.py +0 -51
  108. package/backend/src/flowent/models/graph.py +0 -472
  109. package/backend/src/flowent/models/history.py +0 -272
  110. package/backend/src/flowent/models/llm.py +0 -62
  111. package/backend/src/flowent/models/message.py +0 -33
  112. package/backend/src/flowent/models/tab.py +0 -85
  113. package/backend/src/flowent/models/todo.py +0 -10
  114. package/backend/src/flowent/network.py +0 -146
  115. package/backend/src/flowent/observability_service.py +0 -218
  116. package/backend/src/flowent/prompts/__init__.py +0 -67
  117. package/backend/src/flowent/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
  118. package/backend/src/flowent/prompts/__pycache__/common.cpython-313.pyc +0 -0
  119. package/backend/src/flowent/prompts/__pycache__/steward.cpython-313.pyc +0 -0
  120. package/backend/src/flowent/prompts/common.py +0 -250
  121. package/backend/src/flowent/prompts/steward.py +0 -64
  122. package/backend/src/flowent/providers/__init__.py +0 -23
  123. package/backend/src/flowent/providers/__pycache__/__init__.cpython-313.pyc +0 -0
  124. package/backend/src/flowent/providers/__pycache__/anthropic.cpython-313.pyc +0 -0
  125. package/backend/src/flowent/providers/__pycache__/base_url.cpython-313.pyc +0 -0
  126. package/backend/src/flowent/providers/__pycache__/configuration.cpython-313.pyc +0 -0
  127. package/backend/src/flowent/providers/__pycache__/content.cpython-313.pyc +0 -0
  128. package/backend/src/flowent/providers/__pycache__/errors.cpython-313.pyc +0 -0
  129. package/backend/src/flowent/providers/__pycache__/gateway.cpython-313.pyc +0 -0
  130. package/backend/src/flowent/providers/__pycache__/headers.cpython-313.pyc +0 -0
  131. package/backend/src/flowent/providers/__pycache__/management.cpython-313.pyc +0 -0
  132. package/backend/src/flowent/providers/__pycache__/openai.cpython-313.pyc +0 -0
  133. package/backend/src/flowent/providers/__pycache__/openai_responses.cpython-313.pyc +0 -0
  134. package/backend/src/flowent/providers/__pycache__/registry.cpython-313.pyc +0 -0
  135. package/backend/src/flowent/providers/__pycache__/sse.cpython-313.pyc +0 -0
  136. package/backend/src/flowent/providers/__pycache__/thinking.cpython-313.pyc +0 -0
  137. package/backend/src/flowent/providers/anthropic.py +0 -468
  138. package/backend/src/flowent/providers/base_url.py +0 -60
  139. package/backend/src/flowent/providers/configuration.py +0 -189
  140. package/backend/src/flowent/providers/content.py +0 -122
  141. package/backend/src/flowent/providers/errors.py +0 -223
  142. package/backend/src/flowent/providers/gateway.py +0 -169
  143. package/backend/src/flowent/providers/gemini.py +0 -447
  144. package/backend/src/flowent/providers/headers.py +0 -20
  145. package/backend/src/flowent/providers/management.py +0 -96
  146. package/backend/src/flowent/providers/ollama.py +0 -293
  147. package/backend/src/flowent/providers/openai.py +0 -422
  148. package/backend/src/flowent/providers/openai_responses.py +0 -655
  149. package/backend/src/flowent/providers/registry.py +0 -144
  150. package/backend/src/flowent/providers/sse.py +0 -31
  151. package/backend/src/flowent/providers/thinking.py +0 -79
  152. package/backend/src/flowent/registry.py +0 -73
  153. package/backend/src/flowent/role_management.py +0 -270
  154. package/backend/src/flowent/routes/__init__.py +0 -26
  155. package/backend/src/flowent/routes/__pycache__/__init__.cpython-313.pyc +0 -0
  156. package/backend/src/flowent/routes/__pycache__/access.cpython-313.pyc +0 -0
  157. package/backend/src/flowent/routes/__pycache__/assistant.cpython-313.pyc +0 -0
  158. package/backend/src/flowent/routes/__pycache__/image_assets.cpython-313.pyc +0 -0
  159. package/backend/src/flowent/routes/__pycache__/meta.cpython-313.pyc +0 -0
  160. package/backend/src/flowent/routes/__pycache__/nodes.cpython-313.pyc +0 -0
  161. package/backend/src/flowent/routes/__pycache__/prompts.cpython-313.pyc +0 -0
  162. package/backend/src/flowent/routes/__pycache__/providers_route.cpython-313.pyc +0 -0
  163. package/backend/src/flowent/routes/__pycache__/roles.cpython-313.pyc +0 -0
  164. package/backend/src/flowent/routes/__pycache__/settings.cpython-313.pyc +0 -0
  165. package/backend/src/flowent/routes/__pycache__/tabs.cpython-313.pyc +0 -0
  166. package/backend/src/flowent/routes/__pycache__/ws.cpython-313.pyc +0 -0
  167. package/backend/src/flowent/routes/access.py +0 -48
  168. package/backend/src/flowent/routes/assistant.py +0 -158
  169. package/backend/src/flowent/routes/image_assets.py +0 -33
  170. package/backend/src/flowent/routes/meta.py +0 -28
  171. package/backend/src/flowent/routes/nodes.py +0 -423
  172. package/backend/src/flowent/routes/prompts.py +0 -46
  173. package/backend/src/flowent/routes/providers_route.py +0 -365
  174. package/backend/src/flowent/routes/roles.py +0 -207
  175. package/backend/src/flowent/routes/settings.py +0 -379
  176. package/backend/src/flowent/routes/tabs.py +0 -298
  177. package/backend/src/flowent/routes/ws.py +0 -33
  178. package/backend/src/flowent/runtime.py +0 -160
  179. package/backend/src/flowent/security.py +0 -37
  180. package/backend/src/flowent/settings.py +0 -2112
  181. package/backend/src/flowent/settings_management.py +0 -394
  182. package/backend/src/flowent/state_db.py +0 -108
  183. package/backend/src/flowent/static/assets/AssistantPage-BW7XAd9I.js +0 -1
  184. package/backend/src/flowent/static/assets/ChannelsPage-tCJHgt6m.js +0 -1
  185. package/backend/src/flowent/static/assets/PageScaffold-f6g2l7XN.js +0 -1
  186. package/backend/src/flowent/static/assets/PromptsPage-C3Sxn2D7.js +0 -1
  187. package/backend/src/flowent/static/assets/ProvidersPage-BfmdXmNt.js +0 -3
  188. package/backend/src/flowent/static/assets/RolesPage-DET8wO4r.js +0 -1
  189. package/backend/src/flowent/static/assets/SettingsPage-D-g3deMm.js +0 -3
  190. package/backend/src/flowent/static/assets/ToolsPage-CDmtE2g4.js +0 -1
  191. package/backend/src/flowent/static/assets/WorkspacePage-AZsJ0sD0.js +0 -3
  192. package/backend/src/flowent/static/assets/WorkspacePanels-CteCjolX.js +0 -1
  193. package/backend/src/flowent/static/assets/alert-dialog-Duorp_S-.js +0 -1
  194. package/backend/src/flowent/static/assets/dialog-C3ixjGjN.js +0 -1
  195. package/backend/src/flowent/static/assets/elk-worker.min-C9JGDOE-.js +0 -6312
  196. package/backend/src/flowent/static/assets/graph-vendor-CHpVij2M.css +0 -1
  197. package/backend/src/flowent/static/assets/graph-vendor-DRq_-6fV.js +0 -7
  198. package/backend/src/flowent/static/assets/index--o_0fv0N.css +0 -1
  199. package/backend/src/flowent/static/assets/index-C9HuekJm.js +0 -10
  200. package/backend/src/flowent/static/assets/layout.worker-jMHqAFbP.js +0 -24
  201. package/backend/src/flowent/static/assets/markdown-vendor-C9RtvaJh.js +0 -29
  202. package/backend/src/flowent/static/assets/modelParams-DmnF2hwR.js +0 -1
  203. package/backend/src/flowent/static/assets/providerTypes-DT3Ahwl_.js +0 -1
  204. package/backend/src/flowent/static/assets/react-vendor-mEs_JJxa.js +0 -9
  205. package/backend/src/flowent/static/assets/roles-CuRT_chR.js +0 -1
  206. package/backend/src/flowent/static/assets/rolldown-runtime-BYbx6iT9.js +0 -1
  207. package/backend/src/flowent/static/assets/select-DCfeNu-F.js +0 -1
  208. package/backend/src/flowent/static/assets/surface-pWwG5ogx.js +0 -1
  209. package/backend/src/flowent/static/assets/ui-vendor-C5pJa8N7.js +0 -51
  210. package/backend/src/flowent/static/assets/useAppRoute-FgSHBKhV.js +0 -1
  211. package/backend/src/flowent/static/favicon.svg +0 -4
  212. package/backend/src/flowent/tools/__init__.py +0 -176
  213. package/backend/src/flowent/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  214. package/backend/src/flowent/tools/__pycache__/connect.cpython-313.pyc +0 -0
  215. package/backend/src/flowent/tools/__pycache__/contacts.cpython-313.pyc +0 -0
  216. package/backend/src/flowent/tools/__pycache__/create_agent.cpython-313.pyc +0 -0
  217. package/backend/src/flowent/tools/__pycache__/create_tab.cpython-313.pyc +0 -0
  218. package/backend/src/flowent/tools/__pycache__/delete_tab.cpython-313.pyc +0 -0
  219. package/backend/src/flowent/tools/__pycache__/edit.cpython-313.pyc +0 -0
  220. package/backend/src/flowent/tools/__pycache__/exec.cpython-313.pyc +0 -0
  221. package/backend/src/flowent/tools/__pycache__/fetch.cpython-313.pyc +0 -0
  222. package/backend/src/flowent/tools/__pycache__/idle.cpython-313.pyc +0 -0
  223. package/backend/src/flowent/tools/__pycache__/list_roles.cpython-313.pyc +0 -0
  224. package/backend/src/flowent/tools/__pycache__/list_tabs.cpython-313.pyc +0 -0
  225. package/backend/src/flowent/tools/__pycache__/list_tools.cpython-313.pyc +0 -0
  226. package/backend/src/flowent/tools/__pycache__/manage_prompts.cpython-313.pyc +0 -0
  227. package/backend/src/flowent/tools/__pycache__/manage_providers.cpython-313.pyc +0 -0
  228. package/backend/src/flowent/tools/__pycache__/manage_roles.cpython-313.pyc +0 -0
  229. package/backend/src/flowent/tools/__pycache__/manage_settings.cpython-313.pyc +0 -0
  230. package/backend/src/flowent/tools/__pycache__/read.cpython-313.pyc +0 -0
  231. package/backend/src/flowent/tools/__pycache__/send.cpython-313.pyc +0 -0
  232. package/backend/src/flowent/tools/__pycache__/set_permissions.cpython-313.pyc +0 -0
  233. package/backend/src/flowent/tools/__pycache__/sleep.cpython-313.pyc +0 -0
  234. package/backend/src/flowent/tools/__pycache__/todo.cpython-313.pyc +0 -0
  235. package/backend/src/flowent/tools/connect.py +0 -100
  236. package/backend/src/flowent/tools/contacts.py +0 -22
  237. package/backend/src/flowent/tools/create_agent.py +0 -191
  238. package/backend/src/flowent/tools/create_tab.py +0 -61
  239. package/backend/src/flowent/tools/delete_tab.py +0 -39
  240. package/backend/src/flowent/tools/edit.py +0 -142
  241. package/backend/src/flowent/tools/exec.py +0 -118
  242. package/backend/src/flowent/tools/fetch.py +0 -85
  243. package/backend/src/flowent/tools/idle.py +0 -27
  244. package/backend/src/flowent/tools/list_roles.py +0 -68
  245. package/backend/src/flowent/tools/list_tabs.py +0 -100
  246. package/backend/src/flowent/tools/list_tools.py +0 -28
  247. package/backend/src/flowent/tools/manage_prompts.py +0 -102
  248. package/backend/src/flowent/tools/manage_providers.py +0 -220
  249. package/backend/src/flowent/tools/manage_roles.py +0 -275
  250. package/backend/src/flowent/tools/manage_settings.py +0 -326
  251. package/backend/src/flowent/tools/read.py +0 -152
  252. package/backend/src/flowent/tools/send.py +0 -68
  253. package/backend/src/flowent/tools/set_permissions.py +0 -99
  254. package/backend/src/flowent/tools/sleep.py +0 -41
  255. package/backend/src/flowent/tools/todo.py +0 -51
  256. package/backend/src/flowent/workspace_store.py +0 -479
  257. package/backend/tests/__init__.py +0 -0
  258. package/backend/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  259. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  260. package/backend/tests/conftest.py +0 -6
  261. package/backend/tests/integration/api/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  262. package/backend/tests/integration/api/__pycache__/test_access_api.cpython-313-pytest-9.0.3.pyc +0 -0
  263. package/backend/tests/integration/api/__pycache__/test_assistant_api.cpython-313-pytest-9.0.3.pyc +0 -0
  264. package/backend/tests/integration/api/__pycache__/test_frontend_mounting.cpython-313-pytest-9.0.3.pyc +0 -0
  265. package/backend/tests/integration/api/__pycache__/test_meta_api.cpython-313-pytest-9.0.3.pyc +0 -0
  266. package/backend/tests/integration/api/__pycache__/test_nodes_api.cpython-313-pytest-9.0.3.pyc +0 -0
  267. package/backend/tests/integration/api/__pycache__/test_prompts_api.cpython-313-pytest-9.0.3.pyc +0 -0
  268. package/backend/tests/integration/api/__pycache__/test_roles_api.cpython-313-pytest-9.0.3.pyc +0 -0
  269. package/backend/tests/integration/api/__pycache__/test_tabs_api.cpython-313-pytest-9.0.3.pyc +0 -0
  270. package/backend/tests/integration/api/conftest.py +0 -29
  271. package/backend/tests/integration/api/test_access_api.py +0 -182
  272. package/backend/tests/integration/api/test_assistant_api.py +0 -422
  273. package/backend/tests/integration/api/test_frontend_mounting.py +0 -61
  274. package/backend/tests/integration/api/test_meta_api.py +0 -32
  275. package/backend/tests/integration/api/test_nodes_api.py +0 -787
  276. package/backend/tests/integration/api/test_prompts_api.py +0 -47
  277. package/backend/tests/integration/api/test_roles_api.py +0 -228
  278. package/backend/tests/integration/api/test_tabs_api.py +0 -688
  279. package/backend/tests/unit/__pycache__/test_access.cpython-313-pytest-9.0.3.pyc +0 -0
  280. package/backend/tests/unit/__pycache__/test_cli.cpython-313-pytest-9.0.3.pyc +0 -0
  281. package/backend/tests/unit/__pycache__/test_graph_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  282. package/backend/tests/unit/__pycache__/test_network.cpython-313-pytest-9.0.3.pyc +0 -0
  283. package/backend/tests/unit/__pycache__/test_state_sqlite_storage.cpython-313-pytest-9.0.3.pyc +0 -0
  284. package/backend/tests/unit/__pycache__/test_workspace_store.cpython-313-pytest-9.0.3.pyc +0 -0
  285. package/backend/tests/unit/agent/__pycache__/test_agent_public_api.cpython-313-pytest-9.0.3.pyc +0 -0
  286. package/backend/tests/unit/agent/__pycache__/test_agent_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  287. package/backend/tests/unit/agent/test_agent_public_api.py +0 -822
  288. package/backend/tests/unit/agent/test_agent_runtime.py +0 -3088
  289. package/backend/tests/unit/channels/__pycache__/test_telegram_channel.cpython-313-pytest-9.0.3.pyc +0 -0
  290. package/backend/tests/unit/channels/test_telegram_channel.py +0 -552
  291. package/backend/tests/unit/logging/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  292. package/backend/tests/unit/logging/test_logging.py +0 -132
  293. package/backend/tests/unit/prompts/__pycache__/test_prompts.cpython-313-pytest-9.0.3.pyc +0 -0
  294. package/backend/tests/unit/prompts/test_prompts.py +0 -570
  295. package/backend/tests/unit/providers/__pycache__/test_anthropic_provider.cpython-313-pytest-9.0.3.pyc +0 -0
  296. package/backend/tests/unit/providers/__pycache__/test_errors.cpython-313-pytest-9.0.3.pyc +0 -0
  297. package/backend/tests/unit/providers/__pycache__/test_extract_delta_parts.cpython-313-pytest-9.0.3.pyc +0 -0
  298. package/backend/tests/unit/providers/__pycache__/test_openai_provider.cpython-313-pytest-9.0.3.pyc +0 -0
  299. package/backend/tests/unit/providers/__pycache__/test_openai_responses.cpython-313-pytest-9.0.3.pyc +0 -0
  300. package/backend/tests/unit/providers/__pycache__/test_provider_gateway.cpython-313-pytest-9.0.3.pyc +0 -0
  301. package/backend/tests/unit/providers/__pycache__/test_think_tag_parser.cpython-313-pytest-9.0.3.pyc +0 -0
  302. package/backend/tests/unit/providers/test_anthropic_provider.py +0 -185
  303. package/backend/tests/unit/providers/test_errors.py +0 -68
  304. package/backend/tests/unit/providers/test_extract_delta_parts.py +0 -22
  305. package/backend/tests/unit/providers/test_openai_provider.py +0 -139
  306. package/backend/tests/unit/providers/test_openai_responses.py +0 -402
  307. package/backend/tests/unit/providers/test_provider_gateway.py +0 -359
  308. package/backend/tests/unit/providers/test_think_tag_parser.py +0 -36
  309. package/backend/tests/unit/routes/__pycache__/test_prompts_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  310. package/backend/tests/unit/routes/__pycache__/test_providers_route.cpython-313-pytest-9.0.3.pyc +0 -0
  311. package/backend/tests/unit/routes/__pycache__/test_roles_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  312. package/backend/tests/unit/routes/__pycache__/test_settings_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  313. package/backend/tests/unit/routes/test_prompts_routes.py +0 -82
  314. package/backend/tests/unit/routes/test_providers_route.py +0 -370
  315. package/backend/tests/unit/routes/test_roles_routes.py +0 -539
  316. package/backend/tests/unit/routes/test_settings_routes.py +0 -1123
  317. package/backend/tests/unit/runtime/__pycache__/test_bootstrap_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  318. package/backend/tests/unit/runtime/test_bootstrap_runtime.py +0 -1002
  319. package/backend/tests/unit/sandbox/__pycache__/test_sandbox_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  320. package/backend/tests/unit/sandbox/test_sandbox_tools.py +0 -78
  321. package/backend/tests/unit/security/__pycache__/test_security.cpython-313-pytest-9.0.3.pyc +0 -0
  322. package/backend/tests/unit/security/test_security.py +0 -124
  323. package/backend/tests/unit/settings/__pycache__/test_settings_roles.cpython-313-pytest-9.0.3.pyc +0 -0
  324. package/backend/tests/unit/settings/test_settings_roles.py +0 -703
  325. package/backend/tests/unit/test_access.py +0 -45
  326. package/backend/tests/unit/test_cli.py +0 -102
  327. package/backend/tests/unit/test_graph_runtime.py +0 -72
  328. package/backend/tests/unit/test_network.py +0 -51
  329. package/backend/tests/unit/test_state_sqlite_storage.py +0 -87
  330. package/backend/tests/unit/test_workspace_store.py +0 -228
  331. package/backend/tests/unit/tools/__pycache__/test_connect_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  332. package/backend/tests/unit/tools/__pycache__/test_create_agent_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  333. package/backend/tests/unit/tools/__pycache__/test_delete_tab_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  334. package/backend/tests/unit/tools/__pycache__/test_edit_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  335. package/backend/tests/unit/tools/__pycache__/test_exec_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  336. package/backend/tests/unit/tools/__pycache__/test_fetch_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  337. package/backend/tests/unit/tools/__pycache__/test_manage_prompts_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  338. package/backend/tests/unit/tools/__pycache__/test_manage_providers_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  339. package/backend/tests/unit/tools/__pycache__/test_manage_roles_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  340. package/backend/tests/unit/tools/__pycache__/test_manage_settings_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  341. package/backend/tests/unit/tools/__pycache__/test_read_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  342. package/backend/tests/unit/tools/__pycache__/test_set_permissions_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  343. package/backend/tests/unit/tools/__pycache__/test_todo_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  344. package/backend/tests/unit/tools/__pycache__/test_tool_registry.cpython-313-pytest-9.0.3.pyc +0 -0
  345. package/backend/tests/unit/tools/test_connect_tool.py +0 -228
  346. package/backend/tests/unit/tools/test_create_agent_tool.py +0 -404
  347. package/backend/tests/unit/tools/test_delete_tab_tool.py +0 -116
  348. package/backend/tests/unit/tools/test_edit_tool.py +0 -115
  349. package/backend/tests/unit/tools/test_exec_tool.py +0 -81
  350. package/backend/tests/unit/tools/test_fetch_tool.py +0 -65
  351. package/backend/tests/unit/tools/test_manage_prompts_tool.py +0 -92
  352. package/backend/tests/unit/tools/test_manage_providers_tool.py +0 -460
  353. package/backend/tests/unit/tools/test_manage_roles_tool.py +0 -411
  354. package/backend/tests/unit/tools/test_manage_settings_tool.py +0 -611
  355. package/backend/tests/unit/tools/test_read_tool.py +0 -33
  356. package/backend/tests/unit/tools/test_set_permissions_tool.py +0 -595
  357. package/backend/tests/unit/tools/test_todo_tool.py +0 -37
  358. package/backend/tests/unit/tools/test_tool_registry.py +0 -199
  359. package/dist/frontend/assets/AssistantPage-BW7XAd9I.js +0 -1
  360. package/dist/frontend/assets/ChannelsPage-tCJHgt6m.js +0 -1
  361. package/dist/frontend/assets/PageScaffold-f6g2l7XN.js +0 -1
  362. package/dist/frontend/assets/PromptsPage-C3Sxn2D7.js +0 -1
  363. package/dist/frontend/assets/ProvidersPage-BfmdXmNt.js +0 -3
  364. package/dist/frontend/assets/RolesPage-DET8wO4r.js +0 -1
  365. package/dist/frontend/assets/SettingsPage-D-g3deMm.js +0 -3
  366. package/dist/frontend/assets/ToolsPage-CDmtE2g4.js +0 -1
  367. package/dist/frontend/assets/WorkspacePage-AZsJ0sD0.js +0 -3
  368. package/dist/frontend/assets/WorkspacePanels-CteCjolX.js +0 -1
  369. package/dist/frontend/assets/alert-dialog-Duorp_S-.js +0 -1
  370. package/dist/frontend/assets/dialog-C3ixjGjN.js +0 -1
  371. package/dist/frontend/assets/elk-worker.min-C9JGDOE-.js +0 -6312
  372. package/dist/frontend/assets/graph-vendor-CHpVij2M.css +0 -1
  373. package/dist/frontend/assets/graph-vendor-DRq_-6fV.js +0 -7
  374. package/dist/frontend/assets/index--o_0fv0N.css +0 -1
  375. package/dist/frontend/assets/index-C9HuekJm.js +0 -10
  376. package/dist/frontend/assets/layout.worker-jMHqAFbP.js +0 -24
  377. package/dist/frontend/assets/markdown-vendor-C9RtvaJh.js +0 -29
  378. package/dist/frontend/assets/modelParams-DmnF2hwR.js +0 -1
  379. package/dist/frontend/assets/providerTypes-DT3Ahwl_.js +0 -1
  380. package/dist/frontend/assets/react-vendor-mEs_JJxa.js +0 -9
  381. package/dist/frontend/assets/roles-CuRT_chR.js +0 -1
  382. package/dist/frontend/assets/rolldown-runtime-BYbx6iT9.js +0 -1
  383. package/dist/frontend/assets/select-DCfeNu-F.js +0 -1
  384. package/dist/frontend/assets/surface-pWwG5ogx.js +0 -1
  385. package/dist/frontend/assets/ui-vendor-C5pJa8N7.js +0 -51
  386. package/dist/frontend/assets/useAppRoute-FgSHBKhV.js +0 -1
  387. package/dist/frontend/favicon.svg +0 -4
@@ -1,2401 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import ast
4
- import json
5
- import shutil
6
- import subprocess
7
- import uuid
8
- from copy import deepcopy
9
- from dataclasses import dataclass
10
- from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
11
-
12
- from flowent import settings as settings_module
13
- from flowent.events import event_bus
14
- from flowent.models import (
15
- AgentState,
16
- EdgeKind,
17
- Event,
18
- EventType,
19
- GraphEdge,
20
- GraphNodeRecord,
21
- Message,
22
- NodeConfig,
23
- NodeType,
24
- PortDirection,
25
- PortInboundEntry,
26
- PortType,
27
- ReceivedMessage,
28
- Tab,
29
- WorkflowActivationState,
30
- WorkflowDefinition,
31
- WorkflowNodeDefinition,
32
- WorkflowNodeKind,
33
- WorkflowPort,
34
- content_parts_to_text,
35
- deserialize_content_parts,
36
- )
37
- from flowent.registry import registry
38
- from flowent.runtime import SYSTEM_NODE_TIMEOUT
39
- from flowent.settings import (
40
- CONDUCTOR_ROLE_INCLUDED_TOOLS,
41
- CONDUCTOR_ROLE_NAME,
42
- DESIGNER_ROLE_INCLUDED_TOOLS,
43
- DESIGNER_ROLE_NAME,
44
- STEWARD_ROLE_INCLUDED_TOOLS,
45
- STEWARD_ROLE_NAME,
46
- build_assistant_write_dirs,
47
- find_provider,
48
- find_role,
49
- resolve_model_info,
50
- resolve_path,
51
- )
52
- from flowent.tools import (
53
- MINIMUM_TOOLS,
54
- is_assistant_only_tool_name,
55
- )
56
- from flowent.workspace_store import workspace_store
57
-
58
- LEADER_NODE_NAME = "Leader"
59
-
60
-
61
- @dataclass(frozen=True)
62
- class ContactPath:
63
- target_id: str
64
- target_node_type: str
65
- target_role_name: str | None
66
- target_name: str | None
67
- target_state: str | None
68
- is_leader: bool
69
- from_output_port_key: str
70
- to_input_port_key: str
71
- port_type: str
72
- edge_id: str
73
-
74
- def serialize(self) -> dict[str, object]:
75
- return {
76
- "id": self.target_id,
77
- "target_id": self.target_id,
78
- "node_type": self.target_node_type,
79
- "role_name": self.target_role_name,
80
- "name": self.target_name,
81
- "state": self.target_state,
82
- "is_leader": self.is_leader,
83
- "from_output_port_key": self.from_output_port_key,
84
- "to_input_port_key": self.to_input_port_key,
85
- "port_type": self.port_type,
86
- "edge_id": self.edge_id,
87
- }
88
-
89
-
90
- def build_tools_for_role(
91
- role_name: str,
92
- *,
93
- requested_tools: list[str] | None = None,
94
- settings=None,
95
- assistant_boundary: bool = False,
96
- ) -> list[str]:
97
- current_settings = settings or settings_module.get_settings()
98
- normalized_role_name = role_name.strip()
99
- role = find_role(current_settings, normalized_role_name)
100
- if role is None:
101
- if normalized_role_name == CONDUCTOR_ROLE_NAME:
102
- included_tools = list(CONDUCTOR_ROLE_INCLUDED_TOOLS)
103
- elif normalized_role_name == DESIGNER_ROLE_NAME:
104
- included_tools = list(DESIGNER_ROLE_INCLUDED_TOOLS)
105
- elif normalized_role_name == STEWARD_ROLE_NAME:
106
- included_tools = list(STEWARD_ROLE_INCLUDED_TOOLS)
107
- else:
108
- included_tools = []
109
- excluded_tools: set[str] = set()
110
- else:
111
- included_tools = list(role.included_tools)
112
- excluded_tools = set(role.excluded_tools)
113
-
114
- final_tools: list[str] = []
115
- seen_tools: set[str] = set()
116
- for tool_name in [*MINIMUM_TOOLS, *included_tools, *(requested_tools or [])]:
117
- if tool_name in seen_tools:
118
- continue
119
- if tool_name.startswith("mcp__"):
120
- continue
121
- if not assistant_boundary and is_assistant_only_tool_name(tool_name):
122
- continue
123
- if tool_name in excluded_tools and tool_name not in MINIMUM_TOOLS:
124
- continue
125
- final_tools.append(tool_name)
126
- seen_tools.add(tool_name)
127
- return final_tools
128
-
129
-
130
- def build_assistant_tools(*, settings=None) -> list[str]:
131
- current_settings = settings or settings_module.get_settings()
132
- assistant_tools = build_tools_for_role(
133
- current_settings.assistant.role_name,
134
- settings=current_settings,
135
- assistant_boundary=True,
136
- )
137
- final_tools: list[str] = []
138
- seen_tools: set[str] = set()
139
- for tool_name in [
140
- *MINIMUM_TOOLS,
141
- *STEWARD_ROLE_INCLUDED_TOOLS,
142
- *assistant_tools,
143
- ]:
144
- if tool_name in seen_tools:
145
- continue
146
- final_tools.append(tool_name)
147
- seen_tools.add(tool_name)
148
- return final_tools
149
-
150
-
151
- def resolve_leader_role_name(*, settings=None) -> str:
152
- current_settings = settings or settings_module.get_settings()
153
- configured_role_name = current_settings.leader.role_name.strip()
154
- if configured_role_name and find_role(current_settings, configured_role_name):
155
- return configured_role_name
156
- return CONDUCTOR_ROLE_NAME
157
-
158
-
159
- def get_tab_leader_id(tab_id: str) -> str | None:
160
- tab = workspace_store.get_tab(tab_id)
161
- if tab is None:
162
- return None
163
- return tab.leader_id
164
-
165
-
166
- def is_tab_leader(*, node_id: str, tab_id: str | None = None) -> bool:
167
- resolved_tab_id = tab_id
168
- if resolved_tab_id is None:
169
- record = workspace_store.get_node_record(node_id)
170
- if record is not None:
171
- resolved_tab_id = record.config.tab_id
172
- else:
173
- live_node = registry.get(node_id)
174
- resolved_tab_id = live_node.config.tab_id if live_node is not None else None
175
- if not resolved_tab_id:
176
- return False
177
- return get_tab_leader_id(resolved_tab_id) == node_id
178
-
179
-
180
- def _coerce_port_type(raw_value: object, default: PortType) -> PortType:
181
- try:
182
- return PortType(str(raw_value))
183
- except ValueError:
184
- return default
185
-
186
-
187
- def _port_from_code_config(
188
- raw_port: object,
189
- *,
190
- direction: PortDirection,
191
- ) -> WorkflowPort | None:
192
- if not isinstance(raw_port, dict):
193
- return None
194
- key = raw_port.get("key")
195
- if not isinstance(key, str) or not key.strip():
196
- return None
197
- try:
198
- port_type = PortType(str(raw_port.get("type")))
199
- except ValueError:
200
- return None
201
- return WorkflowPort(
202
- key=key.strip(),
203
- direction=direction,
204
- type=port_type,
205
- required=bool(raw_port.get("required", direction == PortDirection.INPUT)),
206
- multiple=bool(raw_port.get("multiple", False)),
207
- )
208
-
209
-
210
- def _build_code_ports(
211
- config: dict[str, object],
212
- ) -> tuple[list[WorkflowPort], list[WorkflowPort]]:
213
- raw_inputs = config.get("inputs")
214
- raw_outputs = config.get("outputs")
215
- inputs = [
216
- port
217
- for port in (
218
- _port_from_code_config(item, direction=PortDirection.INPUT)
219
- for item in (raw_inputs if isinstance(raw_inputs, list) else [])
220
- )
221
- if port is not None
222
- ]
223
- outputs = [
224
- port
225
- for port in (
226
- _port_from_code_config(item, direction=PortDirection.OUTPUT)
227
- for item in (raw_outputs if isinstance(raw_outputs, list) else [])
228
- )
229
- if port is not None
230
- ]
231
- if not inputs:
232
- inputs = [
233
- WorkflowPort(
234
- key="in",
235
- direction=PortDirection.INPUT,
236
- type=PortType.PARTS,
237
- required=True,
238
- )
239
- ]
240
- if not outputs:
241
- outputs = [
242
- WorkflowPort(
243
- key="out",
244
- direction=PortDirection.OUTPUT,
245
- type=PortType.PARTS,
246
- multiple=True,
247
- )
248
- ]
249
- return inputs, outputs
250
-
251
-
252
- def _default_ports(
253
- node_kind: WorkflowNodeKind,
254
- config: dict[str, object] | None = None,
255
- ) -> tuple[list[WorkflowPort], list[WorkflowPort]]:
256
- node_config = config or {}
257
- if node_kind == WorkflowNodeKind.TRIGGER:
258
- output_type = _coerce_port_type(node_config.get("output_type"), PortType.PARTS)
259
- return (
260
- [],
261
- [
262
- WorkflowPort(
263
- key="out",
264
- direction=PortDirection.OUTPUT,
265
- type=output_type,
266
- multiple=True,
267
- )
268
- ],
269
- )
270
- if node_kind == WorkflowNodeKind.LLM:
271
- input_type = _coerce_port_type(node_config.get("input_type"), PortType.PARTS)
272
- output_type = _coerce_port_type(node_config.get("output_type"), PortType.PARTS)
273
- return (
274
- [
275
- WorkflowPort(
276
- key="in",
277
- direction=PortDirection.INPUT,
278
- type=input_type,
279
- required=True,
280
- )
281
- ],
282
- [
283
- WorkflowPort(
284
- key="out",
285
- direction=PortDirection.OUTPUT,
286
- type=output_type,
287
- multiple=True,
288
- )
289
- ],
290
- )
291
- if node_kind == WorkflowNodeKind.CODE:
292
- return _build_code_ports(node_config)
293
- if node_kind == WorkflowNodeKind.IF:
294
- input_type = _coerce_port_type(node_config.get("input_type"), PortType.PARTS)
295
- return (
296
- [
297
- WorkflowPort(
298
- key="in",
299
- direction=PortDirection.INPUT,
300
- type=input_type,
301
- required=True,
302
- ),
303
- ],
304
- [
305
- WorkflowPort(
306
- key="then",
307
- direction=PortDirection.OUTPUT,
308
- type=input_type,
309
- multiple=True,
310
- ),
311
- WorkflowPort(
312
- key="else",
313
- direction=PortDirection.OUTPUT,
314
- type=input_type,
315
- multiple=True,
316
- ),
317
- ],
318
- )
319
- if node_kind == WorkflowNodeKind.MERGE:
320
- input_type = _coerce_port_type(node_config.get("input_type"), PortType.PARTS)
321
- strategy = node_config.get("strategy")
322
- output_type = (
323
- input_type
324
- if strategy == "first_completed"
325
- else _coerce_port_type(node_config.get("output_type"), PortType.JSON)
326
- )
327
- return (
328
- [
329
- WorkflowPort(
330
- key="in",
331
- direction=PortDirection.INPUT,
332
- type=input_type,
333
- required=True,
334
- multiple=True,
335
- )
336
- ],
337
- [
338
- WorkflowPort(
339
- key="out",
340
- direction=PortDirection.OUTPUT,
341
- type=output_type,
342
- multiple=True,
343
- )
344
- ],
345
- )
346
- if node_kind == WorkflowNodeKind.AGENT:
347
- return (
348
- [
349
- WorkflowPort(
350
- key="in",
351
- direction=PortDirection.INPUT,
352
- type=PortType.PARTS,
353
- required=False,
354
- )
355
- ],
356
- [
357
- WorkflowPort(
358
- key="out",
359
- direction=PortDirection.OUTPUT,
360
- type=PortType.PARTS,
361
- multiple=True,
362
- )
363
- ],
364
- )
365
- return (
366
- [
367
- WorkflowPort(
368
- key="in",
369
- direction=PortDirection.INPUT,
370
- type=PortType.PARTS,
371
- required=True,
372
- )
373
- ],
374
- [
375
- WorkflowPort(
376
- key="out",
377
- direction=PortDirection.OUTPUT,
378
- type=PortType.PARTS,
379
- multiple=True,
380
- )
381
- ],
382
- )
383
-
384
-
385
- def build_workflow_node_definition(
386
- *,
387
- node_id: str,
388
- node_kind: WorkflowNodeKind,
389
- config: dict[str, object] | None = None,
390
- ) -> WorkflowNodeDefinition:
391
- inputs, outputs = _default_ports(node_kind, config)
392
- return WorkflowNodeDefinition(
393
- id=node_id,
394
- type=node_kind,
395
- config=deepcopy(config or {}),
396
- inputs=inputs,
397
- outputs=outputs,
398
- )
399
-
400
-
401
- def list_workflow_nodes(tab_id: str) -> list[WorkflowNodeDefinition]:
402
- tab = workspace_store.get_tab(tab_id)
403
- if tab is None:
404
- return []
405
- return list(tab.definition.nodes)
406
-
407
-
408
- def get_workflow_node(tab_id: str, node_id: str) -> WorkflowNodeDefinition | None:
409
- tab = workspace_store.get_tab(tab_id)
410
- if tab is None:
411
- return None
412
- return tab.definition.get_node(node_id)
413
-
414
-
415
- def get_workflow_node_definition(node_id: str) -> WorkflowNodeDefinition | None:
416
- record = workspace_store.get_node_record(node_id)
417
- if record is not None and record.config.tab_id:
418
- return get_workflow_node(record.config.tab_id, node_id)
419
- live_node = registry.get(node_id)
420
- if live_node is not None and live_node.config.tab_id:
421
- return get_workflow_node(live_node.config.tab_id, node_id)
422
- for tab in workspace_store.list_tabs():
423
- node = tab.definition.get_node(node_id)
424
- if node is not None:
425
- return node
426
- return None
427
-
428
-
429
- def resolve_workflow_node_ref(*, tab_id: str, node_ref: str) -> str | None:
430
- target = registry.get(node_ref)
431
- if target is not None and target.config.tab_id == tab_id:
432
- return target.uuid
433
-
434
- tab = workspace_store.get_tab(tab_id)
435
- if tab is None:
436
- return None
437
-
438
- definition_nodes = list(tab.definition.nodes)
439
- exact_match = next((node for node in definition_nodes if node.id == node_ref), None)
440
- if exact_match is not None:
441
- return exact_match.id
442
-
443
- named_matches = [
444
- node
445
- for node in definition_nodes
446
- if isinstance(node.config.get("name"), str) and node.config["name"] == node_ref
447
- ]
448
- if len(named_matches) == 1:
449
- return named_matches[0].id
450
-
451
- role_matches = [
452
- node
453
- for node in definition_nodes
454
- if node.type == WorkflowNodeKind.AGENT
455
- and isinstance(node.config.get("role_name"), str)
456
- and node.config["role_name"] == node_ref
457
- ]
458
- if len(role_matches) == 1:
459
- return role_matches[0].id
460
-
461
- if 4 <= len(node_ref) < 36:
462
- prefix_matches = [
463
- node for node in definition_nodes if node.id.startswith(node_ref)
464
- ]
465
- if len(prefix_matches) == 1:
466
- return prefix_matches[0].id
467
- return None
468
-
469
-
470
- def _resolve_contact_target_metadata(
471
- *,
472
- tab_id: str,
473
- node_id: str,
474
- ) -> tuple[str, str | None, str | None, str | None, bool]:
475
- live_node = registry.get(node_id)
476
- if live_node is not None:
477
- return (
478
- live_node.config.node_type.value,
479
- live_node.config.role_name,
480
- live_node.config.name,
481
- live_node.state.value,
482
- is_tab_leader(node_id=node_id, tab_id=tab_id),
483
- )
484
-
485
- record = workspace_store.get_node_record(node_id)
486
- if record is not None:
487
- return (
488
- record.config.node_type.value,
489
- record.config.role_name,
490
- record.config.name,
491
- record.state.value,
492
- is_tab_leader(node_id=node_id, tab_id=tab_id),
493
- )
494
-
495
- definition = get_workflow_node(tab_id, node_id)
496
- if definition is None:
497
- return ("agent", None, None, None, False)
498
- return (
499
- definition.type.value,
500
- str(definition.config["role_name"])
501
- if isinstance(definition.config.get("role_name"), str)
502
- else None,
503
- str(definition.config["name"])
504
- if isinstance(definition.config.get("name"), str)
505
- else None,
506
- None,
507
- False,
508
- )
509
-
510
-
511
- def list_agent_contact_paths(*, tab_id: str, node_id: str) -> list[ContactPath]:
512
- if is_tab_leader(node_id=node_id, tab_id=tab_id):
513
- return []
514
- tab = workspace_store.get_tab(tab_id)
515
- if tab is None:
516
- return []
517
-
518
- paths: list[ContactPath] = []
519
- source_node = tab.definition.get_node(node_id)
520
- if source_node is None or source_node.type != WorkflowNodeKind.AGENT:
521
- return paths
522
-
523
- for edge in sorted(
524
- tab.definition.edges, key=lambda item: (item.created_at, item.id)
525
- ):
526
- if edge.from_node_id != node_id:
527
- continue
528
- source_port = _port_matches(
529
- source_node.outputs,
530
- port_key=edge.from_port_key,
531
- direction=PortDirection.OUTPUT,
532
- )
533
- target_node = tab.definition.get_node(edge.to_node_id)
534
- if source_port is None or target_node is None:
535
- continue
536
- target_port = _port_matches(
537
- target_node.inputs,
538
- port_key=edge.to_port_key,
539
- direction=PortDirection.INPUT,
540
- )
541
- if target_port is None or source_port.type != target_port.type:
542
- continue
543
- node_type, role_name, name, state, is_leader_contact = (
544
- _resolve_contact_target_metadata(tab_id=tab_id, node_id=edge.to_node_id)
545
- )
546
- paths.append(
547
- ContactPath(
548
- target_id=edge.to_node_id,
549
- target_node_type=node_type,
550
- target_role_name=role_name,
551
- target_name=name,
552
- target_state=state,
553
- is_leader=is_leader_contact,
554
- from_output_port_key=edge.from_port_key,
555
- to_input_port_key=edge.to_port_key,
556
- port_type=source_port.type.value,
557
- edge_id=edge.id,
558
- )
559
- )
560
- return paths
561
-
562
-
563
- def resolve_agent_contact_path(
564
- *,
565
- tab_id: str,
566
- source_node_id: str,
567
- target_node_id: str,
568
- from_output_port_key: str,
569
- to_input_port_key: str,
570
- ) -> ContactPath | None:
571
- return next(
572
- (
573
- path
574
- for path in list_agent_contact_paths(tab_id=tab_id, node_id=source_node_id)
575
- if path.target_id == target_node_id
576
- and path.from_output_port_key == from_output_port_key
577
- and path.to_input_port_key == to_input_port_key
578
- ),
579
- None,
580
- )
581
-
582
-
583
- def _summarize_port_value(
584
- value: object, *, port_type: PortType, limit: int = 240
585
- ) -> str:
586
- if port_type == PortType.PARTS:
587
- text = content_parts_to_text(
588
- deserialize_content_parts(value if isinstance(value, list) else None)
589
- )
590
- elif isinstance(value, str):
591
- text = value
592
- else:
593
- try:
594
- text = json.dumps(value, ensure_ascii=False, sort_keys=True)
595
- except TypeError:
596
- text = str(value)
597
- text = " ".join(text.split())
598
- if len(text) <= limit:
599
- return text
600
- return text[: limit - 3].rstrip() + "..."
601
-
602
-
603
- def _node_label_snapshot(tab_id: str, node_id: str) -> str | None:
604
- _, role_name, name, _, is_leader_contact = _resolve_contact_target_metadata(
605
- tab_id=tab_id,
606
- node_id=node_id,
607
- )
608
- if name:
609
- return name
610
- if is_leader_contact:
611
- return LEADER_NODE_NAME
612
- if role_name:
613
- return role_name
614
- return node_id[:8]
615
-
616
-
617
- def dispatch_port_value(
618
- *,
619
- tab_id: str,
620
- source_node_id: str,
621
- source_output_port_key: str,
622
- target_node_id: str,
623
- target_input_port_key: str,
624
- value: object,
625
- source_is_agent_send: bool = False,
626
- message_id: str | None = None,
627
- ) -> tuple[dict[str, object] | None, str | None]:
628
- tab = workspace_store.get_tab(tab_id)
629
- if tab is None:
630
- return None, f"Tab '{tab_id}' not found"
631
- source_node = tab.definition.get_node(source_node_id)
632
- target_node = tab.definition.get_node(target_node_id)
633
- if source_node is None:
634
- return None, f"Source node '{source_node_id}' not found"
635
- if target_node is None:
636
- return None, f"Target node '{target_node_id}' not found"
637
- source_port = _port_matches(
638
- source_node.outputs,
639
- port_key=source_output_port_key,
640
- direction=PortDirection.OUTPUT,
641
- )
642
- if source_port is None:
643
- return None, f"Output port '{source_output_port_key}' is invalid"
644
- target_port = _port_matches(
645
- target_node.inputs,
646
- port_key=target_input_port_key,
647
- direction=PortDirection.INPUT,
648
- )
649
- if target_port is None:
650
- return None, f"Input port '{target_input_port_key}' is invalid"
651
- if source_port.type != target_port.type:
652
- return (
653
- None,
654
- f"Port type mismatch: '{source_node.id}.{source_port.key}' is {source_port.type.value} "
655
- f"but '{target_node.id}.{target_port.key}' is {target_port.type.value}",
656
- )
657
- if not _validate_typed_value(value, source_port.type):
658
- return None, f"Value must match {source_port.type.value} port type"
659
-
660
- edge = next(
661
- (
662
- item
663
- for item in tab.definition.edges
664
- if item.from_node_id == source_node_id
665
- and item.to_node_id == target_node_id
666
- and item.from_port_key == source_output_port_key
667
- and item.to_port_key == target_input_port_key
668
- ),
669
- None,
670
- )
671
- if edge is None:
672
- return None, "Send path is not connected"
673
-
674
- value_summary = _summarize_port_value(value, port_type=source_port.type)
675
- payload: dict[str, object] = {
676
- "status": "sent",
677
- "target_id": target_node_id,
678
- "from_output_port_key": source_output_port_key,
679
- "to_input_port_key": target_input_port_key,
680
- "port_type": source_port.type.value,
681
- "value_summary": value_summary,
682
- }
683
-
684
- target = registry.get(target_node_id)
685
- if target is not None and target_node.type == WorkflowNodeKind.AGENT:
686
- if source_port.type == PortType.PARTS:
687
- parts = deserialize_content_parts(value)
688
- if source_is_agent_send:
689
- target._append_history(
690
- ReceivedMessage(
691
- from_id=source_node_id,
692
- parts=parts,
693
- message_id=message_id,
694
- from_output_port_key=source_output_port_key,
695
- to_input_port_key=target_input_port_key,
696
- value_summary=value_summary,
697
- )
698
- )
699
- target.enqueue_message(
700
- Message(
701
- from_id=source_node_id,
702
- to_id=target_node_id,
703
- parts=parts,
704
- message_id=message_id,
705
- history_recorded=True,
706
- from_output_port_key=source_output_port_key,
707
- to_input_port_key=target_input_port_key,
708
- port_type=source_port.type.value,
709
- value=value,
710
- value_summary=value_summary,
711
- )
712
- )
713
- else:
714
- target._append_history(
715
- PortInboundEntry(
716
- from_id=source_node_id,
717
- from_output_port_key=source_output_port_key,
718
- to_input_port_key=target_input_port_key,
719
- port_type=source_port.type.value,
720
- value=value,
721
- source_label=_node_label_snapshot(tab_id, source_node_id),
722
- value_summary=value_summary,
723
- )
724
- )
725
- target.enqueue_message(
726
- Message(
727
- from_id=source_node_id,
728
- to_id=target_node_id,
729
- parts=parts,
730
- message_id=message_id,
731
- history_recorded=True,
732
- from_output_port_key=source_output_port_key,
733
- to_input_port_key=target_input_port_key,
734
- port_type=source_port.type.value,
735
- value=value,
736
- value_summary=value_summary,
737
- port_inbound_recorded=True,
738
- )
739
- )
740
- else:
741
- target._append_history(
742
- PortInboundEntry(
743
- from_id=source_node_id,
744
- from_output_port_key=source_output_port_key,
745
- to_input_port_key=target_input_port_key,
746
- port_type=source_port.type.value,
747
- value=value,
748
- source_label=_node_label_snapshot(tab_id, source_node_id),
749
- value_summary=value_summary,
750
- )
751
- )
752
- target.enqueue_message(
753
- Message(
754
- from_id=source_node_id,
755
- to_id=target_node_id,
756
- content=value_summary,
757
- message_id=message_id,
758
- history_recorded=True,
759
- from_output_port_key=source_output_port_key,
760
- to_input_port_key=target_input_port_key,
761
- port_type=source_port.type.value,
762
- value=value,
763
- value_summary=value_summary,
764
- port_inbound_recorded=True,
765
- )
766
- )
767
-
768
- event_bus.emit(
769
- Event(
770
- type=EventType.NODE_MESSAGE,
771
- agent_id=source_node_id,
772
- data={
773
- "to_id": target_node_id,
774
- "content": value_summary,
775
- "message_id": message_id,
776
- "from_output_port_key": source_output_port_key,
777
- "to_input_port_key": target_input_port_key,
778
- "port_type": source_port.type.value,
779
- },
780
- ),
781
- )
782
- return payload, None
783
-
784
-
785
- def _sync_runtime_positions_into_definition(tab: Tab) -> bool:
786
- changed = False
787
- for record in workspace_store.list_node_records(tab.id):
788
- if is_tab_leader(node_id=record.id, tab_id=tab.id):
789
- continue
790
- if record.position is None:
791
- continue
792
- current = tab.definition.view.positions.get(record.id)
793
- if current == record.position:
794
- continue
795
- tab.definition.view.positions[record.id] = record.position
796
- changed = True
797
- return changed
798
-
799
-
800
- def serialize_tab_summary(tab: Tab) -> dict[str, object]:
801
- if _sync_runtime_positions_into_definition(tab):
802
- workspace_store.upsert_tab(tab)
803
- return {
804
- "id": tab.id,
805
- "title": tab.title,
806
- "leader_id": tab.leader_id,
807
- "activation_state": tab.activation_state.value,
808
- "allow_network": tab.allow_network,
809
- "write_dirs": list(tab.write_dirs),
810
- "created_at": tab.created_at,
811
- "updated_at": tab.updated_at,
812
- "definition": tab.definition.serialize(),
813
- "node_count": len(tab.definition.nodes),
814
- "edge_count": len(tab.definition.edges),
815
- }
816
-
817
-
818
- def _build_leader_record(
819
- *,
820
- tab_id: str,
821
- leader_id: str,
822
- settings,
823
- ) -> GraphNodeRecord:
824
- role_name = resolve_leader_role_name(settings=settings)
825
- return GraphNodeRecord(
826
- id=leader_id,
827
- config=NodeConfig(
828
- node_type=NodeType.AGENT,
829
- role_name=role_name,
830
- tab_id=tab_id,
831
- name=LEADER_NODE_NAME,
832
- tools=build_tools_for_role(role_name, settings=settings),
833
- ),
834
- state=AgentState.INITIALIZING,
835
- )
836
-
837
-
838
- def _sync_tab_permissions_from_legacy_leader(tab: Tab) -> bool:
839
- if tab.permissions_initialized:
840
- return False
841
- tab.permissions_initialized = True
842
- if not tab.leader_id:
843
- workspace_store.upsert_tab(tab)
844
- return False
845
- record = workspace_store.get_node_record(tab.leader_id)
846
- if record is None:
847
- workspace_store.upsert_tab(tab)
848
- return False
849
- tab.allow_network = record.config.allow_network
850
- tab.write_dirs = list(record.config.write_dirs)
851
- workspace_store.upsert_tab(tab)
852
- return True
853
-
854
-
855
- def _sync_leader_record(
856
- *,
857
- tab_id: str,
858
- record: GraphNodeRecord,
859
- settings,
860
- ) -> bool:
861
- role_name = resolve_leader_role_name(settings=settings)
862
- tools = build_tools_for_role(role_name, settings=settings)
863
- changed = False
864
- if record.config.node_type != NodeType.AGENT:
865
- record.config.node_type = NodeType.AGENT
866
- changed = True
867
- if record.config.tab_id != tab_id:
868
- record.config.tab_id = tab_id
869
- changed = True
870
- if record.config.role_name != role_name:
871
- record.config.role_name = role_name
872
- changed = True
873
- if record.config.name != LEADER_NODE_NAME:
874
- record.config.name = LEADER_NODE_NAME
875
- changed = True
876
- if record.config.tools != tools:
877
- record.config.tools = tools
878
- changed = True
879
- return changed
880
-
881
-
882
- def _start_persisted_agent(
883
- *,
884
- record: GraphNodeRecord,
885
- ) -> tuple[GraphNodeRecord | None, str | None]:
886
- from flowent.agent import Agent
887
-
888
- allow_network, write_dirs = resolve_effective_permissions_for_node_record(record)
889
- node = Agent(
890
- NodeConfig(
891
- node_type=record.config.node_type,
892
- role_name=record.config.role_name,
893
- tab_id=record.config.tab_id,
894
- name=record.config.name,
895
- tools=list(record.config.tools),
896
- write_dirs=write_dirs,
897
- allow_network=allow_network,
898
- ),
899
- uuid=record.id,
900
- )
901
- registry.register(node)
902
- node.start()
903
- return workspace_store.get_node_record(record.id), None
904
-
905
-
906
- def ensure_tab_leaders(*, start_nodes: bool = False) -> bool:
907
- settings = settings_module.get_settings()
908
- changed = False
909
- should_start_nodes = start_nodes and bool(registry.get_all())
910
-
911
- for tab in workspace_store.list_tabs():
912
- tab_nodes = list_tab_nodes(tab.id)
913
- leader_record: GraphNodeRecord | None = None
914
-
915
- if tab.leader_id:
916
- current_leader = workspace_store.get_node_record(tab.leader_id)
917
- if (
918
- current_leader is not None
919
- and current_leader.config.tab_id == tab.id
920
- and current_leader.state != AgentState.TERMINATED
921
- ):
922
- leader_record = current_leader
923
- elif (
924
- current_leader is not None
925
- and current_leader.config.tab_id == tab.id
926
- and current_leader.state == AgentState.TERMINATED
927
- ):
928
- workspace_store.delete_node_record(current_leader.id)
929
- changed = True
930
-
931
- if leader_record is None:
932
- conductor_candidates = sorted(
933
- (
934
- node
935
- for node in tab_nodes
936
- if node.state != AgentState.TERMINATED
937
- and node.config.role_name == CONDUCTOR_ROLE_NAME
938
- ),
939
- key=lambda node: (node.created_at, node.id),
940
- )
941
- if conductor_candidates:
942
- leader_record = conductor_candidates[0]
943
- else:
944
- leader_record = _build_leader_record(
945
- tab_id=tab.id,
946
- leader_id=str(uuid.uuid4()),
947
- settings=settings,
948
- )
949
- workspace_store.upsert_node_record(leader_record)
950
- changed = True
951
-
952
- if tab.leader_id != leader_record.id:
953
- tab.leader_id = leader_record.id
954
- workspace_store.upsert_tab(tab)
955
- changed = True
956
-
957
- if _sync_tab_permissions_from_legacy_leader(tab):
958
- changed = True
959
-
960
- if _sync_leader_record(tab_id=tab.id, record=leader_record, settings=settings):
961
- workspace_store.upsert_node_record(leader_record)
962
- changed = True
963
-
964
- if should_start_nodes and registry.get(leader_record.id) is None:
965
- _start_persisted_agent(record=leader_record)
966
-
967
- return changed
968
-
969
-
970
- def sync_assistant_role(*, reason: str) -> None:
971
- assistant = registry.get_assistant()
972
- if assistant is None:
973
- return
974
- settings = settings_module.get_settings()
975
- assistant.config.role_name = settings.assistant.role_name
976
- assistant.config.tools = build_assistant_tools(settings=settings)
977
- assistant.config.write_dirs = list(settings.assistant.write_dirs)
978
- assistant.config.allow_network = settings.assistant.allow_network
979
- assistant._sync_system_prompt_entry()
980
- assistant.set_state(
981
- assistant.state,
982
- reason,
983
- force_emit=True,
984
- )
985
-
986
-
987
- def sync_tab_leaders(*, reason: str) -> None:
988
- ensure_tab_leaders()
989
- settings = settings_module.get_settings()
990
- for tab in workspace_store.list_tabs():
991
- if not tab.leader_id:
992
- continue
993
- record = workspace_store.get_node_record(tab.leader_id)
994
- if record is None:
995
- continue
996
- if _sync_leader_record(tab_id=tab.id, record=record, settings=settings):
997
- workspace_store.upsert_node_record(record)
998
- live_node = registry.get(record.id)
999
- if live_node is None:
1000
- continue
1001
- live_node.config.role_name = record.config.role_name
1002
- live_node.config.name = record.config.name
1003
- live_node.config.tools = list(record.config.tools)
1004
- live_node._sync_system_prompt_entry()
1005
- live_node.set_state(
1006
- live_node.state,
1007
- reason,
1008
- force_emit=True,
1009
- )
1010
-
1011
-
1012
- def _emit_tab_updated(*, tab_id: str, agent_id: str) -> None:
1013
- tab = workspace_store.get_tab(tab_id)
1014
- if tab is None:
1015
- return
1016
- event_bus.emit(
1017
- Event(
1018
- type=EventType.TAB_UPDATED,
1019
- agent_id=agent_id,
1020
- data=serialize_tab_summary(tab),
1021
- )
1022
- )
1023
-
1024
-
1025
- def _start_tab_runtime(tab_id: str) -> None:
1026
- tab = workspace_store.get_tab(tab_id)
1027
- if tab is None:
1028
- return
1029
- ordered_records = sorted(
1030
- list_tab_nodes(tab_id),
1031
- key=lambda record: (
1032
- record.id != tab.leader_id,
1033
- record.created_at,
1034
- record.id,
1035
- ),
1036
- )
1037
- for record in ordered_records:
1038
- if registry.get(record.id) is not None:
1039
- continue
1040
- _start_persisted_agent(record=record)
1041
-
1042
-
1043
- def create_tab(
1044
- *,
1045
- title: str,
1046
- allow_network: bool = False,
1047
- write_dirs: list[str] | None = None,
1048
- ) -> Tab:
1049
- settings = settings_module.get_settings()
1050
- leader_id = str(uuid.uuid4())
1051
- tab = Tab(
1052
- id=str(uuid.uuid4()),
1053
- title=title.strip(),
1054
- leader_id=leader_id,
1055
- definition=WorkflowDefinition(),
1056
- allow_network=allow_network,
1057
- write_dirs=build_assistant_write_dirs(
1058
- write_dirs or [],
1059
- field_name="write_dirs",
1060
- ),
1061
- permissions_initialized=True,
1062
- )
1063
- workspace_store.upsert_tab(tab)
1064
- leader_record = _build_leader_record(
1065
- tab_id=tab.id,
1066
- leader_id=leader_id,
1067
- settings=settings,
1068
- )
1069
- workspace_store.upsert_node_record(leader_record)
1070
- if registry.get_all():
1071
- _start_tab_runtime(tab.id)
1072
- event_bus.emit(
1073
- Event(
1074
- type=EventType.TAB_CREATED,
1075
- agent_id="assistant",
1076
- data=serialize_tab_summary(tab),
1077
- )
1078
- )
1079
- return tab
1080
-
1081
-
1082
- def _is_path_within_boundary(path: str, boundary_dirs: list[str]) -> bool:
1083
- resolved_path = resolve_path(path)
1084
- return any(
1085
- resolved_path.is_relative_to(resolve_path(boundary_dir))
1086
- for boundary_dir in boundary_dirs
1087
- )
1088
-
1089
-
1090
- def resolve_effective_permissions_for_agent(agent) -> tuple[bool, list[str]]:
1091
- if agent.config.node_type == NodeType.ASSISTANT:
1092
- settings = settings_module.get_settings()
1093
- return settings.assistant.allow_network, list(settings.assistant.write_dirs)
1094
- if agent.config.tab_id:
1095
- tab = workspace_store.get_tab(agent.config.tab_id)
1096
- if tab is not None:
1097
- _sync_tab_permissions_from_legacy_leader(tab)
1098
- return tab.allow_network, list(tab.write_dirs)
1099
- return agent.config.allow_network, list(agent.config.write_dirs)
1100
-
1101
-
1102
- def resolve_effective_permissions_for_node_record(
1103
- record: GraphNodeRecord,
1104
- ) -> tuple[bool, list[str]]:
1105
- if record.config.node_type == NodeType.ASSISTANT:
1106
- settings = settings_module.get_settings()
1107
- return settings.assistant.allow_network, list(settings.assistant.write_dirs)
1108
- if record.config.tab_id:
1109
- tab = workspace_store.get_tab(record.config.tab_id)
1110
- if tab is not None:
1111
- _sync_tab_permissions_from_legacy_leader(tab)
1112
- return tab.allow_network, list(tab.write_dirs)
1113
- return record.config.allow_network, list(record.config.write_dirs)
1114
-
1115
-
1116
- def set_tab_permissions(
1117
- *,
1118
- tab_id: str,
1119
- allow_network: bool | None = None,
1120
- write_dirs: list[str] | None = None,
1121
- caller_allow_network: bool,
1122
- caller_write_dirs: list[str],
1123
- actor_id: str,
1124
- ) -> tuple[dict[str, object] | None, str | None]:
1125
- tab = workspace_store.get_tab(tab_id)
1126
- if tab is None:
1127
- return None, f"Tab '{tab_id}' not found"
1128
- _sync_tab_permissions_from_legacy_leader(tab)
1129
- if _is_active(tab):
1130
- return None, _active_edit_error("permissions")
1131
-
1132
- leader_id = get_tab_leader_id(tab_id)
1133
- if not leader_id:
1134
- return None, f"Tab '{tab_id}' does not have a bound Leader"
1135
-
1136
- leader_record = workspace_store.get_node_record(leader_id)
1137
- if leader_record is None:
1138
- return None, f"Leader '{leader_id}' not found"
1139
-
1140
- if allow_network is not None and allow_network and not caller_allow_network:
1141
- return (
1142
- None,
1143
- "allow_network boundary exceeded: caller disallows network access",
1144
- )
1145
- if write_dirs is not None:
1146
- invalid_write_dirs = sorted(
1147
- path
1148
- for path in write_dirs
1149
- if not _is_path_within_boundary(path, caller_write_dirs)
1150
- )
1151
- if invalid_write_dirs:
1152
- return (
1153
- None,
1154
- "write_dirs boundary exceeded: " + ", ".join(invalid_write_dirs),
1155
- )
1156
-
1157
- next_allow_network = tab.allow_network if allow_network is None else allow_network
1158
- next_write_dirs = list(tab.write_dirs) if write_dirs is None else list(write_dirs)
1159
-
1160
- changed_node_ids: list[str] = []
1161
- if tab.allow_network != next_allow_network or tab.write_dirs != next_write_dirs:
1162
- tab.allow_network = next_allow_network
1163
- tab.write_dirs = list(next_write_dirs)
1164
- workspace_store.upsert_tab(tab)
1165
- changed_node_ids = [
1166
- record.id for record in list_tab_nodes(tab_id) if record.id != leader_id
1167
- ]
1168
- if leader_record.id:
1169
- changed_node_ids.insert(0, leader_record.id)
1170
-
1171
- for node_id in changed_node_ids:
1172
- live_node = registry.get(node_id)
1173
- if live_node is not None:
1174
- live_node.set_state(
1175
- live_node.state,
1176
- "tab_permissions_updated",
1177
- force_emit=True,
1178
- )
1179
-
1180
- updated_tab = workspace_store.get_tab(tab_id)
1181
- if updated_tab is not None:
1182
- event_bus.emit(
1183
- Event(
1184
- type=EventType.TAB_UPDATED,
1185
- agent_id=actor_id,
1186
- data=serialize_tab_summary(updated_tab),
1187
- )
1188
- )
1189
-
1190
- return (
1191
- {
1192
- "tab_id": tab_id,
1193
- "leader_id": leader_id,
1194
- "allow_network": next_allow_network,
1195
- "write_dirs": list(next_write_dirs),
1196
- "updated_node_ids": changed_node_ids,
1197
- },
1198
- None,
1199
- )
1200
-
1201
-
1202
- def delete_tab(
1203
- *,
1204
- tab_id: str,
1205
- timeout: float = SYSTEM_NODE_TIMEOUT,
1206
- ) -> tuple[dict[str, object] | None, str | None]:
1207
- tab = workspace_store.get_tab(tab_id)
1208
- if tab is None:
1209
- return None, f"Tab '{tab_id}' not found"
1210
- if _is_active(tab):
1211
- _, deactivate_error = deactivate_tab(
1212
- tab_id=tab_id,
1213
- actor_id="assistant",
1214
- timeout=timeout,
1215
- )
1216
- if deactivate_error is not None:
1217
- return None, deactivate_error
1218
- tab = workspace_store.get_tab(tab_id)
1219
- if tab is None:
1220
- return None, f"Tab '{tab_id}' not found"
1221
-
1222
- stored_nodes = list_tab_nodes(tab_id)
1223
- live_nodes = [node for node in registry.get_all() if node.config.tab_id == tab_id]
1224
-
1225
- removed_node_ids = list(
1226
- dict.fromkeys(
1227
- [
1228
- *(node.id for node in stored_nodes),
1229
- *(node.uuid for node in live_nodes),
1230
- *(node.id for node in tab.definition.nodes),
1231
- ]
1232
- )
1233
- )
1234
- removed_edge_ids = [edge.id for edge in tab.definition.edges]
1235
-
1236
- for node in live_nodes:
1237
- node.request_termination("tab_deleted")
1238
-
1239
- lingering_node_ids: list[str] = []
1240
- for node in live_nodes:
1241
- if not node.wait_for_termination(timeout=timeout):
1242
- lingering_node_ids.append(node.uuid)
1243
-
1244
- if lingering_node_ids:
1245
- return (
1246
- None,
1247
- "Failed to delete workflow because some nodes did not terminate: "
1248
- + ", ".join(node_id[:8] for node_id in lingering_node_ids),
1249
- )
1250
-
1251
- workspace_store.delete_tab(tab_id)
1252
- payload = {
1253
- **tab.serialize(),
1254
- "removed_node_ids": removed_node_ids,
1255
- "removed_edge_ids": removed_edge_ids,
1256
- }
1257
- event_bus.emit(
1258
- Event(
1259
- type=EventType.TAB_DELETED,
1260
- agent_id="assistant",
1261
- data=payload,
1262
- )
1263
- )
1264
- return payload, None
1265
-
1266
-
1267
- def build_node_config(
1268
- *,
1269
- role_name: str,
1270
- tab_id: str,
1271
- name: str | None = None,
1272
- tools: list[str] | None = None,
1273
- ) -> tuple[NodeConfig | None, str | None]:
1274
- settings = settings_module.get_settings()
1275
- role = find_role(settings, role_name.strip())
1276
- if role is None:
1277
- return None, f"Role '{role_name.strip()}' not found"
1278
-
1279
- requested_tools = tools or []
1280
- if not all(isinstance(item, str) for item in requested_tools):
1281
- return None, "tools must be an array of strings"
1282
-
1283
- return (
1284
- NodeConfig(
1285
- node_type=NodeType.AGENT,
1286
- role_name=role.name,
1287
- tab_id=tab_id,
1288
- name=name.strip() if isinstance(name, str) and name.strip() else None,
1289
- tools=build_tools_for_role(
1290
- role.name,
1291
- requested_tools=requested_tools,
1292
- settings=settings,
1293
- ),
1294
- ),
1295
- None,
1296
- )
1297
-
1298
-
1299
- def _persist_tab(tab: Tab, *, actor_id: str) -> Tab:
1300
- workspace_store.upsert_tab(tab)
1301
- _emit_tab_updated(tab_id=tab.id, agent_id=actor_id)
1302
- return tab
1303
-
1304
-
1305
- def _is_active(tab: Tab) -> bool:
1306
- return tab.activation_state == WorkflowActivationState.ACTIVE
1307
-
1308
-
1309
- def _active_edit_error(noun: str) -> str:
1310
- return f"Workflow is active; deactivate it before changing {noun}"
1311
-
1312
-
1313
- def create_graph_node(
1314
- *,
1315
- tab_id: str,
1316
- node_type: WorkflowNodeKind,
1317
- config: dict[str, object] | None = None,
1318
- actor_id: str,
1319
- ) -> tuple[WorkflowNodeDefinition | None, str | None]:
1320
- tab = workspace_store.get_tab(tab_id)
1321
- if tab is None:
1322
- return None, f"Tab '{tab_id}' not found"
1323
- if _is_active(tab):
1324
- return None, _active_edit_error("nodes")
1325
- node_id = str(uuid.uuid4())
1326
- node = build_workflow_node_definition(
1327
- node_id=node_id,
1328
- node_kind=node_type,
1329
- config=config,
1330
- )
1331
- tab.definition.nodes.append(node)
1332
- _persist_tab(tab, actor_id=actor_id)
1333
- return node, None
1334
-
1335
-
1336
- def create_agent_node(
1337
- *,
1338
- role_name: str,
1339
- tab_id: str,
1340
- name: str | None = None,
1341
- tools: list[str] | None = None,
1342
- creator_node_id: str | None = None,
1343
- connect_to_creator: bool | None = None,
1344
- ) -> tuple[GraphNodeRecord | None, str | None]:
1345
- del creator_node_id, connect_to_creator
1346
- tab = workspace_store.get_tab(tab_id)
1347
- if tab is None:
1348
- return None, f"Tab '{tab_id}' not found"
1349
- if _is_active(tab):
1350
- return None, _active_edit_error("nodes")
1351
-
1352
- config, error = build_node_config(
1353
- role_name=role_name,
1354
- tab_id=tab_id,
1355
- name=name,
1356
- tools=tools,
1357
- )
1358
- if error is not None or config is None:
1359
- return None, error
1360
- if config.role_name == CONDUCTOR_ROLE_NAME:
1361
- return None, f"Role '{CONDUCTOR_ROLE_NAME}' is reserved for a workflow Leader"
1362
-
1363
- node_id = str(uuid.uuid4())
1364
- record = GraphNodeRecord(
1365
- id=node_id,
1366
- config=config,
1367
- state=AgentState.INITIALIZING,
1368
- )
1369
- workspace_store.upsert_node_record(record)
1370
- tab.definition.nodes.append(
1371
- build_workflow_node_definition(
1372
- node_id=node_id,
1373
- node_kind=WorkflowNodeKind.AGENT,
1374
- config={
1375
- "role_name": config.role_name or "",
1376
- **({"name": config.name} if config.name else {}),
1377
- },
1378
- )
1379
- )
1380
- workspace_store.upsert_tab(tab)
1381
- started_record, start_error = _start_persisted_agent(record=record)
1382
- if start_error is not None or started_record is None:
1383
- return None, start_error or "Failed to create agent"
1384
- _emit_tab_updated(tab_id=tab_id, agent_id=node_id)
1385
- return started_record, None
1386
-
1387
-
1388
- def update_tab_definition(
1389
- *,
1390
- tab_id: str,
1391
- definition_payload: dict[str, object],
1392
- actor_id: str,
1393
- ) -> tuple[Tab | None, str | None]:
1394
- tab = workspace_store.get_tab(tab_id)
1395
- if tab is None:
1396
- return None, f"Tab '{tab_id}' not found"
1397
- next_definition = WorkflowDefinition.from_mapping(definition_payload)
1398
- node_ids = [node.id for node in next_definition.nodes]
1399
- if len(node_ids) != len(set(node_ids)):
1400
- return None, "Workflow definition contains duplicate node ids"
1401
- edge_ids = [edge.id for edge in next_definition.edges]
1402
- if len(edge_ids) != len(set(edge_ids)):
1403
- return None, "Workflow definition contains duplicate edge ids"
1404
- if _is_active(tab) and _semantic_definition(tab.definition) != _semantic_definition(
1405
- next_definition
1406
- ):
1407
- return None, _active_edit_error("workflow structure")
1408
-
1409
- current_agent_ids = {
1410
- node.id for node in tab.definition.nodes if node.type == WorkflowNodeKind.AGENT
1411
- }
1412
- next_agent_ids = {
1413
- node.id for node in next_definition.nodes if node.type == WorkflowNodeKind.AGENT
1414
- }
1415
- if current_agent_ids != next_agent_ids:
1416
- return None, "Agent nodes must be created or deleted through workflow node APIs"
1417
-
1418
- current_records = {
1419
- record.id: record
1420
- for record in list_tab_nodes(tab_id)
1421
- if not is_tab_leader(node_id=record.id, tab_id=tab_id)
1422
- }
1423
- for node in next_definition.nodes:
1424
- if node.type != WorkflowNodeKind.AGENT:
1425
- continue
1426
- role_name = node.config.get("role_name")
1427
- if not isinstance(role_name, str) or not role_name.strip():
1428
- return None, f"Agent node '{node.id}' requires role_name"
1429
- record = current_records.get(node.id)
1430
- if record is None:
1431
- return None, f"Runtime agent '{node.id}' was not found"
1432
- config, error = build_node_config(
1433
- role_name=role_name,
1434
- tab_id=tab_id,
1435
- name=str(node.config["name"])
1436
- if isinstance(node.config.get("name"), str)
1437
- else None,
1438
- )
1439
- if error is not None or config is None:
1440
- return None, error or f"Failed to validate agent node '{node.id}'"
1441
- record.config.role_name = config.role_name
1442
- record.config.name = config.name
1443
- record.config.tools = config.tools
1444
- workspace_store.upsert_node_record(record)
1445
- live_node = registry.get(node.id)
1446
- if live_node is not None:
1447
- live_node.config.role_name = record.config.role_name
1448
- live_node.config.name = record.config.name
1449
- live_node.config.tools = list(record.config.tools)
1450
- live_node._sync_system_prompt_entry()
1451
- live_node.set_state(
1452
- live_node.state,
1453
- "workflow_definition_updated",
1454
- force_emit=True,
1455
- )
1456
-
1457
- seen_target_ports: set[tuple[str, str]] = set()
1458
- for edge in next_definition.edges:
1459
- source_node = next_definition.get_node(edge.from_node_id)
1460
- target_node = next_definition.get_node(edge.to_node_id)
1461
- if source_node is None:
1462
- return None, f"Edge source node '{edge.from_node_id}' does not exist"
1463
- if target_node is None:
1464
- return None, f"Edge target node '{edge.to_node_id}' does not exist"
1465
- source_port = _port_matches(
1466
- source_node.outputs,
1467
- port_key=edge.from_port_key,
1468
- direction=PortDirection.OUTPUT,
1469
- )
1470
- if source_port is None:
1471
- return None, f"Output port '{edge.from_port_key}' is invalid"
1472
- target_port = _port_matches(
1473
- target_node.inputs,
1474
- port_key=edge.to_port_key,
1475
- direction=PortDirection.INPUT,
1476
- )
1477
- if target_port is None:
1478
- return None, f"Input port '{edge.to_port_key}' is invalid"
1479
- if source_port.type != target_port.type:
1480
- return (
1481
- None,
1482
- f"Port type mismatch: '{source_node.id}.{source_port.key}' is {source_port.type.value} "
1483
- f"but '{target_node.id}.{target_port.key}' is {target_port.type.value}",
1484
- )
1485
- target_key = (edge.to_node_id, edge.to_port_key)
1486
- if target_key in seen_target_ports and not target_port.multiple:
1487
- return None, f"Input port '{edge.to_port_key}' already has an incoming edge"
1488
- seen_target_ports.add(target_key)
1489
-
1490
- tab.definition = next_definition
1491
- _persist_tab(tab, actor_id=actor_id)
1492
- return tab, None
1493
-
1494
-
1495
- def _port_matches(
1496
- ports: list[WorkflowPort],
1497
- *,
1498
- port_key: str,
1499
- direction: PortDirection,
1500
- ) -> WorkflowPort | None:
1501
- return next(
1502
- (
1503
- port
1504
- for port in ports
1505
- if port.key == port_key and port.direction == direction
1506
- ),
1507
- None,
1508
- )
1509
-
1510
-
1511
- def _semantic_definition(definition: WorkflowDefinition) -> dict[str, object]:
1512
- payload = definition.serialize()
1513
- payload.pop("view", None)
1514
- return payload
1515
-
1516
-
1517
- _PORT_TYPES = {item.value for item in PortType}
1518
- _TRIGGER_KINDS = {"manual", "cron"}
1519
- _LLM_RESPONSE_FORMAT_KINDS = {"text", "json_schema"}
1520
- _IF_OPERATORS = {
1521
- "eq",
1522
- "neq",
1523
- "contains",
1524
- "not_contains",
1525
- "is_empty",
1526
- "is_not_empty",
1527
- "gt",
1528
- "lt",
1529
- "gte",
1530
- "lte",
1531
- "is_truthy",
1532
- "is_falsy",
1533
- }
1534
- _MERGE_STRATEGIES = {"collect", "named_object", "first_completed"}
1535
- _CODE_RUNTIMES = {"javascript", "python"}
1536
-
1537
-
1538
- def _validation_error(
1539
- errors: list[dict[str, str]],
1540
- *,
1541
- message: str,
1542
- node_id: str | None = None,
1543
- edge_id: str | None = None,
1544
- path: str | None = None,
1545
- ) -> None:
1546
- error: dict[str, str] = {"message": message}
1547
- if node_id is not None:
1548
- error["node_id"] = node_id
1549
- if edge_id is not None:
1550
- error["edge_id"] = edge_id
1551
- if path is not None:
1552
- error["path"] = path
1553
- errors.append(error)
1554
-
1555
-
1556
- def _is_json_serializable(value: object) -> bool:
1557
- try:
1558
- json.dumps(value)
1559
- except (TypeError, ValueError):
1560
- return False
1561
- return True
1562
-
1563
-
1564
- def _is_valid_parts_value(value: object) -> bool:
1565
- if not isinstance(value, list) or not value:
1566
- return False
1567
- for part in value:
1568
- if not isinstance(part, dict):
1569
- return False
1570
- part_type = part.get("type")
1571
- if part_type == "text":
1572
- text = part.get("text")
1573
- if not isinstance(text, str) or not text:
1574
- return False
1575
- continue
1576
- if part_type == "image":
1577
- asset_id = part.get("asset_id")
1578
- if not isinstance(asset_id, str) or not asset_id.strip():
1579
- return False
1580
- continue
1581
- return False
1582
- return True
1583
-
1584
-
1585
- def _validate_typed_value(value: object, port_type: PortType) -> bool:
1586
- if port_type == PortType.PARTS:
1587
- return _is_valid_parts_value(value)
1588
- if port_type == PortType.STRING:
1589
- return isinstance(value, str) and bool(value)
1590
- return isinstance(value, dict) and _is_json_serializable(value)
1591
-
1592
-
1593
- def _get_string_config(config: dict[str, object], key: str) -> str:
1594
- value = config.get(key)
1595
- return value.strip() if isinstance(value, str) else ""
1596
-
1597
-
1598
- def _parse_response_format_kind(value: object) -> str:
1599
- if isinstance(value, str):
1600
- return value.strip()
1601
- if isinstance(value, dict):
1602
- kind = value.get("kind")
1603
- return kind.strip() if isinstance(kind, str) else ""
1604
- return ""
1605
-
1606
-
1607
- def _response_format_schema(value: object) -> object:
1608
- if not isinstance(value, dict):
1609
- return None
1610
- return value.get("schema")
1611
-
1612
-
1613
- def _validate_cron_expression(value: object) -> bool:
1614
- if not isinstance(value, str):
1615
- return False
1616
- fields = value.split()
1617
- if len(fields) not in {5, 6}:
1618
- return False
1619
- return all(field.strip() for field in fields)
1620
-
1621
-
1622
- def _validate_timezone(value: object) -> bool:
1623
- if not isinstance(value, str) or not value.strip():
1624
- return False
1625
- try:
1626
- ZoneInfo(value.strip())
1627
- except ZoneInfoNotFoundError:
1628
- return False
1629
- return True
1630
-
1631
-
1632
- def _validate_trigger_node(
1633
- node: WorkflowNodeDefinition,
1634
- errors: list[dict[str, str]],
1635
- ) -> None:
1636
- kind = _get_string_config(node.config, "kind")
1637
- if kind not in _TRIGGER_KINDS:
1638
- _validation_error(
1639
- errors,
1640
- node_id=node.id,
1641
- path="config.kind",
1642
- message="trigger kind must be manual or cron",
1643
- )
1644
- output_type = _coerce_port_type(node.config.get("output_type"), PortType.PARTS)
1645
- if str(node.config.get("output_type", output_type.value)) not in _PORT_TYPES:
1646
- _validation_error(
1647
- errors,
1648
- node_id=node.id,
1649
- path="config.output_type",
1650
- message="trigger output_type must be parts, string, or json",
1651
- )
1652
- if not node.outputs or any(port.type != output_type for port in node.outputs):
1653
- _validation_error(
1654
- errors,
1655
- node_id=node.id,
1656
- path="outputs",
1657
- message="trigger output port type must match config.output_type",
1658
- )
1659
- if "message" not in node.config or not _validate_typed_value(
1660
- node.config.get("message"),
1661
- output_type,
1662
- ):
1663
- _validation_error(
1664
- errors,
1665
- node_id=node.id,
1666
- path="config.message",
1667
- message="trigger message must match output_type",
1668
- )
1669
- if kind == "cron":
1670
- if not _validate_cron_expression(node.config.get("cron")):
1671
- _validation_error(
1672
- errors,
1673
- node_id=node.id,
1674
- path="config.cron",
1675
- message="cron trigger requires a 5-field or 6-field expression",
1676
- )
1677
- if not _validate_timezone(node.config.get("timezone")):
1678
- _validation_error(
1679
- errors,
1680
- node_id=node.id,
1681
- path="config.timezone",
1682
- message="cron trigger requires an IANA timezone",
1683
- )
1684
-
1685
-
1686
- def _resolve_llm_model(
1687
- node: WorkflowNodeDefinition,
1688
- ) -> tuple[str, str]:
1689
- model_config = node.config.get("model")
1690
- if isinstance(model_config, dict):
1691
- provider_id = model_config.get("provider_id")
1692
- model = model_config.get("model")
1693
- return (
1694
- provider_id.strip() if isinstance(provider_id, str) else "",
1695
- model.strip() if isinstance(model, str) else "",
1696
- )
1697
- provider_id = node.config.get("provider_id")
1698
- model = node.config.get("model")
1699
- return (
1700
- provider_id.strip() if isinstance(provider_id, str) else "",
1701
- model.strip() if isinstance(model, str) else "",
1702
- )
1703
-
1704
-
1705
- def _validate_llm_node(
1706
- node: WorkflowNodeDefinition,
1707
- errors: list[dict[str, str]],
1708
- ) -> None:
1709
- input_type = _coerce_port_type(node.config.get("input_type"), PortType.PARTS)
1710
- output_type = _coerce_port_type(node.config.get("output_type"), PortType.PARTS)
1711
- if str(node.config.get("input_type", input_type.value)) not in _PORT_TYPES:
1712
- _validation_error(
1713
- errors,
1714
- node_id=node.id,
1715
- path="config.input_type",
1716
- message="llm input_type must be parts, string, or json",
1717
- )
1718
- if str(node.config.get("output_type", output_type.value)) not in _PORT_TYPES:
1719
- _validation_error(
1720
- errors,
1721
- node_id=node.id,
1722
- path="config.output_type",
1723
- message="llm output_type must be parts, string, or json",
1724
- )
1725
- response_format = node.config.get("response_format", {"kind": "text"})
1726
- response_format_kind = _parse_response_format_kind(response_format)
1727
- if response_format_kind not in _LLM_RESPONSE_FORMAT_KINDS:
1728
- _validation_error(
1729
- errors,
1730
- node_id=node.id,
1731
- path="config.response_format",
1732
- message="llm response_format must be text or json_schema",
1733
- )
1734
- if response_format_kind == "json_schema":
1735
- schema = _response_format_schema(response_format)
1736
- if not isinstance(schema, dict) or not _is_json_serializable(schema):
1737
- _validation_error(
1738
- errors,
1739
- node_id=node.id,
1740
- path="config.response_format.schema",
1741
- message="json_schema response_format requires a JSON schema object",
1742
- )
1743
- if output_type != PortType.JSON:
1744
- _validation_error(
1745
- errors,
1746
- node_id=node.id,
1747
- path="config.output_type",
1748
- message="json_schema response_format requires json output_type",
1749
- )
1750
- elif output_type == PortType.JSON:
1751
- _validation_error(
1752
- errors,
1753
- node_id=node.id,
1754
- path="config.output_type",
1755
- message="text response_format requires parts or string output_type",
1756
- )
1757
- provider_id, model_id = _resolve_llm_model(node)
1758
- settings = settings_module.get_settings()
1759
- provider = find_provider(settings, provider_id)
1760
- if provider is None:
1761
- _validation_error(
1762
- errors,
1763
- node_id=node.id,
1764
- path="config.model",
1765
- message="llm model provider was not found",
1766
- )
1767
- return
1768
- if not model_id:
1769
- _validation_error(
1770
- errors,
1771
- node_id=node.id,
1772
- path="config.model",
1773
- message="llm model must not be empty",
1774
- )
1775
- return
1776
- if provider.models and all(entry.model != model_id for entry in provider.models):
1777
- _validation_error(
1778
- errors,
1779
- node_id=node.id,
1780
- path="config.model",
1781
- message="llm model is not in the provider model catalog",
1782
- )
1783
- return
1784
- model_info = resolve_model_info(provider=provider, model_id=model_id)
1785
- if (
1786
- response_format_kind == "json_schema"
1787
- and not model_info.capabilities.structured_output
1788
- ):
1789
- _validation_error(
1790
- errors,
1791
- node_id=node.id,
1792
- path="config.model",
1793
- message="llm model does not support structured_output",
1794
- )
1795
-
1796
-
1797
- def _validate_if_node(
1798
- node: WorkflowNodeDefinition,
1799
- errors: list[dict[str, str]],
1800
- ) -> None:
1801
- expression = node.config.get("expression")
1802
- if not isinstance(expression, dict):
1803
- _validation_error(
1804
- errors,
1805
- node_id=node.id,
1806
- path="config.expression",
1807
- message="if expression must be an object",
1808
- )
1809
- return
1810
- field = expression.get("field")
1811
- operator = expression.get("operator")
1812
- if not isinstance(field, str) or not field.strip().startswith("{{input."):
1813
- _validation_error(
1814
- errors,
1815
- node_id=node.id,
1816
- path="config.expression.field",
1817
- message="if expression field must reference an input path",
1818
- )
1819
- if not isinstance(operator, str) or operator not in _IF_OPERATORS:
1820
- _validation_error(
1821
- errors,
1822
- node_id=node.id,
1823
- path="config.expression.operator",
1824
- message="if expression operator is not supported",
1825
- )
1826
- if (
1827
- operator in {"eq", "neq", "contains", "not_contains", "gt", "lt", "gte", "lte"}
1828
- and "value" not in expression
1829
- ):
1830
- _validation_error(
1831
- errors,
1832
- node_id=node.id,
1833
- path="config.expression.value",
1834
- message="if expression operator requires a value",
1835
- )
1836
- input_type = _coerce_port_type(node.config.get("input_type"), PortType.PARTS)
1837
- if any(port.type != input_type for port in node.inputs + node.outputs):
1838
- _validation_error(
1839
- errors,
1840
- node_id=node.id,
1841
- path="ports",
1842
- message="if node input and output port types must match",
1843
- )
1844
-
1845
-
1846
- def _validate_merge_node(
1847
- node: WorkflowNodeDefinition,
1848
- errors: list[dict[str, str]],
1849
- ) -> None:
1850
- strategy = _get_string_config(node.config, "strategy") or "collect"
1851
- if strategy not in _MERGE_STRATEGIES:
1852
- _validation_error(
1853
- errors,
1854
- node_id=node.id,
1855
- path="config.strategy",
1856
- message="merge strategy must be collect, named_object, or first_completed",
1857
- )
1858
- if strategy == "named_object" and not isinstance(
1859
- node.config.get("named_inputs"),
1860
- dict,
1861
- ):
1862
- _validation_error(
1863
- errors,
1864
- node_id=node.id,
1865
- path="config.named_inputs",
1866
- message="named_object merge requires named_inputs",
1867
- )
1868
- if not node.inputs or not node.inputs[0].multiple:
1869
- _validation_error(
1870
- errors,
1871
- node_id=node.id,
1872
- path="inputs",
1873
- message="merge input port must allow multiple upstream values",
1874
- )
1875
-
1876
-
1877
- def _validate_code_node(
1878
- node: WorkflowNodeDefinition,
1879
- errors: list[dict[str, str]],
1880
- ) -> None:
1881
- runtime = _get_string_config(node.config, "runtime")
1882
- source = node.config.get("source")
1883
- if runtime not in _CODE_RUNTIMES:
1884
- _validation_error(
1885
- errors,
1886
- node_id=node.id,
1887
- path="config.runtime",
1888
- message="code runtime must be javascript or python",
1889
- )
1890
- if not isinstance(source, str) or not source.strip():
1891
- _validation_error(
1892
- errors,
1893
- node_id=node.id,
1894
- path="config.source",
1895
- message="code source must not be empty",
1896
- )
1897
- return
1898
- if runtime == "python":
1899
- try:
1900
- ast.parse(source)
1901
- except SyntaxError as exc:
1902
- _validation_error(
1903
- errors,
1904
- node_id=node.id,
1905
- path="config.source",
1906
- message=f"python source is not parseable: {exc.msg}",
1907
- )
1908
- elif runtime == "javascript" and shutil.which("node"):
1909
- completed = subprocess.run(
1910
- ["node", "--check"],
1911
- input=source,
1912
- text=True,
1913
- capture_output=True,
1914
- timeout=5,
1915
- check=False,
1916
- )
1917
- if completed.returncode != 0:
1918
- _validation_error(
1919
- errors,
1920
- node_id=node.id,
1921
- path="config.source",
1922
- message="javascript source is not parseable",
1923
- )
1924
-
1925
-
1926
- def _validate_node_config(
1927
- node: WorkflowNodeDefinition,
1928
- errors: list[dict[str, str]],
1929
- ) -> None:
1930
- if node.type == WorkflowNodeKind.TRIGGER:
1931
- _validate_trigger_node(node, errors)
1932
- elif node.type == WorkflowNodeKind.LLM:
1933
- _validate_llm_node(node, errors)
1934
- elif node.type == WorkflowNodeKind.IF:
1935
- _validate_if_node(node, errors)
1936
- elif node.type == WorkflowNodeKind.MERGE:
1937
- _validate_merge_node(node, errors)
1938
- elif node.type == WorkflowNodeKind.CODE:
1939
- _validate_code_node(node, errors)
1940
-
1941
-
1942
- def _is_legacy_required_agent_input(
1943
- node: WorkflowNodeDefinition,
1944
- port: WorkflowPort,
1945
- ) -> bool:
1946
- return (
1947
- node.type == WorkflowNodeKind.AGENT
1948
- and port.key == "in"
1949
- and port.direction == PortDirection.INPUT
1950
- and port.type == PortType.PARTS
1951
- and not port.multiple
1952
- )
1953
-
1954
-
1955
- def validate_workflow_activation(tab: Tab) -> list[dict[str, str]]:
1956
- errors: list[dict[str, str]] = []
1957
- if not tab.definition.nodes:
1958
- _validation_error(
1959
- errors,
1960
- message="Add at least one node before activating this workflow",
1961
- path="definition.nodes",
1962
- )
1963
- node_ids = [node.id for node in tab.definition.nodes]
1964
- if len(node_ids) != len(set(node_ids)):
1965
- _validation_error(
1966
- errors,
1967
- message="workflow definition contains duplicate node ids",
1968
- path="definition.nodes",
1969
- )
1970
- edge_ids = [edge.id for edge in tab.definition.edges]
1971
- if len(edge_ids) != len(set(edge_ids)):
1972
- _validation_error(
1973
- errors,
1974
- message="workflow definition contains duplicate edge ids",
1975
- path="definition.edges",
1976
- )
1977
- incoming_edges_by_port: dict[tuple[str, str], list[GraphEdge]] = {}
1978
- seen_edge_endpoints: set[tuple[str, str, str, str]] = set()
1979
- for edge in tab.definition.edges:
1980
- edge_endpoint = (
1981
- edge.from_node_id,
1982
- edge.from_port_key,
1983
- edge.to_node_id,
1984
- edge.to_port_key,
1985
- )
1986
- if edge_endpoint in seen_edge_endpoints:
1987
- _validation_error(
1988
- errors,
1989
- edge_id=edge.id,
1990
- message="duplicate edges are not allowed",
1991
- )
1992
- seen_edge_endpoints.add(edge_endpoint)
1993
- source_node = tab.definition.get_node(edge.from_node_id)
1994
- target_node = tab.definition.get_node(edge.to_node_id)
1995
- if source_node is None:
1996
- _validation_error(
1997
- errors,
1998
- edge_id=edge.id,
1999
- message=f"edge source node '{edge.from_node_id}' does not exist",
2000
- )
2001
- continue
2002
- if target_node is None:
2003
- _validation_error(
2004
- errors,
2005
- edge_id=edge.id,
2006
- message=f"edge target node '{edge.to_node_id}' does not exist",
2007
- )
2008
- continue
2009
- source_port = _port_matches(
2010
- source_node.outputs,
2011
- port_key=edge.from_port_key,
2012
- direction=PortDirection.OUTPUT,
2013
- )
2014
- target_port = _port_matches(
2015
- target_node.inputs,
2016
- port_key=edge.to_port_key,
2017
- direction=PortDirection.INPUT,
2018
- )
2019
- if source_port is None:
2020
- _validation_error(
2021
- errors,
2022
- edge_id=edge.id,
2023
- path="from_port_key",
2024
- message=f"output port '{edge.from_port_key}' is invalid",
2025
- )
2026
- continue
2027
- if target_port is None:
2028
- _validation_error(
2029
- errors,
2030
- edge_id=edge.id,
2031
- path="to_port_key",
2032
- message=f"input port '{edge.to_port_key}' is invalid",
2033
- )
2034
- continue
2035
- if source_port.type != target_port.type:
2036
- _validation_error(
2037
- errors,
2038
- edge_id=edge.id,
2039
- message=(
2040
- f"port type mismatch: '{source_node.id}.{source_port.key}' is {source_port.type.value} "
2041
- f"but '{target_node.id}.{target_port.key}' is {target_port.type.value}"
2042
- ),
2043
- )
2044
- incoming_edges_by_port.setdefault(
2045
- (edge.to_node_id, edge.to_port_key), []
2046
- ).append(edge)
2047
- for node in tab.definition.nodes:
2048
- for port in node.inputs:
2049
- edges = incoming_edges_by_port.get((node.id, port.key), [])
2050
- if (
2051
- port.required
2052
- and not edges
2053
- and not _is_legacy_required_agent_input(node, port)
2054
- ):
2055
- _validation_error(
2056
- errors,
2057
- node_id=node.id,
2058
- path=f"inputs.{port.key}",
2059
- message=f"required input port '{port.key}' has no upstream edge",
2060
- )
2061
- if len(edges) > 1 and not port.multiple:
2062
- _validation_error(
2063
- errors,
2064
- node_id=node.id,
2065
- path=f"inputs.{port.key}",
2066
- message=f"input port '{port.key}' accepts only one upstream edge",
2067
- )
2068
- _validate_node_config(node, errors)
2069
- return errors
2070
-
2071
-
2072
- def activate_tab(
2073
- *,
2074
- tab_id: str,
2075
- actor_id: str = "assistant",
2076
- ) -> tuple[Tab | None, list[dict[str, str]] | None, str | None]:
2077
- tab = workspace_store.get_tab(tab_id)
2078
- if tab is None:
2079
- return None, None, f"Tab '{tab_id}' not found"
2080
- errors = validate_workflow_activation(tab)
2081
- if errors:
2082
- return None, errors, None
2083
- if tab.activation_state != WorkflowActivationState.ACTIVE:
2084
- tab.activation_state = WorkflowActivationState.ACTIVE
2085
- _persist_tab(tab, actor_id=actor_id)
2086
- return tab, None, None
2087
-
2088
-
2089
- def deactivate_tab(
2090
- *,
2091
- tab_id: str,
2092
- actor_id: str = "assistant",
2093
- timeout: float = SYSTEM_NODE_TIMEOUT,
2094
- ) -> tuple[Tab | None, str | None]:
2095
- tab = workspace_store.get_tab(tab_id)
2096
- if tab is None:
2097
- return None, f"Tab '{tab_id}' not found"
2098
-
2099
- lingering_node_ids: list[str] = []
2100
- ordinary_node_ids = {
2101
- node.id
2102
- for node in tab.definition.nodes
2103
- if node.type == WorkflowNodeKind.AGENT
2104
- and not is_tab_leader(node_id=node.id, tab_id=tab_id)
2105
- }
2106
- for node_id in ordinary_node_ids:
2107
- live_node = registry.get(node_id)
2108
- if live_node is not None:
2109
- if live_node.state in {AgentState.RUNNING, AgentState.SLEEPING}:
2110
- live_node.request_interrupt()
2111
- if not live_node.wait_until_idle(timeout=timeout):
2112
- lingering_node_ids.append(live_node.uuid)
2113
- continue
2114
- record = workspace_store.get_node_record(node_id)
2115
- if record is None:
2116
- continue
2117
- if record.state in {AgentState.RUNNING, AgentState.SLEEPING}:
2118
- record.state = AgentState.IDLE
2119
- workspace_store.upsert_node_record(record)
2120
-
2121
- if lingering_node_ids:
2122
- return (
2123
- None,
2124
- "Failed to deactivate workflow because some nodes did not stop: "
2125
- + ", ".join(node_id[:8] for node_id in lingering_node_ids),
2126
- )
2127
-
2128
- if tab.activation_state != WorkflowActivationState.INACTIVE:
2129
- tab.activation_state = WorkflowActivationState.INACTIVE
2130
- _persist_tab(tab, actor_id=actor_id)
2131
- return tab, None
2132
-
2133
-
2134
- def create_edge(
2135
- *,
2136
- tab_id: str | None = None,
2137
- from_node_id: str,
2138
- to_node_id: str,
2139
- from_port_key: str = "out",
2140
- to_port_key: str = "in",
2141
- kind: EdgeKind | str = EdgeKind.CONTROL,
2142
- ) -> tuple[GraphEdge | None, str | None]:
2143
- del kind
2144
- resolved_tab_id = tab_id
2145
- if resolved_tab_id is None:
2146
- source_record = workspace_store.get_node_record(from_node_id)
2147
- target_record = workspace_store.get_node_record(to_node_id)
2148
- if source_record is not None and source_record.config.tab_id:
2149
- resolved_tab_id = source_record.config.tab_id
2150
- elif target_record is not None and target_record.config.tab_id:
2151
- resolved_tab_id = target_record.config.tab_id
2152
- if resolved_tab_id is None:
2153
- return None, "tab_id is required"
2154
- tab = workspace_store.get_tab(resolved_tab_id)
2155
- if tab is None:
2156
- return None, f"Tab '{resolved_tab_id}' not found"
2157
- if _is_active(tab):
2158
- return None, _active_edit_error("edges")
2159
- if is_tab_leader(node_id=from_node_id, tab_id=resolved_tab_id) or is_tab_leader(
2160
- node_id=to_node_id,
2161
- tab_id=resolved_tab_id,
2162
- ):
2163
- return None, "Workflow Leader does not participate in Workflow Graph edges"
2164
- if from_node_id == to_node_id:
2165
- return None, "Self-loop edges are not allowed"
2166
- source_node = tab.definition.get_node(from_node_id)
2167
- target_node = tab.definition.get_node(to_node_id)
2168
- if source_node is None:
2169
- return None, f"Node '{from_node_id}' not found"
2170
- if target_node is None:
2171
- return None, f"Node '{to_node_id}' not found"
2172
- source_port = _port_matches(
2173
- source_node.outputs,
2174
- port_key=from_port_key,
2175
- direction=PortDirection.OUTPUT,
2176
- )
2177
- if source_port is None:
2178
- return None, f"Output port '{from_port_key}' is invalid"
2179
- target_port = _port_matches(
2180
- target_node.inputs,
2181
- port_key=to_port_key,
2182
- direction=PortDirection.INPUT,
2183
- )
2184
- if target_port is None:
2185
- return None, f"Input port '{to_port_key}' is invalid"
2186
- if source_port.type != target_port.type:
2187
- return (
2188
- None,
2189
- f"Port type mismatch: '{source_node.id}.{source_port.key}' is {source_port.type.value} "
2190
- f"but '{target_node.id}.{target_port.key}' is {target_port.type.value}",
2191
- )
2192
- if any(
2193
- edge.from_node_id == from_node_id
2194
- and edge.from_port_key == from_port_key
2195
- and edge.to_node_id == to_node_id
2196
- and edge.to_port_key == to_port_key
2197
- for edge in tab.definition.edges
2198
- ):
2199
- return None, "Duplicate edges are not allowed"
2200
- if not target_port.multiple and any(
2201
- edge.to_node_id == to_node_id and edge.to_port_key == to_port_key
2202
- for edge in tab.definition.edges
2203
- ):
2204
- return None, f"Input port '{to_port_key}' already has an incoming edge"
2205
-
2206
- edge = GraphEdge(
2207
- id=str(uuid.uuid4()),
2208
- tab_id=resolved_tab_id,
2209
- from_node_id=from_node_id,
2210
- from_port_key=from_port_key,
2211
- to_node_id=to_node_id,
2212
- to_port_key=to_port_key,
2213
- )
2214
- tab.definition.edges.append(edge)
2215
- _persist_tab(tab, actor_id=from_node_id)
2216
- return edge, None
2217
-
2218
-
2219
- def delete_edge(
2220
- *,
2221
- tab_id: str,
2222
- edge_id: str | None = None,
2223
- from_node_id: str | None = None,
2224
- to_node_id: str | None = None,
2225
- from_port_key: str | None = None,
2226
- to_port_key: str | None = None,
2227
- ) -> tuple[dict[str, object] | None, str | None]:
2228
- tab = workspace_store.get_tab(tab_id)
2229
- if tab is None:
2230
- return None, f"Tab '{tab_id}' not found"
2231
- if _is_active(tab):
2232
- return None, _active_edit_error("edges")
2233
-
2234
- matched_edge: GraphEdge | None = None
2235
- for edge in tab.definition.edges:
2236
- if edge_id is not None and edge.id == edge_id:
2237
- matched_edge = edge
2238
- break
2239
- if (
2240
- from_node_id is not None
2241
- and to_node_id is not None
2242
- and edge.from_node_id == from_node_id
2243
- and edge.to_node_id == to_node_id
2244
- and (from_port_key is None or edge.from_port_key == from_port_key)
2245
- and (to_port_key is None or edge.to_port_key == to_port_key)
2246
- ):
2247
- matched_edge = edge
2248
- break
2249
- if matched_edge is None:
2250
- return None, "Edge not found"
2251
-
2252
- tab.definition.edges = [
2253
- edge for edge in tab.definition.edges if edge.id != matched_edge.id
2254
- ]
2255
- _persist_tab(tab, actor_id=matched_edge.from_node_id)
2256
- return matched_edge.serialize(), None
2257
-
2258
-
2259
- def delete_agent_node(
2260
- *,
2261
- tab_id: str,
2262
- node_id: str,
2263
- timeout: float = SYSTEM_NODE_TIMEOUT,
2264
- ) -> tuple[dict[str, object] | None, str | None]:
2265
- tab = workspace_store.get_tab(tab_id)
2266
- if tab is None:
2267
- return None, f"Tab '{tab_id}' not found"
2268
- if _is_active(tab):
2269
- return None, _active_edit_error("nodes")
2270
-
2271
- node_definition = tab.definition.get_node(node_id)
2272
- if node_definition is None:
2273
- return None, f"Node '{node_id}' not found"
2274
- if is_tab_leader(node_id=node_id, tab_id=tab_id):
2275
- return None, "Workflow Leader cannot be deleted from the graph"
2276
-
2277
- related_edges = [
2278
- edge
2279
- for edge in tab.definition.edges
2280
- if edge.from_node_id == node_id or edge.to_node_id == node_id
2281
- ]
2282
- live_node = registry.get(node_id)
2283
- record = workspace_store.get_node_record(node_id)
2284
-
2285
- if live_node is not None:
2286
- live_node.request_termination("graph_deleted")
2287
- if not live_node.wait_for_termination(timeout=timeout):
2288
- return (
2289
- None,
2290
- f"Failed to delete node '{node_id}' because it did not terminate",
2291
- )
2292
-
2293
- if record is not None:
2294
- workspace_store.delete_node_record(node_id)
2295
-
2296
- tab.definition.nodes = [node for node in tab.definition.nodes if node.id != node_id]
2297
- tab.definition.edges = [
2298
- edge
2299
- for edge in tab.definition.edges
2300
- if edge.id not in {item.id for item in related_edges}
2301
- ]
2302
- tab.definition.view.positions.pop(node_id, None)
2303
- workspace_store.upsert_tab(tab)
2304
- payload: dict[str, object] = {
2305
- "id": node_id,
2306
- "tab_id": tab_id,
2307
- "removed_edge_ids": [edge.id for edge in related_edges],
2308
- }
2309
- event_bus.emit(
2310
- Event(
2311
- type=EventType.NODE_DELETED,
2312
- agent_id=node_id,
2313
- data=payload,
2314
- )
2315
- )
2316
- _emit_tab_updated(
2317
- tab_id=tab_id,
2318
- agent_id=node_id,
2319
- )
2320
- return payload, None
2321
-
2322
-
2323
- def dispatch_node_message(
2324
- *,
2325
- node_id: str,
2326
- content: str,
2327
- parts: list | None = None,
2328
- from_id: str = "human",
2329
- ) -> tuple[str | None, str | None]:
2330
- target = registry.get(node_id)
2331
- if target is None:
2332
- return f"Node '{node_id}' is not active", None
2333
- message_id = str(uuid.uuid4())
2334
- normalized_parts = list(parts or [])
2335
- target._append_history(
2336
- ReceivedMessage(
2337
- from_id=from_id,
2338
- parts=normalized_parts,
2339
- content=content,
2340
- message_id=message_id,
2341
- )
2342
- )
2343
- target.enqueue_message(
2344
- Message(
2345
- from_id=from_id,
2346
- to_id=node_id,
2347
- parts=normalized_parts,
2348
- content=content,
2349
- message_id=message_id,
2350
- history_recorded=True,
2351
- )
2352
- )
2353
- return None, message_id
2354
-
2355
-
2356
- def list_tab_nodes(tab_id: str) -> list[GraphNodeRecord]:
2357
- return sorted(
2358
- workspace_store.list_node_records(tab_id),
2359
- key=lambda record: (record.created_at, record.id),
2360
- )
2361
-
2362
-
2363
- def list_tab_edges(tab_id: str) -> list[GraphEdge]:
2364
- tab = workspace_store.get_tab(tab_id)
2365
- if tab is None:
2366
- return []
2367
- return sorted(
2368
- [
2369
- GraphEdge(
2370
- id=edge.id,
2371
- tab_id=tab_id,
2372
- from_node_id=edge.from_node_id,
2373
- from_port_key=edge.from_port_key,
2374
- to_node_id=edge.to_node_id,
2375
- to_port_key=edge.to_port_key,
2376
- kind=edge.kind,
2377
- created_at=edge.created_at,
2378
- )
2379
- for edge in tab.definition.edges
2380
- ],
2381
- key=lambda edge: (edge.created_at, edge.id),
2382
- )
2383
-
2384
-
2385
- def list_node_connection_ids(*, tab_id: str, node_id: str) -> list[str]:
2386
- if is_tab_leader(node_id=node_id, tab_id=tab_id):
2387
- return []
2388
-
2389
- connection_ids: list[str] = []
2390
- seen_node_ids: set[str] = set()
2391
- for edge in list_tab_edges(tab_id):
2392
- other_node_id: str | None = None
2393
- if edge.from_node_id == node_id:
2394
- other_node_id = edge.to_node_id
2395
- elif edge.to_node_id == node_id:
2396
- other_node_id = edge.from_node_id
2397
- if other_node_id is None or other_node_id in seen_node_ids:
2398
- continue
2399
- seen_node_ids.add(other_node_id)
2400
- connection_ids.append(other_node_id)
2401
- return connection_ids