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.
Files changed (51) hide show
  1. {sky_dev-0.1.9 → sky_dev-0.1.14}/CHANGELOG.md +32 -0
  2. {sky_dev-0.1.9 → sky_dev-0.1.14}/PKG-INFO +1 -1
  3. {sky_dev-0.1.9 → sky_dev-0.1.14}/pyproject.toml +1 -1
  4. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/__init__.py +1 -1
  5. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/cli.py +26 -1
  6. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/core/chat.py +1 -5
  7. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/core/router.py +56 -31
  8. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/core/workflow.py +70 -47
  9. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/errors.py +1 -1
  10. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/security/guardrails_nvidia.py +86 -36
  11. sky_dev-0.1.14/sky/tools/test_tools.py +315 -0
  12. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky_dev.egg-info/PKG-INFO +1 -1
  13. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky_dev.egg-info/SOURCES.txt +1 -0
  14. {sky_dev-0.1.9 → sky_dev-0.1.14}/.env.example +0 -0
  15. {sky_dev-0.1.9 → sky_dev-0.1.14}/CONTRIBUTING.md +0 -0
  16. {sky_dev-0.1.9 → sky_dev-0.1.14}/LICENSE +0 -0
  17. {sky_dev-0.1.9 → sky_dev-0.1.14}/MANIFEST.in +0 -0
  18. {sky_dev-0.1.9 → sky_dev-0.1.14}/README.md +0 -0
  19. {sky_dev-0.1.9 → sky_dev-0.1.14}/SECURITY.md +0 -0
  20. {sky_dev-0.1.9 → sky_dev-0.1.14}/setup.cfg +0 -0
  21. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/config/__init__.py +0 -0
  22. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/config/models.yaml +0 -0
  23. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/config/schema.py +0 -0
  24. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/core/__init__.py +0 -0
  25. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/core/approval.py +0 -0
  26. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/core/benchmark.py +0 -0
  27. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/core/fast_loop.py +0 -0
  28. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/core/mode_prompts.py +0 -0
  29. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/core/subagent.py +0 -0
  30. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/memory/__init__.py +0 -0
  31. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/memory/indexer.py +0 -0
  32. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/memory/vectorstore.py +0 -0
  33. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/security/__init__.py +0 -0
  34. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/security/audit.py +0 -0
  35. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/security/detection.py +0 -0
  36. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/security/guardrails.py +0 -0
  37. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/security/prompts.py +0 -0
  38. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/security/rate_limit.py +0 -0
  39. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/security/sanitize.py +0 -0
  40. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/storage/__init__.py +0 -0
  41. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/storage/db.py +0 -0
  42. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/tools/__init__.py +0 -0
  43. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/tools/fs_tools.py +0 -0
  44. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/tools/git_tools.py +0 -0
  45. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/tools/registry.py +0 -0
  46. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/tools/search_tools.py +0 -0
  47. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky/tools/shell_tools.py +0 -0
  48. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky_dev.egg-info/dependency_links.txt +0 -0
  49. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky_dev.egg-info/entry_points.txt +0 -0
  50. {sky_dev-0.1.9 → sky_dev-0.1.14}/sky_dev.egg-info/requires.txt +0 -0
  51. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sky-dev
3
- Version: 0.1.9
3
+ Version: 0.1.14
4
4
  Summary: Sky - Agentic coding assistant. Build without boundaries.
5
5
  Author-email: Aaditya A <aaditya@corover.ai>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sky-dev"
7
- version = "0.1.9"
7
+ version = "0.1.14"
8
8
  description = "Sky - Agentic coding assistant. Build without boundaries."
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -1,6 +1,6 @@
1
1
  """Sky - Build without boundaries."""
2
2
 
3
- __version__ = "0.1.9"
3
+ __version__ = "0.1.14"
4
4
  __author__ = "Aaditya A"
5
5
  __author_email__ = "aaditya@corover.ai"
6
6
  __description__ = "Local, CLI-based, agentic software development assistant"
@@ -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("NIM_API_KEY")
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
- self.console.print(Panel(
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
- for provider_name, provider_config in self.config.providers.items():
26
- if provider_config.requires_api_key:
27
- # Find keys for this provider, e.g., GROQ_API_KEY, NIM_API_KEY
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
- def _init_client(self, provider_name: str):
47
- """Initialize the client for a specific provider."""
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 not keys:
77
+ if len(keys) <= 1:
54
78
  return
55
79
 
56
- idx = self.current_key_idx.get(provider_name, 0)
57
- api_key = keys[idx]
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": 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.clients.get(provider_name)
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 {provider_name.upper()}_API_KEY to .env or run `sky init`"
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.current_key_idx[provider_name] = next_idx
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.router.route(
176
- "planning",
177
- messages,
178
- expected_tool_calls=0,
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 json.JSONDecodeError:
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.router.route(
233
- "planning",
234
- messages,
235
- expected_tool_calls=0,
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 json.JSONDecodeError:
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
- # Collect any test-related changes
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
- state["test_results"] = {
383
- "passed": result.get("passed", 0),
384
- "failed": result.get("failed", 0),
385
- "errors": result.get("errors", []),
386
- "success": result.get("success", False)
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
- "passed": 0,
392
- "failed": 1,
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.router.route(
456
- "planning",
457
- messages,
458
- expected_tool_calls=0,
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 json.JSONDecodeError:
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.router.route(
550
- "planning",
551
- messages,
552
- expected_tool_calls=0,
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
- # Execute asynchronously
645
- async for event in app.astream(state, config):
646
- pass
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
- "NIM_API_KEY_MISSING": {
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
- def discover_guardrail_model() -> Optional[str]:
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=os.getenv("NVIDIA_NIM_API_KEY")
58
+ api_key=api_key
72
59
  )
73
- self.model = discover_guardrail_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 available."""
78
- return bool(self.model)
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sky-dev
3
- Version: 0.1.9
3
+ Version: 0.1.14
4
4
  Summary: Sky - Agentic coding assistant. Build without boundaries.
5
5
  Author-email: Aaditya A <aaditya@corover.ai>
6
6
  License: MIT
@@ -40,6 +40,7 @@ sky/tools/git_tools.py
40
40
  sky/tools/registry.py
41
41
  sky/tools/search_tools.py
42
42
  sky/tools/shell_tools.py
43
+ sky/tools/test_tools.py
43
44
  sky_dev.egg-info/PKG-INFO
44
45
  sky_dev.egg-info/SOURCES.txt
45
46
  sky_dev.egg-info/dependency_links.txt
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