@scemoon/cdh 1.0.4 → 1.0.6

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 (269) hide show
  1. package/ai-dlc-skill/SKILL.md +4 -2
  2. package/ai-dlc-skill/agents/master.md +6 -0
  3. package/ai-dlc-skill/agents/plan-agent.md +2 -2
  4. package/ai-dlc-skill/agents/understand-agent.md +4 -4
  5. package/ai-dlc-skill/agents/verify-agent.md +1 -1
  6. package/ai-dlc-skill/architecture/project-structure.md +10 -10
  7. package/ai-dlc-skill/brownfield/README.md +2 -2
  8. package/ai-dlc-skill/brownfield/generate-context.sh +3 -3
  9. package/ai-dlc-skill/brownfield/scripts/extract-api.sh +5 -5
  10. package/ai-dlc-skill/components/backend.md +1 -1
  11. package/ai-dlc-skill/components/web.md +2 -2
  12. package/ai-dlc-skill/contracts/README.md +3 -3
  13. package/ai-dlc-skill/cross-tool/cline/export.sh +1 -1
  14. package/ai-dlc-skill/cross-tool/copilot/export.sh +1 -1
  15. package/ai-dlc-skill/cross-tool/onecode/export-skills.sh +7 -3
  16. package/ai-dlc-skill/cross-tool/onecode/export.sh +14 -8
  17. package/ai-dlc-skill/phases/deliver/lifecycle.md +6 -6
  18. package/ai-dlc-skill/phases/deliver/prompt.md +2 -2
  19. package/ai-dlc-skill/phases/deliver/rules.md +1 -1
  20. package/ai-dlc-skill/phases/plan/lifecycle.md +11 -11
  21. package/ai-dlc-skill/phases/plan/prompt.md +6 -6
  22. package/ai-dlc-skill/phases/plan/rules.md +1 -1
  23. package/ai-dlc-skill/phases/understand/entry.md +1 -1
  24. package/ai-dlc-skill/phases/understand/lifecycle.md +7 -7
  25. package/ai-dlc-skill/phases/understand/prompt.md +8 -8
  26. package/ai-dlc-skill/phases/understand/rules.md +5 -5
  27. package/ai-dlc-skill/phases/verify/lifecycle.md +24 -24
  28. package/ai-dlc-skill/phases/verify/prompt.md +8 -8
  29. package/ai-dlc-skill/phases/verify/rules.md +9 -9
  30. package/ai-dlc-skill/providers/README.md +2 -2
  31. package/ai-dlc-skill/providers/aliyun/preview.yaml +2 -2
  32. package/ai-dlc-skill/providers/tcb/preview.yaml +2 -2
  33. package/ai-dlc-skill/requirements.md +7 -7
  34. package/ai-dlc-skill/skill.yaml +4 -4
  35. package/ai-dlc-skill/templates/integration/CHANGELOG.md +2 -2
  36. package/ai-dlc-skill/templates/integration/contract-diff.md +7 -7
  37. package/ai-dlc-skill/templates/integration/contract-spec.md +7 -7
  38. package/ai-dlc-skill/templates/integration/runtime-contract.yaml +1 -1
  39. package/ai-dlc-skill/templates/project/README.md +18 -19
  40. package/ai-dlc-skill/templates/project/template.md +21 -21
  41. package/ai-dlc-skill/walkthrough/collect.sh +1 -1
  42. package/ai-dlc-skill/walkthrough/template.md +2 -2
  43. package/ai-dlc-skill/workflows/ai-dlc.yaml +12 -12
  44. package/ai-dlc-skill/workflows/deployment.yaml +4 -4
  45. package/cdh/__pycache__/cdh_mcp_injector.cpython-314.pyc +0 -0
  46. package/cdh/__pycache__/cdh_mcp_loader.cpython-314.pyc +0 -0
  47. package/cdh/__pycache__/cdh_mcp_manager.cpython-314.pyc +0 -0
  48. package/cdh/__pycache__/cdh_session_aggregator.cpython-314.pyc +0 -0
  49. package/cdh/__pycache__/cdh_skill_loader.cpython-314.pyc +0 -0
  50. package/cdh/__pycache__/cdh_skill_manager.cpython-314.pyc +0 -0
  51. package/cdh/__pycache__/cli.cpython-313.pyc +0 -0
  52. package/cdh/__pycache__/cli.cpython-314.pyc +0 -0
  53. package/cdh/__pycache__/scaffold.cpython-314.pyc +0 -0
  54. package/cdh/cdh_mcp_injector.py +115 -0
  55. package/cdh/cdh_mcp_loader.py +67 -0
  56. package/cdh/cdh_mcp_manager.py +73 -0
  57. package/cdh/cdh_session_aggregator.py +258 -0
  58. package/cdh/cdh_skill_manager.py +105 -0
  59. package/cdh/cli.py +64 -4
  60. package/cdh/scaffold.py +192 -41
  61. package/onecode/__init__.py +1 -1
  62. package/onecode/__pycache__/cli.cpython-314.pyc +0 -0
  63. package/onecode/__pycache__/commands.cpython-314.pyc +0 -0
  64. package/onecode/__pycache__/config.cpython-314.pyc +0 -0
  65. package/onecode/__pycache__/config_screen.cpython-314.pyc +0 -0
  66. package/onecode/__pycache__/migrate.cpython-314.pyc +0 -0
  67. package/onecode/agent/__pycache__/cdh_loader.cpython-314.pyc +0 -0
  68. package/onecode/agent/__pycache__/context.cpython-314.pyc +0 -0
  69. package/onecode/agent/__pycache__/engine.cpython-314.pyc +0 -0
  70. package/onecode/agent/__pycache__/hooks.cpython-314.pyc +0 -0
  71. package/onecode/agent/__pycache__/onecode_agent_acp.cpython-314.pyc +0 -0
  72. package/onecode/agent/__pycache__/permissions.cpython-314.pyc +0 -0
  73. package/onecode/agent/__pycache__/project_doc.cpython-314.pyc +0 -0
  74. package/onecode/agent/__pycache__/session.cpython-314.pyc +0 -0
  75. package/onecode/agent/agents/__pycache__/types.cpython-314.pyc +0 -0
  76. package/onecode/agent/agents/types.py +47 -164
  77. package/onecode/agent/attachments.py +0 -1
  78. package/onecode/agent/cdh_loader.py +17 -25
  79. package/onecode/agent/context.py +8 -3
  80. package/onecode/agent/engine.py +835 -220
  81. package/onecode/agent/hooks.py +1 -1
  82. package/onecode/agent/onecode_agent_acp.py +460 -47
  83. package/onecode/agent/onecode_agent_acp.py.bak +2447 -0
  84. package/onecode/agent/permissions.py +0 -1
  85. package/onecode/agent/project_doc.py +19 -0
  86. package/onecode/agent/session.py +17 -3
  87. package/onecode/agent/snapshot.py +3 -4
  88. package/onecode/agent/tools/__pycache__/agent_tools.cpython-314.pyc +0 -0
  89. package/onecode/agent/tools/__pycache__/apply_patch_tool.cpython-314.pyc +0 -0
  90. package/onecode/agent/tools/__pycache__/bash_tool.cpython-314.pyc +0 -0
  91. package/onecode/agent/tools/__pycache__/communication_tools.cpython-314.pyc +0 -0
  92. package/onecode/agent/tools/__pycache__/config_tool.cpython-314.pyc +0 -0
  93. package/onecode/agent/tools/__pycache__/cron_tools.cpython-314.pyc +0 -0
  94. package/onecode/agent/tools/__pycache__/file_ops.cpython-314.pyc +0 -0
  95. package/onecode/agent/tools/__pycache__/file_tools.cpython-314.pyc +0 -0
  96. package/onecode/agent/tools/__pycache__/git_tools.cpython-314.pyc +0 -0
  97. package/onecode/agent/tools/__pycache__/lsp_tools.cpython-314.pyc +0 -0
  98. package/onecode/agent/tools/__pycache__/mcp_tools.cpython-314.pyc +0 -0
  99. package/onecode/agent/tools/__pycache__/permission_handler.cpython-314.pyc +0 -0
  100. package/onecode/agent/tools/__pycache__/protocol.cpython-314.pyc +0 -0
  101. package/onecode/agent/tools/__pycache__/registry.cpython-314.pyc +0 -0
  102. package/onecode/agent/tools/__pycache__/sandbox.cpython-314.pyc +0 -0
  103. package/onecode/agent/tools/__pycache__/skill_tools.cpython-314.pyc +0 -0
  104. package/onecode/agent/tools/__pycache__/todo_tools.cpython-314.pyc +0 -0
  105. package/onecode/agent/tools/__pycache__/web_tools.cpython-314.pyc +0 -0
  106. package/onecode/agent/tools/agent_tools.py +4 -6
  107. package/onecode/agent/tools/apply_patch_tool.py +2 -8
  108. package/onecode/agent/tools/bash_tool.py +32 -12
  109. package/onecode/agent/tools/communication_tools.py +4 -5
  110. package/onecode/agent/tools/config_tool.py +3 -3
  111. package/onecode/agent/tools/cron_tools.py +4 -5
  112. package/onecode/agent/tools/file_ops.py +37 -3
  113. package/onecode/agent/tools/file_tools.py +10 -11
  114. package/onecode/agent/tools/git_tools.py +2 -2
  115. package/onecode/agent/tools/lsp_tools.py +2 -2
  116. package/onecode/agent/tools/mcp_tools.py +3 -5
  117. package/onecode/agent/tools/permission_handler.py +1 -1
  118. package/onecode/agent/tools/protocol.py +1 -1
  119. package/onecode/agent/tools/registry.py +69 -2
  120. package/onecode/agent/tools/sandbox.py +110 -16
  121. package/onecode/agent/tools/skill_tools.py +2 -2
  122. package/onecode/agent/tools/todo_tools.py +38 -19
  123. package/onecode/agent/tools/web_tools.py +6 -7
  124. package/onecode/builtin_skills/agent-browser/SKILL.md +212 -0
  125. package/onecode/builtin_skills/agent-browser/skill.yaml +8 -0
  126. package/onecode/builtin_skills/skill-creator/SKILL.md +106 -0
  127. package/onecode/builtin_skills/skill-creator/skill.yaml +8 -0
  128. package/onecode/cli.py +186 -6
  129. package/onecode/codebase/__pycache__/chunker.cpython-314.pyc +0 -0
  130. package/onecode/codebase/__pycache__/indexer.cpython-314.pyc +0 -0
  131. package/onecode/codebase/__pycache__/retriever.cpython-314.pyc +0 -0
  132. package/onecode/codebase/__pycache__/storage.cpython-314.pyc +0 -0
  133. package/onecode/codebase/chunker.py +0 -5
  134. package/onecode/codebase/indexer.py +14 -1
  135. package/onecode/codebase/retriever.py +13 -2
  136. package/onecode/codebase/storage.py +5 -4
  137. package/onecode/commands.py +81 -6
  138. package/onecode/config.py +41 -12
  139. package/onecode/config_screen.py +7 -3
  140. package/onecode/mcp/__pycache__/manager.cpython-314.pyc +0 -0
  141. package/onecode/mcp/manager.py +2 -3
  142. package/onecode/memory/__init__.py +7 -53
  143. package/onecode/memory/__pycache__/__init__.cpython-314.pyc +0 -0
  144. package/onecode/memory/__pycache__/backend.cpython-314.pyc +0 -0
  145. package/onecode/memory/__pycache__/offload.cpython-314.pyc +0 -0
  146. package/onecode/memory/__pycache__/pyramid.cpython-314.pyc +0 -0
  147. package/onecode/memory/__pycache__/recall.cpython-314.pyc +0 -0
  148. package/onecode/memory/backend.py +13 -39
  149. package/onecode/memory/pyramid.py +2 -71
  150. package/onecode/memory/recall.py +38 -3
  151. package/onecode/migrate.py +122 -0
  152. package/onecode/models/__pycache__/messages.cpython-314.pyc +0 -0
  153. package/onecode/models/__pycache__/provider.cpython-314.pyc +0 -0
  154. package/onecode/models/messages.py +58 -0
  155. package/onecode/models/provider.py +20 -2
  156. package/onecode/models/providers/__pycache__/anthropic_provider.cpython-314.pyc +0 -0
  157. package/onecode/models/providers/__pycache__/glm_provider.cpython-314.pyc +0 -0
  158. package/onecode/models/providers/__pycache__/minimax_provider.cpython-314.pyc +0 -0
  159. package/onecode/models/providers/__pycache__/minimaxi_provider.cpython-314.pyc +0 -0
  160. package/onecode/models/providers/__pycache__/ollama_provider.cpython-314.pyc +0 -0
  161. package/onecode/models/providers/anthropic_provider.py +59 -12
  162. package/onecode/models/providers/glm_provider.py +40 -0
  163. package/onecode/models/providers/minimax_provider.py +3 -0
  164. package/onecode/models/providers/minimaxi_provider.py +3 -0
  165. package/onecode/models/providers/ollama_provider.py +3 -0
  166. package/onecode/server/app.py +1 -1
  167. package/onecode/skills/__pycache__/argument_substitution.cpython-314.pyc +0 -0
  168. package/onecode/skills/__pycache__/bootstrap.cpython-314.pyc +0 -0
  169. package/onecode/skills/__pycache__/loader.cpython-314.pyc +0 -0
  170. package/onecode/skills/__pycache__/manager.cpython-314.pyc +0 -0
  171. package/onecode/skills/argument_substitution.py +0 -1
  172. package/onecode/skills/bootstrap.py +124 -0
  173. package/onecode/skills/loader.py +35 -53
  174. package/onecode/skills/manager.py +2 -2
  175. package/onecode/storage/activity.py +3 -4
  176. package/onecode/storage/session.py +3 -4
  177. package/onecode/tasks/manager.py +23 -10
  178. package/onecode/trace/tracer.py +2 -3
  179. package/package.json +1 -1
  180. package/pyproject.toml +5 -1
  181. package/run.js +68 -14
  182. package/tui/__pycache__/about.cpython-314.pyc +0 -0
  183. package/tui/__pycache__/app.cpython-313.pyc +0 -0
  184. package/tui/__pycache__/app.cpython-314.pyc +0 -0
  185. package/tui/__pycache__/danger.cpython-314.pyc +0 -0
  186. package/tui/__pycache__/directory_suggester.cpython-314.pyc +0 -0
  187. package/tui/__pycache__/messages.cpython-314.pyc +0 -0
  188. package/tui/__pycache__/path_complete.cpython-314.pyc +0 -0
  189. package/tui/__pycache__/paths.cpython-314.pyc +0 -0
  190. package/tui/__pycache__/session_tracker.cpython-314.pyc +0 -0
  191. package/tui/__pycache__/settings_schema.cpython-314.pyc +0 -0
  192. package/tui/acp/__pycache__/agent.cpython-314.pyc +0 -0
  193. package/tui/acp/__pycache__/api.cpython-314.pyc +0 -0
  194. package/tui/acp/__pycache__/messages.cpython-314.pyc +0 -0
  195. package/tui/acp/__pycache__/prompt.cpython-314.pyc +0 -0
  196. package/tui/acp/__pycache__/protocol.cpython-314.pyc +0 -0
  197. package/tui/acp/agent.py +125 -58
  198. package/tui/acp/api.py +12 -0
  199. package/tui/acp/messages.py +3 -1
  200. package/tui/acp/prompt.py +0 -57
  201. package/tui/acp/protocol.py +6 -0
  202. package/tui/app.py +11 -33
  203. package/tui/messages.py +2 -0
  204. package/tui/path_complete.py +1 -1
  205. package/tui/paths.py +16 -0
  206. package/tui/screens/__pycache__/agent_modal.cpython-314.pyc +0 -0
  207. package/tui/screens/__pycache__/component_picker.cpython-314.pyc +0 -0
  208. package/tui/screens/__pycache__/diff_screen.cpython-314.pyc +0 -0
  209. package/tui/screens/__pycache__/log.cpython-314.pyc +0 -0
  210. package/tui/screens/__pycache__/main.cpython-313.pyc +0 -0
  211. package/tui/screens/__pycache__/main.cpython-314.pyc +0 -0
  212. package/tui/screens/__pycache__/permissions.cpython-314.pyc +0 -0
  213. package/tui/screens/__pycache__/projects_app.cpython-314.pyc +0 -0
  214. package/tui/screens/__pycache__/projects_screen.cpython-314.pyc +0 -0
  215. package/tui/screens/__pycache__/sessions.cpython-314.pyc +0 -0
  216. package/tui/screens/__pycache__/store.cpython-314.pyc +0 -0
  217. package/tui/screens/__pycache__/tool_content_screen.cpython-314.pyc +0 -0
  218. package/tui/screens/component_picker.py +35 -15
  219. package/tui/screens/diff_screen.py +84 -0
  220. package/tui/screens/log.py +25 -25
  221. package/tui/screens/main.py +48 -26
  222. package/tui/screens/projects.tcss +5 -1
  223. package/tui/screens/projects_app.py +6 -6
  224. package/tui/screens/projects_screen.py +19 -39
  225. package/tui/screens/sessions.py +3 -5
  226. package/tui/session_tracker.py +3 -0
  227. package/tui/settings_schema.py +1 -1
  228. package/tui/visuals/__pycache__/columns.cpython-314.pyc +0 -0
  229. package/tui/visuals/columns.py +1 -2
  230. package/tui/widgets/__pycache__/acp_content.cpython-314.pyc +0 -0
  231. package/tui/widgets/__pycache__/agent_response.cpython-314.pyc +0 -0
  232. package/tui/widgets/__pycache__/agent_thought.cpython-314.pyc +0 -0
  233. package/tui/widgets/__pycache__/conversation.cpython-314.pyc +0 -0
  234. package/tui/widgets/__pycache__/diff_view.cpython-314.pyc +0 -0
  235. package/tui/widgets/__pycache__/directory_input.cpython-314.pyc +0 -0
  236. package/tui/widgets/__pycache__/future_text.cpython-314.pyc +0 -0
  237. package/tui/widgets/__pycache__/grid_select.cpython-314.pyc +0 -0
  238. package/tui/widgets/__pycache__/load_more.cpython-314.pyc +0 -0
  239. package/tui/widgets/__pycache__/mandelbrot.cpython-314.pyc +0 -0
  240. package/tui/widgets/__pycache__/markdown_note.cpython-314.pyc +0 -0
  241. package/tui/widgets/__pycache__/modified_files.cpython-314.pyc +0 -0
  242. package/tui/widgets/__pycache__/plan.cpython-314.pyc +0 -0
  243. package/tui/widgets/__pycache__/project_grid_select.cpython-314.pyc +0 -0
  244. package/tui/widgets/__pycache__/project_summary.cpython-314.pyc +0 -0
  245. package/tui/widgets/__pycache__/session_grid_select.cpython-314.pyc +0 -0
  246. package/tui/widgets/__pycache__/session_summary.cpython-314.pyc +0 -0
  247. package/tui/widgets/__pycache__/shell_result.cpython-314.pyc +0 -0
  248. package/tui/widgets/__pycache__/side_bar.cpython-314.pyc +0 -0
  249. package/tui/widgets/__pycache__/subagent.cpython-314.pyc +0 -0
  250. package/tui/widgets/__pycache__/subagent_screen.cpython-314.pyc +0 -0
  251. package/tui/widgets/__pycache__/terminal.cpython-314.pyc +0 -0
  252. package/tui/widgets/__pycache__/tool_call.cpython-314.pyc +0 -0
  253. package/tui/widgets/agent_response.py +0 -1
  254. package/tui/widgets/agent_thought.py +0 -1
  255. package/tui/widgets/conversation.py +157 -14
  256. package/tui/widgets/diff_view.py +10 -0
  257. package/tui/widgets/load_more.py +24 -0
  258. package/tui/widgets/modified_files.py +86 -12
  259. package/tui/widgets/plan.py +1 -1
  260. package/tui/widgets/project_grid_select.py +9 -2
  261. package/tui/widgets/session_grid_select.py +6 -0
  262. package/tui/widgets/side_bar.py +10 -0
  263. package/tui/widgets/subagent.py +92 -58
  264. package/tui/widgets/subagent_screen.py +638 -115
  265. package/tui/widgets/terminal.py +1 -2
  266. package/tui/widgets/terminal_tool.py +11 -2
  267. package/tui/widgets/tool_call.py +35 -2
  268. package/onecode/memory/offload.py +0 -114
  269. package/onecode/memory/symbolic.py +0 -164
@@ -1,15 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import json
4
5
  import logging
5
6
  import re
6
- import time
7
7
  from pathlib import Path
8
- from typing import AsyncIterator, Optional
8
+ from typing import Any, AsyncIterator, Optional
9
9
 
10
10
  from dataclasses import dataclass
11
11
  from typing import Callable
12
12
 
13
+ from onecode.agent.agents.types import AgentPermission
13
14
  from onecode.agent.context import ContextManager
14
15
  from onecode.agent.permissions_store import PermissionStore
15
16
  from onecode.models.provider import ContentBlockType, ProviderRegistry
@@ -39,6 +40,11 @@ class ToolEvent:
39
40
 
40
41
  ToolEventHandler = Callable[[ToolEvent], None]
41
42
 
43
+
44
+ class TurnCancelledError(Exception):
45
+ """Raised when the user cancels the agent's turn mid-execution."""
46
+
47
+
42
48
  TOOL_CALL_RE = re.compile(
43
49
  r'<tool_call\s+name=["\']([^"\']+)["\']\s+id=["\']([^"\']+)["\']>(.*?)</tool_call>',
44
50
  re.DOTALL,
@@ -390,6 +396,7 @@ class TodoManager:
390
396
  def clear_todos(self) -> None:
391
397
  self._todos.clear()
392
398
  self._order.clear()
399
+ self._id_counter = 0
393
400
  self._mark_dirty()
394
401
 
395
402
  # ── Serialization ──
@@ -411,7 +418,22 @@ class TodoManager:
411
418
  on the fly to the new ``{subject, status}`` shape.
412
419
  """
413
420
  tm = cls(on_change=on_change)
414
- tm._id_counter = data.get("id_counter", 0)
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)
415
437
 
416
438
  raw_todos: list[dict] = []
417
439
  if "todos" in data:
@@ -426,13 +448,12 @@ class TodoManager:
426
448
  if "status" not in todo and "done" in todo:
427
449
  todo["status"] = "completed" if todo.pop("done") else "pending"
428
450
  todo.setdefault("status", "pending")
429
- tid = todo.get("id") or tm._next_id()
451
+ tid = todo.get("id") or self._next_id()
430
452
  todo["id"] = tid
431
- todo.setdefault("_order", tm._id_counter)
432
- tm._todos[tid] = todo
433
- if tid not in tm._order:
434
- tm._order.append(tid)
435
- return tm
453
+ todo.setdefault("_order", self._id_counter)
454
+ self._todos[tid] = todo
455
+ if tid not in self._order:
456
+ self._order.append(tid)
436
457
 
437
458
 
438
459
  # Tools that require a task plan before execution (plan gate)
@@ -457,7 +478,7 @@ class AgentEngine:
457
478
  self.context = ContextManager()
458
479
  self.file_ops = ToolFactory.create_file_ops(self._project_dir)
459
480
  self.shell = ToolFactory.create_shell(self._project_dir)
460
- self.current_agent: AgentConfig = BuildAgent()
481
+ self.current_agent: AgentConfig = BuildAgent() # noqa: F821
461
482
  self.iterations = 0
462
483
  self.total_tokens = 0
463
484
  self._skills_loaded = False
@@ -486,7 +507,7 @@ class AgentEngine:
486
507
  self._config_tool_write = ConfigWriteTool(app_config) if app_config else None
487
508
 
488
509
  # Codebase engine (lazy init)
489
- self._codebase_engine: Optional["CodebaseEngine"] = None
510
+ self._codebase_engine: Optional["CodebaseEngine"] = None # noqa: F821
490
511
 
491
512
  # Tool registry (Clawd-Code pattern)
492
513
  self._tool_registry = self._build_tool_registry()
@@ -509,6 +530,19 @@ class AgentEngine:
509
530
  # Subagent depth limit — subagents are leaf nodes (max depth = 1).
510
531
  self._subagent_depth: int = 0
511
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
+
512
546
  # Monotonic tool-call id counter — shared across every
513
547
  # ``chat_stream`` turn so the same id is never reused even when the
514
548
  # model re-emits ``legacy-0`` / ``minimax-0`` in a fresh turn.
@@ -535,7 +569,12 @@ class AgentEngine:
535
569
  self._react_phase: str = "thought" # "thought" | "action" | "observation"
536
570
  self._direct_execution_count: int = 0 # Track direct tool use for routing-decision reminder
537
571
 
538
- def _build_tool_registry(self) -> ToolRegistry:
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
539
578
  from onecode.agent.tools.registry import ToolRegistry
540
579
  from onecode.agent.tools.file_tools import ReadTool, WriteTool, EditTool, InsertTool, UndoEditTool, GlobTool, GrepTool, ListTool
541
580
  from onecode.agent.tools.apply_patch_tool import ApplyPatchTool
@@ -543,7 +582,7 @@ class AgentEngine:
543
582
  from onecode.agent.tools.web_tools import WebFetchTool, WebSearchTool
544
583
  from onecode.agent.tools.communication_tools import SendMessageTool, AskUserTool, ToolSearchTool
545
584
  from onecode.agent.tools.todo_tools import (TodoCreateTool, TodoGetTool, TodoListTool, TodoUpdateTool,
546
- TodoOutputTool, TodoStopTool)
585
+ TodoOutputTool, TodoStopTool, TodoClearTool)
547
586
  from onecode.agent.tools.agent_tools import AgentTool, TaskTool
548
587
  from onecode.agent.tools.skill_tools import SkillTool
549
588
  from onecode.agent.tools.mcp_tools import MCPTool as MCPToolTool, MCPResourcesTool
@@ -577,6 +616,7 @@ class AgentEngine:
577
616
  registry.register(TodoUpdateTool(self._todo_manager))
578
617
  registry.register(TodoOutputTool(self._todo_manager))
579
618
  registry.register(TodoStopTool(self._todo_manager))
619
+ registry.register(TodoClearTool(self._todo_manager))
580
620
  # Agent tools
581
621
  registry.register(AgentTool(registry, permission_checker=self._check_tool_permission))
582
622
  registry.register(TaskTool(self._spawn_subagent_async))
@@ -684,23 +724,96 @@ class AgentEngine:
684
724
  ]
685
725
  return [StreamEvent.plan(wire_entries)]
686
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
+
687
803
  def _refresh_pending_todos_nudge(self) -> None:
688
804
  """Inject a reminder that nudges the agent to advance unfinished todos.
689
805
 
690
806
  Called at the start of each ReAct turn. If there are pending or
691
- in-progress todos, a ``<!-- PENDING_TODOS -->`` section is added to
692
- the system context so the LLM sees it during its next Thought phase
693
- and prefers to:
694
- 1. continue the in-progress todo, or
695
- 2. start the next pending todo (respecting ``addBlockedBy`` DAG), or
696
- 3. call ``AskUser`` if the todo is blocked on user input.
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.
697
810
 
698
811
  When no todos are open, any stale nudge is removed so the context
699
812
  stays clean.
700
813
  """
701
814
  marker = "<!-- PENDING_TODOS -->"
702
- todos = self._todo_manager.list_todos()
703
- open_todos = [t for t in todos if t.get("status") in ("pending", "in_progress")]
815
+ open_todos = [t for t in self._todo_manager.list_todos()
816
+ if t.get("status") in ("pending", "in_progress")]
704
817
  if not open_todos:
705
818
  self.context.remove_system_by_marker(marker)
706
819
  return
@@ -716,26 +829,19 @@ class AgentEngine:
716
829
  return f"- `{sid}`{owner_part}: {sub}"
717
830
 
718
831
  lines: list[str] = []
719
- lines.append(
720
- "There are unfinished todos from earlier in this session. Prioritize "
721
- "them before starting new work: finish in-progress items, then move "
722
- "to the next pending todo in DAG order (respect `addBlockedBy`). "
723
- "If a todo is blocked on user input or external information, call "
724
- "`AskUser` to clarify instead of starting a new branch."
725
- )
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.")
726
835
  if in_progress:
727
- lines.append("")
728
- lines.append("**In progress:**")
729
- lines.extend(_label(t) for t in in_progress)
836
+ labels = "\n".join(_label(t) for t in in_progress)
837
+ lines.append(f"\nIn progress:\n{labels}")
730
838
  if pending:
731
- lines.append("")
732
- lines.append("**Pending:**")
733
- lines.extend(_label(t) for t in pending)
734
- lines.append("")
839
+ labels = "\n".join(_label(t) for t in pending)
840
+ lines.append(f"\nPending:\n{labels}")
735
841
  lines.append(
736
- "Next action: either `TodoUpdate(status=\"in_progress\")` to pick one "
737
- "up, advance it with the appropriate tool, and `TodoUpdate(status=\"completed\")` "
738
- "when done or `AskUser` to unblock."
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."
739
845
  )
740
846
  body = f"{marker}\n" + "\n".join(lines)
741
847
  if not self.context.replace_system_section(marker, body):
@@ -746,8 +852,12 @@ class AgentEngine:
746
852
 
747
853
  Sets a dirty flag so the next opportunity in ``chat_stream`` will
748
854
  emit a fresh plan event. Decoupling the callback from the emit
749
- keeps the public API synchronous and avoids re-entrancy when
750
- subagents mutate the same TodoManager concurrently.
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.
751
861
 
752
862
  Also persists todos to ``.cdh/todos.json`` immediately so they
753
863
  survive a crash or Ctrl+C before the next ACP turn boundary.
@@ -761,11 +871,13 @@ class AgentEngine:
761
871
 
762
872
  def set_agent(self, agent_type: str) -> None:
763
873
  from onecode.agent.agents.types import (
874
+ AgentMode,
764
875
  AgentPermission,
876
+ SUBAGENT_CONSTRAINTS,
765
877
  create_agent,
766
- PLAN_INSTRUCTIONS,
767
- REACT_CYCLE,
768
- TOOL_DESCRIPTIONS,
878
+ PLAN_GATE_HARD,
879
+ PLAN_GATE_SOFT,
880
+ REACT_WORKFLOW,
769
881
  filter_tool_descriptions,
770
882
  )
771
883
  self.current_agent = create_agent(agent_type)
@@ -781,51 +893,97 @@ class AgentEngine:
781
893
  restrictions.append("- Shell commands require user approval")
782
894
  system_parts.append("\n".join(restrictions))
783
895
 
896
+ if self.current_agent.mode == AgentMode.SUBAGENT:
897
+ system_parts.append(SUBAGENT_CONSTRAINTS)
898
+
784
899
  if self.current_agent.permission_task != AgentPermission.DENY:
785
- system_parts.append(REACT_CYCLE)
786
- system_parts.append(PLAN_INSTRUCTIONS)
787
-
788
- # Tell the model how to format intermediate reasoning so the TUI
789
- # can render it as a collapsible "thought" block instead of
790
- # leaking it into the visible answer. Without these tags, the
791
- # model's planning prose ("I will now update X…", "X is done,
792
- # let me verify…") flows through as plain ``agent_message_chunk``
793
- # and shows up in chat interleaved with tool calls.
794
- system_parts.append(
795
- "\n## Response style\n"
796
- "- **Every turn must start with Chain of Thought reasoning** "
797
- "inside `<thinking>`. Analyze the current state, determine the "
798
- "next step, and decide whether to delegate via `Spawn` or track via `TodoCreate`.\n"
799
- "- **Route by complexity**:\n"
800
- " - Simple / single-step work → use the direct tool and optionally "
801
- "`TodoCreate` to surface progress in the sidebar.\n"
802
- " - Complex / multi-step work use `Spawn(agent_type, prompt)` to "
803
- "delegate to a focused subagent rather than calling execution tools directly.\n"
804
- "- If you need to reason between tool calls, wrap your "
805
- "reasoning in `<thinking>...</thinking>`. The TUI will "
806
- "render the wrapped block as a collapsible thought and "
807
- "keep it out of the main answer.\n"
808
- "- Do not narrate your plan in the visible answer. Avoid "
809
- 'phrases like "I will now…", "Let me first…", "X is done, '
810
- 'let me verify…" those should go inside `<thinking>`. '
811
- "The visible answer is only the final user-facing summary.\n"
812
- )
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)
813
963
 
964
+ # Inject TOOL_DESCRIPTIONS as its own marker so it can be independently
965
+ # stripped when the provider supports native tool schemas.
814
966
  tool_desc = filter_tool_descriptions(
815
967
  allowlist=self.current_agent.tools or None,
816
968
  denylist=self.current_agent.disallowed_tools or None,
817
969
  )
818
- system_parts.append(tool_desc)
819
-
820
- tagged_content = "<!-- AGENT_CONFIG -->\n" + "\n".join(system_parts)
821
- if not self.context.replace_system_section("AGENT_CONFIG", tagged_content):
822
- self.context.add_system(tagged_content)
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)
823
973
 
824
974
  # Re-apply user permission overrides (e.g. "allow always")
825
975
  self._perm_store.apply_to(self.current_agent)
826
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
+
827
985
  def get_available_tools(self) -> str:
828
- from onecode.agent.agents.types import TOOL_DESCRIPTIONS, filter_tool_descriptions
986
+ from onecode.agent.agents.types import filter_tool_descriptions
829
987
  return filter_tool_descriptions(
830
988
  allowlist=self.current_agent.tools or None,
831
989
  denylist=self.current_agent.disallowed_tools or None,
@@ -838,6 +996,7 @@ class AgentEngine:
838
996
  # Remove previously loaded skill-tagged messages so they don't
839
997
  # accumulate across agent switches or skill re-loads.
840
998
  self.context.remove_system_by_marker("<!-- SKILL:")
999
+ self.context.remove_system_by_marker("<!-- PROJECT_DOC -->")
841
1000
  self.context.remove_system_by_marker("<!-- CDH_PROJECT -->")
842
1001
  self._skills_loaded = True
843
1002
 
@@ -845,6 +1004,12 @@ class AgentEngine:
845
1004
  tagged = f"<!-- SKILL:{skill.name} -->\n{skill.content}"
846
1005
  self.context.add_system(tagged)
847
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
+
848
1013
  # Load project .cdh/ state into context
849
1014
  from onecode.agent.cdh_loader import CdhProjectLoader
850
1015
  cdh_content = CdhProjectLoader.load_for_workspace(self._workspace)
@@ -869,14 +1034,6 @@ class AgentEngine:
869
1034
  if env_id:
870
1035
  context_parts.append(f"TCB EnvId: {env_id}")
871
1036
 
872
- agents_md_path = self._workspace / "AGENTS.md"
873
- if agents_md_path.exists():
874
- try:
875
- content = agents_md_path.read_text(encoding="utf-8")
876
- context_parts.append(f"\n--- AGENTS.md ---\n{content[:2000]}")
877
- except Exception:
878
- pass
879
-
880
1037
  self.context.add_system("\n".join(context_parts))
881
1038
 
882
1039
  def _should_retrieve_codebase(self) -> bool:
@@ -982,11 +1139,26 @@ class AgentEngine:
982
1139
  "Spawn": "task",
983
1140
  "Agent": "task",
984
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",
985
1150
  }
986
1151
 
987
1152
  def _check_tool_permission(self, name: str, inp: dict) -> str | None:
988
1153
  """Unified agent-level permission check for all tools."""
989
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})"})
990
1162
  perm_key = self._TOOL_NAME_TO_PERM_KEY.get(name)
991
1163
  if perm_key is None:
992
1164
  return None
@@ -1011,7 +1183,9 @@ class AgentEngine:
1011
1183
  return {**base, "content": denied, "is_error": True}
1012
1184
 
1013
1185
  call = RegistryToolCall(name=name, input=inp, tool_use_id=tid)
1014
- result = self._tool_registry.dispatch(call)
1186
+ result = await self._tool_registry.dispatch_async(
1187
+ call, cancel_check=lambda: self._cancelled,
1188
+ )
1015
1189
 
1016
1190
  # Handle SendMessage tracking
1017
1191
  if name == "SendMessage":
@@ -1026,7 +1200,7 @@ class AgentEngine:
1026
1200
  logger.exception(f"Tool execution error: {e}")
1027
1201
  return {**base, "content": f"Error: {safe_error_msg(e)}", "is_error": True}
1028
1202
 
1029
- def _format_tool_output(self, result: RegistryToolResult) -> str:
1203
+ def _format_tool_output(self, result: RegistryToolResult) -> str: # noqa: F821
1030
1204
  import json
1031
1205
  output = result.output
1032
1206
  if isinstance(output, str):
@@ -1042,6 +1216,20 @@ class AgentEngine:
1042
1216
  for child in self._child_engines:
1043
1217
  child._cancelled = True
1044
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
+
1045
1233
  async def chat_stream(self, user_input: str | list[dict]) -> AsyncIterator[StreamEvent | str]:
1046
1234
  self._load_skills()
1047
1235
  preview = user_input[:100] if isinstance(user_input, str) else f"[{len(user_input)} content blocks]"
@@ -1068,7 +1256,14 @@ class AgentEngine:
1068
1256
  self._plan_dirty = False
1069
1257
 
1070
1258
  # ── Codebase auto-retrieval ──
1071
- if isinstance(user_input, str) and self._should_retrieve_codebase():
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
+ ):
1072
1267
  try:
1073
1268
  engine = await self._get_codebase_engine()
1074
1269
  if engine:
@@ -1083,9 +1278,16 @@ class AgentEngine:
1083
1278
  logger.warning("Codebase retrieval failed: %s", e)
1084
1279
 
1085
1280
  # ── Long-term memory recall ──
1086
- if isinstance(user_input, str) and self._session:
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
+ ):
1087
1288
  try:
1088
- results = self._memory.search_memories(user_input, top_k=5)
1289
+ top_k = self.app.config.memory.top_k
1290
+ results = self._memory.search_memories(user_input, top_k=top_k)
1089
1291
  if results:
1090
1292
  lines = ["## Relevant past memories"]
1091
1293
  for r in results:
@@ -1140,30 +1342,23 @@ class AgentEngine:
1140
1342
  provider_kwargs = dict(api_key=config.api_key or "", endpoint=config.endpoint or None)
1141
1343
  provider = provider_cls(**provider_kwargs)
1142
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
+
1143
1351
  # Reset per-turn usage tracking
1144
1352
  self._turn_usages = []
1145
1353
 
1146
1354
  # Reset cancellation flag (adapter also resets it before calling)
1147
1355
  self._cancelled = False
1148
1356
 
1149
- # ── Agent loop: CoT + ReAct (思考 → 行动 → 观察) ──
1357
+ # ── Agent loop: 思考 Todo → 行动 (per-Round) ──
1150
1358
  is_anthropic = provider.is_anthropic_style()
1151
1359
  max_turns = self.current_agent.max_turns or 10
1152
1360
 
1153
- # Initialize ReAct phase section (first turn prompt)
1154
- cot_phase_init = (
1155
- "<!-- REACT_PHASE -->\n"
1156
- "## Turn 1 — Thought Phase (思考)\n"
1157
- "Begin this turn by reasoning step by step in `<thinking>`:\n"
1158
- "1. **Current state**: Analyze the user request and what needs to be done.\n"
1159
- "2. **Goal**: What should I accomplish this turn?\n"
1160
- "3. **Routing**: For the next action, choose by complexity:\n"
1161
- " - Simple (1 tool call, 1 file) → direct tool + optional `TodoCreate` for tracking\n"
1162
- " - Complex (multi-file, multi-step, research-heavy) → `Spawn(agent_type, prompt)`\n"
1163
- " - Pure research → `Read/Grep/Glob/WebFetch` directly, or `explore/scout` subagent\n"
1164
- "4. **Plan when needed**: For multi-step work, create a todo plan first via "
1165
- "`TodoCreate`, then mark each todo as `in_progress` / `completed` as you go.\n"
1166
- )
1361
+ cot_phase_init = "<!-- REACT_PHASE -->\n## Round 1 — 思考 → Todo → 行动\n"
1167
1362
  if not self.context.replace_system_section("REACT_PHASE", cot_phase_init):
1168
1363
  self.context.add_system(cot_phase_init)
1169
1364
 
@@ -1175,24 +1370,16 @@ class AgentEngine:
1175
1370
  # ── Thought Phase: update CoT reasoning guidance for this turn ──
1176
1371
  # Cleanup stale reminders that may have accumulated
1177
1372
  self.context.remove_system_by_marker("<!-- ROUTING_REMINDER -->")
1373
+ self.context.remove_system_by_marker("<!-- PLAN_REMINDER -->")
1178
1374
  # Inject (or refresh) a nudge that prioritizes any unfinished todos
1179
1375
  # from this session. The marker is replaced in-place so the context
1180
1376
  # stays bounded — the agent always sees the current open list.
1181
1377
  self._refresh_pending_todos_nudge()
1182
1378
  if turn > 0:
1183
- cot_phase = (
1184
- f"<!-- REACT_PHASE -->\n"
1185
- f"## Turn {turn + 1} — Thought Phase (思考)\n"
1186
- "Begin this turn by reasoning step by step in `<thinking>`:\n"
1187
- "1. **Current state**: What just happened? What results do I have?\n"
1188
- "2. **Goal**: What should I accomplish this turn?\n"
1189
- "3. **Routing**: For the next action, choose by complexity:\n"
1190
- " - Simple (1 tool call, 1 file) → direct tool + optional `TodoCreate` for tracking\n"
1191
- " - Complex (multi-file, multi-step, research-heavy) → `Spawn(agent_type, prompt)`\n"
1192
- "4. **Plan when needed**: For multi-step work, advance todos via `TodoUpdate` "
1193
- "and delegate substantial items via `Spawn`.\n"
1379
+ self.context.replace_system_section(
1380
+ "REACT_PHASE",
1381
+ f"<!-- REACT_PHASE -->\n## Round {turn + 1} — 思考 Todo → 行动\n",
1194
1382
  )
1195
- self.context.replace_system_section("REACT_PHASE", cot_phase)
1196
1383
 
1197
1384
  self._pending_thinking_blocks = []
1198
1385
 
@@ -1214,6 +1401,8 @@ class AgentEngine:
1214
1401
  _original_cb = self.on_text_chunk
1215
1402
  def _stream_wrapper(text: str) -> None:
1216
1403
  self._streaming_used = True
1404
+ if self._cancelled:
1405
+ raise TurnCancelledError()
1217
1406
  _original_cb(text)
1218
1407
  stream_cb = _stream_wrapper
1219
1408
  else:
@@ -1291,6 +1480,9 @@ class AgentEngine:
1291
1480
  logger.exception(f"Error during chat() fallback turn {turn+1}: {e}")
1292
1481
  yield StreamEvent.error(safe_error_msg(e))
1293
1482
  break
1483
+ except TurnCancelledError:
1484
+ yield StreamEvent.text_delta("\n\n*Turn cancelled*\n\n")
1485
+ return
1294
1486
  except Exception as e:
1295
1487
  ctx_msgs = len(self.context.messages)
1296
1488
  ctx_tokens = self.context._token_count
@@ -1302,6 +1494,12 @@ class AgentEngine:
1302
1494
  yield StreamEvent.error(f"Provider error (turn {turn+1}): {safe_error_msg(e)}")
1303
1495
  break
1304
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
+
1305
1503
  # Track usage
1306
1504
  self._turn_usages.append(turn_usage)
1307
1505
  if turn_usage:
@@ -1327,7 +1525,7 @@ class AgentEngine:
1327
1525
  # Log the raw model response so developers can verify the
1328
1526
  # model is actually emitting ``<thinking>`` markers (and not
1329
1527
  # bleeding planning prose into the visible answer). Goes
1330
- # to the onecode root logger → ~/.cdh/logs/cdh.log when
1528
+ # to the onecode root logger → ~/.onecode/logs/onecode.log when
1331
1529
  # ``setup_logging(DEBUG)`` is in effect.
1332
1530
  logger.debug(
1333
1531
  "RAW_RESPONSE turn=%d text_len=%d first_200=%r "
@@ -1362,14 +1560,15 @@ class AgentEngine:
1362
1560
 
1363
1561
  self.iterations += 1
1364
1562
 
1365
- if not tool_uses:
1366
- if not self._streaming_used:
1367
- if clean_text.strip():
1368
- for i in range(0, len(clean_text), 12):
1369
- yield StreamEvent.text_delta(clean_text[i:i + 12])
1370
- elif self._last_user_msg:
1371
- yield StreamEvent.text_delta(self._last_user_msg)
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])
1372
1570
 
1571
+ if not tool_uses:
1373
1572
  # ── Store conversation in long-term memory ──
1374
1573
  if self._session:
1375
1574
  try:
@@ -1382,6 +1581,90 @@ class AgentEngine:
1382
1581
  )
1383
1582
  except Exception as e:
1384
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
1385
1668
  break
1386
1669
 
1387
1670
  # Emit ToolEvents (Clawd-Code pattern) and StreamEvents for TUI
@@ -1397,6 +1680,9 @@ class AgentEngine:
1397
1680
  yield StreamEvent.tool_call_complete(tu["id"], tu["name"], tu["input"])
1398
1681
 
1399
1682
  for tu in tool_uses:
1683
+ if self._cancelled:
1684
+ yield StreamEvent.text_delta("\n\n*Turn cancelled*\n\n")
1685
+ return
1400
1686
  logger.info(f"Executing tool: {tu['name']} (id={tu['id']})")
1401
1687
 
1402
1688
  # Spawn tool: forward subagent text deltas to the TUI as
@@ -1416,27 +1702,82 @@ class AgentEngine:
1416
1702
  else:
1417
1703
  subagent_type = tu["input"].get("agent_type", "general")
1418
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
+ )
1419
1714
  is_failed = False
1420
1715
  error_msg = ""
1421
1716
  yield StreamEvent.subagent_start(subagent_type, tu["id"], subagent_prompt)
1422
1717
  accumulated: list[str] = []
1423
- async for sub_event, sub_text in self._spawn_subagent_async_streaming(
1424
- subagent_type, subagent_prompt
1425
- ):
1426
- if not sub_text:
1427
- continue
1428
- if sub_event.type == StreamEventType.SUBAGENT_END:
1429
- # Carry status/error from subagent_end event
1430
- if hasattr(sub_event, "subagent_status") and sub_event.subagent_status == "failed":
1431
- is_failed = True
1432
- error_msg = sub_event.subagent_error or ""
1433
- continue # 修复 Bug 1: 不 append final combined text(避免文本重复)
1434
- if sub_event.type == StreamEventType.SUBAGENT_THINKING:
1435
- yield StreamEvent.subagent_thinking(tu["id"], sub_text)
1436
- continue
1437
- # TEXT_DELTA
1438
- accumulated.append(sub_text)
1439
- yield StreamEvent.subagent_chunk(tu["id"], sub_text)
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
+ )
1440
1781
  yield StreamEvent.subagent_end(
1441
1782
  tu["id"],
1442
1783
  agent_type=subagent_type,
@@ -1444,6 +1785,10 @@ class AgentEngine:
1444
1785
  error=error_msg,
1445
1786
  )
1446
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
+ )
1447
1792
  formatted = self._format_subagent_output(
1448
1793
  subagent_type, subagent_prompt, raw_output
1449
1794
  ) if not is_failed else (
@@ -1458,6 +1803,9 @@ class AgentEngine:
1458
1803
  "content": formatted,
1459
1804
  "raw_content": raw_output,
1460
1805
  }
1806
+ # Auto-advance todo after subagent completes
1807
+ if not is_failed:
1808
+ self._auto_advance_after_spawn(subagent_prompt)
1461
1809
  elif (self._plan_gate_mode != "off"
1462
1810
  and not self._todo_manager.list_todos()
1463
1811
  and tu["name"] in _EXECUTION_TOOLS
@@ -1488,16 +1836,17 @@ class AgentEngine:
1488
1836
  self.context.add_system(
1489
1837
  "<!-- PLAN_REMINDER -->\n"
1490
1838
  "## Routing Decision\n"
1491
- "Before your next action, choose the right tool based on complexity:\n\n"
1492
- "**Simple / single-step work** use `TodoCreate` to track a lightweight "
1493
- "checklist item in the sidebar. Examples: 1 file edit, 1 Read, 1 Bash. "
1494
- "The Todo widget shows progress to the user.\n\n"
1495
- "**Complex / multi-step work** → use `Spawn(agent_type, prompt)` to "
1496
- "delegate to a focused subagent. Examples: cross-file refactor, multi-"
1497
- "file feature, research requiring 3+ tool calls, work needing isolated "
1498
- "context. The subagent encapsulates work and returns structured output.\n\n"
1499
- "**Default**: When unsure `Spawn`. Todo is for visible progress on "
1500
- "simple items; Spawn is for substantial work.\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"
1501
1850
  )
1502
1851
  result = await self._execute_tool(tu)
1503
1852
  else:
@@ -1509,17 +1858,19 @@ class AgentEngine:
1509
1858
  self.context.add_system(
1510
1859
  "<!-- ROUTING_REMINDER -->\n"
1511
1860
  "You've used direct execution tools repeatedly. Pause and decide:\n"
1512
- "- If this is a single-step trivial change continue, but "
1513
- "consider `TodoCreate` to surface progress in the sidebar.\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"
1514
1864
  "- If this work involves >1 tool call, multiple files, or "
1515
1865
  "research → STOP direct execution and delegate via "
1516
1866
  "`Spawn(agent_type=\"general\", prompt=\"...\")` instead. The "
1517
- "subagent isolates context and returns a structured summary."
1867
+ "subagent isolates context and returns a structured summary; "
1868
+ "then mark the todo completed."
1518
1869
  )
1519
1870
  result_str = str(result.get("content", ""))
1520
1871
  is_error = result.get("is_error", False)
1521
1872
  category = result.get("category", "unknown")
1522
- # For Task tools, use raw subagent output for TUI display
1873
+ # For Spawn tools, use raw subagent output for TUI display
1523
1874
  # (no SUMMARY/CHANGES/… structured wrapper)
1524
1875
  tui_content = str(result.get("raw_content", result_str))
1525
1876
 
@@ -1627,6 +1978,13 @@ class AgentEngine:
1627
1978
  path=tu["input"].get("path", ""),
1628
1979
  command=tu["input"].get("command", "")[:200],
1629
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
1630
1988
  else:
1631
1989
  from onecode.models.messages import ToolCategory as MsgToolCategory
1632
1990
  try:
@@ -1664,6 +2022,9 @@ class AgentEngine:
1664
2022
  yield event
1665
2023
  self._plan_dirty = False
1666
2024
 
2025
+ # Tools were called this turn — reset the empty-turn counter
2026
+ self._empty_tool_turns = 0
2027
+
1667
2028
  if self._cancelled:
1668
2029
  yield StreamEvent.text_delta("\n\n*Turn cancelled*\n\n")
1669
2030
  return
@@ -1672,7 +2033,29 @@ class AgentEngine:
1672
2033
  f"turn {i+1}: {u.get('total_tokens', '?')} tokens"
1673
2034
  for i, u in enumerate(self._turn_usages) if u
1674
2035
  )
1675
- logger.info(f"Chat stream complete after {self.iterations} turn(s). Usage: [{usage_summary}]")
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)
1676
2059
 
1677
2060
  def has_pending_approval(self) -> bool:
1678
2061
  """Check if there's a pending approval request from ASK permission."""
@@ -1746,6 +2129,7 @@ class AgentEngine:
1746
2129
  """Reset ReAct loop state for a fresh cycle."""
1747
2130
  self._react_phase = "thought"
1748
2131
  self._direct_execution_count = 0
2132
+ self._empty_tool_turns = 0
1749
2133
  self._plan_gate_fired = False
1750
2134
  # Invalidate the plan-emit dedupe cache so the next chat_stream
1751
2135
  # always produces a fresh snapshot for the TUI.
@@ -1832,7 +2216,7 @@ class AgentEngine:
1832
2216
  return self._format_subagent_output(agent_type, prompt, result)
1833
2217
 
1834
2218
  async def _spawn_subagent_async_streaming(
1835
- self, agent_type: str, prompt: str
2219
+ self, agent_type: str, prompt: str, subagent_id: str = ""
1836
2220
  ) -> AsyncIterator[tuple[StreamEvent, str]]:
1837
2221
  """Streaming variant of :meth:`_spawn_subagent_async`.
1838
2222
 
@@ -1855,51 +2239,281 @@ class AgentEngine:
1855
2239
 
1856
2240
  sub_engine._subagent_depth = self._subagent_depth + 1
1857
2241
 
1858
- # Inherit parent context: project info, skills
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.
1859
2246
  sub_engine._project_context_loaded = self._project_context_loaded
1860
2247
  sub_engine._project_config = dict(self._project_config)
1861
- sub_engine._skills_loaded = True
1862
- for skill in self._skill_loader.get_enabled():
1863
- if skill.content not in (m.content for m in sub_engine.context.messages if m.role == "system"):
1864
- sub_engine.context.add_system(skill.content)
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
+ )
1865
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.
1866
2297
  sub_engine.set_agent(agent_type)
1867
2298
 
1868
2299
  # Track child so parent cancellation cascades
1869
2300
  self._child_engines.append(sub_engine)
1870
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
+
1871
2364
  parts: list[str] = []
2365
+ _streamed_via_text = False # True once on_text_chunk has delivered tokens
2366
+
1872
2367
  try:
1873
- async for chunk in sub_engine.chat_stream(prompt):
1874
- if isinstance(chunk, StreamEvent):
1875
- if chunk.type == StreamEventType.TEXT_DELTA and chunk.text:
1876
- parts.append(chunk.text)
1877
- yield chunk, chunk.text
1878
- elif chunk.type == StreamEventType.THINKING and chunk.thinking:
1879
- # 修复 Bug 4: 转发思考块
1880
- yield chunk, chunk.thinking
1881
- else:
1882
- if chunk:
1883
- parts.append(chunk)
1884
- except Exception as e:
1885
- logger.exception(f"Subagent error: {e}")
1886
- err_text = (
1887
- f"SUMMARY:\nSub-agent failed with error: {e}\n\n"
1888
- "CHANGES:\nNone.\n\nEVIDENCE:\nNone.\n\nRISKS:\nNone.\n\n"
1889
- f"BLOCKERS:\n{e}"
1890
- )
1891
- yield (StreamEvent.subagent_end(
1892
- "", agent_type=agent_type, status="failed", error=str(e),
1893
- ), err_text)
1894
- else:
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.
1895
2473
  yield (StreamEvent.subagent_end(
1896
- "", agent_type=agent_type, status="completed",
1897
- ), "".join(parts))
2474
+ "", agent_type=agent_type, status="failed", error="cancelled",
2475
+ ), "")
2476
+ raise
1898
2477
  finally:
1899
- # Clean up completed subagent to prevent memory leak
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)
1900
2500
  if sub_engine in self._child_engines:
1901
2501
  self._child_engines.remove(sub_engine)
1902
-
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
+
1903
2517
  def _format_subagent_output(self, agent_type: str, prompt: str, result: str) -> str:
1904
2518
  """Format sub-agent output in DeepSeek-TUI structured contract format.
1905
2519
 
@@ -1910,32 +2524,25 @@ class AgentEngine:
1910
2524
  RISKS: what could go wrong / what the parent should double-check
1911
2525
  BLOCKERS: what stopped you; "None." if you finished cleanly
1912
2526
  """
1913
- # Try to detect if the result already has structured sections
1914
- result_lower = result.lower()
1915
- has_sections = all(
1916
- tag.lower() in result_lower
1917
- for tag in ["summary", "changes", "evidence", "risks", "blockers"]
1918
- )
1919
-
2527
+ # Detect structured sections via line-start regex, not substring match
2528
+ has_sections = self._SUBAGENT_SECTION_RE.search(result) is not None
2529
+
1920
2530
  if has_sections:
1921
- # Already structured — just ensure proper formatting
1922
2531
  return result
1923
- else:
1924
- # Add structured wrapper around raw result
1925
- return (
1926
- f"SUMMARY:\n{agent_type.capitalize()} agent completed task: {prompt[:200]}\n\n"
1927
- f"CHANGES:\nNone detected.\n\n"
1928
- f"EVIDENCE:\n- See detailed output below\n\n"
1929
- f"RISKS:\nNone identified.\n\n"
1930
- f"BLOCKERS:\nNone.\n\n"
1931
- f"--- Raw Output ---\n{result}"
1932
- )
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
+ )
1933
2541
 
1934
2542
  def spawn_subagent(self, agent_type: str, prompt: str) -> dict:
1935
2543
  if self.current_agent.permission_task == AgentPermission.DENY:
1936
2544
  return {"success": False, "error": "Subagent denied"}
1937
2545
  import asyncio
1938
- import concurrent.futures
1939
2546
  try:
1940
2547
  loop = asyncio.get_event_loop()
1941
2548
  if loop.is_running():
@@ -1966,10 +2573,10 @@ class AgentEngine:
1966
2573
  if session.messages:
1967
2574
  self.context.load_from_session(session.messages)
1968
2575
  if session.todos:
1969
- self._todo_manager = TodoManager.from_dict({
2576
+ self._todo_manager.reload_from_dict({
1970
2577
  "todos": session.todos,
1971
2578
  "id_counter": len(session.todos),
1972
- }, on_change=self._on_todo_change)
2579
+ })
1973
2580
  # Restore context usage stats
1974
2581
  self._restore_session_stats(session)
1975
2582
 
@@ -1999,10 +2606,10 @@ class AgentEngine:
1999
2606
  self._session = session
2000
2607
  self.context.load_from_session(session.messages)
2001
2608
  if session.todos:
2002
- self._todo_manager = TodoManager.from_dict({
2609
+ self._todo_manager.reload_from_dict({
2003
2610
  "todos": session.todos,
2004
2611
  "id_counter": len(session.todos),
2005
- }, on_change=self._on_todo_change)
2612
+ })
2006
2613
  self._inject_loaded_tasks_into_context()
2007
2614
  # Restore context usage stats
2008
2615
  self._restore_session_stats(session)
@@ -2055,11 +2662,17 @@ class AgentEngine:
2055
2662
  self._turn_usages = list(restored_turns)
2056
2663
 
2057
2664
  def save_todos_to_project(self) -> None:
2058
- """Save todos to .cdh/todos.json in the project directory."""
2665
+ """Save todos to .cdh/todos.json uses find_cdh_dir_for_todos
2666
+ so sub-projects never overwrite a parent project's todos."""
2059
2667
  from onecode.agent.cdh_loader import CdhProjectLoader
2060
- cdh_dir = CdhProjectLoader.find_cdh_dir(self._project_dir)
2668
+ cdh_dir = CdhProjectLoader.find_cdh_dir_for_todos(self._project_dir)
2061
2669
  if cdh_dir is None:
2062
- return
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
2063
2676
  try:
2064
2677
  tm_data = self._todo_manager.to_dict()
2065
2678
  CdhProjectLoader.save_todos(cdh_dir, tm_data)
@@ -2067,17 +2680,19 @@ class AgentEngine:
2067
2680
  logger.warning("Failed to save todos to .cdh: %s", e)
2068
2681
 
2069
2682
  def load_todos_from_project(self) -> None:
2070
- """Restore todos from .cdh/todos.json (with legacy tasks.json fallback)."""
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)."""
2071
2687
  from onecode.agent.cdh_loader import CdhProjectLoader
2072
- cdh_dir = CdhProjectLoader.find_cdh_dir(self._project_dir)
2688
+ cdh_dir = CdhProjectLoader.find_cdh_dir_for_todos(self._project_dir)
2073
2689
  if cdh_dir is None:
2074
2690
  return
2075
2691
  try:
2076
2692
  data = CdhProjectLoader.load_todos(cdh_dir)
2077
2693
  if data and (data.get("tasks") or data.get("todos")):
2078
- self._todo_manager = TodoManager.from_dict(
2079
- data, on_change=self._on_todo_change
2080
- )
2694
+ self._todo_manager.reload_from_dict(data)
2695
+ self.context.remove_system_by_marker("<!-- NEW_SESSION_HINT -->")
2081
2696
  except Exception as e:
2082
2697
  logger.warning("Failed to load todos from .cdh: %s", e)
2083
2698