synthiaresearch 0.0.1

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.
package/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # synthiaresearch
2
+
3
+ JavaScript/TypeScript SDK for the Synthia API.
4
+
5
+ ```bash
6
+ npm install synthiaresearch
7
+ ```
8
+
9
+ ```ts
10
+ import { Synthia } from "synthiaresearch";
11
+
12
+ const synthia = new Synthia(); // reads SYNTHIA_API_KEY / SYNTHIA_BASE_URL
13
+
14
+ const { dataset } = await synthia.prepare(async (probe) => {
15
+ return myAgent.respond(probe);
16
+ });
17
+
18
+ const results = await dataset.rollout(async (transcript, sandbox) => {
19
+ return myAgent.respondWithTools(transcript, sandbox);
20
+ });
21
+
22
+ const check = await synthia.rollouts.qualityCheck(results);
23
+ await check.wait({ verbose: true });
24
+ ```
25
+
26
+ Requires Node 18+. Mirrors the Python SDK (`synthiaresearch` on PyPI).
@@ -0,0 +1,298 @@
1
+ export declare const DEFAULT_BASE_URL = "https://synthia-research--synthia-api-web.modal.run";
2
+ /** One traced tool invocation made by the agent while answering a probe. */
3
+ export interface ToolCall {
4
+ name: string;
5
+ input: Record<string, unknown>;
6
+ output?: Record<string, unknown> | null;
7
+ is_error?: boolean;
8
+ }
9
+ /** An agent's reply plus the tool calls it made producing it. */
10
+ export interface AgentReply {
11
+ reply: string;
12
+ tool_calls?: ToolCall[];
13
+ }
14
+ export type Agent = (probe: string) => string | AgentReply | Promise<string | AgentReply>;
15
+ export interface SandboxConfig {
16
+ seed: number;
17
+ fail_tools: string[];
18
+ state: Record<string, unknown>;
19
+ }
20
+ export interface ToolEvent {
21
+ name: string;
22
+ input: Record<string, unknown>;
23
+ output: Record<string, unknown>;
24
+ is_error: boolean;
25
+ }
26
+ /**
27
+ * Local replica of the server's deterministic tool sandbox.
28
+ *
29
+ * Must stay hash-identical to synthia_api.pipeline.rollout.ToolSandbox:
30
+ * outputs are a pure function of (tool name, input, state version, seed),
31
+ * which is how the server reproduces the same tool behavior from the
32
+ * events the SDK reports. Tool inputs must be JSON-native values that
33
+ * round-trip exactly.
34
+ */
35
+ export declare class ToolSandbox {
36
+ seed: number;
37
+ failTools: Set<string>;
38
+ state: Record<string, unknown>;
39
+ events: ToolEvent[];
40
+ constructor(seed: number, failTools?: Set<string>, state?: Record<string, unknown>);
41
+ static fromConfig(config: SandboxConfig): ToolSandbox;
42
+ call(name: string, toolInput: Record<string, unknown>): Record<string, unknown>;
43
+ }
44
+ export interface TranscriptTurn {
45
+ role: string;
46
+ content: string;
47
+ }
48
+ export type RolloutAgent = (transcript: TranscriptTurn[], sandbox: ToolSandbox) => string | Promise<string>;
49
+ declare class Http {
50
+ private baseUrl;
51
+ headers: Record<string, string>;
52
+ /** Gate awaited before every request; Synthia points it at the session
53
+ * handshake so all later requests carry the session headers. */
54
+ ready: Promise<void>;
55
+ constructor(baseUrl: string, headers: Record<string, string>);
56
+ get(path: string, params?: Record<string, string | number>): Promise<any>;
57
+ post(path: string, body?: unknown): Promise<any>;
58
+ /** Un-gated request returning the raw Response (used by the handshake). */
59
+ raw(method: string, path: string, body?: unknown, params?: Record<string, string | number>): Promise<Response>;
60
+ }
61
+ export interface UserModel {
62
+ id: string;
63
+ probe_session_id: string;
64
+ persona: string;
65
+ traits: string[];
66
+ representation_id: string | null;
67
+ }
68
+ export interface WaitOptions {
69
+ pollInterval?: number;
70
+ timeout?: number;
71
+ verbose?: boolean;
72
+ }
73
+ /**
74
+ * A validation of a dataset: a per-scenario judge gate plus collective
75
+ * fidelity/diversity reports and an advisory verdict.
76
+ */
77
+ export declare class ValidationRun {
78
+ #private;
79
+ id: string;
80
+ dataset_id: string;
81
+ status: string;
82
+ verdict: string | null;
83
+ reference: Record<string, unknown> | null;
84
+ validity: Record<string, unknown> | null;
85
+ fidelity: Record<string, unknown> | null;
86
+ diversity: Record<string, unknown> | null;
87
+ error: string | null;
88
+ constructor(data: any, http: Http);
89
+ /** Poll until validation finishes; verbose prints server telemetry. */
90
+ wait(opts?: WaitOptions): Promise<ValidationRun>;
91
+ /** Per-scenario judge verdicts: scenario_id, passed, judge. */
92
+ scenarios(): Promise<any[]>;
93
+ }
94
+ export declare class Dataset {
95
+ #private;
96
+ id: string;
97
+ generation_id: string;
98
+ user_model_id: string;
99
+ row_count: number;
100
+ constructor(data: any, http: Http);
101
+ download(): Promise<any[]>;
102
+ /** Start an async validation run over this dataset. */
103
+ validate(): Promise<ValidationRun>;
104
+ /** Play this dataset's scenarios against a local agent. */
105
+ rollout(agent: RolloutAgent, opts?: RunOptions): Promise<RolloutResult[]>;
106
+ }
107
+ export declare class GenerationJob {
108
+ #private;
109
+ id: string;
110
+ status: string;
111
+ user_model_id: string;
112
+ count: number;
113
+ dataset_id: string | null;
114
+ error: string | null;
115
+ constructor(data: any, http: Http);
116
+ /** Poll until the job finishes; verbose prints server telemetry live. */
117
+ wait(opts?: WaitOptions): Promise<Dataset>;
118
+ }
119
+ export declare class Seeds {
120
+ #private;
121
+ constructor(http: Http);
122
+ /** Upload seed material (documents, tool schemas, policies, traces...). */
123
+ ingest(opts: {
124
+ kind: string;
125
+ source: string;
126
+ content: Record<string, unknown>;
127
+ version?: string;
128
+ metadata?: Record<string, unknown>;
129
+ }): Promise<any>;
130
+ }
131
+ export declare class UserModels {
132
+ #private;
133
+ constructor(http: Http);
134
+ /**
135
+ * Probe `agent` until the server converges on a user model.
136
+ *
137
+ * The agent runs locally; only probe questions, replies, and traced
138
+ * tool calls travel over the wire. `verbose` prints the server's
139
+ * telemetry (probe decisions, ingestion, inference) after each turn.
140
+ */
141
+ createFromProbe(agent: Agent, opts?: {
142
+ maxTurns?: number;
143
+ verbose?: boolean;
144
+ }): Promise<UserModel>;
145
+ get(modelId: string): Promise<UserModel>;
146
+ list(session?: string): Promise<UserModel[]>;
147
+ }
148
+ export declare class Datasets {
149
+ #private;
150
+ constructor(http: Http);
151
+ get(datasetId: string): Promise<Dataset>;
152
+ /** Datasets newest first; `session` filters to one SDK session. */
153
+ list(session?: string): Promise<Dataset[]>;
154
+ /**
155
+ * Start a generation job. qualityCheckId names a completed quality
156
+ * check whose results calibrate the batch's difficulty and coverage.
157
+ */
158
+ generate(userModel: UserModel | string, opts?: {
159
+ count?: number;
160
+ qualityCheckId?: string;
161
+ }): Promise<GenerationJob>;
162
+ }
163
+ /**
164
+ * Outcome of Synthia.prepare(): the dataset to roll out, the user model
165
+ * behind it, and how the decision was made.
166
+ */
167
+ export interface PrepareResult {
168
+ dataset: Dataset;
169
+ userModel: UserModel;
170
+ action: "generated" | "reused";
171
+ reason: string;
172
+ successRate: number | null;
173
+ qualityCheckId: string | null;
174
+ }
175
+ /**
176
+ * An async evaluation of finished rollouts: per rollout, the server
177
+ * analyzes the agent's state trajectory and judges pass/fail. The
178
+ * per-rollout results are the product; there is no aggregate verdict.
179
+ */
180
+ export declare class QualityCheck {
181
+ #private;
182
+ id: string;
183
+ status: string;
184
+ rollout_ids: string[];
185
+ error: string | null;
186
+ constructor(data: any, http: Http);
187
+ /** Poll until the check finishes; verbose prints server telemetry. */
188
+ wait(opts?: WaitOptions): Promise<QualityCheck>;
189
+ /**
190
+ * Per-rollout results: rollout_id, passed, states (the agentic-state
191
+ * trajectory), and judge (dimensions + issues).
192
+ */
193
+ rollouts(): Promise<any[]>;
194
+ }
195
+ /**
196
+ * One finished rollout: the conversation a scenario produced, plus every
197
+ * tool call the agent made along the way (each tagged with its turn_idx).
198
+ */
199
+ export interface RolloutResult {
200
+ rollout_id: string;
201
+ scenario_id: string;
202
+ status: string;
203
+ turns: number;
204
+ transcript: TranscriptTurn[];
205
+ tool_events: any[];
206
+ }
207
+ export interface RunOptions {
208
+ maxTurns?: number;
209
+ concurrency?: number;
210
+ }
211
+ export declare class Rollouts {
212
+ #private;
213
+ constructor(http: Http, sessionId?: string | null);
214
+ /**
215
+ * A stored rollout's full captured state: status, seed, transcript,
216
+ * tool events, and sandbox.
217
+ */
218
+ get(rolloutId: string): Promise<any>;
219
+ /**
220
+ * Play a dataset's scenarios against `agent` (most recent dataset when
221
+ * none is given).
222
+ *
223
+ * The agent runs locally: each turn it gets the transcript so far and
224
+ * a deterministic ToolSandbox for its tool calls; only its reply and
225
+ * tool events travel over the wire. Scenarios run with `concurrency`
226
+ * in-flight at once (turns within one conversation are sequential) —
227
+ * pass concurrency: 1 to run them strictly one at a time.
228
+ */
229
+ run(agent: RolloutAgent, dataset?: Dataset | string | null, opts?: RunOptions): Promise<RolloutResult[]>;
230
+ /**
231
+ * Start an async quality check over finished rollouts: the server
232
+ * analyzes each rollout's agentic states in parallel and judges
233
+ * whether the agent passed each scenario.
234
+ */
235
+ qualityCheck(rollouts: (RolloutResult | string)[]): Promise<QualityCheck>;
236
+ /** Run one rollout session; one HTTP round-trip per agent turn. */
237
+ runScenario(agent: RolloutAgent, scenarioId: string, opts?: {
238
+ maxTurns?: number;
239
+ randomSeed?: number;
240
+ }): Promise<RolloutResult>;
241
+ }
242
+ export interface SynthiaOptions {
243
+ apiKey?: string;
244
+ baseUrl?: string;
245
+ session?: string | false;
246
+ }
247
+ export interface PrepareOptions {
248
+ count?: number;
249
+ maxTurns?: number;
250
+ minSuccessRate?: number;
251
+ maxSuccessRate?: number;
252
+ verbose?: boolean;
253
+ }
254
+ /**
255
+ * Client entry point.
256
+ *
257
+ * Session identity: every client belongs to a named session — the stable,
258
+ * account-scoped identity of one script, persisted across executions
259
+ * (same name resumes the same session; re-runs reuse its datasets instead
260
+ * of re-probing/re-generating). Resolution order: `session` option >
261
+ * SYNTHIA_SESSION env var > derived "project/script" name from the entry
262
+ * point. `session: false` opts out into a fresh ephemeral session.
263
+ *
264
+ * Degradation: an old server without /v1/sdk-sessions -> no session;
265
+ * keyless against a keyed server -> anonymous session; an invalid apiKey
266
+ * fails on first use with the server's message.
267
+ */
268
+ export declare class Synthia {
269
+ #private;
270
+ sessionName: string;
271
+ sessionId: string | null;
272
+ invocationId: string | null;
273
+ seeds: Seeds;
274
+ userModels: UserModels;
275
+ datasets: Datasets;
276
+ rollouts: Rollouts;
277
+ constructor(options?: SynthiaOptions);
278
+ /**
279
+ * Probe + generate only when needed; otherwise reuse the latest dataset.
280
+ *
281
+ * The main entry point for the probe and generation steps. `count` is
282
+ * exact: the returned dataset has exactly that many rows, so reuse
283
+ * requires the latest dataset to match it in addition to the quality
284
+ * gate. Probing and generation run only when no dataset exists yet,
285
+ * when the row count differs (generation-only — the session's probed
286
+ * user model is reused), or when the latest completed quality check's
287
+ * pass rate falls outside [minSuccessRate, maxSuccessRate].
288
+ * Out-of-band regeneration passes that quality check to the server,
289
+ * which feeds its real results into scenario generation so the new
290
+ * batch recalibrates difficulty and coverage.
291
+ *
292
+ * All lookups are scoped to this client's session: re-running the same
293
+ * script reuses its own dataset, and drift signals from other
294
+ * scripts/sessions never trigger regeneration here.
295
+ */
296
+ prepare(agent: Agent, opts?: PrepareOptions): Promise<PrepareResult>;
297
+ }
298
+ export {};
package/dist/client.js ADDED
@@ -0,0 +1,729 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { basename, dirname, extname, resolve } from "node:path";
3
+ export const DEFAULT_BASE_URL = "https://synthia-research--synthia-api-web.modal.run";
4
+ const SDK_VERSION = "0.0.1";
5
+ // Launcher argv[1] stems that don't identify a script (REPLs, runners).
6
+ const INTERACTIVE_STEMS = new Set(["", "node", "npx", "tsx", "ts-node"]);
7
+ /**
8
+ * Stable session name derived from the entry-point script.
9
+ *
10
+ * Last two path components only ("project/script"): readable, survives
11
+ * moving the project, and never leaks the full directory layout. Falls
12
+ * back to the working directory's name for REPLs.
13
+ */
14
+ function defaultSessionName() {
15
+ const argv1 = process.argv[1] ?? "";
16
+ const stem = argv1 ? basename(argv1, extname(argv1)) : "";
17
+ if (!argv1 || INTERACTIVE_STEMS.has(stem))
18
+ return basename(process.cwd());
19
+ const p = resolve(argv1);
20
+ return `${basename(dirname(p))}/${stem}`;
21
+ }
22
+ /**
23
+ * JSON serialization matching Python's json.dumps(v, sort_keys=True):
24
+ * sorted keys, ", "/": " separators, non-ASCII escaped. Required so
25
+ * ToolSandbox hashes agree with the server. Caveat: JS has one number
26
+ * type, so Python's 1 vs 1.0 distinction cannot be reproduced here.
27
+ */
28
+ function pyJson(v) {
29
+ if (v === null || v === undefined)
30
+ return "null";
31
+ if (typeof v === "boolean")
32
+ return v ? "true" : "false";
33
+ if (typeof v === "number")
34
+ return String(v);
35
+ if (typeof v === "string")
36
+ return pyString(v);
37
+ if (Array.isArray(v))
38
+ return "[" + v.map(pyJson).join(", ") + "]";
39
+ const obj = v;
40
+ const keys = Object.keys(obj).sort();
41
+ return ("{" + keys.map((k) => `${pyString(k)}: ${pyJson(obj[k])}`).join(", ") + "}");
42
+ }
43
+ function pyString(s) {
44
+ return JSON.stringify(s).replace(/[\u0080-\uffff]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
45
+ }
46
+ /**
47
+ * Local replica of the server's deterministic tool sandbox.
48
+ *
49
+ * Must stay hash-identical to synthia_api.pipeline.rollout.ToolSandbox:
50
+ * outputs are a pure function of (tool name, input, state version, seed),
51
+ * which is how the server reproduces the same tool behavior from the
52
+ * events the SDK reports. Tool inputs must be JSON-native values that
53
+ * round-trip exactly.
54
+ */
55
+ export class ToolSandbox {
56
+ seed;
57
+ failTools;
58
+ state;
59
+ events = [];
60
+ constructor(seed, failTools, state) {
61
+ this.seed = seed;
62
+ this.failTools = failTools ?? new Set();
63
+ this.state = { ...(state ?? {}) };
64
+ }
65
+ static fromConfig(config) {
66
+ return new ToolSandbox(config.seed, new Set(config.fail_tools), config.state);
67
+ }
68
+ call(name, toolInput) {
69
+ const isError = this.failTools.has(name);
70
+ let output;
71
+ if (isError) {
72
+ output = { error: `${name} is unavailable` };
73
+ }
74
+ else {
75
+ const version = this.state["version"] ?? 0;
76
+ const digest = createHash("sha256")
77
+ .update(pyJson([name, toolInput, version, this.seed]))
78
+ .digest("hex")
79
+ .slice(0, 8);
80
+ output = { ok: true, result_id: digest };
81
+ this.state["version"] = version + 1;
82
+ this.state[`last_${name}`] = digest;
83
+ }
84
+ this.events.push({ name, input: toolInput, output, is_error: isError });
85
+ return output;
86
+ }
87
+ }
88
+ class HttpError extends Error {
89
+ status;
90
+ body;
91
+ constructor(status, body, method, path) {
92
+ super(`${method} ${path} failed with ${status}: ${body}`);
93
+ this.status = status;
94
+ this.body = body;
95
+ }
96
+ }
97
+ class Http {
98
+ baseUrl;
99
+ headers;
100
+ /** Gate awaited before every request; Synthia points it at the session
101
+ * handshake so all later requests carry the session headers. */
102
+ ready = Promise.resolve();
103
+ constructor(baseUrl, headers) {
104
+ this.baseUrl = baseUrl;
105
+ this.headers = headers;
106
+ }
107
+ async get(path, params) {
108
+ await this.ready;
109
+ const r = await this.raw("GET", path, undefined, params);
110
+ if (!r.ok)
111
+ throw new HttpError(r.status, await r.text(), "GET", path);
112
+ return r.json();
113
+ }
114
+ async post(path, body) {
115
+ await this.ready;
116
+ const r = await this.raw("POST", path, body);
117
+ if (!r.ok)
118
+ throw new HttpError(r.status, await r.text(), "POST", path);
119
+ return r.json();
120
+ }
121
+ /** Un-gated request returning the raw Response (used by the handshake). */
122
+ async raw(method, path, body, params) {
123
+ const url = new URL(this.baseUrl + path);
124
+ for (const [k, v] of Object.entries(params ?? {})) {
125
+ url.searchParams.set(k, String(v));
126
+ }
127
+ return fetch(url, {
128
+ method,
129
+ headers: { "content-type": "application/json", ...this.headers },
130
+ body: body === undefined ? undefined : JSON.stringify(body),
131
+ // Generous timeout: probe convergence runs model inference server-side.
132
+ signal: AbortSignal.timeout(300_000),
133
+ });
134
+ }
135
+ }
136
+ const sleep = (ms) => new Promise((res) => setTimeout(res, ms));
137
+ /** Cursor-based poller that prints a run's server-side telemetry. */
138
+ class EventStream {
139
+ http;
140
+ path;
141
+ enabled;
142
+ #after = 0;
143
+ constructor(http, path, enabled) {
144
+ this.http = http;
145
+ this.path = path;
146
+ this.enabled = enabled;
147
+ }
148
+ async pump() {
149
+ if (!this.enabled)
150
+ return;
151
+ const body = await this.http.get(this.path, { after: this.#after });
152
+ for (const event of body.data) {
153
+ this.#after = event.seq;
154
+ const detail = Object.entries(event.data)
155
+ .map(([k, v]) => `${k}=${v}`)
156
+ .join(" ");
157
+ console.log(` ~ [${event.stage}] ${event.message}` +
158
+ (detail ? ` (${detail})` : ""));
159
+ }
160
+ }
161
+ }
162
+ /**
163
+ * A validation of a dataset: a per-scenario judge gate plus collective
164
+ * fidelity/diversity reports and an advisory verdict.
165
+ */
166
+ export class ValidationRun {
167
+ id;
168
+ dataset_id;
169
+ status;
170
+ verdict;
171
+ reference;
172
+ validity;
173
+ fidelity;
174
+ diversity;
175
+ error;
176
+ #http;
177
+ constructor(data, http) {
178
+ this.id = data.id;
179
+ this.dataset_id = data.dataset_id;
180
+ this.status = data.status;
181
+ this.verdict = data.verdict;
182
+ this.reference = data.reference;
183
+ this.validity = data.validity;
184
+ this.fidelity = data.fidelity;
185
+ this.diversity = data.diversity;
186
+ this.error = data.error;
187
+ this.#http = http;
188
+ }
189
+ /** Poll until validation finishes; verbose prints server telemetry. */
190
+ async wait(opts = {}) {
191
+ const { pollInterval = 2, timeout = 1800, verbose = false } = opts;
192
+ const events = new EventStream(this.#http, `/v1/validation-runs/${this.id}/events`, verbose);
193
+ const deadline = Date.now() + timeout * 1000;
194
+ while (this.status === "running") {
195
+ if (Date.now() > deadline) {
196
+ throw new Error(`validation run ${this.id} still running after ${timeout}s`);
197
+ }
198
+ await sleep(pollInterval * 1000);
199
+ const data = await this.#http.get(`/v1/validation-runs/${this.id}`);
200
+ this.status = data.status;
201
+ this.verdict = data.verdict;
202
+ this.reference = data.reference;
203
+ this.validity = data.validity;
204
+ this.fidelity = data.fidelity;
205
+ this.diversity = data.diversity;
206
+ this.error = data.error;
207
+ await events.pump();
208
+ }
209
+ await events.pump(); // catch events written after the final status poll
210
+ if (this.status !== "succeeded") {
211
+ throw new Error(`validation run ${this.id} failed: ${this.error}`);
212
+ }
213
+ return this;
214
+ }
215
+ /** Per-scenario judge verdicts: scenario_id, passed, judge. */
216
+ async scenarios() {
217
+ const body = await this.#http.get(`/v1/validation-runs/${this.id}/scenarios`);
218
+ return body.data;
219
+ }
220
+ }
221
+ export class Dataset {
222
+ id;
223
+ generation_id;
224
+ user_model_id;
225
+ row_count;
226
+ #http;
227
+ constructor(data, http) {
228
+ this.id = data.id;
229
+ this.generation_id = data.generation_id;
230
+ this.user_model_id = data.user_model_id;
231
+ this.row_count = data.row_count;
232
+ this.#http = http;
233
+ }
234
+ async download() {
235
+ const body = await this.#http.get(`/v1/datasets/${this.id}/rows`);
236
+ return body.data;
237
+ }
238
+ /** Start an async validation run over this dataset. */
239
+ async validate() {
240
+ const data = await this.#http.post(`/v1/datasets/${this.id}/validations`);
241
+ return new ValidationRun(data, this.#http);
242
+ }
243
+ /** Play this dataset's scenarios against a local agent. */
244
+ async rollout(agent, opts = {}) {
245
+ return new Rollouts(this.#http).run(agent, this, opts);
246
+ }
247
+ }
248
+ export class GenerationJob {
249
+ id;
250
+ status;
251
+ user_model_id;
252
+ count;
253
+ dataset_id;
254
+ error;
255
+ #http;
256
+ constructor(data, http) {
257
+ this.id = data.id;
258
+ this.status = data.status;
259
+ this.user_model_id = data.user_model_id;
260
+ this.count = data.count;
261
+ this.dataset_id = data.dataset_id;
262
+ this.error = data.error;
263
+ this.#http = http;
264
+ }
265
+ /** Poll until the job finishes; verbose prints server telemetry live. */
266
+ async wait(opts = {}) {
267
+ const { pollInterval = 2, timeout = 1800, verbose = false } = opts;
268
+ const events = new EventStream(this.#http, `/v1/generations/${this.id}/events`, verbose);
269
+ const deadline = Date.now() + timeout * 1000;
270
+ while (this.status === "running") {
271
+ if (Date.now() > deadline) {
272
+ throw new Error(`generation ${this.id} still running after ${timeout}s`);
273
+ }
274
+ await sleep(pollInterval * 1000);
275
+ const data = await this.#http.get(`/v1/generations/${this.id}`);
276
+ this.status = data.status;
277
+ this.dataset_id = data.dataset_id;
278
+ this.error = data.error;
279
+ await events.pump();
280
+ }
281
+ await events.pump(); // catch events written after the final status poll
282
+ if (this.status !== "succeeded") {
283
+ throw new Error(`generation ${this.id} failed: ${this.error}`);
284
+ }
285
+ const data = await this.#http.get(`/v1/datasets/${this.dataset_id}`);
286
+ return new Dataset(data, this.#http);
287
+ }
288
+ }
289
+ export class Seeds {
290
+ #http;
291
+ constructor(http) {
292
+ this.#http = http;
293
+ }
294
+ /** Upload seed material (documents, tool schemas, policies, traces...). */
295
+ async ingest(opts) {
296
+ return this.#http.post("/v1/seeds", {
297
+ kind: opts.kind,
298
+ source: opts.source,
299
+ content: opts.content,
300
+ version: opts.version ?? "1",
301
+ metadata: opts.metadata ?? {},
302
+ });
303
+ }
304
+ }
305
+ export class UserModels {
306
+ #http;
307
+ constructor(http) {
308
+ this.#http = http;
309
+ }
310
+ /**
311
+ * Probe `agent` until the server converges on a user model.
312
+ *
313
+ * The agent runs locally; only probe questions, replies, and traced
314
+ * tool calls travel over the wire. `verbose` prints the server's
315
+ * telemetry (probe decisions, ingestion, inference) after each turn.
316
+ */
317
+ async createFromProbe(agent, opts = {}) {
318
+ const { maxTurns = 10, verbose = false } = opts;
319
+ let session = await this.#http.post("/v1/probe-sessions", {
320
+ max_turns: maxTurns,
321
+ });
322
+ const events = new EventStream(this.#http, `/v1/probe-sessions/${session.id}/events`, verbose);
323
+ while (session.status === "active") {
324
+ const raw = await agent(session.next_probe);
325
+ const reply = typeof raw === "string" ? raw : raw.reply;
326
+ const toolCalls = typeof raw === "string"
327
+ ? []
328
+ : (raw.tool_calls ?? []).map((tc) => ({
329
+ name: tc.name,
330
+ input: tc.input,
331
+ output: tc.output ?? null,
332
+ is_error: tc.is_error ?? false,
333
+ }));
334
+ session = await this.#http.post(`/v1/probe-sessions/${session.id}/responses`, { reply, tool_calls: toolCalls });
335
+ await events.pump();
336
+ }
337
+ return this.get(session.user_model_id);
338
+ }
339
+ async get(modelId) {
340
+ return this.#http.get(`/v1/user-models/${modelId}`);
341
+ }
342
+ async list(session) {
343
+ const params = session ? { sdk_session: session } : undefined;
344
+ const body = await this.#http.get("/v1/user-models", params);
345
+ return body.data;
346
+ }
347
+ }
348
+ export class Datasets {
349
+ #http;
350
+ constructor(http) {
351
+ this.#http = http;
352
+ }
353
+ async get(datasetId) {
354
+ const data = await this.#http.get(`/v1/datasets/${datasetId}`);
355
+ return new Dataset(data, this.#http);
356
+ }
357
+ /** Datasets newest first; `session` filters to one SDK session. */
358
+ async list(session) {
359
+ const params = session ? { sdk_session: session } : undefined;
360
+ const body = await this.#http.get("/v1/datasets", params);
361
+ return body.data.map((d) => new Dataset(d, this.#http));
362
+ }
363
+ /**
364
+ * Start a generation job. qualityCheckId names a completed quality
365
+ * check whose results calibrate the batch's difficulty and coverage.
366
+ */
367
+ async generate(userModel, opts = {}) {
368
+ const { count = 20, qualityCheckId } = opts;
369
+ const modelId = typeof userModel === "string" ? userModel : userModel.id;
370
+ const body = {
371
+ user_model_id: modelId,
372
+ count,
373
+ };
374
+ if (qualityCheckId)
375
+ body["quality_check_id"] = qualityCheckId;
376
+ const data = await this.#http.post("/v1/generations", body);
377
+ return new GenerationJob(data, this.#http);
378
+ }
379
+ }
380
+ /**
381
+ * An async evaluation of finished rollouts: per rollout, the server
382
+ * analyzes the agent's state trajectory and judges pass/fail. The
383
+ * per-rollout results are the product; there is no aggregate verdict.
384
+ */
385
+ export class QualityCheck {
386
+ id;
387
+ status;
388
+ rollout_ids;
389
+ error;
390
+ #http;
391
+ constructor(data, http) {
392
+ this.id = data.id;
393
+ this.status = data.status;
394
+ this.rollout_ids = data.rollout_ids;
395
+ this.error = data.error;
396
+ this.#http = http;
397
+ }
398
+ /** Poll until the check finishes; verbose prints server telemetry. */
399
+ async wait(opts = {}) {
400
+ const { pollInterval = 2, timeout = 1800, verbose = false } = opts;
401
+ const events = new EventStream(this.#http, `/v1/quality-checks/${this.id}/events`, verbose);
402
+ const deadline = Date.now() + timeout * 1000;
403
+ while (this.status === "running") {
404
+ if (Date.now() > deadline) {
405
+ throw new Error(`quality check ${this.id} still running after ${timeout}s`);
406
+ }
407
+ await sleep(pollInterval * 1000);
408
+ const data = await this.#http.get(`/v1/quality-checks/${this.id}`);
409
+ this.status = data.status;
410
+ this.error = data.error;
411
+ await events.pump();
412
+ }
413
+ await events.pump(); // catch events written after the final status poll
414
+ if (this.status !== "succeeded") {
415
+ throw new Error(`quality check ${this.id} failed: ${this.error}`);
416
+ }
417
+ return this;
418
+ }
419
+ /**
420
+ * Per-rollout results: rollout_id, passed, states (the agentic-state
421
+ * trajectory), and judge (dimensions + issues).
422
+ */
423
+ async rollouts() {
424
+ const body = await this.#http.get(`/v1/quality-checks/${this.id}/rollouts`);
425
+ return body.data;
426
+ }
427
+ }
428
+ export class Rollouts {
429
+ #http;
430
+ #sessionId;
431
+ constructor(http, sessionId = null) {
432
+ this.#http = http;
433
+ this.#sessionId = sessionId;
434
+ }
435
+ /**
436
+ * A stored rollout's full captured state: status, seed, transcript,
437
+ * tool events, and sandbox.
438
+ */
439
+ async get(rolloutId) {
440
+ return this.#http.get(`/v1/rollouts/${rolloutId}`);
441
+ }
442
+ /**
443
+ * Play a dataset's scenarios against `agent` (most recent dataset when
444
+ * none is given).
445
+ *
446
+ * The agent runs locally: each turn it gets the transcript so far and
447
+ * a deterministic ToolSandbox for its tool calls; only its reply and
448
+ * tool events travel over the wire. Scenarios run with `concurrency`
449
+ * in-flight at once (turns within one conversation are sequential) —
450
+ * pass concurrency: 1 to run them strictly one at a time.
451
+ */
452
+ async run(agent, dataset = null, opts = {}) {
453
+ const { maxTurns = 12, concurrency = 4 } = opts;
454
+ let datasetId;
455
+ if (dataset === null) {
456
+ // Session-scoped default: this script's latest dataset, so two
457
+ // concurrent scripts never pick up each other's data.
458
+ let data = [];
459
+ if (this.#sessionId) {
460
+ const body = await this.#http.get("/v1/datasets", {
461
+ sdk_session: this.#sessionId,
462
+ });
463
+ data = body.data;
464
+ }
465
+ if (!data.length) {
466
+ const body = await this.#http.get("/v1/datasets");
467
+ data = body.data;
468
+ if (data.length && this.#sessionId) {
469
+ console.log(`note: no dataset in this session yet; ` +
470
+ `using latest dataset ${data[0].id}`);
471
+ }
472
+ }
473
+ if (!data.length) {
474
+ throw new Error("no datasets exist yet; generate one first");
475
+ }
476
+ datasetId = data[0].id;
477
+ }
478
+ else {
479
+ datasetId = typeof dataset === "string" ? dataset : dataset.id;
480
+ }
481
+ const body = await this.#http.get(`/v1/datasets/${datasetId}/rows`);
482
+ const rows = body.data;
483
+ const results = new Array(rows.length);
484
+ let next = 0;
485
+ const worker = async () => {
486
+ while (next < rows.length) {
487
+ const i = next++;
488
+ results[i] = await this.runScenario(agent, rows[i].scenario_id, {
489
+ maxTurns,
490
+ });
491
+ }
492
+ };
493
+ await Promise.all(Array.from({ length: Math.min(concurrency, rows.length) }, worker));
494
+ return results;
495
+ }
496
+ /**
497
+ * Start an async quality check over finished rollouts: the server
498
+ * analyzes each rollout's agentic states in parallel and judges
499
+ * whether the agent passed each scenario.
500
+ */
501
+ async qualityCheck(rollouts) {
502
+ const rolloutIds = rollouts.map((r) => typeof r === "string" ? r : r.rollout_id);
503
+ const data = await this.#http.post("/v1/quality-checks", {
504
+ rollout_ids: rolloutIds,
505
+ });
506
+ return new QualityCheck(data, this.#http);
507
+ }
508
+ /** Run one rollout session; one HTTP round-trip per agent turn. */
509
+ async runScenario(agent, scenarioId, opts = {}) {
510
+ const { maxTurns = 12, randomSeed = null } = opts;
511
+ let session = await this.#http.post("/v1/rollouts", {
512
+ scenario_id: scenarioId,
513
+ random_seed: randomSeed,
514
+ max_turns: maxTurns,
515
+ });
516
+ while (session.status === "running") {
517
+ const sandbox = ToolSandbox.fromConfig(session.sandbox);
518
+ const reply = await agent(session.transcript, sandbox);
519
+ session = await this.#http.post(`/v1/rollouts/${session.id}/turns`, {
520
+ reply,
521
+ tool_calls: sandbox.events,
522
+ });
523
+ }
524
+ return {
525
+ rollout_id: session.id,
526
+ scenario_id: scenarioId,
527
+ status: session.status,
528
+ turns: session.turn,
529
+ transcript: session.transcript,
530
+ tool_events: session.tool_events,
531
+ };
532
+ }
533
+ }
534
+ /**
535
+ * Client entry point.
536
+ *
537
+ * Session identity: every client belongs to a named session — the stable,
538
+ * account-scoped identity of one script, persisted across executions
539
+ * (same name resumes the same session; re-runs reuse its datasets instead
540
+ * of re-probing/re-generating). Resolution order: `session` option >
541
+ * SYNTHIA_SESSION env var > derived "project/script" name from the entry
542
+ * point. `session: false` opts out into a fresh ephemeral session.
543
+ *
544
+ * Degradation: an old server without /v1/sdk-sessions -> no session;
545
+ * keyless against a keyed server -> anonymous session; an invalid apiKey
546
+ * fails on first use with the server's message.
547
+ */
548
+ export class Synthia {
549
+ sessionName;
550
+ sessionId = null;
551
+ invocationId = null;
552
+ seeds;
553
+ userModels;
554
+ datasets;
555
+ rollouts;
556
+ #http;
557
+ constructor(options = {}) {
558
+ const apiKey = options.apiKey ?? process.env["SYNTHIA_API_KEY"];
559
+ const baseUrl = options.baseUrl ?? process.env["SYNTHIA_BASE_URL"] ?? DEFAULT_BASE_URL;
560
+ const headers = apiKey
561
+ ? { authorization: `Bearer ${apiKey}` }
562
+ : {};
563
+ this.#http = new Http(baseUrl, headers);
564
+ if (options.session === false) {
565
+ this.sessionName =
566
+ `${defaultSessionName()}/eph-` +
567
+ randomUUID().replace(/-/g, "").slice(0, 8);
568
+ }
569
+ else if (options.session) {
570
+ this.sessionName = options.session;
571
+ }
572
+ else {
573
+ this.sessionName =
574
+ process.env["SYNTHIA_SESSION"] || defaultSessionName();
575
+ }
576
+ // The handshake gates every later request, so all of them carry the
577
+ // session headers without callers having to await anything up front.
578
+ this.#http.ready = this.#startSession();
579
+ this.seeds = new Seeds(this.#http);
580
+ this.userModels = new UserModels(this.#http);
581
+ this.datasets = new Datasets(this.#http);
582
+ this.rollouts = new Rollouts(this.#http, null);
583
+ }
584
+ /**
585
+ * One handshake per process: get-or-create the named session and mint
586
+ * this invocation; all later requests carry both ids as headers.
587
+ */
588
+ async #startSession() {
589
+ let r;
590
+ try {
591
+ r = await this.#http.raw("POST", "/v1/sdk-sessions", {
592
+ name: this.sessionName,
593
+ sdk_version: SDK_VERSION,
594
+ });
595
+ }
596
+ catch {
597
+ return; // transient network trouble: run without session tracking
598
+ }
599
+ if (r.status === 404)
600
+ return; // server predates sessions: degrade
601
+ if (r.status === 401) {
602
+ const detail = await r
603
+ .json()
604
+ .then((b) => b.detail)
605
+ .catch(() => null);
606
+ throw new Error(detail ?? "invalid API key");
607
+ }
608
+ if (!r.ok) {
609
+ throw new HttpError(r.status, await r.text(), "POST", "/v1/sdk-sessions");
610
+ }
611
+ const data = (await r.json());
612
+ this.sessionId = data.sdk_session_id;
613
+ this.invocationId = data.sdk_invocation_id;
614
+ this.#http.headers["X-Synthia-Session"] = this.sessionId;
615
+ this.#http.headers["X-Synthia-Invocation"] = this.invocationId;
616
+ this.rollouts = new Rollouts(this.#http, this.sessionId);
617
+ }
618
+ /**
619
+ * Probe + generate only when needed; otherwise reuse the latest dataset.
620
+ *
621
+ * The main entry point for the probe and generation steps. `count` is
622
+ * exact: the returned dataset has exactly that many rows, so reuse
623
+ * requires the latest dataset to match it in addition to the quality
624
+ * gate. Probing and generation run only when no dataset exists yet,
625
+ * when the row count differs (generation-only — the session's probed
626
+ * user model is reused), or when the latest completed quality check's
627
+ * pass rate falls outside [minSuccessRate, maxSuccessRate].
628
+ * Out-of-band regeneration passes that quality check to the server,
629
+ * which feeds its real results into scenario generation so the new
630
+ * batch recalibrates difficulty and coverage.
631
+ *
632
+ * All lookups are scoped to this client's session: re-running the same
633
+ * script reuses its own dataset, and drift signals from other
634
+ * scripts/sessions never trigger regeneration here.
635
+ */
636
+ async prepare(agent, opts = {}) {
637
+ const { count = 20, maxTurns = 10, minSuccessRate = 0.6, maxSuccessRate = 0.9, verbose = false, } = opts;
638
+ await this.#http.ready;
639
+ const existing = await this.datasets.list(this.sessionId ?? undefined); // newest first
640
+ if (!existing.length) {
641
+ return this.#probeAndGenerate(agent, {
642
+ count,
643
+ maxTurns,
644
+ verbose,
645
+ reason: this.sessionId
646
+ ? "no datasets in this session yet"
647
+ : "no datasets exist yet",
648
+ successRate: null,
649
+ qualityCheckId: null,
650
+ });
651
+ }
652
+ const latest = await this.#http.get("/v1/quality-checks/latest", this.sessionId ? { sdk_session: this.sessionId } : undefined);
653
+ const rate = latest.id !== null && latest.total > 0
654
+ ? latest.passed / latest.total
655
+ : null;
656
+ if (rate !== null && !(minSuccessRate <= rate && rate <= maxSuccessRate)) {
657
+ const direction = rate < minSuccessRate ? "below" : "above";
658
+ const bound = rate < minSuccessRate ? minSuccessRate : maxSuccessRate;
659
+ return this.#probeAndGenerate(agent, {
660
+ count,
661
+ maxTurns,
662
+ verbose,
663
+ reason: `success rate ${pct(rate)} ${direction} ${pct(bound)}; ` +
664
+ `regenerating calibrated on ${latest.id}`,
665
+ successRate: rate,
666
+ qualityCheckId: latest.id,
667
+ });
668
+ }
669
+ // Quality is in band (or unjudged): reuse only on an exact size
670
+ // match; otherwise regenerate at the requested count — without
671
+ // re-probing, since nothing suggests the agent changed.
672
+ if (existing[0].row_count !== count) {
673
+ return this.#probeAndGenerate(agent, {
674
+ count,
675
+ maxTurns,
676
+ verbose,
677
+ reason: `latest dataset has ${existing[0].row_count} rows; ` +
678
+ `requested ${count}`,
679
+ successRate: rate,
680
+ qualityCheckId: null,
681
+ });
682
+ }
683
+ return {
684
+ dataset: existing[0],
685
+ userModel: await this.userModels.get(existing[0].user_model_id),
686
+ action: "reused",
687
+ reason: rate !== null
688
+ ? `success rate ${pct(rate)} within ` +
689
+ `${pct(minSuccessRate)}-${pct(maxSuccessRate)} band`
690
+ : "no completed quality check to judge by; reusing latest dataset",
691
+ successRate: rate,
692
+ qualityCheckId: null,
693
+ };
694
+ }
695
+ async #probeAndGenerate(agent, opts) {
696
+ // Without a drift signal the agent hasn't been shown to change, so a
697
+ // user model this session already probed is still good — skip the
698
+ // probe. Drift-triggered regeneration re-probes deliberately.
699
+ let reason = opts.reason;
700
+ let userModel = null;
701
+ if (this.sessionId && !opts.qualityCheckId) {
702
+ const sessionModels = await this.userModels.list(this.sessionId);
703
+ if (sessionModels.length) {
704
+ userModel = sessionModels[sessionModels.length - 1]; // newest last
705
+ reason += "; reusing session user model (no drift signal)";
706
+ }
707
+ }
708
+ if (userModel === null) {
709
+ userModel = await this.userModels.createFromProbe(agent, {
710
+ maxTurns: opts.maxTurns,
711
+ verbose: opts.verbose,
712
+ });
713
+ }
714
+ const job = await this.datasets.generate(userModel, {
715
+ count: opts.count,
716
+ qualityCheckId: opts.qualityCheckId ?? undefined,
717
+ });
718
+ const dataset = await job.wait({ verbose: opts.verbose });
719
+ return {
720
+ dataset,
721
+ userModel,
722
+ action: "generated",
723
+ reason,
724
+ successRate: opts.successRate,
725
+ qualityCheckId: opts.qualityCheckId,
726
+ };
727
+ }
728
+ }
729
+ const pct = (x) => `${Math.round(x * 100)}%`;
@@ -0,0 +1,2 @@
1
+ export { Synthia, ToolSandbox, Dataset, GenerationJob, ValidationRun, QualityCheck, Seeds, UserModels, Datasets, Rollouts, DEFAULT_BASE_URL, } from "./client.js";
2
+ export type { Agent, AgentReply, PrepareOptions, PrepareResult, RolloutAgent, RolloutResult, RunOptions, SandboxConfig, SynthiaOptions, ToolCall, ToolEvent, TranscriptTurn, UserModel, WaitOptions, } from "./client.js";
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { Synthia, ToolSandbox, Dataset, GenerationJob, ValidationRun, QualityCheck, Seeds, UserModels, Datasets, Rollouts, DEFAULT_BASE_URL, } from "./client.js";
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "synthiaresearch",
3
+ "version": "0.0.1",
4
+ "description": "JavaScript/TypeScript SDK for the Synthia API",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "scripts": {
22
+ "build": "tsc",
23
+ "prepublishOnly": "npm run build"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^22.0.0",
27
+ "typescript": "^5.5.0"
28
+ }
29
+ }