synth-ai 0.2.10__py3-none-any.whl → 0.2.13.dev1__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 (73) hide show
  1. examples/agora_ex/README_MoE.md +224 -0
  2. examples/agora_ex/__init__.py +7 -0
  3. examples/agora_ex/agora_ex.py +65 -0
  4. examples/agora_ex/agora_ex_task_app.py +590 -0
  5. examples/agora_ex/configs/rl_lora_qwen3_moe_2xh200.toml +121 -0
  6. examples/agora_ex/reward_fn_grpo-human.py +129 -0
  7. examples/agora_ex/system_prompt_CURRENT.md +63 -0
  8. examples/agora_ex/task_app/agora_ex_task_app.py +590 -0
  9. examples/agora_ex/task_app/reward_fn_grpo-human.py +129 -0
  10. examples/agora_ex/task_app/system_prompt_CURRENT.md +63 -0
  11. examples/multi_step/configs/crafter_rl_outcome.toml +74 -0
  12. examples/multi_step/configs/crafter_rl_stepwise_hosted_judge.toml +175 -0
  13. examples/multi_step/configs/crafter_rl_stepwise_shaped.toml +83 -0
  14. examples/multi_step/configs/crafter_rl_stepwise_simple.toml +78 -0
  15. examples/multi_step/crafter_rl_lora.md +51 -10
  16. examples/multi_step/sse_metrics_streaming_notes.md +357 -0
  17. examples/multi_step/task_app_config_notes.md +494 -0
  18. examples/warming_up_to_rl/configs/eval_stepwise_complex.toml +35 -0
  19. examples/warming_up_to_rl/configs/eval_stepwise_consistent.toml +26 -0
  20. examples/warming_up_to_rl/configs/eval_stepwise_per_achievement.toml +36 -0
  21. examples/warming_up_to_rl/configs/eval_stepwise_simple.toml +32 -0
  22. examples/warming_up_to_rl/run_eval.py +267 -41
  23. examples/warming_up_to_rl/task_app/grpo_crafter.py +3 -33
  24. examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/openai_client.py +109 -45
  25. examples/warming_up_to_rl/task_app/synth_envs_hosted/policy_routes.py +42 -46
  26. examples/warming_up_to_rl/task_app/synth_envs_hosted/rollout.py +376 -193
  27. synth_ai/__init__.py +41 -1
  28. synth_ai/api/train/builders.py +74 -33
  29. synth_ai/api/train/cli.py +29 -6
  30. synth_ai/api/train/configs/__init__.py +44 -0
  31. synth_ai/api/train/configs/rl.py +133 -0
  32. synth_ai/api/train/configs/sft.py +94 -0
  33. synth_ai/api/train/configs/shared.py +24 -0
  34. synth_ai/api/train/env_resolver.py +18 -19
  35. synth_ai/api/train/supported_algos.py +8 -5
  36. synth_ai/api/train/utils.py +6 -1
  37. synth_ai/cli/__init__.py +4 -2
  38. synth_ai/cli/_storage.py +19 -0
  39. synth_ai/cli/balance.py +14 -2
  40. synth_ai/cli/calc.py +37 -22
  41. synth_ai/cli/demo.py +38 -39
  42. synth_ai/cli/legacy_root_backup.py +12 -14
  43. synth_ai/cli/recent.py +12 -7
  44. synth_ai/cli/rl_demo.py +81 -102
  45. synth_ai/cli/status.py +4 -3
  46. synth_ai/cli/task_apps.py +146 -137
  47. synth_ai/cli/traces.py +4 -3
  48. synth_ai/cli/watch.py +3 -2
  49. synth_ai/demos/core/cli.py +121 -159
  50. synth_ai/environments/examples/crafter_classic/environment.py +16 -0
  51. synth_ai/evals/__init__.py +15 -0
  52. synth_ai/evals/client.py +85 -0
  53. synth_ai/evals/types.py +42 -0
  54. synth_ai/jobs/client.py +15 -3
  55. synth_ai/judge_schemas.py +127 -0
  56. synth_ai/rubrics/__init__.py +22 -0
  57. synth_ai/rubrics/validators.py +126 -0
  58. synth_ai/task/server.py +14 -7
  59. synth_ai/tracing_v3/decorators.py +51 -26
  60. synth_ai/tracing_v3/examples/basic_usage.py +12 -7
  61. synth_ai/tracing_v3/llm_call_record_helpers.py +107 -53
  62. synth_ai/tracing_v3/replica_sync.py +8 -4
  63. synth_ai/tracing_v3/serialization.py +130 -0
  64. synth_ai/tracing_v3/storage/utils.py +11 -9
  65. synth_ai/tracing_v3/turso/__init__.py +12 -0
  66. synth_ai/tracing_v3/turso/daemon.py +2 -1
  67. synth_ai/tracing_v3/turso/native_manager.py +28 -15
  68. {synth_ai-0.2.10.dist-info → synth_ai-0.2.13.dev1.dist-info}/METADATA +4 -2
  69. {synth_ai-0.2.10.dist-info → synth_ai-0.2.13.dev1.dist-info}/RECORD +73 -40
  70. {synth_ai-0.2.10.dist-info → synth_ai-0.2.13.dev1.dist-info}/entry_points.txt +0 -1
  71. {synth_ai-0.2.10.dist-info → synth_ai-0.2.13.dev1.dist-info}/WHEEL +0 -0
  72. {synth_ai-0.2.10.dist-info → synth_ai-0.2.13.dev1.dist-info}/licenses/LICENSE +0 -0
  73. {synth_ai-0.2.10.dist-info → synth_ai-0.2.13.dev1.dist-info}/top_level.txt +0 -0
synth_ai/cli/task_apps.py CHANGED
@@ -28,10 +28,61 @@ except Exception: # pragma: no cover - fallback
28
28
  import uuid
29
29
 
30
30
  import click
31
+ from click.exceptions import Abort
31
32
 
32
- from synth_ai.config.base_url import PROD_BASE_URL_DEFAULT
33
- from synth_ai.task.apps import ModalDeploymentConfig, TaskAppConfig, TaskAppEntry, registry
34
- from synth_ai.task.server import create_task_app, run_task_app
33
+ # ---------------------------------------------------------------------------
34
+ # Dynamic imports to avoid hard dependencies during type checking.
35
+ # ---------------------------------------------------------------------------
36
+ ModalDeploymentConfigType = TaskAppConfigType = TaskAppEntryType = Any
37
+
38
+ try: # Resolve base URL defaults lazily
39
+ _config_module = importlib.import_module("synth_ai.config.base_url")
40
+ PROD_BASE_URL_DEFAULT = cast(str, _config_module.PROD_BASE_URL_DEFAULT)
41
+ except Exception: # pragma: no cover - fallback
42
+ PROD_BASE_URL_DEFAULT = "https://agent-learning.onrender.com"
43
+
44
+ try:
45
+ _task_apps_module = importlib.import_module("synth_ai.task.apps")
46
+ ModalDeploymentConfig = cast(
47
+ type[ModalDeploymentConfigType], _task_apps_module.ModalDeploymentConfig
48
+ )
49
+ TaskAppConfig = cast(type[TaskAppConfigType], _task_apps_module.TaskAppConfig)
50
+ TaskAppEntry = cast(type[TaskAppEntryType], _task_apps_module.TaskAppEntry)
51
+ registry = _task_apps_module.registry
52
+ except Exception as exc: # pragma: no cover - critical dependency
53
+ raise RuntimeError("Unable to load task app registry") from exc
54
+
55
+ try:
56
+ _task_server_module = importlib.import_module("synth_ai.task.server")
57
+ create_task_app = _task_server_module.create_task_app
58
+ run_task_app = _task_server_module.run_task_app
59
+ except Exception as exc: # pragma: no cover - critical dependency
60
+ raise RuntimeError("Unable to load task app server utilities") from exc
61
+
62
+
63
+ def _load_demo_directory() -> Path | None:
64
+ """Return the demo task apps directory if available."""
65
+
66
+ try:
67
+ module = importlib.import_module("synth_ai.demos.demo_task_apps.core")
68
+ loader = module.load_demo_dir
69
+ demo_dir = loader()
70
+ if isinstance(demo_dir, (str, Path)):
71
+ demo_path = Path(demo_dir)
72
+ if demo_path.exists():
73
+ return demo_path.resolve()
74
+ except Exception:
75
+ return None
76
+ return None
77
+
78
+
79
+ def _maybe_import(name: str) -> Any:
80
+ """Safely import a module by name and return it, or None on failure."""
81
+
82
+ try:
83
+ return importlib.import_module(name)
84
+ except Exception:
85
+ return None
35
86
 
36
87
  REPO_ROOT = Path(__file__).resolve().parents[2]
37
88
 
@@ -62,12 +113,12 @@ class AppChoice:
62
113
  source: str
63
114
  description: str | None = None
64
115
  aliases: tuple[str, ...] = ()
65
- entry: TaskAppEntry | None = None
66
- entry_loader: Callable[[], TaskAppEntry] | None = None
116
+ entry: TaskAppEntryType | None = None
117
+ entry_loader: Callable[[], TaskAppEntryType] | None = None
67
118
  modal_script: Path | None = None
68
119
  lineno: int | None = None
69
120
 
70
- def ensure_entry(self) -> TaskAppEntry:
121
+ def ensure_entry(self) -> TaskAppEntryType:
71
122
  if self.entry is not None:
72
123
  return self.entry
73
124
  if self.entry_loader is None:
@@ -152,17 +203,9 @@ def _candidate_search_roots() -> list[Path]:
152
203
  """Only search for task apps in the current working directory and subdirectories."""
153
204
  roots: list[Path] = []
154
205
 
155
- # Prioritize demo directory if it exists
156
- try:
157
- from synth_ai.demos.demo_task_apps.core import load_demo_dir
158
-
159
- demo_dir = load_demo_dir()
160
- if demo_dir:
161
- demo_path = Path(demo_dir)
162
- if demo_path.exists() and demo_path.is_dir():
163
- roots.append(demo_path.resolve())
164
- except Exception:
165
- pass
206
+ demo_path = _load_demo_directory()
207
+ if demo_path is not None and demo_path.is_dir():
208
+ roots.append(demo_path)
166
209
 
167
210
  # Allow explicit search paths via environment variable
168
211
  env_paths = os.environ.get("SYNTH_TASK_APP_SEARCH_PATH")
@@ -359,7 +402,7 @@ def _collect_task_app_choices() -> list[AppChoice]:
359
402
 
360
403
  choices: list[AppChoice] = []
361
404
  with contextlib.suppress(Exception):
362
- import synth_ai.demos.demo_task_apps # noqa: F401
405
+ _maybe_import("synth_ai.demos.demo_task_apps")
363
406
  # Only use discovered task apps, not registered ones (since we moved them to examples)
364
407
  choices.extend(_collect_scanned_task_configs())
365
408
  choices.extend(_collect_modal_scripts())
@@ -515,16 +558,9 @@ def _app_choice_sort_key(choice: AppChoice) -> tuple[int, int, int, int, int, st
515
558
 
516
559
  # Further prioritize apps in the demo directory if one is set
517
560
  demo_rank = 1
518
- try:
519
- from synth_ai.demos.demo_task_apps.core import load_demo_dir
520
-
521
- demo_dir = load_demo_dir()
522
- if demo_dir:
523
- demo_path = Path(demo_dir).resolve()
524
- if choice.path.is_relative_to(demo_path):
525
- demo_rank = 0
526
- except Exception:
527
- pass
561
+ demo_dir = _load_demo_directory()
562
+ if demo_dir and choice.path.is_relative_to(demo_dir):
563
+ demo_rank = 0
528
564
 
529
565
  modal_rank = 1 if choice.modal_script else 0
530
566
 
@@ -598,7 +634,7 @@ def _has_modal_support_in_file(path: Path) -> bool:
598
634
  return False
599
635
 
600
636
 
601
- def _extract_modal_config_from_file(path: Path) -> ModalDeploymentConfig | None:
637
+ def _extract_modal_config_from_file(path: Path) -> ModalDeploymentConfigType | None:
602
638
  """Extract ModalDeploymentConfig from a file by parsing the AST."""
603
639
  try:
604
640
  source = path.read_text(encoding="utf-8")
@@ -629,7 +665,7 @@ def _extract_modal_config_from_file(path: Path) -> ModalDeploymentConfig | None:
629
665
  return None
630
666
 
631
667
 
632
- def _build_modal_config_from_ast(modal_call: ast.Call) -> ModalDeploymentConfig | None:
668
+ def _build_modal_config_from_ast(modal_call: ast.Call) -> ModalDeploymentConfigType | None:
633
669
  """Build a ModalDeploymentConfig from an AST Call node."""
634
670
  try:
635
671
  # Extract keyword arguments
@@ -637,44 +673,41 @@ def _build_modal_config_from_ast(modal_call: ast.Call) -> ModalDeploymentConfig
637
673
  for kw in modal_call.keywords:
638
674
  if kw.arg and isinstance(kw.value, ast.Constant):
639
675
  kwargs[kw.arg] = kw.value.value
640
- elif kw.arg == "pip_packages" and isinstance(kw.value, ast.List | ast.Tuple):
676
+ elif kw.arg == "pip_packages" and isinstance(kw.value, (ast.List, ast.Tuple)):
641
677
  # Handle pip_packages list/tuple
642
- packages = []
678
+ packages: list[str] = []
643
679
  for elt in kw.value.elts:
644
680
  if isinstance(elt, ast.Constant):
645
681
  packages.append(elt.value)
646
682
  kwargs[kw.arg] = tuple(packages)
647
- elif kw.arg == "extra_local_dirs" and isinstance(kw.value, ast.List | ast.Tuple):
683
+ elif kw.arg == "extra_local_dirs" and isinstance(kw.value, (ast.List, ast.Tuple)):
648
684
  # Handle extra_local_dirs list/tuple of tuples
649
685
  dirs = []
650
686
  for elt in kw.value.elts:
651
- if isinstance(elt, ast.List | ast.Tuple) and len(elt.elts) == 2:
687
+ if isinstance(elt, (ast.List, ast.Tuple)) and len(elt.elts) == 2:
652
688
  src = elt.elts[0].value if isinstance(elt.elts[0], ast.Constant) else None
653
689
  dst = elt.elts[1].value if isinstance(elt.elts[1], ast.Constant) else None
654
690
  if src and dst:
655
691
  dirs.append((src, dst))
656
692
  kwargs[kw.arg] = tuple(dirs)
657
- elif kw.arg == "secret_names" and isinstance(kw.value, ast.List | ast.Tuple):
693
+ elif kw.arg == "secret_names" and isinstance(kw.value, (ast.List, ast.Tuple)):
658
694
  # Handle secret_names list/tuple
659
695
  secrets = []
660
696
  for elt in kw.value.elts:
661
697
  if isinstance(elt, ast.Constant):
662
698
  secrets.append(elt.value)
663
699
  kwargs[kw.arg] = tuple(secrets)
664
- elif kw.arg == "volume_mounts" and isinstance(kw.value, ast.List | ast.Tuple):
700
+ elif kw.arg == "volume_mounts" and isinstance(kw.value, (ast.List, ast.Tuple)):
665
701
  # Handle volume_mounts list/tuple of tuples
666
702
  mounts = []
667
703
  for elt in kw.value.elts:
668
- if isinstance(elt, ast.List | ast.Tuple) and len(elt.elts) == 2:
704
+ if isinstance(elt, (ast.List, ast.Tuple)) and len(elt.elts) == 2:
669
705
  name = elt.elts[0].value if isinstance(elt.elts[0], ast.Constant) else None
670
706
  mount = elt.elts[1].value if isinstance(elt.elts[1], ast.Constant) else None
671
707
  if name and mount:
672
708
  mounts.append((name, mount))
673
709
  kwargs[kw.arg] = tuple(mounts)
674
710
 
675
- # Create ModalDeploymentConfig with extracted arguments
676
- from synth_ai.task.apps import ModalDeploymentConfig
677
-
678
711
  return ModalDeploymentConfig(**kwargs)
679
712
  except Exception:
680
713
  return None
@@ -713,7 +746,7 @@ def _prompt_user_for_choice(choices: list[AppChoice]) -> AppChoice:
713
746
  click.echo(_format_choice(choice, idx))
714
747
  try:
715
748
  response = click.prompt("Enter choice", default="1", type=str).strip() or "1"
716
- except (click.exceptions.Abort, EOFError, KeyboardInterrupt) as exc:
749
+ except (Abort, EOFError, KeyboardInterrupt) as exc:
717
750
  raise click.ClickException("Task app selection cancelled by user") from exc
718
751
  if not response.isdigit():
719
752
  raise click.ClickException("Selection must be a number")
@@ -745,6 +778,9 @@ def _select_app_choice(app_id: str | None, purpose: str) -> AppChoice:
745
778
  if not matches:
746
779
  available = ", ".join(sorted({c.app_id for c in filtered}))
747
780
  raise click.ClickException(f"Task app '{app_id}' not found. Available: {available}")
781
+ exact_matches = [c for c in matches if c.app_id == app_id]
782
+ if len(exact_matches) == 1:
783
+ return exact_matches[0]
748
784
  if len(matches) == 1:
749
785
  return matches[0]
750
786
  # Prefer entries with modal support when required
@@ -798,7 +834,7 @@ def _import_task_app_module(
798
834
 
799
835
  def _load_entry_from_path(
800
836
  path: Path, app_id: str, module_search_roots: Sequence[Path] | None = None
801
- ) -> TaskAppEntry:
837
+ ) -> TaskAppEntryType:
802
838
  resolved = path.resolve()
803
839
  search_roots: list[Path] = []
804
840
  seen_roots: set[Path] = set()
@@ -849,8 +885,8 @@ def _load_entry_from_path(
849
885
  detail = last_error or exc
850
886
  raise click.ClickException(f"Failed to import {resolved}: {detail}") from detail
851
887
 
852
- config_obj: TaskAppConfig | None = None
853
- factory_callable: Callable[[], TaskAppConfig] | None = None
888
+ config_obj: TaskAppConfigType | None = None
889
+ factory_callable: Callable[[], TaskAppConfigType] | None = None
854
890
 
855
891
  for attr_name in dir(module):
856
892
  try:
@@ -860,7 +896,7 @@ def _load_entry_from_path(
860
896
  if isinstance(attr, TaskAppConfig) and attr.app_id == app_id:
861
897
  config_obj = attr
862
898
 
863
- def _return_config(cfg: TaskAppConfig = attr) -> TaskAppConfig:
899
+ def _return_config(cfg: TaskAppConfigType = attr) -> TaskAppConfigType:
864
900
  return cfg
865
901
 
866
902
  factory_callable = _return_config
@@ -900,8 +936,8 @@ def _load_entry_from_path(
900
936
  bound_func: Callable[[], TaskAppConfig] = cast(Callable[[], TaskAppConfig], attr) # type: ignore[assignment]
901
937
 
902
938
  def _factory_noargs(
903
- func: Callable[[], TaskAppConfig] = bound_func,
904
- ) -> TaskAppConfig:
939
+ func: Callable[[], TaskAppConfigType] = bound_func,
940
+ ) -> TaskAppConfigType:
905
941
  return func()
906
942
 
907
943
  factory_callable = _factory_noargs
@@ -919,7 +955,7 @@ def _load_entry_from_path(
919
955
  f"Could not locate TaskAppConfig for '{app_id}' in {resolved}."
920
956
  ) from exc
921
957
 
922
- modal_cfg: ModalDeploymentConfig | None = None
958
+ modal_cfg: ModalDeploymentConfigType | None = None
923
959
  for attr_name in dir(module):
924
960
  try:
925
961
  attr = getattr(module, attr_name)
@@ -1008,7 +1044,6 @@ def _modal_command_prefix(modal_cli: str) -> list[str]:
1008
1044
  def _build_modal_app_wrapper(original_script: Path) -> tuple[Path, Path]:
1009
1045
  source_dir = original_script.parent.resolve()
1010
1046
  repo_root = REPO_ROOT
1011
- synth_src = (repo_root / "synth_ai").resolve()
1012
1047
  temp_root = Path(tempfile.mkdtemp(prefix="synth_modal_app_"))
1013
1048
 
1014
1049
  wrapper_source = textwrap.dedent(
@@ -1103,7 +1138,7 @@ def _run_modal_script(
1103
1138
  try:
1104
1139
  _preflight_env_key(env_paths_list, crash_on_failure=True)
1105
1140
  except Exception as _pf_err:
1106
- raise click.ClickException(str(_pf_err))
1141
+ raise click.ClickException(str(_pf_err)) from _pf_err
1107
1142
 
1108
1143
  proc_env = os.environ.copy()
1109
1144
  pythonpath_entries: list[str] = []
@@ -1172,10 +1207,8 @@ def _run_modal_script(
1172
1207
  finally:
1173
1208
  if wrapper_info is not None:
1174
1209
  wrapper_path, temp_root = wrapper_info
1175
- try:
1210
+ with contextlib.suppress(Exception):
1176
1211
  wrapper_path.unlink(missing_ok=True)
1177
- except Exception:
1178
- pass
1179
1212
  shutil.rmtree(temp_root, ignore_errors=True)
1180
1213
 
1181
1214
 
@@ -1201,10 +1234,12 @@ def _preflight_env_key(env_paths: Sequence[Path] | None = None, *, crash_on_fail
1201
1234
 
1202
1235
  minted = False
1203
1236
  if not env_api_key:
1237
+ secrets_module = _maybe_import("synth_ai.learning.rl.secrets")
1204
1238
  try:
1205
- from synth_ai.learning.rl.secrets import mint_environment_api_key
1206
-
1207
- env_api_key = mint_environment_api_key()
1239
+ if secrets_module is None:
1240
+ raise RuntimeError("secrets module unavailable")
1241
+ mint_env_key = secrets_module.mint_environment_api_key
1242
+ env_api_key = mint_env_key()
1208
1243
  os.environ["ENVIRONMENT_API_KEY"] = env_api_key
1209
1244
  os.environ.setdefault("DEV_ENVIRONMENT_API_KEY", env_api_key)
1210
1245
  minted = True
@@ -1347,8 +1382,8 @@ def _preflight_env_key(env_paths: Sequence[Path] | None = None, *, crash_on_fail
1347
1382
 
1348
1383
 
1349
1384
  def _run_modal_with_entry(
1350
- entry: TaskAppEntry,
1351
- modal_cfg: ModalDeploymentConfig,
1385
+ entry: TaskAppEntryType,
1386
+ modal_cfg: ModalDeploymentConfigType,
1352
1387
  modal_cli: str,
1353
1388
  modal_name: str | None,
1354
1389
  env_paths: list[Path],
@@ -1504,7 +1539,7 @@ def _interactive_fill_env(env_path: Path) -> Path | None:
1504
1539
  value = click.prompt(
1505
1540
  label, default=default, show_default=bool(default) or not required
1506
1541
  ).strip()
1507
- except (click.exceptions.Abort, EOFError, KeyboardInterrupt):
1542
+ except (Abort, EOFError, KeyboardInterrupt):
1508
1543
  click.echo("Aborted env creation.")
1509
1544
  return None
1510
1545
  if value or not required:
@@ -1545,7 +1580,7 @@ def _ensure_env_values(env_paths: list[Path], fallback_dir: Path) -> None:
1545
1580
 
1546
1581
 
1547
1582
  def _deploy_entry(
1548
- entry: TaskAppEntry,
1583
+ entry: TaskAppEntryType,
1549
1584
  modal_name: str | None,
1550
1585
  dry_run: bool,
1551
1586
  modal_cli: str,
@@ -1573,7 +1608,7 @@ def _deploy_entry(
1573
1608
 
1574
1609
 
1575
1610
  def _modal_serve_entry(
1576
- entry: TaskAppEntry,
1611
+ entry: TaskAppEntryType,
1577
1612
  modal_name: str | None,
1578
1613
  modal_cli: str,
1579
1614
  env_file: Sequence[str],
@@ -1673,20 +1708,15 @@ def serve_command(
1673
1708
  trace_dir: str | None,
1674
1709
  trace_db: str | None,
1675
1710
  ) -> None:
1676
- # Change to demo directory if stored (REQUIRED for demo isolation)
1677
- from synth_ai.demos.demo_task_apps.core import load_demo_dir
1678
-
1679
- demo_dir = load_demo_dir()
1680
- if demo_dir:
1681
- demo_path = Path(demo_dir)
1682
- if not demo_path.is_dir():
1711
+ demo_dir_path = _load_demo_directory()
1712
+ if demo_dir_path:
1713
+ if not demo_dir_path.is_dir():
1683
1714
  raise click.ClickException(
1684
- f"Demo directory not found: {demo_dir}\nRun 'synth-ai setup' to create a demo."
1715
+ f"Demo directory not found: {demo_dir_path}\nRun 'synth-ai setup' to create a demo."
1685
1716
  )
1686
- os.chdir(demo_dir)
1687
- click.echo(f"Using demo directory: {demo_dir}\n")
1688
- # Store demo directory for path resolution
1689
- os.environ["SYNTH_DEMO_DIR"] = str(demo_path.resolve())
1717
+ os.chdir(demo_dir_path)
1718
+ click.echo(f"Using demo directory: {demo_dir_path}\n")
1719
+ os.environ["SYNTH_DEMO_DIR"] = str(demo_dir_path.resolve())
1690
1720
 
1691
1721
  # Prompt for port if not provided
1692
1722
  if port is None:
@@ -1752,9 +1782,10 @@ def info_command(base_url: str | None, api_key: str | None, seeds: tuple[int, ..
1752
1782
  base = (base_url or _os.getenv("TASK_APP_BASE_URL") or "http://127.0.0.1:8001").rstrip("/")
1753
1783
 
1754
1784
  # Resolve API key, permitting dev fallbacks
1755
- try:
1756
- from synth_ai.task.auth import normalize_environment_api_key as _norm_key
1757
- except Exception:
1785
+ auth_module = _maybe_import("synth_ai.task.auth")
1786
+ if auth_module is not None:
1787
+ _norm_key = getattr(auth_module, "normalize_environment_api_key", lambda: _os.getenv("ENVIRONMENT_API_KEY"))
1788
+ else:
1758
1789
  _norm_key = lambda: _os.getenv("ENVIRONMENT_API_KEY") # noqa: E731
1759
1790
  key = (api_key or _norm_key() or "").strip()
1760
1791
  if not key:
@@ -1831,20 +1862,15 @@ def serve_task_group(
1831
1862
  trace_dir: str | None,
1832
1863
  trace_db: str | None,
1833
1864
  ) -> None:
1834
- # Change to demo directory if stored (REQUIRED for demo isolation)
1835
- from synth_ai.demos.demo_task_apps.core import load_demo_dir
1836
-
1837
- demo_dir = load_demo_dir()
1838
- if demo_dir:
1839
- demo_path = Path(demo_dir)
1840
- if not demo_path.is_dir():
1865
+ demo_dir_path = _load_demo_directory()
1866
+ if demo_dir_path:
1867
+ if not demo_dir_path.is_dir():
1841
1868
  raise click.ClickException(
1842
- f"Demo directory not found: {demo_dir}\nRun 'synth-ai setup' to create a demo."
1869
+ f"Demo directory not found: {demo_dir_path}\nRun 'synth-ai setup' to create a demo."
1843
1870
  )
1844
- os.chdir(demo_dir)
1845
- click.echo(f"Using demo directory: {demo_dir}\n")
1846
- # Store demo directory for path resolution
1847
- os.environ["SYNTH_DEMO_DIR"] = str(demo_path.resolve())
1871
+ os.chdir(demo_dir_path)
1872
+ click.echo(f"Using demo directory: {demo_dir_path}\n")
1873
+ os.environ["SYNTH_DEMO_DIR"] = str(demo_dir_path.resolve())
1848
1874
 
1849
1875
  # Prompt for port if not provided
1850
1876
  if port is None:
@@ -1881,7 +1907,7 @@ def serve_task_group(
1881
1907
  )
1882
1908
 
1883
1909
 
1884
- def _determine_env_files(entry: TaskAppEntry, user_env_files: Sequence[str]) -> list[Path]:
1910
+ def _determine_env_files(entry: TaskAppEntryType, user_env_files: Sequence[str]) -> list[Path]:
1885
1911
  resolved: list[Path] = []
1886
1912
  for candidate in user_env_files:
1887
1913
  p = Path(candidate).expanduser()
@@ -2068,17 +2094,10 @@ def _validate_required_env_keys() -> None:
2068
2094
  def _print_demo_next_steps_if_applicable() -> None:
2069
2095
  """Print next steps if currently in a demo directory."""
2070
2096
  try:
2071
- from synth_ai.demos.demo_task_apps.core import load_demo_dir
2072
-
2073
2097
  cwd = Path.cwd().resolve()
2074
- demo_dir = load_demo_dir()
2098
+ demo_dir = _load_demo_directory()
2075
2099
 
2076
- # Check if we're in the demo directory
2077
- if (
2078
- demo_dir
2079
- and Path(demo_dir).resolve() == cwd
2080
- and (cwd / "run_local_rollout_traced.py").exists()
2081
- ):
2100
+ if demo_dir and demo_dir == cwd and (cwd / "run_local_rollout_traced.py").exists():
2082
2101
  click.echo("\n" + "=" * 60)
2083
2102
  click.echo("Next step: Collect traced rollouts")
2084
2103
  click.echo("=" * 60)
@@ -2088,12 +2107,11 @@ def _print_demo_next_steps_if_applicable() -> None:
2088
2107
  click.echo("\nRun this 5-10 times to collect diverse traces.")
2089
2108
  click.echo("=" * 60 + "\n")
2090
2109
  except Exception:
2091
- # Silently fail - this is just a helpful printout
2092
2110
  pass
2093
2111
 
2094
2112
 
2095
2113
  def _serve_entry(
2096
- entry: TaskAppEntry,
2114
+ entry: TaskAppEntryType,
2097
2115
  host: str,
2098
2116
  port: int,
2099
2117
  env_file: Sequence[str],
@@ -2134,13 +2152,13 @@ def _serve_entry(
2134
2152
  os.environ["SQLD_DB_PATH"] = str(db_path)
2135
2153
  os.environ["TURSO_LOCAL_DB_URL"] = db_url
2136
2154
  click.echo(f"Tracing DB path set to {db_path}")
2137
- from synth_ai.tracing_v3.config import CONFIG as TRACE_CONFIG
2138
-
2139
- # Use the explicitly set URL if available
2140
- new_db_url = os.getenv("TURSO_LOCAL_DB_URL") or TRACE_CONFIG.db_url
2141
- TRACE_CONFIG.db_url = new_db_url
2142
- if new_db_url:
2143
- click.echo(f"Tracing DB URL resolved to {new_db_url}")
2155
+ tracing_config_module = _maybe_import("synth_ai.tracing_v3.config")
2156
+ if tracing_config_module is not None:
2157
+ trace_config = tracing_config_module.CONFIG
2158
+ new_db_url = os.getenv("TURSO_LOCAL_DB_URL") or trace_config.db_url
2159
+ trace_config.db_url = new_db_url
2160
+ if new_db_url:
2161
+ click.echo(f"Tracing DB URL resolved to {new_db_url}")
2144
2162
  elif os.getenv("TASKAPP_TRACING_ENABLED"):
2145
2163
  click.echo("Tracing enabled via environment variables")
2146
2164
 
@@ -2183,18 +2201,14 @@ def deploy_app(
2183
2201
  ) -> None:
2184
2202
  """Deploy a task app to Modal."""
2185
2203
 
2186
- # Change to demo directory if stored (for consistent discovery)
2187
- from synth_ai.demos.demo_task_apps.core import load_demo_dir
2188
-
2189
- demo_dir = load_demo_dir()
2190
- if demo_dir:
2191
- demo_path = Path(demo_dir)
2192
- if not demo_path.is_dir():
2204
+ demo_dir_path = _load_demo_directory()
2205
+ if demo_dir_path:
2206
+ if not demo_dir_path.is_dir():
2193
2207
  raise click.ClickException(
2194
- f"Demo directory not found: {demo_dir}\nRun 'synth-ai demo' to create a demo."
2208
+ f"Demo directory not found: {demo_dir_path}\nRun 'synth-ai demo' to create a demo."
2195
2209
  )
2196
- os.chdir(demo_dir)
2197
- click.echo(f"Using demo directory: {demo_dir}\n")
2210
+ os.chdir(demo_dir_path)
2211
+ click.echo(f"Using demo directory: {demo_dir_path}\n")
2198
2212
 
2199
2213
  choice = _select_app_choice(app_id, purpose="deploy")
2200
2214
 
@@ -2241,8 +2255,8 @@ def modal_serve_app(
2241
2255
 
2242
2256
 
2243
2257
  def _write_modal_entrypoint(
2244
- entry: TaskAppEntry,
2245
- modal_cfg: ModalDeploymentConfig,
2258
+ entry: TaskAppEntryType,
2259
+ modal_cfg: ModalDeploymentConfigType,
2246
2260
  override_name: str | None,
2247
2261
  *,
2248
2262
  dotenv_paths: Sequence[str] | None = None,
@@ -2291,30 +2305,25 @@ def _write_modal_entrypoint(
2291
2305
  pip_packages = list(modal_cfg.pip_packages)
2292
2306
  # Ensure synth-ai (matching host version if available) is installed in the container
2293
2307
  synth_pkg = "synth-ai"
2294
- try:
2295
- import synth_ai as _host_synth
2296
-
2297
- host_ver = getattr(_host_synth, "__version__", None)
2308
+ host_synth = _maybe_import("synth_ai")
2309
+ if host_synth is not None:
2310
+ host_ver = getattr(host_synth, "__version__", None)
2298
2311
  if host_ver:
2299
2312
  synth_pkg = f"synth-ai=={host_ver}"
2300
- except Exception:
2301
- pass
2302
2313
  if not any(str(p).startswith("synth-ai") for p in pip_packages):
2303
2314
  pip_packages.insert(0, synth_pkg)
2304
2315
 
2305
2316
  local_dirs = [(str(Path(src)), dst) for src, dst in modal_cfg.extra_local_dirs]
2306
2317
  # Also mount the host synth_ai source if available to ensure latest code is used
2307
- try:
2308
- import synth_ai as _host_synth
2309
-
2310
- host_synth_dir = Path(_host_synth.__file__).resolve().parent
2311
- # host_synth_dir points to .../synth_ai; mount that directory
2312
- sy_dst = "/opt/synth_ai_repo/synth_ai"
2313
- candidate = (str(host_synth_dir), sy_dst)
2314
- if candidate not in local_dirs:
2315
- local_dirs.insert(0, candidate)
2316
- except Exception:
2317
- pass
2318
+ if host_synth is not None:
2319
+ try:
2320
+ host_synth_dir = Path(host_synth.__file__).resolve().parent
2321
+ sy_dst = "/opt/synth_ai_repo/synth_ai"
2322
+ candidate = (str(host_synth_dir), sy_dst)
2323
+ if candidate not in local_dirs:
2324
+ local_dirs.insert(0, candidate)
2325
+ except Exception:
2326
+ pass
2318
2327
  # Ensure the discovered app directory is mounted, regardless of modal_cfg
2319
2328
  if original_path:
2320
2329
  discovered_dir = str(Path(original_path).resolve().parent)
@@ -2544,7 +2553,7 @@ def eval_command(
2544
2553
  elif isinstance(ef, list):
2545
2554
  env_file = tuple(str(x) for x in ef) # type: ignore[assignment]
2546
2555
 
2547
- entry: TaskAppEntry | None = None
2556
+ entry: TaskAppEntryType | None = None
2548
2557
  if task_app_url is None:
2549
2558
  choice = _select_app_choice(app_id, purpose="eval")
2550
2559
  entry = choice.ensure_entry()
synth_ai/cli/traces.py CHANGED
@@ -11,6 +11,8 @@ from rich import box
11
11
  from rich.console import Console
12
12
  from rich.table import Table
13
13
 
14
+ from ._storage import load_storage
15
+
14
16
 
15
17
  def register(cli):
16
18
  @cli.command()
@@ -26,8 +28,6 @@ def register(cli):
26
28
  console = Console()
27
29
 
28
30
  async def _run():
29
- from synth_ai.tracing_v3.storage.factory import StorageConfig, create_storage
30
-
31
31
  # Discover DBs under ./synth_ai.db/dbs (or override via env)
32
32
  root = os.getenv("SYNTH_TRACES_ROOT", "./synth_ai.db/dbs")
33
33
  if not os.path.isdir(root):
@@ -58,7 +58,8 @@ def register(cli):
58
58
 
59
59
  async def db_counts(db_dir: str) -> tuple[int, dict[str, int], int, str | None, int]:
60
60
  data_file = os.path.join(db_dir, "data")
61
- mgr = create_storage(StorageConfig(connection_string=f"sqlite+aiosqlite:///{data_file}"))
61
+ create_storage, storage_config = load_storage()
62
+ mgr = create_storage(storage_config(connection_string=f"sqlite+aiosqlite:///{data_file}"))
62
63
  await mgr.initialize()
63
64
  try:
64
65
  traces_df = await mgr.query_traces("SELECT COUNT(*) AS c FROM session_traces")
synth_ai/cli/watch.py CHANGED
@@ -16,11 +16,12 @@ from rich.console import Console, Group
16
16
  from rich.panel import Panel
17
17
  from rich.table import Table
18
18
 
19
- from synth_ai.tracing_v3.storage.factory import StorageConfig, create_storage
19
+ from ._storage import load_storage
20
20
 
21
21
 
22
22
  def _open_db(db_url: str):
23
- return create_storage(StorageConfig(connection_string=db_url))
23
+ create_storage, storage_config = load_storage()
24
+ return create_storage(storage_config(connection_string=db_url))
24
25
 
25
26
 
26
27
  class _State: