nimcode 0.1.22__tar.gz → 0.3.0__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.
- {nimcode-0.1.22 → nimcode-0.3.0}/PKG-INFO +2 -3
- {nimcode-0.1.22 → nimcode-0.3.0}/setup.py +2 -3
- {nimcode-0.1.22 → nimcode-0.3.0}/src/nimcode/agent.py +25 -90
- {nimcode-0.1.22 → nimcode-0.3.0}/src/nimcode/cli.py +45 -48
- {nimcode-0.1.22 → nimcode-0.3.0}/src/nimcode/config.py +2 -2
- nimcode-0.3.0/src/nimcode/memory.py +69 -0
- {nimcode-0.1.22 → nimcode-0.3.0}/src/nimcode/nim_client.py +51 -66
- {nimcode-0.1.22 → nimcode-0.3.0}/src/nimcode/permissions.py +8 -34
- nimcode-0.3.0/src/nimcode/rag.py +121 -0
- {nimcode-0.1.22 → nimcode-0.3.0}/src/nimcode/repl.py +32 -18
- {nimcode-0.1.22 → nimcode-0.3.0}/src/nimcode/tools.py +53 -17
- {nimcode-0.1.22 → nimcode-0.3.0}/src/nimcode.egg-info/PKG-INFO +2 -3
- {nimcode-0.1.22 → nimcode-0.3.0}/src/nimcode.egg-info/SOURCES.txt +1 -9
- {nimcode-0.1.22 → nimcode-0.3.0}/src/nimcode.egg-info/requires.txt +1 -2
- {nimcode-0.1.22 → nimcode-0.3.0}/tests/test_agent.py +6 -7
- {nimcode-0.1.22 → nimcode-0.3.0}/tests/test_cli.py +13 -25
- {nimcode-0.1.22 → nimcode-0.3.0}/tests/test_memory.py +4 -4
- {nimcode-0.1.22 → nimcode-0.3.0}/tests/test_permissions.py +29 -31
- nimcode-0.3.0/tests/test_plugin_manager.py +53 -0
- {nimcode-0.1.22 → nimcode-0.3.0}/tests/test_repl.py +9 -6
- {nimcode-0.1.22 → nimcode-0.3.0}/tests/test_tools.py +13 -3
- nimcode-0.3.0/tests/test_updater.py +41 -0
- nimcode-0.1.22/src/nimcode/analytics.py +0 -68
- nimcode-0.1.22/src/nimcode/memory.py +0 -140
- nimcode-0.1.22/src/nimcode/repo_map.py +0 -67
- nimcode-0.1.22/src/nimcode/secret_scanner.py +0 -48
- nimcode-0.1.22/src/nimcode/stdio_server.py +0 -163
- nimcode-0.1.22/src/nimcode/swarm.py +0 -42
- nimcode-0.1.22/tests/test_analytics.py +0 -22
- nimcode-0.1.22/tests/test_nimcoderules.py +0 -19
- nimcode-0.1.22/tests/test_plugin_manager.py +0 -35
- nimcode-0.1.22/tests/test_repo_map.py +0 -22
- nimcode-0.1.22/tests/test_secret_scanner.py +0 -31
- nimcode-0.1.22/tests/test_updater.py +0 -39
- {nimcode-0.1.22 → nimcode-0.3.0}/README.md +0 -0
- {nimcode-0.1.22 → nimcode-0.3.0}/setup.cfg +0 -0
- {nimcode-0.1.22 → nimcode-0.3.0}/src/nimcode/__init__.py +0 -0
- {nimcode-0.1.22 → nimcode-0.3.0}/src/nimcode/lenient_parser.py +0 -0
- {nimcode-0.1.22 → nimcode-0.3.0}/src/nimcode/mcp_client.py +0 -0
- {nimcode-0.1.22 → nimcode-0.3.0}/src/nimcode/plugin_manager.py +0 -0
- {nimcode-0.1.22 → nimcode-0.3.0}/src/nimcode/updater.py +0 -0
- {nimcode-0.1.22 → nimcode-0.3.0}/src/nimcode/watcher.py +0 -0
- {nimcode-0.1.22 → nimcode-0.3.0}/src/nimcode.egg-info/dependency_links.txt +0 -0
- {nimcode-0.1.22 → nimcode-0.3.0}/src/nimcode.egg-info/entry_points.txt +0 -0
- {nimcode-0.1.22 → nimcode-0.3.0}/src/nimcode.egg-info/top_level.txt +0 -0
- {nimcode-0.1.22 → nimcode-0.3.0}/tests/test_config.py +0 -0
- {nimcode-0.1.22 → nimcode-0.3.0}/tests/test_lenient_parser.py +0 -0
- {nimcode-0.1.22 → nimcode-0.3.0}/tests/test_mcp_client.py +0 -0
- {nimcode-0.1.22 → nimcode-0.3.0}/tests/test_nim_client.py +0 -0
- {nimcode-0.1.22 → nimcode-0.3.0}/tests/test_repl_extra.py +0 -0
- {nimcode-0.1.22 → nimcode-0.3.0}/tests/test_repl_trust.py +0 -0
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nimcode
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A standalone, robust coding agent for NVIDIA NIM models.
|
|
5
5
|
Author: Autonomous Agent
|
|
6
6
|
Requires-Dist: httpx>=0.27.0
|
|
7
7
|
Requires-Dist: rich>=13.7.0
|
|
8
8
|
Requires-Dist: prompt_toolkit>=3.0.0
|
|
9
9
|
Requires-Dist: mcp>=1.2.0
|
|
10
|
-
Requires-Dist:
|
|
11
|
-
Requires-Dist: watchdog>=4.0.0
|
|
10
|
+
Requires-Dist: watchdog>=3.0.0
|
|
12
11
|
Dynamic: author
|
|
13
12
|
Dynamic: requires-dist
|
|
14
13
|
Dynamic: summary
|
|
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
|
|
|
2
2
|
|
|
3
3
|
setup(
|
|
4
4
|
name="nimcode",
|
|
5
|
-
version="0.
|
|
5
|
+
version="0.3.0",
|
|
6
6
|
packages=find_packages(where="src"),
|
|
7
7
|
package_dir={"": "src"},
|
|
8
8
|
install_requires=[
|
|
@@ -10,8 +10,7 @@ setup(
|
|
|
10
10
|
"rich>=13.7.0",
|
|
11
11
|
"prompt_toolkit>=3.0.0",
|
|
12
12
|
"mcp>=1.2.0",
|
|
13
|
-
"
|
|
14
|
-
"watchdog>=4.0.0"
|
|
13
|
+
"watchdog>=3.0.0"
|
|
15
14
|
],
|
|
16
15
|
entry_points={
|
|
17
16
|
"console_scripts": [
|
|
@@ -8,7 +8,6 @@ from .permissions import PermissionEngine, PermissionMode
|
|
|
8
8
|
from .config import load_settings, save_global_setting
|
|
9
9
|
from .mcp_client import MCPManager
|
|
10
10
|
from .memory import MemoryManager
|
|
11
|
-
from .analytics import AnalyticsEngine
|
|
12
11
|
|
|
13
12
|
logger = logging.getLogger(__name__)
|
|
14
13
|
|
|
@@ -28,7 +27,7 @@ Available Tools:
|
|
|
28
27
|
- Bash: {"tool": "Bash", "args": {"command": "string"}}
|
|
29
28
|
- Read: {"tool": "Read", "args": {"file_path": "string"}}
|
|
30
29
|
- Write: {"tool": "Write", "args": {"file_path": "string", "content": "string"}}
|
|
31
|
-
-
|
|
30
|
+
- Edit: {"tool": "Edit", "args": {"file_path": "string", "old_string": "string", "new_string": "string"}}
|
|
32
31
|
- Glob: {"tool": "Glob", "args": {"pattern": "string"}}
|
|
33
32
|
- Grep: {"tool": "Grep", "args": {"query": "string", "directory": "string"}}
|
|
34
33
|
|
|
@@ -49,35 +48,19 @@ When you have completely fulfilled the user's request and have no more tools to
|
|
|
49
48
|
"""
|
|
50
49
|
|
|
51
50
|
class Agent:
|
|
52
|
-
def __init__(self, api_key: str, model: str = None, max_turns: int = 30, permission_mode: PermissionMode = PermissionMode.DEFAULT, max_tokens: int =
|
|
51
|
+
def __init__(self, api_key: str, model: str = None, max_turns: int = 30, permission_mode: PermissionMode = PermissionMode.DEFAULT, max_tokens: int = 4000):
|
|
53
52
|
# Load global settings
|
|
54
53
|
self.settings = load_settings()
|
|
55
54
|
self.model = model or self.settings.get("model", "meta/llama-3.1-70b-instruct")
|
|
56
|
-
|
|
57
|
-
self.
|
|
58
|
-
self.client = NimClient(api_key=api_key, base_url=self.base_url, model=self.model, is_local=self.is_local)
|
|
55
|
+
api_base_url = self.settings.get("api_base_url", "https://integrate.api.nvidia.com/v1")
|
|
56
|
+
self.client = NimClient(api_key=api_key, base_url=api_base_url, model=self.model)
|
|
59
57
|
|
|
60
58
|
# Initialize MCP Manager
|
|
61
59
|
self.mcp = MCPManager(self.settings)
|
|
62
|
-
self.analytics = AnalyticsEngine()
|
|
63
|
-
self.memory = MemoryManager(model_name=model, fallback_max_tokens=max_tokens)
|
|
64
60
|
|
|
65
61
|
# Base system prompt
|
|
66
62
|
final_prompt = SYSTEM_PROMPT + self.mcp.get_system_prompt_additions()
|
|
67
63
|
|
|
68
|
-
# We inject the Repo Map dynamically during the first run to avoid blocking startup.
|
|
69
|
-
|
|
70
|
-
# Load .nimcoderules if present
|
|
71
|
-
rules_path = os.path.join(os.getcwd(), ".nimcoderules")
|
|
72
|
-
if os.path.exists(rules_path):
|
|
73
|
-
try:
|
|
74
|
-
with open(rules_path, "r", encoding="utf-8") as f:
|
|
75
|
-
rules = f.read()
|
|
76
|
-
final_prompt += f"\n\nPROJECT-SPECIFIC RULES (.nimcoderules):\n{rules}\n"
|
|
77
|
-
logger.info("Loaded .nimcoderules")
|
|
78
|
-
except Exception as e:
|
|
79
|
-
logger.error(f"Failed to load .nimcoderules: {e}")
|
|
80
|
-
|
|
81
64
|
# Load skills if present
|
|
82
65
|
skills_dir = os.path.join(os.getcwd(), ".nimcode", "skills")
|
|
83
66
|
if os.path.exists(skills_dir) and os.path.isdir(skills_dir):
|
|
@@ -99,18 +82,13 @@ class Agent:
|
|
|
99
82
|
final_prompt += f"\n\nGIT CONTEXT:\nBranch: {branch}\nUncommitted changes:\n{status if status else 'None'}"
|
|
100
83
|
except Exception as e:
|
|
101
84
|
logger.error(f"Failed to load git context: {e}")
|
|
102
|
-
|
|
103
|
-
# Repo Map
|
|
104
|
-
repo_map = self._generate_repo_map(os.getcwd())
|
|
105
|
-
if repo_map:
|
|
106
|
-
final_prompt += f"\n\nREPOSITORY MAP:\n{repo_map}"
|
|
107
85
|
|
|
108
86
|
self.messages: List[Dict[str, Any]] = [
|
|
109
87
|
{"role": "system", "content": final_prompt}
|
|
110
88
|
]
|
|
111
89
|
self.max_turns = max_turns
|
|
112
90
|
self.permission_engine = PermissionEngine(mode=permission_mode)
|
|
113
|
-
self.memory = MemoryManager(
|
|
91
|
+
self.memory = MemoryManager(max_tokens=max_tokens)
|
|
114
92
|
|
|
115
93
|
def save_history(self):
|
|
116
94
|
"""Saves current conversation to NIMCODE.md"""
|
|
@@ -131,28 +109,6 @@ class Agent:
|
|
|
131
109
|
except Exception as e:
|
|
132
110
|
logger.error(f"Failed to load history: {e}")
|
|
133
111
|
|
|
134
|
-
def _generate_repo_map(self, cwd: str) -> str:
|
|
135
|
-
"""Generates a fast, lightweight file tree map."""
|
|
136
|
-
ignore_dirs = {'.git', 'node_modules', '__pycache__', '.venv', 'venv', 'env', '.nimcode'}
|
|
137
|
-
tree = []
|
|
138
|
-
for root, dirs, files in os.walk(cwd):
|
|
139
|
-
dirs[:] = [d for d in dirs if d not in ignore_dirs and not d.startswith('.')]
|
|
140
|
-
level = root.replace(cwd, '').count(os.sep)
|
|
141
|
-
indent = ' ' * 4 * level
|
|
142
|
-
basename = os.path.basename(root)
|
|
143
|
-
if basename:
|
|
144
|
-
tree.append(f"{indent}{basename}/")
|
|
145
|
-
subindent = ' ' * 4 * (level + 1)
|
|
146
|
-
for f in files:
|
|
147
|
-
if not f.endswith('.pyc') and not f.startswith('.'):
|
|
148
|
-
tree.append(f"{subindent}{f}")
|
|
149
|
-
|
|
150
|
-
# Limit to 500 lines to save context
|
|
151
|
-
if len(tree) > 500:
|
|
152
|
-
tree = tree[:500] + ["... (truncated for context limit)"]
|
|
153
|
-
|
|
154
|
-
return "\n".join(tree)
|
|
155
|
-
|
|
156
112
|
async def _stream_response(self) -> str:
|
|
157
113
|
from rich.live import Live
|
|
158
114
|
from rich.markdown import Markdown
|
|
@@ -215,40 +171,33 @@ class Agent:
|
|
|
215
171
|
live.update(Markdown(response_text + "\n\n*[yellow]Stream interrupted by user.[/yellow]*", code_theme=code_theme))
|
|
216
172
|
c.print("\n[yellow]Generation interrupted.[/yellow]")
|
|
217
173
|
|
|
218
|
-
#
|
|
219
|
-
|
|
220
|
-
|
|
174
|
+
# Approximate Token Tracker Update
|
|
175
|
+
est_tokens = len(response_text) // 4
|
|
176
|
+
if not hasattr(self, "session_tokens"):
|
|
177
|
+
self.session_tokens = 0
|
|
178
|
+
self.session_tokens += est_tokens
|
|
221
179
|
|
|
222
|
-
|
|
180
|
+
# Approximate cost based on 70B typical rates ($3/1M tokens)
|
|
181
|
+
cost = (self.session_tokens / 1000000) * 3.0
|
|
182
|
+
c.print(f"[dim]Output est. tokens: {est_tokens} | Session Cost: ~${cost:.4f}[/dim]")
|
|
223
183
|
|
|
224
|
-
#
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
184
|
+
# Context usage warning
|
|
185
|
+
import json
|
|
186
|
+
total_context_chars = sum(len(str(m.get("content", ""))) for m in self.messages)
|
|
187
|
+
total_est_tokens = total_context_chars // 4
|
|
188
|
+
max_context = 128000 # Assume standard Llama-3.1 128k context for now
|
|
189
|
+
if total_est_tokens > max_context * 0.8:
|
|
190
|
+
c.print("[bold yellow]⚠️ Context window is over 80% full. Consider running /compact or /clear.[/bold yellow]")
|
|
228
191
|
|
|
229
192
|
return response_text
|
|
230
193
|
|
|
231
194
|
async def run_headless(self, query: str, max_turns: int = 5) -> str:
|
|
232
195
|
"""Runs the agent without terminal streaming, useful for background tasks."""
|
|
233
|
-
if len(self.messages) == 1:
|
|
234
|
-
try:
|
|
235
|
-
from .repo_map import RepoMapper
|
|
236
|
-
import asyncio
|
|
237
|
-
mapper = RepoMapper(os.getcwd())
|
|
238
|
-
repo_map = await asyncio.to_thread(mapper.generate_map)
|
|
239
|
-
self.messages[0]["content"] += f"\n\n--- REPOSITORY MAP ---\n{repo_map}\n----------------------\n"
|
|
240
|
-
except Exception as e:
|
|
241
|
-
logger.error(f"Failed to generate repo map: {e}")
|
|
242
|
-
|
|
243
196
|
self.messages.append({"role": "user", "content": query})
|
|
244
197
|
turn = 0
|
|
245
198
|
from .tools import ToolRegistry
|
|
246
199
|
while turn < max_turns:
|
|
247
200
|
turn += 1
|
|
248
|
-
if self.memory.count_messages_tokens(self.messages) > (self.memory.max_tokens * 0.8):
|
|
249
|
-
logger.info("Context full in headless mode. Distilling memory via LLM...")
|
|
250
|
-
self.messages = await self._distill_memory()
|
|
251
|
-
|
|
252
201
|
try:
|
|
253
202
|
response_text = await self.client.chat_one_shot(self.messages)
|
|
254
203
|
except Exception as e:
|
|
@@ -285,14 +234,9 @@ class Agent:
|
|
|
285
234
|
|
|
286
235
|
messages = [{"role": "system", "content": "You are a summarization AI."}, {"role": "user", "content": summary_prompt}]
|
|
287
236
|
try:
|
|
288
|
-
# Use a robust default model for distillation to prevent 404 errors with unsupported endpoints
|
|
289
|
-
original_model = self.client.model
|
|
290
|
-
self.client.model = "meta/llama-3.1-8b-instruct"
|
|
291
237
|
summary = await self.client.chat_one_shot(messages)
|
|
292
|
-
self.client.model = original_model
|
|
293
238
|
system_msg["content"] += f"\n\n[PREVIOUS MEMORY SUMMARY]\n{summary}"
|
|
294
239
|
except Exception as e:
|
|
295
|
-
self.client.model = original_model
|
|
296
240
|
logger.error(f"Distillation failed: {e}")
|
|
297
241
|
|
|
298
242
|
return [system_msg] + recent_msgs
|
|
@@ -302,18 +246,6 @@ class Agent:
|
|
|
302
246
|
if hasattr(self.mcp, "connect_all"):
|
|
303
247
|
await self.mcp.connect_all()
|
|
304
248
|
|
|
305
|
-
if len(self.messages) == 1:
|
|
306
|
-
try:
|
|
307
|
-
from rich.console import Console
|
|
308
|
-
Console().print("[dim italic]Indexing repository...[/dim italic]")
|
|
309
|
-
from .repo_map import RepoMapper
|
|
310
|
-
import asyncio
|
|
311
|
-
mapper = RepoMapper(os.getcwd())
|
|
312
|
-
repo_map = await asyncio.to_thread(mapper.generate_map)
|
|
313
|
-
self.messages[0]["content"] += f"\n\n--- REPOSITORY MAP ---\n{repo_map}\n----------------------\n"
|
|
314
|
-
except Exception as e:
|
|
315
|
-
logger.error(f"Failed to generate repo map: {e}")
|
|
316
|
-
|
|
317
249
|
if initial_prompt:
|
|
318
250
|
self.messages.append({"role": "user", "content": initial_prompt})
|
|
319
251
|
|
|
@@ -325,7 +257,7 @@ class Agent:
|
|
|
325
257
|
|
|
326
258
|
# Compact context before calling API
|
|
327
259
|
from rich.console import Console
|
|
328
|
-
if self.memory.count_messages_tokens(self.messages) >
|
|
260
|
+
if self.memory.count_messages_tokens(self.messages) > self.memory.max_tokens:
|
|
329
261
|
logger.info("Context full. Distilling memory via LLM...")
|
|
330
262
|
Console().print("[dim italic]🧠 Context full. Distilling memory into a summary to save tokens...[/dim italic]")
|
|
331
263
|
self.messages = await self._distill_memory()
|
|
@@ -358,6 +290,9 @@ class Agent:
|
|
|
358
290
|
|
|
359
291
|
if not tool_calls:
|
|
360
292
|
# Model responded with plain text but didn't say TASK_COMPLETE.
|
|
293
|
+
if "[Error: Model API returned" in full_content or "[Error communicating with" in full_content:
|
|
294
|
+
break
|
|
295
|
+
|
|
361
296
|
self.messages.append({"role": "user", "content": "Please continue. Use a tool or output TASK_COMPLETE."})
|
|
362
297
|
continue
|
|
363
298
|
|
|
@@ -366,7 +301,7 @@ class Agent:
|
|
|
366
301
|
tool_name = tool_call.get("tool", "Unknown")
|
|
367
302
|
|
|
368
303
|
logger.info(f"Checking permissions for tool: {tool_name}")
|
|
369
|
-
if not
|
|
304
|
+
if not self.permission_engine.check_permission(tool_call):
|
|
370
305
|
self.messages.append({
|
|
371
306
|
"role": "user",
|
|
372
307
|
"content": f"User explicitly denied permission to execute {tool_name}. Please choose another approach."
|
|
@@ -14,20 +14,13 @@ def run_login():
|
|
|
14
14
|
console.print("Get your API key from [bold underline blue]https://build.nvidia.com/[/bold underline blue]")
|
|
15
15
|
|
|
16
16
|
import getpass
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
try:
|
|
20
|
-
api_key = getpass.getpass("Enter your NVIDIA NIM API Key: ")
|
|
21
|
-
except (EOFError, Exception):
|
|
22
|
-
console.print("[red]Could not prompt for API key. Please set NIM_API_KEY environment variable or run 'nimcode login' in a regular terminal.[/red]")
|
|
23
|
-
sys.exit(1)
|
|
24
|
-
|
|
17
|
+
api_key = getpass.getpass("Enter your NVIDIA NIM API Key: ")
|
|
25
18
|
if not api_key.strip():
|
|
26
19
|
console.print("[red]API Key cannot be empty.[/red]")
|
|
27
20
|
return
|
|
28
21
|
|
|
29
22
|
save_global_setting("api_key", api_key.strip())
|
|
30
|
-
console.print("[green][OK] API Key saved
|
|
23
|
+
console.print("[green][OK] API Key saved successfully to ~/.nimcode/settings.json[/green]")
|
|
31
24
|
|
|
32
25
|
def run_doctor():
|
|
33
26
|
console.print("[bold cyan]NimCode Doctor[/bold cyan] - Diagnostics")
|
|
@@ -63,7 +56,19 @@ def install_hook():
|
|
|
63
56
|
os.chmod(hook_path, os.stat(hook_path).st_mode | stat.S_IEXEC)
|
|
64
57
|
console.print(f"[green][OK][/green] Git hook installed to {hook_path}")
|
|
65
58
|
|
|
59
|
+
def _silence_anyio_errors():
|
|
60
|
+
import sys
|
|
61
|
+
original_hook = sys.unraisablehook
|
|
62
|
+
def custom_unraisablehook(unraisable):
|
|
63
|
+
if unraisable.exc_type == RuntimeError and "exit cancel scope in a different task" in str(unraisable.exc_value):
|
|
64
|
+
return
|
|
65
|
+
if unraisable.exc_type == BaseExceptionGroup and "unhandled errors in a TaskGroup" in str(unraisable.exc_value):
|
|
66
|
+
return
|
|
67
|
+
original_hook(unraisable)
|
|
68
|
+
sys.unraisablehook = custom_unraisablehook
|
|
69
|
+
|
|
66
70
|
def main():
|
|
71
|
+
_silence_anyio_errors()
|
|
67
72
|
parser = argparse.ArgumentParser(description="NimCode: Autonomous Coding Agent for NVIDIA NIM APIs")
|
|
68
73
|
|
|
69
74
|
# Check for doctor manually to avoid subparser conflict
|
|
@@ -74,25 +79,27 @@ def main():
|
|
|
74
79
|
elif sys.argv[1] == "install-hook":
|
|
75
80
|
install_hook()
|
|
76
81
|
return
|
|
77
|
-
elif sys.argv[1] == "login"
|
|
82
|
+
elif sys.argv[1] == "login":
|
|
78
83
|
run_login()
|
|
79
84
|
return
|
|
80
85
|
|
|
81
86
|
# Main CLI arguments
|
|
82
87
|
parser.add_argument("prompt", nargs="?", default=None, help="The task you want NimCode to accomplish. If omitted, starts interactive REPL.")
|
|
83
88
|
parser.add_argument("--api-key", "-k", default=None, help="NVIDIA NIM API Key. Can also be set via NIM_API_KEY environment variable.")
|
|
84
|
-
parser.add_argument("--model", "-m", default=
|
|
89
|
+
parser.add_argument("--model", "-m", default="meta/llama-3.1-70b-instruct", help="Model ID to use from NIM.")
|
|
85
90
|
parser.add_argument("--max-turns", "-t", type=int, default=30, help="Maximum number of turns the agent is allowed to run.")
|
|
86
91
|
parser.add_argument("--permission-mode", "-p", type=PermissionMode, choices=list(PermissionMode), default=PermissionMode.DEFAULT, help="Permission mode for mutating tools.")
|
|
87
92
|
parser.add_argument("--resume", "-r", action="store_true", help="Resume from the last session stored in NIMCODE.md.")
|
|
88
|
-
parser.add_argument("--stdio", action="store_true", help="Run NimCode as a JSON-RPC server over stdio for IDE integration.")
|
|
89
93
|
|
|
90
94
|
args = parser.parse_args()
|
|
91
95
|
|
|
92
96
|
settings = load_settings()
|
|
97
|
+
api_base_url = settings.get("api_base_url", "https://integrate.api.nvidia.com/v1")
|
|
93
98
|
|
|
94
99
|
final_key = args.api_key or os.environ.get("NIM_API_KEY") or settings.get("api_key")
|
|
95
|
-
|
|
100
|
+
is_local = "localhost" in api_base_url or "127.0.0.1" in api_base_url
|
|
101
|
+
|
|
102
|
+
if not final_key and not is_local:
|
|
96
103
|
console.print("[yellow]No API Key found. Let's get you set up![/yellow]")
|
|
97
104
|
run_login()
|
|
98
105
|
settings = load_settings()
|
|
@@ -100,32 +107,12 @@ def main():
|
|
|
100
107
|
if not final_key:
|
|
101
108
|
console.print("[bold red]API Key is required to use NimCode. Exiting.[/bold red]")
|
|
102
109
|
sys.exit(1)
|
|
103
|
-
|
|
104
|
-
model = args.model or settings.get("model")
|
|
105
|
-
if not model and not args.stdio:
|
|
106
|
-
from rich.prompt import Prompt
|
|
107
|
-
from .nim_client import NimClient
|
|
108
|
-
client = NimClient(api_key=final_key)
|
|
109
|
-
console.print("[yellow]Fetching available models from NVIDIA NIM...[/yellow]")
|
|
110
|
-
models = asyncio.run(client.get_available_models())
|
|
111
|
-
console.print("\n[bold cyan]Available Models:[/bold cyan]")
|
|
112
|
-
for i, m in enumerate(models):
|
|
113
|
-
console.print(f"[{i+1}] {m}")
|
|
114
|
-
|
|
115
|
-
choice = Prompt.ask("Select a model number", choices=[str(i) for i in range(1, len(models)+1)], default="1")
|
|
116
|
-
model = models[int(choice) - 1]
|
|
117
|
-
|
|
118
|
-
from .config import save_global_setting
|
|
119
|
-
if Prompt.ask("Save this model as default?", choices=["y", "n"], default="y") == "y":
|
|
120
|
-
save_global_setting("model", model)
|
|
121
|
-
console.print(f"[green]Saved {model} as default model.[/green]")
|
|
122
110
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
111
|
+
final_key = final_key or "local-dummy-key"
|
|
112
|
+
|
|
126
113
|
agent = Agent(
|
|
127
114
|
api_key=final_key,
|
|
128
|
-
model=model,
|
|
115
|
+
model=args.model,
|
|
129
116
|
max_turns=args.max_turns,
|
|
130
117
|
permission_mode=args.permission_mode
|
|
131
118
|
)
|
|
@@ -134,15 +121,29 @@ def main():
|
|
|
134
121
|
# Load from history if possible
|
|
135
122
|
agent.load_history()
|
|
136
123
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
124
|
+
async def safe_start_repl(repl):
|
|
125
|
+
loop = asyncio.get_running_loop()
|
|
126
|
+
def custom_exception_handler(loop, context):
|
|
127
|
+
msg = context.get("message", "")
|
|
128
|
+
exception = context.get("exception", None)
|
|
129
|
+
if "unhandled errors in a TaskGroup" in str(msg) or "unhandled errors in a TaskGroup" in str(exception):
|
|
130
|
+
return
|
|
131
|
+
if exception and isinstance(exception, RuntimeError) and "exit cancel scope in a different task" in str(exception):
|
|
132
|
+
return
|
|
133
|
+
if "asynchronous generator" in str(msg):
|
|
134
|
+
return
|
|
135
|
+
loop.default_exception_handler(context)
|
|
136
|
+
loop.set_exception_handler(custom_exception_handler)
|
|
137
|
+
|
|
140
138
|
try:
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
piped_input = sys.stdin.read().strip()
|
|
144
|
-
except Exception:
|
|
139
|
+
await repl.start_repl()
|
|
140
|
+
except asyncio.CancelledError:
|
|
145
141
|
pass
|
|
142
|
+
|
|
143
|
+
piped_input = None
|
|
144
|
+
if not sys.stdin.isatty():
|
|
145
|
+
piped_input = sys.stdin.read().strip()
|
|
146
|
+
|
|
146
147
|
if piped_input:
|
|
147
148
|
console.print(f"[bold green]Starting NimCode[/bold green] with model [cyan]{args.model}[/cyan]")
|
|
148
149
|
prompt = f"{piped_input}\n\n{args.prompt or ''}".strip()
|
|
@@ -152,14 +153,10 @@ def main():
|
|
|
152
153
|
console.print(f"[bold green]Starting NimCode[/bold green] with model [cyan]{args.model}[/cyan]")
|
|
153
154
|
console.print(f"Task: {args.prompt}")
|
|
154
155
|
asyncio.run(agent.run(args.prompt))
|
|
155
|
-
elif args.stdio:
|
|
156
|
-
from .stdio_server import StdioServer
|
|
157
|
-
server = StdioServer(agent)
|
|
158
|
-
asyncio.run(server.start())
|
|
159
156
|
else:
|
|
160
157
|
from .repl import NimcodeREPL
|
|
161
158
|
repl = NimcodeREPL(agent)
|
|
162
|
-
asyncio.run(repl
|
|
159
|
+
asyncio.run(safe_start_repl(repl))
|
|
163
160
|
|
|
164
161
|
# We don't print "Done!" for REPL to keep it clean on exit
|
|
165
162
|
if args.prompt or piped_input:
|
|
@@ -9,6 +9,7 @@ def load_settings() -> Dict[str, Any]:
|
|
|
9
9
|
"""Loads configuration from ~/.nimcode/settings.json and .nimcode/settings.json"""
|
|
10
10
|
settings = {
|
|
11
11
|
"model": "meta/llama-3.1-70b-instruct",
|
|
12
|
+
"api_base_url": "https://integrate.api.nvidia.com/v1",
|
|
12
13
|
"mcp_servers": {}
|
|
13
14
|
}
|
|
14
15
|
|
|
@@ -21,8 +22,7 @@ def load_settings() -> Dict[str, Any]:
|
|
|
21
22
|
settings.update(global_settings)
|
|
22
23
|
except Exception as e:
|
|
23
24
|
logger.error(f"Failed to load global settings: {e}")
|
|
24
|
-
|
|
25
|
-
# Keyring caused 30-second hangs on Windows, so we no longer scrub api_key from settings.
|
|
25
|
+
|
|
26
26
|
# Local settings
|
|
27
27
|
local_path = os.path.join(os.getcwd(), ".nimcode", "settings.json")
|
|
28
28
|
if os.path.exists(local_path):
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import List, Dict, Any
|
|
3
|
+
|
|
4
|
+
class MemoryManager:
|
|
5
|
+
def __init__(self, max_tokens: int = 4000):
|
|
6
|
+
# We assume 1 token ~= 4 chars roughly
|
|
7
|
+
self.max_tokens = max_tokens
|
|
8
|
+
|
|
9
|
+
@staticmethod
|
|
10
|
+
def count_tokens(text: str) -> int:
|
|
11
|
+
"""Roughly count tokens in a string."""
|
|
12
|
+
if not text:
|
|
13
|
+
return 0
|
|
14
|
+
return len(text) // 4 + 1
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def count_messages_tokens(cls, messages: List[Dict[str, Any]]) -> int:
|
|
18
|
+
total = 0
|
|
19
|
+
for msg in messages:
|
|
20
|
+
content = msg.get("content", "")
|
|
21
|
+
total += cls.count_tokens(content)
|
|
22
|
+
return total
|
|
23
|
+
|
|
24
|
+
def compact_context(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
25
|
+
"""
|
|
26
|
+
If messages exceed max_tokens, drops the oldest messages.
|
|
27
|
+
Always keeps the System prompt (first message).
|
|
28
|
+
Always keeps the most recent user prompt.
|
|
29
|
+
"""
|
|
30
|
+
total_tokens = self.count_messages_tokens(messages)
|
|
31
|
+
if total_tokens <= self.max_tokens:
|
|
32
|
+
return messages
|
|
33
|
+
|
|
34
|
+
# We need to compact
|
|
35
|
+
if not messages:
|
|
36
|
+
return messages
|
|
37
|
+
|
|
38
|
+
compacted = [messages[0]] # System prompt
|
|
39
|
+
remaining_messages = messages[1:]
|
|
40
|
+
|
|
41
|
+
# We start from the end and add backwards until we hit the limit
|
|
42
|
+
# Reserved tokens for system prompt
|
|
43
|
+
current_tokens = self.count_tokens(messages[0].get("content", ""))
|
|
44
|
+
|
|
45
|
+
kept_messages = []
|
|
46
|
+
for msg in reversed(remaining_messages):
|
|
47
|
+
msg_tokens = self.count_tokens(msg.get("content", ""))
|
|
48
|
+
if current_tokens + msg_tokens > self.max_tokens:
|
|
49
|
+
# If we haven't even kept the most recent message, we MUST keep it and just truncate its content
|
|
50
|
+
if not kept_messages:
|
|
51
|
+
truncated_content = msg.get("content", "")[:(self.max_tokens - current_tokens) * 4]
|
|
52
|
+
kept_messages.insert(0, {"role": msg["role"], "content": truncated_content + "...[TRUNCATED]"})
|
|
53
|
+
break
|
|
54
|
+
|
|
55
|
+
kept_messages.insert(0, msg)
|
|
56
|
+
current_tokens += msg_tokens
|
|
57
|
+
|
|
58
|
+
compacted.extend(kept_messages)
|
|
59
|
+
return compacted
|
|
60
|
+
|
|
61
|
+
@staticmethod
|
|
62
|
+
def log_to_nimcode_md(turn: int, prompt: str, response: str, cwd: str = ".") -> None:
|
|
63
|
+
"""Appends the interaction to NIMCODE.md for persistent session history."""
|
|
64
|
+
file_path = os.path.join(cwd, "NIMCODE.md")
|
|
65
|
+
with open(file_path, "a", encoding="utf-8") as f:
|
|
66
|
+
f.write(f"## Turn {turn}\n\n")
|
|
67
|
+
f.write(f"**User**: {prompt}\n\n")
|
|
68
|
+
f.write(f"**Agent**: {response}\n\n")
|
|
69
|
+
f.write("---\n\n")
|
|
@@ -8,58 +8,17 @@ from typing import List, Dict, Any, Optional, AsyncGenerator
|
|
|
8
8
|
logger = logging.getLogger(__name__)
|
|
9
9
|
|
|
10
10
|
class NimClient:
|
|
11
|
-
def __init__(self, api_key: str, base_url: str =
|
|
11
|
+
def __init__(self, api_key: str, base_url: str = "https://integrate.api.nvidia.com/v1", model: str = "meta/llama-3.1-70b-instruct"):
|
|
12
12
|
self.api_key = api_key
|
|
13
|
-
self.
|
|
14
|
-
self.
|
|
15
|
-
self.model = model or "meta/llama-3.1-70b-instruct"
|
|
13
|
+
self.base_url = base_url.rstrip("/")
|
|
14
|
+
self.model = model
|
|
16
15
|
self.headers = {
|
|
17
16
|
"Authorization": f"Bearer {self.api_key}",
|
|
18
17
|
"Content-Type": "application/json",
|
|
19
18
|
"Accept": "text/event-stream"
|
|
20
19
|
}
|
|
21
20
|
|
|
22
|
-
@staticmethod
|
|
23
|
-
def get_model_context_length(model_name: str) -> int:
|
|
24
|
-
"""Heuristically determines the maximum token context length from the model name."""
|
|
25
|
-
model_lower = model_name.lower()
|
|
26
|
-
if "llama-3.1" in model_lower or "llama-3.2" in model_lower:
|
|
27
|
-
return 128000
|
|
28
|
-
elif "nemotron" in model_lower:
|
|
29
|
-
return 128000 # Nemotron-4 is usually high context
|
|
30
|
-
elif "mixtral" in model_lower:
|
|
31
|
-
return 64000
|
|
32
|
-
elif "phi" in model_lower:
|
|
33
|
-
return 128000
|
|
34
|
-
elif "gemma" in model_lower:
|
|
35
|
-
return 8192
|
|
36
|
-
# Default fallback
|
|
37
|
-
return 32000
|
|
38
|
-
|
|
39
21
|
async def get_available_models(self) -> List[str]:
|
|
40
|
-
if self.is_local:
|
|
41
|
-
try:
|
|
42
|
-
async with httpx.AsyncClient() as client:
|
|
43
|
-
response = await client.get(f"{self.base_url}/models", timeout=5.0)
|
|
44
|
-
if response.status_code == 200:
|
|
45
|
-
data = response.json()
|
|
46
|
-
return [m["id"] for m in data.get("data", [])]
|
|
47
|
-
except Exception as e:
|
|
48
|
-
logger.error(f"Failed to fetch local models: {e}")
|
|
49
|
-
return ["local-model"]
|
|
50
|
-
|
|
51
|
-
try:
|
|
52
|
-
async with httpx.AsyncClient() as client:
|
|
53
|
-
response = await client.get(f"{self.base_url}/models", headers={"Authorization": f"Bearer {self.api_key}"}, timeout=10.0)
|
|
54
|
-
if response.status_code == 200:
|
|
55
|
-
data = response.json()
|
|
56
|
-
return [m["id"] for m in data.get("data", [])]
|
|
57
|
-
else:
|
|
58
|
-
logger.error(f"Failed to fetch models from API: {response.status_code}")
|
|
59
|
-
except Exception as e:
|
|
60
|
-
logger.error(f"Failed to fetch models: {e}")
|
|
61
|
-
|
|
62
|
-
# Fallback list if API fails
|
|
63
22
|
return [
|
|
64
23
|
"meta/llama-3.1-70b-instruct",
|
|
65
24
|
"meta/llama-3.1-8b-instruct",
|
|
@@ -131,31 +90,57 @@ class NimClient:
|
|
|
131
90
|
"stream": stream
|
|
132
91
|
}
|
|
133
92
|
|
|
134
|
-
|
|
93
|
+
max_retries = 15
|
|
94
|
+
base_delay = 2.0
|
|
95
|
+
max_delay = 60.0
|
|
96
|
+
|
|
97
|
+
for attempt in range(max_retries):
|
|
98
|
+
chunk_yielded = False
|
|
135
99
|
try:
|
|
136
|
-
async with
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
100
|
+
async with httpx.AsyncClient() as client:
|
|
101
|
+
async with client.stream("POST", f"{self.base_url}/chat/completions", headers=self.headers, json=payload, timeout=120.0) as response:
|
|
102
|
+
if response.status_code in [408, 429, 500, 502, 503, 504, 529]:
|
|
103
|
+
raise httpx.HTTPStatusError(f"Temporary server error {response.status_code}", request=response.request, response=response)
|
|
104
|
+
|
|
105
|
+
response.raise_for_status()
|
|
106
|
+
|
|
107
|
+
async for line in response.aiter_lines():
|
|
108
|
+
if line.startswith("data: ") and line != "data: [DONE]":
|
|
109
|
+
data_str = line[6:]
|
|
110
|
+
try:
|
|
111
|
+
data_json = json.loads(data_str)
|
|
112
|
+
chunk = data_json["choices"][0]["delta"].get("content", "")
|
|
113
|
+
if chunk:
|
|
114
|
+
chunk_yielded = True
|
|
115
|
+
yield chunk
|
|
116
|
+
except json.JSONDecodeError:
|
|
117
|
+
pass
|
|
118
|
+
return # Success
|
|
148
119
|
except httpx.HTTPStatusError as e:
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
120
|
+
transient_codes = [408, 429, 500, 502, 503, 504, 529]
|
|
121
|
+
if e.response.status_code not in transient_codes or chunk_yielded or attempt == max_retries - 1:
|
|
122
|
+
try:
|
|
123
|
+
await e.response.aread()
|
|
124
|
+
text = e.response.text
|
|
125
|
+
except Exception:
|
|
126
|
+
text = "<unread stream>"
|
|
127
|
+
logger.error(f"API HTTP error: {e.response.status_code} - {text}")
|
|
128
|
+
yield f"\n\n[Error: Model API returned {e.response.status_code}. Please check your API key if 401.]"
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
delay = min(max_delay, base_delay * (2 ** attempt))
|
|
132
|
+
logger.warning(f"API HTTP error {e.response.status_code}. Retrying in {delay}s...")
|
|
133
|
+
await asyncio.sleep(delay)
|
|
134
|
+
|
|
156
135
|
except Exception as e:
|
|
157
|
-
|
|
158
|
-
|
|
136
|
+
if chunk_yielded or attempt == max_retries - 1:
|
|
137
|
+
logger.error(f"API connection error: {type(e).__name__} - {e}")
|
|
138
|
+
yield f"\n\n[Error communicating with NVIDIA API: {type(e).__name__} - {e}]"
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
delay = min(max_delay, base_delay * (2 ** attempt))
|
|
142
|
+
logger.warning(f"API connection error: {type(e).__name__} - {e}. Retrying in {delay}s...")
|
|
143
|
+
await asyncio.sleep(delay)
|
|
159
144
|
|
|
160
145
|
def count_tokens_approx(self, messages: List[Dict[str, Any]]) -> int:
|
|
161
146
|
total_chars = 0
|