synth-ai 0.2.9.dev3__py3-none-any.whl → 0.2.9.dev5__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.

Potentially problematic release.


This version of synth-ai might be problematic. Click here for more details.

Files changed (107) hide show
  1. examples/analyze_semantic_words.sh +17 -0
  2. examples/common_old/backend.py +21 -0
  3. examples/crafter_debug_render.py +180 -0
  4. examples/evals_old/README.md +98 -0
  5. examples/evals_old/__init__.py +6 -0
  6. examples/evals_old/compare_models.py +1037 -0
  7. examples/evals_old/example_log.md +145 -0
  8. examples/evals_old/run_demo.sh +126 -0
  9. examples/evals_old/trace_analysis.py +270 -0
  10. examples/finetuning_old/_backup_synth_qwen/config.toml +29 -0
  11. examples/finetuning_old/_backup_synth_qwen/example_log.md +324 -0
  12. examples/finetuning_old/_backup_synth_qwen/filter_traces.py +60 -0
  13. examples/finetuning_old/_backup_synth_qwen/filter_traces_achievements.py +239 -0
  14. examples/finetuning_old/_backup_synth_qwen/purge_v3_traces.py +109 -0
  15. examples/finetuning_old/_backup_synth_qwen/react_agent_lm.py +1924 -0
  16. examples/finetuning_old/_backup_synth_qwen/readme.md +49 -0
  17. examples/finetuning_old/_backup_synth_qwen/run_crafter_qwen4b.py +114 -0
  18. examples/finetuning_old/_backup_synth_qwen/run_demo.sh +195 -0
  19. examples/finetuning_old/_backup_synth_qwen/sft_kickoff.py +118 -0
  20. examples/finetuning_old/synth_qwen_v1/README.md +68 -0
  21. examples/finetuning_old/synth_qwen_v1/filter_traces.py +60 -0
  22. examples/finetuning_old/synth_qwen_v1/filter_traces_achievements.py +239 -0
  23. examples/finetuning_old/synth_qwen_v1/finetune.py +46 -0
  24. examples/finetuning_old/synth_qwen_v1/hello_ft_model.py +71 -0
  25. examples/finetuning_old/synth_qwen_v1/infer.py +37 -0
  26. examples/finetuning_old/synth_qwen_v1/poll.py +44 -0
  27. examples/finetuning_old/synth_qwen_v1/prepare_data.py +35 -0
  28. examples/finetuning_old/synth_qwen_v1/purge_v3_traces.py +109 -0
  29. examples/finetuning_old/synth_qwen_v1/react_agent_lm.py +1932 -0
  30. examples/finetuning_old/synth_qwen_v1/run_crafter_sft_job.py +207 -0
  31. examples/finetuning_old/synth_qwen_v1/run_ft_job.py +232 -0
  32. examples/finetuning_old/synth_qwen_v1/upload_data.py +34 -0
  33. examples/finetuning_old/synth_qwen_v1/util.py +147 -0
  34. examples/rl/README.md +169 -0
  35. examples/rl/configs/eval_base_qwen.toml +15 -0
  36. examples/rl/configs/eval_rl_qwen.toml +11 -0
  37. examples/rl/configs/rl_from_base_qwen.toml +35 -0
  38. examples/rl/configs/rl_from_base_qwen17.toml +74 -0
  39. examples/rl/configs/rl_from_ft_qwen.toml +35 -0
  40. examples/rl/download_dataset.py +64 -0
  41. examples/rl/run_eval.py +435 -0
  42. examples/rl/run_rl_and_save.py +94 -0
  43. examples/rl/task_app/README.md +22 -0
  44. {synth_ai/task/apps → examples/rl/task_app}/math_single_step.py +8 -8
  45. examples/rl/task_app/math_task_app.py +107 -0
  46. examples/rl_old/task_app.py +962 -0
  47. examples/run_crafter_demo.sh +10 -0
  48. examples/warming_up_to_rl/analyze_trace_db.py +420 -0
  49. examples/warming_up_to_rl/configs/crafter_fft.toml +48 -0
  50. examples/warming_up_to_rl/configs/crafter_fft_4b.toml +54 -0
  51. examples/warming_up_to_rl/configs/eval_fft_qwen4b.toml +20 -0
  52. examples/warming_up_to_rl/configs/eval_groq_qwen32b.toml +13 -0
  53. examples/warming_up_to_rl/configs/eval_modal_qwen4b.toml +23 -0
  54. examples/warming_up_to_rl/configs/rl_from_base_qwen4b.toml +73 -0
  55. examples/warming_up_to_rl/configs/rl_from_ft.toml +56 -0
  56. examples/warming_up_to_rl/export_trace_sft.py +541 -0
  57. examples/warming_up_to_rl/groq_test.py +88 -0
  58. examples/warming_up_to_rl/manage_secrets.py +127 -0
  59. examples/warming_up_to_rl/old/event_rewards.md +234 -0
  60. examples/warming_up_to_rl/old/notes.md +73 -0
  61. examples/warming_up_to_rl/readme.md +172 -0
  62. examples/warming_up_to_rl/run_eval.py +434 -0
  63. examples/warming_up_to_rl/run_fft_and_save.py +309 -0
  64. examples/warming_up_to_rl/run_local_rollout.py +188 -0
  65. examples/warming_up_to_rl/run_local_rollout_modal.py +160 -0
  66. examples/warming_up_to_rl/run_local_rollout_parallel.py +342 -0
  67. examples/warming_up_to_rl/run_local_rollout_traced.py +372 -0
  68. examples/warming_up_to_rl/run_rl_and_save.py +101 -0
  69. examples/warming_up_to_rl/run_rollout_remote.py +129 -0
  70. examples/warming_up_to_rl/task_app/README.md +38 -0
  71. {synth_ai/task/apps → examples/warming_up_to_rl/task_app}/grpo_crafter.py +7 -7
  72. examples/warming_up_to_rl/task_app/grpo_crafter_task_app.py +165 -0
  73. examples/warming_up_to_rl/task_app/synth_envs_hosted/README.md +173 -0
  74. examples/warming_up_to_rl/task_app/synth_envs_hosted/__init__.py +5 -0
  75. examples/warming_up_to_rl/task_app/synth_envs_hosted/branching.py +145 -0
  76. examples/warming_up_to_rl/task_app/synth_envs_hosted/environment_routes.py +1271 -0
  77. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/__init__.py +1 -0
  78. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/__init__.py +6 -0
  79. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/app.py +1 -0
  80. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/environment.py +429 -0
  81. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/policy.py +442 -0
  82. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/react_agent.py +96 -0
  83. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/shared.py +302 -0
  84. examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/tools.py +47 -0
  85. examples/warming_up_to_rl/task_app/synth_envs_hosted/hosted_app.py +202 -0
  86. examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/__init__.py +5 -0
  87. examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/openai_client.py +512 -0
  88. examples/warming_up_to_rl/task_app/synth_envs_hosted/main.py +102 -0
  89. examples/warming_up_to_rl/task_app/synth_envs_hosted/policy_routes.py +985 -0
  90. examples/warming_up_to_rl/task_app/synth_envs_hosted/registry.py +197 -0
  91. examples/warming_up_to_rl/task_app/synth_envs_hosted/rollout.py +1749 -0
  92. examples/warming_up_to_rl/task_app/synth_envs_hosted/storage/__init__.py +5 -0
  93. examples/warming_up_to_rl/task_app/synth_envs_hosted/storage/volume.py +217 -0
  94. examples/warming_up_to_rl/task_app/synth_envs_hosted/test_agents.py +160 -0
  95. examples/warming_up_to_rl/task_app/synth_envs_hosted/test_service.py +146 -0
  96. examples/warming_up_to_rl/task_app/synth_envs_hosted/utils.py +61 -0
  97. synth_ai/api/train/config_finder.py +18 -18
  98. synth_ai/api/train/env_resolver.py +28 -1
  99. synth_ai/cli/task_apps.py +291 -56
  100. synth_ai/task/apps/__init__.py +54 -13
  101. {synth_ai-0.2.9.dev3.dist-info → synth_ai-0.2.9.dev5.dist-info}/METADATA +1 -1
  102. {synth_ai-0.2.9.dev3.dist-info → synth_ai-0.2.9.dev5.dist-info}/RECORD +106 -13
  103. {synth_ai-0.2.9.dev3.dist-info → synth_ai-0.2.9.dev5.dist-info}/top_level.txt +1 -0
  104. synth_ai/environments/examples/sokoban/units/astar_common.py +0 -95
  105. {synth_ai-0.2.9.dev3.dist-info → synth_ai-0.2.9.dev5.dist-info}/WHEEL +0 -0
  106. {synth_ai-0.2.9.dev3.dist-info → synth_ai-0.2.9.dev5.dist-info}/entry_points.txt +0 -0
  107. {synth_ai-0.2.9.dev3.dist-info → synth_ai-0.2.9.dev5.dist-info}/licenses/LICENSE +0 -0
synth_ai/cli/task_apps.py CHANGED
@@ -37,8 +37,6 @@ DEFAULT_IGNORE_DIRS = {
37
37
 
38
38
  DEFAULT_SEARCH_RELATIVE = (
39
39
  Path("."),
40
- Path("examples"),
41
- Path("synth_ai"),
42
40
  )
43
41
 
44
42
 
@@ -70,32 +68,21 @@ def _should_ignore_path(path: Path) -> bool:
70
68
 
71
69
 
72
70
  def _candidate_search_roots() -> list[Path]:
71
+ """Only search for task apps in the current working directory and subdirectories."""
73
72
  roots: list[Path] = []
73
+
74
+ # Allow explicit search paths via environment variable
74
75
  env_paths = os.environ.get("SYNTH_TASK_APP_SEARCH_PATH")
75
76
  if env_paths:
76
77
  for chunk in env_paths.split(os.pathsep):
77
78
  if chunk:
78
79
  roots.append(Path(chunk).expanduser())
79
80
 
81
+ # Always include current working directory
80
82
  cwd = Path.cwd().resolve()
81
83
  roots.append(cwd)
82
84
 
83
- for rel in DEFAULT_SEARCH_RELATIVE:
84
- try:
85
- candidate = (cwd / rel).resolve()
86
- except Exception:
87
- continue
88
- roots.append(candidate)
89
- if REPO_ROOT not in (None, candidate):
90
- try:
91
- repo_candidate = (REPO_ROOT / rel).resolve()
92
- except Exception:
93
- repo_candidate = None
94
- if repo_candidate:
95
- roots.append(repo_candidate)
96
-
97
- roots.append(REPO_ROOT)
98
-
85
+ # Remove duplicates while preserving order
99
86
  seen: set[Path] = set()
100
87
  ordered: list[Path] = []
101
88
  for root in roots:
@@ -119,6 +106,10 @@ class _TaskAppConfigVisitor(ast.NodeVisitor):
119
106
  app_id = _extract_app_id(node)
120
107
  if app_id:
121
108
  self.matches.append((app_id, getattr(node, "lineno", 0)))
109
+ elif _is_register_task_app_call(node):
110
+ app_id = _extract_register_app_id(node)
111
+ if app_id:
112
+ self.matches.append((app_id, getattr(node, "lineno", 0)))
122
113
  self.generic_visit(node)
123
114
 
124
115
 
@@ -142,6 +133,27 @@ def _extract_app_id(node: ast.Call) -> str | None:
142
133
  return None
143
134
 
144
135
 
136
+ def _is_register_task_app_call(node: ast.Call) -> bool:
137
+ func = node.func
138
+ if isinstance(func, ast.Name) and func.id == "register_task_app":
139
+ return True
140
+ if isinstance(func, ast.Attribute) and func.attr == "register_task_app":
141
+ return True
142
+ return False
143
+
144
+
145
+ def _extract_register_app_id(node: ast.Call) -> str | None:
146
+ # Look for entry=TaskAppEntry(app_id="...", ...)
147
+ for kw in node.keywords:
148
+ if kw.arg == "entry" and isinstance(kw.value, ast.Call):
149
+ entry_call = kw.value
150
+ if isinstance(entry_call.func, ast.Name) and entry_call.func.id == "TaskAppEntry":
151
+ for entry_kw in entry_call.keywords:
152
+ if entry_kw.arg == "app_id" and isinstance(entry_kw.value, ast.Constant) and isinstance(entry_kw.value.value, str):
153
+ return entry_kw.value.value
154
+ return None
155
+
156
+
145
157
  class _ModalAppVisitor(ast.NodeVisitor):
146
158
  def __init__(self) -> None:
147
159
  self.app_aliases: set[str] = set()
@@ -186,12 +198,14 @@ def _extract_modal_app_name(node: ast.Call) -> str | None:
186
198
  return None
187
199
 
188
200
 
189
- @functools.lru_cache(maxsize=1)
190
201
  def _collect_task_app_choices() -> list[AppChoice]:
202
+ # Clear registry to avoid duplicate registration errors
203
+ registry.clear()
204
+
191
205
  choices: list[AppChoice] = []
192
206
  with contextlib.suppress(Exception):
193
207
  import synth_ai.demos.demo_task_apps # noqa: F401
194
- choices.extend(_collect_registered_choices())
208
+ # Only use discovered task apps, not registered ones (since we moved them to examples)
195
209
  choices.extend(_collect_scanned_task_configs())
196
210
  choices.extend(_collect_modal_scripts())
197
211
 
@@ -333,10 +347,112 @@ def _choice_has_modal_support(choice: AppChoice) -> bool:
333
347
  try:
334
348
  entry = choice.ensure_entry()
335
349
  except click.ClickException:
336
- return False
350
+ # If we can't load the entry, try to detect Modal support via AST parsing
351
+ return _has_modal_support_in_file(choice.path)
337
352
  return entry.modal is not None
338
353
 
339
354
 
355
+ def _has_modal_support_in_file(path: Path) -> bool:
356
+ """Detect if a file has Modal deployment support by parsing the AST."""
357
+ try:
358
+ source = path.read_text(encoding="utf-8")
359
+ tree = ast.parse(source, filename=str(path))
360
+
361
+ # Look for ModalDeploymentConfig in register_task_app calls
362
+ for node in ast.walk(tree):
363
+ if isinstance(node, ast.Call):
364
+ if _is_register_task_app_call(node):
365
+ # Check if the entry has modal=ModalDeploymentConfig(...)
366
+ for kw in node.keywords:
367
+ if kw.arg == "entry" and isinstance(kw.value, ast.Call):
368
+ entry_call = kw.value
369
+ if isinstance(entry_call.func, ast.Name) and entry_call.func.id == "TaskAppEntry":
370
+ for entry_kw in entry_call.keywords:
371
+ if entry_kw.arg == "modal" and isinstance(entry_kw.value, ast.Call):
372
+ modal_call = entry_kw.value
373
+ if isinstance(modal_call.func, ast.Name) and modal_call.func.id == "ModalDeploymentConfig":
374
+ return True
375
+ except Exception:
376
+ pass
377
+ return False
378
+
379
+
380
+ def _extract_modal_config_from_file(path: Path) -> ModalDeploymentConfig | None:
381
+ """Extract ModalDeploymentConfig from a file by parsing the AST."""
382
+ try:
383
+ source = path.read_text(encoding="utf-8")
384
+ tree = ast.parse(source, filename=str(path))
385
+
386
+ # Look for ModalDeploymentConfig in register_task_app calls
387
+ for node in ast.walk(tree):
388
+ if isinstance(node, ast.Call):
389
+ if _is_register_task_app_call(node):
390
+ # Check if the entry has modal=ModalDeploymentConfig(...)
391
+ for kw in node.keywords:
392
+ if kw.arg == "entry" and isinstance(kw.value, ast.Call):
393
+ entry_call = kw.value
394
+ if isinstance(entry_call.func, ast.Name) and entry_call.func.id == "TaskAppEntry":
395
+ for entry_kw in entry_call.keywords:
396
+ if entry_kw.arg == "modal" and isinstance(entry_kw.value, ast.Call):
397
+ modal_call = entry_kw.value
398
+ if isinstance(modal_call.func, ast.Name) and modal_call.func.id == "ModalDeploymentConfig":
399
+ # Extract the arguments to ModalDeploymentConfig
400
+ return _build_modal_config_from_ast(modal_call)
401
+ except Exception:
402
+ pass
403
+ return None
404
+
405
+
406
+ def _build_modal_config_from_ast(modal_call: ast.Call) -> ModalDeploymentConfig | None:
407
+ """Build a ModalDeploymentConfig from an AST Call node."""
408
+ try:
409
+ # Extract keyword arguments
410
+ kwargs = {}
411
+ for kw in modal_call.keywords:
412
+ if kw.arg and isinstance(kw.value, ast.Constant):
413
+ kwargs[kw.arg] = kw.value.value
414
+ elif kw.arg == "pip_packages" and isinstance(kw.value, (ast.List, ast.Tuple)):
415
+ # Handle pip_packages list/tuple
416
+ packages = []
417
+ for elt in kw.value.elts:
418
+ if isinstance(elt, ast.Constant):
419
+ packages.append(elt.value)
420
+ kwargs[kw.arg] = tuple(packages)
421
+ elif kw.arg == "extra_local_dirs" and isinstance(kw.value, (ast.List, ast.Tuple)):
422
+ # Handle extra_local_dirs list/tuple of tuples
423
+ dirs = []
424
+ for elt in kw.value.elts:
425
+ if isinstance(elt, (ast.List, ast.Tuple)) and len(elt.elts) == 2:
426
+ src = elt.elts[0].value if isinstance(elt.elts[0], ast.Constant) else None
427
+ dst = elt.elts[1].value if isinstance(elt.elts[1], ast.Constant) else None
428
+ if src and dst:
429
+ dirs.append((src, dst))
430
+ kwargs[kw.arg] = tuple(dirs)
431
+ elif kw.arg == "secret_names" and isinstance(kw.value, (ast.List, ast.Tuple)):
432
+ # Handle secret_names list/tuple
433
+ secrets = []
434
+ for elt in kw.value.elts:
435
+ if isinstance(elt, ast.Constant):
436
+ secrets.append(elt.value)
437
+ kwargs[kw.arg] = tuple(secrets)
438
+ elif kw.arg == "volume_mounts" and isinstance(kw.value, (ast.List, ast.Tuple)):
439
+ # Handle volume_mounts list/tuple of tuples
440
+ mounts = []
441
+ for elt in kw.value.elts:
442
+ if isinstance(elt, (ast.List, ast.Tuple)) and len(elt.elts) == 2:
443
+ name = elt.elts[0].value if isinstance(elt.elts[0], ast.Constant) else None
444
+ mount = elt.elts[1].value if isinstance(elt.elts[1], ast.Constant) else None
445
+ if name and mount:
446
+ mounts.append((name, mount))
447
+ kwargs[kw.arg] = tuple(mounts)
448
+
449
+ # Create ModalDeploymentConfig with extracted arguments
450
+ from synth_ai.task.apps import ModalDeploymentConfig
451
+ return ModalDeploymentConfig(**kwargs)
452
+ except Exception:
453
+ return None
454
+
455
+
340
456
  def _choice_has_local_support(choice: AppChoice) -> bool:
341
457
  if choice.modal_script:
342
458
  return False
@@ -418,6 +534,10 @@ def _load_entry_from_path(path: Path, app_id: str) -> TaskAppEntry:
418
534
  raise click.ClickException(f"Unable to load Python module from {resolved}")
419
535
  module = importlib.util.module_from_spec(spec)
420
536
  sys.modules[module_name] = module
537
+
538
+ # Clear registry before importing to avoid duplicate registration errors
539
+ registry.clear()
540
+
421
541
  try:
422
542
  spec.loader.exec_module(module)
423
543
  except Exception as exc:
@@ -468,10 +588,16 @@ def _load_entry_from_path(path: Path, app_id: str) -> TaskAppEntry:
468
588
  config_obj = result
469
589
  break
470
590
 
591
+ # If no TaskAppConfig found directly, check if it was registered via register_task_app
471
592
  if factory_callable is None or config_obj is None:
472
- raise click.ClickException(
473
- f"Could not locate TaskAppConfig for '{app_id}' in {resolved}."
474
- )
593
+ try:
594
+ # Check if the app was registered in the registry
595
+ entry = registry.get(app_id)
596
+ return entry
597
+ except KeyError:
598
+ raise click.ClickException(
599
+ f"Could not locate TaskAppConfig for '{app_id}' in {resolved}."
600
+ )
475
601
 
476
602
  modal_cfg: ModalDeploymentConfig | None = None
477
603
  for attr_name in dir(module):
@@ -482,6 +608,10 @@ def _load_entry_from_path(path: Path, app_id: str) -> TaskAppEntry:
482
608
  if isinstance(attr, ModalDeploymentConfig):
483
609
  modal_cfg = attr
484
610
  break
611
+
612
+ # If no ModalDeploymentConfig found, try to detect it via AST parsing
613
+ if modal_cfg is None:
614
+ modal_cfg = _extract_modal_config_from_file(resolved)
485
615
 
486
616
  description = inspect.getdoc(module) or f"Discovered task app in {resolved.name}"
487
617
  env_files: Iterable[str] = getattr(module, "ENV_FILES", ()) # type: ignore[arg-type]
@@ -507,20 +637,35 @@ def _resolve_env_paths_for_script(script_path: Path, explicit: Sequence[str]) ->
507
637
  resolved.append(p)
508
638
  return resolved
509
639
 
640
+ # Always prompt for env file selection instead of auto-loading defaults
510
641
  script_dir = script_path.parent.resolve()
511
- fallback_order = [
512
- script_dir / ".env",
513
- REPO_ROOT / "examples" / "rl" / ".env",
514
- REPO_ROOT / "examples" / "warming_up_to_rl" / ".env",
515
- REPO_ROOT / ".env",
516
- ]
517
- resolved = [p for p in fallback_order if p.exists()]
518
- if resolved:
519
- return resolved
520
- created = _interactive_create_env(script_dir)
521
- if created is None:
522
- raise click.ClickException("Env file required (--env-file) for this task app")
523
- return [created]
642
+ cwd = Path.cwd()
643
+
644
+ # Look for env files in current working directory first, then repo root
645
+ env_candidates = []
646
+
647
+ # Add CWD env files first (prioritized)
648
+ cwd_env_files = sorted(cwd.glob('**/*.env'))
649
+ env_candidates.extend(cwd_env_files)
650
+
651
+ # Add repo root env files
652
+ repo_env_files = sorted(REPO_ROOT.glob('**/*.env'))
653
+ # Avoid duplicates
654
+ for repo_file in repo_env_files:
655
+ if repo_file not in env_candidates:
656
+ env_candidates.append(repo_file)
657
+
658
+ if not env_candidates:
659
+ created = _interactive_create_env(script_dir)
660
+ if created is None:
661
+ raise click.ClickException("Env file required (--env-file) for this task app")
662
+ return [created]
663
+
664
+ click.echo('Select env file to load:')
665
+ for idx, path in enumerate(env_candidates, start=1):
666
+ click.echo(f" {idx}) {path}")
667
+ choice = click.prompt('Enter choice', type=click.IntRange(1, len(env_candidates)))
668
+ return [env_candidates[choice - 1]]
524
669
 
525
670
 
526
671
  def _run_modal_script(
@@ -554,7 +699,7 @@ def _run_modal_script(
554
699
  raise click.ClickException(f"modal {command} failed with exit code {exc.returncode}") from exc
555
700
 
556
701
 
557
- def _preflight_env_key() -> None:
702
+ def _preflight_env_key(crash_on_failure: bool = False) -> None:
558
703
  try:
559
704
  raw_backend = os.environ.get("BACKEND_BASE_URL") or os.environ.get("SYNTH_BASE_URL") or "http://localhost:8000/api"
560
705
  backend_base = raw_backend.rstrip('/')
@@ -591,13 +736,24 @@ def _preflight_env_key() -> None:
591
736
  click.echo("[preflight] verifying env key presence…")
592
737
  ver = c.get(f"{backend_base.rstrip('/')}/v1/env-keys/verify")
593
738
  if ver.status_code == 200 and (ver.json() or {}).get("present"):
594
- click.echo("✅ ENVIRONMENT_API_KEY upserted and verified in backend")
739
+ # Show first and last 5 chars of the API key for verification
740
+ key_preview = f"{env_api_key[:5]}...{env_api_key[-5:]}" if len(env_api_key) > 10 else env_api_key
741
+ click.echo(f"✅ ENVIRONMENT_API_KEY upserted and verified in backend ({key_preview})")
595
742
  else:
596
- click.echo("[WARN] ENVIRONMENT_API_KEY verification failed; proceeding anyway")
597
- except Exception:
598
- click.echo("[WARN] Failed to encrypt/upload ENVIRONMENT_API_KEY; proceeding anyway")
599
- except Exception:
600
- click.echo("[WARN] Backend preflight for ENVIRONMENT_API_KEY failed; proceeding anyway")
743
+ error_msg = "ENVIRONMENT_API_KEY verification failed"
744
+ if crash_on_failure:
745
+ raise click.ClickException(f"[CRITICAL] {error_msg}")
746
+ click.echo(f"[WARN] {error_msg}; proceeding anyway")
747
+ except Exception as e:
748
+ error_msg = f"Failed to encrypt/upload ENVIRONMENT_API_KEY: {e}"
749
+ if crash_on_failure:
750
+ raise click.ClickException(f"[CRITICAL] {error_msg}")
751
+ click.echo(f"[WARN] {error_msg}; proceeding anyway")
752
+ except Exception as e:
753
+ error_msg = f"Backend preflight for ENVIRONMENT_API_KEY failed: {e}"
754
+ if crash_on_failure:
755
+ raise click.ClickException(f"[CRITICAL] {error_msg}")
756
+ click.echo(f"[WARN] {error_msg}; proceeding anyway")
601
757
 
602
758
 
603
759
  def _run_modal_with_entry(
@@ -609,6 +765,7 @@ def _run_modal_with_entry(
609
765
  command: str,
610
766
  *,
611
767
  dry_run: bool = False,
768
+ original_path: Path | None = None,
612
769
  ) -> None:
613
770
  modal_path = shutil.which(modal_cli)
614
771
  if modal_path is None:
@@ -620,13 +777,14 @@ def _run_modal_with_entry(
620
777
  fallback_dir = env_paths_list[0].parent if env_paths_list else Path.cwd()
621
778
  _ensure_env_values(env_paths_list, fallback_dir)
622
779
  _load_env_values(env_paths_list)
623
- _preflight_env_key()
780
+ _preflight_env_key(crash_on_failure=True)
624
781
 
625
782
  script_path = _write_modal_entrypoint(
626
783
  entry,
627
784
  modal_cfg,
628
785
  modal_name,
629
786
  dotenv_paths=dotenv_paths,
787
+ original_path=original_path,
630
788
  )
631
789
  cmd = [modal_path, command, str(script_path)]
632
790
 
@@ -742,6 +900,7 @@ def _deploy_entry(
742
900
  dry_run: bool,
743
901
  modal_cli: str,
744
902
  env_file: Sequence[str],
903
+ original_path: Path | None = None,
745
904
  ) -> None:
746
905
  modal_cfg = entry.modal
747
906
  if modal_cfg is None:
@@ -749,7 +908,7 @@ def _deploy_entry(
749
908
 
750
909
  env_paths = _determine_env_files(entry, env_file)
751
910
  click.echo('Using env file(s): ' + ', '.join(str(p) for p in env_paths))
752
- _run_modal_with_entry(entry, modal_cfg, modal_cli, modal_name, env_paths, command="deploy", dry_run=dry_run)
911
+ _run_modal_with_entry(entry, modal_cfg, modal_cli, modal_name, env_paths, command="deploy", dry_run=dry_run, original_path=original_path)
753
912
 
754
913
 
755
914
  def _modal_serve_entry(
@@ -757,6 +916,7 @@ def _modal_serve_entry(
757
916
  modal_name: str | None,
758
917
  modal_cli: str,
759
918
  env_file: Sequence[str],
919
+ original_path: Path | None = None,
760
920
  ) -> None:
761
921
  modal_cfg = entry.modal
762
922
  if modal_cfg is None:
@@ -764,7 +924,7 @@ def _modal_serve_entry(
764
924
 
765
925
  env_paths = _determine_env_files(entry, env_file)
766
926
  click.echo('Using env file(s): ' + ', '.join(str(p) for p in env_paths))
767
- _run_modal_with_entry(entry, modal_cfg, modal_cli, modal_name, env_paths, command="serve")
927
+ _run_modal_with_entry(entry, modal_cfg, modal_cli, modal_name, env_paths, command="serve", original_path=original_path)
768
928
 
769
929
  @click.group(
770
930
  name='task-app',
@@ -862,11 +1022,22 @@ def _determine_env_files(entry: TaskAppEntry, user_env_files: Sequence[str]) ->
862
1022
  if resolved:
863
1023
  return resolved
864
1024
 
865
- defaults = [Path(path).expanduser() for path in (entry.env_files or []) if Path(path).expanduser().exists()]
866
- if defaults:
867
- return defaults
868
-
869
- env_candidates = sorted(REPO_ROOT.glob('**/*.env'))
1025
+ # Always prompt for env file selection instead of auto-loading defaults
1026
+ # Look for env files in current working directory first, then repo root
1027
+ cwd = Path.cwd()
1028
+ env_candidates = []
1029
+
1030
+ # Add CWD env files first (prioritized)
1031
+ cwd_env_files = sorted(cwd.glob('**/*.env'))
1032
+ env_candidates.extend(cwd_env_files)
1033
+
1034
+ # Add repo root env files
1035
+ repo_env_files = sorted(REPO_ROOT.glob('**/*.env'))
1036
+ # Avoid duplicates
1037
+ for repo_file in repo_env_files:
1038
+ if repo_file not in env_candidates:
1039
+ env_candidates.append(repo_file)
1040
+
870
1041
  if not env_candidates:
871
1042
  raise click.ClickException('No env file found. Pass --env-file explicitly.')
872
1043
 
@@ -995,7 +1166,7 @@ def deploy_app(app_id: str | None, modal_name: str | None, dry_run: bool, modal_
995
1166
  return
996
1167
 
997
1168
  entry = choice.ensure_entry()
998
- _deploy_entry(entry, modal_name, dry_run, modal_cli, env_file)
1169
+ _deploy_entry(entry, modal_name, dry_run, modal_cli, env_file, original_path=choice.path)
999
1170
 
1000
1171
  @task_app_group.command('modal-serve')
1001
1172
  @click.argument('app_id', type=str, required=False)
@@ -1012,7 +1183,7 @@ def modal_serve_app(app_id: str | None, modal_cli: str, modal_name: str | None,
1012
1183
  return
1013
1184
 
1014
1185
  entry = choice.ensure_entry()
1015
- _modal_serve_entry(entry, modal_name, modal_cli, env_file)
1186
+ _modal_serve_entry(entry, modal_name, modal_cli, env_file, original_path=choice.path)
1016
1187
 
1017
1188
 
1018
1189
  def _write_modal_entrypoint(
@@ -1021,21 +1192,58 @@ def _write_modal_entrypoint(
1021
1192
  override_name: str | None,
1022
1193
  *,
1023
1194
  dotenv_paths: Sequence[str] | None = None,
1195
+ original_path: Path | None = None,
1024
1196
  ) -> Path:
1025
1197
  modal_name = override_name or modal_cfg.app_name
1026
1198
 
1199
+ # For dynamically discovered apps, import the module by its package path
1200
+ # Compute the module name relative to the mounted repo root (/opt/synth_ai_repo)
1201
+ remote_file_str: str | None = None
1202
+ if original_path:
1203
+ try:
1204
+ # Build lookup of local->remote mounts
1205
+ mount_map: list[tuple[Path, Path]] = [
1206
+ (Path(local).resolve(), Path(remote)) for (local, remote) in modal_cfg.extra_local_dirs
1207
+ ]
1208
+ orig = Path(original_path).resolve()
1209
+ for local_src, remote_dst in mount_map:
1210
+ with contextlib.suppress(Exception):
1211
+ if orig.is_relative_to(local_src): # py311+
1212
+ remote_file_str = str((remote_dst / orig.relative_to(local_src)).resolve())
1213
+ break
1214
+ try:
1215
+ rel = orig.relative_to(local_src)
1216
+ remote_file_str = str((remote_dst / rel).resolve())
1217
+ break
1218
+ except Exception:
1219
+ pass
1220
+ except Exception:
1221
+ remote_file_str = None
1027
1222
  module_name = entry.config_factory.__module__
1223
+
1224
+ # Prefer a guaranteed mount for the discovered file to avoid package import issues
1225
+ guaranteed_file_str: str | None = None
1226
+ if original_path:
1227
+ guaranteed_file_str = str((Path("/opt/synth_ai_repo/__local_task_app__") / Path(original_path).stem).with_suffix('.py'))
1228
+
1028
1229
  dotenv_paths = [str(Path(path)) for path in (dotenv_paths or [])]
1029
1230
 
1030
1231
  pip_packages = list(modal_cfg.pip_packages)
1031
1232
 
1032
1233
  local_dirs = [(str(Path(src)), dst) for src, dst in modal_cfg.extra_local_dirs]
1234
+ # Ensure the discovered app directory is mounted, regardless of modal_cfg
1235
+ if original_path:
1236
+ discovered_dir = str(Path(original_path).resolve().parent)
1237
+ mount_dst = "/opt/synth_ai_repo/__local_task_app__"
1238
+ if (discovered_dir, mount_dst) not in local_dirs:
1239
+ local_dirs.append((discovered_dir, mount_dst))
1033
1240
  secret_names = list(modal_cfg.secret_names)
1034
1241
  volume_mounts = [(name, mount) for name, mount in modal_cfg.volume_mounts]
1035
1242
 
1036
1243
  script = f"""from __future__ import annotations
1037
1244
 
1038
1245
  import importlib
1246
+ import importlib.util
1039
1247
  import sys
1040
1248
  sys.path.insert(0, '/opt/synth_ai_repo')
1041
1249
 
@@ -1047,6 +1255,7 @@ from synth_ai.task.server import create_task_app
1047
1255
  ENTRY_ID = {entry.app_id!r}
1048
1256
  MODAL_APP_NAME = {modal_name!r}
1049
1257
  MODULE_NAME = {module_name!r}
1258
+ MODULE_FILE = {guaranteed_file_str or remote_file_str!r}
1050
1259
  DOTENV_PATHS = {dotenv_paths!r}
1051
1260
 
1052
1261
  image = Image.debian_slim(python_version={modal_cfg.python_version!r})
@@ -1070,8 +1279,34 @@ volume_map = {{}}
1070
1279
  for vol_name, mount_path in volume_mounts:
1071
1280
  volume_map[mount_path] = Volume.from_name(vol_name, create_if_missing=True)
1072
1281
 
1073
- importlib.import_module(MODULE_NAME)
1074
-
1282
+ # Import the module to trigger registration
1283
+ if MODULE_FILE:
1284
+ spec = importlib.util.spec_from_file_location(MODULE_NAME or 'task_app_module', MODULE_FILE)
1285
+ if spec and spec.loader:
1286
+ mod = importlib.util.module_from_spec(spec)
1287
+ sys.modules[MODULE_NAME or 'task_app_module'] = mod
1288
+ spec.loader.exec_module(mod)
1289
+ else:
1290
+ raise RuntimeError("Failed to import task app from file: " + str(MODULE_FILE))
1291
+ else:
1292
+ # Try module import; if that fails, attempt to load from the guaranteed mount
1293
+ try:
1294
+ importlib.import_module(MODULE_NAME)
1295
+ except Exception:
1296
+ import os
1297
+ fallback_file = '/opt/synth_ai_repo/__local_task_app__/' + (MODULE_NAME.split('.')[-1] if MODULE_NAME else 'task_app') + '.py'
1298
+ if os.path.exists(fallback_file):
1299
+ spec = importlib.util.spec_from_file_location(MODULE_NAME or 'task_app_module', fallback_file)
1300
+ if spec and spec.loader:
1301
+ mod = importlib.util.module_from_spec(spec)
1302
+ sys.modules[MODULE_NAME or 'task_app_module'] = mod
1303
+ spec.loader.exec_module(mod)
1304
+ else:
1305
+ raise
1306
+ else:
1307
+ raise
1308
+
1309
+ # Get the entry from registry (now that it's registered)
1075
1310
  entry = registry.get(ENTRY_ID)
1076
1311
  modal_cfg = entry.modal
1077
1312
  if modal_cfg is None:
@@ -2,7 +2,11 @@ from __future__ import annotations
2
2
 
3
3
  """Registry for Task Apps exposed via the shared FastAPI harness."""
4
4
 
5
+ import importlib
6
+ import os
7
+ import sys
5
8
  from dataclasses import dataclass, field
9
+ from pathlib import Path
6
10
  from typing import Callable, Dict, Iterable, List, Sequence
7
11
 
8
12
  from ..server import TaskAppConfig
@@ -64,6 +68,11 @@ class TaskAppRegistry:
64
68
 
65
69
  def __iter__(self) -> Iterable[TaskAppEntry]:
66
70
  return iter(self.list())
71
+
72
+ def clear(self) -> None:
73
+ """Clear all registered task apps."""
74
+ self._entries.clear()
75
+ self._alias_to_id.clear()
67
76
 
68
77
 
69
78
  registry = TaskAppRegistry()
@@ -73,16 +82,48 @@ def register_task_app(*, entry: TaskAppEntry) -> None:
73
82
  registry.register(entry)
74
83
 
75
84
 
76
-
77
- # Register built-in task apps
78
- try:
79
- from . import grpo_crafter # noqa: F401
80
- except Exception:
81
- # Defer import errors so CLI can report missing deps gracefully
82
- pass
83
-
84
- try:
85
- from . import math_single_step # noqa: F401
86
- except Exception:
87
- # Defer import errors so CLI can report missing deps gracefully
88
- pass
85
+ def discover_task_apps_from_cwd() -> None:
86
+ """Discover and register task apps from the current working directory and subdirectories."""
87
+ cwd = Path.cwd()
88
+
89
+ # Look for task app files in common patterns
90
+ patterns = [
91
+ "**/task_app/*.py",
92
+ "**/task_apps/*.py",
93
+ "**/*_task_app.py",
94
+ "**/grpo_crafter.py",
95
+ "**/math_single_step.py",
96
+ ]
97
+
98
+ discovered_files = []
99
+ for pattern in patterns:
100
+ discovered_files.extend(cwd.glob(pattern))
101
+
102
+ # Add current directory to Python path temporarily
103
+ original_path = sys.path.copy()
104
+ try:
105
+ sys.path.insert(0, str(cwd))
106
+
107
+ for file_path in discovered_files:
108
+ if file_path.name.startswith('__'):
109
+ continue
110
+
111
+ # Convert file path to module name
112
+ relative_path = file_path.relative_to(cwd)
113
+ module_parts = list(relative_path.parts[:-1]) + [relative_path.stem]
114
+ module_name = '.'.join(module_parts)
115
+
116
+ try:
117
+ # Import the module to trigger registration
118
+ importlib.import_module(module_name)
119
+ except Exception as exc:
120
+ # Silently skip modules that can't be imported
121
+ # This allows for graceful handling of missing dependencies
122
+ continue
123
+
124
+ finally:
125
+ sys.path[:] = original_path
126
+
127
+
128
+ # Note: Task apps are now discovered dynamically by the CLI, not auto-registered
129
+ # This allows for better separation between SDK and example-specific implementations
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: synth-ai
3
- Version: 0.2.9.dev3
3
+ Version: 0.2.9.dev5
4
4
  Summary: RL as a service SDK - Core AI functionality and tracing
5
5
  Author-email: Synth AI <josh@usesynth.ai>
6
6
  License-Expression: MIT