flowent 0.0.0 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (494) hide show
  1. package/README.md +70 -10
  2. package/assets/flowent-banner.png +0 -0
  3. package/backend/.python-version +1 -0
  4. package/backend/pyproject.toml +57 -0
  5. package/backend/src/flowent/__init__.py +3 -0
  6. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  7. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/access.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/assistant_commands.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/config.cpython-313.pyc +0 -0
  13. package/backend/src/flowent/__pycache__/events.cpython-313.pyc +0 -0
  14. package/backend/src/flowent/__pycache__/graph_runtime.cpython-313.pyc +0 -0
  15. package/backend/src/flowent/__pycache__/graph_service.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/__pycache__/image_assets.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/__pycache__/mcp_service.cpython-313.pyc +0 -0
  20. package/backend/src/flowent/__pycache__/model_metadata.cpython-313.pyc +0 -0
  21. package/backend/src/flowent/__pycache__/network.cpython-313.pyc +0 -0
  22. package/backend/src/flowent/__pycache__/registry.cpython-313.pyc +0 -0
  23. package/backend/src/flowent/__pycache__/role_management.cpython-313.pyc +0 -0
  24. package/backend/src/flowent/__pycache__/runtime.cpython-313.pyc +0 -0
  25. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  26. package/backend/src/flowent/__pycache__/security.cpython-313.pyc +0 -0
  27. package/backend/src/flowent/__pycache__/settings.cpython-313.pyc +0 -0
  28. package/backend/src/flowent/__pycache__/settings_management.cpython-313.pyc +0 -0
  29. package/backend/src/flowent/__pycache__/state_db.cpython-313.pyc +0 -0
  30. package/backend/src/flowent/__pycache__/stats_service.cpython-313.pyc +0 -0
  31. package/backend/src/flowent/__pycache__/workspace_store.cpython-313.pyc +0 -0
  32. package/backend/src/flowent/_version.py +7 -0
  33. package/backend/src/flowent/access.py +247 -0
  34. package/backend/src/flowent/agent.py +2808 -0
  35. package/backend/src/flowent/assistant_commands.py +106 -0
  36. package/backend/src/flowent/channels/__init__.py +3 -0
  37. package/backend/src/flowent/channels/__pycache__/__init__.cpython-313.pyc +0 -0
  38. package/backend/src/flowent/channels/__pycache__/telegram.cpython-313.pyc +0 -0
  39. package/backend/src/flowent/channels/telegram.py +615 -0
  40. package/backend/src/flowent/cli.py +85 -0
  41. package/backend/src/flowent/config.py +14 -0
  42. package/backend/src/flowent/dev.py +3 -0
  43. package/backend/src/flowent/events.py +157 -0
  44. package/backend/src/flowent/graph_runtime.py +60 -0
  45. package/backend/src/flowent/graph_service.py +1346 -0
  46. package/backend/src/flowent/image_assets.py +356 -0
  47. package/backend/src/flowent/logging.py +155 -0
  48. package/backend/src/flowent/main.py +124 -0
  49. package/backend/src/flowent/mcp_service.py +1904 -0
  50. package/backend/src/flowent/model_metadata.py +98 -0
  51. package/backend/src/flowent/models/__init__.py +121 -0
  52. package/backend/src/flowent/models/__pycache__/__init__.cpython-313.pyc +0 -0
  53. package/backend/src/flowent/models/__pycache__/agent.cpython-313.pyc +0 -0
  54. package/backend/src/flowent/models/__pycache__/base.cpython-313.pyc +0 -0
  55. package/backend/src/flowent/models/__pycache__/blueprint.cpython-313.pyc +0 -0
  56. package/backend/src/flowent/models/__pycache__/content.cpython-313.pyc +0 -0
  57. package/backend/src/flowent/models/__pycache__/delta.cpython-313.pyc +0 -0
  58. package/backend/src/flowent/models/__pycache__/event.cpython-313.pyc +0 -0
  59. package/backend/src/flowent/models/__pycache__/graph.cpython-313.pyc +0 -0
  60. package/backend/src/flowent/models/__pycache__/history.cpython-313.pyc +0 -0
  61. package/backend/src/flowent/models/__pycache__/llm.cpython-313.pyc +0 -0
  62. package/backend/src/flowent/models/__pycache__/message.cpython-313.pyc +0 -0
  63. package/backend/src/flowent/models/__pycache__/tab.cpython-313.pyc +0 -0
  64. package/backend/src/flowent/models/__pycache__/todo.cpython-313.pyc +0 -0
  65. package/backend/src/flowent/models/agent.py +33 -0
  66. package/backend/src/flowent/models/base.py +24 -0
  67. package/backend/src/flowent/models/blueprint.py +176 -0
  68. package/backend/src/flowent/models/content.py +164 -0
  69. package/backend/src/flowent/models/delta.py +44 -0
  70. package/backend/src/flowent/models/event.py +51 -0
  71. package/backend/src/flowent/models/graph.py +437 -0
  72. package/backend/src/flowent/models/history.py +214 -0
  73. package/backend/src/flowent/models/llm.py +61 -0
  74. package/backend/src/flowent/models/message.py +27 -0
  75. package/backend/src/flowent/models/tab.py +48 -0
  76. package/backend/src/flowent/models/todo.py +10 -0
  77. package/backend/src/flowent/network.py +146 -0
  78. package/backend/src/flowent/prompts/__init__.py +67 -0
  79. package/backend/src/flowent/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
  80. package/backend/src/flowent/prompts/__pycache__/common.cpython-313.pyc +0 -0
  81. package/backend/src/flowent/prompts/__pycache__/steward.cpython-313.pyc +0 -0
  82. package/backend/src/flowent/prompts/common.py +250 -0
  83. package/backend/src/flowent/prompts/steward.py +64 -0
  84. package/backend/src/flowent/providers/__init__.py +23 -0
  85. package/backend/src/flowent/providers/__pycache__/__init__.cpython-313.pyc +0 -0
  86. package/backend/src/flowent/providers/__pycache__/anthropic.cpython-313.pyc +0 -0
  87. package/backend/src/flowent/providers/__pycache__/base_url.cpython-313.pyc +0 -0
  88. package/backend/src/flowent/providers/__pycache__/configuration.cpython-313.pyc +0 -0
  89. package/backend/src/flowent/providers/__pycache__/content.cpython-313.pyc +0 -0
  90. package/backend/src/flowent/providers/__pycache__/errors.cpython-313.pyc +0 -0
  91. package/backend/src/flowent/providers/__pycache__/gateway.cpython-313.pyc +0 -0
  92. package/backend/src/flowent/providers/__pycache__/headers.cpython-313.pyc +0 -0
  93. package/backend/src/flowent/providers/__pycache__/management.cpython-313.pyc +0 -0
  94. package/backend/src/flowent/providers/__pycache__/openai.cpython-313.pyc +0 -0
  95. package/backend/src/flowent/providers/__pycache__/openai_responses.cpython-313.pyc +0 -0
  96. package/backend/src/flowent/providers/__pycache__/registry.cpython-313.pyc +0 -0
  97. package/backend/src/flowent/providers/__pycache__/sse.cpython-313.pyc +0 -0
  98. package/backend/src/flowent/providers/__pycache__/thinking.cpython-313.pyc +0 -0
  99. package/backend/src/flowent/providers/anthropic.py +468 -0
  100. package/backend/src/flowent/providers/base_url.py +60 -0
  101. package/backend/src/flowent/providers/configuration.py +182 -0
  102. package/backend/src/flowent/providers/content.py +122 -0
  103. package/backend/src/flowent/providers/errors.py +223 -0
  104. package/backend/src/flowent/providers/gateway.py +169 -0
  105. package/backend/src/flowent/providers/gemini.py +447 -0
  106. package/backend/src/flowent/providers/headers.py +20 -0
  107. package/backend/src/flowent/providers/management.py +96 -0
  108. package/backend/src/flowent/providers/ollama.py +293 -0
  109. package/backend/src/flowent/providers/openai.py +422 -0
  110. package/backend/src/flowent/providers/openai_responses.py +655 -0
  111. package/backend/src/flowent/providers/registry.py +144 -0
  112. package/backend/src/flowent/providers/sse.py +31 -0
  113. package/backend/src/flowent/providers/thinking.py +79 -0
  114. package/backend/src/flowent/registry.py +73 -0
  115. package/backend/src/flowent/role_management.py +255 -0
  116. package/backend/src/flowent/routes/__init__.py +30 -0
  117. package/backend/src/flowent/routes/__pycache__/__init__.cpython-313.pyc +0 -0
  118. package/backend/src/flowent/routes/__pycache__/access.cpython-313.pyc +0 -0
  119. package/backend/src/flowent/routes/__pycache__/assistant.cpython-313.pyc +0 -0
  120. package/backend/src/flowent/routes/__pycache__/image_assets.cpython-313.pyc +0 -0
  121. package/backend/src/flowent/routes/__pycache__/mcp.cpython-313.pyc +0 -0
  122. package/backend/src/flowent/routes/__pycache__/meta.cpython-313.pyc +0 -0
  123. package/backend/src/flowent/routes/__pycache__/nodes.cpython-313.pyc +0 -0
  124. package/backend/src/flowent/routes/__pycache__/prompts.cpython-313.pyc +0 -0
  125. package/backend/src/flowent/routes/__pycache__/providers_route.cpython-313.pyc +0 -0
  126. package/backend/src/flowent/routes/__pycache__/roles.cpython-313.pyc +0 -0
  127. package/backend/src/flowent/routes/__pycache__/settings.cpython-313.pyc +0 -0
  128. package/backend/src/flowent/routes/__pycache__/stats.cpython-313.pyc +0 -0
  129. package/backend/src/flowent/routes/__pycache__/tabs.cpython-313.pyc +0 -0
  130. package/backend/src/flowent/routes/__pycache__/ws.cpython-313.pyc +0 -0
  131. package/backend/src/flowent/routes/access.py +48 -0
  132. package/backend/src/flowent/routes/assistant.py +155 -0
  133. package/backend/src/flowent/routes/image_assets.py +33 -0
  134. package/backend/src/flowent/routes/mcp.py +125 -0
  135. package/backend/src/flowent/routes/meta.py +28 -0
  136. package/backend/src/flowent/routes/nodes.py +365 -0
  137. package/backend/src/flowent/routes/prompts.py +46 -0
  138. package/backend/src/flowent/routes/providers_route.py +364 -0
  139. package/backend/src/flowent/routes/roles.py +207 -0
  140. package/backend/src/flowent/routes/settings.py +324 -0
  141. package/backend/src/flowent/routes/stats.py +229 -0
  142. package/backend/src/flowent/routes/tabs.py +292 -0
  143. package/backend/src/flowent/routes/ws.py +33 -0
  144. package/backend/src/flowent/runtime.py +188 -0
  145. package/backend/src/flowent/sandbox.py +45 -0
  146. package/backend/src/flowent/security.py +42 -0
  147. package/backend/src/flowent/settings.py +2467 -0
  148. package/backend/src/flowent/settings_management.py +286 -0
  149. package/backend/src/flowent/state_db.py +120 -0
  150. package/backend/src/flowent/static/assets/AssistantPage-B3Xc08AS.js +1 -0
  151. package/backend/src/flowent/static/assets/ChannelsPage-ByLd28xk.js +1 -0
  152. package/backend/src/flowent/static/assets/HomePage-C0hAx9_l.js +3 -0
  153. package/backend/src/flowent/static/assets/McpPage-DkrYLvBv.js +7 -0
  154. package/backend/src/flowent/static/assets/PageScaffold-D4jO9ooX.js +1 -0
  155. package/backend/src/flowent/static/assets/PromptsPage-DWA7rRJd.js +1 -0
  156. package/backend/src/flowent/static/assets/ProvidersPage-PUWT8seJ.js +3 -0
  157. package/backend/src/flowent/static/assets/RolesPage-CqcclGRw.js +1 -0
  158. package/backend/src/flowent/static/assets/SettingsPage-8tS2cJgX.js +3 -0
  159. package/backend/src/flowent/static/assets/StatsPage-BX9khYzu.js +1 -0
  160. package/backend/src/flowent/static/assets/ToolsPage-9Tl9FdeD.js +1 -0
  161. package/backend/src/flowent/static/assets/WorkspaceCommandDialog-CCXxjDL8.js +1 -0
  162. package/backend/src/flowent/static/assets/WorkspacePanels-aMdJ7ZH7.js +1 -0
  163. package/backend/src/flowent/static/assets/alert-dialog-kFYVQ7oX.js +1 -0
  164. package/backend/src/flowent/static/assets/badge-74-3jsCg.js +1 -0
  165. package/backend/src/flowent/static/assets/constants-XUzFf6i1.js +1 -0
  166. package/backend/src/flowent/static/assets/datetime-m6_O_Ci9.js +1 -0
  167. package/backend/src/flowent/static/assets/dialog-BeGSweF6.js +1 -0
  168. package/backend/src/flowent/static/assets/elk-worker.min-C9JGDOE-.js +6312 -0
  169. package/backend/src/flowent/static/assets/graph-vendor-CHpVij2M.css +1 -0
  170. package/backend/src/flowent/static/assets/graph-vendor-DRq_-6fV.js +7 -0
  171. package/backend/src/flowent/static/assets/index-BHC1Vhy8.css +1 -0
  172. package/backend/src/flowent/static/assets/index-CL1ALZ3r.js +10 -0
  173. package/backend/src/flowent/static/assets/layout.worker-jMHqAFbP.js +24 -0
  174. package/backend/src/flowent/static/assets/markdown-vendor-DVdy_w12.js +29 -0
  175. package/backend/src/flowent/static/assets/modelParams-CaHd0903.js +1 -0
  176. package/backend/src/flowent/static/assets/react-vendor-mEs_JJxa.js +9 -0
  177. package/backend/src/flowent/static/assets/roles-2OLDeTc5.js +1 -0
  178. package/backend/src/flowent/static/assets/rolldown-runtime-BYbx6iT9.js +1 -0
  179. package/backend/src/flowent/static/assets/select-DL_LPeDj.js +1 -0
  180. package/backend/src/flowent/static/assets/shared-CMxbpLeQ.js +1 -0
  181. package/backend/src/flowent/static/assets/triState-DEr3NkXV.js +1 -0
  182. package/backend/src/flowent/static/assets/ui-vendor-Dg9NNnWX.js +51 -0
  183. package/backend/src/flowent/static/index.html +36 -0
  184. package/backend/src/flowent/stats_service.py +218 -0
  185. package/backend/src/flowent/tools/__init__.py +201 -0
  186. package/backend/src/flowent/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  187. package/backend/src/flowent/tools/__pycache__/connect.cpython-313.pyc +0 -0
  188. package/backend/src/flowent/tools/__pycache__/contacts.cpython-313.pyc +0 -0
  189. package/backend/src/flowent/tools/__pycache__/create_agent.cpython-313.pyc +0 -0
  190. package/backend/src/flowent/tools/__pycache__/create_tab.cpython-313.pyc +0 -0
  191. package/backend/src/flowent/tools/__pycache__/delete_tab.cpython-313.pyc +0 -0
  192. package/backend/src/flowent/tools/__pycache__/edit.cpython-313.pyc +0 -0
  193. package/backend/src/flowent/tools/__pycache__/exec.cpython-313.pyc +0 -0
  194. package/backend/src/flowent/tools/__pycache__/fetch.cpython-313.pyc +0 -0
  195. package/backend/src/flowent/tools/__pycache__/idle.cpython-313.pyc +0 -0
  196. package/backend/src/flowent/tools/__pycache__/list_roles.cpython-313.pyc +0 -0
  197. package/backend/src/flowent/tools/__pycache__/list_tabs.cpython-313.pyc +0 -0
  198. package/backend/src/flowent/tools/__pycache__/list_tools.cpython-313.pyc +0 -0
  199. package/backend/src/flowent/tools/__pycache__/manage_prompts.cpython-313.pyc +0 -0
  200. package/backend/src/flowent/tools/__pycache__/manage_providers.cpython-313.pyc +0 -0
  201. package/backend/src/flowent/tools/__pycache__/manage_roles.cpython-313.pyc +0 -0
  202. package/backend/src/flowent/tools/__pycache__/manage_settings.cpython-313.pyc +0 -0
  203. package/backend/src/flowent/tools/__pycache__/mcp.cpython-313.pyc +0 -0
  204. package/backend/src/flowent/tools/__pycache__/read.cpython-313.pyc +0 -0
  205. package/backend/src/flowent/tools/__pycache__/send.cpython-313.pyc +0 -0
  206. package/backend/src/flowent/tools/__pycache__/set_permissions.cpython-313.pyc +0 -0
  207. package/backend/src/flowent/tools/__pycache__/sleep.cpython-313.pyc +0 -0
  208. package/backend/src/flowent/tools/__pycache__/todo.cpython-313.pyc +0 -0
  209. package/backend/src/flowent/tools/connect.py +156 -0
  210. package/backend/src/flowent/tools/contacts.py +22 -0
  211. package/backend/src/flowent/tools/create_agent.py +270 -0
  212. package/backend/src/flowent/tools/create_tab.py +59 -0
  213. package/backend/src/flowent/tools/delete_tab.py +39 -0
  214. package/backend/src/flowent/tools/edit.py +142 -0
  215. package/backend/src/flowent/tools/exec.py +117 -0
  216. package/backend/src/flowent/tools/fetch.py +85 -0
  217. package/backend/src/flowent/tools/idle.py +27 -0
  218. package/backend/src/flowent/tools/list_roles.py +50 -0
  219. package/backend/src/flowent/tools/list_tabs.py +96 -0
  220. package/backend/src/flowent/tools/list_tools.py +24 -0
  221. package/backend/src/flowent/tools/manage_prompts.py +102 -0
  222. package/backend/src/flowent/tools/manage_providers.py +220 -0
  223. package/backend/src/flowent/tools/manage_roles.py +275 -0
  224. package/backend/src/flowent/tools/manage_settings.py +346 -0
  225. package/backend/src/flowent/tools/mcp.py +199 -0
  226. package/backend/src/flowent/tools/read.py +152 -0
  227. package/backend/src/flowent/tools/send.py +50 -0
  228. package/backend/src/flowent/tools/set_permissions.py +84 -0
  229. package/backend/src/flowent/tools/sleep.py +41 -0
  230. package/backend/src/flowent/tools/todo.py +51 -0
  231. package/backend/src/flowent/workspace_store.py +479 -0
  232. package/backend/tests/__init__.py +0 -0
  233. package/backend/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  234. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  235. package/backend/tests/conftest.py +6 -0
  236. package/backend/tests/integration/api/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  237. package/backend/tests/integration/api/__pycache__/test_access_api.cpython-313-pytest-9.0.3.pyc +0 -0
  238. package/backend/tests/integration/api/__pycache__/test_assistant_api.cpython-313-pytest-9.0.3.pyc +0 -0
  239. package/backend/tests/integration/api/__pycache__/test_frontend_mounting.cpython-313-pytest-9.0.3.pyc +0 -0
  240. package/backend/tests/integration/api/__pycache__/test_mcp_api.cpython-313-pytest-9.0.3.pyc +0 -0
  241. package/backend/tests/integration/api/__pycache__/test_meta_api.cpython-313-pytest-9.0.3.pyc +0 -0
  242. package/backend/tests/integration/api/__pycache__/test_nodes_api.cpython-313-pytest-9.0.3.pyc +0 -0
  243. package/backend/tests/integration/api/__pycache__/test_prompts_api.cpython-313-pytest-9.0.3.pyc +0 -0
  244. package/backend/tests/integration/api/__pycache__/test_roles_api.cpython-313-pytest-9.0.3.pyc +0 -0
  245. package/backend/tests/integration/api/__pycache__/test_tabs_api.cpython-313-pytest-9.0.3.pyc +0 -0
  246. package/backend/tests/integration/api/conftest.py +29 -0
  247. package/backend/tests/integration/api/test_access_api.py +182 -0
  248. package/backend/tests/integration/api/test_assistant_api.py +354 -0
  249. package/backend/tests/integration/api/test_frontend_mounting.py +61 -0
  250. package/backend/tests/integration/api/test_mcp_api.py +116 -0
  251. package/backend/tests/integration/api/test_meta_api.py +33 -0
  252. package/backend/tests/integration/api/test_nodes_api.py +486 -0
  253. package/backend/tests/integration/api/test_prompts_api.py +47 -0
  254. package/backend/tests/integration/api/test_roles_api.py +227 -0
  255. package/backend/tests/integration/api/test_tabs_api.py +501 -0
  256. package/backend/tests/unit/__pycache__/test_access.cpython-313-pytest-9.0.3.pyc +0 -0
  257. package/backend/tests/unit/__pycache__/test_cli.cpython-313-pytest-9.0.3.pyc +0 -0
  258. package/backend/tests/unit/__pycache__/test_graph_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  259. package/backend/tests/unit/__pycache__/test_network.cpython-313-pytest-9.0.3.pyc +0 -0
  260. package/backend/tests/unit/__pycache__/test_state_sqlite_storage.cpython-313-pytest-9.0.3.pyc +0 -0
  261. package/backend/tests/unit/__pycache__/test_workspace_store.cpython-313-pytest-9.0.3.pyc +0 -0
  262. package/backend/tests/unit/agent/__pycache__/test_agent_public_api.cpython-313-pytest-9.0.3.pyc +0 -0
  263. package/backend/tests/unit/agent/__pycache__/test_agent_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  264. package/backend/tests/unit/agent/test_agent_public_api.py +746 -0
  265. package/backend/tests/unit/agent/test_agent_runtime.py +2726 -0
  266. package/backend/tests/unit/channels/__pycache__/test_telegram_channel.cpython-313-pytest-9.0.3.pyc +0 -0
  267. package/backend/tests/unit/channels/test_telegram_channel.py +552 -0
  268. package/backend/tests/unit/logging/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  269. package/backend/tests/unit/logging/test_logging.py +132 -0
  270. package/backend/tests/unit/prompts/__pycache__/test_prompts.cpython-313-pytest-9.0.3.pyc +0 -0
  271. package/backend/tests/unit/prompts/test_prompts.py +569 -0
  272. package/backend/tests/unit/providers/__pycache__/test_anthropic_provider.cpython-313-pytest-9.0.3.pyc +0 -0
  273. package/backend/tests/unit/providers/__pycache__/test_errors.cpython-313-pytest-9.0.3.pyc +0 -0
  274. package/backend/tests/unit/providers/__pycache__/test_extract_delta_parts.cpython-313-pytest-9.0.3.pyc +0 -0
  275. package/backend/tests/unit/providers/__pycache__/test_openai_provider.cpython-313-pytest-9.0.3.pyc +0 -0
  276. package/backend/tests/unit/providers/__pycache__/test_openai_responses.cpython-313-pytest-9.0.3.pyc +0 -0
  277. package/backend/tests/unit/providers/__pycache__/test_provider_gateway.cpython-313-pytest-9.0.3.pyc +0 -0
  278. package/backend/tests/unit/providers/__pycache__/test_think_tag_parser.cpython-313-pytest-9.0.3.pyc +0 -0
  279. package/backend/tests/unit/providers/test_anthropic_provider.py +185 -0
  280. package/backend/tests/unit/providers/test_errors.py +68 -0
  281. package/backend/tests/unit/providers/test_extract_delta_parts.py +22 -0
  282. package/backend/tests/unit/providers/test_openai_provider.py +139 -0
  283. package/backend/tests/unit/providers/test_openai_responses.py +402 -0
  284. package/backend/tests/unit/providers/test_provider_gateway.py +359 -0
  285. package/backend/tests/unit/providers/test_think_tag_parser.py +36 -0
  286. package/backend/tests/unit/routes/__pycache__/test_prompts_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  287. package/backend/tests/unit/routes/__pycache__/test_providers_route.cpython-313-pytest-9.0.3.pyc +0 -0
  288. package/backend/tests/unit/routes/__pycache__/test_roles_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  289. package/backend/tests/unit/routes/__pycache__/test_settings_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  290. package/backend/tests/unit/routes/__pycache__/test_stats_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  291. package/backend/tests/unit/routes/test_prompts_routes.py +104 -0
  292. package/backend/tests/unit/routes/test_providers_route.py +368 -0
  293. package/backend/tests/unit/routes/test_roles_routes.py +426 -0
  294. package/backend/tests/unit/routes/test_settings_routes.py +1138 -0
  295. package/backend/tests/unit/routes/test_stats_routes.py +149 -0
  296. package/backend/tests/unit/runtime/__pycache__/test_bootstrap_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  297. package/backend/tests/unit/runtime/test_bootstrap_runtime.py +1012 -0
  298. package/backend/tests/unit/sandbox/__pycache__/test_sandbox_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  299. package/backend/tests/unit/sandbox/test_sandbox_tools.py +78 -0
  300. package/backend/tests/unit/security/__pycache__/test_security.cpython-313-pytest-9.0.3.pyc +0 -0
  301. package/backend/tests/unit/security/test_security.py +110 -0
  302. package/backend/tests/unit/settings/__pycache__/test_settings_roles.cpython-313-pytest-9.0.3.pyc +0 -0
  303. package/backend/tests/unit/settings/test_settings_roles.py +711 -0
  304. package/backend/tests/unit/test_access.py +45 -0
  305. package/backend/tests/unit/test_cli.py +124 -0
  306. package/backend/tests/unit/test_graph_runtime.py +72 -0
  307. package/backend/tests/unit/test_network.py +51 -0
  308. package/backend/tests/unit/test_state_sqlite_storage.py +93 -0
  309. package/backend/tests/unit/test_workspace_store.py +231 -0
  310. package/backend/tests/unit/tools/__pycache__/test_connect_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  311. package/backend/tests/unit/tools/__pycache__/test_create_agent_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  312. package/backend/tests/unit/tools/__pycache__/test_delete_tab_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  313. package/backend/tests/unit/tools/__pycache__/test_edit_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  314. package/backend/tests/unit/tools/__pycache__/test_exec_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  315. package/backend/tests/unit/tools/__pycache__/test_fetch_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  316. package/backend/tests/unit/tools/__pycache__/test_manage_prompts_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  317. package/backend/tests/unit/tools/__pycache__/test_manage_providers_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  318. package/backend/tests/unit/tools/__pycache__/test_manage_roles_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  319. package/backend/tests/unit/tools/__pycache__/test_manage_settings_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  320. package/backend/tests/unit/tools/__pycache__/test_read_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  321. package/backend/tests/unit/tools/__pycache__/test_set_permissions_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  322. package/backend/tests/unit/tools/__pycache__/test_todo_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  323. package/backend/tests/unit/tools/__pycache__/test_tool_registry.cpython-313-pytest-9.0.3.pyc +0 -0
  324. package/backend/tests/unit/tools/test_connect_tool.py +229 -0
  325. package/backend/tests/unit/tools/test_create_agent_tool.py +524 -0
  326. package/backend/tests/unit/tools/test_delete_tab_tool.py +83 -0
  327. package/backend/tests/unit/tools/test_edit_tool.py +115 -0
  328. package/backend/tests/unit/tools/test_exec_tool.py +81 -0
  329. package/backend/tests/unit/tools/test_fetch_tool.py +65 -0
  330. package/backend/tests/unit/tools/test_manage_prompts_tool.py +117 -0
  331. package/backend/tests/unit/tools/test_manage_providers_tool.py +458 -0
  332. package/backend/tests/unit/tools/test_manage_roles_tool.py +411 -0
  333. package/backend/tests/unit/tools/test_manage_settings_tool.py +608 -0
  334. package/backend/tests/unit/tools/test_read_tool.py +33 -0
  335. package/backend/tests/unit/tools/test_set_permissions_tool.py +391 -0
  336. package/backend/tests/unit/tools/test_todo_tool.py +37 -0
  337. package/backend/tests/unit/tools/test_tool_registry.py +91 -0
  338. package/backend/uv.lock +1144 -0
  339. package/bin/flowent.mjs +62 -35
  340. package/dist/frontend/assets/AssistantPage-B3Xc08AS.js +1 -0
  341. package/dist/frontend/assets/ChannelsPage-ByLd28xk.js +1 -0
  342. package/dist/frontend/assets/HomePage-C0hAx9_l.js +3 -0
  343. package/dist/frontend/assets/McpPage-DkrYLvBv.js +7 -0
  344. package/dist/frontend/assets/PageScaffold-D4jO9ooX.js +1 -0
  345. package/dist/frontend/assets/PromptsPage-DWA7rRJd.js +1 -0
  346. package/dist/frontend/assets/ProvidersPage-PUWT8seJ.js +3 -0
  347. package/dist/frontend/assets/RolesPage-CqcclGRw.js +1 -0
  348. package/dist/frontend/assets/SettingsPage-8tS2cJgX.js +3 -0
  349. package/dist/frontend/assets/StatsPage-BX9khYzu.js +1 -0
  350. package/dist/frontend/assets/ToolsPage-9Tl9FdeD.js +1 -0
  351. package/dist/frontend/assets/WorkspaceCommandDialog-CCXxjDL8.js +1 -0
  352. package/dist/frontend/assets/WorkspacePanels-aMdJ7ZH7.js +1 -0
  353. package/dist/frontend/assets/alert-dialog-kFYVQ7oX.js +1 -0
  354. package/dist/frontend/assets/badge-74-3jsCg.js +1 -0
  355. package/dist/frontend/assets/constants-XUzFf6i1.js +1 -0
  356. package/dist/frontend/assets/datetime-m6_O_Ci9.js +1 -0
  357. package/dist/frontend/assets/dialog-BeGSweF6.js +1 -0
  358. package/dist/frontend/assets/elk-worker.min-C9JGDOE-.js +6312 -0
  359. package/dist/frontend/assets/graph-vendor-CHpVij2M.css +1 -0
  360. package/dist/frontend/assets/graph-vendor-DRq_-6fV.js +7 -0
  361. package/dist/frontend/assets/index-BHC1Vhy8.css +1 -0
  362. package/dist/frontend/assets/index-CL1ALZ3r.js +10 -0
  363. package/dist/frontend/assets/layout.worker-jMHqAFbP.js +24 -0
  364. package/dist/frontend/assets/markdown-vendor-DVdy_w12.js +29 -0
  365. package/dist/frontend/assets/modelParams-CaHd0903.js +1 -0
  366. package/dist/frontend/assets/react-vendor-mEs_JJxa.js +9 -0
  367. package/dist/frontend/assets/roles-2OLDeTc5.js +1 -0
  368. package/dist/frontend/assets/rolldown-runtime-BYbx6iT9.js +1 -0
  369. package/dist/frontend/assets/select-DL_LPeDj.js +1 -0
  370. package/dist/frontend/assets/shared-CMxbpLeQ.js +1 -0
  371. package/dist/frontend/assets/triState-DEr3NkXV.js +1 -0
  372. package/dist/frontend/assets/ui-vendor-Dg9NNnWX.js +51 -0
  373. package/dist/frontend/index.html +36 -0
  374. package/package.json +28 -41
  375. package/dist/.next/BUILD_ID +0 -1
  376. package/dist/.next/app-path-routes-manifest.json +0 -6
  377. package/dist/.next/build-manifest.json +0 -20
  378. package/dist/.next/package.json +0 -1
  379. package/dist/.next/prerender-manifest.json +0 -114
  380. package/dist/.next/required-server-files.json +0 -333
  381. package/dist/.next/routes-manifest.json +0 -69
  382. package/dist/.next/server/app/_global-error/page/app-paths-manifest.json +0 -3
  383. package/dist/.next/server/app/_global-error/page/build-manifest.json +0 -16
  384. package/dist/.next/server/app/_global-error/page/next-font-manifest.json +0 -6
  385. package/dist/.next/server/app/_global-error/page/react-loadable-manifest.json +0 -1
  386. package/dist/.next/server/app/_global-error/page/server-reference-manifest.json +0 -4
  387. package/dist/.next/server/app/_global-error/page.js +0 -9
  388. package/dist/.next/server/app/_global-error/page.js.map +0 -5
  389. package/dist/.next/server/app/_global-error/page.js.nft.json +0 -1
  390. package/dist/.next/server/app/_global-error/page_client-reference-manifest.js +0 -3
  391. package/dist/.next/server/app/_global-error.html +0 -1
  392. package/dist/.next/server/app/_global-error.meta +0 -15
  393. package/dist/.next/server/app/_global-error.rsc +0 -14
  394. package/dist/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +0 -5
  395. package/dist/.next/server/app/_global-error.segments/_full.segment.rsc +0 -14
  396. package/dist/.next/server/app/_global-error.segments/_head.segment.rsc +0 -5
  397. package/dist/.next/server/app/_global-error.segments/_index.segment.rsc +0 -5
  398. package/dist/.next/server/app/_global-error.segments/_tree.segment.rsc +0 -1
  399. package/dist/.next/server/app/_not-found/page/app-paths-manifest.json +0 -3
  400. package/dist/.next/server/app/_not-found/page/build-manifest.json +0 -16
  401. package/dist/.next/server/app/_not-found/page/next-font-manifest.json +0 -10
  402. package/dist/.next/server/app/_not-found/page/react-loadable-manifest.json +0 -1
  403. package/dist/.next/server/app/_not-found/page/server-reference-manifest.json +0 -4
  404. package/dist/.next/server/app/_not-found/page.js +0 -13
  405. package/dist/.next/server/app/_not-found/page.js.map +0 -5
  406. package/dist/.next/server/app/_not-found/page.js.nft.json +0 -1
  407. package/dist/.next/server/app/_not-found/page_client-reference-manifest.js +0 -3
  408. package/dist/.next/server/app/_not-found.html +0 -1
  409. package/dist/.next/server/app/_not-found.meta +0 -16
  410. package/dist/.next/server/app/_not-found.rsc +0 -16
  411. package/dist/.next/server/app/_not-found.segments/_full.segment.rsc +0 -16
  412. package/dist/.next/server/app/_not-found.segments/_head.segment.rsc +0 -6
  413. package/dist/.next/server/app/_not-found.segments/_index.segment.rsc +0 -5
  414. package/dist/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +0 -5
  415. package/dist/.next/server/app/_not-found.segments/_not-found.segment.rsc +0 -5
  416. package/dist/.next/server/app/_not-found.segments/_tree.segment.rsc +0 -2
  417. package/dist/.next/server/app/icon.svg/route/app-paths-manifest.json +0 -3
  418. package/dist/.next/server/app/icon.svg/route/build-manifest.json +0 -9
  419. package/dist/.next/server/app/icon.svg/route.js +0 -6
  420. package/dist/.next/server/app/icon.svg/route.js.map +0 -5
  421. package/dist/.next/server/app/icon.svg/route.js.nft.json +0 -1
  422. package/dist/.next/server/app/icon.svg.meta +0 -1
  423. package/dist/.next/server/app/index.html +0 -1
  424. package/dist/.next/server/app/index.meta +0 -14
  425. package/dist/.next/server/app/index.rsc +0 -15
  426. package/dist/.next/server/app/index.segments/__PAGE__.segment.rsc +0 -5
  427. package/dist/.next/server/app/index.segments/_full.segment.rsc +0 -15
  428. package/dist/.next/server/app/index.segments/_head.segment.rsc +0 -6
  429. package/dist/.next/server/app/index.segments/_index.segment.rsc +0 -5
  430. package/dist/.next/server/app/index.segments/_tree.segment.rsc +0 -3
  431. package/dist/.next/server/app/page/app-paths-manifest.json +0 -3
  432. package/dist/.next/server/app/page/build-manifest.json +0 -16
  433. package/dist/.next/server/app/page/next-font-manifest.json +0 -10
  434. package/dist/.next/server/app/page/react-loadable-manifest.json +0 -1
  435. package/dist/.next/server/app/page/server-reference-manifest.json +0 -4
  436. package/dist/.next/server/app/page.js +0 -14
  437. package/dist/.next/server/app/page.js.map +0 -5
  438. package/dist/.next/server/app/page.js.nft.json +0 -1
  439. package/dist/.next/server/app/page_client-reference-manifest.js +0 -3
  440. package/dist/.next/server/app-paths-manifest.json +0 -6
  441. package/dist/.next/server/chunks/[externals]_next_dist_0arv.vj._.js +0 -3
  442. package/dist/.next/server/chunks/[root-of-the-server]__0vcj1q1._.js +0 -13
  443. package/dist/.next/server/chunks/[turbopack]_runtime.js +0 -903
  444. package/dist/.next/server/chunks/_next-internal_server_app_icon_svg_route_actions_0-0ehc~.js +0 -3
  445. package/dist/.next/server/chunks/ssr/05w9_next_dist_0ihu0u9._.js +0 -6
  446. package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_12u3mib._.js +0 -3
  447. package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_builtin_forbidden_04fbe_..js +0 -3
  448. package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_builtin_global-error_0brpl_..js +0 -3
  449. package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_builtin_unauthorized_0~2g66g.js +0 -3
  450. package/dist/.next/server/chunks/ssr/05w9_next_dist_esm_build_templates_app-page_0~cyr1_.js +0 -4
  451. package/dist/.next/server/chunks/ssr/05w9_next_dist_esm_build_templates_app-page_1105emf.js +0 -4
  452. package/dist/.next/server/chunks/ssr/05w9_next_dist_esm_build_templates_app-page_11uhyqv.js +0 -4
  453. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0.t9_75._.js +0 -33
  454. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0c0ud_z._.js +0 -3
  455. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0f9_8d4._.js +0 -3
  456. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0l5ko41._.js +0 -19
  457. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0mn6z7i._.js +0 -3
  458. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0npxxst._.js +0 -33
  459. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0qjhaca._.js +0 -3
  460. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0rwgw3s._.js +0 -3
  461. package/dist/.next/server/chunks/ssr/[turbopack]_runtime.js +0 -903
  462. package/dist/.next/server/chunks/ssr/_next-internal_server_app__global-error_page_actions_0k77kol.js +0 -3
  463. package/dist/.next/server/chunks/ssr/_next-internal_server_app__not-found_page_actions_0eq97pa.js +0 -3
  464. package/dist/.next/server/chunks/ssr/_next-internal_server_app_page_actions_09-gtaw.js +0 -3
  465. package/dist/.next/server/chunks/ssr/node_modules__pnpm_056~6.6._.js +0 -3
  466. package/dist/.next/server/chunks/ssr/node_modules__pnpm_0~j0k.e._.js +0 -33
  467. package/dist/.next/server/functions-config-manifest.json +0 -4
  468. package/dist/.next/server/middleware-build-manifest.js +0 -20
  469. package/dist/.next/server/middleware-manifest.json +0 -6
  470. package/dist/.next/server/next-font-manifest.js +0 -1
  471. package/dist/.next/server/next-font-manifest.json +0 -13
  472. package/dist/.next/server/pages/404.html +0 -1
  473. package/dist/.next/server/pages/500.html +0 -1
  474. package/dist/.next/server/pages-manifest.json +0 -4
  475. package/dist/.next/server/prefetch-hints.json +0 -1
  476. package/dist/.next/server/server-reference-manifest.js +0 -1
  477. package/dist/.next/server/server-reference-manifest.json +0 -5
  478. package/dist/.next/static/7FFlzRe2eS-D0Lw5oEpmC/_buildManifest.js +0 -11
  479. package/dist/.next/static/7FFlzRe2eS-D0Lw5oEpmC/_clientMiddlewareManifest.js +0 -1
  480. package/dist/.next/static/7FFlzRe2eS-D0Lw5oEpmC/_ssgManifest.js +0 -1
  481. package/dist/.next/static/chunks/01qk2~bgf76vu.js +0 -1
  482. package/dist/.next/static/chunks/03~yq9q893hmn.js +0 -1
  483. package/dist/.next/static/chunks/080queev.r2uy.js +0 -31
  484. package/dist/.next/static/chunks/0v3lyuj75aq50.js +0 -1
  485. package/dist/.next/static/chunks/10b~xdx5c-i7s.js +0 -5
  486. package/dist/.next/static/chunks/15~9l5n.~r-.4.css +0 -2
  487. package/dist/.next/static/chunks/turbopack-0m-970~qvs7sc.js +0 -1
  488. package/dist/.next/static/media/7178b3e590c64307-s.11.cyxs5p-0z~.woff2 +0 -0
  489. package/dist/.next/static/media/8a480f0b521d4e75-s.06d3mdzz5bre_.woff2 +0 -0
  490. package/dist/.next/static/media/caa3a2e1cccd8315-s.p.16t1db8_9y2o~.woff2 +0 -0
  491. package/dist/package.json +0 -87
  492. package/dist/server.js +0 -38
  493. /package/{dist/.next/server/app/icon.svg.body → backend/src/flowent/static/favicon.svg} +0 -0
  494. /package/dist/{.next/static/media/icon.0.r~afrtrocz9.svg → frontend/favicon.svg} +0 -0
@@ -0,0 +1,2467 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ import threading
7
+ from dataclasses import asdict, dataclass, field
8
+ from math import isfinite
9
+ from pathlib import Path
10
+
11
+ from loguru import logger
12
+
13
+ from flowent.prompts.steward import STEWARD_ROLE_SYSTEM_PROMPT
14
+
15
+ APP_DATA_DIR_ENV_VAR = "FLOWENT_APP_DATA_DIR"
16
+ WORKING_DIR = Path(os.getcwd()).resolve()
17
+
18
+
19
+ def _resolve_path_from_base(
20
+ raw_path: str | Path,
21
+ *,
22
+ base_dir: str | Path | None = None,
23
+ strict: bool = False,
24
+ ) -> Path:
25
+ path = Path(raw_path).expanduser()
26
+ if not path.is_absolute():
27
+ anchor = Path(base_dir).expanduser() if base_dir is not None else WORKING_DIR
28
+ path = anchor / path
29
+ return path.resolve(strict=strict)
30
+
31
+
32
+ def _resolve_startup_app_data_dir() -> Path:
33
+ raw_app_data_dir = os.environ.get(APP_DATA_DIR_ENV_VAR)
34
+ if isinstance(raw_app_data_dir, str) and raw_app_data_dir.strip():
35
+ return _resolve_path_from_base(raw_app_data_dir.strip(), strict=False)
36
+ return Path("~/.flowent").expanduser().resolve(strict=False)
37
+
38
+
39
+ APP_DATA_DIR = _resolve_startup_app_data_dir()
40
+ _SETTINGS_FILE = APP_DATA_DIR / "settings.json"
41
+ STEWARD_ROLE_NAME = "Steward"
42
+ WORKER_ROLE_NAME = "Worker"
43
+ CONDUCTOR_ROLE_NAME = "Conductor"
44
+ DESIGNER_ROLE_NAME = "Designer"
45
+ STEWARD_ROLE_DESCRIPTION = "Human-facing system entry role for task intake and workspace-level boundary management."
46
+ WORKER_ROLE_DESCRIPTION = "General execution role for narrow implementation, research, and file-oriented task work inside a workflow."
47
+ CONDUCTOR_ROLE_DESCRIPTION = "Default Leader role for workflow-level planning, Workflow Graph orchestration, and result synthesis."
48
+ DESIGNER_ROLE_DESCRIPTION = "Frontend implementation and visual design role for UI, layout, styling, and interaction refinement tasks."
49
+ STEWARD_ROLE_INCLUDED_TOOLS = [
50
+ "create_workflow",
51
+ "delete_workflow",
52
+ "set_permissions",
53
+ "list_workflows",
54
+ "list_roles",
55
+ "list_tools",
56
+ "manage_providers",
57
+ "manage_roles",
58
+ "manage_settings",
59
+ "manage_prompts",
60
+ ]
61
+ WORKER_ROLE_SYSTEM_PROMPT = (
62
+ "You are the Worker role - a narrow execution node inside a workflow. "
63
+ "Follow the assigned subtask, use the tools you were given to complete it, "
64
+ "and report back clearly. You are not the Human-facing system entrypoint "
65
+ "and you are not the workflow-level orchestrator."
66
+ )
67
+ CONDUCTOR_ROLE_SYSTEM_PROMPT = """\
68
+ You are the Conductor role currently used by a workflow's Leader.
69
+
70
+ Your responsibilities:
71
+ - Receive execution briefs from the Assistant for this workflow through the workflow's Leader identity
72
+ - Decide how the task should be decomposed inside the current workflow
73
+ - Design, expand, adjust, and simplify this workflow's Workflow Graph as the work evolves
74
+ - Coordinate agents, aggregate their results, and return a coherent result upstream to the Assistant
75
+
76
+ ## Ownership
77
+
78
+ - This role is the default behavior template for a workflow's Leader, not a separate product identity outside the Leader
79
+ - The workflow's Leader is the only owner-level entrypoint for this workflow
80
+ - You are not a global orchestrator shared across workflows
81
+ - The Assistant owns Human-facing intake and task-boundary management; the Leader owns this workflow's internal execution structure
82
+ - Regular task-node results should usually come back to you first, then you summarize and escalate upstream when appropriate
83
+
84
+ ## Decision Framework
85
+
86
+ - Start from the Assistant's brief, not from the Human directly.
87
+ - Analyze the task first, then choose the structure that best fits it: one Worker, fan-out, pipeline, fan-out-fan-in, reviewer loop, or another topology that matches the work.
88
+ - Do not default to creating a single Worker and handing it the entire task. Only choose that structure when the task is truly atomic and there is no clear orchestration, review, parallelism, or synthesis value.
89
+ - Prefer multi-agent parallelism over serial single-agent execution. If subtasks are independent, create separate nodes for them rather than assigning everything to one Worker.
90
+ - Prefer adding peer nodes to the current workflow with `create_agent`, then wire them with `connect` to match the topology you want.
91
+ - Treat this workflow as the execution boundary. Do not push internal Workflow Graph design back to the Assistant.
92
+ - Do not treat any single topology as the default. Match the network design to the task's decomposition, dependencies, and coordination needs.
93
+
94
+ ## Workflow
95
+
96
+ 1. **Receive** the brief from the Assistant as the current workflow's Leader
97
+ 2. **Plan** using `todo` - break into subtasks, decide what to delegate, and design the network structure that best fits the work
98
+ 3. **Inspect roles** with `list_roles`; use `list_tools` for a full tool inventory
99
+ 4. **Create the network structure** with `create_agent` and `connect`
100
+ 5. **Dispatch immediately** after creation: use `send` to give each node that should begin working its first concrete task, including where its result should go; creating nodes does not begin execution by itself
101
+ 6. **Adjust topology dynamically** with `create_agent` and `connect` when the structure needs to change during execution
102
+ 7. **Coordinate** as results arrive; update your plan when needed
103
+ 8. **Aggregate** and return the final result or escalation upstream to the Assistant
104
+
105
+ ## Guidelines
106
+
107
+ - Prefer `create_agent` and `connect` as the primary control plane for the current workflow
108
+ - Do not create a node and then `idle` without dispatching work unless you intentionally want the new node to stay idle
109
+ - Your default posture is orchestration, not being the long-running executor for specialized work
110
+ - When a task is primarily frontend implementation, UI design, visual design, page redesign, or interaction refinement, prefer creating a Designer node for that work
111
+ - When a task needs execution-heavy tools such as `read`, `exec`, `edit`, or `fetch` outside that frontend or UI design scope, create a Worker node to do that work
112
+ - Create agents with only the tools they need
113
+ - Use `write_dirs` for file write access
114
+ - When dispatching tasks to nodes, specify where each node should send its result and use `send` for that handoff. Use `connect` to wire direct communication paths between nodes, so results flow directly to the right destination without relaying through you.
115
+ - Prefer explicit network topology over ad-hoc relaying: wire synthesizers, reviewers, and feedback loops with `connect` rather than manually relaying every message yourself
116
+ - Once delegation is clearly the right move, execute it directly without asking the Assistant or Human
117
+ - Keep the overall workflow graph understandable; add complexity only when it materially improves throughput, quality, or resilience
118
+ """
119
+ DESIGNER_ROLE_SYSTEM_PROMPT = """\
120
+ You are the Designer role - a frontend implementation and visual design node inside a workflow.
121
+
122
+ Your responsibilities:
123
+ - Implement and refine frontend surfaces such as pages, components, layouts, and interaction details
124
+ - Make concrete design decisions about typography, spacing, color, motion, and overall visual direction when the task calls for them
125
+ - Produce polished UI changes directly with the tools you were given
126
+ - Report back clearly on what changed, what remains open, and any design tradeoffs that matter
127
+
128
+ ## Boundaries
129
+
130
+ - You are not the Human-facing system entrypoint
131
+ - You are not the workflow-level orchestrator
132
+ - You are not the default executor for unrelated backend or general-purpose coding work
133
+ - If the task is not actually about frontend implementation, UI design, or visual styling, hand it back or ask for a more suitable node
134
+ """
135
+ BUILTIN_ROLE_NAMES = frozenset(
136
+ {STEWARD_ROLE_NAME, WORKER_ROLE_NAME, CONDUCTOR_ROLE_NAME, DESIGNER_ROLE_NAME}
137
+ )
138
+ WORKER_ROLE_INCLUDED_TOOLS = ["read", "exec"]
139
+ CONDUCTOR_ROLE_INCLUDED_TOOLS = [
140
+ "create_agent",
141
+ "connect",
142
+ "list_workflows",
143
+ "list_roles",
144
+ "list_tools",
145
+ ]
146
+ DESIGNER_ROLE_INCLUDED_TOOLS = ["read", "edit", "exec"]
147
+ MODEL_REASONING_EFFORT_OPTIONS = frozenset({"none", "low", "medium", "high", "xhigh"})
148
+ MODEL_VERBOSITY_OPTIONS = frozenset({"low", "medium", "high"})
149
+ MODEL_RETRY_POLICY_OPTIONS = frozenset({"no_retry", "limited", "unlimited"})
150
+ PROVIDER_MODEL_SOURCE_OPTIONS = frozenset({"discovered", "manual"})
151
+ MCP_TRANSPORT_OPTIONS = frozenset({"stdio", "streamable_http"})
152
+ REMOVED_TOOL_NAMES = frozenset({"exit", "list_connections"})
153
+ RENAMED_TOOL_NAMES = {
154
+ "create_tab": "create_workflow",
155
+ "delete_tab": "delete_workflow",
156
+ "list_tabs": "list_workflows",
157
+ }
158
+ DEFAULT_LLM_TIMEOUT_MS = 10000
159
+ DEFAULT_LLM_MAX_RETRIES = 5
160
+ DEFAULT_LLM_RETRY_POLICY = "limited"
161
+ DEFAULT_LLM_RETRY_INITIAL_DELAY_SECONDS = 0.5
162
+ DEFAULT_LLM_RETRY_MAX_DELAY_SECONDS = 8.0
163
+ DEFAULT_LLM_RETRY_BACKOFF_CAP_RETRIES = 5
164
+ DEFAULT_LLM_AUTO_COMPACT_TOKEN_LIMIT: int | None = None
165
+ DEFAULT_ASSISTANT_ALLOW_NETWORK = True
166
+ DEFAULT_MCP_SERVER_STARTUP_TIMEOUT_SEC = 10
167
+ DEFAULT_MCP_SERVER_TOOL_TIMEOUT_SEC = 30
168
+
169
+
170
+ def build_default_app_data_dir() -> str:
171
+ return str(Path(_SETTINGS_FILE).parent.resolve(strict=False))
172
+
173
+
174
+ def build_default_working_dir() -> str:
175
+ return str(WORKING_DIR)
176
+
177
+
178
+ def get_app_data_dir_path() -> Path:
179
+ return Path(_SETTINGS_FILE).parent.resolve(strict=False)
180
+
181
+
182
+ def get_runtime_working_dir_path() -> Path:
183
+ return Path(get_settings().working_dir).resolve(strict=False)
184
+
185
+
186
+ def resolve_path(
187
+ raw_path: str | Path,
188
+ *,
189
+ base_dir: str | Path | None = None,
190
+ strict: bool = False,
191
+ ) -> Path:
192
+ base_path = base_dir if base_dir is not None else get_runtime_working_dir_path()
193
+ return _resolve_path_from_base(raw_path, base_dir=base_path, strict=strict)
194
+
195
+
196
+ def build_default_assistant_write_dirs(
197
+ working_dir: str | Path | None = None,
198
+ ) -> list[str]:
199
+ target_working_dir = working_dir if working_dir is not None else WORKING_DIR
200
+ return [str(resolve_path(target_working_dir, base_dir=WORKING_DIR, strict=False))]
201
+
202
+
203
+ @dataclass
204
+ class EventLogSettings:
205
+ timestamp_format: str = "absolute"
206
+
207
+
208
+ @dataclass
209
+ class AccessSettings:
210
+ code: str = ""
211
+ code_hash: str = ""
212
+ code_salt: str = ""
213
+ session_generation: int = 0
214
+ session_signing_secret: str = ""
215
+
216
+
217
+ @dataclass
218
+ class ProviderModelCatalogEntry:
219
+ model: str
220
+ source: str = "manual"
221
+ context_window_tokens: int | None = None
222
+ input_image: bool | None = None
223
+ output_image: bool | None = None
224
+
225
+
226
+ @dataclass
227
+ class ProviderConfig:
228
+ id: str
229
+ name: str
230
+ type: str
231
+ base_url: str
232
+ api_key: str
233
+ headers: dict[str, str] = field(default_factory=dict)
234
+ retry_429_delay_seconds: int = 0
235
+ models: list[ProviderModelCatalogEntry] = field(default_factory=list)
236
+
237
+
238
+ @dataclass
239
+ class RoleModelConfig:
240
+ provider_id: str
241
+ model: str
242
+
243
+
244
+ @dataclass
245
+ class ModelParams:
246
+ reasoning_effort: str | None = None
247
+ verbosity: str | None = None
248
+ max_output_tokens: int | None = None
249
+ temperature: float | None = None
250
+ top_p: float | None = None
251
+
252
+
253
+ def build_default_model_params() -> ModelParams:
254
+ return ModelParams()
255
+
256
+
257
+ def _normalize_assistant_write_dir(
258
+ raw_write_dir: str,
259
+ *,
260
+ base_dir: str | Path | None = None,
261
+ ) -> str:
262
+ return str(resolve_path(raw_write_dir, base_dir=base_dir, strict=False))
263
+
264
+
265
+ def _normalize_mcp_server_cwd(
266
+ raw_cwd: str,
267
+ *,
268
+ base_dir: str | Path | None = None,
269
+ ) -> str:
270
+ return str(resolve_path(raw_cwd, base_dir=base_dir, strict=False))
271
+
272
+
273
+ def build_working_dir(
274
+ raw_working_dir: object,
275
+ *,
276
+ field_name: str = "working_dir",
277
+ ) -> str:
278
+ if not isinstance(raw_working_dir, str):
279
+ raise ValueError(f"{field_name} must be a string")
280
+ stripped = raw_working_dir.strip()
281
+ if not stripped:
282
+ raise ValueError(f"{field_name} must not be empty")
283
+ try:
284
+ normalized = str(_resolve_path_from_base(stripped, strict=True))
285
+ except FileNotFoundError as exc:
286
+ raise ValueError(f"{field_name} must be an existing directory") from exc
287
+ except OSError as exc:
288
+ raise ValueError(f"{field_name} must be an accessible directory") from exc
289
+ path = Path(normalized)
290
+ if not path.is_dir():
291
+ raise ValueError(f"{field_name} must be an existing directory")
292
+ if not os.access(path, os.R_OK | os.X_OK):
293
+ raise ValueError(f"{field_name} must be an accessible directory")
294
+ return normalized
295
+
296
+
297
+ @dataclass
298
+ class RoleConfig:
299
+ name: str
300
+ system_prompt: str
301
+ description: str = ""
302
+ model: RoleModelConfig | None = None
303
+ model_params: ModelParams | None = None
304
+ included_tools: list[str] = field(default_factory=list)
305
+ excluded_tools: list[str] = field(default_factory=list)
306
+
307
+
308
+ @dataclass
309
+ class MCPServerConfig:
310
+ name: str
311
+ transport: str
312
+ enabled: bool = True
313
+ required: bool = False
314
+ startup_timeout_sec: int = DEFAULT_MCP_SERVER_STARTUP_TIMEOUT_SEC
315
+ tool_timeout_sec: int = DEFAULT_MCP_SERVER_TOOL_TIMEOUT_SEC
316
+ enabled_tools: list[str] = field(default_factory=list)
317
+ disabled_tools: list[str] = field(default_factory=list)
318
+ scopes: list[str] = field(default_factory=list)
319
+ oauth_resource: str = ""
320
+ launcher: str = ""
321
+ command: str = ""
322
+ args: list[str] = field(default_factory=list)
323
+ env: dict[str, str] = field(default_factory=dict)
324
+ env_vars: list[str] = field(default_factory=list)
325
+ cwd: str = ""
326
+ url: str = ""
327
+ bearer_token_env_var: str = ""
328
+ http_headers: dict[str, str] = field(default_factory=dict)
329
+ env_http_headers: list[str] = field(default_factory=list)
330
+
331
+
332
+ @dataclass
333
+ class ModelSettings:
334
+ active_provider_id: str = ""
335
+ active_model: str = ""
336
+ input_image: bool | None = None
337
+ output_image: bool | None = None
338
+ context_window_tokens: int | None = None
339
+ params: ModelParams = field(default_factory=build_default_model_params)
340
+ timeout_ms: int = DEFAULT_LLM_TIMEOUT_MS
341
+ retry_policy: str = DEFAULT_LLM_RETRY_POLICY
342
+ max_retries: int = DEFAULT_LLM_MAX_RETRIES
343
+ retry_initial_delay_seconds: float = DEFAULT_LLM_RETRY_INITIAL_DELAY_SECONDS
344
+ retry_max_delay_seconds: float = DEFAULT_LLM_RETRY_MAX_DELAY_SECONDS
345
+ retry_backoff_cap_retries: int = DEFAULT_LLM_RETRY_BACKOFF_CAP_RETRIES
346
+ auto_compact_token_limit: int | None = DEFAULT_LLM_AUTO_COMPACT_TOKEN_LIMIT
347
+
348
+
349
+ @dataclass
350
+ class AssistantSettings:
351
+ role_name: str = STEWARD_ROLE_NAME
352
+ allow_network: bool = DEFAULT_ASSISTANT_ALLOW_NETWORK
353
+ write_dirs: list[str] = field(default_factory=build_default_assistant_write_dirs)
354
+
355
+
356
+ @dataclass
357
+ class LeaderSettings:
358
+ role_name: str = CONDUCTOR_ROLE_NAME
359
+
360
+
361
+ @dataclass
362
+ class TelegramPendingChat:
363
+ chat_id: int
364
+ username: str | None = None
365
+ display_name: str = ""
366
+ first_seen_at: float = 0.0
367
+ last_seen_at: float = 0.0
368
+
369
+
370
+ @dataclass
371
+ class TelegramApprovedChat:
372
+ chat_id: int
373
+ username: str | None = None
374
+ display_name: str = ""
375
+ approved_at: float = 0.0
376
+
377
+
378
+ @dataclass
379
+ class TelegramSettings:
380
+ bot_token: str = ""
381
+ pending_chats: list[TelegramPendingChat] = field(default_factory=list)
382
+ approved_chats: list[TelegramApprovedChat] = field(default_factory=list)
383
+
384
+
385
+ @dataclass
386
+ class Settings:
387
+ app_data_dir: str = field(default_factory=build_default_app_data_dir)
388
+ working_dir: str = field(default_factory=build_default_working_dir)
389
+ event_log: EventLogSettings = field(default_factory=EventLogSettings)
390
+ access: AccessSettings = field(default_factory=AccessSettings)
391
+ assistant: AssistantSettings = field(default_factory=AssistantSettings)
392
+ leader: LeaderSettings = field(default_factory=LeaderSettings)
393
+ telegram: TelegramSettings = field(default_factory=TelegramSettings)
394
+ model: ModelSettings = field(default_factory=ModelSettings)
395
+ custom_prompt: str = ""
396
+ custom_post_prompt: str = ""
397
+ providers: list[ProviderConfig] = field(default_factory=list)
398
+ roles: list[RoleConfig] = field(default_factory=list)
399
+ mcp_servers: list[MCPServerConfig] = field(default_factory=list)
400
+
401
+
402
+ _cached_settings: Settings | None = None
403
+ _cached_settings_file_signature: tuple[int, int] | None = None
404
+ _settings_lock = threading.Lock()
405
+
406
+
407
+ def normalize_tool_names(tool_names: list[str]) -> list[str]:
408
+ normalized: list[str] = []
409
+ seen: set[str] = set()
410
+ for tool_name in tool_names:
411
+ stripped = tool_name.strip()
412
+ name = RENAMED_TOOL_NAMES.get(stripped, stripped)
413
+ if not name or name in seen or name in REMOVED_TOOL_NAMES:
414
+ continue
415
+ normalized.append(name)
416
+ seen.add(name)
417
+ return normalized
418
+
419
+
420
+ def validate_role_tool_config(
421
+ included_tools: list[str],
422
+ excluded_tools: list[str],
423
+ ) -> None:
424
+ overlap = sorted(set(included_tools) & set(excluded_tools))
425
+ if overlap:
426
+ raise ValueError(
427
+ "included_tools and excluded_tools cannot overlap: " + ", ".join(overlap)
428
+ )
429
+
430
+
431
+ def serialize_role_model(
432
+ role_model: RoleModelConfig | None,
433
+ ) -> dict[str, str] | None:
434
+ if role_model is None:
435
+ return None
436
+ return {
437
+ "provider_id": role_model.provider_id,
438
+ "model": role_model.model,
439
+ }
440
+
441
+
442
+ def serialize_model_params(
443
+ model_params: ModelParams | None,
444
+ ) -> dict[str, object] | None:
445
+ if model_params is None:
446
+ return None
447
+ return {
448
+ "reasoning_effort": model_params.reasoning_effort,
449
+ "verbosity": model_params.verbosity,
450
+ "max_output_tokens": model_params.max_output_tokens,
451
+ "temperature": model_params.temperature,
452
+ "top_p": model_params.top_p,
453
+ }
454
+
455
+
456
+ def is_empty_model_params(model_params: ModelParams | None) -> bool:
457
+ return model_params is None or all(
458
+ value is None for value in asdict(model_params).values()
459
+ )
460
+
461
+
462
+ def merge_model_params(
463
+ defaults: ModelParams | None,
464
+ override: ModelParams | None,
465
+ ) -> ModelParams | None:
466
+ merged = asdict(defaults) if defaults is not None else asdict(ModelParams())
467
+ if override is not None:
468
+ for key, value in asdict(override).items():
469
+ if value is not None:
470
+ merged[key] = value
471
+ params = ModelParams(**merged)
472
+ return None if is_empty_model_params(params) else params
473
+
474
+
475
+ def build_model_params_from_mapping(raw_model_params: object) -> ModelParams | None:
476
+ if raw_model_params is None:
477
+ return None
478
+ if not isinstance(raw_model_params, dict):
479
+ raise ValueError("model_params must be an object or null")
480
+
481
+ raw_reasoning_effort = raw_model_params.get("reasoning_effort")
482
+ raw_verbosity = raw_model_params.get("verbosity")
483
+ raw_max_output_tokens = raw_model_params.get("max_output_tokens")
484
+ raw_temperature = raw_model_params.get("temperature")
485
+ raw_top_p = raw_model_params.get("top_p")
486
+
487
+ if raw_reasoning_effort is not None:
488
+ if not isinstance(raw_reasoning_effort, str):
489
+ raise ValueError("model_params.reasoning_effort must be a string")
490
+ reasoning_effort = raw_reasoning_effort.strip().lower()
491
+ if reasoning_effort and reasoning_effort not in MODEL_REASONING_EFFORT_OPTIONS:
492
+ raise ValueError(
493
+ "model_params.reasoning_effort must be one of: "
494
+ + ", ".join(sorted(MODEL_REASONING_EFFORT_OPTIONS))
495
+ )
496
+ else:
497
+ reasoning_effort = None
498
+
499
+ if raw_verbosity is not None:
500
+ if not isinstance(raw_verbosity, str):
501
+ raise ValueError("model_params.verbosity must be a string")
502
+ verbosity = raw_verbosity.strip().lower()
503
+ if verbosity and verbosity not in MODEL_VERBOSITY_OPTIONS:
504
+ raise ValueError(
505
+ "model_params.verbosity must be one of: "
506
+ + ", ".join(sorted(MODEL_VERBOSITY_OPTIONS))
507
+ )
508
+ else:
509
+ verbosity = None
510
+
511
+ if raw_max_output_tokens is not None:
512
+ if isinstance(raw_max_output_tokens, bool) or not isinstance(
513
+ raw_max_output_tokens, int
514
+ ):
515
+ raise ValueError("model_params.max_output_tokens must be an integer")
516
+ if raw_max_output_tokens <= 0:
517
+ raise ValueError("model_params.max_output_tokens must be greater than 0")
518
+ max_output_tokens = raw_max_output_tokens
519
+ else:
520
+ max_output_tokens = None
521
+
522
+ if raw_temperature is not None:
523
+ if isinstance(raw_temperature, bool) or not isinstance(
524
+ raw_temperature, (int, float)
525
+ ):
526
+ raise ValueError("model_params.temperature must be a number")
527
+ temperature = float(raw_temperature)
528
+ if not isfinite(temperature) or temperature < 0 or temperature > 2:
529
+ raise ValueError("model_params.temperature must be between 0 and 2")
530
+ else:
531
+ temperature = None
532
+
533
+ if raw_top_p is not None:
534
+ if isinstance(raw_top_p, bool) or not isinstance(raw_top_p, (int, float)):
535
+ raise ValueError("model_params.top_p must be a number")
536
+ top_p = float(raw_top_p)
537
+ if not isfinite(top_p) or top_p <= 0 or top_p > 1:
538
+ raise ValueError("model_params.top_p must be greater than 0 and at most 1")
539
+ else:
540
+ top_p = None
541
+
542
+ params = ModelParams(
543
+ reasoning_effort=reasoning_effort or None,
544
+ verbosity=verbosity or None,
545
+ max_output_tokens=max_output_tokens,
546
+ temperature=temperature,
547
+ top_p=top_p,
548
+ )
549
+ return None if is_empty_model_params(params) else params
550
+
551
+
552
+ def build_model_max_retries(
553
+ raw_max_retries: object,
554
+ *,
555
+ field_name: str = "model.max_retries",
556
+ ) -> int:
557
+ if isinstance(raw_max_retries, bool) or not isinstance(raw_max_retries, int):
558
+ raise ValueError(f"{field_name} must be an integer")
559
+ if raw_max_retries <= 0:
560
+ raise ValueError(f"{field_name} must be greater than 0")
561
+ return raw_max_retries
562
+
563
+
564
+ def build_model_input_image(
565
+ raw_input_image: object,
566
+ *,
567
+ field_name: str = "model.input_image",
568
+ ) -> bool | None:
569
+ if raw_input_image is None:
570
+ return None
571
+ if not isinstance(raw_input_image, bool):
572
+ raise ValueError(f"{field_name} must be a boolean or null")
573
+ return raw_input_image
574
+
575
+
576
+ def build_model_output_image(
577
+ raw_output_image: object,
578
+ *,
579
+ field_name: str = "model.output_image",
580
+ ) -> bool | None:
581
+ if raw_output_image is None:
582
+ return None
583
+ if not isinstance(raw_output_image, bool):
584
+ raise ValueError(f"{field_name} must be a boolean or null")
585
+ return raw_output_image
586
+
587
+
588
+ def build_model_context_window_tokens(
589
+ raw_context_window_tokens: object,
590
+ *,
591
+ field_name: str = "model.context_window_tokens",
592
+ ) -> int | None:
593
+ if raw_context_window_tokens is None:
594
+ return None
595
+ if isinstance(raw_context_window_tokens, bool) or not isinstance(
596
+ raw_context_window_tokens, int
597
+ ):
598
+ raise ValueError(f"{field_name} must be an integer or null")
599
+ if raw_context_window_tokens <= 0:
600
+ raise ValueError(f"{field_name} must be greater than 0")
601
+ return raw_context_window_tokens
602
+
603
+
604
+ def build_model_auto_compact_token_limit(
605
+ raw_auto_compact_token_limit: object,
606
+ *,
607
+ field_name: str = "model.auto_compact_token_limit",
608
+ ) -> int | None:
609
+ if raw_auto_compact_token_limit is None:
610
+ return None
611
+ if isinstance(raw_auto_compact_token_limit, bool) or not isinstance(
612
+ raw_auto_compact_token_limit, int
613
+ ):
614
+ raise ValueError(f"{field_name} must be an integer or null")
615
+ if raw_auto_compact_token_limit <= 0:
616
+ raise ValueError(f"{field_name} must be greater than 0")
617
+ return raw_auto_compact_token_limit
618
+
619
+
620
+ def build_assistant_allow_network(
621
+ raw_allow_network: object,
622
+ *,
623
+ field_name: str = "assistant.allow_network",
624
+ ) -> bool:
625
+ if not isinstance(raw_allow_network, bool):
626
+ raise ValueError(f"{field_name} must be a boolean")
627
+ return raw_allow_network
628
+
629
+
630
+ def build_assistant_write_dirs(
631
+ raw_write_dirs: object,
632
+ *,
633
+ field_name: str = "assistant.write_dirs",
634
+ base_dir: str | Path | None = None,
635
+ ) -> list[str]:
636
+ if not isinstance(raw_write_dirs, list):
637
+ raise ValueError(f"{field_name} must be an array of strings")
638
+
639
+ normalized: list[str] = []
640
+ seen: set[str] = set()
641
+ for raw_item in raw_write_dirs:
642
+ if not isinstance(raw_item, str):
643
+ raise ValueError(f"{field_name} must be an array of strings")
644
+ stripped = raw_item.strip()
645
+ if not stripped:
646
+ continue
647
+ normalized_item = _normalize_assistant_write_dir(
648
+ stripped,
649
+ base_dir=base_dir,
650
+ )
651
+ if normalized_item in seen:
652
+ continue
653
+ seen.add(normalized_item)
654
+ normalized.append(normalized_item)
655
+ return normalized
656
+
657
+
658
+ def build_mcp_server_mounts(
659
+ raw_server_names: object,
660
+ *,
661
+ field_name: str,
662
+ ) -> list[str]:
663
+ if not isinstance(raw_server_names, list):
664
+ raise ValueError(f"{field_name} must be an array of strings")
665
+
666
+ normalized: list[str] = []
667
+ seen: set[str] = set()
668
+ for raw_item in raw_server_names:
669
+ if not isinstance(raw_item, str):
670
+ raise ValueError(f"{field_name} must be an array of strings")
671
+ name = raw_item.strip()
672
+ if not name or name in seen:
673
+ continue
674
+ normalized.append(name)
675
+ seen.add(name)
676
+ return normalized
677
+
678
+
679
+ def build_model_retry_policy(
680
+ raw_retry_policy: object,
681
+ *,
682
+ field_name: str = "model.retry_policy",
683
+ ) -> str:
684
+ if not isinstance(raw_retry_policy, str):
685
+ raise ValueError(f"{field_name} must be a string")
686
+ retry_policy = raw_retry_policy.strip().lower()
687
+ if retry_policy not in MODEL_RETRY_POLICY_OPTIONS:
688
+ raise ValueError(
689
+ f"{field_name} must be one of: "
690
+ + ", ".join(sorted(MODEL_RETRY_POLICY_OPTIONS))
691
+ )
692
+ return retry_policy
693
+
694
+
695
+ def build_model_retry_initial_delay_seconds(
696
+ raw_delay_seconds: object,
697
+ *,
698
+ field_name: str = "model.retry_initial_delay_seconds",
699
+ ) -> float:
700
+ if isinstance(raw_delay_seconds, bool) or not isinstance(
701
+ raw_delay_seconds, (int, float)
702
+ ):
703
+ raise ValueError(f"{field_name} must be a number")
704
+ delay_seconds = float(raw_delay_seconds)
705
+ if not isfinite(delay_seconds) or delay_seconds <= 0:
706
+ raise ValueError(f"{field_name} must be greater than 0")
707
+ return delay_seconds
708
+
709
+
710
+ def build_model_retry_max_delay_seconds(
711
+ raw_delay_seconds: object,
712
+ *,
713
+ field_name: str = "model.retry_max_delay_seconds",
714
+ ) -> float:
715
+ if isinstance(raw_delay_seconds, bool) or not isinstance(
716
+ raw_delay_seconds, (int, float)
717
+ ):
718
+ raise ValueError(f"{field_name} must be a number")
719
+ delay_seconds = float(raw_delay_seconds)
720
+ if not isfinite(delay_seconds) or delay_seconds <= 0:
721
+ raise ValueError(f"{field_name} must be greater than 0")
722
+ return delay_seconds
723
+
724
+
725
+ def build_model_retry_backoff_cap_retries(
726
+ raw_cap_retries: object,
727
+ *,
728
+ field_name: str = "model.retry_backoff_cap_retries",
729
+ ) -> int:
730
+ if isinstance(raw_cap_retries, bool) or not isinstance(raw_cap_retries, int):
731
+ raise ValueError(f"{field_name} must be an integer")
732
+ if raw_cap_retries <= 0:
733
+ raise ValueError(f"{field_name} must be greater than 0")
734
+ return raw_cap_retries
735
+
736
+
737
+ def validate_model_retry_backoff_settings(
738
+ *,
739
+ retry_initial_delay_seconds: float,
740
+ retry_max_delay_seconds: float,
741
+ ) -> None:
742
+ if retry_max_delay_seconds < retry_initial_delay_seconds:
743
+ raise ValueError(
744
+ "model.retry_max_delay_seconds must be greater than or equal to "
745
+ "model.retry_initial_delay_seconds"
746
+ )
747
+
748
+
749
+ def build_model_timeout_ms(
750
+ raw_timeout_ms: object,
751
+ *,
752
+ field_name: str = "model.timeout_ms",
753
+ ) -> int:
754
+ if isinstance(raw_timeout_ms, bool) or not isinstance(raw_timeout_ms, int):
755
+ raise ValueError(f"{field_name} must be an integer")
756
+ if raw_timeout_ms <= 0:
757
+ raise ValueError(f"{field_name} must be greater than 0")
758
+ return raw_timeout_ms
759
+
760
+
761
+ def build_mcp_transport(
762
+ raw_transport: object,
763
+ *,
764
+ field_name: str = "transport",
765
+ ) -> str:
766
+ if not isinstance(raw_transport, str):
767
+ raise ValueError(f"{field_name} must be a string")
768
+ transport = raw_transport.strip().lower()
769
+ if transport not in MCP_TRANSPORT_OPTIONS:
770
+ raise ValueError(
771
+ f"{field_name} must be one of: " + ", ".join(sorted(MCP_TRANSPORT_OPTIONS))
772
+ )
773
+ return transport
774
+
775
+
776
+ def build_mcp_timeout_seconds(
777
+ raw_timeout_seconds: object,
778
+ *,
779
+ field_name: str,
780
+ ) -> int:
781
+ if isinstance(raw_timeout_seconds, bool) or not isinstance(
782
+ raw_timeout_seconds, int
783
+ ):
784
+ raise ValueError(f"{field_name} must be an integer")
785
+ if raw_timeout_seconds <= 0:
786
+ raise ValueError(f"{field_name} must be greater than 0")
787
+ return raw_timeout_seconds
788
+
789
+
790
+ def build_mcp_string_list(
791
+ raw_items: object,
792
+ *,
793
+ field_name: str,
794
+ ) -> list[str]:
795
+ if not isinstance(raw_items, list):
796
+ raise ValueError(f"{field_name} must be an array of strings")
797
+ normalized: list[str] = []
798
+ seen: set[str] = set()
799
+ for raw_item in raw_items:
800
+ if not isinstance(raw_item, str):
801
+ raise ValueError(f"{field_name} must be an array of strings")
802
+ item = raw_item.strip()
803
+ if not item or item in seen:
804
+ continue
805
+ normalized.append(item)
806
+ seen.add(item)
807
+ return normalized
808
+
809
+
810
+ def build_mcp_env_var_names(
811
+ raw_items: object,
812
+ *,
813
+ field_name: str,
814
+ ) -> list[str]:
815
+ return build_mcp_string_list(raw_items, field_name=field_name)
816
+
817
+
818
+ def build_mcp_cwd(
819
+ raw_cwd: object,
820
+ *,
821
+ field_name: str,
822
+ base_dir: str | Path | None = None,
823
+ ) -> str:
824
+ if raw_cwd is None:
825
+ return ""
826
+ if not isinstance(raw_cwd, str):
827
+ raise ValueError(f"{field_name} must be a string")
828
+ cwd = raw_cwd.strip()
829
+ if not cwd:
830
+ return ""
831
+ return _normalize_mcp_server_cwd(cwd, base_dir=base_dir)
832
+
833
+
834
+ def build_provider_headers(
835
+ raw_headers: object,
836
+ *,
837
+ field_name: str = "headers",
838
+ ) -> dict[str, str]:
839
+ if raw_headers is None:
840
+ return {}
841
+ if not isinstance(raw_headers, dict):
842
+ raise ValueError(f"{field_name} must be a JSON object")
843
+
844
+ headers: dict[str, str] = {}
845
+ for key, value in raw_headers.items():
846
+ if not isinstance(key, str) or not isinstance(value, str):
847
+ raise ValueError(f"{field_name} must be a JSON object of string values")
848
+ headers[key] = value
849
+ return headers
850
+
851
+
852
+ def build_provider_retry_429_delay_seconds(
853
+ raw_delay_seconds: object,
854
+ *,
855
+ field_name: str = "retry_429_delay_seconds",
856
+ ) -> int:
857
+ if isinstance(raw_delay_seconds, bool) or not isinstance(raw_delay_seconds, int):
858
+ raise ValueError(f"{field_name} must be an integer")
859
+ if raw_delay_seconds < 0:
860
+ raise ValueError(f"{field_name} must be greater than or equal to 0")
861
+ return raw_delay_seconds
862
+
863
+
864
+ def _normalize_provider_headers(raw_headers: object) -> tuple[dict[str, str], bool]:
865
+ if raw_headers is None:
866
+ return {}, False
867
+ if not isinstance(raw_headers, dict):
868
+ return {}, True
869
+
870
+ headers: dict[str, str] = {}
871
+ migrated = False
872
+ for key, value in raw_headers.items():
873
+ if not isinstance(key, str) or not isinstance(value, str):
874
+ migrated = True
875
+ continue
876
+ headers[key] = value
877
+ return headers, migrated
878
+
879
+
880
+ def _normalize_provider_model_source(raw_source: object) -> tuple[str, bool]:
881
+ if raw_source is None:
882
+ return "manual", True
883
+ if not isinstance(raw_source, str):
884
+ return "manual", True
885
+ normalized = raw_source.strip().lower()
886
+ if normalized not in PROVIDER_MODEL_SOURCE_OPTIONS:
887
+ return "manual", True
888
+ return normalized, normalized != raw_source
889
+
890
+
891
+ def _normalize_provider_model_catalog_entries(
892
+ raw_models: object,
893
+ ) -> tuple[list[ProviderModelCatalogEntry], bool]:
894
+ if raw_models is None:
895
+ return [], False
896
+ if not isinstance(raw_models, list):
897
+ return [], True
898
+
899
+ entries_by_model: dict[str, ProviderModelCatalogEntry] = {}
900
+ migrated = False
901
+ for raw_entry in raw_models:
902
+ if not isinstance(raw_entry, dict):
903
+ migrated = True
904
+ continue
905
+ raw_model = raw_entry.get("model")
906
+ if not isinstance(raw_model, str) or not raw_model.strip():
907
+ migrated = True
908
+ continue
909
+ model = raw_model.strip()
910
+ source, source_migrated = _normalize_provider_model_source(
911
+ raw_entry.get("source")
912
+ )
913
+ input_image, input_image_migrated = _normalize_nullable_bool(
914
+ raw_entry.get("input_image")
915
+ )
916
+ output_image, output_image_migrated = _normalize_nullable_bool(
917
+ raw_entry.get("output_image")
918
+ )
919
+ context_window_tokens, context_window_tokens_migrated = _normalize_positive_int(
920
+ raw_entry.get("context_window_tokens")
921
+ )
922
+ migrated = (
923
+ migrated
924
+ or source_migrated
925
+ or input_image_migrated
926
+ or output_image_migrated
927
+ or context_window_tokens_migrated
928
+ or model != raw_model
929
+ or model in entries_by_model
930
+ )
931
+ entries_by_model[model] = ProviderModelCatalogEntry(
932
+ model=model,
933
+ source=source,
934
+ context_window_tokens=context_window_tokens,
935
+ input_image=input_image,
936
+ output_image=output_image,
937
+ )
938
+ return list(entries_by_model.values()), migrated
939
+
940
+
941
+ def _fallback_role_description(role_name: str, system_prompt: str) -> str:
942
+ for line in system_prompt.splitlines():
943
+ stripped = " ".join(line.split())
944
+ if stripped:
945
+ return stripped[:160]
946
+ normalized_role_name = " ".join(role_name.split())
947
+ if normalized_role_name:
948
+ return f"{normalized_role_name} role."
949
+ return "Custom role."
950
+
951
+
952
+ def _normalize_role_description(
953
+ raw_description: object,
954
+ *,
955
+ role_name: str,
956
+ system_prompt: str,
957
+ ) -> tuple[str, bool]:
958
+ if isinstance(raw_description, str):
959
+ stripped = " ".join(raw_description.split())
960
+ if stripped:
961
+ return stripped, stripped != raw_description
962
+ return _fallback_role_description(role_name, system_prompt), True
963
+
964
+
965
+ def serialize_provider_model_catalog_entry(
966
+ entry: ProviderModelCatalogEntry,
967
+ ) -> dict[str, object]:
968
+ return {
969
+ "model": entry.model,
970
+ "source": entry.source,
971
+ "context_window_tokens": entry.context_window_tokens,
972
+ "input_image": entry.input_image,
973
+ "output_image": entry.output_image,
974
+ }
975
+
976
+
977
+ def serialize_provider(provider: ProviderConfig) -> dict[str, object]:
978
+ return {
979
+ "id": provider.id,
980
+ "name": provider.name,
981
+ "type": provider.type,
982
+ "base_url": provider.base_url,
983
+ "api_key": provider.api_key,
984
+ "headers": dict(provider.headers),
985
+ "retry_429_delay_seconds": provider.retry_429_delay_seconds,
986
+ "models": [
987
+ serialize_provider_model_catalog_entry(entry) for entry in provider.models
988
+ ],
989
+ }
990
+
991
+
992
+ def serialize_mcp_server(server: MCPServerConfig) -> dict[str, object]:
993
+ return {
994
+ "name": server.name,
995
+ "transport": server.transport,
996
+ "enabled": server.enabled,
997
+ "required": server.required,
998
+ "startup_timeout_sec": server.startup_timeout_sec,
999
+ "tool_timeout_sec": server.tool_timeout_sec,
1000
+ "enabled_tools": list(server.enabled_tools),
1001
+ "disabled_tools": list(server.disabled_tools),
1002
+ "scopes": list(server.scopes),
1003
+ "oauth_resource": server.oauth_resource,
1004
+ "launcher": server.launcher,
1005
+ "command": server.command,
1006
+ "args": list(server.args),
1007
+ "env": dict(server.env),
1008
+ "env_vars": list(server.env_vars),
1009
+ "cwd": server.cwd,
1010
+ "url": server.url,
1011
+ "bearer_token_env_var": server.bearer_token_env_var,
1012
+ "http_headers": dict(server.http_headers),
1013
+ "env_http_headers": list(server.env_http_headers),
1014
+ }
1015
+
1016
+
1017
+ def serialize_role(role: RoleConfig) -> dict[str, object]:
1018
+ return {
1019
+ "name": role.name,
1020
+ "description": role.description,
1021
+ "system_prompt": role.system_prompt,
1022
+ "model": serialize_role_model(role.model),
1023
+ "model_params": serialize_model_params(role.model_params),
1024
+ "included_tools": list(role.included_tools),
1025
+ "excluded_tools": list(role.excluded_tools),
1026
+ "is_builtin": is_builtin_role_name(role.name),
1027
+ }
1028
+
1029
+
1030
+ def mask_secret(secret: str) -> str:
1031
+ if not secret:
1032
+ return ""
1033
+ return f"sk-...{secret[-4:]}"
1034
+
1035
+
1036
+ def serialize_telegram_settings(
1037
+ telegram: TelegramSettings,
1038
+ *,
1039
+ mask_token: bool = True,
1040
+ ) -> dict[str, object]:
1041
+ return {
1042
+ "bot_token": mask_secret(telegram.bot_token)
1043
+ if mask_token
1044
+ else telegram.bot_token,
1045
+ "pending_chats": [asdict(chat) for chat in telegram.pending_chats],
1046
+ "approved_chats": [asdict(chat) for chat in telegram.approved_chats],
1047
+ }
1048
+
1049
+
1050
+ def serialize_settings(
1051
+ settings: Settings,
1052
+ *,
1053
+ mask_telegram_token: bool = True,
1054
+ ) -> dict[str, object]:
1055
+ data = asdict(settings)
1056
+ provider = find_provider(settings, settings.model.active_provider_id)
1057
+ if provider is None or not settings.model.active_model.strip():
1058
+ model_info = None
1059
+ else:
1060
+ model_info = resolve_model_info(
1061
+ provider=provider,
1062
+ model_id=settings.model.active_model,
1063
+ input_image=settings.model.input_image,
1064
+ output_image=settings.model.output_image,
1065
+ context_window_tokens=settings.model.context_window_tokens,
1066
+ )
1067
+ data["model"]["capabilities"] = (
1068
+ asdict(model_info.capabilities) if model_info is not None else None
1069
+ )
1070
+ data["model"]["resolved_context_window_tokens"] = (
1071
+ model_info.context_window_tokens if model_info is not None else None
1072
+ )
1073
+ data["telegram"] = serialize_telegram_settings(
1074
+ settings.telegram,
1075
+ mask_token=mask_telegram_token,
1076
+ )
1077
+ data["access"] = {
1078
+ "configured": bool(
1079
+ settings.access.code.strip()
1080
+ and settings.access.code_hash.strip()
1081
+ and settings.access.code_salt.strip()
1082
+ )
1083
+ }
1084
+ data["mcp_servers"] = [
1085
+ serialize_mcp_server(server) for server in settings.mcp_servers
1086
+ ]
1087
+ return data
1088
+
1089
+
1090
+ def find_provider_model_catalog_entry(
1091
+ provider: ProviderConfig,
1092
+ model_id: str,
1093
+ ) -> ProviderModelCatalogEntry | None:
1094
+ normalized_model_id = model_id.strip()
1095
+ if not normalized_model_id:
1096
+ return None
1097
+ for entry in provider.models:
1098
+ if entry.model.strip() == normalized_model_id:
1099
+ return entry
1100
+ return None
1101
+
1102
+
1103
+ def resolve_model_info(
1104
+ *,
1105
+ provider: ProviderConfig,
1106
+ model_id: str,
1107
+ input_image: bool | None = None,
1108
+ output_image: bool | None = None,
1109
+ context_window_tokens: int | None = None,
1110
+ ):
1111
+ from flowent.model_metadata import build_model_info
1112
+
1113
+ catalog_entry = find_provider_model_catalog_entry(provider, model_id)
1114
+ return build_model_info(
1115
+ provider_type=provider.type,
1116
+ model_id=model_id,
1117
+ input_image=(
1118
+ input_image
1119
+ if input_image is not None
1120
+ else catalog_entry.input_image
1121
+ if catalog_entry is not None
1122
+ else None
1123
+ ),
1124
+ output_image=(
1125
+ output_image
1126
+ if output_image is not None
1127
+ else catalog_entry.output_image
1128
+ if catalog_entry is not None
1129
+ else None
1130
+ ),
1131
+ context_window_tokens=(
1132
+ context_window_tokens
1133
+ if context_window_tokens is not None
1134
+ else catalog_entry.context_window_tokens
1135
+ if catalog_entry is not None
1136
+ else None
1137
+ ),
1138
+ )
1139
+
1140
+
1141
+ def _normalize_role_model(
1142
+ raw_role_model: object,
1143
+ *,
1144
+ default_provider_id: str,
1145
+ ) -> tuple[RoleModelConfig | None, bool]:
1146
+ if raw_role_model is None:
1147
+ return None, False
1148
+
1149
+ if isinstance(raw_role_model, dict):
1150
+ provider_id = str(raw_role_model.get("provider_id", "")).strip()
1151
+ model = str(raw_role_model.get("model", "")).strip()
1152
+ if provider_id and model:
1153
+ return RoleModelConfig(provider_id=provider_id, model=model), False
1154
+ if model and default_provider_id:
1155
+ return (
1156
+ RoleModelConfig(provider_id=default_provider_id, model=model),
1157
+ True,
1158
+ )
1159
+ return None, bool(provider_id or model)
1160
+
1161
+ if isinstance(raw_role_model, str):
1162
+ model = raw_role_model.strip()
1163
+ if model and default_provider_id:
1164
+ return (
1165
+ RoleModelConfig(provider_id=default_provider_id, model=model),
1166
+ True,
1167
+ )
1168
+ return None, True
1169
+
1170
+ return None, True
1171
+
1172
+
1173
+ def _normalize_model_param_choice(
1174
+ raw_value: object,
1175
+ *,
1176
+ allowed: frozenset[str],
1177
+ ) -> tuple[str | None, bool]:
1178
+ if raw_value is None:
1179
+ return None, False
1180
+ if not isinstance(raw_value, str):
1181
+ return None, True
1182
+
1183
+ value = raw_value.strip().lower()
1184
+ if not value:
1185
+ return None, raw_value != ""
1186
+ if value not in allowed:
1187
+ return None, True
1188
+ return value, value != raw_value
1189
+
1190
+
1191
+ def _normalize_positive_int(raw_value: object) -> tuple[int | None, bool]:
1192
+ if raw_value is None:
1193
+ return None, False
1194
+ if isinstance(raw_value, bool):
1195
+ return None, True
1196
+ if isinstance(raw_value, int):
1197
+ return (raw_value, False) if raw_value > 0 else (None, True)
1198
+ if isinstance(raw_value, float) and raw_value.is_integer():
1199
+ value = int(raw_value)
1200
+ return (value, True) if value > 0 else (None, True)
1201
+ return None, True
1202
+
1203
+
1204
+ def _normalize_nullable_bool(raw_value: object) -> tuple[bool | None, bool]:
1205
+ if raw_value is None:
1206
+ return None, False
1207
+ if isinstance(raw_value, bool):
1208
+ return raw_value, False
1209
+ return None, True
1210
+
1211
+
1212
+ def _normalize_temperature(raw_value: object) -> tuple[float | None, bool]:
1213
+ if raw_value is None:
1214
+ return None, False
1215
+ if isinstance(raw_value, bool):
1216
+ return None, True
1217
+ if not isinstance(raw_value, (int, float)):
1218
+ return None, True
1219
+
1220
+ value = float(raw_value)
1221
+ if not isfinite(value) or value < 0 or value > 2:
1222
+ return None, True
1223
+ return value, False
1224
+
1225
+
1226
+ def _normalize_top_p(raw_value: object) -> tuple[float | None, bool]:
1227
+ if raw_value is None:
1228
+ return None, False
1229
+ if isinstance(raw_value, bool):
1230
+ return None, True
1231
+ if not isinstance(raw_value, (int, float)):
1232
+ return None, True
1233
+
1234
+ value = float(raw_value)
1235
+ if not isfinite(value) or value <= 0 or value > 1:
1236
+ return None, True
1237
+ return value, False
1238
+
1239
+
1240
+ def _normalize_optional_model_params(
1241
+ raw_model_params: object,
1242
+ ) -> tuple[ModelParams | None, bool]:
1243
+ if raw_model_params is None:
1244
+ return None, False
1245
+ if not isinstance(raw_model_params, dict):
1246
+ return None, True
1247
+
1248
+ reasoning_effort, migrated_reasoning = _normalize_model_param_choice(
1249
+ raw_model_params.get("reasoning_effort"),
1250
+ allowed=MODEL_REASONING_EFFORT_OPTIONS,
1251
+ )
1252
+ verbosity, migrated_verbosity = _normalize_model_param_choice(
1253
+ raw_model_params.get("verbosity"),
1254
+ allowed=MODEL_VERBOSITY_OPTIONS,
1255
+ )
1256
+ max_output_tokens, migrated_max_output_tokens = _normalize_positive_int(
1257
+ raw_model_params.get("max_output_tokens")
1258
+ )
1259
+ temperature, migrated_temperature = _normalize_temperature(
1260
+ raw_model_params.get("temperature")
1261
+ )
1262
+ top_p, migrated_top_p = _normalize_top_p(raw_model_params.get("top_p"))
1263
+
1264
+ params = ModelParams(
1265
+ reasoning_effort=reasoning_effort,
1266
+ verbosity=verbosity,
1267
+ max_output_tokens=max_output_tokens,
1268
+ temperature=temperature,
1269
+ top_p=top_p,
1270
+ )
1271
+ migrated = (
1272
+ migrated_reasoning
1273
+ or migrated_verbosity
1274
+ or migrated_max_output_tokens
1275
+ or migrated_temperature
1276
+ or migrated_top_p
1277
+ )
1278
+
1279
+ if is_empty_model_params(params):
1280
+ return None, migrated or bool(raw_model_params)
1281
+
1282
+ return params, migrated
1283
+
1284
+
1285
+ def _normalize_model_params_with_defaults(
1286
+ raw_model_params: object,
1287
+ ) -> tuple[ModelParams, bool]:
1288
+ params, migrated = _normalize_optional_model_params(raw_model_params)
1289
+ if params is not None:
1290
+ return params, migrated
1291
+ return build_default_model_params(), migrated or raw_model_params is None
1292
+
1293
+
1294
+ def _normalize_int_list(raw_values: object) -> tuple[list[int], bool]:
1295
+ if raw_values is None:
1296
+ return [], False
1297
+ if not isinstance(raw_values, list):
1298
+ return [], True
1299
+
1300
+ normalized: list[int] = []
1301
+ migrated = False
1302
+ for raw_value in raw_values:
1303
+ if isinstance(raw_value, bool):
1304
+ migrated = True
1305
+ continue
1306
+ if isinstance(raw_value, int):
1307
+ value = raw_value
1308
+ elif isinstance(raw_value, float) and raw_value.is_integer():
1309
+ value = int(raw_value)
1310
+ migrated = True
1311
+ elif isinstance(raw_value, str):
1312
+ stripped = raw_value.strip()
1313
+ if not stripped:
1314
+ migrated = True
1315
+ continue
1316
+ try:
1317
+ value = int(stripped)
1318
+ except ValueError:
1319
+ migrated = True
1320
+ continue
1321
+ migrated = True
1322
+ else:
1323
+ migrated = True
1324
+ continue
1325
+
1326
+ if value in normalized:
1327
+ migrated = True
1328
+ continue
1329
+ normalized.append(value)
1330
+
1331
+ return normalized, migrated
1332
+
1333
+
1334
+ def _normalize_float(raw_value: object) -> tuple[float, bool]:
1335
+ if isinstance(raw_value, bool):
1336
+ return 0.0, True
1337
+ if isinstance(raw_value, (int, float)):
1338
+ value = float(raw_value)
1339
+ return value, False
1340
+ if isinstance(raw_value, str):
1341
+ stripped = raw_value.strip()
1342
+ if not stripped:
1343
+ return 0.0, True
1344
+ try:
1345
+ return float(stripped), True
1346
+ except ValueError:
1347
+ return 0.0, True
1348
+ return 0.0, raw_value is not None
1349
+
1350
+
1351
+ def _normalize_optional_string(raw_value: object) -> tuple[str | None, bool]:
1352
+ if raw_value is None:
1353
+ return None, False
1354
+ if not isinstance(raw_value, str):
1355
+ return None, True
1356
+ stripped = raw_value.strip()
1357
+ if not stripped:
1358
+ return None, raw_value != ""
1359
+ return stripped, stripped != raw_value
1360
+
1361
+
1362
+ def _normalize_required_string(raw_value: object) -> tuple[str, bool]:
1363
+ if not isinstance(raw_value, str):
1364
+ return "", raw_value is not None
1365
+ stripped = raw_value.strip()
1366
+ return stripped, stripped != raw_value
1367
+
1368
+
1369
+ def _normalize_mcp_mount_list(raw_values: object) -> tuple[list[str], bool]:
1370
+ if raw_values is None:
1371
+ return [], False
1372
+ if not isinstance(raw_values, list):
1373
+ return [], True
1374
+ normalized: list[str] = []
1375
+ seen: set[str] = set()
1376
+ migrated = False
1377
+ for raw_value in raw_values:
1378
+ if not isinstance(raw_value, str):
1379
+ migrated = True
1380
+ continue
1381
+ value = raw_value.strip()
1382
+ if not value:
1383
+ migrated = True
1384
+ continue
1385
+ if value in seen:
1386
+ migrated = True
1387
+ continue
1388
+ if value != raw_value:
1389
+ migrated = True
1390
+ normalized.append(value)
1391
+ seen.add(value)
1392
+ return normalized, migrated
1393
+
1394
+
1395
+ def _normalize_mcp_bool(raw_value: object, *, default: bool) -> tuple[bool, bool]:
1396
+ if raw_value is None:
1397
+ return default, True
1398
+ if not isinstance(raw_value, bool):
1399
+ return default, True
1400
+ return raw_value, False
1401
+
1402
+
1403
+ def _normalize_mcp_timeout(
1404
+ raw_value: object,
1405
+ *,
1406
+ default: int,
1407
+ ) -> tuple[int, bool]:
1408
+ if raw_value is None:
1409
+ return default, True
1410
+ try:
1411
+ return build_mcp_timeout_seconds(raw_value, field_name="timeout"), False
1412
+ except ValueError:
1413
+ return default, True
1414
+
1415
+
1416
+ def _normalize_mcp_headers(raw_headers: object) -> tuple[dict[str, str], bool]:
1417
+ if raw_headers is None:
1418
+ return {}, False
1419
+ try:
1420
+ return build_provider_headers(raw_headers, field_name="headers"), False
1421
+ except ValueError:
1422
+ return {}, True
1423
+
1424
+
1425
+ def _build_mcp_server_config(
1426
+ raw_server: object,
1427
+ *,
1428
+ base_dir: str | Path | None = None,
1429
+ ) -> tuple[MCPServerConfig | None, bool]:
1430
+ if not isinstance(raw_server, dict):
1431
+ return None, True
1432
+
1433
+ raw_name = raw_server.get("name", raw_server.get("server_name"))
1434
+ if not isinstance(raw_name, str) or not raw_name.strip():
1435
+ return None, True
1436
+ name = raw_name.strip()
1437
+ migrated = name != raw_name or "server_name" in raw_server
1438
+
1439
+ raw_transport = raw_server.get("transport", "stdio")
1440
+ try:
1441
+ transport = build_mcp_transport(raw_transport)
1442
+ except ValueError:
1443
+ transport = "stdio"
1444
+ migrated = True
1445
+
1446
+ enabled, enabled_migrated = _normalize_mcp_bool(
1447
+ raw_server.get("enabled"),
1448
+ default=True,
1449
+ )
1450
+ required, required_migrated = _normalize_mcp_bool(
1451
+ raw_server.get("required"),
1452
+ default=False,
1453
+ )
1454
+ startup_timeout_sec, startup_timeout_migrated = _normalize_mcp_timeout(
1455
+ raw_server.get("startup_timeout_sec"),
1456
+ default=DEFAULT_MCP_SERVER_STARTUP_TIMEOUT_SEC,
1457
+ )
1458
+ tool_timeout_sec, tool_timeout_migrated = _normalize_mcp_timeout(
1459
+ raw_server.get("tool_timeout_sec"),
1460
+ default=DEFAULT_MCP_SERVER_TOOL_TIMEOUT_SEC,
1461
+ )
1462
+ enabled_tools, enabled_tools_migrated = _normalize_mcp_mount_list(
1463
+ raw_server.get("enabled_tools")
1464
+ )
1465
+ disabled_tools, disabled_tools_migrated = _normalize_mcp_mount_list(
1466
+ raw_server.get("disabled_tools")
1467
+ )
1468
+ scopes, scopes_migrated = _normalize_mcp_mount_list(raw_server.get("scopes"))
1469
+ env_vars, env_vars_migrated = _normalize_mcp_mount_list(raw_server.get("env_vars"))
1470
+ env_http_headers, env_http_headers_migrated = _normalize_mcp_mount_list(
1471
+ raw_server.get("env_http_headers")
1472
+ )
1473
+ env, env_migrated = _normalize_mcp_headers(raw_server.get("env"))
1474
+ http_headers, http_headers_migrated = _normalize_mcp_headers(
1475
+ raw_server.get("http_headers")
1476
+ )
1477
+
1478
+ raw_args = raw_server.get("args")
1479
+ if raw_args is None:
1480
+ args: list[str] = []
1481
+ args_migrated = False
1482
+ else:
1483
+ args, args_migrated = _normalize_mcp_mount_list(raw_args)
1484
+
1485
+ raw_oauth_resource = raw_server.get("oauth_resource")
1486
+ oauth_resource = (
1487
+ raw_oauth_resource.strip() if isinstance(raw_oauth_resource, str) else ""
1488
+ )
1489
+ raw_launcher = raw_server.get("launcher")
1490
+ launcher = raw_launcher.strip() if isinstance(raw_launcher, str) else ""
1491
+ if isinstance(raw_launcher, str) and launcher != raw_launcher:
1492
+ migrated = True
1493
+ if raw_launcher not in {None, ""} and not isinstance(raw_launcher, str):
1494
+ migrated = True
1495
+ raw_command = raw_server.get("command")
1496
+ command = raw_command.strip() if isinstance(raw_command, str) else ""
1497
+ cwd_raw = raw_server.get("cwd")
1498
+ cwd = ""
1499
+ if isinstance(cwd_raw, str) and cwd_raw.strip():
1500
+ cwd = _normalize_mcp_server_cwd(cwd_raw.strip(), base_dir=base_dir)
1501
+ migrated = migrated or cwd != cwd_raw
1502
+ elif cwd_raw not in {None, ""}:
1503
+ migrated = True
1504
+ raw_url = raw_server.get("url")
1505
+ url = raw_url.strip() if isinstance(raw_url, str) else ""
1506
+ raw_bearer_token_env_var = raw_server.get("bearer_token_env_var")
1507
+ bearer_token_env_var = (
1508
+ raw_bearer_token_env_var.strip()
1509
+ if isinstance(raw_bearer_token_env_var, str)
1510
+ else ""
1511
+ )
1512
+
1513
+ if transport == "stdio":
1514
+ if (
1515
+ scopes
1516
+ or oauth_resource
1517
+ or url
1518
+ or bearer_token_env_var
1519
+ or http_headers
1520
+ or env_http_headers
1521
+ ):
1522
+ migrated = True
1523
+ scopes = []
1524
+ oauth_resource = ""
1525
+ url = ""
1526
+ bearer_token_env_var = ""
1527
+ http_headers = {}
1528
+ env_http_headers = []
1529
+ else:
1530
+ if command or args or env or env_vars or cwd:
1531
+ migrated = True
1532
+ command = ""
1533
+ args = []
1534
+ env = {}
1535
+ env_vars = []
1536
+ cwd = ""
1537
+
1538
+ migrated = (
1539
+ migrated
1540
+ or enabled_migrated
1541
+ or required_migrated
1542
+ or startup_timeout_migrated
1543
+ or tool_timeout_migrated
1544
+ or enabled_tools_migrated
1545
+ or disabled_tools_migrated
1546
+ or scopes_migrated
1547
+ or args_migrated
1548
+ or env_migrated
1549
+ or env_vars_migrated
1550
+ or http_headers_migrated
1551
+ or env_http_headers_migrated
1552
+ )
1553
+
1554
+ return (
1555
+ MCPServerConfig(
1556
+ name=name,
1557
+ transport=transport,
1558
+ enabled=enabled,
1559
+ required=required,
1560
+ startup_timeout_sec=startup_timeout_sec,
1561
+ tool_timeout_sec=tool_timeout_sec,
1562
+ enabled_tools=enabled_tools,
1563
+ disabled_tools=disabled_tools,
1564
+ scopes=scopes,
1565
+ oauth_resource=oauth_resource,
1566
+ launcher=launcher,
1567
+ command=command,
1568
+ args=args,
1569
+ env=env,
1570
+ env_vars=env_vars,
1571
+ cwd=cwd,
1572
+ url=url,
1573
+ bearer_token_env_var=bearer_token_env_var,
1574
+ http_headers=http_headers,
1575
+ env_http_headers=env_http_headers,
1576
+ ),
1577
+ migrated,
1578
+ )
1579
+
1580
+
1581
+ def _normalize_mcp_servers(
1582
+ raw_servers: object,
1583
+ *,
1584
+ base_dir: str | Path | None = None,
1585
+ ) -> tuple[list[MCPServerConfig], bool]:
1586
+ if raw_servers is None:
1587
+ return [], False
1588
+ if not isinstance(raw_servers, list):
1589
+ return [], True
1590
+
1591
+ normalized: list[MCPServerConfig] = []
1592
+ seen: set[str] = set()
1593
+ migrated = False
1594
+ for raw_server in raw_servers:
1595
+ server, server_migrated = _build_mcp_server_config(
1596
+ raw_server,
1597
+ base_dir=base_dir,
1598
+ )
1599
+ migrated = migrated or server_migrated
1600
+ if server is None:
1601
+ continue
1602
+ if server.name in seen:
1603
+ migrated = True
1604
+ continue
1605
+ seen.add(server.name)
1606
+ normalized.append(server)
1607
+ return normalized, migrated
1608
+
1609
+
1610
+ def _build_pending_chat(raw_chat: object) -> tuple[TelegramPendingChat | None, bool]:
1611
+ if not isinstance(raw_chat, dict):
1612
+ return None, True
1613
+
1614
+ chat_id_value = raw_chat.get("chat_id")
1615
+ if isinstance(chat_id_value, bool) or not isinstance(chat_id_value, int):
1616
+ return None, True
1617
+
1618
+ username, username_migrated = _normalize_optional_string(raw_chat.get("username"))
1619
+ display_name, display_name_migrated = _normalize_required_string(
1620
+ raw_chat.get("display_name")
1621
+ )
1622
+ first_seen_at, first_seen_migrated = _normalize_float(raw_chat.get("first_seen_at"))
1623
+ last_seen_at, last_seen_migrated = _normalize_float(raw_chat.get("last_seen_at"))
1624
+
1625
+ pending_chat = TelegramPendingChat(
1626
+ chat_id=chat_id_value,
1627
+ username=username,
1628
+ display_name=display_name,
1629
+ first_seen_at=first_seen_at,
1630
+ last_seen_at=last_seen_at,
1631
+ )
1632
+ migrated = (
1633
+ username_migrated
1634
+ or display_name_migrated
1635
+ or first_seen_migrated
1636
+ or last_seen_migrated
1637
+ )
1638
+ return pending_chat, migrated
1639
+
1640
+
1641
+ def _build_approved_chat(
1642
+ raw_chat: object,
1643
+ ) -> tuple[TelegramApprovedChat | None, bool]:
1644
+ if not isinstance(raw_chat, dict):
1645
+ return None, True
1646
+
1647
+ chat_id_value = raw_chat.get("chat_id")
1648
+ if isinstance(chat_id_value, bool) or not isinstance(chat_id_value, int):
1649
+ return None, True
1650
+
1651
+ username, username_migrated = _normalize_optional_string(raw_chat.get("username"))
1652
+ display_name, display_name_migrated = _normalize_required_string(
1653
+ raw_chat.get("display_name")
1654
+ )
1655
+ approved_at, approved_at_migrated = _normalize_float(raw_chat.get("approved_at"))
1656
+
1657
+ approved_chat = TelegramApprovedChat(
1658
+ chat_id=chat_id_value,
1659
+ username=username,
1660
+ display_name=display_name,
1661
+ approved_at=approved_at,
1662
+ )
1663
+ migrated = username_migrated or display_name_migrated or approved_at_migrated
1664
+ return approved_chat, migrated
1665
+
1666
+
1667
+ def _normalize_pending_chats(
1668
+ raw_chats: object,
1669
+ ) -> tuple[list[TelegramPendingChat], bool]:
1670
+ if raw_chats is None:
1671
+ return [], False
1672
+ if not isinstance(raw_chats, list):
1673
+ return [], True
1674
+
1675
+ normalized: list[TelegramPendingChat] = []
1676
+ seen_chat_ids: set[int] = set()
1677
+ migrated = False
1678
+ for raw_chat in raw_chats:
1679
+ chat, chat_migrated = _build_pending_chat(raw_chat)
1680
+ migrated = migrated or chat_migrated
1681
+ if chat is None:
1682
+ continue
1683
+ if chat.chat_id in seen_chat_ids:
1684
+ migrated = True
1685
+ continue
1686
+ seen_chat_ids.add(chat.chat_id)
1687
+ normalized.append(chat)
1688
+ return normalized, migrated
1689
+
1690
+
1691
+ def _normalize_approved_chats(
1692
+ raw_chats: object,
1693
+ ) -> tuple[list[TelegramApprovedChat], bool]:
1694
+ if raw_chats is None:
1695
+ return [], False
1696
+ if not isinstance(raw_chats, list):
1697
+ return [], True
1698
+
1699
+ normalized: list[TelegramApprovedChat] = []
1700
+ seen_chat_ids: set[int] = set()
1701
+ migrated = False
1702
+ for raw_chat in raw_chats:
1703
+ chat, chat_migrated = _build_approved_chat(raw_chat)
1704
+ migrated = migrated or chat_migrated
1705
+ if chat is None:
1706
+ continue
1707
+ if chat.chat_id in seen_chat_ids:
1708
+ migrated = True
1709
+ continue
1710
+ seen_chat_ids.add(chat.chat_id)
1711
+ normalized.append(chat)
1712
+ return normalized, migrated
1713
+
1714
+
1715
+ def _build_settings(data: dict[str, object]) -> tuple[Settings, bool]:
1716
+ migrated = False
1717
+
1718
+ event_log_data = data.get("event_log", {})
1719
+ if not isinstance(event_log_data, dict):
1720
+ event_log_data = {}
1721
+ event_log = EventLogSettings(**event_log_data)
1722
+
1723
+ app_data_dir = build_default_app_data_dir()
1724
+ raw_app_data_dir = data.get("app_data_dir")
1725
+ if (
1726
+ raw_app_data_dir is None
1727
+ or not isinstance(raw_app_data_dir, str)
1728
+ or raw_app_data_dir.strip() != app_data_dir
1729
+ ):
1730
+ migrated = True
1731
+
1732
+ raw_working_dir = data.get("working_dir")
1733
+ if raw_working_dir is None:
1734
+ working_dir = build_default_working_dir()
1735
+ migrated = True
1736
+ else:
1737
+ try:
1738
+ working_dir = build_working_dir(raw_working_dir)
1739
+ if not isinstance(raw_working_dir, str) or working_dir != raw_working_dir:
1740
+ migrated = True
1741
+ except ValueError:
1742
+ working_dir = build_default_working_dir()
1743
+ migrated = True
1744
+
1745
+ access_data = data.get("access", {})
1746
+ if access_data is None:
1747
+ access_data = {}
1748
+ if not isinstance(access_data, dict):
1749
+ access_data = {}
1750
+ migrated = True
1751
+ raw_access_code_hash = access_data.get("code_hash", "")
1752
+ raw_access_code_salt = access_data.get("code_salt", "")
1753
+ raw_access_code = access_data.get("code", "")
1754
+ raw_access_session_generation = access_data.get("session_generation", 0)
1755
+ raw_access_session_signing_secret = access_data.get("session_signing_secret", "")
1756
+ access_code = raw_access_code.strip() if isinstance(raw_access_code, str) else ""
1757
+ access_code_hash = (
1758
+ raw_access_code_hash.strip() if isinstance(raw_access_code_hash, str) else ""
1759
+ )
1760
+ access_code_salt = (
1761
+ raw_access_code_salt.strip() if isinstance(raw_access_code_salt, str) else ""
1762
+ )
1763
+ access_session_signing_secret = (
1764
+ raw_access_session_signing_secret.strip()
1765
+ if isinstance(raw_access_session_signing_secret, str)
1766
+ else ""
1767
+ )
1768
+ if raw_access_code is not None and not isinstance(raw_access_code, str):
1769
+ migrated = True
1770
+ if raw_access_code_hash is not None and not isinstance(raw_access_code_hash, str):
1771
+ migrated = True
1772
+ if raw_access_code_salt is not None and not isinstance(raw_access_code_salt, str):
1773
+ migrated = True
1774
+ if raw_access_session_signing_secret is not None and not isinstance(
1775
+ raw_access_session_signing_secret,
1776
+ str,
1777
+ ):
1778
+ migrated = True
1779
+ if isinstance(raw_access_session_generation, bool) or not isinstance(
1780
+ raw_access_session_generation,
1781
+ int,
1782
+ ):
1783
+ access_session_generation = 0
1784
+ if "session_generation" in access_data:
1785
+ migrated = True
1786
+ else:
1787
+ access_session_generation = max(raw_access_session_generation, 0)
1788
+ if access_session_generation != raw_access_session_generation:
1789
+ migrated = True
1790
+ if not access_code or not access_code_hash or not access_code_salt:
1791
+ if access_code or access_code_hash or access_code_salt:
1792
+ migrated = True
1793
+ access_code = ""
1794
+ access_code_hash = ""
1795
+ access_code_salt = ""
1796
+ access = AccessSettings(
1797
+ code=access_code,
1798
+ code_hash=access_code_hash,
1799
+ code_salt=access_code_salt,
1800
+ session_generation=access_session_generation,
1801
+ session_signing_secret=access_session_signing_secret,
1802
+ )
1803
+
1804
+ assistant_data = data.get("assistant", {})
1805
+ if not isinstance(assistant_data, dict):
1806
+ assistant_data = {}
1807
+ migrated = True
1808
+ if "assistant" not in data:
1809
+ migrated = True
1810
+ assistant_role_name = assistant_data.get("role_name")
1811
+ raw_assistant_allow_network = assistant_data.get("allow_network")
1812
+ if raw_assistant_allow_network is None:
1813
+ assistant_allow_network = DEFAULT_ASSISTANT_ALLOW_NETWORK
1814
+ migrated = True
1815
+ else:
1816
+ try:
1817
+ assistant_allow_network = build_assistant_allow_network(
1818
+ raw_assistant_allow_network
1819
+ )
1820
+ except ValueError:
1821
+ assistant_allow_network = DEFAULT_ASSISTANT_ALLOW_NETWORK
1822
+ migrated = True
1823
+ raw_assistant_write_dirs = assistant_data.get("write_dirs")
1824
+ if raw_assistant_write_dirs is None or not isinstance(
1825
+ raw_assistant_write_dirs, list
1826
+ ):
1827
+ assistant_write_dirs = build_default_assistant_write_dirs(working_dir)
1828
+ migrated = True
1829
+ else:
1830
+ assistant_write_dirs = []
1831
+ seen_assistant_write_dirs: set[str] = set()
1832
+ for raw_item in raw_assistant_write_dirs:
1833
+ if not isinstance(raw_item, str):
1834
+ migrated = True
1835
+ continue
1836
+ stripped = raw_item.strip()
1837
+ if not stripped:
1838
+ migrated = True
1839
+ continue
1840
+ normalized_item = _normalize_assistant_write_dir(
1841
+ stripped,
1842
+ base_dir=working_dir,
1843
+ )
1844
+ if normalized_item != raw_item:
1845
+ migrated = True
1846
+ if normalized_item in seen_assistant_write_dirs:
1847
+ migrated = True
1848
+ continue
1849
+ seen_assistant_write_dirs.add(normalized_item)
1850
+ assistant_write_dirs.append(normalized_item)
1851
+ if "mcp_servers" in assistant_data:
1852
+ migrated = True
1853
+ assistant = AssistantSettings(
1854
+ role_name=assistant_role_name.strip()
1855
+ if isinstance(assistant_role_name, str) and assistant_role_name.strip()
1856
+ else STEWARD_ROLE_NAME,
1857
+ allow_network=assistant_allow_network,
1858
+ write_dirs=assistant_write_dirs,
1859
+ )
1860
+ if assistant.role_name == STEWARD_ROLE_NAME and (
1861
+ not isinstance(assistant_role_name, str) or not assistant_role_name.strip()
1862
+ ):
1863
+ migrated = True
1864
+
1865
+ leader_data = data.get("leader", {})
1866
+ if not isinstance(leader_data, dict):
1867
+ leader_data = {}
1868
+ migrated = True
1869
+ if "leader" not in data:
1870
+ migrated = True
1871
+ leader_role_name = leader_data.get("role_name")
1872
+ leader = LeaderSettings(
1873
+ role_name=leader_role_name.strip()
1874
+ if isinstance(leader_role_name, str) and leader_role_name.strip()
1875
+ else CONDUCTOR_ROLE_NAME
1876
+ )
1877
+ if leader.role_name == CONDUCTOR_ROLE_NAME and (
1878
+ not isinstance(leader_role_name, str) or not leader_role_name.strip()
1879
+ ):
1880
+ migrated = True
1881
+
1882
+ telegram_data = data.get("telegram", {})
1883
+ if not isinstance(telegram_data, dict):
1884
+ telegram_data = {}
1885
+ migrated = True
1886
+ if "telegram" not in data:
1887
+ migrated = True
1888
+ bot_token = telegram_data.get("bot_token", "")
1889
+ pending_chats, pending_chats_migrated = _normalize_pending_chats(
1890
+ telegram_data.get("pending_chats")
1891
+ )
1892
+ approved_chats, approved_chats_migrated = _normalize_approved_chats(
1893
+ telegram_data.get("approved_chats")
1894
+ )
1895
+ migrated = migrated or pending_chats_migrated or approved_chats_migrated
1896
+ if "pending_links" in telegram_data:
1897
+ migrated = True
1898
+ if "allowed_user_ids" in telegram_data or "registered_chat_ids" in telegram_data:
1899
+ migrated = True
1900
+ legacy_registered_chat_ids, _ = _normalize_int_list(
1901
+ telegram_data.get("registered_chat_ids")
1902
+ )
1903
+ for chat_id in legacy_registered_chat_ids:
1904
+ if any(chat.chat_id == chat_id for chat in approved_chats):
1905
+ continue
1906
+ approved_chats.append(
1907
+ TelegramApprovedChat(
1908
+ chat_id=chat_id,
1909
+ approved_at=0.0,
1910
+ )
1911
+ )
1912
+ telegram = TelegramSettings(
1913
+ bot_token=bot_token.strip() if isinstance(bot_token, str) else "",
1914
+ pending_chats=pending_chats,
1915
+ approved_chats=approved_chats,
1916
+ )
1917
+ if bot_token is not None and not isinstance(bot_token, str):
1918
+ migrated = True
1919
+
1920
+ model_data = data.get("model", {})
1921
+ if not isinstance(model_data, dict):
1922
+ model_data = {}
1923
+ migrated = True
1924
+ model_params, model_params_migrated = _normalize_model_params_with_defaults(
1925
+ model_data.get("params")
1926
+ )
1927
+ migrated = migrated or model_params_migrated
1928
+ raw_model_retry_policy = model_data.get("retry_policy")
1929
+ if raw_model_retry_policy is None:
1930
+ model_retry_policy = DEFAULT_LLM_RETRY_POLICY
1931
+ migrated = True
1932
+ else:
1933
+ try:
1934
+ model_retry_policy = build_model_retry_policy(raw_model_retry_policy)
1935
+ except ValueError:
1936
+ model_retry_policy = DEFAULT_LLM_RETRY_POLICY
1937
+ migrated = True
1938
+ raw_model_max_retries = model_data.get("max_retries")
1939
+ if raw_model_max_retries is None:
1940
+ model_max_retries = DEFAULT_LLM_MAX_RETRIES
1941
+ migrated = True
1942
+ else:
1943
+ try:
1944
+ model_max_retries = build_model_max_retries(raw_model_max_retries)
1945
+ except ValueError:
1946
+ model_max_retries = DEFAULT_LLM_MAX_RETRIES
1947
+ migrated = True
1948
+ raw_retry_initial_delay_seconds = model_data.get("retry_initial_delay_seconds")
1949
+ if raw_retry_initial_delay_seconds is None:
1950
+ retry_initial_delay_seconds = DEFAULT_LLM_RETRY_INITIAL_DELAY_SECONDS
1951
+ migrated = True
1952
+ else:
1953
+ try:
1954
+ retry_initial_delay_seconds = build_model_retry_initial_delay_seconds(
1955
+ raw_retry_initial_delay_seconds
1956
+ )
1957
+ except ValueError:
1958
+ retry_initial_delay_seconds = DEFAULT_LLM_RETRY_INITIAL_DELAY_SECONDS
1959
+ migrated = True
1960
+ raw_retry_max_delay_seconds = model_data.get("retry_max_delay_seconds")
1961
+ if raw_retry_max_delay_seconds is None:
1962
+ retry_max_delay_seconds = DEFAULT_LLM_RETRY_MAX_DELAY_SECONDS
1963
+ migrated = True
1964
+ else:
1965
+ try:
1966
+ retry_max_delay_seconds = build_model_retry_max_delay_seconds(
1967
+ raw_retry_max_delay_seconds
1968
+ )
1969
+ except ValueError:
1970
+ retry_max_delay_seconds = DEFAULT_LLM_RETRY_MAX_DELAY_SECONDS
1971
+ migrated = True
1972
+ raw_retry_backoff_cap_retries = model_data.get("retry_backoff_cap_retries")
1973
+ if raw_retry_backoff_cap_retries is None:
1974
+ retry_backoff_cap_retries = DEFAULT_LLM_RETRY_BACKOFF_CAP_RETRIES
1975
+ migrated = True
1976
+ else:
1977
+ try:
1978
+ retry_backoff_cap_retries = build_model_retry_backoff_cap_retries(
1979
+ raw_retry_backoff_cap_retries
1980
+ )
1981
+ except ValueError:
1982
+ retry_backoff_cap_retries = DEFAULT_LLM_RETRY_BACKOFF_CAP_RETRIES
1983
+ migrated = True
1984
+ input_image, migrated_input_image = _normalize_nullable_bool(
1985
+ model_data.get("input_image")
1986
+ )
1987
+ output_image, migrated_output_image = _normalize_nullable_bool(
1988
+ model_data.get("output_image")
1989
+ )
1990
+ context_window_tokens, migrated_context_window_tokens = _normalize_positive_int(
1991
+ model_data.get("context_window_tokens")
1992
+ )
1993
+ auto_compact_token_limit, migrated_auto_compact_token_limit = (
1994
+ _normalize_positive_int(model_data.get("auto_compact_token_limit"))
1995
+ )
1996
+ if "auto_compact" in model_data or "auto_compact_threshold" in model_data:
1997
+ migrated = True
1998
+ try:
1999
+ validate_model_retry_backoff_settings(
2000
+ retry_initial_delay_seconds=retry_initial_delay_seconds,
2001
+ retry_max_delay_seconds=retry_max_delay_seconds,
2002
+ )
2003
+ except ValueError:
2004
+ retry_initial_delay_seconds = DEFAULT_LLM_RETRY_INITIAL_DELAY_SECONDS
2005
+ retry_max_delay_seconds = DEFAULT_LLM_RETRY_MAX_DELAY_SECONDS
2006
+ migrated = True
2007
+ raw_model_timeout_ms = model_data.get("timeout_ms")
2008
+ if raw_model_timeout_ms is None:
2009
+ model_timeout_ms = DEFAULT_LLM_TIMEOUT_MS
2010
+ migrated = True
2011
+ else:
2012
+ try:
2013
+ model_timeout_ms = build_model_timeout_ms(raw_model_timeout_ms)
2014
+ except ValueError:
2015
+ model_timeout_ms = DEFAULT_LLM_TIMEOUT_MS
2016
+ migrated = True
2017
+ model_settings = ModelSettings(
2018
+ active_provider_id=str(model_data.get("active_provider_id", "")),
2019
+ active_model=str(model_data.get("active_model", "")),
2020
+ input_image=input_image,
2021
+ output_image=output_image,
2022
+ context_window_tokens=context_window_tokens,
2023
+ params=model_params,
2024
+ timeout_ms=model_timeout_ms,
2025
+ retry_policy=model_retry_policy,
2026
+ max_retries=model_max_retries,
2027
+ retry_initial_delay_seconds=retry_initial_delay_seconds,
2028
+ retry_max_delay_seconds=retry_max_delay_seconds,
2029
+ retry_backoff_cap_retries=retry_backoff_cap_retries,
2030
+ auto_compact_token_limit=auto_compact_token_limit,
2031
+ )
2032
+ migrated = (
2033
+ migrated
2034
+ or migrated_input_image
2035
+ or migrated_output_image
2036
+ or migrated_context_window_tokens
2037
+ or migrated_auto_compact_token_limit
2038
+ )
2039
+ custom_prompt = str(data.get("custom_prompt", ""))
2040
+ if "custom_post_prompt" in data:
2041
+ custom_post_prompt = str(data.get("custom_post_prompt", ""))
2042
+ else:
2043
+ custom_post_prompt = str(data.get("post_prompt", ""))
2044
+ if "post_prompt" in data:
2045
+ migrated = True
2046
+
2047
+ providers_raw = data.get("providers", [])
2048
+ if not isinstance(providers_raw, list):
2049
+ providers_raw = []
2050
+ providers = []
2051
+ for provider in providers_raw:
2052
+ if not isinstance(provider, dict):
2053
+ continue
2054
+ headers, headers_migrated = _normalize_provider_headers(provider.get("headers"))
2055
+ models, models_migrated = _normalize_provider_model_catalog_entries(
2056
+ provider.get("models")
2057
+ )
2058
+ migrated = migrated or headers_migrated
2059
+ migrated = migrated or models_migrated
2060
+ raw_retry_429_delay_seconds = provider.get("retry_429_delay_seconds")
2061
+ if raw_retry_429_delay_seconds is None:
2062
+ retry_429_delay_seconds = 0
2063
+ migrated = True
2064
+ else:
2065
+ try:
2066
+ retry_429_delay_seconds = build_provider_retry_429_delay_seconds(
2067
+ raw_retry_429_delay_seconds
2068
+ )
2069
+ except ValueError:
2070
+ retry_429_delay_seconds = 0
2071
+ migrated = True
2072
+ providers.append(
2073
+ ProviderConfig(
2074
+ id=str(provider.get("id", "")),
2075
+ name=str(provider.get("name", "")),
2076
+ type=str(provider.get("type", "openai_compatible")),
2077
+ base_url=str(provider.get("base_url", "")),
2078
+ api_key=str(provider.get("api_key", "")),
2079
+ headers=headers,
2080
+ retry_429_delay_seconds=retry_429_delay_seconds,
2081
+ models=models,
2082
+ )
2083
+ )
2084
+
2085
+ roles_raw = data.get("roles", [])
2086
+ if not isinstance(roles_raw, list):
2087
+ roles_raw = []
2088
+ roles = []
2089
+ for role in roles_raw:
2090
+ if not isinstance(role, dict):
2091
+ continue
2092
+ role_name = str(role.get("name", ""))
2093
+ if "id" in role:
2094
+ migrated = True
2095
+ if not role_name:
2096
+ role_name = str(role.get("id", ""))
2097
+ if "included_tools" in role:
2098
+ included_tools_raw = role.get("included_tools", [])
2099
+ else:
2100
+ included_tools_raw = role.get("required_tools", [])
2101
+ if "required_tools" in role:
2102
+ migrated = True
2103
+ if not isinstance(included_tools_raw, list):
2104
+ included_tools_raw = []
2105
+ excluded_tools_raw = role.get("excluded_tools", [])
2106
+ if not isinstance(excluded_tools_raw, list):
2107
+ excluded_tools_raw = []
2108
+
2109
+ role_model: RoleModelConfig | None = None
2110
+ role_model_params: ModelParams | None = None
2111
+ if "model" in role:
2112
+ role_model, role_model_migrated = _normalize_role_model(
2113
+ role.get("model"),
2114
+ default_provider_id=model_settings.active_provider_id.strip(),
2115
+ )
2116
+ migrated = migrated or role_model_migrated
2117
+ elif "model_override" in role:
2118
+ role_model, role_model_migrated = _normalize_role_model(
2119
+ role.get("model_override"),
2120
+ default_provider_id=model_settings.active_provider_id.strip(),
2121
+ )
2122
+ migrated = migrated or role_model_migrated or True
2123
+ if "model_params" in role:
2124
+ role_model_params, role_model_params_migrated = (
2125
+ _normalize_optional_model_params(role.get("model_params"))
2126
+ )
2127
+ migrated = migrated or role_model_params_migrated
2128
+ role_system_prompt = str(role.get("system_prompt", ""))
2129
+ role_description, role_description_migrated = _normalize_role_description(
2130
+ role.get("description"),
2131
+ role_name=role_name,
2132
+ system_prompt=role_system_prompt,
2133
+ )
2134
+ migrated = migrated or role_description_migrated
2135
+
2136
+ roles.append(
2137
+ RoleConfig(
2138
+ name=role_name,
2139
+ system_prompt=role_system_prompt,
2140
+ description=role_description,
2141
+ model=role_model,
2142
+ model_params=role_model_params,
2143
+ included_tools=normalize_tool_names(
2144
+ [name for name in included_tools_raw if isinstance(name, str)]
2145
+ ),
2146
+ excluded_tools=normalize_tool_names(
2147
+ [name for name in excluded_tools_raw if isinstance(name, str)]
2148
+ ),
2149
+ )
2150
+ )
2151
+
2152
+ mcp_servers, mcp_servers_migrated = _normalize_mcp_servers(
2153
+ data.get("mcp_servers"),
2154
+ base_dir=working_dir,
2155
+ )
2156
+ migrated = migrated or mcp_servers_migrated
2157
+
2158
+ return (
2159
+ Settings(
2160
+ app_data_dir=app_data_dir,
2161
+ working_dir=working_dir,
2162
+ event_log=event_log,
2163
+ access=access,
2164
+ assistant=assistant,
2165
+ leader=leader,
2166
+ telegram=telegram,
2167
+ model=model_settings,
2168
+ custom_prompt=custom_prompt,
2169
+ custom_post_prompt=custom_post_prompt,
2170
+ providers=providers,
2171
+ roles=roles,
2172
+ mcp_servers=mcp_servers,
2173
+ ),
2174
+ migrated,
2175
+ )
2176
+
2177
+
2178
+ def _read_settings_file() -> tuple[Settings, bool]:
2179
+ with _SETTINGS_FILE.open(encoding="utf-8") as settings_file:
2180
+ data = json.load(settings_file)
2181
+ if not isinstance(data, dict):
2182
+ raise ValueError("settings file must contain a JSON object")
2183
+ return _build_settings(data)
2184
+
2185
+
2186
+ def _get_settings_file_signature() -> tuple[int, int] | None:
2187
+ try:
2188
+ stat_result = _SETTINGS_FILE.stat()
2189
+ except FileNotFoundError:
2190
+ return None
2191
+ return (stat_result.st_mtime_ns, stat_result.st_size)
2192
+
2193
+
2194
+ def _preserve_newer_live_access(settings: Settings) -> None:
2195
+ if _get_settings_file_signature() is None:
2196
+ return
2197
+ try:
2198
+ live_settings, _ = _read_settings_file()
2199
+ except Exception as exc:
2200
+ logger.warning(
2201
+ "Failed to read live settings from {} while preserving access: {}",
2202
+ _SETTINGS_FILE,
2203
+ exc,
2204
+ )
2205
+ return
2206
+ if live_settings.access.session_generation > settings.access.session_generation:
2207
+ settings.access = live_settings.access
2208
+ return
2209
+ if (
2210
+ live_settings.access.session_signing_secret.strip()
2211
+ and not settings.access.session_signing_secret.strip()
2212
+ ):
2213
+ settings.access.session_signing_secret = (
2214
+ live_settings.access.session_signing_secret
2215
+ )
2216
+
2217
+
2218
+ def load_settings() -> Settings:
2219
+ global _cached_settings, _cached_settings_file_signature
2220
+ current_signature = _get_settings_file_signature()
2221
+ with _settings_lock:
2222
+ if (
2223
+ _cached_settings is not None
2224
+ and _cached_settings_file_signature == current_signature
2225
+ ):
2226
+ return _cached_settings
2227
+
2228
+ if current_signature is None:
2229
+ loaded_settings = Settings()
2230
+ loaded_signature = None
2231
+ with _settings_lock:
2232
+ _cached_settings = loaded_settings
2233
+ _cached_settings_file_signature = loaded_signature
2234
+ return _cached_settings
2235
+
2236
+ try:
2237
+ loaded_settings, migrated = _read_settings_file()
2238
+ loaded_signature = _get_settings_file_signature()
2239
+ except Exception as exc:
2240
+ logger.warning(
2241
+ "Failed to load settings from {}: {}. Falling back to defaults.",
2242
+ _SETTINGS_FILE,
2243
+ exc,
2244
+ )
2245
+ loaded_settings = Settings()
2246
+ loaded_signature = current_signature
2247
+ migrated = False
2248
+
2249
+ if migrated:
2250
+ try:
2251
+ save_settings(loaded_settings)
2252
+ loaded_signature = _get_settings_file_signature()
2253
+ except Exception as exc:
2254
+ logger.warning(
2255
+ "Failed to persist migrated settings to {}: {}",
2256
+ _SETTINGS_FILE,
2257
+ exc,
2258
+ )
2259
+
2260
+ with _settings_lock:
2261
+ _cached_settings = loaded_settings
2262
+ _cached_settings_file_signature = loaded_signature
2263
+ return _cached_settings
2264
+
2265
+
2266
+ def save_settings(settings: Settings) -> None:
2267
+ global _cached_settings, _cached_settings_file_signature
2268
+ temp_path: Path | None = None
2269
+ _SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
2270
+ _preserve_newer_live_access(settings)
2271
+
2272
+ try:
2273
+ with tempfile.NamedTemporaryFile(
2274
+ mode="w",
2275
+ encoding="utf-8",
2276
+ dir=_SETTINGS_FILE.parent,
2277
+ prefix=f"{_SETTINGS_FILE.name}.",
2278
+ suffix=".tmp",
2279
+ delete=False,
2280
+ ) as temp_file:
2281
+ temp_path = Path(temp_file.name)
2282
+ json.dump(asdict(settings), temp_file, indent=2)
2283
+ temp_file.flush()
2284
+ os.fsync(temp_file.fileno())
2285
+
2286
+ os.replace(temp_path, _SETTINGS_FILE)
2287
+ except Exception:
2288
+ if temp_path is not None:
2289
+ temp_path.unlink(missing_ok=True)
2290
+ raise
2291
+
2292
+ persisted_signature = _get_settings_file_signature()
2293
+ with _settings_lock:
2294
+ _cached_settings = settings
2295
+ _cached_settings_file_signature = persisted_signature
2296
+
2297
+
2298
+ def get_settings() -> Settings:
2299
+ return load_settings()
2300
+
2301
+
2302
+ def find_provider(settings: Settings, provider_id: str) -> ProviderConfig | None:
2303
+ for p in settings.providers:
2304
+ if p.id == provider_id:
2305
+ return p
2306
+ return None
2307
+
2308
+
2309
+ def find_role(settings: Settings, role_name: str) -> RoleConfig | None:
2310
+ for r in settings.roles:
2311
+ if r.name == role_name:
2312
+ return r
2313
+ return None
2314
+
2315
+
2316
+ def find_mcp_server(settings: Settings, server_name: str) -> MCPServerConfig | None:
2317
+ for server in settings.mcp_servers:
2318
+ if server.name == server_name:
2319
+ return server
2320
+ return None
2321
+
2322
+
2323
+ def clear_provider_references(settings: Settings, provider_id: str) -> bool:
2324
+ changed = False
2325
+
2326
+ if settings.model.active_provider_id == provider_id:
2327
+ settings.model.active_provider_id = ""
2328
+ settings.model.active_model = ""
2329
+ changed = True
2330
+
2331
+ for role in settings.roles:
2332
+ if role.model is None or role.model.provider_id != provider_id:
2333
+ continue
2334
+ role.model = None
2335
+ changed = True
2336
+ return changed
2337
+
2338
+
2339
+ def build_steward_role() -> RoleConfig:
2340
+ return RoleConfig(
2341
+ name=STEWARD_ROLE_NAME,
2342
+ system_prompt=STEWARD_ROLE_SYSTEM_PROMPT,
2343
+ description=STEWARD_ROLE_DESCRIPTION,
2344
+ included_tools=list(STEWARD_ROLE_INCLUDED_TOOLS),
2345
+ excluded_tools=[],
2346
+ )
2347
+
2348
+
2349
+ def build_worker_role() -> RoleConfig:
2350
+ return RoleConfig(
2351
+ name=WORKER_ROLE_NAME,
2352
+ system_prompt=WORKER_ROLE_SYSTEM_PROMPT,
2353
+ description=WORKER_ROLE_DESCRIPTION,
2354
+ included_tools=list(WORKER_ROLE_INCLUDED_TOOLS),
2355
+ excluded_tools=[],
2356
+ )
2357
+
2358
+
2359
+ def build_conductor_role() -> RoleConfig:
2360
+ return RoleConfig(
2361
+ name=CONDUCTOR_ROLE_NAME,
2362
+ system_prompt=CONDUCTOR_ROLE_SYSTEM_PROMPT,
2363
+ description=CONDUCTOR_ROLE_DESCRIPTION,
2364
+ included_tools=list(CONDUCTOR_ROLE_INCLUDED_TOOLS),
2365
+ excluded_tools=[],
2366
+ )
2367
+
2368
+
2369
+ def build_designer_role() -> RoleConfig:
2370
+ return RoleConfig(
2371
+ name=DESIGNER_ROLE_NAME,
2372
+ system_prompt=DESIGNER_ROLE_SYSTEM_PROMPT,
2373
+ description=DESIGNER_ROLE_DESCRIPTION,
2374
+ included_tools=list(DESIGNER_ROLE_INCLUDED_TOOLS),
2375
+ excluded_tools=[],
2376
+ )
2377
+
2378
+
2379
+ def rename_role_references(
2380
+ settings: Settings,
2381
+ old_role_name: str,
2382
+ new_role_name: str,
2383
+ ) -> bool:
2384
+ changed = False
2385
+ if settings.assistant.role_name == old_role_name:
2386
+ settings.assistant.role_name = new_role_name
2387
+ changed = True
2388
+ if settings.leader.role_name == old_role_name:
2389
+ settings.leader.role_name = new_role_name
2390
+ changed = True
2391
+ return changed
2392
+
2393
+
2394
+ def clear_role_references(settings: Settings, role_name: str) -> bool:
2395
+ changed = False
2396
+ if settings.assistant.role_name == role_name:
2397
+ settings.assistant.role_name = STEWARD_ROLE_NAME
2398
+ changed = True
2399
+ if settings.leader.role_name == role_name:
2400
+ settings.leader.role_name = CONDUCTOR_ROLE_NAME
2401
+ changed = True
2402
+ return changed
2403
+
2404
+
2405
+ def ensure_assistant_role(settings: Settings) -> bool:
2406
+ if find_role(settings, settings.assistant.role_name) is not None:
2407
+ return False
2408
+ settings.assistant.role_name = STEWARD_ROLE_NAME
2409
+ return True
2410
+
2411
+
2412
+ def ensure_leader_role(settings: Settings) -> bool:
2413
+ if find_role(settings, settings.leader.role_name) is not None:
2414
+ return False
2415
+ settings.leader.role_name = CONDUCTOR_ROLE_NAME
2416
+ return True
2417
+
2418
+
2419
+ def _ensure_builtin_role(settings: Settings, standard_role: RoleConfig) -> bool:
2420
+ current_role = find_role(settings, standard_role.name)
2421
+ if current_role is None:
2422
+ settings.roles.append(standard_role)
2423
+ return True
2424
+ if (
2425
+ current_role.description != standard_role.description
2426
+ or current_role.system_prompt != standard_role.system_prompt
2427
+ or current_role.included_tools != standard_role.included_tools
2428
+ or current_role.excluded_tools != standard_role.excluded_tools
2429
+ ):
2430
+ current_role.description = standard_role.description
2431
+ current_role.system_prompt = standard_role.system_prompt
2432
+ current_role.included_tools = list(standard_role.included_tools)
2433
+ current_role.excluded_tools = list(standard_role.excluded_tools)
2434
+ return True
2435
+ return False
2436
+
2437
+
2438
+ def ensure_builtin_roles(settings: Settings) -> bool:
2439
+ changed = False
2440
+ builtin_role_order = [
2441
+ build_steward_role(),
2442
+ build_worker_role(),
2443
+ build_conductor_role(),
2444
+ build_designer_role(),
2445
+ ]
2446
+ for standard_role in builtin_role_order:
2447
+ changed = _ensure_builtin_role(settings, standard_role) or changed
2448
+ changed = ensure_assistant_role(settings) or changed
2449
+ changed = ensure_leader_role(settings) or changed
2450
+
2451
+ ordered_roles: list[RoleConfig] = []
2452
+ builtin_role_names = {role.name for role in builtin_role_order}
2453
+ for standard_role in builtin_role_order:
2454
+ current_role = find_role(settings, standard_role.name)
2455
+ if current_role is not None:
2456
+ ordered_roles.append(current_role)
2457
+ for role in settings.roles:
2458
+ if role.name not in builtin_role_names:
2459
+ ordered_roles.append(role)
2460
+ if ordered_roles != settings.roles:
2461
+ settings.roles = ordered_roles
2462
+ changed = True
2463
+ return changed
2464
+
2465
+
2466
+ def is_builtin_role_name(role_name: str) -> bool:
2467
+ return role_name in BUILTIN_ROLE_NAMES