sky-dev 0.1.9__tar.gz → 0.1.14__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.
- {sky_dev-0.1.9 → sky_dev-0.1.14}/CHANGELOG.md +32 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/PKG-INFO +1 -1
- {sky_dev-0.1.9 → sky_dev-0.1.14}/pyproject.toml +1 -1
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/__init__.py +1 -1
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/cli.py +26 -1
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/core/chat.py +1 -5
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/core/router.py +56 -31
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/core/workflow.py +70 -47
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/errors.py +1 -1
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/security/guardrails_nvidia.py +86 -36
- sky_dev-0.1.14/sky/tools/test_tools.py +315 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky_dev.egg-info/PKG-INFO +1 -1
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky_dev.egg-info/SOURCES.txt +1 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/.env.example +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/CONTRIBUTING.md +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/LICENSE +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/MANIFEST.in +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/README.md +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/SECURITY.md +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/setup.cfg +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/config/__init__.py +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/config/models.yaml +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/config/schema.py +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/core/__init__.py +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/core/approval.py +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/core/benchmark.py +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/core/fast_loop.py +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/core/mode_prompts.py +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/core/subagent.py +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/memory/__init__.py +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/memory/indexer.py +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/memory/vectorstore.py +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/security/__init__.py +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/security/audit.py +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/security/detection.py +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/security/guardrails.py +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/security/prompts.py +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/security/rate_limit.py +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/security/sanitize.py +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/storage/__init__.py +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/storage/db.py +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/tools/__init__.py +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/tools/fs_tools.py +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/tools/git_tools.py +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/tools/registry.py +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/tools/search_tools.py +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/tools/shell_tools.py +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky_dev.egg-info/dependency_links.txt +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky_dev.egg-info/entry_points.txt +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky_dev.egg-info/requires.txt +0 -0
- {sky_dev-0.1.9 → sky_dev-0.1.14}/sky_dev.egg-info/top_level.txt +0 -0
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.14] - 2026-10-06
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- Workflow crash: "No module named 'sky.tools.test_tools'"
|
|
7
|
+
- Workflow hang: 60s timeout added to LLM calls
|
|
8
|
+
- Guardrails crash: "Missing credentials" when API key absent
|
|
9
|
+
- Router now lazily loads provider clients on first use
|
|
10
|
+
- Error messages now reference correct env var name (`NVIDIA_NIM_API_KEY`)
|
|
11
|
+
- `.env` reloaded dynamically for late-loaded keys
|
|
12
|
+
- Client rotation on HTTP 429 rate limit
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- Lazy provider initialization (no eager client creation)
|
|
16
|
+
- Graceful Ctrl+C handling during workflow
|
|
17
|
+
- Elapsed time shown for each workflow step
|
|
18
|
+
|
|
19
|
+
### UI
|
|
20
|
+
- Replaced cloud logo with SKY ASCII art
|
|
21
|
+
- Added username in header (from sky init)
|
|
22
|
+
- Removed model and working directory from chat header
|
|
23
|
+
|
|
24
|
+
## [0.1.10] - 2026-09-28
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
- Claude-style beautiful terminal UI components (`sky.ui`)
|
|
28
|
+
- Clean ASCII art logo (Option B)
|
|
29
|
+
- Personalized username configuration (`sky init` asks for username)
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- Fixed NVIDIA Guardrails 404 Error by adding a runtime model health-check verification
|
|
33
|
+
- Fixed visual clipping by removing model and working directory from the chat header
|
|
34
|
+
|
|
3
35
|
## [0.1.9] - 2026-09-28
|
|
4
36
|
|
|
5
37
|
### Added
|
|
@@ -22,6 +22,7 @@ import typer
|
|
|
22
22
|
from rich.console import Console
|
|
23
23
|
|
|
24
24
|
from sky import __version__
|
|
25
|
+
from sky.ui import render_header, render_message, render_thinking, render_tool_call, render_diff, render_permission
|
|
25
26
|
|
|
26
27
|
app = typer.Typer(help="SKY - Local CLI-based agentic software development assistant")
|
|
27
28
|
console = Console()
|
|
@@ -238,6 +239,19 @@ def chat(
|
|
|
238
239
|
|
|
239
240
|
Created by Aaditya A (AI/ML Intern at CoRover.ai)
|
|
240
241
|
"""
|
|
242
|
+
render_header(
|
|
243
|
+
version=__version__,
|
|
244
|
+
tips=[
|
|
245
|
+
"Ask Sky to explain your code",
|
|
246
|
+
"Try: sky plan 'add feature X'",
|
|
247
|
+
"Use sky agent for full execution",
|
|
248
|
+
],
|
|
249
|
+
whats_new=[
|
|
250
|
+
"Added NVIDIA Guardrails",
|
|
251
|
+
"Faster CLI startup",
|
|
252
|
+
"Dynamic model discovery",
|
|
253
|
+
],
|
|
254
|
+
)
|
|
241
255
|
from sky.config import load_config, load_models_config
|
|
242
256
|
from sky.storage import get_db
|
|
243
257
|
from sky.core.router import ModelRouter
|
|
@@ -689,6 +703,17 @@ def init(
|
|
|
689
703
|
|
|
690
704
|
from sky.config.schema import get_config_dir
|
|
691
705
|
config_dir = get_config_dir(global_mode=global_install)
|
|
706
|
+
|
|
707
|
+
import json
|
|
708
|
+
import os
|
|
709
|
+
default_user = os.getenv("USER") or os.getenv("USERNAME") or "Developer"
|
|
710
|
+
username = Prompt.ask("What should Sky call you?", default=default_user)
|
|
711
|
+
|
|
712
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
713
|
+
user_config = config_dir / "user.json"
|
|
714
|
+
with open(user_config, "w") as f:
|
|
715
|
+
json.dump({"username": username}, f)
|
|
716
|
+
console.print(f"[green]✅ Username saved: {username}[/green]")
|
|
692
717
|
|
|
693
718
|
def _save_env(groq_key: str, nim_key: str):
|
|
694
719
|
with open(config_dir / ".env", "w") as f:
|
|
@@ -833,7 +858,7 @@ def validate_model(provider: str, model_id: str) -> bool:
|
|
|
833
858
|
except:
|
|
834
859
|
pass
|
|
835
860
|
elif provider == "nim":
|
|
836
|
-
key = os.getenv("
|
|
861
|
+
key = os.getenv("NVIDIA_NIM_API_KEY")
|
|
837
862
|
if key:
|
|
838
863
|
try:
|
|
839
864
|
res = httpx.get("https://integrate.api.nvidia.com/v1/models", headers={"Authorization": f"Bearer {key}"})
|
|
@@ -71,11 +71,7 @@ class ChatEngine:
|
|
|
71
71
|
self.console.print("[yellow]⚠️ No API keys found. Run 'sky init' first.[/yellow]")
|
|
72
72
|
return
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
Markdown(self.get_welcome_message()),
|
|
76
|
-
title="☁️ Sky Chat",
|
|
77
|
-
border_style="cyan"
|
|
78
|
-
))
|
|
74
|
+
# Welcome message is now rendered by SkyHeader in cli.py
|
|
79
75
|
|
|
80
76
|
if initial_prompt:
|
|
81
77
|
self._process_user_message(initial_prompt)
|
|
@@ -14,6 +14,16 @@ class ModelRouter:
|
|
|
14
14
|
|
|
15
15
|
def __init__(self, config: ModelRoutingConfig, db: DatabaseManager, session_id: str):
|
|
16
16
|
import os
|
|
17
|
+
from dotenv import load_dotenv
|
|
18
|
+
from sky.config.schema import get_config_dir
|
|
19
|
+
|
|
20
|
+
# Load .env
|
|
21
|
+
env_path = get_config_dir() / ".env"
|
|
22
|
+
if env_path.exists():
|
|
23
|
+
load_dotenv(env_path)
|
|
24
|
+
else:
|
|
25
|
+
load_dotenv()
|
|
26
|
+
|
|
17
27
|
self.config = config
|
|
18
28
|
self.db = db
|
|
19
29
|
self.session_id = session_id
|
|
@@ -22,42 +32,57 @@ class ModelRouter:
|
|
|
22
32
|
self.provider_keys = {}
|
|
23
33
|
self.current_key_idx = {}
|
|
24
34
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
env_prefix = "NVIDIA_NIM" if provider_name == "nim" else provider_name.upper()
|
|
29
|
-
keys = []
|
|
30
|
-
for key_name in [f"{env_prefix}_API_KEY", f"{env_prefix}_API_KEY_2", f"{env_prefix}_API_KEY_3"]:
|
|
31
|
-
key = os.getenv(key_name)
|
|
32
|
-
if key:
|
|
33
|
-
keys.append(key)
|
|
34
|
-
if not keys:
|
|
35
|
-
# Log a warning, but don't crash unless they try to use it
|
|
36
|
-
pass
|
|
37
|
-
else:
|
|
38
|
-
self.provider_keys[provider_name] = keys
|
|
39
|
-
self.current_key_idx[provider_name] = 0
|
|
40
|
-
else:
|
|
41
|
-
self.provider_keys[provider_name] = ["dummy"]
|
|
42
|
-
self.current_key_idx[provider_name] = 0
|
|
43
|
-
|
|
44
|
-
self._init_client(provider_name)
|
|
35
|
+
def _get_client(self, provider_name: str) -> Any:
|
|
36
|
+
"""Get or initialize the client for a specific provider."""
|
|
37
|
+
import os
|
|
45
38
|
|
|
46
|
-
|
|
47
|
-
|
|
39
|
+
if provider_name in self.clients:
|
|
40
|
+
return self.clients[provider_name]
|
|
41
|
+
|
|
48
42
|
provider_config = self.config.providers.get(provider_name)
|
|
49
43
|
if not provider_config:
|
|
50
|
-
return
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
if provider_config.requires_api_key:
|
|
47
|
+
env_prefix = "NVIDIA_NIM" if provider_name == "nim" else provider_name.upper()
|
|
48
|
+
keys = []
|
|
49
|
+
for key_name in [f"{env_prefix}_API_KEY", f"{env_prefix}_API_KEY_2", f"{env_prefix}_API_KEY_3"]:
|
|
50
|
+
key = os.getenv(key_name)
|
|
51
|
+
if key:
|
|
52
|
+
keys.append(key)
|
|
53
|
+
if not keys:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
self.provider_keys[provider_name] = keys
|
|
57
|
+
self.current_key_idx[provider_name] = 0
|
|
58
|
+
api_key = keys[0]
|
|
59
|
+
else:
|
|
60
|
+
self.provider_keys[provider_name] = ["dummy"]
|
|
61
|
+
self.current_key_idx[provider_name] = 0
|
|
62
|
+
api_key = "dummy"
|
|
51
63
|
|
|
64
|
+
kwargs = {
|
|
65
|
+
"api_key": api_key,
|
|
66
|
+
"timeout": httpx.Timeout(provider_config.timeout)
|
|
67
|
+
}
|
|
68
|
+
if provider_config.base_url:
|
|
69
|
+
kwargs["base_url"] = provider_config.base_url
|
|
70
|
+
|
|
71
|
+
self.clients[provider_name] = AsyncOpenAI(**kwargs)
|
|
72
|
+
return self.clients[provider_name]
|
|
73
|
+
|
|
74
|
+
def _rotate_client(self, provider_name: str):
|
|
75
|
+
"""Rotate to the next API key and reinitialize the client."""
|
|
52
76
|
keys = self.provider_keys.get(provider_name, [])
|
|
53
|
-
if
|
|
77
|
+
if len(keys) <= 1:
|
|
54
78
|
return
|
|
55
79
|
|
|
56
|
-
|
|
57
|
-
|
|
80
|
+
next_idx = (self.current_key_idx[provider_name] + 1) % len(keys)
|
|
81
|
+
self.current_key_idx[provider_name] = next_idx
|
|
58
82
|
|
|
83
|
+
provider_config = self.config.providers.get(provider_name)
|
|
59
84
|
kwargs = {
|
|
60
|
-
"api_key":
|
|
85
|
+
"api_key": keys[next_idx],
|
|
61
86
|
"timeout": httpx.Timeout(provider_config.timeout)
|
|
62
87
|
}
|
|
63
88
|
if provider_config.base_url:
|
|
@@ -106,12 +131,13 @@ class ModelRouter:
|
|
|
106
131
|
"""Call API, rotating keys if rate limit is hit."""
|
|
107
132
|
from rich.console import Console
|
|
108
133
|
|
|
109
|
-
client = self.
|
|
134
|
+
client = self._get_client(provider_name)
|
|
110
135
|
if not client:
|
|
136
|
+
env_prefix = "NVIDIA_NIM" if provider_name == "nim" else provider_name.upper()
|
|
111
137
|
raise ConfigurationError(
|
|
112
138
|
f"No active client for provider: {provider_name}.",
|
|
113
139
|
code="SKY-001",
|
|
114
|
-
suggestion=f"Add {
|
|
140
|
+
suggestion=f"Add {env_prefix}_API_KEY to .env or run `sky init`"
|
|
115
141
|
)
|
|
116
142
|
|
|
117
143
|
keys = self.provider_keys.get(provider_name, [])
|
|
@@ -127,8 +153,7 @@ class ModelRouter:
|
|
|
127
153
|
if len(keys) > 1:
|
|
128
154
|
next_idx = (self.current_key_idx[provider_name] + 1) % len(keys)
|
|
129
155
|
Console().print(f"[yellow]{provider_name.upper()} API key rate limited. Rotating to next key (slot {next_idx + 1}/{len(keys)})...[/yellow]")
|
|
130
|
-
self.
|
|
131
|
-
self._init_client(provider_name)
|
|
156
|
+
self._rotate_client(provider_name)
|
|
132
157
|
client = self.clients[provider_name]
|
|
133
158
|
else:
|
|
134
159
|
break
|
|
@@ -7,6 +7,9 @@ import json
|
|
|
7
7
|
import logging
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
import asyncio
|
|
10
|
+
import time
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
10
13
|
|
|
11
14
|
from langgraph.graph import StateGraph, END
|
|
12
15
|
from langgraph.checkpoint.sqlite import SqliteSaver
|
|
@@ -21,7 +24,7 @@ from sky.core.subagent import SubagentRunner, SubagentRole
|
|
|
21
24
|
from sky.memory.indexer import RepoIndexer
|
|
22
25
|
|
|
23
26
|
logger = logging.getLogger(__name__)
|
|
24
|
-
|
|
27
|
+
console = Console()
|
|
25
28
|
|
|
26
29
|
class WorkflowStatus(str, Enum):
|
|
27
30
|
"""Workflow execution status."""
|
|
@@ -154,9 +157,21 @@ class WorkflowEngine:
|
|
|
154
157
|
import asyncio
|
|
155
158
|
if isinstance(self.event_queue, asyncio.Queue):
|
|
156
159
|
await self.event_queue.put(event)
|
|
160
|
+
|
|
161
|
+
async def _call_model_with_timeout(self, role, messages, timeout=60):
|
|
162
|
+
try:
|
|
163
|
+
return await asyncio.wait_for(
|
|
164
|
+
self.router.route(role, messages, expected_tool_calls=0),
|
|
165
|
+
timeout=timeout
|
|
166
|
+
)
|
|
167
|
+
except asyncio.TimeoutError:
|
|
168
|
+
logger.error(f"Model call timed out after {timeout}s")
|
|
169
|
+
return {}, False, None
|
|
157
170
|
|
|
158
171
|
async def _refine_goal(self, state: WorkflowState) -> WorkflowState:
|
|
159
172
|
"""Refine the user's goal using the planning model."""
|
|
173
|
+
start = time.time()
|
|
174
|
+
console.print("[dim]Refining goal...[/dim]")
|
|
160
175
|
await self._emit({"type": "step", "step": "refining", "message": "🎯 Refining goal..."})
|
|
161
176
|
logger.info("Refining goal...")
|
|
162
177
|
|
|
@@ -172,11 +187,10 @@ Be specific about what files or components might be involved."""},
|
|
|
172
187
|
{"role": "user", "content": state["user_goal"]}
|
|
173
188
|
]
|
|
174
189
|
|
|
175
|
-
response, was_fallback, model_used = await self.
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
)
|
|
190
|
+
response, was_fallback, model_used = await self._call_model_with_timeout("planning", messages)
|
|
191
|
+
|
|
192
|
+
elapsed = time.time() - start
|
|
193
|
+
console.print(f"[dim]Refined in {elapsed:.1f}s[/dim]")
|
|
180
194
|
|
|
181
195
|
content = response.get("content", "")
|
|
182
196
|
|
|
@@ -192,7 +206,7 @@ Be specific about what files or components might be involved."""},
|
|
|
192
206
|
else:
|
|
193
207
|
state["refined_goal"] = content
|
|
194
208
|
state["goal_confidence"] = 0.5
|
|
195
|
-
except
|
|
209
|
+
except Exception:
|
|
196
210
|
state["refined_goal"] = content
|
|
197
211
|
state["goal_confidence"] = 0.5
|
|
198
212
|
|
|
@@ -202,6 +216,8 @@ Be specific about what files or components might be involved."""},
|
|
|
202
216
|
|
|
203
217
|
async def _decompose(self, state: WorkflowState) -> WorkflowState:
|
|
204
218
|
"""Decompose the goal into subtasks."""
|
|
219
|
+
start = time.time()
|
|
220
|
+
console.print("[dim]Breaking down into tasks...[/dim]")
|
|
205
221
|
await self._emit({"type": "step", "step": "decomposing", "message": "Breaking down into tasks..."})
|
|
206
222
|
logger.info("Decomposing goal into tasks...")
|
|
207
223
|
|
|
@@ -229,11 +245,10 @@ CRITICAL RULES:
|
|
|
229
245
|
{"role": "user", "content": f"Goal: {state.get('refined_goal', state['user_goal'])}"}
|
|
230
246
|
]
|
|
231
247
|
|
|
232
|
-
response, was_fallback, model_used = await self.
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
)
|
|
248
|
+
response, was_fallback, model_used = await self._call_model_with_timeout("planning", messages)
|
|
249
|
+
|
|
250
|
+
elapsed = time.time() - start
|
|
251
|
+
console.print(f"[dim]Decomposed in {elapsed:.1f}s[/dim]")
|
|
237
252
|
|
|
238
253
|
content = response.get("content", "")
|
|
239
254
|
|
|
@@ -254,7 +269,7 @@ CRITICAL RULES:
|
|
|
254
269
|
"tools_needed": ["read_file", "write_file", "edit_file", "run_tests"],
|
|
255
270
|
"role": "coder"
|
|
256
271
|
}]
|
|
257
|
-
except
|
|
272
|
+
except Exception:
|
|
258
273
|
state["tasks"] = [{
|
|
259
274
|
"id": "task_1",
|
|
260
275
|
"description": state.get("refined_goal", state["user_goal"]),
|
|
@@ -363,41 +378,39 @@ CRITICAL RULES:
|
|
|
363
378
|
|
|
364
379
|
async def _run_tests(self, state: WorkflowState) -> WorkflowState:
|
|
365
380
|
"""Run tests after code changes."""
|
|
381
|
+
start = time.time()
|
|
382
|
+
console.print("[dim]Running tests...[/dim]")
|
|
366
383
|
await self._emit({"type": "step", "step": "testing", "message": "Running tests..."})
|
|
367
384
|
logger.info("Running tests...")
|
|
368
385
|
|
|
369
|
-
#
|
|
370
|
-
test_files = []
|
|
371
|
-
for result in state.get("subagent_results", []):
|
|
372
|
-
if result.get("test_results"):
|
|
373
|
-
test_files.extend(result.get("test_files", []))
|
|
374
|
-
|
|
375
|
-
# Run tests using the test tool
|
|
376
|
-
from sky.tools.test_tools import run_tests
|
|
386
|
+
from sky.tools.test_tools import run_tests # Direct import — no fallback
|
|
377
387
|
|
|
378
388
|
try:
|
|
379
|
-
# run_tests returns a dict matching exactly what we need
|
|
380
389
|
result = run_tests(timeout=120)
|
|
390
|
+
state["test_results"] = result
|
|
381
391
|
|
|
382
|
-
|
|
383
|
-
"
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
state["status"] = WorkflowStatus.TESTING.value
|
|
392
|
+
if result.get("framework") == "none":
|
|
393
|
+
console.print(f"[yellow]⚠️ {result.get('error', 'No test framework detected')}[/yellow]")
|
|
394
|
+
elif result.get("success"):
|
|
395
|
+
console.print(f"[green]✅ {result['passed']} passed[/green]")
|
|
396
|
+
else:
|
|
397
|
+
console.print(f"[red]❌ {result['failed']} failed, {result['passed']} passed[/red]")
|
|
389
398
|
except Exception as e:
|
|
399
|
+
logger.error(f"Test execution failed: {e}")
|
|
390
400
|
state["test_results"] = {
|
|
391
|
-
"
|
|
392
|
-
"
|
|
393
|
-
"errors": [str(e)],
|
|
394
|
-
"success": False
|
|
401
|
+
"error": str(e),
|
|
402
|
+
"success": False,
|
|
395
403
|
}
|
|
396
404
|
|
|
405
|
+
elapsed = time.time() - start
|
|
406
|
+
console.print(f"[dim]Tests completed in {elapsed:.1f}s[/dim]")
|
|
407
|
+
state["status"] = WorkflowStatus.TESTING.value
|
|
397
408
|
return state
|
|
398
409
|
|
|
399
410
|
async def _reflect(self, state: WorkflowState) -> WorkflowState:
|
|
400
411
|
"""Reflect on test results and decide next action."""
|
|
412
|
+
start = time.time()
|
|
413
|
+
console.print("[dim]Reflecting on results...[/dim]")
|
|
401
414
|
await self._emit({"type": "step", "step": "reflecting", "message": "Reflecting on results..."})
|
|
402
415
|
logger.info("Reflecting on results...")
|
|
403
416
|
|
|
@@ -452,11 +465,10 @@ Failed tasks: {[t for t in state['task_status'] if state['task_status'][t] == 'f
|
|
|
452
465
|
Provide analysis and suggested fixes."""}
|
|
453
466
|
]
|
|
454
467
|
|
|
455
|
-
response, was_fallback, model_used = await self.
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
)
|
|
468
|
+
response, was_fallback, model_used = await self._call_model_with_timeout("planning", messages)
|
|
469
|
+
|
|
470
|
+
elapsed = time.time() - start
|
|
471
|
+
console.print(f"[dim]Reflected in {elapsed:.1f}s[/dim]")
|
|
460
472
|
|
|
461
473
|
content = response.get("content", "")
|
|
462
474
|
|
|
@@ -475,7 +487,7 @@ Provide analysis and suggested fixes."""}
|
|
|
475
487
|
for task_id, status in state["task_status"].items():
|
|
476
488
|
if status == "failed":
|
|
477
489
|
state["task_status"][task_id] = "pending"
|
|
478
|
-
except
|
|
490
|
+
except Exception:
|
|
479
491
|
# Fallback: retry failed tasks
|
|
480
492
|
for task_id, status in state["task_status"].items():
|
|
481
493
|
if status == "failed":
|
|
@@ -506,6 +518,8 @@ Provide analysis and suggested fixes."""}
|
|
|
506
518
|
|
|
507
519
|
async def _summarize(self, state: WorkflowState) -> WorkflowState:
|
|
508
520
|
"""Generate final summary."""
|
|
521
|
+
start = time.time()
|
|
522
|
+
console.print("[dim]Generating summary...[/dim]")
|
|
509
523
|
await self._emit({"type": "step", "step": "summarizing", "message": "Generating summary..."})
|
|
510
524
|
logger.info("Generating summary...")
|
|
511
525
|
|
|
@@ -546,11 +560,10 @@ Test Results: {json.dumps(test_results, indent=2)}
|
|
|
546
560
|
Create a summary of the work."""}
|
|
547
561
|
]
|
|
548
562
|
|
|
549
|
-
response, was_fallback, model_used = await self.
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
)
|
|
563
|
+
response, was_fallback, model_used = await self._call_model_with_timeout("planning", messages)
|
|
564
|
+
|
|
565
|
+
elapsed = time.time() - start
|
|
566
|
+
console.print(f"[dim]Summarized in {elapsed:.1f}s[/dim]")
|
|
554
567
|
|
|
555
568
|
content = response.get("content", "")
|
|
556
569
|
|
|
@@ -558,6 +571,12 @@ Create a summary of the work."""}
|
|
|
558
571
|
state["status"] = WorkflowStatus.COMPLETED.value
|
|
559
572
|
|
|
560
573
|
await self._emit({"type": "complete", "summary": content})
|
|
574
|
+
|
|
575
|
+
console.print()
|
|
576
|
+
console.print(f"[bold green]✅ Workflow Complete![/bold green]")
|
|
577
|
+
if state.get("test_results", {}).get("skipped"):
|
|
578
|
+
console.print("[dim]Note: Tests were skipped[/dim]")
|
|
579
|
+
console.print()
|
|
561
580
|
|
|
562
581
|
return state
|
|
563
582
|
|
|
@@ -641,9 +660,13 @@ Create a summary of the work."""}
|
|
|
641
660
|
if snapshot:
|
|
642
661
|
state = snapshot.values
|
|
643
662
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
663
|
+
try:
|
|
664
|
+
# Execute asynchronously
|
|
665
|
+
async for event in app.astream(state, config):
|
|
666
|
+
pass
|
|
667
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
668
|
+
console.print("\n[yellow]Workflow interrupted. Progress saved to checkpoint.[/yellow]")
|
|
669
|
+
raise typer.Exit(0)
|
|
647
670
|
|
|
648
671
|
# Get final state
|
|
649
672
|
final_state_snapshot = await app.aget_state(config)
|
|
@@ -35,7 +35,7 @@ ERROR_MESSAGES = {
|
|
|
35
35
|
"message": "🔑 Groq API key not found.",
|
|
36
36
|
"suggestion": "Run `sky init` to set up your API keys, or add GROQ_API_KEY to .env"
|
|
37
37
|
},
|
|
38
|
-
"
|
|
38
|
+
"NVIDIA_NIM_API_KEY_MISSING": {
|
|
39
39
|
"message": "🔑 NVIDIA NIM API key not found.",
|
|
40
40
|
"suggestion": "Run `sky init` to set up your API keys, or add NVIDIA_NIM_API_KEY to .env"
|
|
41
41
|
},
|
|
@@ -18,38 +18,7 @@ PREFERRED_MODELS = [
|
|
|
18
18
|
"mistralai/mistral-large-2-instruct", # Alternative
|
|
19
19
|
]
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
"""Discover an available model from NVIDIA NIM."""
|
|
23
|
-
api_key = os.getenv("NVIDIA_NIM_API_KEY")
|
|
24
|
-
if not api_key:
|
|
25
|
-
return None
|
|
26
|
-
|
|
27
|
-
try:
|
|
28
|
-
res = httpx.get(
|
|
29
|
-
"https://integrate.api.nvidia.com/v1/models",
|
|
30
|
-
headers={"Authorization": f"Bearer {api_key}"},
|
|
31
|
-
timeout=10
|
|
32
|
-
)
|
|
33
|
-
available = {m["id"] for m in res.json().get("data", [])}
|
|
34
|
-
|
|
35
|
-
# Pick the first preferred model that's available
|
|
36
|
-
for model in PREFERRED_MODELS:
|
|
37
|
-
if model in available:
|
|
38
|
-
logger.info(f"Using guardrail model: {model}")
|
|
39
|
-
return model
|
|
40
|
-
|
|
41
|
-
# Fallback: any Llama or Nemotron model
|
|
42
|
-
for model_id in available:
|
|
43
|
-
if "llama" in model_id.lower() or "nemotron" in model_id.lower():
|
|
44
|
-
logger.info(f"Fallback guardrail model: {model_id}")
|
|
45
|
-
return model_id
|
|
46
|
-
|
|
47
|
-
logger.warning("No suitable guardrail model found")
|
|
48
|
-
return None
|
|
49
|
-
except Exception as e:
|
|
50
|
-
logger.error(f"Model discovery failed: {e}")
|
|
51
|
-
return None
|
|
52
|
-
|
|
21
|
+
_verified_model_cache: Optional[str] = None
|
|
53
22
|
|
|
54
23
|
class NVIDIAGuardrails:
|
|
55
24
|
"""NVIDIA NIM Guardrails wrapper with async parallel support."""
|
|
@@ -66,19 +35,98 @@ class NVIDIAGuardrails:
|
|
|
66
35
|
if getattr(self, "_initialized", False):
|
|
67
36
|
return
|
|
68
37
|
|
|
38
|
+
# Load .env
|
|
39
|
+
from dotenv import load_dotenv
|
|
40
|
+
from sky.config.schema import get_config_dir
|
|
41
|
+
env_path = get_config_dir() / ".env"
|
|
42
|
+
if env_path.exists():
|
|
43
|
+
load_dotenv(env_path)
|
|
44
|
+
else:
|
|
45
|
+
load_dotenv()
|
|
46
|
+
|
|
47
|
+
api_key = os.getenv("NVIDIA_NIM_API_KEY")
|
|
48
|
+
|
|
49
|
+
if not api_key:
|
|
50
|
+
logger.warning("NVIDIA_NIM_API_KEY not set — guardrails disabled")
|
|
51
|
+
self.client = None
|
|
52
|
+
self.model = None
|
|
53
|
+
self._initialized = True
|
|
54
|
+
return
|
|
55
|
+
|
|
69
56
|
self.client = AsyncOpenAI(
|
|
70
57
|
base_url="https://integrate.api.nvidia.com/v1",
|
|
71
|
-
api_key=
|
|
58
|
+
api_key=api_key
|
|
72
59
|
)
|
|
73
|
-
self.model =
|
|
60
|
+
self.model: Optional[str] = None
|
|
74
61
|
self._initialized = True
|
|
62
|
+
|
|
63
|
+
async def _verify_model(self, model_id: str) -> bool:
|
|
64
|
+
"""Verify the model actually works with this account."""
|
|
65
|
+
try:
|
|
66
|
+
response = await self.client.chat.completions.create(
|
|
67
|
+
model=model_id,
|
|
68
|
+
messages=[{"role": "user", "content": "hi"}],
|
|
69
|
+
max_tokens=1,
|
|
70
|
+
timeout=5,
|
|
71
|
+
)
|
|
72
|
+
return response is not None
|
|
73
|
+
except Exception as e:
|
|
74
|
+
logger.debug(f"Model {model_id} verification failed: {e}")
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
async def discover_guardrail_model(self) -> Optional[str]:
|
|
78
|
+
"""Discover a model that actually works with this account."""
|
|
79
|
+
api_key = os.getenv("NVIDIA_NIM_API_KEY")
|
|
80
|
+
if not api_key:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
res = httpx.get(
|
|
85
|
+
"https://integrate.api.nvidia.com/v1/models",
|
|
86
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
87
|
+
timeout=10,
|
|
88
|
+
)
|
|
89
|
+
available = {m["id"] for m in res.json().get("data", [])}
|
|
90
|
+
|
|
91
|
+
for model in PREFERRED_MODELS:
|
|
92
|
+
if model in available:
|
|
93
|
+
# Verify the model actually works
|
|
94
|
+
if await self._verify_model(model):
|
|
95
|
+
logger.info(f"Using verified guardrail model: {model}")
|
|
96
|
+
return model
|
|
97
|
+
else:
|
|
98
|
+
logger.warning(f"Model {model} listed but not accessible — trying next")
|
|
99
|
+
|
|
100
|
+
# Fallback: any llama/nemotron model that verifies
|
|
101
|
+
for model_id in available:
|
|
102
|
+
if "llama" in model_id.lower() or "nemotron" in model_id.lower():
|
|
103
|
+
if await self._verify_model(model_id):
|
|
104
|
+
logger.info(f"Fallback verified model: {model_id}")
|
|
105
|
+
return model_id
|
|
106
|
+
|
|
107
|
+
logger.warning("No verifiable guardrail model found")
|
|
108
|
+
return None
|
|
109
|
+
except Exception as e:
|
|
110
|
+
logger.error(f"Model discovery failed: {e}")
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
async def get_verified_model(self) -> Optional[str]:
|
|
114
|
+
"""Get cached verified model, discover if needed."""
|
|
115
|
+
global _verified_model_cache
|
|
116
|
+
if _verified_model_cache is not None:
|
|
117
|
+
self.model = _verified_model_cache
|
|
118
|
+
return _verified_model_cache
|
|
119
|
+
_verified_model_cache = await self.discover_guardrail_model()
|
|
120
|
+
self.model = _verified_model_cache
|
|
121
|
+
return _verified_model_cache
|
|
75
122
|
|
|
76
123
|
def is_available(self) -> bool:
|
|
77
|
-
"""Check if NVIDIA guardrails are
|
|
78
|
-
return
|
|
124
|
+
"""Check if NVIDIA guardrails are actually usable."""
|
|
125
|
+
return self.client is not None and self.model is not None
|
|
79
126
|
|
|
80
127
|
async def check_input_async(self, user_input: str) -> Dict[str, Any]:
|
|
81
128
|
"""Async check for jailbreak attempts."""
|
|
129
|
+
await self.get_verified_model()
|
|
82
130
|
if not self.is_available():
|
|
83
131
|
return {"passed": True, "reason": "NVIDIA unavailable", "layer": "nvidia"}
|
|
84
132
|
|
|
@@ -116,6 +164,7 @@ class NVIDIAGuardrails:
|
|
|
116
164
|
|
|
117
165
|
async def check_topic_async(self, user_input: str) -> Dict[str, Any]:
|
|
118
166
|
"""Async check for on-topic content."""
|
|
167
|
+
await self.get_verified_model()
|
|
119
168
|
if not self.is_available():
|
|
120
169
|
return {"passed": True, "reason": "NVIDIA unavailable", "layer": "nvidia"}
|
|
121
170
|
|
|
@@ -152,6 +201,7 @@ class NVIDIAGuardrails:
|
|
|
152
201
|
|
|
153
202
|
async def check_response_async(self, response: str) -> Dict[str, Any]:
|
|
154
203
|
"""Async check for unsafe content in model responses."""
|
|
204
|
+
await self.get_verified_model()
|
|
155
205
|
if not self.is_available():
|
|
156
206
|
return {"passed": True, "reason": "NVIDIA unavailable", "layer": "nvidia"}
|
|
157
207
|
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
import subprocess
|
|
4
|
+
import tempfile
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List, Optional
|
|
8
|
+
import logging
|
|
9
|
+
import xml.etree.ElementTree as ET
|
|
10
|
+
|
|
11
|
+
from sky.tools.registry import register_tool, RiskTier
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
def _detect_test_framework(project_root: Path) -> Optional[str]:
|
|
16
|
+
"""Detect the testing framework used in the project."""
|
|
17
|
+
# Check for pytest
|
|
18
|
+
if (project_root / "pytest.ini").exists():
|
|
19
|
+
return "pytest"
|
|
20
|
+
if (project_root / "pyproject.toml").exists():
|
|
21
|
+
content = (project_root / "pyproject.toml").read_text()
|
|
22
|
+
if "[tool.pytest]" in content:
|
|
23
|
+
return "pytest"
|
|
24
|
+
if (project_root / "setup.cfg").exists():
|
|
25
|
+
content = (project_root / "setup.cfg").read_text()
|
|
26
|
+
if "[tool:pytest]" in content:
|
|
27
|
+
return "pytest"
|
|
28
|
+
|
|
29
|
+
# Check for pytest or unittest in tests/
|
|
30
|
+
tests_dir = project_root / "tests"
|
|
31
|
+
if tests_dir.exists() and tests_dir.is_dir():
|
|
32
|
+
for file in tests_dir.glob("test_*.py"):
|
|
33
|
+
content = file.read_text()
|
|
34
|
+
if "pytest" in content:
|
|
35
|
+
return "pytest"
|
|
36
|
+
if "unittest" in content:
|
|
37
|
+
return "unittest"
|
|
38
|
+
return "unittest" # Default if tests/test_*.py exist but no specific imports
|
|
39
|
+
|
|
40
|
+
# Check for npm
|
|
41
|
+
if (project_root / "package.json").exists():
|
|
42
|
+
content = json.loads((project_root / "package.json").read_text())
|
|
43
|
+
if "test" in content.get("scripts", {}):
|
|
44
|
+
return "npm"
|
|
45
|
+
|
|
46
|
+
# Check for cargo
|
|
47
|
+
if (project_root / "Cargo.toml").exists():
|
|
48
|
+
return "cargo"
|
|
49
|
+
|
|
50
|
+
# Check for go
|
|
51
|
+
if (project_root / "go.mod").exists():
|
|
52
|
+
return "go"
|
|
53
|
+
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
def _detect_linter(project_root: Path) -> Optional[str]:
|
|
57
|
+
"""Detect the linter used in the project."""
|
|
58
|
+
# Check for ruff
|
|
59
|
+
if (project_root / "ruff.toml").exists():
|
|
60
|
+
return "ruff"
|
|
61
|
+
if (project_root / "pyproject.toml").exists():
|
|
62
|
+
content = (project_root / "pyproject.toml").read_text()
|
|
63
|
+
if "[tool.ruff]" in content:
|
|
64
|
+
return "ruff"
|
|
65
|
+
|
|
66
|
+
# Check for flake8
|
|
67
|
+
if (project_root / ".flake8").exists():
|
|
68
|
+
return "flake8"
|
|
69
|
+
if (project_root / "setup.cfg").exists():
|
|
70
|
+
content = (project_root / "setup.cfg").read_text()
|
|
71
|
+
if "[flake8]" in content:
|
|
72
|
+
return "flake8"
|
|
73
|
+
if (project_root / "tox.ini").exists():
|
|
74
|
+
content = (project_root / "tox.ini").read_text()
|
|
75
|
+
if "[flake8]" in content:
|
|
76
|
+
return "flake8"
|
|
77
|
+
|
|
78
|
+
# Check for eslint
|
|
79
|
+
if (project_root / "package.json").exists():
|
|
80
|
+
content = json.loads((project_root / "package.json").read_text())
|
|
81
|
+
if "eslint" in content.get("devDependencies", {}) or "eslint" in content.get("dependencies", {}):
|
|
82
|
+
return "eslint"
|
|
83
|
+
if any(project_root.glob(".eslintrc*")):
|
|
84
|
+
return "eslint"
|
|
85
|
+
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
def _parse_junitxml(xml_path: Path) -> Dict[str, Any]:
|
|
89
|
+
"""Parse pytest JUnit output."""
|
|
90
|
+
tree = ET.parse(xml_path)
|
|
91
|
+
root = tree.getroot()
|
|
92
|
+
|
|
93
|
+
testsuite = root if root.tag == "testsuite" else root.find("testsuite")
|
|
94
|
+
if testsuite is None:
|
|
95
|
+
return {"error": "Invalid JUnit XML format"}
|
|
96
|
+
|
|
97
|
+
errors_list = []
|
|
98
|
+
for testcase in testsuite.findall("testcase"):
|
|
99
|
+
failure = testcase.find("failure")
|
|
100
|
+
if failure is not None:
|
|
101
|
+
errors_list.append(failure.text or failure.get("message", "Unknown error"))
|
|
102
|
+
error = testcase.find("error")
|
|
103
|
+
if error is not None:
|
|
104
|
+
errors_list.append(error.text or error.get("message", "Unknown error"))
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
"passed": int(testsuite.get("tests", 0)) - int(testsuite.get("failures", 0)) - int(testsuite.get("errors", 0)) - int(testsuite.get("skipped", 0)),
|
|
108
|
+
"failed": int(testsuite.get("failures", 0)) + int(testsuite.get("errors", 0)),
|
|
109
|
+
"skipped": int(testsuite.get("skipped", 0)),
|
|
110
|
+
"total": int(testsuite.get("tests", 0)),
|
|
111
|
+
"errors": errors_list
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
def _parse_pytest_output(stdout: str) -> Dict[str, Any]:
|
|
115
|
+
"""Fallback parser if JUnitXML unavailable."""
|
|
116
|
+
passed = 0
|
|
117
|
+
failed = 0
|
|
118
|
+
skipped = 0
|
|
119
|
+
|
|
120
|
+
# Look for the summary line: e.g. "== 1 passed, 2 failed in 0.12s =="
|
|
121
|
+
summary_match = re.search(r'==.*?(\d+)\s+passed.*?==', stdout)
|
|
122
|
+
if summary_match:
|
|
123
|
+
passed = int(summary_match.group(1))
|
|
124
|
+
|
|
125
|
+
failed_match = re.search(r'==.*?(\d+)\s+failed.*?==', stdout)
|
|
126
|
+
if failed_match:
|
|
127
|
+
failed = int(failed_match.group(1))
|
|
128
|
+
|
|
129
|
+
skipped_match = re.search(r'==.*?(\d+)\s+skipped.*?==', stdout)
|
|
130
|
+
if skipped_match:
|
|
131
|
+
skipped = int(skipped_match.group(1))
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
"passed": passed,
|
|
135
|
+
"failed": failed,
|
|
136
|
+
"skipped": skipped,
|
|
137
|
+
"total": passed + failed + skipped,
|
|
138
|
+
"errors": [line for line in stdout.split('\n') if "FAILED" in line or "ERROR" in line]
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
def _truncate(text: str, max_len: int = 2000) -> str:
|
|
142
|
+
"""Truncate long output."""
|
|
143
|
+
if len(text) <= max_len:
|
|
144
|
+
return text
|
|
145
|
+
return text[:max_len//2] + "\n...[truncated]...\n" + text[-max_len//2:]
|
|
146
|
+
|
|
147
|
+
@register_tool(
|
|
148
|
+
name="run_tests",
|
|
149
|
+
description="Auto-detect and run the project's test suite, returning structured results",
|
|
150
|
+
risk_tier=RiskTier.SAFE,
|
|
151
|
+
)
|
|
152
|
+
def run_tests(target: Optional[str] = None, timeout: int = 120) -> Dict[str, Any]:
|
|
153
|
+
"""Run tests and return structured results."""
|
|
154
|
+
project_root = Path.cwd()
|
|
155
|
+
framework = _detect_test_framework(project_root)
|
|
156
|
+
|
|
157
|
+
if not framework:
|
|
158
|
+
return {"framework": "none", "error": "No test framework detected", "success": False}
|
|
159
|
+
|
|
160
|
+
target_str = target if target else ""
|
|
161
|
+
start_time = time.time()
|
|
162
|
+
result = {
|
|
163
|
+
"framework": framework,
|
|
164
|
+
"passed": 0,
|
|
165
|
+
"failed": 0,
|
|
166
|
+
"errors": [],
|
|
167
|
+
"skipped": 0,
|
|
168
|
+
"total": 0,
|
|
169
|
+
"success": False,
|
|
170
|
+
"duration_seconds": 0.0,
|
|
171
|
+
"output": ""
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
if framework == "pytest":
|
|
176
|
+
with tempfile.NamedTemporaryFile(suffix=".xml", delete=False) as tmp:
|
|
177
|
+
xml_path = tmp.name
|
|
178
|
+
|
|
179
|
+
cmd = f"python -m pytest {target_str} --junitxml={xml_path} -q"
|
|
180
|
+
process = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
|
|
181
|
+
|
|
182
|
+
if Path(xml_path).exists() and Path(xml_path).stat().st_size > 0:
|
|
183
|
+
parsed = _parse_junitxml(Path(xml_path))
|
|
184
|
+
if "error" not in parsed:
|
|
185
|
+
result.update(parsed)
|
|
186
|
+
else:
|
|
187
|
+
parsed_fallback = _parse_pytest_output(process.stdout + process.stderr)
|
|
188
|
+
result.update(parsed_fallback)
|
|
189
|
+
else:
|
|
190
|
+
parsed_fallback = _parse_pytest_output(process.stdout + process.stderr)
|
|
191
|
+
result.update(parsed_fallback)
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
Path(xml_path).unlink(missing_ok=True)
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
elif framework == "unittest":
|
|
199
|
+
cmd = f"python -m unittest discover {target_str}"
|
|
200
|
+
process = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
|
|
201
|
+
out = process.stdout + process.stderr
|
|
202
|
+
# Unittest output is hard to parse structure-wise simply, doing basic search
|
|
203
|
+
runs = re.search(r'Ran (\d+) tests', out)
|
|
204
|
+
if runs:
|
|
205
|
+
result["total"] = int(runs.group(1))
|
|
206
|
+
failures = re.search(r'failures=(\d+)', out)
|
|
207
|
+
errors = re.search(r'errors=(\d+)', out)
|
|
208
|
+
result["failed"] = (int(failures.group(1)) if failures else 0) + (int(errors.group(1)) if errors else 0)
|
|
209
|
+
result["passed"] = result["total"] - result["failed"]
|
|
210
|
+
|
|
211
|
+
elif framework == "npm":
|
|
212
|
+
cmd = "npm test"
|
|
213
|
+
process = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
|
|
214
|
+
|
|
215
|
+
elif framework == "cargo":
|
|
216
|
+
cmd = "cargo test"
|
|
217
|
+
process = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
|
|
218
|
+
|
|
219
|
+
elif framework == "go":
|
|
220
|
+
cmd = "go test ./..."
|
|
221
|
+
process = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
|
|
222
|
+
|
|
223
|
+
if framework in ("npm", "cargo", "go"):
|
|
224
|
+
# Generic fallback for non-python frameworks since detailed parsing isn't specified
|
|
225
|
+
result["success"] = process.returncode == 0
|
|
226
|
+
|
|
227
|
+
result["output"] = _truncate(process.stdout + "\n" + process.stderr)
|
|
228
|
+
result["success"] = process.returncode == 0
|
|
229
|
+
|
|
230
|
+
except subprocess.TimeoutExpired as e:
|
|
231
|
+
result["error"] = f"Command timed out after {timeout} seconds"
|
|
232
|
+
if e.stdout:
|
|
233
|
+
result["output"] = _truncate(e.stdout.decode(errors='replace'))
|
|
234
|
+
except Exception as e:
|
|
235
|
+
result["error"] = str(e)
|
|
236
|
+
logger.error(f"Test run failed: {e}")
|
|
237
|
+
|
|
238
|
+
result["duration_seconds"] = round(time.time() - start_time, 2)
|
|
239
|
+
return result
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@register_tool(
|
|
243
|
+
name="lint",
|
|
244
|
+
description="Run the project's linter and return structured issues",
|
|
245
|
+
risk_tier=RiskTier.SAFE,
|
|
246
|
+
)
|
|
247
|
+
def lint(path: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
248
|
+
"""Run linter and return structured issues."""
|
|
249
|
+
project_root = Path.cwd()
|
|
250
|
+
linter = _detect_linter(project_root)
|
|
251
|
+
|
|
252
|
+
if not linter:
|
|
253
|
+
return []
|
|
254
|
+
|
|
255
|
+
target_str = path if path else "."
|
|
256
|
+
issues = []
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
if linter == "ruff":
|
|
260
|
+
cmd = f"ruff check {target_str} --output-format=json"
|
|
261
|
+
process = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
|
262
|
+
if process.stdout.strip():
|
|
263
|
+
try:
|
|
264
|
+
data = json.loads(process.stdout)
|
|
265
|
+
for item in data:
|
|
266
|
+
issues.append({
|
|
267
|
+
"file": item.get("filename", ""),
|
|
268
|
+
"line": item.get("location", {}).get("row", 0),
|
|
269
|
+
"column": item.get("location", {}).get("column", 0),
|
|
270
|
+
"code": item.get("code", ""),
|
|
271
|
+
"message": item.get("message", ""),
|
|
272
|
+
"severity": "error"
|
|
273
|
+
})
|
|
274
|
+
except json.JSONDecodeError:
|
|
275
|
+
pass
|
|
276
|
+
|
|
277
|
+
elif linter == "flake8":
|
|
278
|
+
cmd = f"flake8 {target_str}"
|
|
279
|
+
process = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
|
280
|
+
for line in process.stdout.splitlines():
|
|
281
|
+
match = re.match(r'(.*?):(\d+):(\d+):\s+([A-Z0-9]+)\s+(.*)', line)
|
|
282
|
+
if match:
|
|
283
|
+
issues.append({
|
|
284
|
+
"file": match.group(1),
|
|
285
|
+
"line": int(match.group(2)),
|
|
286
|
+
"column": int(match.group(3)),
|
|
287
|
+
"code": match.group(4),
|
|
288
|
+
"message": match.group(5),
|
|
289
|
+
"severity": "error"
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
elif linter == "eslint":
|
|
293
|
+
cmd = f"npx eslint {target_str} --format=json"
|
|
294
|
+
process = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
|
295
|
+
if process.stdout.strip():
|
|
296
|
+
try:
|
|
297
|
+
data = json.loads(process.stdout)
|
|
298
|
+
for file_item in data:
|
|
299
|
+
for msg in file_item.get("messages", []):
|
|
300
|
+
severity = "error" if msg.get("severity") == 2 else "warning"
|
|
301
|
+
issues.append({
|
|
302
|
+
"file": file_item.get("filePath", ""),
|
|
303
|
+
"line": msg.get("line", 0),
|
|
304
|
+
"column": msg.get("column", 0),
|
|
305
|
+
"code": msg.get("ruleId", ""),
|
|
306
|
+
"message": msg.get("message", ""),
|
|
307
|
+
"severity": severity
|
|
308
|
+
})
|
|
309
|
+
except json.JSONDecodeError:
|
|
310
|
+
pass
|
|
311
|
+
|
|
312
|
+
except Exception as e:
|
|
313
|
+
logger.warning(f"Linter execution failed: {e}")
|
|
314
|
+
|
|
315
|
+
return issues
|
|
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
|