synthiaresearch 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ node_modules/
2
+ .env*
3
+ !.env.example
4
+ .vercel/
5
+ __pycache__/
6
+ .venv/
7
+ .next/
8
+ dist/
9
+ .DS_Store
10
+ .vercel
11
+ *.tsbuildinfo
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: synthiaresearch
3
+ Version: 0.0.1
4
+ Summary: Python SDK for the Synthia API
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: httpx>=0.27
@@ -0,0 +1,33 @@
1
+ # synthia
2
+
3
+ Python SDK for the Synthia API.
4
+
5
+ ```python
6
+ from synthia import Synthia
7
+
8
+ client = Synthia(api_key="sk_...")
9
+
10
+ # 1. Probe your agent to infer an ideal user model.
11
+ # The agent runs locally; any `(probe: str) -> str` callable works.
12
+ user_model = client.user_models.create_from_probe(my_agent, max_turns=10)
13
+
14
+ # 2. Generate synthetic data from it.
15
+ job = client.datasets.generate(user_model, count=1000)
16
+ dataset = job.wait()
17
+
18
+ # 3. Play the scenarios against your agent, then quality-check the rollouts:
19
+ # a server-side parallel job analyzes each rollout's agentic states and
20
+ # judges whether the agent passed each scenario.
21
+ results = client.rollouts.run(my_rollout_agent, dataset)
22
+ evaluations = client.rollouts.quality_check(results).wait().rollouts()
23
+ ```
24
+
25
+ ## Layout
26
+
27
+ - `src/synthia/client.py` — the whole SDK: the `Synthia` client, resource classes (seeds, user models, datasets), and the local probe loop with tool-call tracing.
28
+ - `src/synthia/__init__.py` — public exports.
29
+ - `pyproject.toml` — the `synthia` package definition (httpx as the sole runtime dependency).
30
+ - `uv.lock` — uv lockfile.
31
+
32
+ For an end-to-end demo against a real tool-using agent, see
33
+ `examples/turo-support-agent/` at the repo root.
@@ -0,0 +1,15 @@
1
+ [project]
2
+ name = "synthiaresearch"
3
+ version = "0.0.1"
4
+ description = "Python SDK for the Synthia API"
5
+ requires-python = ">=3.10"
6
+ dependencies = [
7
+ "httpx>=0.27",
8
+ ]
9
+
10
+ [build-system]
11
+ requires = ["hatchling"]
12
+ build-backend = "hatchling.build"
13
+
14
+ [tool.hatch.build.targets.wheel]
15
+ packages = ["src/synthia"]
@@ -0,0 +1,17 @@
1
+ from .client import (
2
+ AgentReply,
3
+ Dataset,
4
+ GenerationJob,
5
+ PrepareResult,
6
+ QualityCheck,
7
+ RolloutResult,
8
+ Synthia,
9
+ ToolCall,
10
+ ToolSandbox,
11
+ UserModel,
12
+ ValidationRun,
13
+ )
14
+
15
+ __all__ = ["Synthia", "UserModel", "GenerationJob", "Dataset", "AgentReply",
16
+ "ToolCall", "ValidationRun", "QualityCheck", "RolloutResult",
17
+ "ToolSandbox", "PrepareResult"]
@@ -0,0 +1,637 @@
1
+ import hashlib
2
+ import json
3
+ import os
4
+ import sys
5
+ import time
6
+ import uuid
7
+ from concurrent.futures import ThreadPoolExecutor
8
+ from dataclasses import asdict, dataclass, field
9
+ from importlib import metadata
10
+ from pathlib import Path
11
+ from typing import Callable, Union
12
+
13
+ import httpx
14
+
15
+ DEFAULT_BASE_URL = "https://synthia-research--synthia-api-web.modal.run"
16
+
17
+ # Launcher argv[0] values that don't identify a script (notebooks, REPLs).
18
+ _INTERACTIVE_STEMS = {"", "-c", "-m", "ipykernel_launcher", "pydevconsole"}
19
+
20
+
21
+ def _default_session_name() -> str:
22
+ """Stable session name derived from the entry-point script.
23
+
24
+ Last two path components only ("project/script"): readable, survives
25
+ moving the project, and never leaks the full directory layout. Falls
26
+ back to the working directory's name for notebooks and REPLs.
27
+ """
28
+ argv0 = sys.argv[0] if sys.argv else ""
29
+ if not argv0 or Path(argv0).stem in _INTERACTIVE_STEMS:
30
+ return Path.cwd().resolve().name
31
+ p = Path(argv0).resolve()
32
+ return f"{p.parent.name}/{p.stem}"
33
+
34
+
35
+ def _sdk_version() -> str | None:
36
+ try:
37
+ return metadata.version("synthia")
38
+ except metadata.PackageNotFoundError:
39
+ return None
40
+
41
+
42
+ @dataclass
43
+ class ToolCall:
44
+ """One traced tool invocation made by the agent while answering a probe."""
45
+ name: str
46
+ input: dict
47
+ output: dict | None = None
48
+ is_error: bool = False
49
+
50
+
51
+ @dataclass
52
+ class AgentReply:
53
+ """An agent's reply plus the tool calls it made producing it."""
54
+ reply: str
55
+ tool_calls: list[ToolCall] = field(default_factory=list)
56
+
57
+
58
+ # An agent takes a probe question and returns its reply — a plain string, or
59
+ # an AgentReply carrying traced tool calls.
60
+ Agent = Callable[[str], Union[str, AgentReply]]
61
+
62
+
63
+ class ToolSandbox:
64
+ """Local replica of the server's deterministic tool sandbox.
65
+
66
+ Must stay hash-identical to synthia_api.pipeline.rollout.ToolSandbox:
67
+ outputs are a pure function of (tool name, input, state version, seed),
68
+ which is how the server reproduces the same tool behavior from the
69
+ events the SDK reports. Tool inputs must be JSON-native values that
70
+ round-trip exactly (e.g. tuples arrive as lists, 1 and 1.0 differ).
71
+ """
72
+
73
+ def __init__(self, seed: int, fail_tools: set[str] | None = None,
74
+ state: dict | None = None):
75
+ self.seed = seed
76
+ self.fail_tools = fail_tools or set()
77
+ self.state = dict(state or {})
78
+ self.events: list[dict] = []
79
+
80
+ @classmethod
81
+ def from_config(cls, config: dict) -> "ToolSandbox":
82
+ return cls(seed=config["seed"], fail_tools=set(config["fail_tools"]),
83
+ state=config["state"])
84
+
85
+ def call(self, name: str, tool_input: dict) -> dict:
86
+ is_error = name in self.fail_tools
87
+ if is_error:
88
+ output = {"error": f"{name} is unavailable"}
89
+ else:
90
+ digest = hashlib.sha256(
91
+ json.dumps([name, tool_input, self.state.get("version", 0),
92
+ self.seed], sort_keys=True).encode()
93
+ ).hexdigest()[:8]
94
+ output = {"ok": True, "result_id": digest}
95
+ self.state["version"] = self.state.get("version", 0) + 1
96
+ self.state[f"last_{name}"] = digest
97
+ self.events.append({"name": name, "input": tool_input,
98
+ "output": output, "is_error": is_error})
99
+ return output
100
+
101
+
102
+ # A rollout agent takes the conversation transcript ([{role, content}]) and a
103
+ # ToolSandbox for its tool calls, and returns its reply text. It is invoked
104
+ # from worker threads when rollouts run concurrently, so it must be
105
+ # thread-safe.
106
+ RolloutAgent = Callable[[list, ToolSandbox], str]
107
+
108
+
109
+ class _EventStream:
110
+ """Cursor-based poller that prints a run's server-side telemetry."""
111
+
112
+ def __init__(self, http: httpx.Client, path: str, enabled: bool):
113
+ self._http = http
114
+ self._path = path
115
+ self._after = 0
116
+ self._enabled = enabled
117
+
118
+ def pump(self) -> None:
119
+ """Fetch and print events newer than the cursor."""
120
+ if not self._enabled:
121
+ return
122
+ r = self._http.get(self._path, params={"after": self._after})
123
+ r.raise_for_status()
124
+ for event in r.json()["data"]:
125
+ self._after = event["seq"]
126
+ detail = " ".join(f"{k}={v}" for k, v in event["data"].items())
127
+ print(f" ~ [{event['stage']}] {event['message']}"
128
+ + (f" ({detail})" if detail else ""))
129
+
130
+
131
+ @dataclass
132
+ class UserModel:
133
+ id: str
134
+ probe_session_id: str
135
+ persona: str
136
+ traits: list[str]
137
+ representation_id: str | None
138
+
139
+
140
+ @dataclass
141
+ class ValidationRun:
142
+ """A validation of a dataset: a per-scenario judge gate plus
143
+ collective fidelity/diversity reports and an advisory verdict."""
144
+ id: str
145
+ dataset_id: str
146
+ status: str
147
+ verdict: str | None
148
+ reference: dict | None
149
+ validity: dict | None
150
+ fidelity: dict | None
151
+ diversity: dict | None
152
+ error: str | None
153
+ _http: httpx.Client
154
+
155
+ def wait(self, poll_interval: float = 2.0, timeout: float = 1800.0,
156
+ verbose: bool = False) -> "ValidationRun":
157
+ """Poll until validation finishes; verbose prints server telemetry."""
158
+ events = _EventStream(
159
+ self._http, f"/v1/validation-runs/{self.id}/events", verbose)
160
+ deadline = time.monotonic() + timeout
161
+ while self.status == "running":
162
+ if time.monotonic() > deadline:
163
+ raise TimeoutError(
164
+ f"validation run {self.id} still running after {timeout}s")
165
+ time.sleep(poll_interval)
166
+ r = self._http.get(f"/v1/validation-runs/{self.id}")
167
+ r.raise_for_status()
168
+ data = r.json()
169
+ for key in ("status", "verdict", "reference", "validity",
170
+ "fidelity", "diversity", "error"):
171
+ setattr(self, key, data[key])
172
+ events.pump()
173
+ events.pump() # catch events written after the final status poll
174
+ if self.status != "succeeded":
175
+ raise RuntimeError(f"validation run {self.id} failed: {self.error}")
176
+ return self
177
+
178
+ def scenarios(self) -> list[dict]:
179
+ """Per-scenario judge verdicts: scenario_id, passed, judge."""
180
+ r = self._http.get(f"/v1/validation-runs/{self.id}/scenarios")
181
+ r.raise_for_status()
182
+ return r.json()["data"]
183
+
184
+
185
+ @dataclass
186
+ class Dataset:
187
+ id: str
188
+ generation_id: str
189
+ user_model_id: str
190
+ row_count: int
191
+ _http: httpx.Client
192
+
193
+ def download(self) -> list[dict]:
194
+ r = self._http.get(f"/v1/datasets/{self.id}/rows")
195
+ r.raise_for_status()
196
+ return r.json()["data"]
197
+
198
+ def validate(self) -> ValidationRun:
199
+ """Start an async validation run over this dataset."""
200
+ r = self._http.post(f"/v1/datasets/{self.id}/validations")
201
+ r.raise_for_status()
202
+ return ValidationRun(**r.json(), _http=self._http)
203
+
204
+ def rollout(self, agent: "RolloutAgent", **kwargs) -> "list[RolloutResult]":
205
+ """Play this dataset's scenarios against a local agent."""
206
+ return Rollouts(self._http).run(agent, dataset=self, **kwargs)
207
+
208
+
209
+ @dataclass
210
+ class GenerationJob:
211
+ id: str
212
+ status: str
213
+ user_model_id: str
214
+ count: int
215
+ dataset_id: str | None
216
+ error: str | None
217
+ _http: httpx.Client
218
+
219
+ def wait(self, poll_interval: float = 2.0, timeout: float = 1800.0,
220
+ verbose: bool = False) -> Dataset:
221
+ """Poll until the job finishes; verbose prints server telemetry live."""
222
+ events = _EventStream(
223
+ self._http, f"/v1/generations/{self.id}/events", verbose)
224
+ deadline = time.monotonic() + timeout
225
+ while self.status == "running":
226
+ if time.monotonic() > deadline:
227
+ raise TimeoutError(f"generation {self.id} still running after {timeout}s")
228
+ time.sleep(poll_interval)
229
+ r = self._http.get(f"/v1/generations/{self.id}")
230
+ r.raise_for_status()
231
+ data = r.json()
232
+ self.status = data["status"]
233
+ self.dataset_id = data["dataset_id"]
234
+ self.error = data["error"]
235
+ events.pump()
236
+ events.pump() # catch events written after the final status poll
237
+ if self.status != "succeeded":
238
+ raise RuntimeError(f"generation {self.id} failed: {self.error}")
239
+ r = self._http.get(f"/v1/datasets/{self.dataset_id}")
240
+ r.raise_for_status()
241
+ return Dataset(**r.json(), _http=self._http)
242
+
243
+
244
+ class Seeds:
245
+ def __init__(self, http: httpx.Client):
246
+ self._http = http
247
+
248
+ def ingest(self, *, kind: str, source: str, content: dict,
249
+ version: str = "1", metadata: dict | None = None) -> dict:
250
+ """Upload seed material (documents, tool schemas, policies, traces...)."""
251
+ r = self._http.post("/v1/seeds", json={
252
+ "kind": kind, "source": source, "content": content,
253
+ "version": version, "metadata": metadata or {},
254
+ })
255
+ r.raise_for_status()
256
+ return r.json()
257
+
258
+
259
+ class UserModels:
260
+ def __init__(self, http: httpx.Client):
261
+ self._http = http
262
+
263
+ def create_from_probe(self, agent: Agent, max_turns: int = 10,
264
+ verbose: bool = False) -> UserModel:
265
+ """Probe `agent` until the server converges on a user model.
266
+
267
+ The agent runs locally; only probe questions, replies, and traced
268
+ tool calls travel over the wire. `verbose` prints the server's
269
+ telemetry (probe decisions, ingestion, inference) after each turn.
270
+ """
271
+ r = self._http.post("/v1/probe-sessions", json={"max_turns": max_turns})
272
+ r.raise_for_status()
273
+ session = r.json()
274
+ events = _EventStream(
275
+ self._http, f"/v1/probe-sessions/{session['id']}/events", verbose)
276
+ while session["status"] == "active":
277
+ raw = agent(session["next_probe"])
278
+ reply = raw if isinstance(raw, str) else raw.reply
279
+ tool_calls = [] if isinstance(raw, str) else [
280
+ asdict(tc) for tc in raw.tool_calls
281
+ ]
282
+ r = self._http.post(
283
+ f"/v1/probe-sessions/{session['id']}/responses",
284
+ json={"reply": reply, "tool_calls": tool_calls},
285
+ )
286
+ r.raise_for_status()
287
+ session = r.json()
288
+ events.pump()
289
+ return self.get(session["user_model_id"])
290
+
291
+ def get(self, model_id: str) -> UserModel:
292
+ r = self._http.get(f"/v1/user-models/{model_id}")
293
+ r.raise_for_status()
294
+ return UserModel(**r.json())
295
+
296
+ def list(self, session: str | None = None) -> list[UserModel]:
297
+ params = {"sdk_session": session} if session else {}
298
+ r = self._http.get("/v1/user-models", params=params)
299
+ r.raise_for_status()
300
+ return [UserModel(**m) for m in r.json()["data"]]
301
+
302
+
303
+ class Datasets:
304
+ def __init__(self, http: httpx.Client):
305
+ self._http = http
306
+
307
+ def get(self, dataset_id: str) -> Dataset:
308
+ r = self._http.get(f"/v1/datasets/{dataset_id}")
309
+ r.raise_for_status()
310
+ return Dataset(**r.json(), _http=self._http)
311
+
312
+ def list(self, session: str | None = None) -> list[Dataset]:
313
+ """Datasets newest first; `session` filters to one SDK session."""
314
+ params = {"sdk_session": session} if session else {}
315
+ r = self._http.get("/v1/datasets", params=params)
316
+ r.raise_for_status()
317
+ return [Dataset(**d, _http=self._http) for d in r.json()["data"]]
318
+
319
+ def generate(self, user_model: UserModel | str, count: int = 20, *,
320
+ quality_check_id: str | None = None) -> GenerationJob:
321
+ """Start a generation job. quality_check_id names a completed quality
322
+ check whose results calibrate the batch's difficulty and coverage."""
323
+ model_id = user_model.id if isinstance(user_model, UserModel) else user_model
324
+ body = {"user_model_id": model_id, "count": count}
325
+ if quality_check_id:
326
+ body["quality_check_id"] = quality_check_id
327
+ r = self._http.post("/v1/generations", json=body)
328
+ r.raise_for_status()
329
+ return GenerationJob(**r.json(), _http=self._http)
330
+
331
+
332
+ @dataclass
333
+ class PrepareResult:
334
+ """Outcome of Synthia.prepare(): the dataset to roll out, the user model
335
+ behind it, and how the decision was made."""
336
+ dataset: Dataset
337
+ user_model: UserModel
338
+ action: str # "generated" | "reused"
339
+ reason: str # human-readable decision trail
340
+ success_rate: float | None # latest completed check's rate, if any
341
+ quality_check_id: str | None # check that calibrated generation, if any
342
+
343
+
344
+ @dataclass
345
+ class QualityCheck:
346
+ """An async evaluation of finished rollouts: per rollout, the server
347
+ analyzes the agent's state trajectory and judges pass/fail. The
348
+ per-rollout results are the product; there is no aggregate verdict."""
349
+ id: str
350
+ status: str
351
+ rollout_ids: list[str]
352
+ error: str | None
353
+ _http: httpx.Client
354
+
355
+ def wait(self, poll_interval: float = 2.0, timeout: float = 1800.0,
356
+ verbose: bool = False) -> "QualityCheck":
357
+ """Poll until the check finishes; verbose prints server telemetry."""
358
+ events = _EventStream(
359
+ self._http, f"/v1/quality-checks/{self.id}/events", verbose)
360
+ deadline = time.monotonic() + timeout
361
+ while self.status == "running":
362
+ if time.monotonic() > deadline:
363
+ raise TimeoutError(
364
+ f"quality check {self.id} still running after {timeout}s")
365
+ time.sleep(poll_interval)
366
+ r = self._http.get(f"/v1/quality-checks/{self.id}")
367
+ r.raise_for_status()
368
+ data = r.json()
369
+ self.status = data["status"]
370
+ self.error = data["error"]
371
+ events.pump()
372
+ events.pump() # catch events written after the final status poll
373
+ if self.status != "succeeded":
374
+ raise RuntimeError(f"quality check {self.id} failed: {self.error}")
375
+ return self
376
+
377
+ def rollouts(self) -> list[dict]:
378
+ """Per-rollout results: rollout_id, passed, states (the agentic-state
379
+ trajectory), and judge (dimensions + issues)."""
380
+ r = self._http.get(f"/v1/quality-checks/{self.id}/rollouts")
381
+ r.raise_for_status()
382
+ return r.json()["data"]
383
+
384
+
385
+ @dataclass
386
+ class RolloutResult:
387
+ """One finished rollout: the conversation a scenario produced, plus every
388
+ tool call the agent made along the way (each tagged with its turn_idx)."""
389
+ rollout_id: str
390
+ scenario_id: str
391
+ status: str
392
+ turns: int
393
+ transcript: list[dict]
394
+ tool_events: list[dict]
395
+
396
+
397
+ class Rollouts:
398
+ def __init__(self, http: httpx.Client, session_id: str | None = None):
399
+ self._http = http
400
+ self._session_id = session_id
401
+
402
+ def get(self, rollout_id: str) -> dict:
403
+ """A stored rollout's full captured state: status, seed, transcript,
404
+ tool events, and sandbox."""
405
+ r = self._http.get(f"/v1/rollouts/{rollout_id}")
406
+ r.raise_for_status()
407
+ return r.json()
408
+
409
+ def run(self, agent: RolloutAgent, dataset: Union[Dataset, str, None] = None,
410
+ *, max_turns: int = 12, concurrency: int = 4) -> list[RolloutResult]:
411
+ """Play a dataset's scenarios against `agent` (most recent dataset
412
+ when none is given).
413
+
414
+ The agent runs locally: each turn it gets the transcript so far and
415
+ a deterministic ToolSandbox for its tool calls; only its reply and
416
+ tool events travel over the wire. Scenarios run on `concurrency`
417
+ worker threads (turns within one conversation are sequential), so
418
+ the agent must be thread-safe — pass concurrency=1 to opt out.
419
+ """
420
+ if dataset is None:
421
+ # Session-scoped default: this script's latest dataset, so two
422
+ # concurrent scripts never pick up each other's data.
423
+ data = []
424
+ if self._session_id:
425
+ r = self._http.get("/v1/datasets",
426
+ params={"sdk_session": self._session_id})
427
+ r.raise_for_status()
428
+ data = r.json()["data"]
429
+ if not data:
430
+ r = self._http.get("/v1/datasets")
431
+ r.raise_for_status()
432
+ data = r.json()["data"]
433
+ if data and self._session_id:
434
+ print(f"note: no dataset in this session yet; "
435
+ f"using latest dataset {data[0]['id']}")
436
+ if not data:
437
+ raise RuntimeError("no datasets exist yet; generate one first")
438
+ dataset_id = data[0]["id"]
439
+ else:
440
+ dataset_id = dataset.id if isinstance(dataset, Dataset) else dataset
441
+ r = self._http.get(f"/v1/datasets/{dataset_id}/rows")
442
+ r.raise_for_status()
443
+ rows = r.json()["data"]
444
+ with ThreadPoolExecutor(max_workers=concurrency) as pool:
445
+ return list(pool.map(
446
+ lambda row: self.run_scenario(
447
+ agent, row["scenario_id"], max_turns=max_turns),
448
+ rows))
449
+
450
+ def quality_check(
451
+ self, rollouts: "list[Union[RolloutResult, str]]") -> QualityCheck:
452
+ """Start an async quality check over finished rollouts: the server
453
+ analyzes each rollout's agentic states in parallel and judges
454
+ whether the agent passed each scenario."""
455
+ rollout_ids = [r.rollout_id if isinstance(r, RolloutResult) else r
456
+ for r in rollouts]
457
+ r = self._http.post("/v1/quality-checks",
458
+ json={"rollout_ids": rollout_ids})
459
+ r.raise_for_status()
460
+ return QualityCheck(**r.json(), _http=self._http)
461
+
462
+ def run_scenario(self, agent: RolloutAgent, scenario_id: str, *,
463
+ max_turns: int = 12,
464
+ random_seed: int | None = None) -> RolloutResult:
465
+ """Run one rollout session; one HTTP round-trip per agent turn."""
466
+ r = self._http.post("/v1/rollouts", json={
467
+ "scenario_id": scenario_id, "random_seed": random_seed,
468
+ "max_turns": max_turns,
469
+ })
470
+ r.raise_for_status()
471
+ session = r.json()
472
+ while session["status"] == "running":
473
+ sandbox = ToolSandbox.from_config(session["sandbox"])
474
+ reply = agent(session["transcript"], sandbox)
475
+ r = self._http.post(
476
+ f"/v1/rollouts/{session['id']}/turns",
477
+ json={"reply": reply, "tool_calls": sandbox.events},
478
+ )
479
+ r.raise_for_status()
480
+ session = r.json()
481
+ return RolloutResult(
482
+ rollout_id=session["id"], scenario_id=scenario_id,
483
+ status=session["status"], turns=session["turn"],
484
+ transcript=session["transcript"],
485
+ tool_events=session["tool_events"])
486
+
487
+
488
+ class Synthia:
489
+ """Client entry point.
490
+
491
+ Session identity: every client belongs to a named session — the stable,
492
+ account-scoped identity of one script, persisted across executions
493
+ (same name resumes the same session; re-runs reuse its datasets instead
494
+ of re-probing/re-generating). Resolution order: `session` arg >
495
+ SYNTHIA_SESSION env var > derived "project/script" name from the entry
496
+ point. `session=False` opts out into a fresh ephemeral session.
497
+
498
+ Degradation: an old server without /v1/sdk-sessions -> no session
499
+ (today's behavior); keyless against a keyed server -> anonymous session;
500
+ an invalid api_key fails immediately with the server's message.
501
+ """
502
+
503
+ def __init__(self, api_key: str | None = None, base_url: str | None = None,
504
+ session: Union[str, bool, None] = None):
505
+ api_key = api_key or os.environ.get("SYNTHIA_API_KEY")
506
+ base_url = base_url or os.environ.get("SYNTHIA_BASE_URL", DEFAULT_BASE_URL)
507
+ headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
508
+ # Generous timeout: probe convergence runs model inference server-side.
509
+ self._http = httpx.Client(base_url=base_url, headers=headers, timeout=300.0)
510
+ if session is False:
511
+ self.session_name = (f"{_default_session_name()}"
512
+ f"/eph-{uuid.uuid4().hex[:8]}")
513
+ elif isinstance(session, str) and session:
514
+ self.session_name = session
515
+ else:
516
+ self.session_name = (os.environ.get("SYNTHIA_SESSION")
517
+ or _default_session_name())
518
+ self.session_id: str | None = None
519
+ self.invocation_id: str | None = None
520
+ self._start_session()
521
+ self.seeds = Seeds(self._http)
522
+ self.user_models = UserModels(self._http)
523
+ self.datasets = Datasets(self._http)
524
+ self.rollouts = Rollouts(self._http, session_id=self.session_id)
525
+
526
+ def _start_session(self) -> None:
527
+ """One handshake per process: get-or-create the named session and
528
+ mint this invocation; all later requests carry both ids as headers."""
529
+ try:
530
+ r = self._http.post("/v1/sdk-sessions", json={
531
+ "name": self.session_name, "sdk_version": _sdk_version(),
532
+ })
533
+ except httpx.HTTPError:
534
+ return # transient network trouble: run without session tracking
535
+ if r.status_code == 404:
536
+ return # server predates sessions: degrade to old behavior
537
+ if r.status_code == 401:
538
+ raise RuntimeError(r.json().get("detail", "invalid API key"))
539
+ r.raise_for_status()
540
+ data = r.json()
541
+ self.session_id = data["sdk_session_id"]
542
+ self.invocation_id = data["sdk_invocation_id"]
543
+ self._http.headers["X-Synthia-Session"] = self.session_id
544
+ self._http.headers["X-Synthia-Invocation"] = self.invocation_id
545
+
546
+ def prepare(self, agent: Agent, *, count: int = 20, max_turns: int = 10,
547
+ min_success_rate: float = 0.6, max_success_rate: float = 0.9,
548
+ verbose: bool = False) -> PrepareResult:
549
+ """Probe + generate only when needed; otherwise reuse the latest dataset.
550
+
551
+ The main entry point for the probe and generation steps. `count` is
552
+ exact: the returned dataset has exactly that many rows, so reuse
553
+ requires the latest dataset to match it in addition to the quality
554
+ gate. Probing and generation run only when no dataset exists yet,
555
+ when the row count differs (generation-only — the session's probed
556
+ user model is reused), or when the latest completed quality check's
557
+ pass rate falls outside [min_success_rate, max_success_rate].
558
+ Out-of-band regeneration passes that quality check to the server,
559
+ which feeds its real results (pass rate, per-scenario outcomes,
560
+ judge issues) into scenario generation so the new batch recalibrates
561
+ difficulty and coverage.
562
+
563
+ All lookups are scoped to this client's session: re-running the
564
+ same script reuses its own dataset, and drift signals from other
565
+ scripts/sessions never trigger regeneration here.
566
+ """
567
+ existing = (self.datasets.list(session=self.session_id)
568
+ if self.session_id else self.datasets.list()) # newest first
569
+ if not existing:
570
+ return self._probe_and_generate(
571
+ agent, count=count, max_turns=max_turns, verbose=verbose,
572
+ reason=("no datasets in this session yet" if self.session_id
573
+ else "no datasets exist yet"),
574
+ success_rate=None, quality_check_id=None)
575
+
576
+ r = self._http.get(
577
+ "/v1/quality-checks/latest",
578
+ params={"sdk_session": self.session_id} if self.session_id else {})
579
+ r.raise_for_status()
580
+ latest = r.json()
581
+ rate = (latest["passed"] / latest["total"]
582
+ if latest["id"] is not None and latest["total"] > 0 else None)
583
+
584
+ if rate is not None and not (min_success_rate <= rate <= max_success_rate):
585
+ direction = ("below" if rate < min_success_rate else "above")
586
+ bound = (min_success_rate if rate < min_success_rate
587
+ else max_success_rate)
588
+ return self._probe_and_generate(
589
+ agent, count=count, max_turns=max_turns, verbose=verbose,
590
+ reason=f"success rate {rate:.0%} {direction} {bound:.0%}; "
591
+ f"regenerating calibrated on {latest['id']}",
592
+ success_rate=rate, quality_check_id=latest["id"])
593
+
594
+ # Quality is in band (or unjudged): reuse only on an exact size
595
+ # match; otherwise regenerate at the requested count — without
596
+ # re-probing, since nothing suggests the agent changed.
597
+ if existing[0].row_count != count:
598
+ return self._probe_and_generate(
599
+ agent, count=count, max_turns=max_turns, verbose=verbose,
600
+ reason=f"latest dataset has {existing[0].row_count} rows; "
601
+ f"requested {count}",
602
+ success_rate=rate, quality_check_id=None)
603
+
604
+ return PrepareResult(
605
+ dataset=existing[0],
606
+ user_model=self.user_models.get(existing[0].user_model_id),
607
+ action="reused",
608
+ reason=(f"success rate {rate:.0%} within "
609
+ f"{min_success_rate:.0%}-{max_success_rate:.0%} band"
610
+ if rate is not None else
611
+ "no completed quality check to judge by; "
612
+ "reusing latest dataset"),
613
+ success_rate=rate, quality_check_id=None)
614
+
615
+ def _probe_and_generate(self, agent: Agent, *, count: int, max_turns: int,
616
+ verbose: bool, reason: str,
617
+ success_rate: float | None,
618
+ quality_check_id: str | None) -> PrepareResult:
619
+ # Without a drift signal the agent hasn't been shown to change, so a
620
+ # user model this session already probed is still good — skip the
621
+ # probe. Drift-triggered regeneration re-probes deliberately.
622
+ user_model = None
623
+ if self.session_id and not quality_check_id:
624
+ session_models = self.user_models.list(session=self.session_id)
625
+ if session_models:
626
+ user_model = session_models[-1] # newest (list is oldest-first)
627
+ reason += "; reusing session user model (no drift signal)"
628
+ if user_model is None:
629
+ user_model = self.user_models.create_from_probe(
630
+ agent, max_turns=max_turns, verbose=verbose)
631
+ job = self.datasets.generate(user_model, count=count,
632
+ quality_check_id=quality_check_id)
633
+ dataset = job.wait(verbose=verbose)
634
+ return PrepareResult(dataset=dataset, user_model=user_model,
635
+ action="generated", reason=reason,
636
+ success_rate=success_rate,
637
+ quality_check_id=quality_check_id)
@@ -0,0 +1,104 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.10"
4
+
5
+ [[package]]
6
+ name = "anyio"
7
+ version = "4.14.1"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ dependencies = [
10
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
11
+ { name = "idna" },
12
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
13
+ ]
14
+ sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" }
15
+ wheels = [
16
+ { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
17
+ ]
18
+
19
+ [[package]]
20
+ name = "certifi"
21
+ version = "2026.6.17"
22
+ source = { registry = "https://pypi.org/simple" }
23
+ sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
24
+ wheels = [
25
+ { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
26
+ ]
27
+
28
+ [[package]]
29
+ name = "exceptiongroup"
30
+ version = "1.3.1"
31
+ source = { registry = "https://pypi.org/simple" }
32
+ dependencies = [
33
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
34
+ ]
35
+ sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
36
+ wheels = [
37
+ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
38
+ ]
39
+
40
+ [[package]]
41
+ name = "h11"
42
+ version = "0.16.0"
43
+ source = { registry = "https://pypi.org/simple" }
44
+ sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
45
+ wheels = [
46
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
47
+ ]
48
+
49
+ [[package]]
50
+ name = "httpcore"
51
+ version = "1.0.9"
52
+ source = { registry = "https://pypi.org/simple" }
53
+ dependencies = [
54
+ { name = "certifi" },
55
+ { name = "h11" },
56
+ ]
57
+ sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
58
+ wheels = [
59
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
60
+ ]
61
+
62
+ [[package]]
63
+ name = "httpx"
64
+ version = "0.28.1"
65
+ source = { registry = "https://pypi.org/simple" }
66
+ dependencies = [
67
+ { name = "anyio" },
68
+ { name = "certifi" },
69
+ { name = "httpcore" },
70
+ { name = "idna" },
71
+ ]
72
+ sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
73
+ wheels = [
74
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
75
+ ]
76
+
77
+ [[package]]
78
+ name = "idna"
79
+ version = "3.18"
80
+ source = { registry = "https://pypi.org/simple" }
81
+ sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
82
+ wheels = [
83
+ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
84
+ ]
85
+
86
+ [[package]]
87
+ name = "synthia"
88
+ version = "0.0.1"
89
+ source = { editable = "." }
90
+ dependencies = [
91
+ { name = "httpx" },
92
+ ]
93
+
94
+ [package.metadata]
95
+ requires-dist = [{ name = "httpx", specifier = ">=0.27" }]
96
+
97
+ [[package]]
98
+ name = "typing-extensions"
99
+ version = "4.16.0"
100
+ source = { registry = "https://pypi.org/simple" }
101
+ sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
102
+ wheels = [
103
+ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
104
+ ]