axons 0.1.0__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.
axon/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """
2
+ Axon - AI-powered coding agent.
3
+ CLI tool and MCP server for AI-assisted code generation and editing.
4
+ """
5
+
6
+ __version__ = "0.1.0"
axon/agent.py ADDED
@@ -0,0 +1,436 @@
1
+ """Agent components — memory manager, tool executor, verification, and orchestration loop."""
2
+
3
+ import asyncio
4
+ import json
5
+ from collections import Counter
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from rich.console import Console
11
+ from rich.markdown import Markdown
12
+ from rich.panel import Panel
13
+ from rich.syntax import Syntax
14
+ from rich import box
15
+
16
+ from axon.config import AxonConfig
17
+ from axon.llm import LLMMessage, ToolCall, create_provider, LLMProvider
18
+ from axon.tools import TOOL_DEFINITIONS, PLANNING_TOOL_DEFINITIONS, execute_tool, ToolResult
19
+
20
+ console = Console()
21
+
22
+ CONTEXT_WINDOW_KEEP = 20
23
+ MAX_IDENTICAL_RESULTS = 3
24
+ # Detect alternating-loop patterns over this many recent calls
25
+ LOOP_WINDOW = 8
26
+ LOOP_REPEAT_THRESHOLD = 3 # if any single signature appears this many times in the window
27
+
28
+ # Approval levels
29
+ MODE_AUTO = "auto" # auto-run all tools
30
+ MODE_NORMAL = "normal" # ask before write_file, edit_file, apply_patch
31
+ MODE_SAFE = "safe" # ask before write, edit, run_command, git_commit
32
+
33
+ # Which tools need approval per mode
34
+ RISKY_TOOLS = {"write_file", "apply_patch"}
35
+ VERY_RISKY_TOOLS = {"write_file", "apply_patch", "run_command", "git_commit"}
36
+
37
+
38
+ # ─── Memory Manager ──────────────────────────────────────────────────────────────
39
+
40
+ class ConversationMemory:
41
+ """Manages conversation history with safe compression.
42
+
43
+ The first user message (the original task) is pinned and survives all
44
+ compression so the model never forgets what it was asked to do.
45
+ """
46
+
47
+ def __init__(self, system_prompt: str, persist: bool = False):
48
+ self.messages: list[LLMMessage] = []
49
+ self.persist = persist
50
+ self._pinned_task: Optional[LLMMessage] = None # #1: pinned original task
51
+ self.messages.append(LLMMessage(role="system", content=system_prompt))
52
+
53
+ def add_user(self, content: str):
54
+ msg = LLMMessage(role="user", content=content)
55
+ # Pin the very first user message as the original task
56
+ if self._pinned_task is None:
57
+ self._pinned_task = msg
58
+ self.messages.append(msg)
59
+
60
+ def add_assistant(self, content: str, tool_calls: Optional[list[ToolCall]] = None):
61
+ self.messages.append(LLMMessage(role="assistant", content=content, tool_calls=tool_calls or []))
62
+
63
+ def add_tool_result(self, tc: ToolCall, result: ToolResult):
64
+ self.messages.append(
65
+ LLMMessage(role="tool", content=result.to_str(), tool_call_id=tc.id)
66
+ )
67
+
68
+ def compress(self):
69
+ """Trim old messages while preserving valid role structure.
70
+
71
+ Rules:
72
+ - Always keep messages[0] (system prompt).
73
+ - Always re-inject the pinned task message after trimming.
74
+ - Keep the most recent CONTEXT_WINDOW_KEEP non-system messages.
75
+ - After trimming, drop orphaned tool/assistant messages at the boundary.
76
+ - Prepend a trim notice only when the kept slice doesn't start with a user msg.
77
+ """
78
+ non_system = self.messages[1:]
79
+ if len(non_system) <= CONTEXT_WINDOW_KEEP:
80
+ return
81
+
82
+ kept = non_system[-(CONTEXT_WINDOW_KEEP):]
83
+
84
+ # Drop orphaned tool results at the front (no paired assistant above them)
85
+ while kept and kept[0].role == "tool":
86
+ kept = kept[1:]
87
+
88
+ # Drop an assistant-with-tool-calls at the front if its results were trimmed
89
+ if kept and kept[0].role == "assistant" and kept[0].tool_calls:
90
+ call_ids = {tc.id for tc in kept[0].tool_calls}
91
+ result_ids = {m.tool_call_id for m in kept if m.role == "tool"}
92
+ if not call_ids.issubset(result_ids):
93
+ kept = kept[1:]
94
+ while kept and kept[0].role == "tool":
95
+ kept = kept[1:]
96
+
97
+ if not kept:
98
+ return # nothing safe to keep — don't corrupt history
99
+
100
+ # #4 FIX: only insert trim notice when the first kept msg is NOT already a user msg
101
+ # (previously both branches did the same insert, causing double user messages)
102
+ if kept[0].role != "user":
103
+ trim_notice = LLMMessage(
104
+ role="user",
105
+ content="[Earlier conversation was trimmed. Continue working on the current task.]"
106
+ )
107
+ kept.insert(0, trim_notice)
108
+
109
+ # #1: Re-inject pinned task at the front if it's not already there
110
+ if self._pinned_task is not None and kept[0] is not self._pinned_task:
111
+ # Avoid duplicating if the pinned task is already in kept
112
+ if self._pinned_task not in kept:
113
+ kept.insert(0, self._pinned_task)
114
+
115
+ self.messages = [self.messages[0]] + kept
116
+
117
+ def get_messages(self) -> list[LLMMessage]:
118
+ return self.messages
119
+
120
+
121
+ # ─── Verification Manager ──────────────────────────────────────────────────────
122
+
123
+ class VerificationManager:
124
+ """Tracks verification checkpoints and ensures changes are verified."""
125
+
126
+ def __init__(self):
127
+ self.changes_made: list[dict] = [] # list of {tool, path, time}
128
+ self.verified = False
129
+
130
+ def record_change(self, tool_name: str, path: str):
131
+ self.changes_made.append({
132
+ "tool": tool_name,
133
+ "path": path,
134
+ "time": datetime.now().isoformat(),
135
+ })
136
+ self.verified = False
137
+
138
+ def mark_verified(self):
139
+ self.verified = True
140
+
141
+ def has_unverified_changes(self) -> bool:
142
+ return len(self.changes_made) > 0 and not self.verified
143
+
144
+ def summary(self) -> str:
145
+ if not self.changes_made:
146
+ return ""
147
+ lines = [f"[Changes made this session ({len(self.changes_made)}):]"]
148
+ for c in self.changes_made:
149
+ lines.append(f" - {c['tool']}: {c['path']}")
150
+ if self.verified:
151
+ lines.append(" ✓ Verified")
152
+ else:
153
+ lines.append(" ⚠ Not yet verified")
154
+ return "\n".join(lines)
155
+
156
+
157
+ # ─── Tool Executor ─────────────────────────────────────────────────────────────
158
+
159
+ class ToolExecutor:
160
+ """Handles tool call execution and result tracking."""
161
+
162
+ def __init__(self, workspace: Path, approval_mode: str = MODE_AUTO):
163
+ self.workspace = workspace
164
+ self.last_signatures: list[str] = []
165
+ self.approval_mode = approval_mode
166
+
167
+ def set_approval_mode(self, mode: str):
168
+ self.approval_mode = mode
169
+
170
+ def needs_approval(self, tool_name: str) -> bool:
171
+ if self.approval_mode == MODE_AUTO:
172
+ return False
173
+ if self.approval_mode == MODE_SAFE:
174
+ return tool_name in VERY_RISKY_TOOLS
175
+ if self.approval_mode == MODE_NORMAL:
176
+ return tool_name in RISKY_TOOLS
177
+ return False
178
+
179
+ async def execute(self, tc: ToolCall) -> ToolResult:
180
+ # Ask for approval if needed
181
+ if self.needs_approval(tc.name):
182
+ args_str = json.dumps(tc.arguments, indent=2)[:500]
183
+ print(f"\n[Axon needs approval]")
184
+ print(f"Tool: {tc.name}")
185
+ print(f"Args:\n{args_str}")
186
+ try:
187
+ loop = asyncio.get_event_loop()
188
+ answer = await loop.run_in_executor(None, input, "Run this? (Y/n): ")
189
+ if answer.strip().lower() == "n":
190
+ return ToolResult(
191
+ success=False,
192
+ error="Skipped — user declined approval.",
193
+ tool_name=tc.name,
194
+ )
195
+ except (EOFError, KeyboardInterrupt):
196
+ return ToolResult(
197
+ success=False,
198
+ error="Skipped — interrupted.",
199
+ tool_name=tc.name,
200
+ )
201
+
202
+ result = await execute_tool(tc.name, tc.arguments, self.workspace)
203
+ sig = f"{tc.name}:{json.dumps(tc.arguments, sort_keys=True)}"
204
+ self.last_signatures.append(sig)
205
+ return result
206
+
207
+ def is_looping(self) -> bool:
208
+ """Detect both identical-repeat and alternating-loop patterns."""
209
+ if len(self.last_signatures) < MAX_IDENTICAL_RESULTS:
210
+ return False
211
+
212
+ # Classic: last N calls all identical
213
+ recent = self.last_signatures[-MAX_IDENTICAL_RESULTS:]
214
+ if len(set(recent)) == 1:
215
+ return True
216
+
217
+ # Alternating: within the last LOOP_WINDOW calls, any signature
218
+ # appears LOOP_REPEAT_THRESHOLD or more times
219
+ if len(self.last_signatures) >= LOOP_WINDOW:
220
+ window = self.last_signatures[-LOOP_WINDOW:]
221
+ counts = Counter(window)
222
+ if counts.most_common(1)[0][1] >= LOOP_REPEAT_THRESHOLD:
223
+ return True
224
+
225
+ return False
226
+
227
+ def reset_loop_detection(self):
228
+ self.last_signatures = []
229
+
230
+
231
+ # ─── Orchestrator ──────────────────────────────────────────────────────────────
232
+
233
+ class Agent:
234
+ """High-level agent that orchestrates the AI coding loop."""
235
+
236
+ def __init__(
237
+ self,
238
+ config: AxonConfig,
239
+ workspace: Path,
240
+ task: str,
241
+ interactive: bool = True,
242
+ persist: bool = False,
243
+ approval_mode: str = MODE_AUTO,
244
+ ):
245
+ self.config = config
246
+ self.workspace = workspace
247
+ self.interactive = interactive
248
+ self.approval_mode = approval_mode
249
+ self.memory = ConversationMemory(config.system_prompt, persist=persist)
250
+ self.executor = ToolExecutor(workspace, approval_mode)
251
+ self.verifier = VerificationManager()
252
+ self.provider: LLMProvider = create_provider(config.llm)
253
+ self.persist = persist
254
+ self.active_plan: Optional[str] = None
255
+
256
+ self.memory.add_user(task)
257
+
258
+ def set_approval_mode(self, mode: str):
259
+ self.approval_mode = mode
260
+ self.executor.set_approval_mode(mode)
261
+
262
+ def add_task(self, new_task: str):
263
+ """Add a follow-up task to the existing conversation (reuse provider)."""
264
+ self.memory.add_user(new_task)
265
+
266
+ def set_plan(self, plan: str):
267
+ """Set a structured plan that the agent can reference."""
268
+ self.active_plan = plan
269
+ self.memory.add_user(
270
+ f"[PLAN FOR THIS TASK]\n{plan}\n\n"
271
+ "Follow this plan step by step. Update progress after each step."
272
+ )
273
+
274
+ async def _run_planning_phase(self) -> str:
275
+ """#5: Dedicated planning phase — one call with read-only tools, returns the plan.
276
+
277
+ The model is given only safe/read tools so it can't accidentally start
278
+ making changes while planning. It must respond with a numbered plan.
279
+ """
280
+ console.print("[dim]Planning...[/]")
281
+
282
+ # Inject a planning-specific instruction
283
+ planning_prompt = LLMMessage(
284
+ role="user",
285
+ content=(
286
+ "[PLANNING PHASE]\n"
287
+ "Before doing anything, produce a numbered step-by-step plan for this task.\n"
288
+ "- Use the available tools to explore the codebase if needed.\n"
289
+ "- Your response MUST end with a section starting with '## Plan' listing numbered steps.\n"
290
+ "- Do NOT make any file edits or run any commands yet — this is planning only.\n"
291
+ "- Keep steps concrete: specify which files and what changes."
292
+ ),
293
+ )
294
+
295
+ planning_messages = self.memory.get_messages() + [planning_prompt]
296
+
297
+ content, _ = await self.provider.chat(planning_messages, PLANNING_TOOL_DEFINITIONS)
298
+
299
+ if content:
300
+ _print_assistant(content, title="Axon · Planning")
301
+
302
+ return content or ""
303
+
304
+ async def run(self) -> str:
305
+ iteration = 0
306
+ final_result = ""
307
+
308
+ # #5: Structural planning phase (unless plan was manually set via /plan)
309
+ if self.active_plan is None:
310
+ plan_text = await self._run_planning_phase()
311
+ if plan_text:
312
+ self.active_plan = plan_text
313
+ self.memory.add_assistant(plan_text)
314
+ self.memory.add_user(
315
+ "[Planning complete. Now execute the plan step by step. "
316
+ "After each step update progress. Verify changes after edits. "
317
+ "Call finish_task when done.]"
318
+ )
319
+
320
+ while iteration < self.config.max_iterations:
321
+ iteration += 1
322
+
323
+ content, tool_calls = await self.provider.chat(
324
+ self.memory.get_messages(), TOOL_DEFINITIONS
325
+ )
326
+
327
+ # #8: Buffer all content — print once per turn as one panel
328
+ turn_content_parts: list[str] = []
329
+ if content:
330
+ turn_content_parts.append(content)
331
+
332
+ if tool_calls:
333
+ self.memory.add_assistant(content, tool_calls)
334
+
335
+ for tc in tool_calls:
336
+ _print_tool_call(tc)
337
+ result = await self.executor.execute(tc)
338
+ _print_tool_result(tc.name, result.to_str())
339
+ self.memory.add_tool_result(tc, result)
340
+
341
+ # Track changes for verification
342
+ if tc.name in ("write_file", "apply_patch"):
343
+ path = tc.arguments.get("path", "unknown")
344
+ self.verifier.record_change(tc.name, path)
345
+
346
+ # After a change tool, prompt verification
347
+ if tc.name in ("write_file", "apply_patch"):
348
+ self.memory.add_user(
349
+ "[Change made. Now verify: check the diff with git_diff, run relevant tests, "
350
+ "and confirm everything works before continuing. If you wrote a new file, check it compiles/runs correctly.]"
351
+ )
352
+
353
+ # Mark verified when the agent checks the diff
354
+ if tc.name == "git_diff" and result.success:
355
+ self.verifier.mark_verified()
356
+
357
+ # #6: Handle ask_question with optional numbered options
358
+ if tc.name == "ask_question":
359
+ answer = result.to_str()
360
+ # answer is already captured by _ask_question in tools.py
361
+ # just make sure it feeds back into memory (already done via add_tool_result)
362
+
363
+ if tc.name == "finish_task":
364
+ # #2: Guard finish_task — block if there are unverified changes
365
+ if self.verifier.has_unverified_changes():
366
+ self.memory.add_user(
367
+ "[finish_task blocked: you have unverified changes. "
368
+ "Run git_diff to review your edits, then run tests or the linter "
369
+ "to confirm everything works. Only call finish_task after verifying.]"
370
+ )
371
+ # Don't break — let the loop continue so the model can verify
372
+ else:
373
+ final_result = tc.arguments.get("result", "")
374
+ break
375
+
376
+ if final_result:
377
+ break
378
+
379
+ # Loop detection
380
+ if self.executor.is_looping():
381
+ self.memory.add_user(
382
+ "[You appear to be stuck in a loop — the same actions keep repeating. "
383
+ "Step back, think about why the approach isn't working, and try a fundamentally different strategy. "
384
+ "If you are truly blocked, call ask_question to get help from the user.]"
385
+ )
386
+ self.executor.reset_loop_detection()
387
+ else:
388
+ self.memory.add_assistant(content)
389
+
390
+ # #8: Print all buffered content for this turn as one panel
391
+ if turn_content_parts:
392
+ _print_assistant("\n\n".join(turn_content_parts))
393
+
394
+ self.memory.compress()
395
+
396
+ if self.interactive and content:
397
+ try:
398
+ resp = input("\n[Axon] Continue? (Y/n): ").strip().lower()
399
+ if resp == "n":
400
+ final_result = "Session ended by user."
401
+ break
402
+ except (EOFError, KeyboardInterrupt):
403
+ final_result = "Session ended by user."
404
+ break
405
+
406
+ if self.persist and final_result:
407
+ summary = final_result
408
+ if self.verifier.has_unverified_changes():
409
+ summary += "\n⚠ Some changes may not have been verified."
410
+ self.memory.add_user(f"[Previous task completed] {summary}")
411
+
412
+ return final_result or "Task completed."
413
+
414
+
415
+ # ─── UI helpers ────────────────────────────────────────────────────────────────
416
+
417
+ def _print_assistant(content: str, title: str = "Axon"):
418
+ """#8: Print assistant content as a single panel — one box per call."""
419
+ console.print()
420
+ console.print(Panel(Markdown(content), title=title, border_style="blue", title_align="left"))
421
+
422
+
423
+ def _print_tool_call(tc: ToolCall):
424
+ args_str = json.dumps(tc.arguments, indent=2)
425
+ console.print(Panel(
426
+ f"[bold yellow]Tool:[/] [cyan]{tc.name}[/]\n[bold yellow]Args:[/]\n{args_str}",
427
+ title="🔧 Tool Call", border_style="yellow", title_align="left",
428
+ ))
429
+
430
+
431
+ def _print_tool_result(tool_name: str, result: str):
432
+ display = result[:1000] + "\n... (truncated)" if len(result) > 1000 else result
433
+ console.print(Panel(
434
+ Syntax(display, "text", theme="monokai"),
435
+ title=f"📦 Result: {tool_name}", border_style="green", title_align="left",
436
+ ))