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.
Files changed (58) hide show
  1. {nimcode-0.8.8 → nimcode-0.8.10}/PKG-INFO +9 -2
  2. {nimcode-0.8.8 → nimcode-0.8.10}/README.md +8 -1
  3. nimcode-0.8.10/src/nimcode/__version__.py +1 -0
  4. {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/agent.py +39 -7
  5. nimcode-0.8.10/src/nimcode/agents/subagent.py +23 -0
  6. {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/lenient_parser.py +78 -3
  7. {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/mcp_client.py +45 -0
  8. {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/nim_client.py +31 -11
  9. {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/repo_map.py +65 -1
  10. {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/tools.py +24 -3
  11. {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode.egg-info/PKG-INFO +9 -2
  12. {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode.egg-info/SOURCES.txt +2 -0
  13. {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_lenient_parser.py +23 -0
  14. nimcode-0.8.10/tests/test_v2_features.py +97 -0
  15. nimcode-0.8.8/src/nimcode/__version__.py +0 -1
  16. {nimcode-0.8.8 → nimcode-0.8.10}/setup.cfg +0 -0
  17. {nimcode-0.8.8 → nimcode-0.8.10}/setup.py +0 -0
  18. {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/__init__.py +0 -0
  19. {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/agents/__init__.py +0 -0
  20. {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/agents/qa_agent.py +0 -0
  21. {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/cli.py +0 -0
  22. {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/config.py +0 -0
  23. {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/memory.py +0 -0
  24. {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/model_registry.py +0 -0
  25. {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/permissions.py +0 -0
  26. {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/plugin_manager.py +0 -0
  27. {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/rag.py +0 -0
  28. {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/repl.py +0 -0
  29. {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/task_manager.py +0 -0
  30. {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/updater.py +0 -0
  31. {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode/watcher.py +0 -0
  32. {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode.egg-info/dependency_links.txt +0 -0
  33. {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode.egg-info/entry_points.txt +0 -0
  34. {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode.egg-info/requires.txt +0 -0
  35. {nimcode-0.8.8 → nimcode-0.8.10}/src/nimcode.egg-info/top_level.txt +0 -0
  36. {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_advanced_blockers.py +0 -0
  37. {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_agent.py +0 -0
  38. {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_bash_blockers.py +0 -0
  39. {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_cli.py +0 -0
  40. {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_config.py +0 -0
  41. {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_lazy_blockers.py +0 -0
  42. {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_mcp_client.py +0 -0
  43. {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_memory.py +0 -0
  44. {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_model_registry.py +0 -0
  45. {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_nim_client.py +0 -0
  46. {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_permissions.py +0 -0
  47. {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_plan_injection.py +0 -0
  48. {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_plugin_manager.py +0 -0
  49. {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_qa_agent.py +0 -0
  50. {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_repl.py +0 -0
  51. {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_repl_extra.py +0 -0
  52. {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_repl_fixes.py +0 -0
  53. {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_repl_trust.py +0 -0
  54. {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_repo_map.py +0 -0
  55. {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_task_manager.py +0 -0
  56. {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_tools.py +0 -0
  57. {nimcode-0.8.8 → nimcode-0.8.10}/tests/test_updater.py +0 -0
  58. {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.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.6 (Latest)
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.6 (Latest)
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
- result = ToolRegistry.execute(tool_call)
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 not ToolRegistry.get_tool_schema(tool_name):
601
- # Attempt MCP
617
+ if tool_name == "CallMCP":
602
618
  try:
603
- mcp_result = await self.mcp.call_tool_by_name(tool_name, tool_call.get("args", {}))
604
- # mcp_result.content is a list of CallToolResult objects
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 {tool_name}: {e}"
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
- result = ToolRegistry.execute(tool_call)
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
- open_braces = repaired.count("{") - repaired.count("}")
62
- open_brackets = repaired.count("[") - repaired.count("]")
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
- async with httpx.AsyncClient() as client:
54
- response = await client.post(
55
- f"{self.base_url}/chat/completions",
56
- headers=self.headers,
57
- json=payload,
58
- timeout=self.timeout
59
- )
60
- response.raise_for_status()
61
- data = response.json()
62
- return data["choices"][0]["message"]["content"]
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
- yield f"\n\n[Error: Model API returned {e.response.status_code}. Please check your API key if 401.]"
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.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.6 (Latest)
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