ata-coder 2.4.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. ata_coder/__init__.py +1 -0
  2. ata_coder/agent.py +874 -0
  3. ata_coder/agent_compact.py +190 -0
  4. ata_coder/agent_controller.py +218 -0
  5. ata_coder/agent_extension.py +69 -0
  6. ata_coder/agent_routing.py +105 -0
  7. ata_coder/agent_subsystems.py +72 -0
  8. ata_coder/agent_tools.py +318 -0
  9. ata_coder/agent_undo.py +63 -0
  10. ata_coder/anthropic_client.py +465 -0
  11. ata_coder/change_tracker.py +368 -0
  12. ata_coder/clawd_integration.py +574 -0
  13. ata_coder/commands/__init__.py +128 -0
  14. ata_coder/commands/_core.py +184 -0
  15. ata_coder/commands/_safety.py +95 -0
  16. ata_coder/commands/_settings.py +241 -0
  17. ata_coder/commands/_workflow.py +451 -0
  18. ata_coder/commands.py +974 -0
  19. ata_coder/config.py +257 -0
  20. ata_coder/core/__init__.py +35 -0
  21. ata_coder/core/events.py +73 -0
  22. ata_coder/core/queue.py +85 -0
  23. ata_coder/core/state.py +17 -0
  24. ata_coder/event_queue.py +5 -0
  25. ata_coder/extension.py +654 -0
  26. ata_coder/extensions/__init__.py +1 -0
  27. ata_coder/extensions/hello_skill.py +47 -0
  28. ata_coder/fool_proof.py +295 -0
  29. ata_coder/git_workflow.py +371 -0
  30. ata_coder/gui.py +511 -0
  31. ata_coder/llm_client.py +543 -0
  32. ata_coder/main.py +814 -0
  33. ata_coder/mcp_client.py +1095 -0
  34. ata_coder/memory.py +539 -0
  35. ata_coder/model_registry.py +134 -0
  36. ata_coder/model_router.py +105 -0
  37. ata_coder/permissions.py +274 -0
  38. ata_coder/privilege.py +464 -0
  39. ata_coder/project.py +273 -0
  40. ata_coder/prompt_template.py +423 -0
  41. ata_coder/prompts/auto-mode.md +7 -0
  42. ata_coder/prompts/coding-rules.md +40 -0
  43. ata_coder/prompts/execution-guardrails.md +14 -0
  44. ata_coder/prompts/memory-system.md +24 -0
  45. ata_coder/prompts/output-style.md +23 -0
  46. ata_coder/prompts/safety.md +17 -0
  47. ata_coder/prompts/slash-commands.md +24 -0
  48. ata_coder/prompts/sub-agents.md +38 -0
  49. ata_coder/prompts/system-reminders.md +17 -0
  50. ata_coder/prompts/system.md +105 -0
  51. ata_coder/prompts/tool-policy.md +46 -0
  52. ata_coder/repl_theme.py +99 -0
  53. ata_coder/repl_tracker.py +89 -0
  54. ata_coder/repl_ui.py +1214 -0
  55. ata_coder/safety_guard.py +434 -0
  56. ata_coder/self_correct.py +346 -0
  57. ata_coder/server.py +882 -0
  58. ata_coder/server_session.py +159 -0
  59. ata_coder/server_shell.py +129 -0
  60. ata_coder/session.py +431 -0
  61. ata_coder/settings.py +439 -0
  62. ata_coder/setup_wizard.py +136 -0
  63. ata_coder/skill_extension.py +92 -0
  64. ata_coder/skills/architect/SKILL.md +42 -0
  65. ata_coder/skills/code-reviewer/SKILL.md +37 -0
  66. ata_coder/skills/codecraft/SKILL.md +452 -0
  67. ata_coder/skills/debugger/SKILL.md +45 -0
  68. ata_coder/skills/doc-writer/SKILL.md +36 -0
  69. ata_coder/skills/general-coder/SKILL.md +76 -0
  70. ata_coder/skills/math-calculator/README.md +40 -0
  71. ata_coder/skills/math-calculator/SKILL.md +59 -0
  72. ata_coder/skills/math-calculator/handler.py +103 -0
  73. ata_coder/skills/math-calculator/prompts/system.md +8 -0
  74. ata_coder/skills/math-calculator/requirements.txt +2 -0
  75. ata_coder/skills/math-calculator/resources/constants.json +8 -0
  76. ata_coder/skills/math-calculator/tests/test_handler.py +53 -0
  77. ata_coder/skills/security-auditor/SKILL.md +40 -0
  78. ata_coder/skills/test-writer/SKILL.md +36 -0
  79. ata_coder/skills/weather-skill/README.md +45 -0
  80. ata_coder/skills/weather-skill/handler.py +76 -0
  81. ata_coder/skills/weather-skill/manifest.json +48 -0
  82. ata_coder/skills/weather-skill/prompts/system_prompt.txt +9 -0
  83. ata_coder/skills/weather-skill/prompts/user_prompt_template.txt +3 -0
  84. ata_coder/skills/weather-skill/requirements.txt +1 -0
  85. ata_coder/skills/weather-skill/resources/city_list.json +17 -0
  86. ata_coder/skills/weather-skill/resources/error_messages.json +7 -0
  87. ata_coder/skills/weather-skill/tests/test_handler.py +28 -0
  88. ata_coder/skills/weather-skill/weather_utils.py +50 -0
  89. ata_coder/skills.py +1014 -0
  90. ata_coder/sub_agent.py +273 -0
  91. ata_coder/sub_agent_manager.py +203 -0
  92. ata_coder/system_prompt_builder.py +146 -0
  93. ata_coder/task_planner.py +391 -0
  94. ata_coder/terminal.py +318 -0
  95. ata_coder/test_runner.py +219 -0
  96. ata_coder/thread_supervisor.py +195 -0
  97. ata_coder/tool_defs.py +335 -0
  98. ata_coder/tools/__init__.py +11 -0
  99. ata_coder/tools/definitions.py +335 -0
  100. ata_coder/tools/executor.py +1036 -0
  101. ata_coder/tools/result.py +26 -0
  102. ata_coder/tools/subagent.py +332 -0
  103. ata_coder/tools/web.py +361 -0
  104. ata_coder/tools.py +1576 -0
  105. ata_coder/types.py +92 -0
  106. ata_coder/utils.py +113 -0
  107. ata_coder/web/css/style.css +180 -0
  108. ata_coder/web/index.html +84 -0
  109. ata_coder/web/js/app.js +489 -0
  110. ata_coder/web/package-lock.json +25 -0
  111. ata_coder/web/package.json +10 -0
  112. ata_coder/web/tsconfig.json +13 -0
  113. ata_coder-2.4.2.dist-info/METADATA +799 -0
  114. ata_coder-2.4.2.dist-info/RECORD +118 -0
  115. ata_coder-2.4.2.dist-info/WHEEL +5 -0
  116. ata_coder-2.4.2.dist-info/entry_points.txt +2 -0
  117. ata_coder-2.4.2.dist-info/licenses/LICENSE +21 -0
  118. ata_coder-2.4.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,190 @@
1
+ """Context compaction and token budget management — mixin for CoderAgent."""
2
+ import copy
3
+ import json
4
+ import logging
5
+
6
+ from .types import Message
7
+ from .clawd_integration import get_clawd
8
+ from .model_router import get_subagent_model
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class CompactionMixin:
14
+ """Context window compaction and token budget management."""
15
+
16
+ # ── Compaction token budget ──────────────────────────────────────────
17
+ RECENT_TOKEN_BUDGET = 80_000 # max tokens to keep from recent messages
18
+ COMPACT_IF_FEWER_THAN = 6 # skip compaction if fewer than this many msgs
19
+
20
+ def _force_truncate(self) -> None:
21
+ """Drop the oldest non-system messages when we exceed 95% of max tokens.
22
+
23
+ Called only as a last resort after compaction has already run.
24
+ Keeps the system prompt, the last 4 messages, and everything in between
25
+ gets replaced with a summary marker.
26
+ """
27
+ if len(self._state.messages) <= 6:
28
+ return
29
+ # Preserve system message if present; otherwise keep first message
30
+ first = self._state.messages[0]
31
+ is_system = isinstance(first, dict) and first.get("role") == "system"
32
+ keep_tail = self._state.messages[-4:] # last 2 exchanges
33
+ truncated = ([first] if is_system else []) + [
34
+ {"role": "user", "content": "[Conversation truncated — token limit reached]"},
35
+ {"role": "assistant", "content": "Understood. I'll continue with the most recent context."},
36
+ ] + keep_tail
37
+ old = len(self._state.messages)
38
+ self._state.messages = truncated
39
+ self._cached_system_prompt = None
40
+ logger.warning("Force-truncated: %d → %d messages", old, len(truncated))
41
+
42
+ # ── Compaction token budget ──────────────────────────────────────────
43
+
44
+ async def compact(self) -> str:
45
+ """
46
+ Compact conversation by summarising old messages.
47
+
48
+ Strategy: keep system prompt + recent messages up to
49
+ RECENT_TOKEN_BUDGET tokens, summarise everything in between using
50
+ a cheap LLM call. Falls back to a lightweight extractive summary
51
+ if the API call fails.
52
+
53
+ Uses token budget for recent messages (not a fixed count) so that
54
+ large file reads don't survive compaction intact.
55
+ """
56
+ if len(self._state.messages) <= self.COMPACT_IF_FEWER_THAN:
57
+ return "Already compact."
58
+
59
+ # Clawd: PreCompact
60
+ get_clawd().compact()
61
+
62
+ first = self._state.messages[0]
63
+ has_system = isinstance(first, dict) and first.get("role") == "system"
64
+ all_but_system = self._state.messages[1:] if has_system else self._state.messages[:]
65
+
66
+ # Walk backwards through recent messages, accumulating up to the budget
67
+ recent: list[Message] = []
68
+ recent_tokens = 0
69
+ for msg in reversed(all_but_system):
70
+ msg_tokens = self._estimate_message_tokens(msg)
71
+ if recent_tokens + msg_tokens > self.RECENT_TOKEN_BUDGET and recent:
72
+ # Stop — we've filled the recent budget
73
+ break
74
+ recent.insert(0, msg)
75
+ recent_tokens += msg_tokens
76
+
77
+ # The middle is everything NOT in recent and NOT the system msg
78
+ kept_count = len(recent)
79
+ middle = all_but_system[:-kept_count] if kept_count > 0 else all_but_system
80
+
81
+ if not middle:
82
+ return "Already compact (all messages fit in recent budget)."
83
+
84
+ # Extract key facts from middle messages for the fallback summary
85
+ tool_count = sum(1 for m in middle if m.get("tool_calls"))
86
+ user_msgs = [m.get("content", "")[:200] for m in middle if m.get("role") == "user"]
87
+ file_ops = self._collect_file_ops(middle)
88
+
89
+ summary = await self._summarise_messages(middle, file_ops, user_msgs, tool_count)
90
+
91
+ truncated: list[Message] = (
92
+ [first] if has_system else []
93
+ ) + [
94
+ {"role": "user", "content": "[Conversation summary]\\n" + summary},
95
+ {"role": "assistant", "content": "Understood. I'll continue with the remaining context using the summary above."},
96
+ ]
97
+ truncated.extend(recent)
98
+ old_count = len(self._state.messages)
99
+ old_tokens = self.get_token_estimate()
100
+ self._state.messages = truncated
101
+ self._cached_system_prompt = None # messages[0] changed — invalidate
102
+ new_tokens = self.get_token_estimate()
103
+
104
+ logger.info("Compacted: %d→%d msgs, ~%d→%d tokens (files: %d, tools: %d, recent_budget: %d)",
105
+ old_count, len(truncated), old_tokens, new_tokens,
106
+ len(file_ops), tool_count, recent_tokens)
107
+ return (f"Compacted from {old_count}→{len(truncated)} messages "
108
+ f"(~{old_tokens:,}→~{new_tokens:,} tokens, {len(file_ops)} files, {tool_count} tool calls).")
109
+
110
+ def _estimate_message_tokens(self, msg: Message) -> int:
111
+ """Rough token estimate for a single message."""
112
+ content = msg.get("content", "") or ""
113
+ # Use the LLM client's estimator if available
114
+ try:
115
+ return self.llm.count_tokens_approx([msg])
116
+ except Exception:
117
+ # CJK-aware fallback
118
+ import re
119
+ cjk = len(re.findall(r'[一-鿿 -〿＀-￯]', content))
120
+ other = len(content) - cjk
121
+ tokens = (cjk * 2 // 3) + (other // 4)
122
+ for tc in msg.get("tool_calls", []):
123
+ try:
124
+ tokens += len(json.dumps(tc)) // 4
125
+ except Exception:
126
+ pass
127
+ return max(1, tokens)
128
+
129
+ def _collect_file_ops(self, messages: list[Message]) -> list[str]:
130
+ """Collect files modified in a message list."""
131
+ ops: list[str] = []
132
+ for m in messages:
133
+ for tc in m.get("tool_calls", []):
134
+ fn = tc.get("function", {})
135
+ if fn.get("name") in ("write_file", "edit_file"):
136
+ try:
137
+ args = json.loads(fn.get("arguments", "{}"))
138
+ fp = args.get("file_path", "")
139
+ if fp:
140
+ ops.append(fp)
141
+ except json.JSONDecodeError:
142
+ pass
143
+ return ops
144
+
145
+ async def _summarise_messages(self, middle: list[Message], file_ops: list[str],
146
+ user_msgs: list[str], tool_count: int) -> str:
147
+ """Generate a summary of the middle conversation segment.
148
+
149
+ Attempts a cheap LLM call first; falls back to a lightweight extractive
150
+ summary so the user never loses context entirely.
151
+ """
152
+ # ── LLM-based summary (best effort) ──────────────────────────────
153
+ try:
154
+ summary_prompt = (
155
+ "Summarise this conversation segment in 3-5 bullet points. "
156
+ "Focus on: what the user asked, what files were changed, what "
157
+ "decisions were made, and any unresolved issues. "
158
+ "Be concise — this summary will replace the full conversation "
159
+ "history to save context tokens.\n\n"
160
+ f"Files modified: {', '.join(file_ops) if file_ops else 'none'}\n"
161
+ f"Tool calls: {tool_count}\n"
162
+ f"User requests: {'; '.join(user_msgs[:5])}\n"
163
+ )
164
+ # Use a separate cheap-model client for summarisation
165
+ from .llm_client import LLMClient
166
+ summary_config = copy.deepcopy(self.llm.config)
167
+ summary_config.model = get_subagent_model()
168
+ sc = LLMClient(summary_config)
169
+ try:
170
+ resp = await sc.chat([{"role": "user", "content": summary_prompt}], tools=[])
171
+ llm_summary = (resp.get("content") or "").strip()
172
+ if llm_summary:
173
+ # Include extractive data as context for the LLM summary
174
+ parts = [llm_summary]
175
+ if file_ops:
176
+ parts.append(f"\nFiles touched: {', '.join(file_ops[:10])}")
177
+ return "\n".join(parts)
178
+ finally:
179
+ await sc.close()
180
+ except Exception:
181
+ logger.debug("LLM summarisation unavailable, using extractive fallback")
182
+
183
+ # ── Extractive fallback ─────────────────────────────────────────
184
+ parts = [f"Summarised {len(middle)} messages ({tool_count} tool calls)."]
185
+ if user_msgs:
186
+ parts.append(f"Topics: {'; '.join(user_msgs[:5])}")
187
+ if file_ops:
188
+ parts.append(f"Files modified: {', '.join(file_ops[:10])}")
189
+ return "\n".join(parts)
190
+
@@ -0,0 +1,218 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Agent Controller — runs CoderAgent as an asyncio task.
4
+
5
+ With the async event loop, the agent runs as a coroutine on the same
6
+ thread as the UI. asyncio.Task provides built-in cancellation, and
7
+ asyncio.Event replaces threading.Event for coordination.
8
+
9
+ No more background threads, heartbeat pumpers, or thread supervisors.
10
+ """
11
+
12
+ import asyncio
13
+ import logging
14
+ from typing import Any, Optional
15
+
16
+ from .agent import CoderAgent, CompleteEvent, ErrorEvent
17
+ from .config import AppConfig
18
+ from .agent_subsystems import AgentSubsystems
19
+ from .core.queue import EventQueue
20
+ from .tools import ToolExecutor
21
+ from .sub_agent_manager import SubAgentManager
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ __all__ = ["AgentController"]
26
+
27
+
28
+ class AgentController:
29
+ """
30
+ Wraps CoderAgent for asyncio task execution.
31
+
32
+ Owns:
33
+ - The CoderAgent instance (runs as an asyncio.Task)
34
+ - Event queue (agent → UI communication)
35
+ - SubAgentManager (for parallel sub-agent execution)
36
+
37
+ Usage:
38
+ controller = AgentController(config, subsystems, tool_exec)
39
+ await controller.start()
40
+ await controller.submit("write a hello world script")
41
+ async for event in controller.event_queue:
42
+ ui.on_event(event)
43
+ await controller.shutdown()
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ config: AppConfig | None = None,
49
+ subsystems: AgentSubsystems | None = None,
50
+ tool_executor: ToolExecutor | None = None,
51
+ ):
52
+ self._config = config or AppConfig.load()
53
+ self._subsystems = subsystems or AgentSubsystems()
54
+ self._tool_exec = tool_executor or ToolExecutor(self._config.agent)
55
+
56
+ # Async event queue (agent → UI)
57
+ self.event_queue = EventQueue(maxsize=5000)
58
+
59
+ # Async coordination primitives
60
+ self._cancel = asyncio.Event()
61
+ self._busy = asyncio.Event()
62
+
63
+ # Sub-agent manager (created on start)
64
+ self._sub_agent_mgr = None
65
+
66
+ # Agent and its task
67
+ self._agent: Optional[CoderAgent] = None
68
+ self._agent_task: Optional[asyncio.Task] = None
69
+
70
+ # ── Lifecycle ──────────────────────────────────────────────────────────
71
+
72
+ async def start(self) -> None:
73
+ """Create the agent and prepare for task submission."""
74
+ if self._agent_task and not self._agent_task.done():
75
+ return
76
+
77
+ # Create SubAgentManager
78
+ max_sub = getattr(self._config.agent, "max_sub_agents", 5)
79
+ sub_timeout = getattr(self._config.agent, "sub_agent_timeout", 300.0)
80
+ self._sub_agent_mgr = SubAgentManager(
81
+ self._config,
82
+ max_concurrent=max_sub,
83
+ default_timeout=sub_timeout,
84
+ )
85
+
86
+ # Create agent
87
+ self._agent = CoderAgent(
88
+ config=self._config,
89
+ tool_executor=self._tool_exec,
90
+ subsystems=self._subsystems,
91
+ )
92
+ # Wire event queue and sub-agent manager
93
+ self._agent._event_queue = self.event_queue
94
+ self._agent.set_sub_agent_manager(self._sub_agent_mgr)
95
+ self._tool_exec.set_sub_agent_manager(self._sub_agent_mgr)
96
+
97
+ self._cancel.clear()
98
+ self._busy.clear()
99
+
100
+ logger.info("AgentController started (async)")
101
+
102
+ async def shutdown(self) -> None:
103
+ """Stop the agent and cleanup."""
104
+ self._cancel.set()
105
+ # Cancel agent task
106
+ if self._agent_task and not self._agent_task.done():
107
+ self._agent_task.cancel()
108
+ try:
109
+ await self._agent_task
110
+ except asyncio.CancelledError:
111
+ pass
112
+ except Exception:
113
+ logger.exception("Agent task shutdown error")
114
+ self._agent_task = None
115
+
116
+ # Cancel all sub-agents
117
+ if self._sub_agent_mgr:
118
+ await self._sub_agent_mgr.shutdown()
119
+ self._sub_agent_mgr = None
120
+
121
+ if self._agent:
122
+ try:
123
+ await self._agent.shutdown()
124
+ except Exception:
125
+ logger.exception("Agent shutdown error")
126
+ self._agent = None
127
+
128
+ logger.info("AgentController shut down")
129
+
130
+ # ── Task submission ────────────────────────────────────────────────────
131
+
132
+ async def submit(
133
+ self,
134
+ task: str,
135
+ skill_name: str | None = None,
136
+ explicit_model: str = "",
137
+ stream: bool = True,
138
+ ) -> None:
139
+ """
140
+ Submit a task for the agent to process.
141
+
142
+ Creates an asyncio.Task that runs the agent coroutine.
143
+ If the agent is not started, starts it automatically.
144
+ """
145
+ if not self._agent:
146
+ await self.start()
147
+
148
+ self._busy.set()
149
+ self._cancel.clear()
150
+
151
+ async def _agent_runner():
152
+ """Run the agent task with proper error handling."""
153
+ try:
154
+ logger.info("Agent starting task: %.80s", task)
155
+ result = await self._agent.run(
156
+ task, stream=stream,
157
+ skill_name=skill_name,
158
+ explicit_model=explicit_model,
159
+ )
160
+ logger.info("Agent completed task (len=%d)", len(result))
161
+ except asyncio.CancelledError:
162
+ logger.info("Agent task cancelled")
163
+ raise
164
+ except Exception as e:
165
+ logger.exception("Agent task failed")
166
+ await self.event_queue.put(
167
+ ErrorEvent(f"Agent error: {e}")
168
+ )
169
+ await self.event_queue.put(
170
+ CompleteEvent(
171
+ total_tool_calls=(
172
+ self._agent._state.tool_call_count
173
+ if self._agent else 0
174
+ ),
175
+ total_time=0,
176
+ )
177
+ )
178
+ finally:
179
+ self._busy.clear()
180
+
181
+ self._agent_task = asyncio.create_task(_agent_runner())
182
+
183
+ async def cancel(self) -> None:
184
+ """Request cancellation of the current agent run."""
185
+ self._cancel.set()
186
+ if self._agent_task and not self._agent_task.done():
187
+ self._agent_task.cancel()
188
+ logger.info("Agent cancel requested")
189
+
190
+ def is_busy(self) -> bool:
191
+ """Check if the agent is currently processing a task."""
192
+ return self._busy.is_set()
193
+
194
+ def is_running(self) -> bool:
195
+ """Check if the agent task is active."""
196
+ return self._agent_task is not None and not self._agent_task.done()
197
+
198
+ # ── Sub-agent management ───────────────────────────────────────────────
199
+
200
+ def set_sub_agent_manager(self, mgr: Any) -> None:
201
+ """Set the SubAgentManager reference."""
202
+ self._sub_agent_mgr = mgr
203
+ if self._agent:
204
+ self._agent.set_sub_agent_manager(mgr)
205
+
206
+ # ── Health ─────────────────────────────────────────────────────────────
207
+
208
+ @property
209
+ def agent(self) -> Optional[CoderAgent]:
210
+ return self._agent
211
+
212
+ def health_status(self) -> dict[str, Any]:
213
+ """Return health status (simplified — no threads to monitor)."""
214
+ return {
215
+ "agent": "running" if self.is_running() else "idle",
216
+ "busy": self._busy.is_set(),
217
+ "sub_agents": len(self._sub_agent_mgr.list_active()) if self._sub_agent_mgr else 0,
218
+ }
@@ -0,0 +1,69 @@
1
+ """Extension management (skills, discovery, hook points) — mixin for CoderAgent."""
2
+ import logging
3
+ from typing import Any
4
+
5
+ from .skill_extension import SkillExtension
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class ExtensionMixin:
11
+ """Extension lifecycle: register skills, discover extensions, register hook points."""
12
+
13
+ # ── Extension management ──────────────────────────────────────────────
14
+
15
+ def _register_skills_as_extensions(self) -> None:
16
+ """Register all loaded SkillManager skills as SkillExtension adapters."""
17
+ if not self.subsys.has_skills:
18
+ return
19
+ for skill in self.subsys.skills.list_skills():
20
+ ext = SkillExtension(skill)
21
+ if self.ext_mgr.register(ext):
22
+ logger.debug("Registered skill extension: skill:%s", skill.name)
23
+ else:
24
+ logger.debug("Skill extension already registered: skill:%s", skill.name)
25
+
26
+ def _discover_extensions(self) -> None:
27
+ """Discover extensions from configured extension directories."""
28
+ ext_dirs = getattr(self.config.agent, "extension_dirs", [])
29
+ if not ext_dirs:
30
+ return
31
+ for d in ext_dirs:
32
+ loaded = self.ext_mgr.discover(d)
33
+ if loaded:
34
+ logger.debug("Discovered %d extensions in %s", len(loaded), d)
35
+
36
+ def _register_extension_points(self) -> None:
37
+ """Register hook points extensions can tap into."""
38
+ self._ep_on_run_start = self.ext_mgr.extension_point(
39
+ "on_agent_run_start",
40
+ "Called when agent.run() starts — (task, skill_name)"
41
+ )
42
+ self._ep_on_run_complete = self.ext_mgr.extension_point(
43
+ "on_agent_run_complete",
44
+ "Called when agent.run() completes — (task, result, tool_call_count)"
45
+ )
46
+ self._ep_on_tool_execute = self.ext_mgr.extension_point(
47
+ "on_tool_execute",
48
+ "Called before each tool execution — (tool_name, arguments)"
49
+ )
50
+ self._ep_on_tool_result = self.ext_mgr.extension_point(
51
+ "on_tool_result",
52
+ "Called after each tool result — (tool_name, result)"
53
+ )
54
+ self._ep_on_system_prompt = self.ext_mgr.extension_point(
55
+ "on_system_prompt_build",
56
+ "Called during system prompt construction — (prompt, task)"
57
+ )
58
+ self._ep_on_model_route = self.ext_mgr.extension_point(
59
+ "on_model_route",
60
+ "Called after model routing — (task, complexity, model)"
61
+ )
62
+ logger.debug(
63
+ "Registered %d extension points",
64
+ len(self.ext_mgr.list_extension_points()),
65
+ )
66
+
67
+ def set_sub_agent_manager(self, mgr: Any) -> None:
68
+ """Set the SubAgentManager for spawn_subagent tool support."""
69
+ self._sub_agent_mgr = mgr
@@ -0,0 +1,105 @@
1
+ """Model routing and task complexity classification — mixin for CoderAgent."""
2
+ import logging
3
+
4
+ from .settings import get_settings
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ class ModelRoutingMixin:
10
+ """Model routing based on task complexity heuristics and effort level."""
11
+
12
+ # ── Model routing ────────────────────────────────────────────────────
13
+
14
+ def _route_for_task(self, task: str, explicit_model: str = "") -> None:
15
+ """Route to the appropriate model based on task complexity + effort level."""
16
+ if explicit_model:
17
+ self._route_model(explicit_model)
18
+ self._routed_complexity = "explicit"
19
+ else:
20
+ settings = get_settings()
21
+ complexity = self._ai_classify(task)
22
+
23
+ if complexity == "simple":
24
+ routed_model = settings.model_haiku
25
+ elif complexity == "complex":
26
+ routed_model = settings.model_opus
27
+ elif complexity == "normal":
28
+ routed_model = settings.default_model
29
+ else:
30
+ logger.warning("Unknown complexity %r", complexity)
31
+ routed_model = settings.default_model
32
+
33
+ # Adjust by effort
34
+ effort = getattr(self.config, "effort", "medium")
35
+ if effort == "low":
36
+ routed_model = settings.model_haiku
37
+ elif effort in ("xhigh", "max"):
38
+ routed_model = settings.model_opus
39
+
40
+ self._route_model(routed_model)
41
+ self._routed_complexity = complexity
42
+
43
+ logger.info("Model: %s (complexity=%s)", self.current_model, self._routed_complexity)
44
+
45
+ def _route_model(self, model: str) -> None:
46
+ """Switch the LLM client to use a different model at runtime."""
47
+ if self.llm.config.model == model:
48
+ return
49
+ logger.info("Switching model: %s → %s", self.llm.config.model, model)
50
+ self.llm.config.model = model
51
+
52
+ @property
53
+ def current_model(self) -> str:
54
+ return self.llm.config.model
55
+
56
+ @staticmethod
57
+ def _ai_classify(task: str) -> str:
58
+ """
59
+ Classify task complexity using fast heuristics (NO extra LLM call).
60
+
61
+ Returns 'simple', 'complex', or 'normal'.
62
+
63
+ Heuristics:
64
+ - Very short (< 60 chars) → simple
65
+ - Very long (> 500 chars) → complex
66
+ - Contains complexity keywords → complex
67
+ - Contains simple-query keywords → simple
68
+ - Default → normal
69
+ """
70
+ task_lower = task.lower().strip()
71
+ task_len = len(task)
72
+
73
+ # ── Length-based shortcut (configurable thresholds) ──────────────
74
+ try:
75
+ s = get_settings()
76
+ simple_max = s.get("complexity", "simple_max_chars", default=60)
77
+ complex_min = s.get("complexity", "complex_min_chars", default=500)
78
+ except Exception:
79
+ simple_max, complex_min = 60, 500 # fallback defaults
80
+
81
+ if task_len <= simple_max:
82
+ return "simple"
83
+ if task_len >= complex_min:
84
+ return "complex"
85
+
86
+ # ── Complexity keywords ───────────────────────────────────────
87
+ complex_keywords = [
88
+ "implement", "refactor", "architecture", "migrate", "redesign",
89
+ "optimize", "debug", "fix bug", "restructure", "multi-file",
90
+ "across multiple", "entire codebase", "from scratch", "set up",
91
+ "configure", "deploy", "pipeline", "database schema", "api endpoint",
92
+ ]
93
+ if any(kw in task_lower for kw in complex_keywords):
94
+ return "complex"
95
+
96
+ # ── Simple-query keywords ─────────────────────────────────────
97
+ simple_keywords = [
98
+ "what is", "how do i", "explain", "show me", "find", "search",
99
+ "list", "tell me about", "describe", "definition of", "example of",
100
+ "difference between", "why does", "where is",
101
+ ]
102
+ if any(kw in task_lower for kw in simple_keywords):
103
+ return "simple"
104
+
105
+ return "normal"
@@ -0,0 +1,72 @@
1
+ """
2
+ Agent subsystems container — replaces the loose collection of Optional[X]
3
+ parameters in CoderAgent.__init__ with a single structured dataclass.
4
+
5
+ Each subsystem is independently initialisable; CoderAgent only needs the
6
+ ones that are actually provided.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import TYPE_CHECKING
13
+
14
+ if TYPE_CHECKING:
15
+ from .extension import ExtensionManager
16
+ from .skills import SkillManager
17
+ from .memory import MemoryStore
18
+ from .mcp_client import MCPClient
19
+ from .prompt_template import TemplateManager
20
+ from .permissions import PermissionStore
21
+ from .project import ProjectInfo
22
+ from .session import SessionManager
23
+
24
+
25
+ @dataclass
26
+ class AgentSubsystems:
27
+ """Holds all optional subsystems wired into the agent at construction time.
28
+
29
+ Any field left as None means that feature is disabled — the agent skips
30
+ the corresponding behaviours (no skill detection, no memory recall, etc.).
31
+ """
32
+
33
+ skills: SkillManager | None = None
34
+ memory: MemoryStore | None = None
35
+ mcp: MCPClient | None = None
36
+ templates: TemplateManager | None = None
37
+ permissions: PermissionStore | None = None
38
+ project_info: ProjectInfo | None = None
39
+ sessions: SessionManager | None = None
40
+ extensions: ExtensionManager | None = None
41
+
42
+ @property
43
+ def has_skills(self) -> bool:
44
+ return self.skills is not None
45
+
46
+ @property
47
+ def has_memory(self) -> bool:
48
+ return self.memory is not None
49
+
50
+ @property
51
+ def has_mcp(self) -> bool:
52
+ return self.mcp is not None
53
+
54
+ @property
55
+ def has_templates(self) -> bool:
56
+ return self.templates is not None
57
+
58
+ @property
59
+ def has_permissions(self) -> bool:
60
+ return self.permissions is not None
61
+
62
+ @property
63
+ def has_project_info(self) -> bool:
64
+ return self.project_info is not None
65
+
66
+ @property
67
+ def has_sessions(self) -> bool:
68
+ return self.sessions is not None
69
+
70
+ @property
71
+ def has_extensions(self) -> bool:
72
+ return self.extensions is not None