flowent 0.0.7 → 0.0.10

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