aizen-ai-cli 2.2.5__tar.gz → 2.4.1__tar.gz
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.
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/PKG-INFO +9 -2
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/README.md +8 -1
- aizen_ai_cli-2.4.1/aizen/agent.py +274 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen/commands.py +37 -18
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen/config.py +31 -10
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen/context.py +61 -0
- aizen_ai_cli-2.4.1/aizen/main.py +499 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen/session.py +49 -36
- aizen_ai_cli-2.4.1/aizen/tools/__init__.py +13 -0
- aizen_ai_cli-2.4.1/aizen/tools/commands.py +222 -0
- aizen_ai_cli-2.4.1/aizen/tools/dispatcher.py +437 -0
- aizen_ai_cli-2.4.1/aizen/tools/file_ops.py +309 -0
- aizen_ai_cli-2.4.1/aizen/tools/helpers.py +352 -0
- aizen_ai_cli-2.4.1/aizen/tools/search.py +199 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen/utils.py +11 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen_ai_cli.egg-info/PKG-INFO +9 -2
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen_ai_cli.egg-info/SOURCES.txt +14 -2
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/pyproject.toml +3 -1
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/setup.py +1 -1
- aizen_ai_cli-2.4.1/tests/test_agent.py +178 -0
- aizen_ai_cli-2.4.1/tests/test_commands.py +96 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/tests/test_config.py +5 -1
- aizen_ai_cli-2.4.1/tests/test_dispatcher.py +85 -0
- aizen_ai_cli-2.4.1/tests/test_file_ops.py +168 -0
- aizen_ai_cli-2.4.1/tests/test_helpers.py +46 -0
- aizen_ai_cli-2.4.1/tests/test_plugins.py +114 -0
- aizen_ai_cli-2.4.1/tests/test_retry.py +149 -0
- aizen_ai_cli-2.4.1/tests/test_search.py +78 -0
- aizen_ai_cli-2.2.5/aizen/main.py +0 -627
- aizen_ai_cli-2.2.5/aizen/tools.py +0 -1244
- aizen_ai_cli-2.2.5/tests/test_commands.py +0 -212
- aizen_ai_cli-2.2.5/tests/test_tools.py +0 -427
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/MANIFEST.in +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen/__init__.py +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen/exceptions.py +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen/logging_config.py +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen/mcp.py +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen/plugins.py +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen/retry.py +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen_ai_cli.egg-info/dependency_links.txt +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen_ai_cli.egg-info/entry_points.txt +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen_ai_cli.egg-info/requires.txt +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/aizen_ai_cli.egg-info/top_level.txt +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/requirements.txt +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/setup.cfg +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/tests/test_context.py +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/tests/test_main.py +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/tests/test_mcp.py +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/tests/test_session.py +0 -0
- {aizen_ai_cli-2.2.5 → aizen_ai_cli-2.4.1}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aizen-ai-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.1
|
|
4
4
|
Summary: Aizen AI Agent — A professional-grade AI coding assistant for your terminal.
|
|
5
5
|
Author: Irtaza Malik
|
|
6
6
|
License: MIT
|
|
@@ -54,9 +54,12 @@ A helpful AI coding assistant you can use right in your terminal. Aizen reads yo
|
|
|
54
54
|
- **SQLite Session Persistence** — Session storage is powered by a SQLite database (`~/.aizen_sessions/aizen.db`), auto-migrating older JSON sessions.
|
|
55
55
|
- **Project-Specific Rules** — Customizes agent behavior per repository by auto-loading `.aizen_rules` or `.cursorrules` from the current working directory.
|
|
56
56
|
- **Smart Autocomplete** — `@`-mention files with Tab completion that respects `.gitignore` and supports directory traversal.
|
|
57
|
+
- **Vision Support** — Attach images natively (e.g., `@mockup.png`) and Aizen will automatically encode them for Vision APIs (GPT-4o, Claude 3.5 Sonnet).
|
|
58
|
+
- **Real-time Command Streaming** — Long-running shell commands stream their output live to the terminal instead of freezing with a spinner.
|
|
59
|
+
- **Smart Context Pruning** — Automatically drops old, large file attachments first when hitting the context limit before resorting to LLM summarization.
|
|
57
60
|
|
|
58
61
|
### Tools
|
|
59
|
-
Aizen has
|
|
62
|
+
Aizen has 10 built-in tools the AI can use:
|
|
60
63
|
|
|
61
64
|
| Tool | Description |
|
|
62
65
|
|------|-------------|
|
|
@@ -70,6 +73,8 @@ Aizen has 9 built-in tools the AI can use:
|
|
|
70
73
|
| `list_directory` | List files/folders with sizes, respecting `.gitignore` |
|
|
71
74
|
| `grep_search` | Search for text or regex patterns across the codebase |
|
|
72
75
|
| `find_files` | Find files by glob pattern (e.g., `*.py`, `Dockerfile`) |
|
|
76
|
+
| `get_file_outline` | Extract AST outline of a Python file (classes, methods, docstrings) without blowing up the context window |
|
|
77
|
+
| `web_search` | Search the web for current information, docs, or API references |
|
|
73
78
|
|
|
74
79
|
### Commands
|
|
75
80
|
|
|
@@ -93,9 +98,11 @@ Aizen has 9 built-in tools the AI can use:
|
|
|
93
98
|
| `/export [file]` | Export conversation to a Markdown file |
|
|
94
99
|
| `/config` | View current configuration |
|
|
95
100
|
| `/mcp` | View configured MCP servers and their connection status |
|
|
101
|
+
| `/auto [task]` | Enter a fully autonomous agentic loop to execute a complex task step-by-step |
|
|
96
102
|
|
|
97
103
|
### Safety & UX
|
|
98
104
|
- **Command Safety** — Read-only commands (`ls`, `cat`, `git status`, etc.) auto-execute. Destructive commands (`rm`, `sudo`, etc.) always require confirmation.
|
|
105
|
+
- **Autonomous Limits** — The `/auto` mode enforces a strict 25-step execution limit to prevent infinite loops and runaway costs.
|
|
99
106
|
- **`--yolo` Mode** — Auto-approve all operations for power users.
|
|
100
107
|
- **Background Tasks** — Run builds, tests, or other long-running tasks asynchronously while continuing to interact with Aizen.
|
|
101
108
|
- **File Backups** — Every file modification creates a backup. Use `/undo` to restore.
|
|
@@ -14,9 +14,12 @@ A helpful AI coding assistant you can use right in your terminal. Aizen reads yo
|
|
|
14
14
|
- **SQLite Session Persistence** — Session storage is powered by a SQLite database (`~/.aizen_sessions/aizen.db`), auto-migrating older JSON sessions.
|
|
15
15
|
- **Project-Specific Rules** — Customizes agent behavior per repository by auto-loading `.aizen_rules` or `.cursorrules` from the current working directory.
|
|
16
16
|
- **Smart Autocomplete** — `@`-mention files with Tab completion that respects `.gitignore` and supports directory traversal.
|
|
17
|
+
- **Vision Support** — Attach images natively (e.g., `@mockup.png`) and Aizen will automatically encode them for Vision APIs (GPT-4o, Claude 3.5 Sonnet).
|
|
18
|
+
- **Real-time Command Streaming** — Long-running shell commands stream their output live to the terminal instead of freezing with a spinner.
|
|
19
|
+
- **Smart Context Pruning** — Automatically drops old, large file attachments first when hitting the context limit before resorting to LLM summarization.
|
|
17
20
|
|
|
18
21
|
### Tools
|
|
19
|
-
Aizen has
|
|
22
|
+
Aizen has 10 built-in tools the AI can use:
|
|
20
23
|
|
|
21
24
|
| Tool | Description |
|
|
22
25
|
|------|-------------|
|
|
@@ -30,6 +33,8 @@ Aizen has 9 built-in tools the AI can use:
|
|
|
30
33
|
| `list_directory` | List files/folders with sizes, respecting `.gitignore` |
|
|
31
34
|
| `grep_search` | Search for text or regex patterns across the codebase |
|
|
32
35
|
| `find_files` | Find files by glob pattern (e.g., `*.py`, `Dockerfile`) |
|
|
36
|
+
| `get_file_outline` | Extract AST outline of a Python file (classes, methods, docstrings) without blowing up the context window |
|
|
37
|
+
| `web_search` | Search the web for current information, docs, or API references |
|
|
33
38
|
|
|
34
39
|
### Commands
|
|
35
40
|
|
|
@@ -53,9 +58,11 @@ Aizen has 9 built-in tools the AI can use:
|
|
|
53
58
|
| `/export [file]` | Export conversation to a Markdown file |
|
|
54
59
|
| `/config` | View current configuration |
|
|
55
60
|
| `/mcp` | View configured MCP servers and their connection status |
|
|
61
|
+
| `/auto [task]` | Enter a fully autonomous agentic loop to execute a complex task step-by-step |
|
|
56
62
|
|
|
57
63
|
### Safety & UX
|
|
58
64
|
- **Command Safety** — Read-only commands (`ls`, `cat`, `git status`, etc.) auto-execute. Destructive commands (`rm`, `sudo`, etc.) always require confirmation.
|
|
65
|
+
- **Autonomous Limits** — The `/auto` mode enforces a strict 25-step execution limit to prevent infinite loops and runaway costs.
|
|
59
66
|
- **`--yolo` Mode** — Auto-approve all operations for power users.
|
|
60
67
|
- **Background Tasks** — Run builds, tests, or other long-running tasks asynchronously while continuing to interact with Aizen.
|
|
61
68
|
- **File Backups** — Every file modification creates a backup. Use `/undo` to restore.
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AgentRunner — Encapsulates the core agent turn loop.
|
|
3
|
+
|
|
4
|
+
Extracted from main.py to enable:
|
|
5
|
+
- Isolated testing with mocked clients
|
|
6
|
+
- Cleaner separation between CLI plumbing and agent logic
|
|
7
|
+
- Reuse in non-interactive contexts (e.g., scripted pipelines)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import random
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from rich.live import Live
|
|
16
|
+
from rich.markdown import Markdown
|
|
17
|
+
from rich.spinner import Spinner
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
|
|
20
|
+
from .config import Theme, console, get_active_model
|
|
21
|
+
from .logging_config import logger
|
|
22
|
+
from .tools import execute_tool
|
|
23
|
+
from .utils import Struct, truncate_output
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AgentRunner:
|
|
27
|
+
"""Handles a single conversational turn: stream → parse → execute tools → loop."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
client,
|
|
32
|
+
active_tools: list[dict],
|
|
33
|
+
context_manager,
|
|
34
|
+
token_tracker,
|
|
35
|
+
mcp_manager=None,
|
|
36
|
+
auto_approve: bool = False,
|
|
37
|
+
is_auto_mode: bool = False,
|
|
38
|
+
auto_iteration_count: int = 0,
|
|
39
|
+
max_auto_iterations: int = 50,
|
|
40
|
+
):
|
|
41
|
+
self.client = client
|
|
42
|
+
self.active_tools = active_tools
|
|
43
|
+
self.context_manager = context_manager
|
|
44
|
+
self.token_tracker = token_tracker
|
|
45
|
+
self.mcp_manager = mcp_manager
|
|
46
|
+
self.auto_approve = auto_approve
|
|
47
|
+
self.is_auto_mode = is_auto_mode
|
|
48
|
+
self.auto_iteration_count = auto_iteration_count
|
|
49
|
+
self.max_auto_iterations = max_auto_iterations
|
|
50
|
+
|
|
51
|
+
async def run_turn(self, messages: list[dict]) -> None:
|
|
52
|
+
"""
|
|
53
|
+
Execute a full agent turn: stream the model's response, handle tool calls,
|
|
54
|
+
and loop until the model produces a final text response (no more tool calls).
|
|
55
|
+
"""
|
|
56
|
+
while True:
|
|
57
|
+
if self.is_auto_mode:
|
|
58
|
+
self.auto_iteration_count += 1
|
|
59
|
+
if self.auto_iteration_count > self.max_auto_iterations:
|
|
60
|
+
console.print(
|
|
61
|
+
f" [{Theme.WARNING}]⚠️ Autonomous mode reached iteration limit "
|
|
62
|
+
f"({self.max_auto_iterations}). Exiting auto mode.[/{Theme.WARNING}]"
|
|
63
|
+
)
|
|
64
|
+
self.is_auto_mode = False
|
|
65
|
+
messages.append({
|
|
66
|
+
"role": "user",
|
|
67
|
+
"content": (
|
|
68
|
+
f"You have reached the maximum number of autonomous iterations "
|
|
69
|
+
f"({self.max_auto_iterations}). Please provide a brief summary "
|
|
70
|
+
f"of what you have accomplished and what remains."
|
|
71
|
+
),
|
|
72
|
+
})
|
|
73
|
+
self.auto_iteration_count = 0
|
|
74
|
+
|
|
75
|
+
# Stream the response
|
|
76
|
+
stream_result = await self._stream_response(messages)
|
|
77
|
+
if stream_result is None:
|
|
78
|
+
break # Error occurred (already printed to console)
|
|
79
|
+
|
|
80
|
+
full_content, tool_calls_list, api_usage = stream_result
|
|
81
|
+
|
|
82
|
+
# Track tokens
|
|
83
|
+
self._track_tokens(messages, full_content, api_usage)
|
|
84
|
+
|
|
85
|
+
# Add assistant message to history
|
|
86
|
+
assistant_msg: dict[str, Any] = {
|
|
87
|
+
"role": "assistant",
|
|
88
|
+
"content": full_content or "",
|
|
89
|
+
}
|
|
90
|
+
if tool_calls_list:
|
|
91
|
+
assistant_msg["tool_calls"] = tool_calls_list
|
|
92
|
+
messages.append(assistant_msg)
|
|
93
|
+
|
|
94
|
+
# If no tool calls, we're done with this turn
|
|
95
|
+
if not tool_calls_list:
|
|
96
|
+
break
|
|
97
|
+
|
|
98
|
+
# Execute tool calls
|
|
99
|
+
tool_results = await self._execute_tools(tool_calls_list)
|
|
100
|
+
messages.extend(tool_results)
|
|
101
|
+
|
|
102
|
+
# Loop continues — model processes tool results
|
|
103
|
+
|
|
104
|
+
async def _stream_response(
|
|
105
|
+
self, messages: list[dict]
|
|
106
|
+
) -> tuple[str, list[dict], Any] | None:
|
|
107
|
+
"""
|
|
108
|
+
Stream a response from the model.
|
|
109
|
+
|
|
110
|
+
Returns (full_content, tool_calls_list, api_usage) or None on error.
|
|
111
|
+
"""
|
|
112
|
+
full_content = ""
|
|
113
|
+
accumulated_tool_calls: dict[int, dict] = {}
|
|
114
|
+
api_usage = None
|
|
115
|
+
|
|
116
|
+
spinner_label = random.choice([
|
|
117
|
+
"Thinking...", "Analyzing...", "Reasoning...",
|
|
118
|
+
"Processing...", "Considering...", "Exploring...", "Synthesizing...",
|
|
119
|
+
])
|
|
120
|
+
|
|
121
|
+
if self.is_auto_mode:
|
|
122
|
+
spinner_text = Text(
|
|
123
|
+
f" [Step {self.auto_iteration_count}/{self.max_auto_iterations}] {spinner_label}",
|
|
124
|
+
style=f"{Theme.MUTED} italic",
|
|
125
|
+
)
|
|
126
|
+
else:
|
|
127
|
+
spinner_text = Text(f" {spinner_label}", style=f"{Theme.MUTED} italic")
|
|
128
|
+
|
|
129
|
+
spinner_display = Spinner("dots2", text=spinner_text, style=f"{Theme.PRIMARY} bold")
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
with Live(
|
|
133
|
+
spinner_display, console=console, refresh_per_second=8
|
|
134
|
+
) as live:
|
|
135
|
+
from openai import AsyncStream
|
|
136
|
+
|
|
137
|
+
model = get_active_model()
|
|
138
|
+
api_params: dict[str, Any] = {
|
|
139
|
+
"model": model,
|
|
140
|
+
"messages": messages,
|
|
141
|
+
"stream": True,
|
|
142
|
+
"stream_options": {"include_usage": True},
|
|
143
|
+
}
|
|
144
|
+
if self.active_tools:
|
|
145
|
+
api_params["tools"] = self.active_tools
|
|
146
|
+
api_params["tool_choice"] = "auto"
|
|
147
|
+
|
|
148
|
+
stream: AsyncStream = await self.client.chat.completions.create(**api_params)
|
|
149
|
+
|
|
150
|
+
async for chunk in stream:
|
|
151
|
+
if hasattr(chunk, "usage") and chunk.usage:
|
|
152
|
+
api_usage = chunk.usage
|
|
153
|
+
|
|
154
|
+
delta = chunk.choices[0].delta if chunk.choices else None
|
|
155
|
+
if not delta:
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
if delta.content:
|
|
159
|
+
full_content += delta.content
|
|
160
|
+
if full_content.strip():
|
|
161
|
+
try:
|
|
162
|
+
display_content = f"**◆ AIZEN:** {full_content}"
|
|
163
|
+
rendered = Markdown(display_content)
|
|
164
|
+
live.update(rendered)
|
|
165
|
+
except Exception:
|
|
166
|
+
display_text = Text.from_markup(
|
|
167
|
+
f"{Theme.BADGE} {full_content}"
|
|
168
|
+
)
|
|
169
|
+
live.update(display_text)
|
|
170
|
+
|
|
171
|
+
if delta.tool_calls:
|
|
172
|
+
for tc in delta.tool_calls:
|
|
173
|
+
idx = tc.index
|
|
174
|
+
if idx not in accumulated_tool_calls:
|
|
175
|
+
accumulated_tool_calls[idx] = {
|
|
176
|
+
"id": "", "name": "", "arguments": "", "type": "function",
|
|
177
|
+
}
|
|
178
|
+
if tc.id:
|
|
179
|
+
accumulated_tool_calls[idx]["id"] = tc.id
|
|
180
|
+
if tc.function:
|
|
181
|
+
if tc.function.name:
|
|
182
|
+
accumulated_tool_calls[idx]["name"] += tc.function.name
|
|
183
|
+
if tc.function.arguments:
|
|
184
|
+
accumulated_tool_calls[idx]["arguments"] += tc.function.arguments
|
|
185
|
+
|
|
186
|
+
names = [v["name"] for v in accumulated_tool_calls.values() if v["name"]]
|
|
187
|
+
if names and not full_content.strip():
|
|
188
|
+
tool_text = Text()
|
|
189
|
+
tool_text.append(" ◆ ", style=f"bold {Theme.ACCENT}")
|
|
190
|
+
tool_text.append("Invoking ", style=f"{Theme.TEXT}")
|
|
191
|
+
tool_text.append(f"{', '.join(names)}", style=f"bold {Theme.ACCENT}")
|
|
192
|
+
tool_text.append(" ...", style=f"{Theme.MUTED}")
|
|
193
|
+
live.update(tool_text)
|
|
194
|
+
|
|
195
|
+
except Exception as e:
|
|
196
|
+
# Re-raise — let the caller (main_loop) handle specific exception types
|
|
197
|
+
raise
|
|
198
|
+
|
|
199
|
+
# Build tool calls list
|
|
200
|
+
tool_calls_list: list[dict[str, Any]] = []
|
|
201
|
+
for idx in sorted(accumulated_tool_calls.keys()):
|
|
202
|
+
tc = accumulated_tool_calls[idx]
|
|
203
|
+
tool_calls_list.append({
|
|
204
|
+
"id": tc["id"],
|
|
205
|
+
"type": "function",
|
|
206
|
+
"function": {
|
|
207
|
+
"name": tc["name"],
|
|
208
|
+
"arguments": tc["arguments"],
|
|
209
|
+
},
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
return full_content, tool_calls_list, api_usage
|
|
213
|
+
|
|
214
|
+
async def _execute_tools(self, tool_calls_list: list[dict]) -> list[dict]:
|
|
215
|
+
"""Execute tool calls (in parallel where safe) and return tool result messages."""
|
|
216
|
+
|
|
217
|
+
async def _exec_tool(tc_dict: dict) -> dict:
|
|
218
|
+
func_name = tc_dict["function"]["name"]
|
|
219
|
+
if func_name.startswith("mcp_") and self.mcp_manager:
|
|
220
|
+
try:
|
|
221
|
+
args = json.loads(tc_dict["function"]["arguments"])
|
|
222
|
+
result = await self.mcp_manager.call_tool(func_name, args)
|
|
223
|
+
except json.JSONDecodeError:
|
|
224
|
+
result = f"Error: Invalid JSON arguments for {func_name}."
|
|
225
|
+
else:
|
|
226
|
+
func_struct = Struct(**tc_dict["function"])
|
|
227
|
+
tc_struct = Struct(
|
|
228
|
+
id=tc_dict["id"],
|
|
229
|
+
type=tc_dict["type"],
|
|
230
|
+
function=func_struct,
|
|
231
|
+
)
|
|
232
|
+
result = await asyncio.to_thread(execute_tool, tc_struct, self.auto_approve)
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
"role": "tool",
|
|
236
|
+
"tool_call_id": tc_dict["id"],
|
|
237
|
+
"name": func_name,
|
|
238
|
+
"content": truncate_output(result),
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
tool_results = await asyncio.gather(
|
|
242
|
+
*[_exec_tool(tc) for tc in tool_calls_list],
|
|
243
|
+
return_exceptions=True,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# Handle individual tool failures gracefully
|
|
247
|
+
for i, result in enumerate(tool_results):
|
|
248
|
+
if isinstance(result, Exception):
|
|
249
|
+
logger.error("Tool execution failed: %s", result)
|
|
250
|
+
tool_results[i] = {
|
|
251
|
+
"role": "tool",
|
|
252
|
+
"tool_call_id": tool_calls_list[i]["id"],
|
|
253
|
+
"name": tool_calls_list[i]["function"]["name"],
|
|
254
|
+
"content": f"Error: Tool execution failed — {type(result).__name__}: {result}",
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return list(tool_results)
|
|
258
|
+
|
|
259
|
+
def _track_tokens(self, messages, full_content, api_usage):
|
|
260
|
+
"""Update token tracking from API usage or estimation."""
|
|
261
|
+
if api_usage and hasattr(api_usage, "prompt_tokens"):
|
|
262
|
+
self.token_tracker.add_api_usage(
|
|
263
|
+
api_usage.prompt_tokens or 0,
|
|
264
|
+
api_usage.completion_tokens or 0,
|
|
265
|
+
)
|
|
266
|
+
self.context_manager.update(
|
|
267
|
+
(api_usage.prompt_tokens or 0) + (api_usage.completion_tokens or 0)
|
|
268
|
+
)
|
|
269
|
+
elif full_content:
|
|
270
|
+
estimated_input = self.context_manager.estimate_messages_tokens(
|
|
271
|
+
messages, self.token_tracker.estimate_tokens
|
|
272
|
+
)
|
|
273
|
+
estimated_output = self.token_tracker.estimate_tokens(full_content)
|
|
274
|
+
self.token_tracker.add_usage(estimated_input, estimated_output)
|
|
@@ -5,8 +5,10 @@ import re
|
|
|
5
5
|
import subprocess
|
|
6
6
|
from datetime import datetime
|
|
7
7
|
|
|
8
|
+
import questionary
|
|
9
|
+
|
|
8
10
|
from prompt_toolkit.completion import Completer, Completion
|
|
9
|
-
from prompt_toolkit
|
|
11
|
+
from prompt_toolkit import PromptSession
|
|
10
12
|
from rich.table import Table
|
|
11
13
|
|
|
12
14
|
from .config import (
|
|
@@ -44,6 +46,7 @@ SLASH_COMMANDS = [
|
|
|
44
46
|
("/mcp", "View configured MCP servers and their status"),
|
|
45
47
|
("/commit", "Auto-generate and commit changes"),
|
|
46
48
|
("/diff", "Show all uncommitted changes"),
|
|
49
|
+
("/auto", "Enter autonomous agentic mode for a complex task"),
|
|
47
50
|
]
|
|
48
51
|
|
|
49
52
|
# In-memory checkpoint storage for conversation branching
|
|
@@ -284,6 +287,10 @@ async def handle_slash_command(
|
|
|
284
287
|
help_table.add_row(" 🔀 /commit", "Auto-generate and commit changes")
|
|
285
288
|
help_table.add_row(" 📊 /diff", "Show all uncommitted changes")
|
|
286
289
|
|
|
290
|
+
# ── Agent ──
|
|
291
|
+
help_table.add_row(f"[bold {Theme.MUTED}]── Agent ──[/bold {Theme.MUTED}]", "")
|
|
292
|
+
help_table.add_row(" 🤖 /auto [task]", "Enter autonomous mode for a complex task (max iterations apply)")
|
|
293
|
+
|
|
287
294
|
# ── Shortcuts ──
|
|
288
295
|
help_table.add_row(f"[bold {Theme.MUTED}]── Shortcuts ──[/bold {Theme.MUTED}]", "")
|
|
289
296
|
help_table.add_row(f" [{Theme.PINK}]@file / @url[/{Theme.PINK}]", "Attach file context or web URL")
|
|
@@ -416,12 +423,8 @@ async def handle_slash_command(
|
|
|
416
423
|
# Attempt LLM-based summarization for much better context retention
|
|
417
424
|
console.print(f" [{Theme.MUTED}]Summarizing conversation with AI...[/{Theme.MUTED}]")
|
|
418
425
|
try:
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
_config = load_config()
|
|
422
|
-
_api_key = _config.get("OPENROUTER_API_KEY", "")
|
|
423
|
-
_api_base = _config.get("API_BASE_URL", "https://openrouter.ai/api/v1")
|
|
424
|
-
_client = _AsyncOpenAI(base_url=_api_base, api_key=_api_key)
|
|
426
|
+
# Use the client passed to handle_slash_command
|
|
427
|
+
_client = client
|
|
425
428
|
|
|
426
429
|
# Build a summarization request from the middle messages
|
|
427
430
|
summary_messages = [
|
|
@@ -601,8 +604,8 @@ async def handle_slash_command(
|
|
|
601
604
|
console.print(f" [{Theme.WARNING}]No changes found to commit.[/{Theme.WARNING}]\n")
|
|
602
605
|
return False
|
|
603
606
|
|
|
604
|
-
answer =
|
|
605
|
-
if
|
|
607
|
+
answer = await questionary.confirm("No staged changes. Stage all current changes?").ask_async()
|
|
608
|
+
if not answer:
|
|
606
609
|
console.print(f" [{Theme.WARNING}]Commit aborted.[/{Theme.WARNING}]\n")
|
|
607
610
|
return False
|
|
608
611
|
|
|
@@ -626,24 +629,40 @@ async def handle_slash_command(
|
|
|
626
629
|
messages=commit_messages,
|
|
627
630
|
max_tokens=200,
|
|
628
631
|
)
|
|
629
|
-
|
|
632
|
+
commit_content = response.choices[0].message.content
|
|
633
|
+
commit_msg = commit_content.strip() if commit_content else ""
|
|
630
634
|
# Remove any markdown codeblocks if model didn't listen
|
|
631
635
|
commit_msg = commit_msg.replace("```text", "").replace("```", "").strip()
|
|
632
636
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
637
|
+
if not commit_msg:
|
|
638
|
+
console.print(f"\n [{Theme.WARNING}]⚠️ The model failed to generate a commit message.[/{Theme.WARNING}]")
|
|
639
|
+
action = "Edit message"
|
|
640
|
+
else:
|
|
641
|
+
console.print(f"\n [bold {Theme.TEXT}]Generated Commit Message:[/bold {Theme.TEXT}]")
|
|
642
|
+
console.print(f" [{Theme.ACCENT}]{commit_msg}[/{Theme.ACCENT}]\n")
|
|
643
|
+
|
|
644
|
+
action = await questionary.select(
|
|
645
|
+
"Commit with this message?",
|
|
646
|
+
choices=[
|
|
647
|
+
"Yes, commit this",
|
|
648
|
+
"Edit message",
|
|
649
|
+
"Cancel"
|
|
650
|
+
]
|
|
651
|
+
).ask_async()
|
|
638
652
|
|
|
639
|
-
if action
|
|
653
|
+
if action == "Yes, commit this":
|
|
640
654
|
final_msg = commit_msg
|
|
641
|
-
elif action
|
|
642
|
-
final_msg =
|
|
655
|
+
elif action == "Edit message":
|
|
656
|
+
final_msg = await questionary.text("Edit message:", default=commit_msg).ask_async()
|
|
643
657
|
else:
|
|
644
658
|
console.print("[yellow]Commit aborted.[/yellow]\n")
|
|
645
659
|
return False
|
|
646
660
|
|
|
661
|
+
final_msg = final_msg.strip()
|
|
662
|
+
if not final_msg:
|
|
663
|
+
console.print(f" [{Theme.ERROR}]Error: Commit message cannot be empty. Aborted.[/{Theme.ERROR}]\n")
|
|
664
|
+
return False
|
|
665
|
+
|
|
647
666
|
subprocess.run(["git", "commit", "-m", final_msg], check=True)
|
|
648
667
|
console.print(f" [{Theme.SUCCESS}]✓ Committed successfully.[/{Theme.SUCCESS}]\n")
|
|
649
668
|
|
|
@@ -4,7 +4,6 @@ import logging
|
|
|
4
4
|
import os
|
|
5
5
|
import shutil
|
|
6
6
|
import ssl
|
|
7
|
-
import sys
|
|
8
7
|
import threading
|
|
9
8
|
import time
|
|
10
9
|
import urllib.error
|
|
@@ -21,7 +20,7 @@ logger = logging.getLogger("aizen")
|
|
|
21
20
|
|
|
22
21
|
# Read version from installed package metadata (stays in sync with pyproject.toml).
|
|
23
22
|
# Falls back to a hardcoded value only when running from source without installing.
|
|
24
|
-
_FALLBACK_VERSION = "2.
|
|
23
|
+
_FALLBACK_VERSION = "2.4.1"
|
|
25
24
|
try:
|
|
26
25
|
VERSION = _pkg_version("aizen-ai-cli")
|
|
27
26
|
except PackageNotFoundError:
|
|
@@ -83,6 +82,8 @@ DANGEROUS_PATTERNS = [
|
|
|
83
82
|
r"\brm\s", r"\bsudo\b", r"\bchmod\b", r"\bchown\b", r"\bmkfs\b",
|
|
84
83
|
r"\bdd\b", r":\(\)\{", r"\bkill\b", r"\bpkill\b", r"\bshutdown\b",
|
|
85
84
|
r"\breboot\b", r">\s*/dev/", r"\bcurl\b.*\|\s*(ba)?sh",
|
|
85
|
+
r"\bmktemp\b.*>", r"\btruncate\b", r"\bmv\s+/(?!tmp)", r"chmod\s+777",
|
|
86
|
+
r"git\s+push\s+--force", r"\bdocker\s+run\b", r"\bpip\s+install\b", r"\bnpm\s+install\b",
|
|
86
87
|
# Shell injection patterns
|
|
87
88
|
r"`[^`]+`", # Backtick command substitution
|
|
88
89
|
r"\$\([^)]+\)", # $() command substitution
|
|
@@ -168,12 +169,14 @@ def build_system_prompt(config: dict | None = None) -> str:
|
|
|
168
169
|
|
|
169
170
|
return "\n".join(parts)
|
|
170
171
|
|
|
171
|
-
# Global state for active model
|
|
172
|
+
# Global state for active model (protected by lock for thread safety)
|
|
172
173
|
active_model = DEFAULT_MODEL
|
|
174
|
+
_model_lock = threading.Lock()
|
|
173
175
|
|
|
174
176
|
def set_active_model(model_name: str, save: bool = False):
|
|
175
177
|
global active_model
|
|
176
|
-
|
|
178
|
+
with _model_lock:
|
|
179
|
+
active_model = model_name
|
|
177
180
|
if save:
|
|
178
181
|
try:
|
|
179
182
|
config = load_config()
|
|
@@ -184,7 +187,8 @@ def set_active_model(model_name: str, save: bool = False):
|
|
|
184
187
|
logger.error("Failed to save default model: %s", e)
|
|
185
188
|
|
|
186
189
|
def get_active_model() -> str:
|
|
187
|
-
|
|
190
|
+
with _model_lock:
|
|
191
|
+
return active_model
|
|
188
192
|
|
|
189
193
|
# ─── Configuration ──────────────────────────────────────────────────────────────
|
|
190
194
|
|
|
@@ -209,13 +213,30 @@ def migrate_legacy_data():
|
|
|
209
213
|
|
|
210
214
|
def load_config() -> dict:
|
|
211
215
|
migrate_legacy_data()
|
|
216
|
+
|
|
217
|
+
config = {}
|
|
218
|
+
# Load global config
|
|
212
219
|
if os.path.exists(CONFIG_PATH):
|
|
213
220
|
try:
|
|
214
221
|
with open(CONFIG_PATH) as f:
|
|
215
|
-
|
|
222
|
+
config = json.load(f)
|
|
216
223
|
except Exception as e:
|
|
217
|
-
logger.debug("Failed to load config file: %s", e)
|
|
218
|
-
|
|
224
|
+
logger.debug("Failed to load global config file: %s", e)
|
|
225
|
+
|
|
226
|
+
# Merge local config if present
|
|
227
|
+
local_config_path = os.path.join(os.getcwd(), ".aizen_config.json")
|
|
228
|
+
if os.path.exists(local_config_path):
|
|
229
|
+
try:
|
|
230
|
+
with open(local_config_path) as f:
|
|
231
|
+
local_config = json.load(f)
|
|
232
|
+
# Merge local config keys (overriding global ones)
|
|
233
|
+
if isinstance(local_config, dict):
|
|
234
|
+
config.update(local_config)
|
|
235
|
+
console.print(f"{Theme.SYS} Local config loaded from [#d3fbff]{local_config_path}[/#d3fbff]")
|
|
236
|
+
except Exception as e:
|
|
237
|
+
logger.debug("Failed to load local config file: %s", e)
|
|
238
|
+
|
|
239
|
+
return config
|
|
219
240
|
|
|
220
241
|
|
|
221
242
|
def get_mcp_servers(config: dict) -> dict:
|
|
@@ -252,8 +273,8 @@ def get_api_key(config: dict, reset: bool = False) -> str:
|
|
|
252
273
|
|
|
253
274
|
key = getpass.getpass("API Key: ").strip()
|
|
254
275
|
if not key:
|
|
255
|
-
|
|
256
|
-
|
|
276
|
+
from .exceptions import APIKeyError
|
|
277
|
+
raise APIKeyError("API Key cannot be empty.")
|
|
257
278
|
|
|
258
279
|
config["OPENROUTER_API_KEY"] = key
|
|
259
280
|
save_config(config)
|
|
@@ -171,3 +171,64 @@ class ContextManager:
|
|
|
171
171
|
def get_footer_text(self) -> str:
|
|
172
172
|
"""Get a compact footer string showing context usage."""
|
|
173
173
|
return f"[{Theme.MUTED}]ctx:[/{Theme.MUTED}] {self.get_usage_bar(10)}"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class ContextPruner:
|
|
177
|
+
"""Handles smart pruning and summarization of old conversation context."""
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def prune_attached_contexts(messages: list[dict]) -> int:
|
|
181
|
+
"""
|
|
182
|
+
Removes <file_context>, <url_context>, etc. blocks from older user messages.
|
|
183
|
+
Returns the number of messages modified.
|
|
184
|
+
"""
|
|
185
|
+
import re
|
|
186
|
+
dropped_count = 0
|
|
187
|
+
|
|
188
|
+
# Keep the system prompt and the last couple of turns intact
|
|
189
|
+
if len(messages) <= 3:
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
for msg in messages[1:-2]:
|
|
193
|
+
if msg.get("role") == "user" and msg.get("content"):
|
|
194
|
+
old_content = msg["content"]
|
|
195
|
+
new_content = re.sub(r'<file_context path="[^"]+">.*?</file_context>', '[File context dropped]', old_content, flags=re.DOTALL)
|
|
196
|
+
new_content = re.sub(r'<url_context url="[^"]+">.*?</url_context>', '[URL context dropped]', new_content, flags=re.DOTALL)
|
|
197
|
+
new_content = re.sub(r'<directory_context path="[^"]+">.*?</directory_context>', '[Directory context dropped]', new_content, flags=re.DOTALL)
|
|
198
|
+
new_content = re.sub(r'<command_context cmd="[^"]+">.*?</command_context>', '[Command context dropped]', new_content, flags=re.DOTALL)
|
|
199
|
+
|
|
200
|
+
if old_content != new_content:
|
|
201
|
+
msg["content"] = new_content
|
|
202
|
+
dropped_count += 1
|
|
203
|
+
|
|
204
|
+
return dropped_count
|
|
205
|
+
|
|
206
|
+
@staticmethod
|
|
207
|
+
def summarize_old_messages(messages: list[dict], recent_count: int = 4) -> list[dict]:
|
|
208
|
+
"""
|
|
209
|
+
Condenses older messages into a naive summary to save tokens.
|
|
210
|
+
Modifies the `messages` list in place and returns the summary message text.
|
|
211
|
+
"""
|
|
212
|
+
if len(messages) <= recent_count + 2:
|
|
213
|
+
return ""
|
|
214
|
+
|
|
215
|
+
system_msg = messages[0]
|
|
216
|
+
recent = messages[-recent_count:]
|
|
217
|
+
middle = messages[1:-recent_count]
|
|
218
|
+
|
|
219
|
+
user_topics = [
|
|
220
|
+
m["content"][:100].replace('\n', ' ')
|
|
221
|
+
for m in middle
|
|
222
|
+
if m.get("role") == "user" and m.get("content")
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
summary = "Previous conversation summary: The user and assistant discussed " + "; ".join(user_topics[:5]) + ". The assistant helped with these requests."
|
|
226
|
+
|
|
227
|
+
messages[:] = [
|
|
228
|
+
system_msg,
|
|
229
|
+
{"role": "user", "content": f"Previous conversation summary:\n{summary}"},
|
|
230
|
+
{"role": "assistant", "content": "Understood. I have the context. How can I continue helping?"},
|
|
231
|
+
] + recent
|
|
232
|
+
|
|
233
|
+
return summary
|
|
234
|
+
|