synth-ai 0.2.9.dev3__py3-none-any.whl → 0.2.9.dev4__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.
- examples/analyze_semantic_words.sh +17 -0
- examples/common_old/backend.py +21 -0
- examples/crafter_debug_render.py +180 -0
- examples/evals_old/README.md +98 -0
- examples/evals_old/__init__.py +6 -0
- examples/evals_old/compare_models.py +1037 -0
- examples/evals_old/example_log.md +145 -0
- examples/evals_old/run_demo.sh +126 -0
- examples/evals_old/trace_analysis.py +270 -0
- examples/finetuning_old/_backup_synth_qwen/config.toml +29 -0
- examples/finetuning_old/_backup_synth_qwen/example_log.md +324 -0
- examples/finetuning_old/_backup_synth_qwen/filter_traces.py +60 -0
- examples/finetuning_old/_backup_synth_qwen/filter_traces_achievements.py +239 -0
- examples/finetuning_old/_backup_synth_qwen/purge_v3_traces.py +109 -0
- examples/finetuning_old/_backup_synth_qwen/react_agent_lm.py +1924 -0
- examples/finetuning_old/_backup_synth_qwen/readme.md +49 -0
- examples/finetuning_old/_backup_synth_qwen/run_crafter_qwen4b.py +114 -0
- examples/finetuning_old/_backup_synth_qwen/run_demo.sh +195 -0
- examples/finetuning_old/_backup_synth_qwen/sft_kickoff.py +118 -0
- examples/finetuning_old/synth_qwen_v1/README.md +68 -0
- examples/finetuning_old/synth_qwen_v1/filter_traces.py +60 -0
- examples/finetuning_old/synth_qwen_v1/filter_traces_achievements.py +239 -0
- examples/finetuning_old/synth_qwen_v1/finetune.py +46 -0
- examples/finetuning_old/synth_qwen_v1/hello_ft_model.py +71 -0
- examples/finetuning_old/synth_qwen_v1/infer.py +37 -0
- examples/finetuning_old/synth_qwen_v1/poll.py +44 -0
- examples/finetuning_old/synth_qwen_v1/prepare_data.py +35 -0
- examples/finetuning_old/synth_qwen_v1/purge_v3_traces.py +109 -0
- examples/finetuning_old/synth_qwen_v1/react_agent_lm.py +1932 -0
- examples/finetuning_old/synth_qwen_v1/run_crafter_sft_job.py +207 -0
- examples/finetuning_old/synth_qwen_v1/run_ft_job.py +232 -0
- examples/finetuning_old/synth_qwen_v1/upload_data.py +34 -0
- examples/finetuning_old/synth_qwen_v1/util.py +147 -0
- examples/rl/README.md +169 -0
- examples/rl/configs/eval_base_qwen.toml +15 -0
- examples/rl/configs/eval_rl_qwen.toml +11 -0
- examples/rl/configs/rl_from_base_qwen.toml +35 -0
- examples/rl/configs/rl_from_base_qwen17.toml +74 -0
- examples/rl/configs/rl_from_ft_qwen.toml +35 -0
- examples/rl/download_dataset.py +64 -0
- examples/rl/run_eval.py +435 -0
- examples/rl/run_rl_and_save.py +94 -0
- examples/rl/task_app/README.md +22 -0
- {synth_ai/task/apps → examples/rl/task_app}/math_single_step.py +8 -8
- examples/rl/task_app/math_task_app.py +107 -0
- examples/rl_old/task_app.py +962 -0
- examples/run_crafter_demo.sh +10 -0
- examples/warming_up_to_rl/analyze_trace_db.py +420 -0
- examples/warming_up_to_rl/configs/crafter_fft.toml +48 -0
- examples/warming_up_to_rl/configs/crafter_fft_4b.toml +54 -0
- examples/warming_up_to_rl/configs/eval_fft_qwen4b.toml +20 -0
- examples/warming_up_to_rl/configs/eval_groq_qwen32b.toml +13 -0
- examples/warming_up_to_rl/configs/eval_modal_qwen4b.toml +23 -0
- examples/warming_up_to_rl/configs/rl_from_base_qwen4b.toml +73 -0
- examples/warming_up_to_rl/configs/rl_from_ft.toml +56 -0
- examples/warming_up_to_rl/export_trace_sft.py +541 -0
- examples/warming_up_to_rl/groq_test.py +88 -0
- examples/warming_up_to_rl/manage_secrets.py +127 -0
- examples/warming_up_to_rl/old/event_rewards.md +234 -0
- examples/warming_up_to_rl/old/notes.md +73 -0
- examples/warming_up_to_rl/readme.md +172 -0
- examples/warming_up_to_rl/run_eval.py +434 -0
- examples/warming_up_to_rl/run_fft_and_save.py +309 -0
- examples/warming_up_to_rl/run_local_rollout.py +188 -0
- examples/warming_up_to_rl/run_local_rollout_modal.py +160 -0
- examples/warming_up_to_rl/run_local_rollout_parallel.py +342 -0
- examples/warming_up_to_rl/run_local_rollout_traced.py +372 -0
- examples/warming_up_to_rl/run_rl_and_save.py +101 -0
- examples/warming_up_to_rl/run_rollout_remote.py +129 -0
- examples/warming_up_to_rl/task_app/README.md +38 -0
- {synth_ai/task/apps → examples/warming_up_to_rl/task_app}/grpo_crafter.py +7 -7
- examples/warming_up_to_rl/task_app/grpo_crafter_task_app.py +165 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/README.md +173 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/__init__.py +5 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/branching.py +145 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/environment_routes.py +1271 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/__init__.py +1 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/__init__.py +6 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/app.py +1 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/environment.py +429 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/policy.py +442 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/react_agent.py +96 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/shared.py +302 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/tools.py +47 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/hosted_app.py +202 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/__init__.py +5 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/openai_client.py +512 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/main.py +102 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/policy_routes.py +985 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/registry.py +197 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/rollout.py +1749 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/storage/__init__.py +5 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/storage/volume.py +217 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/test_agents.py +160 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/test_service.py +146 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/test_stepwise_rewards.py +58 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/utils.py +61 -0
- synth_ai/api/train/config_finder.py +18 -18
- synth_ai/api/train/env_resolver.py +28 -1
- synth_ai/cli/task_apps.py +264 -55
- synth_ai/task/apps/__init__.py +54 -13
- {synth_ai-0.2.9.dev3.dist-info → synth_ai-0.2.9.dev4.dist-info}/METADATA +1 -1
- {synth_ai-0.2.9.dev3.dist-info → synth_ai-0.2.9.dev4.dist-info}/RECORD +107 -12
- {synth_ai-0.2.9.dev3.dist-info → synth_ai-0.2.9.dev4.dist-info}/top_level.txt +1 -0
- {synth_ai-0.2.9.dev3.dist-info → synth_ai-0.2.9.dev4.dist-info}/WHEEL +0 -0
- {synth_ai-0.2.9.dev3.dist-info → synth_ai-0.2.9.dev4.dist-info}/entry_points.txt +0 -0
- {synth_ai-0.2.9.dev3.dist-info → synth_ai-0.2.9.dev4.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
### Quickstart (using config.toml)
|
|
2
|
+
|
|
3
|
+
The defaults come from `examples/finetuning/synth_qwen/config.toml`. Override with env vars only when needed.
|
|
4
|
+
|
|
5
|
+
#### 1) Rollouts → v3 traces
|
|
6
|
+
```bash
|
|
7
|
+
set -a; source .env 2>/dev/null || true; set +a
|
|
8
|
+
uvpm examples.finetuning.synth_qwen.run_crafter_qwen4b
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Notes:
|
|
12
|
+
- Model, episodes, steps, difficulty, temperature, tool choice, etc. are taken from `config.toml`.
|
|
13
|
+
- Only API key and v3 db path are specified here.
|
|
14
|
+
|
|
15
|
+
Example output (abridged):
|
|
16
|
+
```text
|
|
17
|
+
✅ Crafter service is healthy
|
|
18
|
+
🚀 Running 10 episodes (concurrency=5)...
|
|
19
|
+
✅ Completed 10 episodes in ~366s
|
|
20
|
+
📊 EVALUATION RESULTS
|
|
21
|
+
Episodes completed: 10/10
|
|
22
|
+
Average reward per episode: 1.10
|
|
23
|
+
Average steps per episode: 87.00
|
|
24
|
+
💾 Results: traces/v3/synth_ai.db
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
#### 2) Filter traces → SFT JSONL
|
|
28
|
+
```bash
|
|
29
|
+
uvpm examples.finetuning.synth_qwen.filter_traces_achievements
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Notes:
|
|
33
|
+
- Filter settings (achievements, thresholds, output path) are defined in `config.toml`.
|
|
34
|
+
|
|
35
|
+
Example output:
|
|
36
|
+
```text
|
|
37
|
+
Using database: sqlite+aiosqlite:///$PWD/traces/v3/synth_ai.db/dbs/default/data
|
|
38
|
+
Output file: ft_data/qwen4b_crafter_sft_collect_wood.jsonl
|
|
39
|
+
✅ Wrote 13 examples from 13 sessions
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
#### 3) Kick off SFT (prod)
|
|
43
|
+
```bash
|
|
44
|
+
set -a; source .env 2>/dev/null || true; set +a
|
|
45
|
+
uvpm examples.finetuning.synth_qwen.sft_kickoff
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Notes:
|
|
49
|
+
- Base model and training JSONL path come from `config.toml`.
|
|
50
|
+
|
|
51
|
+
Example output (abridged):
|
|
52
|
+
```text
|
|
53
|
+
🚀 Starting Qwen 4B SFT
|
|
54
|
+
⏳ poll ...
|
|
55
|
+
🟢 Qwen4B SFT fine-tune succeeded → ft:Qwen/Qwen3-4B-Instruct-2507:ftjob-22
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
#### 4) Rollouts with fine-tuned model
|
|
59
|
+
```bash
|
|
60
|
+
set -a; source .env 2>/dev/null || true; set +a
|
|
61
|
+
CRAFTER_MODEL="ft:Qwen/Qwen3-4B-Instruct-2507:ftjob-22" uvpm examples.finetuning.synth_qwen.run_crafter_qwen4b
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Notes:
|
|
65
|
+
- Replace `ftjob-22` with the job id printed by your SFT step.
|
|
66
|
+
- If you see 401 Invalid API key, export the prod key: `export SYNTH_API_KEY="$SYNTH_API_KEY_PROD"`.
|
|
67
|
+
|
|
68
|
+
Example output (abridged):
|
|
69
|
+
```text
|
|
70
|
+
✅ Model warmed up successfully!
|
|
71
|
+
🚀 Running 10 episodes (concurrency=5)...
|
|
72
|
+
✅ Completed 10 episodes in ~480s
|
|
73
|
+
📊 EVALUATION RESULTS
|
|
74
|
+
Episodes completed: 10/10
|
|
75
|
+
Average reward per episode: 1.60
|
|
76
|
+
Average steps per episode: 90.80
|
|
77
|
+
Achievements: collect_wood in 6/10 episodes
|
|
78
|
+
💾 Results: traces/v3/synth_ai.db
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
joshuapurtell@Mac synth-ai % bash examples/finetuning/synth_qwen/run_demo.sh
|
|
85
|
+
Synth Qwen4B finetuning demo (Crafter)
|
|
86
|
+
|
|
87
|
+
Run rollouts to generate v3 traces now? [Y/n]: Y
|
|
88
|
+
Using config defaults from examples/finetuning/synth_qwen/config.toml (override below if desired).
|
|
89
|
+
Model id [Enter=use config]:
|
|
90
|
+
Episodes [Enter=use config]: 5
|
|
91
|
+
Max steps [Enter=use config]: 5
|
|
92
|
+
Difficulty [Enter=use config]:
|
|
93
|
+
Enable think mode? (1/0) [Enter=0]:
|
|
94
|
+
|
|
95
|
+
Running rollouts (v3 tracing)...
|
|
96
|
+
Detected SYNTH_API_KEY (sk_liv...ac95). Use this key? [Y/n]: N
|
|
97
|
+
Use SYNTH_API_KEY_PROD (sk_liv...a2a4)? [y/N]: y
|
|
98
|
+
[PATCH] Attempting to apply Crafter deterministic patch...
|
|
99
|
+
[PATCH] Patching crafter.Env._balance_object...
|
|
100
|
+
[PATCH] crafter.Env._balance_object patched.
|
|
101
|
+
[PATCH] Attempting to apply Crafter serialization patch v3...
|
|
102
|
+
[PATCH] Adding enhanced save/load methods to crafter.Env...
|
|
103
|
+
[PATCH] crafter.Env.save() and load() methods added (v3).
|
|
104
|
+
[PATCH] Crafter serialization patch v3 complete.
|
|
105
|
+
[PATCH] Attempting to apply simplified Crafter world configuration patch...
|
|
106
|
+
[PATCH] Simplified Crafter world configuration patch complete.
|
|
107
|
+
[PATCH] Available configs: easy, normal, hard, peaceful
|
|
108
|
+
🔧 Using Synth base URL = https://agent-learning.onrender.com/api
|
|
109
|
+
🔇 Quiet mode enabled - suppressing verbose logs
|
|
110
|
+
✅ Crafter service is healthy: {'status': 'ok', 'supported_environments': ['CrafterClassic', 'CrafterCustom']}
|
|
111
|
+
|
|
112
|
+
🔥 Warming up Qwen/Qwen3-4B-Instruct-2507 on Synth backend...
|
|
113
|
+
✅ Warmed Qwen/Qwen3-4B-Instruct-2507 in 3s arming elapsed=0s
|
|
114
|
+
✅ Model warmed up successfully!
|
|
115
|
+
|
|
116
|
+
🚀 Starting sqld daemon for v3 tracing...
|
|
117
|
+
✅ sqld daemon started
|
|
118
|
+
|
|
119
|
+
📊 V3 Tracing enabled. Traces will be saved to: traces/v3/synth_ai.db
|
|
120
|
+
Experiment: crafter_lm_synth_Qwen/Qwen3-4B-Instruct-2507_20250808_165304
|
|
121
|
+
|
|
122
|
+
🚀 Running 5 episodes (concurrency=5)...
|
|
123
|
+
|
|
124
|
+
📤 Starting episodes...
|
|
125
|
+
Episode 1: 100%|███████████████| 5/5 [00:18<00:00, 3.71s/it, tc=1, act=3, tok=78, in=860, tps=22.4]
|
|
126
|
+
Episode 0: 100%|██████████████| 5/5 [00:19<00:00, 3.89s/it, tc=1, act=3, tok=94, in=861, tps=22.79]
|
|
127
|
+
Episode 2: 100%|██████████████| 5/5 [00:18<00:00, 3.74s/it, tc=1, act=2, tok=73, in=832, tps=21.06]
|
|
128
|
+
Episode 4: 100%|██████████████| 5/5 [00:19<00:00, 3.90s/it, tc=1, act=3, tok=91, in=818, tps=17.34]
|
|
129
|
+
Episode 3: 100%|██████████████| 5/5 [00:20<00:00, 4.15s/it, tc=1, act=2, tok=86, in=859, tps=21.96]
|
|
130
|
+
|
|
131
|
+
✅ Completed 5 episodes in 23.57 seconds
|
|
132
|
+
|
|
133
|
+
==================================================
|
|
134
|
+
📊 EVALUATION RESULTS
|
|
135
|
+
==================================================
|
|
136
|
+
Episodes completed: 5/5
|
|
137
|
+
Failed episodes: 0
|
|
138
|
+
Total reward: 1.00
|
|
139
|
+
Average reward per episode: 0.20
|
|
140
|
+
Total steps: 68
|
|
141
|
+
Average steps per episode: 13.60
|
|
142
|
+
|
|
143
|
+
Seeds used:
|
|
144
|
+
Episode 0: seed 1
|
|
145
|
+
Episode 1: seed 2
|
|
146
|
+
Episode 2: seed 3
|
|
147
|
+
Episode 3: seed 4
|
|
148
|
+
Episode 4: seed 5
|
|
149
|
+
Unique achievements unlocked: 1
|
|
150
|
+
|
|
151
|
+
Achievements unlocked:
|
|
152
|
+
- collect_sapling: 1 episodes (20.0%)
|
|
153
|
+
|
|
154
|
+
Action counts (total: 68):
|
|
155
|
+
- move_right: 25 (36.8%)
|
|
156
|
+
- do: 19 (27.9%)
|
|
157
|
+
- move_down: 10 (14.7%)
|
|
158
|
+
- move_left: 5 (7.4%)
|
|
159
|
+
- place_stone: 3 (4.4%)
|
|
160
|
+
- move_up: 3 (4.4%)
|
|
161
|
+
- make_wood_sword: 1 (1.5%)
|
|
162
|
+
- make_stone_pickaxe: 1 (1.5%)
|
|
163
|
+
- make_wood_pickaxe: 1 (1.5%)
|
|
164
|
+
|
|
165
|
+
💾 Results available in Turso database: traces/v3/synth_ai.db
|
|
166
|
+
Experiment ID: exp_a0b091fd12c6
|
|
167
|
+
Use the filter_traces_sft_turso.py script to extract fine-tuning data
|
|
168
|
+
|
|
169
|
+
Markdown row:
|
|
170
|
+
| Qwen/Qwen3-4B-Instruct-2507 | 5 | 0.20 | 0.020 | 0.001 | 68.000 | 13.600 |
|
|
171
|
+
|
|
172
|
+
✅ Stopped sqld daemon
|
|
173
|
+
|
|
174
|
+
Filter v3 traces into SFT JSONL now? [Y/n]: Y
|
|
175
|
+
Using DB: sqlite+aiosqlite:////Users/joshuapurtell/Documents/GitHub/synth-ai/traces/v3/synth_ai.db/dbs/default/data
|
|
176
|
+
You can override filter options; Enter to use config defaults.
|
|
177
|
+
Required achievements (space-separated) [Enter=config]:
|
|
178
|
+
Restrict to models (space-separated) [Enter=all]:
|
|
179
|
+
Output JSONL path [Enter=config]:
|
|
180
|
+
Min total reward [Enter=config]: 1
|
|
181
|
+
Max total cost [Enter=config]:
|
|
182
|
+
Max total tokens [Enter=config]:
|
|
183
|
+
|
|
184
|
+
Filtering traces to SFT JSONL...
|
|
185
|
+
[PATCH] Attempting to apply Crafter deterministic patch...
|
|
186
|
+
[PATCH] Patching crafter.Env._balance_object...
|
|
187
|
+
[PATCH] crafter.Env._balance_object patched.
|
|
188
|
+
[PATCH] Attempting to apply Crafter serialization patch v3...
|
|
189
|
+
[PATCH] Adding enhanced save/load methods to crafter.Env...
|
|
190
|
+
[PATCH] crafter.Env.save() and load() methods added (v3).
|
|
191
|
+
[PATCH] Crafter serialization patch v3 complete.
|
|
192
|
+
[PATCH] Attempting to apply simplified Crafter world configuration patch...
|
|
193
|
+
[PATCH] Simplified Crafter world configuration patch complete.
|
|
194
|
+
[PATCH] Available configs: easy, normal, hard, peaceful
|
|
195
|
+
🤖 Modal/Synth FT Filter (achievements)
|
|
196
|
+
Using database: sqlite+aiosqlite:////Users/joshuapurtell/Documents/GitHub/synth-ai/traces/v3/synth_ai.db/dbs/default/data
|
|
197
|
+
Output file: ft_data/qwen4b_crafter_sft.jsonl
|
|
198
|
+
Filters: {
|
|
199
|
+
"required_achievements": [
|
|
200
|
+
"collect_wood"
|
|
201
|
+
],
|
|
202
|
+
"models": [
|
|
203
|
+
"Qwen/Qwen3-4B-Instruct-2507"
|
|
204
|
+
],
|
|
205
|
+
"min_total_reward": 1.0,
|
|
206
|
+
"max_cost": 10.0,
|
|
207
|
+
"max_tokens": 100000
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
✅ Wrote 23 examples from 23 sessions
|
|
211
|
+
|
|
212
|
+
Kick off SFT training job now? [Y/n]: Y
|
|
213
|
+
Enter overrides for training job; Enter to use config.
|
|
214
|
+
Base model [Enter=config]:
|
|
215
|
+
Training JSONL path [Enter=config]:
|
|
216
|
+
|
|
217
|
+
Starting SFT job...
|
|
218
|
+
Detected SYNTH_API_KEY (sk_liv...a2a4). Use this key? [Y/n]: n
|
|
219
|
+
Use SYNTH_API_KEY_PROD (sk_liv...a2a4)? [y/N]: Y
|
|
220
|
+
🚀 Starting Qwen 4B SFT
|
|
221
|
+
⏳ poll 1/20 – status = queued
|
|
222
|
+
⏳ poll 2/20 – status = queued
|
|
223
|
+
⏳ poll 3/20 – status = queued
|
|
224
|
+
⏳ poll 4/20 – status = queued
|
|
225
|
+
⏳ poll 5/20 – status = queued
|
|
226
|
+
⏳ poll 6/20 – status = queued
|
|
227
|
+
⏳ poll 7/20 – status = queued
|
|
228
|
+
⏳ poll 8/20 – status = queued
|
|
229
|
+
⏳ poll 9/20 – status = running
|
|
230
|
+
⏳ poll 10/20 – status = queued
|
|
231
|
+
⏳ poll 11/20 – status = queued
|
|
232
|
+
⏳ poll 12/20 – status = succeeded
|
|
233
|
+
🟢 Qwen4B SFT fine-tune succeeded → ft:Qwen/Qwen3-4B-Instruct-2507:ftjob-6cedf721e0ca4c80968834b71e2bdace
|
|
234
|
+
⏱️ wall-clock: 249.3s | trained_tokens: 41777
|
|
235
|
+
Captured fine-tuned model id: ft:Qwen/Qwen3-4B-Instruct-2507:ftjob-6cedf721e0ca4c80968834b71e2bdace
|
|
236
|
+
SFT logs saved to: logs/sft_kickoff_20250808_165436.log
|
|
237
|
+
|
|
238
|
+
Roll out fine-tuned model 'ft:Qwen/Qwen3-4B-Instruct-2507:ftjob-6cedf721e0ca4c80968834b71e2bdace' in Crafter now? [y/N]: Y
|
|
239
|
+
Episodes [Enter=config]: 5
|
|
240
|
+
Max steps [Enter=config]: 5
|
|
241
|
+
Difficulty [Enter=config]:
|
|
242
|
+
Enable think mode? (1/0) [Enter=0]:
|
|
243
|
+
|
|
244
|
+
Running rollouts with fine-tuned model...
|
|
245
|
+
[PATCH] Attempting to apply Crafter deterministic patch...
|
|
246
|
+
[PATCH] Patching crafter.Env._balance_object...
|
|
247
|
+
[PATCH] crafter.Env._balance_object patched.
|
|
248
|
+
[PATCH] Attempting to apply Crafter serialization patch v3...
|
|
249
|
+
[PATCH] Adding enhanced save/load methods to crafter.Env...
|
|
250
|
+
[PATCH] crafter.Env.save() and load() methods added (v3).
|
|
251
|
+
[PATCH] Crafter serialization patch v3 complete.
|
|
252
|
+
[PATCH] Attempting to apply simplified Crafter world configuration patch...
|
|
253
|
+
[PATCH] Simplified Crafter world configuration patch complete.
|
|
254
|
+
[PATCH] Available configs: easy, normal, hard, peaceful
|
|
255
|
+
🔧 Using Synth base URL = https://agent-learning.onrender.com/api
|
|
256
|
+
🔇 Quiet mode enabled - suppressing verbose logs
|
|
257
|
+
✅ Crafter service is healthy: {'status': 'ok', 'supported_environments': ['CrafterClassic', 'CrafterCustom']}
|
|
258
|
+
|
|
259
|
+
🔥 Warming up ft:Qwen/Qwen3-4B-Instruct-2507:ftjob-6cedf721e0ca4c80968834b71e2bdace on Synth backend...
|
|
260
|
+
⏳ Warming ft:Qwen/Qwen3-4B-Instruct-2507:ftjob-6cedf721e0ca4c80968834b71e2bdace [|] status=timeout elapsed=10⏳ Warming ft:Qwen/Qwen3-4B-Instruct-2507:ftjob-6cedf721e0ca4c80968834b71e2bdace [/] status=timeout elapsed=21✅ Warmed ft:Qwen/Qwen3-4B-Instruct-2507:ftjob-6cedf721e0ca4c80968834b71e2bdace in 22s
|
|
261
|
+
✅ Model warmed up successfully!
|
|
262
|
+
|
|
263
|
+
🚀 Starting sqld daemon for v3 tracing...
|
|
264
|
+
✅ sqld daemon started
|
|
265
|
+
|
|
266
|
+
📊 V3 Tracing enabled. Traces will be saved to: traces/v3/synth_ai.db
|
|
267
|
+
Experiment: crafter_lm_synth_ft:Qwen/Qwen3-4B-Instruct-2507:ftjob-6cedf721e0ca4c80968834b71e2bdace_20250808_165943
|
|
268
|
+
|
|
269
|
+
🚀 Running 5 episodes (concurrency=5)...
|
|
270
|
+
|
|
271
|
+
📤 Starting episodes...
|
|
272
|
+
Episode 2: 100%|██████████████| 5/5 [00:48<00:00, 9.80s/it, tc=1, act=3, tok=75, in=833, tps=15.24]
|
|
273
|
+
Episode 1: 100%|██████████████| 5/5 [00:52<00:00, 10.48s/it, tc=1, act=3, tok=73, in=840, tps=10.74]
|
|
274
|
+
Episode 3: 100%|██████████████| 5/5 [00:54<00:00, 10.88s/it, tc=1, act=3, tok=79, in=834, tps=15.59]
|
|
275
|
+
Episode 4: 100%|██████████████| 5/5 [00:54<00:00, 10.90s/it, tc=1, act=3, tok=75, in=817, tps=11.93]
|
|
276
|
+
Episode 0: 100%|███████████████| 5/5 [00:56<00:00, 11.38s/it, tc=1, act=2, tok=91, in=850, tps=5.53]
|
|
277
|
+
|
|
278
|
+
✅ Completed 5 episodes in 58.29 seconds
|
|
279
|
+
|
|
280
|
+
==================================================
|
|
281
|
+
📊 EVALUATION RESULTS
|
|
282
|
+
==================================================
|
|
283
|
+
Episodes completed: 5/5
|
|
284
|
+
Failed episodes: 0
|
|
285
|
+
Total reward: 3.00
|
|
286
|
+
Average reward per episode: 0.60
|
|
287
|
+
Total steps: 72
|
|
288
|
+
Average steps per episode: 14.40
|
|
289
|
+
|
|
290
|
+
Seeds used:
|
|
291
|
+
Episode 0: seed 1
|
|
292
|
+
Episode 1: seed 2
|
|
293
|
+
Episode 2: seed 3
|
|
294
|
+
Episode 3: seed 4
|
|
295
|
+
Episode 4: seed 5
|
|
296
|
+
Unique achievements unlocked: 2
|
|
297
|
+
|
|
298
|
+
Achievements unlocked:
|
|
299
|
+
- collect_sapling: 2 episodes (40.0%)
|
|
300
|
+
- collect_wood: 1 episodes (20.0%)
|
|
301
|
+
|
|
302
|
+
Action counts (total: 72):
|
|
303
|
+
- move_right: 25 (34.7%)
|
|
304
|
+
- do: 19 (26.4%)
|
|
305
|
+
- move_down: 8 (11.1%)
|
|
306
|
+
- move_left: 8 (11.1%)
|
|
307
|
+
- move_up: 6 (8.3%)
|
|
308
|
+
- place_stone: 2 (2.8%)
|
|
309
|
+
- place_table: 2 (2.8%)
|
|
310
|
+
- make_wood_pickaxe: 1 (1.4%)
|
|
311
|
+
- place_plant: 1 (1.4%)
|
|
312
|
+
|
|
313
|
+
💾 Results available in Turso database: traces/v3/synth_ai.db
|
|
314
|
+
Experiment ID: exp_56d8e29cbf5a
|
|
315
|
+
Use the filter_traces_sft_turso.py script to extract fine-tuning data
|
|
316
|
+
|
|
317
|
+
Markdown row:
|
|
318
|
+
| ft:Qwen/Qwen3-4B-Instruct-2507:ftjob-6cedf721e0ca4c80968834b71e2bdace | 5 | 0.60 | 0.240 | 0.012 | 72.000 | 14.400 |
|
|
319
|
+
|
|
320
|
+
✅ Stopped sqld daemon
|
|
321
|
+
|
|
322
|
+
Done. You can re-run this script to repeat steps as needed.
|
|
323
|
+
joshuapurtell@Mac synth-ai %
|
|
324
|
+
```
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Filter v3 Crafter traces into an SFT-ready JSONL using the maintained
|
|
4
|
+
Modal/Synth filter logic (no CLI needed). Intended to be run after
|
|
5
|
+
collecting trajectories with the Crafter runner.
|
|
6
|
+
|
|
7
|
+
Environment:
|
|
8
|
+
- CRAFTER_DB_URL (default: sqlite:///traces_v3_lm_synth/traces.db)
|
|
9
|
+
- OUTPUT_JSONL (default: ft_data/qwen4b_crafter_sft.jsonl)
|
|
10
|
+
- MIN_TOTAL_REWARD (float, default: 1.0)
|
|
11
|
+
- MIN_ACHIEVEMENTS (int, default: 0)
|
|
12
|
+
- MAX_COST (float, default: 10.0)
|
|
13
|
+
- MAX_TOKENS (int, default: 100000)
|
|
14
|
+
- MODELS (optional, space-separated model names; default empty = all)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
# Reuse the existing filtering implementation
|
|
23
|
+
from synth_ai.environments.examples.crafter_classic.agent_demos.crafter_modal_ft.filter_traces_sft_turso import (
|
|
24
|
+
filter_traces_from_turso,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_config() -> dict[str, Any]:
|
|
29
|
+
models_env = os.getenv("MODELS", "").strip()
|
|
30
|
+
models: list[str] = models_env.split() if models_env else []
|
|
31
|
+
return {
|
|
32
|
+
"mode": "trajectory",
|
|
33
|
+
"filters": {
|
|
34
|
+
"min_total_reward": float(os.getenv("MIN_TOTAL_REWARD", "1.0")),
|
|
35
|
+
"min_achievements": int(os.getenv("MIN_ACHIEVEMENTS", "0")),
|
|
36
|
+
"max_cost": float(os.getenv("MAX_COST", "10.0")),
|
|
37
|
+
"max_tokens": int(os.getenv("MAX_TOKENS", "100000")),
|
|
38
|
+
"models": models,
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def main() -> None:
|
|
44
|
+
db_url = os.getenv("CRAFTER_DB_URL", "sqlite:///traces_v3_lm_synth/traces.db")
|
|
45
|
+
output_path = os.getenv("OUTPUT_JSONL", "ft_data/qwen4b_crafter_sft.jsonl")
|
|
46
|
+
config = build_config()
|
|
47
|
+
|
|
48
|
+
print("🤖 Modal/Synth Fine-Tuning Data Filter (v3)")
|
|
49
|
+
print("Using database:", db_url)
|
|
50
|
+
print("Output file:", output_path)
|
|
51
|
+
print("Config:", json.dumps(config, indent=2))
|
|
52
|
+
|
|
53
|
+
num_examples, stats = await filter_traces_from_turso(db_url, output_path, config)
|
|
54
|
+
|
|
55
|
+
print("\n✅ Wrote", num_examples, "training examples to", output_path)
|
|
56
|
+
print("📊 Stats keys:", list(stats.keys()))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Filter v3 Crafter traces into SFT JSONL requiring specific achievements.
|
|
4
|
+
|
|
5
|
+
Environment:
|
|
6
|
+
- CRAFTER_DB_URL (default: sqlite:///traces_v3_lm_synth/traces.db)
|
|
7
|
+
- OUTPUT_JSONL (default: ft_data/qwen4b_crafter_sft_ach.jsonl)
|
|
8
|
+
- REQUIRED_ACHIEVEMENTS (space-separated, default: collect_wood)
|
|
9
|
+
- MIN_TOTAL_REWARD (float, default: 0.0)
|
|
10
|
+
- MAX_COST (float, default: inf)
|
|
11
|
+
- MAX_TOKENS (int, default: inf)
|
|
12
|
+
- MODELS (optional, space-separated model names; default empty = all)
|
|
13
|
+
- WINDOW_MODE=1 to emit per-turn user→assistant examples
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import json
|
|
18
|
+
import math
|
|
19
|
+
import os
|
|
20
|
+
import tomllib
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
# Preferred path (modal-specific)
|
|
26
|
+
from synth_ai.environments.examples.crafter_classic.agent_demos.crafter_modal_ft.filter_traces_sft_turso import ( # type: ignore
|
|
27
|
+
FinetuningDataExtractorV3,
|
|
28
|
+
)
|
|
29
|
+
except Exception: # pragma: no cover
|
|
30
|
+
try:
|
|
31
|
+
# Fallback path used in some dist builds
|
|
32
|
+
from synth_ai.environments.examples.crafter_classic.agent_demos.crafter_openai_ft.filter_traces_sft_turso import ( # type: ignore
|
|
33
|
+
FinetuningDataExtractorV3,
|
|
34
|
+
)
|
|
35
|
+
except Exception as _import_err: # pragma: no cover
|
|
36
|
+
raise ImportError(
|
|
37
|
+
"Could not import FinetuningDataExtractorV3 from synth_ai.") from _import_err
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def env_list(name: str) -> list[str]:
|
|
41
|
+
val = os.getenv(name, "").strip()
|
|
42
|
+
return val.split() if val else []
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def normalize_db_url(raw: str) -> str:
|
|
46
|
+
# Accept file path or sqlite URLs; ensure async driver prefix
|
|
47
|
+
if raw.endswith(".db") and not raw.startswith("sqlite"):
|
|
48
|
+
return f"sqlite+aiosqlite:///{raw}"
|
|
49
|
+
if raw.startswith("sqlite+aiosqlite///"):
|
|
50
|
+
return raw
|
|
51
|
+
if raw.startswith("sqlite///") and raw.endswith(".db"):
|
|
52
|
+
return raw.replace("sqlite///", "sqlite+aiosqlite///")
|
|
53
|
+
return raw
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_filters() -> dict[str, Any]:
|
|
57
|
+
cfg_default = Path(__file__).with_name("config.toml")
|
|
58
|
+
cfg_path = os.getenv("CRAFTER_CONFIG", str(cfg_default))
|
|
59
|
+
cfg: dict[str, Any] = {}
|
|
60
|
+
if os.path.exists(cfg_path):
|
|
61
|
+
with open(cfg_path, "rb") as f:
|
|
62
|
+
cfg = tomllib.load(f)
|
|
63
|
+
fcfg = cfg.get("filter", {})
|
|
64
|
+
# Default: no required achievements gating unless provided via env/config
|
|
65
|
+
req = set(env_list("REQUIRED_ACHIEVEMENTS") or fcfg.get("required_achievements", []))
|
|
66
|
+
models = env_list("MODELS")
|
|
67
|
+
# Default: allow zero reward unless overridden
|
|
68
|
+
min_reward = float(os.getenv("MIN_TOTAL_REWARD", str(fcfg.get("min_total_reward", 0.0))))
|
|
69
|
+
max_cost = float(os.getenv("MAX_COST", str(fcfg.get("max_cost", math.inf))))
|
|
70
|
+
max_tokens = int(os.getenv("MAX_TOKENS", str(fcfg.get("max_tokens", 1_000_000_000))))
|
|
71
|
+
return {
|
|
72
|
+
"required_achievements": req,
|
|
73
|
+
"models": models,
|
|
74
|
+
"min_total_reward": min_reward,
|
|
75
|
+
"max_cost": max_cost,
|
|
76
|
+
"max_tokens": max_tokens,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def main() -> None:
|
|
81
|
+
cfg_default = Path(__file__).with_name("config.toml")
|
|
82
|
+
cfg_path = os.getenv("CRAFTER_CONFIG", str(cfg_default))
|
|
83
|
+
cfg: dict[str, Any] = {}
|
|
84
|
+
if os.path.exists(cfg_path):
|
|
85
|
+
with open(cfg_path, "rb") as f:
|
|
86
|
+
cfg = tomllib.load(f)
|
|
87
|
+
fcfg = cfg.get("filter", {})
|
|
88
|
+
tcfg = cfg.get("traces", {})
|
|
89
|
+
|
|
90
|
+
# Prefer env; else derive from config or repo-local v3 path
|
|
91
|
+
raw_db_url = os.getenv("CRAFTER_DB_URL", "")
|
|
92
|
+
if not raw_db_url:
|
|
93
|
+
db_path = fcfg.get("db_path")
|
|
94
|
+
if not db_path and tcfg.get("sqld_db_path"):
|
|
95
|
+
# derive the internal data file path from the sqld dir
|
|
96
|
+
db_path = str(Path(tcfg["sqld_db_path"]) / "dbs" / "default" / "data")
|
|
97
|
+
if db_path:
|
|
98
|
+
raw_db_url = f"sqlite+aiosqlite:///{db_path}"
|
|
99
|
+
else:
|
|
100
|
+
# Try repo-local default: traces/v3/synth_ai.db/dbs/default/data
|
|
101
|
+
repo_root = Path(__file__).resolve().parents[3]
|
|
102
|
+
candidate = repo_root / "traces" / "v3" / "synth_ai.db" / "dbs" / "default" / "data"
|
|
103
|
+
raw_db_url = f"sqlite+aiosqlite:///{candidate}"
|
|
104
|
+
db_url = normalize_db_url(raw_db_url)
|
|
105
|
+
output_path = os.getenv(
|
|
106
|
+
"OUTPUT_JSONL", fcfg.get("output_jsonl", "ft_data/qwen4b_crafter_sft_ach.jsonl")
|
|
107
|
+
)
|
|
108
|
+
filters = build_filters()
|
|
109
|
+
# Default: require >1 achievements unless explicitly specified in config/env
|
|
110
|
+
# If caller set REQUIRED_ACHIEVEMENTS or provided config 'required_achievements', we won't override.
|
|
111
|
+
# Otherwise, enforce min achievements via MIN_ACHIEVEMENTS (default 2)
|
|
112
|
+
if not filters.get("required_achievements"):
|
|
113
|
+
try:
|
|
114
|
+
min_ach = int(os.getenv("MIN_ACHIEVEMENTS", str(fcfg.get("min_achievements", 2))))
|
|
115
|
+
except Exception:
|
|
116
|
+
min_ach = 2
|
|
117
|
+
filters["min_achievements"] = min_ach
|
|
118
|
+
|
|
119
|
+
window_mode = os.getenv("WINDOW_MODE", "0") == "1"
|
|
120
|
+
|
|
121
|
+
print("🤖 Modal/Synth FT Filter (achievements)")
|
|
122
|
+
print("Using database:", db_url)
|
|
123
|
+
print("Output file:", output_path)
|
|
124
|
+
print(
|
|
125
|
+
"Filters:",
|
|
126
|
+
json.dumps(
|
|
127
|
+
{k: (list(v) if isinstance(v, set) else v) for k, v in filters.items()}, indent=2
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
print("Window mode:", window_mode)
|
|
131
|
+
|
|
132
|
+
# Print distributions (achievements and rewards) before filtering for visibility
|
|
133
|
+
try:
|
|
134
|
+
import numpy as _np
|
|
135
|
+
from collections import Counter as _Counter
|
|
136
|
+
async with FinetuningDataExtractorV3(db_url) as _ex:
|
|
137
|
+
_sessions = await _ex.get_all_sessions()
|
|
138
|
+
_ach_counts: _Counter[str] = _Counter()
|
|
139
|
+
_rewards: list[float] = []
|
|
140
|
+
for _, _row in _sessions.iterrows():
|
|
141
|
+
_sid = _row["session_id"]
|
|
142
|
+
_ach = await _ex.get_session_achievements(_sid) or []
|
|
143
|
+
for _a in _ach:
|
|
144
|
+
_ach_counts[_a] += 1
|
|
145
|
+
_met = await _ex.get_session_metrics(_sid)
|
|
146
|
+
try:
|
|
147
|
+
_rewards.append(float(_met.get("total_reward", 0.0) or 0.0))
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
print(f"\nTotal sessions: {len(_sessions)}")
|
|
151
|
+
if _ach_counts:
|
|
152
|
+
print("\nAchievements by session (count):")
|
|
153
|
+
for _name, _c in sorted(_ach_counts.items(), key=lambda x: (-x[1], x[0])):
|
|
154
|
+
print(f" {_name}: {_c}")
|
|
155
|
+
if _rewards:
|
|
156
|
+
_r = _np.array(_rewards, dtype=float)
|
|
157
|
+
print("\nReward stats:")
|
|
158
|
+
print(f" min={_r.min():.2f} median={_np.median(_r):.2f} mean={_r.mean():.2f} max={_r.max():.2f}")
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
required: set[str] = filters["required_achievements"]
|
|
163
|
+
models: list[str] = filters["models"]
|
|
164
|
+
min_reward: float = filters["min_total_reward"]
|
|
165
|
+
max_cost: float = filters["max_cost"]
|
|
166
|
+
max_tokens: int = filters["max_tokens"]
|
|
167
|
+
|
|
168
|
+
stats: dict[str, Any] = {
|
|
169
|
+
"total_sessions": 0,
|
|
170
|
+
"kept_sessions": 0,
|
|
171
|
+
"total_examples": 0,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async with FinetuningDataExtractorV3(db_url) as extractor:
|
|
175
|
+
all_sessions = await extractor.get_all_sessions()
|
|
176
|
+
stats["total_sessions"] = len(all_sessions)
|
|
177
|
+
|
|
178
|
+
kept: list[str] = []
|
|
179
|
+
for _, row in all_sessions.iterrows():
|
|
180
|
+
session_id = row["session_id"]
|
|
181
|
+
metrics = await extractor.get_session_metrics(session_id)
|
|
182
|
+
|
|
183
|
+
if metrics["total_reward"] < min_reward:
|
|
184
|
+
continue
|
|
185
|
+
if metrics["total_cost"] > max_cost:
|
|
186
|
+
continue
|
|
187
|
+
if metrics["total_tokens"] > max_tokens:
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
if models:
|
|
191
|
+
model_query = """
|
|
192
|
+
SELECT DISTINCT model_name
|
|
193
|
+
FROM events
|
|
194
|
+
WHERE session_id = :session_id
|
|
195
|
+
AND event_type = 'cais'
|
|
196
|
+
AND model_name IS NOT NULL
|
|
197
|
+
"""
|
|
198
|
+
model_df = await extractor.db_manager.query_traces(
|
|
199
|
+
model_query, {"session_id": session_id}
|
|
200
|
+
)
|
|
201
|
+
session_models = model_df["model_name"].tolist() if not model_df.empty else []
|
|
202
|
+
if not any(m in models for m in session_models):
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
# Respect either explicit required achievements OR min_achievements fallback
|
|
206
|
+
min_ach = int(filters.get("min_achievements", 0))
|
|
207
|
+
if required or min_ach > 0:
|
|
208
|
+
achievements = await extractor.get_session_achievements(session_id)
|
|
209
|
+
if not achievements:
|
|
210
|
+
continue
|
|
211
|
+
if required:
|
|
212
|
+
if not (required & set(achievements)):
|
|
213
|
+
continue
|
|
214
|
+
else:
|
|
215
|
+
if len(achievements) < min_ach:
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
kept.append(session_id)
|
|
219
|
+
|
|
220
|
+
stats["kept_sessions"] = len(kept)
|
|
221
|
+
|
|
222
|
+
if window_mode:
|
|
223
|
+
training_data = await extractor.extract_openai_window_format(kept)
|
|
224
|
+
else:
|
|
225
|
+
training_data = await extractor.extract_openai_format(kept)
|
|
226
|
+
|
|
227
|
+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
|
228
|
+
with open(output_path, "w") as f:
|
|
229
|
+
for ex in training_data:
|
|
230
|
+
f.write(json.dumps(ex) + "\n")
|
|
231
|
+
stats["total_examples"] = len(training_data)
|
|
232
|
+
|
|
233
|
+
print(
|
|
234
|
+
"\n✅ Wrote", stats["total_examples"], "examples from", stats["kept_sessions"], "sessions"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
if __name__ == "__main__":
|
|
239
|
+
asyncio.run(main())
|