flowent 0.0.6 → 0.0.10

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