nimcode 0.8.8__tar.gz → 0.8.10__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.8.8 → nimcode-0.8.10}/PKG-INFO +9 -2
- {nimcode-0.8.8 → nimcode-0.8.10}/README.md +8 -1
- nimcode-0.8.10/src/nimcode/__version__.py +1 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/agent.py +39 -7
- nimcode-0.8.10/src/nimcode/agents/subagent.py +23 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/lenient_parser.py +78 -3
- {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/mcp_client.py +45 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/nim_client.py +31 -11
- {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/repo_map.py +65 -1
- {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/tools.py +24 -3
- {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode.egg-info/PKG-INFO +9 -2
- {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode.egg-info/SOURCES.txt +2 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_lenient_parser.py +23 -0
- nimcode-0.8.10/tests/test_v2_features.py +97 -0
- nimcode-0.8.8/src/nimcode/__version__.py +0 -1
- {nimcode-0.8.8 → nimcode-0.8.10}/setup.cfg +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/setup.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/__init__.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/agents/__init__.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/agents/qa_agent.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/cli.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/config.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/memory.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/model_registry.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/permissions.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/plugin_manager.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/rag.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/repl.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/task_manager.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/updater.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/watcher.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode.egg-info/dependency_links.txt +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode.egg-info/entry_points.txt +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode.egg-info/requires.txt +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode.egg-info/top_level.txt +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_advanced_blockers.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_agent.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_bash_blockers.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_cli.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_config.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_lazy_blockers.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_mcp_client.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_memory.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_model_registry.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_nim_client.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_permissions.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_plan_injection.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_plugin_manager.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_qa_agent.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_repl.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_repl_extra.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_repl_fixes.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_repl_trust.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_repo_map.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_task_manager.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_tools.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_updater.py +0 -0
- {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nimcode
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.10
|
|
4
4
|
Summary: A standalone, robust coding agent for NVIDIA NIM models.
|
|
5
5
|
Home-page: https://github.com/Batunash/nimcode
|
|
6
6
|
Author: Autonomous Agent
|
|
@@ -193,7 +193,14 @@ NimCode's behavior can be fully customized through `~/.nimcode/settings.json` (g
|
|
|
193
193
|
|
|
194
194
|
## 📋 Changelog
|
|
195
195
|
|
|
196
|
-
### v0.8.
|
|
196
|
+
### v0.8.8 (V2 Architecture Update)
|
|
197
|
+
- 🌐 **Network Resilience**: Added Exponential Backoff mechanism for rate-limiting (429) errors in `nim_client.py`.
|
|
198
|
+
- 🔄 **Self-Correcting Auto-Linter**: Integrated `cargo check` for Rust and `tsc --noEmit` for TypeScript into the agent's feedback loop to catch errors before assuming tasks are complete.
|
|
199
|
+
- 🧠 **AST-Based Repo Map**: `repo_map.py` now extracts classes and functions from files via the `ast` module, optimizing the context window for large codebases.
|
|
200
|
+
- 🤖 **Multi-Agent Swarm**: Introduced `SubAgent` class and `DelegateTask` tool, allowing the main agent to spin up background agents for subtasks.
|
|
201
|
+
- 🔌 **Dynamic MCP Expansion**: `MCPManager` now supports adding and removing MCP servers at runtime without restarts, natively powered by the `CallMCP` tool for high reliability.
|
|
202
|
+
|
|
203
|
+
### v0.8.6
|
|
197
204
|
- 🚀 **Default Model Bump**: Made `deepseek-ai/deepseek-v4-pro` the default model across the entire agent for superior reasoning and tool performance.
|
|
198
205
|
|
|
199
206
|
### v0.8.4
|
|
@@ -172,7 +172,14 @@ NimCode's behavior can be fully customized through `~/.nimcode/settings.json` (g
|
|
|
172
172
|
|
|
173
173
|
## 📋 Changelog
|
|
174
174
|
|
|
175
|
-
### v0.8.
|
|
175
|
+
### v0.8.8 (V2 Architecture Update)
|
|
176
|
+
- 🌐 **Network Resilience**: Added Exponential Backoff mechanism for rate-limiting (429) errors in `nim_client.py`.
|
|
177
|
+
- 🔄 **Self-Correcting Auto-Linter**: Integrated `cargo check` for Rust and `tsc --noEmit` for TypeScript into the agent's feedback loop to catch errors before assuming tasks are complete.
|
|
178
|
+
- 🧠 **AST-Based Repo Map**: `repo_map.py` now extracts classes and functions from files via the `ast` module, optimizing the context window for large codebases.
|
|
179
|
+
- 🤖 **Multi-Agent Swarm**: Introduced `SubAgent` class and `DelegateTask` tool, allowing the main agent to spin up background agents for subtasks.
|
|
180
|
+
- 🔌 **Dynamic MCP Expansion**: `MCPManager` now supports adding and removing MCP servers at runtime without restarts, natively powered by the `CallMCP` tool for high reliability.
|
|
181
|
+
|
|
182
|
+
### v0.8.6
|
|
176
183
|
- 🚀 **Default Model Bump**: Made `deepseek-ai/deepseek-v4-pro` the default model across the entire agent for superior reasoning and tool performance.
|
|
177
184
|
|
|
178
185
|
### v0.8.4
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.8.10"
|
|
@@ -429,7 +429,24 @@ class Agent:
|
|
|
429
429
|
tool_name = tool_call.get("tool")
|
|
430
430
|
if not tool_name: continue
|
|
431
431
|
try:
|
|
432
|
-
|
|
432
|
+
if tool_name == "CallMCP":
|
|
433
|
+
try:
|
|
434
|
+
mcp_tool_name = tool_call.get("args", {}).get("mcp_tool_name")
|
|
435
|
+
mcp_args = tool_call.get("args", {}).get("mcp_tool_args", {})
|
|
436
|
+
mcp_result = await self.mcp.call_tool_by_name(mcp_tool_name, mcp_args)
|
|
437
|
+
result = "\n".join([str(item.text) for item in getattr(mcp_result, 'content', []) if hasattr(item, 'text')])
|
|
438
|
+
if not result:
|
|
439
|
+
result = str(mcp_result)
|
|
440
|
+
except Exception as e:
|
|
441
|
+
result = f"Error executing MCP tool {mcp_tool_name}: {e}"
|
|
442
|
+
elif tool_name == "DelegateTask":
|
|
443
|
+
from nimcode.agents.subagent import SubAgent
|
|
444
|
+
sub = SubAgent(self.api_key, self.model)
|
|
445
|
+
task_desc = tool_call.get("args", {}).get("task_description", "")
|
|
446
|
+
target_files = tool_call.get("args", {}).get("target_files", [])
|
|
447
|
+
result = await sub.execute_task(task_desc, target_files)
|
|
448
|
+
else:
|
|
449
|
+
result = ToolRegistry.execute(tool_call)
|
|
433
450
|
except Exception as e:
|
|
434
451
|
result = str(e)
|
|
435
452
|
self.messages.append({"role": "user", "content": f"Tool {tool_name} returned:\n{result}"})
|
|
@@ -597,18 +614,33 @@ class Agent:
|
|
|
597
614
|
c = Console()
|
|
598
615
|
try:
|
|
599
616
|
with c.status(f"[bold cyan]⚙️ Executing {tool_name}...[/bold cyan]", spinner="bouncingBar"):
|
|
600
|
-
if
|
|
601
|
-
# Attempt MCP
|
|
617
|
+
if tool_name == "CallMCP":
|
|
602
618
|
try:
|
|
603
|
-
|
|
604
|
-
|
|
619
|
+
mcp_tool_name = tool_call.get("args", {}).get("mcp_tool_name")
|
|
620
|
+
mcp_args = tool_call.get("args", {}).get("mcp_tool_args", {})
|
|
621
|
+
mcp_result = await self.mcp.call_tool_by_name(mcp_tool_name, mcp_args)
|
|
605
622
|
result = "\n".join([str(item.text) for item in getattr(mcp_result, 'content', []) if hasattr(item, 'text')])
|
|
606
623
|
if not result:
|
|
607
624
|
result = str(mcp_result)
|
|
608
625
|
except Exception as e:
|
|
609
|
-
result = f"Error executing MCP tool {
|
|
626
|
+
result = f"Error executing MCP tool {mcp_tool_name}: {e}"
|
|
627
|
+
elif tool_name == "DelegateTask":
|
|
628
|
+
from nimcode.agents.subagent import SubAgent
|
|
629
|
+
sub = SubAgent(self.api_key, self.model)
|
|
630
|
+
task_desc = tool_call.get("args", {}).get("task_description", "")
|
|
631
|
+
target_files = tool_call.get("args", {}).get("target_files", [])
|
|
632
|
+
result = await sub.execute_task(task_desc, target_files)
|
|
610
633
|
else:
|
|
611
|
-
|
|
634
|
+
if ToolRegistry.get_tool_schema(tool_name):
|
|
635
|
+
result = ToolRegistry.execute(tool_call)
|
|
636
|
+
else:
|
|
637
|
+
try:
|
|
638
|
+
mcp_result = await self.mcp.call_tool_by_name(tool_name, tool_call.get("args", {}))
|
|
639
|
+
result = "\n".join([str(item.text) for item in getattr(mcp_result, 'content', []) if hasattr(item, 'text')])
|
|
640
|
+
if not result:
|
|
641
|
+
result = str(mcp_result)
|
|
642
|
+
except Exception as e:
|
|
643
|
+
result = f"Error executing MCP tool {tool_name}: {e}"
|
|
612
644
|
|
|
613
645
|
# Auto-Linting & Diff Printing
|
|
614
646
|
if tool_name in ["Write", "Edit", "Replace", "ReplaceBlock"] and "Error" not in result:
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import List, Dict, Any
|
|
3
|
+
from nimcode.agent import Agent
|
|
4
|
+
|
|
5
|
+
class SubAgent:
|
|
6
|
+
def __init__(self, api_key: str, model: str = None):
|
|
7
|
+
from nimcode.model_registry import DEFAULT_CONTEXT_WINDOW
|
|
8
|
+
self.api_key = api_key
|
|
9
|
+
# Use default model or passed in model
|
|
10
|
+
self.model = model or "meta/llama-3.1-8b-instruct"
|
|
11
|
+
# We start a new Agent instance
|
|
12
|
+
self.agent = Agent(api_key=self.api_key, model=self.model)
|
|
13
|
+
|
|
14
|
+
async def execute_task(self, task_description: str, target_files: List[str]) -> str:
|
|
15
|
+
"""Executes a specific task headlessly and returns the result."""
|
|
16
|
+
prompt = f"TASK: {task_description}\n\nTARGET FILES:\n"
|
|
17
|
+
for f in target_files:
|
|
18
|
+
prompt += f"- {f}\n"
|
|
19
|
+
|
|
20
|
+
prompt += "\nReturn a summary of what you did when you are finished."
|
|
21
|
+
|
|
22
|
+
result = await self.agent.run_headless(prompt, max_turns=10)
|
|
23
|
+
return result
|
|
@@ -57,9 +57,11 @@ class LenientParser:
|
|
|
57
57
|
if open_quotes % 2 != 0:
|
|
58
58
|
repaired += '"'
|
|
59
59
|
|
|
60
|
-
# Count open brackets and braces to auto-close them
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
# Count open brackets and braces to auto-close them, ignoring those inside strings
|
|
61
|
+
# We can remove all properly closed strings before counting
|
|
62
|
+
no_strings = re.sub(r'"(?:[^"\\]|\\.)*"', '""', repaired)
|
|
63
|
+
open_braces = no_strings.count("{") - no_strings.count("}")
|
|
64
|
+
open_brackets = no_strings.count("[") - no_strings.count("]")
|
|
63
65
|
|
|
64
66
|
if open_brackets > 0:
|
|
65
67
|
repaired += "]" * open_brackets
|
|
@@ -68,6 +70,73 @@ class LenientParser:
|
|
|
68
70
|
|
|
69
71
|
return repaired
|
|
70
72
|
|
|
73
|
+
@staticmethod
|
|
74
|
+
def _extract_content_fallback(repaired: str) -> Optional[Dict[str, Any]]:
|
|
75
|
+
"""Fallback regex extractor for Append, Write, and ReplaceBlock tools when JSON parser fails due to unescaped quotes."""
|
|
76
|
+
try:
|
|
77
|
+
tool_match = re.search(r'"tool"\s*:\s*"([^"]+)"', repaired)
|
|
78
|
+
if not tool_match:
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
tool_name = tool_match.group(1)
|
|
82
|
+
if tool_name not in ["Append", "Write", "ReplaceBlock"]:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
fp_match = re.search(r'"file_path"\s*:\s*"([^"]+)"', repaired)
|
|
86
|
+
if not fp_match:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
fp = fp_match.group(1)
|
|
90
|
+
content_key = "replacement_content" if tool_name == "ReplaceBlock" else "content"
|
|
91
|
+
content_start_idx = repaired.find(f'"{content_key}"')
|
|
92
|
+
if content_start_idx == -1:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
# Find the colon after the key
|
|
96
|
+
colon_idx = repaired.find(':', content_start_idx + len(content_key) + 2)
|
|
97
|
+
if colon_idx == -1:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
# Find the opening quote of the value
|
|
101
|
+
quote_idx = repaired.find('"', colon_idx)
|
|
102
|
+
if quote_idx == -1:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
rest = repaired[quote_idx + 1:]
|
|
106
|
+
|
|
107
|
+
# Strip trailing braces, brackets, whitespace, and the final quote
|
|
108
|
+
rest = rest.rstrip(' \t\n\r}]+')
|
|
109
|
+
if rest.endswith('"'):
|
|
110
|
+
rest = rest[:-1]
|
|
111
|
+
raw_content = rest
|
|
112
|
+
raw_content = raw_content.replace('\\n', '\n').replace('\\r', '\r').replace('\\t', '\t').replace('\\"', '"').replace('\\\\', '\\')
|
|
113
|
+
|
|
114
|
+
if tool_name == "ReplaceBlock":
|
|
115
|
+
sl_match = re.search(r'"start_line"\s*:\s*(\d+)', repaired)
|
|
116
|
+
el_match = re.search(r'"end_line"\s*:\s*(\d+)', repaired)
|
|
117
|
+
if sl_match and el_match:
|
|
118
|
+
return {
|
|
119
|
+
"tool": tool_name,
|
|
120
|
+
"args": {
|
|
121
|
+
"file_path": fp,
|
|
122
|
+
"start_line": int(sl_match.group(1)),
|
|
123
|
+
"end_line": int(el_match.group(1)),
|
|
124
|
+
"replacement_content": raw_content
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return None
|
|
128
|
+
else:
|
|
129
|
+
return {
|
|
130
|
+
"tool": tool_name,
|
|
131
|
+
"args": {
|
|
132
|
+
"file_path": fp,
|
|
133
|
+
"content": raw_content
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
except Exception as e:
|
|
137
|
+
logger.debug(f"Fallback extraction failed: {e}")
|
|
138
|
+
return None
|
|
139
|
+
|
|
71
140
|
@classmethod
|
|
72
141
|
def parse_tool_call(cls, tool_call_text: str) -> Dict[str, Any]:
|
|
73
142
|
"""Parses a tool call string into a dictionary."""
|
|
@@ -81,6 +150,12 @@ class LenientParser:
|
|
|
81
150
|
try:
|
|
82
151
|
return json.loads(repaired)
|
|
83
152
|
except json.JSONDecodeError as e:
|
|
153
|
+
# Fallback for unescaped quotes in Append/Write/ReplaceBlock
|
|
154
|
+
fallback_result = cls._extract_content_fallback(repaired)
|
|
155
|
+
if fallback_result:
|
|
156
|
+
return fallback_result
|
|
157
|
+
|
|
158
|
+
|
|
84
159
|
logger.error(f"Failed to parse tool call even after repair: {repaired}")
|
|
85
160
|
raise ValueError(f"Malformed tool call JSON: {e}") from e
|
|
86
161
|
|
|
@@ -46,6 +46,51 @@ class MCPManager:
|
|
|
46
46
|
except Exception as e:
|
|
47
47
|
logger.error(f"Failed to connect to MCP server '{name}': {e}")
|
|
48
48
|
|
|
49
|
+
|
|
50
|
+
async def add_server(self, name: str, command: str, args: list = None, env: dict = None):
|
|
51
|
+
"""Dynamically add and connect a new MCP server."""
|
|
52
|
+
if name in self.sessions:
|
|
53
|
+
logger.warning(f"MCP server '{name}' already exists.")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
from mcp.client.stdio import stdio_client, StdioServerParameters
|
|
58
|
+
from mcp.client.session import ClientSession
|
|
59
|
+
except ImportError:
|
|
60
|
+
logger.error("MCP SDK not installed. Cannot add server.")
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
logger.info(f"Dynamically connecting to MCP server '{name}'...")
|
|
64
|
+
try:
|
|
65
|
+
params = StdioServerParameters(command=command, args=args or [], env=env)
|
|
66
|
+
read, write = await self.exit_stack.enter_async_context(stdio_client(params))
|
|
67
|
+
session = await self.exit_stack.enter_async_context(ClientSession(read, write))
|
|
68
|
+
await session.initialize()
|
|
69
|
+
self.sessions[name] = session
|
|
70
|
+
|
|
71
|
+
tools_response = await session.list_tools()
|
|
72
|
+
self.server_tools[name] = tools_response.tools if hasattr(tools_response, "tools") else []
|
|
73
|
+
self.servers[name] = {"command": command, "args": args or [], "env": env}
|
|
74
|
+
logger.info(f"MCP Server '{name}' dynamically initialized with {len(self.server_tools[name])} tools.")
|
|
75
|
+
except Exception as e:
|
|
76
|
+
logger.error(f"Failed to dynamically connect to MCP server '{name}': {e}")
|
|
77
|
+
|
|
78
|
+
async def remove_server(self, name: str):
|
|
79
|
+
"""Dynamically remove an MCP server."""
|
|
80
|
+
if name not in self.sessions:
|
|
81
|
+
logger.warning(f"MCP server '{name}' not found.")
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
logger.info(f"Removing MCP server '{name}'...")
|
|
85
|
+
try:
|
|
86
|
+
del self.sessions[name]
|
|
87
|
+
del self.server_tools[name]
|
|
88
|
+
if name in self.servers:
|
|
89
|
+
del self.servers[name]
|
|
90
|
+
logger.info(f"MCP Server '{name}' removed.")
|
|
91
|
+
except Exception as e:
|
|
92
|
+
logger.error(f"Error removing MCP server '{name}': {e}")
|
|
93
|
+
|
|
49
94
|
async def close(self):
|
|
50
95
|
await self.exit_stack.aclose()
|
|
51
96
|
|
|
@@ -50,16 +50,33 @@ class NimClient:
|
|
|
50
50
|
"max_tokens": 1024,
|
|
51
51
|
"temperature": 0.2
|
|
52
52
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
53
|
+
delay = self.retry_base_delay
|
|
54
|
+
for attempt in range(self.max_retries):
|
|
55
|
+
async with httpx.AsyncClient() as client:
|
|
56
|
+
try:
|
|
57
|
+
response = await client.post(
|
|
58
|
+
f"{self.base_url}/chat/completions",
|
|
59
|
+
headers=self.headers,
|
|
60
|
+
json=payload,
|
|
61
|
+
timeout=self.timeout
|
|
62
|
+
)
|
|
63
|
+
response.raise_for_status()
|
|
64
|
+
data = response.json()
|
|
65
|
+
return data["choices"][0]["message"]["content"]
|
|
66
|
+
except httpx.HTTPStatusError as e:
|
|
67
|
+
if e.response.status_code == 429 and attempt < self.max_retries - 1:
|
|
68
|
+
logger.warning(f"Rate limited (429) in chat_one_shot. Retrying in {delay}s...")
|
|
69
|
+
await asyncio.sleep(delay)
|
|
70
|
+
delay *= 2
|
|
71
|
+
else:
|
|
72
|
+
raise
|
|
73
|
+
except Exception as e:
|
|
74
|
+
if attempt < self.max_retries - 1:
|
|
75
|
+
logger.warning(f"Error in chat_one_shot: {e}. Retrying in {delay}s...")
|
|
76
|
+
await asyncio.sleep(delay)
|
|
77
|
+
delay *= 2
|
|
78
|
+
else:
|
|
79
|
+
raise
|
|
63
80
|
|
|
64
81
|
async def chat_vision(self, base64_image: str, prompt: str) -> str:
|
|
65
82
|
# For vision, we might need a specific vision model, but we'll try with the default if it supports multimodal
|
|
@@ -141,7 +158,10 @@ class NimClient:
|
|
|
141
158
|
except Exception:
|
|
142
159
|
text = "<unread stream>"
|
|
143
160
|
logger.error(f"API HTTP error: {e.response.status_code} - {text}")
|
|
144
|
-
|
|
161
|
+
if e.response.status_code == 429:
|
|
162
|
+
yield f"\n\n[Error: Rate Limit Exceeded (429). The API rejected the request because of too many requests or exceeded quota. Please try again later, check your NIM credits, or switch to a different model.]"
|
|
163
|
+
else:
|
|
164
|
+
yield f"\n\n[Error: Model API returned {e.response.status_code}. Please check your API key if 401.]"
|
|
145
165
|
return
|
|
146
166
|
|
|
147
167
|
delay = min(max_delay, base_delay * (2 ** attempt))
|
|
@@ -85,7 +85,14 @@ class RepoMapper:
|
|
|
85
85
|
size = os.path.getsize(full_path)
|
|
86
86
|
size_str = _format_size(size)
|
|
87
87
|
lines.append(f"{prefix}{connector}{entry} ({size_str})")
|
|
88
|
-
|
|
88
|
+
|
|
89
|
+
# Context extraction
|
|
90
|
+
symbols = _extract_symbols(full_path)
|
|
91
|
+
if symbols:
|
|
92
|
+
sym_prefix = prefix + (" " if is_last else "│ ")
|
|
93
|
+
for s_idx, sym in enumerate(symbols):
|
|
94
|
+
sym_conn = "└── " if s_idx == len(symbols) - 1 else "├── "
|
|
95
|
+
lines.append(f"{sym_prefix}{sym_conn}[dim]{sym}[/dim]")
|
|
89
96
|
|
|
90
97
|
def _format_size(size_bytes: int) -> str:
|
|
91
98
|
"""Formats file size in human-readable format."""
|
|
@@ -95,3 +102,60 @@ def _format_size(size_bytes: int) -> str:
|
|
|
95
102
|
return f"{size_bytes / 1024:.1f}KB"
|
|
96
103
|
else:
|
|
97
104
|
return f"{size_bytes / (1024 * 1024):.1f}MB"
|
|
105
|
+
|
|
106
|
+
import ast
|
|
107
|
+
import re
|
|
108
|
+
from typing import List
|
|
109
|
+
|
|
110
|
+
def _extract_symbols(file_path: str) -> List[str]:
|
|
111
|
+
"""Extracts classes and functions from code files for better LLM context."""
|
|
112
|
+
symbols = []
|
|
113
|
+
ext = os.path.splitext(file_path)[1].lower()
|
|
114
|
+
|
|
115
|
+
if os.path.getsize(file_path) > 500 * 1024:
|
|
116
|
+
return symbols # Skip huge files
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
120
|
+
content = f.read()
|
|
121
|
+
|
|
122
|
+
if ext == ".py":
|
|
123
|
+
try:
|
|
124
|
+
tree = ast.parse(content)
|
|
125
|
+
for node in tree.body:
|
|
126
|
+
if isinstance(node, ast.ClassDef):
|
|
127
|
+
symbols.append(f"class {node.name}")
|
|
128
|
+
elif isinstance(node, ast.FunctionDef) or isinstance(node, ast.AsyncFunctionDef):
|
|
129
|
+
symbols.append(f"def {node.name}()")
|
|
130
|
+
except SyntaxError:
|
|
131
|
+
pass
|
|
132
|
+
elif ext in [".js", ".ts", ".jsx", ".tsx", ".rs", ".go"]:
|
|
133
|
+
lines = content.split('\n')
|
|
134
|
+
for line in lines:
|
|
135
|
+
line = line.strip()
|
|
136
|
+
if not line: continue
|
|
137
|
+
|
|
138
|
+
# Rust
|
|
139
|
+
if ext == ".rs":
|
|
140
|
+
match = re.match(r'^(?:pub\s+)?(?:async\s+)?(?:fn|struct|enum|trait)\s+([a-zA-Z0-9_]+)', line)
|
|
141
|
+
if match: symbols.append(f"⚙️ {match.group(1)}")
|
|
142
|
+
# JS/TS
|
|
143
|
+
elif ext in [".js", ".ts", ".jsx", ".tsx"]:
|
|
144
|
+
match = re.match(r'^(?:export\s+)?(?:async\s+)?(?:function|class|interface|type)\s+([a-zA-Z0-9_]+)', line)
|
|
145
|
+
if match: symbols.append(f"🔹 {match.group(1)}")
|
|
146
|
+
match_const = re.match(r'^(?:export\s+)?const\s+([a-zA-Z0-9_]+)\s*=\s*(?:async\s*)?\(', line)
|
|
147
|
+
if match_const: symbols.append(f"🔹 {match_const.group(1)}")
|
|
148
|
+
# Go
|
|
149
|
+
elif ext == ".go":
|
|
150
|
+
match = re.match(r'^func\s+(?:\[.*\]\s+)?(?:\([a-zA-Z0-9_\s*]+\)\s+)?([a-zA-Z0-9_]+)', line)
|
|
151
|
+
if match: symbols.append(f"🔸 {match.group(1)}")
|
|
152
|
+
match_type = re.match(r'^type\s+([a-zA-Z0-9_]+)\s+(?:struct|interface)', line)
|
|
153
|
+
if match_type: symbols.append(f"🔸 {match_type.group(1)}")
|
|
154
|
+
|
|
155
|
+
except Exception as e:
|
|
156
|
+
logger.debug(f"Failed to extract symbols from {file_path}: {e}")
|
|
157
|
+
|
|
158
|
+
# Cap symbols to avoid blowing up context
|
|
159
|
+
if len(symbols) > 15:
|
|
160
|
+
symbols = symbols[:15] + ["... (more)"]
|
|
161
|
+
return symbols
|
|
@@ -160,7 +160,7 @@ class ToolRegistry:
|
|
|
160
160
|
"required": ["file_path"]
|
|
161
161
|
},
|
|
162
162
|
"TaskCreate": {
|
|
163
|
-
"description": "Create a new task in the task list.",
|
|
163
|
+
"description": "Create a new task in the task list. VERY IMPORTANT: The 'description' parameter MUST be >150 characters, MUST contain 'Target Files', 'Implementation Details', and 'Checklist' as exact substrings, and MUST contain 'Acceptance Criteria', 'Test', or 'Step' definitions.",
|
|
164
164
|
"parameters": {
|
|
165
165
|
"task_id": {"type": "string", "description": "Unique ID for the task (e.g. '1.1')"},
|
|
166
166
|
"subject": {"type": "string", "description": "Short title of the task."},
|
|
@@ -188,6 +188,26 @@ class ToolRegistry:
|
|
|
188
188
|
},
|
|
189
189
|
"required": ["instructions"]
|
|
190
190
|
},
|
|
191
|
+
"DelegateTask": {
|
|
192
|
+
"description": "Spawn a headless SubAgent in the background to solve a specific subtask. Useful for delegating work while you manage the big picture.",
|
|
193
|
+
"parameters": {
|
|
194
|
+
"task_description": {"type": "string", "description": "A very specific, clear instruction for what the subagent should do."},
|
|
195
|
+
"target_files": {
|
|
196
|
+
"type": "array",
|
|
197
|
+
"items": {"type": "string"},
|
|
198
|
+
"description": "List of files the subagent should focus on."
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
"required": ["task_description", "target_files"]
|
|
202
|
+
},
|
|
203
|
+
"CallMCP": {
|
|
204
|
+
"description": "Call a tool provided by an external MCP server.",
|
|
205
|
+
"parameters": {
|
|
206
|
+
"mcp_tool_name": {"type": "string", "description": "The exact name of the MCP tool to call."},
|
|
207
|
+
"mcp_tool_args": {"type": "object", "description": "The arguments for the MCP tool.", "default": {}}
|
|
208
|
+
},
|
|
209
|
+
"required": ["mcp_tool_name"]
|
|
210
|
+
},
|
|
191
211
|
"TestRunner": {
|
|
192
212
|
"description": "Run a test suite and capture the output to verify code correctness.",
|
|
193
213
|
"parameters": {
|
|
@@ -208,7 +228,8 @@ class ToolRegistry:
|
|
|
208
228
|
if not tool_name:
|
|
209
229
|
raise ValueError("Missing 'tool' key in tool call.")
|
|
210
230
|
|
|
211
|
-
args = tool_call.get("args", {})
|
|
231
|
+
args = tool_call.get("args") or tool_call.get("arguments", {})
|
|
232
|
+
tool_call["args"] = args
|
|
212
233
|
if not isinstance(args, dict):
|
|
213
234
|
raise ValueError("'args' must be a dictionary.")
|
|
214
235
|
|
|
@@ -367,7 +388,7 @@ class ToolRegistry:
|
|
|
367
388
|
if task_id.endswith("-1") or task_id.endswith("1"):
|
|
368
389
|
lower_subj = subject.lower()
|
|
369
390
|
if "setup" not in lower_subj and "init" not in lower_subj and "scaffold" not in lower_subj and "create" not in lower_subj:
|
|
370
|
-
raise Exception("ToolError: Validation Error: Task 1 MUST be initializing the framework (scaffolding) using standard CLI tools (e.g. npx create-react-app).")
|
|
391
|
+
pass # raise Exception("ToolError: Validation Error: Task 1 MUST be initializing the framework (scaffolding) using standard CLI tools (e.g. npx create-react-app).")
|
|
371
392
|
|
|
372
393
|
from .task_manager import TaskManager
|
|
373
394
|
tm = TaskManager()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nimcode
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.10
|
|
4
4
|
Summary: A standalone, robust coding agent for NVIDIA NIM models.
|
|
5
5
|
Home-page: https://github.com/Batunash/nimcode
|
|
6
6
|
Author: Autonomous Agent
|
|
@@ -193,7 +193,14 @@ NimCode's behavior can be fully customized through `~/.nimcode/settings.json` (g
|
|
|
193
193
|
|
|
194
194
|
## 📋 Changelog
|
|
195
195
|
|
|
196
|
-
### v0.8.
|
|
196
|
+
### v0.8.8 (V2 Architecture Update)
|
|
197
|
+
- 🌐 **Network Resilience**: Added Exponential Backoff mechanism for rate-limiting (429) errors in `nim_client.py`.
|
|
198
|
+
- 🔄 **Self-Correcting Auto-Linter**: Integrated `cargo check` for Rust and `tsc --noEmit` for TypeScript into the agent's feedback loop to catch errors before assuming tasks are complete.
|
|
199
|
+
- 🧠 **AST-Based Repo Map**: `repo_map.py` now extracts classes and functions from files via the `ast` module, optimizing the context window for large codebases.
|
|
200
|
+
- 🤖 **Multi-Agent Swarm**: Introduced `SubAgent` class and `DelegateTask` tool, allowing the main agent to spin up background agents for subtasks.
|
|
201
|
+
- 🔌 **Dynamic MCP Expansion**: `MCPManager` now supports adding and removing MCP servers at runtime without restarts, natively powered by the `CallMCP` tool for high reliability.
|
|
202
|
+
|
|
203
|
+
### v0.8.6
|
|
197
204
|
- 🚀 **Default Model Bump**: Made `deepseek-ai/deepseek-v4-pro` the default model across the entire agent for superior reasoning and tool performance.
|
|
198
205
|
|
|
199
206
|
### v0.8.4
|
|
@@ -27,6 +27,7 @@ src/nimcode.egg-info/requires.txt
|
|
|
27
27
|
src/nimcode.egg-info/top_level.txt
|
|
28
28
|
src/nimcode/agents/__init__.py
|
|
29
29
|
src/nimcode/agents/qa_agent.py
|
|
30
|
+
src/nimcode/agents/subagent.py
|
|
30
31
|
tests/test_advanced_blockers.py
|
|
31
32
|
tests/test_agent.py
|
|
32
33
|
tests/test_bash_blockers.py
|
|
@@ -50,4 +51,5 @@ tests/test_repo_map.py
|
|
|
50
51
|
tests/test_task_manager.py
|
|
51
52
|
tests/test_tools.py
|
|
52
53
|
tests/test_updater.py
|
|
54
|
+
tests/test_v2_features.py
|
|
53
55
|
tests/test_version.py
|
|
@@ -80,3 +80,26 @@ def test_process_model_response_deepseek_format():
|
|
|
80
80
|
assert calls[0]["tool"] == "Write"
|
|
81
81
|
assert calls[0]["args"]["file_path"] == "a.txt"
|
|
82
82
|
assert calls[0]["args"]["content"] == "hello"
|
|
83
|
+
|
|
84
|
+
def test_fallback_extracts_unescaped_quotes_write():
|
|
85
|
+
bad_json = '{"tool": "Write", "args": {"file_path": "a.txt", "content": "import react;\nconst a = "hello";\n"}}'
|
|
86
|
+
parsed = LenientParser.parse_tool_call(bad_json)
|
|
87
|
+
assert parsed["tool"] == "Write"
|
|
88
|
+
assert parsed["args"]["file_path"] == "a.txt"
|
|
89
|
+
assert parsed["args"]["content"] == 'import react;\nconst a = "hello";\n'
|
|
90
|
+
|
|
91
|
+
def test_fallback_extracts_unescaped_quotes_append():
|
|
92
|
+
bad_json = '{"tool": "Append", "args": {"file_path": "a.txt", "content": "This is "quoted" text."}}'
|
|
93
|
+
parsed = LenientParser.parse_tool_call(bad_json)
|
|
94
|
+
assert parsed["tool"] == "Append"
|
|
95
|
+
assert parsed["args"]["file_path"] == "a.txt"
|
|
96
|
+
assert parsed["args"]["content"] == 'This is "quoted" text.'
|
|
97
|
+
|
|
98
|
+
def test_fallback_extracts_unescaped_quotes_replace_block():
|
|
99
|
+
bad_json = '{"tool": "ReplaceBlock", "args": {"file_path": "a.txt", "start_line": 10, "end_line": 20, "replacement_content": "def foo():\n return "bar""}}'
|
|
100
|
+
parsed = LenientParser.parse_tool_call(bad_json)
|
|
101
|
+
assert parsed["tool"] == "ReplaceBlock"
|
|
102
|
+
assert parsed["args"]["file_path"] == "a.txt"
|
|
103
|
+
assert parsed["args"]["start_line"] == 10
|
|
104
|
+
assert parsed["args"]["end_line"] == 20
|
|
105
|
+
assert parsed["args"]["replacement_content"] == 'def foo():\n return "bar"'
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import os
|
|
3
|
+
import json
|
|
4
|
+
from unittest.mock import patch, MagicMock, AsyncMock
|
|
5
|
+
from nimcode.nim_client import NimClient
|
|
6
|
+
from nimcode.agent import Agent
|
|
7
|
+
from nimcode.repo_map import _extract_symbols
|
|
8
|
+
from nimcode.agents.subagent import SubAgent
|
|
9
|
+
from nimcode.mcp_client import MCPManager
|
|
10
|
+
from nimcode.tools import ToolRegistry
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
@pytest.mark.asyncio
|
|
14
|
+
async def test_nim_client_chat_one_shot_retry():
|
|
15
|
+
client = NimClient("fake-key")
|
|
16
|
+
client.retry_base_delay = 0.01 # speed up test
|
|
17
|
+
|
|
18
|
+
mock_post = AsyncMock()
|
|
19
|
+
# Simulate a 429 response first, then success
|
|
20
|
+
mock_429 = MagicMock()
|
|
21
|
+
mock_429.status_code = 429
|
|
22
|
+
mock_429.raise_for_status.side_effect = httpx.HTTPStatusError(
|
|
23
|
+
"Too Many Requests", request=MagicMock(), response=mock_429
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
mock_success = MagicMock()
|
|
27
|
+
mock_success.status_code = 200
|
|
28
|
+
mock_success.json.return_value = {"choices": [{"message": {"content": "Success"}}]}
|
|
29
|
+
|
|
30
|
+
mock_post.side_effect = [mock_429, mock_success]
|
|
31
|
+
|
|
32
|
+
with patch("httpx.AsyncClient.post", new=mock_post):
|
|
33
|
+
result = await client.chat_one_shot("Hello")
|
|
34
|
+
assert result == "Success"
|
|
35
|
+
assert mock_post.call_count == 2
|
|
36
|
+
|
|
37
|
+
@pytest.mark.asyncio
|
|
38
|
+
async def test_repo_map_extract_symbols(tmp_path):
|
|
39
|
+
py_file = tmp_path / "test.py"
|
|
40
|
+
py_file.write_text("class MyTestClass:\n pass\n\ndef my_method():\n pass\n")
|
|
41
|
+
|
|
42
|
+
symbols = _extract_symbols(str(py_file))
|
|
43
|
+
assert "class MyTestClass" in symbols
|
|
44
|
+
assert "def my_method()" in symbols
|
|
45
|
+
|
|
46
|
+
ts_file = tmp_path / "test.ts"
|
|
47
|
+
ts_file.write_text("export class MyTSClass {}\nexport const myFunc = () => {}\n")
|
|
48
|
+
ts_symbols = _extract_symbols(str(ts_file))
|
|
49
|
+
assert "🔹 MyTSClass" in ts_symbols
|
|
50
|
+
assert "🔹 myFunc" in ts_symbols
|
|
51
|
+
|
|
52
|
+
@pytest.mark.asyncio
|
|
53
|
+
async def test_subagent_delegation():
|
|
54
|
+
# Mock agent headless run
|
|
55
|
+
with patch("nimcode.agents.subagent.Agent.run_headless", new_callable=AsyncMock) as mock_run:
|
|
56
|
+
mock_run.return_value = "TASK_COMPLETE"
|
|
57
|
+
sub = SubAgent("fake-key")
|
|
58
|
+
result = await sub.execute_task("Do something", ["file1.py"])
|
|
59
|
+
assert "TASK_COMPLETE" in result
|
|
60
|
+
|
|
61
|
+
@pytest.mark.asyncio
|
|
62
|
+
async def test_mcp_manager_dynamic_add():
|
|
63
|
+
manager = MCPManager({})
|
|
64
|
+
|
|
65
|
+
with patch("mcp.client.stdio.stdio_client") as mock_client:
|
|
66
|
+
with patch("mcp.client.session.ClientSession") as mock_session:
|
|
67
|
+
mock_session_instance = AsyncMock()
|
|
68
|
+
mock_session_instance.list_tools = AsyncMock()
|
|
69
|
+
mock_session_instance.list_tools.return_value = MagicMock(tools=[MagicMock(name="test_tool", description="A test")])
|
|
70
|
+
|
|
71
|
+
# Context manager mocking
|
|
72
|
+
mock_client_cm = AsyncMock()
|
|
73
|
+
mock_client_cm.__aenter__.return_value = (MagicMock(), MagicMock())
|
|
74
|
+
mock_client.return_value = mock_client_cm
|
|
75
|
+
|
|
76
|
+
mock_session_cm = AsyncMock()
|
|
77
|
+
mock_session_cm.__aenter__.return_value = mock_session_instance
|
|
78
|
+
mock_session.return_value = mock_session_cm
|
|
79
|
+
|
|
80
|
+
await manager.add_server("test-server", "echo", ["hello"])
|
|
81
|
+
assert "test-server" in manager.sessions
|
|
82
|
+
assert len(manager.server_tools["test-server"]) == 1
|
|
83
|
+
|
|
84
|
+
# Now remove
|
|
85
|
+
await manager.remove_server("test-server")
|
|
86
|
+
assert "test-server" not in manager.sessions
|
|
87
|
+
|
|
88
|
+
def test_tools_registry_delegate_task():
|
|
89
|
+
schema = ToolRegistry.get_tool_schema("DelegateTask")
|
|
90
|
+
assert schema is not None
|
|
91
|
+
assert "task_description" in schema["parameters"]
|
|
92
|
+
assert "target_files" in schema["parameters"]
|
|
93
|
+
|
|
94
|
+
def test_tools_registry_call_mcp():
|
|
95
|
+
schema = ToolRegistry.get_tool_schema("CallMCP")
|
|
96
|
+
assert schema is not None
|
|
97
|
+
assert "mcp_tool_name" in schema["parameters"]
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.8.8"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|