flowent 0.0.1 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (493) hide show
  1. package/README.md +19 -8
  2. package/backend/.python-version +1 -0
  3. package/backend/pyproject.toml +57 -0
  4. package/backend/src/flowent/__init__.py +3 -0
  5. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  6. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  7. package/backend/src/flowent/__pycache__/access.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/assistant_commands.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/config.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/events.cpython-313.pyc +0 -0
  13. package/backend/src/flowent/__pycache__/graph_runtime.cpython-313.pyc +0 -0
  14. package/backend/src/flowent/__pycache__/graph_service.cpython-313.pyc +0 -0
  15. package/backend/src/flowent/__pycache__/image_assets.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/mcp_service.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/__pycache__/model_metadata.cpython-313.pyc +0 -0
  20. package/backend/src/flowent/__pycache__/network.cpython-313.pyc +0 -0
  21. package/backend/src/flowent/__pycache__/registry.cpython-313.pyc +0 -0
  22. package/backend/src/flowent/__pycache__/role_management.cpython-313.pyc +0 -0
  23. package/backend/src/flowent/__pycache__/runtime.cpython-313.pyc +0 -0
  24. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  25. package/backend/src/flowent/__pycache__/security.cpython-313.pyc +0 -0
  26. package/backend/src/flowent/__pycache__/settings.cpython-313.pyc +0 -0
  27. package/backend/src/flowent/__pycache__/settings_management.cpython-313.pyc +0 -0
  28. package/backend/src/flowent/__pycache__/state_db.cpython-313.pyc +0 -0
  29. package/backend/src/flowent/__pycache__/stats_service.cpython-313.pyc +0 -0
  30. package/backend/src/flowent/__pycache__/workspace_store.cpython-313.pyc +0 -0
  31. package/backend/src/flowent/_version.py +7 -0
  32. package/backend/src/flowent/access.py +247 -0
  33. package/backend/src/flowent/agent.py +2808 -0
  34. package/backend/src/flowent/assistant_commands.py +106 -0
  35. package/backend/src/flowent/channels/__init__.py +3 -0
  36. package/backend/src/flowent/channels/__pycache__/__init__.cpython-313.pyc +0 -0
  37. package/backend/src/flowent/channels/__pycache__/telegram.cpython-313.pyc +0 -0
  38. package/backend/src/flowent/channels/telegram.py +615 -0
  39. package/backend/src/flowent/cli.py +85 -0
  40. package/backend/src/flowent/config.py +14 -0
  41. package/backend/src/flowent/dev.py +3 -0
  42. package/backend/src/flowent/events.py +157 -0
  43. package/backend/src/flowent/graph_runtime.py +60 -0
  44. package/backend/src/flowent/graph_service.py +1346 -0
  45. package/backend/src/flowent/image_assets.py +356 -0
  46. package/backend/src/flowent/logging.py +155 -0
  47. package/backend/src/flowent/main.py +124 -0
  48. package/backend/src/flowent/mcp_service.py +1904 -0
  49. package/backend/src/flowent/model_metadata.py +98 -0
  50. package/backend/src/flowent/models/__init__.py +121 -0
  51. package/backend/src/flowent/models/__pycache__/__init__.cpython-313.pyc +0 -0
  52. package/backend/src/flowent/models/__pycache__/agent.cpython-313.pyc +0 -0
  53. package/backend/src/flowent/models/__pycache__/base.cpython-313.pyc +0 -0
  54. package/backend/src/flowent/models/__pycache__/blueprint.cpython-313.pyc +0 -0
  55. package/backend/src/flowent/models/__pycache__/content.cpython-313.pyc +0 -0
  56. package/backend/src/flowent/models/__pycache__/delta.cpython-313.pyc +0 -0
  57. package/backend/src/flowent/models/__pycache__/event.cpython-313.pyc +0 -0
  58. package/backend/src/flowent/models/__pycache__/graph.cpython-313.pyc +0 -0
  59. package/backend/src/flowent/models/__pycache__/history.cpython-313.pyc +0 -0
  60. package/backend/src/flowent/models/__pycache__/llm.cpython-313.pyc +0 -0
  61. package/backend/src/flowent/models/__pycache__/message.cpython-313.pyc +0 -0
  62. package/backend/src/flowent/models/__pycache__/tab.cpython-313.pyc +0 -0
  63. package/backend/src/flowent/models/__pycache__/todo.cpython-313.pyc +0 -0
  64. package/backend/src/flowent/models/agent.py +33 -0
  65. package/backend/src/flowent/models/base.py +24 -0
  66. package/backend/src/flowent/models/blueprint.py +176 -0
  67. package/backend/src/flowent/models/content.py +164 -0
  68. package/backend/src/flowent/models/delta.py +44 -0
  69. package/backend/src/flowent/models/event.py +51 -0
  70. package/backend/src/flowent/models/graph.py +437 -0
  71. package/backend/src/flowent/models/history.py +214 -0
  72. package/backend/src/flowent/models/llm.py +61 -0
  73. package/backend/src/flowent/models/message.py +27 -0
  74. package/backend/src/flowent/models/tab.py +48 -0
  75. package/backend/src/flowent/models/todo.py +10 -0
  76. package/backend/src/flowent/network.py +146 -0
  77. package/backend/src/flowent/prompts/__init__.py +67 -0
  78. package/backend/src/flowent/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
  79. package/backend/src/flowent/prompts/__pycache__/common.cpython-313.pyc +0 -0
  80. package/backend/src/flowent/prompts/__pycache__/steward.cpython-313.pyc +0 -0
  81. package/backend/src/flowent/prompts/common.py +250 -0
  82. package/backend/src/flowent/prompts/steward.py +64 -0
  83. package/backend/src/flowent/providers/__init__.py +23 -0
  84. package/backend/src/flowent/providers/__pycache__/__init__.cpython-313.pyc +0 -0
  85. package/backend/src/flowent/providers/__pycache__/anthropic.cpython-313.pyc +0 -0
  86. package/backend/src/flowent/providers/__pycache__/base_url.cpython-313.pyc +0 -0
  87. package/backend/src/flowent/providers/__pycache__/configuration.cpython-313.pyc +0 -0
  88. package/backend/src/flowent/providers/__pycache__/content.cpython-313.pyc +0 -0
  89. package/backend/src/flowent/providers/__pycache__/errors.cpython-313.pyc +0 -0
  90. package/backend/src/flowent/providers/__pycache__/gateway.cpython-313.pyc +0 -0
  91. package/backend/src/flowent/providers/__pycache__/headers.cpython-313.pyc +0 -0
  92. package/backend/src/flowent/providers/__pycache__/management.cpython-313.pyc +0 -0
  93. package/backend/src/flowent/providers/__pycache__/openai.cpython-313.pyc +0 -0
  94. package/backend/src/flowent/providers/__pycache__/openai_responses.cpython-313.pyc +0 -0
  95. package/backend/src/flowent/providers/__pycache__/registry.cpython-313.pyc +0 -0
  96. package/backend/src/flowent/providers/__pycache__/sse.cpython-313.pyc +0 -0
  97. package/backend/src/flowent/providers/__pycache__/thinking.cpython-313.pyc +0 -0
  98. package/backend/src/flowent/providers/anthropic.py +468 -0
  99. package/backend/src/flowent/providers/base_url.py +60 -0
  100. package/backend/src/flowent/providers/configuration.py +182 -0
  101. package/backend/src/flowent/providers/content.py +122 -0
  102. package/backend/src/flowent/providers/errors.py +223 -0
  103. package/backend/src/flowent/providers/gateway.py +169 -0
  104. package/backend/src/flowent/providers/gemini.py +447 -0
  105. package/backend/src/flowent/providers/headers.py +20 -0
  106. package/backend/src/flowent/providers/management.py +96 -0
  107. package/backend/src/flowent/providers/ollama.py +293 -0
  108. package/backend/src/flowent/providers/openai.py +422 -0
  109. package/backend/src/flowent/providers/openai_responses.py +655 -0
  110. package/backend/src/flowent/providers/registry.py +144 -0
  111. package/backend/src/flowent/providers/sse.py +31 -0
  112. package/backend/src/flowent/providers/thinking.py +79 -0
  113. package/backend/src/flowent/registry.py +73 -0
  114. package/backend/src/flowent/role_management.py +255 -0
  115. package/backend/src/flowent/routes/__init__.py +30 -0
  116. package/backend/src/flowent/routes/__pycache__/__init__.cpython-313.pyc +0 -0
  117. package/backend/src/flowent/routes/__pycache__/access.cpython-313.pyc +0 -0
  118. package/backend/src/flowent/routes/__pycache__/assistant.cpython-313.pyc +0 -0
  119. package/backend/src/flowent/routes/__pycache__/image_assets.cpython-313.pyc +0 -0
  120. package/backend/src/flowent/routes/__pycache__/mcp.cpython-313.pyc +0 -0
  121. package/backend/src/flowent/routes/__pycache__/meta.cpython-313.pyc +0 -0
  122. package/backend/src/flowent/routes/__pycache__/nodes.cpython-313.pyc +0 -0
  123. package/backend/src/flowent/routes/__pycache__/prompts.cpython-313.pyc +0 -0
  124. package/backend/src/flowent/routes/__pycache__/providers_route.cpython-313.pyc +0 -0
  125. package/backend/src/flowent/routes/__pycache__/roles.cpython-313.pyc +0 -0
  126. package/backend/src/flowent/routes/__pycache__/settings.cpython-313.pyc +0 -0
  127. package/backend/src/flowent/routes/__pycache__/stats.cpython-313.pyc +0 -0
  128. package/backend/src/flowent/routes/__pycache__/tabs.cpython-313.pyc +0 -0
  129. package/backend/src/flowent/routes/__pycache__/ws.cpython-313.pyc +0 -0
  130. package/backend/src/flowent/routes/access.py +48 -0
  131. package/backend/src/flowent/routes/assistant.py +155 -0
  132. package/backend/src/flowent/routes/image_assets.py +33 -0
  133. package/backend/src/flowent/routes/mcp.py +125 -0
  134. package/backend/src/flowent/routes/meta.py +28 -0
  135. package/backend/src/flowent/routes/nodes.py +365 -0
  136. package/backend/src/flowent/routes/prompts.py +46 -0
  137. package/backend/src/flowent/routes/providers_route.py +364 -0
  138. package/backend/src/flowent/routes/roles.py +207 -0
  139. package/backend/src/flowent/routes/settings.py +324 -0
  140. package/backend/src/flowent/routes/stats.py +229 -0
  141. package/backend/src/flowent/routes/tabs.py +292 -0
  142. package/backend/src/flowent/routes/ws.py +33 -0
  143. package/backend/src/flowent/runtime.py +188 -0
  144. package/backend/src/flowent/sandbox.py +45 -0
  145. package/backend/src/flowent/security.py +42 -0
  146. package/backend/src/flowent/settings.py +2467 -0
  147. package/backend/src/flowent/settings_management.py +286 -0
  148. package/backend/src/flowent/state_db.py +120 -0
  149. package/backend/src/flowent/static/assets/AssistantPage-B3Xc08AS.js +1 -0
  150. package/backend/src/flowent/static/assets/ChannelsPage-ByLd28xk.js +1 -0
  151. package/backend/src/flowent/static/assets/HomePage-C0hAx9_l.js +3 -0
  152. package/backend/src/flowent/static/assets/McpPage-DkrYLvBv.js +7 -0
  153. package/backend/src/flowent/static/assets/PageScaffold-D4jO9ooX.js +1 -0
  154. package/backend/src/flowent/static/assets/PromptsPage-DWA7rRJd.js +1 -0
  155. package/backend/src/flowent/static/assets/ProvidersPage-PUWT8seJ.js +3 -0
  156. package/backend/src/flowent/static/assets/RolesPage-CqcclGRw.js +1 -0
  157. package/backend/src/flowent/static/assets/SettingsPage-8tS2cJgX.js +3 -0
  158. package/backend/src/flowent/static/assets/StatsPage-BX9khYzu.js +1 -0
  159. package/backend/src/flowent/static/assets/ToolsPage-9Tl9FdeD.js +1 -0
  160. package/backend/src/flowent/static/assets/WorkspaceCommandDialog-CCXxjDL8.js +1 -0
  161. package/backend/src/flowent/static/assets/WorkspacePanels-aMdJ7ZH7.js +1 -0
  162. package/backend/src/flowent/static/assets/alert-dialog-kFYVQ7oX.js +1 -0
  163. package/backend/src/flowent/static/assets/badge-74-3jsCg.js +1 -0
  164. package/backend/src/flowent/static/assets/constants-XUzFf6i1.js +1 -0
  165. package/backend/src/flowent/static/assets/datetime-m6_O_Ci9.js +1 -0
  166. package/backend/src/flowent/static/assets/dialog-BeGSweF6.js +1 -0
  167. package/backend/src/flowent/static/assets/elk-worker.min-C9JGDOE-.js +6312 -0
  168. package/backend/src/flowent/static/assets/graph-vendor-CHpVij2M.css +1 -0
  169. package/backend/src/flowent/static/assets/graph-vendor-DRq_-6fV.js +7 -0
  170. package/backend/src/flowent/static/assets/index-BHC1Vhy8.css +1 -0
  171. package/backend/src/flowent/static/assets/index-CL1ALZ3r.js +10 -0
  172. package/backend/src/flowent/static/assets/layout.worker-jMHqAFbP.js +24 -0
  173. package/backend/src/flowent/static/assets/markdown-vendor-DVdy_w12.js +29 -0
  174. package/backend/src/flowent/static/assets/modelParams-CaHd0903.js +1 -0
  175. package/backend/src/flowent/static/assets/react-vendor-mEs_JJxa.js +9 -0
  176. package/backend/src/flowent/static/assets/roles-2OLDeTc5.js +1 -0
  177. package/backend/src/flowent/static/assets/rolldown-runtime-BYbx6iT9.js +1 -0
  178. package/backend/src/flowent/static/assets/select-DL_LPeDj.js +1 -0
  179. package/backend/src/flowent/static/assets/shared-CMxbpLeQ.js +1 -0
  180. package/backend/src/flowent/static/assets/triState-DEr3NkXV.js +1 -0
  181. package/backend/src/flowent/static/assets/ui-vendor-Dg9NNnWX.js +51 -0
  182. package/backend/src/flowent/static/index.html +36 -0
  183. package/backend/src/flowent/stats_service.py +218 -0
  184. package/backend/src/flowent/tools/__init__.py +201 -0
  185. package/backend/src/flowent/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  186. package/backend/src/flowent/tools/__pycache__/connect.cpython-313.pyc +0 -0
  187. package/backend/src/flowent/tools/__pycache__/contacts.cpython-313.pyc +0 -0
  188. package/backend/src/flowent/tools/__pycache__/create_agent.cpython-313.pyc +0 -0
  189. package/backend/src/flowent/tools/__pycache__/create_tab.cpython-313.pyc +0 -0
  190. package/backend/src/flowent/tools/__pycache__/delete_tab.cpython-313.pyc +0 -0
  191. package/backend/src/flowent/tools/__pycache__/edit.cpython-313.pyc +0 -0
  192. package/backend/src/flowent/tools/__pycache__/exec.cpython-313.pyc +0 -0
  193. package/backend/src/flowent/tools/__pycache__/fetch.cpython-313.pyc +0 -0
  194. package/backend/src/flowent/tools/__pycache__/idle.cpython-313.pyc +0 -0
  195. package/backend/src/flowent/tools/__pycache__/list_roles.cpython-313.pyc +0 -0
  196. package/backend/src/flowent/tools/__pycache__/list_tabs.cpython-313.pyc +0 -0
  197. package/backend/src/flowent/tools/__pycache__/list_tools.cpython-313.pyc +0 -0
  198. package/backend/src/flowent/tools/__pycache__/manage_prompts.cpython-313.pyc +0 -0
  199. package/backend/src/flowent/tools/__pycache__/manage_providers.cpython-313.pyc +0 -0
  200. package/backend/src/flowent/tools/__pycache__/manage_roles.cpython-313.pyc +0 -0
  201. package/backend/src/flowent/tools/__pycache__/manage_settings.cpython-313.pyc +0 -0
  202. package/backend/src/flowent/tools/__pycache__/mcp.cpython-313.pyc +0 -0
  203. package/backend/src/flowent/tools/__pycache__/read.cpython-313.pyc +0 -0
  204. package/backend/src/flowent/tools/__pycache__/send.cpython-313.pyc +0 -0
  205. package/backend/src/flowent/tools/__pycache__/set_permissions.cpython-313.pyc +0 -0
  206. package/backend/src/flowent/tools/__pycache__/sleep.cpython-313.pyc +0 -0
  207. package/backend/src/flowent/tools/__pycache__/todo.cpython-313.pyc +0 -0
  208. package/backend/src/flowent/tools/connect.py +156 -0
  209. package/backend/src/flowent/tools/contacts.py +22 -0
  210. package/backend/src/flowent/tools/create_agent.py +270 -0
  211. package/backend/src/flowent/tools/create_tab.py +59 -0
  212. package/backend/src/flowent/tools/delete_tab.py +39 -0
  213. package/backend/src/flowent/tools/edit.py +142 -0
  214. package/backend/src/flowent/tools/exec.py +117 -0
  215. package/backend/src/flowent/tools/fetch.py +85 -0
  216. package/backend/src/flowent/tools/idle.py +27 -0
  217. package/backend/src/flowent/tools/list_roles.py +50 -0
  218. package/backend/src/flowent/tools/list_tabs.py +96 -0
  219. package/backend/src/flowent/tools/list_tools.py +24 -0
  220. package/backend/src/flowent/tools/manage_prompts.py +102 -0
  221. package/backend/src/flowent/tools/manage_providers.py +220 -0
  222. package/backend/src/flowent/tools/manage_roles.py +275 -0
  223. package/backend/src/flowent/tools/manage_settings.py +346 -0
  224. package/backend/src/flowent/tools/mcp.py +199 -0
  225. package/backend/src/flowent/tools/read.py +152 -0
  226. package/backend/src/flowent/tools/send.py +50 -0
  227. package/backend/src/flowent/tools/set_permissions.py +84 -0
  228. package/backend/src/flowent/tools/sleep.py +41 -0
  229. package/backend/src/flowent/tools/todo.py +51 -0
  230. package/backend/src/flowent/workspace_store.py +479 -0
  231. package/backend/tests/__init__.py +0 -0
  232. package/backend/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  233. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  234. package/backend/tests/conftest.py +6 -0
  235. package/backend/tests/integration/api/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  236. package/backend/tests/integration/api/__pycache__/test_access_api.cpython-313-pytest-9.0.3.pyc +0 -0
  237. package/backend/tests/integration/api/__pycache__/test_assistant_api.cpython-313-pytest-9.0.3.pyc +0 -0
  238. package/backend/tests/integration/api/__pycache__/test_frontend_mounting.cpython-313-pytest-9.0.3.pyc +0 -0
  239. package/backend/tests/integration/api/__pycache__/test_mcp_api.cpython-313-pytest-9.0.3.pyc +0 -0
  240. package/backend/tests/integration/api/__pycache__/test_meta_api.cpython-313-pytest-9.0.3.pyc +0 -0
  241. package/backend/tests/integration/api/__pycache__/test_nodes_api.cpython-313-pytest-9.0.3.pyc +0 -0
  242. package/backend/tests/integration/api/__pycache__/test_prompts_api.cpython-313-pytest-9.0.3.pyc +0 -0
  243. package/backend/tests/integration/api/__pycache__/test_roles_api.cpython-313-pytest-9.0.3.pyc +0 -0
  244. package/backend/tests/integration/api/__pycache__/test_tabs_api.cpython-313-pytest-9.0.3.pyc +0 -0
  245. package/backend/tests/integration/api/conftest.py +29 -0
  246. package/backend/tests/integration/api/test_access_api.py +182 -0
  247. package/backend/tests/integration/api/test_assistant_api.py +354 -0
  248. package/backend/tests/integration/api/test_frontend_mounting.py +61 -0
  249. package/backend/tests/integration/api/test_mcp_api.py +116 -0
  250. package/backend/tests/integration/api/test_meta_api.py +33 -0
  251. package/backend/tests/integration/api/test_nodes_api.py +486 -0
  252. package/backend/tests/integration/api/test_prompts_api.py +47 -0
  253. package/backend/tests/integration/api/test_roles_api.py +227 -0
  254. package/backend/tests/integration/api/test_tabs_api.py +501 -0
  255. package/backend/tests/unit/__pycache__/test_access.cpython-313-pytest-9.0.3.pyc +0 -0
  256. package/backend/tests/unit/__pycache__/test_cli.cpython-313-pytest-9.0.3.pyc +0 -0
  257. package/backend/tests/unit/__pycache__/test_graph_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  258. package/backend/tests/unit/__pycache__/test_network.cpython-313-pytest-9.0.3.pyc +0 -0
  259. package/backend/tests/unit/__pycache__/test_state_sqlite_storage.cpython-313-pytest-9.0.3.pyc +0 -0
  260. package/backend/tests/unit/__pycache__/test_workspace_store.cpython-313-pytest-9.0.3.pyc +0 -0
  261. package/backend/tests/unit/agent/__pycache__/test_agent_public_api.cpython-313-pytest-9.0.3.pyc +0 -0
  262. package/backend/tests/unit/agent/__pycache__/test_agent_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  263. package/backend/tests/unit/agent/test_agent_public_api.py +746 -0
  264. package/backend/tests/unit/agent/test_agent_runtime.py +2726 -0
  265. package/backend/tests/unit/channels/__pycache__/test_telegram_channel.cpython-313-pytest-9.0.3.pyc +0 -0
  266. package/backend/tests/unit/channels/test_telegram_channel.py +552 -0
  267. package/backend/tests/unit/logging/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  268. package/backend/tests/unit/logging/test_logging.py +132 -0
  269. package/backend/tests/unit/prompts/__pycache__/test_prompts.cpython-313-pytest-9.0.3.pyc +0 -0
  270. package/backend/tests/unit/prompts/test_prompts.py +569 -0
  271. package/backend/tests/unit/providers/__pycache__/test_anthropic_provider.cpython-313-pytest-9.0.3.pyc +0 -0
  272. package/backend/tests/unit/providers/__pycache__/test_errors.cpython-313-pytest-9.0.3.pyc +0 -0
  273. package/backend/tests/unit/providers/__pycache__/test_extract_delta_parts.cpython-313-pytest-9.0.3.pyc +0 -0
  274. package/backend/tests/unit/providers/__pycache__/test_openai_provider.cpython-313-pytest-9.0.3.pyc +0 -0
  275. package/backend/tests/unit/providers/__pycache__/test_openai_responses.cpython-313-pytest-9.0.3.pyc +0 -0
  276. package/backend/tests/unit/providers/__pycache__/test_provider_gateway.cpython-313-pytest-9.0.3.pyc +0 -0
  277. package/backend/tests/unit/providers/__pycache__/test_think_tag_parser.cpython-313-pytest-9.0.3.pyc +0 -0
  278. package/backend/tests/unit/providers/test_anthropic_provider.py +185 -0
  279. package/backend/tests/unit/providers/test_errors.py +68 -0
  280. package/backend/tests/unit/providers/test_extract_delta_parts.py +22 -0
  281. package/backend/tests/unit/providers/test_openai_provider.py +139 -0
  282. package/backend/tests/unit/providers/test_openai_responses.py +402 -0
  283. package/backend/tests/unit/providers/test_provider_gateway.py +359 -0
  284. package/backend/tests/unit/providers/test_think_tag_parser.py +36 -0
  285. package/backend/tests/unit/routes/__pycache__/test_prompts_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  286. package/backend/tests/unit/routes/__pycache__/test_providers_route.cpython-313-pytest-9.0.3.pyc +0 -0
  287. package/backend/tests/unit/routes/__pycache__/test_roles_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  288. package/backend/tests/unit/routes/__pycache__/test_settings_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  289. package/backend/tests/unit/routes/__pycache__/test_stats_routes.cpython-313-pytest-9.0.3.pyc +0 -0
  290. package/backend/tests/unit/routes/test_prompts_routes.py +104 -0
  291. package/backend/tests/unit/routes/test_providers_route.py +368 -0
  292. package/backend/tests/unit/routes/test_roles_routes.py +426 -0
  293. package/backend/tests/unit/routes/test_settings_routes.py +1138 -0
  294. package/backend/tests/unit/routes/test_stats_routes.py +149 -0
  295. package/backend/tests/unit/runtime/__pycache__/test_bootstrap_runtime.cpython-313-pytest-9.0.3.pyc +0 -0
  296. package/backend/tests/unit/runtime/test_bootstrap_runtime.py +1012 -0
  297. package/backend/tests/unit/sandbox/__pycache__/test_sandbox_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  298. package/backend/tests/unit/sandbox/test_sandbox_tools.py +78 -0
  299. package/backend/tests/unit/security/__pycache__/test_security.cpython-313-pytest-9.0.3.pyc +0 -0
  300. package/backend/tests/unit/security/test_security.py +110 -0
  301. package/backend/tests/unit/settings/__pycache__/test_settings_roles.cpython-313-pytest-9.0.3.pyc +0 -0
  302. package/backend/tests/unit/settings/test_settings_roles.py +711 -0
  303. package/backend/tests/unit/test_access.py +45 -0
  304. package/backend/tests/unit/test_cli.py +124 -0
  305. package/backend/tests/unit/test_graph_runtime.py +72 -0
  306. package/backend/tests/unit/test_network.py +51 -0
  307. package/backend/tests/unit/test_state_sqlite_storage.py +93 -0
  308. package/backend/tests/unit/test_workspace_store.py +231 -0
  309. package/backend/tests/unit/tools/__pycache__/test_connect_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  310. package/backend/tests/unit/tools/__pycache__/test_create_agent_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  311. package/backend/tests/unit/tools/__pycache__/test_delete_tab_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  312. package/backend/tests/unit/tools/__pycache__/test_edit_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  313. package/backend/tests/unit/tools/__pycache__/test_exec_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  314. package/backend/tests/unit/tools/__pycache__/test_fetch_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  315. package/backend/tests/unit/tools/__pycache__/test_manage_prompts_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  316. package/backend/tests/unit/tools/__pycache__/test_manage_providers_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  317. package/backend/tests/unit/tools/__pycache__/test_manage_roles_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  318. package/backend/tests/unit/tools/__pycache__/test_manage_settings_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  319. package/backend/tests/unit/tools/__pycache__/test_read_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  320. package/backend/tests/unit/tools/__pycache__/test_set_permissions_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  321. package/backend/tests/unit/tools/__pycache__/test_todo_tool.cpython-313-pytest-9.0.3.pyc +0 -0
  322. package/backend/tests/unit/tools/__pycache__/test_tool_registry.cpython-313-pytest-9.0.3.pyc +0 -0
  323. package/backend/tests/unit/tools/test_connect_tool.py +229 -0
  324. package/backend/tests/unit/tools/test_create_agent_tool.py +524 -0
  325. package/backend/tests/unit/tools/test_delete_tab_tool.py +83 -0
  326. package/backend/tests/unit/tools/test_edit_tool.py +115 -0
  327. package/backend/tests/unit/tools/test_exec_tool.py +81 -0
  328. package/backend/tests/unit/tools/test_fetch_tool.py +65 -0
  329. package/backend/tests/unit/tools/test_manage_prompts_tool.py +117 -0
  330. package/backend/tests/unit/tools/test_manage_providers_tool.py +458 -0
  331. package/backend/tests/unit/tools/test_manage_roles_tool.py +411 -0
  332. package/backend/tests/unit/tools/test_manage_settings_tool.py +608 -0
  333. package/backend/tests/unit/tools/test_read_tool.py +33 -0
  334. package/backend/tests/unit/tools/test_set_permissions_tool.py +391 -0
  335. package/backend/tests/unit/tools/test_todo_tool.py +37 -0
  336. package/backend/tests/unit/tools/test_tool_registry.py +91 -0
  337. package/backend/uv.lock +1144 -0
  338. package/bin/flowent.mjs +62 -36
  339. package/dist/frontend/assets/AssistantPage-B3Xc08AS.js +1 -0
  340. package/dist/frontend/assets/ChannelsPage-ByLd28xk.js +1 -0
  341. package/dist/frontend/assets/HomePage-C0hAx9_l.js +3 -0
  342. package/dist/frontend/assets/McpPage-DkrYLvBv.js +7 -0
  343. package/dist/frontend/assets/PageScaffold-D4jO9ooX.js +1 -0
  344. package/dist/frontend/assets/PromptsPage-DWA7rRJd.js +1 -0
  345. package/dist/frontend/assets/ProvidersPage-PUWT8seJ.js +3 -0
  346. package/dist/frontend/assets/RolesPage-CqcclGRw.js +1 -0
  347. package/dist/frontend/assets/SettingsPage-8tS2cJgX.js +3 -0
  348. package/dist/frontend/assets/StatsPage-BX9khYzu.js +1 -0
  349. package/dist/frontend/assets/ToolsPage-9Tl9FdeD.js +1 -0
  350. package/dist/frontend/assets/WorkspaceCommandDialog-CCXxjDL8.js +1 -0
  351. package/dist/frontend/assets/WorkspacePanels-aMdJ7ZH7.js +1 -0
  352. package/dist/frontend/assets/alert-dialog-kFYVQ7oX.js +1 -0
  353. package/dist/frontend/assets/badge-74-3jsCg.js +1 -0
  354. package/dist/frontend/assets/constants-XUzFf6i1.js +1 -0
  355. package/dist/frontend/assets/datetime-m6_O_Ci9.js +1 -0
  356. package/dist/frontend/assets/dialog-BeGSweF6.js +1 -0
  357. package/dist/frontend/assets/elk-worker.min-C9JGDOE-.js +6312 -0
  358. package/dist/frontend/assets/graph-vendor-CHpVij2M.css +1 -0
  359. package/dist/frontend/assets/graph-vendor-DRq_-6fV.js +7 -0
  360. package/dist/frontend/assets/index-BHC1Vhy8.css +1 -0
  361. package/dist/frontend/assets/index-CL1ALZ3r.js +10 -0
  362. package/dist/frontend/assets/layout.worker-jMHqAFbP.js +24 -0
  363. package/dist/frontend/assets/markdown-vendor-DVdy_w12.js +29 -0
  364. package/dist/frontend/assets/modelParams-CaHd0903.js +1 -0
  365. package/dist/frontend/assets/react-vendor-mEs_JJxa.js +9 -0
  366. package/dist/frontend/assets/roles-2OLDeTc5.js +1 -0
  367. package/dist/frontend/assets/rolldown-runtime-BYbx6iT9.js +1 -0
  368. package/dist/frontend/assets/select-DL_LPeDj.js +1 -0
  369. package/dist/frontend/assets/shared-CMxbpLeQ.js +1 -0
  370. package/dist/frontend/assets/triState-DEr3NkXV.js +1 -0
  371. package/dist/frontend/assets/ui-vendor-Dg9NNnWX.js +51 -0
  372. package/dist/frontend/index.html +36 -0
  373. package/package.json +27 -41
  374. package/dist/.next/BUILD_ID +0 -1
  375. package/dist/.next/app-path-routes-manifest.json +0 -6
  376. package/dist/.next/build-manifest.json +0 -20
  377. package/dist/.next/package.json +0 -1
  378. package/dist/.next/prerender-manifest.json +0 -114
  379. package/dist/.next/required-server-files.json +0 -333
  380. package/dist/.next/routes-manifest.json +0 -69
  381. package/dist/.next/server/app/_global-error/page/app-paths-manifest.json +0 -3
  382. package/dist/.next/server/app/_global-error/page/build-manifest.json +0 -16
  383. package/dist/.next/server/app/_global-error/page/next-font-manifest.json +0 -6
  384. package/dist/.next/server/app/_global-error/page/react-loadable-manifest.json +0 -1
  385. package/dist/.next/server/app/_global-error/page/server-reference-manifest.json +0 -4
  386. package/dist/.next/server/app/_global-error/page.js +0 -9
  387. package/dist/.next/server/app/_global-error/page.js.map +0 -5
  388. package/dist/.next/server/app/_global-error/page.js.nft.json +0 -1
  389. package/dist/.next/server/app/_global-error/page_client-reference-manifest.js +0 -3
  390. package/dist/.next/server/app/_global-error.html +0 -1
  391. package/dist/.next/server/app/_global-error.meta +0 -15
  392. package/dist/.next/server/app/_global-error.rsc +0 -14
  393. package/dist/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +0 -5
  394. package/dist/.next/server/app/_global-error.segments/_full.segment.rsc +0 -14
  395. package/dist/.next/server/app/_global-error.segments/_head.segment.rsc +0 -5
  396. package/dist/.next/server/app/_global-error.segments/_index.segment.rsc +0 -5
  397. package/dist/.next/server/app/_global-error.segments/_tree.segment.rsc +0 -1
  398. package/dist/.next/server/app/_not-found/page/app-paths-manifest.json +0 -3
  399. package/dist/.next/server/app/_not-found/page/build-manifest.json +0 -16
  400. package/dist/.next/server/app/_not-found/page/next-font-manifest.json +0 -10
  401. package/dist/.next/server/app/_not-found/page/react-loadable-manifest.json +0 -1
  402. package/dist/.next/server/app/_not-found/page/server-reference-manifest.json +0 -4
  403. package/dist/.next/server/app/_not-found/page.js +0 -13
  404. package/dist/.next/server/app/_not-found/page.js.map +0 -5
  405. package/dist/.next/server/app/_not-found/page.js.nft.json +0 -1
  406. package/dist/.next/server/app/_not-found/page_client-reference-manifest.js +0 -3
  407. package/dist/.next/server/app/_not-found.html +0 -1
  408. package/dist/.next/server/app/_not-found.meta +0 -16
  409. package/dist/.next/server/app/_not-found.rsc +0 -16
  410. package/dist/.next/server/app/_not-found.segments/_full.segment.rsc +0 -16
  411. package/dist/.next/server/app/_not-found.segments/_head.segment.rsc +0 -6
  412. package/dist/.next/server/app/_not-found.segments/_index.segment.rsc +0 -5
  413. package/dist/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +0 -5
  414. package/dist/.next/server/app/_not-found.segments/_not-found.segment.rsc +0 -5
  415. package/dist/.next/server/app/_not-found.segments/_tree.segment.rsc +0 -2
  416. package/dist/.next/server/app/icon.svg/route/app-paths-manifest.json +0 -3
  417. package/dist/.next/server/app/icon.svg/route/build-manifest.json +0 -9
  418. package/dist/.next/server/app/icon.svg/route.js +0 -6
  419. package/dist/.next/server/app/icon.svg/route.js.map +0 -5
  420. package/dist/.next/server/app/icon.svg/route.js.nft.json +0 -1
  421. package/dist/.next/server/app/icon.svg.meta +0 -1
  422. package/dist/.next/server/app/index.html +0 -1
  423. package/dist/.next/server/app/index.meta +0 -14
  424. package/dist/.next/server/app/index.rsc +0 -15
  425. package/dist/.next/server/app/index.segments/__PAGE__.segment.rsc +0 -5
  426. package/dist/.next/server/app/index.segments/_full.segment.rsc +0 -15
  427. package/dist/.next/server/app/index.segments/_head.segment.rsc +0 -6
  428. package/dist/.next/server/app/index.segments/_index.segment.rsc +0 -5
  429. package/dist/.next/server/app/index.segments/_tree.segment.rsc +0 -3
  430. package/dist/.next/server/app/page/app-paths-manifest.json +0 -3
  431. package/dist/.next/server/app/page/build-manifest.json +0 -16
  432. package/dist/.next/server/app/page/next-font-manifest.json +0 -10
  433. package/dist/.next/server/app/page/react-loadable-manifest.json +0 -1
  434. package/dist/.next/server/app/page/server-reference-manifest.json +0 -4
  435. package/dist/.next/server/app/page.js +0 -14
  436. package/dist/.next/server/app/page.js.map +0 -5
  437. package/dist/.next/server/app/page.js.nft.json +0 -1
  438. package/dist/.next/server/app/page_client-reference-manifest.js +0 -3
  439. package/dist/.next/server/app-paths-manifest.json +0 -6
  440. package/dist/.next/server/chunks/[externals]_next_dist_0arv.vj._.js +0 -3
  441. package/dist/.next/server/chunks/[root-of-the-server]__0vcj1q1._.js +0 -13
  442. package/dist/.next/server/chunks/[turbopack]_runtime.js +0 -903
  443. package/dist/.next/server/chunks/_next-internal_server_app_icon_svg_route_actions_0-0ehc~.js +0 -3
  444. package/dist/.next/server/chunks/ssr/05w9_next_dist_0ihu0u9._.js +0 -6
  445. package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_12u3mib._.js +0 -3
  446. package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_builtin_forbidden_04fbe_..js +0 -3
  447. package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_builtin_global-error_0brpl_..js +0 -3
  448. package/dist/.next/server/chunks/ssr/05w9_next_dist_client_components_builtin_unauthorized_0~2g66g.js +0 -3
  449. package/dist/.next/server/chunks/ssr/05w9_next_dist_esm_build_templates_app-page_0~cyr1_.js +0 -4
  450. package/dist/.next/server/chunks/ssr/05w9_next_dist_esm_build_templates_app-page_1105emf.js +0 -4
  451. package/dist/.next/server/chunks/ssr/05w9_next_dist_esm_build_templates_app-page_11uhyqv.js +0 -4
  452. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0.t9_75._.js +0 -33
  453. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0c0ud_z._.js +0 -3
  454. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0f9_8d4._.js +0 -3
  455. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0l5ko41._.js +0 -19
  456. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0mn6z7i._.js +0 -3
  457. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0npxxst._.js +0 -33
  458. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0qjhaca._.js +0 -3
  459. package/dist/.next/server/chunks/ssr/[root-of-the-server]__0rwgw3s._.js +0 -3
  460. package/dist/.next/server/chunks/ssr/[turbopack]_runtime.js +0 -903
  461. package/dist/.next/server/chunks/ssr/_next-internal_server_app__global-error_page_actions_0k77kol.js +0 -3
  462. package/dist/.next/server/chunks/ssr/_next-internal_server_app__not-found_page_actions_0eq97pa.js +0 -3
  463. package/dist/.next/server/chunks/ssr/_next-internal_server_app_page_actions_09-gtaw.js +0 -3
  464. package/dist/.next/server/chunks/ssr/node_modules__pnpm_056~6.6._.js +0 -3
  465. package/dist/.next/server/chunks/ssr/node_modules__pnpm_0~j0k.e._.js +0 -33
  466. package/dist/.next/server/functions-config-manifest.json +0 -4
  467. package/dist/.next/server/middleware-build-manifest.js +0 -20
  468. package/dist/.next/server/middleware-manifest.json +0 -6
  469. package/dist/.next/server/next-font-manifest.js +0 -1
  470. package/dist/.next/server/next-font-manifest.json +0 -13
  471. package/dist/.next/server/pages/404.html +0 -1
  472. package/dist/.next/server/pages/500.html +0 -1
  473. package/dist/.next/server/pages-manifest.json +0 -4
  474. package/dist/.next/server/prefetch-hints.json +0 -1
  475. package/dist/.next/server/server-reference-manifest.js +0 -1
  476. package/dist/.next/server/server-reference-manifest.json +0 -5
  477. package/dist/.next/static/SMWpxFVvkpYFxY7uuFvGB/_buildManifest.js +0 -11
  478. package/dist/.next/static/SMWpxFVvkpYFxY7uuFvGB/_clientMiddlewareManifest.js +0 -1
  479. package/dist/.next/static/SMWpxFVvkpYFxY7uuFvGB/_ssgManifest.js +0 -1
  480. package/dist/.next/static/chunks/01qk2~bgf76vu.js +0 -1
  481. package/dist/.next/static/chunks/03~yq9q893hmn.js +0 -1
  482. package/dist/.next/static/chunks/080queev.r2uy.js +0 -31
  483. package/dist/.next/static/chunks/0v3lyuj75aq50.js +0 -1
  484. package/dist/.next/static/chunks/10b~xdx5c-i7s.js +0 -5
  485. package/dist/.next/static/chunks/14gla2ascffgv.css +0 -2
  486. package/dist/.next/static/chunks/turbopack-0m-970~qvs7sc.js +0 -1
  487. package/dist/.next/static/media/7178b3e590c64307-s.11.cyxs5p-0z~.woff2 +0 -0
  488. package/dist/.next/static/media/8a480f0b521d4e75-s.06d3mdzz5bre_.woff2 +0 -0
  489. package/dist/.next/static/media/caa3a2e1cccd8315-s.p.16t1db8_9y2o~.woff2 +0 -0
  490. package/dist/package.json +0 -88
  491. package/dist/server.js +0 -38
  492. /package/{dist/.next/server/app/icon.svg.body → backend/src/flowent/static/favicon.svg} +0 -0
  493. /package/dist/{.next/static/media/icon.0.r~afrtrocz9.svg → frontend/favicon.svg} +0 -0
@@ -0,0 +1,2808 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ import time as _time
6
+ import traceback
7
+ import uuid as _uuid
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass, field
10
+ from functools import lru_cache, partial
11
+ from queue import Empty, Queue
12
+ from typing import Any
13
+
14
+ from loguru import logger
15
+
16
+ from flowent.assistant_commands import build_assistant_help_text
17
+ from flowent.events import event_bus
18
+ from flowent.image_assets import create_image_asset, require_image_asset
19
+ from flowent.models import (
20
+ AgentState,
21
+ AssistantText,
22
+ AssistantThinking,
23
+ CommandResultEntry,
24
+ ContentDelta,
25
+ ErrorEntry,
26
+ Event,
27
+ EventType,
28
+ HistoryEntry,
29
+ ImagePart,
30
+ LLMOutputImagePart,
31
+ LLMOutputTextPart,
32
+ LLMResponse,
33
+ LLMUsage,
34
+ Message,
35
+ ModelInfo,
36
+ NodeConfig,
37
+ NodeType,
38
+ ReceivedMessage,
39
+ SentMessage,
40
+ StateEntry,
41
+ SystemEntry,
42
+ TextPart,
43
+ ThinkingDelta,
44
+ TodoItem,
45
+ ToolCall,
46
+ ToolResultDelta,
47
+ content_parts_to_text,
48
+ deserialize_content_parts,
49
+ has_image_parts,
50
+ parse_content_parts_payload,
51
+ )
52
+ from flowent.prompts import get_system_prompt
53
+ from flowent.providers.errors import LLMProviderError
54
+ from flowent.providers.gateway import gateway
55
+ from flowent.providers.thinking import ThinkTagParser, split_thinking_content
56
+ from flowent.security import authorize
57
+ from flowent.settings import get_settings
58
+
59
+
60
+ @lru_cache(maxsize=1)
61
+ def _get_tool_registry() -> Any:
62
+ from flowent.tools import build_tool_registry
63
+
64
+ return build_tool_registry()
65
+
66
+
67
+ @dataclass
68
+ class WakeSignal:
69
+ reason: str
70
+ payload: dict[str, Any]
71
+ resume_reason: str = ""
72
+
73
+
74
+ @dataclass
75
+ class StreamingContentState:
76
+ content_buffer: str = ""
77
+ thinking_buffer: str = ""
78
+ saw_content_chunks: bool = False
79
+ think_parser: ThinkTagParser = field(default_factory=ThinkTagParser)
80
+ emitted_human_content: bool = False
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class ContextPreflight:
85
+ estimated_total_tokens: int
86
+ context_window_tokens: int | None = None
87
+ auto_compact_token_limit: int | None = None
88
+ safe_input_tokens: int | None = None
89
+
90
+
91
+ class InterruptRequestedError(Exception):
92
+ def __init__(self, stream_state: StreamingContentState | None = None) -> None:
93
+ super().__init__("interrupt requested")
94
+ self.stream_state = stream_state
95
+
96
+
97
+ class ContextPreflightError(RuntimeError):
98
+ pass
99
+
100
+
101
+ DEFAULT_CONTEXT_OUTPUT_BUDGET_TOKENS = 1024
102
+ DEFAULT_CONTEXT_PROVIDER_HEADROOM_TOKENS = 1024
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class PreparedLLMContext:
107
+ messages: list[dict[str, Any]]
108
+ system_messages: list[dict[str, Any]]
109
+ execution_context_messages: list[dict[str, Any]]
110
+ runtime_tail_messages: list[dict[str, Any]]
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class ContextTokenUsageBaseline:
115
+ usage: LLMUsage
116
+ system_messages: list[dict[str, Any]]
117
+ execution_context_messages: list[dict[str, Any]]
118
+ runtime_tail_messages: list[dict[str, Any]]
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class ResolvedModelSource:
123
+ provider_id: str | None
124
+ provider_name: str | None
125
+ provider_type: str | None
126
+ model: str | None
127
+ model_info: ModelInfo | None
128
+
129
+
130
+ def build_error_context(content: str) -> str:
131
+ return f"<system>Previous runtime error:\n{content}</system>"
132
+
133
+
134
+ class Agent:
135
+ def __init__(
136
+ self,
137
+ config: NodeConfig,
138
+ uuid: str | None = None,
139
+ ) -> None:
140
+ self.uuid = uuid or str(_uuid.uuid4())
141
+ self.config = config
142
+ self.node_type = config.node_type
143
+ self.role_name = config.role_name
144
+ self.state = AgentState.INITIALIZING
145
+ self.todos: list[TodoItem] = []
146
+ self.connections: list[str] = []
147
+ self.history: list[HistoryEntry] = []
148
+ self.history.append(StateEntry(state=self.state.value, reason="created"))
149
+ self._terminate = threading.Event()
150
+ self._interrupt_requested = threading.Event()
151
+ self._interrupt_callback_lock = threading.Lock()
152
+ self._interrupt_callback: Callable[[], None] | None = None
153
+ self._command_interrupt_lock = threading.Lock()
154
+ self._pause_after_interrupt_requested = threading.Event()
155
+ self._paused_for_command = threading.Event()
156
+ self._resume_after_command = threading.Event()
157
+ self._idle_state_event = threading.Event()
158
+ self._idle_started_at: float | None = None
159
+ self._idle_started_by_tool_call_id: str | None = None
160
+ self._wake_queue: Queue[WakeSignal] = Queue()
161
+ self._thread: threading.Thread | None = None
162
+ self._termination_reason: str = ""
163
+ self._preserve_workspace_state_on_exit = False
164
+ self._connections_lock = threading.Lock()
165
+ self._history_lock = threading.Lock()
166
+ self._todos_lock = threading.Lock()
167
+ self._runtime_notice_lock = threading.Lock()
168
+ self._execution_context_lock = threading.Lock()
169
+ self._execution_context_summary: str = ""
170
+ self._execution_context_history_cutoff: int = 0
171
+ self._context_token_usage_baseline: ContextTokenUsageBaseline | None = None
172
+ self._pending_runtime_notices: list[str] = []
173
+ self._pending_input_turn = False
174
+ self._turn_started_with_pending_input = False
175
+ self._turn_made_progress = False
176
+ self._log = logger.bind(
177
+ agent_id=self.uuid[:8], node_type=self.config.node_type.value
178
+ )
179
+
180
+ def _persist_workspace_node(self) -> None:
181
+ if self.node_type != NodeType.ASSISTANT and not self.config.tab_id:
182
+ return
183
+ from flowent.models import GraphNodeRecord
184
+ from flowent.workspace_store import workspace_store
185
+
186
+ existing = workspace_store.get_node_record(self.uuid)
187
+ record = GraphNodeRecord(
188
+ id=self.uuid,
189
+ config=self.config,
190
+ state=self.state,
191
+ todos=self.get_todos_snapshot(),
192
+ history=self.get_history_snapshot(),
193
+ execution_context_summary=self.get_execution_context_summary(),
194
+ execution_context_history_cutoff=self.get_execution_context_history_cutoff(),
195
+ position=existing.position if existing is not None else None,
196
+ created_at=existing.created_at if existing is not None else _time.time(),
197
+ updated_at=_time.time(),
198
+ )
199
+ workspace_store.upsert_node_record(record)
200
+
201
+ def add_connection(self, other_uuid: str) -> None:
202
+ with self._connections_lock:
203
+ if other_uuid not in self.connections:
204
+ self.connections.append(other_uuid)
205
+
206
+ def remove_connection(self, other_uuid: str) -> None:
207
+ with self._connections_lock:
208
+ if other_uuid in self.connections:
209
+ self.connections.remove(other_uuid)
210
+
211
+ def is_connected_to(self, uuid: str) -> bool:
212
+ with self._connections_lock:
213
+ return uuid in self.connections
214
+
215
+ def get_connections_snapshot(self) -> list[str]:
216
+ with self._connections_lock:
217
+ return list(self.connections)
218
+
219
+ def get_history_snapshot(self) -> list[HistoryEntry]:
220
+ with self._history_lock:
221
+ return list(self.history)
222
+
223
+ def get_todos_snapshot(self) -> list[TodoItem]:
224
+ with self._todos_lock:
225
+ return [TodoItem(text=t.text) for t in self.todos]
226
+
227
+ def get_execution_context_summary(self) -> str:
228
+ with self._execution_context_lock:
229
+ return self._execution_context_summary
230
+
231
+ def get_execution_context_history_cutoff(self) -> int:
232
+ with self._execution_context_lock:
233
+ return self._execution_context_history_cutoff
234
+
235
+ def _set_execution_context(
236
+ self,
237
+ *,
238
+ summary: str,
239
+ history_cutoff: int,
240
+ ) -> None:
241
+ with self._execution_context_lock:
242
+ self._execution_context_summary = summary
243
+ self._execution_context_history_cutoff = max(history_cutoff, 0)
244
+ self._context_token_usage_baseline = None
245
+
246
+ def _reset_execution_context(self) -> None:
247
+ self._set_execution_context(summary="", history_cutoff=0)
248
+
249
+ def prime_runtime_state(self, state: AgentState) -> None:
250
+ self.state = state
251
+ if state == AgentState.IDLE:
252
+ self._idle_started_at = _time.perf_counter()
253
+ self._idle_state_event.set()
254
+ else:
255
+ self._idle_started_at = None
256
+ self._idle_started_by_tool_call_id = None
257
+ self._idle_state_event.clear()
258
+
259
+ def set_todos(self, todos: list[TodoItem]) -> None:
260
+ with self._todos_lock:
261
+ self.todos = [TodoItem(text=t.text) for t in todos]
262
+ self._persist_workspace_node()
263
+
264
+ def request_idle(self, *, tool_call_id: str | None = None) -> str:
265
+ if self._has_pending_runtime_notices():
266
+ self._log.debug("Skipping idle because runtime notice is pending")
267
+ return ""
268
+ if (
269
+ self.node_type == NodeType.ASSISTANT
270
+ and self._turn_started_with_pending_input
271
+ and not self._turn_made_progress
272
+ ):
273
+ self._queue_runtime_notice(self._build_idle_without_progress_notice())
274
+ self._log.debug("Skipping idle because fresh input is still unhandled")
275
+ return ""
276
+ actionable_todo = self._get_first_actionable_todo()
277
+ if actionable_todo is not None:
278
+ self._queue_runtime_notice(
279
+ self._build_actionable_todo_notice(actionable_todo)
280
+ )
281
+ self._log.debug("Skipping idle because TODO is still actionable")
282
+ return ""
283
+ self._idle_started_by_tool_call_id = tool_call_id
284
+ self.set_state(AgentState.IDLE)
285
+ signal = self._wait_for_wakeup()
286
+ elapsed = self._get_idle_elapsed_seconds(tool_call_id=tool_call_id)
287
+ self._resume_from_wakeup(signal)
288
+ return f"idle {elapsed:.2f}s"
289
+
290
+ def request_sleep(self, *, seconds: float) -> str:
291
+ duration = max(0.0, seconds)
292
+ started_at = _time.perf_counter()
293
+ if duration <= 0:
294
+ self._queue_runtime_notice(self._build_sleep_deadline_notice())
295
+ return "slept 0.00s"
296
+
297
+ self.set_state(AgentState.SLEEPING, f"sleeping for {duration:.2f}s")
298
+ while not self._terminate.is_set():
299
+ self._raise_if_interrupt_requested()
300
+ remaining = duration - (_time.perf_counter() - started_at)
301
+ if remaining <= 0:
302
+ self._queue_runtime_notice(self._build_sleep_deadline_notice())
303
+ self.set_state(AgentState.RUNNING, "sleep deadline reached")
304
+ break
305
+ try:
306
+ signal = self._wake_queue.get(timeout=min(remaining, 0.1))
307
+ except Empty:
308
+ continue
309
+ if signal.reason == "termination":
310
+ self._terminate.set()
311
+ break
312
+ if signal.reason == "message":
313
+ self._resume_from_wakeup(signal)
314
+ self._drain_messages()
315
+ elapsed = max(0.0, _time.perf_counter() - started_at)
316
+ return f"woken by message after {elapsed:.2f}s"
317
+ elapsed = min(duration, max(0.0, _time.perf_counter() - started_at))
318
+ return f"slept {elapsed:.2f}s"
319
+
320
+ def _get_idle_elapsed_seconds(self, *, tool_call_id: str | None) -> float:
321
+ started_at = self._idle_started_at
322
+ if started_at is None:
323
+ return 0.0
324
+ if tool_call_id is not None and self._idle_started_by_tool_call_id not in {
325
+ None,
326
+ tool_call_id,
327
+ }:
328
+ return 0.0
329
+ return max(0.0, _time.perf_counter() - started_at)
330
+
331
+ def get_contact_ids_snapshot(self) -> list[str]:
332
+ from flowent.graph_service import get_tab_leader_id
333
+ from flowent.registry import registry
334
+ from flowent.workspace_store import workspace_store
335
+
336
+ if self.node_type == NodeType.ASSISTANT:
337
+ return [
338
+ leader_id
339
+ for leader_id in (
340
+ get_tab_leader_id(tab.id) for tab in workspace_store.list_tabs()
341
+ )
342
+ if leader_id and registry.get(leader_id) is not None
343
+ ]
344
+
345
+ seen_ids: set[str] = set()
346
+ contact_ids: list[str] = []
347
+
348
+ def append_contact(node_id: str) -> None:
349
+ if node_id == self.uuid or node_id in seen_ids:
350
+ return
351
+ if registry.get(node_id) is None:
352
+ return
353
+ seen_ids.add(node_id)
354
+ contact_ids.append(node_id)
355
+
356
+ if self.config.tab_id is None:
357
+ return contact_ids
358
+
359
+ leader_id = get_tab_leader_id(self.config.tab_id)
360
+ is_leader = leader_id == self.uuid
361
+ assistant = registry.get_assistant()
362
+ if is_leader and assistant is not None:
363
+ append_contact(assistant.uuid)
364
+ if not is_leader and leader_id is not None:
365
+ append_contact(leader_id)
366
+
367
+ if is_leader:
368
+ for node in registry.get_all():
369
+ if node.uuid == self.uuid or node.node_type != NodeType.AGENT:
370
+ continue
371
+ if node.config.tab_id != self.config.tab_id:
372
+ continue
373
+ if node.uuid == leader_id:
374
+ continue
375
+ append_contact(node.uuid)
376
+ return contact_ids
377
+
378
+ return contact_ids
379
+
380
+ def get_contacts_info(self) -> list[dict[str, Any]]:
381
+ from flowent.graph_service import is_tab_leader
382
+ from flowent.registry import registry
383
+
384
+ result: list[dict[str, Any]] = []
385
+ for contact_id in self.get_contact_ids_snapshot():
386
+ node = registry.get(contact_id)
387
+ if node is None:
388
+ continue
389
+ result.append(
390
+ {
391
+ "id": node.uuid,
392
+ "node_type": node.config.node_type.value,
393
+ "role_name": node.config.role_name,
394
+ "name": node.config.name,
395
+ "state": node.state.value,
396
+ "is_leader": (
397
+ node.config.tab_id is not None
398
+ and is_tab_leader(node_id=node.uuid, tab_id=node.config.tab_id)
399
+ ),
400
+ }
401
+ )
402
+ return result
403
+
404
+ def can_contact(self, node_id: str) -> bool:
405
+ return node_id in set(self.get_contact_ids_snapshot())
406
+
407
+ def wait_until_idle(self, timeout: float | None = None) -> bool:
408
+ if self.state == AgentState.IDLE:
409
+ return True
410
+ return self._idle_state_event.wait(timeout=timeout)
411
+
412
+ def start(self) -> None:
413
+ from flowent.graph_service import is_tab_leader
414
+
415
+ self._thread = threading.Thread(
416
+ target=self._run,
417
+ name=f"agent-{self.uuid[:8]}",
418
+ daemon=True,
419
+ )
420
+ self._thread.start()
421
+ event_bus.emit(
422
+ Event(
423
+ type=EventType.NODE_CREATED,
424
+ agent_id=self.uuid,
425
+ data={
426
+ "node_type": self.config.node_type.value,
427
+ "role_name": self.config.role_name,
428
+ "name": self.config.name,
429
+ "tab_id": self.config.tab_id,
430
+ "is_leader": is_tab_leader(
431
+ node_id=self.uuid,
432
+ tab_id=self.config.tab_id,
433
+ ),
434
+ },
435
+ ),
436
+ )
437
+ self._persist_workspace_node()
438
+
439
+ def _append_history(self, entry: HistoryEntry) -> None:
440
+ with self._history_lock:
441
+ self.history.append(entry)
442
+ if isinstance(entry, ReceivedMessage):
443
+ self._pending_input_turn = True
444
+ data = entry.serialize()
445
+ self._log.debug(
446
+ "History append: type={}, content_len={}",
447
+ data.get("type"),
448
+ len(getattr(entry, "content", None) or "")
449
+ if hasattr(entry, "content")
450
+ else 0,
451
+ )
452
+ event_bus.emit(
453
+ Event(
454
+ type=EventType.HISTORY_ENTRY_ADDED,
455
+ agent_id=self.uuid,
456
+ data=data,
457
+ ),
458
+ )
459
+ self._persist_workspace_node()
460
+
461
+ def _clear_pending_message_wakeups(self) -> None:
462
+ preserved_signals: list[WakeSignal] = []
463
+
464
+ while True:
465
+ try:
466
+ signal = self._wake_queue.get_nowait()
467
+ except Empty:
468
+ break
469
+
470
+ if signal.reason != "message":
471
+ preserved_signals.append(signal)
472
+
473
+ for signal in preserved_signals:
474
+ self._wake_queue.put(signal)
475
+
476
+ def _extract_pending_message_wakeups(self) -> list[WakeSignal]:
477
+ extracted_signals: list[WakeSignal] = []
478
+ preserved_signals: list[WakeSignal] = []
479
+
480
+ while True:
481
+ try:
482
+ signal = self._wake_queue.get_nowait()
483
+ except Empty:
484
+ break
485
+
486
+ if signal.reason == "message":
487
+ extracted_signals.append(signal)
488
+ continue
489
+ preserved_signals.append(signal)
490
+
491
+ for signal in preserved_signals:
492
+ self._wake_queue.put(signal)
493
+
494
+ return extracted_signals
495
+
496
+ def _restore_pending_message_wakeups(
497
+ self,
498
+ signals: list[WakeSignal],
499
+ ) -> None:
500
+ for signal in signals:
501
+ self._wake_queue.put(signal)
502
+
503
+ def _pause_for_command_execution(self, *, timeout: float) -> bool:
504
+ if self.state not in {AgentState.RUNNING, AgentState.SLEEPING}:
505
+ return False
506
+ if not self._command_interrupt_lock.acquire(timeout=timeout):
507
+ raise TimeoutError("Assistant did not pause for the command in time")
508
+
509
+ self._pause_after_interrupt_requested.set()
510
+ self._paused_for_command.clear()
511
+ self._resume_after_command.clear()
512
+
513
+ try:
514
+ if not self.request_interrupt():
515
+ self._pause_after_interrupt_requested.clear()
516
+ self._resume_after_command.set()
517
+ self._command_interrupt_lock.release()
518
+ return False
519
+ if not self._paused_for_command.wait(timeout=timeout):
520
+ raise TimeoutError("Assistant did not pause after interrupt")
521
+ return True
522
+ except Exception:
523
+ self._pause_after_interrupt_requested.clear()
524
+ self._resume_after_command.set()
525
+ self._command_interrupt_lock.release()
526
+ raise
527
+
528
+ def _resume_after_command_execution(self) -> None:
529
+ self._pause_after_interrupt_requested.clear()
530
+ self._resume_after_command.set()
531
+ self._command_interrupt_lock.release()
532
+
533
+ def clear_chat_history(self, *, interrupt_timeout: float = 5.0) -> None:
534
+ if self.node_type != NodeType.ASSISTANT:
535
+ raise RuntimeError("Only assistant chat history can be cleared")
536
+
537
+ paused_for_command = self._pause_for_command_execution(
538
+ timeout=interrupt_timeout
539
+ )
540
+ try:
541
+ self._clear_pending_message_wakeups()
542
+ with self._runtime_notice_lock:
543
+ self._pending_runtime_notices.clear()
544
+ with self._history_lock:
545
+ self.history = [
546
+ entry
547
+ for entry in self.history
548
+ if isinstance(entry, (SystemEntry, StateEntry))
549
+ ]
550
+ self._pending_input_turn = False
551
+ self._turn_started_with_pending_input = False
552
+ self._turn_made_progress = False
553
+ self._reset_execution_context()
554
+
555
+ event_bus.emit(
556
+ Event(
557
+ type=EventType.HISTORY_CLEARED,
558
+ agent_id=self.uuid,
559
+ data={"scope": "assistant_chat"},
560
+ )
561
+ )
562
+ self._persist_workspace_node()
563
+ finally:
564
+ if paused_for_command:
565
+ self._resume_after_command_execution()
566
+
567
+ def retry_human_message(
568
+ self,
569
+ *,
570
+ message_id: str,
571
+ interrupt_timeout: float = 5.0,
572
+ ) -> str:
573
+ if self.node_type != NodeType.ASSISTANT:
574
+ raise RuntimeError("Only assistant chat history can be retried")
575
+
576
+ normalized_message_id = message_id.strip()
577
+ if not normalized_message_id:
578
+ raise ValueError("Assistant retry message_id cannot be empty")
579
+
580
+ paused_for_command = self._pause_for_command_execution(
581
+ timeout=interrupt_timeout
582
+ )
583
+ extracted_message_signals: list[WakeSignal] = []
584
+ previous_runtime_notices: list[str] = []
585
+ previous_history: list[HistoryEntry] = []
586
+ previous_pending_input_turn = False
587
+ previous_turn_started_with_pending_input = False
588
+ previous_turn_made_progress = False
589
+ previous_execution_summary = ""
590
+ previous_execution_cutoff = 0
591
+ previous_context_token_usage_baseline: ContextTokenUsageBaseline | None = None
592
+ try:
593
+ with self._history_lock:
594
+ anchor_index = -1
595
+ anchor_parts: list[TextPart | ImagePart] = []
596
+ current_history = list(self.history)
597
+ for index, entry in enumerate(current_history):
598
+ if (
599
+ isinstance(entry, ReceivedMessage)
600
+ and entry.from_id == "human"
601
+ and entry.message_id == normalized_message_id
602
+ ):
603
+ anchor_index = index
604
+ anchor_parts = list(entry.parts)
605
+ break
606
+
607
+ if anchor_index < 0:
608
+ raise LookupError(
609
+ f"Assistant human message `{normalized_message_id}` was not found."
610
+ )
611
+
612
+ if has_image_parts(anchor_parts) and not self.supports_input_image():
613
+ raise RuntimeError(
614
+ "Assistant current model does not support `input_image`."
615
+ )
616
+
617
+ for part in anchor_parts:
618
+ asset_id = getattr(part, "asset_id", None)
619
+ if isinstance(asset_id, str):
620
+ require_image_asset(asset_id)
621
+
622
+ extracted_message_signals = self._extract_pending_message_wakeups()
623
+ with self._runtime_notice_lock:
624
+ previous_runtime_notices = list(self._pending_runtime_notices)
625
+ self._pending_runtime_notices.clear()
626
+
627
+ with self._history_lock:
628
+ previous_history = list(self.history)
629
+ previous_pending_input_turn = self._pending_input_turn
630
+ previous_turn_started_with_pending_input = (
631
+ self._turn_started_with_pending_input
632
+ )
633
+ previous_turn_made_progress = self._turn_made_progress
634
+ self.history = [
635
+ entry
636
+ for index, entry in enumerate(previous_history)
637
+ if index < anchor_index
638
+ or isinstance(entry, (SystemEntry, StateEntry))
639
+ ]
640
+ retried_message_id = str(_uuid.uuid4())
641
+ self.history.append(
642
+ ReceivedMessage(
643
+ from_id="human",
644
+ parts=anchor_parts,
645
+ message_id=retried_message_id,
646
+ )
647
+ )
648
+ self._pending_input_turn = True
649
+ self._turn_started_with_pending_input = False
650
+ self._turn_made_progress = False
651
+
652
+ with self._execution_context_lock:
653
+ previous_execution_summary = self._execution_context_summary
654
+ previous_execution_cutoff = self._execution_context_history_cutoff
655
+ previous_context_token_usage_baseline = (
656
+ self._context_token_usage_baseline
657
+ )
658
+ self._set_execution_context(summary="", history_cutoff=0)
659
+ self._persist_workspace_node()
660
+ self.enqueue_message(
661
+ Message(
662
+ from_id="human",
663
+ to_id=self.uuid,
664
+ parts=anchor_parts,
665
+ message_id=retried_message_id,
666
+ history_recorded=True,
667
+ )
668
+ )
669
+ history_snapshot = self.get_history_snapshot()
670
+ event_bus.emit(
671
+ Event(
672
+ type=EventType.HISTORY_REPLACED,
673
+ agent_id=self.uuid,
674
+ data={
675
+ "scope": "assistant_retry",
676
+ "replaced_message_id": normalized_message_id,
677
+ "message_id": retried_message_id,
678
+ "history": [entry.serialize() for entry in history_snapshot],
679
+ },
680
+ )
681
+ )
682
+ return retried_message_id
683
+ except Exception:
684
+ if previous_history:
685
+ with self._history_lock:
686
+ self.history = previous_history
687
+ self._pending_input_turn = previous_pending_input_turn
688
+ self._turn_started_with_pending_input = (
689
+ previous_turn_started_with_pending_input
690
+ )
691
+ self._turn_made_progress = previous_turn_made_progress
692
+ with self._runtime_notice_lock:
693
+ self._pending_runtime_notices = previous_runtime_notices
694
+ with self._execution_context_lock:
695
+ self._execution_context_summary = previous_execution_summary
696
+ self._execution_context_history_cutoff = previous_execution_cutoff
697
+ self._context_token_usage_baseline = (
698
+ previous_context_token_usage_baseline
699
+ )
700
+ if extracted_message_signals:
701
+ self._restore_pending_message_wakeups(extracted_message_signals)
702
+ self._persist_workspace_node()
703
+ raise
704
+ finally:
705
+ if paused_for_command:
706
+ self._resume_after_command_execution()
707
+
708
+ def retry_received_message(
709
+ self,
710
+ *,
711
+ message_id: str,
712
+ interrupt_timeout: float = 5.0,
713
+ ) -> str:
714
+ normalized_message_id = message_id.strip()
715
+ if not normalized_message_id:
716
+ raise ValueError("Retry message_id cannot be empty")
717
+
718
+ leader_human_only = False
719
+ if self.node_type != NodeType.ASSISTANT and self.config.tab_id:
720
+ from flowent.graph_service import is_tab_leader
721
+
722
+ leader_human_only = is_tab_leader(
723
+ node_id=self.uuid,
724
+ tab_id=self.config.tab_id,
725
+ )
726
+
727
+ paused_for_command = self._pause_for_command_execution(
728
+ timeout=interrupt_timeout
729
+ )
730
+ extracted_message_signals: list[WakeSignal] = []
731
+ previous_runtime_notices: list[str] = []
732
+ previous_history: list[HistoryEntry] = []
733
+ previous_pending_input_turn = False
734
+ previous_turn_started_with_pending_input = False
735
+ previous_turn_made_progress = False
736
+ previous_execution_summary = ""
737
+ previous_execution_cutoff = 0
738
+ previous_context_token_usage_baseline: ContextTokenUsageBaseline | None = None
739
+ try:
740
+ with self._history_lock:
741
+ anchor_index = -1
742
+ anchor_parts: list[TextPart | ImagePart] = []
743
+ anchor_from_id = "human"
744
+ current_history = list(self.history)
745
+ for index, entry in enumerate(current_history):
746
+ if (
747
+ isinstance(entry, ReceivedMessage)
748
+ and entry.message_id == normalized_message_id
749
+ and (not leader_human_only or entry.from_id == "human")
750
+ ):
751
+ anchor_index = index
752
+ anchor_parts = list(entry.parts)
753
+ anchor_from_id = entry.from_id
754
+ break
755
+
756
+ if anchor_index < 0:
757
+ if leader_human_only:
758
+ raise LookupError(
759
+ f"Leader human message `{normalized_message_id}` was not found."
760
+ )
761
+ raise LookupError(
762
+ f"Received message `{normalized_message_id}` was not found."
763
+ )
764
+
765
+ if has_image_parts(anchor_parts) and not self.supports_input_image():
766
+ raise RuntimeError("Current model does not support `input_image`.")
767
+
768
+ for part in anchor_parts:
769
+ asset_id = getattr(part, "asset_id", None)
770
+ if isinstance(asset_id, str):
771
+ require_image_asset(asset_id)
772
+
773
+ extracted_message_signals = self._extract_pending_message_wakeups()
774
+ with self._runtime_notice_lock:
775
+ previous_runtime_notices = list(self._pending_runtime_notices)
776
+ self._pending_runtime_notices.clear()
777
+
778
+ with self._history_lock:
779
+ previous_history = list(self.history)
780
+ previous_pending_input_turn = self._pending_input_turn
781
+ previous_turn_started_with_pending_input = (
782
+ self._turn_started_with_pending_input
783
+ )
784
+ previous_turn_made_progress = self._turn_made_progress
785
+ self.history = [
786
+ entry
787
+ for index, entry in enumerate(previous_history)
788
+ if index < anchor_index
789
+ or isinstance(entry, (SystemEntry, StateEntry))
790
+ ]
791
+ retried_message_id = str(_uuid.uuid4())
792
+ self.history.append(
793
+ ReceivedMessage(
794
+ from_id=anchor_from_id,
795
+ parts=anchor_parts,
796
+ message_id=retried_message_id,
797
+ )
798
+ )
799
+ self._pending_input_turn = True
800
+ self._turn_started_with_pending_input = False
801
+ self._turn_made_progress = False
802
+
803
+ with self._execution_context_lock:
804
+ previous_execution_summary = self._execution_context_summary
805
+ previous_execution_cutoff = self._execution_context_history_cutoff
806
+ previous_context_token_usage_baseline = (
807
+ self._context_token_usage_baseline
808
+ )
809
+ self._set_execution_context(summary="", history_cutoff=0)
810
+ self._persist_workspace_node()
811
+ self.enqueue_message(
812
+ Message(
813
+ from_id=anchor_from_id,
814
+ to_id=self.uuid,
815
+ parts=anchor_parts,
816
+ message_id=retried_message_id,
817
+ history_recorded=True,
818
+ )
819
+ )
820
+ history_snapshot = self.get_history_snapshot()
821
+ event_bus.emit(
822
+ Event(
823
+ type=EventType.HISTORY_REPLACED,
824
+ agent_id=self.uuid,
825
+ data={
826
+ "scope": "node_retry",
827
+ "replaced_message_id": normalized_message_id,
828
+ "message_id": retried_message_id,
829
+ "history": [entry.serialize() for entry in history_snapshot],
830
+ },
831
+ )
832
+ )
833
+ return retried_message_id
834
+ except Exception:
835
+ if previous_history:
836
+ with self._history_lock:
837
+ self.history = previous_history
838
+ self._pending_input_turn = previous_pending_input_turn
839
+ self._turn_started_with_pending_input = (
840
+ previous_turn_started_with_pending_input
841
+ )
842
+ self._turn_made_progress = previous_turn_made_progress
843
+ with self._runtime_notice_lock:
844
+ self._pending_runtime_notices = previous_runtime_notices
845
+ self._restore_pending_message_wakeups(extracted_message_signals)
846
+ with self._execution_context_lock:
847
+ self._execution_context_summary = previous_execution_summary
848
+ self._execution_context_history_cutoff = previous_execution_cutoff
849
+ self._context_token_usage_baseline = (
850
+ previous_context_token_usage_baseline
851
+ )
852
+ self._persist_workspace_node()
853
+ raise
854
+ finally:
855
+ if paused_for_command:
856
+ self._resume_after_command_execution()
857
+
858
+ def compact_chat_history(
859
+ self,
860
+ *,
861
+ focus: str | None = None,
862
+ interrupt_timeout: float = 5.0,
863
+ ) -> CommandResultEntry:
864
+ if self.node_type != NodeType.ASSISTANT:
865
+ raise RuntimeError("Only assistant chat history can be compacted")
866
+
867
+ paused_for_command = self._pause_for_command_execution(
868
+ timeout=interrupt_timeout
869
+ )
870
+ try:
871
+ self._run_compact_with_stats(trigger_type="manual", focus=focus)
872
+ content = "Compacted the current Assistant execution context."
873
+ if focus and focus.strip():
874
+ content += f"\n\nFocus: {focus.strip()}"
875
+
876
+ with self._runtime_notice_lock:
877
+ self._pending_runtime_notices.clear()
878
+
879
+ return CommandResultEntry(
880
+ command_name="/compact",
881
+ content=content,
882
+ include_in_context=False,
883
+ )
884
+ finally:
885
+ if paused_for_command:
886
+ self._resume_after_command_execution()
887
+
888
+ def execute_assistant_command(
889
+ self,
890
+ *,
891
+ command_name: str,
892
+ argument: str = "",
893
+ interrupt_timeout: float = 5.0,
894
+ ) -> CommandResultEntry:
895
+ append_to_history = True
896
+ if command_name == "/clear":
897
+ self.clear_chat_history(interrupt_timeout=interrupt_timeout)
898
+ entry = CommandResultEntry(
899
+ command_name=command_name,
900
+ content="Cleared the current Assistant chat history.",
901
+ include_in_context=False,
902
+ )
903
+ append_to_history = False
904
+ elif command_name == "/compact":
905
+ entry = self.compact_chat_history(
906
+ focus=argument or None,
907
+ interrupt_timeout=interrupt_timeout,
908
+ )
909
+ elif command_name == "/help":
910
+ entry = CommandResultEntry(
911
+ command_name=command_name,
912
+ content=build_assistant_help_text(),
913
+ )
914
+ else:
915
+ raise RuntimeError(f"Unsupported Assistant command: {command_name}")
916
+
917
+ if append_to_history:
918
+ self._append_history(entry)
919
+ return entry
920
+
921
+ def _run(self) -> None:
922
+ with logger.contextualize(
923
+ agent_id=self.uuid[:8],
924
+ node_type=self.config.node_type.value,
925
+ ):
926
+ with self._history_lock:
927
+ has_system_entry = any(
928
+ isinstance(entry, SystemEntry) for entry in self.history
929
+ )
930
+ if has_system_entry:
931
+ self._sync_system_prompt_entry()
932
+ else:
933
+ self._append_history(
934
+ SystemEntry(content=get_system_prompt(self.config))
935
+ )
936
+
937
+ if self.state == AgentState.INITIALIZING:
938
+ self.set_state(AgentState.IDLE, "initialized, awaiting first message")
939
+ self._log.info("Agent started, waiting for first message")
940
+ else:
941
+ self._log.info(
942
+ "Agent restored in state {}, waiting for input",
943
+ self.state.value,
944
+ )
945
+ self._wait_for_input()
946
+
947
+ if self._terminate.is_set():
948
+ if self._should_preserve_workspace_state_on_exit():
949
+ self._log.info(
950
+ "Agent stopped for process exit with state {}",
951
+ self.state.value,
952
+ )
953
+ return
954
+ self._finalize_termination("terminated before first message")
955
+ return
956
+
957
+ while not self._terminate.is_set():
958
+ try:
959
+ self._sync_system_prompt_entry()
960
+ self._drain_messages()
961
+ self._turn_started_with_pending_input = self._pending_input_turn
962
+ self._turn_made_progress = False
963
+
964
+ tools_schema = _get_tool_registry().get_tools_schema(self)
965
+ prepared_context = self._prepare_messages_for_llm()
966
+ messages = prepared_context.messages
967
+
968
+ self._log.debug(
969
+ "LLM request: messages={}, tools={}, history_len={}",
970
+ len(messages),
971
+ len(tools_schema) if tools_schema else 0,
972
+ len(self.history),
973
+ )
974
+ stream_state: StreamingContentState | None = None
975
+ try:
976
+ response, stream_state = self._chat_with_retries(
977
+ prepared_context=prepared_context,
978
+ tools_schema=tools_schema,
979
+ )
980
+ self._flush_streaming_think_parser(stream_state)
981
+
982
+ self._log.debug(
983
+ "LLM response: content_len={}, parts_len={}, thinking_len={}, tool_calls={}",
984
+ len(response.content) if response.content else 0,
985
+ len(response.parts) if response.parts else 0,
986
+ len(response.thinking) if response.thinking else 0,
987
+ [tc.name for tc in response.tool_calls]
988
+ if response.tool_calls
989
+ else None,
990
+ )
991
+
992
+ final_thinking = (
993
+ response.thinking or stream_state.thinking_buffer
994
+ )
995
+ final_content = (
996
+ stream_state.content_buffer
997
+ if stream_state.saw_content_chunks
998
+ else response.content
999
+ )
1000
+ final_parts = response.parts
1001
+ if final_content and final_thinking:
1002
+ final_content, _ = split_thinking_content(final_content)
1003
+
1004
+ if final_thinking:
1005
+ self._append_history(
1006
+ AssistantThinking(content=final_thinking),
1007
+ )
1008
+ stream_state.thinking_buffer = ""
1009
+
1010
+ if response.tool_calls:
1011
+ self._log.debug(
1012
+ "Processing {} tool call(s)",
1013
+ len(response.tool_calls),
1014
+ )
1015
+ if final_parts:
1016
+ self._record_content_parts_output(
1017
+ self._normalize_llm_output_parts(final_parts),
1018
+ emitted_human_content=stream_state.emitted_human_content,
1019
+ )
1020
+ stream_state.content_buffer = ""
1021
+ elif final_content:
1022
+ self._record_text_output(
1023
+ final_content,
1024
+ emitted_human_content=stream_state.emitted_human_content,
1025
+ )
1026
+ stream_state.content_buffer = ""
1027
+ self._raise_if_interrupt_requested()
1028
+ stop_future_send_calls = False
1029
+ for tc in response.tool_calls:
1030
+ if stop_future_send_calls and tc.name == "send":
1031
+ continue
1032
+ tool_result = self._handle_tool_call(
1033
+ tc.name,
1034
+ tc.arguments,
1035
+ tc.id,
1036
+ )
1037
+ if tc.name == "send" and self._tool_result_has_error(
1038
+ tool_result
1039
+ ):
1040
+ stop_future_send_calls = True
1041
+ self._raise_if_interrupt_requested()
1042
+ if self._terminate.is_set():
1043
+ break
1044
+ elif final_parts:
1045
+ self._record_content_parts_output(
1046
+ self._normalize_llm_output_parts(final_parts),
1047
+ emitted_human_content=stream_state.emitted_human_content,
1048
+ )
1049
+ stream_state.content_buffer = ""
1050
+ self._log.debug(
1051
+ "No tool calls, continuing execution after structured response"
1052
+ )
1053
+ elif final_content:
1054
+ self._record_text_output(
1055
+ final_content,
1056
+ emitted_human_content=stream_state.emitted_human_content,
1057
+ )
1058
+ stream_state.content_buffer = ""
1059
+ self._log.debug(
1060
+ "No tool calls, continuing execution after text response"
1061
+ )
1062
+ else:
1063
+ self._log.warning(
1064
+ "LLM returned empty response (no content, no tool_calls)",
1065
+ )
1066
+ except InterruptRequestedError as exc:
1067
+ self._handle_interrupt(exc.stream_state or stream_state)
1068
+ if self._terminate.is_set():
1069
+ break
1070
+ continue
1071
+
1072
+ except LLMProviderError as exc:
1073
+ self._interrupt_requested.clear()
1074
+ self.set_interrupt_callback(None)
1075
+ self._log.warning("Agent LLM provider error: {}", exc)
1076
+ error_summary = str(exc)
1077
+ self._append_history(ErrorEntry(content=error_summary))
1078
+ self.set_state(AgentState.ERROR, error_summary)
1079
+ self._wait_for_input()
1080
+ if self._terminate.is_set():
1081
+ break
1082
+
1083
+ except ContextPreflightError as exc:
1084
+ self._interrupt_requested.clear()
1085
+ self.set_interrupt_callback(None)
1086
+ self._log.warning("Agent context preflight failed: {}", exc)
1087
+ error_summary = str(exc)
1088
+ self._append_history(ErrorEntry(content=error_summary))
1089
+ self.set_state(AgentState.ERROR, error_summary)
1090
+ self._wait_for_input()
1091
+ if self._terminate.is_set():
1092
+ break
1093
+
1094
+ except Exception as exc:
1095
+ self._interrupt_requested.clear()
1096
+ self.set_interrupt_callback(None)
1097
+ self._log.exception("Agent error")
1098
+ tb_str = traceback.format_exc()
1099
+ self._append_history(
1100
+ ErrorEntry(content=f"{type(exc).__name__}: {exc}\n\n{tb_str}"),
1101
+ )
1102
+ self.set_state(AgentState.ERROR, f"{type(exc).__name__}: {exc}")
1103
+ self._wait_for_input()
1104
+ if self._terminate.is_set():
1105
+ break
1106
+
1107
+ if self._should_preserve_workspace_state_on_exit():
1108
+ self._log.info(
1109
+ "Agent stopped for process exit with state {}",
1110
+ self.state.value,
1111
+ )
1112
+ return
1113
+
1114
+ self._finalize_termination(self._termination_reason or "finished")
1115
+
1116
+ def _sync_system_prompt_entry(self) -> None:
1117
+ system_prompt = get_system_prompt(self.config)
1118
+ with self._history_lock:
1119
+ for entry in self.history:
1120
+ if isinstance(entry, SystemEntry):
1121
+ entry.content = system_prompt
1122
+ break
1123
+ else:
1124
+ self.history.insert(0, SystemEntry(content=system_prompt))
1125
+
1126
+ @staticmethod
1127
+ def _build_runtime_system_message(content: str) -> dict[str, str]:
1128
+ return {"role": "user", "content": f"<system>{content}</system>"}
1129
+
1130
+ def _build_runtime_tail_messages(self) -> list[dict[str, str]]:
1131
+ with self._todos_lock:
1132
+ todos = [TodoItem(text=t.text) for t in self.todos]
1133
+ with self._history_lock:
1134
+ history_snapshot = list(self.history)
1135
+ runtime_notices = self._consume_runtime_notices()
1136
+ custom_post_prompt = get_settings().custom_post_prompt.strip()
1137
+ messages: list[dict[str, str]] = []
1138
+ todo_message = self._build_runtime_todo_message(todos)
1139
+ if todo_message is not None:
1140
+ messages.append(todo_message)
1141
+ messages.append(
1142
+ self._build_runtime_post_prompt_message(
1143
+ has_todos=bool(todos),
1144
+ pending_agent_dispatches=self._get_pending_agent_dispatches(
1145
+ history_snapshot
1146
+ ),
1147
+ )
1148
+ )
1149
+ for notice in runtime_notices:
1150
+ messages.append(self._build_runtime_system_message(notice))
1151
+ if custom_post_prompt:
1152
+ messages.append(self._build_runtime_system_message(custom_post_prompt))
1153
+ return messages
1154
+
1155
+ def _build_runtime_todo_message(
1156
+ self,
1157
+ todos: list[TodoItem],
1158
+ ) -> dict[str, str] | None:
1159
+ if not todos:
1160
+ return None
1161
+ lines = []
1162
+ for todo in todos:
1163
+ lines.append(f" - {todo.text}")
1164
+ todo_text = "Current TODO list:\n" + "\n".join(lines)
1165
+ return self._build_runtime_system_message(todo_text)
1166
+
1167
+ @staticmethod
1168
+ def _get_created_agent_label(payload: dict[str, Any]) -> str | None:
1169
+ agent_id = payload.get("agent_id")
1170
+ if not isinstance(agent_id, str) or not agent_id:
1171
+ agent_id = payload.get("id")
1172
+ if not isinstance(agent_id, str) or not agent_id:
1173
+ return None
1174
+ name = payload.get("name")
1175
+ if (not isinstance(name, str) or not name.strip()) and isinstance(
1176
+ payload.get("config"), dict
1177
+ ):
1178
+ config = payload["config"]
1179
+ config_name = config.get("name")
1180
+ config_role_name = config.get("role_name")
1181
+ if isinstance(config_name, str) and config_name.strip():
1182
+ name = config_name
1183
+ elif isinstance(config_role_name, str) and config_role_name.strip():
1184
+ name = config_role_name
1185
+ short_id = agent_id[:8]
1186
+ if isinstance(name, str) and name.strip():
1187
+ return f"{name.strip()} (`{short_id}`)"
1188
+ return f"`{short_id}`"
1189
+
1190
+ @classmethod
1191
+ def _iter_dispatched_agent_payloads(
1192
+ cls,
1193
+ tool_name: str,
1194
+ payload: dict[str, Any],
1195
+ ) -> list[tuple[str, str]]:
1196
+ if tool_name == "create_agent":
1197
+ label = cls._get_created_agent_label(payload)
1198
+ agent_id = payload.get("id")
1199
+ if isinstance(agent_id, str) and label is not None:
1200
+ return [(agent_id, label)]
1201
+ return []
1202
+ return []
1203
+
1204
+ def _get_pending_agent_dispatches(
1205
+ self,
1206
+ history_snapshot: list[HistoryEntry],
1207
+ ) -> list[str]:
1208
+ pending: dict[str, str] = {}
1209
+
1210
+ for entry in history_snapshot:
1211
+ if isinstance(entry, ToolCall):
1212
+ if entry.tool_name != "create_agent":
1213
+ continue
1214
+ if entry.result is None:
1215
+ continue
1216
+ try:
1217
+ payload = json.loads(entry.result)
1218
+ except json.JSONDecodeError:
1219
+ continue
1220
+ if not isinstance(payload, dict) or payload.get("error") is not None:
1221
+ continue
1222
+ for agent_id, label in self._iter_dispatched_agent_payloads(
1223
+ entry.tool_name,
1224
+ payload,
1225
+ ):
1226
+ pending[agent_id] = label
1227
+ continue
1228
+
1229
+ if isinstance(entry, SentMessage):
1230
+ pending.pop(entry.to_id, None)
1231
+
1232
+ return list(pending.values())
1233
+
1234
+ def _build_runtime_post_prompt_message(
1235
+ self,
1236
+ *,
1237
+ has_todos: bool,
1238
+ pending_agent_dispatches: list[str],
1239
+ ) -> dict[str, str]:
1240
+ lines = [
1241
+ "Runtime post prompt:",
1242
+ "- Plain content is never delivered to other agents.",
1243
+ "- To send a formal message to another node, use `send` with a single `target` and ordered `parts`.",
1244
+ "- Use `contacts` to inspect the node ids and names you can currently message directly.",
1245
+ "- `@target:` or any other `@name:` text inside normal content is just text. It does not send anything.",
1246
+ ]
1247
+ if pending_agent_dispatches:
1248
+ targets = ", ".join(pending_agent_dispatches)
1249
+ lines.append(
1250
+ f"- Newly created agents still waiting for their first task: {targets}."
1251
+ )
1252
+ lines.append(
1253
+ "- `create_agent` only adds a new agent node to the current workflow. It does not start work by itself."
1254
+ )
1255
+ lines.append(
1256
+ "- Before calling `idle`, dispatch each waiting agent a concrete first task with `send`."
1257
+ )
1258
+ elif has_todos:
1259
+ lines.append(
1260
+ "- If the TODO list is not complete yet, use `todo` to replace it with the latest remaining items."
1261
+ )
1262
+ else:
1263
+ lines.append(
1264
+ "- If there is no unfinished TODO and the task is finished with no immediate next action, call `idle`."
1265
+ )
1266
+ return self._build_runtime_system_message("\n".join(lines))
1267
+
1268
+ @staticmethod
1269
+ def _build_idle_without_progress_notice() -> str:
1270
+ return (
1271
+ "Idle reminder: you received a new message this turn, but this "
1272
+ "response did not send a reply, call `send`, or use any "
1273
+ "non-idle tool. Do not call `idle` yet. First reply to the Human, "
1274
+ "dispatch/delegate work, or take another concrete step."
1275
+ )
1276
+
1277
+ @staticmethod
1278
+ def _build_actionable_todo_notice(todo_text: str) -> str:
1279
+ return (
1280
+ "Idle reminder: your first remaining TODO still looks actionable "
1281
+ f"(`{todo_text}`). Do that next, or update the TODO list so the "
1282
+ "first remaining item is the actual waiting step, before calling "
1283
+ "`idle`."
1284
+ )
1285
+
1286
+ @staticmethod
1287
+ def _build_sleep_deadline_notice() -> str:
1288
+ return (
1289
+ "Sleep deadline reached: the timed wait has expired. Continue from "
1290
+ "that deadline wake-up and decide whether to retry, follow up, or "
1291
+ "escalate."
1292
+ )
1293
+
1294
+ def _queue_runtime_notice(self, content: str) -> None:
1295
+ with self._runtime_notice_lock:
1296
+ if content not in self._pending_runtime_notices:
1297
+ self._pending_runtime_notices.append(content)
1298
+
1299
+ def _has_pending_runtime_notices(self) -> bool:
1300
+ with self._runtime_notice_lock:
1301
+ return bool(self._pending_runtime_notices)
1302
+
1303
+ def _consume_runtime_notices(self) -> list[str]:
1304
+ with self._runtime_notice_lock:
1305
+ notices = list(self._pending_runtime_notices)
1306
+ self._pending_runtime_notices.clear()
1307
+ return notices
1308
+
1309
+ def _handle_llm_chunk(
1310
+ self,
1311
+ state: StreamingContentState,
1312
+ chunk_type: str,
1313
+ text: str,
1314
+ ) -> None:
1315
+ self._raise_if_interrupt_requested()
1316
+ if chunk_type == "content":
1317
+ state.saw_content_chunks = True
1318
+ for normalized_type, normalized_text in state.think_parser.feed(text):
1319
+ if normalized_type == "thinking":
1320
+ self._handle_streaming_thinking_chunk(state, normalized_text)
1321
+ else:
1322
+ self._handle_streaming_content_chunk(state, normalized_text)
1323
+ return
1324
+ if chunk_type != "thinking":
1325
+ return
1326
+ self._handle_streaming_thinking_chunk(state, text)
1327
+
1328
+ def _handle_streaming_content_chunk(
1329
+ self,
1330
+ state: StreamingContentState,
1331
+ text: str,
1332
+ ) -> None:
1333
+ if not text:
1334
+ return
1335
+ state.content_buffer += text
1336
+ delta = ContentDelta(text=text)
1337
+ event_bus.emit(
1338
+ Event(
1339
+ type=EventType.HISTORY_ENTRY_DELTA,
1340
+ agent_id=self.uuid,
1341
+ data=delta.serialize(),
1342
+ ),
1343
+ )
1344
+ if self.node_type == NodeType.ASSISTANT:
1345
+ state.emitted_human_content = True
1346
+ event_bus.emit(
1347
+ Event(
1348
+ type=EventType.ASSISTANT_CONTENT,
1349
+ agent_id=self.uuid,
1350
+ data={"content": text},
1351
+ ),
1352
+ )
1353
+
1354
+ def _handle_streaming_thinking_chunk(
1355
+ self,
1356
+ state: StreamingContentState,
1357
+ text: str,
1358
+ ) -> None:
1359
+ if not text:
1360
+ return
1361
+ state.thinking_buffer += text
1362
+ delta = ThinkingDelta(text=text)
1363
+ event_bus.emit(
1364
+ Event(
1365
+ type=EventType.HISTORY_ENTRY_DELTA,
1366
+ agent_id=self.uuid,
1367
+ data=delta.serialize(),
1368
+ ),
1369
+ )
1370
+
1371
+ def _flush_streaming_think_parser(self, state: StreamingContentState) -> None:
1372
+ for normalized_type, normalized_text in state.think_parser.flush():
1373
+ if normalized_type == "thinking":
1374
+ self._handle_streaming_thinking_chunk(state, normalized_text)
1375
+ else:
1376
+ self._handle_streaming_content_chunk(state, normalized_text)
1377
+
1378
+ def _deliver_message(
1379
+ self,
1380
+ target: Agent,
1381
+ parts: list[TextPart | ImagePart],
1382
+ message_id: str,
1383
+ ) -> None:
1384
+ content_preview = content_parts_to_text(parts)
1385
+ target._append_history(
1386
+ ReceivedMessage(
1387
+ from_id=self.uuid,
1388
+ parts=parts,
1389
+ message_id=message_id,
1390
+ )
1391
+ )
1392
+ target.enqueue_message(
1393
+ Message(
1394
+ from_id=self.uuid,
1395
+ to_id=target.uuid,
1396
+ parts=parts,
1397
+ message_id=message_id,
1398
+ history_recorded=True,
1399
+ )
1400
+ )
1401
+ self._log.debug(
1402
+ "Message sent: {} -> {} ({} chars)",
1403
+ self.uuid[:8],
1404
+ target.uuid[:8],
1405
+ len(content_preview),
1406
+ )
1407
+ event_bus.emit(
1408
+ Event(
1409
+ type=EventType.NODE_MESSAGE,
1410
+ agent_id=self.uuid,
1411
+ data={
1412
+ "to_id": target.uuid,
1413
+ "content": content_preview,
1414
+ "message_id": message_id,
1415
+ },
1416
+ ),
1417
+ )
1418
+
1419
+ def _record_text_output(
1420
+ self,
1421
+ content: str,
1422
+ *,
1423
+ emitted_human_content: bool,
1424
+ ) -> None:
1425
+ normalized_content, normalized_thinking = split_thinking_content(content)
1426
+
1427
+ if normalized_thinking.strip():
1428
+ self._mark_turn_progress()
1429
+ self._append_history(AssistantThinking(content=normalized_thinking))
1430
+
1431
+ plain_content = normalized_content
1432
+ if not plain_content.strip():
1433
+ return
1434
+
1435
+ self._record_content_parts_output(
1436
+ [TextPart(text=plain_content)],
1437
+ emitted_human_content=emitted_human_content,
1438
+ )
1439
+
1440
+ def _record_content_output(
1441
+ self,
1442
+ content: str,
1443
+ *,
1444
+ emitted_human_content: bool,
1445
+ ) -> None:
1446
+ self._record_text_output(
1447
+ content,
1448
+ emitted_human_content=emitted_human_content,
1449
+ )
1450
+
1451
+ def _normalize_llm_output_parts(
1452
+ self,
1453
+ parts: list[LLMOutputTextPart | LLMOutputImagePart],
1454
+ ) -> list[TextPart | ImagePart]:
1455
+ normalized: list[TextPart | ImagePart] = []
1456
+ for part in parts:
1457
+ if isinstance(part, LLMOutputTextPart):
1458
+ normalized_content, normalized_thinking = split_thinking_content(
1459
+ part.text
1460
+ )
1461
+ if normalized_thinking.strip():
1462
+ self._append_history(AssistantThinking(content=normalized_thinking))
1463
+ if normalized_content:
1464
+ normalized.append(TextPart(text=normalized_content))
1465
+ continue
1466
+ if not self.supports_output_image():
1467
+ raise ContextPreflightError(
1468
+ "Current model does not support `output_image`."
1469
+ )
1470
+ asset = create_image_asset(part.data, mime_type=part.mime_type)
1471
+ normalized.append(
1472
+ ImagePart(
1473
+ asset_id=asset.id,
1474
+ mime_type=asset.mime_type,
1475
+ width=part.width or asset.width,
1476
+ height=part.height or asset.height,
1477
+ )
1478
+ )
1479
+ return normalized
1480
+
1481
+ def _record_content_parts_output(
1482
+ self,
1483
+ parts: list[TextPart | ImagePart],
1484
+ *,
1485
+ emitted_human_content: bool,
1486
+ ) -> None:
1487
+ normalized_parts = [
1488
+ part for part in parts if not isinstance(part, TextPart) or part.text
1489
+ ]
1490
+ if not normalized_parts:
1491
+ return
1492
+
1493
+ visible_text = "".join(
1494
+ part.text for part in normalized_parts if isinstance(part, TextPart)
1495
+ )
1496
+ content_preview = content_parts_to_text(normalized_parts)
1497
+ self._mark_turn_progress()
1498
+
1499
+ if (
1500
+ self.node_type == NodeType.ASSISTANT
1501
+ and not emitted_human_content
1502
+ and visible_text.strip()
1503
+ ):
1504
+ event_bus.emit(
1505
+ Event(
1506
+ type=EventType.ASSISTANT_CONTENT,
1507
+ agent_id=self.uuid,
1508
+ data={"content": visible_text},
1509
+ ),
1510
+ )
1511
+
1512
+ self._append_history(
1513
+ AssistantText(
1514
+ parts=normalized_parts,
1515
+ content=content_preview,
1516
+ )
1517
+ )
1518
+
1519
+ def _resolve_contact_target(self, target_ref: str) -> Agent:
1520
+ from flowent.graph_runtime import resolve_node_ref
1521
+
1522
+ target = resolve_node_ref(target_ref)
1523
+ if target is None:
1524
+ raise ValueError(f"Send failed: target `{target_ref}` was not found.")
1525
+ if not self.can_contact(target.uuid):
1526
+ raise ValueError(f"Send failed: target `{target_ref}` is not in contacts.")
1527
+ return target
1528
+
1529
+ def supports_input_image(self) -> bool:
1530
+ _, model_info = self._get_effective_model_info()
1531
+ if model_info is None:
1532
+ return False
1533
+ return model_info.capabilities.input_image
1534
+
1535
+ def supports_output_image(self) -> bool:
1536
+ _, model_info = self._get_effective_model_info()
1537
+ if model_info is None:
1538
+ return False
1539
+ return model_info.capabilities.output_image
1540
+
1541
+ def send_message(
1542
+ self,
1543
+ *,
1544
+ target_ref: str,
1545
+ raw_parts: Any,
1546
+ ) -> str:
1547
+ parts = parse_content_parts_payload(raw_parts)
1548
+ target = self._resolve_contact_target(target_ref)
1549
+ if has_image_parts(parts) and not target.supports_input_image():
1550
+ raise ValueError(
1551
+ f"Send failed: target `{target_ref}` does not support `input_image`."
1552
+ )
1553
+ for part in parts:
1554
+ asset_id = getattr(part, "asset_id", None)
1555
+ if isinstance(asset_id, str):
1556
+ require_image_asset(asset_id)
1557
+ message_id = str(_uuid.uuid4())
1558
+ self._deliver_message(target, parts, message_id)
1559
+ self._mark_turn_progress()
1560
+ self._append_history(
1561
+ SentMessage(
1562
+ to_id=target.uuid,
1563
+ parts=parts,
1564
+ message_id=message_id,
1565
+ )
1566
+ )
1567
+ return json.dumps({"status": "sent", "target_id": target.uuid})
1568
+
1569
+ def _mark_turn_progress(self) -> None:
1570
+ self._turn_made_progress = True
1571
+ self._pending_input_turn = False
1572
+
1573
+ def _get_first_actionable_todo(self) -> str | None:
1574
+ with self._todos_lock:
1575
+ if not self.todos:
1576
+ return None
1577
+ first_todo = self.todos[0].text.strip()
1578
+ if not first_todo:
1579
+ return None
1580
+ normalized = first_todo.lower()
1581
+ waiting_prefixes = (
1582
+ "wait",
1583
+ "await",
1584
+ "waiting",
1585
+ "awaiting",
1586
+ "monitor",
1587
+ "listen",
1588
+ "idle",
1589
+ "sleep",
1590
+ )
1591
+ if normalized.startswith(waiting_prefixes) or first_todo.startswith(
1592
+ ("等待", "等候", "监听", "休眠", "空闲")
1593
+ ):
1594
+ return None
1595
+ return first_todo
1596
+
1597
+ def _raise_if_interrupt_requested(self) -> None:
1598
+ if self._interrupt_requested.is_set():
1599
+ raise InterruptRequestedError()
1600
+
1601
+ def _get_llm_retry_policy(self) -> str:
1602
+ return get_settings().model.retry_policy
1603
+
1604
+ def _get_llm_max_retries(self) -> int:
1605
+ settings = get_settings()
1606
+ if settings.model.retry_policy != "limited":
1607
+ return 0
1608
+ return settings.model.max_retries
1609
+
1610
+ def _get_llm_retry_delay(self, retry_number: int) -> float:
1611
+ settings = get_settings()
1612
+ capped_retry_number = min(
1613
+ max(retry_number - 1, 0),
1614
+ settings.model.retry_backoff_cap_retries - 1,
1615
+ )
1616
+ return min(
1617
+ settings.model.retry_max_delay_seconds,
1618
+ settings.model.retry_initial_delay_seconds * (2**capped_retry_number),
1619
+ )
1620
+
1621
+ def _get_llm_retry_429_delay(self, status_code: int | None) -> float:
1622
+ if status_code != 429:
1623
+ return 0.0
1624
+ from flowent.settings import find_provider, find_role
1625
+
1626
+ settings = get_settings()
1627
+ provider_id = settings.model.active_provider_id
1628
+ role_cfg = (
1629
+ find_role(settings, self.config.role_name)
1630
+ if self.config.role_name
1631
+ else None
1632
+ )
1633
+ if (
1634
+ role_cfg is not None
1635
+ and role_cfg.model is not None
1636
+ and role_cfg.model.provider_id
1637
+ and role_cfg.model.model
1638
+ ):
1639
+ provider_id = role_cfg.model.provider_id
1640
+ if not provider_id:
1641
+ return 0.0
1642
+ provider = find_provider(settings, provider_id)
1643
+ if provider is None:
1644
+ return 0.0
1645
+ return float(provider.retry_429_delay_seconds)
1646
+
1647
+ def _wait_for_llm_retry_delay(self, delay_seconds: float) -> None:
1648
+ self.set_interrupt_callback(self._interrupt_requested.set)
1649
+ try:
1650
+ if self._interrupt_requested.wait(max(delay_seconds, 0.0)):
1651
+ raise InterruptRequestedError()
1652
+ finally:
1653
+ self.set_interrupt_callback(None)
1654
+
1655
+ def _chat_with_retries(
1656
+ self,
1657
+ *,
1658
+ prepared_context: PreparedLLMContext,
1659
+ tools_schema: list[dict[str, Any]] | None,
1660
+ ) -> tuple[LLMResponse, StreamingContentState]:
1661
+ retry_policy = self._get_llm_retry_policy()
1662
+ retry_limit = self._get_llm_max_retries()
1663
+ retry_count = 0
1664
+ started_at = _time.time()
1665
+
1666
+ while True:
1667
+ stream_state = StreamingContentState()
1668
+ try:
1669
+ try:
1670
+ messages = prepared_context.messages
1671
+ response = gateway.chat(
1672
+ messages=messages,
1673
+ tools=tools_schema or None,
1674
+ on_chunk=partial(self._handle_llm_chunk, stream_state),
1675
+ register_interrupt=self.set_interrupt_callback,
1676
+ role_name=self.config.role_name,
1677
+ )
1678
+ except Exception:
1679
+ if self._interrupt_requested.is_set():
1680
+ raise InterruptRequestedError(stream_state) from None
1681
+ raise
1682
+ finally:
1683
+ self.set_interrupt_callback(None)
1684
+ self._raise_if_interrupt_requested()
1685
+ self._record_request_stats(
1686
+ started_at=started_at,
1687
+ ended_at=_time.time(),
1688
+ retry_count=retry_count,
1689
+ result="success",
1690
+ usage=response.usage,
1691
+ raw_usage=response.raw_usage,
1692
+ )
1693
+ self._record_context_token_usage_baseline(
1694
+ prepared_context=prepared_context,
1695
+ usage=response.usage,
1696
+ )
1697
+ return response, stream_state
1698
+ except InterruptRequestedError:
1699
+ raise
1700
+ except LLMProviderError as exc:
1701
+ should_retry = False
1702
+ if exc.transient:
1703
+ if retry_policy == "limited":
1704
+ should_retry = retry_count < retry_limit
1705
+ elif retry_policy == "unlimited":
1706
+ should_retry = True
1707
+ if not should_retry:
1708
+ self._record_request_stats(
1709
+ started_at=started_at,
1710
+ ended_at=_time.time(),
1711
+ retry_count=retry_count,
1712
+ result="error",
1713
+ error_summary=str(exc),
1714
+ )
1715
+ raise
1716
+ retry_count += 1
1717
+ delay_seconds = self._get_llm_retry_delay(
1718
+ retry_count
1719
+ ) + self._get_llm_retry_429_delay(exc.status_code)
1720
+ self._log.warning(
1721
+ "Transient LLM error, retry {} ({}) in {:.2f}s: {}",
1722
+ retry_count,
1723
+ retry_policy,
1724
+ delay_seconds,
1725
+ exc,
1726
+ )
1727
+ self._wait_for_llm_retry_delay(delay_seconds)
1728
+ except Exception as exc:
1729
+ self._record_request_stats(
1730
+ started_at=started_at,
1731
+ ended_at=_time.time(),
1732
+ retry_count=retry_count,
1733
+ result="error",
1734
+ error_summary=str(exc),
1735
+ )
1736
+ raise
1737
+
1738
+ def _handle_interrupt(self, stream_state: StreamingContentState | None) -> None:
1739
+ if stream_state is not None:
1740
+ self._flush_streaming_think_parser(stream_state)
1741
+ if stream_state.thinking_buffer:
1742
+ self._append_history(
1743
+ AssistantThinking(content=stream_state.thinking_buffer),
1744
+ )
1745
+ if stream_state.content_buffer:
1746
+ self._record_text_output(
1747
+ stream_state.content_buffer,
1748
+ emitted_human_content=stream_state.emitted_human_content,
1749
+ )
1750
+ self._interrupt_requested.clear()
1751
+ self.set_interrupt_callback(None)
1752
+ self._pending_input_turn = False
1753
+ self.set_state(AgentState.IDLE, "interrupted by human")
1754
+ if self._pause_after_interrupt_requested.is_set():
1755
+ self._paused_for_command.set()
1756
+ self._resume_after_command.wait()
1757
+ self._paused_for_command.clear()
1758
+ self._resume_after_command.clear()
1759
+ self._wait_for_input()
1760
+
1761
+ def _generate_compacted_history_summary(
1762
+ self,
1763
+ *,
1764
+ focus: str | None = None,
1765
+ context_messages: list[dict[str, Any]],
1766
+ ) -> str:
1767
+ if not context_messages:
1768
+ return "- No prior execution context was available to compact."
1769
+
1770
+ focus_text = focus.strip() if focus else ""
1771
+ request_lines = [
1772
+ "Compact this agent execution context into a durable markdown summary.",
1773
+ "Preserve only confirmed information.",
1774
+ "Keep the summary concise and directly reusable as future context.",
1775
+ "Use these sections in order:",
1776
+ "## Current Goal",
1777
+ "## Active Task Boundary",
1778
+ "## Key Constraints",
1779
+ "## Confirmed Decisions",
1780
+ "## Open Questions",
1781
+ "## Next Actions",
1782
+ ]
1783
+ if focus_text:
1784
+ request_lines.append(f"Prioritize this focus: {focus_text}")
1785
+ request_lines.append("Return only the markdown summary.")
1786
+
1787
+ response = gateway.chat(
1788
+ messages=[
1789
+ {
1790
+ "role": "system",
1791
+ "content": (
1792
+ "You compress Assistant conversations into durable "
1793
+ "task summaries. Do not address the human. Do not "
1794
+ "invent facts. Keep the result tightly scoped to what "
1795
+ "future turns need."
1796
+ ),
1797
+ },
1798
+ *context_messages,
1799
+ {"role": "user", "content": "\n".join(request_lines)},
1800
+ ],
1801
+ tools=None,
1802
+ role_name=self.config.role_name,
1803
+ )
1804
+ summary = (response.content or response.thinking or "").strip()
1805
+ if not summary:
1806
+ raise RuntimeError("Assistant compact did not produce a summary")
1807
+ return summary
1808
+
1809
+ def _build_prepared_llm_context(self) -> PreparedLLMContext:
1810
+ system_messages = [
1811
+ {"role": "system", "content": get_system_prompt(self.config)}
1812
+ ]
1813
+ with self._history_lock:
1814
+ history_snapshot = list(self.history)
1815
+ execution_context_messages = self._build_execution_context_messages(
1816
+ history_snapshot
1817
+ )
1818
+ runtime_tail_messages = self._build_runtime_tail_messages()
1819
+ return PreparedLLMContext(
1820
+ messages=[
1821
+ *system_messages,
1822
+ *execution_context_messages,
1823
+ *runtime_tail_messages,
1824
+ ],
1825
+ system_messages=system_messages,
1826
+ execution_context_messages=execution_context_messages,
1827
+ runtime_tail_messages=runtime_tail_messages,
1828
+ )
1829
+
1830
+ def _build_messages(self) -> list[dict[str, Any]]:
1831
+ return self._build_prepared_llm_context().messages
1832
+
1833
+ def _build_execution_context_messages(
1834
+ self,
1835
+ history_snapshot: list[HistoryEntry],
1836
+ ) -> list[dict[str, Any]]:
1837
+ with self._execution_context_lock:
1838
+ summary = self._execution_context_summary.strip()
1839
+ history_cutoff = min(
1840
+ self._execution_context_history_cutoff,
1841
+ len(history_snapshot),
1842
+ )
1843
+
1844
+ messages: list[dict[str, Any]] = []
1845
+ if summary:
1846
+ messages.append(
1847
+ self._build_runtime_system_message(
1848
+ f"Compacted execution context:\n{summary}"
1849
+ )
1850
+ )
1851
+ messages.extend(self._build_history_messages(history_snapshot[history_cutoff:]))
1852
+ return messages
1853
+
1854
+ @staticmethod
1855
+ def _serialize_context_parts(
1856
+ parts: list[TextPart | ImagePart],
1857
+ ) -> str | list[dict[str, Any]]:
1858
+ if all(isinstance(part, TextPart) for part in parts):
1859
+ text_parts = [part for part in parts if isinstance(part, TextPart)]
1860
+ return "".join(part.text for part in text_parts)
1861
+ return [part.serialize() for part in parts]
1862
+
1863
+ @classmethod
1864
+ def _wrap_context_parts(
1865
+ cls,
1866
+ parts: list[TextPart | ImagePart],
1867
+ *,
1868
+ prefix: str,
1869
+ suffix: str = "",
1870
+ ) -> str | list[dict[str, Any]]:
1871
+ if all(isinstance(part, TextPart) for part in parts):
1872
+ text_parts = [part for part in parts if isinstance(part, TextPart)]
1873
+ return prefix + "".join(part.text for part in text_parts) + suffix
1874
+ wrapped: list[TextPart | ImagePart] = [TextPart(text=prefix), *parts]
1875
+ if suffix:
1876
+ wrapped.append(TextPart(text=suffix))
1877
+ return cls._serialize_context_parts(wrapped)
1878
+
1879
+ def _build_history_messages(
1880
+ self,
1881
+ history_snapshot: list[HistoryEntry],
1882
+ ) -> list[dict[str, Any]]:
1883
+ messages: list[dict[str, Any]] = []
1884
+ pending_tool_calls: list[dict[str, Any]] = []
1885
+
1886
+ for entry in history_snapshot:
1887
+ if isinstance(entry, SystemEntry):
1888
+ continue
1889
+
1890
+ elif isinstance(entry, ReceivedMessage):
1891
+ self._flush_tool_calls(messages, pending_tool_calls)
1892
+ messages.append(
1893
+ {
1894
+ "role": "user",
1895
+ "content": self._wrap_context_parts(
1896
+ entry.parts,
1897
+ prefix=f'<message from="{entry.from_id}">',
1898
+ suffix="</message>",
1899
+ ),
1900
+ }
1901
+ )
1902
+
1903
+ elif isinstance(entry, AssistantText):
1904
+ self._flush_tool_calls(messages, pending_tool_calls)
1905
+ messages.append(
1906
+ {
1907
+ "role": "assistant",
1908
+ "content": self._serialize_context_parts(entry.parts),
1909
+ }
1910
+ )
1911
+
1912
+ elif isinstance(entry, SentMessage):
1913
+ self._flush_tool_calls(messages, pending_tool_calls)
1914
+ messages.append(
1915
+ {
1916
+ "role": "assistant",
1917
+ "content": self._wrap_context_parts(
1918
+ entry.parts,
1919
+ prefix=f'<message to="{entry.to_id}">',
1920
+ suffix="</message>",
1921
+ ),
1922
+ }
1923
+ )
1924
+
1925
+ elif isinstance(entry, AssistantThinking):
1926
+ pass
1927
+
1928
+ elif isinstance(entry, ToolCall):
1929
+ if entry.streaming:
1930
+ continue
1931
+
1932
+ pending_tool_calls.append(
1933
+ {
1934
+ "id": entry.tool_call_id,
1935
+ "type": "function",
1936
+ "function": {
1937
+ "name": entry.tool_name,
1938
+ "arguments": json.dumps(entry.arguments)
1939
+ if entry.arguments
1940
+ else "{}",
1941
+ },
1942
+ }
1943
+ )
1944
+
1945
+ if entry.result is not None:
1946
+ self._flush_tool_calls(messages, pending_tool_calls)
1947
+ messages.append(
1948
+ {
1949
+ "role": "tool",
1950
+ "tool_call_id": entry.tool_call_id,
1951
+ "content": entry.result,
1952
+ }
1953
+ )
1954
+
1955
+ elif isinstance(entry, ErrorEntry):
1956
+ self._flush_tool_calls(messages, pending_tool_calls)
1957
+ messages.append(
1958
+ {
1959
+ "role": "user",
1960
+ "content": build_error_context(entry.content),
1961
+ }
1962
+ )
1963
+
1964
+ elif isinstance(entry, CommandResultEntry):
1965
+ if not entry.include_in_context or entry.command_name == "/compact":
1966
+ continue
1967
+ self._flush_tool_calls(messages, pending_tool_calls)
1968
+ messages.append(self._build_runtime_system_message(entry.content))
1969
+
1970
+ self._flush_tool_calls(messages, pending_tool_calls)
1971
+ return messages
1972
+
1973
+ @staticmethod
1974
+ def _estimate_text_tokens(text: str) -> int:
1975
+ stripped = text.strip()
1976
+ if not stripped:
1977
+ return 0
1978
+ return max(1, (len(stripped) + 3) // 4)
1979
+
1980
+ @classmethod
1981
+ def _estimate_message_tokens(cls, message: dict[str, Any]) -> int:
1982
+ total = cls._estimate_text_tokens(str(message.get("role", "")))
1983
+ content = message.get("content")
1984
+ if isinstance(content, str):
1985
+ total += cls._estimate_text_tokens(content)
1986
+ elif content is not None:
1987
+ total += cls._estimate_text_tokens(json.dumps(content, ensure_ascii=False))
1988
+ tool_calls = message.get("tool_calls")
1989
+ if isinstance(tool_calls, list):
1990
+ for tool_call in tool_calls:
1991
+ if not isinstance(tool_call, dict):
1992
+ continue
1993
+ function = tool_call.get("function")
1994
+ if not isinstance(function, dict):
1995
+ continue
1996
+ total += cls._estimate_text_tokens(str(function.get("name", "")))
1997
+ total += cls._estimate_text_tokens(str(function.get("arguments", "")))
1998
+ return total
1999
+
2000
+ @classmethod
2001
+ def _estimate_input_tokens(cls, messages: list[dict[str, Any]]) -> int:
2002
+ return sum(cls._estimate_message_tokens(message) for message in messages)
2003
+
2004
+ def _estimate_tokens_from_usage_baseline(
2005
+ self,
2006
+ prepared_context: PreparedLLMContext,
2007
+ ) -> int | None:
2008
+ with self._execution_context_lock:
2009
+ baseline = self._context_token_usage_baseline
2010
+
2011
+ if baseline is None:
2012
+ return None
2013
+ if baseline.system_messages != prepared_context.system_messages:
2014
+ return None
2015
+ if baseline.runtime_tail_messages != prepared_context.runtime_tail_messages:
2016
+ return None
2017
+
2018
+ baseline_execution_messages = baseline.execution_context_messages
2019
+ current_execution_messages = prepared_context.execution_context_messages
2020
+ if len(current_execution_messages) < len(baseline_execution_messages):
2021
+ return None
2022
+ if (
2023
+ current_execution_messages[: len(baseline_execution_messages)]
2024
+ != baseline_execution_messages
2025
+ ):
2026
+ return None
2027
+
2028
+ tail_messages = current_execution_messages[len(baseline_execution_messages) :]
2029
+ return baseline.usage.total_tokens + self._estimate_input_tokens(tail_messages)
2030
+
2031
+ def _record_context_token_usage_baseline(
2032
+ self,
2033
+ *,
2034
+ prepared_context: PreparedLLMContext,
2035
+ usage: LLMUsage | None,
2036
+ ) -> None:
2037
+ if usage is None:
2038
+ return
2039
+
2040
+ with self._execution_context_lock:
2041
+ self._context_token_usage_baseline = ContextTokenUsageBaseline(
2042
+ usage=usage,
2043
+ system_messages=list(prepared_context.system_messages),
2044
+ execution_context_messages=list(
2045
+ prepared_context.execution_context_messages
2046
+ ),
2047
+ runtime_tail_messages=list(prepared_context.runtime_tail_messages),
2048
+ )
2049
+
2050
+ def _get_stats_node_label(self) -> str:
2051
+ if self.config.name:
2052
+ return self.config.name
2053
+ if self.config.role_name:
2054
+ return self.config.role_name
2055
+ if self.node_type == NodeType.ASSISTANT:
2056
+ return "Assistant"
2057
+ from flowent.graph_service import is_tab_leader
2058
+
2059
+ if is_tab_leader(node_id=self.uuid, tab_id=self.config.tab_id):
2060
+ return "Leader"
2061
+ return "Agent"
2062
+
2063
+ def _get_stats_tab_title(self) -> str | None:
2064
+ if not self.config.tab_id:
2065
+ return None
2066
+ from flowent.workspace_store import workspace_store
2067
+
2068
+ tab = workspace_store.get_tab(self.config.tab_id)
2069
+ if tab is None:
2070
+ return None
2071
+ return tab.title
2072
+
2073
+ def _get_effective_model_source(self) -> ResolvedModelSource:
2074
+ from flowent.settings import find_provider, find_role, resolve_model_info
2075
+
2076
+ settings = get_settings()
2077
+ provider_id = settings.model.active_provider_id
2078
+ model_id = settings.model.active_model
2079
+ use_system_model_overrides = True
2080
+ role_cfg = (
2081
+ find_role(settings, self.config.role_name)
2082
+ if self.config.role_name
2083
+ else None
2084
+ )
2085
+ if (
2086
+ role_cfg is not None
2087
+ and role_cfg.model is not None
2088
+ and role_cfg.model.provider_id
2089
+ and role_cfg.model.model
2090
+ ):
2091
+ provider_id = role_cfg.model.provider_id
2092
+ model_id = role_cfg.model.model
2093
+ use_system_model_overrides = False
2094
+ if not provider_id or not model_id:
2095
+ return ResolvedModelSource(
2096
+ provider_id=None,
2097
+ provider_name=None,
2098
+ provider_type=None,
2099
+ model=None,
2100
+ model_info=None,
2101
+ )
2102
+ provider = find_provider(settings, provider_id)
2103
+ if provider is None:
2104
+ return ResolvedModelSource(
2105
+ provider_id=None,
2106
+ provider_name=None,
2107
+ provider_type=None,
2108
+ model=None,
2109
+ model_info=None,
2110
+ )
2111
+ return ResolvedModelSource(
2112
+ provider_id=provider.id,
2113
+ provider_name=provider.name,
2114
+ provider_type=provider.type,
2115
+ model=model_id,
2116
+ model_info=resolve_model_info(
2117
+ provider=provider,
2118
+ model_id=model_id,
2119
+ input_image=(
2120
+ settings.model.input_image if use_system_model_overrides else None
2121
+ ),
2122
+ output_image=(
2123
+ settings.model.output_image if use_system_model_overrides else None
2124
+ ),
2125
+ context_window_tokens=(
2126
+ settings.model.context_window_tokens
2127
+ if use_system_model_overrides
2128
+ else None
2129
+ ),
2130
+ ),
2131
+ )
2132
+
2133
+ def _get_effective_model_info(self) -> tuple[str | None, ModelInfo | None]:
2134
+ resolved_source = self._get_effective_model_source()
2135
+ return resolved_source.provider_type, resolved_source.model_info
2136
+
2137
+ def _record_request_stats(
2138
+ self,
2139
+ *,
2140
+ started_at: float,
2141
+ ended_at: float,
2142
+ retry_count: int,
2143
+ result: str,
2144
+ usage: LLMUsage | None = None,
2145
+ raw_usage: dict[str, Any] | None = None,
2146
+ error_summary: str | None = None,
2147
+ ) -> None:
2148
+ from flowent.stats_service import RequestRecordInput, stats_store
2149
+
2150
+ resolved_source = self._get_effective_model_source()
2151
+ stats_store.record_request(
2152
+ RequestRecordInput(
2153
+ node_id=self.uuid,
2154
+ node_label=self._get_stats_node_label(),
2155
+ role_name=self.config.role_name,
2156
+ tab_id=self.config.tab_id,
2157
+ tab_title=self._get_stats_tab_title(),
2158
+ provider_id=resolved_source.provider_id,
2159
+ provider_name=resolved_source.provider_name,
2160
+ provider_type=resolved_source.provider_type,
2161
+ model=resolved_source.model,
2162
+ started_at=started_at,
2163
+ ended_at=ended_at,
2164
+ retry_count=retry_count,
2165
+ result="success" if result == "success" else "error",
2166
+ normalized_usage=usage,
2167
+ raw_usage=raw_usage,
2168
+ error_summary=error_summary,
2169
+ )
2170
+ )
2171
+
2172
+ def _run_compact_with_stats(
2173
+ self,
2174
+ *,
2175
+ trigger_type: str,
2176
+ focus: str | None = None,
2177
+ ) -> str:
2178
+ from flowent.stats_service import CompactRecordInput, stats_store
2179
+
2180
+ started_at = _time.time()
2181
+ resolved_source = self._get_effective_model_source()
2182
+ try:
2183
+ result = self._compact_execution_context(focus=focus)
2184
+ except Exception as exc:
2185
+ stats_store.record_compact(
2186
+ CompactRecordInput(
2187
+ node_id=self.uuid,
2188
+ node_label=self._get_stats_node_label(),
2189
+ role_name=self.config.role_name,
2190
+ tab_id=self.config.tab_id,
2191
+ tab_title=self._get_stats_tab_title(),
2192
+ provider_id=resolved_source.provider_id,
2193
+ provider_name=resolved_source.provider_name,
2194
+ provider_type=resolved_source.provider_type,
2195
+ model=resolved_source.model,
2196
+ trigger_type="manual" if trigger_type == "manual" else "auto",
2197
+ started_at=started_at,
2198
+ ended_at=_time.time(),
2199
+ result="error",
2200
+ error_summary=str(exc),
2201
+ )
2202
+ )
2203
+ raise
2204
+ stats_store.record_compact(
2205
+ CompactRecordInput(
2206
+ node_id=self.uuid,
2207
+ node_label=self._get_stats_node_label(),
2208
+ role_name=self.config.role_name,
2209
+ tab_id=self.config.tab_id,
2210
+ tab_title=self._get_stats_tab_title(),
2211
+ provider_id=resolved_source.provider_id,
2212
+ provider_name=resolved_source.provider_name,
2213
+ provider_type=resolved_source.provider_type,
2214
+ model=resolved_source.model,
2215
+ trigger_type="manual" if trigger_type == "manual" else "auto",
2216
+ started_at=started_at,
2217
+ ended_at=_time.time(),
2218
+ result="success",
2219
+ )
2220
+ )
2221
+ return result
2222
+
2223
+ def _get_effective_output_budget_tokens(self) -> int:
2224
+ from flowent.settings import find_role, merge_model_params
2225
+
2226
+ settings = get_settings()
2227
+ role_cfg = (
2228
+ find_role(settings, self.config.role_name)
2229
+ if self.config.role_name
2230
+ else None
2231
+ )
2232
+ model_params = merge_model_params(
2233
+ settings.model.params,
2234
+ role_cfg.model_params if role_cfg is not None else None,
2235
+ )
2236
+ if model_params is not None and model_params.max_output_tokens is not None:
2237
+ return max(1, model_params.max_output_tokens)
2238
+ return DEFAULT_CONTEXT_OUTPUT_BUDGET_TOKENS
2239
+
2240
+ def _compute_context_preflight(
2241
+ self,
2242
+ prepared_context: PreparedLLMContext,
2243
+ ) -> ContextPreflight:
2244
+ settings = get_settings()
2245
+ _, model_info = self._get_effective_model_info()
2246
+ estimated_total_tokens = self._estimate_tokens_from_usage_baseline(
2247
+ prepared_context
2248
+ )
2249
+ if estimated_total_tokens is None:
2250
+ estimated_total_tokens = self._estimate_input_tokens(
2251
+ prepared_context.messages
2252
+ )
2253
+
2254
+ auto_compact_token_limit = settings.model.auto_compact_token_limit
2255
+ if model_info is None or model_info.context_window_tokens is None:
2256
+ return ContextPreflight(
2257
+ estimated_total_tokens=estimated_total_tokens,
2258
+ auto_compact_token_limit=auto_compact_token_limit,
2259
+ )
2260
+
2261
+ output_budget_tokens = self._get_effective_output_budget_tokens()
2262
+ safe_input_tokens = max(
2263
+ 1,
2264
+ model_info.context_window_tokens
2265
+ - output_budget_tokens
2266
+ - DEFAULT_CONTEXT_PROVIDER_HEADROOM_TOKENS,
2267
+ )
2268
+ return ContextPreflight(
2269
+ estimated_total_tokens=estimated_total_tokens,
2270
+ context_window_tokens=model_info.context_window_tokens,
2271
+ auto_compact_token_limit=auto_compact_token_limit,
2272
+ safe_input_tokens=safe_input_tokens,
2273
+ )
2274
+
2275
+ def _compact_execution_context(self, *, focus: str | None = None) -> str:
2276
+ with self._history_lock:
2277
+ history_snapshot = list(self.history)
2278
+ context_messages = self._build_execution_context_messages(history_snapshot)
2279
+ if not context_messages:
2280
+ self._set_execution_context(
2281
+ summary="",
2282
+ history_cutoff=len(history_snapshot),
2283
+ )
2284
+ self._persist_workspace_node()
2285
+ return ""
2286
+ summary = self._generate_compacted_history_summary(
2287
+ focus=focus,
2288
+ context_messages=context_messages,
2289
+ )
2290
+ self._set_execution_context(
2291
+ summary=summary,
2292
+ history_cutoff=len(history_snapshot),
2293
+ )
2294
+ self._persist_workspace_node()
2295
+ return summary
2296
+
2297
+ def _prepare_messages_for_llm(self) -> PreparedLLMContext:
2298
+ prepared_context = self._build_prepared_llm_context()
2299
+ preflight = self._compute_context_preflight(prepared_context)
2300
+ if (
2301
+ preflight.auto_compact_token_limit is None
2302
+ or preflight.estimated_total_tokens < preflight.auto_compact_token_limit
2303
+ ):
2304
+ return prepared_context
2305
+
2306
+ self._log.debug(
2307
+ "Automatic compact preflight: estimated_total={}, token_limit={}, safe={}, context_window={}",
2308
+ preflight.estimated_total_tokens,
2309
+ preflight.auto_compact_token_limit,
2310
+ preflight.safe_input_tokens,
2311
+ preflight.context_window_tokens,
2312
+ )
2313
+ try:
2314
+ self._run_compact_with_stats(trigger_type="auto")
2315
+ except Exception as exc:
2316
+ if (
2317
+ preflight.safe_input_tokens is not None
2318
+ and preflight.estimated_total_tokens > preflight.safe_input_tokens
2319
+ ):
2320
+ raise ContextPreflightError(
2321
+ "Automatic compact failed and the current execution context exceeds the safe model window."
2322
+ ) from exc
2323
+ self._log.warning("Automatic compact failed below safe window: {}", exc)
2324
+ return prepared_context
2325
+
2326
+ prepared_context = self._build_prepared_llm_context()
2327
+ post_compact = self._compute_context_preflight(prepared_context)
2328
+ if (
2329
+ post_compact.safe_input_tokens is not None
2330
+ and post_compact.estimated_total_tokens > post_compact.safe_input_tokens
2331
+ ):
2332
+ raise ContextPreflightError(
2333
+ "Automatic compact completed but the current execution context still exceeds the safe model window."
2334
+ )
2335
+ return prepared_context
2336
+
2337
+ @staticmethod
2338
+ def _flush_tool_calls(
2339
+ messages: list[dict[str, Any]],
2340
+ pending: list[dict[str, Any]],
2341
+ ) -> None:
2342
+ if not pending:
2343
+ return
2344
+ last = messages[-1] if messages else None
2345
+ if last and last["role"] == "assistant":
2346
+ last.setdefault("tool_calls", []).extend(pending)
2347
+ else:
2348
+ messages.append({"role": "assistant", "tool_calls": list(pending)})
2349
+ pending.clear()
2350
+
2351
+ @staticmethod
2352
+ def _tool_result_has_error(result: str | None) -> bool:
2353
+ if not isinstance(result, str):
2354
+ return False
2355
+ try:
2356
+ payload = json.loads(result)
2357
+ except json.JSONDecodeError:
2358
+ return False
2359
+ return isinstance(payload, dict) and isinstance(payload.get("error"), str)
2360
+
2361
+ def _drain_messages(self) -> None:
2362
+ drained: list[WakeSignal] = []
2363
+ while True:
2364
+ try:
2365
+ signal = self._wake_queue.get_nowait()
2366
+ except Empty:
2367
+ break
2368
+ if signal.reason == "message":
2369
+ drained.append(signal)
2370
+ continue
2371
+ if signal.reason == "termination":
2372
+ self._terminate.set()
2373
+ break
2374
+
2375
+ if drained:
2376
+ self._log.debug("Drained {} message(s) from queue", len(drained))
2377
+
2378
+ for signal in drained:
2379
+ message = signal.payload.get("message", {})
2380
+ content = message.get("content", "")
2381
+ parts = deserialize_content_parts(
2382
+ message.get("parts"),
2383
+ fallback_text=content if isinstance(content, str) else None,
2384
+ )
2385
+ from_id = message.get("from", "")
2386
+ message_id = message.get("message_id")
2387
+ history_recorded = bool(message.get("history_recorded", False))
2388
+ if (
2389
+ not isinstance(content, str)
2390
+ or not isinstance(from_id, str)
2391
+ or (message_id is not None and not isinstance(message_id, str))
2392
+ ):
2393
+ continue
2394
+ self._log.debug(
2395
+ "Message from {}: {}",
2396
+ from_id,
2397
+ (content[:100] + "...") if len(content) > 100 else content,
2398
+ )
2399
+ if not history_recorded:
2400
+ self._append_history(
2401
+ ReceivedMessage(
2402
+ from_id=from_id,
2403
+ parts=parts,
2404
+ content=content,
2405
+ message_id=message_id,
2406
+ ),
2407
+ )
2408
+
2409
+ def _wait_for_input(self) -> None:
2410
+ signal = self._wait_for_wakeup()
2411
+ self._resume_from_wakeup(signal)
2412
+
2413
+ def _resume_from_wakeup(self, signal: WakeSignal) -> None:
2414
+ if signal.reason == "message":
2415
+ message = signal.payload.get("message")
2416
+ if isinstance(message, dict):
2417
+ content = message.get("content")
2418
+ parts = deserialize_content_parts(
2419
+ message.get("parts"),
2420
+ fallback_text=content if isinstance(content, str) else None,
2421
+ )
2422
+ from_id = message.get("from")
2423
+ message_id = message.get("message_id")
2424
+ history_recorded = bool(message.get("history_recorded", False))
2425
+ if (
2426
+ isinstance(content, str)
2427
+ and isinstance(from_id, str)
2428
+ and (message_id is None or isinstance(message_id, str))
2429
+ and not history_recorded
2430
+ ):
2431
+ self._append_history(
2432
+ ReceivedMessage(
2433
+ from_id=from_id,
2434
+ parts=parts,
2435
+ content=content,
2436
+ message_id=message_id,
2437
+ )
2438
+ )
2439
+
2440
+ if signal.reason != "termination":
2441
+ self.set_state(
2442
+ AgentState.RUNNING,
2443
+ signal.resume_reason or f"woke due to {signal.reason}",
2444
+ )
2445
+
2446
+ def _wait_for_wakeup(self) -> WakeSignal:
2447
+ while not self._terminate.is_set():
2448
+ try:
2449
+ return self._wake_queue.get(timeout=2.0)
2450
+ except Empty:
2451
+ continue
2452
+
2453
+ return WakeSignal(
2454
+ reason="termination",
2455
+ payload={},
2456
+ resume_reason="termination requested",
2457
+ )
2458
+
2459
+ def _handle_tool_call(
2460
+ self,
2461
+ name: str,
2462
+ arguments: dict[str, Any],
2463
+ call_id: str,
2464
+ ) -> str | None:
2465
+ self._log.debug(
2466
+ "Tool call: name={}, call_id={}, args={}",
2467
+ name,
2468
+ call_id[:8],
2469
+ json.dumps(arguments, ensure_ascii=False)[:200],
2470
+ )
2471
+
2472
+ tool = _get_tool_registry().get(name)
2473
+ if tool is None:
2474
+ self._log.warning("Unknown tool: {}", name)
2475
+ error_msg = json.dumps({"error": f"Unknown tool: {name}"})
2476
+ self._append_history(
2477
+ ToolCall(
2478
+ tool_name=name,
2479
+ tool_call_id=call_id,
2480
+ arguments=arguments,
2481
+ result=error_msg,
2482
+ streaming=False,
2483
+ ),
2484
+ )
2485
+ return error_msg
2486
+
2487
+ registry = _get_tool_registry()
2488
+ if hasattr(registry, "get_tools_for_agent"):
2489
+ allowed_tool_names = {
2490
+ allowed_tool.name for allowed_tool in registry.get_tools_for_agent(self)
2491
+ }
2492
+ else:
2493
+ allowed_tool_names = set(self.config.tools)
2494
+ if name not in allowed_tool_names:
2495
+ self._log.warning("Tool not granted in current boundary: {}", name)
2496
+ error_msg = json.dumps({"error": f"Tool not granted: {name}"})
2497
+ self._append_history(
2498
+ ToolCall(
2499
+ tool_name=name,
2500
+ tool_call_id=call_id,
2501
+ arguments=arguments,
2502
+ result=error_msg,
2503
+ streaming=False,
2504
+ ),
2505
+ )
2506
+ return error_msg
2507
+
2508
+ authorization_error = authorize(name, self, arguments)
2509
+ if authorization_error is not None:
2510
+ self._log.warning(
2511
+ "Tool denied by security policy: {} ({})",
2512
+ name,
2513
+ authorization_error,
2514
+ )
2515
+ error_msg = json.dumps({"error": authorization_error})
2516
+ self._append_history(
2517
+ ToolCall(
2518
+ tool_name=name,
2519
+ tool_call_id=call_id,
2520
+ arguments=arguments,
2521
+ result=error_msg,
2522
+ streaming=False,
2523
+ ),
2524
+ )
2525
+ return error_msg
2526
+
2527
+ event_bus.emit(
2528
+ Event(
2529
+ type=EventType.TOOL_CALLED,
2530
+ agent_id=self.uuid,
2531
+ data={"tool": name, "arguments": arguments},
2532
+ ),
2533
+ )
2534
+
2535
+ if name == "send":
2536
+ t0 = _time.perf_counter()
2537
+ try:
2538
+ self._raise_if_interrupt_requested()
2539
+ result = tool.execute(
2540
+ self,
2541
+ arguments,
2542
+ on_output=None,
2543
+ tool_call_id=call_id,
2544
+ )
2545
+ self._raise_if_interrupt_requested()
2546
+ elapsed = _time.perf_counter() - t0
2547
+ self._log.debug(
2548
+ "Tool {} completed in {:.2f}s, result_len={}",
2549
+ name,
2550
+ elapsed,
2551
+ len(result) if result else 0,
2552
+ )
2553
+ return result
2554
+ except InterruptRequestedError:
2555
+ raise
2556
+ except Exception as exc:
2557
+ elapsed = _time.perf_counter() - t0
2558
+ self._log.warning(
2559
+ "Tool {} failed after {:.2f}s: {}", name, elapsed, exc
2560
+ )
2561
+ error_text = str(exc)
2562
+ self._append_history(ErrorEntry(content=error_text))
2563
+ return json.dumps({"error": error_text})
2564
+
2565
+ streaming_entry = ToolCall(
2566
+ tool_name=name,
2567
+ tool_call_id=call_id,
2568
+ arguments=arguments,
2569
+ streaming=True,
2570
+ )
2571
+ self._append_history(streaming_entry)
2572
+ streamed_result_parts: list[str] = []
2573
+
2574
+ def _on_tool_output(text: str) -> None:
2575
+ self._raise_if_interrupt_requested()
2576
+ streamed_result_parts.append(text)
2577
+ delta = ToolResultDelta(tool_call_id=call_id, text=text)
2578
+ event_bus.emit(
2579
+ Event(
2580
+ type=EventType.HISTORY_ENTRY_DELTA,
2581
+ agent_id=self.uuid,
2582
+ data=delta.serialize(),
2583
+ ),
2584
+ )
2585
+ self._raise_if_interrupt_requested()
2586
+
2587
+ t0 = _time.perf_counter()
2588
+ try:
2589
+ self._raise_if_interrupt_requested()
2590
+ result = tool.execute(
2591
+ self,
2592
+ arguments,
2593
+ on_output=_on_tool_output,
2594
+ tool_call_id=call_id,
2595
+ )
2596
+ self._raise_if_interrupt_requested()
2597
+ elapsed = _time.perf_counter() - t0
2598
+ self._log.debug(
2599
+ "Tool {} completed in {:.2f}s, result_len={}",
2600
+ name,
2601
+ elapsed,
2602
+ len(result) if result else 0,
2603
+ )
2604
+
2605
+ self._finalize_tool_call(call_id, name, arguments, result)
2606
+ return result
2607
+ except InterruptRequestedError:
2608
+ partial_result = "".join(streamed_result_parts) or None
2609
+ self._finalize_tool_call(call_id, name, arguments, partial_result)
2610
+ raise
2611
+ except Exception as e:
2612
+ elapsed = _time.perf_counter() - t0
2613
+ self._log.exception("Tool {} failed after {:.2f}s", name, elapsed)
2614
+ error_msg = json.dumps({"error": str(e)})
2615
+ self._finalize_tool_call(call_id, name, arguments, error_msg)
2616
+ return error_msg
2617
+
2618
+ def _finalize_tool_call(
2619
+ self,
2620
+ call_id: str,
2621
+ name: str,
2622
+ arguments: dict[str, Any],
2623
+ result: str | None,
2624
+ ) -> None:
2625
+ final: ToolCall | None = None
2626
+ with self._history_lock:
2627
+ for i in range(len(self.history) - 1, -1, -1):
2628
+ entry = self.history[i]
2629
+ if (
2630
+ isinstance(entry, ToolCall)
2631
+ and entry.tool_call_id == call_id
2632
+ and entry.streaming
2633
+ ):
2634
+ final = ToolCall(
2635
+ tool_name=name,
2636
+ tool_call_id=call_id,
2637
+ arguments=arguments,
2638
+ result=result,
2639
+ streaming=False,
2640
+ )
2641
+ self.history[i] = final
2642
+ break
2643
+
2644
+ if final is not None:
2645
+ event_bus.emit(
2646
+ Event(
2647
+ type=EventType.HISTORY_ENTRY_ADDED,
2648
+ agent_id=self.uuid,
2649
+ data=final.serialize(),
2650
+ ),
2651
+ )
2652
+
2653
+ def enqueue_message(self, msg: Message) -> None:
2654
+ payload = {
2655
+ "from": msg.from_id,
2656
+ "content": msg.content,
2657
+ "parts": [part.serialize() for part in msg.parts],
2658
+ "history_recorded": msg.history_recorded,
2659
+ }
2660
+ if msg.message_id is not None:
2661
+ payload["message_id"] = msg.message_id
2662
+ self._wake_queue.put(
2663
+ WakeSignal(
2664
+ reason="message",
2665
+ payload={"message": payload},
2666
+ resume_reason=f"received message from {msg.from_id}",
2667
+ )
2668
+ )
2669
+
2670
+ def set_state(
2671
+ self,
2672
+ state: AgentState,
2673
+ reason: str = "",
2674
+ *,
2675
+ force_emit: bool = False,
2676
+ ) -> None:
2677
+ from flowent.graph_service import is_tab_leader
2678
+
2679
+ old = self.state
2680
+ self.state = state
2681
+ if state == AgentState.IDLE:
2682
+ if old != AgentState.IDLE:
2683
+ self._idle_started_at = _time.perf_counter()
2684
+ self._idle_state_event.set()
2685
+ else:
2686
+ if old == AgentState.IDLE:
2687
+ self._idle_started_at = None
2688
+ self._idle_started_by_tool_call_id = None
2689
+ self._idle_state_event.clear()
2690
+ if old != state or force_emit:
2691
+ if old != state:
2692
+ self._append_history(
2693
+ StateEntry(state=state.value, reason=reason),
2694
+ )
2695
+ self._log.debug(
2696
+ "State: {} -> {}{}",
2697
+ old.value,
2698
+ state.value,
2699
+ f" ({reason})" if reason else "",
2700
+ )
2701
+ event_bus.emit(
2702
+ Event(
2703
+ type=EventType.NODE_STATE_CHANGED,
2704
+ agent_id=self.uuid,
2705
+ data={
2706
+ "old_state": old.value,
2707
+ "new_state": state.value,
2708
+ "tab_id": self.config.tab_id,
2709
+ "role_name": self.config.role_name,
2710
+ "name": self.config.name,
2711
+ "is_leader": is_tab_leader(
2712
+ node_id=self.uuid,
2713
+ tab_id=self.config.tab_id,
2714
+ ),
2715
+ "todos": [t.serialize() for t in self.get_todos_snapshot()],
2716
+ },
2717
+ ),
2718
+ )
2719
+ self._persist_workspace_node()
2720
+
2721
+ def request_termination(self, reason: str = "") -> None:
2722
+ self._preserve_workspace_state_on_exit = False
2723
+ self._termination_reason = reason
2724
+ self._terminate.set()
2725
+ self._wake_queue.put(
2726
+ WakeSignal(
2727
+ reason="termination",
2728
+ payload={},
2729
+ resume_reason="termination requested",
2730
+ )
2731
+ )
2732
+
2733
+ def request_process_exit(self) -> None:
2734
+ self._preserve_workspace_state_on_exit = (
2735
+ self.node_type == NodeType.ASSISTANT or bool(self.config.tab_id)
2736
+ )
2737
+ self._termination_reason = "process_exit"
2738
+ self._terminate.set()
2739
+ self._wake_queue.put(
2740
+ WakeSignal(
2741
+ reason="termination",
2742
+ payload={},
2743
+ resume_reason="process exit requested",
2744
+ )
2745
+ )
2746
+
2747
+ def request_interrupt(self) -> bool:
2748
+ if self.state not in {AgentState.RUNNING, AgentState.SLEEPING}:
2749
+ return False
2750
+ self._interrupt_requested.set()
2751
+ self._invoke_interrupt_callback()
2752
+ return True
2753
+
2754
+ def is_interrupt_requested(self) -> bool:
2755
+ return self._interrupt_requested.is_set()
2756
+
2757
+ def set_interrupt_callback(self, callback: Callable[[], None] | None) -> None:
2758
+ with self._interrupt_callback_lock:
2759
+ self._interrupt_callback = callback
2760
+ if callback is not None and self._interrupt_requested.is_set():
2761
+ self._invoke_interrupt_callback()
2762
+
2763
+ def _invoke_interrupt_callback(self) -> None:
2764
+ with self._interrupt_callback_lock:
2765
+ callback = self._interrupt_callback
2766
+ if callback is None:
2767
+ return
2768
+ try:
2769
+ callback()
2770
+ except Exception:
2771
+ self._log.debug("Interrupt callback raised")
2772
+
2773
+ def terminate_and_wait(self, timeout: float = 10.0) -> None:
2774
+ self.request_termination("shutdown")
2775
+ self.wait_for_termination(timeout=timeout)
2776
+
2777
+ def wait_for_termination(self, timeout: float = 10.0) -> bool:
2778
+ if self._thread and self._thread.is_alive():
2779
+ self._thread.join(timeout=timeout)
2780
+ return not (self._thread and self._thread.is_alive())
2781
+
2782
+ def _should_preserve_workspace_state_on_exit(self) -> bool:
2783
+ return self._preserve_workspace_state_on_exit and (
2784
+ self.node_type == NodeType.ASSISTANT or bool(self.config.tab_id)
2785
+ )
2786
+
2787
+ def _finalize_termination(self, reason: str) -> None:
2788
+ from flowent.registry import registry
2789
+
2790
+ self.set_state(AgentState.TERMINATED, reason)
2791
+ self._log.info("Agent terminated (reason: {})", reason)
2792
+ event_bus.emit(
2793
+ Event(
2794
+ type=EventType.NODE_TERMINATED,
2795
+ agent_id=self.uuid,
2796
+ data={"reason": reason},
2797
+ ),
2798
+ )
2799
+ registry.unregister(self.uuid)
2800
+
2801
+ peer_ids = self.get_connections_snapshot()
2802
+ for peer_id in peer_ids:
2803
+ peer = registry.get(peer_id)
2804
+ if peer is not None:
2805
+ peer.remove_connection(self.uuid)
2806
+ self.remove_connection(peer_id)
2807
+
2808
+ self._persist_workspace_node()