@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.
- package/ai-dlc-skill/SKILL.md +4 -2
- package/ai-dlc-skill/agents/master.md +6 -0
- package/ai-dlc-skill/agents/plan-agent.md +2 -2
- package/ai-dlc-skill/agents/understand-agent.md +4 -4
- package/ai-dlc-skill/agents/verify-agent.md +1 -1
- package/ai-dlc-skill/architecture/project-structure.md +10 -10
- package/ai-dlc-skill/brownfield/README.md +2 -2
- package/ai-dlc-skill/brownfield/generate-context.sh +3 -3
- package/ai-dlc-skill/brownfield/scripts/extract-api.sh +5 -5
- package/ai-dlc-skill/components/backend.md +1 -1
- package/ai-dlc-skill/components/web.md +2 -2
- package/ai-dlc-skill/contracts/README.md +3 -3
- package/ai-dlc-skill/cross-tool/cline/export.sh +1 -1
- package/ai-dlc-skill/cross-tool/copilot/export.sh +1 -1
- package/ai-dlc-skill/cross-tool/onecode/export-skills.sh +7 -3
- package/ai-dlc-skill/cross-tool/onecode/export.sh +14 -8
- package/ai-dlc-skill/phases/deliver/lifecycle.md +6 -6
- package/ai-dlc-skill/phases/deliver/prompt.md +2 -2
- package/ai-dlc-skill/phases/deliver/rules.md +1 -1
- package/ai-dlc-skill/phases/plan/lifecycle.md +11 -11
- package/ai-dlc-skill/phases/plan/prompt.md +6 -6
- package/ai-dlc-skill/phases/plan/rules.md +1 -1
- package/ai-dlc-skill/phases/understand/entry.md +1 -1
- package/ai-dlc-skill/phases/understand/lifecycle.md +7 -7
- package/ai-dlc-skill/phases/understand/prompt.md +8 -8
- package/ai-dlc-skill/phases/understand/rules.md +5 -5
- package/ai-dlc-skill/phases/verify/lifecycle.md +24 -24
- package/ai-dlc-skill/phases/verify/prompt.md +8 -8
- package/ai-dlc-skill/phases/verify/rules.md +9 -9
- package/ai-dlc-skill/providers/README.md +2 -2
- package/ai-dlc-skill/providers/aliyun/preview.yaml +2 -2
- package/ai-dlc-skill/providers/tcb/preview.yaml +2 -2
- package/ai-dlc-skill/requirements.md +7 -7
- package/ai-dlc-skill/skill.yaml +4 -4
- package/ai-dlc-skill/templates/integration/CHANGELOG.md +2 -2
- package/ai-dlc-skill/templates/integration/contract-diff.md +7 -7
- package/ai-dlc-skill/templates/integration/contract-spec.md +7 -7
- package/ai-dlc-skill/templates/integration/runtime-contract.yaml +1 -1
- package/ai-dlc-skill/templates/project/README.md +18 -19
- package/ai-dlc-skill/templates/project/template.md +21 -21
- package/ai-dlc-skill/walkthrough/collect.sh +1 -1
- package/ai-dlc-skill/walkthrough/template.md +2 -2
- package/ai-dlc-skill/workflows/ai-dlc.yaml +12 -12
- package/ai-dlc-skill/workflows/deployment.yaml +4 -4
- package/cdh/__pycache__/cdh_mcp_injector.cpython-314.pyc +0 -0
- package/cdh/__pycache__/cdh_mcp_loader.cpython-314.pyc +0 -0
- package/cdh/__pycache__/cdh_mcp_manager.cpython-314.pyc +0 -0
- package/cdh/__pycache__/cdh_session_aggregator.cpython-314.pyc +0 -0
- package/cdh/__pycache__/cdh_skill_loader.cpython-314.pyc +0 -0
- package/cdh/__pycache__/cdh_skill_manager.cpython-314.pyc +0 -0
- package/cdh/__pycache__/cli.cpython-313.pyc +0 -0
- package/cdh/__pycache__/cli.cpython-314.pyc +0 -0
- package/cdh/__pycache__/scaffold.cpython-314.pyc +0 -0
- package/cdh/cdh_mcp_injector.py +115 -0
- package/cdh/cdh_mcp_loader.py +67 -0
- package/cdh/cdh_mcp_manager.py +73 -0
- package/cdh/cdh_session_aggregator.py +258 -0
- package/cdh/cdh_skill_manager.py +105 -0
- package/cdh/cli.py +64 -4
- package/cdh/scaffold.py +192 -41
- package/onecode/__init__.py +1 -1
- package/onecode/__pycache__/cli.cpython-314.pyc +0 -0
- package/onecode/__pycache__/commands.cpython-314.pyc +0 -0
- package/onecode/__pycache__/config.cpython-314.pyc +0 -0
- package/onecode/__pycache__/config_screen.cpython-314.pyc +0 -0
- package/onecode/__pycache__/migrate.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/cdh_loader.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/context.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/engine.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/hooks.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/onecode_agent_acp.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/permissions.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/project_doc.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/session.cpython-314.pyc +0 -0
- package/onecode/agent/agents/__pycache__/types.cpython-314.pyc +0 -0
- package/onecode/agent/agents/types.py +47 -164
- package/onecode/agent/attachments.py +0 -1
- package/onecode/agent/cdh_loader.py +17 -25
- package/onecode/agent/context.py +8 -3
- package/onecode/agent/engine.py +835 -220
- package/onecode/agent/hooks.py +1 -1
- package/onecode/agent/onecode_agent_acp.py +460 -47
- package/onecode/agent/onecode_agent_acp.py.bak +2447 -0
- package/onecode/agent/permissions.py +0 -1
- package/onecode/agent/project_doc.py +19 -0
- package/onecode/agent/session.py +17 -3
- package/onecode/agent/snapshot.py +3 -4
- package/onecode/agent/tools/__pycache__/agent_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/apply_patch_tool.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/bash_tool.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/communication_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/config_tool.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/cron_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/file_ops.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/file_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/git_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/lsp_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/mcp_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/permission_handler.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/protocol.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/registry.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/sandbox.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/skill_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/todo_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/web_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/agent_tools.py +4 -6
- package/onecode/agent/tools/apply_patch_tool.py +2 -8
- package/onecode/agent/tools/bash_tool.py +32 -12
- package/onecode/agent/tools/communication_tools.py +4 -5
- package/onecode/agent/tools/config_tool.py +3 -3
- package/onecode/agent/tools/cron_tools.py +4 -5
- package/onecode/agent/tools/file_ops.py +37 -3
- package/onecode/agent/tools/file_tools.py +10 -11
- package/onecode/agent/tools/git_tools.py +2 -2
- package/onecode/agent/tools/lsp_tools.py +2 -2
- package/onecode/agent/tools/mcp_tools.py +3 -5
- package/onecode/agent/tools/permission_handler.py +1 -1
- package/onecode/agent/tools/protocol.py +1 -1
- package/onecode/agent/tools/registry.py +69 -2
- package/onecode/agent/tools/sandbox.py +110 -16
- package/onecode/agent/tools/skill_tools.py +2 -2
- package/onecode/agent/tools/todo_tools.py +38 -19
- package/onecode/agent/tools/web_tools.py +6 -7
- package/onecode/builtin_skills/agent-browser/SKILL.md +212 -0
- package/onecode/builtin_skills/agent-browser/skill.yaml +8 -0
- package/onecode/builtin_skills/skill-creator/SKILL.md +106 -0
- package/onecode/builtin_skills/skill-creator/skill.yaml +8 -0
- package/onecode/cli.py +186 -6
- package/onecode/codebase/__pycache__/chunker.cpython-314.pyc +0 -0
- package/onecode/codebase/__pycache__/indexer.cpython-314.pyc +0 -0
- package/onecode/codebase/__pycache__/retriever.cpython-314.pyc +0 -0
- package/onecode/codebase/__pycache__/storage.cpython-314.pyc +0 -0
- package/onecode/codebase/chunker.py +0 -5
- package/onecode/codebase/indexer.py +14 -1
- package/onecode/codebase/retriever.py +13 -2
- package/onecode/codebase/storage.py +5 -4
- package/onecode/commands.py +81 -6
- package/onecode/config.py +41 -12
- package/onecode/config_screen.py +7 -3
- package/onecode/mcp/__pycache__/manager.cpython-314.pyc +0 -0
- package/onecode/mcp/manager.py +2 -3
- package/onecode/memory/__init__.py +7 -53
- package/onecode/memory/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/memory/__pycache__/backend.cpython-314.pyc +0 -0
- package/onecode/memory/__pycache__/offload.cpython-314.pyc +0 -0
- package/onecode/memory/__pycache__/pyramid.cpython-314.pyc +0 -0
- package/onecode/memory/__pycache__/recall.cpython-314.pyc +0 -0
- package/onecode/memory/backend.py +13 -39
- package/onecode/memory/pyramid.py +2 -71
- package/onecode/memory/recall.py +38 -3
- package/onecode/migrate.py +122 -0
- package/onecode/models/__pycache__/messages.cpython-314.pyc +0 -0
- package/onecode/models/__pycache__/provider.cpython-314.pyc +0 -0
- package/onecode/models/messages.py +58 -0
- package/onecode/models/provider.py +20 -2
- package/onecode/models/providers/__pycache__/anthropic_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/glm_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/minimax_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/minimaxi_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/ollama_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/anthropic_provider.py +59 -12
- package/onecode/models/providers/glm_provider.py +40 -0
- package/onecode/models/providers/minimax_provider.py +3 -0
- package/onecode/models/providers/minimaxi_provider.py +3 -0
- package/onecode/models/providers/ollama_provider.py +3 -0
- package/onecode/server/app.py +1 -1
- package/onecode/skills/__pycache__/argument_substitution.cpython-314.pyc +0 -0
- package/onecode/skills/__pycache__/bootstrap.cpython-314.pyc +0 -0
- package/onecode/skills/__pycache__/loader.cpython-314.pyc +0 -0
- package/onecode/skills/__pycache__/manager.cpython-314.pyc +0 -0
- package/onecode/skills/argument_substitution.py +0 -1
- package/onecode/skills/bootstrap.py +124 -0
- package/onecode/skills/loader.py +35 -53
- package/onecode/skills/manager.py +2 -2
- package/onecode/storage/activity.py +3 -4
- package/onecode/storage/session.py +3 -4
- package/onecode/tasks/manager.py +23 -10
- package/onecode/trace/tracer.py +2 -3
- package/package.json +1 -1
- package/pyproject.toml +5 -1
- package/run.js +68 -14
- package/tui/__pycache__/about.cpython-314.pyc +0 -0
- package/tui/__pycache__/app.cpython-313.pyc +0 -0
- package/tui/__pycache__/app.cpython-314.pyc +0 -0
- package/tui/__pycache__/danger.cpython-314.pyc +0 -0
- package/tui/__pycache__/directory_suggester.cpython-314.pyc +0 -0
- package/tui/__pycache__/messages.cpython-314.pyc +0 -0
- package/tui/__pycache__/path_complete.cpython-314.pyc +0 -0
- package/tui/__pycache__/paths.cpython-314.pyc +0 -0
- package/tui/__pycache__/session_tracker.cpython-314.pyc +0 -0
- package/tui/__pycache__/settings_schema.cpython-314.pyc +0 -0
- package/tui/acp/__pycache__/agent.cpython-314.pyc +0 -0
- package/tui/acp/__pycache__/api.cpython-314.pyc +0 -0
- package/tui/acp/__pycache__/messages.cpython-314.pyc +0 -0
- package/tui/acp/__pycache__/prompt.cpython-314.pyc +0 -0
- package/tui/acp/__pycache__/protocol.cpython-314.pyc +0 -0
- package/tui/acp/agent.py +125 -58
- package/tui/acp/api.py +12 -0
- package/tui/acp/messages.py +3 -1
- package/tui/acp/prompt.py +0 -57
- package/tui/acp/protocol.py +6 -0
- package/tui/app.py +11 -33
- package/tui/messages.py +2 -0
- package/tui/path_complete.py +1 -1
- package/tui/paths.py +16 -0
- package/tui/screens/__pycache__/agent_modal.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/component_picker.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/diff_screen.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/log.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/main.cpython-313.pyc +0 -0
- package/tui/screens/__pycache__/main.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/permissions.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/projects_app.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/projects_screen.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/sessions.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/store.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/tool_content_screen.cpython-314.pyc +0 -0
- package/tui/screens/component_picker.py +35 -15
- package/tui/screens/diff_screen.py +84 -0
- package/tui/screens/log.py +25 -25
- package/tui/screens/main.py +48 -26
- package/tui/screens/projects.tcss +5 -1
- package/tui/screens/projects_app.py +6 -6
- package/tui/screens/projects_screen.py +19 -39
- package/tui/screens/sessions.py +3 -5
- package/tui/session_tracker.py +3 -0
- package/tui/settings_schema.py +1 -1
- package/tui/visuals/__pycache__/columns.cpython-314.pyc +0 -0
- package/tui/visuals/columns.py +1 -2
- package/tui/widgets/__pycache__/acp_content.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/agent_response.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/agent_thought.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/conversation.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/diff_view.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/directory_input.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/future_text.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/grid_select.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/load_more.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/mandelbrot.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/markdown_note.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/modified_files.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/plan.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/project_grid_select.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/project_summary.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/session_grid_select.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/session_summary.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/shell_result.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/side_bar.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/subagent.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/subagent_screen.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/terminal.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/tool_call.cpython-314.pyc +0 -0
- package/tui/widgets/agent_response.py +0 -1
- package/tui/widgets/agent_thought.py +0 -1
- package/tui/widgets/conversation.py +157 -14
- package/tui/widgets/diff_view.py +10 -0
- package/tui/widgets/load_more.py +24 -0
- package/tui/widgets/modified_files.py +86 -12
- package/tui/widgets/plan.py +1 -1
- package/tui/widgets/project_grid_select.py +9 -2
- package/tui/widgets/session_grid_select.py +6 -0
- package/tui/widgets/side_bar.py +10 -0
- package/tui/widgets/subagent.py +92 -58
- package/tui/widgets/subagent_screen.py +638 -115
- package/tui/widgets/terminal.py +1 -2
- package/tui/widgets/terminal_tool.py +11 -2
- package/tui/widgets/tool_call.py +35 -2
- package/onecode/memory/offload.py +0 -114
- package/onecode/memory/symbolic.py +0 -164
package/onecode/agent/engine.py
CHANGED
|
@@ -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.
|
|
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
|
|
451
|
+
tid = todo.get("id") or self._next_id()
|
|
430
452
|
todo["id"] = tid
|
|
431
|
-
todo.setdefault("_order",
|
|
432
|
-
|
|
433
|
-
if tid not in
|
|
434
|
-
|
|
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
|
-
|
|
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
|
|
692
|
-
the system context
|
|
693
|
-
|
|
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
|
-
|
|
703
|
-
|
|
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
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
728
|
-
lines.append("
|
|
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
|
-
|
|
732
|
-
lines.append("
|
|
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
|
-
"
|
|
737
|
-
"
|
|
738
|
-
"
|
|
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
|
|
750
|
-
|
|
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
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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(
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
1184
|
-
|
|
1185
|
-
f"##
|
|
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 → ~/.
|
|
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
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
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
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
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
|
-
"
|
|
1492
|
-
"
|
|
1493
|
-
"
|
|
1494
|
-
"
|
|
1495
|
-
"
|
|
1496
|
-
"
|
|
1497
|
-
"
|
|
1498
|
-
"
|
|
1499
|
-
"**
|
|
1500
|
-
"
|
|
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
|
-
"-
|
|
1513
|
-
"
|
|
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
|
|
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}
|
|
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,
|
|
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 =
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
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
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
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="
|
|
1897
|
-
), ""
|
|
2474
|
+
"", agent_type=agent_type, status="failed", error="cancelled",
|
|
2475
|
+
), "")
|
|
2476
|
+
raise
|
|
1898
2477
|
finally:
|
|
1899
|
-
|
|
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
|
-
#
|
|
1914
|
-
|
|
1915
|
-
|
|
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
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
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
|
|
2576
|
+
self._todo_manager.reload_from_dict({
|
|
1970
2577
|
"todos": session.todos,
|
|
1971
2578
|
"id_counter": len(session.todos),
|
|
1972
|
-
}
|
|
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
|
|
2609
|
+
self._todo_manager.reload_from_dict({
|
|
2003
2610
|
"todos": session.todos,
|
|
2004
2611
|
"id_counter": len(session.todos),
|
|
2005
|
-
}
|
|
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
|
|
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.
|
|
2668
|
+
cdh_dir = CdhProjectLoader.find_cdh_dir_for_todos(self._project_dir)
|
|
2061
2669
|
if cdh_dir is None:
|
|
2062
|
-
|
|
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
|
|
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.
|
|
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
|
|
2079
|
-
|
|
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
|
|