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/agent.py ADDED
@@ -0,0 +1,874 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Core Agent loop for ATA Coder.
4
+
5
+ Integrates:
6
+ - Skills system (configurable personas)
7
+ - Memory system (persistent context across sessions)
8
+ - MCP client (cross-system tool interoperability)
9
+ - Prompt templates (dynamic context injection)
10
+ - Permission system (interactive allow/deny)
11
+ - Project detection (language, framework, build system)
12
+ - Session persistence (save/resume/export)
13
+
14
+ The agent runs a conversation loop:
15
+ 1. Build system prompt from skill + memory + templates + project context
16
+ 2. Send conversation to the LLM
17
+ 3. Execute tool calls (built-in + MCP) with permission checks
18
+ 4. Feed results back and continue
19
+ 5. Complete when the task is done
20
+ """
21
+
22
+ import asyncio
23
+ import json
24
+ import logging
25
+ import os
26
+ import time
27
+ from typing import Any, Callable
28
+
29
+ from .config import AppConfig
30
+ from .llm_client import LLMClient, SYSTEM_PROMPT
31
+ from .anthropic_client import AnthropicClient
32
+ from .tools import ToolExecutor, TOOL_DEFINITIONS, ToolResult
33
+ from .types import Message
34
+ from .agent_subsystems import AgentSubsystems
35
+ from .system_prompt_builder import SystemPromptBuilder
36
+ from .fool_proof import FoolProofEngine
37
+ from .change_tracker import ChangeTracker
38
+ from .privilege import PrivilegeManager
39
+
40
+ from .self_correct import SelfCorrectionEngine
41
+ from .git_workflow import GitWorkflow
42
+ from .extension import get_extension_manager
43
+ from .clawd_integration import get_clawd
44
+ from .agent_compact import CompactionMixin
45
+ from .agent_tools import ToolExecutionMixin
46
+ from .agent_routing import ModelRoutingMixin
47
+ from .agent_extension import ExtensionMixin
48
+
49
+ # ── Event types & Agent state ──────────────────────────────────────────
50
+ from .core import ( # noqa: F401 — re-exported for external use
51
+ AgentEvent, CompleteEvent, ErrorEvent, ReasoningEvent,
52
+ SkillChangedEvent, TextDeltaEvent, ThinkingEvent,
53
+ ToolCallEvent, ToolResultEvent, ToolStreamEvent,
54
+ )
55
+ from .core.state import AgentState
56
+
57
+ logger = logging.getLogger(__name__)
58
+
59
+
60
+ class _SessionLogger(logging.LoggerAdapter):
61
+ """Injects ``session_id`` into log records for structured tracing."""
62
+
63
+ def process(self, msg, kwargs):
64
+ sid = self.extra.get("session_id", "") if self.extra else ""
65
+ if sid:
66
+ return f"[{sid[:8]}] {msg}", kwargs
67
+ return msg, kwargs
68
+
69
+
70
+ # ── The Agent ────────────────────────────────────────────────────────────────
71
+
72
+ class CoderAgent(CompactionMixin, ToolExecutionMixin,
73
+ ModelRoutingMixin, ExtensionMixin):
74
+ """
75
+ The main ATA Coder agent with skills, memory, MCP, templates,
76
+ permissions, project detection, and session persistence.
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ config: AppConfig | None = None,
82
+ tool_executor: ToolExecutor | None = None,
83
+ subsystems: AgentSubsystems | None = None,
84
+ ):
85
+ self.config = config or AppConfig.load()
86
+
87
+ # Choose client: Anthropic or OpenAI format
88
+ if self.config.llm.use_anthropic:
89
+ self.llm = AnthropicClient(self.config.llm)
90
+ self._use_anthropic = True
91
+ else:
92
+ self.llm = LLMClient(self.config.llm)
93
+ self._use_anthropic = False
94
+
95
+ self.tools = tool_executor or ToolExecutor(self.config.agent)
96
+
97
+ # ── Subsystems ────────────────────────────────────────────────────
98
+ self.subsys = subsystems or AgentSubsystems()
99
+ self.skills = self.subsys.skills
100
+ self.memory = self.subsys.memory
101
+ self.mcp = self.subsys.mcp
102
+ if self.mcp:
103
+ self.tools.set_mcp_client(self.mcp)
104
+ self.templates = self.subsys.templates
105
+ self.permissions = self.subsys.permissions
106
+ self.project_info = self.subsys.project_info
107
+ self.sessions = self.subsys.sessions
108
+
109
+ # ── Extension Manager ─────────────────────────────────────────────
110
+ if self.subsys.extensions is not None:
111
+ self.ext_mgr = self.subsys.extensions
112
+ else:
113
+ self.ext_mgr = get_extension_manager()
114
+ self.subsys.extensions = self.ext_mgr
115
+
116
+ # Register skills as extensions
117
+ self._register_skills_as_extensions()
118
+
119
+ # Discover extensions from extension directories
120
+ self._discover_extensions()
121
+
122
+ # Register extension points for agent lifecycle hooks
123
+ self._register_extension_points()
124
+
125
+ # Activate all skill-tagged extensions (multi-skill)
126
+ for ext_name in [e.meta.name for e in self.ext_mgr.get_by_tag("skill")]:
127
+ self.ext_mgr.activate(ext_name)
128
+
129
+ # ── System prompt builder ─────────────────────────────────────────
130
+ self._prompt_builder = SystemPromptBuilder(
131
+ subsystems=self.subsys,
132
+ workspace_dir=self.tools.workspace,
133
+ model=self.config.llm.model,
134
+ default_prompt=SYSTEM_PROMPT,
135
+ )
136
+
137
+ # ── Tool & safety infrastructure ──────────────────────────────────
138
+ self.change_tracker = ChangeTracker()
139
+ self.fool_proof = FoolProofEngine(
140
+ workspace=self.tools.workspace,
141
+ permission_store=self.permissions,
142
+ change_tracker=self.change_tracker,
143
+ )
144
+
145
+ self.privilege_mgr = PrivilegeManager(self.tools.workspace)
146
+ self.self_correct = SelfCorrectionEngine(max_retries=1)
147
+ self.git = GitWorkflow(self.tools.workspace)
148
+
149
+ self._state = AgentState()
150
+ self._on_event: Callable[[AgentEvent], None] | None = None
151
+ self._current_session_id: str = ""
152
+ self._pending_memory_suggestions: list[str] = []
153
+ self._cached_system_prompt: str | None = None # invalidated on new build / compact
154
+ self._cached_allowed_tools: set[str] | None = None # invalidated on skill change
155
+
156
+ # Build the combined tool list
157
+ self._all_tools = list(TOOL_DEFINITIONS)
158
+ if self.mcp:
159
+ mcp_tools = self.mcp.get_tools()
160
+ self._all_tools.extend(mcp_tools)
161
+ logger.debug(
162
+ "MCP tools added: %d", len(mcp_tools),
163
+ )
164
+
165
+ # Extension tools
166
+ ext_tools = self.ext_mgr.aggregate_tools()
167
+ if ext_tools:
168
+ self._all_tools.extend(ext_tools)
169
+ logger.debug("Extension tools added: %d", len(ext_tools))
170
+
171
+ logger.debug(
172
+ "Total tools: %d builtin + %s MCP + %s extensions = %d",
173
+ len(TOOL_DEFINITIONS),
174
+ len(self.mcp.get_tools()) if self.mcp else 0,
175
+ len(ext_tools),
176
+ len(self._all_tools),
177
+ )
178
+
179
+ self.llm.register_tools(self._all_tools)
180
+
181
+ # ── Sub-agent manager (set later by AgentController if used) ──────
182
+ self._sub_agent_mgr = None
183
+
184
+ # ── Parallel tool execution uses asyncio.gather ───────────────────
185
+
186
+ # ── Model routing → agent_routing.py (ModelRoutingMixin)
187
+ # ── Extension management → agent_extension.py (ExtensionMixin)
188
+
189
+ # ── Event system ──────────────────────────────────────────────────────
190
+
191
+ def on_event(self, callback: Callable[[AgentEvent], None]) -> None:
192
+ self._on_event = callback
193
+
194
+ def _emit(self, event: AgentEvent) -> None:
195
+ """Emit event to both callback and EventQueue (if available).
196
+
197
+ Uses put_nowait() for non-blocking FIFO — safe to call from
198
+ both asyncio tasks and asyncio.to_thread() contexts.
199
+ """
200
+ event_queue = getattr(self, "_event_queue", None)
201
+ if event_queue is not None:
202
+ try:
203
+ event_queue.put_nowait(event)
204
+ except asyncio.QueueFull:
205
+ logger.warning("Event queue full (%d pending), dropping event: %s",
206
+ event_queue.count(), type(event).__name__)
207
+ except Exception:
208
+ logger.debug("Event queue closed — dropping event: %s", type(event).__name__)
209
+ # Backward-compatible callback
210
+ if self._on_event:
211
+ self._on_event(event)
212
+
213
+ # ── Main entry point ──────────────────────────────────────────────────
214
+
215
+ async def run(self, task: str, stream: bool = True, skill_name: str | None = None,
216
+ explicit_model: str = "", reset_context: bool = True) -> str:
217
+ """
218
+ Run the agent on a given task.
219
+
220
+ Args:
221
+ task: User task description
222
+ stream: Enable streaming output
223
+ skill_name: Force a specific skill (or None for auto-detect)
224
+ explicit_model: Explicit model override (bypasses auto-routing)
225
+ reset_context: If False, preserve existing conversation history
226
+ (for persistent sessions like the HTTP API).
227
+
228
+ Returns:
229
+ Final response text
230
+ """
231
+ # ── Persistent session: preserve existing conversation ─────────────
232
+ if not reset_context and self._state.messages:
233
+ # Append new user message to existing conversation; keep system
234
+ # prompt and all prior messages intact.
235
+ self._state.messages.append({"role": "user", "content": task})
236
+ # Rebuild system prompt for updated memory context but don't
237
+ # replace the original system message (memory/git context may
238
+ # have changed, but conversation integrity is paramount).
239
+ system_prompt = self._build_system_prompt(task)
240
+ self._cached_system_prompt = system_prompt
241
+ self._cached_allowed_tools = None
242
+ self._state.tool_call_count = 0 # reset per-run counter
243
+ logger.info("Agent run (session): skill=%s, model=%s, session=%s, "
244
+ "history=%d msgs, task=%.100s",
245
+ self.skills.active_skill.name if self.skills and self.skills.active_skill else "default",
246
+ self.current_model,
247
+ self._current_session_id,
248
+ len(self._state.messages),
249
+ task)
250
+ else:
251
+ self._state = AgentState(start_time=time.time())
252
+
253
+ # ── Model routing ──────────────────────────────────────────────
254
+ self._route_for_task(task, explicit_model)
255
+
256
+ # Trigger extension point: on_model_route
257
+ self._ep_on_model_route.trigger(
258
+ task=task, complexity=self._routed_complexity, model=self.current_model
259
+ )
260
+
261
+ # Trigger extension point: on_run_start
262
+ self._ep_on_run_start.trigger(task=task, skill_name=skill_name)
263
+
264
+ # Reset change tracker for new run
265
+ self.change_tracker.reset()
266
+ self.change_tracker.dry_run = False
267
+
268
+ # Generate session ID
269
+ from .session import generate_session_id
270
+ self._current_session_id = generate_session_id(
271
+ task,
272
+ skill_name or (self.skills.active_skill.name if self.skills and self.skills.active_skill else ""),
273
+ )
274
+ # Per-session structured logger — injects session_id prefix
275
+ self._slog = _SessionLogger(logger, {"session_id": self._current_session_id})
276
+
277
+ # Skill selection (multi-skill support)
278
+ if skill_name and self.skills:
279
+ skill = self.skills.activate(skill_name, merge=True)
280
+ if skill:
281
+ self._emit(SkillChangedEvent(skill.name))
282
+ elif self.skills:
283
+ # Keyword-based skill detection — zero extra LLM calls.
284
+ # Single-skill activation only: multi-skill merging causes
285
+ # confusion with weaker models (prompt dilution).
286
+ detected = self.skills.detect_skill(task)
287
+ if detected and detected.name != "general-coder":
288
+ self.skills.activate(detected.name, merge=False)
289
+ self._emit(SkillChangedEvent(detected.name))
290
+ logger.info(
291
+ "Skill route: %s for: %.80s", detected.name, task
292
+ )
293
+
294
+ # Build system prompt — pass the user task for targeted memory recall
295
+ system_prompt = self._build_system_prompt(task)
296
+ self._cached_system_prompt = system_prompt # pre-seed cache
297
+ self._cached_allowed_tools = None # invalidate on new run
298
+
299
+ self._state.messages = [
300
+ {"role": "system", "content": system_prompt},
301
+ {"role": "user", "content": task},
302
+ ]
303
+
304
+ logger.info("Agent run: skill=%s, model=%s, session=%s, task=%.100s",
305
+ self.skills.active_skill.name if self.skills and self.skills.active_skill else "default",
306
+ self.current_model,
307
+ self._current_session_id,
308
+ task)
309
+
310
+ # ── Main agent loop with error boundary ──────────────────────────────
311
+ try:
312
+ return await self._run_loop(task, stream)
313
+ except (KeyboardInterrupt, SystemExit, asyncio.CancelledError):
314
+ raise
315
+ except Exception as e:
316
+ logger.critical("Agent fatal error: %s", e, exc_info=True)
317
+ self._emit(ErrorEvent(f"Fatal error: {e}"))
318
+ return f"Error: {e}"
319
+ finally:
320
+ # Auto-save session after every task (best-effort, never crashes)
321
+ self._auto_save_session()
322
+ # Always deactivate skill after task — prevents state leak
323
+ if self.skills:
324
+ self.skills.deactivate()
325
+
326
+ async def _run_loop(self, task: str, stream: bool = True) -> str:
327
+ """Main agent loop — extracted for error boundary isolation."""
328
+ SAFETY_LIMIT = 999 # circuit breaker — not a tool-call "limit"
329
+ _consecutive_failures = 0 # break loop when model is stuck failing
330
+ _MAX_CONSECUTIVE_FAILURES = 5
331
+ last_text = "" # guard against UnboundLocalError when LLM returns empty content
332
+ while True:
333
+ # Circuit breaker: prevent infinite loop when the model keeps
334
+ # emitting tool calls (hallucination / API bug). This is NOT a
335
+ # user-facing tool limit — just a last-resort safety net.
336
+ if self._state.tool_call_count >= SAFETY_LIMIT:
337
+ logger.critical(
338
+ "SAFETY_LIMIT reached: %d tool calls. Breaking loop.",
339
+ self._state.tool_call_count,
340
+ )
341
+ self._emit(ErrorEvent(
342
+ f"Safety limit reached ({SAFETY_LIMIT} tool calls). "
343
+ "The model may be stuck in a tool-call loop."
344
+ ))
345
+ # Clawd: error state — prevent stuck thinking animation
346
+ get_clawd().error(
347
+ f"Safety limit reached ({SAFETY_LIMIT} tool calls)"
348
+ )
349
+ break
350
+
351
+ self._emit(ThinkingEvent())
352
+
353
+ # Clawd: model is generating, show thinking animation
354
+ get_clawd().thinking()
355
+
356
+ # Auto-compact when approaching the effective context limit.
357
+ # effective_context_tokens (default 200k) reflects the range where
358
+ # the model actually pays attention, not the theoretical 1M window.
359
+ # We compact at 80% of effective limit, which is well below the
360
+ # theoretical max_context_tokens.
361
+ est_tokens = self.get_token_estimate()
362
+ max_tokens = self.config.agent.max_context_tokens
363
+ effective = self.config.agent.effective_context_tokens
364
+ if est_tokens > effective:
365
+ logger.warning("Token budget: %d/%d effective (%.0f%% of %d max), auto-compacting",
366
+ est_tokens, effective, est_tokens / max(max_tokens, 1) * 100, max_tokens)
367
+ await self.compact()
368
+ # Re-estimate AFTER compaction — the message list has changed
369
+ est_tokens = self.get_token_estimate()
370
+ # Hard ceiling: if compaction didn't help enough, force-truncate
371
+ if est_tokens > max_tokens * 0.95:
372
+ logger.critical("Hard token ceiling: %d > 95%% of %d max. Force-truncating.",
373
+ est_tokens, max_tokens)
374
+ self._force_truncate()
375
+
376
+ # Get allowed tools from multi-skill intersection
377
+ allowed_tool_names = self._compute_allowed_tools()
378
+
379
+ filtered_tools = self._all_tools
380
+ if allowed_tool_names is not None and len(allowed_tool_names) > 0:
381
+ filtered_tools = [
382
+ t for t in self._all_tools
383
+ if t["function"]["name"] in allowed_tool_names
384
+ or t["function"]["name"].startswith("mcp__")
385
+ ]
386
+
387
+ if stream:
388
+ response = await self._streaming_chat(filtered_tools)
389
+ else:
390
+ response = await self.llm.chat(
391
+ self._state.messages,
392
+ tools=filtered_tools,
393
+ system_prompt=self._extract_system_prompt(),
394
+ )
395
+
396
+ tool_calls = response.get("tool_calls", [])
397
+ text = response.get("content", "")
398
+
399
+ if text:
400
+ last_text = text
401
+
402
+ if not tool_calls:
403
+ final_response = text or last_text
404
+ # Clawd: Stop — model finished its turn
405
+ get_clawd().stop(assistant_output=final_response)
406
+ break
407
+
408
+ # Pre-parse args for both parallelization check and execution
409
+ pre_parsed: dict[int, dict] = {}
410
+ for i, tc in enumerate(tool_calls):
411
+ try:
412
+ pre_parsed[i] = json.loads(tc["function"]["arguments"])
413
+ except json.JSONDecodeError:
414
+ pre_parsed[i] = {}
415
+ batch_results: list[ToolResult] = []
416
+
417
+ # Execute tool calls (parallel if independent, serial if dependent)
418
+ if len(tool_calls) > 1 and self._can_parallelize(tool_calls, pre_parsed):
419
+ # Clawd: one PreToolUse for the batch (not per-tool)
420
+ get_clawd().tool_use(
421
+ tool_name=", ".join(tc["function"]["name"] for tc in tool_calls[:3]),
422
+ tool_input={"batch_size": len(tool_calls)},
423
+ )
424
+
425
+ results = await self._execute_parallel(tool_calls, text)
426
+ batch_results = results
427
+ self._state.tool_call_count += len(tool_calls)
428
+
429
+ # Clawd: one PostToolUse for the batch
430
+ all_ok = all(r.success for r in results)
431
+ get_clawd().tool_result(tool_name="batch", success=all_ok)
432
+
433
+ # One assistant message with ALL tool_calls (OpenAI standard)
434
+ assistant_msg: dict[str, Any] = {
435
+ "role": "assistant", "content": text or None, "tool_calls": tool_calls,
436
+ }
437
+ if response.get("reasoning_content"):
438
+ assistant_msg["reasoning_content"] = response["reasoning_content"]
439
+ self._state.messages.append(assistant_msg)
440
+ for tc, result in zip(tool_calls, results, strict=True):
441
+ self._warn_if_large_result(result, tc["function"]["name"])
442
+ self._store_tool_result(result, tc["id"])
443
+ else:
444
+ # Clawd: one PreToolUse for the batch (not per-tool)
445
+ get_clawd().tool_use(
446
+ tool_name=", ".join(tc["function"]["name"] for tc in tool_calls[:3]),
447
+ tool_input={"batch_size": len(tool_calls)},
448
+ )
449
+
450
+ for i, tc in enumerate(tool_calls):
451
+ self._state.tool_call_count += 1
452
+ tool_name = tc["function"]["name"]
453
+ arguments = pre_parsed.get(i, {})
454
+
455
+ result = await self._execute_tool(tool_name, arguments)
456
+ batch_results.append(result)
457
+ self._warn_if_large_result(result, tool_name)
458
+
459
+ assistant_msg: dict[str, Any] = {
460
+ "role": "assistant", "content": text or None, "tool_calls": [tc],
461
+ }
462
+ if response.get("reasoning_content"):
463
+ assistant_msg["reasoning_content"] = response["reasoning_content"]
464
+
465
+ self._state.messages.append(assistant_msg)
466
+ self._store_tool_result(result, tc["id"])
467
+
468
+ # Clawd: one PostToolUse for the serial batch
469
+ all_ok = all(r.success for r in batch_results)
470
+ get_clawd().tool_result(tool_name="batch", success=all_ok)
471
+
472
+ # ── Consecutive failure detection ──────────────────────────
473
+ # When every tool call in a batch fails, increment counter.
474
+ # Break the loop after N consecutive all-fail batches to
475
+ # prevent infinite token burn when the model is stuck.
476
+ if batch_results and not any(r.success for r in batch_results):
477
+ _consecutive_failures += 1
478
+ logger.warning("All %d tool(s) failed this turn (streak=%d/%d)",
479
+ len(batch_results), _consecutive_failures, _MAX_CONSECUTIVE_FAILURES)
480
+ if _consecutive_failures >= _MAX_CONSECUTIVE_FAILURES:
481
+ self._emit(ErrorEvent(
482
+ f"Too many consecutive tool failures "
483
+ f"({_consecutive_failures} batches). "
484
+ "The model may be stuck in a failure loop."
485
+ ))
486
+ # Clawd: error state — prevent stuck thinking animation
487
+ get_clawd().error(
488
+ "Too many consecutive tool failures"
489
+ )
490
+ break
491
+ else:
492
+ _consecutive_failures = 0 # any success resets the streak
493
+
494
+ elapsed = time.time() - self._state.start_time
495
+ self._emit(CompleteEvent(
496
+ self._state.tool_call_count, elapsed,
497
+ estimated_tokens=self.get_token_estimate(),
498
+ ))
499
+
500
+ # ── Auto-suggest memories ────────────────────────────────────────
501
+ if self.memory:
502
+ try:
503
+ user_msgs = [m.get("content", "") for m in self._state.messages
504
+ if m.get("role") == "user"]
505
+ # Collect tool error messages so the memory system can learn
506
+ # from failed patterns (e.g. "cd is blocked" → "use python subprocess")
507
+ tool_errors = [m.get("content", "") for m in self._state.messages
508
+ if m.get("role") == "tool"
509
+ and m.get("content", "").startswith("Error:")]
510
+ suggestions = self.memory.suggest_from_conversation(
511
+ user_msgs, tool_errors=tool_errors,
512
+ )
513
+ if suggestions:
514
+ logger.info("Memory suggestions: %d", len(suggestions))
515
+ # Store suggestions on the instance so the UI can display them
516
+ self._pending_memory_suggestions = suggestions
517
+ except Exception:
518
+ self._pending_memory_suggestions = []
519
+
520
+ # Trigger extension point: on_run_complete
521
+ self._ep_on_run_complete.trigger(
522
+ task=task,
523
+ result=final_response or "Task completed.",
524
+ tool_call_count=self._state.tool_call_count,
525
+ )
526
+
527
+ return final_response or "Task completed."
528
+
529
+ # ── Tool execution → agent_tools.py (ToolExecutionMixin)
530
+
531
+ async def _streaming_chat(self, filtered_tools: list[dict] | None = None) -> Message:
532
+ """Stream chat with tool collection."""
533
+ collected_text = ""
534
+ tool_calls: list[dict] = []
535
+ reasoning_content = ""
536
+ _thinking_sent = False # throttle Clawd thinking updates
537
+
538
+ async for delta_type, content in self.llm.chat_stream(
539
+ self._state.messages,
540
+ tools=filtered_tools or None,
541
+ system_prompt=self._extract_system_prompt(),
542
+ ):
543
+ if delta_type == "text":
544
+ collected_text += content
545
+ self._emit(TextDeltaEvent(content))
546
+ if not _thinking_sent:
547
+ get_clawd().thinking()
548
+ _thinking_sent = True
549
+ elif delta_type == "tool_call":
550
+ tool_calls.append(content)
551
+ elif delta_type == "finish":
552
+ pass
553
+ elif delta_type == "reasoning":
554
+ reasoning_content += content
555
+ self._emit(ReasoningEvent(content))
556
+ if not _thinking_sent:
557
+ get_clawd().thinking()
558
+ _thinking_sent = True
559
+
560
+ result: Message = {
561
+ "role": "assistant",
562
+ "content": collected_text,
563
+ "tool_calls": tool_calls,
564
+ }
565
+ if reasoning_content:
566
+ result["reasoning_content"] = reasoning_content
567
+ return result
568
+
569
+ async def chat(self, message: str, stream: bool = True) -> str:
570
+ """Continue conversation with follow-up.
571
+
572
+ Mirrors the main run() loop: skill tool filtering, token compaction,
573
+ consecutive-failure detection, and circuit breaker.
574
+ """
575
+ self._state.messages.append({"role": "user", "content": message})
576
+
577
+ SAFETY_LIMIT = 999 # circuit breaker
578
+ _consecutive_failures = 0
579
+ _MAX_CONSECUTIVE_FAILURES = 5
580
+
581
+ while self._state.tool_call_count < SAFETY_LIMIT:
582
+ # ── Token budget: auto-compact when approaching the limit ────
583
+ est_tokens = self.get_token_estimate()
584
+ max_tokens = self.config.agent.max_context_tokens
585
+ effective = self.config.agent.effective_context_tokens
586
+ if est_tokens > effective:
587
+ logger.warning("chat(): token budget %d/%d effective, auto-compacting",
588
+ est_tokens, effective)
589
+ await self.compact()
590
+ est_tokens = self.get_token_estimate()
591
+ if est_tokens > max_tokens * 0.95:
592
+ logger.critical("chat(): hard ceiling %d > 95%% of %d, force-truncating",
593
+ est_tokens, max_tokens)
594
+ self._force_truncate()
595
+
596
+ # ── Skill tool filtering ────────────────────────────────────
597
+ allowed_tool_names = self._compute_allowed_tools()
598
+ filtered_tools = self._all_tools
599
+ if allowed_tool_names is not None and len(allowed_tool_names) > 0:
600
+ filtered_tools = [
601
+ t for t in self._all_tools
602
+ if t["function"]["name"] in allowed_tool_names
603
+ or t["function"]["name"].startswith("mcp__")
604
+ ]
605
+
606
+ if stream:
607
+ response = await self._streaming_chat(filtered_tools)
608
+ else:
609
+ response = await self.llm.chat(
610
+ self._state.messages,
611
+ tools=filtered_tools,
612
+ system_prompt=self._extract_system_prompt(),
613
+ )
614
+
615
+ tool_calls = response.get("tool_calls", [])
616
+ text = response.get("content", "")
617
+
618
+ if not tool_calls:
619
+ return text or "Done."
620
+
621
+ # Execute tool calls (serial for safety in follow-up context)
622
+ batch_results: list[ToolResult] = []
623
+ for tc in tool_calls:
624
+ self._state.tool_call_count += 1
625
+ tool_name = tc["function"]["name"]
626
+ try:
627
+ arguments = json.loads(tc["function"]["arguments"])
628
+ except json.JSONDecodeError:
629
+ arguments = {}
630
+
631
+ result = await self._execute_tool(tool_name, arguments)
632
+ batch_results.append(result)
633
+ self._warn_if_large_result(result, tool_name)
634
+
635
+ self._state.messages.append({
636
+ "role": "assistant",
637
+ "content": text or None,
638
+ "tool_calls": [tc],
639
+ })
640
+ self._store_tool_result(result, tc["id"])
641
+
642
+ # ── Consecutive failure detection ───────────────────────────
643
+ if batch_results and not any(r.success for r in batch_results):
644
+ _consecutive_failures += 1
645
+ logger.warning("chat(): all %d tool(s) failed (streak=%d/%d)",
646
+ len(batch_results), _consecutive_failures, _MAX_CONSECUTIVE_FAILURES)
647
+ if _consecutive_failures >= _MAX_CONSECUTIVE_FAILURES:
648
+ self._emit(ErrorEvent(
649
+ f"Too many consecutive tool failures "
650
+ f"({_consecutive_failures} batches)."
651
+ ))
652
+ break
653
+ else:
654
+ _consecutive_failures = 0
655
+
656
+ return text or "Done."
657
+
658
+ # ── Tool filtering → agent_tools.py (ToolExecutionMixin)
659
+
660
+ # ── System prompt builder ─────────────────────────────────────────────
661
+
662
+ def _build_system_prompt(self, user_input: str = "") -> str:
663
+ """Build a context-rich system prompt from all subsystems.
664
+
665
+ Delegates to the extracted SystemPromptBuilder so each section
666
+ (environment, project, tools, MCP, memory, formatting) lives in
667
+ its own method and can be tested individually.
668
+
669
+ When *user_input* is provided, memory recall is targeted to the
670
+ current task rather than returning a generic summary.
671
+ """
672
+ # Refresh model name on each build (may have changed via /model)
673
+ self._prompt_builder.model = self.config.llm.model
674
+ return self._prompt_builder.build(TOOL_DEFINITIONS, user_input=user_input)
675
+
676
+ # ── Memory commands ───────────────────────────────────────────────────
677
+
678
+ def remember(self, name: str, description: str, content: str,
679
+ memory_type: str = "reference") -> str:
680
+ """Store a memory. Called by /remember command."""
681
+ if not self.memory:
682
+ return "Memory system not initialized."
683
+ from .memory import Memory
684
+ m = Memory(
685
+ name=name,
686
+ description=description,
687
+ content=content,
688
+ metadata={"type": memory_type},
689
+ )
690
+ self.memory.add(m)
691
+ return f"Memory saved: {name}"
692
+
693
+ def recall(self, query: str) -> str:
694
+ """Search memories. Called by /recall command."""
695
+ if not self.memory:
696
+ return "Memory system not initialized."
697
+ results = self.memory.search(query)
698
+ if not results:
699
+ return f"No memories found for: {query}"
700
+ lines = [f"Found {len(results)} memories:"]
701
+ for m in results[:10]:
702
+ lines.append(f"\n### {m.description}")
703
+ lines.append(f"Type: {m.memory_type} | Updated: {m.updated}")
704
+ lines.append(m.content[:300])
705
+ return "\n".join(lines)
706
+
707
+ # ── Helpers → agent_tools.py (ToolExecutionMixin)
708
+ # ── Parallel execution → agent_tools.py (ToolExecutionMixin)
709
+ # ── Undo / Redo / Dry-run ────────────────────────────────────────────
710
+
711
+ def undo(self, count: int = 1) -> str:
712
+ """Undo the last N changes."""
713
+ if not self.change_tracker:
714
+ return "Change tracker not available."
715
+ reverted = self.change_tracker.undo(count)
716
+ if not reverted:
717
+ return "Nothing to undo."
718
+ lines = [f"Undid {len(reverted)} change(s):"]
719
+ for c in reverted:
720
+ lines.append(f" {c.summary}")
721
+ return "\n".join(lines)
722
+
723
+ def undo_all(self) -> str:
724
+ """Undo all changes in this session."""
725
+ if not self.change_tracker:
726
+ return "Change tracker not available."
727
+ reverted = self.change_tracker.undo_all()
728
+ if not reverted:
729
+ return "Nothing to undo."
730
+ return f"Undid all {len(reverted)} changes."
731
+
732
+ def restore_change(self, change_id: int) -> str:
733
+ """Re-apply a reverted change."""
734
+ if not self.change_tracker:
735
+ return "Change tracker not available."
736
+ restored = self.change_tracker.restore(change_id)
737
+ if restored:
738
+ return f"Restored: {restored.summary}"
739
+ return f"Change #{change_id} not found or not reverted."
740
+
741
+ def list_changes(self) -> str:
742
+ """List all changes in this session."""
743
+ if not self.change_tracker:
744
+ return "Change tracker not available."
745
+ return self.change_tracker.summary()
746
+
747
+ def show_change_diff(self, last_n: int = 3) -> str:
748
+ """Show diffs for recent changes."""
749
+ if not self.change_tracker:
750
+ return "Change tracker not available."
751
+ return self.change_tracker.diff_summary(last_n)
752
+
753
+ def toggle_dry_run(self, enabled: bool | None = None) -> str:
754
+ """Enable or disable dry-run mode."""
755
+ if not self.change_tracker:
756
+ return "Change tracker not available."
757
+ if enabled is None:
758
+ enabled = not self.change_tracker.dry_run
759
+ self.change_tracker.dry_run = enabled
760
+ if enabled:
761
+ return "DRY-RUN MODE ON — changes will be PREVIEWED only, not applied."
762
+ else:
763
+ return "DRY-RUN MODE OFF — changes will be applied normally."
764
+
765
+ def _extract_system_prompt(self) -> str:
766
+ """Return the system prompt from the current conversation state.
767
+
768
+ Cached — only re-scans when messages[0] is replaced (e.g. after compaction).
769
+ """
770
+ if self._cached_system_prompt is not None:
771
+ return self._cached_system_prompt
772
+ for m in self._state.messages:
773
+ if m.get("role") == "system":
774
+ self._cached_system_prompt = m.get("content", "")
775
+ return self._cached_system_prompt
776
+ return ""
777
+
778
+ # ── Utility ───────────────────────────────────────────────────────────
779
+
780
+ # ── Session persistence ─────────────────────────────────────────────
781
+
782
+ def save_session(self, session_id: str = "") -> str:
783
+ """Save current conversation to session storage (manual /save)."""
784
+ if not self.sessions:
785
+ return "Session storage not available."
786
+ sid = session_id or self._current_session_id
787
+ if not sid:
788
+ from .session import generate_session_id
789
+ sid = generate_session_id("manual-save", workspace=str(self.tools.workspace))
790
+ return self._do_save(sid)
791
+
792
+ def _auto_save_session(self) -> None:
793
+ """Auto-save after every task completion (fire-and-forget, best-effort)."""
794
+ if not self.sessions:
795
+ return
796
+ # Generate session ID on first auto-save
797
+ if not self._current_session_id:
798
+ from .session import generate_session_id
799
+ # Find first user message for the task hash
800
+ task_hint = ""
801
+ for msg in self._state.messages:
802
+ if msg.get("role") == "user":
803
+ task_hint = msg.get("content", "")[:100]
804
+ break
805
+ self._current_session_id = generate_session_id(
806
+ task_hint or "conversation",
807
+ skill=self.skills.active_skill.name if self.skills and self.skills.active_skill else "",
808
+ workspace=str(self.tools.workspace),
809
+ )
810
+ try:
811
+ self._do_save(self._current_session_id)
812
+ except Exception:
813
+ logger.warning("Auto-save failed for session %s", self._current_session_id, exc_info=True)
814
+
815
+ def _do_save(self, sid: str) -> str:
816
+ """Internal: persist messages + update index."""
817
+ from .utils import sanitize_surrogates
818
+ first_user_msg = ""
819
+ for msg in self._state.messages:
820
+ if msg.get("role") == "user":
821
+ first_user_msg = sanitize_surrogates(msg.get("content", "")[:200])
822
+ break
823
+ self.sessions.save(
824
+ session_id=sid,
825
+ messages=self._state.messages,
826
+ summary=first_user_msg,
827
+ skill=self.skills.active_skill.name if self.skills and self.skills.active_skill else "",
828
+ model=self.config.llm.model,
829
+ workspace=str(self.tools.workspace),
830
+ tool_call_count=self._state.tool_call_count,
831
+ )
832
+ self._current_session_id = sid
833
+ return sid
834
+
835
+
836
+ # Compaction → agent_compact.py (CompactionMixin)
837
+ @property
838
+ def session_id(self) -> str:
839
+ return self._current_session_id
840
+
841
+ # ── Change tracking helper → agent_tools.py (ToolExecutionMixin._read_old_content)
842
+
843
+ def get_token_estimate(self) -> int:
844
+ """Estimate total tokens in the conversation."""
845
+ return self.llm.count_tokens_approx(self._state.messages)
846
+
847
+ def get_conversation_summary(self) -> str:
848
+ msgs = self._state.messages
849
+ total = len(msgs)
850
+ tool_calls = sum(1 for m in msgs if m.get("tool_calls"))
851
+ user_msgs = sum(1 for m in msgs if m.get("role") == "user")
852
+ tokens = self.get_token_estimate()
853
+ return (
854
+ f"Session: {self._current_session_id or 'unsaved'}\n"
855
+ f"Messages: {total} ({user_msgs} user turns, {tool_calls} tool calls)\n"
856
+ f"Tokens: ~{tokens:,} / {self.config.agent.max_context_tokens:,}\n"
857
+ f"Skill: {self.skills.active_skill.name if self.skills and self.skills.active_skill else 'default'}\n"
858
+ f"Model: {self.config.llm.model}"
859
+ )
860
+
861
+ def reset(self) -> None:
862
+ self._state = AgentState(start_time=time.time())
863
+ self._current_session_id = ""
864
+ if self.skills:
865
+ self.skills.deactivate()
866
+ logger.info("Agent state reset")
867
+
868
+ async def shutdown(self) -> None:
869
+ """Clean up resources."""
870
+ # Clawd: final SessionEnd
871
+ get_clawd().shutdown()
872
+ await self.llm.close()
873
+ if self.mcp:
874
+ await self.mcp.stop_all()