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
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()
|