DeltaZN commited on
Commit
ea2442a
·
1 Parent(s): 7a273d3

Add Modal LLM backends and agent memory updates

Browse files
README.md CHANGED
@@ -74,7 +74,11 @@ applied by the server as base damage plus a deterministic random increase from 0
74
  to 5. Dead NPCs cannot act, but remain in the world at their death position.
75
  The model chooses only an action. It does not write memories directly; after all
76
  actions are applied, the simulation records outcome observations such as movement
77
- results, speech heard, attacks received, blocked movement, and death.
 
 
 
 
78
 
79
  The UI also exposes a God Console when the API runs with an OpenAI-compatible
80
  connector. It sends freeform commands such as "Ada wants to kill everyone",
@@ -83,6 +87,11 @@ applies the returned tool calls to the world state. Current God Console tools
83
  can set NPC health, attack damage, personality, divine directive, memory, and
84
  position.
85
 
 
 
 
 
 
86
  `config/game.llm.local.json` defaults to OpenRouter's
87
  `nvidia/nemotron-3-ultra-550b-a55b` model. Set your OpenRouter API key before
88
  starting the server:
@@ -109,6 +118,92 @@ The connector choice is binary: `connector.type: "deterministic"` makes every
109
  tick deterministic, while `connector.type: "openai_compatible"` asks the model
110
  on every tick.
111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  ## Architecture
113
 
114
  The core rule is that the Python simulation owns meaning, and the 3D client owns display.
 
74
  to 5. Dead NPCs cannot act, but remain in the world at their death position.
75
  The model chooses only an action. It does not write memories directly; after all
76
  actions are applied, the simulation records outcome observations such as movement
77
+ results, speech heard, attacks received, and death.
78
+ Routine movement is not stored as memory because the NPC already receives its
79
+ current position every tick. Older meaningful memories are compacted into a
80
+ rolling `memory_summary`, while the latest five meaningful memories remain as
81
+ individual observations.
82
 
83
  The UI also exposes a God Console when the API runs with an OpenAI-compatible
84
  connector. It sends freeform commands such as "Ada wants to kill everyone",
 
87
  can set NPC health, attack damage, personality, divine directive, memory, and
88
  position.
89
 
90
+ God Console can use a separate OpenAI-compatible connector via `god_console`.
91
+ The active Modal config keeps NPC ticks on the H100 Nemotron endpoint and
92
+ defaults God Console to a Qwen3.6-27B-FP8 H100 endpoint. Override these with
93
+ `GOD_CONSOLE_QWEN_BASE_URL` and `GOD_CONSOLE_QWEN_MODEL`.
94
+
95
  `config/game.llm.local.json` defaults to OpenRouter's
96
  `nvidia/nemotron-3-ultra-550b-a55b` model. Set your OpenRouter API key before
97
  starting the server:
 
118
  tick deterministic, while `connector.type: "openai_compatible"` asks the model
119
  on every tick.
120
 
121
+ When the UI first loads, it fetches `/claw3d/state?warmup=1`. That tells the
122
+ backend to start fire-and-forget health warmups for configured Modal model
123
+ endpoints. Each resolved `.modal.run/health` URL is pinged in a daemon thread
124
+ without a request timeout. Normal UI refreshes only read the latest model
125
+ statuses. The UI shows each model as `checking`, `warmup` if no response has
126
+ arrived after two seconds, or `ready` after HTTP 200.
127
+
128
+ For Nemotron 3 Nano tool calling, the Modal configs use reasoning-off chat
129
+ template kwargs and NVIDIA's recommended tool-call sampling values:
130
+ `temperature: 0.6` and `top_p: 0.95`.
131
+
132
+ ### Modal Nemotron Nano
133
+
134
+ This repo includes a Modal deployment for
135
+ `nvidia/NVIDIA-Nemotron-3-Nano-4B-FP8` using vLLM's OpenAI-compatible server:
136
+
137
+ ```powershell
138
+ uv run python -m modal setup
139
+ uv run python -m modal deploy deployments/modal_nemotron_nano.py
140
+ ```
141
+
142
+ The deploy command prints a Modal URL such as:
143
+
144
+ ```text
145
+ https://<workspace>--god-simulator-nemotron3-nano-serve.modal.run
146
+ ```
147
+
148
+ Set the game connector base URL to the Modal endpoint's OpenAI-compatible `/v1`
149
+ path, then start the API with the Modal config:
150
+
151
+ ```powershell
152
+ $env:MODAL_NEMOTRON_BASE_URL="https://<workspace>--god-simulator-nemotron3-nano-serve.modal.run/v1"
153
+ $env:MODAL_NEMOTRON_MODEL="nemotron3-nano-4b-fp8"
154
+ uv run python -m god_simulator run --config config/game.modal.local.json
155
+ ```
156
+
157
+ `config/game.modal.local.json` is the active Modal profile: NPC decisions use
158
+ the H100 Nemotron Nano endpoint, and God Console uses the Qwen3.6 H100 endpoint.
159
+
160
+ To run a Modal smoke test that checks health, chat completions, and tool calls:
161
+
162
+ ```powershell
163
+ $env:MODAL_NEMOTRON_ENDPOINT="https://<workspace>--god-simulator-nemotron3-nano-serve.modal.run"
164
+ uv run python deployments/smoke_modal_endpoint.py
165
+ ```
166
+
167
+ ### Modal Qwen3.6 God Console
168
+
169
+ God Console uses `Qwen/Qwen3.6-27B-FP8` on a single H100. The FP8 checkpoint is
170
+ used because the official vLLM recipe lists BF16 Qwen3.6-27B as a 2xH100 setup,
171
+ while FP8 fits a single H100. Deploy it with:
172
+
173
+ ```powershell
174
+ uv run python -X utf8 -m modal deploy deployments/modal_qwen36_27b_h100.py
175
+ uv run python deployments/smoke_modal_endpoint.py https://<workspace>--god-simulator-qwen36-27b-h100-serve.modal.run --model qwen3.6-27b-h100-fp8
176
+ ```
177
+
178
+ For cheaper development runs, deploy the T4 variant. It uses the BF16 checkpoint
179
+ loaded as float16, lower context, one vLLM sequence, and one Modal container:
180
+
181
+ ```powershell
182
+ uv run python -X utf8 -m modal deploy deployments/modal_nemotron_nano_t4.py
183
+ uv run python deployments/smoke_modal_endpoint.py https://<workspace>--god-simulator-nemotron3-nano-t4-serve.modal.run --model nemotron3-nano-4b-t4-fp16
184
+ uv run python -m god_simulator run --config config/game.modal.t4.local.json
185
+ ```
186
+
187
+ The A10 variant is a better low-cost target for the full game tick than T4. It
188
+ uses the BF16 checkpoint loaded as float16, one vLLM sequence, one Modal
189
+ container, and keeps the full action response token budget:
190
+
191
+ ```powershell
192
+ uv run python -X utf8 -m modal deploy deployments/modal_nemotron_nano_a10.py
193
+ uv run python deployments/smoke_modal_endpoint.py https://<workspace>--god-simulator-nemotron3-nano-a10-serve.modal.run --model nemotron3-nano-4b-a10-fp16
194
+ uv run python -m god_simulator run --config config/game.modal.a10.local.json
195
+ ```
196
+
197
+ For a high-throughput single-container endpoint, deploy the B200 variant. It
198
+ uses the FP8 checkpoint, one Modal container, and allows the backend to dispatch
199
+ NPC decisions in parallel batches:
200
+
201
+ ```powershell
202
+ uv run python -X utf8 -m modal deploy deployments/modal_nemotron_nano_b200.py
203
+ uv run python deployments/smoke_modal_endpoint.py https://<workspace>--god-simulator-nemotron3-nano-b200-serve.modal.run --model nemotron3-nano-4b-b200-fp8
204
+ uv run python -m god_simulator run --config config/game.modal.b200.local.json
205
+ ```
206
+
207
  ## Architecture
208
 
209
  The core rule is that the Python simulation owns meaning, and the 3D client owns display.
config/game.llm.local.json CHANGED
@@ -6,7 +6,7 @@
6
  "seed": 42
7
  },
8
  "npcs": {
9
- "count": 5
10
  },
11
  "simulation": {
12
  "tick_ms": 500
@@ -24,8 +24,27 @@
24
  "model": "nvidia/nemotron-3-ultra-550b-a55b",
25
  "api_key_env": "OPENROUTER_API_KEY",
26
  "timeout_seconds": 300,
 
 
27
  "max_parallel_npc_calls": 5,
28
  "fallback_to_deterministic": true,
29
  "extra_body": {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  }
31
  }
 
6
  "seed": 42
7
  },
8
  "npcs": {
9
+ "count": 10
10
  },
11
  "simulation": {
12
  "tick_ms": 500
 
24
  "model": "nvidia/nemotron-3-ultra-550b-a55b",
25
  "api_key_env": "OPENROUTER_API_KEY",
26
  "timeout_seconds": 300,
27
+ "temperature": 0.6,
28
+ "top_p": 0.95,
29
  "max_parallel_npc_calls": 5,
30
  "fallback_to_deterministic": true,
31
  "extra_body": {}
32
+ },
33
+ "god_console": {
34
+ "type": "openai_compatible",
35
+ "base_url_env": "GOD_CONSOLE_QWEN_BASE_URL",
36
+ "model_env": "GOD_CONSOLE_QWEN_MODEL",
37
+ "base_url": "https://georgij-sawin--god-simulator-qwen36-27b-h100-serve.modal.run/v1",
38
+ "model": "qwen3.6-27b-h100-fp8",
39
+ "timeout_seconds": 600,
40
+ "max_tokens": 1200,
41
+ "temperature": 0.6,
42
+ "top_p": 0.95,
43
+ "tool_choice": "required",
44
+ "extra_body": {
45
+ "chat_template_kwargs": {
46
+ "enable_thinking": false
47
+ }
48
+ }
49
  }
50
  }
config/game.local.json CHANGED
@@ -6,7 +6,7 @@
6
  "seed": 42
7
  },
8
  "npcs": {
9
- "count": 5
10
  },
11
  "simulation": {
12
  "tick_ms": 500
 
6
  "seed": 42
7
  },
8
  "npcs": {
9
+ "count": 10
10
  },
11
  "simulation": {
12
  "tick_ms": 500
config/game.modal.a10.local.json ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "world": {
3
+ "width": 80,
4
+ "depth": 80,
5
+ "terrain": "plain_green",
6
+ "seed": 42
7
+ },
8
+ "npcs": {
9
+ "count": 10
10
+ },
11
+ "simulation": {
12
+ "tick_ms": 500
13
+ },
14
+ "server": {
15
+ "host": "127.0.0.1",
16
+ "port": 8000
17
+ },
18
+ "connector": {
19
+ "type": "openai_compatible",
20
+ "base_url_env": "MODAL_NEMOTRON_A10_BASE_URL",
21
+ "model_env": "MODAL_NEMOTRON_A10_MODEL",
22
+ "base_url": "https://georgij-sawin--god-simulator-nemotron3-nano-a10-serve.modal.run/v1",
23
+ "model": "nemotron3-nano-4b-a10-fp16",
24
+ "timeout_seconds": 600,
25
+ "max_tokens": 1400,
26
+ "temperature": 0.6,
27
+ "top_p": 0.95,
28
+ "tool_choice": "required",
29
+ "max_parallel_npc_calls": 1,
30
+ "fallback_to_deterministic": true,
31
+ "extra_body": {
32
+ "chat_template_kwargs": {
33
+ "enable_thinking": false
34
+ }
35
+ }
36
+ },
37
+ "god_console": {
38
+ "type": "openai_compatible",
39
+ "base_url_env": "GOD_CONSOLE_QWEN_BASE_URL",
40
+ "model_env": "GOD_CONSOLE_QWEN_MODEL",
41
+ "base_url": "https://georgij-sawin--god-simulator-qwen36-27b-h100-serve.modal.run/v1",
42
+ "model": "qwen3.6-27b-h100-fp8",
43
+ "timeout_seconds": 600,
44
+ "max_tokens": 1200,
45
+ "temperature": 0.6,
46
+ "top_p": 0.95,
47
+ "tool_choice": "required",
48
+ "extra_body": {
49
+ "chat_template_kwargs": {
50
+ "enable_thinking": false
51
+ }
52
+ }
53
+ }
54
+ }
config/game.modal.b200.local.json ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "world": {
3
+ "width": 80,
4
+ "depth": 80,
5
+ "terrain": "plain_green",
6
+ "seed": 42
7
+ },
8
+ "npcs": {
9
+ "count": 10
10
+ },
11
+ "simulation": {
12
+ "tick_ms": 500
13
+ },
14
+ "server": {
15
+ "host": "127.0.0.1",
16
+ "port": 8000
17
+ },
18
+ "connector": {
19
+ "type": "openai_compatible",
20
+ "base_url_env": "MODAL_NEMOTRON_B200_BASE_URL",
21
+ "model_env": "MODAL_NEMOTRON_B200_MODEL",
22
+ "base_url": "https://georgij-sawin--god-simulator-nemotron3-nano-b200-serve.modal.run/v1",
23
+ "model": "nemotron3-nano-4b-b200-fp8",
24
+ "timeout_seconds": 600,
25
+ "max_tokens": 1400,
26
+ "temperature": 0.6,
27
+ "top_p": 0.95,
28
+ "tool_choice": "required",
29
+ "max_parallel_npc_calls": 5,
30
+ "fallback_to_deterministic": true,
31
+ "extra_body": {
32
+ "chat_template_kwargs": {
33
+ "enable_thinking": false
34
+ }
35
+ }
36
+ },
37
+ "god_console": {
38
+ "type": "openai_compatible",
39
+ "base_url_env": "GOD_CONSOLE_QWEN_BASE_URL",
40
+ "model_env": "GOD_CONSOLE_QWEN_MODEL",
41
+ "base_url": "https://georgij-sawin--god-simulator-qwen36-27b-h100-serve.modal.run/v1",
42
+ "model": "qwen3.6-27b-h100-fp8",
43
+ "timeout_seconds": 600,
44
+ "max_tokens": 1200,
45
+ "temperature": 0.6,
46
+ "top_p": 0.95,
47
+ "tool_choice": "required",
48
+ "extra_body": {
49
+ "chat_template_kwargs": {
50
+ "enable_thinking": false
51
+ }
52
+ }
53
+ }
54
+ }
config/game.modal.local.json ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "world": {
3
+ "width": 80,
4
+ "depth": 80,
5
+ "terrain": "plain_green",
6
+ "seed": 42
7
+ },
8
+ "npcs": {
9
+ "count": 10
10
+ },
11
+ "simulation": {
12
+ "tick_ms": 500
13
+ },
14
+ "server": {
15
+ "host": "127.0.0.1",
16
+ "port": 8000
17
+ },
18
+ "connector": {
19
+ "type": "openai_compatible",
20
+ "base_url_env": "MODAL_NEMOTRON_BASE_URL",
21
+ "model_env": "MODAL_NEMOTRON_MODEL",
22
+ "base_url": "https://georgij-sawin--god-simulator-nemotron3-nano-serve.modal.run/v1",
23
+ "model": "nemotron3-nano-4b-fp8",
24
+ "timeout_seconds": 600,
25
+ "max_tokens": 1400,
26
+ "temperature": 0.6,
27
+ "top_p": 0.95,
28
+ "tool_choice": "required",
29
+ "max_parallel_npc_calls": 8,
30
+ "fallback_to_deterministic": true,
31
+ "extra_body": {
32
+ "chat_template_kwargs": {
33
+ "enable_thinking": false
34
+ }
35
+ }
36
+ },
37
+ "god_console": {
38
+ "type": "openai_compatible",
39
+ "base_url_env": "GOD_CONSOLE_QWEN_BASE_URL",
40
+ "model_env": "GOD_CONSOLE_QWEN_MODEL",
41
+ "base_url": "https://georgij-sawin--god-simulator-qwen36-27b-h100-serve.modal.run/v1",
42
+ "model": "qwen3.6-27b-h100-fp8",
43
+ "timeout_seconds": 600,
44
+ "max_tokens": 1200,
45
+ "temperature": 0.6,
46
+ "top_p": 0.95,
47
+ "tool_choice": "required",
48
+ "extra_body": {
49
+ "chat_template_kwargs": {
50
+ "enable_thinking": false
51
+ }
52
+ }
53
+ }
54
+ }
config/game.modal.t4.local.json ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "world": {
3
+ "width": 80,
4
+ "depth": 80,
5
+ "terrain": "plain_green",
6
+ "seed": 42
7
+ },
8
+ "npcs": {
9
+ "count": 10
10
+ },
11
+ "simulation": {
12
+ "tick_ms": 500
13
+ },
14
+ "server": {
15
+ "host": "127.0.0.1",
16
+ "port": 8000
17
+ },
18
+ "connector": {
19
+ "type": "openai_compatible",
20
+ "base_url_env": "MODAL_NEMOTRON_T4_BASE_URL",
21
+ "model_env": "MODAL_NEMOTRON_T4_MODEL",
22
+ "base_url": "https://georgij-sawin--god-simulator-nemotron3-nano-t4-serve.modal.run/v1",
23
+ "model": "nemotron3-nano-4b-t4-fp16",
24
+ "timeout_seconds": 300,
25
+ "max_tokens": 256,
26
+ "temperature": 0.6,
27
+ "top_p": 0.95,
28
+ "tool_choice": "required",
29
+ "max_parallel_npc_calls": 1,
30
+ "fallback_to_deterministic": true,
31
+ "extra_body": {
32
+ "chat_template_kwargs": {
33
+ "enable_thinking": false
34
+ }
35
+ }
36
+ },
37
+ "god_console": {
38
+ "type": "openai_compatible",
39
+ "base_url_env": "GOD_CONSOLE_QWEN_BASE_URL",
40
+ "model_env": "GOD_CONSOLE_QWEN_MODEL",
41
+ "base_url": "https://georgij-sawin--god-simulator-qwen36-27b-h100-serve.modal.run/v1",
42
+ "model": "qwen3.6-27b-h100-fp8",
43
+ "timeout_seconds": 600,
44
+ "max_tokens": 1200,
45
+ "temperature": 0.6,
46
+ "top_p": 0.95,
47
+ "tool_choice": "required",
48
+ "extra_body": {
49
+ "chat_template_kwargs": {
50
+ "enable_thinking": false
51
+ }
52
+ }
53
+ }
54
+ }
deployments/modal_nemotron_nano.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+
6
+ import modal
7
+
8
+ MINUTES = 60
9
+
10
+ APP_NAME = "god-simulator-nemotron3-nano"
11
+ MODEL_NAME = "nvidia/NVIDIA-Nemotron-3-Nano-4B-FP8"
12
+ SERVED_MODEL_NAME = "nemotron3-nano-4b-fp8"
13
+ REASONING_PARSER_URL = (
14
+ "https://huggingface.co/nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16/"
15
+ "resolve/main/nano_v3_reasoning_parser.py"
16
+ )
17
+ REASONING_PARSER_PATH = "/root/nano_v3_reasoning_parser.py"
18
+
19
+ VLLM_PORT = 8000
20
+ GPU_TYPE = os.environ.get("MODAL_NEMOTRON_GPU", "H100")
21
+ MAX_MODEL_LEN = int(os.environ.get("MODAL_NEMOTRON_MAX_MODEL_LEN", "32768"))
22
+ MAX_NUM_SEQS = int(os.environ.get("MODAL_NEMOTRON_MAX_NUM_SEQS", "8"))
23
+ TARGET_INPUTS = int(os.environ.get("MODAL_NEMOTRON_TARGET_INPUTS", "8"))
24
+
25
+ hf_cache_vol = modal.Volume.from_name("huggingface-cache", create_if_missing=True)
26
+ vllm_cache_vol = modal.Volume.from_name("vllm-cache", create_if_missing=True)
27
+
28
+ vllm_image = (
29
+ modal.Image.from_registry(
30
+ "vllm/vllm-openai:v0.15.1",
31
+ setup_dockerfile_commands=["RUN ln -sf $(which python3) /usr/bin/python"],
32
+ )
33
+ .entrypoint([])
34
+ .run_commands(
35
+ "python3 -m pip install --upgrade "
36
+ "'typing_extensions>=4.15.0' 'aiohttp>=3.13.3' 'protobuf>=6.33.5,<7'",
37
+ "python3 -c \"import urllib.request; "
38
+ f"urllib.request.urlretrieve('{REASONING_PARSER_URL}', '{REASONING_PARSER_PATH}')\""
39
+ )
40
+ .env(
41
+ {
42
+ "HF_HUB_CACHE": "/root/.cache/huggingface",
43
+ "HF_XET_HIGH_PERFORMANCE": "1",
44
+ "VLLM_LOG_STATS_INTERVAL": "10",
45
+ "TORCH_CPP_LOG_LEVEL": "FATAL",
46
+ }
47
+ )
48
+ )
49
+
50
+ app = modal.App(APP_NAME)
51
+
52
+
53
+ @app.function(
54
+ image=vllm_image,
55
+ gpu=GPU_TYPE,
56
+ volumes={
57
+ "/root/.cache/huggingface": hf_cache_vol,
58
+ "/root/.cache/vllm": vllm_cache_vol,
59
+ },
60
+ scaledown_window=2 * MINUTES,
61
+ timeout=30 * MINUTES,
62
+ )
63
+ @modal.concurrent(max_inputs=TARGET_INPUTS)
64
+ @modal.web_server(port=VLLM_PORT, startup_timeout=30 * MINUTES)
65
+ def serve() -> None:
66
+ cmd = [
67
+ "vllm",
68
+ "serve",
69
+ MODEL_NAME,
70
+ "--served-model-name",
71
+ SERVED_MODEL_NAME,
72
+ "--host",
73
+ "0.0.0.0",
74
+ "--port",
75
+ str(VLLM_PORT),
76
+ "--max-num-seqs",
77
+ str(MAX_NUM_SEQS),
78
+ "--tensor-parallel-size",
79
+ "1",
80
+ "--max-model-len",
81
+ str(MAX_MODEL_LEN),
82
+ "--trust-remote-code",
83
+ "--mamba_ssm_cache_dtype",
84
+ "float32",
85
+ "--enable-auto-tool-choice",
86
+ "--tool-call-parser",
87
+ "qwen3_coder",
88
+ "--reasoning-parser-plugin",
89
+ REASONING_PARSER_PATH,
90
+ "--reasoning-parser",
91
+ "nano_v3",
92
+ "--kv-cache-dtype",
93
+ "fp8",
94
+ "--gpu-memory-utilization",
95
+ "0.9",
96
+ ]
97
+ print(*cmd, flush=True)
98
+ subprocess.Popen(cmd)
deployments/modal_nemotron_nano_a10.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+
6
+ import modal
7
+
8
+ MINUTES = 60
9
+
10
+ APP_NAME = "god-simulator-nemotron3-nano-a10"
11
+ MODEL_NAME = "nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16"
12
+ SERVED_MODEL_NAME = "nemotron3-nano-4b-a10-fp16"
13
+ REASONING_PARSER_URL = (
14
+ "https://huggingface.co/nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16/"
15
+ "resolve/main/nano_v3_reasoning_parser.py"
16
+ )
17
+ REASONING_PARSER_PATH = "/root/nano_v3_reasoning_parser.py"
18
+
19
+ VLLM_PORT = 8000
20
+ GPU_TYPE = os.environ.get("MODAL_NEMOTRON_A10_GPU", "A10G")
21
+ MAX_MODEL_LEN = int(os.environ.get("MODAL_NEMOTRON_A10_MAX_MODEL_LEN", "4096"))
22
+ MAX_NUM_SEQS = int(os.environ.get("MODAL_NEMOTRON_A10_MAX_NUM_SEQS", "1"))
23
+ TARGET_INPUTS = int(os.environ.get("MODAL_NEMOTRON_A10_TARGET_INPUTS", "1"))
24
+
25
+ hf_cache_vol = modal.Volume.from_name("huggingface-cache", create_if_missing=True)
26
+ vllm_cache_vol = modal.Volume.from_name("vllm-cache", create_if_missing=True)
27
+
28
+ vllm_image = (
29
+ modal.Image.from_registry(
30
+ "vllm/vllm-openai:v0.15.1",
31
+ setup_dockerfile_commands=["RUN ln -sf $(which python3) /usr/bin/python"],
32
+ )
33
+ .entrypoint([])
34
+ .run_commands(
35
+ "python3 -m pip install --upgrade "
36
+ "'typing_extensions>=4.15.0' 'aiohttp>=3.13.3' 'protobuf>=6.33.5,<7'",
37
+ "python3 -c \"import urllib.request; "
38
+ f"urllib.request.urlretrieve('{REASONING_PARSER_URL}', '{REASONING_PARSER_PATH}')\""
39
+ )
40
+ .env(
41
+ {
42
+ "HF_HUB_CACHE": "/root/.cache/huggingface",
43
+ "HF_XET_HIGH_PERFORMANCE": "1",
44
+ "VLLM_LOG_STATS_INTERVAL": "10",
45
+ "TORCH_CPP_LOG_LEVEL": "FATAL",
46
+ }
47
+ )
48
+ )
49
+
50
+ app = modal.App(APP_NAME)
51
+
52
+
53
+ @app.function(
54
+ image=vllm_image,
55
+ gpu=GPU_TYPE,
56
+ volumes={
57
+ "/root/.cache/huggingface": hf_cache_vol,
58
+ "/root/.cache/vllm": vllm_cache_vol,
59
+ },
60
+ max_containers=1,
61
+ scaledown_window=2 * MINUTES,
62
+ timeout=30 * MINUTES,
63
+ )
64
+ @modal.concurrent(max_inputs=TARGET_INPUTS, target_inputs=TARGET_INPUTS)
65
+ @modal.web_server(port=VLLM_PORT, startup_timeout=30 * MINUTES)
66
+ def serve() -> None:
67
+ cmd = [
68
+ "vllm",
69
+ "serve",
70
+ MODEL_NAME,
71
+ "--served-model-name",
72
+ SERVED_MODEL_NAME,
73
+ "--host",
74
+ "0.0.0.0",
75
+ "--port",
76
+ str(VLLM_PORT),
77
+ "--max-num-seqs",
78
+ str(MAX_NUM_SEQS),
79
+ "--tensor-parallel-size",
80
+ "1",
81
+ "--max-model-len",
82
+ str(MAX_MODEL_LEN),
83
+ "--dtype",
84
+ "float16",
85
+ "--trust-remote-code",
86
+ "--mamba_ssm_cache_dtype",
87
+ "float32",
88
+ "--enable-auto-tool-choice",
89
+ "--tool-call-parser",
90
+ "qwen3_coder",
91
+ "--reasoning-parser-plugin",
92
+ REASONING_PARSER_PATH,
93
+ "--reasoning-parser",
94
+ "nano_v3",
95
+ "--gpu-memory-utilization",
96
+ "0.85",
97
+ "--enforce-eager",
98
+ ]
99
+ print(*cmd, flush=True)
100
+ subprocess.Popen(cmd)
deployments/modal_nemotron_nano_b200.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+
6
+ import modal
7
+
8
+ MINUTES = 60
9
+
10
+ APP_NAME = "god-simulator-nemotron3-nano-b200"
11
+ MODEL_NAME = "nvidia/NVIDIA-Nemotron-3-Nano-4B-FP8"
12
+ SERVED_MODEL_NAME = "nemotron3-nano-4b-b200-fp8"
13
+ REASONING_PARSER_URL = (
14
+ "https://huggingface.co/nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16/"
15
+ "resolve/main/nano_v3_reasoning_parser.py"
16
+ )
17
+ REASONING_PARSER_PATH = "/root/nano_v3_reasoning_parser.py"
18
+
19
+ VLLM_PORT = 8000
20
+ GPU_TYPE = os.environ.get("MODAL_NEMOTRON_B200_GPU", "B200")
21
+ MAX_MODEL_LEN = int(os.environ.get("MODAL_NEMOTRON_B200_MAX_MODEL_LEN", "32768"))
22
+ MAX_NUM_SEQS = int(os.environ.get("MODAL_NEMOTRON_B200_MAX_NUM_SEQS", "8"))
23
+ TARGET_INPUTS = int(os.environ.get("MODAL_NEMOTRON_B200_TARGET_INPUTS", "5"))
24
+
25
+ hf_cache_vol = modal.Volume.from_name("huggingface-cache", create_if_missing=True)
26
+ vllm_cache_vol = modal.Volume.from_name("vllm-cache", create_if_missing=True)
27
+
28
+ vllm_image = (
29
+ modal.Image.from_registry(
30
+ "vllm/vllm-openai:v0.15.1",
31
+ setup_dockerfile_commands=["RUN ln -sf $(which python3) /usr/bin/python"],
32
+ )
33
+ .entrypoint([])
34
+ .run_commands(
35
+ "python3 -m pip install --upgrade "
36
+ "'typing_extensions>=4.15.0' 'aiohttp>=3.13.3' 'protobuf>=6.33.5,<7'",
37
+ "python3 -c \"import urllib.request; "
38
+ f"urllib.request.urlretrieve('{REASONING_PARSER_URL}', '{REASONING_PARSER_PATH}')\""
39
+ )
40
+ .env(
41
+ {
42
+ "HF_HUB_CACHE": "/root/.cache/huggingface",
43
+ "HF_XET_HIGH_PERFORMANCE": "1",
44
+ "VLLM_LOG_STATS_INTERVAL": "10",
45
+ "TORCH_CPP_LOG_LEVEL": "FATAL",
46
+ }
47
+ )
48
+ )
49
+
50
+ app = modal.App(APP_NAME)
51
+
52
+
53
+ @app.function(
54
+ image=vllm_image,
55
+ gpu=GPU_TYPE,
56
+ volumes={
57
+ "/root/.cache/huggingface": hf_cache_vol,
58
+ "/root/.cache/vllm": vllm_cache_vol,
59
+ },
60
+ max_containers=1,
61
+ scaledown_window=2 * MINUTES,
62
+ timeout=30 * MINUTES,
63
+ )
64
+ @modal.concurrent(max_inputs=TARGET_INPUTS, target_inputs=TARGET_INPUTS)
65
+ @modal.web_server(port=VLLM_PORT, startup_timeout=30 * MINUTES)
66
+ def serve() -> None:
67
+ cmd = [
68
+ "vllm",
69
+ "serve",
70
+ MODEL_NAME,
71
+ "--served-model-name",
72
+ SERVED_MODEL_NAME,
73
+ "--host",
74
+ "0.0.0.0",
75
+ "--port",
76
+ str(VLLM_PORT),
77
+ "--max-num-seqs",
78
+ str(MAX_NUM_SEQS),
79
+ "--tensor-parallel-size",
80
+ "1",
81
+ "--max-model-len",
82
+ str(MAX_MODEL_LEN),
83
+ "--trust-remote-code",
84
+ "--mamba_ssm_cache_dtype",
85
+ "float32",
86
+ "--enable-auto-tool-choice",
87
+ "--tool-call-parser",
88
+ "qwen3_coder",
89
+ "--reasoning-parser-plugin",
90
+ REASONING_PARSER_PATH,
91
+ "--reasoning-parser",
92
+ "nano_v3",
93
+ "--kv-cache-dtype",
94
+ "fp8",
95
+ "--gpu-memory-utilization",
96
+ "0.9",
97
+ ]
98
+ print(*cmd, flush=True)
99
+ subprocess.Popen(cmd)
deployments/modal_nemotron_nano_t4.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+
6
+ import modal
7
+
8
+ MINUTES = 60
9
+
10
+ APP_NAME = "god-simulator-nemotron3-nano-t4"
11
+ MODEL_NAME = "nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16"
12
+ SERVED_MODEL_NAME = "nemotron3-nano-4b-t4-fp16"
13
+ REASONING_PARSER_URL = (
14
+ "https://huggingface.co/nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16/"
15
+ "resolve/main/nano_v3_reasoning_parser.py"
16
+ )
17
+ REASONING_PARSER_PATH = "/root/nano_v3_reasoning_parser.py"
18
+
19
+ VLLM_PORT = 8000
20
+ GPU_TYPE = os.environ.get("MODAL_NEMOTRON_T4_GPU", "T4")
21
+ MAX_MODEL_LEN = int(os.environ.get("MODAL_NEMOTRON_T4_MAX_MODEL_LEN", "2048"))
22
+ MAX_NUM_SEQS = int(os.environ.get("MODAL_NEMOTRON_T4_MAX_NUM_SEQS", "1"))
23
+ TARGET_INPUTS = int(os.environ.get("MODAL_NEMOTRON_T4_TARGET_INPUTS", "1"))
24
+
25
+ hf_cache_vol = modal.Volume.from_name("huggingface-cache", create_if_missing=True)
26
+ vllm_cache_vol = modal.Volume.from_name("vllm-cache", create_if_missing=True)
27
+
28
+ vllm_image = (
29
+ modal.Image.from_registry(
30
+ "vllm/vllm-openai:v0.15.1",
31
+ setup_dockerfile_commands=["RUN ln -sf $(which python3) /usr/bin/python"],
32
+ )
33
+ .entrypoint([])
34
+ .run_commands(
35
+ "python3 -m pip install --upgrade "
36
+ "'typing_extensions>=4.15.0' 'aiohttp>=3.13.3' 'protobuf>=6.33.5,<7'",
37
+ "python3 -c \"import urllib.request; "
38
+ f"urllib.request.urlretrieve('{REASONING_PARSER_URL}', '{REASONING_PARSER_PATH}')\""
39
+ )
40
+ .env(
41
+ {
42
+ "HF_HUB_CACHE": "/root/.cache/huggingface",
43
+ "HF_XET_HIGH_PERFORMANCE": "1",
44
+ "VLLM_LOG_STATS_INTERVAL": "10",
45
+ "TORCH_CPP_LOG_LEVEL": "FATAL",
46
+ }
47
+ )
48
+ )
49
+
50
+ app = modal.App(APP_NAME)
51
+
52
+
53
+ @app.function(
54
+ image=vllm_image,
55
+ gpu=GPU_TYPE,
56
+ volumes={
57
+ "/root/.cache/huggingface": hf_cache_vol,
58
+ "/root/.cache/vllm": vllm_cache_vol,
59
+ },
60
+ max_containers=1,
61
+ scaledown_window=1 * MINUTES,
62
+ timeout=30 * MINUTES,
63
+ )
64
+ @modal.concurrent(max_inputs=TARGET_INPUTS, target_inputs=TARGET_INPUTS)
65
+ @modal.web_server(port=VLLM_PORT, startup_timeout=30 * MINUTES)
66
+ def serve() -> None:
67
+ cmd = [
68
+ "vllm",
69
+ "serve",
70
+ MODEL_NAME,
71
+ "--served-model-name",
72
+ SERVED_MODEL_NAME,
73
+ "--host",
74
+ "0.0.0.0",
75
+ "--port",
76
+ str(VLLM_PORT),
77
+ "--max-num-seqs",
78
+ str(MAX_NUM_SEQS),
79
+ "--tensor-parallel-size",
80
+ "1",
81
+ "--max-model-len",
82
+ str(MAX_MODEL_LEN),
83
+ "--dtype",
84
+ "float16",
85
+ "--trust-remote-code",
86
+ "--mamba_ssm_cache_dtype",
87
+ "float32",
88
+ "--enable-auto-tool-choice",
89
+ "--tool-call-parser",
90
+ "qwen3_coder",
91
+ "--reasoning-parser-plugin",
92
+ REASONING_PARSER_PATH,
93
+ "--reasoning-parser",
94
+ "nano_v3",
95
+ "--gpu-memory-utilization",
96
+ "0.6",
97
+ "--enforce-eager",
98
+ ]
99
+ print(*cmd, flush=True)
100
+ subprocess.Popen(cmd)
deployments/modal_qwen36_27b_h100.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+
6
+ import modal
7
+
8
+ MINUTES = 60
9
+
10
+ APP_NAME = "god-simulator-qwen36-27b-h100"
11
+ MODEL_NAME = "Qwen/Qwen3.6-27B-FP8"
12
+ SERVED_MODEL_NAME = "qwen3.6-27b-h100-fp8"
13
+
14
+ VLLM_PORT = 8000
15
+ GPU_TYPE = os.environ.get("MODAL_QWEN36_GPU", "H100")
16
+ MAX_MODEL_LEN = int(os.environ.get("MODAL_QWEN36_MAX_MODEL_LEN", "32768"))
17
+ MAX_NUM_SEQS = int(os.environ.get("MODAL_QWEN36_MAX_NUM_SEQS", "2"))
18
+ TARGET_INPUTS = int(os.environ.get("MODAL_QWEN36_TARGET_INPUTS", "2"))
19
+
20
+ hf_cache_vol = modal.Volume.from_name("huggingface-cache", create_if_missing=True)
21
+ vllm_cache_vol = modal.Volume.from_name("vllm-cache", create_if_missing=True)
22
+
23
+ vllm_image = (
24
+ modal.Image.from_registry(
25
+ "vllm/vllm-openai:v0.19.0",
26
+ setup_dockerfile_commands=["RUN ln -sf $(which python3) /usr/bin/python"],
27
+ )
28
+ .entrypoint([])
29
+ .run_commands(
30
+ "python3 -m pip install --upgrade "
31
+ "'typing_extensions>=4.15.0' 'aiohttp>=3.13.3' 'protobuf>=6.33.5,<7'"
32
+ )
33
+ .env(
34
+ {
35
+ "HF_HUB_CACHE": "/root/.cache/huggingface",
36
+ "HF_XET_HIGH_PERFORMANCE": "1",
37
+ "VLLM_LOG_STATS_INTERVAL": "10",
38
+ "TORCH_CPP_LOG_LEVEL": "FATAL",
39
+ }
40
+ )
41
+ )
42
+
43
+ app = modal.App(APP_NAME)
44
+
45
+
46
+ @app.function(
47
+ image=vllm_image,
48
+ gpu=GPU_TYPE,
49
+ volumes={
50
+ "/root/.cache/huggingface": hf_cache_vol,
51
+ "/root/.cache/vllm": vllm_cache_vol,
52
+ },
53
+ max_containers=1,
54
+ scaledown_window=2 * MINUTES,
55
+ timeout=30 * MINUTES,
56
+ )
57
+ @modal.concurrent(max_inputs=TARGET_INPUTS, target_inputs=TARGET_INPUTS)
58
+ @modal.web_server(port=VLLM_PORT, startup_timeout=30 * MINUTES)
59
+ def serve() -> None:
60
+ cmd = [
61
+ "vllm",
62
+ "serve",
63
+ MODEL_NAME,
64
+ "--served-model-name",
65
+ SERVED_MODEL_NAME,
66
+ "--host",
67
+ "0.0.0.0",
68
+ "--port",
69
+ str(VLLM_PORT),
70
+ "--max-num-seqs",
71
+ str(MAX_NUM_SEQS),
72
+ "--tensor-parallel-size",
73
+ "1",
74
+ "--max-model-len",
75
+ str(MAX_MODEL_LEN),
76
+ "--trust-remote-code",
77
+ "--enable-auto-tool-choice",
78
+ "--tool-call-parser",
79
+ "qwen3_coder",
80
+ "--reasoning-parser",
81
+ "qwen3",
82
+ "--language-model-only",
83
+ "--kv-cache-dtype",
84
+ "fp8",
85
+ "--gpu-memory-utilization",
86
+ "0.9",
87
+ "--default-chat-template-kwargs",
88
+ '{"enable_thinking": false}',
89
+ ]
90
+ print(*cmd, flush=True)
91
+ subprocess.Popen(cmd)
deployments/smoke_modal_endpoint.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import time
7
+ from typing import Any
8
+ from urllib.error import HTTPError, URLError
9
+ from urllib.request import Request, urlopen
10
+
11
+ DEFAULT_MODEL = "nemotron3-nano-4b-fp8"
12
+
13
+
14
+ def main() -> None:
15
+ parser = argparse.ArgumentParser(
16
+ description="Smoke-test the deployed Modal vLLM OpenAI-compatible endpoint."
17
+ )
18
+ parser.add_argument(
19
+ "endpoint",
20
+ nargs="?",
21
+ default=os.getenv("MODAL_NEMOTRON_ENDPOINT"),
22
+ help="Modal endpoint root URL, for example https://...modal.run.",
23
+ )
24
+ parser.add_argument("--model", default=os.getenv("MODAL_NEMOTRON_MODEL", DEFAULT_MODEL))
25
+ parser.add_argument("--timeout-seconds", type=int, default=1800)
26
+ args = parser.parse_args()
27
+
28
+ if not args.endpoint:
29
+ raise SystemExit(
30
+ "Pass the Modal endpoint URL or set MODAL_NEMOTRON_ENDPOINT. "
31
+ "Use the root URL, not the /v1 path."
32
+ )
33
+
34
+ endpoint = _normalize_root_url(args.endpoint)
35
+ wait_for_health(endpoint, timeout_seconds=args.timeout_seconds)
36
+ chat(endpoint, model=args.model)
37
+ tool_check(endpoint, model=args.model)
38
+
39
+
40
+ def wait_for_health(endpoint: str, *, timeout_seconds: int) -> None:
41
+ deadline = time.monotonic() + timeout_seconds
42
+ health_url = f"{endpoint}/health"
43
+ while time.monotonic() < deadline:
44
+ try:
45
+ with urlopen(health_url, timeout=30) as response:
46
+ if response.status == 200:
47
+ print("Health check passed.")
48
+ return
49
+ except (HTTPError, URLError, TimeoutError):
50
+ pass
51
+ time.sleep(5)
52
+ raise TimeoutError(f"Endpoint did not become healthy before timeout: {health_url}")
53
+
54
+
55
+ def chat(endpoint: str, *, model: str) -> None:
56
+ body = _post_json(
57
+ f"{endpoint}/v1/chat/completions",
58
+ {
59
+ "model": model,
60
+ "messages": [
61
+ {"role": "system", "content": "You answer directly and briefly."},
62
+ {"role": "user", "content": "What is the capital of Bulgaria?"},
63
+ ],
64
+ "max_tokens": 64,
65
+ "temperature": 0.6,
66
+ "top_p": 0.95,
67
+ "chat_template_kwargs": {"enable_thinking": False},
68
+ },
69
+ timeout=180,
70
+ )
71
+ message = body["choices"][0]["message"]
72
+ print("Chat response:")
73
+ print(message.get("content") or json.dumps(message, indent=2))
74
+
75
+
76
+ def tool_check(endpoint: str, *, model: str) -> None:
77
+ body = _post_json(
78
+ f"{endpoint}/v1/chat/completions",
79
+ {
80
+ "model": model,
81
+ "messages": [
82
+ {
83
+ "role": "system",
84
+ "content": "You are an NPC controller. Use the walk tool exactly once.",
85
+ },
86
+ {"role": "user", "content": "Move npc-001 to x=4 and z=0."},
87
+ ],
88
+ "tools": [
89
+ {
90
+ "type": "function",
91
+ "function": {
92
+ "name": "walk",
93
+ "description": "Move one NPC to a target coordinate.",
94
+ "parameters": {
95
+ "type": "object",
96
+ "properties": {
97
+ "npc_id": {"type": "string"},
98
+ "target_x": {"type": "number"},
99
+ "target_z": {"type": "number"},
100
+ },
101
+ "required": ["npc_id", "target_x", "target_z"],
102
+ "additionalProperties": False,
103
+ },
104
+ },
105
+ }
106
+ ],
107
+ "tool_choice": "auto",
108
+ "max_tokens": 128,
109
+ "temperature": 0.6,
110
+ "top_p": 0.95,
111
+ "chat_template_kwargs": {"enable_thinking": False},
112
+ },
113
+ timeout=180,
114
+ )
115
+ message = body["choices"][0]["message"]
116
+ tool_calls = message.get("tool_calls") or []
117
+ if not tool_calls:
118
+ print(json.dumps(message, indent=2))
119
+ raise RuntimeError("Tool check failed: response did not include tool_calls.")
120
+
121
+ print("Tool check passed:")
122
+ print(json.dumps(tool_calls, indent=2))
123
+
124
+
125
+ def _post_json(url: str, payload: dict[str, Any], *, timeout: int) -> dict[str, Any]:
126
+ data = json.dumps(payload).encode("utf-8")
127
+ request = Request(
128
+ url,
129
+ data=data,
130
+ headers={"Content-Type": "application/json"},
131
+ method="POST",
132
+ )
133
+ with urlopen(request, timeout=timeout) as response:
134
+ decoded = json.loads(response.read().decode("utf-8"))
135
+ if not isinstance(decoded, dict):
136
+ raise RuntimeError("Expected JSON object response.")
137
+ return decoded
138
+
139
+
140
+ def _normalize_root_url(raw: str) -> str:
141
+ root = raw.strip().rstrip("/")
142
+ if root.endswith("/v1"):
143
+ root = root.removesuffix("/v1")
144
+ if not root.startswith(("http://", "https://")):
145
+ raise ValueError("Endpoint must start with http:// or https://.")
146
+ return root
147
+
148
+
149
+ if __name__ == "__main__":
150
+ main()
frontend/src/App.tsx CHANGED
@@ -81,7 +81,7 @@ export function App() {
81
 
82
  useEffect(() => {
83
  const abortController = new AbortController();
84
- fetchWorldSnapshot(abortController.signal)
85
  .then((nextSnapshot) => {
86
  setSnapshot(nextSnapshot);
87
  setSelectedId(nextSnapshot.entities[0]?.id ?? null);
@@ -123,6 +123,22 @@ export function App() {
123
  return () => window.clearInterval(intervalId);
124
  }, [isServerTickPending, refresh]);
125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  const selectedEntity = useMemo(
127
  () => snapshot?.entities.find((entity) => entity.id === selectedId) ?? null,
128
  [selectedId, snapshot],
@@ -169,6 +185,15 @@ export function App() {
169
  <TooltipLabel className="statusPill" value={tickLabel} />
170
  <TooltipLabel className="statusPill" value={npcCountLabel} />
171
  <TooltipLabel className="statusPill" value={simulatorLabel} />
 
 
 
 
 
 
 
 
 
172
  </div>
173
  </div>
174
 
@@ -250,6 +275,12 @@ export function App() {
250
  </div>
251
  ) : null}
252
  <div className="memoryList" aria-label={`${selectedEntity.label} memories`}>
 
 
 
 
 
 
253
  {selectedEntity.state.memories
254
  .slice()
255
  .reverse()
@@ -336,3 +367,36 @@ function TooltipLabel({ value, className, children }: TooltipLabelProps) {
336
  </span>
337
  );
338
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
 
82
  useEffect(() => {
83
  const abortController = new AbortController();
84
+ fetchWorldSnapshot(abortController.signal, { warmup: true })
85
  .then((nextSnapshot) => {
86
  setSnapshot(nextSnapshot);
87
  setSelectedId(nextSnapshot.entities[0]?.id ?? null);
 
123
  return () => window.clearInterval(intervalId);
124
  }, [isServerTickPending, refresh]);
125
 
126
+ const modelStatuses = snapshot?.simulation.models ?? [];
127
+
128
+ useEffect(() => {
129
+ if (!snapshot || !modelStatuses.some((model) => model.status !== "ready")) {
130
+ return;
131
+ }
132
+
133
+ const intervalId = window.setInterval(() => {
134
+ refresh().catch((nextError) => {
135
+ setError(nextError instanceof Error ? nextError.message : "World refresh failed");
136
+ });
137
+ }, 2000);
138
+
139
+ return () => window.clearInterval(intervalId);
140
+ }, [modelStatuses, refresh, snapshot]);
141
+
142
  const selectedEntity = useMemo(
143
  () => snapshot?.entities.find((entity) => entity.id === selectedId) ?? null,
144
  [selectedId, snapshot],
 
185
  <TooltipLabel className="statusPill" value={tickLabel} />
186
  <TooltipLabel className="statusPill" value={npcCountLabel} />
187
  <TooltipLabel className="statusPill" value={simulatorLabel} />
188
+ {modelStatuses.map((model) => (
189
+ <TooltipLabel
190
+ key={model.id}
191
+ className={`statusPill modelStatus ${modelStatusClass(model.status)}`}
192
+ value={`${model.label}: ${model.model ?? "unknown model"} - ${formatModelStatus(model.status)}`}
193
+ >
194
+ {`${shortModelLabel(model.label)} ${formatModelStatus(model.status)}`}
195
+ </TooltipLabel>
196
+ ))}
197
  </div>
198
  </div>
199
 
 
275
  </div>
276
  ) : null}
277
  <div className="memoryList" aria-label={`${selectedEntity.label} memories`}>
278
+ {selectedEntity.state.memory_summary ? (
279
+ <div className="memorySummary">
280
+ <TooltipLabel className="memoryTick" value="Summary" />
281
+ <p>{selectedEntity.state.memory_summary}</p>
282
+ </div>
283
+ ) : null}
284
  {selectedEntity.state.memories
285
  .slice()
286
  .reverse()
 
367
  </span>
368
  );
369
  }
370
+
371
+ function shortModelLabel(label: string): string {
372
+ return label === "God Console" ? "God" : "NPC";
373
+ }
374
+
375
+ function formatModelStatus(status: string): string {
376
+ switch (status) {
377
+ case "checking":
378
+ return "checking";
379
+ case "warmup":
380
+ return "warmup";
381
+ case "ready":
382
+ return "ready";
383
+ case "error":
384
+ return "error";
385
+ default:
386
+ return "unknown";
387
+ }
388
+ }
389
+
390
+ function modelStatusClass(status: string): string {
391
+ switch (status) {
392
+ case "ready":
393
+ return "modelStatusReady";
394
+ case "checking":
395
+ case "warmup":
396
+ return "modelStatusWarmup";
397
+ case "error":
398
+ return "modelStatusError";
399
+ default:
400
+ return "modelStatusUnknown";
401
+ }
402
+ }
frontend/src/api.ts CHANGED
@@ -12,8 +12,16 @@ export class ApiResponseError extends Error {
12
  }
13
  }
14
 
15
- export async function fetchWorldSnapshot(signal?: AbortSignal): Promise<WorldSnapshot> {
16
- const response = await fetch(`${API_BASE}/claw3d/state`, { signal });
 
 
 
 
 
 
 
 
17
  if (!response.ok) {
18
  throw new ApiResponseError(`World snapshot failed: ${response.status}`, response.status);
19
  }
 
12
  }
13
  }
14
 
15
+ export async function fetchWorldSnapshot(
16
+ signal?: AbortSignal,
17
+ options: { warmup?: boolean } = {},
18
+ ): Promise<WorldSnapshot> {
19
+ const url = new URL(`${API_BASE}/claw3d/state`, window.location.origin);
20
+ if (options.warmup) {
21
+ url.searchParams.set("warmup", "1");
22
+ }
23
+
24
+ const response = await fetch(url, { signal });
25
  if (!response.ok) {
26
  throw new ApiResponseError(`World snapshot failed: ${response.status}`, response.status);
27
  }
frontend/src/styles.css CHANGED
@@ -44,7 +44,7 @@ button {
44
  top: 14px;
45
  left: 14px;
46
  right: 14px;
47
- z-index: 30;
48
  display: flex;
49
  align-items: center;
50
  justify-content: space-between;
@@ -103,6 +103,23 @@ button {
103
  text-align: center;
104
  }
105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  .controls {
107
  position: absolute;
108
  right: 14px;
@@ -293,6 +310,7 @@ button {
293
  padding: 9px 10px 10px;
294
  }
295
 
 
296
  .memoryItem {
297
  display: grid;
298
  gap: 4px;
@@ -300,6 +318,10 @@ button {
300
  border-bottom: 1px solid rgb(255 255 255 / 10%);
301
  }
302
 
 
 
 
 
303
  .memoryItem:last-child {
304
  border-bottom: 0;
305
  }
@@ -310,6 +332,7 @@ button {
310
  line-height: 1.1;
311
  }
312
 
 
313
  .memoryItem p {
314
  position: relative;
315
  margin: 0;
@@ -318,6 +341,10 @@ button {
318
  line-height: 1.35;
319
  }
320
 
 
 
 
 
321
  .tooltipAnchor {
322
  position: relative;
323
  min-width: 0;
 
44
  top: 14px;
45
  left: 14px;
46
  right: 14px;
47
+ z-index: 60;
48
  display: flex;
49
  align-items: center;
50
  justify-content: space-between;
 
103
  text-align: center;
104
  }
105
 
106
+ .modelStatus {
107
+ min-width: 86px;
108
+ }
109
+
110
+ .modelStatusReady {
111
+ color: #d9f4cf;
112
+ }
113
+
114
+ .modelStatusWarmup,
115
+ .modelStatusUnknown {
116
+ color: #fff3a1;
117
+ }
118
+
119
+ .modelStatusError {
120
+ color: #ffd3c8;
121
+ }
122
+
123
  .controls {
124
  position: absolute;
125
  right: 14px;
 
310
  padding: 9px 10px 10px;
311
  }
312
 
313
+ .memorySummary,
314
  .memoryItem {
315
  display: grid;
316
  gap: 4px;
 
318
  border-bottom: 1px solid rgb(255 255 255 / 10%);
319
  }
320
 
321
+ .memorySummary {
322
+ padding-top: 0;
323
+ }
324
+
325
  .memoryItem:last-child {
326
  border-bottom: 0;
327
  }
 
332
  line-height: 1.1;
333
  }
334
 
335
+ .memorySummary p,
336
  .memoryItem p {
337
  position: relative;
338
  margin: 0;
 
341
  line-height: 1.35;
342
  }
343
 
344
+ .memorySummary p {
345
+ color: rgb(245 247 240 / 72%);
346
+ }
347
+
348
  .tooltipAnchor {
349
  position: relative;
350
  min-width: 0;
frontend/src/types.ts CHANGED
@@ -32,6 +32,7 @@ export type EntitySnapshot = {
32
  god_directive: string | null;
33
  is_alive: boolean;
34
  memory_count: number;
 
35
  memories: MemorySnapshot[];
36
  };
37
  render: {
@@ -54,7 +55,16 @@ export type WorldSnapshot = {
54
  last_tick_source: string;
55
  tick_in_progress: boolean;
56
  pending_tick: number | null;
 
57
  };
58
  terrain: TerrainSnapshot;
59
  entities: EntitySnapshot[];
60
  };
 
 
 
 
 
 
 
 
 
32
  god_directive: string | null;
33
  is_alive: boolean;
34
  memory_count: number;
35
+ memory_summary: string | null;
36
  memories: MemorySnapshot[];
37
  };
38
  render: {
 
55
  last_tick_source: string;
56
  tick_in_progress: boolean;
57
  pending_tick: number | null;
58
+ models?: ModelStatusSnapshot[];
59
  };
60
  terrain: TerrainSnapshot;
61
  entities: EntitySnapshot[];
62
  };
63
+
64
+ export type ModelStatusSnapshot = {
65
+ id: string;
66
+ label: string;
67
+ model: string | null;
68
+ status: "unknown" | "checking" | "warmup" | "ready" | "error" | string;
69
+ http_status: number | null;
70
+ };
pyproject.toml CHANGED
@@ -18,6 +18,10 @@ requires = ["hatchling"]
18
  build-backend = "hatchling.build"
19
 
20
  [dependency-groups]
 
 
 
 
21
  dev = [
22
  "pytest>=8.0",
23
  "ruff>=0.8",
 
18
  build-backend = "hatchling.build"
19
 
20
  [dependency-groups]
21
+ deploy = [
22
+ "aiohttp>=3.14.1",
23
+ "modal>=1.4.3",
24
+ ]
25
  dev = [
26
  "pytest>=8.0",
27
  "ruff>=0.8",
src/god_simulator/api/server.py CHANGED
@@ -1,14 +1,18 @@
1
  from __future__ import annotations
2
 
3
  import json
 
4
  from copy import deepcopy
5
  from dataclasses import dataclass
6
  from http import HTTPStatus
7
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8
- from threading import Lock
9
- from typing import Any
 
 
 
10
 
11
- from god_simulator.config import GameConfig
12
  from god_simulator.domain import WorldState
13
  from god_simulator.rendering.claw3d_contract import to_claw3d_snapshot
14
  from god_simulator.simulation.connectors.base import WorldSimulator
@@ -22,10 +26,14 @@ from god_simulator.simulation.tick import apply_tick_plan, plan_world_tick
22
 
23
  def run_server(*, world: WorldState, config: GameConfig) -> None:
24
  simulator = create_world_simulator(config)
 
25
  god_console = (
26
- GodConsole(config.connector) if config.connector.type == "openai_compatible" else None
 
 
27
  )
28
- handler = _build_handler(world, simulator, god_console)
 
29
  address = (config.server.host, config.server.port)
30
  server = ThreadingHTTPServer(address, handler)
31
  print(f"God Simulator API listening on http://{config.server.host}:{config.server.port}")
@@ -41,6 +49,7 @@ def _build_handler(
41
  world: WorldState,
42
  simulator: WorldSimulator,
43
  god_console: GodConsole | None = None,
 
44
  ) -> type[BaseHTTPRequestHandler]:
45
  lock = Lock()
46
  tick_status = _TickStatus()
@@ -55,7 +64,10 @@ def _build_handler(
55
  self.end_headers()
56
 
57
  def do_GET(self) -> None:
58
- if self.path == "/health":
 
 
 
59
  with lock:
60
  payload = {
61
  "status": "ok",
@@ -67,25 +79,31 @@ def _build_handler(
67
  self._send_json(payload)
68
  return
69
 
70
- if self.path == "/state":
71
  with lock:
72
  payload = world.to_dict()
73
  payload["simulation"] = _tick_status_payload(tick_status)
74
  self._send_json(payload)
75
  return
76
 
77
- if self.path == "/claw3d/state":
 
 
 
 
78
  with lock:
79
  payload = to_claw3d_snapshot(
80
  world,
81
  tick_in_progress=tick_status.in_progress,
82
  pending_tick=tick_status.pending_tick,
83
  )
 
 
84
  self._send_json(payload)
85
  return
86
 
87
  self._send_json(
88
- {"error": "not_found", "path": self.path},
89
  status=HTTPStatus.NOT_FOUND,
90
  )
91
 
@@ -296,3 +314,203 @@ def _tick_status_payload(tick_status: _TickStatus) -> dict[str, Any]:
296
  "tick_in_progress": tick_status.in_progress,
297
  "pending_tick": tick_status.pending_tick,
298
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
  import json
4
+ import time
5
  from copy import deepcopy
6
  from dataclasses import dataclass
7
  from http import HTTPStatus
8
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9
+ from threading import Lock, Thread
10
+ from typing import Any, Protocol
11
+ from urllib.error import HTTPError, URLError
12
+ from urllib.parse import parse_qs, urlparse
13
+ from urllib.request import urlopen
14
 
15
+ from god_simulator.config import ConnectorConfig, GameConfig
16
  from god_simulator.domain import WorldState
17
  from god_simulator.rendering.claw3d_contract import to_claw3d_snapshot
18
  from god_simulator.simulation.connectors.base import WorldSimulator
 
26
 
27
  def run_server(*, world: WorldState, config: GameConfig) -> None:
28
  simulator = create_world_simulator(config)
29
+ god_console_config = config.god_console or config.connector
30
  god_console = (
31
+ GodConsole(god_console_config)
32
+ if god_console_config.type == "openai_compatible"
33
+ else None
34
  )
35
+ modal_health_warmer = _ModalHealthWarmer(_modal_health_targets(config))
36
+ handler = _build_handler(world, simulator, god_console, modal_health_warmer)
37
  address = (config.server.host, config.server.port)
38
  server = ThreadingHTTPServer(address, handler)
39
  print(f"God Simulator API listening on http://{config.server.host}:{config.server.port}")
 
49
  world: WorldState,
50
  simulator: WorldSimulator,
51
  god_console: GodConsole | None = None,
52
+ modal_health_warmer: _HealthWarmer | None = None,
53
  ) -> type[BaseHTTPRequestHandler]:
54
  lock = Lock()
55
  tick_status = _TickStatus()
 
64
  self.end_headers()
65
 
66
  def do_GET(self) -> None:
67
+ parsed_path = urlparse(self.path)
68
+ path = parsed_path.path
69
+
70
+ if path == "/health":
71
  with lock:
72
  payload = {
73
  "status": "ok",
 
79
  self._send_json(payload)
80
  return
81
 
82
+ if path == "/state":
83
  with lock:
84
  payload = world.to_dict()
85
  payload["simulation"] = _tick_status_payload(tick_status)
86
  self._send_json(payload)
87
  return
88
 
89
+ if path == "/claw3d/state":
90
+ if modal_health_warmer is not None:
91
+ query = parse_qs(parsed_path.query)
92
+ if _truthy_query_value(query, "warmup"):
93
+ modal_health_warmer.trigger(force=True)
94
  with lock:
95
  payload = to_claw3d_snapshot(
96
  world,
97
  tick_in_progress=tick_status.in_progress,
98
  pending_tick=tick_status.pending_tick,
99
  )
100
+ if modal_health_warmer is not None:
101
+ payload["simulation"]["models"] = modal_health_warmer.statuses()
102
  self._send_json(payload)
103
  return
104
 
105
  self._send_json(
106
+ {"error": "not_found", "path": path},
107
  status=HTTPStatus.NOT_FOUND,
108
  )
109
 
 
314
  "tick_in_progress": tick_status.in_progress,
315
  "pending_tick": tick_status.pending_tick,
316
  }
317
+
318
+
319
+ class _ModalHealthWarmer:
320
+ def __init__(
321
+ self,
322
+ targets: list[_ModalHealthTarget],
323
+ *,
324
+ cooldown_seconds: float = 300,
325
+ warmup_after_seconds: float = 2,
326
+ opener: _UrlOpener = urlopen,
327
+ ) -> None:
328
+ self._states = {
329
+ target.id: _ModalHealthState(target=target)
330
+ for target in targets
331
+ }
332
+ self._cooldown_seconds = cooldown_seconds
333
+ self._warmup_after_seconds = warmup_after_seconds
334
+ self._opener = opener
335
+ self._lock = Lock()
336
+
337
+ def trigger(self, *, force: bool = False) -> None:
338
+ if not self._states:
339
+ return
340
+
341
+ now = time.monotonic()
342
+ launches: list[tuple[str, str, int]] = []
343
+
344
+ with self._lock:
345
+ for target_id, state in self._states.items():
346
+ if state.in_progress:
347
+ continue
348
+ if (
349
+ not force
350
+ and (
351
+ now - state.last_started_at < self._cooldown_seconds
352
+ )
353
+ ):
354
+ continue
355
+ state.in_progress = True
356
+ state.status = "checking"
357
+ state.http_status = None
358
+ state.last_started_at = now
359
+ state.generation += 1
360
+ launches.append((target_id, state.target.health_url, state.generation))
361
+
362
+ for target_id, health_url, generation in launches:
363
+ Thread(
364
+ target=self._mark_warmup_if_pending,
365
+ args=(target_id, generation),
366
+ name="modal-health-warmup-status",
367
+ daemon=True,
368
+ ).start()
369
+ Thread(
370
+ target=self._run_one,
371
+ args=(target_id, health_url, generation),
372
+ name="modal-health-warmer",
373
+ daemon=True,
374
+ ).start()
375
+
376
+ def statuses(self) -> list[dict[str, Any]]:
377
+ with self._lock:
378
+ return [
379
+ {
380
+ "id": state.target.id,
381
+ "label": state.target.label,
382
+ "model": state.target.model,
383
+ "status": state.status,
384
+ "http_status": state.http_status,
385
+ }
386
+ for state in self._states.values()
387
+ ]
388
+
389
+ def _mark_warmup_if_pending(self, target_id: str, generation: int) -> None:
390
+ time.sleep(self._warmup_after_seconds)
391
+ with self._lock:
392
+ state = self._states.get(target_id)
393
+ if (
394
+ state is not None
395
+ and state.generation == generation
396
+ and state.in_progress
397
+ and state.status == "checking"
398
+ ):
399
+ state.status = "warmup"
400
+
401
+ def _run_one(self, target_id: str, health_url: str, generation: int) -> None:
402
+ try:
403
+ response = self._opener(health_url)
404
+ try:
405
+ http_status = int(getattr(response, "status", 0))
406
+ self._set_status(
407
+ target_id,
408
+ generation,
409
+ "ready" if http_status == 200 else "error",
410
+ http_status,
411
+ )
412
+ print(
413
+ f"Modal health check warmed {health_url}: {http_status}",
414
+ flush=True,
415
+ )
416
+ finally:
417
+ response.close()
418
+ except (HTTPError, TimeoutError, URLError, OSError) as exc:
419
+ self._set_status(target_id, generation, "error", None)
420
+ print(f"Modal health check failed for {health_url}: {exc}", flush=True)
421
+
422
+ def _set_status(
423
+ self,
424
+ target_id: str,
425
+ generation: int,
426
+ status: str,
427
+ http_status: int | None,
428
+ ) -> None:
429
+ with self._lock:
430
+ state = self._states.get(target_id)
431
+ if state is None or state.generation != generation:
432
+ return
433
+ state.status = status
434
+ state.http_status = http_status
435
+ state.in_progress = False
436
+
437
+
438
+ @dataclass(frozen=True, slots=True)
439
+ class _ModalHealthTarget:
440
+ id: str
441
+ label: str
442
+ model: str | None
443
+ health_url: str
444
+
445
+
446
+ @dataclass(slots=True)
447
+ class _ModalHealthState:
448
+ target: _ModalHealthTarget
449
+ status: str = "unknown"
450
+ http_status: int | None = None
451
+ last_started_at: float = 0.0
452
+ generation: int = 0
453
+ in_progress: bool = False
454
+
455
+
456
+ class _UrlResponse(Protocol):
457
+ status: int
458
+
459
+ def close(self) -> None: ...
460
+
461
+
462
+ class _UrlOpener(Protocol):
463
+ def __call__(self, url: str) -> _UrlResponse: ...
464
+
465
+
466
+ class _HealthWarmer(Protocol):
467
+ def trigger(self, *, force: bool = False) -> None: ...
468
+
469
+ def statuses(self) -> list[dict[str, Any]]: ...
470
+
471
+
472
+ def _modal_health_urls(config: GameConfig) -> list[str]:
473
+ return [target.health_url for target in _modal_health_targets(config)]
474
+
475
+
476
+ def _modal_health_targets(config: GameConfig) -> list[_ModalHealthTarget]:
477
+ targets: list[_ModalHealthTarget] = []
478
+ for target_id, label, connector in (
479
+ ("npc_model", "NPC model", config.connector),
480
+ ("god_console", "God Console", config.god_console),
481
+ ):
482
+ if connector is None:
483
+ continue
484
+ health_url = _modal_health_url(connector)
485
+ if health_url is not None:
486
+ targets.append(
487
+ _ModalHealthTarget(
488
+ id=target_id,
489
+ label=label,
490
+ model=connector.model,
491
+ health_url=health_url,
492
+ )
493
+ )
494
+ return targets
495
+
496
+
497
+ def _modal_health_url(connector: ConnectorConfig) -> str | None:
498
+ if connector.type != "openai_compatible":
499
+ return None
500
+
501
+ root = connector.base_url or connector.api_url
502
+ if not root:
503
+ return None
504
+
505
+ root = root.strip().rstrip("/")
506
+ for suffix in ("/v1/chat/completions", "/chat/completions", "/v1"):
507
+ if root.endswith(suffix):
508
+ root = root.removesuffix(suffix)
509
+
510
+ if ".modal.run" not in root:
511
+ return None
512
+ return f"{root}/health"
513
+
514
+
515
+ def _truthy_query_value(query: dict[str, list[str]], key: str) -> bool:
516
+ return any(value.lower() in ("1", "true", "yes") for value in query.get(key, []))
src/god_simulator/config.py CHANGED
@@ -42,6 +42,10 @@ class ConnectorConfig:
42
  model: str | None = None
43
  api_key_env: str | None = None
44
  timeout_seconds: int = 60
 
 
 
 
45
  max_parallel_npc_calls: int = 5
46
  fallback_to_deterministic: bool = True
47
  extra_body: dict[str, Any] | None = None
@@ -56,6 +60,7 @@ class GameConfig:
56
  connector: ConnectorConfig = field(
57
  default_factory=lambda: ConnectorConfig(type="deterministic")
58
  )
 
59
 
60
 
61
  def load_game_config(path: Path) -> GameConfig:
@@ -71,6 +76,7 @@ def parse_game_config(raw: dict[str, Any]) -> GameConfig:
71
  simulation = _required_mapping(raw, "simulation")
72
  server = _required_mapping(raw, "server")
73
  connector = _optional_mapping(raw, "connector") or {"type": "deterministic"}
 
74
 
75
  terrain_raw = _required_str(world, "terrain")
76
  if terrain_raw != "plain_green":
@@ -91,6 +97,7 @@ def parse_game_config(raw: dict[str, Any]) -> GameConfig:
91
  port=_required_positive_int(server, "port"),
92
  ),
93
  connector=_parse_connector_config(connector),
 
94
  )
95
 
96
 
@@ -119,6 +126,9 @@ def _parse_connector_config(raw: dict[str, Any]) -> ConnectorConfig:
119
  if model_env:
120
  model = os.getenv(model_env) or model
121
 
 
 
 
122
  return ConnectorConfig(
123
  type=connector_type,
124
  base_url=base_url,
@@ -126,6 +136,10 @@ def _parse_connector_config(raw: dict[str, Any]) -> ConnectorConfig:
126
  model=model,
127
  api_key_env=_optional_str(raw, "api_key_env"),
128
  timeout_seconds=_optional_positive_int(raw, "timeout_seconds") or 60,
 
 
 
 
129
  max_parallel_npc_calls=_optional_positive_int(raw, "max_parallel_npc_calls") or 5,
130
  fallback_to_deterministic=_optional_bool(raw, "fallback_to_deterministic", default=True),
131
  extra_body=_optional_mapping(raw, "extra_body"),
@@ -180,6 +194,28 @@ def _optional_positive_int(raw: dict[str, Any], key: str) -> int | None:
180
  return value
181
 
182
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  def _required_str(raw: dict[str, Any], key: str) -> str:
184
  value = raw.get(key)
185
  if not isinstance(value, str) or not value:
@@ -205,6 +241,19 @@ def _optional_bool(raw: dict[str, Any], key: str, *, default: bool) -> bool:
205
  return value
206
 
207
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  def _load_dotenv_for_config(config_path: Path) -> None:
209
  candidates = [
210
  Path.cwd() / ".env",
 
42
  model: str | None = None
43
  api_key_env: str | None = None
44
  timeout_seconds: int = 60
45
+ max_tokens: int = 1400
46
+ temperature: float = 0.6
47
+ top_p: float = 0.95
48
+ tool_choice: str | dict[str, Any] | None = None
49
  max_parallel_npc_calls: int = 5
50
  fallback_to_deterministic: bool = True
51
  extra_body: dict[str, Any] | None = None
 
60
  connector: ConnectorConfig = field(
61
  default_factory=lambda: ConnectorConfig(type="deterministic")
62
  )
63
+ god_console: ConnectorConfig | None = None
64
 
65
 
66
  def load_game_config(path: Path) -> GameConfig:
 
76
  simulation = _required_mapping(raw, "simulation")
77
  server = _required_mapping(raw, "server")
78
  connector = _optional_mapping(raw, "connector") or {"type": "deterministic"}
79
+ god_console = _optional_mapping(raw, "god_console")
80
 
81
  terrain_raw = _required_str(world, "terrain")
82
  if terrain_raw != "plain_green":
 
97
  port=_required_positive_int(server, "port"),
98
  ),
99
  connector=_parse_connector_config(connector),
100
+ god_console=_parse_connector_config(god_console) if god_console else None,
101
  )
102
 
103
 
 
126
  if model_env:
127
  model = os.getenv(model_env) or model
128
 
129
+ temperature = _optional_non_negative_float(raw, "temperature")
130
+ top_p = _optional_probability(raw, "top_p")
131
+
132
  return ConnectorConfig(
133
  type=connector_type,
134
  base_url=base_url,
 
136
  model=model,
137
  api_key_env=_optional_str(raw, "api_key_env"),
138
  timeout_seconds=_optional_positive_int(raw, "timeout_seconds") or 60,
139
+ max_tokens=_optional_positive_int(raw, "max_tokens") or 1400,
140
+ temperature=0.6 if temperature is None else temperature,
141
+ top_p=0.95 if top_p is None else top_p,
142
+ tool_choice=_optional_tool_choice(raw, "tool_choice"),
143
  max_parallel_npc_calls=_optional_positive_int(raw, "max_parallel_npc_calls") or 5,
144
  fallback_to_deterministic=_optional_bool(raw, "fallback_to_deterministic", default=True),
145
  extra_body=_optional_mapping(raw, "extra_body"),
 
194
  return value
195
 
196
 
197
+ def _optional_non_negative_float(raw: dict[str, Any], key: str) -> float | None:
198
+ value = raw.get(key)
199
+ if value is None:
200
+ return None
201
+ if not isinstance(value, int | float) or isinstance(value, bool):
202
+ raise ValueError(f"Expected '{key}' to be a number.")
203
+ if value < 0:
204
+ raise ValueError(f"Expected '{key}' to be non-negative.")
205
+ return float(value)
206
+
207
+
208
+ def _optional_probability(raw: dict[str, Any], key: str) -> float | None:
209
+ value = raw.get(key)
210
+ if value is None:
211
+ return None
212
+ if not isinstance(value, int | float) or isinstance(value, bool):
213
+ raise ValueError(f"Expected '{key}' to be a number.")
214
+ if value <= 0 or value > 1:
215
+ raise ValueError(f"Expected '{key}' to be between 0 and 1.")
216
+ return float(value)
217
+
218
+
219
  def _required_str(raw: dict[str, Any], key: str) -> str:
220
  value = raw.get(key)
221
  if not isinstance(value, str) or not value:
 
241
  return value
242
 
243
 
244
+ def _optional_tool_choice(raw: dict[str, Any], key: str) -> str | dict[str, Any] | None:
245
+ value = raw.get(key)
246
+ if value is None:
247
+ return None
248
+ if isinstance(value, str):
249
+ if not value:
250
+ raise ValueError(f"Expected '{key}' to be a non-empty string or object.")
251
+ return value
252
+ if isinstance(value, dict):
253
+ return value
254
+ raise ValueError(f"Expected '{key}' to be a string or object.")
255
+
256
+
257
  def _load_dotenv_for_config(config_path: Path) -> None:
258
  candidates = [
259
  Path.cwd() / ".env",
src/god_simulator/domain.py CHANGED
@@ -29,6 +29,7 @@ class Npc:
29
  personality: str = "neutral"
30
  god_directive: str | None = None
31
  intention: str = "idle"
 
32
  memory: list[MemoryEntry] = field(default_factory=list)
33
 
34
 
 
29
  personality: str = "neutral"
30
  god_directive: str | None = None
31
  intention: str = "idle"
32
+ memory_summary: str | None = None
33
  memory: list[MemoryEntry] = field(default_factory=list)
34
 
35
 
src/god_simulator/rendering/claw3d_contract.py CHANGED
@@ -52,6 +52,7 @@ def to_claw3d_snapshot(
52
  "god_directive": npc.god_directive,
53
  "is_alive": is_alive(npc),
54
  "memory_count": len(npc.memory),
 
55
  "memories": [
56
  {
57
  "tick": memory.tick,
 
52
  "god_directive": npc.god_directive,
53
  "is_alive": is_alive(npc),
54
  "memory_count": len(npc.memory),
55
+ "memory_summary": npc.memory_summary,
56
  "memories": [
57
  {
58
  "tick": memory.tick,
src/god_simulator/simulation/connectors/deterministic.py CHANGED
@@ -32,7 +32,6 @@ class DeterministicWorldSimulator:
32
  npc_id=npc.id,
33
  action="walk",
34
  target=target,
35
- memory=f"{npc.name} kept exploring." if next_tick % 10 == 0 else None,
36
  )
37
  )
38
 
 
32
  npc_id=npc.id,
33
  action="walk",
34
  target=target,
 
35
  )
36
  )
37
 
src/god_simulator/simulation/connectors/openai_compatible.py CHANGED
@@ -21,12 +21,12 @@ from god_simulator.simulation.mechanics import (
21
  BLOCK_SIZE,
22
  GRID_BLOCKS,
23
  MAX_WALK_DISTANCE,
24
- NPC_COLLISION_RADIUS,
25
  TALK_RADIUS,
26
  VISIBLE_RADIUS,
27
  distance_between,
28
  is_alive,
29
  )
 
30
 
31
  ChatCompleter = Callable[[dict[str, Any]], dict[str, Any]]
32
 
@@ -250,9 +250,12 @@ class OpenAICompatibleWorldSimulator:
250
  "model": self._config.model,
251
  "messages": _build_messages(world, next_tick, npc),
252
  "tools": ACTION_TOOLS,
253
- "temperature": 0.7,
254
- "max_tokens": 1400,
 
255
  }
 
 
256
  if self._config.extra_body:
257
  request["extra_body"] = self._config.extra_body
258
 
@@ -278,9 +281,10 @@ def _build_messages(
278
  "role": "system",
279
  "content": (
280
  f"You are {npc.name}, a {npc.role} in a small civilization sandbox. "
281
- "Choose your own next action from your current state and observations. "
282
- "Use exactly one walk, talk, or attack tool call. For talk, the message "
283
- "argument is the exact words you say aloud."
 
284
  ),
285
  },
286
  {
@@ -293,19 +297,9 @@ def _build_messages(
293
  "observable_other_npcs_field": "visible_npcs",
294
  },
295
  "task": (
296
- "Choose the next action for this NPC only. Other NPCs are known "
297
- "only through visible_npcs. If tool calls are unavailable, return "
298
- "JSON with a single action object."
299
  ),
300
- "fallback_json_schema": {
301
- "action": {
302
- "action": "walk | talk | attack",
303
- "npc_id": npc.id,
304
- "target": {"x": 0.0, "z": 0.0},
305
- "target_npc_id": "npc-002",
306
- "message": "exact words spoken for talk",
307
- }
308
- },
309
  "rules": [
310
  f"x must be between {-half_width} and {half_width}",
311
  f"z must be between {-half_depth} and {half_depth}",
@@ -314,8 +308,6 @@ def _build_messages(
314
  "prefer target coordinates at block centers",
315
  "do not invent npc ids",
316
  f"walk moves at most {MAX_WALK_DISTANCE:g} world units per tick",
317
- "NPC centers must stay at least "
318
- f"{NPC_COLLISION_RADIUS:g} world units apart",
319
  f"talk requires target distance <= {TALK_RADIUS:g} world units",
320
  f"attack requires target distance <= {ATTACK_RADIUS:g} world units",
321
  f"each NPC can see only visible_npcs within {VISIBLE_RADIUS:g} world units",
@@ -357,12 +349,13 @@ def _npc_context(world: WorldState, npc: Npc) -> dict[str, Any]:
357
  "personality": npc.personality,
358
  "god_directive": npc.god_directive,
359
  "intention": npc.intention,
 
360
  "recent_observations": [
361
  {
362
  "tick": memory.tick,
363
  "text": memory.text,
364
  }
365
- for memory in npc.memory[-5:]
366
  ],
367
  "visible_npcs": _visible_npc_summaries(world, npc),
368
  }
 
21
  BLOCK_SIZE,
22
  GRID_BLOCKS,
23
  MAX_WALK_DISTANCE,
 
24
  TALK_RADIUS,
25
  VISIBLE_RADIUS,
26
  distance_between,
27
  is_alive,
28
  )
29
+ from god_simulator.simulation.memory import recent_meaningful_memories
30
 
31
  ChatCompleter = Callable[[dict[str, Any]], dict[str, Any]]
32
 
 
250
  "model": self._config.model,
251
  "messages": _build_messages(world, next_tick, npc),
252
  "tools": ACTION_TOOLS,
253
+ "temperature": self._config.temperature,
254
+ "top_p": self._config.top_p,
255
+ "max_tokens": self._config.max_tokens,
256
  }
257
+ if self._config.tool_choice is not None:
258
+ request["tool_choice"] = self._config.tool_choice
259
  if self._config.extra_body:
260
  request["extra_body"] = self._config.extra_body
261
 
 
281
  "role": "system",
282
  "content": (
283
  f"You are {npc.name}, a {npc.role} in a small civilization sandbox. "
284
+ "Choose only your own next action from your current state and observations. "
285
+ "Use exactly one provided tool: walk, talk, or attack. Do not write prose, "
286
+ "JSON, narration, or reasoning text. For talk, the message argument is "
287
+ "the exact words you say aloud."
288
  ),
289
  },
290
  {
 
297
  "observable_other_npcs_field": "visible_npcs",
298
  },
299
  "task": (
300
+ "Choose one immediate action for this NPC only. Other NPCs are "
301
+ "known only through visible_npcs. Call exactly one tool."
 
302
  ),
 
 
 
 
 
 
 
 
 
303
  "rules": [
304
  f"x must be between {-half_width} and {half_width}",
305
  f"z must be between {-half_depth} and {half_depth}",
 
308
  "prefer target coordinates at block centers",
309
  "do not invent npc ids",
310
  f"walk moves at most {MAX_WALK_DISTANCE:g} world units per tick",
 
 
311
  f"talk requires target distance <= {TALK_RADIUS:g} world units",
312
  f"attack requires target distance <= {ATTACK_RADIUS:g} world units",
313
  f"each NPC can see only visible_npcs within {VISIBLE_RADIUS:g} world units",
 
349
  "personality": npc.personality,
350
  "god_directive": npc.god_directive,
351
  "intention": npc.intention,
352
+ "memory_summary": npc.memory_summary,
353
  "recent_observations": [
354
  {
355
  "tick": memory.tick,
356
  "text": memory.text,
357
  }
358
+ for memory in recent_meaningful_memories(npc)
359
  ],
360
  "visible_npcs": _visible_npc_summaries(world, npc),
361
  }
src/god_simulator/simulation/god_console.py CHANGED
@@ -9,7 +9,8 @@ from typing import Any, cast
9
  from openai import OpenAI
10
 
11
  from god_simulator.config import ConnectorConfig
12
- from god_simulator.domain import MemoryEntry, Vec3, WorldState
 
13
 
14
  GodChatCompleter = Callable[[dict[str, Any]], dict[str, Any]]
15
 
@@ -157,9 +158,12 @@ class GodConsole:
157
  "model": self._config.model,
158
  "messages": _build_messages(world, command),
159
  "tools": GOD_TOOLS,
160
- "temperature": 0.2,
161
- "max_tokens": 1200,
 
162
  }
 
 
163
  if self._config.extra_body:
164
  request["extra_body"] = self._config.extra_body
165
 
@@ -213,15 +217,13 @@ def apply_god_command_plan(world: WorldState, plan: GodCommandPlan) -> list[str]
213
  if directive is None:
214
  continue
215
  npc.god_directive = directive
216
- npc.memory.append(MemoryEntry(tick=world.tick, text=f"God directive: {directive}"))
217
- del npc.memory[:-20]
218
  applied.append(f"{npc.name} directive set.")
219
  case "add_npc_memory":
220
  memory = _short_text(edit.arguments.get("memory"), limit=240)
221
  if memory is None:
222
  continue
223
- npc.memory.append(MemoryEntry(tick=world.tick, text=memory))
224
- del npc.memory[:-20]
225
  applied.append(f"{npc.name} memory added.")
226
  case "set_npc_position":
227
  x = _float_arg(edit.arguments.get("x"))
@@ -272,7 +274,11 @@ def _build_messages(world: WorldState, command: str) -> list[dict[str, str]]:
272
  "personality": npc.personality,
273
  "god_directive": npc.god_directive,
274
  "intention": npc.intention,
275
- "recent_memories": [memory.text for memory in npc.memory[-3:]],
 
 
 
 
276
  }
277
  for npc in world.npcs
278
  ],
 
9
  from openai import OpenAI
10
 
11
  from god_simulator.config import ConnectorConfig
12
+ from god_simulator.domain import Vec3, WorldState
13
+ from god_simulator.simulation.memory import recent_meaningful_memories, remember
14
 
15
  GodChatCompleter = Callable[[dict[str, Any]], dict[str, Any]]
16
 
 
158
  "model": self._config.model,
159
  "messages": _build_messages(world, command),
160
  "tools": GOD_TOOLS,
161
+ "temperature": self._config.temperature,
162
+ "top_p": self._config.top_p,
163
+ "max_tokens": self._config.max_tokens,
164
  }
165
+ if isinstance(self._config.tool_choice, str):
166
+ request["tool_choice"] = self._config.tool_choice
167
  if self._config.extra_body:
168
  request["extra_body"] = self._config.extra_body
169
 
 
217
  if directive is None:
218
  continue
219
  npc.god_directive = directive
220
+ remember(npc, world.tick, f"God directive: {directive}")
 
221
  applied.append(f"{npc.name} directive set.")
222
  case "add_npc_memory":
223
  memory = _short_text(edit.arguments.get("memory"), limit=240)
224
  if memory is None:
225
  continue
226
+ remember(npc, world.tick, memory)
 
227
  applied.append(f"{npc.name} memory added.")
228
  case "set_npc_position":
229
  x = _float_arg(edit.arguments.get("x"))
 
274
  "personality": npc.personality,
275
  "god_directive": npc.god_directive,
276
  "intention": npc.intention,
277
+ "memory_summary": npc.memory_summary,
278
+ "recent_memories": [
279
+ memory.text
280
+ for memory in recent_meaningful_memories(npc, limit=3)
281
+ ],
282
  }
283
  for npc in world.npcs
284
  ],
src/god_simulator/simulation/mechanics.py CHANGED
@@ -11,7 +11,6 @@ ATTACK_RADIUS = 1.0 * BLOCK_SIZE
11
  TALK_RADIUS = 3.0 * BLOCK_SIZE
12
  VISIBLE_RADIUS = 5.0 * BLOCK_SIZE
13
  MAX_WALK_DISTANCE = 1.0 * BLOCK_SIZE
14
- NPC_COLLISION_RADIUS = 0.2 * BLOCK_SIZE
15
 
16
 
17
  def is_alive(npc: Npc) -> bool:
 
11
  TALK_RADIUS = 3.0 * BLOCK_SIZE
12
  VISIBLE_RADIUS = 5.0 * BLOCK_SIZE
13
  MAX_WALK_DISTANCE = 1.0 * BLOCK_SIZE
 
14
 
15
 
16
  def is_alive(npc: Npc) -> bool:
src/god_simulator/simulation/memory.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from god_simulator.domain import MemoryEntry, Npc
4
+
5
+ RECENT_MEMORY_LIMIT = 5
6
+ MEMORY_SUMMARY_LIMIT = 900
7
+
8
+
9
+ def remember(npc: Npc, tick: int, text: str) -> None:
10
+ if _is_routine_memory(text):
11
+ return
12
+
13
+ npc.memory.append(MemoryEntry(tick=tick, text=text))
14
+ compact_memory(npc)
15
+
16
+
17
+ def compact_memory(npc: Npc) -> None:
18
+ meaningful_memories = [
19
+ memory for memory in npc.memory if not _is_routine_memory(memory.text)
20
+ ]
21
+ if len(meaningful_memories) <= RECENT_MEMORY_LIMIT:
22
+ npc.memory = meaningful_memories
23
+ return
24
+
25
+ older_memories = meaningful_memories[:-RECENT_MEMORY_LIMIT]
26
+ npc.memory = meaningful_memories[-RECENT_MEMORY_LIMIT:]
27
+ npc.memory_summary = _merge_summary(npc.memory_summary, older_memories)
28
+
29
+
30
+ def recent_meaningful_memories(npc: Npc, *, limit: int = RECENT_MEMORY_LIMIT) -> list[MemoryEntry]:
31
+ return [
32
+ memory for memory in npc.memory if not _is_routine_memory(memory.text)
33
+ ][-limit:]
34
+
35
+
36
+ def _merge_summary(previous_summary: str | None, older_memories: list[MemoryEntry]) -> str:
37
+ fragments = []
38
+ if previous_summary:
39
+ fragments.append(previous_summary)
40
+ fragments.extend(f"Tick {memory.tick}: {memory.text}" for memory in older_memories)
41
+
42
+ summary = " ".join(" ".join(fragments).split())
43
+ if len(summary) <= MEMORY_SUMMARY_LIMIT:
44
+ return summary
45
+ return "..." + summary[-(MEMORY_SUMMARY_LIMIT - 3) :]
46
+
47
+
48
+ def _is_routine_memory(text: str) -> bool:
49
+ return (
50
+ text == "You waited."
51
+ or text.startswith("You moved to x ")
52
+ or text.endswith(" kept exploring.")
53
+ )
src/god_simulator/simulation/tick.py CHANGED
@@ -3,18 +3,18 @@ from __future__ import annotations
3
  import math
4
  import random
5
 
6
- from god_simulator.domain import MemoryEntry, Npc, Vec3, WorldState
7
  from god_simulator.simulation.connectors.base import NpcDirective, TickPlan, WorldSimulator
8
  from god_simulator.simulation.connectors.deterministic import DeterministicWorldSimulator
9
  from god_simulator.simulation.mechanics import (
10
  ATTACK_RADIUS,
11
  BLOCK_SIZE,
12
  MAX_WALK_DISTANCE,
13
- NPC_COLLISION_RADIUS,
14
  TALK_RADIUS,
15
  distance_between,
16
  is_alive,
17
  )
 
18
 
19
 
20
  def advance_world(world: WorldState, simulator: WorldSimulator | None = None) -> WorldState:
@@ -40,7 +40,7 @@ def apply_tick_plan(world: WorldState, next_tick: int, plan: TickPlan) -> None:
40
  half_width = world.terrain.width / 2
41
  half_depth = world.terrain.depth / 2
42
  npcs_by_id = {npc.id: npc for npc in world.npcs}
43
- walk_targets, blocked_walkers = _resolve_walk_targets(
44
  world,
45
  plan.directives,
46
  half_width=half_width,
@@ -60,13 +60,6 @@ def apply_tick_plan(world: WorldState, next_tick: int, plan: TickPlan) -> None:
60
  case "walk":
61
  if not directive.target:
62
  _apply_walk(npc, None, next_tick)
63
- elif npc.id in blocked_walkers:
64
- npc.intention = "blocked"
65
- _append_memory(
66
- npc,
67
- next_tick,
68
- "You could not move because the destination was occupied.",
69
- )
70
  else:
71
  _apply_walk(npc, walk_targets.get(npc.id), next_tick)
72
  case "talk":
@@ -77,7 +70,7 @@ def apply_tick_plan(world: WorldState, next_tick: int, plan: TickPlan) -> None:
77
  npc.intention = "idle"
78
 
79
  if directive.memory:
80
- _append_memory(npc, next_tick, directive.memory)
81
 
82
  for npc in world.npcs:
83
  if not is_alive(npc):
@@ -93,9 +86,9 @@ def _resolve_walk_targets(
93
  *,
94
  half_width: float,
95
  half_depth: float,
96
- ) -> tuple[dict[str, Vec3], set[str]]:
97
  npcs_by_id = {npc.id: npc for npc in world.npcs}
98
- candidates: dict[str, Vec3] = {}
99
 
100
  for directive in directives:
101
  if directive.action != "walk" or not directive.target:
@@ -105,30 +98,14 @@ def _resolve_walk_targets(
105
  if not npc or not is_alive(npc):
106
  continue
107
 
108
- candidates[npc.id] = _planned_walk_target(
109
  npc,
110
  directive.target,
111
  half_width=half_width,
112
  half_depth=half_depth,
113
  )
114
 
115
- blocked = {
116
- npc_id
117
- for npc_id, target in candidates.items()
118
- if _collides_with_current_position(npcs_by_id[npc_id], target, world.npcs)
119
- }
120
-
121
- candidate_items = list(candidates.items())
122
- for index, (first_id, first_target) in enumerate(candidate_items):
123
- for second_id, second_target in candidate_items[index + 1 :]:
124
- if _positions_too_close(first_target, second_target):
125
- blocked.add(first_id)
126
- blocked.add(second_id)
127
-
128
- return (
129
- {npc_id: target for npc_id, target in candidates.items() if npc_id not in blocked},
130
- blocked,
131
- )
132
 
133
 
134
  def _planned_walk_target(
@@ -159,23 +136,10 @@ def _planned_walk_target(
159
  def _apply_walk(npc: Npc, target: Vec3 | None, next_tick: int) -> None:
160
  if target is None:
161
  npc.intention = "idle"
162
- _append_memory(npc, next_tick, "You waited.")
163
  return
164
 
165
  npc.position = target
166
  npc.intention = "walking"
167
- _append_memory(npc, next_tick, f"You moved to x {target.x:.1f}, z {target.z:.1f}.")
168
-
169
-
170
- def _collides_with_current_position(npc: Npc, target: Vec3, npcs: list[Npc]) -> bool:
171
- return any(
172
- other.id != npc.id and _positions_too_close(target, other.position)
173
- for other in npcs
174
- )
175
-
176
-
177
- def _positions_too_close(first: Vec3, second: Vec3) -> bool:
178
- return math.hypot(first.x - second.x, first.z - second.z) < NPC_COLLISION_RADIUS
179
 
180
 
181
  def _apply_talk(
@@ -187,18 +151,18 @@ def _apply_talk(
187
  target = npcs_by_id.get(directive.target_npc_id or "")
188
  if not target or target.id == npc.id or not is_alive(target):
189
  npc.intention = "could not talk"
190
- _append_memory(npc, next_tick, "You could not talk because the target was unavailable.")
191
  return
192
 
193
  if distance_between(npc, target) > TALK_RADIUS:
194
  npc.intention = f"too far to talk to {target.name}"
195
- _append_memory(npc, next_tick, f"You were too far away to talk to {target.name}.")
196
  return
197
 
198
  message = directive.message or "checking in"
199
  npc.intention = f"talking to {target.name}"
200
- _append_memory(npc, next_tick, f"You told {target.name}: {message}")
201
- _append_memory(target, next_tick, f"{npc.name} told you: {message}")
202
 
203
 
204
  def _apply_attack(
@@ -211,29 +175,24 @@ def _apply_attack(
211
  target = npcs_by_id.get(directive.target_npc_id or "")
212
  if not target or target.id == npc.id or not is_alive(target):
213
  npc.intention = "could not attack"
214
- _append_memory(npc, next_tick, "You could not attack because the target was unavailable.")
215
  return
216
 
217
  if distance_between(npc, target) > ATTACK_RADIUS:
218
  npc.intention = f"too far to attack {target.name}"
219
- _append_memory(npc, next_tick, f"You were too far away to attack {target.name}.")
220
  return
221
 
222
  rng = random.Random(f"{world.seed}:{next_tick}:{npc.id}:attack:{target.id}")
223
  damage = npc.attack_damage + rng.randint(0, 5)
224
  target.health -= damage
225
  npc.intention = f"attacking {target.name}"
226
- _append_memory(npc, next_tick, f"You attacked {target.name} for {damage} damage.")
227
- _append_memory(target, next_tick, f"{npc.name} attacked you for {damage} damage.")
228
 
229
  if not is_alive(target):
230
  target.intention = "dead"
231
- _append_memory(target, next_tick, "You died.")
232
-
233
-
234
- def _append_memory(npc: Npc, tick: int, text: str) -> None:
235
- npc.memory.append(MemoryEntry(tick=tick, text=text))
236
- del npc.memory[:-20]
237
 
238
 
239
  def _clamp(value: float, minimum: float, maximum: float) -> float:
 
3
  import math
4
  import random
5
 
6
+ from god_simulator.domain import Npc, Vec3, WorldState
7
  from god_simulator.simulation.connectors.base import NpcDirective, TickPlan, WorldSimulator
8
  from god_simulator.simulation.connectors.deterministic import DeterministicWorldSimulator
9
  from god_simulator.simulation.mechanics import (
10
  ATTACK_RADIUS,
11
  BLOCK_SIZE,
12
  MAX_WALK_DISTANCE,
 
13
  TALK_RADIUS,
14
  distance_between,
15
  is_alive,
16
  )
17
+ from god_simulator.simulation.memory import remember
18
 
19
 
20
  def advance_world(world: WorldState, simulator: WorldSimulator | None = None) -> WorldState:
 
40
  half_width = world.terrain.width / 2
41
  half_depth = world.terrain.depth / 2
42
  npcs_by_id = {npc.id: npc for npc in world.npcs}
43
+ walk_targets = _resolve_walk_targets(
44
  world,
45
  plan.directives,
46
  half_width=half_width,
 
60
  case "walk":
61
  if not directive.target:
62
  _apply_walk(npc, None, next_tick)
 
 
 
 
 
 
 
63
  else:
64
  _apply_walk(npc, walk_targets.get(npc.id), next_tick)
65
  case "talk":
 
70
  npc.intention = "idle"
71
 
72
  if directive.memory:
73
+ remember(npc, next_tick, directive.memory)
74
 
75
  for npc in world.npcs:
76
  if not is_alive(npc):
 
86
  *,
87
  half_width: float,
88
  half_depth: float,
89
+ ) -> dict[str, Vec3]:
90
  npcs_by_id = {npc.id: npc for npc in world.npcs}
91
+ targets: dict[str, Vec3] = {}
92
 
93
  for directive in directives:
94
  if directive.action != "walk" or not directive.target:
 
98
  if not npc or not is_alive(npc):
99
  continue
100
 
101
+ targets[npc.id] = _planned_walk_target(
102
  npc,
103
  directive.target,
104
  half_width=half_width,
105
  half_depth=half_depth,
106
  )
107
 
108
+ return targets
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
 
111
  def _planned_walk_target(
 
136
  def _apply_walk(npc: Npc, target: Vec3 | None, next_tick: int) -> None:
137
  if target is None:
138
  npc.intention = "idle"
 
139
  return
140
 
141
  npc.position = target
142
  npc.intention = "walking"
 
 
 
 
 
 
 
 
 
 
 
 
143
 
144
 
145
  def _apply_talk(
 
151
  target = npcs_by_id.get(directive.target_npc_id or "")
152
  if not target or target.id == npc.id or not is_alive(target):
153
  npc.intention = "could not talk"
154
+ remember(npc, next_tick, "You could not talk because the target was unavailable.")
155
  return
156
 
157
  if distance_between(npc, target) > TALK_RADIUS:
158
  npc.intention = f"too far to talk to {target.name}"
159
+ remember(npc, next_tick, f"You were too far away to talk to {target.name}.")
160
  return
161
 
162
  message = directive.message or "checking in"
163
  npc.intention = f"talking to {target.name}"
164
+ remember(npc, next_tick, f"You told {target.name}: {message}")
165
+ remember(target, next_tick, f"{npc.name} told you: {message}")
166
 
167
 
168
  def _apply_attack(
 
175
  target = npcs_by_id.get(directive.target_npc_id or "")
176
  if not target or target.id == npc.id or not is_alive(target):
177
  npc.intention = "could not attack"
178
+ remember(npc, next_tick, "You could not attack because the target was unavailable.")
179
  return
180
 
181
  if distance_between(npc, target) > ATTACK_RADIUS:
182
  npc.intention = f"too far to attack {target.name}"
183
+ remember(npc, next_tick, f"You were too far away to attack {target.name}.")
184
  return
185
 
186
  rng = random.Random(f"{world.seed}:{next_tick}:{npc.id}:attack:{target.id}")
187
  damage = npc.attack_damage + rng.randint(0, 5)
188
  target.health -= damage
189
  npc.intention = f"attacking {target.name}"
190
+ remember(npc, next_tick, f"You attacked {target.name} for {damage} damage.")
191
+ remember(target, next_tick, f"{npc.name} attacked you for {damage} damage.")
192
 
193
  if not is_alive(target):
194
  target.intention = "dead"
195
+ remember(target, next_tick, "You died.")
 
 
 
 
 
196
 
197
 
198
  def _clamp(value: float, minimum: float, maximum: float) -> float:
tests/e2e/god-simulator-scene.spec.ts CHANGED
@@ -89,6 +89,7 @@ test("shows tooltips only for collapsed text labels", async ({ page }) => {
89
  god_directive: null,
90
  is_alive: true,
91
  memory_count: 1,
 
92
  memories: [{ tick: 0, text: "Lena arrived as a builder." }],
93
  },
94
  render: {
 
89
  god_directive: null,
90
  is_alive: true,
91
  memory_count: 1,
92
+ memory_summary: null,
93
  memories: [{ tick: 0, text: "Lena arrived as a builder." }],
94
  },
95
  render: {
tests/test_api_server.py CHANGED
@@ -3,11 +3,23 @@ from __future__ import annotations
3
  import json
4
  from http.client import HTTPConnection
5
  from http.server import ThreadingHTTPServer
6
- from threading import Event, Thread
7
  from typing import Any
8
 
9
- from god_simulator.api.server import _build_handler
10
- from god_simulator.config import GameConfig, NpcConfig, ServerConfig, SimulationConfig, WorldConfig
 
 
 
 
 
 
 
 
 
 
 
 
11
  from god_simulator.domain import WorldState
12
  from god_simulator.simulation.connectors.base import TickPlan
13
  from god_simulator.simulation.spawning import create_world
@@ -64,6 +76,139 @@ def test_snapshot_returns_previous_world_while_tick_is_planning() -> None:
64
  server_thread.join(timeout=1)
65
 
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  class _SlowSimulator:
68
  name = "slow"
69
 
@@ -77,6 +222,48 @@ class _SlowSimulator:
77
  return TickPlan(source=self.name, directives=[])
78
 
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  def _request_json(
81
  port: int,
82
  method: str,
@@ -97,10 +284,17 @@ def _request_json(
97
  return response.status, payload
98
 
99
 
100
- def _config(*, npc_count: int) -> GameConfig:
 
 
 
 
 
101
  return GameConfig(
102
  world=WorldConfig(width=80, depth=80, terrain="plain_green", seed=42),
103
  npcs=NpcConfig(count=npc_count),
104
  simulation=SimulationConfig(tick_ms=500),
105
  server=ServerConfig(host="127.0.0.1", port=8000),
 
 
106
  )
 
3
  import json
4
  from http.client import HTTPConnection
5
  from http.server import ThreadingHTTPServer
6
+ from threading import Event, Lock, Thread
7
  from typing import Any
8
 
9
+ from god_simulator.api.server import (
10
+ _build_handler,
11
+ _modal_health_urls,
12
+ _ModalHealthTarget,
13
+ _ModalHealthWarmer,
14
+ )
15
+ from god_simulator.config import (
16
+ ConnectorConfig,
17
+ GameConfig,
18
+ NpcConfig,
19
+ ServerConfig,
20
+ SimulationConfig,
21
+ WorldConfig,
22
+ )
23
  from god_simulator.domain import WorldState
24
  from god_simulator.simulation.connectors.base import TickPlan
25
  from god_simulator.simulation.spawning import create_world
 
76
  server_thread.join(timeout=1)
77
 
78
 
79
+ def test_snapshot_triggers_modal_health_warmup() -> None:
80
+ world = create_world(_config(npc_count=2))
81
+ warmer = _FakeHealthWarmer()
82
+ server = ThreadingHTTPServer(
83
+ ("127.0.0.1", 0),
84
+ _build_handler(world, _SlowSimulator(), modal_health_warmer=warmer),
85
+ )
86
+ port = int(server.server_address[1])
87
+ server_thread = Thread(target=server.serve_forever, daemon=True)
88
+
89
+ server_thread.start()
90
+ try:
91
+ status, snapshot = _request_json(port, "GET", "/claw3d/state?warmup=1", timeout=1)
92
+
93
+ assert status == 200
94
+ assert snapshot["tick"] == 0
95
+ assert snapshot["simulation"]["models"] == [
96
+ {
97
+ "id": "npc_model",
98
+ "label": "NPC model",
99
+ "model": "test-model",
100
+ "status": "ready",
101
+ "http_status": 200,
102
+ }
103
+ ]
104
+ assert warmer.trigger_count == 1
105
+
106
+ status, _ = _request_json(port, "GET", "/health", timeout=1)
107
+
108
+ assert status == 200
109
+ assert warmer.trigger_count == 1
110
+ finally:
111
+ server.shutdown()
112
+ server.server_close()
113
+ server_thread.join(timeout=1)
114
+
115
+
116
+ def test_snapshot_without_warmup_query_only_reads_model_status() -> None:
117
+ world = create_world(_config(npc_count=2))
118
+ warmer = _FakeHealthWarmer()
119
+ server = ThreadingHTTPServer(
120
+ ("127.0.0.1", 0),
121
+ _build_handler(world, _SlowSimulator(), modal_health_warmer=warmer),
122
+ )
123
+ port = int(server.server_address[1])
124
+ server_thread = Thread(target=server.serve_forever, daemon=True)
125
+
126
+ server_thread.start()
127
+ try:
128
+ status, snapshot = _request_json(port, "GET", "/claw3d/state", timeout=1)
129
+
130
+ assert status == 200
131
+ assert snapshot["simulation"]["models"][0]["status"] == "ready"
132
+ assert warmer.trigger_count == 0
133
+ finally:
134
+ server.shutdown()
135
+ server.server_close()
136
+ server_thread.join(timeout=1)
137
+
138
+
139
+ def test_modal_health_urls_use_only_modal_openai_connectors() -> None:
140
+ config = _config(
141
+ npc_count=2,
142
+ connector=ConnectorConfig(
143
+ type="openai_compatible",
144
+ base_url="https://workspace--npc-serve.modal.run/v1",
145
+ model="npc-model",
146
+ ),
147
+ god_console=ConnectorConfig(
148
+ type="openai_compatible",
149
+ base_url="https://workspace--god-serve.modal.run/v1/chat/completions",
150
+ model="god-model",
151
+ ),
152
+ )
153
+
154
+ assert _modal_health_urls(config) == [
155
+ "https://workspace--npc-serve.modal.run/health",
156
+ "https://workspace--god-serve.modal.run/health",
157
+ ]
158
+
159
+ config = _config(
160
+ npc_count=2,
161
+ connector=ConnectorConfig(
162
+ type="openai_compatible",
163
+ base_url="https://workspace--npc-serve.modal.run/v1",
164
+ model="npc-model",
165
+ ),
166
+ god_console=ConnectorConfig(
167
+ type="openai_compatible",
168
+ base_url="https://openrouter.ai/api/v1",
169
+ model="external-model",
170
+ ),
171
+ )
172
+
173
+ assert _modal_health_urls(config) == [
174
+ "https://workspace--npc-serve.modal.run/health"
175
+ ]
176
+
177
+
178
+ def test_forced_modal_warmup_does_not_duplicate_inflight_checks() -> None:
179
+ opener = _BlockingOpener()
180
+ warmer = _ModalHealthWarmer(
181
+ [
182
+ _ModalHealthTarget(
183
+ id="npc_model",
184
+ label="NPC model",
185
+ model="test-model",
186
+ health_url="https://workspace--npc-serve.modal.run/health",
187
+ )
188
+ ],
189
+ opener=opener,
190
+ )
191
+
192
+ warmer.trigger(force=True)
193
+ assert opener.started.wait(timeout=1)
194
+ warmer.trigger(force=True)
195
+
196
+ statuses = warmer.statuses()
197
+ try:
198
+ assert opener.call_count == 1
199
+ assert statuses == [
200
+ {
201
+ "id": "npc_model",
202
+ "label": "NPC model",
203
+ "model": "test-model",
204
+ "status": "checking",
205
+ "http_status": None,
206
+ }
207
+ ]
208
+ finally:
209
+ opener.release.set()
210
+
211
+
212
  class _SlowSimulator:
213
  name = "slow"
214
 
 
222
  return TickPlan(source=self.name, directives=[])
223
 
224
 
225
+ class _FakeHealthWarmer:
226
+ def __init__(self) -> None:
227
+ self.trigger_count = 0
228
+
229
+ def trigger(self, *, force: bool = False) -> None:
230
+ self.trigger_count += 1
231
+
232
+ def statuses(self) -> list[dict[str, Any]]:
233
+ return [
234
+ {
235
+ "id": "npc_model",
236
+ "label": "NPC model",
237
+ "model": "test-model",
238
+ "status": "ready",
239
+ "http_status": 200,
240
+ }
241
+ ]
242
+
243
+
244
+ class _BlockingOpener:
245
+ def __init__(self) -> None:
246
+ self.started = Event()
247
+ self.release = Event()
248
+ self._lock = Lock()
249
+ self.call_count = 0
250
+
251
+ def __call__(self, url: str) -> Any:
252
+ _ = url
253
+ with self._lock:
254
+ self.call_count += 1
255
+ self.started.set()
256
+ self.release.wait(timeout=5)
257
+ return _FakeResponse()
258
+
259
+
260
+ class _FakeResponse:
261
+ status: int = 200
262
+
263
+ def close(self) -> None:
264
+ return
265
+
266
+
267
  def _request_json(
268
  port: int,
269
  method: str,
 
284
  return response.status, payload
285
 
286
 
287
+ def _config(
288
+ *,
289
+ npc_count: int,
290
+ connector: ConnectorConfig | None = None,
291
+ god_console: ConnectorConfig | None = None,
292
+ ) -> GameConfig:
293
  return GameConfig(
294
  world=WorldConfig(width=80, depth=80, terrain="plain_green", seed=42),
295
  npcs=NpcConfig(count=npc_count),
296
  simulation=SimulationConfig(tick_ms=500),
297
  server=ServerConfig(host="127.0.0.1", port=8000),
298
+ connector=connector or ConnectorConfig(type="deterministic"),
299
+ god_console=god_console,
300
  )
tests/test_claw3d_contract.py CHANGED
@@ -35,6 +35,7 @@ def test_claw3d_snapshot_contains_terrain_and_entities() -> None:
35
  assert snapshot["entities"][0]["state"]["personality"] == "neutral"
36
  assert snapshot["entities"][0]["state"]["god_directive"] is None
37
  assert snapshot["entities"][0]["state"]["memory_count"] == 1
 
38
  assert snapshot["entities"][0]["state"]["memories"] == [
39
  {"tick": 0, "text": "Ada arrived as a farmer."}
40
  ]
 
35
  assert snapshot["entities"][0]["state"]["personality"] == "neutral"
36
  assert snapshot["entities"][0]["state"]["god_directive"] is None
37
  assert snapshot["entities"][0]["state"]["memory_count"] == 1
38
+ assert snapshot["entities"][0]["state"]["memory_summary"] is None
39
  assert snapshot["entities"][0]["state"]["memories"] == [
40
  {"tick": 0, "text": "Ada arrived as a farmer."}
41
  ]
tests/test_config.py CHANGED
@@ -35,15 +35,38 @@ def test_parse_openai_compatible_connector() -> None:
35
  "type": "openai_compatible",
36
  "base_url": "http://llm.local/v1",
37
  "model": "test-model",
 
 
 
 
38
  "max_parallel_npc_calls": 3,
39
  },
 
 
 
 
 
 
 
40
  }
41
  )
42
 
43
  assert config.connector.type == "openai_compatible"
44
  assert config.connector.base_url == "http://llm.local/v1"
45
  assert config.connector.model == "test-model"
 
 
 
 
46
  assert config.connector.max_parallel_npc_calls == 3
 
 
 
 
 
 
 
 
47
 
48
 
49
  def test_rejects_negative_npc_count() -> None:
 
35
  "type": "openai_compatible",
36
  "base_url": "http://llm.local/v1",
37
  "model": "test-model",
38
+ "max_tokens": 256,
39
+ "temperature": 0.6,
40
+ "top_p": 0.95,
41
+ "tool_choice": "required",
42
  "max_parallel_npc_calls": 3,
43
  },
44
+ "god_console": {
45
+ "type": "openai_compatible",
46
+ "base_url": "https://georgij-sawin--god-simulator-qwen36-27b-h100-serve.modal.run/v1",
47
+ "model": "qwen3.6-27b-h100-fp8",
48
+ "max_tokens": 1200,
49
+ "tool_choice": "required",
50
+ },
51
  }
52
  )
53
 
54
  assert config.connector.type == "openai_compatible"
55
  assert config.connector.base_url == "http://llm.local/v1"
56
  assert config.connector.model == "test-model"
57
+ assert config.connector.max_tokens == 256
58
+ assert config.connector.temperature == 0.6
59
+ assert config.connector.top_p == 0.95
60
+ assert config.connector.tool_choice == "required"
61
  assert config.connector.max_parallel_npc_calls == 3
62
+ assert config.god_console is not None
63
+ assert (
64
+ config.god_console.base_url
65
+ == "https://georgij-sawin--god-simulator-qwen36-27b-h100-serve.modal.run/v1"
66
+ )
67
+ assert config.god_console.model == "qwen3.6-27b-h100-fp8"
68
+ assert config.god_console.max_tokens == 1200
69
+ assert config.god_console.tool_choice == "required"
70
 
71
 
72
  def test_rejects_negative_npc_count() -> None:
tests/test_god_console.py CHANGED
@@ -79,6 +79,8 @@ def test_god_console_parses_tool_calls_and_applies_npc_edits() -> None:
79
  applied = apply_god_command_plan(world, plan)
80
 
81
  assert captured_request["model"] == "test-model"
 
 
82
  assert "tool_choice" not in captured_request
83
  assert plan.source == "openai_compatible"
84
  assert plan.summary == "Ada has been rewritten by divine command."
 
79
  applied = apply_god_command_plan(world, plan)
80
 
81
  assert captured_request["model"] == "test-model"
82
+ assert captured_request["temperature"] == 0.6
83
+ assert captured_request["top_p"] == 0.95
84
  assert "tool_choice" not in captured_request
85
  assert plan.source == "openai_compatible"
86
  assert plan.summary == "Ada has been rewritten by divine command."
tests/test_openai_connector.py CHANGED
@@ -68,6 +68,9 @@ def test_openai_connector_uses_tool_calls_for_npc_directives() -> None:
68
  advance_world(world, simulator)
69
 
70
  first_request = captured_requests[0]
 
 
 
71
  assert [tool["function"]["name"] for tool in first_request["tools"]] == [
72
  "walk",
73
  "talk",
@@ -82,10 +85,67 @@ def test_openai_connector_uses_tool_calls_for_npc_directives() -> None:
82
  assert payload["npc"]["id"] == "npc-001"
83
  assert payload["npc"]["health"] == 100
84
  assert "attack_damage" in payload["npc"]
 
85
  assert world.npcs[0].position.x == 6
86
  assert world.npcs[0].position.z == 2
87
  assert world.npcs[0].intention == "walking"
88
- assert world.npcs[0].memory[-1].text == "You moved to x 6.0, z 2.0."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
 
91
  def test_openai_connector_calls_llm_on_every_tick() -> None:
 
68
  advance_world(world, simulator)
69
 
70
  first_request = captured_requests[0]
71
+ assert first_request["max_tokens"] == 1400
72
+ assert first_request["temperature"] == 0.6
73
+ assert first_request["top_p"] == 0.95
74
  assert [tool["function"]["name"] for tool in first_request["tools"]] == [
75
  "walk",
76
  "talk",
 
85
  assert payload["npc"]["id"] == "npc-001"
86
  assert payload["npc"]["health"] == 100
87
  assert "attack_damage" in payload["npc"]
88
+ assert "fallback_json_schema" not in payload
89
  assert world.npcs[0].position.x == 6
90
  assert world.npcs[0].position.z == 2
91
  assert world.npcs[0].intention == "walking"
92
+ assert [memory.text for memory in world.npcs[0].memory] == [
93
+ "Ada arrived as a farmer."
94
+ ]
95
+
96
+
97
+ def test_openai_connector_uses_configured_max_tokens() -> None:
98
+ captured_requests: list[dict[str, Any]] = []
99
+
100
+ def fake_chat_completer(request: dict[str, Any]) -> dict[str, Any]:
101
+ captured_requests.append(request)
102
+ payload = json.loads(request["messages"][1]["content"])
103
+ npc_id = payload["npc"]["id"]
104
+ return {
105
+ "choices": [
106
+ {
107
+ "message": {
108
+ "tool_calls": [
109
+ {
110
+ "type": "function",
111
+ "function": {
112
+ "name": "walk",
113
+ "arguments": json.dumps(
114
+ {
115
+ "npc_id": npc_id,
116
+ "target_x": 6,
117
+ "target_z": 2,
118
+ }
119
+ ),
120
+ },
121
+ }
122
+ ]
123
+ }
124
+ }
125
+ ]
126
+ }
127
+
128
+ world = create_world(_game_config(npc_count=1))
129
+ simulator = OpenAICompatibleWorldSimulator(
130
+ ConnectorConfig(
131
+ type="openai_compatible",
132
+ base_url="http://llm.local/v1",
133
+ model="test-model",
134
+ max_tokens=256,
135
+ temperature=0.2,
136
+ top_p=0.8,
137
+ tool_choice="required",
138
+ ),
139
+ fallback=DeterministicWorldSimulator(),
140
+ chat_completer=fake_chat_completer,
141
+ )
142
+
143
+ advance_world(world, simulator)
144
+
145
+ assert captured_requests[0]["max_tokens"] == 256
146
+ assert captured_requests[0]["temperature"] == 0.2
147
+ assert captured_requests[0]["top_p"] == 0.8
148
+ assert captured_requests[0]["tool_choice"] == "required"
149
 
150
 
151
  def test_openai_connector_calls_llm_on_every_tick() -> None:
tests/test_world_spawning.py CHANGED
@@ -33,6 +33,7 @@ def test_tick_advances_world_without_leaving_terrain() -> None:
33
  assert all(-40 <= npc.position.x <= 40 for npc in world.npcs)
34
  assert all(-40 <= npc.position.z <= 40 for npc in world.npcs)
35
  assert {npc.intention for npc in world.npcs} == {"walking"}
 
36
 
37
 
38
  def test_attack_damages_target_inside_one_block_radius() -> None:
@@ -114,6 +115,36 @@ def test_talk_adds_memory_inside_three_block_radius() -> None:
114
  assert "Hold this position." in listener.memory[-1].text
115
 
116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  def test_dead_npc_stays_in_place_and_cannot_act() -> None:
118
  world = create_world(_config(npc_count=2))
119
  dead, target = world.npcs
@@ -140,7 +171,7 @@ def test_dead_npc_stays_in_place_and_cannot_act() -> None:
140
  assert target.health == 100
141
 
142
 
143
- def test_two_npcs_cannot_walk_to_same_destination_in_one_tick() -> None:
144
  world = create_world(_config(npc_count=2))
145
  first, second = world.npcs
146
  first.position = Vec3(x=-2.0, y=0.0, z=2.0)
@@ -166,13 +197,13 @@ def test_two_npcs_cannot_walk_to_same_destination_in_one_tick() -> None:
166
  ),
167
  )
168
 
169
- assert first.position == Vec3(x=-2.0, y=0.0, z=2.0)
170
- assert second.position == Vec3(x=6.0, y=0.0, z=2.0)
171
- assert first.intention == "blocked"
172
- assert second.intention == "blocked"
173
 
174
 
175
- def test_npc_cannot_walk_into_occupied_position() -> None:
176
  world = create_world(_config(npc_count=2))
177
  first, second = world.npcs
178
  first.position = Vec3(x=-2.0, y=0.0, z=2.0)
@@ -193,9 +224,9 @@ def test_npc_cannot_walk_into_occupied_position() -> None:
193
  ),
194
  )
195
 
196
- assert first.position == Vec3(x=-2.0, y=0.0, z=2.0)
197
  assert second.position == Vec3(x=2.0, y=0.0, z=2.0)
198
- assert first.intention == "blocked"
199
 
200
 
201
  def test_attack_that_drops_health_below_zero_marks_target_dead() -> None:
 
33
  assert all(-40 <= npc.position.x <= 40 for npc in world.npcs)
34
  assert all(-40 <= npc.position.z <= 40 for npc in world.npcs)
35
  assert {npc.intention for npc in world.npcs} == {"walking"}
36
+ assert all(len(npc.memory) == 1 for npc in world.npcs)
37
 
38
 
39
  def test_attack_damages_target_inside_one_block_radius() -> None:
 
115
  assert "Hold this position." in listener.memory[-1].text
116
 
117
 
118
+ def test_memory_keeps_recent_entries_and_summarizes_older_events() -> None:
119
+ world = create_world(_config(npc_count=2))
120
+ speaker, listener = world.npcs
121
+ speaker.position = Vec3(x=0.0, y=0.0, z=0.0)
122
+ listener.position = Vec3(x=0.0, y=0.0, z=3.0)
123
+
124
+ for tick in range(1, 8):
125
+ apply_tick_plan(
126
+ world,
127
+ tick,
128
+ TickPlan(
129
+ source="test",
130
+ directives=[
131
+ NpcDirective(
132
+ npc_id=speaker.id,
133
+ action="talk",
134
+ target_npc_id=listener.id,
135
+ message=f"Message {tick}.",
136
+ )
137
+ ],
138
+ ),
139
+ )
140
+
141
+ assert len(speaker.memory) == 5
142
+ assert speaker.memory_summary is not None
143
+ assert "Ada arrived as a farmer." in speaker.memory_summary
144
+ assert "Message 1." in speaker.memory_summary
145
+ assert "Message 7." in speaker.memory[-1].text
146
+
147
+
148
  def test_dead_npc_stays_in_place_and_cannot_act() -> None:
149
  world = create_world(_config(npc_count=2))
150
  dead, target = world.npcs
 
171
  assert target.health == 100
172
 
173
 
174
+ def test_two_npcs_can_walk_to_same_destination_in_one_tick() -> None:
175
  world = create_world(_config(npc_count=2))
176
  first, second = world.npcs
177
  first.position = Vec3(x=-2.0, y=0.0, z=2.0)
 
197
  ),
198
  )
199
 
200
+ assert first.position == Vec3(x=2.0, y=0.0, z=2.0)
201
+ assert second.position == Vec3(x=2.0, y=0.0, z=2.0)
202
+ assert first.intention == "walking"
203
+ assert second.intention == "walking"
204
 
205
 
206
+ def test_npc_can_walk_into_occupied_position() -> None:
207
  world = create_world(_config(npc_count=2))
208
  first, second = world.npcs
209
  first.position = Vec3(x=-2.0, y=0.0, z=2.0)
 
224
  ),
225
  )
226
 
227
+ assert first.position == Vec3(x=2.0, y=0.0, z=2.0)
228
  assert second.position == Vec3(x=2.0, y=0.0, z=2.0)
229
+ assert first.intention == "walking"
230
 
231
 
232
  def test_attack_that_drops_health_below_zero_marks_target_dead() -> None:
uv.lock CHANGED
The diff for this file is too large to render. See raw diff