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
ata_coder/sub_agent.py ADDED
@@ -0,0 +1,273 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Sub-Agent — independent agent with isolated context window.
4
+
5
+ Each SubAgent runs as an asyncio.Task and has:
6
+ - Independent LLM client (separate httpx session)
7
+ - Independent message history (no context leakage)
8
+ - Independent tool executor (own file cache)
9
+ - Cancel support via asyncio.Task.cancel()
10
+ """
11
+
12
+ import asyncio
13
+ import json
14
+ import logging
15
+ import os
16
+ import uuid
17
+ from dataclasses import dataclass, field
18
+ from typing import Callable, Optional
19
+
20
+ from .config import AppConfig, LLMConfig
21
+ from .llm_client import LLMClient
22
+ from .tools import ToolExecutor, TOOL_DEFINITIONS, ToolResult
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ __all__ = ["SubAgent", "SubAgentResult"]
27
+
28
+
29
+ @dataclass
30
+ class SubAgentResult:
31
+ """Result returned when a sub-agent completes."""
32
+ agent_id: str
33
+ result: Optional[str] = None
34
+ error: Optional[str] = None
35
+ messages: list[dict] = field(default_factory=list)
36
+ success: bool = True
37
+ tool_call_count: int = 0
38
+
39
+
40
+ class SubAgent:
41
+ """
42
+ Independent sub-agent with isolated context window.
43
+
44
+ Usage:
45
+ sub = SubAgent(config=config, skill_prompt="You are a debugger...")
46
+ await sub.run("Find the bug in this code: ...")
47
+ result = await sub.wait(timeout=300)
48
+ print(result.result)
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ config: AppConfig,
54
+ skill_prompt: str = "",
55
+ model: Optional[str] = None,
56
+ tools: Optional[list[dict]] = None,
57
+ event_callback: Optional[Callable[[str, str, ToolResult], None]] = None,
58
+ agent_id: Optional[str] = None,
59
+ ):
60
+ self.id = agent_id or f"sub_{uuid.uuid4().hex[:8]}"
61
+
62
+ # Independent LLM client — support both OpenAI and Anthropic formats
63
+ llm_config = LLMConfig(
64
+ api_key=config.llm.api_key,
65
+ base_url=config.llm.base_url,
66
+ model=model or config.llm.model,
67
+ temperature=config.llm.temperature,
68
+ max_tokens=config.llm.max_tokens,
69
+ thinking_strength=config.llm.thinking_strength,
70
+ use_anthropic=config.llm.use_anthropic,
71
+ )
72
+ if llm_config.use_anthropic:
73
+ from .anthropic_client import AnthropicClient
74
+ self._llm = AnthropicClient(llm_config)
75
+ self._use_anthropic = True
76
+ else:
77
+ self._llm = LLMClient(llm_config)
78
+ self._use_anthropic = False
79
+
80
+ # Independent tool executor
81
+ self._tools = ToolExecutor(config.agent)
82
+ self._tool_defs = tools or list(TOOL_DEFINITIONS)
83
+ self._llm.register_tools(self._tool_defs)
84
+
85
+ self._messages: list[dict] = []
86
+ self._result: Optional[str] = None
87
+ self._error: Optional[str] = None
88
+ self._tool_call_count = 0
89
+ self._status = "idle" # idle | running | done | failed | cancelled
90
+ self._event_callback = event_callback
91
+ self._skill_prompt = skill_prompt
92
+ self._task: Optional[asyncio.Task] = None
93
+ self._done = asyncio.Event()
94
+
95
+ # ── Public API ───────────────────────────────────────────────────────
96
+
97
+ async def run(self, task: str) -> None:
98
+ """Start sub-agent execution as an asyncio.Task."""
99
+ if self._status == "running":
100
+ raise RuntimeError(f"Sub-agent {self.id} is already running")
101
+ self._done.clear()
102
+ self._status = "running"
103
+ self._task = asyncio.create_task(self._run(task))
104
+
105
+ async def wait(self, timeout: Optional[float] = 300.0) -> SubAgentResult:
106
+ """Wait until the sub-agent completes or times out."""
107
+ if self._status == "idle":
108
+ return SubAgentResult(agent_id=self.id, result=None,
109
+ error="Sub-agent was never started", success=False)
110
+ if not self._task:
111
+ return SubAgentResult(agent_id=self.id, result=self._result,
112
+ error=self._error, success=(self._status == "done"),
113
+ messages=list(self._messages),
114
+ tool_call_count=self._tool_call_count)
115
+ try:
116
+ await asyncio.wait_for(self._done.wait(), timeout=timeout)
117
+ except asyncio.TimeoutError:
118
+ await self.cancel()
119
+ return SubAgentResult(
120
+ agent_id=self.id,
121
+ result=self._result,
122
+ error=f"Timeout after {timeout}s",
123
+ success=False,
124
+ tool_call_count=self._tool_call_count,
125
+ )
126
+ return SubAgentResult(
127
+ agent_id=self.id,
128
+ result=self._result,
129
+ error=self._error,
130
+ messages=list(self._messages),
131
+ success=(self._status == "done"),
132
+ tool_call_count=self._tool_call_count,
133
+ )
134
+
135
+ async def cancel(self) -> None:
136
+ """Cancel the sub-agent execution."""
137
+ if self._task and not self._task.done():
138
+ self._task.cancel()
139
+ try:
140
+ await self._task
141
+ except asyncio.CancelledError:
142
+ pass
143
+ if self._status in ("running", "idle"):
144
+ self._status = "cancelled"
145
+ self._done.set()
146
+
147
+ def is_running(self) -> bool:
148
+ return self._status == "running"
149
+
150
+ def is_done(self) -> bool:
151
+ return self._status in ("done", "failed", "cancelled")
152
+
153
+ @property
154
+ def status(self) -> str:
155
+ return self._status
156
+
157
+ @property
158
+ def result(self) -> Optional[str]:
159
+ return self._result
160
+
161
+ @property
162
+ def messages(self) -> list[dict]:
163
+ return list(self._messages)
164
+
165
+ @property
166
+ def tool_call_count(self) -> int:
167
+ return self._tool_call_count
168
+
169
+ # ── Internal ─────────────────────────────────────────────────────────
170
+
171
+ async def _run(self, task: str) -> None:
172
+ """Internal async target."""
173
+ try:
174
+ self._messages = [
175
+ {"role": "system", "content": self._build_prompt()},
176
+ {"role": "user", "content": task},
177
+ ]
178
+ self._result = await self._loop()
179
+ if self._status == "running":
180
+ self._status = "done"
181
+ except asyncio.CancelledError:
182
+ self._status = "cancelled"
183
+ raise
184
+ except Exception as e:
185
+ logger.exception("Sub-agent %s failed", self.id)
186
+ self._error = str(e)
187
+ self._status = "failed"
188
+ finally:
189
+ try:
190
+ await self._llm.close()
191
+ except Exception:
192
+ logger.debug(
193
+ "Error closing LLM for sub-agent %s", self.id, exc_info=True
194
+ )
195
+ self._done.set()
196
+
197
+ async def _loop(self) -> str:
198
+ """Internal tool-call loop (similar to CoderAgent.run() but simplified)."""
199
+ SAFETY_LIMIT = 50
200
+ last_text = ""
201
+
202
+ while self._status == "running":
203
+ if self._tool_call_count >= SAFETY_LIMIT:
204
+ logger.warning("Sub-agent %s reached safety limit", self.id)
205
+ break
206
+
207
+ # Anthropic client takes system prompt separately
208
+ if self._use_anthropic:
209
+ sys_msg = ""
210
+ for m in self._messages:
211
+ if m.get("role") == "system":
212
+ sys_msg = m.get("content", "")
213
+ break
214
+ response = await self._llm.chat(
215
+ self._messages, system_prompt=sys_msg, tools=self._tool_defs
216
+ )
217
+ else:
218
+ response = await self._llm.chat(self._messages, tools=self._tool_defs)
219
+ tool_calls = response.get("tool_calls", [])
220
+ text = response.get("content", "")
221
+
222
+ if text:
223
+ last_text = text
224
+
225
+ if not tool_calls:
226
+ return text or last_text or "Done."
227
+
228
+ # Execute tools serially
229
+ for tc in tool_calls:
230
+ if self._status != "running":
231
+ return last_text
232
+ self._tool_call_count += 1
233
+ tool_name = tc["function"]["name"]
234
+ try:
235
+ arguments = json.loads(tc["function"]["arguments"])
236
+ except json.JSONDecodeError:
237
+ arguments = {}
238
+
239
+ result = await self._tools.execute(tool_name, arguments)
240
+
241
+ self._messages.append({
242
+ "role": "assistant",
243
+ "content": text or None,
244
+ "tool_calls": [tc],
245
+ })
246
+ self._messages.append({
247
+ "role": "tool",
248
+ "tool_call_id": tc["id"],
249
+ "content": result.to_message(),
250
+ })
251
+
252
+ if self._event_callback:
253
+ try:
254
+ self._event_callback(self.id, tool_name, result)
255
+ except Exception:
256
+ logger.debug(
257
+ "Event callback failed for sub-agent %s", self.id,
258
+ exc_info=True,
259
+ )
260
+
261
+ return last_text
262
+
263
+ def _build_prompt(self) -> str:
264
+ """Build the sub-agent's system prompt."""
265
+ parts = []
266
+ if self._skill_prompt:
267
+ parts.append(self._skill_prompt)
268
+ parts.append(
269
+ "You are a sub-agent working on a delegated task. "
270
+ "Complete your task independently and return a clear result. "
271
+ "Do not ask follow-up questions — the main agent cannot see them."
272
+ )
273
+ return "\n\n".join(parts)
@@ -0,0 +1,203 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ SubAgentManager — lifecycle management for concurrent sub-agents.
4
+
5
+ Handles spawning, collecting, cancelling, and listing sub-agents.
6
+ Uses asyncio.Semaphore for concurrency limits.
7
+ """
8
+
9
+ import asyncio
10
+ import logging
11
+ import uuid
12
+ from typing import Callable, Optional
13
+
14
+ from .config import AppConfig
15
+ from .sub_agent import SubAgent, SubAgentResult
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ __all__ = ["SubAgentManager"]
20
+
21
+
22
+ class SubAgentManager:
23
+ """
24
+ Manages sub-agent lifecycle: spawn, collect, cancel, list.
25
+
26
+ Usage:
27
+ mgr = SubAgentManager(config, max_concurrent=5)
28
+ aid = await mgr.spawn("Search for all TODO comments", skill_prompt="...")
29
+ # ... do other work ...
30
+ result = await mgr.collect(aid, timeout=300)
31
+ print(result.result)
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ config: AppConfig,
37
+ max_concurrent: int = 5,
38
+ default_timeout: float = 300.0,
39
+ ):
40
+ self._config = config
41
+ self._default_timeout = default_timeout
42
+ self._agents: dict[str, SubAgent] = {}
43
+ self._semaphore = asyncio.Semaphore(max_concurrent)
44
+ self._collected: set[str] = set() # track agents already released via collect()
45
+
46
+ # ── Spawn ──────────────────────────────────────────────────────────
47
+
48
+ async def spawn(
49
+ self,
50
+ task: str,
51
+ skill_prompt: str = "",
52
+ model: Optional[str] = None,
53
+ tools: Optional[list[dict]] = None,
54
+ event_callback: Optional[Callable] = None,
55
+ ) -> str:
56
+ """
57
+ Spawn a sub-agent. Returns the agent_id.
58
+
59
+ Args:
60
+ task: The task to delegate (must be self-contained)
61
+ skill_prompt: Optional skill system prompt for the sub-agent
62
+ model: Optional model override
63
+ tools: Optional tool list (defaults to built-in tools)
64
+ event_callback: Optional callback for tool results
65
+
66
+ Returns:
67
+ agent_id string
68
+
69
+ Raises:
70
+ RuntimeError: if max concurrent agents reached
71
+ """
72
+ # Acquire semaphore slot with timeout (avoids TOCTOU + private attr access)
73
+ try:
74
+ await asyncio.wait_for(self._semaphore.acquire(), timeout=30.0)
75
+ except asyncio.TimeoutError:
76
+ running = sum(1 for a in self._agents.values() if a.is_running())
77
+ raise RuntimeError(
78
+ f"Max concurrent sub-agents reached ({running} running). "
79
+ f"Wait for some to complete or increase the limit."
80
+ )
81
+
82
+ agent_id = f"sub_{uuid.uuid4().hex[:8]}"
83
+ sub = SubAgent(
84
+ config=self._config,
85
+ skill_prompt=skill_prompt,
86
+ model=model,
87
+ tools=tools,
88
+ event_callback=event_callback,
89
+ agent_id=agent_id,
90
+ )
91
+ self._agents[agent_id] = sub
92
+
93
+ try:
94
+ await sub.run(task)
95
+ except Exception:
96
+ # Release semaphore on failure — caller may never call collect()
97
+ self._semaphore.release()
98
+ raise
99
+ running = sum(1 for a in self._agents.values() if a.is_running())
100
+ logger.info("Sub-agent spawned: %s (running=%d)", agent_id, running)
101
+ return agent_id
102
+
103
+ # ── Collect ────────────────────────────────────────────────────────
104
+
105
+ async def collect(self, agent_id: str, timeout: Optional[float] = None) -> SubAgentResult:
106
+ """Wait for and collect a sub-agent's result."""
107
+ if timeout is None:
108
+ timeout = self._default_timeout
109
+ sub = self._agents.get(agent_id)
110
+ if not sub:
111
+ return SubAgentResult(
112
+ agent_id=agent_id, result=None,
113
+ error=f"Unknown agent: {agent_id}", success=False,
114
+ )
115
+ result = await sub.wait(timeout=timeout)
116
+ # Release semaphore slot (track to avoid double-release in clear_finished)
117
+ self._semaphore.release()
118
+ self._collected.add(agent_id)
119
+ return result
120
+
121
+ async def collect_all(self, timeout: Optional[float] = None) -> list[SubAgentResult]:
122
+ """Collect results from all sub-agents."""
123
+ results = []
124
+ for aid in list(self._agents.keys()):
125
+ results.append(await self.collect(aid, timeout))
126
+ return results
127
+
128
+ # ── Cancel ─────────────────────────────────────────────────────────
129
+
130
+ async def cancel(self, agent_id: str) -> bool:
131
+ """Cancel a specific sub-agent."""
132
+ sub = self._agents.get(agent_id)
133
+ if sub and sub.is_running():
134
+ await sub.cancel()
135
+ return True
136
+ return False
137
+
138
+ async def cancel_all(self) -> None:
139
+ """Cancel all running sub-agents."""
140
+ for sub in list(self._agents.values()):
141
+ if sub.is_running():
142
+ await sub.cancel()
143
+
144
+ # ── Queries ────────────────────────────────────────────────────────
145
+
146
+ def get(self, agent_id: str) -> Optional[SubAgent]:
147
+ """Get a sub-agent by ID."""
148
+ return self._agents.get(agent_id)
149
+
150
+ def list_all(self) -> list[SubAgent]:
151
+ """List all sub-agents."""
152
+ return list(self._agents.values())
153
+
154
+ def list_active(self) -> list[SubAgent]:
155
+ """List only running sub-agents."""
156
+ return [a for a in self._agents.values() if a.is_running()]
157
+
158
+ def list_finished(self) -> list[SubAgent]:
159
+ """List only completed/failed/cancelled sub-agents."""
160
+ return [a for a in self._agents.values() if a.is_done()]
161
+
162
+ @property
163
+ def active_count(self) -> int:
164
+ return sum(1 for a in self._agents.values() if a.is_running())
165
+
166
+ @property
167
+ def total_count(self) -> int:
168
+ return len(self._agents)
169
+
170
+ # ── Cleanup ────────────────────────────────────────────────────────
171
+
172
+ def clear_finished(self) -> int:
173
+ """Remove finished agents from tracking. Returns count removed.
174
+
175
+ Releases one semaphore slot per removed agent that was NOT already
176
+ collected via :meth:`collect` — without this, slots belonging to
177
+ agents cleaned up without :meth:`collect` would leak permanently.
178
+ """
179
+ to_remove = [
180
+ aid for aid, a in self._agents.items()
181
+ if a.is_done()
182
+ ]
183
+ for aid in to_remove:
184
+ del self._agents[aid]
185
+ if aid not in self._collected:
186
+ self._semaphore.release()
187
+ else:
188
+ self._collected.discard(aid)
189
+ if to_remove:
190
+ logger.debug("Cleared %d finished sub-agent(s) (slots released)", len(to_remove))
191
+ return len(to_remove)
192
+
193
+ async def shutdown(self) -> None:
194
+ """Cancel all agents and wait for them to finish."""
195
+ await self.cancel_all()
196
+ # Wait for all tasks to finish
197
+ for agent in list(self._agents.values()):
198
+ if agent._task and not agent._task.done():
199
+ try:
200
+ await asyncio.wait_for(agent._task, timeout=5.0)
201
+ except (asyncio.TimeoutError, asyncio.CancelledError):
202
+ pass
203
+ self._agents.clear()
@@ -0,0 +1,146 @@
1
+ """
2
+ System prompt builder — composes the full system prompt for the coding agent.
3
+
4
+ The prompt is structured to put the skill persona FIRST (the agent's identity),
5
+ followed by supporting context in descending order of relevance.
6
+ """
7
+
8
+ import logging
9
+ import platform
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class SystemPromptBuilder:
17
+ """Constructs the full system prompt for the coding agent.
18
+
19
+ Structure (in order):
20
+ 1. Skill persona (who the agent IS — dominant)
21
+ 2. Memory (relevant context from past sessions)
22
+ 3. Environment (OS, workspace, model — 2 lines)
23
+ 4. Project (language, framework — only if detected)
24
+ 5. Tools (compact name-only list)
25
+ 6. MCP (external servers — only if connected)
26
+ 7. Rules (5 concise operational rules)
27
+ """
28
+
29
+ def __init__(self, subsystems: Any, workspace_dir: str | Path,
30
+ model: str = "", default_prompt: str = ""):
31
+ self.subsys = subsystems
32
+ self.workspace = str(workspace_dir)
33
+ self.model = model
34
+ self.default_prompt = default_prompt
35
+
36
+ def build(self, tool_definitions: list[dict], user_input: str = "") -> str:
37
+ """Build the complete system prompt string."""
38
+ parts: list[str] = []
39
+
40
+ # 1. Skill persona — ALWAYS first
41
+ parts.append(self._skill_section())
42
+
43
+ # 2. Memory — relevant past context
44
+ memory = self._memory_section(user_input)
45
+ if memory:
46
+ parts.append(memory)
47
+
48
+ # 3. Environment — compact
49
+ parts.append(self._environment_section())
50
+
51
+ # 4. Project — only if detected
52
+ project = self._project_section()
53
+ if project:
54
+ parts.append(project)
55
+
56
+ # 5. Tools — compact name list
57
+ parts.append(self._tools_section(tool_definitions))
58
+
59
+ # 6. MCP — only if connected
60
+ mcp = self._mcp_section()
61
+ if mcp:
62
+ parts.append(mcp)
63
+
64
+ # 7. Rules — always last
65
+ parts.append(self._rules_section())
66
+
67
+ return "\n\n".join(parts)
68
+
69
+ # ── Sections ──────────────────────────────────────────────────────────
70
+
71
+ def _skill_section(self) -> str:
72
+ """The agent's persona — from active skills or default prompt."""
73
+ # Primary: ExtensionManager aggregates all active skill extensions
74
+ if self.subsys.has_extensions and self.subsys.extensions:
75
+ aggregated = self.subsys.extensions.aggregate_prompts(base_prompt="")
76
+ if aggregated.strip():
77
+ return aggregated
78
+
79
+ # Fallback: direct skill manager
80
+ if self.subsys.has_skills:
81
+ try:
82
+ prompt = self.subsys.skills.get_system_prompt()
83
+ if prompt.strip():
84
+ return prompt
85
+ except Exception:
86
+ pass
87
+
88
+ return self.default_prompt
89
+
90
+ def _memory_section(self, user_input: str = "") -> str:
91
+ """Targeted memory recall (max 5, score >= 3)."""
92
+ if not self.subsys.has_memory:
93
+ return ""
94
+ mem = self.subsys.memory
95
+ if user_input.strip():
96
+ ctx = mem.recall_context(user_input, max_memories=5)
97
+ else:
98
+ ctx = mem.get_memory_context(max_total=5)
99
+ return ctx if ctx else ""
100
+
101
+ def _environment_section(self) -> str:
102
+ """Single-line environment summary."""
103
+ model_part = f" | Model: {self.model}" if self.model else ""
104
+ return (
105
+ f"OS: {platform.system()} {platform.release()} "
106
+ f"| Python: {platform.python_version()} "
107
+ f"| Workspace: {self.workspace}"
108
+ f"{model_part}"
109
+ )
110
+
111
+ def _project_section(self) -> str:
112
+ if self.subsys.has_project_info and self.subsys.project_info:
113
+ return self.subsys.project_info.to_prompt()
114
+ return ""
115
+
116
+ def _tools_section(self, tool_definitions: list[dict]) -> str:
117
+ """Compact tool list — names only, no descriptions."""
118
+ names: list[str] = []
119
+ for t in tool_definitions:
120
+ fn = t.get("function", t)
121
+ name = fn.get("name", "")
122
+ if name:
123
+ names.append(name)
124
+ return f"Tools ({len(names)}): " + ", ".join(sorted(names))
125
+
126
+ def _mcp_section(self) -> str:
127
+ if not self.subsys.has_mcp:
128
+ return ""
129
+ mcp = self.subsys.mcp
130
+ if not mcp.connected_servers:
131
+ return ""
132
+ servers = ", ".join(mcp.connected_servers)
133
+ return f"MCP servers ({mcp.tool_count} tools): {servers}"
134
+
135
+ def _rules_section(self) -> str:
136
+ """Concise operational rules — replaces the old verbose ops + formatting sections."""
137
+ return (
138
+ "Rules:\n"
139
+ "- Shell cwd is already the workspace. Use compound commands (cd X && do_Y) if needed.\n"
140
+ "- Shell command blocked? Use: python -c \"import subprocess; subprocess.run([...], cwd='...')\"\n"
141
+ "- Read error messages carefully — diagnose before retrying. Auto-correction handles common failures.\n"
142
+ "- File reads are cached — re-reading the same file returns a [cached] note. Use offset/limit to page.\n"
143
+ "- Context auto-compacts at ~200k tokens. When you see the compaction notice, trust the summary.\n"
144
+ "- Respond clearly: use **bold** for key terms, ## headings for structure, code blocks with language tags."
145
+ )
146
+