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.
- ata_coder/__init__.py +1 -0
- ata_coder/agent.py +874 -0
- ata_coder/agent_compact.py +190 -0
- ata_coder/agent_controller.py +218 -0
- ata_coder/agent_extension.py +69 -0
- ata_coder/agent_routing.py +105 -0
- ata_coder/agent_subsystems.py +72 -0
- ata_coder/agent_tools.py +318 -0
- ata_coder/agent_undo.py +63 -0
- ata_coder/anthropic_client.py +465 -0
- ata_coder/change_tracker.py +368 -0
- ata_coder/clawd_integration.py +574 -0
- ata_coder/commands/__init__.py +128 -0
- ata_coder/commands/_core.py +184 -0
- ata_coder/commands/_safety.py +95 -0
- ata_coder/commands/_settings.py +241 -0
- ata_coder/commands/_workflow.py +451 -0
- ata_coder/commands.py +974 -0
- ata_coder/config.py +257 -0
- ata_coder/core/__init__.py +35 -0
- ata_coder/core/events.py +73 -0
- ata_coder/core/queue.py +85 -0
- ata_coder/core/state.py +17 -0
- ata_coder/event_queue.py +5 -0
- ata_coder/extension.py +654 -0
- ata_coder/extensions/__init__.py +1 -0
- ata_coder/extensions/hello_skill.py +47 -0
- ata_coder/fool_proof.py +295 -0
- ata_coder/git_workflow.py +371 -0
- ata_coder/gui.py +511 -0
- ata_coder/llm_client.py +543 -0
- ata_coder/main.py +814 -0
- ata_coder/mcp_client.py +1095 -0
- ata_coder/memory.py +539 -0
- ata_coder/model_registry.py +134 -0
- ata_coder/model_router.py +105 -0
- ata_coder/permissions.py +274 -0
- ata_coder/privilege.py +464 -0
- ata_coder/project.py +273 -0
- ata_coder/prompt_template.py +423 -0
- ata_coder/prompts/auto-mode.md +7 -0
- ata_coder/prompts/coding-rules.md +40 -0
- ata_coder/prompts/execution-guardrails.md +14 -0
- ata_coder/prompts/memory-system.md +24 -0
- ata_coder/prompts/output-style.md +23 -0
- ata_coder/prompts/safety.md +17 -0
- ata_coder/prompts/slash-commands.md +24 -0
- ata_coder/prompts/sub-agents.md +38 -0
- ata_coder/prompts/system-reminders.md +17 -0
- ata_coder/prompts/system.md +105 -0
- ata_coder/prompts/tool-policy.md +46 -0
- ata_coder/repl_theme.py +99 -0
- ata_coder/repl_tracker.py +89 -0
- ata_coder/repl_ui.py +1214 -0
- ata_coder/safety_guard.py +434 -0
- ata_coder/self_correct.py +346 -0
- ata_coder/server.py +882 -0
- ata_coder/server_session.py +159 -0
- ata_coder/server_shell.py +129 -0
- ata_coder/session.py +431 -0
- ata_coder/settings.py +439 -0
- ata_coder/setup_wizard.py +136 -0
- ata_coder/skill_extension.py +92 -0
- ata_coder/skills/architect/SKILL.md +42 -0
- ata_coder/skills/code-reviewer/SKILL.md +37 -0
- ata_coder/skills/codecraft/SKILL.md +452 -0
- ata_coder/skills/debugger/SKILL.md +45 -0
- ata_coder/skills/doc-writer/SKILL.md +36 -0
- ata_coder/skills/general-coder/SKILL.md +76 -0
- ata_coder/skills/math-calculator/README.md +40 -0
- ata_coder/skills/math-calculator/SKILL.md +59 -0
- ata_coder/skills/math-calculator/handler.py +103 -0
- ata_coder/skills/math-calculator/prompts/system.md +8 -0
- ata_coder/skills/math-calculator/requirements.txt +2 -0
- ata_coder/skills/math-calculator/resources/constants.json +8 -0
- ata_coder/skills/math-calculator/tests/test_handler.py +53 -0
- ata_coder/skills/security-auditor/SKILL.md +40 -0
- ata_coder/skills/test-writer/SKILL.md +36 -0
- ata_coder/skills/weather-skill/README.md +45 -0
- ata_coder/skills/weather-skill/handler.py +76 -0
- ata_coder/skills/weather-skill/manifest.json +48 -0
- ata_coder/skills/weather-skill/prompts/system_prompt.txt +9 -0
- ata_coder/skills/weather-skill/prompts/user_prompt_template.txt +3 -0
- ata_coder/skills/weather-skill/requirements.txt +1 -0
- ata_coder/skills/weather-skill/resources/city_list.json +17 -0
- ata_coder/skills/weather-skill/resources/error_messages.json +7 -0
- ata_coder/skills/weather-skill/tests/test_handler.py +28 -0
- ata_coder/skills/weather-skill/weather_utils.py +50 -0
- ata_coder/skills.py +1014 -0
- ata_coder/sub_agent.py +273 -0
- ata_coder/sub_agent_manager.py +203 -0
- ata_coder/system_prompt_builder.py +146 -0
- ata_coder/task_planner.py +391 -0
- ata_coder/terminal.py +318 -0
- ata_coder/test_runner.py +219 -0
- ata_coder/thread_supervisor.py +195 -0
- ata_coder/tool_defs.py +335 -0
- ata_coder/tools/__init__.py +11 -0
- ata_coder/tools/definitions.py +335 -0
- ata_coder/tools/executor.py +1036 -0
- ata_coder/tools/result.py +26 -0
- ata_coder/tools/subagent.py +332 -0
- ata_coder/tools/web.py +361 -0
- ata_coder/tools.py +1576 -0
- ata_coder/types.py +92 -0
- ata_coder/utils.py +113 -0
- ata_coder/web/css/style.css +180 -0
- ata_coder/web/index.html +84 -0
- ata_coder/web/js/app.js +489 -0
- ata_coder/web/package-lock.json +25 -0
- ata_coder/web/package.json +10 -0
- ata_coder/web/tsconfig.json +13 -0
- ata_coder-2.4.2.dist-info/METADATA +799 -0
- ata_coder-2.4.2.dist-info/RECORD +118 -0
- ata_coder-2.4.2.dist-info/WHEEL +5 -0
- ata_coder-2.4.2.dist-info/entry_points.txt +2 -0
- ata_coder-2.4.2.dist-info/licenses/LICENSE +21 -0
- 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
|