@scemoon/cdh 1.0.5 → 1.0.7

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 (551) hide show
  1. package/package.json +2 -2
  2. package/run.js +68 -14
  3. package/ai-dlc-skill/SKILL.md +0 -77
  4. package/ai-dlc-skill/agents/deliver-agent.md +0 -24
  5. package/ai-dlc-skill/agents/master.md +0 -50
  6. package/ai-dlc-skill/agents/plan-agent.md +0 -23
  7. package/ai-dlc-skill/agents/understand-agent.md +0 -23
  8. package/ai-dlc-skill/agents/verify-agent.md +0 -27
  9. package/ai-dlc-skill/architecture/project-structure.md +0 -78
  10. package/ai-dlc-skill/brownfield/README.md +0 -30
  11. package/ai-dlc-skill/brownfield/generate-context.sh +0 -64
  12. package/ai-dlc-skill/brownfield/scripts/deps.sh +0 -27
  13. package/ai-dlc-skill/brownfield/scripts/discover.sh +0 -34
  14. package/ai-dlc-skill/brownfield/scripts/extract-api.sh +0 -45
  15. package/ai-dlc-skill/components/backend.md +0 -34
  16. package/ai-dlc-skill/components/desktop.md +0 -30
  17. package/ai-dlc-skill/components/mya.md +0 -29
  18. package/ai-dlc-skill/components/native.md +0 -30
  19. package/ai-dlc-skill/components/tta.md +0 -29
  20. package/ai-dlc-skill/components/web.md +0 -31
  21. package/ai-dlc-skill/components/wxa.md +0 -29
  22. package/ai-dlc-skill/contracts/README.md +0 -25
  23. package/ai-dlc-skill/core/adaptive-flow.md +0 -36
  24. package/ai-dlc-skill/core/security.md +0 -29
  25. package/ai-dlc-skill/cross-tool/cline/export.sh +0 -31
  26. package/ai-dlc-skill/cross-tool/copilot/export.sh +0 -47
  27. package/ai-dlc-skill/cross-tool/cursor/export.sh +0 -32
  28. package/ai-dlc-skill/cross-tool/export.sh +0 -22
  29. package/ai-dlc-skill/cross-tool/onecode/config.yaml.tpl +0 -17
  30. package/ai-dlc-skill/cross-tool/onecode/export-skills.sh +0 -35
  31. package/ai-dlc-skill/cross-tool/onecode/export.sh +0 -54
  32. package/ai-dlc-skill/phases/deliver/entry.md +0 -26
  33. package/ai-dlc-skill/phases/deliver/lifecycle.md +0 -185
  34. package/ai-dlc-skill/phases/deliver/prompt.md +0 -40
  35. package/ai-dlc-skill/phases/deliver/rules.md +0 -61
  36. package/ai-dlc-skill/phases/plan/entry.md +0 -25
  37. package/ai-dlc-skill/phases/plan/lifecycle.md +0 -204
  38. package/ai-dlc-skill/phases/plan/prompt.md +0 -38
  39. package/ai-dlc-skill/phases/plan/rules.md +0 -25
  40. package/ai-dlc-skill/phases/understand/entry.md +0 -26
  41. package/ai-dlc-skill/phases/understand/lifecycle.md +0 -184
  42. package/ai-dlc-skill/phases/understand/prompt.md +0 -41
  43. package/ai-dlc-skill/phases/understand/rules.md +0 -37
  44. package/ai-dlc-skill/phases/verify/entry.md +0 -26
  45. package/ai-dlc-skill/phases/verify/lifecycle.md +0 -192
  46. package/ai-dlc-skill/phases/verify/prompt.md +0 -43
  47. package/ai-dlc-skill/phases/verify/rules.md +0 -73
  48. package/ai-dlc-skill/practices/bdd.md +0 -91
  49. package/ai-dlc-skill/practices/sdd.md +0 -84
  50. package/ai-dlc-skill/practices/tdd.md +0 -95
  51. package/ai-dlc-skill/providers/README.md +0 -114
  52. package/ai-dlc-skill/providers/aliyun/deployment.yaml +0 -66
  53. package/ai-dlc-skill/providers/aliyun/preview.yaml +0 -84
  54. package/ai-dlc-skill/providers/aliyun/provider.yaml +0 -160
  55. package/ai-dlc-skill/providers/tcb/deployment.yaml +0 -69
  56. package/ai-dlc-skill/providers/tcb/preview.yaml +0 -83
  57. package/ai-dlc-skill/providers/tcb/provider.yaml +0 -172
  58. package/ai-dlc-skill/requirements.md +0 -140
  59. package/ai-dlc-skill/skill.yaml +0 -147
  60. package/ai-dlc-skill/templates/integration/CHANGELOG.md +0 -41
  61. package/ai-dlc-skill/templates/integration/asyncapi.yaml +0 -43
  62. package/ai-dlc-skill/templates/integration/contract-diff.md +0 -63
  63. package/ai-dlc-skill/templates/integration/contract-spec.md +0 -49
  64. package/ai-dlc-skill/templates/integration/openapi.yaml +0 -18
  65. package/ai-dlc-skill/templates/integration/runtime-contract.yaml +0 -36
  66. package/ai-dlc-skill/templates/plan/design.md +0 -49
  67. package/ai-dlc-skill/templates/plan/task-list.md +0 -32
  68. package/ai-dlc-skill/templates/project/README.md +0 -126
  69. package/ai-dlc-skill/templates/project/template.md +0 -119
  70. package/ai-dlc-skill/templates/understand/feature.feature +0 -23
  71. package/ai-dlc-skill/templates/understand/intent.md +0 -29
  72. package/ai-dlc-skill/templates/understand/spec-delta.md +0 -47
  73. package/ai-dlc-skill/templates/verify/test-unit.py +0 -27
  74. package/ai-dlc-skill/walkthrough/collect.sh +0 -50
  75. package/ai-dlc-skill/walkthrough/template.md +0 -54
  76. package/ai-dlc-skill/workflows/ai-dlc.yaml +0 -110
  77. package/ai-dlc-skill/workflows/deployment.yaml +0 -199
  78. package/cdh/__init__.py +0 -0
  79. package/cdh/__main__.py +0 -4
  80. package/cdh/__pycache__/__init__.cpython-314.pyc +0 -0
  81. package/cdh/__pycache__/cdh_mcp_injector.cpython-314.pyc +0 -0
  82. package/cdh/__pycache__/cdh_mcp_loader.cpython-314.pyc +0 -0
  83. package/cdh/__pycache__/cdh_mcp_manager.cpython-314.pyc +0 -0
  84. package/cdh/__pycache__/cdh_session_aggregator.cpython-314.pyc +0 -0
  85. package/cdh/__pycache__/cdh_skill_loader.cpython-314.pyc +0 -0
  86. package/cdh/__pycache__/cdh_skill_manager.cpython-314.pyc +0 -0
  87. package/cdh/__pycache__/cli.cpython-313.pyc +0 -0
  88. package/cdh/__pycache__/cli.cpython-314.pyc +0 -0
  89. package/cdh/__pycache__/scaffold.cpython-314.pyc +0 -0
  90. package/cdh/cdh_mcp_injector.py +0 -115
  91. package/cdh/cdh_mcp_loader.py +0 -67
  92. package/cdh/cdh_mcp_manager.py +0 -73
  93. package/cdh/cdh_session_aggregator.py +0 -258
  94. package/cdh/cdh_skill_manager.py +0 -105
  95. package/cdh/cli.py +0 -758
  96. package/cdh/scaffold.py +0 -728
  97. package/onecode/__init__.py +0 -1
  98. package/onecode/__main__.py +0 -4
  99. package/onecode/__pycache__/__init__.cpython-314.pyc +0 -0
  100. package/onecode/__pycache__/cli.cpython-314.pyc +0 -0
  101. package/onecode/__pycache__/commands.cpython-314.pyc +0 -0
  102. package/onecode/__pycache__/config.cpython-314.pyc +0 -0
  103. package/onecode/__pycache__/config_screen.cpython-314.pyc +0 -0
  104. package/onecode/__pycache__/migrate.cpython-314.pyc +0 -0
  105. package/onecode/agent/__init__.py +0 -0
  106. package/onecode/agent/__pycache__/__init__.cpython-314.pyc +0 -0
  107. package/onecode/agent/__pycache__/cdh_loader.cpython-314.pyc +0 -0
  108. package/onecode/agent/__pycache__/context.cpython-314.pyc +0 -0
  109. package/onecode/agent/__pycache__/engine.cpython-314.pyc +0 -0
  110. package/onecode/agent/__pycache__/hooks.cpython-314.pyc +0 -0
  111. package/onecode/agent/__pycache__/onecode_agent_acp.cpython-314.pyc +0 -0
  112. package/onecode/agent/__pycache__/permissions.cpython-314.pyc +0 -0
  113. package/onecode/agent/__pycache__/permissions_store.cpython-314.pyc +0 -0
  114. package/onecode/agent/__pycache__/project_doc.cpython-314.pyc +0 -0
  115. package/onecode/agent/__pycache__/session.cpython-314.pyc +0 -0
  116. package/onecode/agent/agents/__init__.py +0 -29
  117. package/onecode/agent/agents/__pycache__/__init__.cpython-314.pyc +0 -0
  118. package/onecode/agent/agents/__pycache__/types.cpython-314.pyc +0 -0
  119. package/onecode/agent/agents/types.py +0 -589
  120. package/onecode/agent/attachments.py +0 -112
  121. package/onecode/agent/cdh_loader.py +0 -267
  122. package/onecode/agent/context.py +0 -343
  123. package/onecode/agent/engine.py +0 -2700
  124. package/onecode/agent/hooks.py +0 -86
  125. package/onecode/agent/onecode_agent_acp.py +0 -2743
  126. package/onecode/agent/onecode_agent_acp.py.bak +0 -2447
  127. package/onecode/agent/permissions.py +0 -150
  128. package/onecode/agent/permissions_store.py +0 -62
  129. package/onecode/agent/project_doc.py +0 -19
  130. package/onecode/agent/session.py +0 -195
  131. package/onecode/agent/snapshot.py +0 -144
  132. package/onecode/agent/tools/__init__.py +0 -17
  133. package/onecode/agent/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  134. package/onecode/agent/tools/__pycache__/agent_tools.cpython-314.pyc +0 -0
  135. package/onecode/agent/tools/__pycache__/apply_patch_tool.cpython-314.pyc +0 -0
  136. package/onecode/agent/tools/__pycache__/bash_tool.cpython-314.pyc +0 -0
  137. package/onecode/agent/tools/__pycache__/communication_tools.cpython-314.pyc +0 -0
  138. package/onecode/agent/tools/__pycache__/config_tool.cpython-314.pyc +0 -0
  139. package/onecode/agent/tools/__pycache__/cron_tools.cpython-314.pyc +0 -0
  140. package/onecode/agent/tools/__pycache__/errors.cpython-314.pyc +0 -0
  141. package/onecode/agent/tools/__pycache__/file_ops.cpython-314.pyc +0 -0
  142. package/onecode/agent/tools/__pycache__/file_tools.cpython-314.pyc +0 -0
  143. package/onecode/agent/tools/__pycache__/git_tools.cpython-314.pyc +0 -0
  144. package/onecode/agent/tools/__pycache__/lsp_tools.cpython-314.pyc +0 -0
  145. package/onecode/agent/tools/__pycache__/mcp_tools.cpython-314.pyc +0 -0
  146. package/onecode/agent/tools/__pycache__/permission_handler.cpython-314.pyc +0 -0
  147. package/onecode/agent/tools/__pycache__/permissions.cpython-314.pyc +0 -0
  148. package/onecode/agent/tools/__pycache__/protocol.cpython-314.pyc +0 -0
  149. package/onecode/agent/tools/__pycache__/registry.cpython-314.pyc +0 -0
  150. package/onecode/agent/tools/__pycache__/sandbox.cpython-314.pyc +0 -0
  151. package/onecode/agent/tools/__pycache__/schema_validation.cpython-314.pyc +0 -0
  152. package/onecode/agent/tools/__pycache__/skill_tools.cpython-314.pyc +0 -0
  153. package/onecode/agent/tools/__pycache__/todo_tools.cpython-314.pyc +0 -0
  154. package/onecode/agent/tools/__pycache__/web_tools.cpython-314.pyc +0 -0
  155. package/onecode/agent/tools/agent_tools.py +0 -132
  156. package/onecode/agent/tools/apply_patch_tool.py +0 -138
  157. package/onecode/agent/tools/bash_tool.py +0 -111
  158. package/onecode/agent/tools/communication_tools.py +0 -148
  159. package/onecode/agent/tools/config_tool.py +0 -130
  160. package/onecode/agent/tools/cron_tools.py +0 -178
  161. package/onecode/agent/tools/errors.py +0 -17
  162. package/onecode/agent/tools/file_ops.py +0 -221
  163. package/onecode/agent/tools/file_tools.py +0 -225
  164. package/onecode/agent/tools/git_tools.py +0 -88
  165. package/onecode/agent/tools/lsp_tools.py +0 -404
  166. package/onecode/agent/tools/mcp_tools.py +0 -127
  167. package/onecode/agent/tools/permission_handler.py +0 -46
  168. package/onecode/agent/tools/permissions.py +0 -77
  169. package/onecode/agent/tools/protocol.py +0 -20
  170. package/onecode/agent/tools/registry.py +0 -201
  171. package/onecode/agent/tools/sandbox.py +0 -358
  172. package/onecode/agent/tools/schema_validation.py +0 -100
  173. package/onecode/agent/tools/skill_tools.py +0 -52
  174. package/onecode/agent/tools/todo_tools.py +0 -207
  175. package/onecode/agent/tools/web_tools.py +0 -169
  176. package/onecode/builtin_skills/agent-browser/SKILL.md +0 -212
  177. package/onecode/builtin_skills/agent-browser/skill.yaml +0 -8
  178. package/onecode/builtin_skills/git/SKILL.md +0 -64
  179. package/onecode/builtin_skills/git/skill.yaml +0 -7
  180. package/onecode/builtin_skills/shell/SKILL.md +0 -58
  181. package/onecode/builtin_skills/shell/skill.yaml +0 -8
  182. package/onecode/builtin_skills/skill-creator/SKILL.md +0 -106
  183. package/onecode/builtin_skills/skill-creator/skill.yaml +0 -8
  184. package/onecode/cli.py +0 -935
  185. package/onecode/cloud/__init__.py +0 -0
  186. package/onecode/cloud/base.py +0 -20
  187. package/onecode/codebase/__init__.py +0 -69
  188. package/onecode/codebase/__pycache__/__init__.cpython-314.pyc +0 -0
  189. package/onecode/codebase/__pycache__/chunker.cpython-314.pyc +0 -0
  190. package/onecode/codebase/__pycache__/indexer.cpython-314.pyc +0 -0
  191. package/onecode/codebase/__pycache__/retriever.cpython-314.pyc +0 -0
  192. package/onecode/codebase/__pycache__/storage.cpython-314.pyc +0 -0
  193. package/onecode/codebase/__pycache__/tools.cpython-314.pyc +0 -0
  194. package/onecode/codebase/chunker.py +0 -125
  195. package/onecode/codebase/indexer.py +0 -149
  196. package/onecode/codebase/retriever.py +0 -203
  197. package/onecode/codebase/storage.py +0 -131
  198. package/onecode/codebase/tools.py +0 -59
  199. package/onecode/commands.py +0 -596
  200. package/onecode/config.py +0 -314
  201. package/onecode/config_screen.py +0 -600
  202. package/onecode/cron/__init__.py +0 -3
  203. package/onecode/lsp/__init__.py +0 -3
  204. package/onecode/mcp/__init__.py +0 -4
  205. package/onecode/mcp/__pycache__/__init__.cpython-314.pyc +0 -0
  206. package/onecode/mcp/__pycache__/client.cpython-314.pyc +0 -0
  207. package/onecode/mcp/__pycache__/manager.cpython-314.pyc +0 -0
  208. package/onecode/mcp/client.py +0 -193
  209. package/onecode/mcp/manager.py +0 -190
  210. package/onecode/memory/__init__.py +0 -49
  211. package/onecode/memory/__pycache__/__init__.cpython-314.pyc +0 -0
  212. package/onecode/memory/__pycache__/backend.cpython-314.pyc +0 -0
  213. package/onecode/memory/__pycache__/offload.cpython-314.pyc +0 -0
  214. package/onecode/memory/__pycache__/pyramid.cpython-314.pyc +0 -0
  215. package/onecode/memory/__pycache__/recall.cpython-314.pyc +0 -0
  216. package/onecode/memory/__pycache__/symbolic.cpython-314.pyc +0 -0
  217. package/onecode/memory/backend.py +0 -159
  218. package/onecode/memory/pyramid.py +0 -133
  219. package/onecode/memory/recall.py +0 -203
  220. package/onecode/migrate.py +0 -122
  221. package/onecode/models/__init__.py +0 -4
  222. package/onecode/models/__pycache__/__init__.cpython-314.pyc +0 -0
  223. package/onecode/models/__pycache__/errors.cpython-314.pyc +0 -0
  224. package/onecode/models/__pycache__/messages.cpython-314.pyc +0 -0
  225. package/onecode/models/__pycache__/provider.cpython-314.pyc +0 -0
  226. package/onecode/models/__pycache__/registry.cpython-314.pyc +0 -0
  227. package/onecode/models/errors.py +0 -104
  228. package/onecode/models/messages.py +0 -739
  229. package/onecode/models/provider.py +0 -699
  230. package/onecode/models/providers/__init__.py +0 -17
  231. package/onecode/models/providers/__pycache__/__init__.cpython-314.pyc +0 -0
  232. package/onecode/models/providers/__pycache__/anthropic_provider.cpython-314.pyc +0 -0
  233. package/onecode/models/providers/__pycache__/deepseek_provider.cpython-314.pyc +0 -0
  234. package/onecode/models/providers/__pycache__/glm_provider.cpython-314.pyc +0 -0
  235. package/onecode/models/providers/__pycache__/minimax_provider.cpython-314.pyc +0 -0
  236. package/onecode/models/providers/__pycache__/minimaxi_provider.cpython-314.pyc +0 -0
  237. package/onecode/models/providers/__pycache__/ollama_provider.cpython-314.pyc +0 -0
  238. package/onecode/models/providers/__pycache__/openai_provider.cpython-314.pyc +0 -0
  239. package/onecode/models/providers/anthropic_provider.py +0 -273
  240. package/onecode/models/providers/deepseek_provider.py +0 -212
  241. package/onecode/models/providers/glm_provider.py +0 -195
  242. package/onecode/models/providers/minimax_provider.py +0 -158
  243. package/onecode/models/providers/minimaxi_provider.py +0 -417
  244. package/onecode/models/providers/ollama_provider.py +0 -100
  245. package/onecode/models/providers/openai_provider.py +0 -237
  246. package/onecode/models/registry.py +0 -212
  247. package/onecode/server/__init__.py +0 -3
  248. package/onecode/server/app.py +0 -224
  249. package/onecode/skills/__init__.py +0 -11
  250. package/onecode/skills/__pycache__/__init__.cpython-314.pyc +0 -0
  251. package/onecode/skills/__pycache__/argument_substitution.cpython-314.pyc +0 -0
  252. package/onecode/skills/__pycache__/bootstrap.cpython-314.pyc +0 -0
  253. package/onecode/skills/__pycache__/create.cpython-314.pyc +0 -0
  254. package/onecode/skills/__pycache__/frontmatter.cpython-314.pyc +0 -0
  255. package/onecode/skills/__pycache__/loader.cpython-314.pyc +0 -0
  256. package/onecode/skills/__pycache__/manager.cpython-314.pyc +0 -0
  257. package/onecode/skills/__pycache__/model.cpython-314.pyc +0 -0
  258. package/onecode/skills/argument_substitution.py +0 -38
  259. package/onecode/skills/bootstrap.py +0 -124
  260. package/onecode/skills/create.py +0 -56
  261. package/onecode/skills/frontmatter.py +0 -33
  262. package/onecode/skills/loader.py +0 -139
  263. package/onecode/skills/manager.py +0 -75
  264. package/onecode/skills/model.py +0 -43
  265. package/onecode/storage/__init__.py +0 -0
  266. package/onecode/storage/activity.py +0 -63
  267. package/onecode/storage/project.py +0 -54
  268. package/onecode/storage/session.py +0 -112
  269. package/onecode/tasks/__init__.py +0 -26
  270. package/onecode/tasks/manager.py +0 -232
  271. package/onecode/tasks/models.py +0 -240
  272. package/onecode/trace/__init__.py +0 -0
  273. package/onecode/trace/tracer.py +0 -173
  274. package/onecode/utils/__init__.py +0 -0
  275. package/onecode/utils/helpers.py +0 -20
  276. package/pyproject.toml +0 -74
  277. package/tui/__init__.py +0 -46
  278. package/tui/__main__.py +0 -4
  279. package/tui/__pycache__/__init__.cpython-314.pyc +0 -0
  280. package/tui/__pycache__/_loop.cpython-314.pyc +0 -0
  281. package/tui/__pycache__/_path_fuzzy_search.cpython-314.pyc +0 -0
  282. package/tui/__pycache__/_path_match.cpython-314.pyc +0 -0
  283. package/tui/__pycache__/about.cpython-314.pyc +0 -0
  284. package/tui/__pycache__/agent.cpython-314.pyc +0 -0
  285. package/tui/__pycache__/agent_schema.cpython-314.pyc +0 -0
  286. package/tui/__pycache__/agents.cpython-314.pyc +0 -0
  287. package/tui/__pycache__/answer.cpython-314.pyc +0 -0
  288. package/tui/__pycache__/app.cpython-313.pyc +0 -0
  289. package/tui/__pycache__/app.cpython-314.pyc +0 -0
  290. package/tui/__pycache__/atomic.cpython-314.pyc +0 -0
  291. package/tui/__pycache__/complete.cpython-314.pyc +0 -0
  292. package/tui/__pycache__/constants.cpython-314.pyc +0 -0
  293. package/tui/__pycache__/conversation_markdown.cpython-314.pyc +0 -0
  294. package/tui/__pycache__/danger.cpython-314.pyc +0 -0
  295. package/tui/__pycache__/db.cpython-314.pyc +0 -0
  296. package/tui/__pycache__/dec.cpython-314.pyc +0 -0
  297. package/tui/__pycache__/directory.cpython-314.pyc +0 -0
  298. package/tui/__pycache__/directory_suggester.cpython-314.pyc +0 -0
  299. package/tui/__pycache__/directory_watcher.cpython-314.pyc +0 -0
  300. package/tui/__pycache__/format_path.cpython-314.pyc +0 -0
  301. package/tui/__pycache__/fuzzy.cpython-314.pyc +0 -0
  302. package/tui/__pycache__/fuzzy_index.cpython-314.pyc +0 -0
  303. package/tui/__pycache__/history.cpython-314.pyc +0 -0
  304. package/tui/__pycache__/jsonrpc.cpython-314.pyc +0 -0
  305. package/tui/__pycache__/menus.cpython-314.pyc +0 -0
  306. package/tui/__pycache__/message_log.cpython-314.pyc +0 -0
  307. package/tui/__pycache__/messages.cpython-314.pyc +0 -0
  308. package/tui/__pycache__/path_complete.cpython-314.pyc +0 -0
  309. package/tui/__pycache__/path_filter.cpython-314.pyc +0 -0
  310. package/tui/__pycache__/paths.cpython-314.pyc +0 -0
  311. package/tui/__pycache__/pill.cpython-314.pyc +0 -0
  312. package/tui/__pycache__/platform_commands.cpython-314.pyc +0 -0
  313. package/tui/__pycache__/protocol.cpython-314.pyc +0 -0
  314. package/tui/__pycache__/session_tracker.cpython-314.pyc +0 -0
  315. package/tui/__pycache__/settings.cpython-314.pyc +0 -0
  316. package/tui/__pycache__/settings_schema.cpython-314.pyc +0 -0
  317. package/tui/__pycache__/shell.cpython-314.pyc +0 -0
  318. package/tui/__pycache__/shell_read.cpython-314.pyc +0 -0
  319. package/tui/__pycache__/slash_command.cpython-314.pyc +0 -0
  320. package/tui/__pycache__/version.cpython-314.pyc +0 -0
  321. package/tui/_loop.py +0 -86
  322. package/tui/_path_fuzzy_search.py +0 -48
  323. package/tui/_path_match.py +0 -141
  324. package/tui/about.py +0 -95
  325. package/tui/acp/__pycache__/agent.cpython-314.pyc +0 -0
  326. package/tui/acp/__pycache__/api.cpython-314.pyc +0 -0
  327. package/tui/acp/__pycache__/encode_tool_call_id.cpython-314.pyc +0 -0
  328. package/tui/acp/__pycache__/messages.cpython-314.pyc +0 -0
  329. package/tui/acp/__pycache__/prompt.cpython-314.pyc +0 -0
  330. package/tui/acp/__pycache__/protocol.cpython-314.pyc +0 -0
  331. package/tui/acp/agent.py +0 -1002
  332. package/tui/acp/api.py +0 -73
  333. package/tui/acp/encode_tool_call_id.py +0 -12
  334. package/tui/acp/messages.py +0 -203
  335. package/tui/acp/prompt.py +0 -53
  336. package/tui/acp/protocol.py +0 -499
  337. package/tui/agent.py +0 -70
  338. package/tui/agent_schema.py +0 -76
  339. package/tui/agents.py +0 -46
  340. package/tui/ansi/__init__.py +0 -1
  341. package/tui/ansi/__pycache__/__init__.cpython-314.pyc +0 -0
  342. package/tui/ansi/__pycache__/_ansi.cpython-314.pyc +0 -0
  343. package/tui/ansi/__pycache__/_ansi_colors.cpython-314.pyc +0 -0
  344. package/tui/ansi/__pycache__/_control_codes.cpython-314.pyc +0 -0
  345. package/tui/ansi/__pycache__/_keys.cpython-314.pyc +0 -0
  346. package/tui/ansi/__pycache__/_sgr_styles.cpython-314.pyc +0 -0
  347. package/tui/ansi/__pycache__/_stream_parser.cpython-314.pyc +0 -0
  348. package/tui/ansi/_ansi.py +0 -1689
  349. package/tui/ansi/_ansi_colors.py +0 -264
  350. package/tui/ansi/_control_codes.py +0 -37
  351. package/tui/ansi/_keys.py +0 -251
  352. package/tui/ansi/_sgr_styles.py +0 -64
  353. package/tui/ansi/_stream_parser.py +0 -418
  354. package/tui/answer.py +0 -12
  355. package/tui/app.py +0 -1266
  356. package/tui/atomic.py +0 -37
  357. package/tui/cli.py +0 -313
  358. package/tui/code_analyze.py +0 -27
  359. package/tui/complete.py +0 -38
  360. package/tui/constants.py +0 -61
  361. package/tui/conversation_markdown.py +0 -30
  362. package/tui/danger.py +0 -372
  363. package/tui/data/agents/ampcode.com.toml +0 -52
  364. package/tui/data/agents/augmentcode.com.toml +0 -41
  365. package/tui/data/agents/cdh.cloud-dev-harness.toml +0 -23
  366. package/tui/data/agents/claude.com.toml +0 -41
  367. package/tui/data/agents/copilot.github.com.toml +0 -29
  368. package/tui/data/agents/crowai.dev.toml +0 -36
  369. package/tui/data/agents/cursor.com.toml +0 -38
  370. package/tui/data/agents/deepagents.langchain.com +0 -34
  371. package/tui/data/agents/docker.com.toml +0 -60
  372. package/tui/data/agents/geminicli.com.toml +0 -28
  373. package/tui/data/agents/goose.ai.toml +0 -52
  374. package/tui/data/agents/inference.huggingface.co.toml +0 -41
  375. package/tui/data/agents/kimi.com.toml +0 -35
  376. package/tui/data/agents/kiro.dev.toml +0 -43
  377. package/tui/data/agents/onecode.dev.toml +0 -22
  378. package/tui/data/agents/openai.com.toml +0 -53
  379. package/tui/data/agents/openclaw.ai.toml +0 -37
  380. package/tui/data/agents/opencode.ai.toml +0 -39
  381. package/tui/data/agents/openhands.dev.toml +0 -46
  382. package/tui/data/agents/stakpak.dev.toml +0 -62
  383. package/tui/data/agents/vibe.mistral.ai.toml +0 -31
  384. package/tui/data/agents/vtcode.dev.toml +0 -63
  385. package/tui/data/images/frog.png +0 -0
  386. package/tui/data/sounds/question.wav +0 -0
  387. package/tui/data/sounds/turn-over.wav +0 -0
  388. package/tui/db.py +0 -213
  389. package/tui/dec.py +0 -332
  390. package/tui/directory.py +0 -234
  391. package/tui/directory_suggester.py +0 -93
  392. package/tui/directory_watcher.py +0 -231
  393. package/tui/format_path.py +0 -25
  394. package/tui/fuzzy.py +0 -140
  395. package/tui/fuzzy_index.py +0 -275
  396. package/tui/gist.py +0 -33
  397. package/tui/history.py +0 -138
  398. package/tui/jsonrpc.py +0 -578
  399. package/tui/menus.py +0 -14
  400. package/tui/message_log.py +0 -69
  401. package/tui/messages.py +0 -144
  402. package/tui/option_content.py +0 -51
  403. package/tui/os.py +0 -0
  404. package/tui/path_complete.py +0 -217
  405. package/tui/path_filter.py +0 -124
  406. package/tui/paths.py +0 -87
  407. package/tui/pill.py +0 -23
  408. package/tui/platform_commands.py +0 -52
  409. package/tui/prompt/__pycache__/extract.cpython-314.pyc +0 -0
  410. package/tui/prompt/__pycache__/resource.cpython-314.pyc +0 -0
  411. package/tui/prompt/extract.py +0 -19
  412. package/tui/prompt/resource.py +0 -68
  413. package/tui/protocol.py +0 -28
  414. package/tui/screens/__init__.py +0 -0
  415. package/tui/screens/__pycache__/__init__.cpython-314.pyc +0 -0
  416. package/tui/screens/__pycache__/agent_modal.cpython-314.pyc +0 -0
  417. package/tui/screens/__pycache__/component_picker.cpython-314.pyc +0 -0
  418. package/tui/screens/__pycache__/diff_screen.cpython-314.pyc +0 -0
  419. package/tui/screens/__pycache__/log.cpython-314.pyc +0 -0
  420. package/tui/screens/__pycache__/main.cpython-313.pyc +0 -0
  421. package/tui/screens/__pycache__/main.cpython-314.pyc +0 -0
  422. package/tui/screens/__pycache__/permissions.cpython-314.pyc +0 -0
  423. package/tui/screens/__pycache__/projects_app.cpython-314.pyc +0 -0
  424. package/tui/screens/__pycache__/projects_screen.cpython-314.pyc +0 -0
  425. package/tui/screens/__pycache__/sessions.cpython-314.pyc +0 -0
  426. package/tui/screens/__pycache__/store.cpython-314.pyc +0 -0
  427. package/tui/screens/__pycache__/tool_content_screen.cpython-314.pyc +0 -0
  428. package/tui/screens/action_modal.py +0 -94
  429. package/tui/screens/agent_modal.py +0 -175
  430. package/tui/screens/command_edit_modal.py +0 -74
  431. package/tui/screens/component_picker.py +0 -168
  432. package/tui/screens/diff_screen.py +0 -84
  433. package/tui/screens/log.py +0 -553
  434. package/tui/screens/main.py +0 -385
  435. package/tui/screens/main.tcss +0 -655
  436. package/tui/screens/permissions.py +0 -404
  437. package/tui/screens/permissions.tcss +0 -72
  438. package/tui/screens/projects.tcss +0 -80
  439. package/tui/screens/projects_app.py +0 -347
  440. package/tui/screens/projects_screen.py +0 -284
  441. package/tui/screens/session_resume_modal.py +0 -172
  442. package/tui/screens/session_resume_modal.tcss +0 -35
  443. package/tui/screens/sessions.py +0 -164
  444. package/tui/screens/sessions.tcss +0 -119
  445. package/tui/screens/settings.py +0 -254
  446. package/tui/screens/settings.tcss +0 -101
  447. package/tui/screens/store.py +0 -500
  448. package/tui/screens/store.tcss +0 -274
  449. package/tui/screens/tool_content_screen.py +0 -59
  450. package/tui/session_tracker.py +0 -123
  451. package/tui/settings.py +0 -353
  452. package/tui/settings_schema.py +0 -431
  453. package/tui/shell.py +0 -304
  454. package/tui/shell_read.py +0 -42
  455. package/tui/slash_command.py +0 -34
  456. package/tui/tui.tcss +0 -246
  457. package/tui/twitter.py +0 -38
  458. package/tui/version.py +0 -80
  459. package/tui/visuals/__init__.py +0 -0
  460. package/tui/visuals/__pycache__/__init__.cpython-314.pyc +0 -0
  461. package/tui/visuals/__pycache__/columns.cpython-314.pyc +0 -0
  462. package/tui/visuals/columns.py +0 -272
  463. package/tui/widgets/__init__.py +0 -0
  464. package/tui/widgets/__pycache__/__init__.cpython-314.pyc +0 -0
  465. package/tui/widgets/__pycache__/acp_content.cpython-314.pyc +0 -0
  466. package/tui/widgets/__pycache__/agent_response.cpython-314.pyc +0 -0
  467. package/tui/widgets/__pycache__/agent_thought.cpython-314.pyc +0 -0
  468. package/tui/widgets/__pycache__/ask_user.cpython-314.pyc +0 -0
  469. package/tui/widgets/__pycache__/condensed_path.cpython-314.pyc +0 -0
  470. package/tui/widgets/__pycache__/context_stats.cpython-314.pyc +0 -0
  471. package/tui/widgets/__pycache__/conversation.cpython-314.pyc +0 -0
  472. package/tui/widgets/__pycache__/diff_view.cpython-314.pyc +0 -0
  473. package/tui/widgets/__pycache__/directory_input.cpython-314.pyc +0 -0
  474. package/tui/widgets/__pycache__/flash.cpython-314.pyc +0 -0
  475. package/tui/widgets/__pycache__/future_text.cpython-314.pyc +0 -0
  476. package/tui/widgets/__pycache__/grid_select.cpython-314.pyc +0 -0
  477. package/tui/widgets/__pycache__/highlighted_textarea.cpython-314.pyc +0 -0
  478. package/tui/widgets/__pycache__/load_more.cpython-314.pyc +0 -0
  479. package/tui/widgets/__pycache__/mandelbrot.cpython-314.pyc +0 -0
  480. package/tui/widgets/__pycache__/markdown_note.cpython-314.pyc +0 -0
  481. package/tui/widgets/__pycache__/menu.cpython-314.pyc +0 -0
  482. package/tui/widgets/__pycache__/modified_files.cpython-314.pyc +0 -0
  483. package/tui/widgets/__pycache__/non_selectable_label.cpython-314.pyc +0 -0
  484. package/tui/widgets/__pycache__/note.cpython-314.pyc +0 -0
  485. package/tui/widgets/__pycache__/path_search.cpython-314.pyc +0 -0
  486. package/tui/widgets/__pycache__/plan.cpython-314.pyc +0 -0
  487. package/tui/widgets/__pycache__/project_directory_tree.cpython-314.pyc +0 -0
  488. package/tui/widgets/__pycache__/project_grid_select.cpython-314.pyc +0 -0
  489. package/tui/widgets/__pycache__/project_summary.cpython-314.pyc +0 -0
  490. package/tui/widgets/__pycache__/prompt.cpython-314.pyc +0 -0
  491. package/tui/widgets/__pycache__/question.cpython-314.pyc +0 -0
  492. package/tui/widgets/__pycache__/session_grid_select.cpython-314.pyc +0 -0
  493. package/tui/widgets/__pycache__/session_summary.cpython-314.pyc +0 -0
  494. package/tui/widgets/__pycache__/session_tabs.cpython-314.pyc +0 -0
  495. package/tui/widgets/__pycache__/shell_result.cpython-314.pyc +0 -0
  496. package/tui/widgets/__pycache__/shell_terminal.cpython-314.pyc +0 -0
  497. package/tui/widgets/__pycache__/side_bar.cpython-314.pyc +0 -0
  498. package/tui/widgets/__pycache__/slash_complete.cpython-314.pyc +0 -0
  499. package/tui/widgets/__pycache__/strike_text.cpython-314.pyc +0 -0
  500. package/tui/widgets/__pycache__/subagent.cpython-314.pyc +0 -0
  501. package/tui/widgets/__pycache__/subagent_screen.cpython-314.pyc +0 -0
  502. package/tui/widgets/__pycache__/terminal.cpython-314.pyc +0 -0
  503. package/tui/widgets/__pycache__/throbber.cpython-314.pyc +0 -0
  504. package/tui/widgets/__pycache__/tool_call.cpython-314.pyc +0 -0
  505. package/tui/widgets/__pycache__/user_input.cpython-314.pyc +0 -0
  506. package/tui/widgets/acp_content.py +0 -70
  507. package/tui/widgets/agent_response.py +0 -76
  508. package/tui/widgets/agent_thought.py +0 -167
  509. package/tui/widgets/ask_user.py +0 -264
  510. package/tui/widgets/command_pane.py +0 -223
  511. package/tui/widgets/condensed_path.py +0 -115
  512. package/tui/widgets/context_stats.py +0 -66
  513. package/tui/widgets/conversation.py +0 -2641
  514. package/tui/widgets/danger_warning.py +0 -65
  515. package/tui/widgets/diff_view.py +0 -69
  516. package/tui/widgets/directory_input.py +0 -8
  517. package/tui/widgets/flash.py +0 -81
  518. package/tui/widgets/future_text.py +0 -126
  519. package/tui/widgets/grid_select.py +0 -234
  520. package/tui/widgets/highlighted_textarea.py +0 -180
  521. package/tui/widgets/load_more.py +0 -24
  522. package/tui/widgets/mandelbrot.py +0 -294
  523. package/tui/widgets/markdown_note.py +0 -13
  524. package/tui/widgets/menu.py +0 -147
  525. package/tui/widgets/modified_files.py +0 -306
  526. package/tui/widgets/non_selectable_label.py +0 -5
  527. package/tui/widgets/note.py +0 -18
  528. package/tui/widgets/path_search.py +0 -492
  529. package/tui/widgets/plan.py +0 -285
  530. package/tui/widgets/project_directory_tree.py +0 -102
  531. package/tui/widgets/project_grid_select.py +0 -64
  532. package/tui/widgets/project_summary.py +0 -33
  533. package/tui/widgets/prompt.py +0 -795
  534. package/tui/widgets/question.py +0 -360
  535. package/tui/widgets/session_grid_select.py +0 -230
  536. package/tui/widgets/session_summary.py +0 -115
  537. package/tui/widgets/session_tabs.py +0 -188
  538. package/tui/widgets/shell_result.py +0 -35
  539. package/tui/widgets/shell_terminal.py +0 -40
  540. package/tui/widgets/side_bar.py +0 -124
  541. package/tui/widgets/slash_complete.py +0 -235
  542. package/tui/widgets/strike_text.py +0 -70
  543. package/tui/widgets/subagent.py +0 -287
  544. package/tui/widgets/subagent_screen.py +0 -709
  545. package/tui/widgets/terminal.py +0 -576
  546. package/tui/widgets/terminal_tool.py +0 -360
  547. package/tui/widgets/throbber.py +0 -100
  548. package/tui/widgets/tool_call.py +0 -398
  549. package/tui/widgets/user_input.py +0 -23
  550. package/tui/widgets/version.py +0 -5
  551. package/tui/widgets/welcome.py +0 -31
@@ -1,2700 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import asyncio
4
- import json
5
- import logging
6
- import re
7
- from pathlib import Path
8
- from typing import Any, AsyncIterator, Optional
9
-
10
- from dataclasses import dataclass
11
- from typing import Callable
12
-
13
- from onecode.agent.agents.types import AgentPermission
14
- from onecode.agent.context import ContextManager
15
- from onecode.agent.permissions_store import PermissionStore
16
- from onecode.models.provider import ContentBlockType, ProviderRegistry
17
-
18
- from onecode.agent.session import AgentSession
19
- from onecode.memory import AgentMemory, MemoryLayer
20
- from onecode.models.errors import safe_error_msg
21
- from onecode.models.messages import StreamEvent, StreamEventType
22
-
23
- logger = logging.getLogger("onecode.agent.engine")
24
-
25
- # Subagent depth limit — subagents are leaf nodes (max depth = 1).
26
- _MAX_SUBAGENT_DEPTH = 1
27
-
28
-
29
- @dataclass(frozen=True)
30
- class ToolEvent:
31
- """Event emitted during the agent loop lifecycle (Clawd-Code style)."""
32
- kind: str # "tool_use" | "tool_result" | "tool_error"
33
- tool_name: str = ""
34
- tool_input: dict | None = None
35
- tool_output: Any = None
36
- tool_use_id: str | None = None
37
- is_error: bool = False
38
- error: str | None = None
39
-
40
-
41
- ToolEventHandler = Callable[[ToolEvent], None]
42
-
43
-
44
- class TurnCancelledError(Exception):
45
- """Raised when the user cancels the agent's turn mid-execution."""
46
-
47
-
48
- TOOL_CALL_RE = re.compile(
49
- r'<tool_call\s+name=["\']([^"\']+)["\']\s+id=["\']([^"\']+)["\']>(.*?)</tool_call>',
50
- re.DOTALL,
51
- )
52
-
53
- # Structured tool call format used by the minimaxi / claude / Anthropic-style
54
- # models. Looks like::
55
- #
56
- # <minimax:tool_call>
57
- # <invoke name="Read">
58
- # <parameter name="path">SPEC.md</parameter>
59
- # </invoke>
60
- # <invoke name="Bash">
61
- # <parameter name="command">ls -la</parameter>
62
- # <parameter name="timeout">30</parameter>
63
- # </invoke>
64
- # </minimax:tool_call>
65
- #
66
- # We match the outer block, then split into individual <invoke> elements
67
- # inside ``_extract_minimax_tool_uses``.
68
- MINIMAX_TOOL_CALL_RE = re.compile(
69
- r'<minimax:tool_call>(.*?)</minimax:tool_call>',
70
- re.DOTALL,
71
- )
72
-
73
- # An individual <invoke name="X">...</invoke> block. ``name`` is captured
74
- # separately so we can call ``_parse_minimax_invoke_body`` on the inner XML.
75
- _MINIMAX_INVOKE_RE = re.compile(
76
- r'<invoke\s+name="([^"]+)">(.*?)</invoke>',
77
- re.DOTALL,
78
- )
79
-
80
- # A single <parameter name="X">value</parameter> element. The value can span
81
- # multiple lines, may contain entity-escaped angle brackets, and we tolerate
82
- # either double or single quotes around the name.
83
- _MINIMAX_PARAM_RE = re.compile(
84
- r'<parameter\s+name=["\']([^"\']+)["\']>(.*?)</parameter>',
85
- re.DOTALL,
86
- )
87
-
88
- THINKING_RE = re.compile(
89
- r'<think(?:ing)?>(.*?)</think(?:ing)?>',
90
- re.DOTALL,
91
- )
92
-
93
- _LEGACY_OPEN = "[TOOL_CALL]"
94
- _LEGACY_CLOSE = "[/TOOL_CALL]"
95
-
96
-
97
- def _parse_minimax_invoke_body(body: str) -> dict:
98
- """Parse the inner XML of a single ``<invoke name="X">`` block.
99
-
100
- Returns a dict ``{"name": str, "arguments": {param_name: value, ...}}``.
101
- The ``arguments`` dict is empty if no ``<parameter>`` children are found.
102
- """
103
- body = body.strip()
104
- arguments: dict = {}
105
- for m in _MINIMAX_PARAM_RE.finditer(body):
106
- param_name = m.group(1)
107
- raw_value = m.group(2)
108
- arguments[param_name] = _unescape_xml(raw_value)
109
- return arguments
110
-
111
-
112
- def _unescape_xml(s: str) -> str:
113
- """Reverse the common XML / HTML entity escapes.
114
-
115
- We keep this conservative — the five predefined entities and the numeric
116
- ones — so we never accidentally mangle content that legitimately contains
117
- ampersands.
118
- """
119
- return (
120
- s.replace("&lt;", "<")
121
- .replace("&gt;", ">")
122
- .replace("&quot;", '"')
123
- .replace("&apos;", "'")
124
- .replace("&amp;", "&")
125
- )
126
-
127
-
128
- def _extract_minimax_tool_uses(text: str, id_start: int = 0) -> tuple[list[dict], str, int]:
129
- """Extract ``<minimax:tool_call>...</minimax:tool_call>`` blocks.
130
-
131
- Returns ``(tool_uses, cleaned_text, next_id)`` where ``tool_uses`` is a
132
- list of ``{id, name, input}`` dicts and ``cleaned_text`` has every
133
- matched block removed. ``id_start`` is the next available tool id
134
- counter (caller-owned, see ``AgentEngine._tool_id_counter``); the next
135
- free id is returned in the tuple so callers can pass it to subsequent
136
- extractions without losing uniqueness.
137
- """
138
- tool_uses: list[dict] = []
139
- cleaned = text
140
- counter = id_start
141
- while True:
142
- m = MINIMAX_TOOL_CALL_RE.search(cleaned)
143
- if m is None:
144
- break
145
- block_body = m.group(1)
146
- for inv in _MINIMAX_INVOKE_RE.finditer(block_body):
147
- name = inv.group(1)
148
- arguments = _parse_minimax_invoke_body(inv.group(2))
149
- counter += 1
150
- tool_uses.append({
151
- "id": f"minimax-{counter}",
152
- "name": name,
153
- "input": arguments,
154
- })
155
- cleaned = cleaned[:m.start()] + cleaned[m.end():]
156
- return tool_uses, cleaned, counter
157
-
158
-
159
- def _scan_balanced_braces(text: str, open_pos: int) -> int | None:
160
- if open_pos < 0 or open_pos >= len(text) or text[open_pos] != "{":
161
- return None
162
- depth = 0
163
- i = open_pos
164
- n = len(text)
165
- while i < n:
166
- c = text[i]
167
- if c == "{":
168
- depth += 1
169
- i += 1
170
- elif c == "}":
171
- depth -= 1
172
- if depth == 0:
173
- return i
174
- i += 1
175
- elif c in ('"', "'"):
176
- quote = c
177
- i += 1
178
- while i < n and text[i] != quote:
179
- if text[i] == "\\" and i + 1 < n:
180
- i += 2
181
- else:
182
- i += 1
183
- i += 1
184
- elif c == "/" and i + 1 < n and text[i + 1] == "/":
185
- while i < n and text[i] != "\n":
186
- i += 1
187
- else:
188
- i += 1
189
- return None
190
-
191
-
192
- def _parse_legacy_tool_body(body: str) -> dict:
193
- body = body.strip()
194
- if not body or not body.startswith("{"):
195
- return {"name": None, "arguments": {}}
196
- body_close = _scan_balanced_braces(body, 0)
197
- if body_close is None:
198
- return {"name": None, "arguments": {}}
199
- inner = body[1:body_close]
200
- name_match = re.search(r'tool\s*=>\s*"([^"]+)"', inner)
201
- name = name_match.group(1) if name_match else None
202
- arguments: dict = {}
203
- args_match = re.search(r'args\s*=>\s*\{', inner)
204
- if args_match:
205
- args_outer = _scan_balanced_braces(inner, args_match.end() - 1)
206
- if args_outer is not None:
207
- args_body = inner[args_match.end():args_outer]
208
- for am in re.finditer(
209
- r'--(\w+)\s+"((?:[^"\\]|\\.)*)"', args_body, re.DOTALL
210
- ):
211
- arguments[am.group(1)] = am.group(2)
212
- return {"name": name, "arguments": arguments}
213
-
214
-
215
- def _extract_legacy_tool_uses(
216
- text: str, id_start: int = 0
217
- ) -> tuple[list[dict], str, int]:
218
- """Extract all [TOOL_CALL]...[/TOOL_CALL] blocks from text.
219
-
220
- Returns (tool_uses, cleaned_text, next_id) where tool_uses has
221
- [{id, name, input}, ...] and cleaned_text has all markers removed.
222
- ``id_start`` lets the caller share a monotonic counter across multiple
223
- extraction passes; the next free id is returned so callers can pass it
224
- forward.
225
- """
226
- tool_uses: list[dict] = []
227
- cleaned = text
228
- counter = id_start
229
- while True:
230
- open_idx = cleaned.find(_LEGACY_OPEN)
231
- if open_idx < 0:
232
- break
233
- body_start = open_idx + len(_LEGACY_OPEN)
234
- close_idx = cleaned.find(_LEGACY_CLOSE, body_start)
235
- if close_idx < 0:
236
- # Unclosed marker — stop looking
237
- break
238
- body = cleaned[body_start:close_idx]
239
- span_end = close_idx + len(_LEGACY_CLOSE)
240
- parsed = _parse_legacy_tool_body(body)
241
- name = parsed.get("name")
242
- if name:
243
- counter += 1
244
- tool_uses.append({
245
- "id": f"legacy-{counter}",
246
- "name": name,
247
- "input": parsed.get("arguments", {}),
248
- })
249
- cleaned = cleaned[:open_idx] + cleaned[span_end:]
250
- return tool_uses, cleaned, counter
251
-
252
-
253
- _TODO_STATUSES = {"pending", "in_progress", "completed"}
254
-
255
-
256
- class TodoManager:
257
- """Unified todo manager (formerly TodoManager).
258
-
259
- Each todo has: id, subject, description, activeForm, status, owner, blocks,
260
- blockedBy, metadata, output. Supports dependency tracking and progress
261
- display in the TUI Plan sidebar.
262
-
263
- Persistence format (``to_dict``/``from_dict``) accepts both the new
264
- single-list layout and the legacy ``{"tasks": [...], "todos": [...]}``
265
- layout for backwards compatibility with existing session files.
266
- """
267
-
268
- def __init__(self, on_change: Callable[[], None] | None = None):
269
- self._todos: dict[str, dict] = {}
270
- self._order: list[str] = []
271
- self._id_counter = 0
272
- self._on_change = on_change
273
-
274
- def _mark_dirty(self) -> None:
275
- if self._on_change:
276
- self._on_change()
277
-
278
- def _next_id(self) -> str:
279
- self._id_counter += 1
280
- return f"t{self._id_counter}"
281
-
282
- # ── Todo CRUD ──
283
-
284
- def create_todo(
285
- self,
286
- subject: str,
287
- description: str = "",
288
- active_form: str = "",
289
- metadata: dict | None = None,
290
- ) -> dict:
291
- """Create a todo with dependency tracking support."""
292
- todo_id = self._next_id()
293
- todo = {
294
- "id": todo_id,
295
- "_order": self._id_counter,
296
- "subject": subject,
297
- "description": description,
298
- "activeForm": active_form,
299
- "status": "pending",
300
- "owner": None,
301
- "blocks": [],
302
- "blockedBy": [],
303
- "metadata": dict(metadata or {}),
304
- "output": "",
305
- }
306
- self._todos[todo_id] = todo
307
- self._order.append(todo_id)
308
- self._mark_dirty()
309
- return todo
310
-
311
- def get_todo(self, todo_id: str) -> dict | None:
312
- return self._todos.get(todo_id)
313
-
314
- def list_todos(self) -> list[dict]:
315
- """List all todos in creation order."""
316
- return [self._todos[tid] for tid in self._order if tid in self._todos]
317
-
318
- def update_todo(self, todo_id: str, **fields) -> dict | None:
319
- """Update todo fields. Supports: subject, description, activeForm, status,
320
- owner, metadata, addBlocks, addBlockedBy. Returns updated todo or None."""
321
- todo = self._todos.get(todo_id)
322
- if todo is None:
323
- return None
324
-
325
- updated = []
326
-
327
- for field in ("subject", "description", "activeForm", "owner"):
328
- if field in fields:
329
- v = fields[field]
330
- if isinstance(v, str) and v != todo.get(field):
331
- todo[field] = v
332
- updated.append(field)
333
-
334
- if "status" in fields:
335
- status = fields["status"]
336
- if status == "deleted":
337
- self._todos.pop(todo_id, None)
338
- if todo_id in self._order:
339
- self._order.remove(todo_id)
340
- self._mark_dirty()
341
- return {"id": todo_id, "deleted": True}
342
- if status in _TODO_STATUSES and status != todo.get("status"):
343
- todo["status"] = status
344
- updated.append("status")
345
-
346
- for rel_field, input_key in (("blocks", "addBlocks"), ("blockedBy", "addBlockedBy")):
347
- if input_key in fields:
348
- ids = fields[input_key]
349
- if isinstance(ids, list):
350
- cur = list(todo.get(rel_field) or [])
351
- for x in ids:
352
- if isinstance(x, str) and x not in cur:
353
- cur.append(x)
354
- if cur != todo.get(rel_field):
355
- todo[rel_field] = cur
356
- updated.append(rel_field)
357
-
358
- if "metadata" in fields:
359
- md = fields["metadata"]
360
- if isinstance(md, dict):
361
- existing = dict(todo.get("metadata") or {})
362
- for k, v in md.items():
363
- if v is None:
364
- existing.pop(k, None)
365
- else:
366
- existing[k] = v
367
- todo["metadata"] = existing
368
- updated.append("metadata")
369
-
370
- if "output" in fields:
371
- v = fields["output"]
372
- if isinstance(v, str):
373
- todo["output"] = v
374
- updated.append("output")
375
-
376
- self._mark_dirty()
377
- return todo
378
-
379
- def get_todo_output(self, todo_id: str) -> dict:
380
- """Get output for a todo."""
381
- todo = self._todos.get(todo_id)
382
- if todo is None:
383
- return {"retrieval_status": "not_found", "task": None}
384
- output = str(todo.get("output") or "")
385
- return {
386
- "retrieval_status": "success" if output else "not_ready",
387
- "task": {
388
- "task_id": todo_id,
389
- "task_type": "todo_list",
390
- "status": todo.get("status"),
391
- "description": todo.get("description"),
392
- "output": output,
393
- },
394
- }
395
-
396
- def clear_todos(self) -> None:
397
- self._todos.clear()
398
- self._order.clear()
399
- self._id_counter = 0
400
- self._mark_dirty()
401
-
402
- # ── Serialization ──
403
-
404
- def to_dict(self) -> dict:
405
- """Serialize todos for persistence."""
406
- return {
407
- "todos": [dict(self._todos[tid]) for tid in self._order if tid in self._todos],
408
- "id_counter": self._id_counter,
409
- }
410
-
411
- @classmethod
412
- def from_dict(cls, data: dict, on_change: Callable[[], None] | None = None) -> "TodoManager":
413
- """Restore todo manager from saved dict.
414
-
415
- Accepts the unified ``{"todos": [...]}`` layout (preferred) and the
416
- legacy ``{"tasks": [...], "todos": [...]}`` layout where ``todos``
417
- entries may use the old ``{text, done}`` shape — these are converted
418
- on the fly to the new ``{subject, status}`` shape.
419
- """
420
- tm = cls(on_change=on_change)
421
- tm.reload_from_dict(data)
422
- return tm
423
-
424
- def reload_from_dict(self, data: dict) -> None:
425
- """Reload state from *data* in place, preserving the instance identity.
426
-
427
- This is critical because tool instances (``TodoCreateTool`` etc.)
428
- hold a direct reference to the same ``TodoManager`` object. If we
429
- replaced ``self._todo_manager`` with a new instance, the tools
430
- would silently write to the stale one while the engine reads from
431
- the fresh one — creating the illusion of success but never
432
- persisting or emitting plan updates.
433
- """
434
- self._todos.clear()
435
- self._order.clear()
436
- self._id_counter = data.get("id_counter", 0)
437
-
438
- raw_todos: list[dict] = []
439
- if "todos" in data:
440
- raw_todos.extend(data.get("todos") or [])
441
- if "tasks" in data:
442
- raw_todos.extend(data.get("tasks") or [])
443
-
444
- for t in raw_todos:
445
- todo = dict(t)
446
- if "subject" not in todo and "text" in todo:
447
- todo["subject"] = todo.pop("text")
448
- if "status" not in todo and "done" in todo:
449
- todo["status"] = "completed" if todo.pop("done") else "pending"
450
- todo.setdefault("status", "pending")
451
- tid = todo.get("id") or self._next_id()
452
- todo["id"] = tid
453
- todo.setdefault("_order", self._id_counter)
454
- self._todos[tid] = todo
455
- if tid not in self._order:
456
- self._order.append(tid)
457
-
458
-
459
- # Tools that require a task plan before execution (plan gate)
460
- _EXECUTION_TOOLS: frozenset[str] = frozenset({
461
- "Write", "Edit", "Insert", "ApplyPatch", "Bash",
462
- })
463
-
464
-
465
- class AgentEngine:
466
- def __init__(self, app, project_dir: Path | None = None, perm_store: PermissionStore | None = None):
467
- from onecode.agent.tools.file_ops import ToolFactory
468
- from onecode.agent.agents.types import BuildAgent
469
- from onecode.agent.hooks import HookManager
470
- from onecode.skills.loader import SkillLoader
471
- from onecode.mcp.manager import MCPManager
472
- from onecode.agent.tools.cron_tools import CronScheduler
473
- from onecode.agent.tools.lsp_tools import LSPTool
474
- from onecode.agent.tools.config_tool import ConfigReadTool, ConfigWriteTool
475
- from onecode.agent.tools.communication_tools import SendMessageTool
476
- self.app = app
477
- self._project_dir = (project_dir or Path.cwd()).resolve()
478
- self.context = ContextManager()
479
- self.file_ops = ToolFactory.create_file_ops(self._project_dir)
480
- self.shell = ToolFactory.create_shell(self._project_dir)
481
- self.current_agent: AgentConfig = BuildAgent() # noqa: F821
482
- self.iterations = 0
483
- self.total_tokens = 0
484
- self._skills_loaded = False
485
- self._session: Optional[AgentSession] = None
486
- self._memory = AgentMemory()
487
- self._hooks = HookManager()
488
- self._todo_manager = TodoManager(on_change=self._on_todo_change)
489
- self._plan_dirty: bool = False
490
- # Cache of the last plan snapshot that was emitted to the TUI. Used
491
- # by ``_emit_plan_update`` to dedupe redundant emits when the todos
492
- # have not actually changed between turns. The first emit of a
493
- # session/cycle always wins (cache starts empty).
494
- self._last_emitted_plan: tuple = ()
495
- self._project_config: dict = {}
496
- self._project_context_loaded = False
497
- self._pending_approval: dict | None = None # {tool_call, result_key}
498
- self._last_user_msg: str | None = None # Last SendMessage visible to user
499
-
500
- # Clawd-Code subsystems
501
- self._skill_loader = SkillLoader()
502
- self._mcp = MCPManager()
503
- self._cron_scheduler = CronScheduler()
504
- self._lsp_tool = LSPTool()
505
- app_config = getattr(app, 'config', None)
506
- self._config_tool_read = ConfigReadTool(app_config) if app_config else None
507
- self._config_tool_write = ConfigWriteTool(app_config) if app_config else None
508
-
509
- # Codebase engine (lazy init)
510
- self._codebase_engine: Optional["CodebaseEngine"] = None # noqa: F821
511
-
512
- # Tool registry (Clawd-Code pattern)
513
- self._tool_registry = self._build_tool_registry()
514
- self._send_message_tool = SendMessageTool()
515
-
516
- # Per-turn usage tracking (Clawd-Code style)
517
- self._turn_usages: list[dict[str, int]] = []
518
-
519
- # Event callbacks
520
- self.on_event: ToolEventHandler | None = None
521
- self.on_text_chunk: Callable[[str], None] | None = None
522
- self.on_tool_call_delta: Callable[[str, str, str], None] | None = None
523
- self.on_thinking: Callable[[str], None] | None = None
524
- self._streaming_used: bool = False
525
- self._pending_thinking_blocks: list[str] = []
526
-
527
- # Cancellation support
528
- self._cancelled: bool = False
529
-
530
- # Subagent depth limit — subagents are leaf nodes (max depth = 1).
531
- self._subagent_depth: int = 0
532
-
533
- # Skip codebase auto-retrieval / long-term memory recall inside
534
- # subagent engines. Both are project-seeding steps meant for the
535
- # top-level chat loop; a subagent already receives a fully-formed
536
- # explicit prompt from the parent and does NOT need toEmbed/index
537
- # the whole codebase or recall memories again — that path
538
- # previously called ``CodebaseIndexer.index()`` (a long
539
- # synchronous loop, blocking the event loop and cancel) plus
540
- # ``CodebaseRetriever._retrieve_embedding()`` (one httpx call per
541
- # chunk, each with a 30s timeout, so ~minutes of dead time), which
542
- # is the root cause of the "subagent shows no stream output and
543
- # can't be stopped" hang observed in production.
544
- self._disable_retrieval: bool = False
545
-
546
- # Monotonic tool-call id counter — shared across every
547
- # ``chat_stream`` turn so the same id is never reused even when the
548
- # model re-emits ``legacy-0`` / ``minimax-0`` in a fresh turn.
549
- # Touched only by ``_extract_*_tool_uses`` through the engine
550
- # instance, not by the module-level helpers (which take an explicit
551
- # ``id_start`` for testability).
552
- self._tool_id_counter: int = 0
553
-
554
- # Subagent engines spawned by this engine — we track them so
555
- # cancelling the parent also cancels in-flight subagents.
556
- self._child_engines: list[AgentEngine] = []
557
-
558
- # Permission overrides shared across subagents
559
- self._perm_store: PermissionStore = perm_store or PermissionStore()
560
-
561
- # Plan gate: mode-aware enforcement based on agent type
562
- # "hard" (plan mode) rejects execution tools without plan
563
- # "soft" (build/solo mode) suggests planning but doesn't block
564
- # "off" (agents with permission_task=DENY) no enforcement
565
- self._plan_gate_mode: str = "off"
566
- self._plan_gate_fired: bool = False
567
-
568
- # ReAct state tracking
569
- self._react_phase: str = "thought" # "thought" | "action" | "observation"
570
- self._direct_execution_count: int = 0 # Track direct tool use for routing-decision reminder
571
-
572
- # Consecutive turns with zero tool_uses — if the LLM repeatedly
573
- # refuses to call tools despite pending todos, we cap the loop
574
- # instead of cycling until max_turns is exhausted.
575
- self._empty_tool_turns: int = 0
576
-
577
- def _build_tool_registry(self) -> ToolRegistry: # noqa: F821
578
- from onecode.agent.tools.registry import ToolRegistry
579
- from onecode.agent.tools.file_tools import ReadTool, WriteTool, EditTool, InsertTool, UndoEditTool, GlobTool, GrepTool, ListTool
580
- from onecode.agent.tools.apply_patch_tool import ApplyPatchTool
581
- from onecode.agent.tools.bash_tool import BashTool
582
- from onecode.agent.tools.web_tools import WebFetchTool, WebSearchTool
583
- from onecode.agent.tools.communication_tools import SendMessageTool, AskUserTool, ToolSearchTool
584
- from onecode.agent.tools.todo_tools import (TodoCreateTool, TodoGetTool, TodoListTool, TodoUpdateTool,
585
- TodoOutputTool, TodoStopTool, TodoClearTool)
586
- from onecode.agent.tools.agent_tools import AgentTool, TaskTool
587
- from onecode.agent.tools.skill_tools import SkillTool
588
- from onecode.agent.tools.mcp_tools import MCPTool as MCPToolTool, MCPResourcesTool
589
- from onecode.agent.tools.cron_tools import CronCreateTool, CronListTool, CronRemoveTool
590
- from onecode.agent.tools.git_tools import WorktreeTool
591
- registry = ToolRegistry()
592
- # File tools
593
- registry.register(ReadTool(self.file_ops))
594
- registry.register(WriteTool(self.file_ops))
595
- registry.register(EditTool(self.file_ops))
596
- registry.register(InsertTool(self.file_ops))
597
- registry.register(UndoEditTool(self.file_ops))
598
- registry.register(GlobTool(self.file_ops))
599
- registry.register(GrepTool(self.file_ops))
600
- registry.register(ListTool(self.file_ops))
601
- registry.register(ApplyPatchTool(self.file_ops))
602
- # Shell
603
- registry.register(BashTool(self.shell))
604
- # Web
605
- registry.register(WebFetchTool())
606
- registry.register(WebSearchTool())
607
- # Communication
608
- self._send_message_tool = SendMessageTool()
609
- registry.register(self._send_message_tool)
610
- registry.register(AskUserTool())
611
- registry.register(ToolSearchTool(registry))
612
- # Task management
613
- registry.register(TodoCreateTool(self._todo_manager))
614
- registry.register(TodoGetTool(self._todo_manager))
615
- registry.register(TodoListTool(self._todo_manager))
616
- registry.register(TodoUpdateTool(self._todo_manager))
617
- registry.register(TodoOutputTool(self._todo_manager))
618
- registry.register(TodoStopTool(self._todo_manager))
619
- registry.register(TodoClearTool(self._todo_manager))
620
- # Agent tools
621
- registry.register(AgentTool(registry, permission_checker=self._check_tool_permission))
622
- registry.register(TaskTool(self._spawn_subagent_async))
623
-
624
- # Skill tool (Clawd-Code pattern)
625
- registry.register(SkillTool(self._skill_loader))
626
-
627
- # MCP tools (Clawd-Code pattern)
628
- registry.register(MCPToolTool(self._mcp))
629
- registry.register(MCPResourcesTool(self._mcp))
630
-
631
- # LSP tool (Clawd-Code pattern)
632
- registry.register(self._lsp_tool)
633
-
634
- # Cron tools (Clawd-Code pattern)
635
- registry.register(CronCreateTool(self._cron_scheduler))
636
- registry.register(CronListTool(self._cron_scheduler))
637
- registry.register(CronRemoveTool(self._cron_scheduler))
638
-
639
- # Git worktree tool (Clawd-Code pattern)
640
- registry.register(WorktreeTool(workspace_root=self._project_dir))
641
-
642
- # Config tools (Clawd-Code pattern)
643
- if self._config_tool_read:
644
- registry.register(self._config_tool_read)
645
- if self._config_tool_write:
646
- registry.register(self._config_tool_write)
647
-
648
- # Codebase search tool (lazy — engine initialised on first search)
649
- from onecode.codebase.tools import CodebaseSearchTool
650
- registry.register(CodebaseSearchTool(lambda: self._codebase_engine))
651
- return registry
652
-
653
- def _resolve_plan_gate_mode(self) -> str:
654
- """Determine plan gate strictness based on current agent type.
655
-
656
- Returns "hard" (reject execution tools without plan), "soft" (nudge only),
657
- or "off" (no enforcement).
658
- """
659
- from onecode.agent.agents.types import AgentPermission
660
- if self.current_agent.permission_task == AgentPermission.DENY:
661
- return "off"
662
- if self.current_agent.name == "plan":
663
- return "hard"
664
- return "soft"
665
-
666
- def _plan_gate_first_turn(self) -> bool:
667
- """Returns True only on the first turn where no plan exists yet.
668
-
669
- Once tasks are created or the gate has fired, subsequent turns
670
- proceed without gate intervention.
671
- """
672
- if self._plan_gate_fired:
673
- return False
674
- self._plan_gate_fired = True
675
- return True
676
-
677
- def _notify_event(self, event: ToolEvent) -> None:
678
- """Emit a ToolEvent to the registered callback (Clawd-Code pattern)."""
679
- if self.on_event:
680
- try:
681
- self.on_event(event)
682
- except Exception as e:
683
- logger.warning("ToolEvent callback failed: %s", e)
684
-
685
- def _emit_text_chunks(self, text: str, chunk_size: int = 12) -> None:
686
- """Emit user-visible text in small chunks (Clawd-Code pattern)."""
687
- if self.on_text_chunk is None or not text:
688
- return
689
- if chunk_size <= 0:
690
- chunk_size = len(text)
691
- for idx in range(0, len(text), chunk_size):
692
- try:
693
- self.on_text_chunk(text[idx:idx + chunk_size])
694
- except Exception as e:
695
- logger.warning("on_text_chunk failed: %s", e)
696
- return
697
-
698
- def _emit_plan_update(self) -> list[StreamEvent]:
699
- """Emit a plan update event from current todos, deduped against last emit.
700
-
701
- Returns an empty list when the plan snapshot is byte-identical to the
702
- last emission, so callers can no-op without paying the JSON-RPC + TUI
703
- widget-recompose cost. The cache is a tuple of ``(content, status,
704
- priority)`` triples — order-sensitive, so reordering the todo list
705
- also triggers a re-emit.
706
- """
707
- entries = [
708
- (
709
- t.get("subject") or t.get("description", ""),
710
- t.get("status", "pending"),
711
- t.get("metadata", {}).get("priority", "medium"),
712
- )
713
- for t in self._todo_manager.list_todos()
714
- ]
715
- if entries == self._last_emitted_plan:
716
- return []
717
- self._last_emitted_plan = entries
718
- # Convert the cached tuples back to wire-format dicts for the stream
719
- # event. The TUI side expects dicts; the dedupe cache stays tuples
720
- # to avoid the per-emit dict-construction cost on the hot path.
721
- wire_entries = [
722
- {"content": c, "status": s, "priority": p}
723
- for (c, s, p) in entries
724
- ]
725
- return [StreamEvent.plan(wire_entries)]
726
-
727
- def _has_pending_todos(self) -> bool:
728
- """Return True if there are any pending or in-progress todos."""
729
- return any(
730
- t.get("status") in ("pending", "in_progress")
731
- for t in self._todo_manager.list_todos()
732
- )
733
-
734
- def _build_completion_summary(self) -> str:
735
- """Build a deterministic completion summary from todo state.
736
-
737
- Called after the agent loop exits when the last round had no
738
- visible text — ensures the user always sees a completion report
739
- without an extra LLM round-trip.
740
- """
741
- todos = self._todo_manager.list_todos()
742
- completed = [t for t in todos if t.get("status") == "completed"]
743
- failed = [t for t in todos if t.get("status") == "failed"]
744
- pending = [t for t in todos if t.get("status") in ("pending", "in_progress")]
745
-
746
- lines = ["\n---", "## Session Complete", ""]
747
-
748
- if not todos:
749
- lines.append("No todos were tracked in this session.")
750
- else:
751
- if completed:
752
- lines.append(f"**Completed** ({len(completed)}):")
753
- for t in completed:
754
- lines.append(f"- {t.get('subject') or t.get('description', '')}")
755
- lines.append("")
756
- if pending:
757
- lines.append(f"**Unfinished** ({len(pending)}):")
758
- for t in pending:
759
- lines.append(f"- {t.get('subject') or t.get('description', '')}")
760
- lines.append("")
761
- if failed:
762
- lines.append(f"**Failed** ({len(failed)}):")
763
- for t in failed:
764
- lines.append(f"- {t.get('subject') or t.get('description', '')}")
765
- lines.append("")
766
-
767
- lines.append(
768
- f"**Stats**: {self.iterations} round(s), "
769
- f"{self.total_tokens} tokens"
770
- )
771
- return "\n".join(lines)
772
-
773
- def _auto_advance_after_spawn(self, subagent_prompt: str) -> None:
774
- """After a Spawn subagent completes, auto-advance todo status.
775
-
776
- Finds the first ``in_progress`` todo (typically the one the subagent
777
- was working on) and marks it ``completed``. If another ``pending``
778
- todo remains, injects a user message into the LLM context directing
779
- the agent to continue — no need to wait for the LLM to spontaneously
780
- call ``TodoUpdate`` then ``Spawn`` again.
781
- """
782
- todos = self._todo_manager.list_todos()
783
- in_progress = [t for t in todos if t.get("status") == "in_progress"]
784
- if in_progress:
785
- tid = in_progress[0]["id"]
786
- self._todo_manager.update_todo(tid, status="completed")
787
-
788
- pending = [t for t in self._todo_manager.list_todos() if t.get("status") == "pending"]
789
- if pending:
790
- lines = ["# Continue working — remaining todos", ""]
791
- for t in pending:
792
- sub = t.get("subject", "")
793
- desc = t.get("description", "")
794
- if desc and desc != sub:
795
- lines.append(f"- `{t['id']}`: {sub} — {desc}")
796
- else:
797
- lines.append(f"- `{t['id']}`: {sub}")
798
- lines.append("")
799
- lines.append("Continue with the next pending todo above. Do NOT stop.")
800
- body = "\n".join(lines)
801
- self.context.add_message("user", [{"type": "text", "text": body}])
802
-
803
- def _refresh_pending_todos_nudge(self) -> None:
804
- """Inject a reminder that nudges the agent to advance unfinished todos.
805
-
806
- Called at the start of each ReAct turn. If there are pending or
807
- in-progress todos, a strong ``<!-- PENDING_TODOS -->`` section is
808
- injected into the system context directing the agent to continue
809
- working rather than summarising or stopping early.
810
-
811
- When no todos are open, any stale nudge is removed so the context
812
- stays clean.
813
- """
814
- marker = "<!-- PENDING_TODOS -->"
815
- open_todos = [t for t in self._todo_manager.list_todos()
816
- if t.get("status") in ("pending", "in_progress")]
817
- if not open_todos:
818
- self.context.remove_system_by_marker(marker)
819
- return
820
-
821
- in_progress = [t for t in open_todos if t.get("status") == "in_progress"]
822
- pending = [t for t in open_todos if t.get("status") == "pending"]
823
-
824
- def _label(t: dict) -> str:
825
- sid = t.get("id", "?")
826
- sub = t.get("subject") or t.get("description", "")
827
- owner = t.get("owner")
828
- owner_part = f" (owner={owner})" if owner else ""
829
- return f"- `{sid}`{owner_part}: {sub}"
830
-
831
- lines: list[str] = []
832
- total = len(in_progress) + len(pending)
833
- lines.append(f"## ⚠️ Open todos ({total}) — you MUST continue working on these")
834
- lines.append("Do NOT end your turn until ALL todos below are completed.")
835
- if in_progress:
836
- labels = "\n".join(_label(t) for t in in_progress)
837
- lines.append(f"\nIn progress:\n{labels}")
838
- if pending:
839
- labels = "\n".join(_label(t) for t in pending)
840
- lines.append(f"\nPending:\n{labels}")
841
- lines.append(
842
- "\nFor each completed todo, call TodoUpdate(status=\"completed\") "
843
- "immediately. Then proceed to the next pending todo. "
844
- "Do NOT summarise or stop — keep working."
845
- )
846
- body = f"{marker}\n" + "\n".join(lines)
847
- if not self.context.replace_system_section(marker, body):
848
- self.context.add_system(body)
849
-
850
- def _on_todo_change(self) -> None:
851
- """Callback invoked by TodoManager whenever todos mutate.
852
-
853
- Sets a dirty flag so the next opportunity in ``chat_stream`` will
854
- emit a fresh plan event. Decoupling the callback from the emit
855
- keeps the public API synchronous and avoids re-entrancy.
856
-
857
- Subagents have an isolated TodoManager and (since Todo tools are
858
- denied via disallowed_tools for subagents) cannot mutate the parent's
859
- shared plan, so this callback only fires for the main agent's own
860
- TodoManager.
861
-
862
- Also persists todos to ``.cdh/todos.json`` immediately so they
863
- survive a crash or Ctrl+C before the next ACP turn boundary.
864
- """
865
- self._plan_dirty = True
866
- self.save_todos_to_project()
867
-
868
- @property
869
- def _workspace(self) -> Path:
870
- return self._project_dir
871
-
872
- def set_agent(self, agent_type: str) -> None:
873
- from onecode.agent.agents.types import (
874
- AgentMode,
875
- AgentPermission,
876
- SUBAGENT_CONSTRAINTS,
877
- create_agent,
878
- PLAN_GATE_HARD,
879
- PLAN_GATE_SOFT,
880
- REACT_WORKFLOW,
881
- filter_tool_descriptions,
882
- )
883
- self.current_agent = create_agent(agent_type)
884
- system_parts = [self.current_agent.description]
885
-
886
- edit_ask = self.current_agent.should_ask_for_edit()
887
- bash_ask = self.current_agent.should_ask_for_bash()
888
- if edit_ask or bash_ask:
889
- restrictions = []
890
- if edit_ask:
891
- restrictions.append("- File edits require user approval")
892
- if bash_ask:
893
- restrictions.append("- Shell commands require user approval")
894
- system_parts.append("\n".join(restrictions))
895
-
896
- if self.current_agent.mode == AgentMode.SUBAGENT:
897
- system_parts.append(SUBAGENT_CONSTRAINTS)
898
-
899
- if self.current_agent.permission_task != AgentPermission.DENY:
900
- system_parts.append(REACT_WORKFLOW)
901
- if self.current_agent.mode.name == "SUBAGENT":
902
- pass # subagents don't need plan gate
903
- elif self.current_agent.permission_edit == AgentPermission.DENY and self.current_agent.permission_bash == AgentPermission.DENY:
904
- pass # read-only mode, no gate needed
905
- elif self.current_agent.permission_task == AgentPermission.DENY:
906
- pass
907
- else:
908
- gate = PLAN_GATE_HARD if self.current_agent.mode == AgentMode.PRIMARY and self.current_agent.name == "plan" else PLAN_GATE_SOFT
909
- system_parts.append(gate)
910
-
911
- # Response style with CoT reasoning guidance.
912
- if self.current_agent.mode == AgentMode.SUBAGENT:
913
- # Subagents: simple CoT guidance without Todo/Spawn instructions.
914
- system_parts.append(
915
- "\n## Response style\n"
916
- "- **Every round must start with Chain of Thought reasoning** "
917
- "inside `<thinking>`. Review the last round's tool results, "
918
- "assess progress, and decide the next action.\n"
919
- "- If you need to reason between tool calls, wrap your "
920
- "reasoning in `<thinking>...</thinking>`. The TUI will "
921
- "render the wrapped block as a collapsible thought and "
922
- "keep it out of the main answer.\n"
923
- "- **Intermediate rounds**: visible text is for progress "
924
- "updates, status reports, and questions. Do NOT announce "
925
- '"done" or "complete" unless ALL work is actually finished.\n'
926
- "- **FINAL round** (all work done): output a visible summary "
927
- "describing what was accomplished, changed, or decided.\n"
928
- )
929
- else:
930
- system_parts.append(
931
- "\n## Response style\n"
932
- "- **Every round must start with Chain of Thought reasoning** "
933
- "inside `<thinking>`. Review the last round's tool results, "
934
- "assess progress against todos, and decide the next action. "
935
- "Then plan with `TodoCreate` and route execution by complexity.\n"
936
- "- **Plan + execution routing**:\n"
937
- " - Every task is a `TodoCreate` (persisted to .cdh/todos.json, "
938
- "sidebar Plan). No work without a todo.\n"
939
- " - Simple / single-step todo → execute directly, then "
940
- "`TodoUpdate(status=\"completed\")`.\n"
941
- " - Complex / multi-step todo → `Spawn(agent_type, prompt)` to "
942
- "delegate execution to an isolated subagent, then "
943
- "`TodoUpdate(status=\"completed\")`.\n"
944
- " - Use `TodoClear` to reset the entire plan and start fresh.\n"
945
- "- If you need to reason between tool calls, wrap your "
946
- "reasoning in `<thinking>...</thinking>`. The TUI will "
947
- "render the wrapped block as a collapsible thought and "
948
- "keep it out of the main answer.\n"
949
- "- **Intermediate rounds**: visible text is for progress "
950
- "updates, status reports, and user questions. Do NOT "
951
- 'announce "done", "complete", or "task finished" unless '
952
- "ALL todos are actually completed.\n"
953
- "- Do NOT call `TodoUpdate(status=\"completed\")` unless you "
954
- "have actually executed the work (tool calls or Spawn).\n"
955
- "- **FINAL round** (all todos completed, all work done): "
956
- "output a visible summary describing what was accomplished, "
957
- "what was changed, and any important outcomes or limitations.\n"
958
- )
959
-
960
- tagged_content = "<!-- AGENT_CONFIG -->\n" + "\n".join(system_parts)
961
- if not self.context.replace_system_section("AGENT_CONFIG", tagged_content):
962
- self.context.add_system(tagged_content)
963
-
964
- # Inject TOOL_DESCRIPTIONS as its own marker so it can be independently
965
- # stripped when the provider supports native tool schemas.
966
- tool_desc = filter_tool_descriptions(
967
- allowlist=self.current_agent.tools or None,
968
- denylist=self.current_agent.disallowed_tools or None,
969
- )
970
- tool_tagged = "<!-- TOOL_DESCRIPTIONS -->\n" + tool_desc
971
- if not self.context.replace_system_section("TOOL_DESCRIPTIONS", tool_tagged):
972
- self.context.add_system(tool_tagged)
973
-
974
- # Re-apply user permission overrides (e.g. "allow always")
975
- self._perm_store.apply_to(self.current_agent)
976
-
977
- def _strip_agent_config_tool_descriptions(self) -> None:
978
- """Remove the ``<!-- TOOL_DESCRIPTIONS -->`` system section.
979
-
980
- Called after the provider is known, for providers that support
981
- native tool schemas and don't need the prose TOOL_DESCRIPTIONS.
982
- """
983
- self.context.remove_system_by_marker("<!-- TOOL_DESCRIPTIONS -->")
984
-
985
- def get_available_tools(self) -> str:
986
- from onecode.agent.agents.types import filter_tool_descriptions
987
- return filter_tool_descriptions(
988
- allowlist=self.current_agent.tools or None,
989
- denylist=self.current_agent.disallowed_tools or None,
990
- )
991
-
992
- def _load_skills(self, force: bool = False) -> None:
993
- if self._skills_loaded and not force:
994
- return
995
-
996
- # Remove previously loaded skill-tagged messages so they don't
997
- # accumulate across agent switches or skill re-loads.
998
- self.context.remove_system_by_marker("<!-- SKILL:")
999
- self.context.remove_system_by_marker("<!-- PROJECT_DOC -->")
1000
- self.context.remove_system_by_marker("<!-- CDH_PROJECT -->")
1001
- self._skills_loaded = True
1002
-
1003
- for skill in self._skill_loader.get_enabled():
1004
- tagged = f"<!-- SKILL:{skill.name} -->\n{skill.content}"
1005
- self.context.add_system(tagged)
1006
-
1007
- # Project-level single-file doc (AGENTS.md at workspace root)
1008
- from onecode.agent.project_doc import load_project_doc
1009
- project_doc = load_project_doc(self._workspace)
1010
- if project_doc:
1011
- self.context.add_system(f"<!-- PROJECT_DOC -->\n{project_doc}")
1012
-
1013
- # Load project .cdh/ state into context
1014
- from onecode.agent.cdh_loader import CdhProjectLoader
1015
- cdh_content = CdhProjectLoader.load_for_workspace(self._workspace)
1016
- if cdh_content:
1017
- self.context.add_system(f"<!-- CDH_PROJECT -->\n{cdh_content}")
1018
-
1019
- def _inject_project_context(self, project_name: str) -> None:
1020
- if not project_name:
1021
- return
1022
- if self._project_context_loaded:
1023
- return
1024
-
1025
- self._project_context_loaded = True
1026
- self._project_config = {}
1027
-
1028
- context_parts = [
1029
- f"Project: {project_name}",
1030
- f"Platform: {self._project_config.get('platform', 'unknown')}",
1031
- ]
1032
-
1033
- env_id = self._project_config.get("cloudbase", {}).get("envId", "")
1034
- if env_id:
1035
- context_parts.append(f"TCB EnvId: {env_id}")
1036
-
1037
- self.context.add_system("\n".join(context_parts))
1038
-
1039
- def _should_retrieve_codebase(self) -> bool:
1040
- try:
1041
- cfg = self.app.config.codebase
1042
- return cfg.enabled and cfg.auto_retrieve
1043
- except Exception:
1044
- return False
1045
-
1046
- async def _get_codebase_engine(self):
1047
- if self._codebase_engine is not None:
1048
- return self._codebase_engine
1049
- try:
1050
- cfg = self.app.config.codebase
1051
- if not cfg.enabled:
1052
- return None
1053
- from onecode.codebase import CodebaseEngine
1054
- self._codebase_engine = CodebaseEngine(self._project_dir, cfg)
1055
- await self._codebase_engine.ensure_indexed()
1056
- return self._codebase_engine
1057
- except Exception as e:
1058
- logger.warning("Failed to init codebase engine: %s", e)
1059
- return None
1060
-
1061
- async def chat(self, user_input: str) -> str:
1062
- self._load_skills()
1063
-
1064
- project_name = getattr(self.app, "current_project", None) or ""
1065
- if project_name:
1066
- self._inject_project_context(project_name)
1067
-
1068
- self.context.add_user(user_input)
1069
-
1070
- self.context.config.model = self.app.current_model
1071
- if self.context.should_compact():
1072
- level = self.context.compact()
1073
- if level not in ("none",):
1074
- logger.info("Context compaction applied: %s", level)
1075
-
1076
- provider_cls = ProviderRegistry.get(self.app.current_provider)
1077
- if not provider_cls:
1078
- return f"Provider '{self.app.current_provider}' not available."
1079
-
1080
- config = self.app.config.providers.get(self.app.current_provider)
1081
- if config is None:
1082
- return f"Provider '{self.app.current_provider}' not configured."
1083
-
1084
- provider = provider_cls(
1085
- api_key=config.api_key or "",
1086
- endpoint=config.endpoint or None,
1087
- )
1088
-
1089
- response = await provider.chat(
1090
- self.context.get_context(),
1091
- model=self.app.current_model,
1092
- )
1093
-
1094
- self.context.add_assistant(response.content)
1095
- self.iterations += 1
1096
- self.total_tokens += response.usage.get("total_tokens", 0)
1097
- return response.content
1098
-
1099
- def _parse_tool_calls(self, text: str) -> list[dict]:
1100
- calls = []
1101
- for match in TOOL_CALL_RE.finditer(text):
1102
- name = match.group(1)
1103
- call_id = match.group(2)
1104
- raw_input = match.group(3).strip()
1105
- try:
1106
- parsed_input = json.loads(raw_input) if raw_input else {}
1107
- except json.JSONDecodeError:
1108
- parsed_input = {"raw": raw_input}
1109
- calls.append({"name": name, "id": call_id, "input": parsed_input})
1110
- return calls
1111
-
1112
- def _validate_edit(self, path: str) -> str | None:
1113
- try:
1114
- content = self.file_ops.read(path, 0, 0)
1115
- if content and "error" not in str(content).lower():
1116
- return None
1117
- return f"Warning: File may not have been written correctly: {path}"
1118
- except Exception as e:
1119
- return f"Warning: Could not verify file: {e}"
1120
-
1121
- def _tool_category(self, name: str) -> str:
1122
- """Map tool name to category for structured display."""
1123
- from onecode.models.messages import get_tool_category
1124
- return get_tool_category(name).value
1125
-
1126
- _TOOL_NAME_TO_PERM_KEY: dict[str, str] = {
1127
- "Read": "read",
1128
- "Write": "edit",
1129
- "Edit": "edit",
1130
- "Insert": "edit",
1131
- "UndoEdit": "edit",
1132
- "ApplyPatch": "edit",
1133
- "Bash": "bash",
1134
- "WebFetch": "webfetch",
1135
- "WebSearch": "websearch",
1136
- "Glob": "glob",
1137
- "Grep": "grep",
1138
- "List": "list",
1139
- "Spawn": "task",
1140
- "Agent": "task",
1141
- "Skill": "skill",
1142
- "codebase_search": "read",
1143
- "TodoCreate": "todowrite",
1144
- "TodoGet": "todowrite",
1145
- "TodoList": "todowrite",
1146
- "TodoUpdate": "todowrite",
1147
- "TodoOutput": "todowrite",
1148
- "TodoStop": "todowrite",
1149
- "TodoClear": "todowrite",
1150
- }
1151
-
1152
- def _check_tool_permission(self, name: str, inp: dict) -> str | None:
1153
- """Unified agent-level permission check for all tools."""
1154
- from onecode.agent.agents.types import AgentPermission
1155
- # Enforce agent allow/deny lists at runtime. This is the runtime
1156
- # safety net behind filter_tool_descriptions(): even if the LLM
1157
- # still emits a tool the system prompt hid (e.g. TodoCreate in a
1158
- # subagent, after context compaction re-introduced the name), the
1159
- # call is denied here rather than leaking through.
1160
- if not self.current_agent.tool_allowed(name):
1161
- return json.dumps({"success": False, "error": f"{name} denied (disallowed for {self.current_agent.name})"})
1162
- perm_key = self._TOOL_NAME_TO_PERM_KEY.get(name)
1163
- if perm_key is None:
1164
- return None
1165
- tools_config = self.current_agent.get_tools_config()
1166
- perm = tools_config.get(perm_key, AgentPermission.ALLOW)
1167
- if perm == AgentPermission.DENY:
1168
- return json.dumps({"success": False, "error": f"{name} denied"})
1169
- if perm == AgentPermission.ASK:
1170
- return json.dumps({"success": False, "error": f"{name} requires approval", "requires_approval": True})
1171
- return None
1172
-
1173
- async def _execute_tool(self, tool_call: dict) -> dict:
1174
- from onecode.agent.tools.registry import ToolCall as RegistryToolCall
1175
- name = tool_call["name"]
1176
- tid = tool_call["id"]
1177
- inp = tool_call["input"]
1178
- category = self._tool_category(name)
1179
- base = {"tool_use_id": tid, "is_error": False, "category": category}
1180
- try:
1181
- denied = self._check_tool_permission(name, inp)
1182
- if denied:
1183
- return {**base, "content": denied, "is_error": True}
1184
-
1185
- call = RegistryToolCall(name=name, input=inp, tool_use_id=tid)
1186
- result = await self._tool_registry.dispatch_async(
1187
- call, cancel_check=lambda: self._cancelled,
1188
- )
1189
-
1190
- # Handle SendMessage tracking
1191
- if name == "SendMessage":
1192
- msg_output = result.output if isinstance(result.output, dict) else {}
1193
- self._last_user_msg = msg_output.get("message", "")
1194
- return {**base, "content": json.dumps(result.output), "is_error": result.is_error}
1195
-
1196
- # Format output for backward compatibility
1197
- output = self._format_tool_output(result)
1198
- return {**base, "content": output, "is_error": result.is_error}
1199
- except Exception as e:
1200
- logger.exception(f"Tool execution error: {e}")
1201
- return {**base, "content": f"Error: {safe_error_msg(e)}", "is_error": True}
1202
-
1203
- def _format_tool_output(self, result: RegistryToolResult) -> str: # noqa: F821
1204
- import json
1205
- output = result.output
1206
- if isinstance(output, str):
1207
- return output
1208
- try:
1209
- return json.dumps(output)
1210
- except (TypeError, ValueError):
1211
- return str(output)
1212
-
1213
- async def cancel(self):
1214
- """Cancel the current chat_stream turn and all in-flight subagents."""
1215
- self._cancelled = True
1216
- for child in self._child_engines:
1217
- child._cancelled = True
1218
-
1219
- async def shutdown(self):
1220
- """Release OS resources held by this engine (LSP, MCP, cron)."""
1221
- self._cancelled = True
1222
- for child in self._child_engines:
1223
- child._cancelled = True
1224
- self._lsp_tool.stop_all()
1225
- await self._mcp.disconnect_all()
1226
- self._cron_scheduler.stop_loop()
1227
-
1228
- def __del__(self):
1229
- """Fallback cleanup on garbage collection."""
1230
- self._lsp_tool.stop_all()
1231
- self._cron_scheduler.stop_loop()
1232
-
1233
- async def chat_stream(self, user_input: str | list[dict]) -> AsyncIterator[StreamEvent | str]:
1234
- self._load_skills()
1235
- preview = user_input[:100] if isinstance(user_input, str) else f"[{len(user_input)} content blocks]"
1236
- logger.info(f"chat_stream() called with user_input='{preview}'")
1237
-
1238
- project_name = getattr(self.app, "current_project", None) or ""
1239
- if project_name:
1240
- was_context_loaded = self._project_context_loaded
1241
- self._inject_project_context(project_name)
1242
- if not was_context_loaded:
1243
- yield StreamEvent.text_delta(
1244
- f"\n📋 项目: {project_name}\n继续开发中...\n\n"
1245
- )
1246
- else:
1247
- pass
1248
-
1249
- # Emit initial plan so the TUI Plan widget is mounted (or refreshed
1250
- # to the current snapshot) at the start of every turn. This is
1251
- # required because the Plan widget only renders when an ``entries``
1252
- # value arrives — without this, the widget never appears until the
1253
- # agent happens to call a task tool.
1254
- for event in self._emit_plan_update():
1255
- yield event
1256
- self._plan_dirty = False
1257
-
1258
- # ── Codebase auto-retrieval ──
1259
- # Skipped entirely for subagent engines (see _disable_retrieval)
1260
- # to avoid the 2.3s+ indexer loop and the per-chunk embedding httpx
1261
- # calls that block the event loop and freeze streaming/cancel.
1262
- if (
1263
- isinstance(user_input, str)
1264
- and self._should_retrieve_codebase()
1265
- and not self._disable_retrieval
1266
- ):
1267
- try:
1268
- engine = await self._get_codebase_engine()
1269
- if engine:
1270
- chunks = await engine.retrieve(user_input)
1271
- ctx_text = engine.format_context(
1272
- chunks, max_tokens=self.app.config.codebase.max_chunk_tokens
1273
- )
1274
- if ctx_text:
1275
- if not self.context.replace_system_section("CODEBASE", ctx_text):
1276
- self.context.add_system(f"<!-- CODEBASE -->\n{ctx_text}")
1277
- except Exception as e:
1278
- logger.warning("Codebase retrieval failed: %s", e)
1279
-
1280
- # ── Long-term memory recall ──
1281
- if (
1282
- isinstance(user_input, str)
1283
- and self._session
1284
- and self.app.config.memory.enabled
1285
- and self.app.config.memory.auto_recall
1286
- and not self._disable_retrieval
1287
- ):
1288
- try:
1289
- top_k = self.app.config.memory.top_k
1290
- results = self._memory.search_memories(user_input, top_k=top_k)
1291
- if results:
1292
- lines = ["## Relevant past memories"]
1293
- for r in results:
1294
- lines.append(r.content[:300])
1295
- ctx_text = "<!-- MEMORY -->\n" + "\n".join(lines)
1296
- if not self.context.replace_system_section("MEMORY", ctx_text):
1297
- self.context.add_system(ctx_text)
1298
- except Exception as e:
1299
- logger.warning("Memory recall failed: %s", e)
1300
-
1301
- self.context.add_user(user_input)
1302
-
1303
- # Plan gate: activate based on agent mode, not content heuristics.
1304
- # Gate fires reactively when agent tries execution tools without a plan.
1305
- self._plan_gate_mode = self._resolve_plan_gate_mode()
1306
-
1307
- self.context.config.model = self.app.current_model
1308
- if self.context.should_compact():
1309
- level = self.context.compact()
1310
- if level not in ("none",):
1311
- logger.info("Context compaction at turn start: %s", level)
1312
-
1313
- provider_name = self.app.current_provider
1314
- model_name = self.app.current_model
1315
- logger.info(f"Using provider='{provider_name}', model='{model_name}'")
1316
- logger.info(
1317
- "chat_stream engine state: project=%s agent=%s todos=%d context_msgs=%d ctx_tokens=%d",
1318
- project_name or "(none)",
1319
- self.current_agent.name if self.current_agent else "None",
1320
- len(self._todo_manager.list_todos()),
1321
- len(self.context.messages),
1322
- self.context._token_count,
1323
- )
1324
-
1325
- provider_cls = ProviderRegistry.get(provider_name)
1326
- if not provider_cls:
1327
- available = list(ProviderRegistry._registry.keys()) if hasattr(ProviderRegistry, '_registry') else "unknown"
1328
- error_msg = f"Provider '{provider_name}' not available. Available: {available}"
1329
- logger.error(error_msg)
1330
- yield StreamEvent.error(error_msg)
1331
- return
1332
-
1333
- config = self.app.config.providers.get(provider_name)
1334
- if config is None:
1335
- available = list(self.app.config.providers.keys())
1336
- error_msg = f"Provider '{provider_name}' not configured. Available providers: {available}"
1337
- logger.error(error_msg)
1338
- yield StreamEvent.error(error_msg)
1339
- return
1340
-
1341
- logger.info(f"Creating provider instance: {provider_cls.__name__}")
1342
- provider_kwargs = dict(api_key=config.api_key or "", endpoint=config.endpoint or None)
1343
- provider = provider_cls(**provider_kwargs)
1344
-
1345
- # If the provider supports native tool schemas (OpenAI/Anthropic/DeepSeek
1346
- # etc.), strip the prose TOOL_DESCRIPTIONS from system context to save
1347
- # tokens. The structured ``tools`` kwarg provides the same info.
1348
- if not hasattr(provider, 'supports_native_tools') or provider.supports_native_tools():
1349
- self._strip_agent_config_tool_descriptions()
1350
-
1351
- # Reset per-turn usage tracking
1352
- self._turn_usages = []
1353
-
1354
- # Reset cancellation flag (adapter also resets it before calling)
1355
- self._cancelled = False
1356
-
1357
- # ── Agent loop: 思考 → Todo → 行动 (per-Round) ──
1358
- is_anthropic = provider.is_anthropic_style()
1359
- max_turns = self.current_agent.max_turns or 10
1360
-
1361
- cot_phase_init = "<!-- REACT_PHASE -->\n## Round 1 — 思考 → Todo → 行动\n"
1362
- if not self.context.replace_system_section("REACT_PHASE", cot_phase_init):
1363
- self.context.add_system(cot_phase_init)
1364
-
1365
- for turn in range(max_turns):
1366
- if self._cancelled:
1367
- yield StreamEvent.text_delta("\n\n*Turn cancelled*\n\n")
1368
- return
1369
-
1370
- # ── Thought Phase: update CoT reasoning guidance for this turn ──
1371
- # Cleanup stale reminders that may have accumulated
1372
- self.context.remove_system_by_marker("<!-- ROUTING_REMINDER -->")
1373
- self.context.remove_system_by_marker("<!-- PLAN_REMINDER -->")
1374
- # Inject (or refresh) a nudge that prioritizes any unfinished todos
1375
- # from this session. The marker is replaced in-place so the context
1376
- # stays bounded — the agent always sees the current open list.
1377
- self._refresh_pending_todos_nudge()
1378
- if turn > 0:
1379
- self.context.replace_system_section(
1380
- "REACT_PHASE",
1381
- f"<!-- REACT_PHASE -->\n## Round {turn + 1} — 思考 → Todo → 行动\n",
1382
- )
1383
-
1384
- self._pending_thinking_blocks = []
1385
-
1386
- self.context.config.model = self.app.current_model
1387
- if self.context.should_compact():
1388
- level = self.context.compact()
1389
- if level not in ("none",):
1390
- logger.info("Context compaction at turn %d: %s", turn, level)
1391
-
1392
- turn_usage: dict[str, int] = {}
1393
- response_text = ""
1394
- tool_uses: list[dict] = []
1395
-
1396
- try:
1397
- context_messages = self.context.get_context()
1398
- logger.info(f"Calling provider.chat_stream_response (turn {turn+1})")
1399
- self._streaming_used = False
1400
- if self.on_text_chunk is not None:
1401
- _original_cb = self.on_text_chunk
1402
- def _stream_wrapper(text: str) -> None:
1403
- self._streaming_used = True
1404
- if self._cancelled:
1405
- raise TurnCancelledError()
1406
- _original_cb(text)
1407
- stream_cb = _stream_wrapper
1408
- else:
1409
- stream_cb = None
1410
- chat_response = await provider.chat_stream_response(
1411
- context_messages,
1412
- model=model_name,
1413
- on_text_chunk=stream_cb,
1414
- on_tool_call_delta=self.on_tool_call_delta,
1415
- tools=self._tool_registry.make_openai_schemas(),
1416
- )
1417
- response_text = chat_response.content
1418
- tool_uses = chat_response.tool_uses
1419
- if not tool_uses and (
1420
- "<minimax:tool_call>" in response_text
1421
- or _LEGACY_OPEN in response_text
1422
- ):
1423
- parsed_uses, response_text, self._tool_id_counter = (
1424
- _extract_minimax_tool_uses(
1425
- response_text, id_start=self._tool_id_counter
1426
- )
1427
- )
1428
- legacy_uses, response_text, self._tool_id_counter = (
1429
- _extract_legacy_tool_uses(
1430
- response_text, id_start=self._tool_id_counter
1431
- )
1432
- )
1433
- tool_uses = parsed_uses + legacy_uses
1434
- if tool_uses:
1435
- logger.info(
1436
- "Extracted %d text tool uses (%d minimax, %d legacy) from response",
1437
- len(tool_uses), len(parsed_uses), len(legacy_uses),
1438
- )
1439
- if chat_response.usage:
1440
- turn_usage = chat_response.usage
1441
- except NotImplementedError:
1442
- logger.info("chat_stream_response not supported, falling back to non-streaming chat()")
1443
- try:
1444
- context_messages = self.context.get_context()
1445
- model_response = await provider.chat(
1446
- context_messages,
1447
- model=model_name,
1448
- tools=self._tool_registry.make_openai_schemas(),
1449
- )
1450
- response_text = model_response.get_text()
1451
- tool_uses = [
1452
- {"id": tu.id, "name": tu.name, "input": tu.input}
1453
- for cb in model_response.content
1454
- if cb.type == ContentBlockType.TOOL_USE and cb.tool_use
1455
- for tu in [cb.tool_use]
1456
- ]
1457
- if not tool_uses and (
1458
- "<minimax:tool_call>" in response_text
1459
- or _LEGACY_OPEN in response_text
1460
- ):
1461
- parsed_uses, response_text, self._tool_id_counter = (
1462
- _extract_minimax_tool_uses(
1463
- response_text, id_start=self._tool_id_counter
1464
- )
1465
- )
1466
- legacy_uses, response_text, self._tool_id_counter = (
1467
- _extract_legacy_tool_uses(
1468
- response_text, id_start=self._tool_id_counter
1469
- )
1470
- )
1471
- tool_uses = parsed_uses + legacy_uses
1472
- if tool_uses:
1473
- logger.info(
1474
- "Extracted %d text tool uses (%d minimax, %d legacy) from chat() fallback",
1475
- len(tool_uses), len(parsed_uses), len(legacy_uses),
1476
- )
1477
- if model_response.usage:
1478
- turn_usage = model_response.usage
1479
- except Exception as e:
1480
- logger.exception(f"Error during chat() fallback turn {turn+1}: {e}")
1481
- yield StreamEvent.error(safe_error_msg(e))
1482
- break
1483
- except TurnCancelledError:
1484
- yield StreamEvent.text_delta("\n\n*Turn cancelled*\n\n")
1485
- return
1486
- except Exception as e:
1487
- ctx_msgs = len(self.context.messages)
1488
- ctx_tokens = self.context._token_count
1489
- logger.exception(
1490
- f"Error during chat_stream_response turn {turn+1}: {e}\n"
1491
- f" provider={provider_name} model={model_name} "
1492
- f"context_msgs={ctx_msgs} ctx_tokens={ctx_tokens}"
1493
- )
1494
- yield StreamEvent.error(f"Provider error (turn {turn+1}): {safe_error_msg(e)}")
1495
- break
1496
-
1497
- # Check cancellation after provider call (covers non-streaming
1498
- # responses and cases where on_text_chunk was never called).
1499
- if self._cancelled:
1500
- yield StreamEvent.text_delta("\n\n*Turn cancelled*\n\n")
1501
- return
1502
-
1503
- # Track usage
1504
- self._turn_usages.append(turn_usage)
1505
- if turn_usage:
1506
- self.total_tokens += turn_usage.get("total_tokens", 0)
1507
-
1508
- # Extract thinking blocks from response. In the streaming path
1509
- # the adapter's _make_stream_callback already stripped <thinking>
1510
- # markers from the text and routed them through self.on_thinking
1511
- # (which appends to _pending_thinking_blocks). The non-streaming
1512
- # fallback still has the markers inline, so we parse them here.
1513
- thinking_blocks = list(self._pending_thinking_blocks)
1514
- clean_text = response_text
1515
- for match in THINKING_RE.finditer(response_text):
1516
- tb = match.group(1)
1517
- if tb not in thinking_blocks:
1518
- thinking_blocks.append(tb)
1519
- if thinking_blocks or self._pending_thinking_blocks:
1520
- clean_text = THINKING_RE.sub('', response_text).strip()
1521
- if not self._streaming_used:
1522
- for tb in thinking_blocks:
1523
- yield StreamEvent.thinking(tb)
1524
-
1525
- # Log the raw model response so developers can verify the
1526
- # model is actually emitting ``<thinking>`` markers (and not
1527
- # bleeding planning prose into the visible answer). Goes
1528
- # to the onecode root logger → ~/.onecode/logs/onecode.log when
1529
- # ``setup_logging(DEBUG)`` is in effect.
1530
- logger.debug(
1531
- "RAW_RESPONSE turn=%d text_len=%d first_200=%r "
1532
- "thinking_blocks=%d tool_uses=%d streaming_used=%s",
1533
- turn + 1,
1534
- len(response_text),
1535
- response_text[:200],
1536
- len(thinking_blocks),
1537
- len(tool_uses),
1538
- self._streaming_used,
1539
- )
1540
-
1541
- # Add assistant response to context with proper content blocks.
1542
- # Persist thinking blocks so they survive session reload — the
1543
- # TUI replays them as collapsible Thought widgets.
1544
- if tool_uses or thinking_blocks:
1545
- assistant_blocks: list = []
1546
- for tb in thinking_blocks:
1547
- assistant_blocks.append({"type": "thinking", "thinking": tb})
1548
- if clean_text:
1549
- assistant_blocks.append({"type": "text", "text": clean_text})
1550
- for tu in tool_uses:
1551
- assistant_blocks.append({
1552
- "type": "tool_use",
1553
- "id": tu["id"],
1554
- "name": tu["name"],
1555
- "input": tu["input"],
1556
- })
1557
- self.context.add_assistant(assistant_blocks)
1558
- else:
1559
- self.context.add_assistant(clean_text)
1560
-
1561
- self.iterations += 1
1562
-
1563
- # Always yield text content as TEXT_DELTA, regardless of tool uses.
1564
- # Without this, subagent text is consumed silently when it also
1565
- # emits tool calls — the text goes to context but never reaches
1566
- # _spawn_subagent_async_streaming / the subagent_chunk stream.
1567
- if not self._streaming_used and clean_text.strip():
1568
- for i in range(0, len(clean_text), 12):
1569
- yield StreamEvent.text_delta(clean_text[i:i + 12])
1570
-
1571
- if not tool_uses:
1572
- # ── Store conversation in long-term memory ──
1573
- if self._session:
1574
- try:
1575
- conv_id = self._session.id
1576
- input_str = user_input if isinstance(user_input, str) else str(user_input)
1577
- self._memory.remember(
1578
- MemoryLayer.L0_CONVERSATION,
1579
- f"user: {input_str}\nassistant: {clean_text}",
1580
- {"conversation_id": conv_id},
1581
- )
1582
- except Exception as e:
1583
- logger.warning("Memory store failed: %s", e)
1584
-
1585
- # ── Force continuation if todos remain ──
1586
- # After subagents complete, the LLM may stop calling tools
1587
- # because the structured output strongly signals "done".
1588
- # If there are still pending/in-progress todos, inject a
1589
- # forceful continuation nudge and loop again instead of
1590
- # exiting the turn loop.
1591
- self._empty_tool_turns += 1
1592
- if self._empty_tool_turns >= 3:
1593
- logger.warning(
1594
- "LLM produced %d consecutive empty turns with %d "
1595
- "remaining todos — breaking loop",
1596
- self._empty_tool_turns,
1597
- sum(1 for t in self._todo_manager.list_todos()
1598
- if t.get("status") in ("pending", "in_progress")),
1599
- )
1600
- break
1601
- if self._has_pending_todos():
1602
- open_count = sum(
1603
- 1 for t in self._todo_manager.list_todos()
1604
- if t.get("status") in ("pending", "in_progress")
1605
- )
1606
- logger.info(
1607
- "LLM stopped calling tools but %d pending todos remain — "
1608
- "injecting FORCE_CONTINUE nudge (empty_tool_turns=%d)",
1609
- open_count, self._empty_tool_turns,
1610
- )
1611
- # Auto-advance: since the LLM is stalled, find the next
1612
- # pending todo and inject a direct instruction.
1613
- next_todo = None
1614
- for t in self._todo_manager.list_todos():
1615
- if t.get("status") == "pending":
1616
- next_todo = t
1617
- break
1618
- if next_todo:
1619
- lines = [
1620
- "# Continue working",
1621
- "",
1622
- f"The next pending todo is `{next_todo['id']}`: "
1623
- f"{next_todo.get('subject', '')}",
1624
- "",
1625
- "Execute it now. Do NOT summarise or stop.",
1626
- ]
1627
- self.context.add_message(
1628
- "user",
1629
- [{"type": "text", "text": "\n".join(lines)}],
1630
- )
1631
- else:
1632
- lines = [
1633
- "<!-- FORCE_CONTINUE -->",
1634
- "## CRITICAL: Unfinished todos detected",
1635
- "You stopped without completing all planned todos.",
1636
- "You MUST continue working:",
1637
- "1. Call TodoUpdate(status=\"completed\") for any finished work.",
1638
- "2. Then Spawn or execute the next pending todo.",
1639
- "3. Repeat until ALL todos are Done.",
1640
- "Do NOT stop. Do NOT summarise. Keep executing.",
1641
- ]
1642
- self.context.add_message(
1643
- "user",
1644
- [{"type": "text", "text": "\n".join(lines)}],
1645
- )
1646
- continue
1647
- # No pending todos and no tool calls: work appears done.
1648
- # Guard: if no todos were ever created after multiple rounds,
1649
- # the agent may have done work without tracking it. Inject a
1650
- # one-time confirmation prompt before giving the final break.
1651
- all_todos = self._todo_manager.list_todos()
1652
- if not all_todos and self.iterations > 1:
1653
- confirm_lines = [
1654
- "# Final check",
1655
- "",
1656
- "You haven't created any todos in this session. "
1657
- "Are you sure all work is complete?",
1658
- "",
1659
- "If work remains → `TodoCreate` and continue.",
1660
- "If done → output a visible summary of what you accomplished.",
1661
- ]
1662
- self.context.add_message(
1663
- "user",
1664
- [{"type": "text", "text": "\n".join(confirm_lines)}],
1665
- )
1666
- self._empty_tool_turns += 1
1667
- continue
1668
- break
1669
-
1670
- # Emit ToolEvents (Clawd-Code pattern) and StreamEvents for TUI
1671
- for tu in tool_uses:
1672
- self._notify_event(ToolEvent(
1673
- kind="tool_use",
1674
- tool_name=tu["name"],
1675
- tool_input=tu["input"],
1676
- tool_use_id=tu["id"],
1677
- ))
1678
- yield StreamEvent.tool_call_start(tu["name"], tu["id"])
1679
- for tu in tool_uses:
1680
- yield StreamEvent.tool_call_complete(tu["id"], tu["name"], tu["input"])
1681
-
1682
- for tu in tool_uses:
1683
- if self._cancelled:
1684
- yield StreamEvent.text_delta("\n\n*Turn cancelled*\n\n")
1685
- return
1686
- logger.info(f"Executing tool: {tu['name']} (id={tu['id']})")
1687
-
1688
- # Spawn tool: forward subagent text deltas to the TUI as
1689
- # subagent_chunk events so the SubAgent widget actually has
1690
- # content to render. The Spawn tool's spec is (agent_type,
1691
- # prompt); we read them from the tool input.
1692
- if tu["name"] == "Spawn":
1693
- # Permission check
1694
- denied = self._check_tool_permission("Spawn", tu["input"])
1695
- if denied:
1696
- result = {
1697
- "tool_use_id": tu["id"],
1698
- "is_error": True,
1699
- "category": "task",
1700
- "content": denied,
1701
- }
1702
- else:
1703
- subagent_type = tu["input"].get("agent_type", "general")
1704
- subagent_prompt = tu["input"].get("prompt", "")
1705
- _sp_id = tu["id"]
1706
- _sp_bytes_fwd = 0
1707
- _sp_chunk_count = 0
1708
- logger.debug(
1709
- "[SPAWN-PARENT %s] start subagent_type=%s prompt_len=%d "
1710
- "tool_id=%s cancelled=%s",
1711
- _sp_id, subagent_type, len(subagent_prompt),
1712
- _sp_id, self._cancelled,
1713
- )
1714
- is_failed = False
1715
- error_msg = ""
1716
- yield StreamEvent.subagent_start(subagent_type, tu["id"], subagent_prompt)
1717
- accumulated: list[str] = []
1718
- try:
1719
- async for sub_event, sub_text in self._spawn_subagent_async_streaming(
1720
- subagent_type, subagent_prompt, subagent_id=tu["id"]
1721
- ):
1722
- _sp_chunk_count += 1
1723
- if self._cancelled:
1724
- logger.debug(
1725
- "[SPAWN-PARENT %s] cancelled mid-iter "
1726
- "chunk=%d → break", _sp_id, _sp_chunk_count)
1727
- break
1728
- if sub_event.type == StreamEventType.SUBAGENT_END:
1729
- if hasattr(sub_event, "subagent_status") and sub_event.subagent_status == "failed":
1730
- is_failed = True
1731
- error_msg = sub_event.subagent_error or ""
1732
- logger.debug(
1733
- "[SPAWN-PARENT %s] recv SUBAGENT_END "
1734
- "status=%s → continue",
1735
- _sp_id, getattr(sub_event, "subagent_status", "?"),
1736
- )
1737
- continue # 修复 Bug 1: 不 append final combined text(避免文本重复)
1738
- if sub_event.type == StreamEventType.SUBAGENT_THINKING:
1739
- yield StreamEvent.subagent_thinking(tu["id"], sub_text)
1740
- continue
1741
- # Structured subagent tool events: forward as-is
1742
- # (they already carry subagent_id=tu["id"]) so the
1743
- # ACP emits tool_call/tool_call_update sessionUpdates
1744
- # tagged with subagentId and the TUI renders real
1745
- # ToolCall cards inside the SubAgent widget.
1746
- if sub_event.type in (
1747
- StreamEventType.SUBAGENT_TOOL_CALL,
1748
- StreamEventType.SUBAGENT_TOOL_RESULT,
1749
- ):
1750
- yield sub_event
1751
- continue
1752
- if not sub_text:
1753
- continue
1754
- accumulated.append(sub_text)
1755
- _sp_bytes_fwd += len(sub_text)
1756
- if _sp_chunk_count <= 5 or _sp_chunk_count % 50 == 0:
1757
- logger.debug(
1758
- "[SPAWN-PARENT %s] fwd chunk#%d bytes=%d "
1759
- "total=%d",
1760
- _sp_id, _sp_chunk_count, len(sub_text),
1761
- _sp_bytes_fwd,
1762
- )
1763
- yield StreamEvent.subagent_chunk(tu["id"], sub_text)
1764
- except asyncio.CancelledError:
1765
- logger.debug(
1766
- "[SPAWN-PARENT %s] CancelledError → subagent_end "
1767
- "failed=cancelled + re-raise", _sp_id)
1768
- yield StreamEvent.subagent_end(
1769
- tu["id"],
1770
- agent_type=subagent_type,
1771
- status="failed",
1772
- error="cancelled",
1773
- )
1774
- raise
1775
- logger.debug(
1776
- "[SPAWN-PARENT %s] subagent done fwd_chunks=%d "
1777
- "bytes_fwd=%d is_failed=%s err=%r → yield subagent_end",
1778
- _sp_id, _sp_chunk_count, _sp_bytes_fwd, is_failed,
1779
- error_msg[:80],
1780
- )
1781
- yield StreamEvent.subagent_end(
1782
- tu["id"],
1783
- agent_type=subagent_type,
1784
- status="failed" if is_failed else "completed",
1785
- error=error_msg,
1786
- )
1787
- raw_output = "".join(accumulated)
1788
- logger.debug(
1789
- "[SPAWN-PARENT %s] formatting result raw_len=%d "
1790
- "is_failed=%s", _sp_id, len(raw_output), is_failed,
1791
- )
1792
- formatted = self._format_subagent_output(
1793
- subagent_type, subagent_prompt, raw_output
1794
- ) if not is_failed else (
1795
- f"SUMMARY:\nSub-agent failed with error: {error_msg}\n\n"
1796
- "CHANGES:\nNone.\n\nEVIDENCE:\nNone.\n\nRISKS:\nNone.\n\n"
1797
- f"BLOCKERS:\n{error_msg}"
1798
- )
1799
- result = {
1800
- "tool_use_id": tu["id"],
1801
- "is_error": is_failed, # 修复 Bug 2: 子智能体失败时 is_error=True
1802
- "category": "task",
1803
- "content": formatted,
1804
- "raw_content": raw_output,
1805
- }
1806
- # Auto-advance todo after subagent completes
1807
- if not is_failed:
1808
- self._auto_advance_after_spawn(subagent_prompt)
1809
- elif (self._plan_gate_mode != "off"
1810
- and not self._todo_manager.list_todos()
1811
- and tu["name"] in _EXECUTION_TOOLS
1812
- and self._plan_gate_first_turn()):
1813
- if self._plan_gate_mode == "hard":
1814
- result = {
1815
- "tool_use_id": tu["id"],
1816
- "is_error": True,
1817
- "category": "todo",
1818
- "content": json.dumps({
1819
- "success": False,
1820
- "error": (
1821
- "Plan required. You are in plan mode (ReAct: Thought → Action → Observation). "
1822
- "Before using execution tools, create a todo plan via `TodoCreate()`. "
1823
- "Choose the right granularity:\n"
1824
- "- Each todo = one focused unit of work (file, function, or concern).\n"
1825
- "- Set `addBlockedBy` on dependent todos to express the DAG.\n"
1826
- "- For complex multi-step work, mark the todo with "
1827
- "`metadata={\"delegate_to\": \"general\"}` to indicate it should be "
1828
- "executed by a `Spawn` subagent rather than direct tool calls.\n"
1829
- "Create ALL todos upfront, present the plan for user review, "
1830
- "and only then proceed to execute."
1831
- ),
1832
- }),
1833
- }
1834
- else:
1835
- # Soft gate: execute but inject a routing reminder for next turn
1836
- self.context.add_system(
1837
- "<!-- PLAN_REMINDER -->\n"
1838
- "## Routing Decision\n"
1839
- "Every task is a todo: create it with `TodoCreate` first (it "
1840
- "persists to .cdh/todos.json and shows in the sidebar Plan).\n"
1841
- "Then route EXECUTION by complexity:\n\n"
1842
- "**Simple / single-step** → execute the todo directly with "
1843
- "`Read`/`Edit`/`Bash`, then `TodoUpdate(status=\"completed\")`.\n\n"
1844
- "**Complex / multi-step / needs isolated context** → "
1845
- "`Spawn(agent_type, prompt)` delegates to a focused subagent; "
1846
- "on return, `TodoUpdate(status=\"completed\")`.\n\n"
1847
- "**Plan vs execution**: TodoCreate = planning (always); "
1848
- "Spawn = execution delegation (complex only). They are not "
1849
- "alternatives — Spawn executes a todo, it does not replace one.\n"
1850
- )
1851
- result = await self._execute_tool(tu)
1852
- else:
1853
- result = await self._execute_tool(tu)
1854
- # Routing: track direct execution tool use → nudge routing decision
1855
- if tu["name"] in _EXECUTION_TOOLS:
1856
- self._direct_execution_count += 1
1857
- if self._direct_execution_count >= 2:
1858
- self.context.add_system(
1859
- "<!-- ROUTING_REMINDER -->\n"
1860
- "You've used direct execution tools repeatedly. Pause and decide:\n"
1861
- "- Is this work tracked by a todo? If not, `TodoCreate` first.\n"
1862
- "- If the todo is a single-step trivial change → continue, then "
1863
- "`TodoUpdate(status=\"completed\")`.\n"
1864
- "- If this work involves >1 tool call, multiple files, or "
1865
- "research → STOP direct execution and delegate via "
1866
- "`Spawn(agent_type=\"general\", prompt=\"...\")` instead. The "
1867
- "subagent isolates context and returns a structured summary; "
1868
- "then mark the todo completed."
1869
- )
1870
- result_str = str(result.get("content", ""))
1871
- is_error = result.get("is_error", False)
1872
- category = result.get("category", "unknown")
1873
- # For Spawn tools, use raw subagent output for TUI display
1874
- # (no SUMMARY/CHANGES/… structured wrapper)
1875
- tui_content = str(result.get("raw_content", result_str))
1876
-
1877
- # Handle SendMessage — user-visible only, skip from LLM context
1878
- if tu["name"] == "SendMessage":
1879
- try:
1880
- parsed = json.loads(result_str) if result_str else {}
1881
- msg = parsed.get("message", "") if isinstance(parsed, dict) else ""
1882
- if msg:
1883
- self._last_user_msg = msg
1884
- yield StreamEvent.text_delta(f"\n💬 {msg}\n")
1885
- except (json.JSONDecodeError, ValueError):
1886
- pass
1887
- self._notify_event(ToolEvent(
1888
- kind="tool_result",
1889
- tool_name="SendMessage",
1890
- tool_use_id=tu["id"],
1891
- tool_output={"message": self._last_user_msg},
1892
- ))
1893
- continue
1894
-
1895
- # Handle AskUser — trigger user interaction dialog, don't add to LLM context yet
1896
- if tu["name"] == "AskUser":
1897
- parsed = json.loads(result_str) if result_str else {}
1898
- question = parsed.get("question", "") if isinstance(parsed, dict) else ""
1899
- options = parsed.get("options", []) if isinstance(parsed, dict) else []
1900
- questions = parsed.get("questions", []) if isinstance(parsed, dict) else []
1901
-
1902
- # Check for auto-default on single question + single option
1903
- if not questions and len(options) == 1 and options[0].get("default"):
1904
- default_val = options[0]["value"]
1905
- self._pending_approval = None
1906
- yield StreamEvent.tool_result(
1907
- call_id=tu["id"],
1908
- content=json.dumps({"answer": default_val}),
1909
- )
1910
- continue
1911
-
1912
- # Auto-default for multi-question: skip questions that have
1913
- # a single option with default=true
1914
- if questions:
1915
- auto_answers = {}
1916
- remaining = []
1917
- for i, q in enumerate(questions):
1918
- qopts = q.get("options", [])
1919
- if len(qopts) == 1 and qopts[0].get("default"):
1920
- auto_answers[str(i)] = qopts[0]["value"]
1921
- else:
1922
- remaining.append(q)
1923
- if not remaining:
1924
- self._pending_approval = None
1925
- yield StreamEvent.tool_result(
1926
- call_id=tu["id"],
1927
- content=json.dumps({"answers": auto_answers}),
1928
- )
1929
- continue
1930
- questions = remaining
1931
-
1932
- has_questions = bool(questions)
1933
- self._pending_approval = {"tool_call": tu, "category": category, "ask_user": True}
1934
- if has_questions:
1935
- yield StreamEvent.ask_user(
1936
- call_id=tu["id"],
1937
- action="AskUser",
1938
- question="", # not used when questions is present
1939
- context=result_str,
1940
- options=[],
1941
- questions=questions,
1942
- action_type="ask_user",
1943
- )
1944
- else:
1945
- yield StreamEvent.ask_user(
1946
- call_id=tu["id"],
1947
- action="AskUser",
1948
- question=question,
1949
- context=result_str,
1950
- options=options,
1951
- action_type="ask_user",
1952
- )
1953
- continue
1954
-
1955
- # Detect ASK permission denial
1956
- requires_approval = False
1957
- try:
1958
- parsed = json.loads(result_str) if result_str else {}
1959
- requires_approval = isinstance(parsed, dict) and parsed.get("requires_approval", False)
1960
- except (json.JSONDecodeError, ValueError):
1961
- pass
1962
-
1963
- if requires_approval:
1964
- self._pending_approval = {"tool_call": tu, "category": category}
1965
- self._notify_event(ToolEvent(
1966
- kind="tool_result",
1967
- tool_name=tu["name"],
1968
- tool_use_id=tu["id"],
1969
- is_error=True,
1970
- error="Requires approval",
1971
- ))
1972
- yield StreamEvent.ask_user(
1973
- call_id=tu["id"],
1974
- action=tu["name"],
1975
- question=f"Approve {tu['name']} operation?",
1976
- context=result_str,
1977
- action_type=tu["name"].lower(),
1978
- path=tu["input"].get("path", ""),
1979
- command=tu["input"].get("command", "")[:200],
1980
- )
1981
- # Generator resumes here after approval. The approved tool
1982
- # may have mutated todos (e.g. TodoUpdate via resolve_approval),
1983
- # flushing _plan_dirty so the TUI stays in sync.
1984
- if self._plan_dirty:
1985
- for event in self._emit_plan_update():
1986
- yield event
1987
- self._plan_dirty = False
1988
- else:
1989
- from onecode.models.messages import ToolCategory as MsgToolCategory
1990
- try:
1991
- result_cat = MsgToolCategory(category)
1992
- except ValueError:
1993
- result_cat = MsgToolCategory.UNKNOWN
1994
- self._notify_event(ToolEvent(
1995
- kind="tool_result",
1996
- tool_name=tu["name"],
1997
- tool_output=result_str,
1998
- tool_use_id=tu["id"],
1999
- is_error=is_error,
2000
- ))
2001
- yield StreamEvent.tool_result(
2002
- call_id=tu["id"],
2003
- content=tui_content,
2004
- is_error=is_error,
2005
- category=result_cat,
2006
- )
2007
- # Add structured tool_result to LLM context
2008
- if is_anthropic:
2009
- self.context.add_tool_result(tu["id"], result_str, is_error)
2010
- else:
2011
- self.context.add_message(
2012
- "tool",
2013
- [{"type": "tool_result", "tool_use_id": tu["id"], "content": result_str, "is_error": is_error}],
2014
- name=tu["id"],
2015
- )
2016
- # Emit plan update when tasks/todos changed. Any
2017
- # mutating path through TodoManager flips
2018
- # ``_plan_dirty`` via the ``on_change`` callback, so we
2019
- # do not need to enumerate tool names here.
2020
- if self._plan_dirty:
2021
- for event in self._emit_plan_update():
2022
- yield event
2023
- self._plan_dirty = False
2024
-
2025
- # Tools were called this turn — reset the empty-turn counter
2026
- self._empty_tool_turns = 0
2027
-
2028
- if self._cancelled:
2029
- yield StreamEvent.text_delta("\n\n*Turn cancelled*\n\n")
2030
- return
2031
-
2032
- usage_summary = ", ".join(
2033
- f"turn {i+1}: {u.get('total_tokens', '?')} tokens"
2034
- for i, u in enumerate(self._turn_usages) if u
2035
- )
2036
- logger.info(f"Chat stream complete after {self.iterations} round(s). Usage: [{usage_summary}]")
2037
-
2038
- # ── Final summary fallback ──
2039
- # If the last round had no visible text (all reasoning was inside
2040
- # <thinking>), emit a deterministic summary so the user always sees
2041
- # a completion report — no extra LLM round-trip needed.
2042
- if not self._cancelled:
2043
- last_msg = self.context.messages[-1] if self.context.messages else None
2044
- has_visible = False
2045
- if last_msg and last_msg.get("role") == "assistant":
2046
- content = last_msg.get("content", "")
2047
- if isinstance(content, str):
2048
- has_visible = bool(content.strip())
2049
- elif isinstance(content, list):
2050
- has_visible = any(
2051
- isinstance(block, dict)
2052
- and block.get("type") == "text"
2053
- and block.get("text", "").strip()
2054
- for block in content
2055
- )
2056
- if not has_visible:
2057
- summary = self._build_completion_summary()
2058
- yield StreamEvent.text_delta(summary)
2059
-
2060
- def has_pending_approval(self) -> bool:
2061
- """Check if there's a pending approval request from ASK permission."""
2062
- return self._pending_approval is not None
2063
-
2064
- async def resolve_approval(self, approved: bool, answer: str = "") -> dict | None:
2065
- """Execute the pending action if approved, or return denial.
2066
-
2067
- For AskUser, ``answer`` is the user's free-text response.
2068
-
2069
- Returns the tool result dict, or None if no pending approval.
2070
- """
2071
- from onecode.agent.agents.types import AgentPermission
2072
- if not self._pending_approval:
2073
- return None
2074
-
2075
- tc = self._pending_approval["tool_call"]
2076
- is_ask_user = self._pending_approval.get("ask_user", False)
2077
- self._pending_approval = None
2078
-
2079
- # Handle AskUser — return user's answer as tool result
2080
- if is_ask_user:
2081
- if not approved:
2082
- return {
2083
- "tool_use_id": tc["id"],
2084
- "content": json.dumps({"answer": "", "error": "User cancelled"}),
2085
- "is_error": True,
2086
- "category": tc.get("category", "unknown"),
2087
- }
2088
- # answer is a JSON string when multi-question, plain text otherwise
2089
- try:
2090
- parsed = json.loads(answer)
2091
- is_structured = isinstance(parsed, dict) or isinstance(parsed, list)
2092
- except (json.JSONDecodeError, TypeError):
2093
- is_structured = False
2094
- if is_structured:
2095
- return {
2096
- "tool_use_id": tc["id"],
2097
- "content": json.dumps({"answers": parsed}),
2098
- "is_error": False,
2099
- "category": tc.get("category", "unknown"),
2100
- }
2101
- return {
2102
- "tool_use_id": tc["id"],
2103
- "content": json.dumps({"answer": answer}),
2104
- "is_error": False,
2105
- "category": tc.get("category", "unknown"),
2106
- }
2107
-
2108
- if not approved:
2109
- return {
2110
- "tool_use_id": tc["id"],
2111
- "content": json.dumps({"success": False, "error": "User denied the operation"}),
2112
- "is_error": True,
2113
- "category": tc.get("category", "unknown"),
2114
- }
2115
-
2116
- perm_key = self._TOOL_NAME_TO_PERM_KEY.get(tc["name"])
2117
- if perm_key is None:
2118
- return await self._execute_tool(tc)
2119
-
2120
- attr_name = f"permission_{perm_key}"
2121
- saved = getattr(self.current_agent, attr_name)
2122
- try:
2123
- setattr(self.current_agent, attr_name, AgentPermission.ALLOW)
2124
- return await self._execute_tool(tc)
2125
- finally:
2126
- setattr(self.current_agent, attr_name, saved)
2127
-
2128
- def _reset_react_state(self) -> None:
2129
- """Reset ReAct loop state for a fresh cycle."""
2130
- self._react_phase = "thought"
2131
- self._direct_execution_count = 0
2132
- self._empty_tool_turns = 0
2133
- self._plan_gate_fired = False
2134
- # Invalidate the plan-emit dedupe cache so the next chat_stream
2135
- # always produces a fresh snapshot for the TUI.
2136
- self._last_emitted_plan = ()
2137
- self.context.remove_system_by_marker("<!-- REACT_PHASE -->")
2138
- self.context.remove_system_by_marker("<!-- ROUTING_REMINDER -->")
2139
- self.context.remove_system_by_marker("<!-- PENDING_TODOS -->")
2140
-
2141
- def reset(self):
2142
- self.context.reset()
2143
- self.iterations = 0
2144
- self.total_tokens = 0
2145
- self._turn_usages = []
2146
- self._skills_loaded = False
2147
- self._project_context_loaded = False
2148
- self._skill_loader.invalidate_cache()
2149
- self._reset_react_state()
2150
-
2151
- def status(self) -> str:
2152
- return (
2153
- f"Agent: {self.current_agent.name}\n"
2154
- f"Iterations: {self.iterations}\n"
2155
- f"Total tokens: {self.total_tokens}\n"
2156
- f"Context: {self.context.info()}"
2157
- )
2158
-
2159
- def read_file(self, path: str, offset: int = 0, limit: int = 0) -> str:
2160
- if self.current_agent.permission_read == AgentPermission.DENY:
2161
- return "Read denied"
2162
- return self.file_ops.read(path, offset, limit)
2163
-
2164
- def write_file(self, path: str, content: str) -> dict:
2165
- if self.current_agent.permission_edit == AgentPermission.DENY:
2166
- return {"success": False, "error": "Edit denied"}
2167
- if self.current_agent.permission_edit == AgentPermission.ASK:
2168
- return {"success": False, "error": "Edit requires approval", "requires_approval": True}
2169
- return self.file_ops.write(path, content)
2170
-
2171
- def edit_file(self, path: str, old: str, new: str) -> dict:
2172
- if self.current_agent.permission_edit == AgentPermission.DENY:
2173
- return {"success": False, "error": "Edit denied"}
2174
- if self.current_agent.permission_edit == AgentPermission.ASK:
2175
- return {"success": False, "error": "Edit requires approval", "requires_approval": True}
2176
- return self.file_ops.edit(path, old, new)
2177
-
2178
- def glob_files(self, pattern: str) -> list[str]:
2179
- return self.file_ops.glob(pattern)
2180
-
2181
- def grep_files(self, pattern: str, include: str = None) -> list[str]:
2182
- return self.file_ops.grep(pattern, include)
2183
-
2184
- def list_dir(self, path: str = ".") -> list[dict]:
2185
- return self.file_ops.list(path)
2186
-
2187
- def exec_shell(self, cmd: str, timeout: int = 60) -> dict:
2188
- if self.current_agent.permission_bash == AgentPermission.DENY:
2189
- return {"success": False, "error": "Bash denied"}
2190
- if self.current_agent.permission_bash == AgentPermission.ASK:
2191
- return {"success": False, "error": "Bash requires approval", "requires_approval": True}
2192
- return self.shell.exec(cmd, timeout=timeout)
2193
-
2194
- def web_fetch(self, url: str, prompt: str = None) -> str:
2195
- if self.current_agent.permission_webfetch == AgentPermission.DENY:
2196
- return "WebFetch denied"
2197
- from onecode.agent.tools.web_tools import webfetch
2198
- return webfetch(url, prompt)
2199
-
2200
- def web_search(self, query: str, num_results: int = 5) -> str:
2201
- if self.current_agent.permission_websearch == AgentPermission.DENY:
2202
- return "WebSearch denied"
2203
- from onecode.agent.tools.web_tools import websearch
2204
- return websearch(query, num_results)
2205
-
2206
- async def _spawn_subagent_async(self, agent_type: str, prompt: str) -> str:
2207
- """Spawn a sub-agent with streaming-like output.
2208
-
2209
- Uses chat_stream to get structured output that the ChatPanel
2210
- can parse for professional rendering.
2211
- """
2212
- parts: list[str] = []
2213
- async for _event, text in self._spawn_subagent_async_streaming(agent_type, prompt):
2214
- parts.append(text)
2215
- result = "".join(parts)
2216
- return self._format_subagent_output(agent_type, prompt, result)
2217
-
2218
- async def _spawn_subagent_async_streaming(
2219
- self, agent_type: str, prompt: str, subagent_id: str = ""
2220
- ) -> AsyncIterator[tuple[StreamEvent, str]]:
2221
- """Streaming variant of :meth:`_spawn_subagent_async`.
2222
-
2223
- Yields ``(event, text)`` pairs so the caller (typically the Task-tool
2224
- path in :meth:`chat_stream`) can forward each text delta as a
2225
- ``subagent_chunk`` notification on the ACP wire.
2226
-
2227
- Inherits project context and skills from the parent
2228
- engine so the subagent does not start from a blank slate.
2229
- """
2230
- # Depth check: subagents are leaf nodes (max depth = 1).
2231
- if self._subagent_depth >= _MAX_SUBAGENT_DEPTH:
2232
- err = "Subagent cannot spawn nested subagents (max depth=1)"
2233
- yield (StreamEvent.subagent_end(
2234
- "", agent_type=agent_type, status="failed", error=err,
2235
- ), err)
2236
- return
2237
-
2238
- sub_engine = AgentEngine(self.app, project_dir=self._project_dir, perm_store=self._perm_store)
2239
-
2240
- sub_engine._subagent_depth = self._subagent_depth + 1
2241
-
2242
- # Inherit parent context: project info (but NOT full skill content,
2243
- # which contains Master Orchestrator instructions that conflict with
2244
- # the subagent role). The subagent's own _load_skills() will load
2245
- # only non-conflicting reference info via its own SkillLoader.
2246
- sub_engine._project_context_loaded = self._project_context_loaded
2247
- sub_engine._project_config = dict(self._project_config)
2248
- sub_engine._skills_loaded = False
2249
-
2250
- # Inherit the parent's already-built codebase engine so the subagent
2251
- # does NOT re-run the full project index (CodebaseIndexer.index()
2252
- # is a synchronous chunk loop that would otherwise block the event
2253
- # loop for the duration of indexing and freeze streaming/cancel).
2254
- # Sharing the instance also avoids duplicate SQLite writes.
2255
- sub_engine._codebase_engine = self._codebase_engine
2256
- logger.debug(
2257
- "[SA-STREAM %s] inheriting codebase_engine from parent: %s",
2258
- agent_type, "shared" if self._codebase_engine is not None else "none",
2259
- )
2260
-
2261
- # CRITICAL: disable codebase auto-retrieval + memory recall inside
2262
- # the subagent. Production logs prove that every Spawn hung here:
2263
- # the subagent's ``chat_stream`` re-ran ``CodebaseIndexer.index()``
2264
- # (a 2.3–2.6s synchronous loop, no await yield points) and then
2265
- # ``CodebaseRetriever._retrieve_embedding()`` issued one ``httpx``
2266
- # POST per chunk (30s timeout each, no effective cap), so a 148-
2267
- # chunk project could block the event loop for ~minutes — during
2268
- # which ``on_text_chunk`` never fires (no stream output) and
2269
- # ``CancelledError`` injected by ``prompt_task.cancel()`` cannot
2270
- # propagate (cannot be stopped). A subagent executes a specific
2271
- # prompt handed down by the parent; it does not need project-level
2272
- # codebase seeding or memory recall.
2273
- sub_engine._disable_retrieval = True
2274
- logger.debug(
2275
- "[SA-STREAM %s] set _disable_retrieval=True (subagent skips "
2276
- "codebase auto-retrieval + memory recall)", agent_type,
2277
- )
2278
-
2279
- # Do NOT propagate parent TUI-facing callbacks to the sub_engine.
2280
- # - on_tool_call_delta: would fire the ACP's _on_tool_call_delta for
2281
- # the subagent's inner tools (Read/Bash/...), leaking their NAMES
2282
- # into the MAIN conversation as orphaned in_progress cards while
2283
- # the RESULT only reaches the SubAgent widget via the stream path
2284
- # below (tool_call_start/tool_result -> text_delta -> subagent_chunk).
2285
- # - on_thinking: would append subagent thinking into the PARENT's
2286
- # _pending_thinking_blocks, polluting the parent's persisted session.
2287
- # The subagent's tool calls/thinking are already relayed to the
2288
- # SubAgent widget via SUBAGENT_CHUNK / SUBAGENT_THINKING (see the
2289
- # consumer loop below). on_event is left propagated: the ACP does
2290
- # not wire it, so it is None on the parent and a no-op here.
2291
- if self.on_event is not None:
2292
- sub_engine.on_event = self.on_event
2293
-
2294
- # Apply the requested agent_type's config (role description,
2295
- # response-style / <thinking> tags, tool allow/deny list, REACT_CYCLE)
2296
- # as the AGENT_CONFIG system section.
2297
- sub_engine.set_agent(agent_type)
2298
-
2299
- # Track child so parent cancellation cascades
2300
- self._child_engines.append(sub_engine)
2301
-
2302
- # ── Real-time streaming via asyncio.Queue ──
2303
- # on_text_chunk puts provider tokens into the queue as they arrive,
2304
- # and a background task puts chat_stream events into the same queue.
2305
- # The main loop reads from the queue and yields (event, text) pairs,
2306
- # giving the TUI live subagent output instead of "等待输出".
2307
- _queue: asyncio.Queue[tuple[str, object]] = asyncio.Queue()
2308
- _sa_log_prefix = f"[SA-STREAM {agent_type} depth={self._subagent_depth + 1}]"
2309
- _sa_total_bytes = 0
2310
- _sa_chunk_count = 0
2311
- _sa_text_count = 0
2312
- _sa_first_byte_logged = False
2313
-
2314
- def _on_subagent_text(text: str) -> None:
2315
- nonlocal _sa_total_bytes, _sa_text_count, _sa_first_byte_logged
2316
- try:
2317
- _queue.put_nowait(("text", text))
2318
- _sa_text_count += 1
2319
- _sa_total_bytes += len(text)
2320
- if not _sa_first_byte_logged:
2321
- _sa_first_byte_logged = True
2322
- logger.debug(
2323
- "%s on_text_chunk FIRST token bytes=%d (text_count so far=%d)",
2324
- _sa_log_prefix, len(text), _sa_text_count,
2325
- )
2326
- if _sa_text_count % 20 == 0:
2327
- logger.debug(
2328
- "%s on_text_chunk progress text_calls=%d total_bytes=%d "
2329
- "queued=%d",
2330
- _sa_log_prefix, _sa_text_count, _sa_total_bytes,
2331
- _queue.qsize(),
2332
- )
2333
- except Exception as e:
2334
- logger.warning("%s on_text_chunk queue.put failed: %s", _sa_log_prefix, e)
2335
-
2336
- sub_engine.on_text_chunk = _on_subagent_text
2337
- logger.debug("%s wiring on_text_chunk → queue (sub_engine id=%x)",
2338
- _sa_log_prefix, id(sub_engine))
2339
-
2340
- async def _run_chat_stream() -> None:
2341
- logger.debug("%s _run_chat_stream task started", _sa_log_prefix)
2342
- try:
2343
- async for chunk in sub_engine.chat_stream(prompt):
2344
- await _queue.put(("event", chunk))
2345
- except asyncio.CancelledError:
2346
- logger.debug("%s _run_chat_stream CancelledError → queue(cancelled)",
2347
- _sa_log_prefix)
2348
- await _queue.put(("cancelled", None))
2349
- except Exception as e:
2350
- logger.exception("%s _run_chat_stream exception: %s", _sa_log_prefix, e)
2351
- await _queue.put(("error", e))
2352
- finally:
2353
- logger.debug(
2354
- "%s _run_chat_stream finally → queue(done) text=%d bytes=%d "
2355
- "chunk_events=%d",
2356
- _sa_log_prefix, _sa_text_count, _sa_total_bytes, _sa_chunk_count,
2357
- )
2358
- await _queue.put(("done", None))
2359
-
2360
- _stream_task = asyncio.create_task(_run_chat_stream())
2361
- logger.debug("%s created _stream_task id=%x cancelled_flag=%s",
2362
- _sa_log_prefix, id(_stream_task), self._cancelled)
2363
-
2364
- parts: list[str] = []
2365
- _streamed_via_text = False # True once on_text_chunk has delivered tokens
2366
-
2367
- try:
2368
- logger.debug("%s entering queue.get() consumer loop", _sa_log_prefix)
2369
- while True:
2370
- item_type, item = await _queue.get()
2371
- _sa_chunk_count += 1
2372
- if _sa_chunk_count <= 5 or _sa_chunk_count % 50 == 0:
2373
- logger.debug(
2374
- "%s queue.get() #%d type=%s cancelled_flag=%s qsize=%d "
2375
- "text=%d bytes=%d",
2376
- _sa_log_prefix, _sa_chunk_count, item_type,
2377
- self._cancelled, _queue.qsize(), _sa_text_count,
2378
- _sa_total_bytes,
2379
- )
2380
-
2381
- if item_type == "text":
2382
- _streamed_via_text = True
2383
- parts.append(item)
2384
- yield (StreamEvent.text_delta(item), item)
2385
-
2386
- elif item_type == "event":
2387
- chunk = item
2388
- if isinstance(chunk, StreamEvent):
2389
- if chunk.type == StreamEventType.TEXT_DELTA and chunk.text:
2390
- # Skip TEXT_DELTA if tokens were already streamed
2391
- # in real-time via on_text_chunk (avoids doubles).
2392
- if not _streamed_via_text:
2393
- if _sa_chunk_count <= 5:
2394
- logger.debug(
2395
- "%s TEXT_DELTA via event path (no "
2396
- "streamed_via_text) bytes=%d",
2397
- _sa_log_prefix, len(chunk.text),
2398
- )
2399
- parts.append(chunk.text)
2400
- yield (chunk, chunk.text)
2401
- else:
2402
- if _sa_chunk_count <= 3:
2403
- logger.debug(
2404
- "%s TEXT_DELTA skipped (already "
2405
- "streamed_via_text) bytes=%d",
2406
- _sa_log_prefix, len(chunk.text),
2407
- )
2408
- elif chunk.type == StreamEventType.THINKING and chunk.thinking:
2409
- logger.debug("%s THINKING event bytes=%d",
2410
- _sa_log_prefix, len(chunk.thinking))
2411
- yield (chunk, chunk.thinking)
2412
- elif chunk.type == StreamEventType.TOOL_CALL_START:
2413
- logger.debug("%s TOOL_CALL_START tool=%s",
2414
- _sa_log_prefix, chunk.tool_name)
2415
- yield (StreamEvent.subagent_tool_call(
2416
- subagent_id, "start", chunk.tool_id,
2417
- chunk.tool_name, chunk.tool_category,
2418
- ), "")
2419
- elif chunk.type == StreamEventType.TOOL_CALL_COMPLETE:
2420
- logger.debug("%s TOOL_CALL_COMPLETE tool=%s",
2421
- _sa_log_prefix, chunk.tool_name)
2422
- yield (StreamEvent.subagent_tool_call(
2423
- subagent_id, "complete", chunk.tool_id,
2424
- chunk.tool_name, chunk.tool_category,
2425
- chunk.tool_args,
2426
- ), "")
2427
- elif chunk.type == StreamEventType.TOOL_RESULT:
2428
- logger.debug("%s TOOL_RESULT tool=%s",
2429
- _sa_log_prefix, chunk.tool_name)
2430
- yield (StreamEvent.subagent_tool_result(
2431
- subagent_id, chunk.tool_id, chunk.tool_name,
2432
- chunk.result_content, chunk.result_is_error,
2433
- chunk.result_category,
2434
- ), "")
2435
- elif chunk.type == StreamEventType.ERROR:
2436
- logger.debug("%s ERROR event msg=%s",
2437
- _sa_log_prefix, chunk.error_message)
2438
- text = f"\n[Error: {chunk.error_message}]\n"
2439
- yield (StreamEvent.text_delta(text), text)
2440
- else:
2441
- if chunk:
2442
- parts.append(chunk)
2443
-
2444
- elif item_type == "done":
2445
- logger.debug("%s queue.get()=done → break loop", _sa_log_prefix)
2446
- break
2447
-
2448
- elif item_type == "cancelled":
2449
- logger.debug(
2450
- "%s queue.get()=cancelled → yield subagent_end "
2451
- "failed=cancelled", _sa_log_prefix)
2452
- yield (StreamEvent.subagent_end(
2453
- "", agent_type=agent_type, status="failed", error="cancelled",
2454
- ), "")
2455
- return
2456
-
2457
- elif item_type == "error":
2458
- logger.exception("%s queue.get()=error: %s", _sa_log_prefix, item)
2459
- err_text = (
2460
- f"SUMMARY:\nSub-agent failed with error: {item}\n\n"
2461
- "CHANGES:\nNone.\n\nEVIDENCE:\nNone.\n\nRISKS:\nNone.\n\n"
2462
- f"BLOCKERS:\n{item}"
2463
- )
2464
- yield (StreamEvent.subagent_end(
2465
- "", agent_type=agent_type, status="failed", error=str(item),
2466
- ), err_text)
2467
- return
2468
-
2469
- except asyncio.CancelledError:
2470
- logger.debug("%s consumer loop CancelledError → subagent_end", _sa_log_prefix)
2471
- # Parent cancelled — emit subagent_end so the TUI widget
2472
- # transitions out of "running", then re-raise.
2473
- yield (StreamEvent.subagent_end(
2474
- "", agent_type=agent_type, status="failed", error="cancelled",
2475
- ), "")
2476
- raise
2477
- finally:
2478
- logger.debug(
2479
- "%s consumer finally: stream_task.done=%s cancelled_flag=%s "
2480
- "text=%d bytes=%d chunk_events=%d",
2481
- _sa_log_prefix, _stream_task.done(), self._cancelled,
2482
- _sa_text_count, _sa_total_bytes, _sa_chunk_count,
2483
- )
2484
- if not _stream_task.done():
2485
- logger.debug("%s finally: cancelling _stream_task (still running)",
2486
- _sa_log_prefix)
2487
- _stream_task.cancel()
2488
- try:
2489
- logger.debug("%s finally: awaiting _stream_task ...",
2490
- _sa_log_prefix)
2491
- await _stream_task
2492
- logger.debug("%s finally: await _stream_task returned cleanly",
2493
- _sa_log_prefix)
2494
- except asyncio.CancelledError:
2495
- logger.debug("%s finally: await _stream_task CancelledError",
2496
- _sa_log_prefix)
2497
- except Exception as e:
2498
- logger.debug("%s finally: await _stream_task exception=%s",
2499
- _sa_log_prefix, e)
2500
- if sub_engine in self._child_engines:
2501
- self._child_engines.remove(sub_engine)
2502
- logger.debug(
2503
- "%s finally done text=%d bytes=%d chunk_events=%d",
2504
- _sa_log_prefix, _sa_text_count, _sa_total_bytes, _sa_chunk_count,
2505
- )
2506
-
2507
- # Normal completion
2508
- yield (StreamEvent.subagent_end(
2509
- "", agent_type=agent_type, status="completed",
2510
- ), "".join(parts))
2511
-
2512
- _SUBAGENT_SECTION_RE = re.compile(
2513
- r'^(SUMMARY|CHANGES|EVIDENCE|RISKS|BLOCKERS):\s*$',
2514
- re.IGNORECASE | re.MULTILINE,
2515
- )
2516
-
2517
- def _format_subagent_output(self, agent_type: str, prompt: str, result: str) -> str:
2518
- """Format sub-agent output in DeepSeek-TUI structured contract format.
2519
-
2520
- Output contract:
2521
- SUMMARY: one paragraph; what you did and what happened
2522
- CHANGES: files modified, with one-line descriptions; "None." if read-only
2523
- EVIDENCE: path:line-range citations and key findings; one bullet each
2524
- RISKS: what could go wrong / what the parent should double-check
2525
- BLOCKERS: what stopped you; "None." if you finished cleanly
2526
- """
2527
- # Detect structured sections via line-start regex, not substring match
2528
- has_sections = self._SUBAGENT_SECTION_RE.search(result) is not None
2529
-
2530
- if has_sections:
2531
- return result
2532
-
2533
- return (
2534
- f"SUMMARY:\n{agent_type.capitalize()} agent completed task: {prompt[:200]}\n\n"
2535
- f"CHANGES:\nNone detected.\n\n"
2536
- f"EVIDENCE:\n- See detailed output below\n\n"
2537
- f"RISKS:\nNone identified.\n\n"
2538
- f"BLOCKERS:\nNone.\n\n"
2539
- f"--- Raw Output ---\n{result}"
2540
- )
2541
-
2542
- def spawn_subagent(self, agent_type: str, prompt: str) -> dict:
2543
- if self.current_agent.permission_task == AgentPermission.DENY:
2544
- return {"success": False, "error": "Subagent denied"}
2545
- import asyncio
2546
- try:
2547
- loop = asyncio.get_event_loop()
2548
- if loop.is_running():
2549
- future = asyncio.run_coroutine_threadsafe(
2550
- self._spawn_subagent_async(agent_type, prompt), loop
2551
- )
2552
- result = future.result(timeout=300)
2553
- else:
2554
- result = asyncio.run(self._spawn_subagent_async(agent_type, prompt))
2555
- except Exception as e:
2556
- logger.exception(f"Subagent error: {e}")
2557
- return {"success": False, "error": safe_error_msg(e)}
2558
- return {
2559
- "success": True,
2560
- "agent_type": agent_type,
2561
- "response": result,
2562
- "iterations": 1,
2563
- }
2564
-
2565
- def tool_task(self, agent_type: str, prompt: str) -> str:
2566
- result = self.spawn_subagent(agent_type, prompt)
2567
- if result["success"]:
2568
- return result["response"]
2569
- return f"Error: {result.get('error') or 'Unknown error'}"
2570
-
2571
- def attach_session(self, session: AgentSession) -> None:
2572
- self._session = session
2573
- if session.messages:
2574
- self.context.load_from_session(session.messages)
2575
- if session.todos:
2576
- self._todo_manager.reload_from_dict({
2577
- "todos": session.todos,
2578
- "id_counter": len(session.todos),
2579
- })
2580
- # Restore context usage stats
2581
- self._restore_session_stats(session)
2582
-
2583
- def save_session(self) -> None:
2584
- if self._session:
2585
- try:
2586
- self._session.messages = self.context.to_session_format()
2587
- tm_data = self._todo_manager.to_dict()
2588
- self._session.todos = tm_data.get("todos", [])
2589
- # Persist context usage stats so they survive session reload
2590
- self._session.update_state("stats", {
2591
- "total_tokens": self.total_tokens,
2592
- "iterations": self.iterations,
2593
- "turn_usages": [
2594
- {k: v for k, v in u.items() if isinstance(v, (int, float))}
2595
- for u in self._turn_usages
2596
- ],
2597
- })
2598
- self._session.save()
2599
- except Exception as e:
2600
- logger.exception("Failed to save session: %s", e)
2601
-
2602
- def load_session(self, session_id: str) -> bool:
2603
- session = AgentSession(session_id)
2604
- if not session.load():
2605
- return False
2606
- self._session = session
2607
- self.context.load_from_session(session.messages)
2608
- if session.todos:
2609
- self._todo_manager.reload_from_dict({
2610
- "todos": session.todos,
2611
- "id_counter": len(session.todos),
2612
- })
2613
- self._inject_loaded_tasks_into_context()
2614
- # Restore context usage stats
2615
- self._restore_session_stats(session)
2616
- return True
2617
-
2618
- def _inject_loaded_tasks_into_context(self) -> None:
2619
- """Surface loaded todos to the LLM so it can continue them.
2620
-
2621
- Todos loaded from a previous session are not automatically visible
2622
- in the LLM context — without this nudge the agent would re-create
2623
- them on every fresh turn instead of resuming pending work.
2624
- """
2625
- all_todos = self._todo_manager.list_todos()
2626
- pending = [t for t in all_todos if t.get("status") in ("pending", "in_progress")]
2627
- if not pending:
2628
- return
2629
-
2630
- lines = ["# Resumed todos from previous session", ""]
2631
- lines.append("## Todos")
2632
- for t in pending:
2633
- status = t.get("status", "pending")
2634
- marker = {"in_progress": "[~]", "pending": "[ ]"}.get(status, "[?]")
2635
- subject = t.get("subject") or t.get("description", "")
2636
- desc = t.get("description") or ""
2637
- if desc and desc != subject:
2638
- lines.append(f"- {marker} (id={t.get('id')}) {subject} — {desc}")
2639
- else:
2640
- lines.append(f"- {marker} (id={t.get('id')}) {subject}")
2641
- lines.append("")
2642
-
2643
- lines.append(
2644
- "Continue working on any in_progress or pending todos above. "
2645
- "Use TodoList/TodoGet to inspect details and TodoUpdate to "
2646
- "advance status. Do NOT recreate todos that already exist."
2647
- )
2648
- marker = "<!-- loaded_todos_resume -->"
2649
- body = f"{marker}\n" + "\n".join(lines)
2650
- if not self.context.replace_system_section(marker, body):
2651
- self.context.add_system(body)
2652
-
2653
- def _restore_session_stats(self, session: AgentSession) -> None:
2654
- """Restore context usage stats from session lifecycle_state."""
2655
- stats = session.get_state("stats", {})
2656
- if not stats:
2657
- return
2658
- self.total_tokens = int(stats.get("total_tokens", self.total_tokens))
2659
- self.iterations = int(stats.get("iterations", self.iterations))
2660
- restored_turns = stats.get("turn_usages", [])
2661
- if restored_turns and isinstance(restored_turns, list):
2662
- self._turn_usages = list(restored_turns)
2663
-
2664
- def save_todos_to_project(self) -> None:
2665
- """Save todos to .cdh/todos.json — uses find_cdh_dir_for_todos
2666
- so sub-projects never overwrite a parent project's todos."""
2667
- from onecode.agent.cdh_loader import CdhProjectLoader
2668
- cdh_dir = CdhProjectLoader.find_cdh_dir_for_todos(self._project_dir)
2669
- if cdh_dir is None:
2670
- cdh_dir = self._project_dir / CdhProjectLoader.CDH_DIRNAME
2671
- try:
2672
- cdh_dir.mkdir(parents=True, exist_ok=True)
2673
- except Exception as e:
2674
- logger.warning("Failed to create .cdh directory: %s", e)
2675
- return
2676
- try:
2677
- tm_data = self._todo_manager.to_dict()
2678
- CdhProjectLoader.save_todos(cdh_dir, tm_data)
2679
- except Exception as e:
2680
- logger.warning("Failed to save todos to .cdh: %s", e)
2681
-
2682
- def load_todos_from_project(self) -> None:
2683
- """Restore todos from .cdh/todos.json — uses find_cdh_dir_for_todos
2684
- so sub-projects never accidentally load a parent project's todos.
2685
- If todos are found, the ``<!-- NEW_SESSION_HINT -->`` marker is
2686
- removed from the system context (it only applies to blank sessions)."""
2687
- from onecode.agent.cdh_loader import CdhProjectLoader
2688
- cdh_dir = CdhProjectLoader.find_cdh_dir_for_todos(self._project_dir)
2689
- if cdh_dir is None:
2690
- return
2691
- try:
2692
- data = CdhProjectLoader.load_todos(cdh_dir)
2693
- if data and (data.get("tasks") or data.get("todos")):
2694
- self._todo_manager.reload_from_dict(data)
2695
- self.context.remove_system_by_marker("<!-- NEW_SESSION_HINT -->")
2696
- except Exception as e:
2697
- logger.warning("Failed to load todos from .cdh: %s", e)
2698
-
2699
- def get_session(self) -> Optional[AgentSession]:
2700
- return self._session