synth-ai 0.2.17__py3-none-any.whl → 0.2.19__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 (169) hide show
  1. examples/baseline/banking77_baseline.py +204 -0
  2. examples/baseline/crafter_baseline.py +407 -0
  3. examples/baseline/pokemon_red_baseline.py +326 -0
  4. examples/baseline/simple_baseline.py +56 -0
  5. examples/baseline/warming_up_to_rl_baseline.py +239 -0
  6. examples/blog_posts/gepa/README.md +355 -0
  7. examples/blog_posts/gepa/configs/banking77_gepa_local.toml +95 -0
  8. examples/blog_posts/gepa/configs/banking77_gepa_test.toml +82 -0
  9. examples/blog_posts/gepa/configs/banking77_mipro_local.toml +52 -0
  10. examples/blog_posts/gepa/configs/hotpotqa_gepa_local.toml +59 -0
  11. examples/blog_posts/gepa/configs/hotpotqa_gepa_qwen.toml +36 -0
  12. examples/blog_posts/gepa/configs/hotpotqa_mipro_local.toml +53 -0
  13. examples/blog_posts/gepa/configs/hover_gepa_local.toml +59 -0
  14. examples/blog_posts/gepa/configs/hover_gepa_qwen.toml +36 -0
  15. examples/blog_posts/gepa/configs/hover_mipro_local.toml +53 -0
  16. examples/blog_posts/gepa/configs/ifbench_gepa_local.toml +59 -0
  17. examples/blog_posts/gepa/configs/ifbench_gepa_qwen.toml +36 -0
  18. examples/blog_posts/gepa/configs/ifbench_mipro_local.toml +53 -0
  19. examples/blog_posts/gepa/configs/pupa_gepa_local.toml +60 -0
  20. examples/blog_posts/gepa/configs/pupa_mipro_local.toml +54 -0
  21. examples/blog_posts/gepa/deploy_banking77_task_app.sh +41 -0
  22. examples/blog_posts/gepa/gepa_baseline.py +204 -0
  23. examples/blog_posts/gepa/query_prompts_example.py +97 -0
  24. examples/blog_posts/gepa/run_gepa_banking77.sh +87 -0
  25. examples/blog_posts/gepa/task_apps.py +105 -0
  26. examples/blog_posts/gepa/test_gepa_local.sh +67 -0
  27. examples/blog_posts/gepa/verify_banking77_setup.sh +123 -0
  28. examples/blog_posts/pokemon_vl/configs/eval_gpt5nano.toml +26 -0
  29. examples/blog_posts/pokemon_vl/configs/eval_qwen3_vl.toml +12 -10
  30. examples/blog_posts/pokemon_vl/configs/train_rl_from_sft.toml +1 -0
  31. examples/blog_posts/pokemon_vl/extract_images.py +239 -0
  32. examples/blog_posts/pokemon_vl/pokemon_vl_baseline.py +326 -0
  33. examples/blog_posts/pokemon_vl/run_eval_extract_images.py +209 -0
  34. examples/blog_posts/pokemon_vl/run_qwen_eval_extract_images.py +212 -0
  35. examples/blog_posts/pokemon_vl/text_box_analysis.md +106 -0
  36. examples/blog_posts/warming_up_to_rl/ARCHITECTURE.md +195 -0
  37. examples/blog_posts/warming_up_to_rl/FINAL_TEST_RESULTS.md +127 -0
  38. examples/blog_posts/warming_up_to_rl/INFERENCE_SUCCESS.md +132 -0
  39. examples/blog_posts/warming_up_to_rl/SMOKE_TESTING.md +164 -0
  40. examples/blog_posts/warming_up_to_rl/SMOKE_TEST_COMPLETE.md +253 -0
  41. examples/blog_posts/warming_up_to_rl/configs/eval_baseline_qwen32b_10x20.toml +25 -0
  42. examples/blog_posts/warming_up_to_rl/configs/eval_ft_qwen4b_10x20.toml +26 -0
  43. examples/blog_posts/warming_up_to_rl/configs/filter_high_reward_dataset.toml +1 -1
  44. examples/blog_posts/warming_up_to_rl/configs/smoke_test.toml +75 -0
  45. examples/blog_posts/warming_up_to_rl/configs/train_rl_from_sft.toml +60 -10
  46. examples/blog_posts/warming_up_to_rl/configs/train_sft_qwen4b.toml +1 -1
  47. examples/blog_posts/warming_up_to_rl/warming_up_to_rl_baseline.py +187 -0
  48. examples/multi_step/configs/VERILOG_REWARDS.md +4 -0
  49. examples/multi_step/configs/VERILOG_RL_CHECKLIST.md +4 -0
  50. examples/multi_step/configs/crafter_rl_outcome.toml +1 -0
  51. examples/multi_step/configs/crafter_rl_stepwise_shaped.toml +1 -0
  52. examples/multi_step/configs/crafter_rl_stepwise_simple.toml +1 -0
  53. examples/rl/configs/rl_from_base_qwen17.toml +1 -0
  54. examples/swe/task_app/hosted/inference/openai_client.py +0 -34
  55. examples/swe/task_app/hosted/policy_routes.py +17 -0
  56. examples/swe/task_app/hosted/rollout.py +4 -2
  57. examples/task_apps/banking77/__init__.py +6 -0
  58. examples/task_apps/banking77/banking77_task_app.py +841 -0
  59. examples/task_apps/banking77/deploy_wrapper.py +46 -0
  60. examples/task_apps/crafter/CREATE_SFT_DATASET.md +4 -0
  61. examples/task_apps/crafter/FILTER_COMMAND_STATUS.md +4 -0
  62. examples/task_apps/crafter/FILTER_COMMAND_SUCCESS.md +4 -0
  63. examples/task_apps/crafter/task_app/grpo_crafter.py +24 -2
  64. examples/task_apps/crafter/task_app/synth_envs_hosted/hosted_app.py +49 -0
  65. examples/task_apps/crafter/task_app/synth_envs_hosted/inference/openai_client.py +355 -58
  66. examples/task_apps/crafter/task_app/synth_envs_hosted/policy_routes.py +68 -7
  67. examples/task_apps/crafter/task_app/synth_envs_hosted/rollout.py +78 -21
  68. examples/task_apps/crafter/task_app/synth_envs_hosted/utils.py +194 -1
  69. examples/task_apps/gepa_benchmarks/__init__.py +7 -0
  70. examples/task_apps/gepa_benchmarks/common.py +260 -0
  71. examples/task_apps/gepa_benchmarks/hotpotqa_task_app.py +507 -0
  72. examples/task_apps/gepa_benchmarks/hover_task_app.py +436 -0
  73. examples/task_apps/gepa_benchmarks/ifbench_task_app.py +563 -0
  74. examples/task_apps/gepa_benchmarks/pupa_task_app.py +460 -0
  75. examples/task_apps/pokemon_red/README_IMAGE_ONLY_EVAL.md +4 -0
  76. examples/task_apps/pokemon_red/task_app.py +254 -36
  77. examples/warming_up_to_rl/configs/rl_from_base_qwen4b.toml +1 -0
  78. examples/warming_up_to_rl/task_app/grpo_crafter.py +53 -4
  79. examples/warming_up_to_rl/task_app/synth_envs_hosted/hosted_app.py +49 -0
  80. examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/openai_client.py +152 -41
  81. examples/warming_up_to_rl/task_app/synth_envs_hosted/policy_routes.py +31 -1
  82. examples/warming_up_to_rl/task_app/synth_envs_hosted/rollout.py +33 -3
  83. examples/warming_up_to_rl/task_app/synth_envs_hosted/utils.py +67 -0
  84. examples/workflows/math_rl/configs/rl_from_base_qwen17.toml +1 -0
  85. synth_ai/api/train/builders.py +90 -1
  86. synth_ai/api/train/cli.py +396 -21
  87. synth_ai/api/train/config_finder.py +13 -2
  88. synth_ai/api/train/configs/__init__.py +15 -1
  89. synth_ai/api/train/configs/prompt_learning.py +442 -0
  90. synth_ai/api/train/configs/rl.py +29 -0
  91. synth_ai/api/train/task_app.py +1 -1
  92. synth_ai/api/train/validators.py +277 -0
  93. synth_ai/baseline/__init__.py +25 -0
  94. synth_ai/baseline/config.py +209 -0
  95. synth_ai/baseline/discovery.py +214 -0
  96. synth_ai/baseline/execution.py +146 -0
  97. synth_ai/cli/__init__.py +85 -17
  98. synth_ai/cli/__main__.py +0 -0
  99. synth_ai/cli/claude.py +70 -0
  100. synth_ai/cli/codex.py +84 -0
  101. synth_ai/cli/commands/__init__.py +1 -0
  102. synth_ai/cli/commands/baseline/__init__.py +12 -0
  103. synth_ai/cli/commands/baseline/core.py +637 -0
  104. synth_ai/cli/commands/baseline/list.py +93 -0
  105. synth_ai/cli/commands/eval/core.py +13 -10
  106. synth_ai/cli/commands/filter/core.py +53 -17
  107. synth_ai/cli/commands/help/core.py +0 -1
  108. synth_ai/cli/commands/smoke/__init__.py +7 -0
  109. synth_ai/cli/commands/smoke/core.py +1436 -0
  110. synth_ai/cli/commands/status/subcommands/pricing.py +22 -0
  111. synth_ai/cli/commands/status/subcommands/usage.py +203 -0
  112. synth_ai/cli/commands/train/judge_schemas.py +1 -0
  113. synth_ai/cli/commands/train/judge_validation.py +1 -0
  114. synth_ai/cli/commands/train/validation.py +0 -57
  115. synth_ai/cli/demo.py +35 -3
  116. synth_ai/cli/deploy/__init__.py +40 -25
  117. synth_ai/cli/deploy.py +162 -0
  118. synth_ai/cli/legacy_root_backup.py +14 -8
  119. synth_ai/cli/opencode.py +107 -0
  120. synth_ai/cli/root.py +9 -5
  121. synth_ai/cli/task_app_deploy.py +1 -1
  122. synth_ai/cli/task_apps.py +53 -53
  123. synth_ai/environments/examples/crafter_classic/engine_deterministic_patch.py +7 -4
  124. synth_ai/environments/examples/crafter_classic/engine_serialization_patch_v3.py +9 -5
  125. synth_ai/environments/examples/crafter_classic/world_config_patch_simple.py +4 -3
  126. synth_ai/judge_schemas.py +1 -0
  127. synth_ai/learning/__init__.py +10 -0
  128. synth_ai/learning/prompt_learning_client.py +276 -0
  129. synth_ai/learning/prompt_learning_types.py +184 -0
  130. synth_ai/pricing/__init__.py +2 -0
  131. synth_ai/pricing/model_pricing.py +57 -0
  132. synth_ai/streaming/handlers.py +53 -4
  133. synth_ai/streaming/streamer.py +19 -0
  134. synth_ai/task/apps/__init__.py +1 -0
  135. synth_ai/task/config.py +2 -0
  136. synth_ai/task/tracing_utils.py +25 -25
  137. synth_ai/task/validators.py +44 -8
  138. synth_ai/task_app_cfgs.py +21 -0
  139. synth_ai/tracing_v3/config.py +162 -19
  140. synth_ai/tracing_v3/constants.py +1 -1
  141. synth_ai/tracing_v3/db_config.py +24 -38
  142. synth_ai/tracing_v3/storage/config.py +47 -13
  143. synth_ai/tracing_v3/storage/factory.py +3 -3
  144. synth_ai/tracing_v3/turso/daemon.py +113 -11
  145. synth_ai/tracing_v3/turso/native_manager.py +92 -16
  146. synth_ai/types.py +8 -0
  147. synth_ai/urls.py +11 -0
  148. synth_ai/utils/__init__.py +30 -1
  149. synth_ai/utils/agents.py +74 -0
  150. synth_ai/utils/bin.py +39 -0
  151. synth_ai/utils/cli.py +149 -5
  152. synth_ai/utils/env.py +17 -17
  153. synth_ai/utils/json.py +72 -0
  154. synth_ai/utils/modal.py +283 -1
  155. synth_ai/utils/paths.py +48 -0
  156. synth_ai/utils/uvicorn.py +113 -0
  157. {synth_ai-0.2.17.dist-info → synth_ai-0.2.19.dist-info}/METADATA +102 -4
  158. {synth_ai-0.2.17.dist-info → synth_ai-0.2.19.dist-info}/RECORD +162 -88
  159. synth_ai/cli/commands/deploy/__init__.py +0 -23
  160. synth_ai/cli/commands/deploy/core.py +0 -614
  161. synth_ai/cli/commands/deploy/errors.py +0 -72
  162. synth_ai/cli/commands/deploy/validation.py +0 -11
  163. synth_ai/cli/deploy/core.py +0 -5
  164. synth_ai/cli/deploy/errors.py +0 -23
  165. synth_ai/cli/deploy/validation.py +0 -5
  166. {synth_ai-0.2.17.dist-info → synth_ai-0.2.19.dist-info}/WHEEL +0 -0
  167. {synth_ai-0.2.17.dist-info → synth_ai-0.2.19.dist-info}/entry_points.txt +0 -0
  168. {synth_ai-0.2.17.dist-info → synth_ai-0.2.19.dist-info}/licenses/LICENSE +0 -0
  169. {synth_ai-0.2.17.dist-info → synth_ai-0.2.19.dist-info}/top_level.txt +0 -0
@@ -1,8 +1,11 @@
1
1
  """sqld daemon management utilities."""
2
2
 
3
+ import logging
4
+ import os
3
5
  import pathlib
4
6
  import shutil
5
7
  import subprocess
8
+ import sys
6
9
  import time
7
10
 
8
11
  import requests
@@ -10,6 +13,8 @@ from requests import RequestException
10
13
 
11
14
  from ..config import CONFIG
12
15
 
16
+ logger = logging.getLogger(__name__)
17
+
13
18
 
14
19
  class SqldDaemon:
15
20
  """Manages local sqld daemon lifecycle."""
@@ -18,28 +23,101 @@ class SqldDaemon:
18
23
  self,
19
24
  db_path: str | None = None,
20
25
  http_port: int | None = None,
26
+ hrana_port: int | None = None,
21
27
  binary_path: str | None = None,
22
28
  ):
23
29
  """Initialize sqld daemon manager.
24
30
 
25
31
  Args:
26
32
  db_path: Path to database file (uses config default if not provided)
27
- http_port: HTTP port for daemon (uses config default if not provided)
33
+ http_port: HTTP port for health/API (uses config default + 1 if not provided)
34
+ hrana_port: Hrana WebSocket port for libsql connections (uses config default if not provided)
28
35
  binary_path: Path to sqld binary (auto-detected if not provided)
29
36
  """
30
37
  self.db_path = db_path or CONFIG.sqld_db_path
31
- self.http_port = http_port or CONFIG.sqld_http_port
38
+ self.hrana_port = hrana_port or CONFIG.sqld_http_port # Main port for libsql://
39
+ self.http_port = http_port or (self.hrana_port + 1) # HTTP API on next port
32
40
  self.binary_path = binary_path or self._find_binary()
33
41
  self.process: subprocess.Popen[str] | None = None
34
42
 
35
43
  def _find_binary(self) -> str:
36
- """Find sqld binary in PATH."""
44
+ """Find sqld binary in PATH, auto-installing if needed.
45
+
46
+ Search order:
47
+ 1. CONFIG.sqld_binary in PATH
48
+ 2. libsql-server in PATH
49
+ 3. Common install locations (~/.turso/bin, /usr/local/bin, etc.)
50
+ 4. Auto-install via synth_ai.utils.sqld (if interactive terminal)
51
+
52
+ Returns:
53
+ Path to sqld binary
54
+
55
+ Raises:
56
+ RuntimeError: If binary not found and auto-install fails/disabled
57
+ """
58
+ # Check PATH first
37
59
  binary = shutil.which(CONFIG.sqld_binary) or shutil.which("libsql-server")
38
- if not binary:
39
- raise RuntimeError(
40
- "sqld binary not found in PATH. Install with: brew install turso-tech/tools/sqld"
41
- )
42
- return binary
60
+ if binary:
61
+ logger.debug(f"Found sqld binary in PATH: {binary}")
62
+ return binary
63
+
64
+ # Check common install locations
65
+ try:
66
+ from synth_ai.utils.sqld import find_sqld_binary
67
+ binary = find_sqld_binary()
68
+ if binary:
69
+ logger.debug(f"Found sqld binary in common location: {binary}")
70
+ return binary
71
+ except ImportError:
72
+ logger.debug("synth_ai.utils.sqld not available, skipping common location check")
73
+
74
+ # Try auto-install if enabled and interactive
75
+ auto_install_enabled = os.getenv("SYNTH_AI_AUTO_INSTALL_SQLD", "true").lower() == "true"
76
+
77
+ if auto_install_enabled and sys.stdin.isatty():
78
+ try:
79
+ from synth_ai.utils.sqld import install_sqld
80
+ logger.info("sqld binary not found. Attempting automatic installation...")
81
+
82
+ # Use click if available for better UX, otherwise proceed automatically
83
+ try:
84
+ import click
85
+ if not click.confirm(
86
+ "sqld not found. Install automatically via Homebrew?",
87
+ default=True
88
+ ):
89
+ raise RuntimeError("User declined automatic installation")
90
+ except ImportError:
91
+ # click not available, auto-install without prompt
92
+ logger.info("Installing sqld automatically (non-interactive mode)")
93
+
94
+ binary = install_sqld()
95
+ logger.info(f"Successfully installed sqld to: {binary}")
96
+ return binary
97
+
98
+ except Exception as exc:
99
+ logger.warning(f"Auto-install failed: {exc}")
100
+ # Fall through to error message below
101
+ elif not auto_install_enabled:
102
+ logger.debug("Auto-install disabled via SYNTH_AI_AUTO_INSTALL_SQLD=false")
103
+ elif not sys.stdin.isatty():
104
+ logger.debug("Non-interactive terminal, skipping auto-install prompt")
105
+
106
+ # If we get here, all methods failed
107
+ raise RuntimeError(
108
+ "sqld binary not found. Install using one of these methods:\n"
109
+ "\n"
110
+ "Quick install (recommended):\n"
111
+ " synth-ai turso\n"
112
+ "\n"
113
+ "Manual install:\n"
114
+ " brew install turso-tech/tools/sqld\n"
115
+ " # or\n"
116
+ " curl -sSfL https://get.tur.so/install.sh | bash && turso dev\n"
117
+ "\n"
118
+ "For CI/CD environments:\n"
119
+ " Set SYNTH_AI_AUTO_INSTALL_SQLD=false and pre-install sqld"
120
+ )
43
121
 
44
122
  def start(self, wait_for_ready: bool = True) -> subprocess.Popen:
45
123
  """Start the sqld daemon."""
@@ -53,6 +131,8 @@ class SqldDaemon:
53
131
  self.binary_path,
54
132
  "--db-path",
55
133
  str(db_file),
134
+ "--hrana-listen-addr",
135
+ f"127.0.0.1:{self.hrana_port}",
56
136
  "--http-listen-addr",
57
137
  f"127.0.0.1:{self.http_port}",
58
138
  ]
@@ -112,6 +192,14 @@ class SqldDaemon:
112
192
  """Check if daemon is running."""
113
193
  return self.process is not None and self.process.poll() is None
114
194
 
195
+ def get_hrana_port(self) -> int:
196
+ """Get the Hrana WebSocket port for libsql:// connections."""
197
+ return self.hrana_port
198
+
199
+ def get_http_port(self) -> int:
200
+ """Get the HTTP API port for health checks."""
201
+ return self.http_port
202
+
115
203
  def __enter__(self):
116
204
  """Context manager entry."""
117
205
  self.start()
@@ -126,13 +214,27 @@ class SqldDaemon:
126
214
  _daemon: SqldDaemon | None = None
127
215
 
128
216
 
129
- def start_sqld(db_path: str | None = None, port: int | None = None) -> SqldDaemon:
130
- """Start a global sqld daemon instance."""
217
+ def start_sqld(
218
+ db_path: str | None = None,
219
+ port: int | None = None,
220
+ hrana_port: int | None = None,
221
+ http_port: int | None = None,
222
+ ) -> SqldDaemon:
223
+ """Start a global sqld daemon instance.
224
+
225
+ Args:
226
+ db_path: Path to database file
227
+ port: Legacy parameter - used as hrana_port if hrana_port not specified
228
+ hrana_port: Hrana WebSocket port for libsql:// connections
229
+ http_port: HTTP API port for health checks
230
+ """
131
231
  global _daemon
132
232
  if _daemon and _daemon.is_running():
133
233
  return _daemon
134
234
 
135
- _daemon = SqldDaemon(db_path=db_path, http_port=port)
235
+ # Support legacy 'port' parameter by using it as hrana_port
236
+ final_hrana_port = hrana_port or port
237
+ _daemon = SqldDaemon(db_path=db_path, hrana_port=final_hrana_port, http_port=http_port)
136
238
  _daemon.start()
137
239
  return _daemon
138
240
 
@@ -14,8 +14,11 @@ import re
14
14
  from collections.abc import Callable
15
15
  from dataclasses import asdict, dataclass
16
16
  from datetime import UTC, datetime
17
+ from pathlib import Path
17
18
  from typing import TYPE_CHECKING, Any, cast
19
+ from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
18
20
 
21
+ import httpx
19
22
  import libsql
20
23
  from sqlalchemy.engine import make_url
21
24
 
@@ -60,36 +63,70 @@ class _ConnectionTarget:
60
63
  auth_token: str | None = None
61
64
 
62
65
 
66
+ def _strip_auth_component(url: str) -> tuple[str, str | None]:
67
+ """Remove auth_token query parameter from URL, returning the token separately."""
68
+ parsed = urlparse(url)
69
+ if not parsed.query:
70
+ return url, None
71
+
72
+ params = dict(parse_qsl(parsed.query, keep_blank_values=True))
73
+ token = params.pop("auth_token", None)
74
+ query = urlencode(params, doseq=True)
75
+ sanitised = urlunparse(parsed._replace(query=query))
76
+ return sanitised, token
77
+
78
+
63
79
  def _resolve_connection_target(db_url: str | None, auth_token: str | None) -> _ConnectionTarget:
64
80
  """Normalise the configured database URL."""
65
81
  url = db_url or CONFIG.db_url
66
-
67
- # Fast-path local SQLite URLs (`sqlite+aiosqlite:///path/to/db`)
68
- if url.startswith("sqlite+aiosqlite:///"):
69
- return _ConnectionTarget(database=url.replace("sqlite+aiosqlite:///", ""), auth_token=auth_token)
82
+ sanitised, token_from_url = _strip_auth_component(url)
83
+ effective_token = auth_token or token_from_url or CONFIG.auth_token
70
84
 
71
85
  # SQLAlchemy-compatible libsql scheme (`sqlite+libsql://<endpoint or path>`)
72
- if url.startswith("sqlite+libsql://"):
73
- target = url.replace("sqlite+libsql://", "", 1)
74
- return _ConnectionTarget(database=target, sync_url=target if target.startswith("libsql://") else None, auth_token=auth_token)
86
+ if sanitised.startswith("sqlite+libsql://"):
87
+ raise RuntimeError("sqlite+libsql scheme is no longer supported; use libsql://")
88
+
89
+ # Plain SQLite files: file://, /absolute/path, or relative path
90
+ # libsql.connect() handles these without sync_url or auth_token
91
+ if sanitised.startswith("file://") or sanitised.startswith("/") or "://" not in sanitised:
92
+ # Strip file:// prefix if present, libsql.connect handles both formats
93
+ db_path = sanitised.replace("file://", "") if sanitised.startswith("file://") else sanitised
94
+ return _ConnectionTarget(database=db_path, sync_url=None, auth_token=None)
75
95
 
76
96
  # Native libsql URLs (`libsql://...`).
77
- if url.startswith("libsql://"):
78
- return _ConnectionTarget(database=url, sync_url=url, auth_token=auth_token)
97
+ if sanitised.startswith("libsql://"):
98
+ return _ConnectionTarget(database=sanitised, sync_url=sanitised, auth_token=effective_token)
79
99
 
80
100
  # Fallback to SQLAlchemy URL parsing for anything else we missed.
81
101
  try:
82
- parsed = make_url(url)
83
- if parsed.drivername.startswith("sqlite") and parsed.database:
84
- return _ConnectionTarget(database=parsed.database, auth_token=auth_token)
85
- if parsed.drivername.startswith("libsql"):
102
+ parsed = make_url(sanitised)
103
+ driver = parsed.drivername.lower()
104
+ if driver.startswith("sqlite"):
105
+ database = parsed.database or ""
106
+ if database and database not in {":memory:", ":memory"}:
107
+ # Absolute paths are passed through; relative paths are resolved to cwd
108
+ if database.startswith("/"):
109
+ db_path = database
110
+ else:
111
+ db_path = str(Path(database).expanduser().resolve())
112
+ elif database in {":memory:", ":memory"}:
113
+ db_path = ":memory:"
114
+ else:
115
+ raise RuntimeError("SQLite URL missing database path.")
116
+ return _ConnectionTarget(database=db_path, sync_url=None, auth_token=None)
117
+ if driver.startswith("libsql"):
86
118
  database = parsed.render_as_string(hide_password=False)
87
- return _ConnectionTarget(database=database, sync_url=database, auth_token=auth_token)
119
+ return _ConnectionTarget(database=database, sync_url=database, auth_token=effective_token)
88
120
  except Exception: # pragma: no cover - defensive guardrail
89
121
  logger.debug("Unable to parse db_url via SQLAlchemy", exc_info=True)
90
122
 
91
- # As a last resort use the raw value (libsql.connect can handle absolute paths).
92
- return _ConnectionTarget(database=url, auth_token=auth_token)
123
+ # Python libsql client uses HTTP API for http:// URLs, not Hrana WebSocket
124
+ # For local sqld with http:// URL, we need to ensure it points to the HTTP API port
125
+ # sqld uses two ports: Hrana WebSocket (e.g. 8080) and HTTP API (e.g. 8081)
126
+ # libsql.connect() with http:// uses HTTP API, so URL should point to HTTP API port
127
+ if sanitised.startswith(("http://", "https://", "libsql://")):
128
+ return _ConnectionTarget(database=sanitised, sync_url=sanitised, auth_token=effective_token)
129
+ raise RuntimeError(f"Unsupported tracing database URL: {sanitised}")
93
130
 
94
131
 
95
132
  def _json_dumps(value: Any) -> str | None:
@@ -350,6 +387,45 @@ class NativeLibsqlTraceManager(TraceStorage):
350
387
  if self._initialized:
351
388
  return
352
389
 
390
+ # Fast-fail preflight: if using remote endpoint or local sqld, check health
391
+ # Skip health check for plain SQLite files (sync_url is None)
392
+ if self._target.sync_url:
393
+ try:
394
+ parsed = urlparse(self._target.database or "")
395
+ # Check for local sqld: http://, https://, or libsql://
396
+ if parsed.scheme in ("http", "https", "libsql"):
397
+ host_port = parsed.netloc or ""
398
+ host = (host_port.split(":", 1)[0] or "").strip().lower()
399
+ if host in {"127.0.0.1", "localhost"} and host_port:
400
+ # For http:// URLs, the port should already be the HTTP API port
401
+ # For libsql:// URLs, we need to calculate health check port
402
+ if ":" in host_port:
403
+ port = int(host_port.split(":", 1)[1])
404
+ if parsed.scheme == "libsql":
405
+ # libsql:// uses Hrana port, health check is on HTTP API port (Hrana + 1)
406
+ health_url = f"http://{host}:{port + 1}/health"
407
+ else:
408
+ # http:// already points to HTTP API port
409
+ health_url = f"http://{host}:{port}/health"
410
+ else:
411
+ health_url = f"http://{host_port}/health"
412
+ try:
413
+ async with httpx.AsyncClient(timeout=httpx.Timeout(1.0)) as client:
414
+ resp = await client.get(health_url)
415
+ if resp.status_code != 200:
416
+ raise RuntimeError(
417
+ f"Tracing backend unhealthy at {health_url} (status={resp.status_code})"
418
+ )
419
+ except Exception as exc: # pragma: no cover - network env dependent
420
+ raise RuntimeError(
421
+ f"Tracing backend not reachable at {health_url}. "
422
+ f"Start sqld with both ports: sqld --db-path <path> --hrana-listen-addr {host}:HRANA_PORT --http-listen-addr {host}:HTTP_PORT "
423
+ f"or disable tracing (TASKAPP_TRACING_ENABLED=0)."
424
+ ) from exc
425
+ except Exception:
426
+ # Propagate any preflight failure to abort early
427
+ raise
428
+
353
429
  # Establish a libsql connection for future native operations.
354
430
  self._conn = self._open_connection()
355
431
  self._ensure_schema()
synth_ai/types.py ADDED
@@ -0,0 +1,8 @@
1
+ import typing
2
+ from typing import Literal
3
+
4
+ ModelName = Literal[
5
+ "synth-small",
6
+ "synth-medium"
7
+ ]
8
+ MODEL_NAMES = list(typing.get_args(ModelName))
synth_ai/urls.py ADDED
@@ -0,0 +1,11 @@
1
+ # Base URL for all backends
2
+ BACKEND_URL_BASE = "https://agent-learning.onrender.com"
3
+
4
+ # Synth Research API base (supports OpenAI, Anthropic, and custom formats)
5
+ # Real routes: /api/synth-research/chat/completions, /api/synth-research/messages
6
+ # V1 routes: /api/synth-research/v1/chat/completions, /api/synth-research/v1/messages
7
+ BACKEND_URL_SYNTH_RESEARCH_BASE = BACKEND_URL_BASE + "/api/synth-research"
8
+
9
+ # Provider-specific URLs (for SDKs that expect standard paths)
10
+ BACKEND_URL_SYNTH_RESEARCH_OPENAI = BACKEND_URL_SYNTH_RESEARCH_BASE + "/v1" # For OpenAI SDKs (appends /chat/completions)
11
+ BACKEND_URL_SYNTH_RESEARCH_ANTHROPIC = BACKEND_URL_SYNTH_RESEARCH_BASE # For Anthropic SDKs (appends /v1/messages)
@@ -1,8 +1,18 @@
1
1
  from . import task_app_state
2
+ from .agents import write_agents_md
2
3
  from .base_url import PROD_BASE_URL_DEFAULT, get_backend_from_env, get_learning_v2_base_url
3
- from .cli import PromptedChoiceOption, PromptedChoiceType, print_next_step
4
+ from .bin import install_bin, verify_bin
5
+ from .cli import (
6
+ PromptedChoiceOption,
7
+ PromptedChoiceType,
8
+ PromptedPathOption,
9
+ print_next_step,
10
+ prompt_choice,
11
+ prompt_for_path,
12
+ )
4
13
  from .env import mask_str, resolve_env_var, write_env_var_to_dotenv, write_env_var_to_json
5
14
  from .http import AsyncHttpClient, HTTPError, http_request
15
+ from .json import create_and_write_json, load_json_to_dict, strip_json_comments
6
16
  from .modal import (
7
17
  ensure_modal_installed,
8
18
  ensure_task_app_ready,
@@ -11,6 +21,12 @@ from .modal import (
11
21
  is_modal_public_url,
12
22
  normalize_endpoint_url,
13
23
  )
24
+ from .paths import (
25
+ find_bin_path,
26
+ find_config_path,
27
+ get_env_file_paths,
28
+ get_home_config_file_paths,
29
+ )
14
30
  from .process import ensure_local_port_available, popen_capture, popen_stream, popen_stream_capture
15
31
  from .sqld import SQLD_VERSION, find_sqld_binary, install_sqld
16
32
  from .task_app_discovery import AppChoice, discover_eval_config_paths, select_app_choice
@@ -50,8 +66,11 @@ __all__ = [
50
66
  "PROD_BASE_URL_DEFAULT",
51
67
  "PromptedChoiceOption",
52
68
  "PromptedChoiceType",
69
+ "PromptedPathOption",
70
+ "prompt_for_path",
53
71
  "SQLD_VERSION",
54
72
  "USER_CONFIG_PATH",
73
+ "create_and_write_json",
55
74
  "current_task_app_id",
56
75
  "discover_eval_config_paths",
57
76
  "ensure_env_credentials",
@@ -60,14 +79,20 @@ __all__ = [
60
79
  "ensure_port_free",
61
80
  "ensure_task_app_ready",
62
81
  "find_asgi_apps",
82
+ "find_bin_path",
83
+ "find_config_path",
63
84
  "find_sqld_binary",
64
85
  "get_backend_from_env",
86
+ "get_env_file_paths",
87
+ "get_home_config_file_paths",
65
88
  "get_learning_v2_base_url",
66
89
  "http_request",
90
+ "install_bin",
67
91
  "install_sqld",
68
92
  "is_local_demo_url",
69
93
  "is_modal_public_url",
70
94
  "load_demo_dir",
95
+ "load_json_to_dict",
71
96
  "load_template_id",
72
97
  "load_user_config",
73
98
  "load_user_env",
@@ -84,17 +109,21 @@ __all__ = [
84
109
  "popen_stream_capture",
85
110
  "preflight_env_key",
86
111
  "print_next_step",
112
+ "prompt_choice",
87
113
  "read_task_app_config",
88
114
  "record_task_app",
89
115
  "resolve_env_var",
90
116
  "resolve_task_app_entry",
91
117
  "save_user_config",
92
118
  "select_app_choice",
119
+ "strip_json_comments",
93
120
  "task_app_config_path",
94
121
  "task_app_id_from_path",
95
122
  "task_app_state",
96
123
  "update_task_app_entry",
97
124
  "update_user_config",
125
+ "verify_bin",
126
+ 'write_agents_md',
98
127
  "write_env_var_to_dotenv",
99
128
  "write_env_var_to_json",
100
129
  "write_task_app_config",
@@ -0,0 +1,74 @@
1
+ from pathlib import Path
2
+
3
+ AGENTS_TEXT = """
4
+ sinf
5
+ """
6
+ SYNTH_DIV_START = "### --- SYNTH SECTION START ---"
7
+ SYNTH_DIV_END = "### ---- SYNTH SECTION END ----"
8
+
9
+
10
+ def _render_block() -> str:
11
+ return f"{SYNTH_DIV_START}\n{AGENTS_TEXT}\n{SYNTH_DIV_END}"
12
+
13
+
14
+ def _append_block(prefix: str) -> str:
15
+ prefix = prefix.rstrip()
16
+ block = _render_block()
17
+ if prefix:
18
+ return f"{prefix}\n\n{block}\n"
19
+ return f"{block}\n"
20
+
21
+
22
+ def write_agents_md() -> None:
23
+ path = Path.cwd() / "AGENTS.md"
24
+ if not path.exists():
25
+ path.write_text(_append_block(""), encoding="utf-8")
26
+ return
27
+
28
+ file_text = path.read_text(encoding="utf-8")
29
+
30
+ # Remove orphan end markers first (end markers without a preceding start marker)
31
+ cleaned = file_text
32
+ end_pos = cleaned.find(SYNTH_DIV_END)
33
+ start_pos = cleaned.find(SYNTH_DIV_START)
34
+
35
+ # If there's an end marker before any start marker
36
+ if end_pos != -1 and (start_pos == -1 or end_pos < start_pos):
37
+ if start_pos == -1:
38
+ # No start markers at all - remove everything including content before orphan
39
+ cleaned = cleaned[end_pos + len(SYNTH_DIV_END):].lstrip()
40
+ else:
41
+ # There are start markers after the orphan - preserve content before orphan
42
+ before_orphan = cleaned[:end_pos].rstrip()
43
+ after_orphan = cleaned[end_pos + len(SYNTH_DIV_END):].lstrip()
44
+ cleaned = "\n\n".join(filter(None, [before_orphan, after_orphan]))
45
+
46
+ # Find the first start and last end marker to consolidate multiple sections
47
+ first_start = cleaned.find(SYNTH_DIV_START)
48
+ last_end = cleaned.rfind(SYNTH_DIV_END)
49
+
50
+ if first_start != -1 and last_end != -1 and last_end > first_start:
51
+ # We have at least one valid section, consolidate all into one
52
+ before = cleaned[:first_start].rstrip()
53
+ after = cleaned[last_end + len(SYNTH_DIV_END):].lstrip()
54
+
55
+ parts: list[str] = []
56
+ if before:
57
+ parts.append(before)
58
+ parts.append(_render_block())
59
+ if after:
60
+ parts.append(after)
61
+
62
+ new_text = "\n\n".join(parts)
63
+ if not new_text.endswith("\n"):
64
+ new_text += "\n"
65
+ path.write_text(new_text, encoding="utf-8")
66
+ return
67
+
68
+ # No valid sections found, remove any remaining orphan markers
69
+ cleaned = cleaned.replace(SYNTH_DIV_END, "")
70
+ cleaned = cleaned.replace(AGENTS_TEXT, "")
71
+ cleaned = cleaned.strip()
72
+ if cleaned:
73
+ cleaned += "\n\n"
74
+ path.write_text(f"{cleaned}{_render_block()}\n", encoding="utf-8")
synth_ai/utils/bin.py ADDED
@@ -0,0 +1,39 @@
1
+ import shlex
2
+ import subprocess
3
+ from pathlib import Path
4
+
5
+ from .cli import prompt_choice
6
+
7
+
8
+ def install_bin(name: str, install_options: list[str]) -> bool:
9
+ cmd = prompt_choice(
10
+ f"How would you like to install {name}?",
11
+ install_options
12
+ )
13
+ div_start = f"{'-' * 29} INSTALL START {'-' * 29}"
14
+ div_end = f"{'-' * 30} INSTALL END {'-' * 30}"
15
+ try:
16
+ print(f"Installing {name} via `{cmd}`")
17
+ print('\n' + div_start)
18
+ subprocess.run(shlex.split(cmd), check=True)
19
+ print(div_end + '\n')
20
+ return True
21
+ except subprocess.CalledProcessError as e:
22
+ print(f"Failed to install {name}: {e}")
23
+ print(div_end + '\n')
24
+ return False
25
+
26
+
27
+ def verify_bin(bin_path: Path) -> bool:
28
+ try:
29
+ result = subprocess.run(
30
+ [str(bin_path), "--version"],
31
+ capture_output=True,
32
+ text=True,
33
+ timeout=3,
34
+ check=False
35
+ )
36
+ return result.returncode == 0
37
+ except (OSError, subprocess.SubprocessError) as e:
38
+ print(e)
39
+ return False