rnow 0.2.4__py3-none-any.whl → 0.3.9__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.
rnow/cli/commands.py CHANGED
@@ -221,18 +221,18 @@ def validate_max_tokens_for_context(
221
221
  return None, available
222
222
 
223
223
 
224
- def get_tools_from_env_py(env_path: Path) -> list[dict]:
224
+ def get_tools_from_tools_py(tools_path: Path) -> list[dict]:
225
225
  """
226
- Extract tool definitions from env.py as structured data.
226
+ Extract tool definitions from tools.py as structured data.
227
227
  Returns list of tool dicts with name, description, and schema.
228
228
  """
229
229
  import ast
230
230
 
231
- if not env_path.exists():
231
+ if not tools_path.exists():
232
232
  return []
233
233
 
234
234
  try:
235
- source = env_path.read_text()
235
+ source = tools_path.read_text()
236
236
  tree = ast.parse(source)
237
237
  except (SyntaxError, OSError):
238
238
  return []
@@ -377,25 +377,124 @@ def fetch_mcp_tool_schemas(
377
377
  return all_tools, None
378
378
 
379
379
 
380
+ def get_sandbox_names_from_file(filepath: Path, decorator_name: str) -> set[str]:
381
+ """
382
+ Extract function names with sandbox=True from a file.
383
+
384
+ Args:
385
+ filepath: Path to rewards.py or tools.py
386
+ decorator_name: "reward" or "tool"
387
+
388
+ Returns:
389
+ Set of function names that have sandbox=True
390
+ """
391
+ import ast
392
+
393
+ names = set()
394
+ if not filepath.exists():
395
+ return names
396
+
397
+ try:
398
+ source = filepath.read_text()
399
+ tree = ast.parse(source, filename=str(filepath))
400
+ except SyntaxError:
401
+ return names
402
+
403
+ for node in ast.walk(tree):
404
+ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
405
+ for decorator in node.decorator_list:
406
+ # Check for @decorator_name(sandbox=True)
407
+ if (
408
+ isinstance(decorator, ast.Call)
409
+ and isinstance(decorator.func, ast.Name)
410
+ and decorator.func.id == decorator_name
411
+ ):
412
+ for kw in decorator.keywords:
413
+ if kw.arg == "sandbox":
414
+ # Check if value is True
415
+ if isinstance(kw.value, ast.Constant) and kw.value.value is True:
416
+ names.add(node.name)
417
+ elif isinstance(kw.value, ast.NameConstant) and kw.value.value is True:
418
+ names.add(node.name) # Python 3.7 compat
419
+ return names
420
+
421
+
422
+ def validate_sandbox_docker_requirement(
423
+ train_jsonl_path: Path, rewards_py_path: Path, tools_py_path: Path
424
+ ) -> list[str]:
425
+ """
426
+ Validate that entries using sandbox=True tools/rewards have a docker field.
427
+
428
+ Returns:
429
+ List of error messages (empty if valid)
430
+ """
431
+ errors = []
432
+
433
+ # Get sandbox function names
434
+ sandbox_rewards = get_sandbox_names_from_file(rewards_py_path, "reward")
435
+ sandbox_tools = get_sandbox_names_from_file(tools_py_path, "tool")
436
+
437
+ if not sandbox_rewards and not sandbox_tools:
438
+ return [] # No sandbox functions, nothing to validate
439
+
440
+ try:
441
+ with open(train_jsonl_path, encoding="utf-8") as f:
442
+ for line_num, line in enumerate(f, start=1):
443
+ stripped = line.strip()
444
+ if not stripped:
445
+ continue
446
+
447
+ try:
448
+ record = json.loads(stripped)
449
+ except json.JSONDecodeError:
450
+ continue
451
+
452
+ # Check if entry references sandbox rewards/tools
453
+ entry_rewards = set(record.get("rewards", []))
454
+ entry_tools = set(record.get("tools", []))
455
+
456
+ uses_sandbox_reward = bool(sandbox_rewards & entry_rewards)
457
+ uses_sandbox_tool = bool(sandbox_tools & entry_tools)
458
+
459
+ if (uses_sandbox_reward or uses_sandbox_tool) and not record.get("docker"):
460
+ used = []
461
+ if uses_sandbox_reward:
462
+ used.extend(f"reward:{r}" for r in sandbox_rewards & entry_rewards)
463
+ if uses_sandbox_tool:
464
+ used.extend(f"tool:{t}" for t in sandbox_tools & entry_tools)
465
+ errors.append(
466
+ f"Line {line_num}: Uses sandbox functions ({', '.join(used)}) but missing 'docker' field"
467
+ )
468
+ if len(errors) >= 5:
469
+ errors.append("... (stopping after 5 errors)")
470
+ return errors
471
+
472
+ except Exception as e:
473
+ errors.append(f"Failed to validate sandbox requirements: {e}")
474
+
475
+ return errors
476
+
477
+
380
478
  def validate_train_jsonl(
381
479
  path: Path, dataset_type: models.DatasetType, sample_size: int = 50
382
480
  ) -> list[str]:
383
481
  """
384
- Validate train.jsonl format by sampling first N lines.
482
+ Validate train.jsonl format using Pydantic models.
385
483
  Returns a list of error messages (empty if valid).
386
484
  """
485
+ from pydantic import ValidationError
486
+
387
487
  errors = []
488
+ EntryModel = models.TrainEntryRL if dataset_type == models.DatasetType.RL else models.TrainEntry
388
489
 
389
490
  try:
390
491
  with open(path, encoding="utf-8") as f:
391
492
  lines_checked = 0
392
493
  for line_num, line in enumerate(f, start=1):
393
- # Skip empty lines
394
494
  stripped = line.strip()
395
495
  if not stripped:
396
496
  continue
397
497
 
398
- # Try to parse as JSON
399
498
  try:
400
499
  record = json.loads(stripped)
401
500
  except json.JSONDecodeError as e:
@@ -405,63 +504,21 @@ def validate_train_jsonl(
405
504
  return errors
406
505
  continue
407
506
 
408
- # Check it's a dict
409
- if not isinstance(record, dict):
410
- errors.append(
411
- f"Line {line_num}: Expected JSON object, got {type(record).__name__}"
412
- )
413
- continue
414
-
415
- # Check for required 'messages' field
416
- if "messages" not in record:
417
- errors.append(f"Line {line_num}: Missing required 'messages' field")
418
- continue
419
-
420
- messages = record["messages"]
421
- if not isinstance(messages, list):
422
- errors.append(f"Line {line_num}: 'messages' must be a list")
423
- continue
424
-
425
- if len(messages) == 0:
426
- errors.append(f"Line {line_num}: 'messages' list is empty")
507
+ try:
508
+ EntryModel.model_validate(record)
509
+ except ValidationError as e:
510
+ for err in e.errors():
511
+ loc = ".".join(str(x) for x in err["loc"])
512
+ errors.append(f"Line {line_num}: {loc} - {err['msg']}")
513
+ if len(errors) >= 5:
514
+ errors.append("... (stopping after 5 errors)")
515
+ return errors
427
516
  continue
428
517
 
429
- # Check each message has role and content
430
- for msg_idx, msg in enumerate(messages):
431
- if not isinstance(msg, dict):
432
- errors.append(f"Line {line_num}: Message {msg_idx + 1} must be an object")
433
- break
434
- if "role" not in msg:
435
- errors.append(f"Line {line_num}: Message {msg_idx + 1} missing 'role'")
436
- break
437
- if "content" not in msg:
438
- errors.append(f"Line {line_num}: Message {msg_idx + 1} missing 'content'")
439
- break
440
- if msg["role"] not in ("system", "user", "assistant"):
441
- errors.append(
442
- f"Line {line_num}: Message {msg_idx + 1} has invalid role '{msg['role']}' (expected: system, user, assistant)"
443
- )
444
- break
445
-
446
- # For RL, check for rewards field
447
- if dataset_type == models.DatasetType.RL and "rewards" not in record:
448
- errors.append(
449
- f"Line {line_num}: Missing required 'rewards' field for RL dataset"
450
- )
451
-
452
- # Validate optional 'tools' field if present
453
- if "tools" in record:
454
- tools = record["tools"]
455
- if not isinstance(tools, list):
456
- errors.append(f"Line {line_num}: 'tools' must be a list of tool names")
457
- elif not all(isinstance(t, str) for t in tools):
458
- errors.append(f"Line {line_num}: 'tools' must contain only strings")
459
-
460
518
  lines_checked += 1
461
519
  if lines_checked >= sample_size:
462
520
  break
463
521
 
464
- # Check if file was effectively empty (only whitespace)
465
522
  if lines_checked == 0:
466
523
  errors.append("File contains no valid JSON lines")
467
524
 
@@ -1032,6 +1089,7 @@ def orgs(ctx, org_id: str | None):
1032
1089
  "deepseek-aha",
1033
1090
  "tutorial-reward",
1034
1091
  "tutorial-tool",
1092
+ "web-tasks",
1035
1093
  ]
1036
1094
  ),
1037
1095
  default="start",
@@ -1069,6 +1127,7 @@ def init(template: str, name: str):
1069
1127
  "tutorial-reward": "tutorial-reward",
1070
1128
  "tutorial-tool": "tutorial-tool",
1071
1129
  "deepseek-aha": "deepseek-aha",
1130
+ "web-tasks": "web-tasks",
1072
1131
  "new": "new-project",
1073
1132
  "blank": "my-project",
1074
1133
  }
@@ -1099,7 +1158,7 @@ def init(template: str, name: str):
1099
1158
  "train.jsonl",
1100
1159
  "rewards.py",
1101
1160
  "requirements.txt",
1102
- "env.py",
1161
+ "tools.py",
1103
1162
  "README.md",
1104
1163
  }
1105
1164
 
@@ -1213,7 +1272,7 @@ def init(template: str, name: str):
1213
1272
  click.echo(click.style("Next steps:", bold=True))
1214
1273
  click.echo(f" 1. Edit {click.style('train.jsonl', underline=True)} with your training data")
1215
1274
  click.echo(
1216
- f" 2. Edit {click.style('rewards.py', underline=True)} and {click.style('env.py', underline=True)} with your reward and tool functions"
1275
+ f" 2. Edit {click.style('rewards.py', underline=True)} and {click.style('tools.py', underline=True)} with your reward and tool functions"
1217
1276
  )
1218
1277
  click.echo(f" 3. Run {click.style('rnow run', fg=TEAL_RGB, bold=True)} to start training")
1219
1278
 
@@ -1351,6 +1410,15 @@ def _submit_single_run(
1351
1410
  if jsonl_errors:
1352
1411
  raise click.ClickException(f"Invalid train.jsonl: {jsonl_errors[0]}")
1353
1412
 
1413
+ # Validate sandbox=True functions require docker field in train.jsonl
1414
+ sandbox_errors = validate_sandbox_docker_requirement(
1415
+ train_jsonl_path,
1416
+ rewards_py_path=dir / "rewards.py",
1417
+ tools_py_path=dir / "tools.py",
1418
+ )
1419
+ if sandbox_errors:
1420
+ raise click.ClickException(f"Sandbox validation: {sandbox_errors[0]}")
1421
+
1354
1422
  # Validate rewards.py if present
1355
1423
  if config.dataset_type == models.DatasetType.RL:
1356
1424
  rewards_path = dir / "rewards.py"
@@ -1378,10 +1446,10 @@ def _submit_single_run(
1378
1446
  # Collect all tools
1379
1447
  all_tools = []
1380
1448
 
1381
- # Get tools from env.py
1382
- env_path = dir / "env.py"
1383
- env_tools = get_tools_from_env_py(env_path)
1384
- all_tools.extend(env_tools)
1449
+ # Get tools from tools.py
1450
+ tools_path = dir / "tools.py"
1451
+ tools_py_tools = get_tools_from_tools_py(tools_path)
1452
+ all_tools.extend(tools_py_tools)
1385
1453
 
1386
1454
  # Fetch MCP tools
1387
1455
  mcp_urls = config.rollout.mcp_url
@@ -1447,7 +1515,7 @@ def _submit_single_run(
1447
1515
  )
1448
1516
 
1449
1517
  # Add optional files
1450
- optional_files = {"env.py": dir / "env.py", "requirements.txt": dir / "requirements.txt"}
1518
+ optional_files = {"tools.py": dir / "tools.py", "requirements.txt": dir / "requirements.txt"}
1451
1519
  for file_name, path in optional_files.items():
1452
1520
  if path.exists():
1453
1521
  files.append(
@@ -1457,6 +1525,17 @@ def _submit_single_run(
1457
1525
  )
1458
1526
  )
1459
1527
 
1528
+ # Add Dockerfile.* files for local/ docker images
1529
+ for dockerfile_path in dir.glob("Dockerfile.*"):
1530
+ file_name = dockerfile_path.name
1531
+ click.echo(f" Found Dockerfile: {file_name}")
1532
+ files.append(
1533
+ (
1534
+ file_name.replace(".", "_"),
1535
+ (file_name, open(dockerfile_path, "rb"), "application/octet-stream"),
1536
+ )
1537
+ )
1538
+
1460
1539
  headers = auth.get_auth_headers()
1461
1540
  headers.pop("Content-Type", None)
1462
1541
 
@@ -1732,6 +1811,36 @@ def run(
1732
1811
  if not config.organization_id:
1733
1812
  config.organization_id = get_active_organization()
1734
1813
 
1814
+ # Load secrets from .env file if it exists
1815
+ secret_values = {}
1816
+ env_file = dir / ".env"
1817
+ if env_file.exists():
1818
+ try:
1819
+ with open(env_file) as f:
1820
+ for line in f:
1821
+ line = line.strip()
1822
+ # Skip empty lines and comments
1823
+ if not line or line.startswith("#"):
1824
+ continue
1825
+ # Parse KEY=value format
1826
+ if "=" in line:
1827
+ key, _, value = line.partition("=")
1828
+ key = key.strip()
1829
+ value = value.strip()
1830
+ # Remove quotes if present
1831
+ if (value.startswith('"') and value.endswith('"')) or (
1832
+ value.startswith("'") and value.endswith("'")
1833
+ ):
1834
+ value = value[1:-1]
1835
+ secret_values[key] = value
1836
+
1837
+ if secret_values:
1838
+ click.echo(
1839
+ click.style(f"🔐 Loaded {len(secret_values)} secret(s) from .env", dim=True)
1840
+ )
1841
+ except Exception as e:
1842
+ click.echo(click.style(f"⚠️ Warning: Failed to read .env file: {e}", fg="yellow"))
1843
+
1735
1844
  # Validate required files (all in the same directory now)
1736
1845
  required_files = {
1737
1846
  "train.jsonl": dir / "train.jsonl",
@@ -1807,10 +1916,10 @@ def run(
1807
1916
  # Collect all tools
1808
1917
  all_tools = []
1809
1918
 
1810
- # Get tools from env.py
1811
- env_path = dir / "env.py"
1812
- env_tools = get_tools_from_env_py(env_path)
1813
- all_tools.extend(env_tools)
1919
+ # Get tools from tools.py
1920
+ tools_path = dir / "tools.py"
1921
+ tools_py_tools = get_tools_from_tools_py(tools_path)
1922
+ all_tools.extend(tools_py_tools)
1814
1923
 
1815
1924
  # Fetch MCP tool schemas (with progress indicator)
1816
1925
  mcp_urls = config.rollout.mcp_url if config.rollout else None
@@ -1828,9 +1937,7 @@ def run(
1828
1937
  click.echo(f" rnow is running from: {click.style(sys.executable, dim=True)}")
1829
1938
  click.echo()
1830
1939
  click.echo(" Install it with:")
1831
- click.echo(
1832
- click.style(f" {sys.executable} -m pip install fastmcp", fg=TEAL_RGB)
1833
- )
1940
+ click.echo(click.style(" uv pip install fastmcp", fg=TEAL_RGB))
1834
1941
  click.echo()
1835
1942
  raise click.ClickException("Missing dependency: fastmcp")
1836
1943
 
@@ -1927,19 +2034,19 @@ def run(
1927
2034
  "Please ensure reward names in train.jsonl match functions in rewards.py"
1928
2035
  )
1929
2036
 
1930
- # Validate env.py if present (check for docstrings on @tool functions)
1931
- env_path = dir / "env.py"
1932
- has_env_py = env_path.exists() and env_path.stat().st_size > 0
1933
- if has_env_py:
2037
+ # Validate tools.py if present (check for docstrings on @tool functions)
2038
+ tools_path = dir / "tools.py"
2039
+ has_tools_py = tools_path.exists() and tools_path.stat().st_size > 0
2040
+ if has_tools_py:
1934
2041
  try:
1935
2042
  from rnow.core.tool import validate_tools_file
1936
2043
 
1937
- errors = validate_tools_file(env_path)
2044
+ errors = validate_tools_file(tools_path)
1938
2045
  if errors:
1939
- click.echo(click.style("✗ Invalid env.py:", fg="red", bold=True))
2046
+ click.echo(click.style("✗ Invalid tools.py:", fg="red", bold=True))
1940
2047
  for err in errors:
1941
2048
  click.echo(f" • {err}")
1942
- raise click.ClickException("Please fix env.py before submitting")
2049
+ raise click.ClickException("Please fix tools.py before submitting")
1943
2050
  except ImportError:
1944
2051
  pass # Skip validation if module not available
1945
2052
 
@@ -1950,17 +2057,37 @@ def run(
1950
2057
  mcp_url = config.rollout.mcp_url
1951
2058
  mcp_url_count = len(mcp_url) if isinstance(mcp_url, list) else 1
1952
2059
 
2060
+ # Validate tool support for the model
2061
+ has_tools = has_tools_py or has_mcp_url
2062
+ if has_tools and not models.supports_tool_calling(model_path):
2063
+ click.echo()
2064
+ click.echo(click.style("✗ Model does not support tool calling", fg="red", bold=True))
2065
+ click.echo()
2066
+ click.echo(f" Model {model_path} does not support tool calling.")
2067
+ if "gpt-oss" in model_path.lower():
2068
+ click.echo(" OpenAI gpt-oss models use a format that doesn't support tools.")
2069
+ else:
2070
+ click.echo(" Base/non-instruct models use a format that doesn't support tools.")
2071
+ click.echo()
2072
+ click.echo(click.style(" Options:", bold=True))
2073
+ click.echo(" 1. Remove tools.py and mcp_url from your project")
2074
+ click.echo(
2075
+ " 2. Use a model that supports tools (e.g., Qwen/Qwen3-8B, meta-llama/Llama-3.1-8B-Instruct)"
2076
+ )
2077
+ click.echo()
2078
+ raise click.ClickException("Model does not support tool calling")
2079
+
1953
2080
  # Show tool sources message
1954
- if has_env_py and has_mcp_url:
2081
+ if has_tools_py and has_mcp_url:
1955
2082
  server_text = f"{mcp_url_count} server(s)" if mcp_url_count > 1 else "1 server"
1956
2083
  click.echo(
1957
- click.style("Tools: ", fg=TEAL_RGB) + f"Using MCP ({server_text}) and env.py tools"
2084
+ click.style("Tools: ", fg=TEAL_RGB) + f"Using MCP ({server_text}) and tools.py tools"
1958
2085
  )
1959
2086
  elif has_mcp_url:
1960
2087
  server_text = f"{mcp_url_count} server(s)" if mcp_url_count > 1 else "1 server"
1961
2088
  click.echo(click.style("Tools: ", fg=TEAL_RGB) + f"Using MCP ({server_text})")
1962
- elif has_env_py:
1963
- click.echo(click.style("Tools: ", fg=TEAL_RGB) + "Using env.py tools")
2089
+ elif has_tools_py:
2090
+ click.echo(click.style("Tools: ", fg=TEAL_RGB) + "Using tools.py tools")
1964
2091
 
1965
2092
  # Start cube spinner early
1966
2093
  spinner = CubeSpinner()
@@ -2004,7 +2131,7 @@ def run(
2004
2131
 
2005
2132
  # Add optional files (all in the same directory now)
2006
2133
  optional_files = {
2007
- "env.py": dir / "env.py",
2134
+ "tools.py": dir / "tools.py",
2008
2135
  "requirements.txt": dir / "requirements.txt",
2009
2136
  }
2010
2137
 
@@ -2017,6 +2144,17 @@ def run(
2017
2144
  )
2018
2145
  )
2019
2146
 
2147
+ # Add Dockerfile.* files for local/ docker images
2148
+ for dockerfile_path in dir.glob("Dockerfile.*"):
2149
+ file_name = dockerfile_path.name
2150
+ click.echo(f" Found Dockerfile: {file_name}")
2151
+ files.append(
2152
+ (
2153
+ file_name.replace(".", "_"),
2154
+ (file_name, open(dockerfile_path, "rb"), "application/octet-stream"),
2155
+ )
2156
+ )
2157
+
2020
2158
  # For multipart, we need to omit Content-Type so requests sets the boundary
2021
2159
  headers = auth.get_auth_headers()
2022
2160
  headers.pop("Content-Type", None)
@@ -2038,6 +2176,10 @@ def run(
2038
2176
  if debug:
2039
2177
  submit_data["debug"] = "true"
2040
2178
 
2179
+ # Add secrets if provided (sent as JSON string)
2180
+ if secret_values:
2181
+ submit_data["secrets"] = json.dumps(secret_values)
2182
+
2041
2183
  # Start cube spinner if not already running (for small files)
2042
2184
  if not spinner.running:
2043
2185
  spinner.start()