@scemoon/cdh 1.0.0
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 +75 -0
- package/ai-dlc-skill/agents/deliver-agent.md +24 -0
- package/ai-dlc-skill/agents/master.md +44 -0
- package/ai-dlc-skill/agents/plan-agent.md +23 -0
- package/ai-dlc-skill/agents/understand-agent.md +23 -0
- package/ai-dlc-skill/agents/verify-agent.md +27 -0
- package/ai-dlc-skill/architecture/project-structure.md +78 -0
- package/ai-dlc-skill/brownfield/README.md +30 -0
- package/ai-dlc-skill/brownfield/generate-context.sh +64 -0
- package/ai-dlc-skill/brownfield/scripts/deps.sh +27 -0
- package/ai-dlc-skill/brownfield/scripts/discover.sh +34 -0
- package/ai-dlc-skill/brownfield/scripts/extract-api.sh +45 -0
- package/ai-dlc-skill/components/backend.md +34 -0
- package/ai-dlc-skill/components/desktop.md +30 -0
- package/ai-dlc-skill/components/mya.md +29 -0
- package/ai-dlc-skill/components/native.md +30 -0
- package/ai-dlc-skill/components/tta.md +29 -0
- package/ai-dlc-skill/components/web.md +31 -0
- package/ai-dlc-skill/components/wxa.md +29 -0
- package/ai-dlc-skill/contracts/README.md +25 -0
- package/ai-dlc-skill/core/adaptive-flow.md +36 -0
- package/ai-dlc-skill/core/security.md +29 -0
- package/ai-dlc-skill/cross-tool/cline/export.sh +31 -0
- package/ai-dlc-skill/cross-tool/copilot/export.sh +47 -0
- package/ai-dlc-skill/cross-tool/cursor/export.sh +32 -0
- package/ai-dlc-skill/cross-tool/export.sh +22 -0
- package/ai-dlc-skill/cross-tool/onecode/config.yaml.tpl +17 -0
- package/ai-dlc-skill/cross-tool/onecode/export-skills.sh +31 -0
- package/ai-dlc-skill/cross-tool/onecode/export.sh +48 -0
- package/ai-dlc-skill/phases/deliver/entry.md +26 -0
- package/ai-dlc-skill/phases/deliver/lifecycle.md +185 -0
- package/ai-dlc-skill/phases/deliver/prompt.md +40 -0
- package/ai-dlc-skill/phases/deliver/rules.md +61 -0
- package/ai-dlc-skill/phases/plan/entry.md +25 -0
- package/ai-dlc-skill/phases/plan/lifecycle.md +204 -0
- package/ai-dlc-skill/phases/plan/prompt.md +38 -0
- package/ai-dlc-skill/phases/plan/rules.md +25 -0
- package/ai-dlc-skill/phases/understand/entry.md +26 -0
- package/ai-dlc-skill/phases/understand/lifecycle.md +184 -0
- package/ai-dlc-skill/phases/understand/prompt.md +41 -0
- package/ai-dlc-skill/phases/understand/rules.md +37 -0
- package/ai-dlc-skill/phases/verify/entry.md +26 -0
- package/ai-dlc-skill/phases/verify/lifecycle.md +192 -0
- package/ai-dlc-skill/phases/verify/prompt.md +43 -0
- package/ai-dlc-skill/phases/verify/rules.md +73 -0
- package/ai-dlc-skill/practices/bdd.md +91 -0
- package/ai-dlc-skill/practices/sdd.md +84 -0
- package/ai-dlc-skill/practices/tdd.md +95 -0
- package/ai-dlc-skill/providers/README.md +114 -0
- package/ai-dlc-skill/providers/aliyun/deployment.yaml +66 -0
- package/ai-dlc-skill/providers/aliyun/preview.yaml +84 -0
- package/ai-dlc-skill/providers/aliyun/provider.yaml +160 -0
- package/ai-dlc-skill/providers/tcb/deployment.yaml +69 -0
- package/ai-dlc-skill/providers/tcb/preview.yaml +83 -0
- package/ai-dlc-skill/providers/tcb/provider.yaml +172 -0
- package/ai-dlc-skill/requirements.md +140 -0
- package/ai-dlc-skill/skill.yaml +147 -0
- package/ai-dlc-skill/templates/integration/CHANGELOG.md +41 -0
- package/ai-dlc-skill/templates/integration/asyncapi.yaml +43 -0
- package/ai-dlc-skill/templates/integration/contract-diff.md +63 -0
- package/ai-dlc-skill/templates/integration/contract-spec.md +49 -0
- package/ai-dlc-skill/templates/integration/openapi.yaml +18 -0
- package/ai-dlc-skill/templates/integration/runtime-contract.yaml +36 -0
- package/ai-dlc-skill/templates/plan/design.md +49 -0
- package/ai-dlc-skill/templates/plan/task-list.md +32 -0
- package/ai-dlc-skill/templates/project/README.md +127 -0
- package/ai-dlc-skill/templates/project/template.md +119 -0
- package/ai-dlc-skill/templates/understand/feature.feature +23 -0
- package/ai-dlc-skill/templates/understand/intent.md +29 -0
- package/ai-dlc-skill/templates/understand/spec-delta.md +47 -0
- package/ai-dlc-skill/templates/verify/test-unit.py +27 -0
- package/ai-dlc-skill/walkthrough/collect.sh +50 -0
- package/ai-dlc-skill/walkthrough/template.md +54 -0
- package/ai-dlc-skill/workflows/ai-dlc.yaml +110 -0
- package/ai-dlc-skill/workflows/deployment.yaml +199 -0
- package/cdh/__init__.py +0 -0
- package/cdh/__main__.py +4 -0
- package/cdh/__pycache__/__init__.cpython-313.pyc +0 -0
- package/cdh/__pycache__/__init__.cpython-314.pyc +0 -0
- package/cdh/__pycache__/cli.cpython-314.pyc +0 -0
- package/cdh/__pycache__/scaffold.cpython-313.pyc +0 -0
- package/cdh/__pycache__/scaffold.cpython-314.pyc +0 -0
- package/cdh/cli.py +361 -0
- package/cdh/scaffold.py +312 -0
- package/cli.js +2 -0
- package/onecode/__init__.py +1 -0
- package/onecode/__main__.py +4 -0
- package/onecode/__pycache__/__init__.cpython-314.pyc +0 -0
- 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__/mock_data.cpython-314.pyc +0 -0
- package/onecode/agent/__init__.py +0 -0
- package/onecode/agent/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/cdh_agent_acp.cpython-313.pyc +0 -0
- package/onecode/agent/__pycache__/cdh_agent_acp.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__/harness_skill.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__/permissions_store.cpython-314.pyc +0 -0
- package/onecode/agent/__pycache__/session.cpython-314.pyc +0 -0
- package/onecode/agent/agents/__init__.py +29 -0
- package/onecode/agent/agents/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/agent/agents/__pycache__/types.cpython-314.pyc +0 -0
- package/onecode/agent/agents/types.py +662 -0
- package/onecode/agent/attachments.py +113 -0
- package/onecode/agent/cdh_loader.py +262 -0
- package/onecode/agent/context.py +338 -0
- package/onecode/agent/engine.py +1894 -0
- package/onecode/agent/hooks.py +86 -0
- package/onecode/agent/onecode_agent_acp.py +2203 -0
- package/onecode/agent/permissions.py +151 -0
- package/onecode/agent/permissions_store.py +62 -0
- package/onecode/agent/session.py +186 -0
- package/onecode/agent/snapshot.py +145 -0
- package/onecode/agent/tools/__init__.py +17 -0
- package/onecode/agent/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- 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__/errors.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__/permissions.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__/schema_validation.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/skill_tools.cpython-314.pyc +0 -0
- package/onecode/agent/tools/__pycache__/task_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 +134 -0
- package/onecode/agent/tools/apply_patch_tool.py +144 -0
- package/onecode/agent/tools/bash_tool.py +91 -0
- package/onecode/agent/tools/communication_tools.py +149 -0
- package/onecode/agent/tools/config_tool.py +130 -0
- package/onecode/agent/tools/cron_tools.py +179 -0
- package/onecode/agent/tools/errors.py +17 -0
- package/onecode/agent/tools/file_ops.py +187 -0
- package/onecode/agent/tools/file_tools.py +226 -0
- package/onecode/agent/tools/git_tools.py +88 -0
- package/onecode/agent/tools/lsp_tools.py +404 -0
- package/onecode/agent/tools/mcp_tools.py +129 -0
- package/onecode/agent/tools/permission_handler.py +46 -0
- package/onecode/agent/tools/permissions.py +77 -0
- package/onecode/agent/tools/protocol.py +20 -0
- package/onecode/agent/tools/registry.py +134 -0
- package/onecode/agent/tools/sandbox.py +264 -0
- package/onecode/agent/tools/schema_validation.py +100 -0
- package/onecode/agent/tools/skill_tools.py +52 -0
- package/onecode/agent/tools/task_tools.py +249 -0
- package/onecode/agent/tools/web_tools.py +170 -0
- package/onecode/builtin_skills/git/SKILL.md +64 -0
- package/onecode/builtin_skills/git/skill.yaml +7 -0
- package/onecode/builtin_skills/shell/SKILL.md +58 -0
- package/onecode/builtin_skills/shell/skill.yaml +8 -0
- package/onecode/cli.py +755 -0
- package/onecode/cloud/__init__.py +0 -0
- package/onecode/cloud/base.py +20 -0
- package/onecode/codebase/__init__.py +69 -0
- package/onecode/codebase/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/codebase/__pycache__/chunker.cpython-314.pyc +0 -0
- package/onecode/codebase/__pycache__/config.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/__pycache__/tools.cpython-314.pyc +0 -0
- package/onecode/codebase/chunker.py +130 -0
- package/onecode/codebase/indexer.py +136 -0
- package/onecode/codebase/retriever.py +192 -0
- package/onecode/codebase/storage.py +130 -0
- package/onecode/codebase/tools.py +59 -0
- package/onecode/commands.py +276 -0
- package/onecode/config.py +285 -0
- package/onecode/config_screen.py +594 -0
- package/onecode/cron/__init__.py +3 -0
- package/onecode/lsp/__init__.py +3 -0
- package/onecode/mcp/__init__.py +4 -0
- package/onecode/mcp/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/mcp/__pycache__/client.cpython-314.pyc +0 -0
- package/onecode/mcp/__pycache__/manager.cpython-314.pyc +0 -0
- package/onecode/mcp/client.py +193 -0
- package/onecode/mcp/manager.py +191 -0
- package/onecode/memory/__init__.py +95 -0
- 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/__pycache__/symbolic.cpython-314.pyc +0 -0
- package/onecode/memory/backend.py +185 -0
- package/onecode/memory/offload.py +114 -0
- package/onecode/memory/pyramid.py +202 -0
- package/onecode/memory/recall.py +168 -0
- package/onecode/memory/symbolic.py +164 -0
- package/onecode/models/__init__.py +4 -0
- package/onecode/models/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/models/__pycache__/errors.cpython-314.pyc +0 -0
- package/onecode/models/__pycache__/messages.cpython-314.pyc +0 -0
- package/onecode/models/__pycache__/provider.cpython-314.pyc +0 -0
- package/onecode/models/__pycache__/registry.cpython-314.pyc +0 -0
- package/onecode/models/errors.py +94 -0
- package/onecode/models/messages.py +662 -0
- package/onecode/models/provider.py +450 -0
- package/onecode/models/providers/__init__.py +17 -0
- package/onecode/models/providers/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/anthropic_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/deepseek_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__/mock_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/ollama_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/__pycache__/openai_provider.cpython-314.pyc +0 -0
- package/onecode/models/providers/anthropic_provider.py +224 -0
- package/onecode/models/providers/deepseek_provider.py +210 -0
- package/onecode/models/providers/glm_provider.py +154 -0
- package/onecode/models/providers/minimax_provider.py +154 -0
- package/onecode/models/providers/minimaxi_provider.py +363 -0
- package/onecode/models/providers/ollama_provider.py +96 -0
- package/onecode/models/providers/openai_provider.py +233 -0
- package/onecode/models/registry.py +212 -0
- package/onecode/server/__init__.py +3 -0
- package/onecode/server/app.py +224 -0
- package/onecode/skills/__init__.py +11 -0
- package/onecode/skills/__pycache__/__init__.cpython-314.pyc +0 -0
- package/onecode/skills/__pycache__/argument_substitution.cpython-314.pyc +0 -0
- package/onecode/skills/__pycache__/create.cpython-314.pyc +0 -0
- package/onecode/skills/__pycache__/frontmatter.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/__pycache__/model.cpython-314.pyc +0 -0
- package/onecode/skills/argument_substitution.py +39 -0
- package/onecode/skills/create.py +56 -0
- package/onecode/skills/frontmatter.py +33 -0
- package/onecode/skills/loader.py +157 -0
- package/onecode/skills/manager.py +75 -0
- package/onecode/skills/model.py +43 -0
- package/onecode/storage/__init__.py +0 -0
- package/onecode/storage/activity.py +64 -0
- package/onecode/storage/project.py +54 -0
- package/onecode/storage/session.py +113 -0
- package/onecode/tasks/__init__.py +26 -0
- package/onecode/tasks/manager.py +219 -0
- package/onecode/tasks/models.py +240 -0
- package/onecode/trace/__init__.py +0 -0
- package/onecode/trace/tracer.py +174 -0
- package/onecode/utils/__init__.py +0 -0
- package/onecode/utils/helpers.py +20 -0
- package/onecode-cli.js +2 -0
- package/package.json +32 -0
- package/pyproject.toml +60 -0
- package/run.js +86 -0
- package/tui/__init__.py +46 -0
- package/tui/__main__.py +4 -0
- package/tui/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tui/__pycache__/_loop.cpython-314.pyc +0 -0
- package/tui/__pycache__/_path_fuzzy_search.cpython-314.pyc +0 -0
- package/tui/__pycache__/_path_match.cpython-314.pyc +0 -0
- package/tui/__pycache__/agent.cpython-314.pyc +0 -0
- package/tui/__pycache__/agent_schema.cpython-314.pyc +0 -0
- package/tui/__pycache__/agents.cpython-314.pyc +0 -0
- package/tui/__pycache__/answer.cpython-314.pyc +0 -0
- package/tui/__pycache__/app.cpython-314.pyc +0 -0
- package/tui/__pycache__/atomic.cpython-314.pyc +0 -0
- package/tui/__pycache__/complete.cpython-314.pyc +0 -0
- package/tui/__pycache__/constants.cpython-314.pyc +0 -0
- package/tui/__pycache__/conversation_markdown.cpython-314.pyc +0 -0
- package/tui/__pycache__/danger.cpython-314.pyc +0 -0
- package/tui/__pycache__/db.cpython-314.pyc +0 -0
- package/tui/__pycache__/dec.cpython-314.pyc +0 -0
- package/tui/__pycache__/directory.cpython-314.pyc +0 -0
- package/tui/__pycache__/directory_suggester.cpython-314.pyc +0 -0
- package/tui/__pycache__/directory_watcher.cpython-314.pyc +0 -0
- package/tui/__pycache__/format_path.cpython-314.pyc +0 -0
- package/tui/__pycache__/fuzzy.cpython-314.pyc +0 -0
- package/tui/__pycache__/fuzzy_index.cpython-314.pyc +0 -0
- package/tui/__pycache__/history.cpython-314.pyc +0 -0
- package/tui/__pycache__/jsonrpc.cpython-314.pyc +0 -0
- package/tui/__pycache__/menus.cpython-314.pyc +0 -0
- package/tui/__pycache__/message_log.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__/path_filter.cpython-314.pyc +0 -0
- package/tui/__pycache__/paths.cpython-314.pyc +0 -0
- package/tui/__pycache__/pill.cpython-314.pyc +0 -0
- package/tui/__pycache__/platform_commands.cpython-314.pyc +0 -0
- package/tui/__pycache__/protocol.cpython-314.pyc +0 -0
- package/tui/__pycache__/session_tracker.cpython-314.pyc +0 -0
- package/tui/__pycache__/settings.cpython-314.pyc +0 -0
- package/tui/__pycache__/settings_schema.cpython-314.pyc +0 -0
- package/tui/__pycache__/shell.cpython-314.pyc +0 -0
- package/tui/__pycache__/shell_read.cpython-314.pyc +0 -0
- package/tui/__pycache__/slash_command.cpython-314.pyc +0 -0
- package/tui/__pycache__/version.cpython-314.pyc +0 -0
- package/tui/_loop.py +86 -0
- package/tui/_path_fuzzy_search.py +48 -0
- package/tui/_path_match.py +141 -0
- package/tui/about.py +95 -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__/encode_tool_call_id.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 +886 -0
- package/tui/acp/api.py +55 -0
- package/tui/acp/encode_tool_call_id.py +12 -0
- package/tui/acp/messages.py +190 -0
- package/tui/acp/prompt.py +110 -0
- package/tui/acp/protocol.py +462 -0
- package/tui/agent.py +70 -0
- package/tui/agent_schema.py +76 -0
- package/tui/agents.py +45 -0
- package/tui/ansi/__init__.py +1 -0
- package/tui/ansi/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tui/ansi/__pycache__/_ansi.cpython-314.pyc +0 -0
- package/tui/ansi/__pycache__/_ansi_colors.cpython-314.pyc +0 -0
- package/tui/ansi/__pycache__/_control_codes.cpython-314.pyc +0 -0
- package/tui/ansi/__pycache__/_keys.cpython-314.pyc +0 -0
- package/tui/ansi/__pycache__/_sgr_styles.cpython-314.pyc +0 -0
- package/tui/ansi/__pycache__/_stream_parser.cpython-314.pyc +0 -0
- package/tui/ansi/_ansi.py +1689 -0
- package/tui/ansi/_ansi_colors.py +264 -0
- package/tui/ansi/_control_codes.py +37 -0
- package/tui/ansi/_keys.py +251 -0
- package/tui/ansi/_sgr_styles.py +64 -0
- package/tui/ansi/_stream_parser.py +418 -0
- package/tui/answer.py +12 -0
- package/tui/app.py +1236 -0
- package/tui/atomic.py +37 -0
- package/tui/cli.py +313 -0
- package/tui/code_analyze.py +27 -0
- package/tui/complete.py +38 -0
- package/tui/constants.py +61 -0
- package/tui/conversation_markdown.py +30 -0
- package/tui/danger.py +372 -0
- package/tui/data/agents/ampcode.com.toml +51 -0
- package/tui/data/agents/augmentcode.com.toml +40 -0
- package/tui/data/agents/cdh.cloud-dev-harness.toml +22 -0
- package/tui/data/agents/claude.com.toml +41 -0
- package/tui/data/agents/copilot.github.com.toml +29 -0
- package/tui/data/agents/crowai.dev.toml +35 -0
- package/tui/data/agents/cursor.com.toml +38 -0
- package/tui/data/agents/deepagents.langchain.com +33 -0
- package/tui/data/agents/docker.com.toml +59 -0
- package/tui/data/agents/geminicli.com.toml +28 -0
- package/tui/data/agents/goose.ai.toml +51 -0
- package/tui/data/agents/inference.huggingface.co.toml +41 -0
- package/tui/data/agents/kimi.com.toml +35 -0
- package/tui/data/agents/kiro.dev.toml +42 -0
- package/tui/data/agents/openai.com.toml +53 -0
- package/tui/data/agents/openclaw.ai.toml +37 -0
- package/tui/data/agents/opencode.ai.toml +39 -0
- package/tui/data/agents/openhands.dev.toml +45 -0
- package/tui/data/agents/stakpak.dev.toml +61 -0
- package/tui/data/agents/vibe.mistral.ai.toml +30 -0
- package/tui/data/agents/vtcode.dev.toml +62 -0
- package/tui/data/images/frog.png +0 -0
- package/tui/data/sounds/question.wav +0 -0
- package/tui/data/sounds/turn-over.wav +0 -0
- package/tui/db.py +190 -0
- package/tui/dec.py +332 -0
- package/tui/directory.py +234 -0
- package/tui/directory_suggester.py +93 -0
- package/tui/directory_watcher.py +197 -0
- package/tui/format_path.py +25 -0
- package/tui/fuzzy.py +140 -0
- package/tui/fuzzy_index.py +275 -0
- package/tui/gist.py +33 -0
- package/tui/history.py +138 -0
- package/tui/jsonrpc.py +578 -0
- package/tui/menus.py +14 -0
- package/tui/message_log.py +69 -0
- package/tui/messages.py +139 -0
- package/tui/option_content.py +51 -0
- package/tui/os.py +0 -0
- package/tui/path_complete.py +217 -0
- package/tui/path_filter.py +124 -0
- package/tui/paths.py +71 -0
- package/tui/pill.py +23 -0
- package/tui/platform_commands.py +52 -0
- package/tui/prompt/__pycache__/extract.cpython-314.pyc +0 -0
- package/tui/prompt/__pycache__/resource.cpython-314.pyc +0 -0
- package/tui/prompt/extract.py +19 -0
- package/tui/prompt/resource.py +68 -0
- package/tui/protocol.py +28 -0
- package/tui/screens/__init__.py +0 -0
- package/tui/screens/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/log.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/main.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__/settings.cpython-314.pyc +0 -0
- package/tui/screens/__pycache__/store.cpython-314.pyc +0 -0
- package/tui/screens/action_modal.py +94 -0
- package/tui/screens/agent_modal.py +175 -0
- package/tui/screens/command_edit_modal.py +74 -0
- package/tui/screens/log.py +553 -0
- package/tui/screens/main.py +411 -0
- package/tui/screens/main.tcss +655 -0
- package/tui/screens/permissions.py +404 -0
- package/tui/screens/permissions.tcss +72 -0
- package/tui/screens/projects.tcss +66 -0
- package/tui/screens/projects_app.py +319 -0
- package/tui/screens/projects_screen.py +142 -0
- package/tui/screens/session_resume_modal.py +172 -0
- package/tui/screens/session_resume_modal.tcss +35 -0
- package/tui/screens/sessions.py +166 -0
- package/tui/screens/sessions.tcss +119 -0
- package/tui/screens/settings.py +254 -0
- package/tui/screens/settings.tcss +101 -0
- package/tui/screens/store.py +500 -0
- package/tui/screens/store.tcss +274 -0
- package/tui/screens/tool_content_screen.py +59 -0
- package/tui/session_tracker.py +120 -0
- package/tui/settings.py +353 -0
- package/tui/settings_schema.py +431 -0
- package/tui/shell.py +304 -0
- package/tui/shell_read.py +42 -0
- package/tui/slash_command.py +34 -0
- package/tui/tui.tcss +246 -0
- package/tui/twitter.py +38 -0
- package/tui/version.py +80 -0
- package/tui/visuals/__init__.py +0 -0
- package/tui/visuals/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tui/visuals/__pycache__/columns.cpython-314.pyc +0 -0
- package/tui/visuals/columns.py +273 -0
- package/tui/widgets/__init__.py +0 -0
- package/tui/widgets/__pycache__/__init__.cpython-314.pyc +0 -0
- 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__/ask_user.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/condensed_path.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/context_stats.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/conversation.cpython-313.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__/flash.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__/highlighted_textarea.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__/menu.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/modified_files.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/non_selectable_label.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/note.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/path_search.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/plan.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/project_directory_tree.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__/prompt.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/question.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__/session_tabs.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/shell_result.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/shell_terminal.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/side_bar.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/slash_complete.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/strike_text.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/subagent.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/terminal.cpython-313.pyc +0 -0
- package/tui/widgets/__pycache__/terminal.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/throbber.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/tool_call.cpython-313.pyc +0 -0
- package/tui/widgets/__pycache__/tool_call.cpython-314.pyc +0 -0
- package/tui/widgets/__pycache__/user_input.cpython-314.pyc +0 -0
- package/tui/widgets/acp_content.py +70 -0
- package/tui/widgets/agent_response.py +77 -0
- package/tui/widgets/agent_thought.py +168 -0
- package/tui/widgets/ask_user.py +264 -0
- package/tui/widgets/command_pane.py +223 -0
- package/tui/widgets/condensed_path.py +115 -0
- package/tui/widgets/context_stats.py +66 -0
- package/tui/widgets/conversation.py +2441 -0
- package/tui/widgets/danger_warning.py +65 -0
- package/tui/widgets/diff_view.py +59 -0
- package/tui/widgets/directory_input.py +8 -0
- package/tui/widgets/flash.py +81 -0
- package/tui/widgets/future_text.py +126 -0
- package/tui/widgets/grid_select.py +234 -0
- package/tui/widgets/highlighted_textarea.py +180 -0
- package/tui/widgets/mandelbrot.py +294 -0
- package/tui/widgets/markdown_note.py +13 -0
- package/tui/widgets/menu.py +147 -0
- package/tui/widgets/modified_files.py +106 -0
- package/tui/widgets/non_selectable_label.py +5 -0
- package/tui/widgets/note.py +18 -0
- package/tui/widgets/path_search.py +492 -0
- package/tui/widgets/plan.py +263 -0
- package/tui/widgets/project_directory_tree.py +90 -0
- package/tui/widgets/project_grid_select.py +35 -0
- package/tui/widgets/project_summary.py +33 -0
- package/tui/widgets/prompt.py +795 -0
- package/tui/widgets/question.py +360 -0
- package/tui/widgets/session_grid_select.py +224 -0
- package/tui/widgets/session_summary.py +115 -0
- package/tui/widgets/session_tabs.py +188 -0
- package/tui/widgets/shell_result.py +35 -0
- package/tui/widgets/shell_terminal.py +40 -0
- package/tui/widgets/side_bar.py +114 -0
- package/tui/widgets/slash_complete.py +235 -0
- package/tui/widgets/strike_text.py +70 -0
- package/tui/widgets/subagent.py +87 -0
- package/tui/widgets/terminal.py +577 -0
- package/tui/widgets/terminal_tool.py +351 -0
- package/tui/widgets/throbber.py +100 -0
- package/tui/widgets/tool_call.py +368 -0
- package/tui/widgets/user_input.py +23 -0
- package/tui/widgets/version.py +5 -0
- package/tui/widgets/welcome.py +31 -0
|
@@ -0,0 +1,1894 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import AsyncIterator, Optional
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Callable
|
|
12
|
+
|
|
13
|
+
from onecode.agent.context import ContextManager
|
|
14
|
+
from onecode.agent.permissions_store import PermissionStore
|
|
15
|
+
from onecode.models.provider import ContentBlockType, ProviderRegistry
|
|
16
|
+
|
|
17
|
+
from onecode.agent.session import AgentSession
|
|
18
|
+
from onecode.models.messages import StreamEvent, StreamEventType
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("onecode.agent.engine")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ToolEvent:
|
|
25
|
+
"""Event emitted during the agent loop lifecycle (Clawd-Code style)."""
|
|
26
|
+
kind: str # "tool_use" | "tool_result" | "tool_error"
|
|
27
|
+
tool_name: str = ""
|
|
28
|
+
tool_input: dict | None = None
|
|
29
|
+
tool_output: Any = None
|
|
30
|
+
tool_use_id: str | None = None
|
|
31
|
+
is_error: bool = False
|
|
32
|
+
error: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
ToolEventHandler = Callable[[ToolEvent], None]
|
|
36
|
+
|
|
37
|
+
TOOL_CALL_RE = re.compile(
|
|
38
|
+
r'<tool_call\s+name=["\']([^"\']+)["\']\s+id=["\']([^"\']+)["\']>(.*?)</tool_call>',
|
|
39
|
+
re.DOTALL,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Structured tool call format used by the minimaxi / claude / Anthropic-style
|
|
43
|
+
# models. Looks like::
|
|
44
|
+
#
|
|
45
|
+
# <minimax:tool_call>
|
|
46
|
+
# <invoke name="Read">
|
|
47
|
+
# <parameter name="path">SPEC.md</parameter>
|
|
48
|
+
# </invoke>
|
|
49
|
+
# <invoke name="Bash">
|
|
50
|
+
# <parameter name="command">ls -la</parameter>
|
|
51
|
+
# <parameter name="timeout">30</parameter>
|
|
52
|
+
# </invoke>
|
|
53
|
+
# </minimax:tool_call>
|
|
54
|
+
#
|
|
55
|
+
# We match the outer block, then split into individual <invoke> elements
|
|
56
|
+
# inside ``_extract_minimax_tool_uses``.
|
|
57
|
+
MINIMAX_TOOL_CALL_RE = re.compile(
|
|
58
|
+
r'<minimax:tool_call>(.*?)</minimax:tool_call>',
|
|
59
|
+
re.DOTALL,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# An individual <invoke name="X">...</invoke> block. ``name`` is captured
|
|
63
|
+
# separately so we can call ``_parse_minimax_invoke_body`` on the inner XML.
|
|
64
|
+
_MINIMAX_INVOKE_RE = re.compile(
|
|
65
|
+
r'<invoke\s+name="([^"]+)">(.*?)</invoke>',
|
|
66
|
+
re.DOTALL,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# A single <parameter name="X">value</parameter> element. The value can span
|
|
70
|
+
# multiple lines, may contain entity-escaped angle brackets, and we tolerate
|
|
71
|
+
# either double or single quotes around the name.
|
|
72
|
+
_MINIMAX_PARAM_RE = re.compile(
|
|
73
|
+
r'<parameter\s+name=["\']([^"\']+)["\']>(.*?)</parameter>',
|
|
74
|
+
re.DOTALL,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
THINKING_RE = re.compile(
|
|
78
|
+
r'<think(?:ing)?>(.*?)</think(?:ing)?>',
|
|
79
|
+
re.DOTALL,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
_LEGACY_OPEN = "[TOOL_CALL]"
|
|
83
|
+
_LEGACY_CLOSE = "[/TOOL_CALL]"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _parse_minimax_invoke_body(body: str) -> dict:
|
|
87
|
+
"""Parse the inner XML of a single ``<invoke name="X">`` block.
|
|
88
|
+
|
|
89
|
+
Returns a dict ``{"name": str, "arguments": {param_name: value, ...}}``.
|
|
90
|
+
The ``arguments`` dict is empty if no ``<parameter>`` children are found.
|
|
91
|
+
"""
|
|
92
|
+
body = body.strip()
|
|
93
|
+
arguments: dict = {}
|
|
94
|
+
for m in _MINIMAX_PARAM_RE.finditer(body):
|
|
95
|
+
param_name = m.group(1)
|
|
96
|
+
raw_value = m.group(2)
|
|
97
|
+
arguments[param_name] = _unescape_xml(raw_value)
|
|
98
|
+
return arguments
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _unescape_xml(s: str) -> str:
|
|
102
|
+
"""Reverse the common XML / HTML entity escapes.
|
|
103
|
+
|
|
104
|
+
We keep this conservative — the five predefined entities and the numeric
|
|
105
|
+
ones — so we never accidentally mangle content that legitimately contains
|
|
106
|
+
ampersands.
|
|
107
|
+
"""
|
|
108
|
+
return (
|
|
109
|
+
s.replace("<", "<")
|
|
110
|
+
.replace(">", ">")
|
|
111
|
+
.replace(""", '"')
|
|
112
|
+
.replace("'", "'")
|
|
113
|
+
.replace("&", "&")
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _extract_minimax_tool_uses(text: str, id_start: int = 0) -> tuple[list[dict], str, int]:
|
|
118
|
+
"""Extract ``<minimax:tool_call>...</minimax:tool_call>`` blocks.
|
|
119
|
+
|
|
120
|
+
Returns ``(tool_uses, cleaned_text, next_id)`` where ``tool_uses`` is a
|
|
121
|
+
list of ``{id, name, input}`` dicts and ``cleaned_text`` has every
|
|
122
|
+
matched block removed. ``id_start`` is the next available tool id
|
|
123
|
+
counter (caller-owned, see ``AgentEngine._tool_id_counter``); the next
|
|
124
|
+
free id is returned in the tuple so callers can pass it to subsequent
|
|
125
|
+
extractions without losing uniqueness.
|
|
126
|
+
"""
|
|
127
|
+
tool_uses: list[dict] = []
|
|
128
|
+
cleaned = text
|
|
129
|
+
counter = id_start
|
|
130
|
+
while True:
|
|
131
|
+
m = MINIMAX_TOOL_CALL_RE.search(cleaned)
|
|
132
|
+
if m is None:
|
|
133
|
+
break
|
|
134
|
+
block_body = m.group(1)
|
|
135
|
+
for inv in _MINIMAX_INVOKE_RE.finditer(block_body):
|
|
136
|
+
name = inv.group(1)
|
|
137
|
+
arguments = _parse_minimax_invoke_body(inv.group(2))
|
|
138
|
+
counter += 1
|
|
139
|
+
tool_uses.append({
|
|
140
|
+
"id": f"minimax-{counter}",
|
|
141
|
+
"name": name,
|
|
142
|
+
"input": arguments,
|
|
143
|
+
})
|
|
144
|
+
cleaned = cleaned[:m.start()] + cleaned[m.end():]
|
|
145
|
+
return tool_uses, cleaned, counter
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _scan_balanced_braces(text: str, open_pos: int) -> int | None:
|
|
149
|
+
if open_pos < 0 or open_pos >= len(text) or text[open_pos] != "{":
|
|
150
|
+
return None
|
|
151
|
+
depth = 0
|
|
152
|
+
i = open_pos
|
|
153
|
+
n = len(text)
|
|
154
|
+
while i < n:
|
|
155
|
+
c = text[i]
|
|
156
|
+
if c == "{":
|
|
157
|
+
depth += 1
|
|
158
|
+
i += 1
|
|
159
|
+
elif c == "}":
|
|
160
|
+
depth -= 1
|
|
161
|
+
if depth == 0:
|
|
162
|
+
return i
|
|
163
|
+
i += 1
|
|
164
|
+
elif c in ('"', "'"):
|
|
165
|
+
quote = c
|
|
166
|
+
i += 1
|
|
167
|
+
while i < n and text[i] != quote:
|
|
168
|
+
if text[i] == "\\" and i + 1 < n:
|
|
169
|
+
i += 2
|
|
170
|
+
else:
|
|
171
|
+
i += 1
|
|
172
|
+
i += 1
|
|
173
|
+
elif c == "/" and i + 1 < n and text[i + 1] == "/":
|
|
174
|
+
while i < n and text[i] != "\n":
|
|
175
|
+
i += 1
|
|
176
|
+
else:
|
|
177
|
+
i += 1
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _parse_legacy_tool_body(body: str) -> dict:
|
|
182
|
+
body = body.strip()
|
|
183
|
+
if not body or not body.startswith("{"):
|
|
184
|
+
return {"name": None, "arguments": {}}
|
|
185
|
+
body_close = _scan_balanced_braces(body, 0)
|
|
186
|
+
if body_close is None:
|
|
187
|
+
return {"name": None, "arguments": {}}
|
|
188
|
+
inner = body[1:body_close]
|
|
189
|
+
name_match = re.search(r'tool\s*=>\s*"([^"]+)"', inner)
|
|
190
|
+
name = name_match.group(1) if name_match else None
|
|
191
|
+
arguments: dict = {}
|
|
192
|
+
args_match = re.search(r'args\s*=>\s*\{', inner)
|
|
193
|
+
if args_match:
|
|
194
|
+
args_outer = _scan_balanced_braces(inner, args_match.end() - 1)
|
|
195
|
+
if args_outer is not None:
|
|
196
|
+
args_body = inner[args_match.end():args_outer]
|
|
197
|
+
for am in re.finditer(
|
|
198
|
+
r'--(\w+)\s+"((?:[^"\\]|\\.)*)"', args_body, re.DOTALL
|
|
199
|
+
):
|
|
200
|
+
arguments[am.group(1)] = am.group(2)
|
|
201
|
+
return {"name": name, "arguments": arguments}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _extract_legacy_tool_uses(
|
|
205
|
+
text: str, id_start: int = 0
|
|
206
|
+
) -> tuple[list[dict], str, int]:
|
|
207
|
+
"""Extract all [TOOL_CALL]...[/TOOL_CALL] blocks from text.
|
|
208
|
+
|
|
209
|
+
Returns (tool_uses, cleaned_text, next_id) where tool_uses has
|
|
210
|
+
[{id, name, input}, ...] and cleaned_text has all markers removed.
|
|
211
|
+
``id_start`` lets the caller share a monotonic counter across multiple
|
|
212
|
+
extraction passes; the next free id is returned so callers can pass it
|
|
213
|
+
forward.
|
|
214
|
+
"""
|
|
215
|
+
tool_uses: list[dict] = []
|
|
216
|
+
cleaned = text
|
|
217
|
+
counter = id_start
|
|
218
|
+
while True:
|
|
219
|
+
open_idx = cleaned.find(_LEGACY_OPEN)
|
|
220
|
+
if open_idx < 0:
|
|
221
|
+
break
|
|
222
|
+
body_start = open_idx + len(_LEGACY_OPEN)
|
|
223
|
+
close_idx = cleaned.find(_LEGACY_CLOSE, body_start)
|
|
224
|
+
if close_idx < 0:
|
|
225
|
+
# Unclosed marker — stop looking
|
|
226
|
+
break
|
|
227
|
+
body = cleaned[body_start:close_idx]
|
|
228
|
+
span_end = close_idx + len(_LEGACY_CLOSE)
|
|
229
|
+
parsed = _parse_legacy_tool_body(body)
|
|
230
|
+
name = parsed.get("name")
|
|
231
|
+
if name:
|
|
232
|
+
counter += 1
|
|
233
|
+
tool_uses.append({
|
|
234
|
+
"id": f"legacy-{counter}",
|
|
235
|
+
"name": name,
|
|
236
|
+
"input": parsed.get("arguments", {}),
|
|
237
|
+
})
|
|
238
|
+
cleaned = cleaned[:open_idx] + cleaned[span_end:]
|
|
239
|
+
return tool_uses, cleaned, counter
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
_TASK_STATUSES = {"pending", "in_progress", "completed"}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class TaskManager:
|
|
246
|
+
"""V2 task manager with dependency tracking (Clawd-Code style).
|
|
247
|
+
|
|
248
|
+
Tasks have: id, subject, description, status, owner, blocks, blockedBy, metadata, output.
|
|
249
|
+
Supports dependency tracking for multi-agent coordination.
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
def __init__(self, on_change: Callable[[], None] | None = None):
|
|
253
|
+
self._tasks: dict[str, dict] = {} # id -> task dict
|
|
254
|
+
self._todos: list[dict] = []
|
|
255
|
+
self._plan: list[str] = []
|
|
256
|
+
self._id_counter = 0
|
|
257
|
+
self._on_change = on_change
|
|
258
|
+
|
|
259
|
+
def _mark_dirty(self) -> None:
|
|
260
|
+
if self._on_change:
|
|
261
|
+
self._on_change()
|
|
262
|
+
|
|
263
|
+
def _next_id(self) -> str:
|
|
264
|
+
self._id_counter += 1
|
|
265
|
+
return f"t{self._id_counter}"
|
|
266
|
+
|
|
267
|
+
# ── V2 Task Management ──
|
|
268
|
+
|
|
269
|
+
def create_task(
|
|
270
|
+
self,
|
|
271
|
+
subject: str,
|
|
272
|
+
description: str = "",
|
|
273
|
+
active_form: str = "",
|
|
274
|
+
metadata: dict | None = None,
|
|
275
|
+
) -> dict:
|
|
276
|
+
"""Create a task with dependency tracking support."""
|
|
277
|
+
task_id = self._next_id()
|
|
278
|
+
task = {
|
|
279
|
+
"id": task_id,
|
|
280
|
+
"_order": self._id_counter,
|
|
281
|
+
"subject": subject,
|
|
282
|
+
"description": description,
|
|
283
|
+
"activeForm": active_form,
|
|
284
|
+
"status": "pending",
|
|
285
|
+
"owner": None,
|
|
286
|
+
"blocks": [],
|
|
287
|
+
"blockedBy": [],
|
|
288
|
+
"metadata": dict(metadata or {}),
|
|
289
|
+
"output": "",
|
|
290
|
+
}
|
|
291
|
+
self._tasks[task_id] = task
|
|
292
|
+
self._mark_dirty()
|
|
293
|
+
return task
|
|
294
|
+
|
|
295
|
+
def get_task(self, task_id: str) -> dict | None:
|
|
296
|
+
return self._tasks.get(task_id)
|
|
297
|
+
|
|
298
|
+
def list_tasks(self) -> list[dict]:
|
|
299
|
+
"""List all tasks in creation order."""
|
|
300
|
+
return list(self._tasks.values())
|
|
301
|
+
|
|
302
|
+
def update_task(self, task_id: str, **fields) -> dict | None:
|
|
303
|
+
"""Update task fields. Supports: subject, description, activeForm, status,
|
|
304
|
+
owner, metadata, addBlocks, addBlockedBy. Returns updated task or None."""
|
|
305
|
+
task = self._tasks.get(task_id)
|
|
306
|
+
if task is None:
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
updated = []
|
|
310
|
+
|
|
311
|
+
# String fields
|
|
312
|
+
for field in ("subject", "description", "activeForm", "owner"):
|
|
313
|
+
if field in fields:
|
|
314
|
+
v = fields[field]
|
|
315
|
+
if isinstance(v, str) and v != task.get(field):
|
|
316
|
+
task[field] = v
|
|
317
|
+
updated.append(field)
|
|
318
|
+
|
|
319
|
+
# Status field
|
|
320
|
+
if "status" in fields:
|
|
321
|
+
status = fields["status"]
|
|
322
|
+
if status == "deleted":
|
|
323
|
+
self._tasks.pop(task_id, None)
|
|
324
|
+
self._mark_dirty()
|
|
325
|
+
return {"id": task_id, "deleted": True}
|
|
326
|
+
if status in _TASK_STATUSES and status != task.get("status"):
|
|
327
|
+
task["status"] = status
|
|
328
|
+
updated.append("status")
|
|
329
|
+
|
|
330
|
+
# Dependency fields
|
|
331
|
+
for rel_field, input_key in (("blocks", "addBlocks"), ("blockedBy", "addBlockedBy")):
|
|
332
|
+
if input_key in fields:
|
|
333
|
+
ids = fields[input_key]
|
|
334
|
+
if isinstance(ids, list):
|
|
335
|
+
cur = list(task.get(rel_field) or [])
|
|
336
|
+
for x in ids:
|
|
337
|
+
if isinstance(x, str) and x not in cur:
|
|
338
|
+
cur.append(x)
|
|
339
|
+
if cur != task.get(rel_field):
|
|
340
|
+
task[rel_field] = cur
|
|
341
|
+
updated.append(rel_field)
|
|
342
|
+
|
|
343
|
+
# Metadata
|
|
344
|
+
if "metadata" in fields:
|
|
345
|
+
md = fields["metadata"]
|
|
346
|
+
if isinstance(md, dict):
|
|
347
|
+
existing = dict(task.get("metadata") or {})
|
|
348
|
+
for k, v in md.items():
|
|
349
|
+
if v is None:
|
|
350
|
+
existing.pop(k, None)
|
|
351
|
+
else:
|
|
352
|
+
existing[k] = v
|
|
353
|
+
task["metadata"] = existing
|
|
354
|
+
updated.append("metadata")
|
|
355
|
+
|
|
356
|
+
# Output
|
|
357
|
+
if "output" in fields:
|
|
358
|
+
v = fields["output"]
|
|
359
|
+
if isinstance(v, str):
|
|
360
|
+
task["output"] = v
|
|
361
|
+
updated.append("output")
|
|
362
|
+
|
|
363
|
+
self._mark_dirty()
|
|
364
|
+
return task
|
|
365
|
+
|
|
366
|
+
def get_task_output(self, task_id: str) -> dict:
|
|
367
|
+
"""Get output for a task."""
|
|
368
|
+
task = self._tasks.get(task_id)
|
|
369
|
+
if task is None:
|
|
370
|
+
return {"retrieval_status": "not_found", "task": None}
|
|
371
|
+
output = str(task.get("output") or "")
|
|
372
|
+
return {
|
|
373
|
+
"retrieval_status": "success" if output else "not_ready",
|
|
374
|
+
"task": {
|
|
375
|
+
"task_id": task_id,
|
|
376
|
+
"task_type": "task_list",
|
|
377
|
+
"status": task.get("status"),
|
|
378
|
+
"description": task.get("description"),
|
|
379
|
+
"output": output,
|
|
380
|
+
},
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
def clear_tasks(self) -> None:
|
|
384
|
+
self._tasks.clear()
|
|
385
|
+
self._mark_dirty()
|
|
386
|
+
|
|
387
|
+
# ── Todo Management (unchanged API) ──
|
|
388
|
+
|
|
389
|
+
def add_todo(self, text: str) -> str:
|
|
390
|
+
tid = self._next_id()
|
|
391
|
+
self._todos.append({"text": text, "done": False, "id": tid})
|
|
392
|
+
self._mark_dirty()
|
|
393
|
+
return tid
|
|
394
|
+
|
|
395
|
+
def complete_todo(self, todo_id: str) -> bool:
|
|
396
|
+
for t in self._todos:
|
|
397
|
+
if t["id"] == todo_id:
|
|
398
|
+
t["done"] = True
|
|
399
|
+
self._mark_dirty()
|
|
400
|
+
return True
|
|
401
|
+
return False
|
|
402
|
+
|
|
403
|
+
def remove_todo(self, todo_id: str) -> bool:
|
|
404
|
+
before = len(self._todos)
|
|
405
|
+
self._todos = [t for t in self._todos if t["id"] != todo_id]
|
|
406
|
+
result = len(self._todos) < before
|
|
407
|
+
self._mark_dirty()
|
|
408
|
+
return result
|
|
409
|
+
|
|
410
|
+
def list_todos(self) -> list[dict]:
|
|
411
|
+
return self._todos
|
|
412
|
+
|
|
413
|
+
def clear_todos(self) -> None:
|
|
414
|
+
self._todos = []
|
|
415
|
+
self._mark_dirty()
|
|
416
|
+
|
|
417
|
+
def set_plan(self, plan: list[str]) -> None:
|
|
418
|
+
self._plan = plan
|
|
419
|
+
self._mark_dirty()
|
|
420
|
+
|
|
421
|
+
def get_plan(self) -> list[str]:
|
|
422
|
+
return self._plan
|
|
423
|
+
|
|
424
|
+
# ── Serialization ──
|
|
425
|
+
|
|
426
|
+
def to_dict(self) -> dict:
|
|
427
|
+
"""Serialize all tasks and todos for persistence."""
|
|
428
|
+
return {
|
|
429
|
+
"tasks": [dict(t) for t in self._tasks.values()],
|
|
430
|
+
"todos": [dict(t) for t in self._todos],
|
|
431
|
+
"plan": list(self._plan),
|
|
432
|
+
"id_counter": self._id_counter,
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
@classmethod
|
|
436
|
+
def from_dict(cls, data: dict, on_change: Callable[[], None] | None = None) -> TaskManager:
|
|
437
|
+
"""Restore task manager from saved dict."""
|
|
438
|
+
tm = cls(on_change=on_change)
|
|
439
|
+
tm._id_counter = data.get("id_counter", 0)
|
|
440
|
+
tm._plan = list(data.get("plan", []))
|
|
441
|
+
for t in data.get("tasks", []):
|
|
442
|
+
tid = t.get("id", tm._next_id())
|
|
443
|
+
tm._tasks[tid] = dict(t)
|
|
444
|
+
for t in data.get("todos", []):
|
|
445
|
+
tm._todos.append(dict(t))
|
|
446
|
+
return tm
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# Tools that require a task plan before execution (plan gate)
|
|
450
|
+
_EXECUTION_TOOLS: frozenset[str] = frozenset({
|
|
451
|
+
"Write", "Edit", "Insert", "ApplyPatch", "Bash",
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
class AgentEngine:
|
|
456
|
+
def __init__(self, app, project_dir: Path | None = None, perm_store: PermissionStore | None = None):
|
|
457
|
+
from onecode.agent.tools.file_ops import ToolFactory
|
|
458
|
+
from onecode.agent.agents.types import BuildAgent
|
|
459
|
+
from onecode.agent.hooks import HookManager
|
|
460
|
+
from onecode.skills.loader import SkillLoader
|
|
461
|
+
from onecode.mcp.manager import MCPManager
|
|
462
|
+
from onecode.agent.tools.cron_tools import CronScheduler
|
|
463
|
+
from onecode.agent.tools.lsp_tools import LSPTool
|
|
464
|
+
from onecode.agent.tools.config_tool import ConfigReadTool, ConfigWriteTool
|
|
465
|
+
from onecode.agent.tools.communication_tools import SendMessageTool
|
|
466
|
+
self.app = app
|
|
467
|
+
self._project_dir = (project_dir or Path.cwd()).resolve()
|
|
468
|
+
self.context = ContextManager()
|
|
469
|
+
self.file_ops = ToolFactory.create_file_ops(self._project_dir)
|
|
470
|
+
self.shell = ToolFactory.create_shell(self._project_dir)
|
|
471
|
+
self.current_agent: AgentConfig = BuildAgent()
|
|
472
|
+
self.iterations = 0
|
|
473
|
+
self.total_tokens = 0
|
|
474
|
+
self._skills_loaded = False
|
|
475
|
+
self._session: Optional[AgentSession] = None
|
|
476
|
+
self._hooks = HookManager()
|
|
477
|
+
self._task_manager = TaskManager(on_change=self._on_task_change)
|
|
478
|
+
self._plan_dirty: bool = False
|
|
479
|
+
self._project_config: dict = {}
|
|
480
|
+
self._project_context_loaded = False
|
|
481
|
+
self._pending_approval: dict | None = None # {tool_call, result_key}
|
|
482
|
+
self._last_user_msg: str | None = None # Last SendMessage visible to user
|
|
483
|
+
|
|
484
|
+
# Clawd-Code subsystems
|
|
485
|
+
self._skill_loader = SkillLoader()
|
|
486
|
+
self._mcp = MCPManager()
|
|
487
|
+
self._cron_scheduler = CronScheduler()
|
|
488
|
+
self._lsp_tool = LSPTool()
|
|
489
|
+
app_config = getattr(app, 'config', None)
|
|
490
|
+
self._config_tool_read = ConfigReadTool(app_config) if app_config else None
|
|
491
|
+
self._config_tool_write = ConfigWriteTool(app_config) if app_config else None
|
|
492
|
+
|
|
493
|
+
# Codebase engine (lazy init)
|
|
494
|
+
self._codebase_engine: Optional["CodebaseEngine"] = None
|
|
495
|
+
|
|
496
|
+
# Tool registry (Clawd-Code pattern)
|
|
497
|
+
self._tool_registry = self._build_tool_registry()
|
|
498
|
+
self._send_message_tool = SendMessageTool()
|
|
499
|
+
|
|
500
|
+
# Per-turn usage tracking (Clawd-Code style)
|
|
501
|
+
self._turn_usages: list[dict[str, int]] = []
|
|
502
|
+
|
|
503
|
+
# Event callbacks
|
|
504
|
+
self.on_event: ToolEventHandler | None = None
|
|
505
|
+
self.on_text_chunk: Callable[[str], None] | None = None
|
|
506
|
+
self.on_tool_call_delta: Callable[[str, str, str], None] | None = None
|
|
507
|
+
self.on_thinking: Callable[[str], None] | None = None
|
|
508
|
+
self._streaming_used: bool = False
|
|
509
|
+
self._pending_thinking_blocks: list[str] = []
|
|
510
|
+
|
|
511
|
+
# Cancellation support
|
|
512
|
+
self._cancelled: bool = False
|
|
513
|
+
|
|
514
|
+
# Monotonic tool-call id counter — shared across every
|
|
515
|
+
# ``chat_stream`` turn so the same id is never reused even when the
|
|
516
|
+
# model re-emits ``legacy-0`` / ``minimax-0`` in a fresh turn.
|
|
517
|
+
# Touched only by ``_extract_*_tool_uses`` through the engine
|
|
518
|
+
# instance, not by the module-level helpers (which take an explicit
|
|
519
|
+
# ``id_start`` for testability).
|
|
520
|
+
self._tool_id_counter: int = 0
|
|
521
|
+
|
|
522
|
+
# Subagent engines spawned by this engine — we track them so
|
|
523
|
+
# cancelling the parent also cancels in-flight subagents.
|
|
524
|
+
self._child_engines: list[AgentEngine] = []
|
|
525
|
+
|
|
526
|
+
# Permission overrides shared across subagents
|
|
527
|
+
self._perm_store: PermissionStore = perm_store or PermissionStore()
|
|
528
|
+
|
|
529
|
+
# Plan gate: mode-aware enforcement based on agent type
|
|
530
|
+
# "hard" (plan mode) rejects execution tools without plan
|
|
531
|
+
# "soft" (build/solo mode) suggests planning but doesn't block
|
|
532
|
+
# "off" (agents with permission_task=DENY) no enforcement
|
|
533
|
+
self._plan_gate_mode: str = "off"
|
|
534
|
+
self._plan_gate_fired: bool = False
|
|
535
|
+
|
|
536
|
+
# ReAct state tracking
|
|
537
|
+
self._react_phase: str = "thought" # "thought" | "action" | "observation"
|
|
538
|
+
self._direct_execution_count: int = 0 # Track direct tool use for Task-first routing
|
|
539
|
+
|
|
540
|
+
def _build_tool_registry(self) -> ToolRegistry:
|
|
541
|
+
from onecode.agent.tools.registry import ToolRegistry
|
|
542
|
+
from onecode.agent.tools.file_tools import ReadTool, WriteTool, EditTool, InsertTool, UndoEditTool, GlobTool, GrepTool, ListTool
|
|
543
|
+
from onecode.agent.tools.apply_patch_tool import ApplyPatchTool
|
|
544
|
+
from onecode.agent.tools.bash_tool import BashTool
|
|
545
|
+
from onecode.agent.tools.web_tools import WebFetchTool, WebSearchTool
|
|
546
|
+
from onecode.agent.tools.communication_tools import SendMessageTool, AskUserTool, ToolSearchTool
|
|
547
|
+
from onecode.agent.tools.task_tools import (TaskCreateTool, TaskGetTool, TaskListTool, TaskUpdateTool,
|
|
548
|
+
TaskOutputTool, TaskStopTool, TodoCreateTool, TodoListTool, TodoCompleteTool)
|
|
549
|
+
from onecode.agent.tools.agent_tools import AgentTool, TaskTool
|
|
550
|
+
from onecode.agent.tools.skill_tools import SkillTool
|
|
551
|
+
from onecode.agent.tools.mcp_tools import MCPTool as MCPToolTool, MCPResourcesTool
|
|
552
|
+
from onecode.agent.tools.cron_tools import CronCreateTool, CronListTool, CronRemoveTool
|
|
553
|
+
from onecode.agent.tools.git_tools import WorktreeTool
|
|
554
|
+
registry = ToolRegistry()
|
|
555
|
+
# File tools
|
|
556
|
+
registry.register(ReadTool(self.file_ops))
|
|
557
|
+
registry.register(WriteTool(self.file_ops))
|
|
558
|
+
registry.register(EditTool(self.file_ops))
|
|
559
|
+
registry.register(InsertTool(self.file_ops))
|
|
560
|
+
registry.register(UndoEditTool(self.file_ops))
|
|
561
|
+
registry.register(GlobTool(self.file_ops))
|
|
562
|
+
registry.register(GrepTool(self.file_ops))
|
|
563
|
+
registry.register(ListTool(self.file_ops))
|
|
564
|
+
registry.register(ApplyPatchTool(self.file_ops))
|
|
565
|
+
# Shell
|
|
566
|
+
registry.register(BashTool(self.shell))
|
|
567
|
+
# Web
|
|
568
|
+
registry.register(WebFetchTool())
|
|
569
|
+
registry.register(WebSearchTool())
|
|
570
|
+
# Communication
|
|
571
|
+
self._send_message_tool = SendMessageTool()
|
|
572
|
+
registry.register(self._send_message_tool)
|
|
573
|
+
registry.register(AskUserTool())
|
|
574
|
+
registry.register(ToolSearchTool(registry))
|
|
575
|
+
# Task management
|
|
576
|
+
registry.register(TaskCreateTool(self._task_manager))
|
|
577
|
+
registry.register(TaskGetTool(self._task_manager))
|
|
578
|
+
registry.register(TaskListTool(self._task_manager))
|
|
579
|
+
registry.register(TaskUpdateTool(self._task_manager))
|
|
580
|
+
registry.register(TaskOutputTool(self._task_manager))
|
|
581
|
+
registry.register(TaskStopTool(self._task_manager))
|
|
582
|
+
registry.register(TodoCreateTool(self._task_manager))
|
|
583
|
+
registry.register(TodoListTool(self._task_manager))
|
|
584
|
+
registry.register(TodoCompleteTool(self._task_manager))
|
|
585
|
+
# Agent tools
|
|
586
|
+
registry.register(AgentTool(registry, permission_checker=self._check_tool_permission))
|
|
587
|
+
registry.register(TaskTool(self._spawn_subagent_async))
|
|
588
|
+
|
|
589
|
+
# Skill tool (Clawd-Code pattern)
|
|
590
|
+
registry.register(SkillTool(self._skill_loader))
|
|
591
|
+
|
|
592
|
+
# MCP tools (Clawd-Code pattern)
|
|
593
|
+
registry.register(MCPToolTool(self._mcp))
|
|
594
|
+
registry.register(MCPResourcesTool(self._mcp))
|
|
595
|
+
|
|
596
|
+
# LSP tool (Clawd-Code pattern)
|
|
597
|
+
registry.register(self._lsp_tool)
|
|
598
|
+
|
|
599
|
+
# Cron tools (Clawd-Code pattern)
|
|
600
|
+
registry.register(CronCreateTool(self._cron_scheduler))
|
|
601
|
+
registry.register(CronListTool(self._cron_scheduler))
|
|
602
|
+
registry.register(CronRemoveTool(self._cron_scheduler))
|
|
603
|
+
|
|
604
|
+
# Git worktree tool (Clawd-Code pattern)
|
|
605
|
+
registry.register(WorktreeTool(workspace_root=self._project_dir))
|
|
606
|
+
|
|
607
|
+
# Config tools (Clawd-Code pattern)
|
|
608
|
+
if self._config_tool_read:
|
|
609
|
+
registry.register(self._config_tool_read)
|
|
610
|
+
if self._config_tool_write:
|
|
611
|
+
registry.register(self._config_tool_write)
|
|
612
|
+
|
|
613
|
+
# Codebase search tool (lazy — engine initialised on first search)
|
|
614
|
+
from onecode.codebase.tools import CodebaseSearchTool
|
|
615
|
+
registry.register(CodebaseSearchTool(lambda: self._codebase_engine))
|
|
616
|
+
return registry
|
|
617
|
+
|
|
618
|
+
def _resolve_plan_gate_mode(self) -> str:
|
|
619
|
+
"""Determine plan gate strictness based on current agent type.
|
|
620
|
+
|
|
621
|
+
Returns "hard" (reject execution tools without plan), "soft" (nudge only),
|
|
622
|
+
or "off" (no enforcement).
|
|
623
|
+
"""
|
|
624
|
+
from onecode.agent.agents.types import AgentPermission
|
|
625
|
+
if self.current_agent.permission_task == AgentPermission.DENY:
|
|
626
|
+
return "off"
|
|
627
|
+
if self.current_agent.name == "plan":
|
|
628
|
+
return "hard"
|
|
629
|
+
return "soft"
|
|
630
|
+
|
|
631
|
+
def _plan_gate_first_turn(self) -> bool:
|
|
632
|
+
"""Returns True only on the first turn where no plan exists yet.
|
|
633
|
+
|
|
634
|
+
Once tasks are created or the gate has fired, subsequent turns
|
|
635
|
+
proceed without gate intervention.
|
|
636
|
+
"""
|
|
637
|
+
if self._plan_gate_fired:
|
|
638
|
+
return False
|
|
639
|
+
self._plan_gate_fired = True
|
|
640
|
+
return True
|
|
641
|
+
|
|
642
|
+
def _notify_event(self, event: ToolEvent) -> None:
|
|
643
|
+
"""Emit a ToolEvent to the registered callback (Clawd-Code pattern)."""
|
|
644
|
+
if self.on_event:
|
|
645
|
+
try:
|
|
646
|
+
self.on_event(event)
|
|
647
|
+
except Exception as e:
|
|
648
|
+
logger.warning("ToolEvent callback failed: %s", e)
|
|
649
|
+
|
|
650
|
+
def _emit_text_chunks(self, text: str, chunk_size: int = 12) -> None:
|
|
651
|
+
"""Emit user-visible text in small chunks (Clawd-Code pattern)."""
|
|
652
|
+
if self.on_text_chunk is None or not text:
|
|
653
|
+
return
|
|
654
|
+
if chunk_size <= 0:
|
|
655
|
+
chunk_size = len(text)
|
|
656
|
+
for idx in range(0, len(text), chunk_size):
|
|
657
|
+
try:
|
|
658
|
+
self.on_text_chunk(text[idx:idx + chunk_size])
|
|
659
|
+
except Exception as e:
|
|
660
|
+
logger.warning("on_text_chunk failed: %s", e)
|
|
661
|
+
return
|
|
662
|
+
|
|
663
|
+
def _emit_plan_update(self) -> list[StreamEvent]:
|
|
664
|
+
"""Emit a plan update event from current tasks and todos."""
|
|
665
|
+
entries = []
|
|
666
|
+
for task in self._task_manager.list_tasks():
|
|
667
|
+
entries.append({
|
|
668
|
+
"content": task.get("subject", task.get("description", "")),
|
|
669
|
+
"status": task.get("status", "pending"),
|
|
670
|
+
"priority": task.get("metadata", {}).get("priority", "medium"),
|
|
671
|
+
})
|
|
672
|
+
for todo in self._task_manager.list_todos():
|
|
673
|
+
entries.append({
|
|
674
|
+
"content": todo.get("text", ""),
|
|
675
|
+
"status": "completed" if todo.get("done") else "pending",
|
|
676
|
+
"priority": "low",
|
|
677
|
+
})
|
|
678
|
+
return [StreamEvent.plan(entries)]
|
|
679
|
+
|
|
680
|
+
def _on_task_change(self) -> None:
|
|
681
|
+
"""Callback invoked by TaskManager whenever tasks/todos mutate.
|
|
682
|
+
|
|
683
|
+
Sets a dirty flag so the next opportunity in ``chat_stream`` will
|
|
684
|
+
emit a fresh plan event. Decoupling the callback from the emit
|
|
685
|
+
keeps the public API synchronous and avoids re-entrancy when
|
|
686
|
+
subagents mutate the same TaskManager concurrently.
|
|
687
|
+
"""
|
|
688
|
+
self._plan_dirty = True
|
|
689
|
+
|
|
690
|
+
@property
|
|
691
|
+
def _workspace(self) -> Path:
|
|
692
|
+
return self._project_dir
|
|
693
|
+
|
|
694
|
+
def set_agent(self, agent_type: str) -> None:
|
|
695
|
+
from onecode.agent.agents.types import (
|
|
696
|
+
AgentPermission,
|
|
697
|
+
create_agent,
|
|
698
|
+
PLAN_INSTRUCTIONS,
|
|
699
|
+
REACT_CYCLE,
|
|
700
|
+
TOOL_DESCRIPTIONS,
|
|
701
|
+
filter_tool_descriptions,
|
|
702
|
+
)
|
|
703
|
+
self.current_agent = create_agent(agent_type)
|
|
704
|
+
system_parts = [self.current_agent.description]
|
|
705
|
+
|
|
706
|
+
edit_ask = self.current_agent.should_ask_for_edit()
|
|
707
|
+
bash_ask = self.current_agent.should_ask_for_bash()
|
|
708
|
+
if edit_ask or bash_ask:
|
|
709
|
+
restrictions = []
|
|
710
|
+
if edit_ask:
|
|
711
|
+
restrictions.append("- File edits require user approval")
|
|
712
|
+
if bash_ask:
|
|
713
|
+
restrictions.append("- Shell commands require user approval")
|
|
714
|
+
system_parts.append("\n".join(restrictions))
|
|
715
|
+
|
|
716
|
+
if self.current_agent.permission_task != AgentPermission.DENY:
|
|
717
|
+
system_parts.append(REACT_CYCLE)
|
|
718
|
+
system_parts.append(PLAN_INSTRUCTIONS)
|
|
719
|
+
|
|
720
|
+
# Tell the model how to format intermediate reasoning so the TUI
|
|
721
|
+
# can render it as a collapsible "thought" block instead of
|
|
722
|
+
# leaking it into the visible answer. Without these tags, the
|
|
723
|
+
# model's planning prose ("I will now update X…", "X is done,
|
|
724
|
+
# let me verify…") flows through as plain ``agent_message_chunk``
|
|
725
|
+
# and shows up in chat interleaved with tool calls.
|
|
726
|
+
system_parts.append(
|
|
727
|
+
"\n## Response style\n"
|
|
728
|
+
"- **Every turn must start with Chain of Thought reasoning** "
|
|
729
|
+
"inside `<thinking>`. Analyze the current state, determine the "
|
|
730
|
+
"next step, and decide whether to delegate via `Task`.\n"
|
|
731
|
+
"- **Prefer `Task` delegation**: For any multi-step operation, "
|
|
732
|
+
"file modification, or research, use `Task(agent_type, prompt)` "
|
|
733
|
+
"rather than calling execution tools directly.\n"
|
|
734
|
+
"- If you need to reason between tool calls, wrap your "
|
|
735
|
+
"reasoning in `<thinking>...</thinking>`. The TUI will "
|
|
736
|
+
"render the wrapped block as a collapsible thought and "
|
|
737
|
+
"keep it out of the main answer.\n"
|
|
738
|
+
"- Do not narrate your plan in the visible answer. Avoid "
|
|
739
|
+
'phrases like "I will now…", "Let me first…", "X is done, '
|
|
740
|
+
'let me verify…" — those should go inside `<thinking>`. '
|
|
741
|
+
"The visible answer is only the final user-facing summary.\n"
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
tool_desc = filter_tool_descriptions(
|
|
745
|
+
allowlist=self.current_agent.tools or None,
|
|
746
|
+
denylist=self.current_agent.disallowed_tools or None,
|
|
747
|
+
)
|
|
748
|
+
system_parts.append(tool_desc)
|
|
749
|
+
|
|
750
|
+
tagged_content = "<!-- AGENT_CONFIG -->\n" + "\n".join(system_parts)
|
|
751
|
+
if not self.context.replace_system_section("AGENT_CONFIG", tagged_content):
|
|
752
|
+
self.context.add_system(tagged_content)
|
|
753
|
+
|
|
754
|
+
# Re-apply user permission overrides (e.g. "allow always")
|
|
755
|
+
self._perm_store.apply_to(self.current_agent)
|
|
756
|
+
|
|
757
|
+
def get_available_tools(self) -> str:
|
|
758
|
+
from onecode.agent.agents.types import TOOL_DESCRIPTIONS, filter_tool_descriptions
|
|
759
|
+
return filter_tool_descriptions(
|
|
760
|
+
allowlist=self.current_agent.tools or None,
|
|
761
|
+
denylist=self.current_agent.disallowed_tools or None,
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
def _load_skills(self, force: bool = False) -> None:
|
|
765
|
+
if self._skills_loaded and not force:
|
|
766
|
+
return
|
|
767
|
+
|
|
768
|
+
# Remove previously loaded skill-tagged messages so they don't
|
|
769
|
+
# accumulate across agent switches or skill re-loads.
|
|
770
|
+
self.context.remove_system_by_marker("<!-- SKILL:")
|
|
771
|
+
self.context.remove_system_by_marker("<!-- CDH_PROJECT -->")
|
|
772
|
+
self._skills_loaded = True
|
|
773
|
+
|
|
774
|
+
for skill in self._skill_loader.get_enabled():
|
|
775
|
+
tagged = f"<!-- SKILL:{skill.name} -->\n{skill.content}"
|
|
776
|
+
self.context.add_system(tagged)
|
|
777
|
+
|
|
778
|
+
# Load project .cdh/ state into context
|
|
779
|
+
from onecode.agent.cdh_loader import CdhProjectLoader
|
|
780
|
+
cdh_content = CdhProjectLoader.load_for_workspace(self._workspace)
|
|
781
|
+
if cdh_content:
|
|
782
|
+
self.context.add_system(f"<!-- CDH_PROJECT -->\n{cdh_content}")
|
|
783
|
+
|
|
784
|
+
def _inject_project_context(self, project_name: str) -> None:
|
|
785
|
+
if not project_name:
|
|
786
|
+
return
|
|
787
|
+
if self._project_context_loaded:
|
|
788
|
+
return
|
|
789
|
+
|
|
790
|
+
self._project_context_loaded = True
|
|
791
|
+
self._project_config = {}
|
|
792
|
+
|
|
793
|
+
context_parts = [
|
|
794
|
+
f"Project: {project_name}",
|
|
795
|
+
f"Platform: {self._project_config.get('platform', 'unknown')}",
|
|
796
|
+
]
|
|
797
|
+
|
|
798
|
+
env_id = self._project_config.get("cloudbase", {}).get("envId", "")
|
|
799
|
+
if env_id:
|
|
800
|
+
context_parts.append(f"TCB EnvId: {env_id}")
|
|
801
|
+
|
|
802
|
+
agents_md_path = self._workspace / "AGENTS.md"
|
|
803
|
+
if agents_md_path.exists():
|
|
804
|
+
try:
|
|
805
|
+
content = agents_md_path.read_text(encoding="utf-8")
|
|
806
|
+
context_parts.append(f"\n--- AGENTS.md ---\n{content[:2000]}")
|
|
807
|
+
except Exception:
|
|
808
|
+
pass
|
|
809
|
+
|
|
810
|
+
self.context.add_system("\n".join(context_parts))
|
|
811
|
+
|
|
812
|
+
def _should_retrieve_codebase(self) -> bool:
|
|
813
|
+
try:
|
|
814
|
+
cfg = self.app.config.codebase
|
|
815
|
+
return cfg.enabled and cfg.auto_retrieve
|
|
816
|
+
except Exception:
|
|
817
|
+
return False
|
|
818
|
+
|
|
819
|
+
async def _get_codebase_engine(self):
|
|
820
|
+
if self._codebase_engine is not None:
|
|
821
|
+
return self._codebase_engine
|
|
822
|
+
try:
|
|
823
|
+
cfg = self.app.config.codebase
|
|
824
|
+
if not cfg.enabled:
|
|
825
|
+
return None
|
|
826
|
+
from onecode.codebase import CodebaseEngine
|
|
827
|
+
self._codebase_engine = CodebaseEngine(self._project_dir, cfg)
|
|
828
|
+
await self._codebase_engine.ensure_indexed()
|
|
829
|
+
return self._codebase_engine
|
|
830
|
+
except Exception as e:
|
|
831
|
+
logger.warning("Failed to init codebase engine: %s", e)
|
|
832
|
+
return None
|
|
833
|
+
|
|
834
|
+
async def chat(self, user_input: str) -> str:
|
|
835
|
+
self._load_skills()
|
|
836
|
+
|
|
837
|
+
project_name = getattr(self.app, "current_project", None) or ""
|
|
838
|
+
if project_name:
|
|
839
|
+
self._inject_project_context(project_name)
|
|
840
|
+
|
|
841
|
+
self.context.add_user(user_input)
|
|
842
|
+
|
|
843
|
+
self.context.config.model = self.app.current_model
|
|
844
|
+
if self.context.should_compact():
|
|
845
|
+
level = self.context.compact()
|
|
846
|
+
if level not in ("none",):
|
|
847
|
+
logger.info("Context compaction applied: %s", level)
|
|
848
|
+
|
|
849
|
+
provider_cls = ProviderRegistry.get(self.app.current_provider)
|
|
850
|
+
if not provider_cls:
|
|
851
|
+
return f"Provider '{self.app.current_provider}' not available."
|
|
852
|
+
|
|
853
|
+
config = self.app.config.providers.get(self.app.current_provider)
|
|
854
|
+
if config is None:
|
|
855
|
+
return f"Provider '{self.app.current_provider}' not configured."
|
|
856
|
+
|
|
857
|
+
provider = provider_cls(
|
|
858
|
+
api_key=config.api_key or "",
|
|
859
|
+
endpoint=config.endpoint or None,
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
response = await provider.chat(
|
|
863
|
+
self.context.get_context(),
|
|
864
|
+
model=self.app.current_model,
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
self.context.add_assistant(response.content)
|
|
868
|
+
self.iterations += 1
|
|
869
|
+
self.total_tokens += response.usage.get("total_tokens", 0)
|
|
870
|
+
return response.content
|
|
871
|
+
|
|
872
|
+
def _parse_tool_calls(self, text: str) -> list[dict]:
|
|
873
|
+
calls = []
|
|
874
|
+
for match in TOOL_CALL_RE.finditer(text):
|
|
875
|
+
name = match.group(1)
|
|
876
|
+
call_id = match.group(2)
|
|
877
|
+
raw_input = match.group(3).strip()
|
|
878
|
+
try:
|
|
879
|
+
parsed_input = json.loads(raw_input) if raw_input else {}
|
|
880
|
+
except json.JSONDecodeError:
|
|
881
|
+
parsed_input = {"raw": raw_input}
|
|
882
|
+
calls.append({"name": name, "id": call_id, "input": parsed_input})
|
|
883
|
+
return calls
|
|
884
|
+
|
|
885
|
+
def _validate_edit(self, path: str) -> str | None:
|
|
886
|
+
try:
|
|
887
|
+
content = self.file_ops.read(path, 0, 0)
|
|
888
|
+
if content and "error" not in str(content).lower():
|
|
889
|
+
return None
|
|
890
|
+
return f"Warning: File may not have been written correctly: {path}"
|
|
891
|
+
except Exception as e:
|
|
892
|
+
return f"Warning: Could not verify file: {e}"
|
|
893
|
+
|
|
894
|
+
def _tool_category(self, name: str) -> str:
|
|
895
|
+
"""Map tool name to category for structured display."""
|
|
896
|
+
from onecode.models.messages import get_tool_category
|
|
897
|
+
return get_tool_category(name).value
|
|
898
|
+
|
|
899
|
+
_TOOL_NAME_TO_PERM_KEY: dict[str, str] = {
|
|
900
|
+
"Read": "read",
|
|
901
|
+
"Write": "edit",
|
|
902
|
+
"Edit": "edit",
|
|
903
|
+
"Insert": "edit",
|
|
904
|
+
"UndoEdit": "edit",
|
|
905
|
+
"ApplyPatch": "edit",
|
|
906
|
+
"Bash": "bash",
|
|
907
|
+
"WebFetch": "webfetch",
|
|
908
|
+
"WebSearch": "websearch",
|
|
909
|
+
"Glob": "glob",
|
|
910
|
+
"Grep": "grep",
|
|
911
|
+
"List": "list",
|
|
912
|
+
"Task": "task",
|
|
913
|
+
"Agent": "task",
|
|
914
|
+
"Skill": "skill",
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
def _check_tool_permission(self, name: str, inp: dict) -> str | None:
|
|
918
|
+
"""Unified agent-level permission check for all tools."""
|
|
919
|
+
from onecode.agent.agents.types import AgentPermission
|
|
920
|
+
perm_key = self._TOOL_NAME_TO_PERM_KEY.get(name)
|
|
921
|
+
if perm_key is None:
|
|
922
|
+
return None
|
|
923
|
+
tools_config = self.current_agent.get_tools_config()
|
|
924
|
+
perm = tools_config.get(perm_key, AgentPermission.ALLOW)
|
|
925
|
+
if perm == AgentPermission.DENY:
|
|
926
|
+
return json.dumps({"success": False, "error": f"{name} denied"})
|
|
927
|
+
if perm == AgentPermission.ASK:
|
|
928
|
+
return json.dumps({"success": False, "error": f"{name} requires approval", "requires_approval": True})
|
|
929
|
+
return None
|
|
930
|
+
|
|
931
|
+
async def _execute_tool(self, tool_call: dict) -> dict:
|
|
932
|
+
from onecode.agent.tools.registry import ToolCall as RegistryToolCall
|
|
933
|
+
name = tool_call["name"]
|
|
934
|
+
tid = tool_call["id"]
|
|
935
|
+
inp = tool_call["input"]
|
|
936
|
+
category = self._tool_category(name)
|
|
937
|
+
base = {"tool_use_id": tid, "is_error": False, "category": category}
|
|
938
|
+
try:
|
|
939
|
+
denied = self._check_tool_permission(name, inp)
|
|
940
|
+
if denied:
|
|
941
|
+
return {**base, "content": denied, "is_error": True}
|
|
942
|
+
|
|
943
|
+
call = RegistryToolCall(name=name, input=inp, tool_use_id=tid)
|
|
944
|
+
result = self._tool_registry.dispatch(call)
|
|
945
|
+
|
|
946
|
+
# Handle SendMessage tracking
|
|
947
|
+
if name == "SendMessage":
|
|
948
|
+
msg_output = result.output if isinstance(result.output, dict) else {}
|
|
949
|
+
self._last_user_msg = msg_output.get("message", "")
|
|
950
|
+
return {**base, "content": json.dumps(result.output), "is_error": result.is_error}
|
|
951
|
+
|
|
952
|
+
# Format output for backward compatibility
|
|
953
|
+
output = self._format_tool_output(result)
|
|
954
|
+
return {**base, "content": output, "is_error": result.is_error}
|
|
955
|
+
except Exception as e:
|
|
956
|
+
logger.exception(f"Tool execution error: {e}")
|
|
957
|
+
return {**base, "content": f"Error: {e}", "is_error": True}
|
|
958
|
+
|
|
959
|
+
def _format_tool_output(self, result: RegistryToolResult) -> str:
|
|
960
|
+
import json
|
|
961
|
+
output = result.output
|
|
962
|
+
if isinstance(output, str):
|
|
963
|
+
return output
|
|
964
|
+
try:
|
|
965
|
+
return json.dumps(output)
|
|
966
|
+
except (TypeError, ValueError):
|
|
967
|
+
return str(output)
|
|
968
|
+
|
|
969
|
+
async def cancel(self):
|
|
970
|
+
"""Cancel the current chat_stream turn and all in-flight subagents."""
|
|
971
|
+
self._cancelled = True
|
|
972
|
+
for child in self._child_engines:
|
|
973
|
+
child._cancelled = True
|
|
974
|
+
|
|
975
|
+
async def chat_stream(self, user_input: str | list[dict]) -> AsyncIterator[StreamEvent | str]:
|
|
976
|
+
self._load_skills()
|
|
977
|
+
preview = user_input[:100] if isinstance(user_input, str) else f"[{len(user_input)} content blocks]"
|
|
978
|
+
logger.info(f"chat_stream() called with user_input='{preview}'")
|
|
979
|
+
|
|
980
|
+
project_name = getattr(self.app, "current_project", None) or ""
|
|
981
|
+
if project_name:
|
|
982
|
+
was_context_loaded = self._project_context_loaded
|
|
983
|
+
self._inject_project_context(project_name)
|
|
984
|
+
if not was_context_loaded:
|
|
985
|
+
yield StreamEvent.text_delta(
|
|
986
|
+
f"\n📋 项目: {project_name}\n继续开发中...\n\n"
|
|
987
|
+
)
|
|
988
|
+
else:
|
|
989
|
+
pass
|
|
990
|
+
|
|
991
|
+
# Emit initial plan so the TUI Plan widget is mounted (or refreshed
|
|
992
|
+
# to the current snapshot) at the start of every turn. This is
|
|
993
|
+
# required because the Plan widget only renders when an ``entries``
|
|
994
|
+
# value arrives — without this, the widget never appears until the
|
|
995
|
+
# agent happens to call a task tool.
|
|
996
|
+
for event in self._emit_plan_update():
|
|
997
|
+
yield event
|
|
998
|
+
self._plan_dirty = False
|
|
999
|
+
|
|
1000
|
+
# ── Codebase auto-retrieval ──
|
|
1001
|
+
if isinstance(user_input, str) and self._should_retrieve_codebase():
|
|
1002
|
+
try:
|
|
1003
|
+
engine = await self._get_codebase_engine()
|
|
1004
|
+
if engine:
|
|
1005
|
+
chunks = await engine.retrieve(user_input)
|
|
1006
|
+
ctx_text = engine.format_context(
|
|
1007
|
+
chunks, max_tokens=self.app.config.codebase.max_chunk_tokens
|
|
1008
|
+
)
|
|
1009
|
+
if ctx_text:
|
|
1010
|
+
if not self.context.replace_system_section("CODEBASE", ctx_text):
|
|
1011
|
+
self.context.add_system(f"<!-- CODEBASE -->\n{ctx_text}")
|
|
1012
|
+
except Exception as e:
|
|
1013
|
+
logger.warning("Codebase retrieval failed: %s", e)
|
|
1014
|
+
|
|
1015
|
+
self.context.add_user(user_input)
|
|
1016
|
+
|
|
1017
|
+
# Plan gate: activate based on agent mode, not content heuristics.
|
|
1018
|
+
# Gate fires reactively when agent tries execution tools without a plan.
|
|
1019
|
+
self._plan_gate_mode = self._resolve_plan_gate_mode()
|
|
1020
|
+
|
|
1021
|
+
self.context.config.model = self.app.current_model
|
|
1022
|
+
if self.context.should_compact():
|
|
1023
|
+
level = self.context.compact()
|
|
1024
|
+
if level not in ("none",):
|
|
1025
|
+
logger.info("Context compaction at turn start: %s", level)
|
|
1026
|
+
|
|
1027
|
+
provider_name = self.app.current_provider
|
|
1028
|
+
model_name = self.app.current_model
|
|
1029
|
+
logger.info(f"Using provider='{provider_name}', model='{model_name}'")
|
|
1030
|
+
logger.info(
|
|
1031
|
+
"chat_stream engine state: project=%s agent=%s tasks=%d todos=%d context_msgs=%d ctx_tokens=%d",
|
|
1032
|
+
project_name or "(none)",
|
|
1033
|
+
self.current_agent.name if self.current_agent else "None",
|
|
1034
|
+
len(self._task_manager.list_tasks()),
|
|
1035
|
+
len(self._task_manager.list_todos()),
|
|
1036
|
+
len(self.context.messages),
|
|
1037
|
+
self.context._token_count,
|
|
1038
|
+
)
|
|
1039
|
+
|
|
1040
|
+
provider_cls = ProviderRegistry.get(provider_name)
|
|
1041
|
+
if not provider_cls:
|
|
1042
|
+
available = list(ProviderRegistry._registry.keys()) if hasattr(ProviderRegistry, '_registry') else "unknown"
|
|
1043
|
+
error_msg = f"Provider '{provider_name}' not available. Available: {available}"
|
|
1044
|
+
logger.error(error_msg)
|
|
1045
|
+
yield StreamEvent.error(error_msg)
|
|
1046
|
+
return
|
|
1047
|
+
|
|
1048
|
+
config = self.app.config.providers.get(provider_name)
|
|
1049
|
+
if config is None:
|
|
1050
|
+
available = list(self.app.config.providers.keys())
|
|
1051
|
+
error_msg = f"Provider '{provider_name}' not configured. Available providers: {available}"
|
|
1052
|
+
logger.error(error_msg)
|
|
1053
|
+
yield StreamEvent.error(error_msg)
|
|
1054
|
+
return
|
|
1055
|
+
|
|
1056
|
+
logger.info(f"Creating provider instance: {provider_cls.__name__}")
|
|
1057
|
+
provider_kwargs = dict(api_key=config.api_key or "", endpoint=config.endpoint or None)
|
|
1058
|
+
provider = provider_cls(**provider_kwargs)
|
|
1059
|
+
|
|
1060
|
+
# Reset per-turn usage tracking
|
|
1061
|
+
self._turn_usages = []
|
|
1062
|
+
|
|
1063
|
+
# Reset cancellation flag (adapter also resets it before calling)
|
|
1064
|
+
self._cancelled = False
|
|
1065
|
+
|
|
1066
|
+
# ── Agent loop: CoT + ReAct (思考 → 行动 → 观察) ──
|
|
1067
|
+
is_anthropic = provider.is_anthropic_style()
|
|
1068
|
+
max_turns = self.current_agent.max_turns or 10
|
|
1069
|
+
|
|
1070
|
+
# Initialize ReAct phase section (first turn prompt)
|
|
1071
|
+
cot_phase_init = (
|
|
1072
|
+
"<!-- REACT_PHASE -->\n"
|
|
1073
|
+
"## Turn 1 — Thought Phase (思考)\n"
|
|
1074
|
+
"Begin this turn by reasoning step by step in `<thinking>`:\n"
|
|
1075
|
+
"1. **Current state**: Analyze the user request and what needs to be done.\n"
|
|
1076
|
+
"2. **Goal**: What should I accomplish this turn?\n"
|
|
1077
|
+
"3. **Approach**: Should I delegate via `Task`, plan via `task_create`, "
|
|
1078
|
+
"or use a direct tool?\n"
|
|
1079
|
+
"4. **Task-first**: For any multi-step work or file modifications, "
|
|
1080
|
+
"prefer `Task(agent_type, prompt)` over direct execution tools.\n"
|
|
1081
|
+
)
|
|
1082
|
+
if not self.context.replace_system_section("REACT_PHASE", cot_phase_init):
|
|
1083
|
+
self.context.add_system(cot_phase_init)
|
|
1084
|
+
|
|
1085
|
+
for turn in range(max_turns):
|
|
1086
|
+
if self._cancelled:
|
|
1087
|
+
yield StreamEvent.text_delta("\n\n*Turn cancelled*\n\n")
|
|
1088
|
+
return
|
|
1089
|
+
|
|
1090
|
+
# ── Thought Phase: update CoT reasoning guidance for this turn ──
|
|
1091
|
+
# Cleanup stale reminders that may have accumulated
|
|
1092
|
+
self.context.remove_system_by_marker("<!-- TASK_FIRST_REMINDER -->")
|
|
1093
|
+
if turn > 0:
|
|
1094
|
+
cot_phase = (
|
|
1095
|
+
f"<!-- REACT_PHASE -->\n"
|
|
1096
|
+
f"## Turn {turn + 1} — Thought Phase (思考)\n"
|
|
1097
|
+
"Begin this turn by reasoning step by step in `<thinking>`:\n"
|
|
1098
|
+
"1. **Current state**: What just happened? What results do I have?\n"
|
|
1099
|
+
"2. **Goal**: What should I accomplish this turn?\n"
|
|
1100
|
+
"3. **Approach**: Should I delegate via `Task`, plan via `task_create`, "
|
|
1101
|
+
"or use a direct tool?\n"
|
|
1102
|
+
"4. **Task-first**: For any multi-step work or file modifications, "
|
|
1103
|
+
"prefer `Task(agent_type, prompt)` over direct execution tools.\n"
|
|
1104
|
+
)
|
|
1105
|
+
self.context.replace_system_section("REACT_PHASE", cot_phase)
|
|
1106
|
+
|
|
1107
|
+
self._pending_thinking_blocks = []
|
|
1108
|
+
|
|
1109
|
+
self.context.config.model = self.app.current_model
|
|
1110
|
+
if self.context.should_compact():
|
|
1111
|
+
level = self.context.compact()
|
|
1112
|
+
if level not in ("none",):
|
|
1113
|
+
logger.info("Context compaction at turn %d: %s", turn, level)
|
|
1114
|
+
|
|
1115
|
+
turn_usage: dict[str, int] = {}
|
|
1116
|
+
response_text = ""
|
|
1117
|
+
tool_uses: list[dict] = []
|
|
1118
|
+
|
|
1119
|
+
try:
|
|
1120
|
+
context_messages = self.context.get_context()
|
|
1121
|
+
logger.info(f"Calling provider.chat_stream_response (turn {turn+1})")
|
|
1122
|
+
self._streaming_used = False
|
|
1123
|
+
if self.on_text_chunk is not None:
|
|
1124
|
+
_original_cb = self.on_text_chunk
|
|
1125
|
+
def _stream_wrapper(text: str) -> None:
|
|
1126
|
+
self._streaming_used = True
|
|
1127
|
+
_original_cb(text)
|
|
1128
|
+
stream_cb = _stream_wrapper
|
|
1129
|
+
else:
|
|
1130
|
+
stream_cb = None
|
|
1131
|
+
chat_response = await provider.chat_stream_response(
|
|
1132
|
+
context_messages,
|
|
1133
|
+
model=model_name,
|
|
1134
|
+
on_text_chunk=stream_cb,
|
|
1135
|
+
on_tool_call_delta=self.on_tool_call_delta,
|
|
1136
|
+
tools=self._tool_registry.make_openai_schemas(),
|
|
1137
|
+
)
|
|
1138
|
+
response_text = chat_response.content
|
|
1139
|
+
tool_uses = chat_response.tool_uses
|
|
1140
|
+
if not tool_uses and (
|
|
1141
|
+
"<minimax:tool_call>" in response_text
|
|
1142
|
+
or _LEGACY_OPEN in response_text
|
|
1143
|
+
):
|
|
1144
|
+
parsed_uses, response_text, self._tool_id_counter = (
|
|
1145
|
+
_extract_minimax_tool_uses(
|
|
1146
|
+
response_text, id_start=self._tool_id_counter
|
|
1147
|
+
)
|
|
1148
|
+
)
|
|
1149
|
+
legacy_uses, response_text, self._tool_id_counter = (
|
|
1150
|
+
_extract_legacy_tool_uses(
|
|
1151
|
+
response_text, id_start=self._tool_id_counter
|
|
1152
|
+
)
|
|
1153
|
+
)
|
|
1154
|
+
tool_uses = parsed_uses + legacy_uses
|
|
1155
|
+
if tool_uses:
|
|
1156
|
+
logger.info(
|
|
1157
|
+
"Extracted %d text tool uses (%d minimax, %d legacy) from response",
|
|
1158
|
+
len(tool_uses), len(parsed_uses), len(legacy_uses),
|
|
1159
|
+
)
|
|
1160
|
+
if chat_response.usage:
|
|
1161
|
+
turn_usage = chat_response.usage
|
|
1162
|
+
except NotImplementedError:
|
|
1163
|
+
logger.info("chat_stream_response not supported, falling back to non-streaming chat()")
|
|
1164
|
+
try:
|
|
1165
|
+
context_messages = self.context.get_context()
|
|
1166
|
+
model_response = await provider.chat(
|
|
1167
|
+
context_messages,
|
|
1168
|
+
model=model_name,
|
|
1169
|
+
tools=self._tool_registry.make_openai_schemas(),
|
|
1170
|
+
)
|
|
1171
|
+
response_text = model_response.get_text()
|
|
1172
|
+
tool_uses = [
|
|
1173
|
+
{"id": tu.id, "name": tu.name, "input": tu.input}
|
|
1174
|
+
for cb in model_response.content
|
|
1175
|
+
if cb.type == ContentBlockType.TOOL_USE and cb.tool_use
|
|
1176
|
+
for tu in [cb.tool_use]
|
|
1177
|
+
]
|
|
1178
|
+
if not tool_uses and (
|
|
1179
|
+
"<minimax:tool_call>" in response_text
|
|
1180
|
+
or _LEGACY_OPEN in response_text
|
|
1181
|
+
):
|
|
1182
|
+
parsed_uses, response_text, self._tool_id_counter = (
|
|
1183
|
+
_extract_minimax_tool_uses(
|
|
1184
|
+
response_text, id_start=self._tool_id_counter
|
|
1185
|
+
)
|
|
1186
|
+
)
|
|
1187
|
+
legacy_uses, response_text, self._tool_id_counter = (
|
|
1188
|
+
_extract_legacy_tool_uses(
|
|
1189
|
+
response_text, id_start=self._tool_id_counter
|
|
1190
|
+
)
|
|
1191
|
+
)
|
|
1192
|
+
tool_uses = parsed_uses + legacy_uses
|
|
1193
|
+
if tool_uses:
|
|
1194
|
+
logger.info(
|
|
1195
|
+
"Extracted %d text tool uses (%d minimax, %d legacy) from chat() fallback",
|
|
1196
|
+
len(tool_uses), len(parsed_uses), len(legacy_uses),
|
|
1197
|
+
)
|
|
1198
|
+
if model_response.usage:
|
|
1199
|
+
turn_usage = model_response.usage
|
|
1200
|
+
except Exception as e:
|
|
1201
|
+
logger.exception(f"Error during chat() fallback turn {turn+1}: {e}")
|
|
1202
|
+
yield StreamEvent.error(str(e))
|
|
1203
|
+
break
|
|
1204
|
+
except Exception as e:
|
|
1205
|
+
ctx_msgs = len(self.context.messages)
|
|
1206
|
+
ctx_tokens = self.context._token_count
|
|
1207
|
+
logger.exception(
|
|
1208
|
+
f"Error during chat_stream_response turn {turn+1}: {e}\n"
|
|
1209
|
+
f" provider={provider_name} model={model_name} "
|
|
1210
|
+
f"context_msgs={ctx_msgs} ctx_tokens={ctx_tokens}"
|
|
1211
|
+
)
|
|
1212
|
+
yield StreamEvent.error(f"Provider error (turn {turn+1}): {e}")
|
|
1213
|
+
break
|
|
1214
|
+
|
|
1215
|
+
# Track usage
|
|
1216
|
+
self._turn_usages.append(turn_usage)
|
|
1217
|
+
if turn_usage:
|
|
1218
|
+
self.total_tokens += turn_usage.get("total_tokens", 0)
|
|
1219
|
+
|
|
1220
|
+
# Extract thinking blocks from response. In the streaming path
|
|
1221
|
+
# the adapter's _make_stream_callback already stripped <thinking>
|
|
1222
|
+
# markers from the text and routed them through self.on_thinking
|
|
1223
|
+
# (which appends to _pending_thinking_blocks). The non-streaming
|
|
1224
|
+
# fallback still has the markers inline, so we parse them here.
|
|
1225
|
+
thinking_blocks = list(self._pending_thinking_blocks)
|
|
1226
|
+
clean_text = response_text
|
|
1227
|
+
for match in THINKING_RE.finditer(response_text):
|
|
1228
|
+
tb = match.group(1)
|
|
1229
|
+
if tb not in thinking_blocks:
|
|
1230
|
+
thinking_blocks.append(tb)
|
|
1231
|
+
if thinking_blocks or self._pending_thinking_blocks:
|
|
1232
|
+
clean_text = THINKING_RE.sub('', response_text).strip()
|
|
1233
|
+
if not self._streaming_used:
|
|
1234
|
+
for tb in thinking_blocks:
|
|
1235
|
+
yield StreamEvent.thinking(tb)
|
|
1236
|
+
|
|
1237
|
+
# Log the raw model response so developers can verify the
|
|
1238
|
+
# model is actually emitting ``<thinking>`` markers (and not
|
|
1239
|
+
# bleeding planning prose into the visible answer). Goes
|
|
1240
|
+
# to the onecode root logger → ~/.onecode/logs/cdh.log when
|
|
1241
|
+
# ``setup_logging(DEBUG)`` is in effect.
|
|
1242
|
+
logger.debug(
|
|
1243
|
+
"RAW_RESPONSE turn=%d text_len=%d first_200=%r "
|
|
1244
|
+
"thinking_blocks=%d tool_uses=%d streaming_used=%s",
|
|
1245
|
+
turn + 1,
|
|
1246
|
+
len(response_text),
|
|
1247
|
+
response_text[:200],
|
|
1248
|
+
len(thinking_blocks),
|
|
1249
|
+
len(tool_uses),
|
|
1250
|
+
self._streaming_used,
|
|
1251
|
+
)
|
|
1252
|
+
|
|
1253
|
+
# Add assistant response to context with proper content blocks.
|
|
1254
|
+
# Persist thinking blocks so they survive session reload — the
|
|
1255
|
+
# TUI replays them as collapsible Thought widgets.
|
|
1256
|
+
if tool_uses or thinking_blocks:
|
|
1257
|
+
assistant_blocks: list = []
|
|
1258
|
+
for tb in thinking_blocks:
|
|
1259
|
+
assistant_blocks.append({"type": "thinking", "thinking": tb})
|
|
1260
|
+
if clean_text:
|
|
1261
|
+
assistant_blocks.append({"type": "text", "text": clean_text})
|
|
1262
|
+
for tu in tool_uses:
|
|
1263
|
+
assistant_blocks.append({
|
|
1264
|
+
"type": "tool_use",
|
|
1265
|
+
"id": tu["id"],
|
|
1266
|
+
"name": tu["name"],
|
|
1267
|
+
"input": tu["input"],
|
|
1268
|
+
})
|
|
1269
|
+
self.context.add_assistant(assistant_blocks)
|
|
1270
|
+
else:
|
|
1271
|
+
self.context.add_assistant(clean_text)
|
|
1272
|
+
|
|
1273
|
+
self.iterations += 1
|
|
1274
|
+
|
|
1275
|
+
if not tool_uses:
|
|
1276
|
+
if not self._streaming_used:
|
|
1277
|
+
if clean_text.strip():
|
|
1278
|
+
for i in range(0, len(clean_text), 12):
|
|
1279
|
+
yield StreamEvent.text_delta(clean_text[i:i + 12])
|
|
1280
|
+
elif self._last_user_msg:
|
|
1281
|
+
yield StreamEvent.text_delta(self._last_user_msg)
|
|
1282
|
+
break
|
|
1283
|
+
|
|
1284
|
+
# Emit ToolEvents (Clawd-Code pattern) and StreamEvents for TUI
|
|
1285
|
+
for tu in tool_uses:
|
|
1286
|
+
self._notify_event(ToolEvent(
|
|
1287
|
+
kind="tool_use",
|
|
1288
|
+
tool_name=tu["name"],
|
|
1289
|
+
tool_input=tu["input"],
|
|
1290
|
+
tool_use_id=tu["id"],
|
|
1291
|
+
))
|
|
1292
|
+
yield StreamEvent.tool_call_start(tu["name"], tu["id"])
|
|
1293
|
+
for tu in tool_uses:
|
|
1294
|
+
yield StreamEvent.tool_call_complete(tu["id"], tu["name"], tu["input"])
|
|
1295
|
+
|
|
1296
|
+
for tu in tool_uses:
|
|
1297
|
+
logger.info(f"Executing tool: {tu['name']} (id={tu['id']})")
|
|
1298
|
+
|
|
1299
|
+
# Task tool: forward subagent text deltas to the TUI as
|
|
1300
|
+
# subagent_chunk events so the SubAgent widget actually has
|
|
1301
|
+
# content to render. The Task tool's spec is (agent_type,
|
|
1302
|
+
# prompt); we read them from the tool input.
|
|
1303
|
+
if tu["name"] == "Task":
|
|
1304
|
+
# Permission check
|
|
1305
|
+
denied = self._check_tool_permission("Task", tu["input"])
|
|
1306
|
+
if denied:
|
|
1307
|
+
result = {
|
|
1308
|
+
"tool_use_id": tu["id"],
|
|
1309
|
+
"is_error": True,
|
|
1310
|
+
"category": "task",
|
|
1311
|
+
"content": denied,
|
|
1312
|
+
}
|
|
1313
|
+
else:
|
|
1314
|
+
subagent_type = tu["input"].get("agent_type", "general")
|
|
1315
|
+
subagent_prompt = tu["input"].get("prompt", "")
|
|
1316
|
+
yield StreamEvent.subagent_start(subagent_type, tu["id"])
|
|
1317
|
+
accumulated: list[str] = []
|
|
1318
|
+
async for sub_event, sub_text in self._spawn_subagent_async_streaming(
|
|
1319
|
+
subagent_type, subagent_prompt
|
|
1320
|
+
):
|
|
1321
|
+
if sub_text and sub_event.type == StreamEventType.SUBAGENT_END:
|
|
1322
|
+
accumulated.append(sub_text)
|
|
1323
|
+
elif sub_text:
|
|
1324
|
+
accumulated.append(sub_text)
|
|
1325
|
+
yield StreamEvent.subagent_chunk(tu["id"], sub_text)
|
|
1326
|
+
yield StreamEvent.subagent_end(tu["id"])
|
|
1327
|
+
formatted = self._format_subagent_output(
|
|
1328
|
+
subagent_type, subagent_prompt, "".join(accumulated)
|
|
1329
|
+
)
|
|
1330
|
+
result = {
|
|
1331
|
+
"tool_use_id": tu["id"],
|
|
1332
|
+
"is_error": False,
|
|
1333
|
+
"category": "task",
|
|
1334
|
+
"content": formatted,
|
|
1335
|
+
}
|
|
1336
|
+
elif (self._plan_gate_mode != "off"
|
|
1337
|
+
and not self._task_manager.list_tasks()
|
|
1338
|
+
and tu["name"] in _EXECUTION_TOOLS
|
|
1339
|
+
and self._plan_gate_first_turn()):
|
|
1340
|
+
if self._plan_gate_mode == "hard":
|
|
1341
|
+
result = {
|
|
1342
|
+
"tool_use_id": tu["id"],
|
|
1343
|
+
"is_error": True,
|
|
1344
|
+
"category": "task",
|
|
1345
|
+
"content": json.dumps({
|
|
1346
|
+
"success": False,
|
|
1347
|
+
"error": (
|
|
1348
|
+
"Plan required. You are in plan mode (ReAct: Thought → Action → Observation). "
|
|
1349
|
+
"You MUST create a task plan using task_create() before using execution tools. "
|
|
1350
|
+
"First Think (analyze/explore), then Act by creating ALL tasks upfront "
|
|
1351
|
+
"with dependencies, present the plan for user review, "
|
|
1352
|
+
"and only then proceed to execute."
|
|
1353
|
+
),
|
|
1354
|
+
}),
|
|
1355
|
+
}
|
|
1356
|
+
else:
|
|
1357
|
+
# Soft gate: execute but inject a reminder for next turn
|
|
1358
|
+
self.context.add_system(
|
|
1359
|
+
"<!-- PLAN_REMINDER -->\n"
|
|
1360
|
+
"Note: You used a direct execution tool without delegating via Task. "
|
|
1361
|
+
"For any non-trivial operation, prefer `Task(agent_type, prompt)` "
|
|
1362
|
+
"to delegate the work to a focused subagent instead. Task subagents "
|
|
1363
|
+
"encapsulate multi-step logic, keep the main context clean, and "
|
|
1364
|
+
"return structured SUMMARY/CHANGES/EVIDENCE/RISKS/BLOCKERS output."
|
|
1365
|
+
)
|
|
1366
|
+
result = await self._execute_tool(tu)
|
|
1367
|
+
else:
|
|
1368
|
+
result = await self._execute_tool(tu)
|
|
1369
|
+
# Task-first routing: track direct execution tool use
|
|
1370
|
+
if tu["name"] in _EXECUTION_TOOLS:
|
|
1371
|
+
self._direct_execution_count += 1
|
|
1372
|
+
if self._direct_execution_count >= 2:
|
|
1373
|
+
self.context.add_system(
|
|
1374
|
+
"<!-- TASK_FIRST_REMINDER -->\n"
|
|
1375
|
+
"Reminder: You're using direct execution tools repeatedly. "
|
|
1376
|
+
"For multi-step work, delegate via `Task(agent_type, prompt)` instead. "
|
|
1377
|
+
"This keeps the main context focused and work properly encapsulated."
|
|
1378
|
+
)
|
|
1379
|
+
result_str = str(result.get("content", ""))
|
|
1380
|
+
is_error = result.get("is_error", False)
|
|
1381
|
+
category = result.get("category", "unknown")
|
|
1382
|
+
|
|
1383
|
+
# Handle SendMessage — user-visible only, skip from LLM context
|
|
1384
|
+
if tu["name"] == "SendMessage":
|
|
1385
|
+
try:
|
|
1386
|
+
parsed = json.loads(result_str) if result_str else {}
|
|
1387
|
+
msg = parsed.get("message", "")
|
|
1388
|
+
if msg:
|
|
1389
|
+
self._last_user_msg = msg
|
|
1390
|
+
yield StreamEvent.text_delta(f"\n💬 {msg}\n")
|
|
1391
|
+
except (json.JSONDecodeError, ValueError):
|
|
1392
|
+
pass
|
|
1393
|
+
self._notify_event(ToolEvent(
|
|
1394
|
+
kind="tool_result",
|
|
1395
|
+
tool_name="SendMessage",
|
|
1396
|
+
tool_use_id=tu["id"],
|
|
1397
|
+
tool_output={"message": self._last_user_msg},
|
|
1398
|
+
))
|
|
1399
|
+
continue
|
|
1400
|
+
|
|
1401
|
+
# Handle AskUser — trigger user interaction dialog, don't add to LLM context yet
|
|
1402
|
+
if tu["name"] == "AskUser":
|
|
1403
|
+
parsed = json.loads(result_str) if result_str else {}
|
|
1404
|
+
question = parsed.get("question", "")
|
|
1405
|
+
options = parsed.get("options", [])
|
|
1406
|
+
questions = parsed.get("questions", [])
|
|
1407
|
+
|
|
1408
|
+
# Check for auto-default on single question + single option
|
|
1409
|
+
if not questions and len(options) == 1 and options[0].get("default"):
|
|
1410
|
+
default_val = options[0]["value"]
|
|
1411
|
+
self._pending_approval = None
|
|
1412
|
+
yield StreamEvent.tool_result(
|
|
1413
|
+
call_id=tu["id"],
|
|
1414
|
+
content=json.dumps({"answer": default_val}),
|
|
1415
|
+
)
|
|
1416
|
+
continue
|
|
1417
|
+
|
|
1418
|
+
# Auto-default for multi-question: skip questions that have
|
|
1419
|
+
# a single option with default=true
|
|
1420
|
+
if questions:
|
|
1421
|
+
auto_answers = {}
|
|
1422
|
+
remaining = []
|
|
1423
|
+
for i, q in enumerate(questions):
|
|
1424
|
+
qopts = q.get("options", [])
|
|
1425
|
+
if len(qopts) == 1 and qopts[0].get("default"):
|
|
1426
|
+
auto_answers[str(i)] = qopts[0]["value"]
|
|
1427
|
+
else:
|
|
1428
|
+
remaining.append(q)
|
|
1429
|
+
if not remaining:
|
|
1430
|
+
self._pending_approval = None
|
|
1431
|
+
yield StreamEvent.tool_result(
|
|
1432
|
+
call_id=tu["id"],
|
|
1433
|
+
content=json.dumps({"answers": auto_answers}),
|
|
1434
|
+
)
|
|
1435
|
+
continue
|
|
1436
|
+
questions = remaining
|
|
1437
|
+
|
|
1438
|
+
has_questions = bool(questions)
|
|
1439
|
+
self._pending_approval = {"tool_call": tu, "category": category, "ask_user": True}
|
|
1440
|
+
if has_questions:
|
|
1441
|
+
yield StreamEvent.ask_user(
|
|
1442
|
+
call_id=tu["id"],
|
|
1443
|
+
action="AskUser",
|
|
1444
|
+
question="", # not used when questions is present
|
|
1445
|
+
context=result_str,
|
|
1446
|
+
options=[],
|
|
1447
|
+
questions=questions,
|
|
1448
|
+
action_type="ask_user",
|
|
1449
|
+
)
|
|
1450
|
+
else:
|
|
1451
|
+
yield StreamEvent.ask_user(
|
|
1452
|
+
call_id=tu["id"],
|
|
1453
|
+
action="AskUser",
|
|
1454
|
+
question=question,
|
|
1455
|
+
context=result_str,
|
|
1456
|
+
options=options,
|
|
1457
|
+
action_type="ask_user",
|
|
1458
|
+
)
|
|
1459
|
+
continue
|
|
1460
|
+
|
|
1461
|
+
# Detect ASK permission denial
|
|
1462
|
+
requires_approval = False
|
|
1463
|
+
try:
|
|
1464
|
+
parsed = json.loads(result_str) if result_str else {}
|
|
1465
|
+
requires_approval = parsed.get("requires_approval", False)
|
|
1466
|
+
except (json.JSONDecodeError, ValueError):
|
|
1467
|
+
pass
|
|
1468
|
+
|
|
1469
|
+
if requires_approval:
|
|
1470
|
+
self._pending_approval = {"tool_call": tu, "category": category}
|
|
1471
|
+
self._notify_event(ToolEvent(
|
|
1472
|
+
kind="tool_result",
|
|
1473
|
+
tool_name=tu["name"],
|
|
1474
|
+
tool_use_id=tu["id"],
|
|
1475
|
+
is_error=True,
|
|
1476
|
+
error="Requires approval",
|
|
1477
|
+
))
|
|
1478
|
+
yield StreamEvent.ask_user(
|
|
1479
|
+
call_id=tu["id"],
|
|
1480
|
+
action=tu["name"],
|
|
1481
|
+
question=f"Approve {tu['name']} operation?",
|
|
1482
|
+
context=result_str,
|
|
1483
|
+
action_type=tu["name"].lower(),
|
|
1484
|
+
path=tu["input"].get("path", ""),
|
|
1485
|
+
command=tu["input"].get("command", "")[:200],
|
|
1486
|
+
)
|
|
1487
|
+
else:
|
|
1488
|
+
from onecode.models.messages import ToolCategory as MsgToolCategory
|
|
1489
|
+
try:
|
|
1490
|
+
result_cat = MsgToolCategory(category)
|
|
1491
|
+
except ValueError:
|
|
1492
|
+
result_cat = MsgToolCategory.UNKNOWN
|
|
1493
|
+
self._notify_event(ToolEvent(
|
|
1494
|
+
kind="tool_result",
|
|
1495
|
+
tool_name=tu["name"],
|
|
1496
|
+
tool_output=result_str,
|
|
1497
|
+
tool_use_id=tu["id"],
|
|
1498
|
+
is_error=is_error,
|
|
1499
|
+
))
|
|
1500
|
+
yield StreamEvent.tool_result(
|
|
1501
|
+
call_id=tu["id"],
|
|
1502
|
+
content=result_str,
|
|
1503
|
+
is_error=is_error,
|
|
1504
|
+
category=result_cat,
|
|
1505
|
+
)
|
|
1506
|
+
# Add structured tool_result to LLM context
|
|
1507
|
+
if is_anthropic:
|
|
1508
|
+
self.context.add_tool_result(tu["id"], result_str, is_error)
|
|
1509
|
+
else:
|
|
1510
|
+
self.context.add_message(
|
|
1511
|
+
"tool",
|
|
1512
|
+
[{"type": "tool_result", "tool_use_id": tu["id"], "content": result_str, "is_error": is_error}],
|
|
1513
|
+
name=tu["id"],
|
|
1514
|
+
)
|
|
1515
|
+
# Emit plan update when tasks/todos changed. Any
|
|
1516
|
+
# mutating path through TaskManager flips
|
|
1517
|
+
# ``_plan_dirty`` via the ``on_change`` callback, so we
|
|
1518
|
+
# do not need to enumerate tool names here.
|
|
1519
|
+
if self._plan_dirty:
|
|
1520
|
+
for event in self._emit_plan_update():
|
|
1521
|
+
yield event
|
|
1522
|
+
self._plan_dirty = False
|
|
1523
|
+
|
|
1524
|
+
if self._cancelled:
|
|
1525
|
+
yield StreamEvent.text_delta("\n\n*Turn cancelled*\n\n")
|
|
1526
|
+
return
|
|
1527
|
+
|
|
1528
|
+
usage_summary = ", ".join(
|
|
1529
|
+
f"turn {i+1}: {u.get('total_tokens', '?')} tokens"
|
|
1530
|
+
for i, u in enumerate(self._turn_usages) if u
|
|
1531
|
+
)
|
|
1532
|
+
logger.info(f"Chat stream complete after {self.iterations} turn(s). Usage: [{usage_summary}]")
|
|
1533
|
+
|
|
1534
|
+
def has_pending_approval(self) -> bool:
|
|
1535
|
+
"""Check if there's a pending approval request from ASK permission."""
|
|
1536
|
+
return self._pending_approval is not None
|
|
1537
|
+
|
|
1538
|
+
async def resolve_approval(self, approved: bool, answer: str = "") -> dict | None:
|
|
1539
|
+
"""Execute the pending action if approved, or return denial.
|
|
1540
|
+
|
|
1541
|
+
For AskUser, ``answer`` is the user's free-text response.
|
|
1542
|
+
|
|
1543
|
+
Returns the tool result dict, or None if no pending approval.
|
|
1544
|
+
"""
|
|
1545
|
+
from onecode.agent.agents.types import AgentPermission
|
|
1546
|
+
if not self._pending_approval:
|
|
1547
|
+
return None
|
|
1548
|
+
|
|
1549
|
+
tc = self._pending_approval["tool_call"]
|
|
1550
|
+
is_ask_user = self._pending_approval.get("ask_user", False)
|
|
1551
|
+
self._pending_approval = None
|
|
1552
|
+
|
|
1553
|
+
# Handle AskUser — return user's answer as tool result
|
|
1554
|
+
if is_ask_user:
|
|
1555
|
+
if not approved:
|
|
1556
|
+
return {
|
|
1557
|
+
"tool_use_id": tc["id"],
|
|
1558
|
+
"content": json.dumps({"answer": "", "error": "User cancelled"}),
|
|
1559
|
+
"is_error": True,
|
|
1560
|
+
"category": tc.get("category", "unknown"),
|
|
1561
|
+
}
|
|
1562
|
+
# answer is a JSON string when multi-question, plain text otherwise
|
|
1563
|
+
try:
|
|
1564
|
+
parsed = json.loads(answer)
|
|
1565
|
+
is_structured = isinstance(parsed, dict) or isinstance(parsed, list)
|
|
1566
|
+
except (json.JSONDecodeError, TypeError):
|
|
1567
|
+
is_structured = False
|
|
1568
|
+
if is_structured:
|
|
1569
|
+
return {
|
|
1570
|
+
"tool_use_id": tc["id"],
|
|
1571
|
+
"content": json.dumps({"answers": parsed}),
|
|
1572
|
+
"is_error": False,
|
|
1573
|
+
"category": tc.get("category", "unknown"),
|
|
1574
|
+
}
|
|
1575
|
+
return {
|
|
1576
|
+
"tool_use_id": tc["id"],
|
|
1577
|
+
"content": json.dumps({"answer": answer}),
|
|
1578
|
+
"is_error": False,
|
|
1579
|
+
"category": tc.get("category", "unknown"),
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
if not approved:
|
|
1583
|
+
return {
|
|
1584
|
+
"tool_use_id": tc["id"],
|
|
1585
|
+
"content": json.dumps({"success": False, "error": "User denied the operation"}),
|
|
1586
|
+
"is_error": True,
|
|
1587
|
+
"category": tc.get("category", "unknown"),
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
perm_key = self._TOOL_NAME_TO_PERM_KEY.get(tc["name"])
|
|
1591
|
+
if perm_key is None:
|
|
1592
|
+
return await self._execute_tool(tc)
|
|
1593
|
+
|
|
1594
|
+
attr_name = f"permission_{perm_key}"
|
|
1595
|
+
saved = getattr(self.current_agent, attr_name)
|
|
1596
|
+
try:
|
|
1597
|
+
setattr(self.current_agent, attr_name, AgentPermission.ALLOW)
|
|
1598
|
+
return await self._execute_tool(tc)
|
|
1599
|
+
finally:
|
|
1600
|
+
setattr(self.current_agent, attr_name, saved)
|
|
1601
|
+
|
|
1602
|
+
def _reset_react_state(self) -> None:
|
|
1603
|
+
"""Reset ReAct loop state for a fresh cycle."""
|
|
1604
|
+
self._react_phase = "thought"
|
|
1605
|
+
self._direct_execution_count = 0
|
|
1606
|
+
self._plan_gate_fired = False
|
|
1607
|
+
self.context.remove_system_by_marker("<!-- REACT_PHASE -->")
|
|
1608
|
+
self.context.remove_system_by_marker("<!-- TASK_FIRST_REMINDER -->")
|
|
1609
|
+
|
|
1610
|
+
def reset(self):
|
|
1611
|
+
self.context.reset()
|
|
1612
|
+
self.iterations = 0
|
|
1613
|
+
self.total_tokens = 0
|
|
1614
|
+
self._turn_usages = []
|
|
1615
|
+
self._skills_loaded = False
|
|
1616
|
+
self._project_context_loaded = False
|
|
1617
|
+
self._skill_loader.invalidate_cache()
|
|
1618
|
+
self._reset_react_state()
|
|
1619
|
+
|
|
1620
|
+
def status(self) -> str:
|
|
1621
|
+
return (
|
|
1622
|
+
f"Agent: {self.current_agent.name}\n"
|
|
1623
|
+
f"Iterations: {self.iterations}\n"
|
|
1624
|
+
f"Total tokens: {self.total_tokens}\n"
|
|
1625
|
+
f"Context: {self.context.info()}"
|
|
1626
|
+
)
|
|
1627
|
+
|
|
1628
|
+
def read_file(self, path: str, offset: int = 0, limit: int = 0) -> str:
|
|
1629
|
+
if self.current_agent.permission_read == AgentPermission.DENY:
|
|
1630
|
+
return "Read denied"
|
|
1631
|
+
return self.file_ops.read(path, offset, limit)
|
|
1632
|
+
|
|
1633
|
+
def write_file(self, path: str, content: str) -> dict:
|
|
1634
|
+
if self.current_agent.permission_edit == AgentPermission.DENY:
|
|
1635
|
+
return {"success": False, "error": "Edit denied"}
|
|
1636
|
+
if self.current_agent.permission_edit == AgentPermission.ASK:
|
|
1637
|
+
return {"success": False, "error": "Edit requires approval", "requires_approval": True}
|
|
1638
|
+
return self.file_ops.write(path, content)
|
|
1639
|
+
|
|
1640
|
+
def edit_file(self, path: str, old: str, new: str) -> dict:
|
|
1641
|
+
if self.current_agent.permission_edit == AgentPermission.DENY:
|
|
1642
|
+
return {"success": False, "error": "Edit denied"}
|
|
1643
|
+
if self.current_agent.permission_edit == AgentPermission.ASK:
|
|
1644
|
+
return {"success": False, "error": "Edit requires approval", "requires_approval": True}
|
|
1645
|
+
return self.file_ops.edit(path, old, new)
|
|
1646
|
+
|
|
1647
|
+
def glob_files(self, pattern: str) -> list[str]:
|
|
1648
|
+
return self.file_ops.glob(pattern)
|
|
1649
|
+
|
|
1650
|
+
def grep_files(self, pattern: str, include: str = None) -> list[str]:
|
|
1651
|
+
return self.file_ops.grep(pattern, include)
|
|
1652
|
+
|
|
1653
|
+
def list_dir(self, path: str = ".") -> list[dict]:
|
|
1654
|
+
return self.file_ops.list(path)
|
|
1655
|
+
|
|
1656
|
+
def exec_shell(self, cmd: str, timeout: int = 60) -> dict:
|
|
1657
|
+
if self.current_agent.permission_bash == AgentPermission.DENY:
|
|
1658
|
+
return {"success": False, "error": "Bash denied"}
|
|
1659
|
+
if self.current_agent.permission_bash == AgentPermission.ASK:
|
|
1660
|
+
return {"success": False, "error": "Bash requires approval", "requires_approval": True}
|
|
1661
|
+
return self.shell.exec(cmd, timeout=timeout)
|
|
1662
|
+
|
|
1663
|
+
def web_fetch(self, url: str, prompt: str = None) -> str:
|
|
1664
|
+
if self.current_agent.permission_webfetch == AgentPermission.DENY:
|
|
1665
|
+
return "WebFetch denied"
|
|
1666
|
+
from onecode.agent.tools.web_tools import webfetch
|
|
1667
|
+
return webfetch(url, prompt)
|
|
1668
|
+
|
|
1669
|
+
def web_search(self, query: str, num_results: int = 5) -> str:
|
|
1670
|
+
if self.current_agent.permission_websearch == AgentPermission.DENY:
|
|
1671
|
+
return "WebSearch denied"
|
|
1672
|
+
from onecode.agent.tools.web_tools import websearch
|
|
1673
|
+
return websearch(query, num_results)
|
|
1674
|
+
|
|
1675
|
+
async def _spawn_subagent_async(self, agent_type: str, prompt: str) -> str:
|
|
1676
|
+
"""Spawn a sub-agent with streaming-like output.
|
|
1677
|
+
|
|
1678
|
+
Uses chat_stream to get structured output that the ChatPanel
|
|
1679
|
+
can parse for professional rendering.
|
|
1680
|
+
"""
|
|
1681
|
+
parts: list[str] = []
|
|
1682
|
+
async for _event, text in self._spawn_subagent_async_streaming(agent_type, prompt):
|
|
1683
|
+
parts.append(text)
|
|
1684
|
+
result = "".join(parts)
|
|
1685
|
+
return self._format_subagent_output(agent_type, prompt, result)
|
|
1686
|
+
|
|
1687
|
+
async def _spawn_subagent_async_streaming(
|
|
1688
|
+
self, agent_type: str, prompt: str
|
|
1689
|
+
) -> AsyncIterator[tuple[StreamEvent, str]]:
|
|
1690
|
+
"""Streaming variant of :meth:`_spawn_subagent_async`.
|
|
1691
|
+
|
|
1692
|
+
Yields ``(event, text)`` pairs so the caller (typically the Task-tool
|
|
1693
|
+
path in :meth:`chat_stream`) can forward each text delta as a
|
|
1694
|
+
``subagent_chunk`` notification on the ACP wire. The full agent text
|
|
1695
|
+
is also accumulated and emitted in a single ``(subagent_end, text)``
|
|
1696
|
+
payload at the end so callers that only care about the final output
|
|
1697
|
+
can ignore the intermediate chunks.
|
|
1698
|
+
|
|
1699
|
+
Inherits project context and skills from the parent
|
|
1700
|
+
engine so the subagent does not start from a blank slate.
|
|
1701
|
+
"""
|
|
1702
|
+
sub_engine = AgentEngine(self.app, project_dir=self._project_dir, perm_store=self._perm_store)
|
|
1703
|
+
|
|
1704
|
+
# Inherit parent context: project info, skills
|
|
1705
|
+
sub_engine._project_context_loaded = self._project_context_loaded
|
|
1706
|
+
sub_engine._project_config = dict(self._project_config)
|
|
1707
|
+
sub_engine._skills_loaded = True
|
|
1708
|
+
for skill in self._skill_loader.get_enabled():
|
|
1709
|
+
if skill.content not in (m.content for m in sub_engine.context.messages if m.role == "system"):
|
|
1710
|
+
sub_engine.context.add_system(skill.content)
|
|
1711
|
+
|
|
1712
|
+
sub_engine.set_agent(agent_type)
|
|
1713
|
+
|
|
1714
|
+
# Track child so parent cancellation cascades
|
|
1715
|
+
self._child_engines.append(sub_engine)
|
|
1716
|
+
|
|
1717
|
+
# Link the subagent's cancellation flag to the parent
|
|
1718
|
+
parts: list[str] = []
|
|
1719
|
+
try:
|
|
1720
|
+
async for chunk in sub_engine.chat_stream(prompt):
|
|
1721
|
+
if isinstance(chunk, StreamEvent):
|
|
1722
|
+
if chunk.type == StreamEventType.TEXT_DELTA and chunk.text:
|
|
1723
|
+
parts.append(chunk.text)
|
|
1724
|
+
yield chunk, chunk.text
|
|
1725
|
+
else:
|
|
1726
|
+
if chunk:
|
|
1727
|
+
parts.append(chunk)
|
|
1728
|
+
except Exception as e:
|
|
1729
|
+
logger.exception(f"Subagent error: {e}")
|
|
1730
|
+
err_text = (
|
|
1731
|
+
f"SUMMARY:\nSub-agent failed with error: {e}\n\n"
|
|
1732
|
+
"CHANGES:\nNone.\n\nEVIDENCE:\nNone.\n\nRISKS:\nNone.\n\n"
|
|
1733
|
+
f"BLOCKERS:\n{e}"
|
|
1734
|
+
)
|
|
1735
|
+
yield StreamEvent.subagent_end(""), err_text
|
|
1736
|
+
else:
|
|
1737
|
+
yield StreamEvent.subagent_end(""), "".join(parts)
|
|
1738
|
+
finally:
|
|
1739
|
+
# Clean up completed subagent to prevent memory leak
|
|
1740
|
+
if sub_engine in self._child_engines:
|
|
1741
|
+
self._child_engines.remove(sub_engine)
|
|
1742
|
+
|
|
1743
|
+
def _format_subagent_output(self, agent_type: str, prompt: str, result: str) -> str:
|
|
1744
|
+
"""Format sub-agent output in DeepSeek-TUI structured contract format.
|
|
1745
|
+
|
|
1746
|
+
Output contract:
|
|
1747
|
+
SUMMARY: one paragraph; what you did and what happened
|
|
1748
|
+
CHANGES: files modified, with one-line descriptions; "None." if read-only
|
|
1749
|
+
EVIDENCE: path:line-range citations and key findings; one bullet each
|
|
1750
|
+
RISKS: what could go wrong / what the parent should double-check
|
|
1751
|
+
BLOCKERS: what stopped you; "None." if you finished cleanly
|
|
1752
|
+
"""
|
|
1753
|
+
# Try to detect if the result already has structured sections
|
|
1754
|
+
result_lower = result.lower()
|
|
1755
|
+
has_sections = all(
|
|
1756
|
+
tag.lower() in result_lower
|
|
1757
|
+
for tag in ["summary", "changes", "evidence", "risks", "blockers"]
|
|
1758
|
+
)
|
|
1759
|
+
|
|
1760
|
+
if has_sections:
|
|
1761
|
+
# Already structured — just ensure proper formatting
|
|
1762
|
+
return result
|
|
1763
|
+
else:
|
|
1764
|
+
# Add structured wrapper around raw result
|
|
1765
|
+
return (
|
|
1766
|
+
f"SUMMARY:\n{agent_type.capitalize()} agent completed task: {prompt[:200]}\n\n"
|
|
1767
|
+
f"CHANGES:\nNone detected.\n\n"
|
|
1768
|
+
f"EVIDENCE:\n- See detailed output below\n\n"
|
|
1769
|
+
f"RISKS:\nNone identified.\n\n"
|
|
1770
|
+
f"BLOCKERS:\nNone.\n\n"
|
|
1771
|
+
f"--- Raw Output ---\n{result}"
|
|
1772
|
+
)
|
|
1773
|
+
|
|
1774
|
+
def spawn_subagent(self, agent_type: str, prompt: str) -> dict:
|
|
1775
|
+
if self.current_agent.permission_task == AgentPermission.DENY:
|
|
1776
|
+
return {"success": False, "error": "Subagent denied"}
|
|
1777
|
+
import asyncio
|
|
1778
|
+
import concurrent.futures
|
|
1779
|
+
try:
|
|
1780
|
+
loop = asyncio.get_event_loop()
|
|
1781
|
+
if loop.is_running():
|
|
1782
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
1783
|
+
self._spawn_subagent_async(agent_type, prompt), loop
|
|
1784
|
+
)
|
|
1785
|
+
result = future.result(timeout=300)
|
|
1786
|
+
else:
|
|
1787
|
+
result = asyncio.run(self._spawn_subagent_async(agent_type, prompt))
|
|
1788
|
+
except Exception as e:
|
|
1789
|
+
logger.exception(f"Subagent error: {e}")
|
|
1790
|
+
return {"success": False, "error": str(e)}
|
|
1791
|
+
return {
|
|
1792
|
+
"success": True,
|
|
1793
|
+
"agent_type": agent_type,
|
|
1794
|
+
"response": result,
|
|
1795
|
+
"iterations": 1,
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
def tool_task(self, agent_type: str, prompt: str) -> str:
|
|
1799
|
+
result = self.spawn_subagent(agent_type, prompt)
|
|
1800
|
+
if result["success"]:
|
|
1801
|
+
return result["response"]
|
|
1802
|
+
return f"Error: {result.get('error', 'Unknown error')}"
|
|
1803
|
+
|
|
1804
|
+
def attach_session(self, session: AgentSession) -> None:
|
|
1805
|
+
self._session = session
|
|
1806
|
+
if session.messages:
|
|
1807
|
+
self.context.load_from_session(session.messages)
|
|
1808
|
+
if session.tasks or session.todos:
|
|
1809
|
+
self._task_manager = TaskManager.from_dict({
|
|
1810
|
+
"tasks": session.tasks,
|
|
1811
|
+
"todos": session.todos,
|
|
1812
|
+
"plan": [],
|
|
1813
|
+
"id_counter": len(session.tasks) + len(session.todos),
|
|
1814
|
+
}, on_change=self._on_task_change)
|
|
1815
|
+
# Restore context usage stats
|
|
1816
|
+
self._restore_session_stats(session)
|
|
1817
|
+
|
|
1818
|
+
def save_session(self) -> None:
|
|
1819
|
+
if self._session:
|
|
1820
|
+
try:
|
|
1821
|
+
self._session.messages = self.context.to_session_format()
|
|
1822
|
+
tm_data = self._task_manager.to_dict()
|
|
1823
|
+
self._session.tasks = tm_data.get("tasks", [])
|
|
1824
|
+
self._session.todos = tm_data.get("todos", [])
|
|
1825
|
+
# Persist context usage stats so they survive session reload
|
|
1826
|
+
self._session.update_state("stats", {
|
|
1827
|
+
"total_tokens": self.total_tokens,
|
|
1828
|
+
"iterations": self.iterations,
|
|
1829
|
+
"turn_usages": [
|
|
1830
|
+
{k: v for k, v in u.items() if isinstance(v, (int, float))}
|
|
1831
|
+
for u in self._turn_usages
|
|
1832
|
+
],
|
|
1833
|
+
})
|
|
1834
|
+
self._session.save()
|
|
1835
|
+
except Exception as e:
|
|
1836
|
+
logger.exception("Failed to save session: %s", e)
|
|
1837
|
+
|
|
1838
|
+
def load_session(self, session_id: str) -> bool:
|
|
1839
|
+
session = AgentSession(session_id)
|
|
1840
|
+
if session.load():
|
|
1841
|
+
self._session = session
|
|
1842
|
+
self.context.load_from_session(session.messages)
|
|
1843
|
+
if session.tasks or session.todos:
|
|
1844
|
+
self._task_manager = TaskManager.from_dict({
|
|
1845
|
+
"tasks": session.tasks,
|
|
1846
|
+
"todos": session.todos,
|
|
1847
|
+
"plan": [],
|
|
1848
|
+
"id_counter": len(session.tasks) + len(session.todos),
|
|
1849
|
+
}, on_change=self._on_task_change)
|
|
1850
|
+
# Restore context usage stats
|
|
1851
|
+
self._restore_session_stats(session)
|
|
1852
|
+
return True
|
|
1853
|
+
return False
|
|
1854
|
+
|
|
1855
|
+
def _restore_session_stats(self, session: AgentSession) -> None:
|
|
1856
|
+
"""Restore context usage stats from session lifecycle_state."""
|
|
1857
|
+
stats = session.get_state("stats", {})
|
|
1858
|
+
if not stats:
|
|
1859
|
+
return
|
|
1860
|
+
self.total_tokens = int(stats.get("total_tokens", self.total_tokens))
|
|
1861
|
+
self.iterations = int(stats.get("iterations", self.iterations))
|
|
1862
|
+
restored_turns = stats.get("turn_usages", [])
|
|
1863
|
+
if restored_turns and isinstance(restored_turns, list):
|
|
1864
|
+
self._turn_usages = list(restored_turns)
|
|
1865
|
+
|
|
1866
|
+
def save_tasks_to_project(self) -> None:
|
|
1867
|
+
"""Save tasks/todos to .cdh/tasks.json in the project directory."""
|
|
1868
|
+
from onecode.agent.cdh_loader import CdhProjectLoader
|
|
1869
|
+
cdh_dir = CdhProjectLoader.find_cdh_dir(self._project_dir)
|
|
1870
|
+
if cdh_dir is None:
|
|
1871
|
+
return
|
|
1872
|
+
try:
|
|
1873
|
+
tm_data = self._task_manager.to_dict()
|
|
1874
|
+
CdhProjectLoader.save_tasks(cdh_dir, tm_data)
|
|
1875
|
+
except Exception as e:
|
|
1876
|
+
logger.warning("Failed to save tasks to .cdh: %s", e)
|
|
1877
|
+
|
|
1878
|
+
def load_tasks_from_project(self) -> None:
|
|
1879
|
+
"""Restore tasks/todos from .cdh/tasks.json."""
|
|
1880
|
+
from onecode.agent.cdh_loader import CdhProjectLoader
|
|
1881
|
+
cdh_dir = CdhProjectLoader.find_cdh_dir(self._project_dir)
|
|
1882
|
+
if cdh_dir is None:
|
|
1883
|
+
return
|
|
1884
|
+
try:
|
|
1885
|
+
data = CdhProjectLoader.load_tasks(cdh_dir)
|
|
1886
|
+
if data and (data.get("tasks") or data.get("todos")):
|
|
1887
|
+
self._task_manager = TaskManager.from_dict(
|
|
1888
|
+
data, on_change=self._on_task_change
|
|
1889
|
+
)
|
|
1890
|
+
except Exception as e:
|
|
1891
|
+
logger.warning("Failed to load tasks from .cdh: %s", e)
|
|
1892
|
+
|
|
1893
|
+
def get_session(self) -> Optional[AgentSession]:
|
|
1894
|
+
return self._session
|