pythinker-code 2.0.0__py3-none-any.whl

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 (731) hide show
  1. pythinker_code/CHANGELOG.md +16 -0
  2. pythinker_code/__init__.py +0 -0
  3. pythinker_code/__main__.py +92 -0
  4. pythinker_code/acp/AGENTS.md +93 -0
  5. pythinker_code/acp/__init__.py +13 -0
  6. pythinker_code/acp/convert.py +128 -0
  7. pythinker_code/acp/host.py +298 -0
  8. pythinker_code/acp/mcp.py +46 -0
  9. pythinker_code/acp/server.py +497 -0
  10. pythinker_code/acp/session.py +496 -0
  11. pythinker_code/acp/tools.py +167 -0
  12. pythinker_code/acp/types.py +13 -0
  13. pythinker_code/acp/version.py +45 -0
  14. pythinker_code/agents/default/agent.yaml +36 -0
  15. pythinker_code/agents/default/coder.yaml +25 -0
  16. pythinker_code/agents/default/explore.yaml +46 -0
  17. pythinker_code/agents/default/plan.yaml +30 -0
  18. pythinker_code/agents/default/system.md +164 -0
  19. pythinker_code/agents/okabe/agent.yaml +22 -0
  20. pythinker_code/agentspec.py +163 -0
  21. pythinker_code/app.py +820 -0
  22. pythinker_code/approval_runtime/__init__.py +29 -0
  23. pythinker_code/approval_runtime/models.py +42 -0
  24. pythinker_code/approval_runtime/runtime.py +235 -0
  25. pythinker_code/auth/__init__.py +25 -0
  26. pythinker_code/auth/anthropic_direct.py +207 -0
  27. pythinker_code/auth/deepseek.py +192 -0
  28. pythinker_code/auth/lm_studio.py +418 -0
  29. pythinker_code/auth/minimax.py +203 -0
  30. pythinker_code/auth/oauth.py +1122 -0
  31. pythinker_code/auth/ollama.py +293 -0
  32. pythinker_code/auth/openai.py +771 -0
  33. pythinker_code/auth/opencode_go.py +203 -0
  34. pythinker_code/auth/openrouter.py +225 -0
  35. pythinker_code/auth/platforms.py +466 -0
  36. pythinker_code/background/__init__.py +36 -0
  37. pythinker_code/background/agent_runner.py +231 -0
  38. pythinker_code/background/ids.py +19 -0
  39. pythinker_code/background/manager.py +650 -0
  40. pythinker_code/background/models.py +105 -0
  41. pythinker_code/background/store.py +237 -0
  42. pythinker_code/background/summary.py +66 -0
  43. pythinker_code/background/worker.py +209 -0
  44. pythinker_code/cli/__init__.py +1326 -0
  45. pythinker_code/cli/__main__.py +19 -0
  46. pythinker_code/cli/_lazy_group.py +238 -0
  47. pythinker_code/cli/export.py +322 -0
  48. pythinker_code/cli/info.py +62 -0
  49. pythinker_code/cli/mcp.py +349 -0
  50. pythinker_code/cli/plugin.py +351 -0
  51. pythinker_code/cli/toad.py +74 -0
  52. pythinker_code/cli/vis.py +38 -0
  53. pythinker_code/cli/web.py +80 -0
  54. pythinker_code/config.py +453 -0
  55. pythinker_code/constant.py +33 -0
  56. pythinker_code/exception.py +43 -0
  57. pythinker_code/hooks/__init__.py +4 -0
  58. pythinker_code/hooks/config.py +34 -0
  59. pythinker_code/hooks/engine.py +371 -0
  60. pythinker_code/hooks/events.py +190 -0
  61. pythinker_code/hooks/runner.py +89 -0
  62. pythinker_code/llm.py +412 -0
  63. pythinker_code/metadata.py +79 -0
  64. pythinker_code/notifications/__init__.py +33 -0
  65. pythinker_code/notifications/llm.py +77 -0
  66. pythinker_code/notifications/manager.py +145 -0
  67. pythinker_code/notifications/models.py +50 -0
  68. pythinker_code/notifications/notifier.py +41 -0
  69. pythinker_code/notifications/store.py +118 -0
  70. pythinker_code/notifications/wire.py +21 -0
  71. pythinker_code/plugin/__init__.py +124 -0
  72. pythinker_code/plugin/manager.py +153 -0
  73. pythinker_code/plugin/tool.py +173 -0
  74. pythinker_code/prompts/__init__.py +6 -0
  75. pythinker_code/prompts/compact.md +73 -0
  76. pythinker_code/prompts/init.md +21 -0
  77. pythinker_code/py.typed +0 -0
  78. pythinker_code/session.py +319 -0
  79. pythinker_code/session_fork.py +325 -0
  80. pythinker_code/session_state.py +132 -0
  81. pythinker_code/share.py +14 -0
  82. pythinker_code/skill/__init__.py +727 -0
  83. pythinker_code/skill/flow/__init__.py +99 -0
  84. pythinker_code/skill/flow/d2.py +482 -0
  85. pythinker_code/skill/flow/mermaid.py +266 -0
  86. pythinker_code/skills/pythinker-code-help/SKILL.md +54 -0
  87. pythinker_code/skills/skill-creator/SKILL.md +367 -0
  88. pythinker_code/soul/__init__.py +304 -0
  89. pythinker_code/soul/agent.py +520 -0
  90. pythinker_code/soul/approval.py +267 -0
  91. pythinker_code/soul/btw.py +214 -0
  92. pythinker_code/soul/compaction.py +189 -0
  93. pythinker_code/soul/context.py +339 -0
  94. pythinker_code/soul/denwarenji.py +39 -0
  95. pythinker_code/soul/dynamic_injection.py +84 -0
  96. pythinker_code/soul/dynamic_injections/__init__.py +0 -0
  97. pythinker_code/soul/dynamic_injections/auto_mode.py +72 -0
  98. pythinker_code/soul/dynamic_injections/plan_mode.py +239 -0
  99. pythinker_code/soul/message.py +92 -0
  100. pythinker_code/soul/pythinkersoul.py +1613 -0
  101. pythinker_code/soul/slash.py +340 -0
  102. pythinker_code/soul/toolset.py +788 -0
  103. pythinker_code/subagents/__init__.py +21 -0
  104. pythinker_code/subagents/builder.py +42 -0
  105. pythinker_code/subagents/core.py +86 -0
  106. pythinker_code/subagents/git_context.py +172 -0
  107. pythinker_code/subagents/models.py +54 -0
  108. pythinker_code/subagents/output.py +71 -0
  109. pythinker_code/subagents/registry.py +28 -0
  110. pythinker_code/subagents/runner.py +428 -0
  111. pythinker_code/subagents/store.py +196 -0
  112. pythinker_code/telemetry/__init__.py +211 -0
  113. pythinker_code/telemetry/config.py +54 -0
  114. pythinker_code/telemetry/crash.py +157 -0
  115. pythinker_code/telemetry/metrics.py +208 -0
  116. pythinker_code/telemetry/otel.py +240 -0
  117. pythinker_code/telemetry/sentry.py +167 -0
  118. pythinker_code/telemetry/sink.py +189 -0
  119. pythinker_code/tools/AGENTS.md +6 -0
  120. pythinker_code/tools/__init__.py +105 -0
  121. pythinker_code/tools/agent/__init__.py +277 -0
  122. pythinker_code/tools/agent/description.md +41 -0
  123. pythinker_code/tools/ask_user/__init__.py +159 -0
  124. pythinker_code/tools/ask_user/description.md +19 -0
  125. pythinker_code/tools/background/__init__.py +318 -0
  126. pythinker_code/tools/background/list.md +10 -0
  127. pythinker_code/tools/background/output.md +11 -0
  128. pythinker_code/tools/background/stop.md +8 -0
  129. pythinker_code/tools/display.py +46 -0
  130. pythinker_code/tools/dmail/__init__.py +38 -0
  131. pythinker_code/tools/dmail/dmail.md +17 -0
  132. pythinker_code/tools/file/__init__.py +30 -0
  133. pythinker_code/tools/file/glob.md +17 -0
  134. pythinker_code/tools/file/glob.py +160 -0
  135. pythinker_code/tools/file/grep.md +6 -0
  136. pythinker_code/tools/file/grep_local.py +589 -0
  137. pythinker_code/tools/file/plan_mode.py +45 -0
  138. pythinker_code/tools/file/read.md +16 -0
  139. pythinker_code/tools/file/read.py +300 -0
  140. pythinker_code/tools/file/read_media.md +24 -0
  141. pythinker_code/tools/file/read_media.py +217 -0
  142. pythinker_code/tools/file/replace.md +7 -0
  143. pythinker_code/tools/file/replace.py +195 -0
  144. pythinker_code/tools/file/utils.py +257 -0
  145. pythinker_code/tools/file/write.md +5 -0
  146. pythinker_code/tools/file/write.py +177 -0
  147. pythinker_code/tools/plan/__init__.py +327 -0
  148. pythinker_code/tools/plan/description.md +29 -0
  149. pythinker_code/tools/plan/enter.py +190 -0
  150. pythinker_code/tools/plan/enter_description.md +35 -0
  151. pythinker_code/tools/plan/heroes.py +277 -0
  152. pythinker_code/tools/shell/__init__.py +253 -0
  153. pythinker_code/tools/shell/bash.md +35 -0
  154. pythinker_code/tools/shell/powershell.md +30 -0
  155. pythinker_code/tools/test.py +55 -0
  156. pythinker_code/tools/think/__init__.py +21 -0
  157. pythinker_code/tools/think/think.md +1 -0
  158. pythinker_code/tools/todo/__init__.py +168 -0
  159. pythinker_code/tools/todo/set_todo_list.md +23 -0
  160. pythinker_code/tools/utils.py +199 -0
  161. pythinker_code/tools/web/__init__.py +4 -0
  162. pythinker_code/tools/web/fetch.md +1 -0
  163. pythinker_code/tools/web/fetch.py +189 -0
  164. pythinker_code/tools/web/search.md +1 -0
  165. pythinker_code/tools/web/search.py +163 -0
  166. pythinker_code/ui/__init__.py +0 -0
  167. pythinker_code/ui/acp/__init__.py +99 -0
  168. pythinker_code/ui/print/__init__.py +474 -0
  169. pythinker_code/ui/print/visualize.py +185 -0
  170. pythinker_code/ui/shell/__init__.py +1696 -0
  171. pythinker_code/ui/shell/console.py +109 -0
  172. pythinker_code/ui/shell/debug.py +190 -0
  173. pythinker_code/ui/shell/echo.py +17 -0
  174. pythinker_code/ui/shell/export_import.py +117 -0
  175. pythinker_code/ui/shell/keyboard.py +300 -0
  176. pythinker_code/ui/shell/mcp_status.py +113 -0
  177. pythinker_code/ui/shell/model_picker.py +318 -0
  178. pythinker_code/ui/shell/oauth.py +272 -0
  179. pythinker_code/ui/shell/placeholders.py +531 -0
  180. pythinker_code/ui/shell/prompt.py +2278 -0
  181. pythinker_code/ui/shell/replay.py +215 -0
  182. pythinker_code/ui/shell/session_picker.py +227 -0
  183. pythinker_code/ui/shell/setup.py +212 -0
  184. pythinker_code/ui/shell/slash.py +898 -0
  185. pythinker_code/ui/shell/startup.py +32 -0
  186. pythinker_code/ui/shell/task_browser.py +486 -0
  187. pythinker_code/ui/shell/update.py +350 -0
  188. pythinker_code/ui/shell/usage.py +291 -0
  189. pythinker_code/ui/shell/usage_adapters/__init__.py +50 -0
  190. pythinker_code/ui/shell/usage_adapters/anthropic_admin.py +233 -0
  191. pythinker_code/ui/shell/usage_adapters/base.py +72 -0
  192. pythinker_code/ui/shell/usage_adapters/deepseek.py +137 -0
  193. pythinker_code/ui/shell/usage_adapters/minimax.py +236 -0
  194. pythinker_code/ui/shell/usage_adapters/openai_admin.py +225 -0
  195. pythinker_code/ui/shell/usage_adapters/openai_chatgpt.py +241 -0
  196. pythinker_code/ui/shell/usage_adapters/opencode_go.py +232 -0
  197. pythinker_code/ui/shell/usage_adapters/openrouter.py +105 -0
  198. pythinker_code/ui/shell/usage_adapters/pythinker.py +189 -0
  199. pythinker_code/ui/shell/usage_adapters/pythinker_ai.py +50 -0
  200. pythinker_code/ui/shell/usage_render.py +150 -0
  201. pythinker_code/ui/shell/visualize/__init__.py +165 -0
  202. pythinker_code/ui/shell/visualize/_approval_panel.py +505 -0
  203. pythinker_code/ui/shell/visualize/_blocks.py +629 -0
  204. pythinker_code/ui/shell/visualize/_btw_panel.py +224 -0
  205. pythinker_code/ui/shell/visualize/_input_router.py +48 -0
  206. pythinker_code/ui/shell/visualize/_interactive.py +523 -0
  207. pythinker_code/ui/shell/visualize/_live_view.py +826 -0
  208. pythinker_code/ui/shell/visualize/_question_panel.py +586 -0
  209. pythinker_code/ui/theme.py +241 -0
  210. pythinker_code/usage_ratelimit_cache.py +175 -0
  211. pythinker_code/utils/__init__.py +0 -0
  212. pythinker_code/utils/aiohttp.py +24 -0
  213. pythinker_code/utils/aioqueue.py +72 -0
  214. pythinker_code/utils/broadcast.py +37 -0
  215. pythinker_code/utils/changelog.py +108 -0
  216. pythinker_code/utils/clipboard.py +246 -0
  217. pythinker_code/utils/datetime.py +64 -0
  218. pythinker_code/utils/diff.py +135 -0
  219. pythinker_code/utils/editor.py +91 -0
  220. pythinker_code/utils/environment.py +73 -0
  221. pythinker_code/utils/envvar.py +22 -0
  222. pythinker_code/utils/export.py +696 -0
  223. pythinker_code/utils/file_filter.py +375 -0
  224. pythinker_code/utils/frontmatter.py +70 -0
  225. pythinker_code/utils/io.py +27 -0
  226. pythinker_code/utils/logging.py +146 -0
  227. pythinker_code/utils/media_tags.py +29 -0
  228. pythinker_code/utils/message.py +24 -0
  229. pythinker_code/utils/path.py +199 -0
  230. pythinker_code/utils/proctitle.py +33 -0
  231. pythinker_code/utils/proxy.py +31 -0
  232. pythinker_code/utils/pyinstaller.py +45 -0
  233. pythinker_code/utils/rich/__init__.py +33 -0
  234. pythinker_code/utils/rich/columns.py +99 -0
  235. pythinker_code/utils/rich/diff_render.py +481 -0
  236. pythinker_code/utils/rich/markdown.py +900 -0
  237. pythinker_code/utils/rich/markdown_sample.md +108 -0
  238. pythinker_code/utils/rich/markdown_sample_short.md +2 -0
  239. pythinker_code/utils/rich/syntax.py +114 -0
  240. pythinker_code/utils/sensitive.py +54 -0
  241. pythinker_code/utils/server.py +121 -0
  242. pythinker_code/utils/signals.py +43 -0
  243. pythinker_code/utils/slashcmd.py +124 -0
  244. pythinker_code/utils/string.py +41 -0
  245. pythinker_code/utils/subprocess_env.py +73 -0
  246. pythinker_code/utils/term.py +168 -0
  247. pythinker_code/utils/typing.py +20 -0
  248. pythinker_code/vis/__init__.py +0 -0
  249. pythinker_code/vis/api/__init__.py +5 -0
  250. pythinker_code/vis/api/sessions.py +687 -0
  251. pythinker_code/vis/api/statistics.py +209 -0
  252. pythinker_code/vis/api/system.py +19 -0
  253. pythinker_code/vis/app.py +175 -0
  254. pythinker_code/vis/static/assets/highlighted-body-B3W2YXNL-D2MTYyJz.js +1 -0
  255. pythinker_code/vis/static/assets/index-CezafTt_.js +185 -0
  256. pythinker_code/vis/static/assets/index-DSRInNnm.css +1 -0
  257. pythinker_code/vis/static/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  258. pythinker_code/vis/static/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  259. pythinker_code/vis/static/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  260. pythinker_code/vis/static/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  261. pythinker_code/vis/static/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  262. pythinker_code/vis/static/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  263. pythinker_code/vis/static/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  264. pythinker_code/vis/static/index.html +17 -0
  265. pythinker_code/web/__init__.py +5 -0
  266. pythinker_code/web/api/__init__.py +15 -0
  267. pythinker_code/web/api/config.py +208 -0
  268. pythinker_code/web/api/open_in.py +199 -0
  269. pythinker_code/web/api/sessions.py +1225 -0
  270. pythinker_code/web/app.py +451 -0
  271. pythinker_code/web/auth.py +191 -0
  272. pythinker_code/web/models.py +98 -0
  273. pythinker_code/web/runner/__init__.py +5 -0
  274. pythinker_code/web/runner/messages.py +57 -0
  275. pythinker_code/web/runner/process.py +754 -0
  276. pythinker_code/web/runner/worker.py +97 -0
  277. pythinker_code/web/static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  278. pythinker_code/web/static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  279. pythinker_code/web/static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  280. pythinker_code/web/static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  281. pythinker_code/web/static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  282. pythinker_code/web/static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  283. pythinker_code/web/static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  284. pythinker_code/web/static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  285. pythinker_code/web/static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  286. pythinker_code/web/static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  287. pythinker_code/web/static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  288. pythinker_code/web/static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  289. pythinker_code/web/static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  290. pythinker_code/web/static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  291. pythinker_code/web/static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  292. pythinker_code/web/static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  293. pythinker_code/web/static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  294. pythinker_code/web/static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  295. pythinker_code/web/static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  296. pythinker_code/web/static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  297. pythinker_code/web/static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  298. pythinker_code/web/static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  299. pythinker_code/web/static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  300. pythinker_code/web/static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  301. pythinker_code/web/static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  302. pythinker_code/web/static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  303. pythinker_code/web/static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  304. pythinker_code/web/static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  305. pythinker_code/web/static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  306. pythinker_code/web/static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  307. pythinker_code/web/static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  308. pythinker_code/web/static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  309. pythinker_code/web/static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  310. pythinker_code/web/static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  311. pythinker_code/web/static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  312. pythinker_code/web/static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  313. pythinker_code/web/static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  314. pythinker_code/web/static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  315. pythinker_code/web/static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  316. pythinker_code/web/static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  317. pythinker_code/web/static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  318. pythinker_code/web/static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  319. pythinker_code/web/static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  320. pythinker_code/web/static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  321. pythinker_code/web/static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  322. pythinker_code/web/static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  323. pythinker_code/web/static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  324. pythinker_code/web/static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  325. pythinker_code/web/static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  326. pythinker_code/web/static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  327. pythinker_code/web/static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  328. pythinker_code/web/static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  329. pythinker_code/web/static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  330. pythinker_code/web/static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  331. pythinker_code/web/static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  332. pythinker_code/web/static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  333. pythinker_code/web/static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  334. pythinker_code/web/static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  335. pythinker_code/web/static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  336. pythinker_code/web/static/assets/_baseUniq--dyU3g5v.js +1 -0
  337. pythinker_code/web/static/assets/abap-BdImnpbu.js +1 -0
  338. pythinker_code/web/static/assets/actionscript-3-CfeIJUat.js +1 -0
  339. pythinker_code/web/static/assets/ada-bCR0ucgS.js +1 -0
  340. pythinker_code/web/static/assets/andromeeda-C-Jbm3Hp.js +1 -0
  341. pythinker_code/web/static/assets/angular-html-CU67Zn6k.js +1 -0
  342. pythinker_code/web/static/assets/angular-ts-BwZT4LLn.js +1 -0
  343. pythinker_code/web/static/assets/apache-Pmp26Uib.js +1 -0
  344. pythinker_code/web/static/assets/apex-D8_7TLub.js +1 -0
  345. pythinker_code/web/static/assets/apl-dKokRX4l.js +1 -0
  346. pythinker_code/web/static/assets/applescript-Co6uUVPk.js +1 -0
  347. pythinker_code/web/static/assets/ara-BRHolxvo.js +1 -0
  348. pythinker_code/web/static/assets/arc-DkMjLpYa.js +1 -0
  349. pythinker_code/web/static/assets/architectureDiagram-VXUJARFQ-CHWVaGo9.js +36 -0
  350. pythinker_code/web/static/assets/asciidoc-Dv7Oe6Be.js +1 -0
  351. pythinker_code/web/static/assets/asm-D_Q5rh1f.js +1 -0
  352. pythinker_code/web/static/assets/astro-CbQHKStN.js +1 -0
  353. pythinker_code/web/static/assets/aurora-x-D-2ljcwZ.js +1 -0
  354. pythinker_code/web/static/assets/awk-DMzUqQB5.js +1 -0
  355. pythinker_code/web/static/assets/ayu-dark-CmMr59Fi.js +1 -0
  356. pythinker_code/web/static/assets/ballerina-BFfxhgS-.js +1 -0
  357. pythinker_code/web/static/assets/bat-BkioyH1T.js +1 -0
  358. pythinker_code/web/static/assets/beancount-k_qm7-4y.js +1 -0
  359. pythinker_code/web/static/assets/berry-uYugtg8r.js +1 -0
  360. pythinker_code/web/static/assets/bibtex-CHM0blh-.js +1 -0
  361. pythinker_code/web/static/assets/bicep-Bmn6On1c.js +1 -0
  362. pythinker_code/web/static/assets/blade-D4QpJJKB.js +1 -0
  363. pythinker_code/web/static/assets/blockDiagram-VD42YOAC-DzdKe497.js +122 -0
  364. pythinker_code/web/static/assets/bsl-BO_Y6i37.js +1 -0
  365. pythinker_code/web/static/assets/c-BIGW1oBm.js +1 -0
  366. pythinker_code/web/static/assets/c3-VCDPK7BO.js +1 -0
  367. pythinker_code/web/static/assets/c4Diagram-YG6GDRKO-D84Blotg.js +10 -0
  368. pythinker_code/web/static/assets/cadence-Bv_4Rxtq.js +1 -0
  369. pythinker_code/web/static/assets/cairo-KRGpt6FW.js +1 -0
  370. pythinker_code/web/static/assets/catppuccin-frappe-DFWUc33u.js +1 -0
  371. pythinker_code/web/static/assets/catppuccin-latte-C9dUb6Cb.js +1 -0
  372. pythinker_code/web/static/assets/catppuccin-macchiato-DQyhUUbL.js +1 -0
  373. pythinker_code/web/static/assets/catppuccin-mocha-D87Tk5Gz.js +1 -0
  374. pythinker_code/web/static/assets/channel-CllSjjdl.js +1 -0
  375. pythinker_code/web/static/assets/chunk-4BX2VUAB-C9w8wleE.js +1 -0
  376. pythinker_code/web/static/assets/chunk-55IACEB6-YlYJ8HnF.js +1 -0
  377. pythinker_code/web/static/assets/chunk-B4BG7PRW-Bwtz_AHU.js +165 -0
  378. pythinker_code/web/static/assets/chunk-DI55MBZ5-BbEHkl8h.js +220 -0
  379. pythinker_code/web/static/assets/chunk-FMBD7UC4-BKPbvjLC.js +15 -0
  380. pythinker_code/web/static/assets/chunk-QN33PNHL-D73dQvKf.js +1 -0
  381. pythinker_code/web/static/assets/chunk-QZHKN3VN-zGiLKes_.js +1 -0
  382. pythinker_code/web/static/assets/chunk-TZMSLE5B-LHJCi2fy.js +1 -0
  383. pythinker_code/web/static/assets/clarity-D53aC0YG.js +1 -0
  384. pythinker_code/web/static/assets/classDiagram-2ON5EDUG-vX27iZwa.js +1 -0
  385. pythinker_code/web/static/assets/classDiagram-v2-WZHVMYZB-vX27iZwa.js +1 -0
  386. pythinker_code/web/static/assets/clojure-P80f7IUj.js +1 -0
  387. pythinker_code/web/static/assets/clone-DYBkaPm2.js +1 -0
  388. pythinker_code/web/static/assets/cmake-D1j8_8rp.js +1 -0
  389. pythinker_code/web/static/assets/cobol-nwyudZeR.js +1 -0
  390. pythinker_code/web/static/assets/code-block-IT6T5CEO-NtKViZGl.js +2 -0
  391. pythinker_code/web/static/assets/codeowners-Bp6g37R7.js +1 -0
  392. pythinker_code/web/static/assets/codeql-DsOJ9woJ.js +1 -0
  393. pythinker_code/web/static/assets/coffee-Ch7k5sss.js +1 -0
  394. pythinker_code/web/static/assets/common-lisp-Cg-RD9OK.js +1 -0
  395. pythinker_code/web/static/assets/coq-DkFqJrB1.js +1 -0
  396. pythinker_code/web/static/assets/cose-bilkent-S5V4N54A-DialjZpd.js +1 -0
  397. pythinker_code/web/static/assets/cpp-CofmeUqb.js +1 -0
  398. pythinker_code/web/static/assets/crystal-tKQVLTB8.js +1 -0
  399. pythinker_code/web/static/assets/csharp-K5feNrxe.js +1 -0
  400. pythinker_code/web/static/assets/css-DPfMkruS.js +1 -0
  401. pythinker_code/web/static/assets/csv-fuZLfV_i.js +1 -0
  402. pythinker_code/web/static/assets/cue-D82EKSYY.js +1 -0
  403. pythinker_code/web/static/assets/cypher-COkxafJQ.js +1 -0
  404. pythinker_code/web/static/assets/cytoscape.esm-C_Fzpdck.js +321 -0
  405. pythinker_code/web/static/assets/d-85-TOEBH.js +1 -0
  406. pythinker_code/web/static/assets/dagre-6UL2VRFP-DfuvkZZ7.js +4 -0
  407. pythinker_code/web/static/assets/dark-plus-C3mMm8J8.js +1 -0
  408. pythinker_code/web/static/assets/dart-CF10PKvl.js +1 -0
  409. pythinker_code/web/static/assets/dax-CEL-wOlO.js +1 -0
  410. pythinker_code/web/static/assets/defaultLocale-DX6XiGOO.js +1 -0
  411. pythinker_code/web/static/assets/desktop-BmXAJ9_W.js +1 -0
  412. pythinker_code/web/static/assets/diagram-PSM6KHXK-DLGctX3v.js +24 -0
  413. pythinker_code/web/static/assets/diagram-QEK2KX5R-DnxN6S0F.js +43 -0
  414. pythinker_code/web/static/assets/diagram-S2PKOQOG-Caq_Set9.js +24 -0
  415. pythinker_code/web/static/assets/diff-D97Zzqfu.js +1 -0
  416. pythinker_code/web/static/assets/docker-BcOcwvcX.js +1 -0
  417. pythinker_code/web/static/assets/dotenv-Da5cRb03.js +1 -0
  418. pythinker_code/web/static/assets/dracula-BzJJZx-M.js +1 -0
  419. pythinker_code/web/static/assets/dracula-soft-BXkSAIEj.js +1 -0
  420. pythinker_code/web/static/assets/dream-maker-BtqSS_iP.js +1 -0
  421. pythinker_code/web/static/assets/edge-BkV0erSs.js +1 -0
  422. pythinker_code/web/static/assets/elixir-CDX3lj18.js +1 -0
  423. pythinker_code/web/static/assets/elm-DbKCFpqz.js +1 -0
  424. pythinker_code/web/static/assets/emacs-lisp-C9XAeP06.js +1 -0
  425. pythinker_code/web/static/assets/erDiagram-Q2GNP2WA-BgTfALoK.js +60 -0
  426. pythinker_code/web/static/assets/erb-BOJIQeun.js +1 -0
  427. pythinker_code/web/static/assets/erlang-DsQrWhSR.js +1 -0
  428. pythinker_code/web/static/assets/everforest-dark-BgDCqdQA.js +1 -0
  429. pythinker_code/web/static/assets/everforest-light-C8M2exoo.js +1 -0
  430. pythinker_code/web/static/assets/fennel-BYunw83y.js +1 -0
  431. pythinker_code/web/static/assets/fish-BvzEVeQv.js +1 -0
  432. pythinker_code/web/static/assets/flowDiagram-NV44I4VS-QjW_fnGi.js +162 -0
  433. pythinker_code/web/static/assets/fluent-C4IJs8-o.js +1 -0
  434. pythinker_code/web/static/assets/fortran-fixed-form-CkoXwp7k.js +1 -0
  435. pythinker_code/web/static/assets/fortran-free-form-BxgE0vQu.js +1 -0
  436. pythinker_code/web/static/assets/fsharp-CXgrBDvD.js +1 -0
  437. pythinker_code/web/static/assets/ganttDiagram-JELNMOA3-fqi8JFof.js +267 -0
  438. pythinker_code/web/static/assets/gdresource-B7Tvp0Sc.js +1 -0
  439. pythinker_code/web/static/assets/gdscript-DTMYz4Jt.js +1 -0
  440. pythinker_code/web/static/assets/gdshader-DkwncUOv.js +1 -0
  441. pythinker_code/web/static/assets/genie-D0YGMca9.js +1 -0
  442. pythinker_code/web/static/assets/gherkin-DyxjwDmM.js +1 -0
  443. pythinker_code/web/static/assets/git-commit-F4YmCXRG.js +1 -0
  444. pythinker_code/web/static/assets/git-rebase-r7XF79zn.js +1 -0
  445. pythinker_code/web/static/assets/gitGraphDiagram-NY62KEGX-i7o6VQ8x.js +65 -0
  446. pythinker_code/web/static/assets/github-dark-DHJKELXO.js +1 -0
  447. pythinker_code/web/static/assets/github-dark-default-Cuk6v7N8.js +1 -0
  448. pythinker_code/web/static/assets/github-dark-dimmed-DH5Ifo-i.js +1 -0
  449. pythinker_code/web/static/assets/github-dark-high-contrast-E3gJ1_iC.js +1 -0
  450. pythinker_code/web/static/assets/github-light-DAi9KRSo.js +1 -0
  451. pythinker_code/web/static/assets/github-light-default-D7oLnXFd.js +1 -0
  452. pythinker_code/web/static/assets/github-light-high-contrast-BfjtVDDH.js +1 -0
  453. pythinker_code/web/static/assets/gleam-BspZqrRM.js +1 -0
  454. pythinker_code/web/static/assets/glimmer-js-Rg0-pVw9.js +1 -0
  455. pythinker_code/web/static/assets/glimmer-ts-U6CK756n.js +1 -0
  456. pythinker_code/web/static/assets/glsl-DplSGwfg.js +1 -0
  457. pythinker_code/web/static/assets/gn-n2N0HUVH.js +1 -0
  458. pythinker_code/web/static/assets/gnuplot-DdkO51Og.js +1 -0
  459. pythinker_code/web/static/assets/go-Dn2_MT6a.js +1 -0
  460. pythinker_code/web/static/assets/graph-C0vZK2pT.js +1 -0
  461. pythinker_code/web/static/assets/graphql-ChdNCCLP.js +1 -0
  462. pythinker_code/web/static/assets/groovy-gcz8RCvz.js +1 -0
  463. pythinker_code/web/static/assets/gruvbox-dark-hard-CFHQjOhq.js +1 -0
  464. pythinker_code/web/static/assets/gruvbox-dark-medium-GsRaNv29.js +1 -0
  465. pythinker_code/web/static/assets/gruvbox-dark-soft-CVdnzihN.js +1 -0
  466. pythinker_code/web/static/assets/gruvbox-light-hard-CH1njM8p.js +1 -0
  467. pythinker_code/web/static/assets/gruvbox-light-medium-DRw_LuNl.js +1 -0
  468. pythinker_code/web/static/assets/gruvbox-light-soft-hJgmCMqR.js +1 -0
  469. pythinker_code/web/static/assets/hack-CaT9iCJl.js +1 -0
  470. pythinker_code/web/static/assets/haml-B8DHNrY2.js +1 -0
  471. pythinker_code/web/static/assets/handlebars-BL8al0AC.js +1 -0
  472. pythinker_code/web/static/assets/haskell-Df6bDoY_.js +1 -0
  473. pythinker_code/web/static/assets/haxe-CzTSHFRz.js +1 -0
  474. pythinker_code/web/static/assets/hcl-BWvSN4gD.js +1 -0
  475. pythinker_code/web/static/assets/hjson-D5-asLiD.js +1 -0
  476. pythinker_code/web/static/assets/hlsl-D3lLCCz7.js +1 -0
  477. pythinker_code/web/static/assets/houston-DnULxvSX.js +1 -0
  478. pythinker_code/web/static/assets/html-GMplVEZG.js +1 -0
  479. pythinker_code/web/static/assets/html-derivative-BFtXZ54Q.js +1 -0
  480. pythinker_code/web/static/assets/http-jrhK8wxY.js +1 -0
  481. pythinker_code/web/static/assets/hurl-irOxFIW8.js +1 -0
  482. pythinker_code/web/static/assets/hxml-Bvhsp5Yf.js +1 -0
  483. pythinker_code/web/static/assets/hy-DFXneXwc.js +1 -0
  484. pythinker_code/web/static/assets/imba-DGztddWO.js +1 -0
  485. pythinker_code/web/static/assets/index-BYCCk6-K.js +153 -0
  486. pythinker_code/web/static/assets/index-BpoLgcEt.js +1 -0
  487. pythinker_code/web/static/assets/index-Cpy4G3uJ.js +2 -0
  488. pythinker_code/web/static/assets/index-CzV_vCfu.css +1 -0
  489. pythinker_code/web/static/assets/index-DI2oedCt.js +19 -0
  490. pythinker_code/web/static/assets/index-DdIkp80K.js +5 -0
  491. pythinker_code/web/static/assets/infoDiagram-WHAUD3N6-BMPpeZSM.js +2 -0
  492. pythinker_code/web/static/assets/ini-BEwlwnbL.js +1 -0
  493. pythinker_code/web/static/assets/init-Gi6I4Gst.js +1 -0
  494. pythinker_code/web/static/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  495. pythinker_code/web/static/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  496. pythinker_code/web/static/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  497. pythinker_code/web/static/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  498. pythinker_code/web/static/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  499. pythinker_code/web/static/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  500. pythinker_code/web/static/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  501. pythinker_code/web/static/assets/java-CylS5w8V.js +1 -0
  502. pythinker_code/web/static/assets/javascript-wDzz0qaB.js +1 -0
  503. pythinker_code/web/static/assets/jinja-4LBKfQ-Z.js +1 -0
  504. pythinker_code/web/static/assets/jison-wvAkD_A8.js +1 -0
  505. pythinker_code/web/static/assets/journeyDiagram-XKPGCS4Q-DAM7gngo.js +139 -0
  506. pythinker_code/web/static/assets/json-Cp-IABpG.js +1 -0
  507. pythinker_code/web/static/assets/json5-C9tS-k6U.js +1 -0
  508. pythinker_code/web/static/assets/jsonc-Des-eS-w.js +1 -0
  509. pythinker_code/web/static/assets/jsonl-DcaNXYhu.js +1 -0
  510. pythinker_code/web/static/assets/jsonnet-DFQXde-d.js +1 -0
  511. pythinker_code/web/static/assets/jssm-C2t-YnRu.js +1 -0
  512. pythinker_code/web/static/assets/jsx-g9-lgVsj.js +1 -0
  513. pythinker_code/web/static/assets/julia-CxzCAyBv.js +1 -0
  514. pythinker_code/web/static/assets/kanagawa-dragon-CkXjmgJE.js +1 -0
  515. pythinker_code/web/static/assets/kanagawa-lotus-CfQXZHmo.js +1 -0
  516. pythinker_code/web/static/assets/kanagawa-wave-DWedfzmr.js +1 -0
  517. pythinker_code/web/static/assets/kanban-definition-3W4ZIXB7-ChpBpV0k.js +89 -0
  518. pythinker_code/web/static/assets/katex-D2lIc1rk.css +1 -0
  519. pythinker_code/web/static/assets/kdl-DV7GczEv.js +1 -0
  520. pythinker_code/web/static/assets/kotlin-BdnUsdx6.js +1 -0
  521. pythinker_code/web/static/assets/kusto-DZf3V79B.js +1 -0
  522. pythinker_code/web/static/assets/laserwave-DUszq2jm.js +1 -0
  523. pythinker_code/web/static/assets/latex-B4uzh10-.js +1 -0
  524. pythinker_code/web/static/assets/layout-C3Jp1gKO.js +1 -0
  525. pythinker_code/web/static/assets/lean-BZvkOJ9d.js +1 -0
  526. pythinker_code/web/static/assets/less-B1dDrJ26.js +1 -0
  527. pythinker_code/web/static/assets/light-plus-B7mTdjB0.js +1 -0
  528. pythinker_code/web/static/assets/linear-BGHtL1N4.js +1 -0
  529. pythinker_code/web/static/assets/liquid-DYVedYrR.js +1 -0
  530. pythinker_code/web/static/assets/llvm-BtvRca6l.js +1 -0
  531. pythinker_code/web/static/assets/log-2UxHyX5q.js +1 -0
  532. pythinker_code/web/static/assets/logo-BtOb2qkB.js +1 -0
  533. pythinker_code/web/static/assets/lua-BbnMAYS6.js +1 -0
  534. pythinker_code/web/static/assets/luau-C-HG3fhB.js +1 -0
  535. pythinker_code/web/static/assets/make-CHLpvVh8.js +1 -0
  536. pythinker_code/web/static/assets/markdown-Cvjx9yec.js +1 -0
  537. pythinker_code/web/static/assets/marko-DZsq8hO1.js +1 -0
  538. pythinker_code/web/static/assets/material-theme-D5KoaKCx.js +1 -0
  539. pythinker_code/web/static/assets/material-theme-darker-BfHTSMKl.js +1 -0
  540. pythinker_code/web/static/assets/material-theme-lighter-B0m2ddpp.js +1 -0
  541. pythinker_code/web/static/assets/material-theme-ocean-CyktbL80.js +1 -0
  542. pythinker_code/web/static/assets/material-theme-palenight-Csfq5Kiy.js +1 -0
  543. pythinker_code/web/static/assets/matlab-D7o27uSR.js +1 -0
  544. pythinker_code/web/static/assets/mdc-DUICxH0z.js +1 -0
  545. pythinker_code/web/static/assets/mdx-Cmh6b_Ma.js +1 -0
  546. pythinker_code/web/static/assets/mermaid-VLURNSYL-B2P5VJ9v.css +1 -0
  547. pythinker_code/web/static/assets/mermaid-VLURNSYL-C_HW6koB.js +465 -0
  548. pythinker_code/web/static/assets/mermaid-mWjccvbQ.js +1 -0
  549. pythinker_code/web/static/assets/mermaid.core-CnT4VrPC.js +191 -0
  550. pythinker_code/web/static/assets/min-Dn5VRVmX.js +1 -0
  551. pythinker_code/web/static/assets/min-dark-CafNBF8u.js +1 -0
  552. pythinker_code/web/static/assets/min-light-CTRr51gU.js +1 -0
  553. pythinker_code/web/static/assets/mindmap-definition-VGOIOE7T-x8EwhfIt.js +68 -0
  554. pythinker_code/web/static/assets/mipsasm-CKIfxQSi.js +1 -0
  555. pythinker_code/web/static/assets/mojo-B93PlW-d.js +1 -0
  556. pythinker_code/web/static/assets/monokai-D4h5O-jR.js +1 -0
  557. pythinker_code/web/static/assets/moonbit-Ba13S78F.js +1 -0
  558. pythinker_code/web/static/assets/move-Bu9oaDYs.js +1 -0
  559. pythinker_code/web/static/assets/narrat-DRg8JJMk.js +1 -0
  560. pythinker_code/web/static/assets/nextflow-BrzmwbiE.js +1 -0
  561. pythinker_code/web/static/assets/nginx-DknmC5AR.js +1 -0
  562. pythinker_code/web/static/assets/night-owl-C39BiMTA.js +1 -0
  563. pythinker_code/web/static/assets/nim-CVrawwO9.js +1 -0
  564. pythinker_code/web/static/assets/nix-CwoSXNpI.js +1 -0
  565. pythinker_code/web/static/assets/nord-Ddv68eIx.js +1 -0
  566. pythinker_code/web/static/assets/nushell-C-sUppwS.js +1 -0
  567. pythinker_code/web/static/assets/objective-c-DXmwc3jG.js +1 -0
  568. pythinker_code/web/static/assets/objective-cpp-CLxacb5B.js +1 -0
  569. pythinker_code/web/static/assets/ocaml-C0hk2d4L.js +1 -0
  570. pythinker_code/web/static/assets/one-dark-pro-DVMEJ2y_.js +1 -0
  571. pythinker_code/web/static/assets/one-light-PoHY5YXO.js +1 -0
  572. pythinker_code/web/static/assets/openscad-C4EeE6gA.js +1 -0
  573. pythinker_code/web/static/assets/ordinal-Cboi1Yqb.js +1 -0
  574. pythinker_code/web/static/assets/pascal-D93ZcfNL.js +1 -0
  575. pythinker_code/web/static/assets/perl-C0TMdlhV.js +1 -0
  576. pythinker_code/web/static/assets/php-CDn_0X-4.js +1 -0
  577. pythinker_code/web/static/assets/pieDiagram-ADFJNKIX-DgxBKGz2.js +30 -0
  578. pythinker_code/web/static/assets/pkl-u5AG7uiY.js +1 -0
  579. pythinker_code/web/static/assets/plastic-3e1v2bzS.js +1 -0
  580. pythinker_code/web/static/assets/plsql-ChMvpjG-.js +1 -0
  581. pythinker_code/web/static/assets/po-BTJTHyun.js +1 -0
  582. pythinker_code/web/static/assets/poimandres-CS3Unz2-.js +1 -0
  583. pythinker_code/web/static/assets/polar-C0HS_06l.js +1 -0
  584. pythinker_code/web/static/assets/postcss-CXtECtnM.js +1 -0
  585. pythinker_code/web/static/assets/powerquery-CEu0bR-o.js +1 -0
  586. pythinker_code/web/static/assets/powershell-Dpen1YoG.js +1 -0
  587. pythinker_code/web/static/assets/prisma-Dd19v3D-.js +1 -0
  588. pythinker_code/web/static/assets/prolog-CbFg5uaA.js +1 -0
  589. pythinker_code/web/static/assets/proto-C7zT0LnQ.js +1 -0
  590. pythinker_code/web/static/assets/pug-CGlum2m_.js +1 -0
  591. pythinker_code/web/static/assets/puppet-BMWR74SV.js +1 -0
  592. pythinker_code/web/static/assets/purescript-CklMAg4u.js +1 -0
  593. pythinker_code/web/static/assets/python-B6aJPvgy.js +1 -0
  594. pythinker_code/web/static/assets/qml-3beO22l8.js +1 -0
  595. pythinker_code/web/static/assets/qmldir-C8lEn-DE.js +1 -0
  596. pythinker_code/web/static/assets/qss-IeuSbFQv.js +1 -0
  597. pythinker_code/web/static/assets/quadrantDiagram-AYHSOK5B-DSpe_dqk.js +7 -0
  598. pythinker_code/web/static/assets/r-Dspwwk_N.js +1 -0
  599. pythinker_code/web/static/assets/racket-BqYA7rlc.js +1 -0
  600. pythinker_code/web/static/assets/raku-DXvB9xmW.js +1 -0
  601. pythinker_code/web/static/assets/razor-C1TweQQi.js +1 -0
  602. pythinker_code/web/static/assets/red-bN70gL4F.js +1 -0
  603. pythinker_code/web/static/assets/reg-C-SQnVFl.js +1 -0
  604. pythinker_code/web/static/assets/regexp-CDVJQ6XC.js +1 -0
  605. pythinker_code/web/static/assets/rel-C3B-1QV4.js +1 -0
  606. pythinker_code/web/static/assets/requirementDiagram-UZGBJVZJ-8o9hozL-.js +64 -0
  607. pythinker_code/web/static/assets/riscv-BM1_JUlF.js +1 -0
  608. pythinker_code/web/static/assets/rose-pine-dawn-DHQR4-dF.js +1 -0
  609. pythinker_code/web/static/assets/rose-pine-moon-D4_iv3hh.js +1 -0
  610. pythinker_code/web/static/assets/rose-pine-qdsjHGoJ.js +1 -0
  611. pythinker_code/web/static/assets/rosmsg-BJDFO7_C.js +1 -0
  612. pythinker_code/web/static/assets/rst-B0xPkSld.js +1 -0
  613. pythinker_code/web/static/assets/ruby-BvKwtOVI.js +1 -0
  614. pythinker_code/web/static/assets/rust-B1yitclQ.js +1 -0
  615. pythinker_code/web/static/assets/sankeyDiagram-TZEHDZUN-BLOSUixH.js +10 -0
  616. pythinker_code/web/static/assets/sas-cz2c8ADy.js +1 -0
  617. pythinker_code/web/static/assets/sass-Cj5Yp3dK.js +1 -0
  618. pythinker_code/web/static/assets/scala-C151Ov-r.js +1 -0
  619. pythinker_code/web/static/assets/scheme-C98Dy4si.js +1 -0
  620. pythinker_code/web/static/assets/scss-OYdSNvt2.js +1 -0
  621. pythinker_code/web/static/assets/sdbl-DVxCFoDh.js +1 -0
  622. pythinker_code/web/static/assets/sequenceDiagram-WL72ISMW-6F2G8JTU.js +145 -0
  623. pythinker_code/web/static/assets/shaderlab-Dg9Lc6iA.js +1 -0
  624. pythinker_code/web/static/assets/shellscript-Yzrsuije.js +1 -0
  625. pythinker_code/web/static/assets/shellsession-BADoaaVG.js +1 -0
  626. pythinker_code/web/static/assets/slack-dark-BthQWCQV.js +1 -0
  627. pythinker_code/web/static/assets/slack-ochin-DqwNpetd.js +1 -0
  628. pythinker_code/web/static/assets/smalltalk-BERRCDM3.js +1 -0
  629. pythinker_code/web/static/assets/snazzy-light-Bw305WKR.js +1 -0
  630. pythinker_code/web/static/assets/solarized-dark-DXbdFlpD.js +1 -0
  631. pythinker_code/web/static/assets/solarized-light-L9t79GZl.js +1 -0
  632. pythinker_code/web/static/assets/solidity-rGO070M0.js +1 -0
  633. pythinker_code/web/static/assets/soy-Brmx7dQM.js +1 -0
  634. pythinker_code/web/static/assets/sparql-rVzFXLq3.js +1 -0
  635. pythinker_code/web/static/assets/splunk-BtCnVYZw.js +1 -0
  636. pythinker_code/web/static/assets/sql-BLtJtn59.js +1 -0
  637. pythinker_code/web/static/assets/ssh-config-_ykCGR6B.js +1 -0
  638. pythinker_code/web/static/assets/stata-BH5u7GGu.js +1 -0
  639. pythinker_code/web/static/assets/stateDiagram-FKZM4ZOC-DP8xP0iJ.js +1 -0
  640. pythinker_code/web/static/assets/stateDiagram-v2-4FDKWEC3-1l6-EZNX.js +1 -0
  641. pythinker_code/web/static/assets/stylus-BEDo0Tqx.js +1 -0
  642. pythinker_code/web/static/assets/svelte-zxCyuUbr.js +1 -0
  643. pythinker_code/web/static/assets/swift-Dg5xB15N.js +1 -0
  644. pythinker_code/web/static/assets/synthwave-84-CbfX1IO0.js +1 -0
  645. pythinker_code/web/static/assets/system-verilog-CnnmHF94.js +1 -0
  646. pythinker_code/web/static/assets/systemd-4A_iFExJ.js +1 -0
  647. pythinker_code/web/static/assets/talonscript-CkByrt1z.js +1 -0
  648. pythinker_code/web/static/assets/tasl-QIJgUcNo.js +1 -0
  649. pythinker_code/web/static/assets/tcl-dwOrl1Do.js +1 -0
  650. pythinker_code/web/static/assets/templ-W15q3VgB.js +1 -0
  651. pythinker_code/web/static/assets/terraform-BETggiCN.js +1 -0
  652. pythinker_code/web/static/assets/tex-CvyZ59Mk.js +1 -0
  653. pythinker_code/web/static/assets/timeline-definition-IT6M3QCI-DMgruDfK.js +61 -0
  654. pythinker_code/web/static/assets/tokyo-night-hegEt444.js +1 -0
  655. pythinker_code/web/static/assets/toml-vGWfd6FD.js +1 -0
  656. pythinker_code/web/static/assets/treemap-KMMF4GRG-Bo9ZkrAK.js +128 -0
  657. pythinker_code/web/static/assets/ts-tags-zn1MmPIZ.js +1 -0
  658. pythinker_code/web/static/assets/tsv-B_m7g4N7.js +1 -0
  659. pythinker_code/web/static/assets/tsx-COt5Ahok.js +1 -0
  660. pythinker_code/web/static/assets/turtle-BsS91CYL.js +1 -0
  661. pythinker_code/web/static/assets/twig-CO9l9SDP.js +1 -0
  662. pythinker_code/web/static/assets/typescript-BPQ3VLAy.js +1 -0
  663. pythinker_code/web/static/assets/typespec-BGHnOYBU.js +1 -0
  664. pythinker_code/web/static/assets/typst-DHCkPAjA.js +1 -0
  665. pythinker_code/web/static/assets/v-BcVCzyr7.js +1 -0
  666. pythinker_code/web/static/assets/vala-CsfeWuGM.js +1 -0
  667. pythinker_code/web/static/assets/vb-D17OF-Vu.js +1 -0
  668. pythinker_code/web/static/assets/verilog-BQ8w6xss.js +1 -0
  669. pythinker_code/web/static/assets/vesper-DU1UobuO.js +1 -0
  670. pythinker_code/web/static/assets/vhdl-CeAyd5Ju.js +1 -0
  671. pythinker_code/web/static/assets/viml-CJc9bBzg.js +1 -0
  672. pythinker_code/web/static/assets/vitesse-black-Bkuqu6BP.js +1 -0
  673. pythinker_code/web/static/assets/vitesse-dark-D0r3Knsf.js +1 -0
  674. pythinker_code/web/static/assets/vitesse-light-CVO1_9PV.js +1 -0
  675. pythinker_code/web/static/assets/vue-DN_0RTcg.js +1 -0
  676. pythinker_code/web/static/assets/vue-html-AaS7Mt5G.js +1 -0
  677. pythinker_code/web/static/assets/vue-vine-CQOfvN7w.js +1 -0
  678. pythinker_code/web/static/assets/vyper-CDx5xZoG.js +1 -0
  679. pythinker_code/web/static/assets/wasm-CG6Dc4jp.js +1 -0
  680. pythinker_code/web/static/assets/wasm-MzD3tlZU.js +1 -0
  681. pythinker_code/web/static/assets/wenyan-BV7otONQ.js +1 -0
  682. pythinker_code/web/static/assets/wgsl-Dx-B1_4e.js +1 -0
  683. pythinker_code/web/static/assets/wikitext-BhOHFoWU.js +1 -0
  684. pythinker_code/web/static/assets/wit-5i3qLPDT.js +1 -0
  685. pythinker_code/web/static/assets/wolfram-lXgVvXCa.js +1 -0
  686. pythinker_code/web/static/assets/xml-sdJ4AIDG.js +1 -0
  687. pythinker_code/web/static/assets/xsl-CtQFsRM5.js +1 -0
  688. pythinker_code/web/static/assets/xychartDiagram-PRI3JC2R-GeF2johi.js +7 -0
  689. pythinker_code/web/static/assets/yaml-Buea-lGh.js +1 -0
  690. pythinker_code/web/static/assets/zenscript-DVFEvuxE.js +1 -0
  691. pythinker_code/web/static/assets/zig-VOosw3JB.js +1 -0
  692. pythinker_code/web/static/brand/apple-touch-icon.png +0 -0
  693. pythinker_code/web/static/brand/arctecture.webp +0 -0
  694. pythinker_code/web/static/brand/bimi-logo.svg +46 -0
  695. pythinker_code/web/static/brand/favicon.ico +0 -0
  696. pythinker_code/web/static/brand/fonts/dm-sans-latin-ext.woff2 +0 -0
  697. pythinker_code/web/static/brand/fonts/dm-sans-latin.woff2 +0 -0
  698. pythinker_code/web/static/brand/fonts/instrument-sans-latin-ext.woff2 +0 -0
  699. pythinker_code/web/static/brand/fonts/instrument-sans-latin.woff2 +0 -0
  700. pythinker_code/web/static/brand/fonts/instrument-serif-latin-ext.woff2 +0 -0
  701. pythinker_code/web/static/brand/fonts/instrument-serif-latin.woff2 +0 -0
  702. pythinker_code/web/static/brand/fonts/libre-baskerville-italic-latin-ext.woff2 +0 -0
  703. pythinker_code/web/static/brand/fonts/libre-baskerville-italic-latin.woff2 +0 -0
  704. pythinker_code/web/static/brand/fonts/libre-baskerville-latin-ext.woff2 +0 -0
  705. pythinker_code/web/static/brand/fonts/libre-baskerville-latin.woff2 +0 -0
  706. pythinker_code/web/static/brand/fonts/roboto-latin-ext.woff2 +0 -0
  707. pythinker_code/web/static/brand/fonts/roboto-latin.woff2 +0 -0
  708. pythinker_code/web/static/brand/icon-192.png +0 -0
  709. pythinker_code/web/static/brand/icon-512.png +0 -0
  710. pythinker_code/web/static/brand/icon.svg +1 -0
  711. pythinker_code/web/static/brand/logo.png +0 -0
  712. pythinker_code/web/static/brand/pythinker_animated.svg +79 -0
  713. pythinker_code/web/static/brand/robots.txt +4 -0
  714. pythinker_code/web/static/index.html +15 -0
  715. pythinker_code/web/static/logo.png +0 -0
  716. pythinker_code/web/store/__init__.py +1 -0
  717. pythinker_code/web/store/sessions.py +432 -0
  718. pythinker_code/wire/__init__.py +148 -0
  719. pythinker_code/wire/file.py +151 -0
  720. pythinker_code/wire/jsonrpc.py +263 -0
  721. pythinker_code/wire/protocol.py +2 -0
  722. pythinker_code/wire/root_hub.py +27 -0
  723. pythinker_code/wire/serde.py +26 -0
  724. pythinker_code/wire/server.py +1069 -0
  725. pythinker_code/wire/types.py +698 -0
  726. pythinker_code-2.0.0.dist-info/METADATA +660 -0
  727. pythinker_code-2.0.0.dist-info/RECORD +731 -0
  728. pythinker_code-2.0.0.dist-info/WHEEL +4 -0
  729. pythinker_code-2.0.0.dist-info/entry_points.txt +4 -0
  730. pythinker_code-2.0.0.dist-info/licenses/LICENSE +202 -0
  731. pythinker_code-2.0.0.dist-info/licenses/NOTICE +14 -0
@@ -0,0 +1,1613 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ import uuid
6
+ from collections.abc import Awaitable, Callable, Sequence
7
+ from dataclasses import dataclass
8
+ from functools import partial
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Any, Literal, cast
11
+
12
+ import pythinker_core
13
+ import tenacity
14
+ from pythinker_core import StepResult
15
+ from pythinker_core.chat_provider import (
16
+ APIConnectionError,
17
+ APIEmptyResponseError,
18
+ APIStatusError,
19
+ APITimeoutError,
20
+ RetryableChatProvider,
21
+ )
22
+ from pythinker_core.message import Message
23
+ from tenacity import RetryCallState, retry_if_exception, stop_after_attempt, wait_exponential_jitter
24
+
25
+ from pythinker_code.approval_runtime import (
26
+ ApprovalSource,
27
+ get_current_approval_source_or_none,
28
+ reset_current_approval_source,
29
+ set_current_approval_source,
30
+ )
31
+ from pythinker_code.background import build_active_task_snapshot
32
+ from pythinker_code.hooks.engine import HookEngine
33
+ from pythinker_code.llm import ModelCapability
34
+ from pythinker_code.notifications import (
35
+ NotificationView,
36
+ build_notification_message,
37
+ extract_notification_ids,
38
+ )
39
+ from pythinker_code.skill import Skill, read_skill_text
40
+ from pythinker_code.skill.flow import Flow, FlowEdge, FlowNode, parse_choice
41
+ from pythinker_code.soul import (
42
+ LLMNotSet,
43
+ LLMNotSupported,
44
+ MaxStepsReached,
45
+ Soul,
46
+ StatusSnapshot,
47
+ wire_send,
48
+ )
49
+ from pythinker_code.soul.agent import Agent, Runtime
50
+ from pythinker_code.soul.compaction import (
51
+ CompactionResult,
52
+ SimpleCompaction,
53
+ estimate_text_tokens,
54
+ should_auto_compact,
55
+ )
56
+ from pythinker_code.soul.context import Context
57
+ from pythinker_code.soul.dynamic_injection import (
58
+ DynamicInjection,
59
+ DynamicInjectionProvider,
60
+ normalize_history,
61
+ )
62
+ from pythinker_code.soul.dynamic_injections.auto_mode import AutoModeInjectionProvider
63
+ from pythinker_code.soul.dynamic_injections.plan_mode import PlanModeInjectionProvider
64
+ from pythinker_code.soul.message import (
65
+ check_message,
66
+ system,
67
+ system_reminder,
68
+ tool_result_to_message,
69
+ )
70
+ from pythinker_code.soul.slash import registry as soul_slash_registry
71
+ from pythinker_code.soul.toolset import PythinkerToolset
72
+ from pythinker_code.tools.dmail import NAME as SendDMail_NAME
73
+ from pythinker_code.tools.utils import ToolRejectedError
74
+ from pythinker_code.utils.logging import logger
75
+ from pythinker_code.utils.slashcmd import SlashCommand, parse_slash_command_call
76
+ from pythinker_code.wire.file import WireFile
77
+ from pythinker_code.wire.types import (
78
+ CompactionBegin,
79
+ CompactionEnd,
80
+ ContentPart,
81
+ MCPLoadingBegin,
82
+ MCPLoadingEnd,
83
+ StatusUpdate,
84
+ SteerInput,
85
+ StepBegin,
86
+ StepInterrupted,
87
+ TextPart,
88
+ ToolResult,
89
+ TurnBegin,
90
+ TurnEnd,
91
+ )
92
+
93
+ if TYPE_CHECKING:
94
+
95
+ def type_check(soul: PythinkerSoul):
96
+ _: Soul = soul
97
+
98
+
99
+ SKILL_COMMAND_PREFIX = "skill:"
100
+ FLOW_COMMAND_PREFIX = "flow:"
101
+ DEFAULT_MAX_FLOW_MOVES = 1000
102
+
103
+
104
+ def classify_api_error(e: Exception) -> tuple[str, int | None]:
105
+ """Classify an LLM API exception into (error_type, status_code).
106
+
107
+ Exposed at module level so telemetry tests can import the real function
108
+ instead of duplicating the classification table.
109
+
110
+ Returns:
111
+ (error_type, status_code) where status_code is None for non-HTTP errors.
112
+ """
113
+ status_code: int | None = None
114
+ if isinstance(e, APIStatusError):
115
+ status = getattr(e, "status_code", getattr(e, "status", 0))
116
+ status_code = int(status) if status else None
117
+ if status == 429:
118
+ return "rate_limit", status_code
119
+ if status in (401, 403):
120
+ return "auth", status_code
121
+ if status >= 500:
122
+ return "5xx_server", status_code
123
+ if 400 <= status < 500:
124
+ msg_lower = str(e).lower()
125
+ if (
126
+ "context length" in msg_lower
127
+ or "context_length" in msg_lower
128
+ or "max tokens" in msg_lower
129
+ or "maximum context" in msg_lower
130
+ or "too many tokens" in msg_lower
131
+ ):
132
+ return "context_overflow", status_code
133
+ return "4xx_client", status_code
134
+ return "api", status_code
135
+ if isinstance(e, APIConnectionError):
136
+ return "network", None
137
+ if isinstance(e, (APITimeoutError, TimeoutError)):
138
+ return "timeout", None
139
+ if isinstance(e, APIEmptyResponseError):
140
+ return "empty_response", None
141
+ return "other", None
142
+
143
+
144
+ type StepStopReason = Literal["no_tool_calls", "tool_rejected"]
145
+
146
+
147
+ @dataclass(frozen=True, slots=True)
148
+ class StepOutcome:
149
+ stop_reason: StepStopReason
150
+ assistant_message: Message
151
+
152
+
153
+ type TurnStopReason = StepStopReason
154
+
155
+
156
+ @dataclass(frozen=True, slots=True)
157
+ class TurnOutcome:
158
+ stop_reason: TurnStopReason
159
+ final_message: Message | None
160
+ step_count: int
161
+
162
+
163
+ class PythinkerSoul:
164
+ """The soul of Pythinker CLI."""
165
+
166
+ def __init__(
167
+ self,
168
+ agent: Agent,
169
+ *,
170
+ context: Context,
171
+ ):
172
+ """
173
+ Initialize the soul.
174
+
175
+ Args:
176
+ agent (Agent): The agent to run.
177
+ context (Context): The context of the agent.
178
+ """
179
+ self._agent = agent
180
+ self._runtime = agent.runtime
181
+ self._denwa_renji = agent.runtime.denwa_renji
182
+ self._approval = agent.runtime.approval
183
+ self._context = context
184
+ self._loop_control = agent.runtime.config.loop_control
185
+ self._compaction = SimpleCompaction() # TODO: maybe configurable and composable
186
+
187
+ for tool in agent.toolset.tools:
188
+ if tool.name == SendDMail_NAME:
189
+ self._checkpoint_with_user_message = True
190
+ break
191
+ else:
192
+ self._checkpoint_with_user_message = False
193
+
194
+ self._steer_queue: asyncio.Queue[str | list[ContentPart]] = asyncio.Queue()
195
+ self._plan_mode: bool = self._runtime.session.state.plan_mode
196
+ self._plan_session_id: str | None = self._runtime.session.state.plan_session_id
197
+ # Pre-warm slug cache so the persisted slug survives process restarts
198
+ if self._plan_session_id is not None and self._runtime.session.state.plan_slug is not None:
199
+ from pythinker_code.tools.plan.heroes import seed_slug_cache
200
+
201
+ seed_slug_cache(self._plan_session_id, self._runtime.session.state.plan_slug)
202
+ self._pending_plan_activation_injection: bool = False
203
+ if self._plan_mode:
204
+ self._ensure_plan_session_id()
205
+ self._injection_providers: list[DynamicInjectionProvider] = [
206
+ PlanModeInjectionProvider(),
207
+ *(
208
+ []
209
+ if self._runtime.config.skip_auto_prompt_injection
210
+ else [AutoModeInjectionProvider()]
211
+ ),
212
+ ]
213
+ self._hook_engine: HookEngine = HookEngine()
214
+ self._stop_hook_active: bool = False
215
+ if self._runtime.role == "root":
216
+ self._runtime.notifications.ack_ids("llm", extract_notification_ids(context.history))
217
+
218
+ # Bind plan mode state to tools that support it
219
+ self._bind_plan_mode_tools()
220
+
221
+ self._slash_commands = self._build_slash_commands()
222
+ self._slash_command_map = self._index_slash_commands(self._slash_commands)
223
+
224
+ @property
225
+ def name(self) -> str:
226
+ return self._agent.name
227
+
228
+ @property
229
+ def model_name(self) -> str:
230
+ return self._runtime.llm.chat_provider.model_name if self._runtime.llm else ""
231
+
232
+ @property
233
+ def model_capabilities(self) -> set[ModelCapability] | None:
234
+ if self._runtime.llm is None:
235
+ return None
236
+ return self._runtime.llm.capabilities
237
+
238
+ @property
239
+ def is_yolo(self) -> bool:
240
+ """Whether explicit yolo mode is active."""
241
+ return self._approval.is_yolo()
242
+
243
+ @property
244
+ def is_auto_approve(self) -> bool:
245
+ """Whether tool approvals are bypassed (explicit yolo, or implied by auto mode)."""
246
+ return self._approval.is_auto_approve()
247
+
248
+ @property
249
+ def is_auto(self) -> bool:
250
+ """Whether no user is present (auto mode)."""
251
+ return self._approval.is_auto()
252
+
253
+ @property
254
+ def is_auto_flag(self) -> bool:
255
+ """Whether persisted auto mode is active."""
256
+ return self._approval.is_auto_flag()
257
+
258
+ @property
259
+ def is_subagent(self) -> bool:
260
+ """Whether this soul is running as a subagent rather than the root session."""
261
+ return self._runtime.role == "subagent"
262
+
263
+ @property
264
+ def plan_mode(self) -> bool:
265
+ """Whether plan mode (read-only research and planning) is active."""
266
+ return self._plan_mode
267
+
268
+ @property
269
+ def hook_engine(self) -> HookEngine:
270
+ return self._hook_engine
271
+
272
+ def set_hook_engine(self, engine: HookEngine) -> None:
273
+ self._hook_engine = engine
274
+ if isinstance(self._agent.toolset, PythinkerToolset):
275
+ self._agent.toolset.set_hook_engine(engine)
276
+
277
+ def add_injection_provider(self, provider: DynamicInjectionProvider) -> None:
278
+ """Register an additional dynamic injection provider."""
279
+ self._injection_providers.append(provider)
280
+
281
+ async def _collect_injections(self) -> list[DynamicInjection]:
282
+ """Collect dynamic injections from all registered providers."""
283
+ injections: list[DynamicInjection] = []
284
+ for provider in self._injection_providers:
285
+ try:
286
+ result = await provider.get_injections(self._context.history, self)
287
+ injections.extend(result)
288
+ except Exception:
289
+ logger.warning(
290
+ "injection provider %s failed",
291
+ type(provider).__name__,
292
+ exc_info=True,
293
+ )
294
+ return injections
295
+
296
+ async def _notify_injection_providers_compacted(self) -> None:
297
+ """Notify all injection providers that the context has been compacted.
298
+
299
+ Failures are isolated per-provider so a buggy third-party provider
300
+ cannot abort compaction (which would skip CompactionEnd wire events
301
+ and PostCompact telemetry).
302
+ """
303
+ for provider in self._injection_providers:
304
+ try:
305
+ await provider.on_context_compacted()
306
+ except Exception:
307
+ logger.warning(
308
+ "injection provider %s on_context_compacted failed",
309
+ type(provider).__name__,
310
+ exc_info=True,
311
+ )
312
+
313
+ async def notify_auto_changed(self, enabled: bool) -> None:
314
+ """Notify dynamic injection providers that auto mode changed."""
315
+ for provider in self._injection_providers:
316
+ try:
317
+ await provider.on_auto_changed(enabled)
318
+ except Exception:
319
+ logger.warning(
320
+ "injection provider %s on_auto_changed failed",
321
+ type(provider).__name__,
322
+ exc_info=True,
323
+ )
324
+
325
+ def _bind_plan_mode_tools(self) -> None:
326
+ """Bind plan mode state to tools that support it."""
327
+ if not isinstance(self._agent.toolset, PythinkerToolset):
328
+ return
329
+
330
+ def checker() -> bool:
331
+ return self._plan_mode
332
+
333
+ def path_getter() -> Path | None:
334
+ return self.get_plan_file_path()
335
+
336
+ # WriteFile gets both checker and path_getter (for plan file auto-approve)
337
+ from pythinker_code.tools.file.write import WriteFile
338
+
339
+ write_tool = self._agent.toolset.find("WriteFile")
340
+ if isinstance(write_tool, WriteFile):
341
+ write_tool.bind_plan_mode(checker, path_getter)
342
+
343
+ from pythinker_code.tools.file.replace import StrReplaceFile
344
+
345
+ replace_tool = self._agent.toolset.find("StrReplaceFile")
346
+ if isinstance(replace_tool, StrReplaceFile):
347
+ replace_tool.bind_plan_mode(checker, path_getter)
348
+
349
+ # ExitPlanMode has a special bind() method
350
+ from pythinker_code.tools.plan import ExitPlanMode
351
+
352
+ exit_tool = self._agent.toolset.find("ExitPlanMode")
353
+ if isinstance(exit_tool, ExitPlanMode):
354
+ exit_tool.bind(
355
+ self.toggle_plan_mode,
356
+ path_getter,
357
+ checker,
358
+ self._approval.is_auto,
359
+ )
360
+
361
+ # EnterPlanMode has a special bind() method
362
+ from pythinker_code.tools.plan.enter import EnterPlanMode
363
+
364
+ enter_tool = self._agent.toolset.find("EnterPlanMode")
365
+ if isinstance(enter_tool, EnterPlanMode):
366
+ enter_tool.bind(
367
+ self.toggle_plan_mode,
368
+ path_getter,
369
+ checker,
370
+ self._approval.is_auto_approve,
371
+ )
372
+
373
+ # AskUserQuestion — bind auto-mode checker for auto-dismiss.
374
+ # Yolo alone keeps the tool live; only auto mode (no user present) dismisses.
375
+ from pythinker_code.tools.ask_user import AskUserQuestion
376
+
377
+ ask_tool = self._agent.toolset.find("AskUserQuestion")
378
+ if isinstance(ask_tool, AskUserQuestion):
379
+ ask_tool.bind_auto(self._approval.is_auto)
380
+
381
+ def _ensure_plan_session_id(self) -> None:
382
+ """Allocate a stable plan session ID on first activation."""
383
+ if self._plan_session_id is None:
384
+ import uuid
385
+
386
+ self._plan_session_id = uuid.uuid4().hex
387
+ self._runtime.session.state.plan_session_id = self._plan_session_id
388
+ # Compute and persist slug immediately so the path survives process restarts
389
+ from pythinker_code.tools.plan.heroes import get_or_create_slug
390
+
391
+ slug = get_or_create_slug(self._plan_session_id)
392
+ self._runtime.session.state.plan_slug = slug
393
+ self._runtime.session.save_state()
394
+
395
+ def _set_plan_mode(self, enabled: bool, *, source: Literal["manual", "tool"]) -> bool:
396
+ """Update plan mode state for either manual or tool-driven toggles."""
397
+ if enabled == self._plan_mode:
398
+ return self._plan_mode
399
+ self._plan_mode = enabled
400
+ if enabled:
401
+ self._ensure_plan_session_id()
402
+ self._pending_plan_activation_injection = source == "manual"
403
+ else:
404
+ self._pending_plan_activation_injection = False
405
+ self._plan_session_id = None
406
+ self._runtime.session.state.plan_session_id = None
407
+ self._runtime.session.state.plan_slug = None
408
+ # Persist plan mode to session state so it survives process restarts
409
+ self._runtime.session.state.plan_mode = self._plan_mode
410
+ self._runtime.session.save_state()
411
+ return self._plan_mode
412
+
413
+ def get_plan_file_path(self) -> Path | None:
414
+ """Get the plan file path for the current session."""
415
+ if self._plan_session_id is None:
416
+ return None
417
+ from pythinker_code.tools.plan.heroes import get_plan_file_path
418
+
419
+ return get_plan_file_path(self._plan_session_id)
420
+
421
+ def read_current_plan(self) -> str | None:
422
+ """Read the current plan file content."""
423
+ if self._plan_session_id is None:
424
+ return None
425
+ from pythinker_code.tools.plan.heroes import read_plan_file
426
+
427
+ return read_plan_file(self._plan_session_id)
428
+
429
+ def clear_current_plan(self) -> None:
430
+ """Delete the current plan file."""
431
+ path = self.get_plan_file_path()
432
+ if path and path.exists():
433
+ path.unlink()
434
+
435
+ async def toggle_plan_mode(self) -> bool:
436
+ """Toggle plan mode on/off. Returns the new state.
437
+
438
+ Tools are not hidden/unhidden — instead, each tool checks plan mode
439
+ state at call time and rejects if blocked.
440
+ Periodic reminders are handled by the dynamic injection system.
441
+ """
442
+ return self._set_plan_mode(not self._plan_mode, source="tool")
443
+
444
+ async def toggle_plan_mode_from_manual(self) -> bool:
445
+ """Toggle plan mode from UI/manual entry points (slash command, keybinding)."""
446
+ return self._set_plan_mode(not self._plan_mode, source="manual")
447
+
448
+ async def set_plan_mode_from_manual(self, enabled: bool) -> bool:
449
+ """Set plan mode to a specific state from UI/manual entry points.
450
+
451
+ Unlike toggle, this accepts the desired state directly, avoiding
452
+ race conditions when the caller already knows the target value.
453
+ """
454
+ return self._set_plan_mode(enabled, source="manual")
455
+
456
+ def schedule_plan_activation_reminder(self) -> None:
457
+ """Schedule a plan-mode activation reminder for the next turn.
458
+
459
+ Use this when plan mode is already active (e.g. restored session with
460
+ ``--plan`` flag) and ``_set_plan_mode`` would early-return because the
461
+ state hasn't actually changed.
462
+ """
463
+ if self._plan_mode:
464
+ self._pending_plan_activation_injection = True
465
+
466
+ def consume_pending_plan_activation_injection(self) -> bool:
467
+ """Consume the next-step activation reminder scheduled by a manual toggle."""
468
+ if not self._plan_mode or not self._pending_plan_activation_injection:
469
+ return False
470
+ self._pending_plan_activation_injection = False
471
+ return True
472
+
473
+ @property
474
+ def thinking(self) -> bool | None:
475
+ """Whether thinking mode is enabled."""
476
+ if self._runtime.llm is None:
477
+ return None
478
+ if thinking_effort := self._runtime.llm.chat_provider.thinking_effort:
479
+ return thinking_effort != "off"
480
+ return None
481
+
482
+ @property
483
+ def status(self) -> StatusSnapshot:
484
+ token_count = self._context.token_count
485
+ max_size = self._runtime.llm.max_context_size if self._runtime.llm is not None else 0
486
+ return StatusSnapshot(
487
+ context_usage=self._context_usage,
488
+ yolo_enabled=self._approval.is_yolo_flag(),
489
+ auto_enabled=self._approval.is_auto(),
490
+ plan_mode=self._plan_mode,
491
+ context_tokens=token_count,
492
+ max_context_tokens=max_size,
493
+ mcp_status=self._mcp_status_snapshot(),
494
+ )
495
+
496
+ @property
497
+ def agent(self) -> Agent:
498
+ return self._agent
499
+
500
+ @property
501
+ def runtime(self) -> Runtime:
502
+ return self._runtime
503
+
504
+ @property
505
+ def context(self) -> Context:
506
+ return self._context
507
+
508
+ @property
509
+ def _context_usage(self) -> float:
510
+ if self._runtime.llm is None or self._runtime.llm.max_context_size <= 0:
511
+ return 0.0
512
+ return self._context.token_count / self._runtime.llm.max_context_size
513
+
514
+ @property
515
+ def wire_file(self) -> WireFile:
516
+ return self._runtime.session.wire_file
517
+
518
+ def _mcp_status_snapshot(self):
519
+ if not isinstance(self._agent.toolset, PythinkerToolset):
520
+ return None
521
+ return self._agent.toolset.mcp_status_snapshot()
522
+
523
+ async def start_background_mcp_loading(self) -> bool:
524
+ """Start deferred MCP loading, if any, without exposing toolset internals."""
525
+ if not isinstance(self._agent.toolset, PythinkerToolset):
526
+ return False
527
+ return await self._agent.toolset.start_deferred_mcp_tool_loading()
528
+
529
+ async def wait_for_background_mcp_loading(self) -> None:
530
+ """Wait for any in-flight MCP startup to finish."""
531
+ if not isinstance(self._agent.toolset, PythinkerToolset):
532
+ return
533
+ await self._agent.toolset.wait_for_mcp_tools()
534
+
535
+ async def _checkpoint(self):
536
+ await self._context.checkpoint(self._checkpoint_with_user_message)
537
+
538
+ def steer(self, content: str | list[ContentPart]) -> None:
539
+ """Queue a steer message for injection into the current turn."""
540
+ self._steer_queue.put_nowait(content)
541
+
542
+ async def _consume_pending_steers(self) -> bool:
543
+ """Drain the steer queue and inject as follow-up user messages.
544
+
545
+ Returns True if any steers were consumed.
546
+
547
+ Note: /btw is intercepted at the UI layer (``classify_input``) before
548
+ reaching the steer queue, so it never appears here.
549
+ """
550
+ consumed = False
551
+ while not self._steer_queue.empty():
552
+ content = self._steer_queue.get_nowait()
553
+ await self._inject_steer(content)
554
+ wire_send(SteerInput(user_input=content))
555
+ consumed = True
556
+ return consumed
557
+
558
+ async def _inject_steer(self, content: str | list[ContentPart]) -> None:
559
+ """Inject a single steer as a regular follow-up user message."""
560
+ parts = cast(
561
+ list[ContentPart],
562
+ [TextPart(text=content)] if isinstance(content, str) else list(content),
563
+ )
564
+ message = Message(role="user", content=parts)
565
+ if self._runtime.llm is None:
566
+ raise LLMNotSet()
567
+ if missing_caps := check_message(message, self._runtime.llm.capabilities):
568
+ raise LLMNotSupported(self._runtime.llm, list(missing_caps))
569
+ await self._context.append_message(message)
570
+
571
+ @property
572
+ def available_slash_commands(self) -> list[SlashCommand[Any]]:
573
+ return self._slash_commands
574
+
575
+ async def run(
576
+ self,
577
+ user_input: str | list[ContentPart],
578
+ *,
579
+ skip_user_prompt_hook: bool = False,
580
+ ):
581
+ approval_source_token = None
582
+ created_approval_source: ApprovalSource | None = None
583
+ turn_started = False
584
+ turn_finished = False
585
+ if get_current_approval_source_or_none() is None:
586
+ created_approval_source = ApprovalSource(kind="foreground_turn", id=uuid.uuid4().hex)
587
+ approval_source_token = set_current_approval_source(created_approval_source)
588
+ try:
589
+ # Refresh OAuth tokens on each turn to avoid idle-time expirations.
590
+ await self._runtime.oauth.ensure_fresh(self._runtime)
591
+
592
+ # Set session_id ContextVar for toolset hooks
593
+ from pythinker_code.soul.toolset import set_session_id
594
+
595
+ set_session_id(self._runtime.session.id)
596
+
597
+ from pythinker_code.hooks import events
598
+
599
+ # --- UserPromptSubmit hook ---
600
+ # Synthetic internal prompts (e.g. background-task notification
601
+ # follow-ups injected by ``Print`` after a bg task finishes or
602
+ # the wait ceiling is hit) must bypass ``UserPromptSubmit``:
603
+ # they are not user input, and a user-configured prompt-blocking
604
+ # hook would drop the notification and hang the wait loop.
605
+ if not skip_user_prompt_hook:
606
+ text_input_for_hook = user_input if isinstance(user_input, str) else ""
607
+
608
+ hook_results = await self._hook_engine.trigger(
609
+ "UserPromptSubmit",
610
+ matcher_value=text_input_for_hook,
611
+ input_data=events.user_prompt_submit(
612
+ session_id=self._runtime.session.id,
613
+ cwd=str(Path.cwd()),
614
+ prompt=text_input_for_hook,
615
+ ),
616
+ )
617
+ for result in hook_results:
618
+ if result.action == "block":
619
+ wire_send(TurnBegin(user_input=user_input))
620
+ turn_started = True
621
+ wire_send(TextPart(text=result.reason or "Prompt blocked by hook."))
622
+ wire_send(TurnEnd())
623
+ turn_finished = True
624
+ return
625
+
626
+ wire_send(TurnBegin(user_input=user_input))
627
+ turn_started = True
628
+ user_message = Message(role="user", content=user_input)
629
+ text_input = user_message.extract_text(" ").strip()
630
+
631
+ if command_call := parse_slash_command_call(text_input):
632
+ command = self._find_slash_command(command_call.name)
633
+ if command is None:
634
+ # this should not happen actually, the shell should have filtered it out
635
+ wire_send(TextPart(text=f'Unknown slash command "/{command_call.name}".'))
636
+ else:
637
+ ret = command.func(self, command_call.args)
638
+ if isinstance(ret, Awaitable):
639
+ await ret
640
+ elif self._loop_control.max_ralph_iterations != 0:
641
+ runner = FlowRunner.ralph_loop(
642
+ user_message,
643
+ self._loop_control.max_ralph_iterations,
644
+ )
645
+ await runner.run(self, "")
646
+ else:
647
+ await self._turn(user_message)
648
+
649
+ # --- Stop hook (max 1 re-trigger to prevent infinite loop) ---
650
+ if not self._stop_hook_active:
651
+ stop_results = await self._hook_engine.trigger(
652
+ "Stop",
653
+ input_data=events.stop(
654
+ session_id=self._runtime.session.id,
655
+ cwd=str(Path.cwd()),
656
+ stop_hook_active=False,
657
+ ),
658
+ )
659
+ for result in stop_results:
660
+ if result.action == "block" and result.reason:
661
+ self._stop_hook_active = True
662
+ try:
663
+ await self._turn(Message(role="user", content=result.reason))
664
+ finally:
665
+ self._stop_hook_active = False
666
+ break
667
+
668
+ wire_send(TurnEnd())
669
+ turn_finished = True
670
+
671
+ # Auto-set title after first real turn (skip slash commands)
672
+ if not command_call:
673
+ session = self._runtime.session
674
+ if session.state.custom_title is None:
675
+ from pythinker_code.utils.string import shorten
676
+
677
+ title = shorten(
678
+ Message(role="user", content=user_input).extract_text(" "),
679
+ width=50,
680
+ )
681
+ if title:
682
+ from pythinker_code.session_state import (
683
+ load_session_state,
684
+ save_session_state,
685
+ )
686
+
687
+ # Read-modify-write: load fresh state to avoid
688
+ # overwriting concurrent web changes
689
+ fresh = load_session_state(session.dir)
690
+ if fresh.custom_title is None:
691
+ fresh.custom_title = title
692
+ save_session_state(fresh, session.dir)
693
+ session.state.custom_title = fresh.custom_title
694
+ finally:
695
+ if turn_started and not turn_finished:
696
+ wire_send(TurnEnd())
697
+ if created_approval_source is not None and self._runtime.approval_runtime is not None:
698
+ self._runtime.approval_runtime.cancel_by_source(
699
+ created_approval_source.kind,
700
+ created_approval_source.id,
701
+ )
702
+ if approval_source_token is not None:
703
+ reset_current_approval_source(approval_source_token)
704
+
705
+ async def _turn(self, user_message: Message) -> TurnOutcome:
706
+ from pythinker_code.telemetry import metrics as _m
707
+ from pythinker_code.telemetry import otel as _otel
708
+
709
+ if self._runtime.llm is None:
710
+ raise LLMNotSet()
711
+
712
+ if missing_caps := check_message(user_message, self._runtime.llm.capabilities):
713
+ raise LLMNotSupported(self._runtime.llm, list(missing_caps))
714
+
715
+ with _otel.start_span(
716
+ "pythinker.turn",
717
+ {
718
+ "session.id": self._runtime.session.id,
719
+ "agent.role": self._runtime.role,
720
+ "model": self._runtime.llm.model_name,
721
+ "plan_mode": self._plan_mode,
722
+ },
723
+ ) as span:
724
+ turn_t0 = time.monotonic()
725
+ await self._checkpoint() # this creates the checkpoint 0 on first run
726
+ await self._context.append_message(user_message)
727
+ logger.debug("Appended user message to context")
728
+ outcome = await self._agent_loop()
729
+ span.set_attribute("turn.stop_reason", outcome.stop_reason)
730
+ span.set_attribute("turn.step_count", outcome.step_count)
731
+ _m.record_turn(
732
+ duration_seconds=time.monotonic() - turn_t0,
733
+ step_count=outcome.step_count,
734
+ stop_reason=outcome.stop_reason,
735
+ )
736
+ return outcome
737
+
738
+ def _build_slash_commands(self) -> list[SlashCommand[Any]]:
739
+ commands: list[SlashCommand[Any]] = list(soul_slash_registry.list_commands())
740
+ seen_names = {cmd.name for cmd in commands}
741
+
742
+ for skill in self._runtime.skills.values():
743
+ if skill.type not in ("standard", "flow"):
744
+ continue
745
+ name = f"{SKILL_COMMAND_PREFIX}{skill.name}"
746
+ if name in seen_names:
747
+ logger.warning(
748
+ "Skipping skill slash command /{name}: name already registered",
749
+ name=name,
750
+ )
751
+ continue
752
+ commands.append(
753
+ SlashCommand(
754
+ name=name,
755
+ func=self._make_skill_runner(skill),
756
+ description=skill.description or "",
757
+ aliases=[],
758
+ )
759
+ )
760
+ seen_names.add(name)
761
+
762
+ for skill in self._runtime.skills.values():
763
+ if skill.type != "flow":
764
+ continue
765
+ if skill.flow is None:
766
+ logger.warning("Flow skill {name} has no flow; skipping", name=skill.name)
767
+ continue
768
+ command_name = f"{FLOW_COMMAND_PREFIX}{skill.name}"
769
+ if command_name in seen_names:
770
+ logger.warning(
771
+ "Skipping prompt flow slash command /{name}: name already registered",
772
+ name=command_name,
773
+ )
774
+ continue
775
+ runner = FlowRunner(skill.flow, name=skill.name)
776
+ commands.append(
777
+ SlashCommand(
778
+ name=command_name,
779
+ func=runner.run,
780
+ description=skill.description or "",
781
+ aliases=[],
782
+ )
783
+ )
784
+ seen_names.add(command_name)
785
+
786
+ return commands
787
+
788
+ @staticmethod
789
+ def _index_slash_commands(
790
+ commands: list[SlashCommand[Any]],
791
+ ) -> dict[str, SlashCommand[Any]]:
792
+ indexed: dict[str, SlashCommand[Any]] = {}
793
+ for command in commands:
794
+ indexed[command.name] = command
795
+ for alias in command.aliases:
796
+ indexed[alias] = command
797
+ return indexed
798
+
799
+ def _find_slash_command(self, name: str) -> SlashCommand[Any] | None:
800
+ return self._slash_command_map.get(name)
801
+
802
+ def _make_skill_runner(
803
+ self, skill: Skill
804
+ ) -> Callable[[PythinkerSoul, str], None | Awaitable[None]]:
805
+ async def _run_skill(soul: PythinkerSoul, args: str, *, _skill: Skill = skill) -> None:
806
+ from pythinker_code.telemetry import track
807
+
808
+ track("skill_invoked", skill_name=_skill.name)
809
+ skill_text = await read_skill_text(_skill)
810
+ if skill_text is None:
811
+ wire_send(
812
+ TextPart(text=f'Failed to load skill "/{SKILL_COMMAND_PREFIX}{_skill.name}".')
813
+ )
814
+ return
815
+ extra = args.strip()
816
+ if extra:
817
+ skill_text = f"{skill_text}\n\nUser request:\n{extra}"
818
+ await soul._turn(Message(role="user", content=skill_text))
819
+
820
+ _run_skill.__doc__ = skill.description
821
+ return _run_skill
822
+
823
+ async def _agent_loop(self) -> TurnOutcome:
824
+ """The main agent loop for one run."""
825
+ assert self._runtime.llm is not None
826
+
827
+ # Discard any stale steers from a previous turn.
828
+ while not self._steer_queue.empty():
829
+ self._steer_queue.get_nowait()
830
+
831
+ if isinstance(self._agent.toolset, PythinkerToolset):
832
+ await self.start_background_mcp_loading()
833
+ loading = bool((snapshot := self._mcp_status_snapshot()) and snapshot.loading)
834
+ if loading:
835
+ wire_send(StatusUpdate(mcp_status=snapshot))
836
+ wire_send(MCPLoadingBegin())
837
+ try:
838
+ await self.wait_for_background_mcp_loading()
839
+ # Track MCP connection result
840
+ if loading:
841
+ from pythinker_code.telemetry import track as _track_mcp
842
+
843
+ mcp_snap = self._mcp_status_snapshot()
844
+ if mcp_snap:
845
+ if mcp_snap.connected > 0:
846
+ _track_mcp(
847
+ "mcp_connected",
848
+ server_count=mcp_snap.connected,
849
+ total_count=mcp_snap.total,
850
+ )
851
+ _failed = mcp_snap.total - mcp_snap.connected
852
+ if _failed > 0:
853
+ _track_mcp(
854
+ "mcp_failed",
855
+ failed_count=_failed,
856
+ total_count=mcp_snap.total,
857
+ )
858
+ finally:
859
+ if loading:
860
+ wire_send(StatusUpdate(mcp_status=self._mcp_status_snapshot()))
861
+ wire_send(MCPLoadingEnd())
862
+
863
+ step_no = 0
864
+ self._current_step_no = 0
865
+ while True:
866
+ step_no += 1
867
+ if step_no > self._loop_control.max_steps_per_turn:
868
+ raise MaxStepsReached(self._loop_control.max_steps_per_turn)
869
+
870
+ self._current_step_no = step_no
871
+ wire_send(StepBegin(n=step_no))
872
+ back_to_the_future: BackToTheFuture | None = None
873
+ step_outcome: StepOutcome | None = None
874
+ try:
875
+ # compact the context if needed
876
+ if should_auto_compact(
877
+ self._context.token_count_with_pending,
878
+ self._runtime.llm.max_context_size,
879
+ trigger_ratio=self._loop_control.compaction_trigger_ratio,
880
+ reserved_context_size=self._loop_control.reserved_context_size,
881
+ ):
882
+ logger.info("Context too long, compacting...")
883
+ try:
884
+ await self.compact_context()
885
+ except Exception as compact_err:
886
+ logger.error(
887
+ "Context compaction failed at step {step_no}: {error_type}: {error}",
888
+ step_no=step_no,
889
+ error_type=type(compact_err).__name__,
890
+ error=compact_err,
891
+ )
892
+ raise
893
+
894
+ logger.debug("Beginning step {step_no}", step_no=step_no)
895
+ await self._checkpoint()
896
+ self._denwa_renji.set_n_checkpoints(self._context.n_checkpoints)
897
+ step_outcome = await self._step()
898
+ except BackToTheFuture as e:
899
+ back_to_the_future = e
900
+ except Exception as e:
901
+ # any other exception should interrupt the step
902
+ req_id = getattr(e, "request_id", None)
903
+ logger.error(
904
+ "Agent step {step_no} failed: {error_type}: {error}"
905
+ + (" (request_id={request_id})" if req_id else ""),
906
+ step_no=step_no,
907
+ error_type=type(e).__name__,
908
+ error=e,
909
+ request_id=req_id,
910
+ )
911
+ wire_send(StepInterrupted())
912
+ # Track API/step errors
913
+ from pythinker_code.telemetry import track
914
+
915
+ error_type, status_code = classify_api_error(e)
916
+ if status_code is not None:
917
+ track("api_error", error_type=error_type, status_code=status_code)
918
+ else:
919
+ track("api_error", error_type=error_type)
920
+ # --- StopFailure hook ---
921
+ from pythinker_code.hooks import events as _hook_events
922
+
923
+ _hook_task = asyncio.create_task(
924
+ self._hook_engine.trigger(
925
+ "StopFailure",
926
+ matcher_value=type(e).__name__,
927
+ input_data=_hook_events.stop_failure(
928
+ session_id=self._runtime.session.id,
929
+ cwd=str(Path.cwd()),
930
+ error_type=type(e).__name__,
931
+ error_message=str(e),
932
+ ),
933
+ )
934
+ )
935
+ _hook_task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None)
936
+ # break the agent loop
937
+ raise
938
+
939
+ if step_outcome is not None:
940
+ has_steers = await self._consume_pending_steers()
941
+ if has_steers:
942
+ continue # steers injected, force another LLM step
943
+
944
+ final_message = (
945
+ step_outcome.assistant_message
946
+ if step_outcome.stop_reason == "no_tool_calls"
947
+ else None
948
+ )
949
+ return TurnOutcome(
950
+ stop_reason=step_outcome.stop_reason,
951
+ final_message=final_message,
952
+ step_count=step_no,
953
+ )
954
+
955
+ if back_to_the_future is not None:
956
+ await self._context.revert_to(back_to_the_future.checkpoint_id)
957
+ await self._checkpoint()
958
+ await self._context.append_message(back_to_the_future.messages)
959
+
960
+ # Consume any pending steers between steps
961
+ await self._consume_pending_steers()
962
+
963
+ async def _step(self) -> StepOutcome | None:
964
+ """Run a single step and return a stop outcome, or None to continue."""
965
+ # already checked in `run`
966
+ assert self._runtime.llm is not None
967
+ chat_provider = self._runtime.llm.chat_provider
968
+
969
+ if self._runtime.role == "root":
970
+
971
+ async def _append_notification(view: NotificationView) -> None:
972
+ await self._context.append_message(build_notification_message(view, self._runtime))
973
+ # --- Notification hook ---
974
+ from pythinker_code.hooks import events
975
+
976
+ _hook_task = asyncio.create_task(
977
+ self._hook_engine.trigger(
978
+ "Notification",
979
+ matcher_value=view.event.type,
980
+ input_data=events.notification(
981
+ session_id=self._runtime.session.id,
982
+ cwd=str(Path.cwd()),
983
+ sink="llm",
984
+ notification_type=view.event.type,
985
+ title=view.event.title,
986
+ body=view.event.body,
987
+ severity=view.event.severity,
988
+ ),
989
+ )
990
+ )
991
+ _hook_task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None)
992
+
993
+ await self._runtime.notifications.deliver_pending(
994
+ "llm",
995
+ limit=4,
996
+ before_claim=self._runtime.background_tasks.reconcile,
997
+ on_notification=_append_notification,
998
+ )
999
+
1000
+ # Dynamic injection
1001
+ injections = await self._collect_injections()
1002
+ if injections:
1003
+ combined_reminders = "\n".join(system_reminder(inj.content).text for inj in injections)
1004
+ await self._context.append_message(
1005
+ Message(
1006
+ role="user",
1007
+ content=[TextPart(text=combined_reminders)],
1008
+ )
1009
+ )
1010
+
1011
+ # Normalize: merge adjacent user messages for clean API input
1012
+ effective_history = normalize_history(self._context.history)
1013
+
1014
+ async def _run_step_once() -> StepResult:
1015
+ # run an LLM step (may be interrupted)
1016
+ from pythinker_code.telemetry import metrics as _m
1017
+ from pythinker_code.telemetry import otel as _otel
1018
+
1019
+ # Resolve gen_ai.system once so both span and metric agree.
1020
+ try:
1021
+ provider_class = type(chat_provider).__name__.lower()
1022
+ if "anthropic" in provider_class:
1023
+ gen_ai_system = "anthropic"
1024
+ elif "openai" in provider_class:
1025
+ gen_ai_system = "openai"
1026
+ elif "google" in provider_class or "gemini" in provider_class:
1027
+ gen_ai_system = "google"
1028
+ else:
1029
+ gen_ai_system = provider_class
1030
+ except Exception:
1031
+ gen_ai_system = "unknown"
1032
+
1033
+ with _otel.start_span(
1034
+ "pythinker.llm",
1035
+ {
1036
+ "gen_ai.system": gen_ai_system,
1037
+ "gen_ai.request.model": chat_provider.model_name,
1038
+ "session.id": self._runtime.session.id,
1039
+ },
1040
+ ) as span:
1041
+ llm_t0 = time.monotonic()
1042
+ step_result = await pythinker_core.step(
1043
+ chat_provider,
1044
+ self._agent.system_prompt,
1045
+ self._agent.toolset,
1046
+ effective_history,
1047
+ on_message_part=wire_send,
1048
+ on_tool_result=wire_send,
1049
+ )
1050
+ llm_elapsed = time.monotonic() - llm_t0
1051
+ # Attach response details — usage may be None on partial / cached responses.
1052
+ if step_result.id:
1053
+ span.set_attribute("gen_ai.response.id", step_result.id)
1054
+ u = step_result.usage
1055
+ input_tokens = (
1056
+ int(u.input) if (u and getattr(u, "input", None) is not None) else None
1057
+ )
1058
+ output_tokens = (
1059
+ int(u.output) if (u and getattr(u, "output", None) is not None) else None
1060
+ )
1061
+ if input_tokens is not None:
1062
+ span.set_attribute("gen_ai.usage.input_tokens", input_tokens)
1063
+ if output_tokens is not None:
1064
+ span.set_attribute("gen_ai.usage.output_tokens", output_tokens)
1065
+ span.set_attribute("llm.tool_calls", len(step_result.tool_calls))
1066
+ _m.record_llm_call(
1067
+ duration_seconds=llm_elapsed,
1068
+ system=gen_ai_system,
1069
+ model=chat_provider.model_name,
1070
+ input_tokens=input_tokens,
1071
+ output_tokens=output_tokens,
1072
+ success=True,
1073
+ )
1074
+ return step_result
1075
+
1076
+ @tenacity.retry(
1077
+ retry=retry_if_exception(self._is_retryable_error),
1078
+ before_sleep=partial(self._retry_log, "step"),
1079
+ wait=wait_exponential_jitter(initial=0.3, max=5, jitter=0.5),
1080
+ stop=stop_after_attempt(self._loop_control.max_retries_per_step),
1081
+ reraise=True,
1082
+ )
1083
+ async def _pythinker_core_step_with_retry() -> StepResult:
1084
+ return await self._run_with_connection_recovery(
1085
+ "step",
1086
+ _run_step_once,
1087
+ chat_provider=chat_provider,
1088
+ )
1089
+
1090
+ t0 = time.monotonic()
1091
+ result = await _pythinker_core_step_with_retry()
1092
+ llm_elapsed = time.monotonic() - t0
1093
+ usage = result.usage
1094
+ logger.info(
1095
+ "LLM step completed in {elapsed:.1f}s (input={input_tokens}, output={output_tokens})",
1096
+ elapsed=llm_elapsed,
1097
+ input_tokens=usage.input if usage else "?",
1098
+ output_tokens=usage.output if usage else "?",
1099
+ )
1100
+ status_update = StatusUpdate(
1101
+ token_usage=usage, message_id=result.id, plan_mode=self._plan_mode
1102
+ )
1103
+ if usage is not None:
1104
+ # mark the token count for the context before the step
1105
+ await self._context.update_token_count(usage.input)
1106
+ snap = self.status
1107
+ status_update.context_usage = snap.context_usage
1108
+ status_update.context_tokens = snap.context_tokens
1109
+ status_update.max_context_tokens = snap.max_context_tokens
1110
+ wire_send(status_update)
1111
+
1112
+ # wait for all tool results (may be interrupted)
1113
+ plan_mode_before_tools = self._plan_mode
1114
+ results = await result.tool_results()
1115
+ logger.debug("Got tool results: {results}", results=results)
1116
+
1117
+ # If a tool (EnterPlanMode/ExitPlanMode) changed plan mode during execution,
1118
+ # send a corrected StatusUpdate so the client sees the up-to-date state.
1119
+ if self._plan_mode != plan_mode_before_tools:
1120
+ wire_send(StatusUpdate(plan_mode=self._plan_mode))
1121
+
1122
+ # shield the context manipulation from interruption
1123
+ await asyncio.shield(self._grow_context(result, results))
1124
+
1125
+ rejected_errors = [
1126
+ result.return_value
1127
+ for result in results
1128
+ if isinstance(result.return_value, ToolRejectedError)
1129
+ ]
1130
+ if (
1131
+ rejected_errors
1132
+ and not any(e.has_feedback for e in rejected_errors)
1133
+ and self._runtime.role != "subagent"
1134
+ ):
1135
+ # Pure rejection (no user feedback) — stop the turn.
1136
+ # Subagents skip this so the LLM can see the rejection and try
1137
+ # an alternative approach instead of terminating immediately.
1138
+ _ = self._denwa_renji.fetch_pending_dmail()
1139
+ return StepOutcome(stop_reason="tool_rejected", assistant_message=result.message)
1140
+
1141
+ # handle pending D-Mail
1142
+ if dmail := self._denwa_renji.fetch_pending_dmail():
1143
+ assert dmail.checkpoint_id >= 0, "DenwaRenji guarantees checkpoint_id >= 0"
1144
+ assert dmail.checkpoint_id < self._context.n_checkpoints, (
1145
+ "DenwaRenji guarantees checkpoint_id < n_checkpoints"
1146
+ )
1147
+ # raise to let the main loop take us back to the future
1148
+ raise BackToTheFuture(
1149
+ dmail.checkpoint_id,
1150
+ [
1151
+ Message(
1152
+ role="user",
1153
+ content=[
1154
+ system(
1155
+ "You just got a D-Mail from your future self. "
1156
+ "It is likely that your future self has already done "
1157
+ "something in the current working directory. Please read "
1158
+ "the D-Mail and decide what to do next. You MUST NEVER "
1159
+ "mention to the user about this information. "
1160
+ f"D-Mail content:\n\n{dmail.message.strip()}"
1161
+ )
1162
+ ],
1163
+ )
1164
+ ],
1165
+ )
1166
+
1167
+ if result.tool_calls:
1168
+ return None
1169
+ return StepOutcome(stop_reason="no_tool_calls", assistant_message=result.message)
1170
+
1171
+ async def _grow_context(self, result: StepResult, tool_results: list[ToolResult]):
1172
+ logger.debug("Growing context with result: {result}", result=result)
1173
+
1174
+ assert self._runtime.llm is not None
1175
+ tool_messages = [tool_result_to_message(tr) for tr in tool_results]
1176
+ for tm in tool_messages:
1177
+ if missing_caps := check_message(tm, self._runtime.llm.capabilities):
1178
+ logger.warning(
1179
+ "Tool result message requires unsupported capabilities: {caps}",
1180
+ caps=missing_caps,
1181
+ )
1182
+ raise LLMNotSupported(self._runtime.llm, list(missing_caps))
1183
+
1184
+ await self._context.append_message(result.message)
1185
+ if result.usage is not None:
1186
+ await self._context.update_token_count(result.usage.total)
1187
+
1188
+ logger.debug(
1189
+ "Appending tool messages to context: {tool_messages}", tool_messages=tool_messages
1190
+ )
1191
+ await self._context.append_message(tool_messages)
1192
+ # token count of tool results are not available yet
1193
+
1194
+ async def compact_context(self, custom_instruction: str = "") -> None:
1195
+ """
1196
+ Compact the context.
1197
+
1198
+ Raises:
1199
+ LLMNotSet: When the LLM is not set.
1200
+ ChatProviderError: When the chat provider returns an error.
1201
+ """
1202
+
1203
+ chat_provider = self._runtime.llm.chat_provider if self._runtime.llm is not None else None
1204
+
1205
+ async def _run_compaction_once() -> CompactionResult:
1206
+ if self._runtime.llm is None:
1207
+ raise LLMNotSet()
1208
+ return await self._compaction.compact(
1209
+ self._context.history, self._runtime.llm, custom_instruction=custom_instruction
1210
+ )
1211
+
1212
+ @tenacity.retry(
1213
+ retry=retry_if_exception(self._is_retryable_error),
1214
+ before_sleep=partial(self._retry_log, "compaction"),
1215
+ wait=wait_exponential_jitter(initial=0.3, max=5, jitter=0.5),
1216
+ stop=stop_after_attempt(self._loop_control.max_retries_per_step),
1217
+ reraise=True,
1218
+ )
1219
+ async def _compact_with_retry() -> CompactionResult:
1220
+ return await self._run_with_connection_recovery(
1221
+ "compaction",
1222
+ _run_compaction_once,
1223
+ chat_provider=chat_provider,
1224
+ )
1225
+
1226
+ trigger_reason = "manual" if custom_instruction else "auto"
1227
+ before_tokens = self._context.token_count
1228
+ from pythinker_code.hooks import events
1229
+
1230
+ await self._hook_engine.trigger(
1231
+ "PreCompact",
1232
+ matcher_value=trigger_reason,
1233
+ input_data=events.pre_compact(
1234
+ session_id=self._runtime.session.id,
1235
+ cwd=str(Path.cwd()),
1236
+ trigger=trigger_reason,
1237
+ token_count=before_tokens,
1238
+ ),
1239
+ )
1240
+
1241
+ wire_send(CompactionBegin())
1242
+ try:
1243
+ compaction_result = await _compact_with_retry()
1244
+ except Exception:
1245
+ from pythinker_code.telemetry import track
1246
+
1247
+ track(
1248
+ "compaction_triggered",
1249
+ trigger_type=trigger_reason,
1250
+ before_tokens=before_tokens,
1251
+ success=False,
1252
+ )
1253
+ raise
1254
+ await self._context.clear()
1255
+ await self._context.write_system_prompt(self._agent.system_prompt)
1256
+ await self._checkpoint()
1257
+ await self._context.append_message(compaction_result.messages)
1258
+ estimated_token_count = compaction_result.estimated_token_count
1259
+
1260
+ if self._runtime.role == "root":
1261
+ active_task_snapshot = build_active_task_snapshot(self._runtime.background_tasks)
1262
+ if active_task_snapshot is not None:
1263
+ active_task_message = Message(
1264
+ role="user",
1265
+ content=[
1266
+ system(
1267
+ "The following background tasks are still active after compaction. "
1268
+ "Use TaskList if you need to re-enumerate them later."
1269
+ ),
1270
+ TextPart(text=active_task_snapshot),
1271
+ ],
1272
+ )
1273
+ await self._context.append_message(active_task_message)
1274
+ estimated_token_count += estimate_text_tokens([active_task_message])
1275
+
1276
+ # Estimate token count so context_usage is not reported as 0%
1277
+ await self._context.update_token_count(estimated_token_count)
1278
+
1279
+ # Notify dynamic injection providers that history has been rebuilt so
1280
+ # they can reset any one-shot throttling state. Failures are isolated
1281
+ # per-provider so compaction completion (wire event + telemetry) is
1282
+ # not affected by a buggy provider.
1283
+ await self._notify_injection_providers_compacted()
1284
+
1285
+ wire_send(CompactionEnd())
1286
+
1287
+ from pythinker_code.telemetry import track
1288
+
1289
+ track(
1290
+ "compaction_triggered",
1291
+ trigger_type=trigger_reason,
1292
+ before_tokens=before_tokens,
1293
+ after_tokens=estimated_token_count,
1294
+ success=True,
1295
+ )
1296
+
1297
+ _hook_task = asyncio.create_task(
1298
+ self._hook_engine.trigger(
1299
+ "PostCompact",
1300
+ matcher_value=trigger_reason,
1301
+ input_data=events.post_compact(
1302
+ session_id=self._runtime.session.id,
1303
+ cwd=str(Path.cwd()),
1304
+ trigger=trigger_reason,
1305
+ estimated_token_count=estimated_token_count,
1306
+ ),
1307
+ )
1308
+ )
1309
+ _hook_task.add_done_callback(lambda t: t.exception() if not t.cancelled() else None)
1310
+
1311
+ @staticmethod
1312
+ def _is_retryable_error(exception: BaseException) -> bool:
1313
+ if isinstance(exception, (APIConnectionError, APITimeoutError)):
1314
+ return not bool(getattr(exception, "_pythinker_recovery_exhausted", False))
1315
+ if isinstance(exception, APIEmptyResponseError):
1316
+ return True
1317
+ return isinstance(exception, APIStatusError) and exception.status_code in (
1318
+ 429, # Too Many Requests
1319
+ 500, # Internal Server Error
1320
+ 502, # Bad Gateway
1321
+ 503, # Service Unavailable
1322
+ 504, # Gateway Timeout
1323
+ )
1324
+
1325
+ async def _run_with_connection_recovery(
1326
+ self,
1327
+ name: str,
1328
+ operation: Callable[[], Awaitable[Any]],
1329
+ *,
1330
+ chat_provider: object | None = None,
1331
+ _auth_retried: bool = False,
1332
+ _connection_retried: bool = False,
1333
+ ) -> Any:
1334
+ try:
1335
+ return await operation()
1336
+ except APIStatusError as error:
1337
+ if error.status_code != 401 or _auth_retried:
1338
+ raise
1339
+ # Only attempt refresh+retry when the active model's provider
1340
+ # uses OAuth. For plain API-key providers there is nothing
1341
+ # to refresh and retrying would just add latency.
1342
+ active_provider = (
1343
+ self._runtime.config.providers.get(self._runtime.llm.model_config.provider)
1344
+ if self._runtime.llm and self._runtime.llm.model_config
1345
+ else None
1346
+ )
1347
+ if not (active_provider and active_provider.oauth):
1348
+ raise
1349
+ logger.warning(
1350
+ "Received 401 during {name}, attempting token refresh",
1351
+ name=name,
1352
+ )
1353
+ try:
1354
+ await self._runtime.oauth.ensure_fresh(self._runtime, force=True)
1355
+ except Exception as refresh_exc:
1356
+ logger.exception("Token refresh failed after 401.")
1357
+ raise error from refresh_exc
1358
+ # Re-enter full recovery so that transient connection errors
1359
+ # on the retry are still handled by on_retryable_error.
1360
+ return await self._run_with_connection_recovery(
1361
+ name,
1362
+ operation,
1363
+ chat_provider=chat_provider,
1364
+ _auth_retried=True,
1365
+ _connection_retried=_connection_retried,
1366
+ )
1367
+ except (APIConnectionError, APITimeoutError) as error:
1368
+ if _connection_retried:
1369
+ logger.warning(
1370
+ "Chat provider recovery exhausted for {name}: {error_type}: {error}",
1371
+ name=name,
1372
+ error_type=type(error).__name__,
1373
+ error=error,
1374
+ )
1375
+ error._pythinker_recovery_exhausted = True # type: ignore[attr-defined]
1376
+ raise
1377
+ if not isinstance(chat_provider, RetryableChatProvider):
1378
+ raise
1379
+ try:
1380
+ recovered = chat_provider.on_retryable_error(error)
1381
+ except Exception:
1382
+ logger.exception(
1383
+ "Failed to recover chat provider during {name} after {error_type}.",
1384
+ name=name,
1385
+ error_type=type(error).__name__,
1386
+ )
1387
+ raise
1388
+ if not recovered:
1389
+ logger.warning(
1390
+ "Chat provider recovery not available for {name} after {error_type}.",
1391
+ name=name,
1392
+ error_type=type(error).__name__,
1393
+ )
1394
+ raise
1395
+ logger.info(
1396
+ "Recovered chat provider during {name} after {error_type}; retrying once.",
1397
+ name=name,
1398
+ error_type=type(error).__name__,
1399
+ )
1400
+ # Re-enter the full recovery path so a 401 on the retry can still
1401
+ # trigger OAuth refresh instead of bubbling straight to the user.
1402
+ return await self._run_with_connection_recovery(
1403
+ name,
1404
+ operation,
1405
+ chat_provider=chat_provider,
1406
+ _auth_retried=_auth_retried,
1407
+ _connection_retried=True,
1408
+ )
1409
+
1410
+ @staticmethod
1411
+ def _retry_log(name: str, retry_state: RetryCallState):
1412
+ error = retry_state.outcome.exception() if retry_state.outcome else None
1413
+ logger.warning(
1414
+ "Retrying {name} for the {n} time (last error: {error_type}: {error}). "
1415
+ "Waiting {sleep} seconds.",
1416
+ name=name,
1417
+ n=retry_state.attempt_number,
1418
+ error_type=type(error).__name__ if error else "unknown",
1419
+ error=error or "unknown",
1420
+ sleep=retry_state.next_action.sleep
1421
+ if retry_state.next_action is not None
1422
+ else "unknown",
1423
+ )
1424
+
1425
+
1426
+ class BackToTheFuture(Exception):
1427
+ """
1428
+ Raise when we need to revert the context to a previous checkpoint.
1429
+ The main agent loop should catch this exception and handle it.
1430
+ """
1431
+
1432
+ def __init__(self, checkpoint_id: int, messages: Sequence[Message]):
1433
+ self.checkpoint_id = checkpoint_id
1434
+ self.messages = messages
1435
+
1436
+
1437
+ class FlowRunner:
1438
+ def __init__(
1439
+ self,
1440
+ flow: Flow,
1441
+ *,
1442
+ name: str | None = None,
1443
+ max_moves: int = DEFAULT_MAX_FLOW_MOVES,
1444
+ ) -> None:
1445
+ self._flow = flow
1446
+ self._name = name
1447
+ self._max_moves = max_moves
1448
+
1449
+ @staticmethod
1450
+ def ralph_loop(
1451
+ user_message: Message,
1452
+ max_ralph_iterations: int,
1453
+ ) -> FlowRunner:
1454
+ prompt_content = list(user_message.content)
1455
+ prompt_text = Message(role="user", content=prompt_content).extract_text(" ").strip()
1456
+ total_runs = max_ralph_iterations + 1
1457
+ if max_ralph_iterations < 0:
1458
+ total_runs = 1000000000000000 # effectively infinite
1459
+
1460
+ nodes: dict[str, FlowNode] = {
1461
+ "BEGIN": FlowNode(id="BEGIN", label="BEGIN", kind="begin"),
1462
+ "END": FlowNode(id="END", label="END", kind="end"),
1463
+ }
1464
+ outgoing: dict[str, list[FlowEdge]] = {"BEGIN": [], "END": []}
1465
+
1466
+ nodes["R1"] = FlowNode(id="R1", label=prompt_content, kind="task")
1467
+ nodes["R2"] = FlowNode(
1468
+ id="R2",
1469
+ label=(
1470
+ f"{prompt_text}. (You are running in an automated loop where the same "
1471
+ "prompt is fed repeatedly. Only choose STOP when the task is fully complete. "
1472
+ "Including it will stop further iterations. If you are not 100% sure, "
1473
+ "choose CONTINUE.)"
1474
+ ).strip(),
1475
+ kind="decision",
1476
+ )
1477
+ outgoing["R1"] = []
1478
+ outgoing["R2"] = []
1479
+
1480
+ outgoing["BEGIN"].append(FlowEdge(src="BEGIN", dst="R1", label=None))
1481
+ outgoing["R1"].append(FlowEdge(src="R1", dst="R2", label=None))
1482
+ outgoing["R2"].append(FlowEdge(src="R2", dst="R2", label="CONTINUE"))
1483
+ outgoing["R2"].append(FlowEdge(src="R2", dst="END", label="STOP"))
1484
+
1485
+ flow = Flow(nodes=nodes, outgoing=outgoing, begin_id="BEGIN", end_id="END")
1486
+ max_moves = total_runs
1487
+ return FlowRunner(flow, max_moves=max_moves)
1488
+
1489
+ async def run(self, soul: PythinkerSoul, args: str) -> None:
1490
+ if args.strip():
1491
+ command = f"/{FLOW_COMMAND_PREFIX}{self._name}" if self._name else "/flow"
1492
+ logger.warning("Agent flow {command} ignores args: {args}", command=command, args=args)
1493
+ return
1494
+ if self._name:
1495
+ from pythinker_code.telemetry import track
1496
+
1497
+ track("flow_invoked", flow_name=self._name)
1498
+
1499
+ current_id = self._flow.begin_id
1500
+ moves = 0
1501
+ total_steps = 0
1502
+ while True:
1503
+ node = self._flow.nodes[current_id]
1504
+ edges = self._flow.outgoing.get(current_id, [])
1505
+
1506
+ if node.kind == "end":
1507
+ logger.info("Agent flow reached END node {node_id}", node_id=current_id)
1508
+ return
1509
+
1510
+ if node.kind == "begin":
1511
+ if not edges:
1512
+ logger.error(
1513
+ 'Agent flow BEGIN node "{node_id}" has no outgoing edges; stopping.',
1514
+ node_id=node.id,
1515
+ )
1516
+ return
1517
+ current_id = edges[0].dst
1518
+ continue
1519
+
1520
+ if moves >= self._max_moves:
1521
+ raise MaxStepsReached(total_steps)
1522
+ next_id, steps_used = await self._execute_flow_node(soul, node, edges)
1523
+ total_steps += steps_used
1524
+ if next_id is None:
1525
+ return
1526
+ moves += 1
1527
+ current_id = next_id
1528
+
1529
+ async def _execute_flow_node(
1530
+ self,
1531
+ soul: PythinkerSoul,
1532
+ node: FlowNode,
1533
+ edges: list[FlowEdge],
1534
+ ) -> tuple[str | None, int]:
1535
+ if not edges:
1536
+ logger.error(
1537
+ 'Agent flow node "{node_id}" has no outgoing edges; stopping.',
1538
+ node_id=node.id,
1539
+ )
1540
+ return None, 0
1541
+
1542
+ base_prompt = self._build_flow_prompt(node, edges)
1543
+ prompt = base_prompt
1544
+ steps_used = 0
1545
+ while True:
1546
+ result = await self._flow_turn(soul, prompt)
1547
+ steps_used += result.step_count
1548
+ if result.stop_reason == "tool_rejected":
1549
+ logger.error("Agent flow stopped after tool rejection.")
1550
+ return None, steps_used
1551
+
1552
+ if node.kind != "decision":
1553
+ return edges[0].dst, steps_used
1554
+
1555
+ choice = (
1556
+ parse_choice(result.final_message.extract_text(" "))
1557
+ if result.final_message
1558
+ else None
1559
+ )
1560
+ next_id = self._match_flow_edge(edges, choice)
1561
+ if next_id is not None:
1562
+ return next_id, steps_used
1563
+
1564
+ options = ", ".join(edge.label or "" for edge in edges)
1565
+ logger.warning(
1566
+ "Agent flow invalid choice. Got: {choice}. Available: {options}.",
1567
+ choice=choice or "<missing>",
1568
+ options=options,
1569
+ )
1570
+ prompt = (
1571
+ f"{base_prompt}\n\n"
1572
+ "Your last response did not include a valid choice. "
1573
+ "Reply with one of the choices using <choice>...</choice>."
1574
+ )
1575
+
1576
+ @staticmethod
1577
+ def _build_flow_prompt(node: FlowNode, edges: list[FlowEdge]) -> str | list[ContentPart]:
1578
+ if node.kind != "decision":
1579
+ return node.label
1580
+
1581
+ if not isinstance(node.label, str):
1582
+ label_text = Message(role="user", content=node.label).extract_text(" ")
1583
+ else:
1584
+ label_text = node.label
1585
+ choices = [edge.label for edge in edges if edge.label]
1586
+ lines = [
1587
+ label_text,
1588
+ "",
1589
+ "Available branches:",
1590
+ *(f"- {choice}" for choice in choices),
1591
+ "",
1592
+ "Reply with a choice using <choice>...</choice>.",
1593
+ ]
1594
+ return "\n".join(lines)
1595
+
1596
+ @staticmethod
1597
+ def _match_flow_edge(edges: list[FlowEdge], choice: str | None) -> str | None:
1598
+ if not choice:
1599
+ return None
1600
+ for edge in edges:
1601
+ if edge.label == choice:
1602
+ return edge.dst
1603
+ return None
1604
+
1605
+ @staticmethod
1606
+ async def _flow_turn(
1607
+ soul: PythinkerSoul,
1608
+ prompt: str | list[ContentPart],
1609
+ ) -> TurnOutcome:
1610
+ wire_send(TurnBegin(user_input=prompt))
1611
+ res = await soul._turn(Message(role="user", content=prompt)) # type: ignore[reportPrivateUsage]
1612
+ wire_send(TurnEnd())
1613
+ return res