flowent 0.0.6 → 0.0.10

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