DeltaZN commited on
Commit ·
ea2442a
1
Parent(s): 7a273d3
Add Modal LLM backends and agent memory updates
Browse files- README.md +96 -1
- config/game.llm.local.json +20 -1
- config/game.local.json +1 -1
- config/game.modal.a10.local.json +54 -0
- config/game.modal.b200.local.json +54 -0
- config/game.modal.local.json +54 -0
- config/game.modal.t4.local.json +54 -0
- deployments/modal_nemotron_nano.py +98 -0
- deployments/modal_nemotron_nano_a10.py +100 -0
- deployments/modal_nemotron_nano_b200.py +99 -0
- deployments/modal_nemotron_nano_t4.py +100 -0
- deployments/modal_qwen36_27b_h100.py +91 -0
- deployments/smoke_modal_endpoint.py +150 -0
- frontend/src/App.tsx +65 -1
- frontend/src/api.ts +10 -2
- frontend/src/styles.css +28 -1
- frontend/src/types.ts +10 -0
- pyproject.toml +4 -0
- src/god_simulator/api/server.py +227 -9
- src/god_simulator/config.py +49 -0
- src/god_simulator/domain.py +1 -0
- src/god_simulator/rendering/claw3d_contract.py +1 -0
- src/god_simulator/simulation/connectors/deterministic.py +0 -1
- src/god_simulator/simulation/connectors/openai_compatible.py +14 -21
- src/god_simulator/simulation/god_console.py +14 -8
- src/god_simulator/simulation/mechanics.py +0 -1
- src/god_simulator/simulation/memory.py +53 -0
- src/god_simulator/simulation/tick.py +17 -58
- tests/e2e/god-simulator-scene.spec.ts +1 -0
- tests/test_api_server.py +198 -4
- tests/test_claw3d_contract.py +1 -0
- tests/test_config.py +23 -0
- tests/test_god_console.py +2 -0
- tests/test_openai_connector.py +61 -1
- tests/test_world_spawning.py +39 -8
- uv.lock +0 -0
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,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 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":
|
| 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(
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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(
|
|
|
|
|
|
|
| 27 |
)
|
| 28 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
| 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":
|
| 254 |
-
"
|
|
|
|
| 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
|
| 283 |
-
"
|
|
|
|
| 284 |
),
|
| 285 |
},
|
| 286 |
{
|
|
@@ -293,19 +297,9 @@ def _build_messages(
|
|
| 293 |
"observable_other_npcs_field": "visible_npcs",
|
| 294 |
},
|
| 295 |
"task": (
|
| 296 |
-
"Choose
|
| 297 |
-
"only through visible_npcs.
|
| 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
|
| 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
|
|
|
|
| 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":
|
| 161 |
-
"
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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 |
-
|
| 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 |
-
) ->
|
| 97 |
npcs_by_id = {npc.id: npc for npc in world.npcs}
|
| 98 |
-
|
| 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 |
-
|
| 109 |
npc,
|
| 110 |
directive.target,
|
| 111 |
half_width=half_width,
|
| 112 |
half_depth=half_depth,
|
| 113 |
)
|
| 114 |
|
| 115 |
-
|
| 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 |
-
|
| 191 |
return
|
| 192 |
|
| 193 |
if distance_between(npc, target) > TALK_RADIUS:
|
| 194 |
npc.intention = f"too far to talk to {target.name}"
|
| 195 |
-
|
| 196 |
return
|
| 197 |
|
| 198 |
message = directive.message or "checking in"
|
| 199 |
npc.intention = f"talking to {target.name}"
|
| 200 |
-
|
| 201 |
-
|
| 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 |
-
|
| 215 |
return
|
| 216 |
|
| 217 |
if distance_between(npc, target) > ATTACK_RADIUS:
|
| 218 |
npc.intention = f"too far to attack {target.name}"
|
| 219 |
-
|
| 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 |
-
|
| 227 |
-
|
| 228 |
|
| 229 |
if not is_alive(target):
|
| 230 |
target.intention = "dead"
|
| 231 |
-
|
| 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
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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=
|
| 170 |
-
assert second.position == Vec3(x=
|
| 171 |
-
assert first.intention == "
|
| 172 |
-
assert second.intention == "
|
| 173 |
|
| 174 |
|
| 175 |
-
def
|
| 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=
|
| 197 |
assert second.position == Vec3(x=2.0, y=0.0, z=2.0)
|
| 198 |
-
assert first.intention == "
|
| 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
|
|
|