neuroncli 2.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.
- neuroncli/__init__.py +18 -0
- neuroncli/__main__.py +14 -0
- neuroncli/agent.py +544 -0
- neuroncli/auth.py +263 -0
- neuroncli/cli.py +409 -0
- neuroncli/config.py +112 -0
- neuroncli/git_integration.py +86 -0
- neuroncli/ollama_client.py +150 -0
- neuroncli/openrouter_client.py +220 -0
- neuroncli/prompts.py +109 -0
- neuroncli/provider.py +45 -0
- neuroncli/session.py +88 -0
- neuroncli/tools.py +413 -0
- neuroncli/ui.py +138 -0
- neuroncli-2.1.0.dist-info/METADATA +171 -0
- neuroncli-2.1.0.dist-info/RECORD +19 -0
- neuroncli-2.1.0.dist-info/WHEEL +5 -0
- neuroncli-2.1.0.dist-info/entry_points.txt +2 -0
- neuroncli-2.1.0.dist-info/top_level.txt +1 -0
neuroncli/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""NeuronCLI — AI Coding Agent powered by Kimi K2.5 + Ollama."""
|
|
2
|
+
|
|
3
|
+
from .config import AgentConfig, APP_NAME, VERSION
|
|
4
|
+
from .agent import Agent, AgentResult
|
|
5
|
+
from .provider import ChatMessage, create_provider
|
|
6
|
+
from .auth import ensure_api_key
|
|
7
|
+
from .tools import registry as tool_registry
|
|
8
|
+
|
|
9
|
+
__version__ = VERSION
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Agent",
|
|
12
|
+
"AgentConfig",
|
|
13
|
+
"AgentResult",
|
|
14
|
+
"ChatMessage",
|
|
15
|
+
"create_provider",
|
|
16
|
+
"ensure_api_key",
|
|
17
|
+
"tool_registry",
|
|
18
|
+
]
|
neuroncli/__main__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Allow running as: python -m neuroncli"""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
# Fix Windows terminal encoding — MUST be before any print()
|
|
7
|
+
if sys.platform == "win32":
|
|
8
|
+
os.system("") # Enable ANSI escape codes on Windows
|
|
9
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
10
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
11
|
+
|
|
12
|
+
from neuroncli.cli import main
|
|
13
|
+
|
|
14
|
+
raise SystemExit(main())
|
neuroncli/agent.py
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
"""NeuronCLI — ReAct agent engine v2.1. Parallel tools, git integration, token tracking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from .config import AgentConfig, MODE_YOLO, MODE_PLAN
|
|
13
|
+
from .provider import ChatMessage, create_provider, ProviderConnectionError
|
|
14
|
+
from .prompts import build_system_prompt
|
|
15
|
+
from .tools import registry as tool_registry
|
|
16
|
+
from .git_integration import git_auto_commit, is_git_repo
|
|
17
|
+
from .ui import (
|
|
18
|
+
_ORANGE, _GREEN, _RED, _GRAY, _BLUE, _YELLOW,
|
|
19
|
+
RST, BOLD, DIM,
|
|
20
|
+
SYM_BULLET, SYM_THINK, SYM_OK, SYM_ERR, SYM_ARROW,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ── ANSI Colors ───────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
class C:
|
|
27
|
+
RESET = "\033[0m"
|
|
28
|
+
BOLD = "\033[1m"
|
|
29
|
+
DIM = "\033[2m"
|
|
30
|
+
ITALIC = "\033[3m"
|
|
31
|
+
CYAN = "\033[96m"
|
|
32
|
+
GREEN = "\033[92m"
|
|
33
|
+
YELLOW = "\033[93m"
|
|
34
|
+
RED = "\033[91m"
|
|
35
|
+
MAGENTA = "\033[95m"
|
|
36
|
+
BLUE = "\033[94m"
|
|
37
|
+
GRAY = "\033[90m"
|
|
38
|
+
WHITE = "\033[97m"
|
|
39
|
+
DIFF_ADD = "\033[92m"
|
|
40
|
+
DIFF_DEL = "\033[91m"
|
|
41
|
+
DIFF_HDR = "\033[96m"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ── Tool Call Parsing ─────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
TOOL_CALL_PATTERN = re.compile(
|
|
47
|
+
r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.DOTALL)
|
|
48
|
+
CODE_BLOCK_TOOL_PATTERN = re.compile(
|
|
49
|
+
r"```(?:json)?\s*(\{[^`]*?\"tool\"\s*:.*?\})\s*```", re.DOTALL)
|
|
50
|
+
BARE_JSON_TOOL_PATTERN = re.compile(
|
|
51
|
+
r'(\{\s*"tool"\s*:\s*"[^"]+"\s*,\s*"args"\s*:\s*\{.*?\}\s*\})', re.DOTALL)
|
|
52
|
+
FINAL_ANSWER_PATTERN = re.compile(
|
|
53
|
+
r"<final_answer>(.*?)</final_answer>", re.DOTALL)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class ParsedToolCall:
|
|
58
|
+
tool: str
|
|
59
|
+
args: dict
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _try_parse_json(raw: str) -> dict | None:
|
|
63
|
+
raw = raw.strip().replace("'", '"')
|
|
64
|
+
raw = re.sub(r',\s*}', '}', raw)
|
|
65
|
+
raw = re.sub(r',\s*]', ']', raw)
|
|
66
|
+
try:
|
|
67
|
+
data = json.loads(raw)
|
|
68
|
+
if isinstance(data, dict) and "tool" in data:
|
|
69
|
+
return data
|
|
70
|
+
except json.JSONDecodeError:
|
|
71
|
+
pass
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_all_tool_calls(text: str) -> list[ParsedToolCall]:
|
|
76
|
+
"""Parse ALL tool calls — enables parallel execution."""
|
|
77
|
+
calls = []
|
|
78
|
+
seen = set()
|
|
79
|
+
for pattern in [TOOL_CALL_PATTERN, CODE_BLOCK_TOOL_PATTERN, BARE_JSON_TOOL_PATTERN]:
|
|
80
|
+
for match in pattern.finditer(text):
|
|
81
|
+
data = _try_parse_json(match.group(1))
|
|
82
|
+
if data:
|
|
83
|
+
key = (data["tool"], json.dumps(data.get("args", {}), sort_keys=True))
|
|
84
|
+
if key not in seen:
|
|
85
|
+
seen.add(key)
|
|
86
|
+
calls.append(ParsedToolCall(tool=data["tool"], args=data.get("args", {})))
|
|
87
|
+
return calls
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_final_answer(text: str) -> str | None:
|
|
91
|
+
"""Extract final answer. Uses LAST match to skip cases where
|
|
92
|
+
the LLM mentions <final_answer> in its reasoning text."""
|
|
93
|
+
matches = list(FINAL_ANSWER_PATTERN.finditer(text))
|
|
94
|
+
if matches:
|
|
95
|
+
return matches[-1].group(1).strip()
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _strip_ansi(text: str) -> str:
|
|
100
|
+
return re.sub(r'\033\[[0-9;]*m', '', text)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _render_table(table_text: str) -> str:
|
|
104
|
+
"""Convert markdown table to clean aligned terminal text with brand colors.
|
|
105
|
+
- Headers: Neuron orange + bold
|
|
106
|
+
- Separator: thin orange dashes (capped width)
|
|
107
|
+
- Data rows: clean default color
|
|
108
|
+
- Max column width: 40 chars (truncate with ...)
|
|
109
|
+
"""
|
|
110
|
+
MAX_COL_WIDTH = 40
|
|
111
|
+
|
|
112
|
+
lines = table_text.strip().split('\n')
|
|
113
|
+
rows = []
|
|
114
|
+
for line in lines:
|
|
115
|
+
line = line.strip()
|
|
116
|
+
if not line or not line.startswith('|'):
|
|
117
|
+
continue
|
|
118
|
+
# Skip separator rows (|---|---|)
|
|
119
|
+
if re.match(r'^\|[\s:|\-]+\|$', line):
|
|
120
|
+
continue
|
|
121
|
+
cells = [c.strip() for c in line.strip('|').split('|')]
|
|
122
|
+
rows.append(cells)
|
|
123
|
+
if not rows:
|
|
124
|
+
return table_text
|
|
125
|
+
|
|
126
|
+
# Column count and capped widths
|
|
127
|
+
cols = max(len(r) for r in rows)
|
|
128
|
+
widths = [0] * cols
|
|
129
|
+
for row in rows:
|
|
130
|
+
for i, cell in enumerate(row):
|
|
131
|
+
if i < cols:
|
|
132
|
+
widths[i] = max(widths[i], min(len(cell), MAX_COL_WIDTH))
|
|
133
|
+
|
|
134
|
+
# Truncate helper
|
|
135
|
+
def fit(cell: str, w: int) -> str:
|
|
136
|
+
if len(cell) > w:
|
|
137
|
+
return cell[:w - 3] + "..."
|
|
138
|
+
return f"{cell:<{w}}"
|
|
139
|
+
|
|
140
|
+
# Render with brand colors
|
|
141
|
+
output = []
|
|
142
|
+
for idx, row in enumerate(rows):
|
|
143
|
+
parts = []
|
|
144
|
+
for i in range(cols):
|
|
145
|
+
cell = row[i] if i < len(row) else ''
|
|
146
|
+
w = widths[i]
|
|
147
|
+
if idx == 0: # Header row — brand orange + bold
|
|
148
|
+
parts.append(f"{_ORANGE}{BOLD}{fit(cell, w)}{RST}")
|
|
149
|
+
else: # Data rows
|
|
150
|
+
parts.append(fit(cell, w))
|
|
151
|
+
output.append(' ' + ' '.join(parts))
|
|
152
|
+
if idx == 0:
|
|
153
|
+
# Separator — thin orange line, per-column width
|
|
154
|
+
sep_parts = [f"{_GRAY}{'-' * w}{RST}" for w in widths]
|
|
155
|
+
output.append(' ' + ' '.join(sep_parts))
|
|
156
|
+
return '\n'.join(output)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _clean_for_display(text: str) -> str:
|
|
160
|
+
"""Strip XML tags, LLM reasoning, render markdown as ANSI.
|
|
161
|
+
Uses brand colors: orange headers, blue code, bold text."""
|
|
162
|
+
# Strip XML tags
|
|
163
|
+
text = re.sub(r'</?final_answer>', '', text)
|
|
164
|
+
text = re.sub(r'</?tool_call>', '', text)
|
|
165
|
+
text = re.sub(r'<[a-z_/][^>]*>', '', text)
|
|
166
|
+
text = re.sub(r'(?i)\b(tags? as instructed|as instructed)\.?\s*', '', text)
|
|
167
|
+
|
|
168
|
+
# Strip LLM reasoning preamble (lines starting with thinking patterns)
|
|
169
|
+
reasoning_patterns = [
|
|
170
|
+
r'^.*(?:So I should|I will|I need to|Let me|The user|I\'ll).*$',
|
|
171
|
+
r'^.*(?:block and no tool calls|respond directly).*$',
|
|
172
|
+
r'^.*(?:directly\."|just greet).*$',
|
|
173
|
+
]
|
|
174
|
+
for pat in reasoning_patterns:
|
|
175
|
+
text = re.sub(pat, '', text, flags=re.MULTILINE)
|
|
176
|
+
|
|
177
|
+
# Render markdown tables with brand colors
|
|
178
|
+
table_pattern = re.compile(r'((?:^\|.+\|$\n?){2,})', re.MULTILINE)
|
|
179
|
+
text = table_pattern.sub(lambda m: _render_table(m.group(1)), text)
|
|
180
|
+
|
|
181
|
+
# Headers — brand orange
|
|
182
|
+
text = re.sub(r'^#{1,3}\s+(.+)$',
|
|
183
|
+
lambda m: f"{_ORANGE}{BOLD}{m.group(1)}{RST}",
|
|
184
|
+
text, flags=re.MULTILINE)
|
|
185
|
+
# Bold — white bold
|
|
186
|
+
text = re.sub(r'\*\*(.+?)\*\*', f'{BOLD}\\1{RST}', text)
|
|
187
|
+
# Italic — dim
|
|
188
|
+
text = re.sub(r'(?<!\*)\*([^*]+)\*(?!\*)', f'{DIM}\\1{RST}', text)
|
|
189
|
+
# Inline code — brand cyan
|
|
190
|
+
text = re.sub(r'`([^`]+)`',
|
|
191
|
+
lambda m: f"{_BLUE}{m.group(1)}{RST}",
|
|
192
|
+
text)
|
|
193
|
+
# Bullets — brand orange dot
|
|
194
|
+
text = re.sub(r'^(\s*)- ',
|
|
195
|
+
lambda m: f"{m.group(1)} {_ORANGE}.{RST} ",
|
|
196
|
+
text, flags=re.MULTILINE)
|
|
197
|
+
# Numbered lists — orange number
|
|
198
|
+
text = re.sub(r'^(\s*)(\d+)\.\s',
|
|
199
|
+
lambda m: f"{m.group(1)} {_ORANGE}{m.group(2)}.{RST} ",
|
|
200
|
+
text, flags=re.MULTILINE)
|
|
201
|
+
# Clean extra blank lines
|
|
202
|
+
text = re.sub(r'\n{3,}', '\n\n', text)
|
|
203
|
+
return text.strip()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ── Tool Summaries ────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
def _tool_summary(name: str, args: dict, result: str) -> str:
|
|
209
|
+
if name == "read_file":
|
|
210
|
+
return f" Read {args.get('path', '?')} ({result.count(chr(10))} lines)"
|
|
211
|
+
elif name == "write_file":
|
|
212
|
+
lines = args.get('content', '').count('\n') + 1
|
|
213
|
+
return f" Wrote {args.get('path', '?')} ({lines} lines)"
|
|
214
|
+
elif name == "edit_file":
|
|
215
|
+
old_n = args.get('old_text', '').count('\n') + 1 if args.get('old_text') else 0
|
|
216
|
+
new_n = args.get('new_text', '').count('\n') + 1 if args.get('new_text') else 0
|
|
217
|
+
return f" Edited {args.get('path', '?')} (+{new_n} -{old_n})"
|
|
218
|
+
elif name == "run_command":
|
|
219
|
+
cmd = args.get('command', '?')
|
|
220
|
+
return f" Ran: {cmd[:57] + '...' if len(cmd) > 60 else cmd}"
|
|
221
|
+
elif name in ("list_directory", "get_project_structure"):
|
|
222
|
+
return f" Listed {args.get('path', '.')} ({result.count(chr(10))} items)"
|
|
223
|
+
elif name == "search_in_files":
|
|
224
|
+
return f" Searched \"{args.get('pattern', '?')}\" ({result.count(chr(10))} matches)"
|
|
225
|
+
return f" {name}()"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _truncate_result(result: str, max_chars: int = 4000) -> str:
|
|
229
|
+
"""Truncate tool results to keep context window lean."""
|
|
230
|
+
if len(result) <= max_chars:
|
|
231
|
+
return result
|
|
232
|
+
half = max_chars // 2
|
|
233
|
+
return result[:half] + f"\n\n... [{len(result) - max_chars} chars truncated] ...\n\n" + result[-half:]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _format_diff(path: str, old_text: str, new_text: str) -> str:
|
|
237
|
+
lines = [f"\n {C.DIFF_HDR}{C.BOLD} {path}{C.RESET}",
|
|
238
|
+
f" {C.DIM} {'─' * min(len(path) + 4, 50)}{C.RESET}"]
|
|
239
|
+
for line in (old_text.splitlines() if old_text else []):
|
|
240
|
+
lines.append(f" {C.DIFF_DEL} - {line}{C.RESET}")
|
|
241
|
+
for line in (new_text.splitlines() if new_text else []):
|
|
242
|
+
lines.append(f" {C.DIFF_ADD} + {line}{C.RESET}")
|
|
243
|
+
return "\n".join(lines)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _confirm_action(tool_name: str, tool_args: dict) -> bool:
|
|
247
|
+
"""Permission prompt for destructive operations."""
|
|
248
|
+
if tool_name == "edit_file":
|
|
249
|
+
old, new = tool_args.get("old_text", ""), tool_args.get("new_text", "")
|
|
250
|
+
print(_format_diff(tool_args.get("path", "?"), old, new))
|
|
251
|
+
prompt = f"\n {C.YELLOW}Apply this edit?{C.RESET} [Y/n] "
|
|
252
|
+
elif tool_name == "write_file":
|
|
253
|
+
prompt = f"\n {C.YELLOW}Write to {C.BOLD}{tool_args.get('path', '?')}{C.RESET}{C.YELLOW}?{C.RESET} [Y/n] "
|
|
254
|
+
elif tool_name == "run_command":
|
|
255
|
+
prompt = f"\n {C.YELLOW}Run: {C.BOLD}{tool_args.get('command', '?')}{C.RESET}{C.YELLOW}?{C.RESET} [Y/n] "
|
|
256
|
+
else:
|
|
257
|
+
return True
|
|
258
|
+
try:
|
|
259
|
+
r = input(prompt).strip().lower()
|
|
260
|
+
return r in ("", "y", "yes")
|
|
261
|
+
except (EOFError, KeyboardInterrupt):
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ── Context Compression (H2A-style) ──────────────────────────────
|
|
266
|
+
|
|
267
|
+
def _estimate_tokens(messages: list[ChatMessage]) -> int:
|
|
268
|
+
"""Rough token estimate: ~4 chars per token."""
|
|
269
|
+
return sum(len(m.content) for m in messages) // 4
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _compress_context(messages: list[ChatMessage], keep_system: bool = True) -> list[ChatMessage]:
|
|
273
|
+
"""H2A-style context compression:
|
|
274
|
+
Keep system prompt (head) + last 4 messages (tail).
|
|
275
|
+
Summarize everything in the middle.
|
|
276
|
+
"""
|
|
277
|
+
if len(messages) <= 6:
|
|
278
|
+
return messages
|
|
279
|
+
|
|
280
|
+
head = [messages[0]] if keep_system else [] # System prompt
|
|
281
|
+
tail = messages[-4:] # Last 4 messages (recent context)
|
|
282
|
+
middle = messages[1:-4] if keep_system else messages[:-4]
|
|
283
|
+
|
|
284
|
+
# Summarize middle section
|
|
285
|
+
tool_calls = sum(1 for m in middle if "<tool_call>" in m.content or "[Tool:" in m.content)
|
|
286
|
+
summary = (
|
|
287
|
+
f"[Context compressed: {len(middle)} messages summarized. "
|
|
288
|
+
f"{tool_calls} tool operations were performed. "
|
|
289
|
+
f"Key context is preserved in recent messages below.]"
|
|
290
|
+
)
|
|
291
|
+
compressed = head + [ChatMessage(role="user", content=summary)] + tail
|
|
292
|
+
return compressed
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ── Agent Data Types ──────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
@dataclass
|
|
298
|
+
class AgentStep:
|
|
299
|
+
step_number: int
|
|
300
|
+
thinking: str
|
|
301
|
+
tool_call: ParsedToolCall | None
|
|
302
|
+
tool_result: str
|
|
303
|
+
is_final: bool
|
|
304
|
+
final_answer: str = ""
|
|
305
|
+
elapsed: float = 0.0
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@dataclass
|
|
309
|
+
class AgentResult:
|
|
310
|
+
task: str
|
|
311
|
+
steps: list[AgentStep] = field(default_factory=list)
|
|
312
|
+
final_answer: str = ""
|
|
313
|
+
total_elapsed: float = 0.0
|
|
314
|
+
iterations_used: int = 0
|
|
315
|
+
aborted: bool = False
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# ── Agent Engine ──────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
class Agent:
|
|
321
|
+
"""ReAct agent with parallel tools, context compression, and mode support."""
|
|
322
|
+
|
|
323
|
+
def __init__(self, config: AgentConfig | None = None):
|
|
324
|
+
self.config = config or AgentConfig.from_env()
|
|
325
|
+
self.client = create_provider(self.config)
|
|
326
|
+
self.messages: list[ChatMessage] = []
|
|
327
|
+
self._slow_response_count = 0 # Track slow responses for upgrade nudge
|
|
328
|
+
self._total_input_tokens = 0
|
|
329
|
+
self._total_output_tokens = 0
|
|
330
|
+
self._git_enabled = is_git_repo(self.config.working_dir)
|
|
331
|
+
|
|
332
|
+
def run(self, task: str, neuron_md: str | None = None) -> AgentResult:
|
|
333
|
+
start_time = time.time()
|
|
334
|
+
result = AgentResult(task=task)
|
|
335
|
+
|
|
336
|
+
system_prompt = build_system_prompt(
|
|
337
|
+
tool_registry, self.config.working_dir,
|
|
338
|
+
mode=self.config.mode, neuron_md=neuron_md,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
# Preserve conversation history for follow-ups, but reset system prompt
|
|
342
|
+
if not self.messages:
|
|
343
|
+
self.messages = [ChatMessage(role="system", content=system_prompt)]
|
|
344
|
+
else:
|
|
345
|
+
self.messages[0] = ChatMessage(role="system", content=system_prompt)
|
|
346
|
+
|
|
347
|
+
self.messages.append(ChatMessage(role="user", content=task))
|
|
348
|
+
|
|
349
|
+
for iteration in range(1, self.config.max_iterations + 1):
|
|
350
|
+
step_start = time.time()
|
|
351
|
+
result.iterations_used = iteration
|
|
352
|
+
|
|
353
|
+
# ── Context compression check ─────────────────────
|
|
354
|
+
tokens_est = _estimate_tokens(self.messages)
|
|
355
|
+
if tokens_est > self.config.max_context_tokens * self.config.context_compress_percent:
|
|
356
|
+
self.messages = _compress_context(self.messages)
|
|
357
|
+
print(f" {C.DIM}/compact: context compressed{C.RESET}")
|
|
358
|
+
|
|
359
|
+
# ── Show thinking indicator (brand symbol) ───────
|
|
360
|
+
print(f"\r {SYM_THINK} {DIM}Thinking...{RST}", end="", flush=True)
|
|
361
|
+
|
|
362
|
+
# ── Get LLM response ──────────────────────────────
|
|
363
|
+
full_response = ""
|
|
364
|
+
try:
|
|
365
|
+
if self.config.streaming:
|
|
366
|
+
for token in self.client.chat_stream(self.messages):
|
|
367
|
+
full_response += token
|
|
368
|
+
# Dynamic status hint
|
|
369
|
+
clean = _strip_ansi(full_response).strip()
|
|
370
|
+
words = clean.split()[:6]
|
|
371
|
+
if words:
|
|
372
|
+
hint = " ".join(words)
|
|
373
|
+
if len(hint) > 50:
|
|
374
|
+
hint = hint[:47] + "..."
|
|
375
|
+
secs = time.time() - step_start
|
|
376
|
+
print(f"\r {SYM_THINK} {DIM}{hint}... ({secs:.0f}s){RST} ", end="", flush=True)
|
|
377
|
+
else:
|
|
378
|
+
full_response = self.client.chat(self.messages)
|
|
379
|
+
except Exception as exc:
|
|
380
|
+
print(f"\r {SYM_ERR} {exc}")
|
|
381
|
+
result.aborted = True
|
|
382
|
+
break
|
|
383
|
+
|
|
384
|
+
step_elapsed = time.time() - step_start
|
|
385
|
+
clean_response = _strip_ansi(full_response)
|
|
386
|
+
self.messages.append(ChatMessage(role="assistant", content=clean_response))
|
|
387
|
+
|
|
388
|
+
# ── Token tracking ────────────────────────────────
|
|
389
|
+
self._total_input_tokens += sum(len(m.content) for m in self.messages) // 4
|
|
390
|
+
self._total_output_tokens += len(clean_response) // 4
|
|
391
|
+
|
|
392
|
+
# ── Track slow responses for upgrade nudge ────────
|
|
393
|
+
if step_elapsed > 30:
|
|
394
|
+
self._slow_response_count += 1
|
|
395
|
+
|
|
396
|
+
# ── Check for final answer ────────────────────────
|
|
397
|
+
final = parse_final_answer(clean_response)
|
|
398
|
+
if final:
|
|
399
|
+
print(f"\r{' ' * 80}\r", end="")
|
|
400
|
+
display_text = _clean_for_display(final)
|
|
401
|
+
step = AgentStep(iteration, clean_response, None, "", True, final, step_elapsed)
|
|
402
|
+
result.steps.append(step)
|
|
403
|
+
result.final_answer = final
|
|
404
|
+
print(f"\n{display_text}\n")
|
|
405
|
+
break
|
|
406
|
+
|
|
407
|
+
# ── Check for tool calls (PARALLEL) ───────────────
|
|
408
|
+
all_tool_calls = parse_all_tool_calls(clean_response)
|
|
409
|
+
if all_tool_calls:
|
|
410
|
+
safe_calls = []
|
|
411
|
+
dangerous_calls = []
|
|
412
|
+
for tc in all_tool_calls:
|
|
413
|
+
if tc.tool in ("write_file", "edit_file", "run_command"):
|
|
414
|
+
dangerous_calls.append(tc)
|
|
415
|
+
else:
|
|
416
|
+
safe_calls.append(tc)
|
|
417
|
+
|
|
418
|
+
all_results = []
|
|
419
|
+
|
|
420
|
+
# Execute safe calls in PARALLEL
|
|
421
|
+
if safe_calls:
|
|
422
|
+
with ThreadPoolExecutor(max_workers=min(len(safe_calls), 4)) as pool:
|
|
423
|
+
futures = {
|
|
424
|
+
pool.submit(tool_registry.execute, tc.tool, tc.args, self.config): tc
|
|
425
|
+
for tc in safe_calls
|
|
426
|
+
}
|
|
427
|
+
for future in as_completed(futures):
|
|
428
|
+
tc = futures[future]
|
|
429
|
+
try:
|
|
430
|
+
tool_result = future.result()
|
|
431
|
+
except Exception as e:
|
|
432
|
+
tool_result = f"Error: {e}"
|
|
433
|
+
summary = _tool_summary(tc.tool, tc.args, tool_result)
|
|
434
|
+
print(f"\r{' ' * 80}\r{C.GREEN}{summary}{C.RESET}")
|
|
435
|
+
all_results.append((tc, tool_result))
|
|
436
|
+
|
|
437
|
+
# Execute dangerous calls sequentially
|
|
438
|
+
for tc in dangerous_calls:
|
|
439
|
+
yolo = self.config.mode == MODE_YOLO
|
|
440
|
+
|
|
441
|
+
if not yolo and self.config.needs_confirmation:
|
|
442
|
+
print(f"\r{' ' * 80}\r", end="")
|
|
443
|
+
if not _confirm_action(tc.tool, tc.args):
|
|
444
|
+
all_results.append((tc, "[User denied]"))
|
|
445
|
+
continue
|
|
446
|
+
|
|
447
|
+
tool_result = tool_registry.execute(tc.tool, tc.args, self.config)
|
|
448
|
+
summary = _tool_summary(tc.tool, tc.args, tool_result)
|
|
449
|
+
print(f"\r{' ' * 80}\r{C.GREEN}{summary}{C.RESET}")
|
|
450
|
+
|
|
451
|
+
# Show diff for edits (in YOLO mode, show after applying)
|
|
452
|
+
if tc.tool == "edit_file" and "done" in tool_result.lower():
|
|
453
|
+
old_text = tc.args.get("old_text", "")
|
|
454
|
+
new_text = tc.args.get("new_text", "")
|
|
455
|
+
if old_text and new_text and yolo:
|
|
456
|
+
print(_format_diff(tc.args.get("path", "?"), old_text, new_text))
|
|
457
|
+
|
|
458
|
+
all_results.append((tc, tool_result))
|
|
459
|
+
|
|
460
|
+
# Git auto-commit for write/edit operations
|
|
461
|
+
if self._git_enabled and tc.tool in ("write_file", "edit_file") and "[OK]" in tool_result:
|
|
462
|
+
file_path = tc.args.get("path", "")
|
|
463
|
+
commit_msg = f"{tc.tool.replace('_', ' ')}: {file_path}"
|
|
464
|
+
commit_hash = git_auto_commit(self.config.working_dir, [file_path], commit_msg)
|
|
465
|
+
if commit_hash:
|
|
466
|
+
print(f" {DIM}git: committed {commit_hash}{RST}")
|
|
467
|
+
|
|
468
|
+
# Build result message
|
|
469
|
+
result_parts = [f"[Tool: {tc.tool}] {_truncate_result(tr)}" for tc, tr in all_results]
|
|
470
|
+
combined = "\n---\n".join(result_parts)
|
|
471
|
+
self.messages.append(ChatMessage(role="user", content=(
|
|
472
|
+
f"{combined}\n\nAnalyze results. If done, give <final_answer>. "
|
|
473
|
+
f"Otherwise, call more tools (multiple allowed for parallel execution)."
|
|
474
|
+
)))
|
|
475
|
+
|
|
476
|
+
step = AgentStep(iteration, clean_response, all_tool_calls[0], combined, False, "", step_elapsed)
|
|
477
|
+
result.steps.append(step)
|
|
478
|
+
continue
|
|
479
|
+
|
|
480
|
+
# ── No tool call, no final answer ─────────────────
|
|
481
|
+
stripped = clean_response.strip()
|
|
482
|
+
if len(stripped) > 20 and not stripped.startswith('{') and 'tool' not in stripped[:30].lower():
|
|
483
|
+
display_text = _clean_for_display(stripped)
|
|
484
|
+
print(f"\r{' ' * 80}\r\n{display_text}\n")
|
|
485
|
+
step = AgentStep(iteration, clean_response, None, "", True, stripped, step_elapsed)
|
|
486
|
+
result.steps.append(step)
|
|
487
|
+
result.final_answer = stripped
|
|
488
|
+
break
|
|
489
|
+
|
|
490
|
+
# Nudge
|
|
491
|
+
self.messages.append(ChatMessage(role="user", content=(
|
|
492
|
+
"Provide a <final_answer> or use <tool_call> tags.")))
|
|
493
|
+
result.steps.append(AgentStep(iteration, clean_response, None, "", False, "", step_elapsed))
|
|
494
|
+
|
|
495
|
+
else:
|
|
496
|
+
result.aborted = True
|
|
497
|
+
print(f"\r{' ' * 80}\r {C.RED}X Max iterations reached.{C.RESET}")
|
|
498
|
+
if not result.final_answer:
|
|
499
|
+
result.final_answer = "Task was not completed within the iteration limit."
|
|
500
|
+
|
|
501
|
+
result.total_elapsed = time.time() - start_time
|
|
502
|
+
self._print_status(result)
|
|
503
|
+
|
|
504
|
+
# Upgrade nudge for slow responses
|
|
505
|
+
if self._slow_response_count >= 2 and self.config.provider == "openrouter":
|
|
506
|
+
print(f" {C.YELLOW}Tip:{C.RESET} {C.DIM}Responses are slow on the free tier. "
|
|
507
|
+
f"Run /upgrade for faster speed.{C.RESET}\n")
|
|
508
|
+
|
|
509
|
+
return result
|
|
510
|
+
|
|
511
|
+
def compact(self) -> int:
|
|
512
|
+
"""Manually compress context. Returns messages removed."""
|
|
513
|
+
before = len(self.messages)
|
|
514
|
+
self.messages = _compress_context(self.messages)
|
|
515
|
+
removed = before - len(self.messages)
|
|
516
|
+
return removed
|
|
517
|
+
|
|
518
|
+
def clear_history(self):
|
|
519
|
+
self.messages.clear()
|
|
520
|
+
self._slow_response_count = 0
|
|
521
|
+
|
|
522
|
+
def _print_status(self, result: AgentResult):
|
|
523
|
+
tools_used = [s.tool_call.tool for s in result.steps if s.tool_call]
|
|
524
|
+
parts = []
|
|
525
|
+
for tool_name, label in [("read_file", "read"), ("edit_file", "edited"),
|
|
526
|
+
("write_file", "wrote"), ("run_command", "ran"),
|
|
527
|
+
("search_in_files", "searched")]:
|
|
528
|
+
count = sum(1 for t in tools_used if t == tool_name)
|
|
529
|
+
if count:
|
|
530
|
+
noun = "files" if "file" in tool_name else ("commands" if "command" in tool_name else "patterns")
|
|
531
|
+
parts.append(f"{label} {count} {noun}")
|
|
532
|
+
|
|
533
|
+
summary = ", ".join(parts) if parts else "no tools used"
|
|
534
|
+
model = self.config.active_model.split("/")[-1]
|
|
535
|
+
mode_tag = f" [{self.config.mode}]" if self.config.mode != "standard" else ""
|
|
536
|
+
|
|
537
|
+
# Token count display
|
|
538
|
+
total_tok = self._total_output_tokens
|
|
539
|
+
if total_tok > 1000:
|
|
540
|
+
tok_str = f"~{total_tok / 1000:.1f}k tokens"
|
|
541
|
+
else:
|
|
542
|
+
tok_str = f"~{total_tok} tokens"
|
|
543
|
+
|
|
544
|
+
print(f" {_GRAY}{model}{mode_tag} {RST}{DIM}. {summary} . {result.total_elapsed:.1f}s . {tok_str} . $0.00{RST}\n")
|