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