nimcode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nimcode/__init__.py +0 -0
- nimcode/agent.py +582 -0
- nimcode/cli.py +127 -0
- nimcode/config.py +54 -0
- nimcode/lenient_parser.py +101 -0
- nimcode/mcp_client.py +77 -0
- nimcode/memory.py +69 -0
- nimcode/nim_client.py +120 -0
- nimcode/permissions.py +78 -0
- nimcode/tools.py +323 -0
- nimcode-0.1.0.dist-info/METADATA +12 -0
- nimcode-0.1.0.dist-info/RECORD +15 -0
- nimcode-0.1.0.dist-info/WHEEL +5 -0
- nimcode-0.1.0.dist-info/entry_points.txt +2 -0
- nimcode-0.1.0.dist-info/top_level.txt +1 -0
nimcode/cli.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from .agent import Agent
|
|
7
|
+
from .config import load_settings, save_global_setting
|
|
8
|
+
from .permissions import PermissionMode
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
def run_login():
|
|
13
|
+
console.print("[bold cyan]NimCode Login[/bold cyan]")
|
|
14
|
+
console.print("Get your API key from [bold underline blue]https://build.nvidia.com/[/bold underline blue]")
|
|
15
|
+
|
|
16
|
+
import getpass
|
|
17
|
+
api_key = getpass.getpass("Enter your NVIDIA NIM API Key: ")
|
|
18
|
+
if not api_key.strip():
|
|
19
|
+
console.print("[red]API Key cannot be empty.[/red]")
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
save_global_setting("api_key", api_key.strip())
|
|
23
|
+
console.print("[green][OK] API Key saved successfully to ~/.nimcode/settings.json[/green]")
|
|
24
|
+
|
|
25
|
+
def run_doctor():
|
|
26
|
+
console.print("[bold cyan]NimCode Doctor[/bold cyan] - Diagnostics")
|
|
27
|
+
key = os.environ.get("NIM_API_KEY")
|
|
28
|
+
if key:
|
|
29
|
+
console.print("[green][OK][/green] NIM_API_KEY environment variable is set.")
|
|
30
|
+
else:
|
|
31
|
+
console.print("[red][X][/red] NIM_API_KEY environment variable is missing.")
|
|
32
|
+
|
|
33
|
+
# Check .nimcode existence
|
|
34
|
+
if os.path.exists(".nimcode"):
|
|
35
|
+
console.print("[green][OK][/green] .nimcode directory found in current project.")
|
|
36
|
+
else:
|
|
37
|
+
console.print("[yellow][!][/yellow] No .nimcode directory found. Standard settings apply.")
|
|
38
|
+
|
|
39
|
+
console.print("[green][OK][/green] MCP SDK installed.")
|
|
40
|
+
console.print("Diagnostics complete.")
|
|
41
|
+
|
|
42
|
+
def install_hook():
|
|
43
|
+
if not os.path.exists(".git"):
|
|
44
|
+
console.print("[red][X][/red] Not a git repository.")
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
hook_path = os.path.join(".git", "hooks", "prepare-commit-msg")
|
|
48
|
+
with open(hook_path, "w", encoding="utf-8") as f:
|
|
49
|
+
f.write("#!/bin/sh\n")
|
|
50
|
+
f.write("# NimCode auto-commit hook\n")
|
|
51
|
+
f.write("if [ -z \"$(cat $1)\" ]; then\n")
|
|
52
|
+
f.write(" nimcode /commit > $1\n")
|
|
53
|
+
f.write("fi\n")
|
|
54
|
+
|
|
55
|
+
import stat
|
|
56
|
+
os.chmod(hook_path, os.stat(hook_path).st_mode | stat.S_IEXEC)
|
|
57
|
+
console.print(f"[green][OK][/green] Git hook installed to {hook_path}")
|
|
58
|
+
|
|
59
|
+
def main():
|
|
60
|
+
parser = argparse.ArgumentParser(description="NimCode: Autonomous Coding Agent for NVIDIA NIM APIs")
|
|
61
|
+
|
|
62
|
+
# Check for doctor manually to avoid subparser conflict
|
|
63
|
+
if len(sys.argv) > 1:
|
|
64
|
+
if sys.argv[1] == "doctor":
|
|
65
|
+
run_doctor()
|
|
66
|
+
return
|
|
67
|
+
elif sys.argv[1] == "install-hook":
|
|
68
|
+
install_hook()
|
|
69
|
+
return
|
|
70
|
+
elif sys.argv[1] == "login":
|
|
71
|
+
run_login()
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
# Main CLI arguments
|
|
75
|
+
parser.add_argument("prompt", nargs="?", default=None, help="The task you want NimCode to accomplish. If omitted, starts interactive REPL.")
|
|
76
|
+
parser.add_argument("--api-key", "-k", default=None, help="NVIDIA NIM API Key. Can also be set via NIM_API_KEY environment variable.")
|
|
77
|
+
parser.add_argument("--model", "-m", default="meta/llama-3.1-70b-instruct", help="Model ID to use from NIM.")
|
|
78
|
+
parser.add_argument("--max-turns", "-t", type=int, default=30, help="Maximum number of turns the agent is allowed to run.")
|
|
79
|
+
parser.add_argument("--permission-mode", "-p", type=PermissionMode, choices=list(PermissionMode), default=PermissionMode.DEFAULT, help="Permission mode for mutating tools.")
|
|
80
|
+
parser.add_argument("--resume", "-r", action="store_true", help="Resume from the last session stored in NIMCODE.md.")
|
|
81
|
+
|
|
82
|
+
args = parser.parse_args()
|
|
83
|
+
|
|
84
|
+
settings = load_settings()
|
|
85
|
+
|
|
86
|
+
final_key = args.api_key or os.environ.get("NIM_API_KEY") or settings.get("api_key")
|
|
87
|
+
if not final_key:
|
|
88
|
+
console.print("[yellow]No API Key found. Let's get you set up![/yellow]")
|
|
89
|
+
run_login()
|
|
90
|
+
settings = load_settings()
|
|
91
|
+
final_key = settings.get("api_key")
|
|
92
|
+
if not final_key:
|
|
93
|
+
console.print("[bold red]API Key is required to use NimCode. Exiting.[/bold red]")
|
|
94
|
+
sys.exit(1)
|
|
95
|
+
|
|
96
|
+
console.print(f"[bold green]Starting NimCode[/bold green] with model [cyan]{args.model}[/cyan]")
|
|
97
|
+
|
|
98
|
+
agent = Agent(
|
|
99
|
+
api_key=final_key,
|
|
100
|
+
model=args.model,
|
|
101
|
+
max_turns=args.max_turns,
|
|
102
|
+
permission_mode=args.permission_mode
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if args.resume:
|
|
106
|
+
# Load from history if possible
|
|
107
|
+
agent.load_history()
|
|
108
|
+
|
|
109
|
+
piped_input = None
|
|
110
|
+
if not sys.stdin.isatty():
|
|
111
|
+
piped_input = sys.stdin.read().strip()
|
|
112
|
+
|
|
113
|
+
if piped_input:
|
|
114
|
+
prompt = f"{piped_input}\n\n{args.prompt or ''}".strip()
|
|
115
|
+
console.print(f"Task (with piped input): {prompt}")
|
|
116
|
+
asyncio.run(agent.run(prompt))
|
|
117
|
+
elif args.prompt:
|
|
118
|
+
console.print(f"Task: {args.prompt}")
|
|
119
|
+
asyncio.run(agent.run(args.prompt))
|
|
120
|
+
else:
|
|
121
|
+
console.print("[bold yellow]Entering Interactive REPL Mode[/bold yellow]. Type /exit to quit, /plan for planning mode, /code for coding mode.")
|
|
122
|
+
asyncio.run(agent.start_repl())
|
|
123
|
+
|
|
124
|
+
console.print("[bold green]Done![/bold green]")
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
main()
|
nimcode/config.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Dict, Any
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
def load_settings() -> Dict[str, Any]:
|
|
9
|
+
"""Loads configuration from ~/.nimcode/settings.json and .nimcode/settings.json"""
|
|
10
|
+
settings = {
|
|
11
|
+
"model": "meta/llama-3.1-70b-instruct",
|
|
12
|
+
"mcp_servers": {}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
# Global settings
|
|
16
|
+
global_path = os.path.expanduser("~/.nimcode/settings.json")
|
|
17
|
+
if os.path.exists(global_path):
|
|
18
|
+
try:
|
|
19
|
+
with open(global_path, "r", encoding="utf-8") as f:
|
|
20
|
+
global_settings = json.load(f)
|
|
21
|
+
settings.update(global_settings)
|
|
22
|
+
except Exception as e:
|
|
23
|
+
logger.error(f"Failed to load global settings: {e}")
|
|
24
|
+
|
|
25
|
+
# Local settings
|
|
26
|
+
local_path = os.path.join(os.getcwd(), ".nimcode", "settings.json")
|
|
27
|
+
if os.path.exists(local_path):
|
|
28
|
+
try:
|
|
29
|
+
with open(local_path, "r", encoding="utf-8") as f:
|
|
30
|
+
local_settings = json.load(f)
|
|
31
|
+
settings.update(local_settings)
|
|
32
|
+
except Exception as e:
|
|
33
|
+
logger.error(f"Failed to load local settings: {e}")
|
|
34
|
+
|
|
35
|
+
return settings
|
|
36
|
+
|
|
37
|
+
def save_global_setting(key: str, value: Any) -> None:
|
|
38
|
+
"""Saves a setting to ~/.nimcode/settings.json"""
|
|
39
|
+
global_dir = os.path.expanduser("~/.nimcode")
|
|
40
|
+
os.makedirs(global_dir, exist_ok=True)
|
|
41
|
+
global_path = os.path.join(global_dir, "settings.json")
|
|
42
|
+
|
|
43
|
+
settings = {}
|
|
44
|
+
if os.path.exists(global_path):
|
|
45
|
+
try:
|
|
46
|
+
with open(global_path, "r", encoding="utf-8") as f:
|
|
47
|
+
settings = json.load(f)
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
settings[key] = value
|
|
52
|
+
|
|
53
|
+
with open(global_path, "w", encoding="utf-8") as f:
|
|
54
|
+
json.dump(settings, f, indent=4)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Dict, Any, List, Tuple, Optional
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
class LenientParser:
|
|
9
|
+
@staticmethod
|
|
10
|
+
def extract_tool_calls(text: str) -> List[str]:
|
|
11
|
+
"""Extracts JSON strings from <tool_call> fenced blocks."""
|
|
12
|
+
# The model might forget the closing tag, or put markdown fences around it.
|
|
13
|
+
# We look for <tool_call> and extract everything until </tool_call> or EOF.
|
|
14
|
+
pattern = re.compile(r"<tool_call>\s*(.*?)(?:</tool_call>|\Z)", re.DOTALL | re.IGNORECASE)
|
|
15
|
+
matches = pattern.findall(text)
|
|
16
|
+
|
|
17
|
+
# Clean up markdown code blocks if the model wrapped the JSON in them
|
|
18
|
+
cleaned_matches = []
|
|
19
|
+
for match in matches:
|
|
20
|
+
match = match.strip()
|
|
21
|
+
if match.startswith("```json"):
|
|
22
|
+
match = match[7:]
|
|
23
|
+
elif match.startswith("```"):
|
|
24
|
+
match = match[3:]
|
|
25
|
+
|
|
26
|
+
if match.endswith("```"):
|
|
27
|
+
match = match[:-3]
|
|
28
|
+
|
|
29
|
+
cleaned_matches.append(match.strip())
|
|
30
|
+
|
|
31
|
+
return cleaned_matches
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def repair_json(json_str: str) -> str:
|
|
35
|
+
"""Attempts to fix common model JSON formatting errors."""
|
|
36
|
+
repaired = json_str.strip()
|
|
37
|
+
|
|
38
|
+
# 1. Remove trailing commas in objects and arrays
|
|
39
|
+
repaired = re.sub(r",\s*}", "}", repaired)
|
|
40
|
+
repaired = re.sub(r",\s*]", "]", repaired)
|
|
41
|
+
|
|
42
|
+
# 2. Fix unescaped newlines within strings.
|
|
43
|
+
# A simple approach: we find things that look like string literals and escape newlines.
|
|
44
|
+
# We'll use a state machine or regex for simple cases.
|
|
45
|
+
# For a robust approach without a full parser, we replace literal newlines that are inside quotes.
|
|
46
|
+
# Actually, python's json.loads is quite strict about unescaped newlines.
|
|
47
|
+
def escape_newlines_in_strings(match):
|
|
48
|
+
return match.group(0).replace("\n", "\\n")
|
|
49
|
+
|
|
50
|
+
# Match string literals: " followed by anything except unescaped quote, followed by "
|
|
51
|
+
# We need to be careful with escaped quotes inside the string.
|
|
52
|
+
string_regex = re.compile(r'"(?:[^"\\]|\\.)*"', re.DOTALL)
|
|
53
|
+
repaired = string_regex.sub(escape_newlines_in_strings, repaired)
|
|
54
|
+
|
|
55
|
+
# 3. Very rudimentary single quote to double quote conversion for keys/values
|
|
56
|
+
# (Only if it fails standard parsing, but we can do it proactively for top-level if needed).
|
|
57
|
+
# We will try standard json.loads first, and if it fails, maybe do more aggressive repair.
|
|
58
|
+
return repaired
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def parse_tool_call(cls, tool_call_text: str) -> Dict[str, Any]:
|
|
62
|
+
"""Parses a tool call string into a dictionary."""
|
|
63
|
+
try:
|
|
64
|
+
return json.loads(tool_call_text)
|
|
65
|
+
except json.JSONDecodeError:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
# Try repairing
|
|
69
|
+
repaired = cls.repair_json(tool_call_text)
|
|
70
|
+
try:
|
|
71
|
+
return json.loads(repaired)
|
|
72
|
+
except json.JSONDecodeError as e:
|
|
73
|
+
logger.error(f"Failed to parse tool call even after repair: {repaired}")
|
|
74
|
+
raise ValueError(f"Malformed tool call JSON: {e}") from e
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def process_model_response(cls, text: str) -> Tuple[str, List[Dict[str, Any]]]:
|
|
78
|
+
"""
|
|
79
|
+
Parses the raw text response from the model.
|
|
80
|
+
Returns (plain_text_message, list_of_tool_calls).
|
|
81
|
+
"""
|
|
82
|
+
# First, extract the tool calls
|
|
83
|
+
tool_call_strings = cls.extract_tool_calls(text)
|
|
84
|
+
|
|
85
|
+
tool_calls = []
|
|
86
|
+
for tc_str in tool_call_strings:
|
|
87
|
+
if not tc_str:
|
|
88
|
+
continue
|
|
89
|
+
try:
|
|
90
|
+
tc = cls.parse_tool_call(tc_str)
|
|
91
|
+
tool_calls.append(tc)
|
|
92
|
+
except ValueError:
|
|
93
|
+
# We could append an error message or raise. We'll raise to let the agent loop handle it and re-prompt.
|
|
94
|
+
raise
|
|
95
|
+
|
|
96
|
+
# The plain text is whatever is not in the tool_call blocks.
|
|
97
|
+
# We remove the tool call blocks from the original text to get the assistant's prose.
|
|
98
|
+
plain_text = re.sub(r"<tool_call>\s*(.*?)(?:</tool_call>|\Z)", "", text, flags=re.DOTALL | re.IGNORECASE)
|
|
99
|
+
plain_text = plain_text.strip()
|
|
100
|
+
|
|
101
|
+
return plain_text, tool_calls
|
nimcode/mcp_client.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Dict, Any, List
|
|
3
|
+
from contextlib import AsyncExitStack
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
class MCPManager:
|
|
8
|
+
"""Manages connections to MCP servers."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, mcp_config: Dict[str, Any]):
|
|
11
|
+
self.config = mcp_config
|
|
12
|
+
self.servers = mcp_config.get("mcp_servers", {})
|
|
13
|
+
self.exit_stack = AsyncExitStack()
|
|
14
|
+
self.sessions = {} # name -> ClientSession
|
|
15
|
+
self.server_tools = {} # name -> list of tools
|
|
16
|
+
|
|
17
|
+
async def connect_all(self):
|
|
18
|
+
"""Initialize all MCP servers."""
|
|
19
|
+
if not self.servers:
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from mcp.client.stdio import stdio_client, StdioServerParameters
|
|
24
|
+
from mcp.client.session import ClientSession
|
|
25
|
+
except ImportError:
|
|
26
|
+
logger.warning("MCP SDK not installed. Skipping MCP initialization.")
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
for name, details in self.servers.items():
|
|
30
|
+
cmd = details.get("command")
|
|
31
|
+
args = details.get("args", [])
|
|
32
|
+
env = details.get("env", None)
|
|
33
|
+
|
|
34
|
+
logger.info(f"Connecting to MCP server '{name}'...")
|
|
35
|
+
try:
|
|
36
|
+
params = StdioServerParameters(command=cmd, args=args, env=env)
|
|
37
|
+
read, write = await self.exit_stack.enter_async_context(stdio_client(params))
|
|
38
|
+
session = await self.exit_stack.enter_async_context(ClientSession(read, write))
|
|
39
|
+
await session.initialize()
|
|
40
|
+
self.sessions[name] = session
|
|
41
|
+
|
|
42
|
+
# Fetch tools
|
|
43
|
+
tools_response = await session.list_tools()
|
|
44
|
+
self.server_tools[name] = tools_response.tools if hasattr(tools_response, "tools") else []
|
|
45
|
+
logger.info(f"MCP Server '{name}' initialized with {len(self.server_tools[name])} tools.")
|
|
46
|
+
except Exception as e:
|
|
47
|
+
logger.error(f"Failed to connect to MCP server '{name}': {e}")
|
|
48
|
+
|
|
49
|
+
async def close(self):
|
|
50
|
+
await self.exit_stack.aclose()
|
|
51
|
+
|
|
52
|
+
def get_system_prompt_additions(self) -> str:
|
|
53
|
+
"""Returns extra text to add to the system prompt about MCPs."""
|
|
54
|
+
if not self.sessions:
|
|
55
|
+
return ""
|
|
56
|
+
|
|
57
|
+
lines = ["\n\nAvailable MCP Servers & Tools:"]
|
|
58
|
+
for name, tools in self.server_tools.items():
|
|
59
|
+
lines.append(f"- Server '{name}':")
|
|
60
|
+
for t in tools:
|
|
61
|
+
lines.append(f" * {t.name}: {t.description}")
|
|
62
|
+
return "\n".join(lines)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def call_tool_by_name(self, tool_name: str, arguments: dict) -> Any:
|
|
66
|
+
for server_name, tools in self.server_tools.items():
|
|
67
|
+
for t in tools:
|
|
68
|
+
if t.name == tool_name:
|
|
69
|
+
return await self.sessions[server_name].call_tool(tool_name, arguments)
|
|
70
|
+
raise ValueError(f"MCP Tool {tool_name} not found.")
|
|
71
|
+
|
|
72
|
+
async def call_tool(self, server_name: str, tool_name: str, arguments: dict) -> Any:
|
|
73
|
+
if server_name not in self.sessions:
|
|
74
|
+
raise ValueError(f"Server {server_name} not found or not connected.")
|
|
75
|
+
session = self.sessions[server_name]
|
|
76
|
+
result = await session.call_tool(tool_name, arguments)
|
|
77
|
+
return result
|
nimcode/memory.py
ADDED
|
@@ -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")
|
nimcode/nim_client.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import asyncio
|
|
3
|
+
import logging
|
|
4
|
+
import json
|
|
5
|
+
import random
|
|
6
|
+
from typing import List, Dict, Any, Optional, AsyncGenerator
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
class NimClient:
|
|
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
|
+
self.api_key = api_key
|
|
13
|
+
self.base_url = base_url.rstrip("/")
|
|
14
|
+
self.model = model
|
|
15
|
+
self.headers = {
|
|
16
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
17
|
+
"Content-Type": "application/json",
|
|
18
|
+
"Accept": "text/event-stream"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async def get_available_models(self) -> List[str]:
|
|
22
|
+
return [
|
|
23
|
+
"meta/llama-3.1-70b-instruct",
|
|
24
|
+
"meta/llama-3.1-8b-instruct",
|
|
25
|
+
"meta/llama-3.1-405b-instruct",
|
|
26
|
+
"nvidia/nemotron-4-340b-instruct",
|
|
27
|
+
"mistralai/mixtral-8x22b-instruct-v0.1"
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
async def chat_one_shot(self, prompt: str) -> str:
|
|
31
|
+
payload = {
|
|
32
|
+
"model": self.model,
|
|
33
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
34
|
+
"max_tokens": 1024,
|
|
35
|
+
"temperature": 0.2
|
|
36
|
+
}
|
|
37
|
+
async with httpx.AsyncClient() as client:
|
|
38
|
+
response = await client.post(
|
|
39
|
+
f"{self.base_url}/chat/completions",
|
|
40
|
+
headers=self.headers,
|
|
41
|
+
json=payload,
|
|
42
|
+
timeout=60.0
|
|
43
|
+
)
|
|
44
|
+
response.raise_for_status()
|
|
45
|
+
data = response.json()
|
|
46
|
+
return data["choices"][0]["message"]["content"]
|
|
47
|
+
|
|
48
|
+
async def chat_vision(self, base64_image: str, prompt: str) -> str:
|
|
49
|
+
# For vision, we might need a specific vision model, but we'll try with the default if it supports multimodal
|
|
50
|
+
# Or switch to a known vision model like nv-llama-3.2-90b-vision-instruct
|
|
51
|
+
vision_model = "meta/llama-3.2-90b-vision-instruct"
|
|
52
|
+
payload = {
|
|
53
|
+
"model": vision_model,
|
|
54
|
+
"messages": [
|
|
55
|
+
{
|
|
56
|
+
"role": "user",
|
|
57
|
+
"content": [
|
|
58
|
+
{"type": "text", "text": prompt},
|
|
59
|
+
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{base64_image}"}}
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
],
|
|
63
|
+
"max_tokens": 1024,
|
|
64
|
+
"temperature": 0.2
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async with httpx.AsyncClient() as client:
|
|
68
|
+
try:
|
|
69
|
+
response = await client.post(
|
|
70
|
+
f"{self.base_url}/chat/completions",
|
|
71
|
+
headers=self.headers,
|
|
72
|
+
json=payload,
|
|
73
|
+
timeout=60.0
|
|
74
|
+
)
|
|
75
|
+
response.raise_for_status()
|
|
76
|
+
data = response.json()
|
|
77
|
+
return data["choices"][0]["message"]["content"]
|
|
78
|
+
except httpx.HTTPStatusError as e:
|
|
79
|
+
logger.error(f"Vision API error: {e.response.text}")
|
|
80
|
+
return f"Vision processing failed: {e.response.text}"
|
|
81
|
+
except Exception as e:
|
|
82
|
+
return f"Vision error: {str(e)}"
|
|
83
|
+
|
|
84
|
+
async def chat(self, messages: List[Dict[str, Any]], tools: Optional[List[Dict[str, Any]]] = None, stream: bool = True) -> AsyncGenerator[str, None]:
|
|
85
|
+
payload = {
|
|
86
|
+
"model": self.model,
|
|
87
|
+
"messages": messages,
|
|
88
|
+
"max_tokens": 4096,
|
|
89
|
+
"temperature": 0.2,
|
|
90
|
+
"stream": stream
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async with httpx.AsyncClient() as client:
|
|
94
|
+
try:
|
|
95
|
+
async with client.stream("POST", f"{self.base_url}/chat/completions", headers=self.headers, json=payload, timeout=60.0) as response:
|
|
96
|
+
response.raise_for_status()
|
|
97
|
+
async for line in response.aiter_lines():
|
|
98
|
+
if line.startswith("data: ") and line != "data: [DONE]":
|
|
99
|
+
data_str = line[6:]
|
|
100
|
+
try:
|
|
101
|
+
data_json = json.loads(data_str)
|
|
102
|
+
chunk = data_json["choices"][0]["delta"].get("content", "")
|
|
103
|
+
if chunk:
|
|
104
|
+
yield chunk
|
|
105
|
+
except json.JSONDecodeError:
|
|
106
|
+
pass
|
|
107
|
+
except httpx.HTTPStatusError as e:
|
|
108
|
+
await e.response.aread()
|
|
109
|
+
logger.error(f"API HTTP error: {e.response.status_code} - {e.response.text}")
|
|
110
|
+
yield f"\\n\\n[Error: Model API returned {e.response.status_code}. Please check your NVIDIA API key.]"
|
|
111
|
+
except Exception as e:
|
|
112
|
+
logger.error(f"API connection error: {e}")
|
|
113
|
+
yield f"\\n\\n[Error communicating with NVIDIA API: {e}]"
|
|
114
|
+
|
|
115
|
+
def count_tokens_approx(self, messages: List[Dict[str, Any]]) -> int:
|
|
116
|
+
total_chars = 0
|
|
117
|
+
for msg in messages:
|
|
118
|
+
total_chars += len(msg.get("role", ""))
|
|
119
|
+
total_chars += len(str(msg.get("content", "")))
|
|
120
|
+
return total_chars // 4 + (len(messages) * 4)
|
nimcode/permissions.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Dict, Any
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
class PermissionMode(str, Enum):
|
|
12
|
+
DEFAULT = "default" # Prompts for everything (simulated in headless)
|
|
13
|
+
BYPASS = "bypass" # Allows everything
|
|
14
|
+
AUTO = "auto" # Allows safe reads, prompts for dangerous actions
|
|
15
|
+
|
|
16
|
+
class PermissionEngine:
|
|
17
|
+
def __init__(self, mode: PermissionMode = PermissionMode.DEFAULT):
|
|
18
|
+
self.mode = mode
|
|
19
|
+
self.safe_tools = {"Read", "Glob", "Grep"}
|
|
20
|
+
|
|
21
|
+
def check_permission(self, tool_call: dict) -> bool:
|
|
22
|
+
"""Returns True if permitted, False otherwise."""
|
|
23
|
+
if self.mode == PermissionMode.BYPASS:
|
|
24
|
+
return True
|
|
25
|
+
|
|
26
|
+
tool_name = tool_call.get("tool")
|
|
27
|
+
|
|
28
|
+
if self.mode == PermissionMode.AUTO and tool_name in self.safe_tools:
|
|
29
|
+
return True
|
|
30
|
+
|
|
31
|
+
return self._prompt_user(tool_call)
|
|
32
|
+
|
|
33
|
+
def _prompt_user(self, tool_call: Dict[str, Any]) -> bool:
|
|
34
|
+
from rich.panel import Panel
|
|
35
|
+
from rich.prompt import Prompt
|
|
36
|
+
import json
|
|
37
|
+
|
|
38
|
+
tool_name = tool_call.get("tool")
|
|
39
|
+
args = tool_call.get("args", {})
|
|
40
|
+
|
|
41
|
+
if not sys.stdin.isatty():
|
|
42
|
+
console.print(f"[yellow]Running non-interactive. Approving {tool_name} for tests.[/yellow]")
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
while True:
|
|
46
|
+
content = ""
|
|
47
|
+
if tool_name == "Bash":
|
|
48
|
+
content = f"[bold]Command:[/bold]\n{args.get('command')}"
|
|
49
|
+
elif tool_name == "Write":
|
|
50
|
+
content = f"[bold]File:[/bold] {args.get('file_path')}\n[bold]Action:[/bold] Overwrite with new content"
|
|
51
|
+
elif tool_name == "Edit":
|
|
52
|
+
content = f"[bold]File:[/bold] {args.get('file_path')}\n[bold]Replacing:[/bold] '{args.get('old_string')}' -> '{args.get('new_string')}'"
|
|
53
|
+
else:
|
|
54
|
+
content = f"[bold]Args:[/bold] {json.dumps(args, indent=2)}"
|
|
55
|
+
|
|
56
|
+
panel = Panel(content, title=f"NimCode Wants to Run: {tool_name}", border_style="yellow")
|
|
57
|
+
console.print(panel)
|
|
58
|
+
|
|
59
|
+
choice = Prompt.ask("[bold cyan]Action[/bold cyan] [green](a)ccept[/green] / [red](r)eject[/red] / [blue](e)dit[/blue]", choices=["a", "r", "e"], default="a", show_choices=False)
|
|
60
|
+
|
|
61
|
+
if choice == "a":
|
|
62
|
+
return True
|
|
63
|
+
elif choice == "r":
|
|
64
|
+
return False
|
|
65
|
+
elif choice == "e":
|
|
66
|
+
from prompt_toolkit import prompt as pt_prompt
|
|
67
|
+
if tool_name == "Bash":
|
|
68
|
+
new_cmd = pt_prompt("Edit Command: ", default=args.get("command", ""))
|
|
69
|
+
tool_call["args"]["command"] = new_cmd
|
|
70
|
+
else:
|
|
71
|
+
new_args_str = pt_prompt("Edit Args (JSON): ", default=json.dumps(args))
|
|
72
|
+
try:
|
|
73
|
+
tool_call["args"] = json.loads(new_args_str)
|
|
74
|
+
except json.JSONDecodeError:
|
|
75
|
+
console.print("[red]Invalid JSON. Please try again.[/red]")
|
|
76
|
+
continue
|
|
77
|
+
args = tool_call.get("args", {})
|
|
78
|
+
# Loop repeats and shows the updated panel for final confirmation
|